Files
jacuzzi/frontend/css/style.css
Simon 49992c1db0 Recycle grid cards instead of rebuilding them
Scrolling the grid did nothing but destroy cards and build near-identical
ones back: a template string parsed as innerHTML, ten querySelectors, and
a listener per interactive element, every time a card entered the window.
The virtualizer now keeps a pool and rebinds a card it already has --
18us against 136us to build one, and 74-83% of mounts are served from it.

Two things had to change first. Nothing on a card may close over the
video it is showing, because the card outlives the video, so every
interaction moved to one delegated listener per event type on the grid.
And every card now has the same shape whatever it shows: the optional
parts are always present and hidden when unused, so any pooled card fits
any video. That needed a global [hidden] rule, since .live-badge and
.video-tags carry their own display.

The rest is the release path, which is where this design lives or dies.
A thumbnail carries a generation, so a race or a proxy fallback settling
after the card moved on cannot paint over the video now showing. The
player stamps the card it was opened from, so a recycled element stops
answering for it. The reveal handler, the entrance-animation listener and
the hover preview are all taken back off. Anything missed here surfaces
as one video's title, thumbnail or heart on another video's card, which
is what the smoke suite scrolls back and forth to catch.

Two incidental fixes found while measuring: bindCard no longer writes a
data-tag per tag button (dataset is a proxy, and that alone cost more
than the rest of a rebind put together -- the handler reads the label off
the button), and favorites.has no longer parses a URL for every card that
isn't a favorite by key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-09 09:47:43 +00:00

2636 lines
56 KiB
CSS

* { margin: 0; padding: 0; box-sizing: border-box; }
/* A card keeps its optional parts (live badge, uploader, duration, tags) at all
times and hides the ones this video doesn't need, so any pooled card fits any
video -- see bindCard. Several of those carry their own `display`, which beats
the UA rule for [hidden], so say it once and mean it. */
[hidden] { display: none !important; }
:root {
/* Warm "classic" dark: deep charcoal with a hint of brown, never pure black. */
--bg-primary: #14110d;
--bg-secondary: rgba(28, 24, 18, 0.86);
--bg-tertiary: rgba(244, 232, 212, 0.06);
--text-primary: #f4ece0;
--text-secondary: rgba(244, 236, 224, 0.62);
/* Modern brass accent — the single chromatic note against the neutrals. */
--accent: #c9a567;
--accent-hover: #d9b87f;
--border: rgba(244, 232, 212, 0.12);
--border-hover: rgba(201, 165, 103, 0.45);
--shadow: rgba(8, 6, 3, 0.5);
--font-body: 'Sora', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
--font-display: 'Fraunces', 'Iowan Old Style', 'Palatino Linotype', Georgia, serif;
--font-logo: 'Fraunces', 'Iowan Old Style', 'Palatino Linotype', Georgia, serif;
--radius-lg: 14px;
--radius-md: 10px;
--radius-sm: 7px;
--grid-max: 1480px;
--focus-ring: rgba(201, 165, 103, 0.32);
color-scheme: dark;
}
html, body { height: 100%; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background:
radial-gradient(1100px 560px at 14% -12%, rgba(201, 165, 103, 0.10), transparent 60%),
radial-gradient(900px 520px at 108% 4%, rgba(201, 165, 103, 0.06), transparent 58%),
var(--bg-primary);
background-attachment: fixed;
color: var(--text-primary);
font-family: var(--font-body);
overflow-x: hidden;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* No accidental zoom: `manipulation` drops double-tap-to-zoom (the app's own
taps are handled in JS anyway) while leaving panning and deliberate
pinch-zoom alone, and text-size-adjust stops Safari inflating text of its
own accord after a rotation. */
touch-action: manipulation;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body.drawer-open {
overflow: hidden;
}
/* Subtle film grain so large flat surfaces don't read as sterile vector flats. */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 50;
pointer-events: none;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
body.theme-light::before {
opacity: 0.05;
mix-blend-mode: multiply;
}
body:not(.theme-light) {
color-scheme: dark;
}
body.theme-light {
/* Warm ivory "paper" with a warm-ink text — editorial, not stark white/black. */
--bg-primary: #f6f1e7;
--bg-secondary: rgba(255, 252, 245, 0.86);
--bg-tertiary: rgba(43, 33, 18, 0.05);
--text-primary: #241d12;
--text-secondary: rgba(36, 29, 18, 0.62);
--accent: #9a7338;
--accent-hover: #835f2c;
--border: rgba(43, 33, 18, 0.12);
--border-hover: rgba(154, 115, 56, 0.5);
--shadow: rgba(74, 56, 28, 0.14);
--focus-ring: rgba(154, 115, 56, 0.28);
color-scheme: light;
background:
radial-gradient(1100px 560px at 14% -12%, rgba(154, 115, 56, 0.08), transparent 60%),
radial-gradient(900px 520px at 108% 4%, rgba(154, 115, 56, 0.05), transparent 58%),
var(--bg-primary);
}
/* Top Bar */
.top-bar {
height: 64px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 clamp(16px, 3vw, 36px);
background: linear-gradient(180deg, rgba(20, 17, 13, 0.86), rgba(20, 17, 13, 0.62));
position: sticky;
top: 0;
z-index: 100;
border-bottom: 1px solid var(--border);
gap: 24px;
backdrop-filter: blur(12px);
box-shadow: 0 8px 24px var(--shadow);
}
body.theme-light .top-bar {
background: linear-gradient(180deg, rgba(246, 241, 231, 0.94), rgba(246, 241, 231, 0.78));
}
.logo {
font-size: 28px;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--text-primary);
font-family: var(--font-logo);
font-optical-sizing: auto;
flex-shrink: 0;
}
.search-container {
flex: 1;
max-width: 500px;
position: relative;
}
.search-container input {
width: 100%;
padding: 10px 44px 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-primary);
font-size: 14px;
transition: all 0.2s ease;
}
.search-container input:focus {
outline: none;
border-color: var(--accent);
background: var(--bg-tertiary);
box-shadow: 0 0 0 4px var(--focus-ring);
}
.search-container input::placeholder {
color: var(--text-secondary);
}
.search-clear-btn {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
width: 26px;
height: 26px;
border: none;
border-radius: 50%;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 1;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, background 0.2s ease, color 0.2s ease;
}
.search-clear-btn.is-visible {
opacity: 1;
pointer-events: auto;
}
.search-clear-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.actions {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.icon-btn {
width: 48px;
height: 48px;
border: none;
background: transparent;
border-radius: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
transition: all 0.2s ease;
font-size: 20px;
}
.icon-btn:hover {
background: var(--bg-tertiary);
color: var(--accent);
}
.icon-btn:active {
transform: scale(0.92);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* CDN-served icon images (Heroicons). Black SVG sources, tinted via filter:
neutral to match text by default, brass on hover for the accent moment. */
.icon-svg {
width: 24px;
height: 24px;
display: block;
filter: invert(92%) sepia(6%) saturate(220%) hue-rotate(2deg) brightness(101%);
}
/* Brass tint approximated from a pure-black source. */
.icon-btn:hover .icon-svg {
filter: invert(68%) sepia(42%) saturate(560%) hue-rotate(1deg) brightness(94%) contrast(86%);
}
body.theme-light .icon-svg {
filter: invert(12%) sepia(20%) saturate(620%) hue-rotate(2deg) brightness(95%);
}
body.theme-light .icon-btn:hover .icon-svg {
filter: invert(46%) sepia(36%) saturate(720%) hue-rotate(1deg) brightness(82%) contrast(88%);
}
/* Hamburger Menu */
.hamburger {
display: flex;
flex-direction: column;
gap: 4px;
}
.hamburger span {
width: 24px;
height: 2px;
background: currentColor;
border-radius: 1px;
transition: all 0.3s ease;
}
.menu-toggle.active .hamburger span:nth-child(1) {
transform: translateY(6px) rotate(45deg);
}
.menu-toggle.active .hamburger span:nth-child(2) {
opacity: 0;
}
.menu-toggle.active .hamburger span:nth-child(3) {
transform: translateY(-6px) rotate(-45deg);
}
/* Sidebar */
.sidebar {
position: fixed;
top: 0;
right: -100%;
width: 320px;
height: 100vh;
background: var(--bg-secondary);
z-index: 1001;
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
backdrop-filter: blur(15px);
box-shadow: -20px 0 40px var(--shadow);
}
body.theme-light .sidebar {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
}
.sidebar.open {
right: 0;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h3 {
font-size: 18px;
font-weight: 600;
font-family: var(--font-display);
}
.close-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 24px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
}
.close-btn:hover {
color: var(--accent);
}
.sidebar-content {
flex: 1;
padding: 12px 0;
overflow-y: auto;
}
.sidebar-section {
padding: 8px 0 4px 0;
border-bottom: 1px solid var(--border);
}
.sidebar-section:last-child {
border-bottom: none;
}
.sidebar-subtitle {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 8px 24px 4px 24px;
}
.sidebar-item {
display: block;
padding: 12px 24px;
color: var(--text-primary);
text-decoration: none;
transition: all 0.2s ease;
border-left: 3px solid transparent;
}
.sidebar-item:hover {
background: var(--bg-tertiary);
border-left-color: var(--accent);
color: var(--accent);
}
.setting-item {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
}
.filters-container .setting-item {
padding-top: 12px;
padding-bottom: 12px;
}
.filters-empty {
padding: 12px 24px 20px 24px;
color: var(--text-secondary);
font-size: 13px;
}
.setting-item label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.setting-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
/* Explanatory line under a control -- and where the import reports back. Not a
`label`, so it keeps sentence case and normal letter spacing. */
.setting-note {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
.setting-label-row label {
margin-bottom: 0;
}
.setting-toggle .setting-label-row {
margin-bottom: 10px;
}
.toggle {
position: relative;
display: inline-flex;
align-items: center;
width: 46px;
height: 26px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-track {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
transition: all 0.2s ease;
}
.toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--text-primary);
transition: transform 0.2s ease, background 0.2s ease;
box-shadow: 0 4px 10px var(--shadow);
}
.toggle input:checked + .toggle-track {
background: var(--accent);
border-color: var(--accent);
}
.toggle input:checked + .toggle-track::after {
transform: translateX(20px);
background: var(--bg-primary);
}
.toggle input:focus-visible + .toggle-track {
box-shadow: 0 0 0 4px var(--focus-ring);
}
.btn-link {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
text-decoration: underline;
padding: 0;
}
.btn-link:hover {
color: var(--text-primary);
}
.btn-link:disabled {
color: var(--border);
cursor: not-allowed;
text-decoration: none;
}
.input-row {
display: flex;
gap: 8px;
align-items: center;
}
.input-row input {
flex: 1;
padding: 9px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
font-size: 14px;
}
.input-row input::placeholder {
color: var(--text-secondary);
}
.input-row input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--focus-ring);
}
body.theme-light .input-row input:focus {
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.08);
}
.btn-secondary {
padding: 9px 14px;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-primary);
cursor: pointer;
font-size: 13px;
transition: all 0.2s ease;
width: 100%;
text-align: center;
}
.btn-secondary:hover {
background: var(--bg-tertiary);
border-color: var(--border-hover);
}
.btn-secondary:active {
transform: translateY(1px);
}
.sources-list {
padding: 8px 24px 16px 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.source-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-tertiary);
}
.source-item span {
font-size: 12px;
color: var(--text-primary);
word-break: break-all;
}
.source-item button {
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-primary);
cursor: pointer;
font-size: 12px;
}
.source-item button:hover {
background: var(--bg-tertiary);
}
.setting-item input[type="range"] {
width: 100%;
appearance: none;
-webkit-appearance: none;
height: 6px;
border-radius: 999px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
cursor: pointer;
margin: 6px 0;
}
.setting-item input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--text-primary);
border: none;
cursor: pointer;
}
.setting-item input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--text-primary);
border: none;
cursor: pointer;
}
.setting-item select {
width: 100%;
padding: 9px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
cursor: pointer;
font-size: 14px;
color-scheme: dark;
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%),
linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%),
linear-gradient(to right, transparent, transparent);
background-position:
calc(100% - 16px) calc(1em + 1px),
calc(100% - 11px) calc(1em + 1px),
100% 0;
background-size:
5px 5px,
5px 5px,
2.5em 2.5em;
background-repeat: no-repeat;
}
.multi-select {
display: flex;
flex-direction: column;
gap: 8px;
}
.multi-select-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--text-primary);
padding: 6px 10px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
transition: all 0.2s ease;
}
.multi-select-item input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
flex-shrink: 0;
}
.multi-select-item span {
padding-left: 6px;
}
.multi-select-item:hover {
border-color: var(--border);
background: var(--bg-tertiary);
}
.multi-select-item input[type="checkbox"]:checked + span {
color: var(--accent);
}
.setting-item select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--focus-ring);
}
body.theme-light .setting-item select:focus {
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.08);
}
.setting-item select,
.setting-item select option,
.setting-item select optgroup {
background-color: #0a0a0a;
color: #ffffff;
}
body.theme-light .setting-item select {
color-scheme: light;
}
body.theme-light .setting-item select,
body.theme-light .setting-item select option,
body.theme-light .setting-item select optgroup {
background-color: #ffffff;
color: #000000;
}
.setting-item select option {
background: #0a0a0a;
color: #ffffff;
}
body.theme-light .setting-item select option {
background: #ffffff;
color: #000000;
}
/* Overlay */
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: none;
z-index: 1000;
transition: opacity 0.3s ease;
backdrop-filter: blur(2px);
}
.sidebar-overlay.open {
display: block;
}
.load-more-btn {
position: sticky;
left: 50%;
transform: translateX(-50%);
margin: 16px auto 32px auto;
width: 52px;
height: 52px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
z-index: 10;
box-shadow: 0 10px 20px var(--shadow);
}
.load-more-btn:hover {
background: var(--bg-tertiary);
}
.load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Favorites Bar */
.favorites-bar {
padding: 16px clamp(16px, 3vw, 36px) 4px;
max-width: var(--grid-max);
margin: 0 auto;
}
.favorites-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.favorites-header h3 {
font-size: 12px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
font-family: var(--font-display);
}
.favorites-actions {
display: flex;
align-items: center;
gap: 8px;
}
.favorites-sort {
height: 28px;
padding: 0 8px;
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
}
.favorites-actions .btn-secondary {
height: 28px;
padding: 0 12px;
font-size: 12px;
}
/* Browsing favorites as a grid: the bar above it would be the same list twice,
so it collapses to its header (which carries the way back out). */
body.favorites-view-open .favorites-list,
body.favorites-view-open .favorites-empty {
display: none;
}
.favorites-list {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 12px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.favorites-list::-webkit-scrollbar {
height: 8px;
}
.favorites-list::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 999px;
}
.favorites-list::-webkit-scrollbar-track {
background: transparent;
}
.favorite-card {
position: relative;
flex: 0 0 auto;
width: 220px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-secondary);
overflow: hidden;
cursor: pointer;
box-shadow: 0 8px 18px var(--shadow);
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
scroll-snap-align: start;
}
.favorite-card:hover {
transform: translateY(-4px);
box-shadow: 0 14px 24px var(--shadow);
border-color: var(--border-hover);
}
.favorite-card img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: var(--bg-tertiary);
}
.favorite-info {
padding: 10px 12px 12px 12px;
}
/* One line, always. A title too long for the card scrolls across it (see
App.marquee) rather than wrapping the card to an uneven height. */
.favorite-info h4 {
font-size: 13px;
font-weight: 600;
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
position: relative;
}
.favorite-title-text {
display: inline-block;
padding-right: 24px;
transform: translateX(0);
}
.favorite-card.is-title-active .favorite-title-text {
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
will-change: transform;
}
.favorites-empty {
font-size: 13px;
color: var(--text-secondary);
padding: 4px 0 12px 0;
}
/* Grid Container */
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-auto-rows: 10px;
gap: 16px;
align-items: start;
padding: 24px;
max-width: var(--grid-max);
margin: 0 auto;
}
@media (max-width: 768px) {
.grid-container {
/* Exactly two larger cards per row on phones, rather than packing in
several small ones. */
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 10px;
gap: 12px;
padding: 16px;
}
.top-bar {
flex-wrap: wrap;
height: auto;
padding: 12px 16px;
row-gap: 10px;
}
.search-container {
order: 3;
width: 100%;
max-width: 100%;
flex-basis: 100%;
}
.actions {
order: 2;
width: auto;
margin-left: auto;
justify-content: flex-end;
}
}
@media (max-width: 480px) {
.grid-container {
/* One full-width card per row on phones. */
grid-template-columns: 1fr;
gap: 16px;
}
.logo {
font-size: 18px;
}
.icon-btn {
width: 44px;
height: 44px;
}
}
@media (min-width: 1600px) {
body {
font-size: 16px;
}
.top-bar {
height: 72px;
padding: 0 36px;
}
.grid-container {
gap: 24px;
padding: 32px 48px;
}
.video-card h4 {
font-size: calc(16px * var(--card-font-scale, 1));
}
.video-card p {
font-size: calc(13px * var(--card-font-scale, 1));
}
}
@media (min-width: 1920px) {
.grid-container {
column-width: 280px;
}
}
@media (pointer: coarse) {
.icon-btn {
width: 52px;
height: 52px;
}
.btn-secondary {
padding: 10px 14px;
}
.setting-item select,
.input-row input {
padding: 10px 14px;
}
/* iOS zooms the whole page when you focus a control whose text is under
16px, and it ignores user-scalable=no -- so the font size is the only
lever that actually stops it. Every text control gets 16px on touch
devices; the padding above keeps them looking the same size. */
.search-container input,
.input-row input,
.setting-item select,
.cmdk-input,
input[type="text"],
input[type="search"],
input[type="number"],
input[type="url"],
input[type="email"],
input[type="password"],
input:not([type]),
textarea,
select {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}
/* Video Card */
.video-card {
cursor: pointer;
border-radius: var(--radius-lg);
overflow: hidden;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
background: var(--bg-secondary);
display: inline-flex;
flex-direction: column;
width: 100%;
border: 1px solid var(--border);
box-shadow: 0 6px 16px var(--shadow);
position: relative;
margin-bottom: 0;
/* The grid is virtualized in JS (see App.virtualGrid): only cards near the
viewport are in the DOM at all, and each is absolutely positioned at a
precomputed (top,left). */
}
.video-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 28px var(--shadow);
border-color: var(--border-hover);
}
.video-thumb {
position: relative;
display: block;
}
.video-card img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
background: var(--bg-tertiary);
}
/* Dark-transparent pills overlaid on the bottom of the thumbnail.
Scoped under .video-thumb so they outrank the later .uploader-link rule. */
.video-thumb .video-uploader,
.video-thumb .video-duration {
position: absolute;
bottom: 8px;
z-index: 3;
padding: 3px 8px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
backdrop-filter: blur(2px);
pointer-events: none;
}
.video-thumb .video-duration {
right: 8px;
}
.video-thumb .video-uploader {
left: 8px;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: none;
text-align: left;
font-family: inherit;
cursor: pointer;
pointer-events: auto;
}
.video-thumb .video-uploader:hover {
background: rgba(0, 0, 0, 0.78);
color: #fff;
text-decoration: underline;
}
.video-card h4 {
font-size: calc(15px * var(--card-font-scale, 1));
font-weight: 500;
padding: 12px 12px 10px;
line-height: 1.3;
color: var(--text-primary);
margin: 0;
flex: 1;
font-family: var(--font-display);
font-optical-sizing: auto;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
position: relative;
}
.video-title-text {
display: inline-block;
padding-right: 24px;
transform: translateX(0);
}
.video-card.is-title-active .video-title-text {
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
will-change: transform;
}
@keyframes video-title-marquee {
to {
transform: translateX(calc(-1 * var(--marquee-distance, 0px)));
}
}
.video-card p {
font-size: calc(12px * var(--card-font-scale, 1));
color: var(--text-secondary);
padding: 0 12px 12px 12px;
margin: 0;
}
.video-tags {
display: flex;
flex-wrap: nowrap;
gap: 6px;
padding: 0 12px 8px 12px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.video-tag {
font-size: calc(11px * var(--card-font-scale, 1));
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
line-height: 1.2;
letter-spacing: 0.2px;
white-space: nowrap;
font-family: inherit;
cursor: pointer;
}
.video-tag:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 1px;
}
.uploader-link {
background: transparent;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
text-align: left;
}
.video-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
z-index: 4;
}
.video-card.is-loading .video-loading,
.favorite-card.is-loading .video-loading {
opacity: 1;
}
.video-loading-spinner {
width: 34px;
height: 34px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.35);
border-top-color: var(--text-primary);
animation: video-card-spinner 0.8s linear infinite;
}
@keyframes video-card-spinner {
to {
transform: rotate(360deg);
}
}
.uploader-link:hover {
color: var(--text-primary);
text-decoration: underline;
}
.video-menu-btn {
position: absolute;
top: 10px;
right: 10px;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.55);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
cursor: pointer;
z-index: 2;
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
body.theme-light .video-menu-btn {
background: rgba(255, 255, 255, 0.8);
}
.video-menu-btn:hover {
transform: scale(1.05);
background: var(--bg-secondary);
}
.video-menu {
position: absolute;
top: 48px;
right: 10px;
min-width: 140px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-secondary);
box-shadow: 0 12px 24px var(--shadow);
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 3;
}
.video-menu.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.video-menu-item {
width: 100%;
padding: 10px 12px;
text-align: left;
border: none;
background: transparent;
color: var(--text-primary);
cursor: pointer;
font-size: 13px;
}
.video-menu-item:hover {
background: var(--bg-tertiary);
}
.live-badge {
position: absolute;
top: 10px;
left: 50px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
background: #e0245e;
color: #fff;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1;
text-transform: uppercase;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.feed-live-badge {
top: 16px;
left: 16px;
}
/* Live streams can't be seeked, so hide the feed scrubber. */
.feed-slide.is-live .feed-timeline {
display: none;
}
.favorite-btn {
position: absolute;
top: 10px;
left: 10px;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.55);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 1;
cursor: pointer;
z-index: 1;
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
body.theme-light .favorite-btn {
background: rgba(255, 255, 255, 0.8);
}
.favorite-btn:hover {
transform: scale(1.05);
background: var(--bg-secondary);
}
.favorite-btn.is-favorite {
color: #ff3b30;
border-color: rgba(255, 59, 48, 0.6);
background: rgba(255, 59, 48, 0.12);
}
/* Info Modal */
.info-modal {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
z-index: 2500;
padding: 20px;
}
.info-modal.open {
display: flex;
}
.info-card {
position: relative;
width: min(520px, 92vw);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px 22px;
box-shadow: 0 18px 36px var(--shadow);
}
.info-card h3 {
font-family: var(--font-display);
font-size: 18px;
margin: 0 0 16px 0;
}
.info-close {
position: absolute;
right: 16px;
top: 16px;
border: none;
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: 50%;
width: 32px;
height: 32px;
cursor: pointer;
}
.info-rows,
.info-list {
display: flex;
flex-direction: column;
gap: 10px;
}
/* The panel shows every field the client has, which for a resolved video is the
extractor's whole payload -- dozens of rows. Give the list its own scroll so
the card stays inside the viewport and the close button stays put. */
.info-list {
max-height: min(70vh, 620px);
overflow-y: auto;
padding-right: 4px;
}
.info-section {
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.8px;
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
padding: 6px 0;
}
.info-pending {
font-size: 12px;
color: var(--text-secondary);
padding-top: 4px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
font-size: 13px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.info-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.info-label {
text-transform: uppercase;
letter-spacing: 0.6px;
font-size: 11px;
color: var(--text-secondary);
}
.info-value {
text-align: right;
color: var(--text-primary);
word-break: break-all;
}
.info-json {
margin: 0;
white-space: pre-wrap;
text-align: left;
color: var(--text-primary);
background: var(--bg-tertiary);
border-radius: 8px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.4;
max-height: 180px;
overflow: auto;
flex: 1;
}
.info-empty {
font-size: 13px;
color: var(--text-secondary);
padding-top: 6px;
}
/* --- Custom player: single fake-fullscreen state everywhere ------------ */
.custom-player {
display: none;
position: fixed;
inset: 0;
background: #000;
z-index: 3000;
touch-action: none;
transition: transform 0.25s ease, opacity 0.25s ease;
}
.custom-player.open {
display: block;
animation: cp-open 0.22s ease;
}
@keyframes cp-open {
from { opacity: 0; }
to { opacity: 1; }
}
.cp-surface {
position: absolute;
inset: 0;
}
/* Ambient backdrop: a blurred copy of the poster fills any letterbox bars
behind the contained video (set via the --poster custom property). */
.cp-surface::before {
content: '';
position: absolute;
inset: 0;
background-image: var(--poster);
background-size: cover;
background-position: center;
filter: blur(60px) saturate(1.3) brightness(0.5);
transform: scale(1.25);
opacity: 0.55;
z-index: 0;
pointer-events: none;
}
.cp-video {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.cp-flash {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 8px 18px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 16px;
font-weight: 500;
letter-spacing: 0.02em;
pointer-events: none;
opacity: 0;
z-index: 5;
}
.cp-flash.is-visible {
animation: cp-flash-fade 0.7s ease forwards;
}
@keyframes cp-flash-fade {
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.08); }
}
.cp-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 4;
}
.cp-spinner.is-visible {
opacity: 1;
}
.cp-spinner-ring {
width: 44px;
height: 44px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: var(--accent);
animation: cp-spin 0.8s linear infinite;
}
@keyframes cp-spin {
to { transform: rotate(360deg); }
}
.cp-error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 24px;
background: rgba(0, 0, 0, 0.7);
border-radius: var(--radius-lg);
color: #fff;
text-align: center;
max-width: min(360px, 80vw);
z-index: 5;
}
.cp-error-actions {
display: flex;
gap: 10px;
}
.cp-retry-btn,
.cp-open-btn {
padding: 8px 20px;
border-radius: 999px;
border: 1px solid var(--accent);
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
text-decoration: none;
line-height: 1.2;
}
.cp-retry-btn:hover,
.cp-open-btn:hover {
background: rgba(201, 165, 103, 0.15);
}
.cp-open-btn[hidden] {
display: none;
}
.cp-replay-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 5;
}
.cp-replay-btn .icon-svg {
width: 30px;
height: 30px;
filter: invert(1);
}
.cp-hud {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), transparent 18%, transparent 78%, rgba(0, 0, 0, 0.6));
opacity: 1;
transition: opacity 0.3s ease;
z-index: 3;
}
.custom-player.cp-hud-idle .cp-hud {
opacity: 0;
}
.cp-hud * {
pointer-events: auto;
}
.cp-top-bar {
display: flex;
align-items: center;
gap: 12px;
padding: max(14px, env(safe-area-inset-top)) 16px 0;
}
.cp-close-btn {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 18px;
cursor: pointer;
}
.cp-title {
flex: 1;
min-width: 0;
margin: 0;
font-family: var(--font-display);
font-size: 16px;
color: #fff;
white-space: nowrap;
overflow: hidden;
}
.cp-title-text {
display: inline-block;
padding-right: 24px;
transform: translateX(0);
}
.cp-title.has-marquee .cp-title-text {
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
}
.cp-fav-btn {
position: static;
flex-shrink: 0;
}
.cp-bottom-bar {
padding: 0 16px max(14px, env(safe-area-inset-bottom));
}
.custom-player.is-live .cp-timeline,
.custom-player.is-live .cp-skip-back-btn,
.custom-player.is-live .cp-skip-fwd-btn {
display: none;
}
.cp-timeline {
padding: 10px 0;
cursor: pointer;
}
.cp-timeline-track {
position: relative;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.25);
}
.cp-timeline-buffered {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0%;
border-radius: 2px;
background: rgba(255, 255, 255, 0.35);
}
.cp-timeline-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0%;
border-radius: 2px;
background: var(--accent);
}
.cp-timeline-handle {
position: absolute;
top: 50%;
left: 0%;
width: 13px;
height: 13px;
border-radius: 50%;
background: var(--accent);
transform: translate(-50%, -50%) scale(0.7);
transition: transform 0.15s ease;
}
.cp-timeline:hover .cp-timeline-handle,
.cp-timeline.is-scrubbing .cp-timeline-handle {
transform: translate(-50%, -50%) scale(1);
}
.cp-time-row {
display: flex;
align-items: center;
gap: 10px;
}
.cp-time {
color: #fff;
font-size: 12px;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
min-width: 34px;
}
.cp-time-duration {
text-align: right;
}
.cp-transport {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
justify-content: center;
}
.cp-transport button {
position: relative;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cp-play-btn {
width: 48px !important;
height: 48px !important;
background: rgba(255, 255, 255, 0.12) !important;
}
.cp-play-btn .icon-svg {
width: 22px;
height: 22px;
}
.cp-skip-back-btn .icon-svg,
.cp-skip-fwd-btn .icon-svg {
width: 20px;
height: 20px;
}
.cp-skip-amount {
position: absolute;
bottom: 2px;
font-size: 9px;
font-weight: 600;
background: rgba(0, 0, 0, 0.6);
border-radius: 6px;
padding: 0 3px;
pointer-events: none;
}
.cp-secondary-controls {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.cp-mute-btn,
.cp-pip-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: transparent;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cp-mute-btn .icon-svg,
.cp-pip-btn .icon-svg {
width: 18px;
height: 18px;
}
.cp-volume-range {
width: 64px;
accent-color: var(--accent);
}
.cp-format-btn {
height: 26px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(0, 0, 0, 0.4);
color: #fff;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.cp-format-menu {
position: absolute;
right: 16px;
bottom: 64px;
display: flex;
flex-direction: column;
background: rgba(20, 17, 13, 0.92);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
z-index: 6;
max-height: 40vh;
overflow-y: auto;
}
.cp-format-option {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 18px 9px 14px;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
/* A fixed-width tick column so every label starts on the same x, with only
the playing format's tick actually inked. */
.cp-format-option::before {
content: '\2713';
width: 1em;
flex: none;
opacity: 0;
font-size: 12px;
}
.cp-format-option:hover {
background: var(--bg-tertiary);
}
.cp-format-option.is-active {
color: var(--accent);
font-weight: 600;
}
.cp-format-option.is-active::before {
opacity: 1;
}
/* `.cp-error`/`.cp-replay-btn`/`.cp-format-menu` above set `display` on the
bare class so JS can toggle visibility via the `hidden` attribute alone;
these higher-specificity overrides keep that attribute effective (an
unconditional `display` on the class would otherwise beat the UA
`[hidden] { display: none }` rule). */
.cp-error[hidden],
.cp-replay-btn[hidden],
.cp-format-menu[hidden],
.cp-pip-btn[hidden],
.favorite-btn[hidden] {
display: none;
}
@media (max-width: 480px) {
.cp-title { font-size: 14px; }
.cp-volume-range { display: none; }
}
.error-toast {
position: fixed;
right: 20px;
bottom: 20px;
max-width: min(360px, 90vw);
background: rgba(0, 0, 0, 0.85);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
padding: 12px 14px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
z-index: 3000;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.error-toast.show {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.error-toast button {
background: transparent;
border: none;
color: inherit;
cursor: pointer;
font-size: 16px;
}
body.theme-light .error-toast {
background: rgba(255, 255, 255, 0.95);
color: #000000;
border: 1px solid rgba(0, 0, 0, 0.12);
}
.update-banner {
position: fixed;
left: 50%;
bottom: 20px;
transform: translateX(-50%) translateY(8px);
max-width: min(420px, 92vw);
background: rgba(0, 0, 0, 0.88);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
padding: 12px 14px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
z-index: 3100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.update-banner.show {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%) translateY(0);
}
.update-banner button {
background: #ffffff;
color: #000000;
border: none;
border-radius: 6px;
padding: 6px 14px;
font: inherit;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
body.theme-light .update-banner {
background: rgba(255, 255, 255, 0.96);
color: #000000;
border: 1px solid rgba(0, 0, 0, 0.12);
}
body.theme-light .update-banner button {
background: #111111;
color: #ffffff;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Mode toggle FAB (grid <-> reels) */
.mode-toggle-btn {
position: fixed;
right: 24px;
bottom: 90px;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg-secondary);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 10px 24px var(--shadow);
z-index: 2600;
transition: transform 0.2s ease, background 0.2s ease, opacity 0.4s ease;
}
.mode-toggle-btn:hover {
background: var(--bg-tertiary);
transform: scale(1.06);
}
.mode-toggle-btn:active {
transform: scale(0.94);
}
body.feed-mode-open .mode-toggle-btn {
background: rgba(0, 0, 0, 0.55);
border-color: rgba(255, 255, 255, 0.18);
}
body.feed-mode-open .mode-toggle-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
body.feed-mode-open .mode-toggle-btn .icon-svg {
filter: invert(100%) saturate(0%) !important;
}
/* Reels-style scrollable feed */
.feed-view {
display: none;
position: fixed;
inset: 0;
background: #000;
z-index: 2500;
}
.feed-view.open {
display: block;
}
.feed-scroll {
width: 100%;
height: 100%;
overflow-y: scroll;
scroll-snap-type: y mandatory;
scrollbar-width: none;
}
.feed-scroll::-webkit-scrollbar {
display: none;
}
.feed-sentinel {
height: 1px;
}
/* Stands in for the slides above the rendered window so scroll position and
snap points stay stable while slides are virtualized in and out. */
.feed-top-spacer {
width: 100%;
height: 0;
flex: none;
}
.feed-slide {
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
scroll-snap-align: start;
scroll-snap-stop: always;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #000;
}
.feed-poster,
.feed-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.feed-poster {
transition: opacity 0.2s ease;
}
.feed-slide.is-loaded .feed-poster {
opacity: 0;
}
.feed-info {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 24px 20px 44px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
color: #fff;
pointer-events: none;
transition: opacity 0.4s ease;
}
.feed-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 4px;
white-space: nowrap;
overflow: hidden;
}
.feed-title-text {
display: inline-block;
padding-right: 28px;
transform: translateX(0);
}
.feed-title.has-marquee .feed-title-text {
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
will-change: transform;
}
.feed-uploader {
font-size: 13px;
opacity: 0.8;
margin: 0;
}
.feed-timeline {
position: absolute;
left: 0;
right: 0;
bottom: 4px;
height: 28px;
display: flex;
align-items: center;
padding: 0 16px;
cursor: pointer;
touch-action: none;
z-index: 5;
}
.feed-timeline-track {
position: relative;
width: 100%;
height: 3px;
background: rgba(255, 255, 255, 0.3);
border-radius: 999px;
overflow: visible;
transition: height 0.15s ease;
}
.feed-timeline:hover .feed-timeline-track,
.feed-timeline.is-scrubbing .feed-timeline-track {
height: 5px;
}
.feed-timeline-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0%;
background: #fff;
border-radius: 999px;
}
.feed-timeline-handle {
position: absolute;
top: 50%;
left: 0%;
width: 11px;
height: 11px;
margin-left: -5.5px;
background: #fff;
border-radius: 50%;
transform: translateY(-50%) scale(0);
transition: transform 0.15s ease;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
.feed-timeline:hover .feed-timeline-handle,
.feed-timeline.is-scrubbing .feed-timeline-handle {
transform: translateY(-50%) scale(1);
}
.feed-mute-btn {
position: fixed;
right: 24px;
bottom: 154px;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2600;
transition: background 0.2s ease, opacity 0.4s ease;
}
.feed-mute-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.feed-mute-btn .icon-svg {
filter: invert(100%) saturate(0%) !important;
}
/* Per-slide favorite (heart) button on the feed HUD right rail. */
.feed-fav-btn {
position: absolute;
top: auto;
left: auto;
right: 24px;
bottom: 210px;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.5);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
line-height: 1;
cursor: pointer;
z-index: 6;
transition: background 0.2s ease, transform 0.2s ease, opacity 0.4s ease;
}
.feed-fav-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.feed-fav-btn.is-favorite {
color: #ff3b30;
border-color: rgba(255, 59, 48, 0.6);
background: rgba(255, 59, 48, 0.18);
}
/* Reels HUD right rail additions: PiP + quality, stacked above favorite. */
.feed-pip-btn {
position: absolute;
right: 24px;
bottom: 266px;
z-index: 6;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.18);
transition: background 0.2s ease, opacity 0.4s ease;
}
.feed-pip-btn .icon-svg {
filter: invert(100%) saturate(0%);
}
.feed-format-btn {
position: absolute;
right: 24px;
bottom: 322px;
z-index: 6;
transition: opacity 0.4s ease;
}
.feed-format-menu {
right: 76px;
bottom: 322px;
}
.feed-flash {
z-index: 5;
}
/* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay
interactive (pointer-events untouched) so the buttons keep working while
invisible; any pointer/scroll activity reveals them again (see App.feed). */
body.feed-hud-idle .feed-info,
body.feed-hud-idle .feed-timeline,
body.feed-hud-idle .feed-mute-btn,
body.feed-hud-idle .feed-fav-btn,
body.feed-hud-idle .feed-pip-btn,
body.feed-hud-idle .feed-format-btn,
body.feed-hud-idle .mode-toggle-btn {
opacity: 0;
}
/* =====================================================================
Modern enhancements (see enhance.js)
===================================================================== */
/* --- Card entrance: cards rise/fade in the first time they mount -------- */
@keyframes card-rise {
from { opacity: 0; transform: translateY(18px) scale(0.985); }
to { opacity: 1; transform: none; }
}
.video-card.is-revealing {
animation: card-rise 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
/* --- Cursor-tracking brass spotlight border on hover ------------------- */
.video-card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: radial-gradient(180px circle at var(--mx, 50%) var(--my, 50%),
rgba(201, 165, 103, 0.65), rgba(201, 165, 103, 0) 60%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
z-index: 3;
}
.video-card:hover::after {
opacity: 1;
}
/* --- Thumbnail skeleton shimmer until the image paints ----------------- */
@keyframes thumb-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
.video-card img:not(.is-loaded) {
background-image: linear-gradient(100deg,
transparent 30%, rgba(255, 255, 255, 0.08) 50%, transparent 70%);
background-size: 200% 100%;
background-repeat: no-repeat;
animation: thumb-shimmer 1.3s ease-in-out infinite;
}
body.theme-light .video-card img:not(.is-loaded) {
background-image: linear-gradient(100deg,
transparent 30%, rgba(43, 33, 18, 0.06) 50%, transparent 70%);
}
/* --- Hover video preview (sits over the poster, under the buttons) ----- */
.card-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
object-fit: cover;
background: #000;
z-index: 0;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-card.is-previewing .card-preview {
opacity: 1;
}
/* --- Favorite heart: pop + expanding brass ring on add ----------------- */
@keyframes heart-pop {
0% { transform: scale(1); }
35% { transform: scale(1.35); }
100% { transform: scale(1); }
}
@keyframes heart-ring {
from { opacity: 0.8; transform: scale(0.6); }
to { opacity: 0; transform: scale(1.7); }
}
.favorite-btn.just-favorited {
animation: heart-pop 0.45s ease;
}
.favorite-btn.just-favorited::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid var(--accent);
animation: heart-ring 0.5s ease forwards;
pointer-events: none;
}
/* --- Scroll progress bar ---------------------------------------------- */
.scroll-progress {
position: fixed;
top: 0;
left: 0;
height: 2px;
width: 0;
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
z-index: 200;
transition: width 0.1s linear;
pointer-events: none;
}
/* --- Back-to-top FAB --------------------------------------------------- */
.back-to-top {
position: fixed;
right: 24px;
bottom: 154px;
width: 46px;
height: 46px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 10px 24px var(--shadow);
backdrop-filter: blur(12px);
opacity: 0;
transform: translateY(12px) scale(0.9);
pointer-events: none;
transition: opacity 0.3s ease, transform 0.3s ease, background 0.2s ease;
z-index: 2400;
}
.back-to-top.is-visible {
opacity: 1;
transform: none;
pointer-events: auto;
}
.back-to-top:hover {
background: var(--bg-tertiary);
border-color: var(--border-hover);
}
.back-to-top:active {
transform: scale(0.92);
}
/* --- Command palette (⌘K) --------------------------------------------- */
.command-palette {
position: fixed;
inset: 0;
z-index: 4000;
display: none;
align-items: flex-start;
justify-content: center;
padding-top: 14vh;
background: rgba(10, 8, 4, 0.55);
backdrop-filter: blur(6px);
}
.command-palette.open {
display: flex;
}
.cmdk-box {
width: min(560px, 92vw);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 30px 70px var(--shadow);
overflow: hidden;
animation: card-rise 0.2s ease both;
}
.cmdk-input {
width: 100%;
padding: 18px 20px;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
color: var(--text-primary);
font-size: 16px;
font-family: var(--font-body);
}
.cmdk-input::placeholder { color: var(--text-secondary); }
.cmdk-input:focus { outline: none; }
.cmdk-list {
max-height: 50vh;
overflow-y: auto;
padding: 8px;
}
.cmdk-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
text-align: left;
padding: 11px 14px;
border: none;
background: transparent;
color: var(--text-primary);
border-radius: 10px;
cursor: pointer;
font: inherit;
}
.cmdk-item.is-active {
background: var(--bg-tertiary);
}
.cmdk-label { font-size: 14px; }
.cmdk-hint {
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
flex-shrink: 0;
}
/* --- Reels HUD polish: serif title + brass scrubber + mute pulse ------- */
.feed-title { font-family: var(--font-display); }
.feed-timeline-fill,
.feed-timeline-handle { background: #c9a567; }
@keyframes mute-pulse {
0% { transform: scale(0.8); opacity: 0.75; }
100% { transform: scale(1.5); opacity: 0; }
}
.feed-mute-btn.is-muted::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid rgba(201, 165, 103, 0.7);
animation: mute-pulse 2s ease-out infinite;
pointer-events: none;
}