/* ============================================
   KARAOKEY.LK — DESIGN TOKENS
   ============================================ */
:root {
    --bg: #000000;
    --surface: #0a0a0a;
    --surface-2: #131313;
    --surface-3: #1a1a1a;
    --border: #222222;
    --border-soft: #2a2a2a;
    --text: #ffffff;
    --text-dim: #9a9a9a;
    --text-dimmer: #6b6b6b;
    --accent: #ff2d55;
    --accent-dim: #c81f42;
    --accent-soft: rgba(255, 45, 85, 0.16);
    --success: #2ecc71;
    --warning: #f5a623;
    --danger: #ff4d4f;
    --radius-lg: 20px;
    --radius-md: 14px;
    --radius-sm: 10px;
    --radius-pill: 999px;
    --nav-h: 68px;
    --maxw: 600px;
    --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    /* Song-player chrome/stage backdrop opacity — user-adjustable via the
       slider in the Background Video menu (song.php/player.js), only
       actually visible once that video is on. */
    --player-bg-alpha: 0.85;
}

* { box-sizing: border-box; }
/* Without this, mobile Safari/Chrome can silently boost text size inside a
   narrow block of text to improve readability ("text inflation") — the
   scale factor depends on the surrounding layout's width, which genuinely
   differs between .home-header-actions (home page) and .page-header
   (search page) even though "All" carries the exact same font-size:12px
   in both. That's almost certainly why identical CSS/markup can still
   render "All" a visibly different, less-circular width on one page than
   the other — this locks text rendering to the size actually specified,
   everywhere, so it can't happen again anywhere else on the site either. */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
html, body { margin: 0; padding: 0; }
/* Plain opacity fade only — a transform here (even a no-op translateY(0))
   would make <body> the containing block for every position:fixed element
   on the page (bottom nav, flashes, modals, the page loader), so they'd
   scroll away with the page instead of staying pinned to the viewport. */
html.js-fade body { opacity: 0; transition: opacity 220ms ease; }
html.js-fade body.is-visible { opacity: 1; }

/* Simple red circular preloader — kept for any small inline spot (buttons,
   compact widgets) that still just wants a plain spinner. */
.spinner { width: 22px; height: 22px; border: 3px solid rgba(255,255,255,0.15); border-top-color: var(--accent); border-radius: 50%; animation: spinner-spin 0.7s linear infinite; }
@keyframes spinner-spin { to { transform: rotate(360deg); } }
.page-transition-loader {
    position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
    z-index: 600; opacity: 0; pointer-events: none; transition: opacity 150ms ease;
}
.page-transition-loader.is-active { opacity: 1; }

/* Karaoke-style page-transition preloader — same glowing pulse + bouncing
   equalizer bars as error.php's icon treatment, sized up a bit since this
   one has to read clearly at a glance during a brief full-page transition. */
.page-loader-eq { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; width: 108px; height: 108px; }
.page-loader-eq::before {
    content: ''; position: absolute; inset: 0; border-radius: 50%;
    background: radial-gradient(circle, rgba(255,45,85,0.35) 0%, rgba(255,45,85,0) 70%);
    animation: page-loader-pulse 2.2s ease-in-out infinite;
}
.page-loader-eq span {
    position: relative; width: 8px; border-radius: 4px;
    background: linear-gradient(180deg, var(--accent), var(--accent-dim));
    animation: page-loader-bounce 1.1s ease-in-out infinite;
}
.page-loader-eq span:nth-child(1) { animation-delay: 0s; }
.page-loader-eq span:nth-child(2) { animation-delay: 0.15s; }
.page-loader-eq span:nth-child(3) { animation-delay: 0.3s; }
.page-loader-eq span:nth-child(4) { animation-delay: 0.45s; }
.page-loader-eq span:nth-child(5) { animation-delay: 0.6s; }
@keyframes page-loader-pulse {
    0%, 100% { transform: scale(0.85); opacity: 0.6; }
    50% { transform: scale(1.15); opacity: 1; }
}
@keyframes page-loader-bounce {
    0%, 100% { height: 14px; opacity: 0.5; }
    50% { height: 52px; opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
    .page-loader-eq::before, .page-loader-eq span { animation: none !important; }
}

/* Same equalizer preloader, dropped into the live search results list
   (see assets/js/app.js) while a slow fetch is in flight. */
.search-loading { display: flex; align-items: center; justify-content: center; padding: 48px 20px; }

/* Full-song pre-download gate (see song.php + assets/js/player.js) — same
   equalizer visual as the page-transition preloader above, plus a real
   byte-progress bar (same look as .line-progress/.line-progress-fill) so a
   mobile-data listener sees exactly how much of the file is left, not a
   fake animation. Sits over the whole player page — .is-done starts the
   fade, then player.js sets [hidden] once that finishes. */
.song-download-overlay {
    position: fixed; inset: 0; z-index: 500; background: var(--surface); overflow: hidden;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 18px; padding: 24px; text-align: center; opacity: 1; transition: opacity 300ms ease;
}
.song-download-overlay[hidden] { display: none; }
.song-download-overlay.is-done { opacity: 0; }
/* Same specificity clash .avatar-upload-prompt[hidden] elsewhere in this
   file already has to work around: the browser's own [hidden] rule and
   this button's .btn class rule tie on specificity, and .btn (author
   stylesheet, later in the cascade) wins over the platform default —
   so the "hidden" retry button was actually showing on every load, not
   just after a failure. Needs its own id-qualified [hidden] rule to win. */
#songDownloadRetryBtn[hidden] { display: none; }
.song-download-message { font-size: 15px; font-weight: 700; color: var(--text); margin: 0; min-height: 22px; opacity: 1; transition: opacity 220ms ease; }
/* Sits between the message and the progress bar — swapped icon-for-icon
   with each rotating sentence (see assets/js/player.js), fading out/in in
   step with the text instead of just appearing statically. */
.song-download-icon { display: block; font-size: 24px; color: var(--accent); opacity: 1; transition: opacity 220ms ease; }
.song-download-progress { width: 100%; max-width: 260px; height: 4px; background: #2a2a2a; border-radius: 4px; overflow: hidden; }
.song-download-progress-fill { height: 100%; background: var(--accent); width: 0%; border-radius: 999px; transition: width 150ms linear; }
.song-download-percent { font-size: 12px; color: var(--text-dim); margin: 0; font-family: monospace; }

body {
    background: var(--bg);
    color: var(--text);
    font-family: var(--font);
    -webkit-font-smoothing: antialiased;
}

a { color: inherit; text-decoration: none; }
button { font-family: var(--font); }
img { max-width: 100%; display: block; }

.app {
    max-width: var(--maxw);
    margin: 0 auto;
    min-height: 100vh;
    background: var(--bg);
    position: relative;
    padding-bottom: 24px;
}

.app.has-bottom-nav { padding-bottom: calc(var(--nav-h) + 16px); }

.container { padding: 16px; }

/* Desktop: browse pages (Home/Artists/Artist detail/Lists/Favorites) get real
   width to work with instead of a phone-width column floating on a big screen;
   account-y pages (Profile/Subscribe/Payment/auth) stay comfortably narrow on
   purpose, same as most apps keep settings/checkout flows narrow either way. */
@media (min-width: 860px) {
    .app.app-wide { max-width: 1080px; }
}

/* ============================================
   FLASH MESSAGES — single-line toasts, 3/4 down the screen
   ============================================ */
.flash-stack {
    position: fixed;
    top: 75%; left: 50%;
    transform: translate(-50%, -50%);
    width: min(92%, 440px);
    z-index: 500;
    display: flex;
    flex-direction: column;
    gap: 10px;
    pointer-events: none;
}
.flash {
    display: flex;
    align-items: flex-start;
    gap: 12px;
    padding: 14px 18px;
    border-radius: var(--radius-md);
    background: #1c1c1e;
    border: 1px solid var(--border-soft);
    border-left: 4px solid var(--text-dim);
    box-shadow: 0 14px 40px rgba(0, 0, 0, 0.55);
    font-size: 14px;
    font-weight: 600;
    line-height: 1.4;
    color: var(--text);
    pointer-events: auto;
    animation: flash-in 320ms ease-out;
}
.flash i { font-size: 18px; flex-shrink: 0; margin-top: 1px; }
.flash span { min-width: 0; }
@keyframes flash-in {
    from { opacity: 0; transform: translateY(18px); }
    to { opacity: 1; transform: translateY(0); }
}
.flash-out { opacity: 0 !important; transform: translateY(18px) !important; transition: opacity 280ms ease, transform 280ms ease; }
.flash-success { border-left-color: var(--success); }
.flash-success i { color: var(--success); }
.flash-error { border-left-color: var(--danger); }
.flash-error i { color: var(--danger); }
.flash-info { border-left-color: var(--warning); }
.flash-info i { color: var(--warning); }

/* ============================================
   HEADERS
   ============================================ */
.home-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px 16px 4px;
}
.home-header-brand { display: flex; align-items: center; gap: 10px; }
.home-header-brand img { height: 44px; width: auto; }
.home-header-title { font-size: 26px; font-weight: 800; margin: 0; }
.home-header-actions { display: flex; align-items: center; gap: 8px; }
.home-header-actions a {
    width: 38px; height: 38px; border-radius: 50%;
    background: var(--surface-3); display: flex; align-items: center; justify-content: center;
    color: var(--text-dim); font-size: 15px;
}

/* Freeze the "Home" title and language pills at the top while the sections
   below scroll. On mobile .home-header (logo/actions row) is part of this
   too; on desktop that row is hidden in favor of the fixed top-nav, so this
   wrapper sticks just below it instead of at the very top (top: var(--nav-h))
   so the two frozen bars stack cleanly instead of overlapping. */
.home-top-sticky {
    position: sticky;
    top: 0;
    z-index: 40;
    background: var(--bg);
}
@media (min-width: 860px) {
    .home-top-sticky { top: var(--nav-h); }
}

/* Solo / All / Duet filter — a 3-segment toggle, shown next to the profile
   icon on mobile and next to the Profile nav item on the desktop top-nav. */
.vocal-filter {
    display: flex; align-items: center; height: 38px; background: var(--surface-3);
    border: 2px dashed rgba(255, 45, 85, 0.45); border-radius: var(--radius-pill);
    /* Extra horizontal padding — the outer pill's rounded end-caps curve in from
       the sides, so the left/right buttons need more clearance there than the
       flat top/bottom edges do, or they visually clip into the dashed border. */
    padding: 3px 5px; gap: 3px;
    /* .vocal-filter-btn (each button) already has flex-shrink:0, but the
       PILL ITSELF didn't — harmless in .home-header-actions (just this pill
       + the search icon, plenty of room) but on search.php this same pill
       sits in .page-header alongside a back button and a flex:1 centered
       title actively claiming space, where it could actually get squeezed:
       the buttons refuse to shrink individually, so a compressed pill just
       let its "All" button spill oddly instead of rendering as the clean
       pill/circle shapes it's supposed to. */
    flex-shrink: 0;
}
/* All three buttons (Solo/All/Duet) are forced circles now — aspect-ratio
   here, not just on --icon. "All" being circular before was never an
   explicit rule, just a coincidence of its text + padding happening to
   measure close to the button's height on the home page specifically; on
   the search page's different surrounding layout that same text rendered
   at a slightly different width, so the "circle" fell apart there even
   though the CSS/markup were identical on both pages. Forcing the shape
   directly removes that dependency on text width entirely. */
.vocal-filter-btn {
    display: flex; align-items: center; justify-content: center;
    height: 100%; aspect-ratio: 1 / 1; padding: 0; border-radius: 50%;
    font-size: 12px; font-weight: 700; color: var(--text-dim); flex-shrink: 0;
}
.vocal-filter-btn i { font-size: 13px; }
.vocal-filter-btn.active { background: var(--accent); color: #fff; }
.vocal-filter--desktop { display: none; }
.header-icon-link {
    display: none; align-items: center; justify-content: center;
    width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
    background: var(--surface-3); color: var(--text-dim); font-size: 14px; margin-left: 8px;
}
@media (min-width: 860px) {
    /* Visual order only (Vocal → Search → Profile), independent of the
       underlying markup order — same DOM as mobile, where these three stay
       in place: the vocal filter/search live in the header row, not this
       nav, and Profile is just the 4th tab like any other. */
    .vocal-filter--desktop { display: flex; margin-left: 4px; order: 1; }
    .header-icon-link { display: flex; order: 2; }
    /* search.php's filter carries only the --mobile class (it has no
       separate --desktop sibling to switch to the way index.php does), so
       size it here too — not just --desktop — or it stays stuck at its
       narrow-viewport size on a wide browser window instead of adapting.
       height:34px matches .header-icon-link beside it; 0 vertical padding
       (same idea as the <860px rule below) makes the content box come out
       to exactly 30px (34 - 2*2px border), so a button at height:100% fills
       it exactly — no gap before the dashed border, no overlap into it —
       which is also what was making the rounding look uneven/not-quite-
       circular before: a button floating with an uneven gap around it
       reads as "not properly formed" even though its own border-radius
       math was fine. */
    .vocal-filter--desktop, .vocal-filter--mobile { height: 34px; }
    /* 5px horizontal (the base .vocal-filter rule's own value) was still
       leaving a real left/right gap between the dashed border and the
       Solo/Duet circles at this size — same fix as the <860px rule below,
       just not yet applied up here. 1px matches what already reads as
       correct on mobile. */
    .vocal-filter { padding: 0 1px; }
}
@media (max-width: 859px) {
    /* Sized so the CONTENT box (inside the 2px border, after padding) comes
       out to exactly 38px — the same diameter as the round search-icon
       button beside it (.home-header-actions a) — so .vocal-filter-btn at
       height:100% fills that content box exactly: no gap before the dashed
       border, no overlap into it either. Padding here is horizontal only
       (for the pill's rounded end-caps, see the comment on the base
       .vocal-filter rule) — any vertical padding would eat into that exact
       38px and reopen a gap. */
    .vocal-filter--mobile { height: 42px; } /* 38 content + 2*2 border, 0 vertical padding */
    .vocal-filter { padding: 0 1px; }
    .vocal-filter-btn { height: 100%; }
}

/* Search page: header (with the Solo/All/Duet filter next to the title),
   search bar and language pills all frozen together at the top. */
.search-top-sticky { position: sticky; top: 0; z-index: 40; background: var(--bg); padding-bottom: 10px; }

.page-header {
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 16px;
    position: sticky;
    top: 0;
    background: rgba(0,0,0,0.92);
    backdrop-filter: blur(6px);
    z-index: 20;
    border-bottom: 1px solid var(--border);
}
.back-btn {
    width: 38px; height: 38px; min-width: 38px; border-radius: 50%;
    background: var(--surface-3); display: flex; align-items: center; justify-content: center;
    color: var(--text); font-size: 15px; border: 1px solid var(--border-soft);
}
.page-header-title { flex: 1; text-align: center; font-size: 19px; font-weight: 700; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.page-header-spacer { width: 38px; min-width: 38px; }

/* ============================================
   PILLS / FILTERS / BUTTONS
   ============================================ */
.pill-row { display: flex; gap: 10px; padding: 10px 16px 18px; overflow-x: auto; scrollbar-width: none; }
.pill-row::-webkit-scrollbar { display: none; }
.pill {
    flex-shrink: 0;
    padding: 9px 18px;
    border-radius: var(--radius-pill);
    background: var(--surface-3);
    color: var(--text-dim);
    font-size: 14px;
    font-weight: 600;
    border: 1px solid var(--border-soft);
    white-space: nowrap;
}
.pill.active { background: var(--accent); color: #fff; border-color: var(--accent); }

/* Two independently-scrollable "columns" (e.g. language pills | category
   pills on Home/Search), split by a vertical divider — each side scrolls
   left/right on its own. Phone portrait (the default, mobile-first rule
   below): forced exactly 50/50, so the divider sits fixed dead-center
   regardless of content. From 600px up (landscape phones, tablets,
   desktop) there's reliably enough room to show every language pill at
   its natural size, so the language half switches to shrink-to-fit and
   the divider settles right after the last pill instead, handing the
   category half whatever's left. */
.pill-row-split { display: flex; align-items: stretch; padding: 10px 16px 18px; gap: 10px; }
/* .pill-half-wrap does NOT scroll — it just sizes the column and anchors
   the fade below. .pill-row-half, inside it, is the actual scroller. A
   fade pseudo-element living on .pill-row-half itself would be part of
   its scrollable content and drift away with everything else the moment
   you scroll — putting it on this non-scrolling wrapper instead is what
   keeps it pinned to the box's edge regardless of scroll position, so
   whichever badge happens to be passing under it is what fades. */
.pill-half-wrap { position: relative; min-width: 0; flex: 1 1 50%; max-width: 50%; }
@media (min-width: 600px) {
    .pill-half-wrap:first-child { flex: 0 1 auto; }
    /* The 50% cap above was never meant for this half at this width — it's
       what was silently stopping the category pills from reaching the
       right edge of the screen the way the song-card row does. */
    .pill-half-wrap:last-child { flex: 1 1 auto; max-width: none; }
}
/* No categories yet (a freshly-emptied Admin -> Category) means there's no
   divider and only one half — let it use the full row instead of staying
   capped at 50% with nothing on the other side. */
.pill-half-wrap:only-child { max-width: 100%; flex: 1 1 auto; }
.pill-half-wrap:only-child::after, .pill-half-wrap:only-child::before { display: none; }

.pill-row-half { display: flex; gap: 10px; overflow-x: auto; scrollbar-width: none; min-width: 0; }
.pill-row-half::-webkit-scrollbar { display: none; }

.pill-divider { flex-shrink: 0; align-self: stretch; width: 0; border-left: 2px dashed rgba(255, 45, 85, 0.45); }

/* Black fade over the language half's right edge and the category half's
   left edge — the two edges that sit against the divider — so whatever
   badge is scrolled past there dissolves into black instead of getting
   sharply clipped by the scroll container. Hidden at rest and only faded
   in while that half is actively being scrolled (see the .is-scrolling
   toggle in assets/js/app.js), rather than a permanent overlay. */
.pill-half-wrap:first-child::after, .pill-half-wrap:last-child::before {
    content: ''; position: absolute; top: 0; bottom: 0; width: 36px; pointer-events: none;
    opacity: 0; transition: opacity 150ms ease;
}
.pill-half-wrap:first-child::after { right: 0; background: linear-gradient(to right, transparent, rgba(0, 0, 0, 0.92)); }
.pill-half-wrap:last-child::before { left: 0; background: linear-gradient(to left, transparent, rgba(0, 0, 0, 0.92)); }
.pill-half-wrap.is-scrolling::after, .pill-half-wrap.is-scrolling::before { opacity: 1; }

.btn {
    display: inline-flex; align-items: center; justify-content: center; gap: 8px;
    padding: 12px 20px;
    border-radius: var(--radius-pill);
    font-weight: 700;
    font-size: 14px;
    border: 1px solid transparent;
    cursor: pointer;
    text-align: center;
}
.btn-block { width: 100%; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:active { background: var(--accent-dim); }
.btn-secondary { background: var(--surface-3); color: var(--accent); border-color: var(--border-soft); }
.btn-ghost { background: transparent; color: var(--text); border-color: var(--border-soft); }
.btn-sm { padding: 8px 14px; font-size: 13px; }
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
.btn.is-loading { opacity: 0.7; pointer-events: none; }
.btn-row { display: flex; gap: 10px; }

.badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: var(--radius-pill); font-size: 12px; font-weight: 700; }
.badge-pending { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
.badge-approved { background: rgba(46, 204, 113, 0.15); color: var(--success); }
.badge-rejected { background: rgba(255, 77, 79, 0.15); color: var(--danger); }

/* Current-plan pill shown next to a page title / the profile page's
   Subscription card — named tier-badge, NOT plan-badge, specifically to
   avoid colliding with the pre-existing .plan-badge below (the "Best
   Value"/"Your Current Plan" ribbon on subscribe.php's plan cards) —
   same class name with equal specificity would let whichever rule sits
   later in this file win ties on shared properties like color/position. */
.tier-badge {
    display: inline-flex; align-items: center; gap: 5px;
    padding: 3px 10px; border-radius: var(--radius-pill);
    font-size: 11px; font-weight: 800; letter-spacing: 0.2px;
    vertical-align: middle; white-space: nowrap;
}
.tier-badge--free { background: var(--surface-3); color: var(--text-dim); border: 1px solid var(--border-soft); }
.tier-badge--free i { font-size: 10px; }
.tier-badge--bronze { background: rgba(160, 90, 44, 0.22); color: #c9824f; border: 1px solid rgba(160, 90, 44, 0.4); }
.tier-badge--bronze i { font-size: 10px; }
.tier-badge--gold { background: rgba(212, 175, 55, 0.18); color: #e8c468; border: 1px solid rgba(212, 175, 55, 0.35); }
.tier-badge--gold i { font-size: 10px; }
.tier-badge--diamond { background: rgba(167, 139, 250, 0.18); color: #c4b5fd; border: 1px solid rgba(167, 139, 250, 0.35); }
.tier-badge--diamond i { font-size: 10px; }
.badge-free { background: var(--surface-3); color: var(--text-dim); }

/* ============================================
   SECTIONS / SONG CARDS / ARTIST CARDS
   ============================================ */
.section { margin-bottom: 8px; }
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 16px; }
.section-header h2 { font-size: 17px; font-weight: 800; margin: 0; display: flex; align-items: center; gap: 8px; }
.section-header h2 i { color: var(--accent); font-size: 15px; }
.section-header a { color: var(--accent); font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 4px; transition: opacity 150ms ease; }

.hscroll { display: flex; gap: 14px; overflow-x: auto; padding: 8px 16px 18px; scrollbar-width: none; }
.hscroll::-webkit-scrollbar { display: none; }
/* Applied by app.js only while an actual click-and-drag scroll is in
   progress (see the pointerdown/pointermove handlers there) — left off at
   rest so a pill/card's own link cursor isn't overridden just from hovering. */
.pill-row-half.is-grabbing, .hscroll.is-grabbing, .artist-row.is-grabbing { cursor: grabbing; user-select: none; }

/* The card itself carries the background + full rounded shape now; the
   cover only rounds its TOP corners (flush against the card's own top
   edge) and stays square on the bottom so it sits flush against the
   title/artist text below instead of leaving a rounded notch there. */
.song-card { position: relative; flex-shrink: 0; width: 138px; background: var(--surface-2); border-radius: var(--radius-md); padding-bottom: 10px; }
.song-card-cover { width: 138px; height: 138px; border-radius: var(--radius-md) var(--radius-md) 0 0; overflow: hidden; background: var(--surface-3); }
.song-card-cover img { width: 100%; height: 100%; object-fit: cover; }
.song-card-cover--empty, .feature-card-cover--empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: var(--text-dimmer); font-size: 28px; }
/* Play-count/favorite-count pair, centered on the cover's own bottom edge
   (top matches the cover's height, translateY(-50%) centers the circle ON
   that line regardless of the circle's own size) — half over the image,
   half over the gap above the title. */
/* space-evenly, not space-between — space-between pushes both circles to
   the outer edges and leaves whatever's left in the middle, which is a much
   bigger gap than the two edge gaps; space-evenly keeps all three gaps
   (left/middle/right) equal. */
.song-card-stats { position: absolute; top: 138px; left: 0; right: 0; transform: translateY(-50%); display: flex; justify-content: space-evenly; z-index: 1; }
.song-card-stat {
    width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
    background: var(--surface-2);
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    color: var(--text-dim); font-size: 8px; font-weight: 700; line-height: 1.15;
}
.song-card-stat i { font-size: 10px; margin-bottom: 1px; }
/* Was 8px — bumped up to leave room for .song-card-stats' bottom half. */
.song-card-title { margin: 18px 10px 0; font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color 150ms ease; }
.song-card-artist { margin: 0 10px; font-size: 12px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.feature-card { position: relative; flex-shrink: 0; width: 138px; height: 138px; border-radius: var(--radius-md); overflow: hidden; background: var(--surface-3); }
.feature-card img { width: 100%; height: 100%; object-fit: cover; }
.feature-card-overlay {
    position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 12px 12px;
    background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 90%);
}
.feature-card-title { font-size: 13px; font-weight: 700; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color 150ms ease; }
.feature-card-artist { font-size: 11px; color: #d8d8d8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.artist-row { display: flex; gap: 18px; overflow-x: auto; padding: 8px 16px 18px; scrollbar-width: none; }
.artist-row::-webkit-scrollbar { display: none; }
.artist-avatar-card, .artist-grid .artist-avatar-card { flex-shrink: 0; width: 120px; text-align: center; }
.artist-avatar { width: 112px; height: 112px; aspect-ratio: 1 / 1; flex-shrink: 0; border-radius: 50%; overflow: hidden; background: var(--surface-3); margin: 0 auto; display: flex; align-items: center; justify-content: center; color: var(--text-dimmer); font-size: 26px; border: 1px solid var(--border-soft); }
.artist-avatar img { width: 100%; height: 100%; object-fit: cover; }
.artist-avatar-name { margin-top: 8px; font-size: 13px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.artist-avatar-count { font-size: 11px; color: var(--text-dim); }

.artist-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px 8px; padding: 12px 16px 24px; }
.artist-grid .artist-avatar-card { width: 100%; }
.artist-grid .artist-avatar { width: 92px; height: 92px; }

@media (min-width: 860px) {
    .artist-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 28px 16px; }
    .artist-grid .artist-avatar { width: 118px; height: 118px; }
}

/* Fullscreen photo viewer — tap an artist's photo to see it large, close via
   the × button, tapping the backdrop, or Escape (wired per-page in JS). */
.photo-lightbox {
    position: fixed; inset: 0; background: rgba(0, 0, 0, 0.92); z-index: 700;
    display: flex; align-items: center; justify-content: center; padding: 24px;
    opacity: 0; visibility: hidden; transition: opacity 0.25s ease, visibility 0.25s ease;
}
.photo-lightbox.open { opacity: 1; visibility: visible; }
.photo-lightbox img { max-width: 100%; max-height: 100%; border-radius: var(--radius-lg); object-fit: contain; }
.photo-lightbox-close {
    position: absolute; top: 18px; right: 18px; width: 40px; height: 40px; border-radius: 50%;
    background: var(--surface-3); border: 1px solid var(--border-soft); color: var(--text);
    display: flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer;
}

/* ============================================
   SONG LIST ROWS
   ============================================ */
.list-toolbar { display: flex; justify-content: center; gap: 10px; padding: 4px 16px 18px; }
.search-input-wrap { position: relative; margin: 4px 16px 16px; }
.search-input-wrap i { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--text-dimmer); }
.search-input {
    width: 100%; padding: 12px 14px 12px 38px; border-radius: var(--radius-pill);
    background: var(--surface-3); border: 1px solid var(--border-soft); color: var(--text); font-size: 14px;
}
.search-input::placeholder { color: var(--text-dimmer); }

.song-list { padding: 4px 10px 20px; }
.song-row { display: flex; align-items: center; gap: 12px; padding: 10px 6px; border-radius: var(--radius-md); }
.song-row-main { flex: 1; display: flex; align-items: center; gap: 12px; min-width: 0; }
.song-row-thumb { width: 52px; height: 52px; min-width: 52px; border-radius: var(--radius-sm); overflow: hidden; background: var(--surface-3); display: flex; align-items: center; justify-content: center; color: var(--text-dimmer); }
.song-row-thumb img { width: 100%; height: 100%; object-fit: cover; }
.song-row-text { min-width: 0; }
.song-row-title { font-size: 15px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.song-row-artist { font-size: 13px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.favorite-btn { background: none; border: none; color: var(--text-dim); font-size: 18px; padding: 8px; cursor: pointer; }
.favorite-btn .fa-solid { color: var(--accent); }
.song-row.is-hidden { display: none; }

/* Offline download (Favorites page only — see includes/render.php's
   render_song_row() and assets/js/offline-download.js). Same base look as
   .favorite-btn for visual consistency, its own accent color on the
   "downloaded" state so it doesn't get confused with the heart's red. */
.download-btn { background: none; border: none; color: var(--text-dim); padding: 8px; cursor: pointer; position: relative; width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.download-btn.is-busy { cursor: wait; }
.download-btn:disabled { cursor: wait; }

/* Three states stacked in the same spot (plain download icon / spinner
   while downloading / green check once done) — crossfaded via opacity so
   switching between them reads as a transition, not an instant swap.
   visibility only follows opacity out (not in), delayed by the same
   duration, so a faded-out state stops intercepting clicks/being read by
   screen readers right as it finishes disappearing, with no JS timing
   needed for any of it. Each state is a plain centering WRAPPER around its
   actual icon/spinner, rather than putting position+transform directly on
   the spinner itself — a CSS animation on transform (the spin) replaces
   the element's whole transform value rather than adding to it, which
   was knocking the centering translate() off and making the spinner
   visibly jump to a corner the instant it started spinning. */
.dl-state { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; align-items: center; justify-content: center; opacity: 0; visibility: hidden; transition: opacity 200ms ease, visibility 0s linear 200ms; }
.dl-state.is-active { opacity: 1; visibility: visible; transition: opacity 200ms ease, visibility 0s linear 0s; }
.dl-download i, .dl-check i { font-size: 18px; }
.dl-check i { color: var(--success); }
.download-btn-spinner { width: 16px; height: 16px; border-width: 2px; }

/* Keep list rows at a comfortable reading width even inside the wider
   .app-wide shell — a single column of rows stretched to 1000px+ looks like
   a mistake, not a desktop layout. */
@media (min-width: 860px) {
    .song-list, .search-input-wrap, .list-toolbar { max-width: 680px; margin-left: auto; margin-right: auto; }
    .app-wide .container { max-width: 680px; margin-left: auto; margin-right: auto; }
}

.empty-state { text-align: center; color: var(--text-dim); padding: 48px 20px; }
.empty-state i { font-size: 32px; color: var(--text-dimmer); margin-bottom: 12px; display: block; }

/* ============================================
   BOTTOM NAV
   ============================================ */
.bottom-nav {
    position: fixed;
    bottom: 0; left: 50%; transform: translateX(-50%);
    width: 100%; max-width: var(--maxw);
    height: var(--nav-h);
    background: rgba(6,6,6,0.97);
    backdrop-filter: blur(8px);
    border-top: 1px solid var(--border);
    z-index: 30;
}
.bottom-nav-inner { display: flex; align-items: center; width: 100%; height: 100%; }
.bottom-nav-brand, .bottom-nav-spacer { display: none; }
.bottom-nav-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--text-dimmer); font-size: 11px; font-weight: 600; }
.bottom-nav-item i { font-size: 19px; }
.bottom-nav-item.active { color: var(--accent); }
.bottom-nav-item--extra { display: none; }

/* Desktop: this becomes a top nav bar instead of a mobile bottom tab bar.
   It's still the same fixed element, just re-pinned to the top edge and laid
   out as a compact inline pill row instead of stacked icon-over-label tabs —
   position:fixed works regardless of where the <nav> sits in the markup, so
   no template changes are needed to move it. */
@media (min-width: 860px) {
    .bottom-nav {
        top: 0; bottom: auto;
        left: 0; transform: none;
        width: 100%; max-width: none;
        border-top: none;
        border-bottom: 1px solid var(--border);
    }
    .bottom-nav-inner { max-width: 1080px; margin: 0 auto; padding: 0 24px; gap: 6px; }
    /* An admin account gets one extra pill here (the "Admin" link — see
       partials/bottom-nav.php), which is what was actually crowding the
       brand logo on a modest desktop window, not anything in /admin/* itself. */
    .bottom-nav--admin .bottom-nav-inner { max-width: 1186px; }
    .bottom-nav-brand { display: flex; align-items: center; margin-right: 12px; }
    /* .bottom-nav has a fixed height (--nav-h) at this breakpoint, so this can
       grow right up to comfortably under that ceiling without changing the
       bar's own height at all. */
    .bottom-nav-brand img { height: 46px; }
    .bottom-nav-spacer { display: block; flex: 1; order: 4; }
    .bottom-nav-item, .bottom-nav-item--extra {
        flex: 0 0 auto;
        flex-direction: row;
        gap: 9px;
        padding: 10px 18px;
        border-radius: var(--radius-pill);
        font-size: 14px;
    }
    .bottom-nav-item i { font-size: 16px; }
    .bottom-nav-item.active { background: var(--accent-soft); }
    .bottom-nav-item--extra { display: flex; order: 4; }
    .bottom-nav-item--profile { order: 3; }

    .app.has-bottom-nav { padding-bottom: 24px; padding-top: calc(var(--nav-h) + 20px); }
    .home-header { display: none; } /* the top nav now carries the brand mark */
}

/* ============================================
   FORMS / AUTH PAGES / CARDS
   ============================================ */
.auth-page { display: flex; flex-direction: column; align-items: center; padding: 40px 24px; min-height: 100vh; }
/* Fields felt compressed together at the default .form-group spacing — a
   bit more room between them here only (not a site-wide change, so the
   denser admin forms that also use .form-group are unaffected). */
.auth-page .form-group { margin-bottom: 22px; }
@media (min-width: 860px) {
    /* On a tall desktop viewport the page-top alignment left the whole
       form stranded near the top with empty space below it — center the
       block vertically too, same as most desktop sign-in pages. Mobile
       keeps top-alignment so a long form (e.g. OTP + resend + cancel)
       scrolls naturally instead of being squeezed into the viewport. */
    .auth-page { justify-content: center; min-height: 100vh; }
}
.auth-logo { width: 84px; height: 84px; border-radius: 22px; overflow: hidden; margin-bottom: 18px; }
.auth-logo img { width: 100%; height: 100%; object-fit: cover; }
.auth-title { font-size: 24px; font-weight: 800; margin: 0 0 4px; }
.auth-subtitle { color: var(--text-dim); font-size: 14px; margin-bottom: 28px; text-align: center; }
.auth-card { width: 100%; max-width: 420px; }
.auth-switch { margin-top: 24px; width: 100%; max-width: 420px; text-align: center; }
.auth-switch p { font-size: 13.5px; color: var(--text-dim); margin: 0 0 10px; }
.auth-switch .btn-secondary { border-color: var(--accent); }
.auth-page .btn-ghost { border-color: var(--text-dim); }
.auth-page .fa-whatsapp { font-size: 19px; }

.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; color: var(--text-dim); margin-bottom: 6px; font-weight: 600; }
.form-label i { color: var(--accent); margin-right: 6px; width: 13px; text-align: center; }
.form-input, .form-select, .form-textarea {
    width: 100%; padding: 13px 14px; border-radius: var(--radius-sm);
    background: var(--surface-2); border: 1px solid var(--border-soft); color: var(--text); font-size: 15px;
    font-family: var(--font);
    transition: border-color 150ms ease;
}
.form-input::placeholder { color: var(--text-dimmer); }
.form-input[readonly] { background: var(--surface-3); color: var(--text-dim); cursor: not-allowed; }
.form-select {
    appearance: none; -webkit-appearance: none; -moz-appearance: none;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%239a9a9a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 8l5 5 5-5'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 14px center;
    background-size: 14px;
    padding-right: 38px;
    cursor: pointer;
    /* Tells the browser to render the native open dropdown list itself using
       a dark palette instead of the harsh default white popup — the actual
       list isn't otherwise stylable with CSS, but this alone makes it match
       the rest of the dark UI in every modern browser. */
    color-scheme: dark;
}
.form-select:focus { border-color: var(--accent); outline: none; }
.form-select:hover { border-color: var(--text-dimmer); }
.form-select option { background: var(--surface-2); color: var(--text); padding: 10px; }

/* Leading icon square flush inside an input's own left edge (see
   register.php/login.php's auth forms) — not beside the input, inside it,
   like most professional sign-in forms. */
.input-icon-wrap { position: relative; }
.input-icon-wrap .form-input { padding-left: 46px; }
.input-icon {
    position: absolute; left: 1px; top: 1px; bottom: 1px; width: 42px;
    display: flex; align-items: center; justify-content: center;
    background: var(--surface-3); border-right: 1px solid var(--border-soft);
    border-radius: calc(var(--radius-sm) - 1px) 0 0 calc(var(--radius-sm) - 1px);
    color: var(--text-dim); font-size: 15px; pointer-events: none;
    transition: color 150ms ease, border-color 150ms ease;
}
.input-icon-wrap:focus-within .input-icon { color: var(--accent); border-right-color: var(--accent); }

/* Show/hide toggle for password fields (see register.php/login.php) — sits
   flush inside the input's right edge, mirroring the left icon square. */
.input-icon-wrap.has-toggle .form-input { padding-right: 46px; }
.input-toggle-visibility {
    position: absolute; right: 4px; top: 4px; bottom: 4px; width: 38px; padding: 0;
    display: flex; align-items: center; justify-content: center;
    background: transparent; border: none; border-radius: calc(var(--radius-sm) - 3px);
    color: var(--text-dim); font-size: 15px; cursor: pointer;
    transition: color 150ms ease, background-color 150ms ease;
}
.input-toggle-visibility:hover { color: var(--text); background: var(--surface-3); }
.input-toggle-visibility:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* Country-code + local number, side by side (see register.php/login.php's
   WhatsApp phone fields) — stays on one row even on narrow phones, same as
   virtually every phone-verification UI. The closed trigger stays compact
   (ISO code + dial code, via each <option>'s data-short — see app.js) while
   the open list widens to comfortably fit the full country name. */
.phone-input-row { display: flex; gap: 8px; align-items: stretch; }
.phone-input-row .custom-select { flex: 0 0 118px; }
.phone-input-row .custom-select .custom-select-trigger { width: 118px; padding-right: 26px; }
.phone-input-row .custom-select-list { left: 0; right: auto; min-width: 230px; }

/* 6-box OTP entry (see register.php/login.php's verify steps) — a row of
   single-digit inputs standing in for one text field (assets/js/app.js
   keeps a hidden name="code" input in sync behind them). */
.otp-boxes { display: flex; gap: 8px; justify-content: center; }
.otp-box {
    width: 44px; height: 52px; padding: 0; border-radius: var(--radius-sm);
    background: var(--surface-2); border: 1px solid var(--border-soft); color: var(--text);
    font-size: 20px; font-weight: 800; text-align: center; font-family: var(--font);
    transition: border-color 150ms ease;
}
.otp-box:focus { border-color: var(--accent); outline: none; }
.phone-input-row .input-icon-wrap { flex: 1 1 auto; min-width: 0; }
/* The phone icon square eats the first 43px, leaving the typed number
   sitting almost flush against it at the default 46px — a bit more room
   here specifically (not the general .input-icon-wrap rule, which email/
   password fields elsewhere already look fine with). */
.phone-input-row .input-icon-wrap .form-input { padding-left: 56px; }
@media (max-width: 380px) {
    .phone-input-row .custom-select { flex: 0 0 96px; }
    .phone-input-row .custom-select .custom-select-trigger { width: 96px; }
}

/* Custom dropdown (see app.js) — fully our own styling for both the closed
   trigger and the open list, since the native popup can't be restyled. */
.custom-select { position: relative; }
.custom-select select { display: none; }
.custom-select-trigger {
    width: 100%; padding: 13px 14px; border-radius: var(--radius-sm);
    background: var(--surface-2); border: 1px solid var(--border-soft); color: var(--text);
    font-size: 15px; font-family: var(--font); text-align: left; cursor: pointer;
    display: flex; align-items: center; justify-content: space-between; gap: 10px;
    transition: border-color 150ms ease;
}
.custom-select-trigger-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.custom-select-trigger:hover { border-color: var(--text-dimmer); }
.custom-select-trigger.open { border-color: var(--accent); }
.custom-select-trigger::after {
    content: ''; width: 8px; height: 8px; flex-shrink: 0;
    border-right: 2px solid var(--text-dim); border-bottom: 2px solid var(--text-dim);
    transform: rotate(45deg); transition: transform 0.15s ease; margin-top: -3px;
}
.custom-select-trigger.open::after { transform: rotate(225deg); margin-top: 3px; }
.custom-select-list {
    display: none; position: absolute; top: calc(100% + 6px); left: 0; right: 0;
    background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: var(--radius-sm);
    box-shadow: 0 14px 32px rgba(0, 0, 0, 0.5); z-index: 60; max-height: 260px; overflow-y: auto;
}
.custom-select-list.open { display: block; }
.custom-select-item { padding: 11px 14px; font-size: 14px; color: var(--text); cursor: pointer; transition: background-color 150ms ease; }
.custom-select-item:hover { background: var(--surface-3); }
.custom-select-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 700; }
.custom-select-item.is-filtered-out { display: none; }

/* Themed scrollbar for every open dropdown list, instead of the browser's
   bare default bar. */
.custom-select-list { scrollbar-width: thin; scrollbar-color: var(--accent-dim) var(--surface-3); }
.custom-select-list::-webkit-scrollbar { width: 8px; }
.custom-select-list::-webkit-scrollbar-track { background: var(--surface-3); }
.custom-select-list::-webkit-scrollbar-thumb { background: var(--accent-dim); border-radius: 8px; }
.custom-select-list::-webkit-scrollbar-thumb:hover { background: var(--accent); }

/* Custom-styled tooltip, positioned by assets/js/app.js on hover (mouse
   only — there's no touch equivalent, so this simply doesn't show on a
   touchscreen with no mouse attached) of any [data-tooltip] element.
   position: fixed so it's never clipped by a scrolling ancestor (e.g. a
   horizontally-scrollable pill row or admin table). Pair with an
   ellipsis-truncated element (song/artist card titles, admin table cells,
   etc.) to surface the full value instead of relying on the browser's
   plain native title tooltip. Site-wide — used on both the public pages
   and the admin. */
.app-tooltip {
    position: fixed; z-index: 1000;
    background: var(--surface-3); color: var(--text); border: 1px solid var(--border-soft);
    padding: 6px 10px; border-radius: var(--radius-sm);
    font-size: 12px; font-weight: 500; line-height: 1.4;
    max-width: 320px; width: max-content;
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
    opacity: 0; transform: translateY(4px);
    transition: opacity 120ms ease, transform 120ms ease;
    pointer-events: none;
}
.app-tooltip.is-visible { opacity: 1; transform: translateY(0); }
/* Small notch pointing at whatever's being described — positioned via the
   --arrow-left custom property (assets/js/app.js), clamped to the hovered
   element's own center rather than just the tooltip box's, since the box
   itself can shift sideways near a viewport edge. Two stacked triangles
   (::before slightly larger, in the border color; ::after 1px smaller, in
   the fill color, on top) — a single borderless triangle sat flush against
   the box's own 1px border with no outline of its own, which against a
   similarly-dark page background just visually disappeared into it. This
   gives the notch that same border-soft outline so it reads as one
   continuous shape with the box instead of an invisible seam. Sits below
   the box by default (tooltip is above the target); .is-below flips both
   layers to the top edge instead (tooltip flipped below the target). */
.app-tooltip::before, .app-tooltip::after {
    content: ''; position: absolute; left: var(--arrow-left, 50%);
    width: 0; height: 0; border-style: solid; border-color: transparent;
}
.app-tooltip::before { margin-left: -6px; border-width: 6px; top: 100%; border-top-color: var(--border-soft); }
.app-tooltip::after { margin-left: -5px; border-width: 5px; top: calc(100% - 1px); border-top-color: var(--surface-3); }
.app-tooltip.is-below::before { top: auto; bottom: 100%; border-top-color: transparent; border-bottom-color: var(--border-soft); }
.app-tooltip.is-below::after { top: auto; bottom: calc(100% - 1px); border-top-color: transparent; border-bottom-color: var(--surface-3); }

/* "Type to search" box (opt-in via data-searchable="1" — see the country-code
   pickers) — pinned to the top of the list so it stays visible while
   scrolling through matches. */
.custom-select-search {
    position: sticky; top: 0; z-index: 1; width: 100%; padding: 11px 14px;
    background: var(--surface-3); border: none; border-bottom: 1px solid var(--border-soft);
    color: var(--text); font-size: 14px; font-family: var(--font); outline: none;
}
.custom-select-search::placeholder { color: var(--text-dimmer); }

.form-textarea { resize: vertical; min-height: 90px; }
.form-hint { font-size: 12px; color: var(--text-dimmer); margin-top: 6px; }
.form-error { font-size: 13px; color: var(--danger); margin-top: 6px; }
.file-input-wrap { border: 1.5px dashed var(--border-soft); border-radius: var(--radius-md); padding: 22px; text-align: center; color: var(--text-dim); font-size: 13px; }
.file-input-wrap i { font-size: 24px; margin-bottom: 8px; display: block; color: var(--text-dimmer); }

/* Current photo (left, .artist-avatar) + upload dropzone (right) — see
   admin/artists.php. The left avatar only ever shows what's actually saved;
   a newly-chosen file previews inside the dropzone until Save confirms it. */
.avatar-upload-row { display: flex; gap: 16px; align-items: flex-start; }
.avatar-upload-dropzone {
    flex: 1 1 auto; min-width: 0; min-height: 96px; box-sizing: border-box;
    border: 1.5px dashed var(--border-soft); border-radius: var(--radius-md);
    display: flex; align-items: center; justify-content: center; padding: 14px;
    cursor: pointer; transition: border-color 150ms ease, background-color 150ms ease;
}
.avatar-upload-dropzone:hover { border-color: var(--accent); background: var(--surface-2); }
.avatar-upload-prompt { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; text-align: center; color: var(--text-dim); }
.avatar-upload-prompt i { font-size: 24px; color: var(--text-dimmer); display: block; margin-bottom: 8px; }
.avatar-upload-prompt span { display: block; font-size: 13.5px; font-weight: 600; color: var(--text); }
.avatar-upload-prompt small { display: block; font-size: 11.5px; color: var(--text-dimmer); margin-top: 4px; }
.avatar-upload-chosen { display: flex; align-items: center; gap: 12px; width: 100%; }
/* A class selector like .avatar-upload-prompt/.avatar-upload-chosen ties in
   specificity with the browser's own [hidden] { display: none } rule, and
   author CSS wins ties over the UA stylesheet — so toggling .hidden via JS
   was silently not hiding either box, leaving a stale "New photo selected"
   label and an empty, broken-looking image visible right after Save or the
   clear button. [hidden] here (attribute selector added to the class) wins
   outright regardless of that tie. */
.avatar-upload-prompt[hidden], .avatar-upload-chosen[hidden] { display: none; }
.avatar-upload-chosen img { width: 56px; height: 56px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-soft); }
.avatar-upload-chosen-label { flex: 1 1 auto; font-size: 12.5px; color: var(--accent); font-weight: 600; }
.avatar-upload-clear {
    flex-shrink: 0; width: 30px; height: 30px; border-radius: 50%; padding: 0;
    border: 1px solid var(--border-soft); background: var(--surface-3); color: var(--text-dim);
    display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 13px;
    transition: color 150ms ease, border-color 150ms ease;
}
.avatar-upload-clear:hover { color: var(--danger); border-color: var(--danger); }
@media (max-width: 480px) {
    .avatar-upload-row { flex-direction: column; align-items: center; }
    .avatar-upload-dropzone { width: 100%; }
}

.card { background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); padding: 18px; margin-bottom: 16px; }
.card-title { font-size: 15px; font-weight: 800; margin: 0 0 12px; }
.kv-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 14px; border-bottom: 1px solid var(--border); }
.kv-row:last-child { border-bottom: none; }
.kv-row .k { color: var(--text-dim); }
.kv-row .v { font-weight: 700; }

/* ============================================
   PLAN / PRICING CARDS
   ============================================ */
.plan-grid { display: flex; flex-direction: column; gap: 22px; }
.plan-card { background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); padding: 20px; position: relative; }
.plan-badge { position: absolute; top: -10px; right: 18px; background: var(--accent); color: #fff; font-size: 11px; font-weight: 800; padding: 4px 10px; border-radius: var(--radius-pill); }
.plan-name { font-size: 16px; font-weight: 800; }
.plan-price { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 30px; font-weight: 800; margin: 8px 0 2px; }
.plan-price-days { font-size: 14px; color: var(--text-dim); font-weight: 600; }
/* Tier icon, colored to match the same bronze/gold/diamond family as
   .tier-badge next to page titles — see plan_tier_meta() in Access.php.
   Just a glow (drop-shadow follows the icon's actual glyph shape, unlike
   text-shadow which would blur a rectangular box behind it) — no border on
   the icon itself, the card's own border carries the tier color instead. */
.plan-price-icon { font-size: 26px; flex-shrink: 0; }
.plan-price-icon--free { color: var(--text-dim); filter: drop-shadow(0 0 6px rgba(154, 154, 154, 0.55)); }
.plan-price-icon--bronze { color: #c9824f; filter: drop-shadow(0 0 7px rgba(160, 90, 44, 0.7)); }
.plan-price-icon--gold { color: #e8c468; filter: drop-shadow(0 0 7px rgba(212, 175, 55, 0.65)); }
.plan-price-icon--diamond { color: #c4b5fd; filter: drop-shadow(0 0 7px rgba(167, 139, 250, 0.65)); }
.plan-days { color: var(--text-dim); font-size: 13px; margin-bottom: 16px; }
/* Thin tier-colored border at rest — box-shadow's ring term is set to the
   SAME color already, just at 0 spread (invisible), and the lift-shadow
   term is set to the same blur/offset as the hover version, just at 0
   alpha (also invisible). Keeping both terms present with the exact same
   structure at rest AND on hover — only their spread/alpha numbers change —
   is what actually lets the transition interpolate smoothly in both
   directions; a REST value with fewer shadow layers than the HOVER value
   can't be interpolated at all, so the browser just snaps instantly
   instead of fading, which is why the old hover glow used to vanish the
   moment the mouse left instead of easing out. */
.plan-card-free { border-color: var(--success); box-shadow: 0 10px 24px rgba(0, 0, 0, 0), 0 0 0 0 var(--success); }
.plan-badge-free { background: var(--success); }
.plan-card-disabled { opacity: 0.6; }
/* Every plan card's border/glow matches its own tier — same bronze/gold/
   diamond family as .tier-badge/.plan-price-icon (see plan_tier_meta() in
   Access.php) — a permanent identity, not just whichever plan happens to
   be "best value" today. */
.plan-card-bronze { border-color: #c9824f; box-shadow: 0 10px 24px rgba(0, 0, 0, 0), 0 0 0 0 #c9824f; }
.plan-card-gold { border-color: #e8c468; box-shadow: 0 10px 24px rgba(0, 0, 0, 0), 0 0 0 0 #e8c468; }
.plan-card-diamond { border-color: #a78bfa; box-shadow: 0 10px 24px rgba(0, 0, 0, 0), 0 0 0 0 #a78bfa; }
/* The current-plan accent ring wins over the tier color above when both
   classes land on the same card — defined after those deliberately, since
   equal-specificity classes resolve by source order. */
.plan-card-current { border-color: var(--accent); box-shadow: 0 10px 24px rgba(0, 0, 0, 0), 0 0 0 0 var(--accent); opacity: 1; }
.plan-badge-current { background: var(--accent); }
.plan-badge-diamond { background: linear-gradient(135deg, #6d28d9, #a78bfa); display: flex; align-items: center; gap: 5px; }

/* ============================================
   MODAL (disclaimer, etc.)
   ============================================ */
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 300; }
.modal-overlay[hidden] { display: none; }
.modal-box { background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); max-width: 420px; width: 100%; max-height: 85vh; overflow-y: auto; padding: 22px; }
.modal-header { display: flex; align-items: center; justify-content: center; gap: 10px; margin-bottom: 12px; text-align: center; }
.modal-header i { color: var(--accent); font-size: 20px; }
.modal-header h3 { margin: 0; font-size: 17px; font-weight: 800; }

/* "Crossed out" wifi icon — Font Awesome's free tier has no single
   wifi-slash glyph, so this stacks a plain fa-ban ring over fa-wifi
   instead. Used by assets/js/app.js's offline popup, offline.html's
   banner, and offline-player.html's menu. Deliberately ignores FA's own
   .fa-stack/.fa-stack-1x/.fa-stack-2x relative-em sizing (kept on the
   markup only for semantic/compat reasons) in favor of plain absolute
   centering — the relative sizing depends on the container's line-height
   matching its height exactly, which broke the moment anything on the page
   (like .modal-header i's flat font-size) touched either one. Absolute
   centering can't be knocked off by an unrelated rule elsewhere. */
.crossed-wifi { position: relative; display: inline-block; width: 1.6em; height: 1.6em; vertical-align: middle; font-size: 20px; }
.crossed-wifi i { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0; line-height: 1; }
.crossed-wifi .fa-wifi { font-size: 0.45em; color: var(--accent); }
.crossed-wifi .fa-ban { font-size: 1em; color: #e53935; }
.modal-body { text-align: center; }
.modal-body p { font-size: 13.5px; line-height: 1.6; color: var(--text-dim); margin: 0 0 12px; }
.modal-footer { margin-top: 6px; }
.modal-check { display: flex; align-items: flex-start; justify-content: center; gap: 8px; font-size: 13px; color: var(--text-dim); margin-bottom: 14px; cursor: pointer; text-align: left; }

/* Landscape/fullscreen tip card (see song.php) — the phone icon rotates
   from upright to sideways and back, looping, to visually demonstrate the
   suggested action rather than just describing it in words. */
.landscape-tip-icon { font-size: 44px; color: var(--accent); margin-bottom: 8px; }
.landscape-tip-icon i { display: inline-block; animation: landscapeTipRotate 2.4s ease-in-out infinite; }
@keyframes landscapeTipRotate {
    0%, 15% { transform: rotate(0deg); }
    45%, 65% { transform: rotate(90deg); }
    95%, 100% { transform: rotate(0deg); }
}
.modal-check input { margin-top: 2px; flex-shrink: 0; }

/* ============================================
   ONBOARDING TOUR (assets/js/tour.js) — spotlight highlight is a single
   element with a huge box-shadow (covers the whole viewport except its own
   box), so no separate darkening layer is needed under it.
   ============================================ */
/* Onboarding tour motion system. Two curves, used consistently everywhere
   below instead of ad-hoc "ease"/"ease-out" per rule: -move is Material
   Design's "standard" easing (used for something already on screen
   relocating — neither a sudden start nor an abrupt stop, since neither
   end of the motion is "new"), -enter is a pure deceleration curve (fast
   start, gentle landing — the standard feel for something newly appearing,
   used by most contemporary UI toolkits' popovers/tooltips). */
:root {
    --tour-ease-move: cubic-bezier(0.4, 0, 0.2, 1);
    --tour-ease-enter: cubic-bezier(0, 0, 0.2, 1);
}
.tour-overlay { position: fixed; inset: 0; z-index: 2000; }
.tour-overlay[hidden] { display: none; }
/* Opacity (+ a small scale) only animates at the true start/end of the
   tour — first appearing, and disappearing when it ends/gets skipped; see
   .is-visible in tour.js. Between steps it STAYS visible and instead
   morphs — top/left/width/height transition on their own — so the frame
   smoothly resizes/repositions from the current step's spot to the next
   one, rather than either an instant snap or a fade-out/fade-in cycle. */
.tour-highlight {
    position: fixed;
    border-radius: 14px;
    box-shadow: 0 0 0 4px var(--accent), 0 0 0 9999px rgba(0, 0, 0, 0.78);
    opacity: 0;
    transform: scale(0.97);
    transition: opacity 150ms var(--tour-ease-enter), transform 150ms var(--tour-ease-enter),
        top 220ms var(--tour-ease-move), left 220ms var(--tour-ease-move),
        width 220ms var(--tour-ease-move), height 220ms var(--tour-ease-move);
    pointer-events: none;
}
.tour-highlight.is-visible { opacity: 1; transform: scale(1); }
/* While a scroll to the next step is actually in flight (see
   scrollTargetIntoView() in tour.js), the highlight's top/left/width/height
   are updated on every scroll tick to track the target's live position —
   dropping the transition on just those four properties here makes each
   tick an instant, exact snap instead of a 220ms transition endlessly
   chasing a target that's already moved again. Opacity/transform stay
   transitioned as normal (irrelevant during tracking — the box is already
   visible and not scaling), so nothing else about the enter/exit motion changes. */
.tour-highlight.tour-tracking {
    transition: opacity 150ms var(--tour-ease-enter), transform 150ms var(--tour-ease-enter);
}
.tour-tooltip {
    position: fixed;
    width: min(300px, calc(100vw - 32px));
    background: var(--surface-2);
    border: 1px solid var(--border-soft);
    border-radius: var(--radius-md);
    padding: 16px;
    box-shadow: 0 14px 32px rgba(0, 0, 0, 0.5);
    opacity: 0;
    transform: translateY(6px);
    transition: opacity 150ms var(--tour-ease-enter), transform 150ms var(--tour-ease-enter),
        top 220ms var(--tour-ease-move), left 220ms var(--tour-ease-move), height 220ms var(--tour-ease-move);
}
/* Not just cosmetic — without this, the invisible tooltip (right before
   its very first fade-in, or while fading out at the end) could still eat
   a click meant for whatever's underneath it. */
.tour-tooltip:not(.is-visible) { pointer-events: none; }
.tour-tooltip.is-visible { opacity: 1; transform: translateY(0); }
/* The actual "page change" for the card — title+text slide+fade to the new
   step's copy, direction matching which button was pressed (Next pushes
   left, Back pushes right — the natural "paging" feel), while the tooltip
   box itself stays put and simply relocates alongside the highlight,
   instead of the whole card blanking out and back in on every step. See
   tour.js: text content is swapped while faded out at the exit offset,
   then the element is jumped (still invisible) to the opposite offset and
   eased back to center, so the entrance visibly arrives from the new-
   content side rather than retracing the exit. */
.tour-tooltip-content {
    transition: opacity 140ms var(--tour-ease-move), transform 140ms var(--tour-ease-move);
}
.tour-tooltip-content.tour-exit-next { opacity: 0; transform: translateX(-20px); }
.tour-tooltip-content.tour-exit-prev { opacity: 0; transform: translateX(20px); }
.tour-tooltip-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.tour-progress-dots { display: flex; gap: 5px; }
.tour-progress-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-soft); }
.tour-progress-dots span.active { background: var(--accent); }
.tour-skip-link { background: none; border: none; color: var(--text-dimmer); font-size: 12px; font-weight: 600; cursor: pointer; padding: 4px; transition: color 150ms ease; }
.tour-skip-link:hover { color: var(--text-dim); }
.tour-tooltip h4 { margin: 0 0 6px; font-size: 15px; font-weight: 800; }
.tour-tooltip p { margin: 0 0 14px; font-size: 13px; line-height: 1.55; color: var(--text-dim); }
.tour-tooltip-actions { display: flex; justify-content: flex-end; gap: 10px; }
#tourPrevBtn[hidden] { display: none; }

/* ============================================
   PROFILE
   ============================================ */
.profile-avatar { width: 84px; height: 84px; aspect-ratio: 1 / 1; flex-shrink: 0; border-radius: 50%; background: var(--accent-soft); color: var(--accent); display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; margin: 0 auto 12px; }
.profile-name { text-align: center; font-size: 20px; font-weight: 800; }
.profile-email { text-align: center; color: var(--text-dim); font-size: 13px; margin-bottom: 20px; }

/* ============================================
   NOW PLAYING (song.php)
   ============================================ */
/* height:100vh + overflow:hidden (not min-height) is the load-bearing fix:
   it hard-caps the whole screen to the viewport so NOTHING here can ever
   push the page taller and force an outer scroll — that outer scroll was
   what dragged the top bar out of view. With a hard cap, .lyrics-wrapper is
   the only thing that scrolls (internally), and the top bar / tabs / bottom
   controls just sit at their natural size as ordinary flex children. */
/* Opt-in background video (song.php's menu toggle, wired in player.js) —
   plays only while the song's own audio is actually playing (started on
   its 'play' event, paused on 'pause'/'ended' — see player.js) rather
   than as soon as the toggle is switched on. A SIBLING of .player-page in
   the HTML (see song.php), not a child of it: .player-page has
   overflow:hidden for its own layout reasons, which would otherwise clip
   this position:fixed layer down to just .player-page's own centered,
   possibly-narrower-than-the-screen box on desktop, instead of the full
   viewport/body it's meant to cover. Shown exactly as YouTube renders it —
   no blur, no scale/zoom. Opacity (not display) driven by .active, so
   turning the toggle on/off (or auto-swapping to the next video near the
   end — see player.js) fades smoothly instead of cutting instantly;
   pointer-events:none already keeps it non-interactive either way, so
   there's no need to also juggle display/visibility for that. z-index:0
   here + the chrome/stage/bottom-bar's own z-index:1 (below) is what
   orders .player-page's actual content above this sibling, rather than
   leaning on DOM order alone. */
.player-bg-video { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; opacity: 0; transition: opacity 400ms ease; }
.player-bg-video.active { opacity: 1; }
.player-bg-video iframe {
    position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
    width: 100vw; height: 56.25vw; min-height: 100vh; min-width: 177.78vh; /* 16:9 cover-fit, whichever dimension is the binding constraint — no extra scale, shown at its native size */
    border: none;
}

/* transparent — the three children below (chrome-top/stage/bottom-bar)
   each carry their own var(--player-bg-alpha) background and together
   cover 100% of this box's height, so there's nothing left for this one
   to visually contribute; it still needs position+z-index though, to
   (a) guarantee it paints above its .player-bg-video sibling and
   (b) establish its own stacking context so those children's z-index:1
   stays scoped to just this page. */
.player-page { display: flex; flex-direction: column; height: 100vh; overflow: hidden; background: transparent; max-width: var(--maxw); margin: 0 auto; position: relative; z-index: 1; }

/* Groups topbar+tabs as one unit so the landscape-phone immersive mode below
   can slide/position them together without caring about either one's own
   height — a plain column, identical in effect to having them as separate
   flex children like before. Explicit z-index:1 (see .player-bg-video
   above) is what keeps this — and .player-stage/.player-bottom-bar below —
   guaranteed above the background video; var(--player-bg-alpha) (near-
   opaque by default, not fully solid — see :root) is the actual visible
   backdrop for the whole page now, and end users can lighten it via the
   Background Video menu's own slider (song.php/player.js). */
.player-chrome-top { flex-shrink: 0; display: flex; flex-direction: column; position: relative; z-index: 1; background: rgba(0, 0, 0, var(--player-bg-alpha)); }
.player-topbar { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; padding: 16px; }
/* :not(.player-tab) — without it this also matched the Cover/Lyrics tab
   buttons once fs-landscape-locked mode moves them into this same topbar
   (see player.js's updateTabsPlacement), forcing them into these same
   38px circles instead of their own pill shape; .player-tab's own rule
   has fewer classes than this one, so it couldn't win that fight just by
   also setting padding/font-size like the landscape media query tried. */
.player-topbar button:not(.player-tab), .player-topbar a:not(.player-tab) {
    flex-shrink: 0;
    width: 38px; height: 38px;
    display: flex; align-items: center; justify-content: center;
    background: var(--surface-3); border: 1px solid var(--border-soft); border-radius: 50%;
    color: var(--text-dim); font-size: 16px; cursor: pointer;
}
.player-topbar-title { font-weight: 700; font-size: 15px; color: var(--text-dim); }

.player-tabs { flex-shrink: 0; display: flex; justify-content: center; gap: 10px; padding: 4px 0 10px; }
.player-tab { padding: 9px 22px; border-radius: var(--radius-pill); background: var(--surface-3); color: var(--text-dim); font-weight: 700; font-size: 14px; border: none; cursor: pointer; display: flex; align-items: center; gap: 8px; }
.player-tab.active { background: var(--accent); color: #fff; }

.player-stage { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; z-index: 1; overflow: hidden; background: rgba(0, 0, 0, var(--player-bg-alpha)); }
.player-cover-view, .player-lyrics-view { display: none; flex: 1; min-height: 0; }
.player-cover-view.active, .player-lyrics-view.active { display: flex; }
.player-cover-view { align-items: center; justify-content: center; padding: 20px; }
.player-cover-view img { width: 100%; max-width: 320px; aspect-ratio: 1; object-fit: cover; border-radius: var(--radius-lg); box-shadow: 0 20px 50px rgba(0,0,0,0.6); }
.player-cover-view .no-cover { width: 100%; max-width: 320px; aspect-ratio: 1; border-radius: var(--radius-lg); background: var(--surface-3); display: flex; align-items: center; justify-content: center; font-size: 48px; color: var(--text-dimmer); }

.lyrics-wrapper {
    flex: 1; min-height: 0; overflow-y: auto; padding: 10px 15px 20px; -webkit-overflow-scrolling: touch;
    scrollbar-width: none; -ms-overflow-style: none; /* scrolls normally (incl. the auto-scroll-to-line JS) — just no visible scrollbar track */
}
.lyrics-wrapper::-webkit-scrollbar { display: none; }
/* cursor:pointer is added inline by player.js, admin/editor only — tap-to-
   seek shouldn't look clickable to a regular listener who can't use it. */
.lyric-line { margin: 16px 0; text-align: center; padding: 6px 5px; }
.lyric-line-row { display: flex; align-items: center; justify-content: center; gap: 8px; }
/* --line-scale keeps the responsive clamp() as the base size and just
   multiplies it — the currently-active line reads a little bigger, every
   other line (before and after it) a little smaller, and font-size is
   genuinely transitionable (unlike some other properties) so the size
   change animates smoothly as .current moves from line to line. 500ms to
   match smoothScrollTo()'s own duration in player.js — the grow/shrink is
   meant to finish exactly as the scroll settles, not before or after it;
   keep these two in sync if either one ever changes. */
.line-text { font-size: calc(clamp(17px, 6vw, 34px) * var(--line-scale, 0.92)); font-weight: 800; letter-spacing: 0.3px; line-height: 1.5; transition: font-size 500ms ease; }
.lyric-line.current .line-text { --line-scale: 1.12; }
.lyric-gender-icons { display: flex; flex-shrink: 0; gap: 4px; }
.lyric-gender-emoji { font-size: 20px; line-height: 1; }
.karaoke-word { display: inline-block; transition: color 0.08s linear; margin-right: 6px; color: var(--text-dimmer); }
/* Once a line stops being the active/emphasized one, its words reset to
   dim in the same 500ms it takes the scroll to move away — a deliberate,
   visible grayout, not the fast 80ms tick above (which is only for the
   moment-to-moment word-by-word sweep while a line IS active). */
.lyric-line:not(.current) .karaoke-word { transition: color 500ms ease; }
.player-ctrl-sec.is-disabled { opacity: 0.3; pointer-events: none; }
.line-progress { width: 50%; max-width: 220px; height: 3px; background: #2a2a2a; border-radius: 4px; margin: 6px auto 0; overflow: hidden; }
.line-progress-fill { height: 100%; background: var(--accent); width: 0%; opacity: 1; transition: width 0.05s linear, opacity 0.08s linear; border-radius: 999px; }
/* The instant a line stops being active, its bar fades away (opacity),
   staying at whatever width it last reached — it does NOT animate back
   down to 0% width first. player.js deliberately stops touching the width
   once a line is inactive, so this is the only thing that changes. */
.lyric-line:not(.current) .line-progress-fill { opacity: 0; transition: opacity 500ms ease; }

/* A normal (non-fixed) flex child now — height:100vh + overflow:hidden on
   .player-page already guarantees it stays on-screen at the bottom, with no
   position:fixed/padding-compensation trick needed. */
.player-bottom-bar {
    flex-shrink: 0;
    background: rgba(0, 0, 0, var(--player-bg-alpha));
    border-top: 1px solid var(--border);
    padding-bottom: env(safe-area-inset-bottom, 0);
    position: relative; z-index: 1;
    transition: border-top-color 300ms ease;
}
/* .player-bg-video is a SIBLING of .player-page (see main.css's own note
   above and song.php), not a descendant of .player-bottom-bar — so
   player.js toggles this class on <body> itself (in sync with the video's
   own fade) rather than relying on a CSS sibling selector that couldn't
   reach across that boundary. */
body.bg-video-active .player-bottom-bar { border-top-color: transparent; }

.player-meta { padding: 4px 20px 8px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.player-song-title { font-size: 21px; font-weight: 800; margin: 0 0 4px; }
.player-song-artist { font-size: 14px; color: var(--text-dim); margin: 0; }
.player-meta-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.player-heart-btn { background: none; border: none; color: var(--text-dim); font-size: 22px; padding: 6px; margin-top: 2px; cursor: pointer; }
.player-heart-btn .fa-solid { color: var(--accent); }

/* offline-player.html only — a plain, non-interactive "this is saved for
   offline listening" indicator, sized to visually match the heart/
   fullscreen buttons next to it — fa-circle-check's glyph fills noticeably
   less of its own box than fa-heart does, so matching the raw font-size
   number alone (22px) still read as smaller; bumped until it actually
   looks the same size. */
.offline-downloaded-badge { color: var(--success); font-size: 25px; padding: 6px; margin-top: 2px; }
.player-fullscreen-btn { background: none; border: none; color: var(--text-dim); font-size: 20px; padding: 6px; margin-top: 2px; cursor: pointer; }
.player-fullscreen-btn[hidden] { display: none; }

.player-seek { padding: 6px 20px 0; }
.seek-range { -webkit-appearance: none; width: 100%; height: 4px; border-radius: 4px; background: linear-gradient(to right, var(--accent) 0%, var(--accent) var(--seek-pct, 0%), #333 var(--seek-pct, 0%), #333 100%); }
.seek-range::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--accent); cursor: pointer; }
.seek-range::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: none; cursor: pointer; }
.seek-times { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-dim); margin-top: 6px; font-family: monospace; }

.player-controls { display: flex; align-items: center; justify-content: center; gap: 26px; padding: 14px 20px; }
.player-ctrl-toggle { background: none; border: none; color: var(--text-dimmer); font-size: 18px; cursor: pointer; padding: 6px; }
.player-ctrl-toggle.active { color: var(--accent); }
.player-ctrl-sec { background: none; border: none; color: var(--text); font-size: 24px; cursor: pointer; padding: 6px; }
.player-play-btn { width: 62px; height: 62px; border-radius: 50%; background: #fff; color: #000; border: none; display: flex; align-items: center; justify-content: center; font-size: 22px; cursor: pointer; }

.player-volume { display: flex; align-items: center; gap: 12px; padding: 4px 24px 6px; }
.player-volume i { color: var(--text-dim); font-size: 14px; }
/* Mobile has real hardware volume buttons — this on-screen control is desktop-only for now. */
@media (max-width: 859px) {
    .player-volume { display: none; }
}
.volume-range { -webkit-appearance: none; flex: 1; height: 4px; border-radius: 4px; background: linear-gradient(to right, #fff 0%, #fff var(--vol-pct, 70%), #333 var(--vol-pct, 70%), #333 100%); }
.volume-range::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; cursor: pointer; }
.volume-range::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%; background: #fff; border: none; cursor: pointer; }

.player-queue-label { text-align: center; color: var(--text-dimmer); font-size: 12px; padding: 6px 0 18px; }

/* Landscape phones only (tablets have plenty of vertical room already, so
   this only kicks in under a fairly short viewport height) — the topbar,
   tabs and bottom bar become an overlay instead of pushing the lyrics/cover
   stage around, so hiding them lets the stage claim that space back
   automatically. Auto-hide/reveal timing lives in assets/js/player.js.
   Placed after every base .player-* rule above on purpose — same
   specificity either way, so whichever comes LAST in the file wins
   regardless of the media query, and these need to win. */
@media (orientation: landscape) and (max-height: 500px) {
    /* Many phones are wider than --maxw in landscape (a 600px cap that only
       ever mattered for portrait/desktop), which would otherwise letterbox
       the player with black bars on both sides right when the point is to
       use the full screen. */
    .player-page { max-width: none; }

    .player-chrome-top, .player-bottom-bar {
        position: absolute; left: 0; right: 0; z-index: 25;
        transition: transform 350ms ease;
    }
    .player-chrome-top { top: 0; }
    .player-bottom-bar { bottom: 0; }

    .player-page.is-immersive .player-chrome-top { transform: translateY(-100%); }
    .player-page.is-immersive .player-bottom-bar { transform: translateY(100%); }

    /* A bit more room and a little less chrome in the way makes slightly
       larger lyric text comfortable to read at this width — same
       --line-scale multiplier as the base rule, or the active/inactive
       size difference disappears in landscape. */
    .line-text { font-size: calc(clamp(19px, 6vw, 46px) * var(--line-scale, 0.92)); }

    /* The back/menu buttons (and "Now Playing" title between them) aren't
       needed once you're immersed in landscape via a plain device rotation
       — the device's own back gesture still works, and the menu is one
       rotation back to portrait away. Dropping the whole row also frees up
       more height for lyrics. .fs-landscape-locked is different: that's
       specifically the fullscreen+orientation-lock mode entered via the
       Fullscreen button (see player.js) — rotating back to portrait isn't
       an option there since orientation is deliberately locked, so back/
       menu need to stay reachable (via the same tap-to-reveal cycle as the
       rest of the chrome) as the only way out. */
    .player-topbar { display: none; }
    .player-page.fs-landscape-locked .player-topbar { display: flex; }
    /* fs-landscape-locked specifically (see above) also moves .player-tabs
       itself into this row in place of the now-hidden "Now Playing" title
       (player.js's updateTabsPlacement) — this row's own centering
       (justify-content: space-between with 3 children now) already
       positions it, it just needs the separate-row padding/margin it
       carried as .player-chrome-top's own second row stripped back out.
       min-width:0 is the fix for the actual overlap this had without it —
       .player-tabs is itself a flex container, and a nested flex
       container's default min-width is its own content's min-content
       size, which can be wider than the space actually left between the
       back/menu buttons; without overriding that down to 0 it simply
       overflowed said space and visually sat on top of the menu button
       instead of shrinking to fit. No flex-grow here on purpose — the
       pills keep their normal portrait size/spacing (no separate
       .player-tab override), sized to their own content and centered by
       .player-topbar's own justify-content:space-between, rather than
       stretching to fill the whole row. */
    .player-topbar .player-tabs { min-width: 0; padding: 0; margin: 0; }
    /* With .player-topbar gone (the plain rotation case, not
       fs-landscape-locked), these tabs become the top-most thing on
       screen — the old 2px top padding left them sitting right on the
       screen edge, so it's bumped up here along with a slightly bigger
       icon/label size. Doesn't apply once merged into the topbar above —
       that one keeps its own smaller, single-row sizing. */
    .player-chrome-top > .player-tabs { padding: 12px 0 4px; gap: 8px; }
    .player-tab { padding: 6px 15px; font-size: 13px; gap: 6px; }

    .player-meta { padding: 2px 16px; }
    .player-song-title { font-size: 15px; margin: 0; }
    .player-song-artist { font-size: 11px; }
    /* Fixed-box sizing (rather than font-size + padding) so these can be
       close to full portrait size without the row growing taller than it
       already is — the title/artist stack next to them is the tallest
       thing in this row either way, so a ~30px box fits underneath it with
       room to spare. Gap is left at the base rule's value (same 2px as
       portrait) rather than overridden here. */
    .player-heart-btn, .player-fullscreen-btn {
        width: 30px; height: 30px; padding: 0; margin-top: 0;
        display: flex; align-items: center; justify-content: center;
    }
    .player-heart-btn { font-size: 20px; }
    .player-fullscreen-btn { font-size: 18px; }

    .player-seek { padding: 2px 16px 0; }
    .seek-times { font-size: 10px; margin-top: 2px; }

    .player-controls { padding: 4px 16px; gap: 16px; }
    .player-play-btn { width: 42px; height: 42px; font-size: 16px; }
    .player-ctrl-sec { font-size: 18px; padding: 2px; }
    .player-ctrl-toggle { font-size: 14px; padding: 2px; }

    .player-queue-label { padding: 2px 0 4px; font-size: 10px; }
}

/* Desktop fullscreen (see the min-width — this deliberately doesn't touch
   phone fullscreen, which already gets its own landscape treatment above)
   — .player-page's normal 600px cap exists only to keep things phone-app
   width on an ordinary page; fullscreen has the room to spare, so let it
   use more of it and size the lyrics up to match. :fullscreen matches
   whichever element is actually fullscreen (see player.js — that's <html>,
   not .player-page, specifically so the background video — a sibling of
   .player-page — stays visible while fullscreen too) — but that only
   ever fires for the Fullscreen API (our own button). Pressing F11 (or
   an OS-level window fullscreen) is a completely different, browser-chrome-
   level feature with no API or CSS pseudo-class exposed to the page at
   all — html.window-fullscreen (added/removed by player.js's own
   innerHeight/innerWidth-vs-screen-size heuristic, since that's the only
   way left to even guess at it) is the closest a page can get to covering
   that case too. Placed after every base rule and the landscape block
   above on purpose, same reason noted there — same specificity, so
   source order is what makes this win. */
@media (min-width: 900px) {
    :fullscreen .player-page, :-webkit-full-screen .player-page, html.window-fullscreen .player-page { max-width: 950px; }
    :fullscreen .line-text, :-webkit-full-screen .line-text, html.window-fullscreen .line-text { font-size: calc(clamp(17px, 6vw, 54px) * var(--line-scale, 0.92)); }
}

.locked-banner { margin: 20px; padding: 20px; background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); text-align: center; }
.locked-banner i { font-size: 30px; color: var(--accent); margin-bottom: 10px; display: block; }

/* Slide-in menu overlay (from the original karaoke player) */
.karaoke-menu-overlay {
    position: fixed; inset: 0; background: rgba(0,0,0,0.9); z-index: 999;
    opacity: 0; visibility: hidden; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center;
}
.karaoke-menu-overlay.open { opacity: 1; visibility: visible; }
.karaoke-menu-panel { position: relative; width: 100%; max-width: 480px; max-height: 80vh; background: var(--surface); border-radius: 24px; border: 1px solid var(--border-soft); overflow: hidden; transform: scale(0.9); transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.4, 1.1); margin: 20px; }
.karaoke-menu-overlay.open .karaoke-menu-panel { transform: scale(1); }
.karaoke-menu-header { padding: 20px; border-bottom: 1px solid var(--border); text-align: center; display: flex; justify-content: center; }
.karaoke-menu-header img { max-width: 130px; height: auto; }
.karaoke-menu-items { padding: 12px 0; max-height: 55vh; overflow-y: auto; }
.karaoke-menu-item { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 14px 18px; margin: 4px 14px; background: var(--surface); border-radius: 12px; color: #ccc; border: 1px solid var(--border-soft); font-size: 14px; cursor: pointer; }
.karaoke-menu-item i { width: 18px; color: var(--text-dimmer); }
.karaoke-close-menu { position: absolute; top: 14px; right: 14px; background: var(--surface-3); border: 1px solid var(--border-soft); border-radius: 50%; width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; color: var(--text-dim); cursor: pointer; }

/* The one non-navigation item in the menu (Background Video) — its box
   holds two stacked rows (the toggle, and the opacity slider that fades
   in/out under it) instead of the single row every other item has, so it
   switches to a column layout; the base .karaoke-menu-item rule still
   supplies the shared background/border/padding/radius for the whole box. */
.karaoke-menu-item--bgvideo { flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 2px; cursor: default; }

/* One row inside that box — icon+label left-aligned instead of the
   centered group a single-row plain nav item uses; .karaoke-menu-item-
   label's flex:1 soaks up the remaining space, which is what actually
   pushes the switch to the far edge. The toggle row is a <label> wrapping
   the checkbox (see song.php) so tapping anywhere in it toggles, not just
   the small switch itself — a bigger, easier target on a phone. */
.karaoke-menu-item-row { display: flex; align-items: center; gap: 14px; padding: 6px 0; }
.karaoke-menu-item--toggle { justify-content: flex-start; cursor: pointer; }
.karaoke-menu-item-label { flex: 1; text-align: left; }

/* Generic on/off pill switch — a real checkbox under the hood (see
   song.php/player.js) for free keyboard/label support, just visually
   replaced. Reusable anywhere else a toggle is needed later. */
.toggle-switch { position: relative; display: inline-flex; width: 44px; height: 26px; flex: 0 0 auto; cursor: pointer; }
.toggle-switch input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
.toggle-switch-track { position: absolute; inset: 0; background: var(--surface-3); border: 1px solid var(--border-soft); border-radius: 999px; transition: background-color 150ms ease, border-color 150ms ease; }
/* top:50%+translateY(-50%) (not a fixed top:2px) guarantees true vertical
   centering regardless of the track's own border/box-sizing — a fixed
   pixel offset from the top alone left it reading slightly off-center. */
.toggle-switch-track::before { content: ''; position: absolute; top: 50%; left: 2px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transform: translateY(-50%); transition: transform 150ms ease; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); }
.toggle-switch input:checked + .toggle-switch-track { background: var(--accent); border-color: var(--accent); }
.toggle-switch input:checked + .toggle-switch-track::before { transform: translateY(-50%) translateX(18px); }

/* Lyrics-area opacity slider row — icon+label on their own line like the
   toggle row above, but the slider itself needs more room than that
   row's switch, so it wraps onto a second line at full width rather than
   squeezing in beside the label. Collapsed (0 height, invisible) by
   default and expanded via .is-visible (added/removed in sync with the
   Background Video toggle — see player.js) rather than the plain hidden
   attribute this used to use, so it fades smoothly instead of popping
   in/out. max-height/padding are what actually animate the collapse (a
   bare opacity fade alone would still hold its layout space while
   "invisible"); overflow:hidden keeps the slider itself from peeking out
   mid-transition. */
.karaoke-menu-item--slider {
    flex-wrap: wrap; justify-content: flex-start; gap: 6px 14px; cursor: default;
    max-height: 0; opacity: 0; padding: 0; overflow: hidden;
    transition: opacity 300ms ease, max-height 300ms ease, padding 300ms ease;
}
.karaoke-menu-item--slider.is-visible { max-height: 60px; opacity: 1; padding: 6px 0; }
.karaoke-menu-item--slider .opacity-slider { flex: 1 1 100%; }

/* Plain native <input type="range">, restyled as a solid red fill (not
   just a colored thumb) up to the current value — --fill-percent is kept
   in sync with the slider's own value by player.js on load and on every
   input event. The empty (not-yet-filled) portion is a dark, muted
   maroon rather than a plain gray, so the whole track reads as "red, at
   two different intensities" instead of "red meeting an unrelated gray".
   No border — two shades of the same color already reads clearly on its
   own without one. Set on both the plain selector (what WebKit/Blink
   actually render once -webkit-appearance:none strips their native look)
   and ::-moz-range-track (Firefox renders that pseudo-element's own
   background over the root element's, so the same gradient has to be
   repeated there too, or the fill simply wouldn't show in Firefox at all). */
.opacity-slider {
    -webkit-appearance: none; appearance: none;
    width: 100%; height: 6px; border-radius: 999px; margin: 6px 0 0;
    border: none; outline: none; cursor: pointer;
    background: linear-gradient(to right, var(--accent) 0%, var(--accent) var(--fill-percent, 25%), #3d262a var(--fill-percent, 25%), #3d262a 100%);
}
.opacity-slider::-moz-range-track {
    height: 6px; border-radius: 999px; border: none;
    background: linear-gradient(to right, var(--accent) 0%, var(--accent) var(--fill-percent, 25%), #3d262a var(--fill-percent, 25%), #3d262a 100%);
}
.opacity-slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 18px; height: 18px; border-radius: 50%; margin-top: 0;
    background: var(--accent); border: 2px solid #fff; cursor: pointer;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.opacity-slider::-moz-range-thumb {
    width: 18px; height: 18px; border-radius: 50%; border: 2px solid #fff;
    background: var(--accent); cursor: pointer; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

audio { display: none; }

@media (max-width: 380px) {
    /* .song-card no longer gets a forced height here — it now has a real
       background + the title/artist text below the cover, so pinning its
       height to the cover's 118px (as when it was just an invisible
       wrapper) would clip that text off. */
    .song-card { width: 118px; }
    .song-card-cover, .feature-card { width: 118px; height: 118px; }
    .song-card-stats { top: 118px; }
}

/* ============================================
   HOVER EFFECTS — a small, consistent nudge on every clickable element.
   Touch devices don't trigger :hover (a tap fires it then drops it on the
   next tap elsewhere), so this is purely a desktop/mouse enhancement and
   free to apply broadly. Two families: colored buttons/pills brighten
   slightly; dark icon-circle buttons lighten one shade.
   ============================================ */
.btn, .pill, .vocal-filter-btn, .bottom-nav-item, .back-btn, .home-header-actions a,
.header-icon-link, .player-topbar button, .player-topbar a, .karaoke-close-menu,
.song-card, .artist-avatar-card, .song-row, .favorite-btn, .player-heart-btn,
.player-fullscreen-btn, .player-ctrl-sec, .player-ctrl-toggle, .player-play-btn, .player-tab, .plan-card, .karaoke-menu-item {
    transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease,
        filter 150ms ease, transform 150ms ease, opacity 150ms ease, box-shadow 150ms ease;
}
/* The hover lift/glow below actually changes these child elements
   (.artist-avatar, not its .artist-avatar-card wrapper), not their card
   wrappers — they need their own transition or the transform/shadow would
   snap instantly instead of animating. A touch slower than button
   press-feedback (150ms) so the lift reads as a smooth rise. .song-card
   gets the same lift directly on itself now, so it's covered by the
   150ms transition list above already. */
.feature-card, .artist-avatar {
    transition: transform 220ms ease, box-shadow 220ms ease;
}

.btn:not([disabled]):hover { filter: brightness(1.15); }
.btn:not([disabled]):active { transform: scale(0.97); }

.pill:not(.active):hover { background: var(--surface-2); border-color: var(--text-dimmer); color: var(--text); }
.vocal-filter-btn:not(.active):hover { background: rgba(255, 255, 255, 0.08); color: var(--text); }

.back-btn:hover, .home-header-actions a:hover, .header-icon-link:hover,
.player-topbar button:hover, .player-topbar a:hover, .karaoke-close-menu:hover {
    background: var(--border-soft); color: var(--text);
}

.bottom-nav-item:not(.active):hover { color: var(--text-dim); }
@media (min-width: 860px) {
    .bottom-nav-item:not(.active):hover { background: var(--surface-3); }
}

.section-header a:hover { opacity: 0.75; }

.song-card:hover .song-card-title, .feature-card:hover .feature-card-title { color: var(--accent); }
.song-card:hover, .feature-card:hover, .artist-avatar-card:hover .artist-avatar {
    transform: translateY(-3px);
    box-shadow: 0 10px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.06), 0 0 18px rgba(255, 255, 255, 0.16);
}
.song-row:hover { background: var(--surface-2); }

.favorite-btn:hover, .player-heart-btn:hover, .player-fullscreen-btn:hover { transform: scale(1.15); }
.player-ctrl-sec:hover { transform: scale(1.1); }
.player-ctrl-toggle:not(.active):hover { color: var(--text-dim); }
.player-play-btn:hover { transform: scale(1.06); box-shadow: 0 6px 18px rgba(255, 255, 255, 0.2); }
.player-tab:not(.active):hover { background: var(--border-soft); color: var(--text); }
/* Thin border at rest, a thicker matching-color ring + lift on hover — see
   the comment above .plan-card-free for why rest/hover both carry the same
   2-layer box-shadow shape (only the numbers inside each layer change). */
.plan-card:not(.plan-card-disabled):hover { transform: translateY(-3px); }
.plan-card-free:not(.plan-card-disabled):hover { box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45), 0 0 0 2px var(--success); }
.plan-card-bronze:not(.plan-card-disabled):hover { box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45), 0 0 0 2px #c9824f; }
.plan-card-gold:not(.plan-card-disabled):hover { box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45), 0 0 0 2px #e8c468; }
.plan-card-diamond:not(.plan-card-disabled):hover { box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45), 0 0 0 2px #a78bfa; }
.plan-card-current:hover { box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45), 0 0 0 2px var(--accent); }
.karaoke-menu-item:hover { background: var(--surface-3); border-color: var(--text-dimmer); }
