/* ==========================================================================
   type!beat design system: "Caret"
   Monkeytype-lean minimal: charcoal base, a single lime accent, JetBrains Mono
   throughout, a blinking-caret motif. Dark theme only. Design imported from the
   claude.ai/design "1b Caret" direction (docs/design-1b-caret.md).

   NOTE: the accent custom-property names are still --violet* / --magenta from the
   previous "neon karaoke" theme. They now hold LIME values, so every existing
   var(--violet-bright) etc. across this file re-skins in place without renaming
   dozens of references. Read "violet" as "the accent."
   ========================================================================== */

/* ---- Fonts (self-hosted, OFL; see wwwroot/fonts/OFL-NOTICE.txt) ---- */

@font-face {
    font-family: 'JetBrains Mono';
    font-style: normal;
    font-weight: 400 800;
    font-display: swap;
    src: url('/fonts/jetbrainsmono-latin-wght.woff2') format('woff2');
}

/* ---- Tokens ---- */

:root {
    color-scheme: dark;

    /* base surfaces (charcoal, never pure black) */
    --bg: #141519;
    --bg-raised: #17181e;
    --surface: #191a20;
    --surface-2: #1f2027;
    --line: rgba(233, 234, 228, 0.10);
    --line-bright: rgba(233, 234, 228, 0.22);

    /* text */
    --text: #e9eae4;
    --text-muted: rgba(233, 234, 228, 0.55);
    --text-faint: rgba(233, 234, 228, 0.35);

    /* accent: a single lime. The names below are legacy (see header note); all
       hold shades of the one lime so var() references stay valid and on-brand. */
    --violet: #c9f24d;
    --violet-deep: #b0dd39;
    --violet-bright: #d3f66d;
    --magenta: #a9d838;

    /* readable text colour to sit ON the lime accent (buttons, avatars, badges) */
    --on-accent: #141519;

    /* semantic: lime is the positive accent; warm amber + coral for warn/bad */
    --good: #c9f24d;
    --warn: #f2c14d;
    --bad: #ff6a5a;

    /* gold: the "top of the pile" colour, apart from both the lime accent and the amber
       warn signal. It was already in this file twice over as the SS grade's literal; it is a
       token now because pp (the site's main ranking metric) wears the same gold, and the two
       must not drift into two nearly-identical golds. */
    --gold: #ffd75e;

    /* "gradient" token kept for fills that reference it; Caret is flat, so this
       is a near-flat lime (no violet→magenta sweep anymore). */
    --grad-primary: linear-gradient(90deg, var(--violet-deep), var(--violet));

    /* restrained lime glow, hover feedback only, not neon */
    --glow-violet: 0 0 16px rgba(201, 242, 77, 0.18);
    --glow-magenta: 0 0 16px rgba(201, 242, 77, 0.14);

    /* type: JetBrains Mono is the whole identity: display, body, numerals */
    --font-display: 'JetBrains Mono', ui-monospace, 'Cascadia Code', Consolas, monospace;
    --font-body: 'JetBrains Mono', ui-monospace, 'Cascadia Code', Consolas, monospace;
    --text-xs: 0.75rem;
    --text-sm: 0.875rem;
    --text-base: 1rem;
    --text-lg: 1.125rem;
    --text-xl: 1.375rem;
    --text-2xl: 1.75rem;
    --text-3xl: 2.25rem;
    --text-4xl: clamp(2.5rem, 6vw, 3.5rem);

    /* radii: FLAT. Every rounded square on the site now takes hard corners, so the three BOX radii
       below are all 0. They are kept as named tokens rather than deleted (which would mean editing
       the 40-odd `border-radius: var(--radius-*)` call sites) for the same reason the --violet*
       names above were kept: one decision, one place, reversible without touching a single
       component rule. A call site reading `var(--radius-md)` is therefore not a leftover, it is a
       box opting into whatever the site's box radius currently is, which is none.

       --radius-full is NOT a box radius and is deliberately untouched: it is the capsule value that
       status pills, filter/tab pills, count badges and progress-bar ends need in order to stay
       round. Zeroing it would square off things that were never rounded squares. Circles (avatars,
       the preview disc, the unread dot) set 50% directly and are likewise untouched. */
    --radius-sm: 0;
    --radius-md: 0;
    --radius-lg: 0;
    --radius-full: 999px;

    /* THE ONE DELIBERATE EXCEPTION to the flat rule above: the top toolbar (.site-nav) keeps a
       softened hit area on its nav links, so the bar reads as a distinct layer of chrome sitting
       over the now hard-edged page. This is intended contrast, not a stray radius that escaped the
       flattening pass: do NOT fold it back into --radius-sm.

       Scoped to chrome that ONLY the toolbar has. Shared controls that merely appear in the bar
       (.btn on sign in/settings, .bell-btn, the .user-chip capsule, the avatar circle) stay on the
       site-wide treatment, because each has a twin elsewhere on the site and the two must not
       drift apart, .bell-btn especially: the nav bell and the profile bell are one control. */
    --radius-nav: 5px;

    /* spacing */
    --space-1: 4px;
    --space-2: 8px;
    --space-3: 12px;
    --space-4: 16px;
    --space-5: 24px;
    --space-6: 32px;
    --space-7: 48px;
    --space-8: 64px;
}

/* ---- Reset-lite + base ---- */

*, *::before, *::after { box-sizing: border-box; }

html { height: 100%; }

body {
    margin: 0;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    background-color: var(--bg);
    color: var(--text);
    font-family: var(--font-body);
    font-size: var(--text-base);
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
}

/* ---- Blinking caret (the identity motif) ---- */

@keyframes tbCaret {
    0%, 45% { opacity: 1; }
    50%, 95% { opacity: 0; }
    100% { opacity: 1; }
}

/* a lime block that blinks; inline before wordmarks and mid-headline */
.caret {
    display: inline-block;
    width: 0.14em;
    height: 1em;
    vertical-align: -0.12em;
    margin: 0 0.12em 0 0;
    background: var(--violet);
    animation: tbCaret 1.1s step-end infinite;
}

.caret--tall { height: 1.05em; vertical-align: -0.16em; }

h1, h2, h3, h4 {
    font-family: var(--font-display);
    font-weight: 700;
    line-height: 1.2;
    margin: 0 0 var(--space-4);
}

h1 { font-size: var(--text-3xl); }
h2 { font-size: var(--text-2xl); }
h3 { font-size: var(--text-xl); }
h4 { font-size: var(--text-lg); }

p { margin: 0 0 var(--space-4); }

a {
    color: var(--violet-bright);
    text-decoration: none;
}

a:hover { text-decoration: underline; }

:focus-visible {
    outline: 2px solid var(--violet-bright);
    outline-offset: 2px;
    border-radius: var(--radius-sm);
}

img { max-width: 100%; }

/* ---- Layout ---- */

.container {
    width: 100%;
    max-width: 1080px;
    margin-inline: auto;
    padding-inline: var(--space-4);
}

.site-main {
    flex: 1;
    padding-block: var(--space-6);
}

/* ---- Nav bar ---- */

.site-nav {
    background: rgba(20, 21, 25, 0.88);
    backdrop-filter: blur(8px);
    border-bottom: 1px solid var(--line);
    position: sticky;
    top: 0;
    z-index: 10;
}

.nav-inner {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2) var(--space-5);
    min-height: 56px;
    padding-block: var(--space-2);
}

.wordmark {
    display: inline-flex;
    align-items: center;
    font-family: var(--font-display);
    font-weight: 700;
    font-size: var(--text-lg);
    letter-spacing: -0.02em;
    color: var(--text);
    text-decoration: none;
}

/* the "!" in the wordmark carries the accent */
.wordmark .bang { color: var(--violet); }

.wordmark:hover { text-decoration: none; color: var(--text); }

/* caret block that leads the wordmark (slightly chunkier than the inline caret) */
.wordmark .caret {
    width: 0.62ch;
    height: 1.15em;
    margin-right: 0.5ch;
    vertical-align: middle;
}

.nav-links {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-4);
}

/* The toolbar exception (see --radius-nav in :root). The rest of the site is square; these links
   are the one place that keeps a rounded hit area, on purpose. */
.nav-links a {
    color: var(--text-muted);
    font-weight: 600;
    padding: var(--space-1) var(--space-2);
    border-radius: var(--radius-nav);
}

.nav-links a:hover {
    color: var(--text);
    text-decoration: none;
}

.nav-auth {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: var(--space-3);
}

.nav-auth form { margin: 0; }

/* signed-in identity chip */
.user-chip {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: 3px var(--space-3) 3px 3px;
    border: 1px solid var(--line-bright);
    border-radius: var(--radius-full);
    background: var(--surface);
    color: var(--text);
    font-weight: 700;
}

.user-chip:hover {
    border-color: var(--violet);
    text-decoration: none;
    box-shadow: var(--glow-violet);
}

.user-chip__avatar {
    width: 26px;
    height: 26px;
    border-radius: 50%;
    object-fit: cover;
    background: var(--surface-2);
    flex-shrink: 0;
}

.user-chip__avatar--fallback {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: var(--violet);
    color: #12140c;
    font-size: 13px;
    text-transform: uppercase;
}

.user-chip__name {
    line-height: 1;
    white-space: nowrap;
}

/* ---- Footer ---- */

.site-footer {
    background: var(--bg-raised);
    border-top: 1px solid var(--line);
    padding-block: var(--space-5);
    color: var(--text-muted);
    font-size: var(--text-sm);
}

.footer-inner {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3) var(--space-5);
}

.footer-inner a { color: var(--text-muted); }
.footer-inner a:hover { color: var(--text); }

.footer-copy { margin-left: auto; color: var(--text-faint); }

/* ---- Buttons ---- */

.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-5);
    border: none;
    border-radius: var(--radius-md);
    font-family: var(--font-display);
    font-size: var(--text-base);
    font-weight: 700;
    cursor: pointer;
    text-decoration: none;
    transition: box-shadow 120ms ease, filter 120ms ease, border-color 120ms ease;
}

.btn:hover { text-decoration: none; }

.btn-primary {
    background: var(--violet);
    color: var(--on-accent);
}

.btn-primary:hover {
    filter: brightness(1.06);
    box-shadow: var(--glow-violet);
}

.btn-ghost {
    background: transparent;
    color: var(--text);
    border: 1px solid var(--line-bright);
}

.btn-ghost:hover {
    border-color: var(--violet);
    box-shadow: var(--glow-violet);
}

.btn-small {
    padding: var(--space-1) var(--space-4);
    font-size: var(--text-sm);
}

.btn-big {
    padding: var(--space-3) var(--space-7);
    font-size: var(--text-lg);
}

/* Inert stand-in where an action button would be (e.g. "available in-game only"
   on package-less pre-M3 sets): keeps the layout, refuses the interaction. */
.btn-unavailable {
    cursor: default;
    opacity: .6;
}

.btn-unavailable:hover {
    border-color: var(--line-bright);
    box-shadow: none;
}

/* Destructive ghost (reviewer Unrank): red-lined counterpart of .btn-ghost. */
.btn-danger {
    background: transparent;
    color: var(--bad);
    border: 1px solid rgba(248, 113, 113, 0.5);
}

.btn-danger:hover {
    border-color: var(--bad);
    box-shadow: 0 0 12px rgba(248, 113, 113, 0.35);
}


/* ---- Card ---- */

.card {
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    padding: var(--space-5);
}

.card-title {
    font-family: var(--font-display);
    font-size: var(--text-lg);
    font-weight: 700;
    margin: 0 0 var(--space-3);
}

/* ---- Status pill ---- */

.pill {
    display: inline-flex;
    align-items: center;
    padding: 2px var(--space-3);
    border-radius: var(--radius-full);
    font-size: var(--text-xs);
    font-weight: 800;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    background: var(--surface-2);
    color: var(--text-muted);
}

.pill--ranked {
    background: rgba(74, 222, 128, 0.14);
    color: var(--good);
}

/* Published-but-awaiting-review: amber so it reads "in the pipeline", clearly apart from
   ranked's lime and removed's coral (all can sit beside each other on an owner's profile). */
.pill--pending {
    background: rgba(242, 193, 77, 0.16);
    color: var(--warn);
}

.pill--hidden {
    background: rgba(233, 234, 228, 0.10);
    color: var(--text-muted);
}

/* Deliberately not for ranking: muted slate, published (unlike hidden) but plainly outside the
   ranked pipeline, distinct from pending's amber. */
.pill--unranked {
    background: rgba(148, 163, 184, 0.16);
    color: #b6c0cf;
}

.pill--removed {
    background: rgba(255, 106, 90, 0.16);
    color: var(--bad);
}

.pill--accent {
    background: rgba(201, 242, 77, 0.16);
    color: var(--violet-bright);
}

/* ---- Explicit-content badge ----
   osu's marker, ported to Caret: a small SQUARISH chip (deliberately not the rounded status
   pill, so it never reads as a lifecycle state) in small caps, sitting beside a set title. Muted
   coral on a dark wash: unmistakable at a glance, never louder than the title itself. */

.explicit-badge {
    display: inline-flex;
    align-items: center;
    flex: 0 0 auto;
    padding: 0 5px;
    border: 1px solid rgba(255, 106, 90, 0.35);
    border-radius: var(--radius-sm);
    background: rgba(255, 106, 90, 0.12);
    color: var(--bad);
    font-size: var(--text-xs);
    font-weight: 800;
    letter-spacing: 0.08em;
    line-height: 1.5;
    text-transform: uppercase;
    vertical-align: middle;
    white-space: nowrap;
    cursor: default;
}

/* ---- Stat chip (label + value pair) ---- */

.chip {
    display: inline-flex;
    align-items: baseline;
    gap: var(--space-2);
    padding: var(--space-1) var(--space-3);
    border: 1px solid var(--line);
    border-radius: var(--radius-md);
    background: var(--bg-raised);
    font-size: var(--text-sm);
}

.chip-label { color: var(--text-faint); font-weight: 600; }

.chip-value {
    color: var(--text);
    font-family: var(--font-display);
    font-weight: 700;
}

/* ---- Form field ---- */

.field { margin-bottom: var(--space-4); }

.field label {
    display: block;
    font-weight: 700;
    font-size: var(--text-sm);
    color: var(--text-muted);
    margin-bottom: var(--space-1);
}

.input {
    width: 100%;
    padding: var(--space-2) var(--space-3);
    background: var(--bg-raised);
    border: 1px solid var(--line-bright);
    border-radius: var(--radius-md);
    color: var(--text);
    font-family: var(--font-body);
    font-size: var(--text-base);
}

.input:focus {
    outline: none;
    border-color: var(--violet);
    box-shadow: var(--glow-violet);
}

.field-error {
    color: var(--bad);
    font-size: var(--text-sm);
    margin-top: var(--space-1);
}

.field-hint {
    color: var(--text-faint);
    font-size: var(--text-xs);
    margin-top: var(--space-1);
}

/* top-of-form error banner */
.alert-error {
    background: rgba(248, 113, 113, 0.12);
    border: 1px solid rgba(248, 113, 113, 0.4);
    border-radius: var(--radius-md);
    color: var(--bad);
    padding: var(--space-3) var(--space-4);
    margin-bottom: var(--space-4);
    font-size: var(--text-sm);
}

/* positive/neutral banner (e.g. "we sent a new code"), accent-tinted sibling of alert-error */
.alert-info {
    background: rgba(201, 242, 77, 0.10);
    border: 1px solid rgba(201, 242, 77, 0.4);
    border-radius: var(--radius-md);
    color: var(--good);
    padding: var(--space-3) var(--space-4);
    margin-bottom: var(--space-4);
    font-size: var(--text-sm);
}

/* big centered one-time-code entry on /verify */
.code-input {
    text-align: center;
    font-size: var(--text-2xl);
    letter-spacing: 0.5em;
    font-weight: 700;
    padding-inline: var(--space-3);
}

/* inline "resend" control that reads as a link, not a filled button */
.resend-form { display: inline; }
.resend-form .btn-link {
    background: none;
    border: none;
    padding: 0;
    color: var(--violet);
    font: inherit;
    cursor: pointer;
    text-decoration: underline;
}

/* ---- Table ---- */

.table-wrap {
    overflow-x: auto;
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
}

.table {
    width: 100%;
    border-collapse: collapse;
    font-size: var(--text-sm);
}

.table th {
    text-align: left;
    font-family: var(--font-display);
    font-weight: 700;
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--text-faint);
    background: var(--bg-raised);
    padding: var(--space-2) var(--space-4);
}

.table td {
    padding: var(--space-2) var(--space-4);
    border-top: 1px solid var(--line);
}

.table tbody tr:hover { background: rgba(201, 242, 77, 0.06); }

/* ---- Hero (landing) ---- */

.hero {
    text-align: center;
    padding-block: var(--space-8) var(--space-7);
}

/* lime "// ..." comment kicker above the headline */
.hero-kicker {
    color: var(--violet);
    font-size: var(--text-sm);
    font-weight: 500;
    letter-spacing: 0.04em;
    margin-bottom: var(--space-5);
}

.hero-title {
    font-size: var(--text-4xl);
    font-weight: 700;
    letter-spacing: -0.04em;
    margin-bottom: var(--space-5);
}

.hero-title .wordmark { font-size: inherit; }

/* the faded tail of the headline word ("b|eat") */
.hero-title .tail { color: var(--text-faint); }

.hero-sub {
    color: var(--text-muted);
    font-size: var(--text-lg);
    max-width: 46ch;
    margin: 0 auto var(--space-6);
}

.hero-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-3);
}

.hero-note {
    margin-top: var(--space-4);
    color: var(--text-faint);
    font-size: var(--text-sm);
}

/* footnote line at the bottom of a card (e.g. "No account yet?") */
.card-note {
    margin: var(--space-4) 0 0;
    color: var(--text-muted);
    font-size: var(--text-sm);
}

/* ---- Small helpers ---- */

.muted { color: var(--text-muted); }
.faint { color: var(--text-faint); }
.text-center { text-align: center; }

/* readable-width column for long-form text pages (DMCA, terms, ...) */
.prose { max-width: 68ch; }

.auth-card {
    max-width: 420px;
    margin-inline: auto;
}

.stack > * + * { margin-top: var(--space-4); }

/* ---- Mobile ---- */

@media (max-width: 560px) {
    .nav-inner { gap: var(--space-2) var(--space-3); }
    .nav-auth { margin-left: 0; }
    .footer-copy { margin-left: 0; width: 100%; }
    .site-main { padding-block: var(--space-5); }
}

/* ==========================================================================
   M3 Phase B: public site components
   Landing extras, the shared beatmapset card, listing filters, set page
   (header/stats/leaderboard) and the download page. Same token vocabulary
   as above; violet leads, magenta only ever rides the gradient tail.
   ========================================================================== */

/* ---- Gradient text helper (wordmark treatment for arbitrary words) ---- */

.grad-text {
    background: var(--grad-primary);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}

/* ---- Landing ---- */

/* Caret stat bar: centered, hairline top+bottom, lime numbers + muted labels */
.hero-stats {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-4) var(--space-6);
    margin: var(--space-7) auto 0;
    padding: var(--space-3) 0;
    border-top: 1px solid var(--line);
    border-bottom: 1px solid var(--line);
    color: var(--text-muted);
    font-size: var(--text-sm);
    letter-spacing: 0.02em;
}

.hero-stats strong {
    font-family: var(--font-display);
    font-weight: 700;
    color: var(--violet);
}

/* Demo reel under the stats line: a framed, self-contained gameplay clip. */
.hero-demo {
    margin: var(--space-7) auto 0;
    max-width: 900px;
}

.hero-demo__video {
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: 1280 / 719;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    box-shadow: var(--glow-violet);
}

.landing-newest { margin-top: var(--space-7); }

.landing-newest__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-4);
    margin-bottom: var(--space-4);
}

.landing-newest__head h2 { margin: 0; }

/* ---- Beatmapset card (shared partial) ---- */

.card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
    gap: var(--space-4);
}

@media (max-width: 700px) {
    .card-grid { grid-template-columns: 1fr; }
}

.bset-card {
    display: flex;
    min-height: 104px;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius-md);
    overflow: hidden;
    transition: border-color 150ms ease-in-out, box-shadow 150ms ease-in-out;
}

.bset-card:hover {
    border-color: var(--line-bright);
    box-shadow: var(--glow-violet);
}

.bset-card__media {
    position: relative;
    flex: 0 0 104px;
    background: var(--surface-2);
}

.bset-card__media img,
.bset-card__media .cover-fallback {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* no cover -> flat charcoal panel with a faint lime hairline, never a broken img */
.cover-fallback {
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--surface-2);
    box-shadow: inset 0 0 0 1px rgba(201, 242, 77, 0.08);
}

.cover-fallback::after {
    content: '_';
    color: rgba(201, 242, 77, 0.45);
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 28px;
}

.bset-card__info {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 1px;
    padding: var(--space-3) var(--space-4);
}

/* Title line: the title keeps its ellipsis truncation, the explicit badge (when present) never
   shrinks and never gets clipped away. */
.bset-card__titlerow {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-width: 0;
}

.bset-card__title {
    min-width: 0;
    font-family: var(--font-display);
    font-weight: 700;
    color: var(--text);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.bset-card__artist {
    color: var(--text-muted);
    font-size: var(--text-sm);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.bset-card__mapper {
    color: var(--text-faint);
    font-size: var(--text-xs);
}

.bset-card__stats {
    display: flex;
    gap: var(--space-4);
    margin-top: auto;
    color: var(--text-muted);
    font-size: var(--text-xs);
}

.bset-card__meta {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin-top: var(--space-1);
}

.bset-card__rail {
    display: flex;
    flex-direction: column;
    border-left: 1px solid var(--line);
    background: var(--bg-raised);
}

.bset-card__rail form {
    flex: 1;
    display: flex;
}

.rail-btn {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    border: none;
    background: transparent;
    color: var(--text-muted);
    font-size: 15px;
    cursor: pointer;
}

.rail-btn:hover {
    color: var(--violet-bright);
    background: var(--surface-2);
    text-decoration: none;
}

.rail-btn.is-on { color: var(--violet-bright); }

/* Package-less pre-M3 sets: the download slot stays (grid alignment) but is inert. */
.rail-btn--disabled {
    opacity: .35;
    cursor: default;
}

.rail-btn--disabled:hover {
    color: var(--text-muted);
    background: transparent;
}

/* Webplay rail: a third card column right of the favourite/download stack, spanning both rows
   (it is a flex child of .bset-card, so the default stretch gives it the full card height).
   Only ranked, package-and-diff-complete sets render it; every other card keeps its two-column
   geometry unchanged, so nothing collapses or reflows for them. */
.bset-card__play {
    flex: 0 0 auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    width: 56px;
    border-left: 1px solid var(--line);
    background: var(--bg-raised);
    color: var(--violet);
    text-decoration: none;
}

.bset-card__play:hover {
    color: var(--on-accent);
    background: var(--violet);
    text-decoration: none;
}

.bset-card__play-glyph { font-size: 16px; line-height: 1; }

.bset-card__play-label {
    font-family: var(--font-display);
    font-size: var(--text-xs);
    letter-spacing: 0.02em;
}

/* Narrow screens: the card is already single-column here and the media box eats 104px, so drop
   the word and keep the glyph, the rail stays a comfortable tap target without squeezing the
   title/artist lines into nothing. */
@media (max-width: 560px) {
    .bset-card__play { width: 44px; }

    .bset-card__play-label { display: none; }
}

/* ---- Preview play button (progress ring via conic-gradient + --progress) ---- */

/* A self-contained 44px play/stop disc. Deliberately NOT absolute by default: the base must be
   safe wherever it lands (the set-page action row is plain flow), so a stray/stale stylesheet can
   never let it stretch to fill, or overlay a click target across, its container. The card
   variant opts INTO absolute centring via the scoped rule below. */
.preview-btn {
    position: relative;
    flex: 0 0 auto;
    box-sizing: border-box;
    width: 32px;
    height: 32px;
    min-width: 32px;
    min-height: 32px;
    max-width: 32px;
    max-height: 32px;
    padding: 0;
    border: none;
    border-radius: 50%;
    cursor: pointer;
    background: conic-gradient(var(--violet) var(--progress, 0%), rgba(233, 234, 228, 0.14) 0);
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--text);
    transition: box-shadow 120ms ease;
}

/* Card variant only: centre the disc over the cover thumbnail. Scoped to the media box so it can
   never escape onto a page-level container. */
.bset-card__media .preview-btn {
    position: absolute;
    inset: 0;
    margin: auto;
}

.preview-btn:hover { box-shadow: var(--glow-violet); }

.preview-btn::before {
    content: '';
    position: absolute;
    inset: 2px;
    border-radius: 50%;
    background: var(--surface);
}

.preview-btn::after {
    content: '▶';
    position: relative;
    font-size: 10px;
    line-height: 1;
    padding-left: 2px;
}

.preview-btn.is-playing::after {
    content: '■';
    padding-left: 0;
}

/* set-page variant sits in the action row instead of over a cover. The base rule already handles
   layout (relative + flex-auto); crucially it must stay positioned so the ::before disc cannot
   escape onto the header. */
.preview-btn--big {
    position: relative;
    margin: 0;
}

/* ---- Listing: search + filter rows + paging ---- */

.listing-search {
    display: flex;
    gap: var(--space-3);
    margin-bottom: var(--space-4);
}

.listing-search .input { flex: 1; }

/* ---- Listing: search quick-help (info icon + hover/focus tooltip) ---- */

.search-info {
    position: relative;
    display: inline-flex;
    align-items: stretch;
}

.search-info__trigger {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    padding: 0;
    border: 1px solid var(--line-bright);
    border-radius: var(--radius-md);
    background: var(--bg-raised);
    color: var(--text-muted);
    font-family: var(--font-display);
    font-size: var(--text-lg);
    line-height: 1;
    cursor: help;
    transition: box-shadow 120ms ease, border-color 120ms ease, color 120ms ease;
}

.search-info__trigger:hover,
.search-info__trigger:focus-visible {
    color: var(--text);
    border-color: var(--violet);
    box-shadow: var(--glow-violet);
}

.search-info__tip {
    position: absolute;
    top: calc(100% + var(--space-2));
    right: 0;
    z-index: 30;
    width: max-content;
    max-width: min(24rem, calc(100vw - var(--space-5)));
    padding: var(--space-3) var(--space-4);
    border: 1px solid var(--line);
    border-radius: var(--radius-md);
    background: var(--surface-2);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
    color: var(--text-muted);
    font-size: var(--text-sm);
    text-align: left;
    opacity: 0;
    visibility: hidden;
    transform: translateY(-4px);
    transition: opacity 120ms ease, transform 120ms ease, visibility 120ms;
    pointer-events: none;
}

.search-info:hover .search-info__tip,
.search-info:focus-within .search-info__tip {
    opacity: 1;
    visibility: visible;
    transform: translateY(0);
    pointer-events: auto;
}

.search-info__tip p { margin: 0 0 var(--space-2); }
.search-info__tip p:last-child { margin-bottom: 0; }

.search-info__tip code {
    font-family: var(--font-display);
    color: var(--violet);
    background: var(--surface);
    border-radius: var(--radius-sm);
    padding: 1px 5px;
    white-space: nowrap;
}

/* ---- Listing: search-operator guide (collapsed disclosure) ---- */

.search-guide {
    margin: calc(var(--space-4) * -1) 0 var(--space-4);
    font-size: var(--text-sm);
    color: var(--text-muted);
}

.search-guide summary {
    cursor: pointer;
    color: var(--text-muted);
    padding: var(--space-1) 0;
}

.search-guide summary:hover { color: var(--text); }

.search-guide summary code {
    color: var(--violet);
    font-size: var(--text-xs);
}

.search-guide__body {
    margin-top: var(--space-3);
    padding: var(--space-4);
    border: 1px solid var(--line);
    border-radius: var(--radius-md);
    background: var(--surface);
}

.search-guide__body p { margin: 0 0 var(--space-3); }

.search-guide__table {
    border-collapse: collapse;
    width: 100%;
}

.search-guide__table td {
    padding: var(--space-1) var(--space-3) var(--space-1) 0;
    vertical-align: top;
    white-space: nowrap;
}

.search-guide__table td:nth-child(2) {
    color: var(--text-faint);
    white-space: normal;
}

.search-guide code {
    font-family: var(--font-display);
    background: var(--surface-2);
    border-radius: var(--radius-sm);
    padding: 1px 5px;
}

.search-guide__table td:first-child code { color: var(--violet); }

@media (max-width: 640px) {
    .search-guide__table td { white-space: normal; }
}

.listing-filters { margin-bottom: var(--space-5); }

.filter-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin-bottom: var(--space-2);
}

.filter-label {
    width: 56px;
    color: var(--text-faint);
    font-size: var(--text-xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.filter-pill {
    padding: 2px var(--space-3);
    border-radius: var(--radius-full);
    color: var(--text-muted);
    font-size: var(--text-sm);
    font-weight: 700;
}

.filter-pill:hover {
    color: var(--text);
    background: var(--surface-2);
    text-decoration: none;
}

.filter-pill.is-active {
    background: rgba(201, 242, 77, 0.16);
    color: var(--violet-bright);
}

.show-more {
    display: flex;
    justify-content: center;
    margin-top: var(--space-5);
}

.empty-state {
    text-align: center;
    color: var(--text-muted);
    padding: var(--space-7) var(--space-4);
}

.empty-state h3 { color: var(--text); }

/* ---- Set page: cover header + scrim ---- */

.set-header {
    position: relative;
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    overflow: hidden;
    background-size: cover;
    background-position: center;
}

.set-header--nocover {
    background: var(--surface-2);
    box-shadow: inset 0 0 0 1px rgba(201, 242, 77, 0.06);
}

.set-header__scrim {
    display: grid;
    grid-template-columns: 1fr 280px;
    gap: var(--space-5);
    padding: var(--space-6);
    background: linear-gradient(180deg, rgba(20, 21, 25, 0.62) 0%, rgba(20, 21, 25, 0.90) 100%);
}

@media (max-width: 760px) {
    .set-header__scrim { grid-template-columns: 1fr; padding: var(--space-5); }
}

.set-header__badges {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-3);
    margin-bottom: var(--space-3);
}

.set-header__counter {
    color: var(--text-muted);
    font-size: var(--text-sm);
}

.set-header__title { margin: 0 0 var(--space-1); }

.set-header__artist {
    color: var(--text-muted);
    font-size: var(--text-lg);
}

.set-header__mapper { margin-top: var(--space-1); }

.set-header__dates {
    font-size: var(--text-sm);
    margin-top: var(--space-1);
}

.set-header__actions {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-3);
    margin-top: var(--space-4);
}

.set-header__actions form { margin: 0; }

.fav-btn.is-on {
    border-color: var(--violet);
    color: var(--violet-bright);
    box-shadow: var(--glow-violet);
}

/* Reviewer-only Rank/Unrank strip: its own labelled row under the player actions so
   moderation never reads as part of the ordinary Download/Favourite cluster. */
.review-controls {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    margin-top: var(--space-4);
    padding-top: var(--space-3);
    border-top: 1px dashed var(--line-bright);
}

.review-controls form { margin: 0; }

.review-controls__label {
    font-size: var(--text-xs);
    font-weight: 800;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-faint);
}


.report-box {
    margin-top: var(--space-4);
    font-size: var(--text-sm);
    color: var(--text-muted);
}

.report-box summary { cursor: pointer; }

.report-box form, .report-box__thanks {
    margin-top: var(--space-3);
    max-width: 480px;
}

.report-box__thanks { color: var(--good); }

/* ---- Set page: stats box ---- */

.set-stats {
    position: relative;               /* anchors the visually-hidden tab radios below */
    align-self: start;
    background: rgba(25, 26, 32, 0.85);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    padding: var(--space-4) var(--space-5);
}

/* Heading + the Stats/WPM tab pair on one row, like .set-leaderboard__head does for the board
   toggle. The tabs themselves are .lb-tab's smaller sibling, same capsule, same accent. */
.set-stats__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--space-2);
}

.stat-tabs { display: inline-flex; gap: var(--space-1); }

.stat-tab {
    padding: 2px var(--space-2);
    border-radius: var(--radius-full);
    color: var(--text-muted);
    font-size: var(--text-xs);
    font-weight: 700;
    cursor: pointer;
}

.stat-tab:hover { color: var(--text); background: var(--surface-2); }

/* The state itself. Hidden but FOCUSABLE (not display:none, which would drop them out of the tab
   order): the panel swap is one :checked ~ sibling rule, so the tabs need no JavaScript and a
   visitor with scripting off still gets both panels and can still switch between them. */
.stat-tab__radio {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

#set-stats-tab-stats:checked ~ .set-stats__head .stat-tab--stats,
#set-stats-tab-pace:checked ~ .set-stats__head .stat-tab--pace {
    background: rgba(201, 242, 77, 0.16);
    color: var(--violet-bright);
}

#set-stats-tab-stats:focus-visible ~ .set-stats__head .stat-tab--stats,
#set-stats-tab-pace:focus-visible ~ .set-stats__head .stat-tab--pace {
    outline: 2px solid var(--violet-bright);
    outline-offset: 2px;
}

/* Stats is the default panel, so the pace one is the one that starts hidden: a browser that
   somehow honours none of the rules below still shows the stats box and never an empty card. */
.stat-panel--pace { display: none; }

#set-stats-tab-pace:checked ~ .stat-panel--stats { display: none; }

#set-stats-tab-pace:checked ~ .stat-panel--pace { display: block; }

/* ---- Set page: the WPM curve (the stats box's second tab) ---- */

/* CSS bars rather than an SVG: the column is 240 px wide and the curve is 100 points, so a bar is
   about two pixels and any text an SVG viewBox scaled to fit could print would be unreadable.
   Colours follow .bar-chart__bar so the two plots on the site read as one thing. */
.wpm-graph {
    display: flex;
    align-items: flex-end;
    gap: 1px;
    height: 96px;
    margin-top: var(--space-2);
    padding-top: var(--space-1);
    border-bottom: 1px solid var(--line-bright);
}

.wpm-graph__bar {
    flex: 1 1 0;
    min-width: 1px;
    border-radius: 1px 1px 0 0;
    background: var(--violet);
    opacity: 0.72;
    transition: opacity 120ms ease;
}

.wpm-graph__bar:hover { opacity: 1; }

/* The peak, the one bar worth pointing at (the figure itself is in the row underneath). */
.wpm-graph__bar.is-peak { opacity: 1; box-shadow: var(--glow-violet); }

/* A bucket no rolling window starts in: a flat stub on the baseline, in the recessive line colour
   so it can never be misread as a small value (.bar-chart__zero does the same job). */
.wpm-graph__bar.is-empty {
    height: 2px;
    background: var(--line-bright);
    opacity: 1;
}

.wpm-graph__axis {
    display: flex;
    justify-content: space-between;
    margin: var(--space-1) 0 var(--space-3);
    font-size: var(--text-xs);
}

.wpm-graph__caption {
    margin-top: var(--space-2);
    font-size: var(--text-xs);
}

.stat-row {
    display: flex;
    justify-content: space-between;
    gap: var(--space-3);
    padding: var(--space-1) 0;
    font-size: var(--text-sm);
}

.stat-row__label { color: var(--text-muted); }

.stat-row__value {
    font-family: var(--font-display);
    font-weight: 700;
}

.meter {
    height: 8px;
    margin-top: var(--space-2);
    border-radius: var(--radius-full);
    background: rgba(255, 255, 255, 0.10);
    overflow: hidden;
}

.meter__fill {
    height: 100%;
    border-radius: inherit;
    background: var(--grad-primary);
}

/* ---- Set page: description + tags ---- */

.set-body { margin-top: var(--space-5); }

.set-description {
    white-space: pre-line;           /* plain text with line breaks, no markup pipeline */
    overflow-wrap: anywhere;
    max-width: 68ch;
    margin-bottom: var(--space-4);
}

.tag-chips {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-2);
}

a.pill:hover { text-decoration: none; filter: brightness(1.2); }

/* ---- Set page: lyrics (below the leaderboard) ---- */

.set-lyrics { margin-top: var(--space-6); }

.set-lyrics__text {
    white-space: pre-line;           /* stored '\n' per lyric line, no markup pipeline */
    overflow-wrap: anywhere;
    max-width: 68ch;
}

/* ---- Set page: leaderboard ---- */

.set-leaderboard { margin-top: var(--space-6); }

/* Leaderboard heading + Ranked/Unranked board toggle on one row. */
.set-leaderboard__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--space-3);
}

.lb-tabs { display: inline-flex; gap: var(--space-2); }

.lb-tab {
    padding: 2px var(--space-3);
    border-radius: var(--radius-full);
    color: var(--text-muted);
    font-size: var(--text-sm);
    font-weight: 700;
}

.lb-tab:hover { color: var(--text); background: var(--surface-2); text-decoration: none; }

.lb-tab.is-active { background: rgba(201, 242, 77, 0.16); color: var(--violet-bright); }

.podium {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-4) var(--space-5);
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    padding: var(--space-5);
    margin-bottom: var(--space-4);
}

.podium__rank {
    font-family: var(--font-display);
    font-size: var(--text-2xl);
    font-weight: 800;
    color: var(--violet-bright);
}

.avatar-fallback {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background: var(--violet);
    color: var(--on-accent);
    font-family: var(--font-display);
    font-size: var(--text-xl);
    font-weight: 800;
    text-transform: uppercase;
}

.podium__avatar-link {
    display: inline-flex;
    flex-shrink: 0;
}

.podium__avatar-link:hover {
    text-decoration: none;
}

.podium__avatar {
    display: block;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    object-fit: cover;
    background: var(--surface-2);
}

.podium__name {
    font-family: var(--font-display);
    font-size: var(--text-lg);
    font-weight: 700;
}

.podium__stats {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-3) var(--space-5);
    margin-left: auto;
}

.podium-stat { min-width: 64px; }

.podium-stat__label {
    display: block;
    color: var(--text-faint);
    font-size: var(--text-xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.podium-stat__value {
    font-family: var(--font-display);
    font-weight: 700;
}

.table .num { font-family: var(--font-display); }

/* our own grade lettering, plain colored type, no badge artwork */
.grade {
    font-family: var(--font-display);
    font-weight: 800;
}

.grade--ss { color: var(--gold); }
.grade--s  { color: #e9eae4; }
.grade--a  { color: var(--good); }
.grade--b  { color: #5eb3ff; }
.grade--c  { color: var(--warn); }
.grade--d  { color: var(--bad); }
.grade--f  { color: var(--bad); }

/* ---- Download page ---- */

/* Download page: one card per platform (Windows / Linux / macOS). Deliberately two columns for
   three cards, so the third (macOS, emitted last in Download.cshtml) lands under Windows and the
   slot under Linux stays empty: the trio reads as an L. */
.download-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-4);
    max-width: 720px;
    margin: var(--space-5) auto 0;
}

@media (max-width: 640px) {
    .download-grid { grid-template-columns: 1fr; }
}

.download-card {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-3);
    text-align: center;
    padding: var(--space-5);
}

.download-card--featured {
    border-color: var(--violet-bright);
    box-shadow: inset 0 0 0 1px var(--violet-bright);
}

.download-card--soon { opacity: 0.72; }

.download-card__badge {
    position: absolute;
    top: calc(-1 * var(--space-2));
    right: var(--space-3);
    padding: 2px var(--space-2);
    border-radius: var(--radius-full);
    background: rgba(201, 242, 77, 0.16);
    color: var(--violet-bright);
    font-size: var(--text-xs);
    font-weight: 800;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.download-card__os { margin: 0; }

.download-card__meta { color: var(--text-muted); font-size: var(--text-sm); margin: 0; }

.download-card__mirror { margin: 0; font-size: var(--text-xs); }

.download-card__mirror a { color: var(--text-muted); text-decoration: underline; }

.download-card .btn.is-disabled {
    pointer-events: none;
    opacity: 0.6;
}

/* ==========================================================================
   Phase B3: user profile page
   ========================================================================== */

/* ---- Header: cover band + avatar + identity ---- */

.profile-header {
    margin-bottom: var(--space-5);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    overflow: hidden;
    background: var(--surface);
}

.profile-cover {
    height: 240px;
}

@media (max-width: 560px) {
    .profile-cover { height: 160px; }
}

/* preset cover bands keyed by user id: charcoal with a restrained lime edge, never neon */
.profile-cover--0 { background: linear-gradient(115deg, #17181e, #1f2027 60%, rgba(201, 242, 77, 0.20) 135%); }
.profile-cover--1 { background: linear-gradient(115deg, #191a20, #23242b 55%, rgba(201, 242, 77, 0.12) 120%); }
.profile-cover--2 { background: linear-gradient(115deg, #141519, #1f2027 60%, rgba(201, 242, 77, 0.28) 130%); }
.profile-cover--3 { background: linear-gradient(115deg, #17181e, #26272e 65%, rgba(176, 221, 57, 0.16) 115%); }

.profile-header__bar {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-3) var(--space-5);
}

.profile-avatar {
    width: 96px;
    height: 96px;
    margin-top: -48px; /* overlap the cover's bottom edge by half */
    border: 3px solid var(--surface);
    border-radius: var(--radius-md);
    object-fit: cover;
    background: var(--surface-2);
    flex-shrink: 0;
}

.profile-avatar--fallback {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: var(--violet);
    color: var(--on-accent);
    font-family: var(--font-display);
    font-size: var(--text-3xl);
    font-weight: 800;
    text-transform: uppercase;
}

@media (max-width: 560px) {
    .profile-avatar {
        width: 64px;
        height: 64px;
        margin-top: -32px;
    }

    .profile-avatar--fallback { font-size: var(--text-xl); }
}

.profile-header__name {
    margin: 0;
    font-size: var(--text-2xl);
}

/* ---- Layout: sections + stats sidebar ---- */

.profile-grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 300px;
    gap: var(--space-5);
    align-items: start;
}

@media (max-width: 860px) {
    .profile-grid { grid-template-columns: 1fr; }
    .profile-side { order: -1; } /* stats card reads first on small screens, like osu's header */
}

.profile-section { margin-bottom: var(--space-6); }

.profile-section h2 { margin: 0 0 var(--space-3); }

/* ---- Section reordering (owner only: Pages/Users/_SectionHandle.cshtml + js/profile-reorder.js) ----
   The heading row is a flex line so the control group can sit at its right edge without the
   heading losing its own margin (every section keeps the vertical rhythm it had before). */

.profile-section__head {
    display: flex;
    align-items: baseline;
    gap: var(--space-3);
}

.profile-section__head h2 { flex: 1 1 auto; }

/* Hidden until profile-reorder.js marks the column reorderable, so a browser with JS off shows no
   controls rather than dead ones. */
.section-reorder { display: none; }

.is-reorderable .section-reorder {
    display: inline-flex;
    align-items: center;
    gap: 2px;
    opacity: 0;
    transition: opacity 120ms ease;
}

/* The controls are chrome, not content: they fade in with the section they belong to, and stay up
   while one of them holds focus so a keyboard user never chases an invisible button. */
.is-reorderable .profile-section:hover .section-reorder,
.is-reorderable .profile-section:focus-within .section-reorder,
.is-reorderable [data-section-id].is-dragging .section-reorder { opacity: 1; }

/* Native HTML5 drag-and-drop does not fire for touch at all, so below the narrow breakpoint the
   whole group goes away rather than offering a grip that cannot work (osu's hidden-xs). The move
   buttons go with it: the feature is either all there or not there. */
@media (max-width: 700px) {
    .is-reorderable .section-reorder { display: none; }
}

.section-reorder__grip {
    cursor: grab;
    padding: 0 var(--space-1);
    color: var(--text-faint);
    line-height: 1;
    user-select: none;
}

.section-reorder__grip:hover { color: var(--violet); }

.section-reorder__move {
    background: none;
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    color: var(--text-faint);
    cursor: pointer;
    font-family: inherit;
    font-size: var(--text-xs);
    line-height: 1;
    padding: 2px 4px;
}

.section-reorder__move:hover {
    color: var(--violet);
    border-color: var(--line-bright);
}

/* The dragged section is its own drop preview: it travels through its siblings under the pointer,
   so the gap you see IS where it will land, and nothing has to keep a placeholder in sync.
   The selector is the data attribute, not .profile-section: the Pinned block's draggable element
   is the wrapper div that owns the #pinned anchor, and it is a section like any other here. */
[data-section-id].is-dragging,
[data-section-id].is-dragging * { cursor: grabbing; }

[data-section-id].is-dragging {
    opacity: 0.55;
    outline: 1px dashed var(--line-bright);
    outline-offset: var(--space-2);
}

.profile-reorder-status { margin: 0 0 var(--space-4); }

.profile-empty { margin: 0; }

.profile-section .show-more { margin-top: var(--space-4); }

/* ---- Bar chart (shared partial: Pages/Shared/_BarChart.cshtml) ----
   One series, so the bars wear the site accent and nothing needs a legend; every piece of TEXT
   wears a text token, never the data colour. The SVG carries a fixed viewBox and scales to the
   column, so the type scales with it (no font-size fights at breakpoints). */

.bar-chart {
    margin: 0;
}

.bar-chart__svg {
    display: block;
    width: 100%;
    height: auto;
    overflow: visible; /* the peak label lives in the top padding */
}

.bar-chart__bar {
    fill: var(--violet);
    fill-opacity: 0.72;
    transition: fill-opacity 120ms ease;
}

.bar-chart__bar:hover,
.bar-chart__bar.is-peak {
    fill-opacity: 1;
}

/* A period with no data: a flat stub on the baseline, in the recessive line colour so it can
   never be misread as a small value. */
.bar-chart__zero {
    fill: var(--line-bright);
}

.bar-chart__axis,
.bar-chart__grid {
    stroke: var(--line);
    stroke-width: 1;
}

.bar-chart__axis {
    stroke: var(--line-bright);
}

.bar-chart__tick,
.bar-chart__label {
    fill: var(--text-faint);
    font-family: var(--font-display);
    font-size: 11px;
}

.bar-chart__peak {
    fill: var(--text);
    font-family: var(--font-display);
    font-size: 12px;
}

.bar-chart__caption {
    margin-top: var(--space-2);
    font-size: var(--text-xs);
}

/* The table view: present for anyone who wants exact values (or cannot use the plot), collapsed
   so it never competes with the chart above it. */
.bar-chart__data {
    margin-top: var(--space-2);
}

.bar-chart__data > summary {
    color: var(--text-faint);
    font-size: var(--text-xs);
    cursor: pointer;
}

.bar-chart__data > summary:hover {
    color: var(--text-muted);
}

.bar-chart__table {
    margin-top: var(--space-2);
    border-collapse: collapse;
    font-size: var(--text-xs);
}

.bar-chart__table th,
.bar-chart__table td {
    padding: 2px var(--space-3) 2px 0;
    text-align: left;
    font-weight: 400;
    color: var(--text-muted);
}

.bar-chart__table thead th {
    color: var(--text-faint);
    text-transform: uppercase;
    letter-spacing: 0.08em;
}

/* ---- Stats card ---- */

.profile-rank {
    display: flex;
    flex-direction: column;
    margin-bottom: var(--space-3);
}

.profile-rank__label {
    color: var(--text-faint);
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.08em;
}

.profile-rank__value {
    font-family: var(--font-display);
    font-size: var(--text-3xl);
    font-weight: 800;
    color: var(--violet-bright);
}

/* Total pp, under the performance rank it is drawn from. Gold, like pp on the score rows, so the
   metric keeps one colour across the page; a size below the rank, because the rank is the
   headline and this is what produced it. */
.profile-rank__pp {
    font-family: var(--font-display);
    font-size: var(--text-lg);
    font-weight: 700;
    color: var(--gold);
}

.grade-counts {
    display: flex;
    justify-content: space-between;
    margin-top: var(--space-4);
    padding-top: var(--space-3);
    border-top: 1px solid var(--line);
}

.grade-count {
    display: flex;
    flex-direction: column;
    align-items: center;
    min-width: 32px;
}

.grade-count__num {
    font-family: var(--font-display);
    font-weight: 700;
    color: var(--text-muted);
}

/* ---- Score rows (Best / Recent / Most played lists) ---- */

.score-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}

.score-row {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-2) var(--space-3);
    border: 1px solid var(--line);
    border-radius: var(--radius-md);
    background: var(--surface);
}

.score-row:hover { border-color: var(--line-bright); }

.score-row__thumb {
    width: 40px;
    height: 40px;
    border-radius: var(--radius-sm);
    overflow: hidden;
    flex-shrink: 0;
}

.score-row__thumb img,
.score-row__thumb .cover-fallback {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* Title link + difficulty stacked; this column takes the row's flexible middle. */
.score-row__mapcol {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 1px;
}

/* Title line inside the map column: the link keeps its ellipsis, the explicit badge rides
   beside it instead of being pushed onto its own line by the column flex. */
.score-row__titlerow {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-width: 0;
}

.score-row__map {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--text);
    font-weight: 700;
}

.score-row__map:hover { color: var(--violet-bright); }

.score-row__artist {
    color: var(--text-muted);
    font-weight: 400;
}

/* Difficulty name, sitting under the title like the in-game scorecard. */
.score-row__diff {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--text-muted);
    font-size: var(--text-sm);
    font-weight: 400;
}

.score-row__completion,
.score-row__acc,
.score-row__score,
.score-row__plays {
    font-family: var(--font-display);
    white-space: nowrap;
}

/* Completion is the graded metric: lime; accuracy stays muted secondary. */
.score-row__completion { color: var(--violet); }
.score-row__acc { color: var(--text-muted); }

/* pp, opt-in per row (Pages/ScoreRowModel.cs, only the profile's Best scores section sets it).
   THE row's headline number, since that section is now ordered by it: gold (the same --gold the
   SS grade wears, the site's "top of the pile" colour) and a step larger than the row's other
   figures, so the eye lands on the number the position is drawn from. A 0pp play (custom rate,
   not yet priced) is real and stays visible, just faded right back so it doesn't shout the one
   thing it hasn't earned; it keeps the size so the column can't jog down the list. */
.score-row__pp {
    flex-shrink: 0;
    font-family: var(--font-display);
    font-size: var(--text-lg);
    font-weight: 700;
    color: var(--gold);
    white-space: nowrap;
}

.score-row__pp--zero {
    color: var(--text-faint);
    font-weight: 400;
}

.score-row__mods {
    color: var(--violet-bright);
    font-size: var(--text-sm);
    white-space: nowrap;
}

/* Watch count, only on the "Most viewed replays" section's rows (every row there has one, so
   nothing jogs). Quiet: the number is the section's whole point, not a competing metric. */
.score-row__views {
    flex-shrink: 0;
    font-family: var(--font-display);
    font-size: var(--text-sm);
    color: var(--text-muted);
    white-space: nowrap;
}

/* Replay download slot. Fixed width and always rendered (empty when the play has no stored
   replay) so the date/score columns don't jog left and right down the list. */
.score-row__replay {
    min-width: 52px;
    flex-shrink: 0;
    text-align: right;
    font-size: var(--text-sm);
    white-space: nowrap;
}

/* Pin/unpin slot: rendered only on your own rows (all of them, so the column can't jog), and
   deliberately styled as the same quiet text action the replay link is rather than as another
   button, so the row keeps one vocabulary. */
.score-row__pinform {
    flex-shrink: 0;
    display: flex;
}

.score-row__pin {
    min-width: 44px;
    padding: 0;
    border: 0;
    background: none;
    color: var(--text-muted);
    font: inherit;
    font-size: var(--text-sm);
    text-align: right;
    white-space: nowrap;
    cursor: pointer;
}

.score-row__pin:hover { color: var(--violet-bright); }
.score-row__pin.is-on { color: var(--violet); }

/* Mods a score used, small osu-style coloured badges (see _ModIcons.cshtml), coloured by
   mod category: red = harder, green = easier, violet = automation. */
.mod-icons {
    display: inline-flex;
    gap: 3px;
    flex-wrap: wrap;
    vertical-align: middle;
}

.mod-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 24px;
    padding: 1px 5px;
    border-radius: 0;             /* square like every other box (see the radii tokens) */
    font-family: var(--font-display);
    font-size: 11px;
    font-weight: 700;
    line-height: 1.45;
    color: #12140c;
    background: #9aa0aa;
}

.mod-icon--increase { background: #ff6b5e; }
.mod-icon--reduction { background: #9ad84f; }
.mod-icon--automation { background: #b98cff; }
.mod-icon--other { background: #9aa0aa; }

/* Rate mods (DT/NC/HT) are ranked at every speed and paid a different multiplier at each, so the
   exact rate rides on the badge in a slab welded to the acronym, matching the game's ModIcon. The
   slab deepens with --rate-intensity (0 = as good as no-mod, 1 = the slider's far end) so a 2.00x
   sprint reads darker than a 1.05x nudge before the number itself is read. It is a box inside a
   box, so it squares off with its parent .mod-icon rather than keeping a lone soft corner. */
.mod-icon--rated {
    gap: 4px;
    padding-right: 2px;
}

.mod-icon__rate {
    padding: 0 4px;
    border-radius: 0;
    background: rgba(0, 0, 0, 0.14);
    background: rgba(0, 0, 0, calc(0.08 + 0.12 * var(--rate-intensity, 0)));
    font-size: 10px;
    font-variant-numeric: tabular-nums;
}

/* Difficulty selector on the set page: one pill per difficulty, tinted by star rating. */
.diff-selector {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin-bottom: var(--space-3);
}

.diff-pill {
    display: inline-flex;
    align-items: baseline;
    gap: 6px;
    padding: 5px 10px;
    border-radius: var(--radius-md);
    border: 1px solid var(--line);
    border-left: 3px solid var(--diff-colour);
    background: var(--surface-2);
    text-decoration: none;
    font-size: var(--text-sm);
}

.diff-pill:hover { border-color: var(--diff-colour); }
.diff-pill.is-on { border-color: var(--diff-colour); box-shadow: inset 0 0 0 1px var(--diff-colour); }
.diff-pill__star { color: var(--diff-colour); font-weight: 700; font-family: var(--font-display); }
.diff-pill__name { color: var(--text); }

.score-row__date { white-space: nowrap; }

@media (max-width: 560px) {
    .score-row__date, .score-row__acc { display: none; }
}

/* ===================================================================================
   Account settings (/settings) + profile banner/description
   =================================================================================== */

.settings { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--space-5); }

.settings__head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.settings__head h1 { margin: 0; }

.settings-card { display: flex; flex-direction: column; gap: var(--space-4); }

.settings-media { display: flex; align-items: flex-start; gap: var(--space-5); flex-wrap: wrap; }

.settings-avatar {
    width: 96px; height: 96px; flex-shrink: 0;
    border-radius: var(--radius-md);
    object-fit: cover;
    background: var(--surface-2);
}

.settings-toggle {
    display: flex;
    align-items: flex-start;
    gap: var(--space-3);
    margin-bottom: var(--space-4);
    cursor: pointer;
}

.settings-toggle input {
    width: 18px;
    height: 18px;
    margin-top: 2px;
    flex-shrink: 0;
    accent-color: var(--violet);
}

.settings-toggle__text {
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.settings-avatar--fallback {
    display: inline-flex; align-items: center; justify-content: center;
    background: var(--violet);
    color: var(--on-accent);
    font-family: var(--font-display);
    font-size: var(--text-3xl);
    font-weight: 800;
    text-transform: uppercase;
}

.settings-upload { display: flex; flex-direction: column; gap: var(--space-3); flex: 1 1 260px; min-width: 240px; }
.settings-upload p { margin: 0; }

.settings-banner {
    display: block;
    width: 100%;
    aspect-ratio: 3 / 1;
    object-fit: cover;
    border-radius: var(--radius-md);
    background: var(--surface-2);
}
.settings-banner--fallback { background: linear-gradient(115deg, #141519, #1f2027 60%, rgba(201, 242, 77, 0.20) 130%); }

.settings-description { min-height: 120px; resize: vertical; }

.settings-danger { border-color: rgba(255, 106, 90, 0.40); }
.settings-danger .card-title { color: var(--bad); }

.mono { color: var(--violet); }

/* Profile: an uploaded banner replaces the preset gradient band; the "about" box holds the bio. */
/* Uploaded covers render as a full-width <img> that scales to its own aspect ratio, so the
   whole banner is visible (no crop/zoom). Height is intrinsic; the fixed 240px band only
   applies to the preset gradient covers. */
.profile-cover--image { height: auto; width: 100%; display: block; }
.profile-header__edit { margin-left: auto; align-self: center; }
.profile-about { margin-top: var(--space-4); }
.profile-about__text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-muted); }

/* ============================================================================
   /play: in-browser web player (map picker + typing gameplay).
   ========================================================================== */

.play-head { margin: var(--space-6) 0 var(--space-5); }
.play-head h1 { margin: 0 0 var(--space-2); }
.play-sub { color: var(--text-muted); margin: 0; }
.play-sub a { color: var(--violet); }

.play-empty {
    padding: var(--space-7) var(--space-4);
    text-align: center;
    color: var(--text-muted);
    border: 1px dashed var(--line-bright);
    border-radius: var(--radius-lg);
}

.play-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
    gap: var(--space-4);
    margin-bottom: var(--space-8);
}

.tb-map {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-3);
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    color: inherit;
    font-family: inherit;
    text-align: left;
    cursor: pointer;
    transition: border-color .12s ease, transform .12s ease, box-shadow .12s ease;
}
.tb-map:hover { border-color: var(--violet); transform: translateY(-2px); box-shadow: var(--glow-violet); }
.tb-map:focus-visible { outline: 2px solid var(--violet); outline-offset: 2px; }

.tb-map-cover {
    flex: 0 0 auto;
    width: 56px; height: 56px;
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--grad-primary);
    display: block;
}
.tb-map-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }

.tb-map-info { display: flex; flex-direction: column; min-width: 0; gap: 2px; }
.tb-map-title { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tb-map-artist { color: var(--text-muted); font-size: var(--text-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tb-map-stats { color: var(--violet); font-size: var(--text-xs); margin-top: 2px; }

/* ---- stage ---- */
.play-stage-wrap { position: relative; margin: var(--space-4) 0 var(--space-8); }
.play-close { position: absolute; top: var(--space-3); right: var(--space-3); z-index: 5; }

.tb-player {
    position: relative;
    background: var(--bg-raised);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    padding: var(--space-5);
    min-height: min(70vh, 620px);
    display: flex;
    flex-direction: column;
    overflow: hidden;

    /* The SONG's colour, kept distinct from the accent so the vocal sweep and the cue-in bars
       never read as the player's own caret. This is the desktop client's SungAccent (#7ec8e3,
       TypeBeatStyle), scoped to the player rather than promoted to a global token: nothing else
       on the site draws "where the music is". */
    --tb-sung: #7ec8e3;
}

.tb-hud { display: flex; gap: var(--space-6); align-items: baseline; }
.tb-hud-stat { display: flex; flex-direction: column; }
.tb-hud-val { font-size: var(--text-2xl); font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
.tb-hud-lbl { font-size: var(--text-xs); color: var(--text-muted); text-transform: lowercase; }

.tb-health { height: 4px; border-radius: var(--radius-full); background: var(--surface-2); overflow: hidden; margin: var(--space-3) 0 0; }
.tb-health-fill { height: 100%; width: 100%; background: var(--violet); border-radius: inherit; transition: width .08s linear; }
.tb-health-fill.tb-health-danger { background: var(--bad); }

.tb-stage {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-4);
    text-align: center;
    padding: var(--space-5) 0;
    user-select: none;
}
/* The 3-line lyric stack (the desktop LyricStage). Each row is its own positioning context and
   owns its sung sweep and cue-in bars; the active row additionally hosts both carets and the
   wrong-key pops. Every overlay x is therefore a plain row-local offset, and the auto-shrink
   scale applied to the row carries the glyphs and the overlays together (the client's
   per-display content scale). typebeat-player.js translates .tb-stack itself on a line change
   to reproduce the eased 220ms scroll. */
.tb-stack {
    position: relative;
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-4);
}

.tb-line {
    position: relative;
    font-family: var(--font-display);
    /* A lyric line is always ONE visual row: never soft-wrap. Wrapping split a single line
       across rows. Over-long lines are shrunk to fit by the player's fitRow() (mirrors the C#
       client's auto-shrink) rather than wrapped or clipped. */
    white-space: nowrap;
    line-height: 1.35;
    letter-spacing: 0.02em;
    /* Hold the slot even for an empty row (the first/last line of a map has no neighbour), so
       the stack never jumps and the scroll pitch stays constant. */
    min-height: 1.35em;
    transition: opacity .15s ease-out;
}
.tb-line-prev, .tb-line-next { font-size: clamp(1rem, 2.4vw, 1.4rem); color: var(--text-faint); }
.tb-line-cur { font-size: clamp(1.6rem, 4.4vw, 2.6rem); color: var(--text-muted); font-weight: 600; }

/* Dim ladder: alpha = 1 - dim, from LyricStage.relayout (a line is active) and
   relayoutUpcoming (pre-roll, or the dead zone between a seal and the next line's cue, where
   the centre line stays dimmed until it activates). */
.tb-line-prev { opacity: .3; }
.tb-line-cur  { opacity: .6; }
.tb-line-next { opacity: .4; }
.tb-stack-live .tb-line-cur { opacity: 1; }
.tb-stack-live .tb-line-next { opacity: .6; }

/* vertical-align:top, not the default baseline: a baseline-aligned inline-block leaves the
   strut's descent below it, which would make each row taller than its line-height and drift the
   scroll pitch the stack animation measures.
   position/z-index carry NO layout (a relative box with no offsets sits exactly where it did) and
   exist only to place the glyphs in the row's paint order: above the retype-selection wash and
   below every other overlay, which is where the desktop puts them (LyricLineDisplay adds its
   selection Box before the glyph sprites). Without it an absolutely positioned wash would paint
   over the characters it is supposed to sit behind, whatever the DOM order. */
.tb-cells { display: inline-block; vertical-align: top; white-space: nowrap; position: relative; z-index: 1; }

/* ---- retype selection (backlog 182) ----
   The block a Ctrl+A paints behind the characters it has offered to erase and retype. The PLAYER
   CARET's own colour at 22% alpha, ported by outcome like every desktop colour in this player
   (TypeBeatStyle.Selection is the desktop caret's yellow at 22% for the same reason): the highlight
   reads as "the caret is holding this run" rather than as a sixth character state, and it cannot be
   confused with the error red (which says a cell IS wrong) or the sung blue (which says where the
   song is). --violet (#c9f24d) inlined because rgba() cannot take a hex custom property.
   Low enough alpha that every character state above it, the untyped grey included, keeps
   its own contrast. Sits on the caret's own vertical extent and is moved by the player with the
   same row-local translateX the caret uses, so it stays registered to the glyphs through a fit or
   a resize. */
.tb-selection {
    position: absolute;
    left: 0;
    top: .16em;
    height: 1.05em;
    width: 0;
    z-index: 0;
    background: rgba(201, 242, 77, 0.22);
    pointer-events: none;
}

/* inline-block so a Perfect hit can scale the glyph in place (PlayJudgementFeedback). */
.tb-c { display: inline-block; transition: color .06s linear; }
.tb-c-todo { color: var(--text-muted); }
/* SYNC TINT (LyricLineDisplay.CorrectCharColour). A correct char is not a constant colour: it is
   filled on a continuous ramp by how in sync the keypress was, so the trail behind the caret reads
   as brightness. The player writes the ramp position into --tb-sync-fill per cell (see
   typebeat-player.js: syncTintFill / cellFill, and SYNC_TINT_FLOOR for why the floor exists and
   how it was chosen); the mix is done here so both endpoints stay the site's own design tokens
   rather than being duplicated as literals in JS.
   Two properties matter. The default of 100% means an untinted span is exactly var(--violet), the
   colour this rule shipped before the ramp existed, which is also the fallback for the (impossible)
   correct-cell-with-no-delta. And the first declaration is the pre-color-mix() fallback: a browser
   that drops the second must not fall through to the inherited .tb-line-cur grey. */
.tb-c-hit  { color: var(--violet); }
.tb-c-hit  { color: color-mix(in oklab, var(--violet) var(--tb-sync-fill, 100%), var(--text-muted)); }
.tb-c-off  { color: var(--warn); }
/* Typed WRONG (the default input model since backlog 107: the char lands in the cell instead of
   being rejected, and backspace can take it back). The desktop paints the EXPECTED glyph in its
   error red rather than showing the char the player actually pressed
   (LyricLineDisplay: TypeBeatStyle.ErrorChar), so on a lyric cell cellText() keeps returning
   cell.expected and only the colour changes. A WORD GAP is the one exception (backlog 181): its
   expected char is a space, and a space painted red is invisible, so cellGlyph() hands over the
   TYPED character instead. The lyric stack is monospaced, so that letter takes exactly the advance
   the space had and no cell after it moves. --bad is this site's own coral, which is the same
   signal in the site's palette; a wrong LYRIC cell is deliberately NOT dimmed like .tb-c-miss,
   because it is showing its own character and is something the player can still go back and fix.
   The GAP is the exception (backlog 185): its red letter stands where a space was, so a burst of
   them closes the word boundaries up into one run of text. .tb-c-wrong-gap dims that letter alone,
   additively over the colour above, which keeps it readable as an error while letting the boundary
   read as a boundary again. Between full strength and .tb-c-miss's .4, since the gap typo is still
   live and fixable rather than sealed away. */
.tb-c-wrong, .tb-line-cur .tb-c-wrong { color: var(--bad); }
.tb-c-wrong-gap { opacity: .55; }
/* Missed at seal: the desktop dims the untyped glyph (alpha 0.4) rather than recolouring it,
   which keeps a sealed line readable instead of turning it into a wall of red. */
.tb-c-miss { color: var(--text-muted); opacity: .4; }
.tb-line-cur .tb-c-todo { color: rgba(233,234,228,0.45); }
/* FREESTYLE cells: any key fills them and the pressed char is kept. They wear the desktop
   client's freestyle colour in BOTH states (shimmering placeholder while open, the player's
   own char once filled), so the slot stays recognisable on a finished line. Listed after the
   state rules, and duplicated at .tb-line-cur specificity, so the colour wins in every state.
   DELIBERATELY outside the sync tint above, matching the desktop exclusion: this violet is an
   IDENTITY signal ("this slot was free"), not a state signal, and it has to keep saying that for
   the rest of the play, so dimming it by how in sync the press was would fight the one thing it
   exists to say. Being flat (it reads no --tb-sync-fill) and later in the cascade at equal
   specificity, it beats .tb-c-hit's mix outright; cellFill() also returns null for these cells, so
   the exclusion holds even if the two rules were ever reordered. */
.tb-c-free, .tb-line-cur .tb-c-free { color: #c792ea; }

.tb-c-pop { animation: tbPop 140ms ease-out; }
@keyframes tbPop {
    from { transform: scale(1.25); }
    to   { transform: scale(1); }
}

/* ---- carets ----
   Two 3px beams straddling a cell boundary, exactly as the client draws them: the PLAYER caret
   (accent) marks where you are and blinks while idle, the SUNG caret (song colour) marks where
   the vocal is. Both are moved by the player with a damped translateX off the audio clock, so
   rushing or dragging shows up as the gap between them. */
.tb-caret {
    position: absolute;
    left: 0;
    top: .16em;
    height: 1.05em;
    width: 3px;
    margin-left: -1.5px;
    border-radius: 1px;
    pointer-events: none;
    /* Above .tb-cells, which had to be given a layer of its own for the selection wash to sit
       behind it; this (and the same on the sweep, the cue and the wrong-key layer) keeps every
       overlay painting over the glyphs exactly as it did before. */
    z-index: 2;
}
.tb-caret-player { background: var(--violet); }
.tb-caret-sung { background: var(--tb-sung); }

/* ---- sung sweep (LyricLineDisplay's underline) ----
   A faint full-line track with a fill that sweeps to the vocal position and a bright head.
   Every visible row draws its own, so a line that just sealed keeps its finished sweep while
   the upcoming one starts empty. */
.tb-sweep {
    position: absolute;
    left: 0; right: 0;
    top: 1.28em;
    height: 3px;
    background: rgba(126, 200, 227, 0.20);
    pointer-events: none;
    z-index: 2;
}
.tb-sweep-fill { position: absolute; left: 0; top: 0; height: 100%; width: 0; background: rgba(126, 200, 227, 0.60); }
.tb-sweep-glow { position: absolute; left: 0; top: 0; height: 100%; width: 6px; margin-left: -3px; background: var(--tb-sung); opacity: 0; }

/* ---- cue-in bars ----
   Two depleting bars under the upcoming line's first char: a solid one landing on the line
   BOUNDARY and a 50%-opaque one landing on the FIRST WORD (a mapper may set the boundary
   earlier, so the two can be distinct signals). Sit on the same baseline as the sweep and draw
   over it; the boundary bar is second in the DOM so it wins where they overlap. */
.tb-cue { position: absolute; left: 0; top: 1.28em; width: 0; height: 4px; pointer-events: none; z-index: 2; }
.tb-cue-bar { position: absolute; left: 0; top: 0; height: 4px; width: 0; background: var(--tb-sung); opacity: 0; }

/* ---- rejected wrong key ----
   The offending letter pops up beside the caret (alternating sides), falls away and fades; it
   never enters the line. Pure decoration, so this one animation runs on wall time. */
.tb-wrongkeys { position: absolute; left: 0; top: 0; width: 0; height: 0; pointer-events: none; z-index: 2; }
.tb-wrongkey {
    position: absolute;
    left: 0; top: 0;
    color: var(--bad);
    font-weight: 700;
    font-size: .8em;
    line-height: 1;
    animation: tbWrongKey 600ms ease-out forwards;
}
@keyframes tbWrongKey {
    0%   { transform: translate(0, 0) rotate(0deg); opacity: 1; }
    23%  { transform: translate(calc(var(--dir) * 18px), -30px) rotate(calc(var(--dir) * 7deg)); opacity: 1; }
    100% { transform: translate(calc(var(--dir) * 30px), 80px) rotate(calc(var(--dir) * 18deg)); opacity: 0; }
}

/* ---- instrumental / intro gap ----
   The desktop covers dead air with osu's skip overlay. The browser cannot seek its scheduled
   audio source without moving the gameplay clock, so a long gap gets a labelled countdown
   instead; the cue-in bars still land on the line itself. */
.tb-gap {
    /* Always laid out, only toggled visible: the stack is vertically centred, so a chip that
       came and went with display:none would nudge the lyrics every time a gap opened. */
    display: flex;
    visibility: hidden;
    align-items: center;
    gap: var(--space-3);
    font-size: var(--text-sm);
    color: var(--text-muted);
    font-variant-numeric: tabular-nums;
}
.tb-gap-on { visibility: visible; }
.tb-gap-track { width: 120px; height: 3px; border-radius: var(--radius-full); background: var(--surface-2); overflow: hidden; }
.tb-gap-fill { display: block; height: 100%; width: 0; background: var(--tb-sung); }

.tb-progress { height: 4px; border-radius: var(--radius-full); background: var(--surface-2); overflow: hidden; }
.tb-progress-fill { height: 100%; width: 0; background: var(--violet-deep); border-radius: inherit; }

.tb-meta { margin-top: var(--space-3); font-size: var(--text-sm); color: var(--text-muted); text-align: center; }
.tb-meta-title { color: var(--text); font-weight: 700; }
.tb-meta-artist { color: var(--text-muted); }

.tb-shake { animation: tbShake .18s linear; }
@keyframes tbShake {
    0%, 100% { transform: translateX(0); }
    25% { transform: translateX(-4px); }
    75% { transform: translateX(4px); }
}

/* ---- overlay (start gate + results) ---- */
.tb-overlay {
    position: absolute; inset: 0;
    display: none;
    align-items: center; justify-content: center;
    background: rgba(20, 21, 25, 0.86);
    backdrop-filter: blur(3px);
    z-index: 4;
}
.tb-overlay-on { display: flex; }
.tb-card {
    background: var(--surface);
    border: 1px solid var(--line-bright);
    border-radius: var(--radius-lg);
    padding: var(--space-6);
    text-align: center;
    max-width: 460px;
    width: min(92%, 460px);
    display: flex; flex-direction: column; gap: var(--space-3); align-items: center;
}
.tb-card-title { font-size: var(--text-2xl); font-weight: 700; }
.tb-card-sub { color: var(--text-muted); }
.tb-card-hint { color: var(--text-faint); font-size: var(--text-sm); }

.tb-btn {
    font-family: var(--font-body);
    font-size: var(--text-base);
    padding: var(--space-3) var(--space-5);
    border-radius: var(--radius-md);
    border: 1px solid transparent;
    cursor: pointer;
    transition: filter .12s ease, border-color .12s ease;
}
.tb-btn-primary { background: var(--violet); color: var(--on-accent); font-weight: 700; }
.tb-btn-primary:hover { filter: brightness(1.06); }
.tb-btn-ghost { background: transparent; border-color: var(--line-bright); color: var(--text); }
.tb-btn-ghost:hover { border-color: var(--violet); }

.tb-rank { font-size: 4rem; font-weight: 800; line-height: 1; font-family: var(--font-display); }
.tb-rank-X { color: var(--violet-bright); }
.tb-rank-S { color: var(--violet); }
.tb-rank-A { color: var(--good); }
.tb-rank-B { color: #7fd0e6; }
.tb-rank-C { color: var(--warn); }
.tb-rank-D { color: var(--text-muted); }
.tb-rank-F { color: var(--bad); }

.tb-result-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3) var(--space-4); width: 100%; margin: var(--space-2) 0; }
.tb-result-cell { display: flex; flex-direction: column; }
.tb-result-val { font-size: var(--text-lg); font-weight: 700; font-variant-numeric: tabular-nums; }
.tb-result-lbl { font-size: var(--text-xs); color: var(--text-muted); }

.tb-submit-status { min-height: 1.4em; font-size: var(--text-sm); }
.tb-submit-status a { color: var(--violet); }
.tb-status-good { color: var(--good); }
.tb-status-bad { color: var(--bad); }
.tb-status-muted { color: var(--text-muted); }

.tb-result-actions { display: flex; gap: var(--space-3); flex-wrap: wrap; justify-content: center; }

.tb-loading { padding: var(--space-8); text-align: center; color: var(--text-muted); }
.tb-loading a { color: var(--violet); }

@media (max-width: 520px) {
    .tb-hud { gap: var(--space-4); }
    .tb-hud-val { font-size: var(--text-xl); }
    .tb-player { padding: var(--space-4); }
    /* Five readouts do not fit a phone; sync is the one you can read off the sweep instead. */
    .tb-hud-sync { display: none; }
    .tb-gap-track { width: 72px; }
}

/* ==========================================================================
   Following + mapper watching (task 64)
   Appended as its own block rather than woven into the profile section above,
   so it reads as one feature and stays easy to lift back out.
   ========================================================================== */

/* ---- Profile header: follower/following counters + the two toggles ---- */

/* Counters sit under the joined/last-seen line, inside the identity column. */
.profile-header__social {
    display: flex;
    gap: var(--space-4);
    margin-top: var(--space-2);
}

.profile-social {
    display: inline-flex;
    align-items: baseline;
    gap: 6px;
    color: var(--text-muted);
    font-size: var(--text-sm);
}

.profile-social:hover { color: var(--text); text-decoration: none; }

.profile-social__num {
    font-family: var(--font-display);
    font-weight: 700;
    color: var(--text);
}

/* Right-hand action cluster (follow + bell, or the owner's own links). margin-left:auto keeps it
   pinned to the far edge exactly as the lone "edit profile" button used to be. */
.profile-header__actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin-left: auto;
    flex-wrap: wrap;
    justify-content: flex-end;
}

/* "edit profile" used to be the bar's own last child and pushed itself right with margin-left:auto
   (line ~2013). It now sits INSIDE the actions cluster, which does the pushing, so that inherited
   auto margin would only blow a gap between it and its neighbour. */
.profile-header__actions .profile-header__edit { margin-left: 0; }

/* Already-following state: the ghost button gains the accent hairline so "following" reads as an
   ON toggle rather than a second, weaker call to action. */
.btn-ghost.is-on {
    border-color: var(--violet);
    color: var(--violet-bright);
}

/* The bell. Square icon button sized to sit level with a .btn-small, dim until it is ringing. */
.bell-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    padding: 0;
    border: 1px solid var(--line-bright);
    border-radius: var(--radius-md);
    background: transparent;
    color: var(--text-muted);
    font-size: var(--text-base);
    line-height: 1;
    cursor: pointer;
    text-decoration: none; /* the signed-out variant is an <a> to /login, not a <button> */
    /* Colour emoji ignore `color`; drop the saturation instead so the OFF state reads as off. */
    filter: grayscale(1);
    opacity: 0.7;
    transition: border-color .12s ease, filter .12s ease, opacity .12s ease;
}

.bell-btn:hover {
    border-color: var(--violet);
    opacity: 1;
    text-decoration: none;
}

.bell-btn.is-on {
    border-color: var(--violet);
    background: rgba(201, 242, 77, 0.12);
    filter: none;
    opacity: 1;
}

/* ---- User rows (follower/following lists, watched-mapper strip) ---- */

/* Same rhythm as .score-list / .score-row, one row per person instead of per play. */
.user-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}

.user-row {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-2) var(--space-3);
    border: 1px solid var(--line);
    border-radius: var(--radius-md);
    background: var(--surface);
}

.user-row:hover { border-color: var(--line-bright); }

.user-row__avatar-link {
    display: inline-flex;
    flex-shrink: 0;
}

.user-row__avatar {
    display: block;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    object-fit: cover;
    background: var(--surface-2);
}

/* Initial-letter disc, the small sibling of .avatar-fallback (56px) used by the podium. */
.user-row__avatar--fallback {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: var(--violet);
    color: var(--on-accent);
    font-family: var(--font-display);
    font-size: var(--text-base);
    font-weight: 800;
    text-transform: uppercase;
}

.user-row__who {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 1px;
}

.user-row__name {
    font-family: var(--font-display);
    font-weight: 700;
}

.user-row__meta { font-size: var(--text-xs); }

.user-row__maps { flex-shrink: 0; }

/* ---- Follower / following list pages ---- */

.follow-list h1 { margin-bottom: var(--space-2); }

.follow-list > p { margin-bottom: var(--space-5); }

@media (max-width: 560px) {
    /* The identity column already wraps here; let the actions drop under it rather than squeeze. */
    .profile-header__actions { margin-left: 0; }
}

/* ---- Notification bell + dropdown (task 70) ---- */

/* The bell sits in .nav-auth next to the user chip and is the anchor for its dropdown, so it is
   the positioned ancestor the panel hangs off. */
.notif-bell {
    position: relative;
    display: inline-flex;
}

/* Reuses .bell-btn wholesale (the profile bell): same square, same dim-until-lit treatment, so
   the two bells on the site read as the same control. is-on = you have unread. */
.notif-bell__btn { position: relative; }

/* Unread count, pinned to the bell's top-right corner. Rendered only when non-zero, so there is
   no "hidden at zero" state to keep in sync. */
.notif-bell__badge {
    position: absolute;
    top: -6px;
    right: -6px;
    min-width: 18px;
    height: 18px;
    padding-inline: 5px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--radius-full);
    background: var(--violet);
    color: var(--on-accent);
    font-size: 11px;
    font-weight: 700;
    line-height: 1;
    /* The badge only ever renders when the count is non-zero, which is exactly when the bell
       carries .is-on and has therefore already dropped .bell-btn's grayscale filter. (A child
       cannot undo an ancestor's filter, so this has to hold rather than be corrected here.) */
}

.notif-panel {
    position: absolute;
    top: calc(100% + var(--space-2));
    right: 0;
    width: min(360px, calc(100vw - var(--space-5)));
    max-height: min(70vh, 520px);
    overflow-y: auto;
    padding: var(--space-2);
    background: var(--bg-raised);
    border: 1px solid var(--line-bright);
    border-radius: var(--radius-lg);
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
    /* .site-nav is z-index 10 and sticky; the panel has to clear the page under it. */
    z-index: 20;
}

.notif-panel[hidden] { display: none; }

.notif-panel__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding: var(--space-1) var(--space-2) var(--space-2);
    border-bottom: 1px solid var(--line);
}

.notif-panel__title {
    font-family: var(--font-display);
    font-weight: 700;
    font-size: var(--text-sm);
    color: var(--text-muted);
    text-transform: lowercase;
}

.notif-panel__head form { margin: 0; }

.notif-panel__readall {
    background: none;
    border: 0;
    padding: 0;
    color: var(--text-muted);
    font: inherit;
    font-size: var(--text-xs);
    cursor: pointer;
}

.notif-panel__readall:hover { color: var(--violet-bright); }

.notif-panel__empty {
    padding: var(--space-4) var(--space-2);
    margin: 0;
    font-size: var(--text-sm);
}

.notif-panel__all {
    display: block;
    padding: var(--space-2);
    border-top: 1px solid var(--line);
    text-align: center;
    font-size: var(--text-sm);
    color: var(--text-muted);
}

.notif-panel__all:hover { color: var(--violet-bright); }

/* ---- Notification rows (shared by the dropdown and /watching) ---- */

.notif-list {
    display: flex;
    flex-direction: column;
}

.notif-list form { margin: 0; }

/* The row is a submit button (it POSTs itself read, then redirects), so every button default has
   to be undone before it can look like a list item. */
.notif-row {
    width: 100%;
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-2);
    border: 0;
    border-radius: var(--radius-md);
    background: transparent;
    color: var(--text);
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.notif-row:hover { background: var(--surface-2); }

.notif-row.is-unread { background: rgba(201, 242, 77, 0.06); }

.notif-row.is-unread:hover { background: rgba(201, 242, 77, 0.12); }

.notif-row__cover {
    width: 40px;
    height: 40px;
    flex-shrink: 0;
    border-radius: var(--radius-sm);
    object-fit: cover;
    background: var(--surface-2);
}

.notif-row__cover--fallback { background: var(--grad-primary); opacity: 0.35; }

.notif-row__body {
    min-width: 0;
    flex: 1;
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.notif-row__text {
    font-size: var(--text-sm);
    line-height: 1.35;
    color: var(--text-muted);
    /* Two lines, then ellipsis: a long "artist - title" must not turn one notification into a
       paragraph inside a 360px dropdown. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.notif-row__actor,
.notif-row__subject {
    color: var(--text);
    font-weight: 700;
}

.notif-row__meta { font-size: var(--text-xs); }

.notif-row__dot {
    width: 7px;
    height: 7px;
    flex-shrink: 0;
    border-radius: 50%;
    background: var(--violet);
}

/* On /watching the same rows sit in a normal page section rather than a floating panel, so they
   get the card treatment the other sections use. */
.notif-list--page {
    gap: var(--space-1);
    padding: var(--space-2);
    border: 1px solid var(--line);
    border-radius: var(--radius-md);
    background: var(--surface);
}

.notif-page__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-3);
}

.notif-page__head h2 { margin-bottom: var(--space-3); }

.notif-page__head form { margin: 0; }

@media (max-width: 560px) {
    /* .nav-auth drops its margin-left:auto here and the nav wraps, so the bell ends up near the
       LEFT edge. A panel anchored to the bell's right edge would then hang off-screen to the
       left, so it anchors left instead and clamps to the viewport. */
    .notif-panel {
        right: auto;
        left: 0;
        width: min(360px, calc(100vw - var(--space-4)));
    }
}
