/* ============================================================
   HPPN TV — fullscreen MTV/late-night-cable mode for Explore.
   Scoped under body.tv-mode-active and #tvStage. Easy revert:
   delete the <link> in index.html and the JS section in browse.js.
   ============================================================ */

:root {
    --tv-bg: #050407;
    --tv-ink: #f6efe2;
    --tv-glow: #ffe27a;
    --tv-accent: #ff3d6e;
    --tv-cyan: #4ee2ff;
    --tv-mint: #6effb8;
    --tv-shadow: 0 0 18px rgba(255, 226, 122, 0.55), 0 0 38px rgba(255, 226, 122, 0.18);
    /* Consistent 4px-step spacing scale used throughout the TV chrome so
       padding/gap stays in lockstep across breakpoints. */
    --tv-space-1: 4px;
    --tv-space-2: 8px;
    --tv-space-3: 12px;
    --tv-space-4: 16px;
    --tv-space-5: 22px;
    /* Mobile video frame uses a strict 16:9 ratio so a portrait source can't
       balloon the broadcast tile and push everything below the fold. */
    --tv-mobile-aspect: calc(16 / 9);
    /* Glass-pill surface tokens — shared by the right-side action rail and the
       TV-mode bottom nav so the two read as the same translucent material. */
    --tv-glass-bg: rgba(0, 0, 0, 0.42);
    --tv-glass-border: rgba(246, 239, 226, 0.10);
    --tv-glass-blur: blur(14px) saturate(1.4);
    --tv-glass-shadow: 0 6px 22px rgba(0, 0, 0, 0.5);
}

/* Hide the regular feed + chrome while TV is on */
body.tv-mode-active {
    overflow: hidden;
}
/* When TV mode is active and the user taps the AREA pill, the existing global
   city picker overlay must render ABOVE the TV stage (z:9999), so we lift it. */
body.tv-mode-active #globalCityPickerOverlay {
    z-index: 10001 !important;
}
/* Same lift for the date-range calendar overlay, which the DATES pill opens
   from the TV identity row. Without this, the calendar renders behind the
   stage and is invisible. */
body.tv-mode-active #calendarOverlay {
    z-index: 10001 !important;
}
/* Same lift for the buy-tickets flow popups so the View Show button on the
   stage opens the same donation / multi-event / sticker UX as the swipe
   feed's Buy Tickets button. Without these overrides the modals would render
   beneath the z:9999 stage. */
body.tv-mode-active #donationOverlay,
body.tv-mode-active #stickerOverlay {
    z-index: 10002 !important;
}
body.tv-mode-active #multiEventOverlay { z-index: 10001 !important; }
body.tv-mode-active #multiEventModal   { z-index: 10002 !important; }
body.tv-mode-active #app,
body.tv-mode-active .keyboard-hint,
body.tv-mode-active .floating-support,
body.tv-mode-active .top-bar,
body.tv-mode-active #similarArtistsPage,
body.tv-mode-active .liner-notes-sheet {
    visibility: hidden;
    pointer-events: none;
}

/* Bottom-nav stays visible in TV mode (mobile-only — design-system.css already
   hides it ≥768px via display:none). It's the primary cross-app exit
   affordance now that the floating × is gone. The stage sits at z:9999, so
   we have to lift the nav above it; we stay below the TV-mode global city
   picker overlay (z:10001) so the picker still floats above the nav.
   Dim it to 0.7 by default so the broadcast reads as the hero — it brightens
   the moment the user touches it (or hovers on a desktop accidentally on the
   mobile breakpoint), keeping the cross-app exit affordance discoverable. */
body.tv-mode-active .bottom-nav {
    z-index: 10000;
    opacity: 0.7;
    transition: opacity 220ms ease;
}
body.tv-mode-active .bottom-nav:hover,
body.tv-mode-active .bottom-nav:focus-within,
body.tv-mode-active .bottom-nav:active {
    opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
    body.tv-mode-active .bottom-nav { transition: none; }
}

/* Restyle the bottom-nav as translucent black/blurred so the cream paper tray
   doesn't break the cinematic palette. We override the inner surface (paper
   becomes glass) and ink → warm white so icons stay readable on dark video.
   Active state lights up gold to match the rest of the TV chrome. */
body.tv-mode-active .bottom-nav-inner {
    background: rgba(0, 0, 0, 0.55);
    border-color: rgba(246, 239, 226, 0.16);
    box-shadow: var(--tv-glass-shadow);
    backdrop-filter: blur(18px) saturate(1.4);
    -webkit-backdrop-filter: blur(18px) saturate(1.4);
}
/* On mobile, the outer .bottom-nav gets a cream paper background and a 2px
   ink top-border (design-system.css mobile media query). In TV mode that
   sliver of cream around the dark glass tray reads as a UI bug. Strip both
   so the dark glass extends edge-to-edge. */
@media (max-width: 767px) {
    body.tv-mode-active .bottom-nav {
        background: transparent;
        border-top-color: rgba(246, 239, 226, 0.12);
    }
}
body.tv-mode-active .bottom-nav-item {
    color: rgba(246, 239, 226, 0.6);
}
body.tv-mode-active .bottom-nav-item:not(.active) svg {
    color: rgba(246, 239, 226, 0.72);
}
body.tv-mode-active .bottom-nav-item:hover:not(.active) {
    color: var(--tv-ink);
}
body.tv-mode-active .bottom-nav-item.active {
    color: var(--tv-glow);
}
body.tv-mode-active .bottom-nav-item.active svg {
    color: var(--tv-glow);
    filter: drop-shadow(0 0 6px rgba(255, 226, 122, 0.45));
}
body.tv-mode-active .bottom-nav-item.active::before {
    background: rgba(255, 226, 122, 0.16);
}

/* Stage */
.tv-stage {
    position: fixed;
    inset: 0;
    width: 100vw;
    height: 100dvh; /* dynamic viewport unit — survives iOS address-bar resize */
    background: #000;
    color: var(--tv-ink);
    z-index: 9999;
    overflow: hidden;
    font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
    -webkit-tap-highlight-color: transparent;
    user-select: none;
    -webkit-user-select: none;
    contain: strict;
    /* Take all touch gestures ourselves — prevents iOS rubber-band/back-swipe
       from competing with the TikTok-style vertical pager. Children that need
       native scroll (channel rail, channel menu) re-enable specific axes. */
    touch-action: none;
    overscroll-behavior: none;
    /* Drag offsets driven by the JS pointer handler. .tv-video composes these
       with its own centering translate; .tv-ambilight slides 1:1 with them. */
    --tv-drag-x: 0px;
    --tv-drag-y: 0px;
    /* Post-commit "pop in" offset: video + lower-third + actions-rail jump
       to the OPPOSITE off-screen edge during the loading curtain, then animate
       back to 0 during the fade-in so the new artist visibly slides in from
       below (TikTok feel). Ambilight intentionally does NOT consume this so
       the room stays lit during the swap. */
    --tv-pop-y: 0px;
    /* Bottom-nav is mobile-only (display:none ≥768px in design-system.css)
       and floats above the stage (z:10000) in TV mode. Bottom-anchored chrome
       (ticker, overlay-grid, actions-rail) lifts by this amount so it never
       slides under the nav tray. Overridden in the mobile media query below. */
    --tv-nav-clearance: 0px;
    /* Structural rest-Y for the video frame. 0 means "centered at top:50%".
       Mobile overrides this to a negative value to lift the video into the
       upper portion of the screen so the lower-third + scrubber have room
       to breathe directly underneath. Must be in px units — the JS pointer
       handler parses this with parseFloat() to compensate the drag math
       (see browse.js _tvShouldIgnoreSwipe / pointerdown). */
    --tv-rest-y: 0px;
}
.tv-stage[hidden] { display: none; }

/* Mobile (matches the bottom-nav visibility breakpoint in design-system.css)  —
   reserve room above the bottom-nav. --bottom-nav-h is set on :root in
   design-system.css (56px default, 44px ≤767px), so this picks up automatically. */
@media (max-width: 767px) {
    .tv-stage { --tv-nav-clearance: var(--bottom-nav-h); }
}

/* Ambient backdrop — blurred artist photo bleeds light onto the negative
   space around vertical/letterboxed videos. Cross-fades on artist change. */
.tv-ambient {
    position: absolute;
    inset: -12%;
    background-color: #050407;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    filter: blur(80px) saturate(1.4) brightness(0.55);
    transform: scale(1.25);
    opacity: 0.55;
    z-index: 0;
    pointer-events: none;
    transition: background-image 600ms ease, opacity 600ms ease;
    will-change: background-image;
}

/* Video frame — centered "screen" sized to the SOURCE video's aspect ratio
   (fetched via oEmbed and stored in --tv-aspect). The foreground iframe is
   slightly oversized to clip YT chrome. */
.tv-stage { --tv-aspect: 1.7777; /* default 16:9 */ }

.tv-video {
    position: absolute;
    top: 50%;
    left: 50%;
    /* Compose drag offsets with centering translate, plus a structural rest-Y
       offset that lifts the video on mobile (see --tv-rest-y on .tv-stage)
       and the commit-time pop-in offset (--tv-pop-y) that drives the
       slide-in-from-below animation. At rest with all extras at 0 this
       resolves to translate(-50%, -50%). */
    transform: translate(
        calc(-50% + var(--tv-drag-x)),
        calc(-50% + var(--tv-drag-y) + var(--tv-pop-y, 0px) + var(--tv-rest-y, 0px))
    );
    aspect-ratio: var(--tv-aspect);
    /* Pick whichever dimension is binding given current viewport */
    width: min(86vw, calc(80dvh * var(--tv-aspect)));
    max-height: 80dvh;
    pointer-events: none;
    background: #000;
    z-index: 3;
    overflow: hidden;
    border-radius: 12px;
    box-shadow:
        0 0 60px rgba(0, 0, 0, 0.6),
        0 30px 80px rgba(0, 0, 0, 0.55);
    isolation: isolate;
    will-change: transform;
}
.tv-video iframe,
.tv-video > div {
    position: absolute;
    top: -8%;
    left: -8%;
    width: 116%;
    height: 116%;
    border: 0;
}
/* Subtle dark gradient at the bottom of the video — softens the hard rectangle
   edge so the embed feels native to the TV chrome rather than a YT iframe.
   Sits above the iframe inside the .tv-video stacking context (isolation:isolate
   on .tv-video), pointer-events:none so it never swallows clicks. */
.tv-video::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
    border-radius: inherit;
    background: linear-gradient(
        180deg,
        transparent 0%,
        transparent 62%,
        rgba(0, 0, 0, 0.18) 80%,
        rgba(0, 0, 0, 0.55) 100%
    );
}

/* Ambilight: muted clone of the SAME video, blown up to cover the entire stage
   and heavily blurred. The video's live colors bleed across the negative space
   around the foreground player. */
.tv-ambilight {
    position: absolute;
    inset: -10%;
    z-index: 1;
    pointer-events: none;
    overflow: hidden;
    filter: blur(80px) saturate(1.8) brightness(1.0);
    opacity: 0.92;
    /* Slide with the foreground frame during TikTok-style drags AND honor the
       same structural rest-Y so the bloom tracks the foreground at rest. The
       ambilight is heavily blurred + oversized (inset:-10%), so a 50px lift
       is invisible at the edges but keeps the bloom visually anchored. */
    transform: translate(var(--tv-drag-x), calc(var(--tv-drag-y) + var(--tv-rest-y, 0px)));
    will-change: transform, filter, opacity;
    transition: opacity 600ms ease;
}
.tv-ambilight iframe {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    /* Cover-fit: pick the dimension that makes the video's aspect ratio fully
       paint the entire stage. This is what kills the black YouTube letterbox
       bars that would otherwise wash out the bloom. */
    aspect-ratio: var(--tv-aspect, 1.7777);
    width: max(120vw, calc(120dvh * var(--tv-aspect, 1.7777)));
    border: 0;
    pointer-events: none;
}

/* Desktop — bump the frame up to a near-fullscreen broadcast tile so the
   video carries the screen. The bloom still wraps it because the ambilight
   sits behind, inset:-10%. Targets the new immersive layout where actions /
   genre rail are tucked behind compact Tune/More toggles. */
@media (min-width: 920px) {
    .tv-video {
        width: min(82vw, calc(90dvh * var(--tv-aspect)));
        max-height: 90dvh;
    }
}

/* Tablet / landscape phone */
@media (max-width: 920px) and (min-width: 541px) {
    .tv-video {
        width: min(78vw, calc(70dvh * var(--tv-aspect)));
        max-height: 70dvh;
    }
}

/* Mobile — frame fills viewport width when the video is portrait, or fills
   viewport height when it's landscape. The ambilight always covers the whole
   stage, so any remaining negative space gets the colored bloom.

   The video stays anchored at top:50% (don't switch to `bottom:` anchoring —
   that would force the JS pointer handler to track multiple positioning
   modes). On mobile the bottom chrome cluster now FOLLOWS the video bottom
   edge (see the @media (max-width: 540px) cluster rules below), so the
   video can stay centered (--tv-rest-y stays at 0). The JS pointerdown
   handler still subtracts --tv-rest-y from baseY so any future structural
   lift would compose correctly. */
@media (max-width: 540px) {
    /* Strict 16:9 frame on mobile regardless of source aspect — keeps the
       broadcast tile compact and predictable so metadata reads sooner. The
       ambilight (which still uses --tv-aspect) keeps painting source-true
       colors around the frame. Portrait sources letterbox inside the iframe
       rather than stretching the visible tile. */
    .tv-video {
        aspect-ratio: var(--tv-mobile-aspect);
        width: min(92vw, calc(34dvh * var(--tv-mobile-aspect)));
        max-height: 34dvh;
        border-radius: 14px;
    }
    .tv-stage.tv-portrait .tv-video {
        aspect-ratio: var(--tv-mobile-aspect);
        width: min(92vw, calc(34dvh * var(--tv-mobile-aspect)));
        max-height: 34dvh;
    }
    /* More aggressive iframe bleed on mobile — the small frame means the
       default 8% crop still leaks YT title bar and the "Watch on YouTube"
       pill. The title bar in a small embed is ~22-26% of frame height, so
       28% on each edge is needed to clip it cleanly. */
    .tv-video iframe,
    .tv-video > div {
        top: -28%;
        left: -28%;
        width: 156%;
        height: 156%;
    }
    .tv-ambilight {
        filter: blur(70px) saturate(2) brightness(1.1);
        opacity: 1;
    }
}

/* Vignette — sits BEHIND the video so it tints the ambilight without dimming
   the actual broadcast. Top/bottom darken the rows so overlay text reads
   without competing with the live colors of the video. */
.tv-stage::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
    background:
        radial-gradient(ellipse at center, transparent 42%, rgba(0,0,0,0.45) 92%),
        linear-gradient(180deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.15) 14%, rgba(0,0,0,0) 28%, rgba(0,0,0,0) 64%, rgba(0,0,0,0.4) 88%, rgba(0,0,0,0.75) 100%);
}

/* CRT scanlines — subtle, always on */
.tv-scanlines {
    position: absolute;
    inset: 0;
    z-index: 6;
    pointer-events: none;
    background: repeating-linear-gradient(
        to bottom,
        rgba(255, 255, 255, 0.045) 0px,
        rgba(255, 255, 255, 0.045) 1px,
        transparent 1px,
        transparent 3px
    );
    mix-blend-mode: overlay;
    opacity: 0.7;
}
.tv-scanlines::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(transparent 0%, rgba(255,255,255,0.06) 50%, transparent 100%);
    height: 12vh;
    animation: tv-scan-roll 7s linear infinite;
    mix-blend-mode: screen;
    opacity: 0.5;
}
@keyframes tv-scan-roll {
    0%   { transform: translateY(-12vh); }
    100% { transform: translateY(100vh); }
}

/* Static / flicker — only visible on .active for ~280ms bursts */
.tv-static {
    position: absolute;
    inset: 0;
    z-index: 8;
    pointer-events: none;
    opacity: 0;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.95' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 1.4 0'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.85'/></svg>");
    background-size: 220px 220px;
    mix-blend-mode: screen;
    transition: opacity 80ms linear;
    will-change: opacity, background-position;
}
.tv-static.active {
    opacity: 0.85;
    animation: tv-static-jitter 280ms steps(6, end);
}
/* Stronger / longer burst used when the user changes channels — the
   re-tune deserves a heavier flicker than a track auto-advance. Same
   keyframes, just slowed and held a bit longer. */
.tv-static.active.strong {
    opacity: 0.95;
    animation: tv-static-jitter 540ms steps(10, end);
}
@keyframes tv-static-jitter {
    0%   { background-position: 0 0;       opacity: 0.95; filter: brightness(1.5) contrast(2); }
    20%  { background-position: 70px 30px; opacity: 0.55; }
    40%  { background-position: 130px 90px; opacity: 0.85; }
    60%  { background-position: 40px 180px; opacity: 0.4; }
    80%  { background-position: 200px 120px; opacity: 0.7; }
    100% { background-position: 90px 220px; opacity: 0; }
}

/* Subtle persistent grain at near-zero opacity */
.tv-stage > .tv-grain {
    position: absolute;
    inset: 0;
    z-index: 7;
    pointer-events: none;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.6' numOctaves='1' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.5'/></svg>");
    background-size: 180px 180px;
    opacity: 0.045;
    mix-blend-mode: overlay;
    animation: tv-grain-shift 1.4s steps(4) infinite;
}
@keyframes tv-grain-shift {
    0%   { background-position: 0 0; }
    25%  { background-position: 60px 30px; }
    50%  { background-position: 30px 90px; }
    75%  { background-position: 120px 60px; }
    100% { background-position: 0 150px; }
}

/* Overlay grid (top + bottom rows, content above all video/static) */
.tv-overlay-grid {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    /* 22px clears the slim toned-down ticker below; --tv-nav-clearance lifts
       above the mobile bottom-nav (0 on desktop where the nav is hidden).
       Mobile uses the same height now that the ticker is uniformly slim. */
    bottom: calc(22px + env(safe-area-inset-bottom, 0px) + var(--tv-nav-clearance, 0px));
    z-index: 20;
    display: grid;
    grid-template-rows: auto auto 1fr auto auto auto;
    pointer-events: none; /* children re-enable as needed */
}

.tv-row-top {
    display: flex;
    align-items: center;
    /* Two children: broadcast identity (left) and channel bug (right). The
       previous floating × was removed in favor of the persistent bottom-nav. */
    justify-content: space-between;
    padding: calc(env(safe-area-inset-top, 0px) + 18px) 22px 0;
    gap: 16px;
}
.tv-row-top > * { pointer-events: auto; }

/* Broadcast identity block — wordmark + status line. Stays left-aligned and
   carries everything that describes "this is HPPN TV broadcasting from X".
   The status line wraps below the wordmark if a wide city name forces it. */
.tv-wordmark-block {
    display: inline-flex;
    flex-direction: column;
    align-items: flex-start;
    min-width: 0;
}
.tv-status-row {
    display: inline-flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px 8px;
    margin-top: 6px;
    font-family: 'DM Sans', system-ui, sans-serif;
    font-weight: 500;
    font-size: clamp(9px, 1.6vw, 11px);
    letter-spacing: 0.32em;
    color: rgba(246, 239, 226, 0.65);
    text-transform: uppercase;
}
.tv-status-text {
    color: rgba(246, 239, 226, 0.78);
}
.tv-status-sep {
    color: rgba(246, 239, 226, 0.35);
    font-weight: 700;
    letter-spacing: 0;
}

/* Area / city pill — inline status chip sitting next to "On Air" in the
   identity block. Neutral ink so the warm yellow/red palette stays reserved
   for the channel system. Compact (icon + city + caret), no "AREA" prefix
   because the surrounding context already says we're broadcasting from somewhere. */
.tv-area-pill {
    appearance: none;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 7px 13px 7px 12px;
    border: 1px solid rgba(246, 239, 226, 0.28);
    border-radius: 999px;
    background: rgba(0, 0, 0, 0.42);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    color: var(--tv-ink);
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 12px;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    cursor: pointer;
    transition: border-color 180ms ease, color 180ms ease, background 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.tv-area-pill > svg { width: 14px; height: 14px; }
.tv-area-pill .tv-area-caret { width: 11px; height: 11px; }
.tv-area-pill:hover,
.tv-area-pill:focus-visible {
    border-color: rgba(246, 239, 226, 0.6);
    background: rgba(0, 0, 0, 0.55);
    box-shadow: 0 0 14px rgba(246, 239, 226, 0.18);
    outline: none;
}
.tv-area-pill:active { transform: translateY(1px); }
.tv-area-name,
.tv-date-name {
    color: var(--tv-ink);
}
.tv-area-caret {
    opacity: 0.7;
}
/* Date pill leans on the same chip styling as the area pill; an active
   filter is hinted with a warmer border + subtle glow so the user can see
   at a glance that DATES is filtering the broadcast. */
.tv-date-pill.is-active {
    border-color: rgba(255, 226, 122, 0.55);
    color: var(--tv-glow);
    box-shadow: 0 0 12px rgba(255, 226, 122, 0.18);
}
.tv-date-pill.is-active .tv-date-name {
    color: var(--tv-glow);
}

/* Navigation arrow buttons (desktop-friendly; also visible on mobile) */
.tv-nav {
    appearance: none;
    position: absolute;
    z-index: 25;
    pointer-events: auto;
    border: 1px solid rgba(255, 226, 122, 0.4);
    background: rgba(0, 0, 0, 0.55);
    color: var(--tv-ink);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    cursor: pointer;
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.24em;
    text-transform: uppercase;
    transition: opacity 280ms ease, background 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease;
}
.tv-nav:hover,
.tv-nav:focus-visible {
    background: rgba(255, 226, 122, 0.14);
    border-color: rgba(255, 226, 122, 0.85);
    color: var(--tv-glow);
    outline: none;
}
.tv-nav:active { transform: translateY(1px); }
.tv-nav svg { width: 18px; height: 18px; }

/* Channel rail — horizontal strip below the top row showing prev/current/next
   channels TikTok-style. Click any tab to jump; horizontal swipe on the stage
   surfs through them; on desktop, flanking chevrons advance one at a time. */
.tv-row-rail {
    position: relative;
    z-index: 24;
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 6px 14px 0;
    pointer-events: auto;
    width: 100%;
    max-width: 100%;
    min-width: 0;
    box-sizing: border-box;
}
.tv-rail-chev {
    appearance: none;
    width: 34px;
    height: 34px;
    flex-shrink: 0;
    border-radius: 999px;
    border: 1px solid rgba(246, 239, 226, 0.18);
    background: rgba(0, 0, 0, 0.42);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    color: rgba(246, 239, 226, 0.78);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    transition: background 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
.tv-rail-chev svg { width: 16px; height: 16px; }
.tv-rail-chev:hover,
.tv-rail-chev:focus-visible {
    background: rgba(255, 226, 122, 0.12);
    border-color: rgba(255, 226, 122, 0.55);
    color: var(--tv-glow);
    box-shadow: 0 0 14px rgba(255, 226, 122, 0.18);
    outline: none;
}
.tv-rail-chev:active { transform: translateY(1px); }

.tv-channel-rail {
    flex: 1 1 0;
    min-width: 0;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
    scroll-behavior: smooth;
    -webkit-overflow-scrolling: touch;
    /* Side fade masks. The fade region is wide enough to swallow a partial
       chip whole — a narrower fade leaves half-words like "OP" (instead of
       "POP") readable at the edge, which reads as a rendering bug. */
    mask-image: linear-gradient(to right, transparent 0, #000 72px, #000 calc(100% - 72px), transparent 100%);
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 72px, #000 calc(100% - 72px), transparent 100%);
}
.tv-channel-rail::-webkit-scrollbar { display: none; }
.tv-channel-rail-track {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 8px;
}

.tv-channel-tab {
    appearance: none;
    flex-shrink: 0;
    padding: 8px 14px;
    border-radius: 999px;
    border: 1px solid rgba(246, 239, 226, 0.22);
    background: rgba(0, 0, 0, 0.38);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    color: rgba(246, 239, 226, 0.78);
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    white-space: nowrap;
    cursor: pointer;
    transition: background 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
.tv-channel-tab:hover,
.tv-channel-tab:focus-visible {
    background: rgba(255, 226, 122, 0.10);
    border-color: rgba(255, 226, 122, 0.5);
    color: var(--tv-ink);
    outline: none;
}
.tv-channel-tab.active {
    background:
        radial-gradient(120% 180% at 50% 130%, rgba(255, 61, 110, 0.22), transparent 60%),
        rgba(255, 226, 122, 0.08);
    border-color: rgba(255, 226, 122, 0.9);
    color: #fff;
    box-shadow:
        0 0 18px rgba(255, 226, 122, 0.32),
        inset 0 0 0 1px rgba(255, 255, 255, 0.10);
    transform: scale(1.04);
    text-shadow: 0 0 8px rgba(255, 226, 122, 0.45);
}
/* Tiny "ON AIR" caret that lights up only on the active chip — gives the
   selected channel a TV-style indicator instead of relying on color alone. */
.tv-channel-tab.active::before {
    content: "";
    display: inline-block;
    width: 5px;
    height: 5px;
    margin-right: 6px;
    margin-left: -1px;
    border-radius: 999px;
    background: var(--tv-accent);
    box-shadow: 0 0 5px var(--tv-accent), 0 0 10px rgba(255, 61, 110, 0.5);
    animation: tv-rec-blink 1.4s ease-in-out infinite;
    vertical-align: 1px;
}

/* Mobile — slimmer chips, smaller chevrons, less side padding. The fade
   mask narrows here so the centered active chip + 1 neighbor still has
   room to breathe in a 360px viewport, while still hiding partial words. */
@media (max-width: 540px) {
    .tv-row-rail { padding: 2px 8px 0; gap: 4px; }
    .tv-rail-chev { width: 30px; height: 30px; }
    .tv-rail-chev svg { width: 15px; height: 15px; }
    .tv-channel-tab { padding: 5px 11px; font-size: 10px; letter-spacing: 0.18em; }
    .tv-channel-rail {
        mask-image: linear-gradient(to right, transparent 0, #000 52px, #000 calc(100% - 52px), transparent 100%);
        -webkit-mask-image: linear-gradient(to right, transparent 0, #000 52px, #000 calc(100% - 52px), transparent 100%);
    }
}

/* On really wide desktop screens the chevrons can be hidden — keyboard, swipe,
   wheel, and tab-clicks already cover navigation. */
/* (intentionally always shown for now — easy to discover) */

/* Inline artist prev/next chevrons that flank the action button row */
.tv-action-nav {
    appearance: none;
    width: 36px;
    height: 36px;
    padding: 0;
    border-radius: 999px;
    border: 1px solid rgba(246, 239, 226, 0.35);
    background: rgba(0, 0, 0, 0.5);
    color: var(--tv-ink);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    transition: background 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease;
}
.tv-action-nav svg { width: 16px; height: 16px; }
.tv-action-nav:hover,
.tv-action-nav:focus-visible {
    background: rgba(255, 226, 122, 0.16);
    border-color: rgba(255, 226, 122, 0.85);
    color: var(--tv-glow);
    outline: none;
}
.tv-action-nav:active { transform: translateY(1px); }

/* Hide drag-style arrows on idle to match the actions fade */
.tv-stage.idle .tv-nav { opacity: 0; pointer-events: none; }

@media (max-width: 540px) {
    .tv-area-pill { font-size: 11px; padding: 6px 12px 6px 11px; letter-spacing: 0.18em; gap: 7px; }
    .tv-area-pill > svg { width: 13px; height: 13px; }
    .tv-area-pill .tv-area-caret { width: 10px; height: 10px; }
    .tv-action-nav { width: 32px; height: 32px; }
    .tv-action-nav svg { width: 14px; height: 14px; }
}

.tv-wordmark {
    font-family: 'Fraunces', 'Libre Baskerville', serif;
    font-weight: 900;
    /* Desktop reads as a broadcast title, mobile collapses to a tight inline
       brand mark so the top bar doesn't dominate the viewport. The mobile
       max (28px) is enforced below in the @media (max-width: 540px) cluster. */
    font-size: clamp(22px, 4.6vw, 44px);
    letter-spacing: 0.04em;
    line-height: 0.95;
    color: var(--tv-glow);
    text-shadow: var(--tv-shadow);
    display: inline-flex;
    align-items: center;
    gap: 8px;
}
.tv-wordmark::before {
    content: "";
    display: inline-block;
    width: 9px;
    height: 9px;
    border-radius: 999px;
    background: var(--tv-accent);
    box-shadow: 0 0 14px var(--tv-accent), 0 0 28px rgba(255, 61, 110, 0.4);
    margin-right: 8px;
    animation: tv-rec-blink 1.4s ease-in-out infinite;
}
@keyframes tv-rec-blink {
    0%, 60%, 100% { opacity: 1; }
    70%, 90%      { opacity: 0.25; }
}
/* Channel badge — passive HUD bug. Reads like a TV station logo in the corner
   of a broadcast: it tells you *what channel you're on*, but the rail below
   is the only thing you actually click to switch. Removing the dropdown
   eliminates a redundant entry point and lets the rail own that affordance.
   Slimmed down (less padding, less inset glow, smaller min-width) so it sits
   alongside the wordmark without dominating the header.  */
.tv-channel-badge {
    position: relative;
    display: inline-flex;
    align-items: center;
    padding: 7px 13px;
    border: 1px solid rgba(255, 226, 122, 0.45);
    border-radius: 999px;
    background: rgba(0, 0, 0, 0.42);
    box-shadow: 0 0 12px rgba(255, 226, 122, 0.1);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    color: var(--tv-ink);
    pointer-events: none;
    font-family: 'DM Sans', sans-serif;
    flex-shrink: 0;
}

/* Channel menu (dropdown) */
.tv-channel-menu {
    position: absolute;
    top: calc(100% + 10px);
    right: 0;
    min-width: 240px;
    max-width: min(86vw, 360px);
    max-height: 60vh;
    overflow-y: auto;
    overflow-x: hidden;
    background: rgba(8, 4, 14, 0.92);
    border: 1px solid rgba(255, 226, 122, 0.45);
    border-radius: 8px;
    box-shadow: 0 18px 48px rgba(0,0,0,0.65), 0 0 24px rgba(255, 226, 122, 0.18);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    padding: 6px;
    z-index: 40;
    pointer-events: auto;
    animation: tv-menu-pop 160ms ease-out;
}
.tv-channel-menu[hidden] { display: none; }
@keyframes tv-menu-pop {
    0%   { opacity: 0; transform: translateY(-6px) scale(0.98); }
    100% { opacity: 1; transform: translateY(0) scale(1); }
}
.tv-channel-menu-item {
    appearance: none;
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: 12px;
    padding: 9px 12px;
    border: 1px solid transparent;
    border-radius: 6px;
    background: transparent;
    color: var(--tv-ink);
    font-family: 'DM Sans', sans-serif;
    font-weight: 600;
    font-size: 12.5px;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    text-align: left;
    cursor: pointer;
    transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.tv-channel-menu-item:hover,
.tv-channel-menu-item:focus-visible {
    background: rgba(255, 226, 122, 0.10);
    border-color: rgba(255, 226, 122, 0.4);
    outline: none;
    color: var(--tv-glow);
}
.tv-channel-menu-item.active {
    background: rgba(255, 61, 110, 0.14);
    border-color: rgba(255, 61, 110, 0.55);
    color: #fff;
}
.tv-channel-menu-num {
    font-family: 'Fraunces', serif;
    font-weight: 800;
    font-size: 11px;
    letter-spacing: 0.22em;
    color: var(--tv-glow);
    min-width: 38px;
}
.tv-channel-menu-name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.tv-channel-menu-count {
    font-family: 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: 10.5px;
    letter-spacing: 0.18em;
    color: rgba(246, 239, 226, 0.55);
    margin-left: auto;
}

/* Desktop transport row — explicit prev/play/next replaces wheel-scrolling
   on >=920px viewports. Hidden on mobile/tablet, where touch swipe + the
   scrubber already provide a natural transport. Buttons are real <button>s
   so the existing _tvShouldIgnoreSwipe button-selector keeps stage drag /
   wheel handlers from firing when the user hits transport. */
.tv-transport {
    display: none;
    align-items: center;
    justify-content: center;
    gap: 14px;
    padding: 4px 22px 6px;
    pointer-events: auto;
    transition: opacity 320ms ease;
    opacity: 0.95;
}
.tv-stage.idle .tv-transport {
    opacity: 0;
    pointer-events: none;
}
.tv-transport-btn {
    appearance: none;
    width: 44px;
    height: 44px;
    padding: 0;
    border-radius: 999px;
    border: 1px solid rgba(255, 226, 122, 0.45);
    background: rgba(0, 0, 0, 0.55);
    color: var(--tv-ink);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    transition: background 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
.tv-transport-btn:hover,
.tv-transport-btn:focus-visible {
    background: rgba(255, 226, 122, 0.16);
    border-color: rgba(255, 226, 122, 0.85);
    color: var(--tv-glow);
    box-shadow: 0 0 18px rgba(255, 226, 122, 0.3);
    outline: none;
}
.tv-transport-btn:active { transform: translateY(1px) scale(0.97); }
.tv-transport-btn svg { width: 18px; height: 18px; }
/* Center play/pause is the primary action — slightly larger and pre-glowed */
.tv-transport-play {
    width: 56px;
    height: 56px;
    border-color: rgba(255, 226, 122, 0.7);
    background: rgba(255, 226, 122, 0.16);
    color: var(--tv-glow);
    box-shadow: 0 0 18px rgba(255, 226, 122, 0.32);
}
.tv-transport-play svg { width: 22px; height: 22px; }
/* Stack play + pause SVGs and toggle visibility via .is-playing on the button.
   Default visible icon is "play" (action available when paused/stopped). */
.tv-transport-play .tv-transport-icon-pause { display: none; }
.tv-transport-play.is-playing .tv-transport-icon-play { display: none; }
.tv-transport-play.is-playing .tv-transport-icon-pause { display: inline-block; }

@media (min-width: 920px) {
    .tv-transport { display: flex; }
}

/* Video scrubber — appears in the row above .tv-actions */
.tv-scrub {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 4px 22px 0;
    pointer-events: auto;
    transition: opacity 320ms ease;
    opacity: 0.95;
}
.tv-stage.idle .tv-scrub {
    opacity: 0;
    pointer-events: none;
}
.tv-scrub-time {
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.18em;
    color: rgba(246, 239, 226, 0.85);
    text-shadow: 0 0 6px rgba(0,0,0,0.6);
    min-width: 38px;
    text-align: center;
    font-variant-numeric: tabular-nums;
}
.tv-scrub-track {
    position: relative;
    flex: 1;
    height: 22px;
    display: flex;
    align-items: center;
    cursor: pointer;
    touch-action: none;
}
.tv-scrub-track::before {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: 50%;
    height: 3px;
    transform: translateY(-50%);
    background: rgba(255, 255, 255, 0.18);
    border-radius: 2px;
}
.tv-scrub-fill {
    position: absolute;
    left: 0;
    top: 50%;
    height: 3px;
    width: 0%;
    transform: translateY(-50%);
    background: linear-gradient(90deg, var(--tv-glow), var(--tv-accent));
    border-radius: 2px;
    box-shadow: 0 0 10px rgba(255, 226, 122, 0.55);
    pointer-events: none;
    transition: width 160ms linear;
}
.tv-scrub-handle {
    position: absolute;
    top: 50%;
    left: 0%;
    width: 12px;
    height: 12px;
    border-radius: 999px;
    transform: translate(-50%, -50%);
    background: var(--tv-glow);
    box-shadow: 0 0 10px var(--tv-glow), 0 0 18px rgba(255, 226, 122, 0.6);
    pointer-events: none;
    transition: left 160ms linear, transform 120ms ease;
}
.tv-scrub-track:hover .tv-scrub-handle,
.tv-scrub-track:active .tv-scrub-handle {
    transform: translate(-50%, -50%) scale(1.18);
}
.tv-channel-num {
    font-family: 'Fraunces', serif;
    font-weight: 900;
    font-size: clamp(22px, 5vw, 36px);
    letter-spacing: 0.06em;
    color: var(--tv-glow);
    text-shadow: 0 0 10px rgba(255, 226, 122, 0.55);
    line-height: 1;
}
.tv-channel-num small {
    display: inline-block;
    font-size: 0.55em;
    letter-spacing: 0.32em;
    color: rgba(246, 239, 226, 0.7);
    margin-right: 6px;
    vertical-align: 0.25em;
}
.tv-channel-name {
    margin-top: 4px;
    font-size: 11px;
    letter-spacing: 0.28em;
    text-transform: uppercase;
    color: rgba(246, 239, 226, 0.85);
}

/* Sub-line under the channel number on the badge — shows artist count
   ("14 ARTISTS") rather than the channel name, which is already shown
   prominently in the rail's active chip just below. */
.tv-channel-meta {
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    color: rgba(246, 239, 226, 0.78);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* Brief scale + glow when the channel changes, so the switch registers in
   peripheral vision even if the user is looking at the rail. */
.tv-channel-num.pulse {
    animation: tv-channel-pulse 460ms ease-out;
}
@keyframes tv-channel-pulse {
    0%   { transform: scale(1);    color: var(--tv-glow); text-shadow: 0 0 10px rgba(255, 226, 122, 0.55); }
    35%  { transform: scale(1.18); color: #ffffff;       text-shadow: 0 0 22px rgba(255, 226, 122, 0.95), 0 0 38px rgba(255, 61, 110, 0.55); }
    100% { transform: scale(1);    color: var(--tv-glow); text-shadow: 0 0 10px rgba(255, 226, 122, 0.55); }
}

/* Spacer / video focus zone */
.tv-row-mid {
    /* purely a layout spacer; clicks pass through */
}

/* Bottom row */
.tv-row-bottom {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    gap: 16px;
    /* Right padding reserves a column for the absolutely-positioned
       .tv-actions-rail (≈64px wide) so the upcoming card never runs under
       the heart/comment/share dock. The dock now anchors to the lower-third
       baseline (see .tv-actions-rail bottom override) so the two rails sit
       on a shared horizontal axis. Mobile re-overrides this — the dock
       there sits beside the lower-third already via cluster-top math. */
    padding: 12px 88px 6px 22px;
}
.tv-row-bottom > * { pointer-events: auto; }

.tv-now-playing {
    max-width: 70%;
}
.tv-now-playing-label {
    font-size: 11px;
    letter-spacing: 0.36em;
    text-transform: uppercase;
    color: var(--tv-cyan);
    text-shadow: 0 0 10px rgba(78, 226, 255, 0.45);
    margin-bottom: 6px;
}
.tv-now-playing-name {
    font-family: 'Fraunces', serif;
    font-weight: 800;
    font-size: clamp(28px, 7vw, 56px);
    line-height: 1;
    letter-spacing: 0.005em;
    color: var(--tv-ink);
    text-shadow: 0 2px 24px rgba(0, 0, 0, 0.85), 0 0 30px rgba(255, 226, 122, 0.12);
    word-break: break-word;
}
.tv-now-playing-meta {
    margin-top: 10px;
    font-size: 12px;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    color: rgba(246, 239, 226, 0.92);
    display: flex;
    flex-wrap: wrap;
    gap: 10px 16px;
    align-items: center;
}
.tv-meta-dot {
    display: inline-block;
    width: 4px;
    height: 4px;
    border-radius: 999px;
    background: rgba(246, 239, 226, 0.45);
}
.tv-genre {
    color: var(--tv-mint);
    text-shadow: 0 0 10px rgba(110, 255, 184, 0.35);
}

/* Showing-Near-You card. Always rendered as a <button> for a11y/keyboard
   parity, but visually treated as a card. Picks up `.is-clickable` when the
   current artist has an actual event (venue + date + ticket), in which case
   it gets a hover/focus glow and shows a "View show →" CTA chip. Without
   `.is-clickable` it reads as inert info ("Up Next ...") so users don't
   click it expecting a ticket flow that doesn't exist. */
.tv-upcoming {
    appearance: none;
    background: transparent;
    border: 1px solid transparent;
    border-radius: 10px;
    padding: 8px 12px;
    text-align: right;
    max-width: 40%;
    color: rgba(246, 239, 226, 0.95);
    font: inherit;
    font-size: 12px;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    line-height: 1.4;
    cursor: default;
    transition: background 200ms ease, border-color 200ms ease, box-shadow 200ms ease, transform 160ms ease;
}
.tv-upcoming.is-clickable {
    cursor: pointer;
    background: rgba(0, 0, 0, 0.38);
    border-color: rgba(255, 61, 110, 0.32);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    box-shadow: 0 0 0 1px rgba(255, 61, 110, 0.06), 0 4px 14px rgba(0, 0, 0, 0.4);
}
.tv-upcoming.is-clickable:hover,
.tv-upcoming.is-clickable:focus-visible {
    background: rgba(255, 61, 110, 0.16);
    border-color: rgba(255, 61, 110, 0.7);
    box-shadow: 0 0 18px rgba(255, 61, 110, 0.4), 0 0 36px rgba(255, 61, 110, 0.22);
    outline: none;
    transform: translateY(-1px);
}
.tv-upcoming.is-clickable:active {
    transform: translateY(0) scale(0.98);
    background: rgba(255, 61, 110, 0.22);
}
/* When in "Up Next" mode the button is disabled. Override the user-agent's
   default greying so the card stays legible — the absence of border/glow
   already signals it's inert. */
.tv-upcoming:disabled {
    opacity: 1;
    color: rgba(246, 239, 226, 0.95);
    cursor: default;
}
.tv-upcoming-label {
    color: var(--tv-accent);
    text-shadow: 0 0 8px rgba(255, 61, 110, 0.45);
    margin-bottom: 6px;
    font-size: 10px;
    letter-spacing: 0.36em;
}
.tv-upcoming-line {
    font-size: 13px;
    letter-spacing: 0.16em;
    color: rgba(246, 239, 226, 1);
}
/* CTA chip — only painted when the card is_clickable. Empty span collapses
   so the inert "Up Next" state stays visually quiet. */
.tv-upcoming-cta {
    display: none;
    margin-top: 8px;
    font-size: 10px;
    letter-spacing: 0.32em;
    color: var(--tv-glow);
    text-shadow: 0 0 8px rgba(255, 226, 122, 0.4);
    font-weight: 700;
}
.tv-upcoming-cta::after { content: " →"; }
.tv-upcoming.is-clickable .tv-upcoming-cta { display: block; }

/* TikTok-style vertical action rail — anchored to the BOTTOM-right of the
   stage so the avatar/heart/comment/share never block the artist's face.
   The rail clears the scrubber/ticker that live in the bottom rows of the
   overlay grid (~30px ticker + ~30px scrubber + safe-area), and stays
   visible at all times — it does NOT participate in the idle fade so users
   can always reach Save/Share without having to wake the chrome first.
   The avatar sits proud at the top; save/comment/share live inside a
   .tv-rail-dock glass pill so they read as a single attached control unit. */
.tv-actions-rail {
    position: absolute;
    right: max(14px, env(safe-area-inset-right, 0px));
    /* Anchors to the lower-third baseline so the dock visually belongs WITH
       the artist info rather than floating in dead space. Stack height is
       ~220px (avatar 56 + dock ~170) so the bottom needs to clear the
       ticker + scrubber + lower-third padding. 56px = 24 (ticker) + 28
       (scrub) + 4 (gap). --tv-nav-clearance lifts above the mobile
       bottom-nav (0 on desktop). */
    bottom: calc(56px + env(safe-area-inset-bottom, 0px) + var(--tv-nav-clearance, 0px));
    z-index: 25;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    pointer-events: auto;
}

/* Glass pill containing save/comment/share — single attached control surface
   instead of three free-floating icons. Background reuses the shared glass
   tokens so the pill matches the bottom-nav material at the same depth. */
.tv-rail-dock {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    padding: 8px 6px;
    border-radius: 36px;
    background: var(--tv-glass-bg);
    border: 1px solid var(--tv-glass-border);
    backdrop-filter: var(--tv-glass-blur);
    -webkit-backdrop-filter: var(--tv-glass-blur);
    box-shadow: var(--tv-glass-shadow);
}
/* Buttons inside the dock drop their per-icon glass — the dock provides the
   blur surface, and a nested blur on each icon would double-frost. The hover
   highlight stays so the active button still glows. */
.tv-rail-dock .tv-rail-icon {
    background: transparent;
    box-shadow: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
}
.tv-rail-dock .tv-rail-btn:hover .tv-rail-icon,
.tv-rail-dock .tv-rail-btn:focus-visible .tv-rail-icon {
    background: rgba(255, 226, 122, 0.16);
    box-shadow: 0 0 16px rgba(255, 226, 122, 0.3);
}
.tv-rail-dock .tv-rail-save.is-saved .tv-rail-icon {
    background: rgba(255, 61, 110, 0.22);
    box-shadow: 0 0 16px rgba(255, 61, 110, 0.5);
}

/* Circular avatar — first available album cover, falling back to the artist
   photo and finally a brand-tinted gradient. */
.tv-rail-avatar {
    position: relative;
    display: block;
    width: 56px;
    height: 56px;
    border-radius: 999px;
    border: 2px solid rgba(246, 239, 226, 0.85);
    background: linear-gradient(135deg, #2a1830, #3d1140);
    cursor: pointer;
    text-decoration: none;
    flex-shrink: 0;
    box-shadow: 0 6px 18px rgba(0,0,0,0.55), 0 0 14px rgba(255, 226, 122, 0.18);
    transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.tv-rail-avatar:hover,
.tv-rail-avatar:focus-visible {
    transform: scale(1.06);
    border-color: var(--tv-glow);
    box-shadow: 0 8px 22px rgba(0,0,0,0.6), 0 0 22px rgba(255, 226, 122, 0.45);
    outline: none;
}
.tv-rail-avatar:active { transform: scale(0.96); }
.tv-rail-avatar-img {
    position: absolute;
    inset: 0;
    border-radius: 999px;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
}

/* Save / Comment / Share — circular frosted icon with an OPTIONAL count below.
   The count slot is only rendered (and only contributes flex height) when the
   button has a non-empty value — see `.tv-rail-count:empty` below. The btn
   gap stays 0 by default and only flips to 4px when there's actually a count
   to separate from the icon, so empty buttons collapse to icon-height and the
   rail can sit compact under the video frame. */
.tv-rail-btn {
    appearance: none;
    background: transparent;
    border: 0;
    padding: 0;
    cursor: pointer;
    color: var(--tv-ink);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0;
    transition: transform 160ms ease;
}
.tv-rail-btn:has(.tv-rail-count:not(:empty)) { gap: 4px; }
.tv-rail-btn:focus-visible { outline: none; }
.tv-rail-btn:active { transform: scale(0.94); }
.tv-rail-icon {
    position: relative;
    width: 48px;
    height: 48px;
    border-radius: 999px;
    background: rgba(0, 0, 0, 0.5);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 4px 14px rgba(0,0,0,0.45);
    transition: background 180ms ease, box-shadow 180ms ease;
}
.tv-rail-btn:hover .tv-rail-icon,
.tv-rail-btn:focus-visible .tv-rail-icon {
    background: rgba(255, 226, 122, 0.16);
    box-shadow: 0 4px 18px rgba(0,0,0,0.55), 0 0 18px rgba(255, 226, 122, 0.35);
}
.tv-rail-icon svg {
    width: 24px;
    height: 24px;
    color: var(--tv-ink);
    filter: drop-shadow(0 1px 2px rgba(0,0,0,0.55));
}
.tv-rail-count {
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.02em;
    color: rgba(246, 239, 226, 0.95);
    text-shadow: 0 1px 6px rgba(0,0,0,0.7);
    line-height: 1;
}
/* Collapse the count entirely when there's no value to show. Without this,
   the empty <span> reserves ~13px under every icon and stretches the rail
   vertically — the visual "extra padding" the rail had below the video. */
.tv-rail-count:empty { display: none; }

/* Heart toggle: stack outline + filled SVGs so the swap is instant and the
   filled state can carry its own color/glow without triggering layout. */
.tv-rail-save .tv-rail-icon-fill { display: none; }
.tv-rail-save.is-saved .tv-rail-icon-outline { display: none; }
.tv-rail-save.is-saved .tv-rail-icon-fill {
    display: block;
    color: var(--tv-accent);
}
.tv-rail-save.is-saved .tv-rail-icon {
    background: rgba(255, 61, 110, 0.22);
    box-shadow: 0 0 18px rgba(255, 61, 110, 0.55);
}

/* Bottom broadcast ticker — quiet broadcast accent. Toned down ~25%: lower
   text opacity, thinner border glow, dimmer star, and a touch shorter so it
   doesn't compete with the lower-third for attention. */
.tv-ticker {
    position: absolute;
    left: 0;
    right: 0;
    /* Sits at the safe-area edge on desktop; lifts above the bottom-nav on
       mobile via --tv-nav-clearance (set on .tv-stage in the mobile media query). */
    bottom: calc(env(safe-area-inset-bottom, 0px) + var(--tv-nav-clearance, 0px));
    z-index: 22;
    border-top: 1px solid rgba(255, 226, 122, 0.18);
    border-bottom: 1px solid rgba(255, 226, 122, 0.18);
    background: linear-gradient(90deg, rgba(0,0,0,0.62), rgba(20,5,30,0.62), rgba(0,0,0,0.62));
    overflow: hidden;
    height: 22px;
    display: flex;
    align-items: center;
}
.tv-ticker-track {
    display: inline-flex;
    white-space: nowrap;
    will-change: transform;
    animation: tv-ticker-scroll 42s linear infinite;
    color: var(--tv-glow);
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 10px;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    text-shadow: none;
    opacity: 0.6;
}
.tv-ticker-track > span {
    padding: 0 36px;
    display: inline-flex;
    align-items: center;
    gap: 18px;
}
.tv-ticker-track > span::before {
    content: "★";
    color: var(--tv-accent);
    font-size: 9px;
    opacity: 0.7;
    text-shadow: none;
}
@keyframes tv-ticker-scroll {
    0%   { transform: translateX(0); }
    100% { transform: translateX(-50%); }
}

/* "NO SIGNAL" overlay during empty/tuning */
.tv-no-signal {
    position: absolute;
    inset: 0;
    z-index: 30;
    display: none;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    gap: 10px;
    background: rgba(0, 0, 0, 0.78);
    color: var(--tv-glow);
    font-family: 'Fraunces', serif;
    font-weight: 900;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    text-shadow: var(--tv-shadow);
    pointer-events: none;
}
.tv-stage.no-signal .tv-no-signal {
    display: flex;
}
.tv-no-signal-title { font-size: clamp(28px, 6vw, 56px); }
.tv-no-signal-sub {
    font-family: 'DM Sans', sans-serif;
    font-weight: 600;
    font-size: 11px;
    letter-spacing: 0.45em;
    color: rgba(246, 239, 226, 0.7);
    text-shadow: none;
}

/* Empty state — shown when an area+channel combo legitimately has zero
   live videos (vs. .tv-no-signal which is for transient tuning/static).
   Provides recovery CTAs for the user (switch channel, switch city) so
   they aren't stuck staring at a dead broadcast. */
.tv-empty-state {
    position: absolute;
    inset: 0;
    z-index: 31;
    display: none;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    gap: 14px;
    padding: 32px 24px;
    background: rgba(5, 4, 7, 0.86);
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    color: var(--tv-ink);
    text-align: center;
    pointer-events: auto;
}
.tv-stage.tv-empty .tv-empty-state {
    display: flex;
}
.tv-empty-title {
    font-family: 'Fraunces', serif;
    font-weight: 900;
    font-size: clamp(22px, 4vw, 36px);
    letter-spacing: 0.06em;
    color: var(--tv-glow);
    text-shadow: var(--tv-shadow);
    text-transform: uppercase;
    line-height: 1.1;
    max-width: 22ch;
}
.tv-empty-sub {
    font-family: 'DM Sans', sans-serif;
    font-weight: 600;
    font-size: 12px;
    letter-spacing: 0.32em;
    color: rgba(246, 239, 226, 0.72);
    text-transform: uppercase;
    max-width: 36ch;
    line-height: 1.5;
}
.tv-empty-actions {
    display: inline-flex;
    flex-wrap: wrap;
    gap: 10px;
    margin-top: 6px;
    justify-content: center;
}
.tv-empty-btn {
    appearance: none;
    border: 1px solid rgba(255, 226, 122, 0.55);
    background: rgba(0, 0, 0, 0.5);
    color: var(--tv-glow);
    padding: 9px 16px;
    border-radius: 999px;
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    cursor: pointer;
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    transition: background 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.tv-empty-btn:hover,
.tv-empty-btn:focus-visible {
    background: rgba(255, 226, 122, 0.16);
    border-color: rgba(255, 226, 122, 1);
    color: #fff;
    box-shadow: 0 0 20px rgba(255, 226, 122, 0.45);
    outline: none;
}
.tv-empty-btn:active { transform: translateY(1px); }
.tv-empty-btn.tv-empty-btn-primary {
    background: linear-gradient(135deg, rgba(255, 61, 110, 0.4), rgba(255, 226, 122, 0.32));
    border-color: rgba(255, 226, 122, 0.95);
    color: #fff;
}

/* TikTok-style vertical pager drag —
   - .tv-dragging: finger is down, follow 1:1, no transition.
   - .tv-snap: finger released, animate to commit-target or back to rest.
   The transition wins over any prior transitions on the same property since
   it's specified later in the cascade.

   The lower-third (artist text + Showing-Near-You) and right-side action rail
   ride along with the video so the whole "card" feels like a single TikTok
   tile sliding past the chrome (top wordmark, channel rail, transport,
   ticker stay anchored). The scrubber rides along too — its time numbers
   are per-video, so anchoring it would leave a stale "0:03 / 3:19" floating
   between the moving video and the moving lower-third. */
.tv-row-bottom,
.tv-actions-rail,
.tv-scrub {
    transform: translateY(calc(var(--tv-drag-y, 0px) + var(--tv-pop-y, 0px)));
}
.tv-stage.tv-dragging .tv-video,
.tv-stage.tv-dragging .tv-ambilight,
.tv-stage.tv-dragging .tv-row-bottom,
.tv-stage.tv-dragging .tv-actions-rail,
.tv-stage.tv-dragging .tv-scrub {
    transition: none !important;
}
.tv-stage.tv-snap .tv-video,
.tv-stage.tv-snap .tv-ambilight,
.tv-stage.tv-snap .tv-row-bottom,
.tv-stage.tv-snap .tv-actions-rail,
.tv-stage.tv-snap .tv-scrub {
    transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1) !important;
}

/* Post-commit content swap — hide the foreground frame instantly so the
   YT iframe's black-poster flicker, the new video buffering, and the
   oEmbed aspect-ratio resize all happen behind the curtain. The ambilight
   keeps glowing so the room never goes dark. The card content (lower-third
   + action rail + scrubber) curtains too — otherwise they'd visibly snap
   from the off-screen exit position back to center while the video curtain
   hides the frame, breaking the "everything moves together" illusion. The
   scrubber's stale time would also flash through if it stayed visible. */
.tv-stage.tv-loading .tv-video,
.tv-stage.tv-loading .tv-row-bottom,
.tv-stage.tv-loading .tv-actions-rail,
.tv-stage.tv-loading .tv-scrub {
    opacity: 0;
    transition: none !important;
}
/* Slide IN from the opposite edge while fading from black. The commit JS
   sets --tv-pop-y to the entry offset under .tv-loading (transition:none
   → instant jump off-screen on the opposite side), then swaps to .tv-fading
   and animates --tv-pop-y back to 0 here. Transitioning transform AND
   opacity together gives the "next video sliding up to take the slot"
   feel TikTok has, instead of an opacity-only curtain that reads as a
   pause. */
.tv-stage.tv-fading .tv-video,
.tv-stage.tv-fading .tv-row-bottom,
.tv-stage.tv-fading .tv-actions-rail,
.tv-stage.tv-fading .tv-scrub {
    opacity: 1;
    transition:
        opacity 200ms ease-out,
        transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Soft fade-out used by non-swipe artist advances (Next/Prev buttons,
   arrow keys, wheel, auto-advance). The swipe path uses the slide-out as
   its fade-out, so this only kicks in when there's no slide. */
.tv-stage.tv-curtain-out .tv-video {
    opacity: 0;
    transition: opacity 140ms ease-in;
}

/* Children that genuinely need native touch scroll inside the otherwise
   touch-action:none stage. */
.tv-channel-rail { touch-action: pan-x; }
.tv-channel-menu { touch-action: pan-y; }

/* Subtle entrance for the stage */
body.tv-mode-active .tv-stage {
    animation: tv-power-on 380ms ease-out;
}
@keyframes tv-power-on {
    0%   { opacity: 0; filter: brightness(2.6) contrast(0.4); transform: scale(1.02); }
    35%  { opacity: 1; filter: brightness(1.4) contrast(1.4); }
    100% { opacity: 1; filter: brightness(1)   contrast(1);   transform: scale(1); }
}

/* Mobile tweaks */
@media (max-width: 540px) {
    /* Tight top bar: red dot + HPPN TV (left) ⇄ city pill (status row, left)
       and channel badge (right). Reduce padding so the broadcast can sit
       within the first 25-30% of viewport height instead of getting pushed
       down by chrome. */
    .tv-row-top {
        padding: calc(env(safe-area-inset-top, 0px) + 6px) 12px 0;
        gap: 8px;
    }
    .tv-status-row { gap: 4px 6px; margin-top: 2px; font-size: 9px; letter-spacing: 0.24em; }
    .tv-wordmark { font-size: clamp(17px, 4.8vw, 22px); }
    .tv-wordmark::before { width: 7px; height: 7px; margin-right: 6px; }

    /* === Bottom chrome cluster anchored to video bottom edge ====================
       The default grid-anchored chrome (scrub + lower-third pinned to the
       bottom of the overlay grid) leaves a big void between a centered
       landscape video and the lower-third on portrait phones — a 16:9
       source at 96vw is only ~210px tall on a 390-wide phone, so ~150px
       of dead air sits between the video and the chrome.

       Instead, we anchor the chrome cluster to the VIDEO bottom: stage
       center + half video height + a small visual gap. Half video height
       is computed in CSS from --tv-aspect (set by JS via oEmbed): the
       smaller of (96vw / aspect / 2) and (max-height / 2) is the binding
       dimension. The `min()` cap on --tv-cluster-top prevents portrait
       sources from pushing chrome off-screen — in that edge case the
       cluster falls back near its previous bottom-anchored position.

       Cluster contents (top → bottom):
         1. Scrubber (~32px) — leads with a transparent→dark fade-in
         2. Lower-third meta — stretched from scrub bottom down to the
            ticker, so the cluster reads as one solid dark slab instead
            of leaving a bloom gap above the ticker.
       Below the cluster: ticker + bottom-nav (separately positioned).
       The vertical action rail floats on the right anchored to its
       original bottom offset (overlaps the lower-third's right column,
       which reserves padding-right: 64px to stay clear).

       Video stays centered structurally (--tv-rest-y stays 0). The chrome
       follows the video, not the other way around — so any video aspect
       (and any future viewport) self-balances without per-device tuning. */
    .tv-stage {
        /* Strict 16:9 on mobile — half-height is half of (92vw * 9/16). The
           17dvh cap mirrors the 34dvh max-height on .tv-video. Both branches
           (landscape + portrait sources) use the same formula now since the
           frame is always 16:9 here. */
        --tv-video-half-h: min(calc(46vw / var(--tv-mobile-aspect)), 17dvh);
        /* Lift the video off-center so the screen reads top → bottom as
           video → slider → profile-pic + lower-third. The smaller 16:9 frame
           combined with a tighter top header lets us pull the broadcast tile
           further up — gap between channel rail and video drops from ~80px
           to ~50px so metadata sits closer to where the eye is already
           pointing. */
        --tv-rest-y: -110px;
        /* Cap so portrait sources can't push the rail below the ticker.
           = 100dvh - (scrub→rail offset 38 + rail 200 + ticker 26 + safe + nav). */
        --tv-cluster-top: min(
            calc(50dvh + var(--tv-video-half-h) + 14px + var(--tv-rest-y, 0px)),
            calc(100dvh - 264px - env(safe-area-inset-bottom, 0px) - var(--tv-nav-clearance, 0px))
        );
    }
    .tv-stage.tv-portrait {
        /* Frame is forced to 16:9 above; portrait branch matches landscape so
           the cluster anchors consistently regardless of source aspect. */
        --tv-video-half-h: min(calc(46vw / var(--tv-mobile-aspect)), 17dvh);
        --tv-rest-y: -110px;
    }

    /* Scrubber — directly below the video, leading the cluster. Provides
       a transparent→dark fade-in so the video edge reads cleanly above it.
       Removed from grid flow via absolute positioning so it stacks with
       the lower-third below it independently of the grid's row order. */
    .tv-scrub {
        position: absolute;
        top: var(--tv-cluster-top);
        left: 0;
        right: 0;
        bottom: auto;
        padding: 8px 14px 6px;
        gap: 10px;
        background: linear-gradient(180deg,
            rgba(0, 0, 0, 0)    0%,
            rgba(0, 0, 0, 0.55) 55%,
            rgba(0, 0, 0, 0.78) 100%);
    }
    .tv-scrub-time { font-size: 10.5px; min-width: 32px; }

    /* Lower-third meta — directly below the scrubber, stretched down to
       abut the ticker. Stretching (top + bottom anchors) absorbs the
       leftover space between cluster bottom and ticker top into the dark
       backdrop, so the cluster reads as one solid block from scrubber all
       the way to the ticker. Content is top-aligned (flex-start overrides
       the base flex-end) so the artist name sits naturally just below the
       scrubber rather than floating above the ticker. */
    .tv-row-bottom {
        position: absolute;
        top: calc(var(--tv-cluster-top) + 32px);
        left: 0;
        right: 0;
        bottom: 0; /* = .tv-overlay-grid bottom = right above the ticker */
        padding: 8px 14px 12px;
        gap: 8px;
        flex-wrap: wrap;
        align-items: flex-start;
        background: rgba(0, 0, 0, 0.78);
    }
    /* The vertical action rail starts directly below the scrubber and the
       lower-third sits beside it on the left, so the rail's avatar reads as
       coming AFTER the slider in the visual stack rather than overlapping
       the video. The lower-third reserves a ~64px right column to stay
       clear of the rail. */
    .tv-now-playing { max-width: 100%; padding-right: 64px; }
    .tv-upcoming { max-width: 100%; text-align: left; padding-right: 64px; }
    .tv-now-playing-meta { gap: 6px 10px; font-size: 10.5px; letter-spacing: 0.22em; }

    .tv-actions-rail {
        right: max(8px, env(safe-area-inset-right, 0px));
        top: calc(var(--tv-cluster-top) + 38px);
        bottom: auto;
        gap: 8px;
    }
    .tv-rail-avatar { width: 48px; height: 48px; }
    .tv-rail-dock { padding: 6px 5px; gap: 4px; border-radius: 30px; }
    .tv-rail-icon { width: 40px; height: 40px; }
    .tv-rail-icon svg { width: 19px; height: 19px; }
    .tv-rail-count { font-size: 10px; }

    .tv-channel-badge { padding: 6px 11px; }
    .tv-channel-meta { font-size: 10px; letter-spacing: 0.2em; }
    .tv-channel-menu { min-width: 200px; max-width: 86vw; }

    .tv-ticker { height: 22px; }
    .tv-ticker-track { font-size: 10px; letter-spacing: 0.24em; opacity: 0.55; }
    /* 22px clears the slimmer mobile ticker; --tv-nav-clearance lifts above
       the bottom-nav (set on .tv-stage at this breakpoint, so always non-zero here). */
    .tv-overlay-grid { bottom: calc(22px + env(safe-area-inset-bottom, 0px) + var(--tv-nav-clearance, 0px)); }

    /* Auto-hide the channel rail on idle so it doesn't permanently eat
       ~50px of vertical space on a phone. Wakes the moment the user
       touches the stage or any control — tvScheduleIdleFade is called
       from every input handler. 320ms matches the existing chrome fade
       (.tv-nav, .tv-transport, .tv-scrub) so the whole HUD breathes
       together. Desktop keeps the rail always-visible because vertical
       space isn't precious there. */
    .tv-row-rail {
        transition: opacity 320ms ease;
    }
    .tv-stage.idle .tv-row-rail {
        opacity: 0;
        pointer-events: none;
    }
    /* Override the universal idle fade for the scrubber on mobile. Playback
       progress is primary information on a phone — the user shouldn't have
       to wake the chrome with a tap just to see (or grab) the timeline.
       Desktop keeps the idle fade because mouse hover + transport buttons
       make on-demand chrome cheap there. */
    .tv-stage.idle .tv-scrub {
        opacity: 0.95;
        pointer-events: auto;
    }
}

/* When the user opens comments from the TV rail, lift the existing
   liner-notes sheet (otherwise hidden under .tv-mode-active per the rule
   near the top of this file) above the stage. The sheet's native open/close
   behavior is preserved; the JS just toggles this body class. */
body.tv-mode-active.tv-mode-comments-open .liner-notes-sheet,
body.tv-mode-active.tv-mode-comments-open #linerNotesOverlay {
    visibility: visible !important;
    pointer-events: auto !important;
}
body.tv-mode-active.tv-mode-comments-open .liner-notes-sheet { z-index: 10010 !important; }
body.tv-mode-active.tv-mode-comments-open #linerNotesOverlay { z-index: 10005 !important; }

/* ─── EXIT AFFORDANCE (desktop) ───
   Desktop has no bottom-nav, so we keep the site-header floating above the
   stage as the way back to the rest of the app. Native styling preserved —
   same cream paper / ink / orange Explore pill the user sees everywhere
   else. When the user pushes the stage to true fullscreen the browser
   hides everything outside the fullscreen element for us. Mobile keeps the
   bottom-nav for exit, so the header stays tucked beneath the stage there. */
@media (min-width: 768px) {
    body.tv-mode-active .site-header {
        z-index: 10000;
        transition: opacity 220ms ease, transform 220ms ease;
    }
    /* Push the stage's top row below the floating header so the wordmark
       and channel badge don't slide under hppn.ing's logo / nav. */
    body.tv-mode-active .tv-row-top {
        padding-top: calc(env(safe-area-inset-top, 0px) + 90px);
    }
    /* Header fades with the rest of the chrome when the stage goes idle.
       :has() lets the body-level header read the .idle class that lives on
       a descendant (.tv-stage). Any pointermove / click / wheel / key event
       resets the idle timer in browse.js and brings the header back. */
    body.tv-mode-active:has(.tv-stage.idle) .site-header {
        opacity: 0;
        pointer-events: none;
        transform: translateY(-8px);
    }
}

/* ─── IMMERSIVE PLAYER FADE ───
   Genre rail (top channel tabs) and actions rail (right-side save/comment/
   share) are visible while the user interacts with the stage but fade out
   on idle so the video carries the screen. tvScheduleIdleFade lives in
   browse.js and toggles .tv-stage.idle after 2500ms of inactivity; any
   pointermove / click / wheel / key event clears it. Identity (logo, city,
   badge), now-playing name, scrubber, and ticker stay always-on. */
.tv-row-rail,
.tv-actions-rail {
    transition: opacity 220ms ease, transform 220ms ease;
}
.tv-stage.idle .tv-row-rail {
    opacity: 0;
    pointer-events: none;
    transform: translateY(-6px);
}
.tv-stage.idle .tv-actions-rail {
    opacity: 0;
    pointer-events: none;
    transform: translateX(10px);
}

/* Progress bar (scrub) is listed as essential always-visible UI per the
   immersive brief — override the base idle fade. */
.tv-stage.idle .tv-scrub {
    opacity: 0.85;
    pointer-events: auto;
}

/* Keep the marquee ticker and the right-side actions rail visible on
   mobile even when the stage idles. The ticker carries genre/location
   provenance and the rail is the primary save/comment/share affordance
   — both should be reachable without a tap to wake the chrome.
   Lives outside the @media block above because the universal idle fade
   for .tv-actions-rail is declared further down the file; that source
   order means an override at the same specificity has to come after it. */
@media (max-width: 540px) {
    .tv-stage.idle .tv-ticker {
        opacity: 1;
        pointer-events: auto;
    }
    .tv-stage.idle .tv-actions-rail {
        opacity: 1;
        pointer-events: auto;
        transform: none;
    }
}

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
    .tv-stage, .tv-static, .tv-scanlines::after, .tv-grain, .tv-ticker-track, .tv-wordmark::before, .tv-channel-num.pulse, .tv-channel-tab.active::before {
        animation: none !important;
    }
    /* Drop the springy pager transition too — instant swaps respect the user's
       motion preference but the gesture still resolves correctly because the
       JS commit logic runs regardless. */
    .tv-stage.tv-snap .tv-video,
    .tv-stage.tv-snap .tv-ambilight,
    .tv-stage.tv-snap .tv-row-bottom,
    .tv-stage.tv-snap .tv-actions-rail,
    .tv-stage.tv-snap .tv-scrub,
    .tv-stage.tv-fading .tv-video,
    .tv-stage.tv-fading .tv-row-bottom,
    .tv-stage.tv-fading .tv-actions-rail,
    .tv-stage.tv-fading .tv-scrub {
        transition: none !important;
    }
    .tv-row-rail,
    .tv-actions-rail {
        transition: none !important;
    }
}
