/* ── Fonts ───────────────────────────────────────────────────────────────────
   The families are requested from index.html with <link rel="preconnect"> + <link
   rel="stylesheet">, NOT with an @import here. An @import inside a stylesheet is invisible
   to the browser's preload scanner: it cannot be discovered until app.css has been fetched
   AND parsed, so the font request is serialised behind it on the critical path. A <link> in
   the head is discovered in the initial HTML scan and fetched in parallel.

   The weights requested there are also narrower than they were. Space Grotesk carried 500,
   which is declared nowhere in this app (grep font-weight: the values in use are 400, 600,
   700 and, until this pass, a synthetic 800/900), and JetBrains Mono carried 400/500/600 for
   two rules that both inherit their weight. 800 is requested now because .home__slot-vs
   genuinely wants it — it was asking for a face that was not being loaded, which the browser
   answers by smearing 700 into a faux bold. */

/* ── Box sizing ─────────────────────────────────────────────────────────────
   There was NO box-sizing rule in this file except a single local one on
   #blazor-error-ui, so every element defaulted to content-box. Any rule of the shape
   `width: 100%` + padding therefore rendered WIDER than its container, and the app
   has several of those — .po-page, .po-table--cards tr, .po-table-wrap.

   That overflow used to be invisible rather than obvious, because article.app-content
   sets `overflow-x: clip`: the 32px excess was silently cut off instead of producing a
   scrollbar. Measured before this rule, .po-page resolved to 374.67px + 32px padding =
   407px inside a 375px parent, and on /model/{id} the header rendered "#2 1217 E" with
   "LO" sliced off the right edge — unrecoverable, because a clipped box gives the user
   nothing to scroll. It also defeated .po-table--cards: the card rows still overflowed
   their own wrapper (leaderboard 368px of content in a 343px box), so the "no sideways
   scrolling on a phone" promise .po-table--cards exists to make did not hold.

   Global rather than scoped to the three offenders: this is a whole-class defect, and
   every new `width: 100%` + padding rule would reintroduce it. It is also what every
   component library assumes — Radzen included. */
*,
*::before,
*::after {
    box-sizing: border-box;
}

/* ── Design tokens ───────────────────────────────────────────────────────────
   Colour tokens are declared three times: once as the light default, once under
   prefers-color-scheme for a system-dark viewer, and once under [data-theme] so an
   explicit user choice beats the system. The [data-theme] blocks must come last —
   they win by source order, which is what lets the header toggle override the OS.
   Non-colour tokens (space, radius, motion) are theme-invariant and declared once.  */

/* Light — the default, and what a viewer with no dark preference gets. */
:root {
    --surface-base: #f7f8fb;
    --surface-1: #ffffff;
    --surface-2: #eef1f7;
    --border: #d9dee8;
    --border-strong: #c2c9d6;
    --text: #10141c;
    --text-muted: #55607a;
    /* Was #78829a — 3.84:1 on --surface-1, so every use of it failed SC 1.4.3. #646d82 fixed
       that for white but was measured only against --surface-1 and --surface-base: on
       --surface-accent (#e8edf8) it lands at 4.42:1 and still failed. #5c6478 is the first
       value that clears 4.5:1 on ALL FOUR surface tokens (5.92 / 5.57 / 5.23 / 5.04) while
       still reading a step dimmer than --text-muted. Re-measure against every surface, not
       just white, if this changes again. */
    --text-dim: #5c6478;
    --surface-accent: #e8edf8;

    /* Accents are darkened for light mode: the dark-theme values sit near 2:1 on
       white and fail SC 1.4.3 for text. Contrast is a manual check (PRD §9 item 10) —
       these were picked against --surface-1, so re-verify if that changes. */
    --accent-green: #0f7a45;
    --accent-yellow: #8a6100;
    --accent-red: #c62828;
    --accent-blue: #1560c4;
    --accent-purple: #4a5bd4;
    --accent-cyan: #0a7684;

    --glass-bg: rgba(255, 255, 255, 0.72);
    --glass-border: rgba(16, 20, 28, 0.10);
    --glass-shadow: 0 8px 32px rgba(16, 20, 28, 0.12);

    /* The blur that makes --glass-bg actually glass. The surfaces that set a translucent
       background (TokenRace, TelemetryHud, ModelCard) would otherwise read as "slightly
       see-through panel" rather than glassmorphism. Theme
       independent — a blur radius is not a colour — so it is defined once in the base :root
       and the [data-theme] blocks below are left alone. */
    --glass-blur: 14px;

    --page-gradient:
        radial-gradient(circle at 15% -5%, rgba(10, 118, 132, 0.10), transparent 36%),
        radial-gradient(circle at 88% 0%, rgba(15, 122, 69, 0.08), transparent 34%),
        linear-gradient(180deg, #ffffff 0%, #f7f8fb 40%, #eef1f7 100%);

    --on-accent-green: #ffffff;
    --on-accent: #ffffff;
    --link-hover: #0d4ea3;

    color-scheme: light;
}

/* Dark — the original OLED palette. */
@media (prefers-color-scheme: dark) {
    :root {
        --surface-base: #000000;
        --surface-1: #0a0a0a;
        --surface-2: #111111;
        --border: #1a1a1a;
        --border-strong: #2a2a2a;
        --text: #f5f7fb;
        --text-muted: #9ea8bb;
        --text-dim: #a3a3a3;
        --surface-accent: #131b2d;

        --accent-green: #2ddc84;
        --accent-yellow: #eab308;
        --accent-red: #ef4444;
        --accent-blue: #53a6ff;
        --accent-purple: #7d96ff;
        --accent-cyan: #12b8cf;

        --glass-bg: rgba(19, 27, 45, 0.7);
        --glass-border: rgba(255, 255, 255, 0.08);
        --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);

        --page-gradient:
            radial-gradient(circle at 15% -5%, rgba(18, 184, 207, 0.18), transparent 36%),
            radial-gradient(circle at 88% 0%, rgba(45, 220, 132, 0.1), transparent 34%),
            linear-gradient(180deg, #020306 0%, #000000 40%, #03050a 100%);

        --on-accent-green: #000000;
        --on-accent: #000000;
        --link-hover: #60a5fa;

        color-scheme: dark;
    }
}

/* Explicit user choice — last, so it overrides the system preference in both directions. */
:root[data-theme="light"] {
    --surface-base: #f7f8fb;
    --surface-1: #ffffff;
    --surface-2: #eef1f7;
    --border: #d9dee8;
    --border-strong: #c2c9d6;
    --text: #10141c;
    --text-muted: #55607a;
    /* Was #78829a — 3.84:1 on --surface-1, so every use of it failed SC 1.4.3. #646d82 fixed
       that for white but was measured only against --surface-1 and --surface-base: on
       --surface-accent (#e8edf8) it lands at 4.42:1 and still failed. #5c6478 is the first
       value that clears 4.5:1 on ALL FOUR surface tokens (5.92 / 5.57 / 5.23 / 5.04) while
       still reading a step dimmer than --text-muted. Re-measure against every surface, not
       just white, if this changes again. */
    --text-dim: #5c6478;
    --surface-accent: #e8edf8;

    --accent-green: #0f7a45;
    --accent-yellow: #8a6100;
    --accent-red: #c62828;
    --accent-blue: #1560c4;
    --accent-purple: #4a5bd4;
    --accent-cyan: #0a7684;

    --glass-bg: rgba(255, 255, 255, 0.72);
    --glass-border: rgba(16, 20, 28, 0.10);
    --glass-shadow: 0 8px 32px rgba(16, 20, 28, 0.12);

    --page-gradient:
        radial-gradient(circle at 15% -5%, rgba(10, 118, 132, 0.10), transparent 36%),
        radial-gradient(circle at 88% 0%, rgba(15, 122, 69, 0.08), transparent 34%),
        linear-gradient(180deg, #ffffff 0%, #f7f8fb 40%, #eef1f7 100%);

    --on-accent-green: #ffffff;
    --on-accent: #ffffff;
    --link-hover: #0d4ea3;

    color-scheme: light;
}

:root[data-theme="dark"] {
    --surface-base: #000000;
    --surface-1: #0a0a0a;
    --surface-2: #111111;
    --border: #1a1a1a;
    --border-strong: #2a2a2a;
    --text: #f5f7fb;
    --text-muted: #9ea8bb;
    --text-dim: #a3a3a3;
    --surface-accent: #131b2d;

    --accent-green: #2ddc84;
    --accent-yellow: #eab308;
    --accent-red: #ef4444;
    --accent-blue: #53a6ff;
    --accent-purple: #7d96ff;
    --accent-cyan: #12b8cf;

    --glass-bg: rgba(19, 27, 45, 0.7);
    --glass-border: rgba(255, 255, 255, 0.08);
    --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);

    --page-gradient:
        radial-gradient(circle at 15% -5%, rgba(18, 184, 207, 0.18), transparent 36%),
        radial-gradient(circle at 88% 0%, rgba(45, 220, 132, 0.1), transparent 34%),
        linear-gradient(180deg, #020306 0%, #000000 40%, #03050a 100%);

    --on-accent-green: #000000;
    --on-accent: #000000;
    --link-hover: #60a5fa;

    color-scheme: dark;
}

:root {
    /* Animation Tokens */
    --transition-fast: 150ms ease;
    --transition-medium: 300ms ease;
    --spring-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);

    /* ── Spacing scale ───────────────────────────────────────────────────
       Extended from five steps to nine. The original scale jumped 0.25 → 0.5 → 1,
       which left no token for the gaps people actually needed, so 28 distinct raw
       rem values grew up alongside it (0.28, 0.35, 0.375, 0.45, 0.55, 0.6, 0.65,
       0.7, 0.75, 0.8, 0.85 — most of them visually indistinguishable from each
       other). The in-between steps below are the ones that were being hand-rolled;
       adding them is what makes "use a token" a realistic instruction rather than
       advice everyone has to ignore. */
    --space-2xs: 0.125rem;
    --space-xs: 0.25rem;
    --space-sm: 0.5rem;
    --space-smd: 0.75rem;
    --space-md: 1rem;
    --space-mlg: 1.25rem;
    --space-lg: 1.5rem;
    --space-xl: 2rem;
    --space-2xl: 3rem;

    /* A single-column form (Settings): wide enough for a label, a hint and its control on
       one row, narrow enough that the control stays near the words it belongs to. */
    --measure-form: 45rem;

    /* ── Typography scale ────────────────────────────────────────────────
       There were no typography tokens at all, and 31 distinct font-size values —
       thirteen of them inside the 0.7–0.95rem band (0.7, 0.72, 0.74, 0.75, 0.78,
       0.8, 0.8125, 0.82, 0.85, 0.87, 0.875, 0.9, 0.95). Nobody can see the
       difference between 0.72rem and 0.75rem; what they produce is a page where
       nothing lines up on a vertical rhythm and every new surface invents its own
       sizes. Seven steps, each a visible increment from the last. */
    --text-2xs: 0.6875rem;   /* 11px — badges, seed numbers, table micro-labels */
    --text-xs:  0.75rem;     /* 12px — metadata, captions, timestamps */
    --text-sm:  0.8125rem;   /* 13px — secondary body, hints, table cells */
    --text-md:  0.875rem;    /* 14px — body default */
    --text-lg:  1rem;        /* 16px — emphasised body, section titles */
    /* The top four steps are fluid. Below --text-xl the scale is UI chrome — table cells,
       badges, captions — where a size that moves with the viewport just makes dense layouts
       unpredictable, so those stay fixed. From --text-xl up the text is display copy, where
       a fixed size is either cramped at 1400px or overflowing at 360px.

       Each clamp keeps the previous fixed value as its MAXIMUM, so nothing gets larger than
       it was on a wide screen and the only change is that small screens now scale down. The
       preferred term is `rem + vw` rather than pure vw so the text still honours the user's
       browser font size (a pure-vw heading ignores it entirely and fails SC 1.4.4). */
    --text-xl:  clamp(1.125rem, 1.05rem + 0.35vw, 1.25rem);   /* 18 → 20px — card and panel headings */
    --text-2xl: clamp(1.375rem, 1.19rem + 0.8vw, 1.625rem);   /* 22 → 26px — page titles */
    --text-3xl: clamp(1.75rem, 1.34rem + 1.8vw, 2.25rem);     /* 28 → 36px — hero */
    --text-4xl: clamp(2.5rem, 1.65rem + 3.7vw, 3.5rem);       /* 40 → 56px — the 404 numeral */

    /* Line heights. Tight for headings, open for prose — a single value across
       both is what makes dense tables feel airy and paragraphs feel cramped. */
    --leading-tight: 1.2;
    --leading-snug: 1.35;
    --leading-normal: 1.55;

    --weight-normal: 400;
    --weight-medium: 500;
    --weight-semibold: 600;
    --weight-bold: 700;
    /* Home's "vs" between the slots — the one 800 in the app, and the face index.html
       already loads for it; it was a raw value until a token existed to point at. */
    --weight-extrabold: 800;

    /* ── Breakpoints ─────────────────────────────────────────────────────
       Documented values, NOT custom properties: a custom property is illegal
       inside a @media condition (`@media (max-width: var(--bp-md))` does not
       work), and @custom-media has no native support. So these are a convention
       enforced by review, and they are listed here because that is the only place
       a convention can live.

           --bp-sm   640px   phone      → single column, tables become cards
           --bp-md  1024px   tablet     → two columns collapse to one
           --bp-lg  1400px   wide       → max content width reached

       Before this, 13 different breakpoints were in use (360, 420, 575, 720, 760,
       768, 820, 900, 1000, 1023, 1080, 1200, 1360, 1400) and eight stylesheets had
       none at all. New media queries use one of the three above unless there is a
       specific reason written next to it.

       AUDIT 2026-09-02: the convention had drifted back. --bp-md was in use ZERO
       times — the actual set was 640 (widespread), 1080 (Home only) and 1400
       (ModelProfile only), so every width between phone and 1400px got the desktop
       layout with no adaptation, and seven scoped stylesheets had no media query at
       all (ModelCard, SandboxedViewport, MainLayout, Archive, Leaderboard, Login,
       NotFound). Those now use 1024. Home keeps 1080 as its documented exception —
       its picker-plus-console grid is genuinely tight below that — and NavMenu keeps
       the Bootstrap-legacy 767.98/991.98/1279.98 values, where the fractional part is
       load-bearing against paired min-width rules.

       Prefer a container query to a media query for anything inside a component that
       renders at more than one width: see TelemetryHud, ModelProfile and SandboxedViewport,
       which measure their own box rather than the window. */

    /* ── Chrome ──────────────────────────────────────────────────────────
       The height of the sticky top nav. It was referenced by the comment on `.content`
       ("a descendant that needs a floor must use calc(100dvh - var(--nav-height))") and
       by nothing else, because it had never been declared — so the one calc the codebase
       tells you to write would have resolved to an invalid length and been dropped. The
       value is .navmenu's own min-height; the two must stay in step, which is why the
       stylesheet now reads this token rather than restating 3.5rem. */
    --nav-height: 3.5rem;

    /* Border Radius Scale */
    --radius-sm: 0.375rem;
    --radius-md: 0.75rem;
    --radius-lg: 1rem;
    --radius-full: 9999px;
}

/* ── Status tints ─────────────────────────────────────────────────────────────
   Derived rather than declared: each is a mix of an accent with the current
   surface, and both inputs are already themed, so one definition covers light and
   dark and there is no fourth copy to keep in sync. Components used to hardcode
   these (a literal #052e16 for a success panel, #1f0a0a for a failure card), which
   is exactly what made light mode unreadable.

   Pairing rule: -surface as the background, -border as the border, and the plain
   --accent-* as the text on top. Those accents are already contrast-checked
   against --surface-1 (see the light block above), and mixing 12% of a hue into a
   surface moves it far too little to disturb that. */
:root {
    --success-surface: color-mix(in srgb, var(--accent-green) 12%, var(--surface-1));
    --success-border:  color-mix(in srgb, var(--accent-green) 38%, var(--surface-1));

    --warn-surface:    color-mix(in srgb, var(--accent-yellow) 12%, var(--surface-1));
    --warn-border:     color-mix(in srgb, var(--accent-yellow) 38%, var(--surface-1));

    --danger-surface:  color-mix(in srgb, var(--accent-red) 12%, var(--surface-1));
    --danger-border:   color-mix(in srgb, var(--accent-red) 38%, var(--surface-1));

    --info-surface:    color-mix(in srgb, var(--accent-blue) 12%, var(--surface-1));
    --info-border:     color-mix(in srgb, var(--accent-blue) 38%, var(--surface-1));

    --violet-surface:  color-mix(in srgb, var(--accent-purple) 12%, var(--surface-1));
    --violet-border:   color-mix(in srgb, var(--accent-purple) 38%, var(--surface-1));

    /* Added when the hand-rolled tints were folded in: the two cyan accents on Home (and the
       since-removed nav ticker) had no token to move to, which is part of why they were
       hand-rolled in the first place. */
    --cyan-surface:    color-mix(in srgb, var(--accent-cyan) 12%, var(--surface-1));
    --cyan-border:     color-mix(in srgb, var(--accent-cyan) 38%, var(--surface-1));
}

/* ── Why these tints are OPAQUE, and the failure that made it matter ──────────
   Each --*-surface mixes an accent into --surface-1, which is a solid colour. The result is
   solid too, so the contrast of --accent-* on top of it is the same wherever the chip lands:
   on a card, on the nav bar, over the aurora. Measured in light theme it is 4.53 (cyan) to
   5.03 (blue); in dark, 4.78 (red) to 9.18 (green). All clear SC 1.4.3.

   Around twenty surfaces used to hand-roll the same effect as a TRANSLUCENT literal instead —
   `rgba(45, 220, 132, 0.08)`, `color-mix(in srgb, var(--accent-cyan) 12%, transparent)` — and
   that broke the guarantee twice over. A translucent tint composites against whatever is
   behind it, so the same chip measured differently on the nav (--surface-base) than on a card
   (--surface-1): the old nav ticker pills landed at 4.26 and 4.45 against the nav, both under 4.5,
   while the identical treatment passed elsewhere. And every one of those literals was the
   DARK-theme accent value frozen into the stylesheet, so a light-theme viewer got a dark
   palette's hue behind light-palette text — the tokens exist precisely so a colour cannot be
   picked in one theme and shipped to both.

   So: a status tint is --*-surface for the fill, --*-border for the edge, and the plain
   --accent-* for the text. Do not reintroduce an rgba() literal for one. */

/* ── Modern Animations ────────────────────────────────────────────────────────── */
@keyframes fadeIn {
    from { opacity: 0; transform: translateY(8px); }
    to { opacity: 1; transform: translateY(0); }
}

@keyframes slideUp {
    from { opacity: 0; transform: translateY(20px); }
    to { opacity: 1; transform: translateY(0); }
}

@keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.5; }
}

@keyframes shimmer {
    0% { background-position: -200% 0; }
    100% { background-position: 200% 0; }
}

@keyframes scaleIn {
    from { opacity: 0; transform: scale(0.95); }
    to { opacity: 1; transform: scale(1); }
}

@keyframes glow {
    0%, 100% { box-shadow: 0 0 5px var(--accent-green), 0 0 10px rgba(34, 197, 94, 0.3); }
    50% { box-shadow: 0 0 10px var(--accent-green), 0 0 20px rgba(34, 197, 94, 0.5); }
}

@keyframes float {
    0%, 100% { transform: translateY(0); }
    50% { transform: translateY(-5px); }
}

/* ── Skeleton Loading Styles ───────────────────────────────────────────────────── */
.skeleton {
    background: linear-gradient(
        90deg,
        var(--surface-accent) 25%,
        var(--surface-2) 50%,
        var(--surface-accent) 75%
    );
    background-size: 200% 100%;
    animation: shimmer 1.5s infinite;
    border-radius: var(--radius-sm);
    border: 1px solid var(--glass-border);
}

/* `clip`, not `hidden`. Both stop a stray wide child from producing a horizontal scrollbar,
   but `hidden` makes the element a scroll container — and since html's overflow propagates to
   the viewport, body kept its own, computing overflow-y to `auto`. That gave body a scrollport
   that never scrolls (the viewport does), so every `position: sticky` descendant silently
   froze at its static position. `clip` clips without creating a scroll container, which is
   exactly what it was added for; article.app-content already uses it for the same reason. */
html, body {
    font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
    background-color: var(--surface-base);
    color: var(--text);
    margin: 0;
    padding: 0;
    overflow-x: clip;
}

body {
    background-image: var(--page-gradient);
    background-attachment: fixed;
}

h1, h2, h3, h4, h5, h6 {
    color: var(--text);
    font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
    letter-spacing: -0.015em;
}

h1:focus {
    outline: none;
}

a {
    color: var(--accent-blue);
}

a:hover {
    color: var(--link-hover);
}

/* ── Buttons and tables ──────────────────────────────────────────────────────
   These two primitives replace Radzen's RadzenButton and RadzenDataGrid, which
   were the library's only use across the whole app. They live here rather than in
   a .razor.css because five pages need them and scoped CSS is per-component.
   Styled from the design tokens, so they follow the theme toggle for free.

   .po-btn is the ONLY button primitive in the app. Twelve per-surface button
   classes (home__btn, arena__action-btn, lab__btn, h2h__btn, …) each
   reimplemented it locally and drifted apart; they were folded in here. A surface
   that needs a tweak adds a layout-only class ALONGSIDE .po-btn (width, margin,
   grid placement) — it does not restate padding, colour or border. New visual
   variants belong here as a --modifier, not in a .razor.css. */

.po-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-sm);
    min-height: var(--target-min);
    padding: var(--space-sm) var(--space-md);
    font: inherit;
    font-size: var(--text-md);
    font-weight: 600;
    line-height: 1.2;
    color: var(--text);
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    cursor: pointer;
    text-decoration: none;
    transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}

.po-btn:hover:not(:disabled) {
    background: var(--surface-accent);
    border-color: var(--border-strong);
    color: var(--text);
}

.po-btn:disabled,
.po-btn[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
}

.po-btn--lg {
    padding: var(--space-sm) var(--space-mlg);
    font-size: var(--text-lg);
}

/* Compact form for nav chrome and in-card action rows. Shrinks the padding, not the
   hit area — so it reads through --target-min and grows to 44px on a touch device
   along with everything else. Hard-coding 24px here (as it used to) meant the compact
   buttons, which are exactly the ones in the densest rows, stayed finger-hostile. */
.po-btn--sm {
    min-height: var(--target-min);
    padding: var(--space-xs) var(--space-smd);
    font-size: var(--text-sm);
}

/* Full-width inside a card or a stacked action column. */
.po-btn--block {
    width: 100%;
}

/* --on-accent, not #fff. The literal was measured at 2.54:1 in dark mode — white on
   --accent-blue, which is #53a6ff there — so the app's PRIMARY button failed SC 1.4.3 on
   every dark-theme surface that carries one. --on-accent already exists for exactly this,
   already flips to black in dark, and was already being read by --success and by the model
   card's type chip; this variant simply was not using it. Black on #53a6ff is 8.27:1, and
   white on the light palette's #1560c4 stays at 6.00:1. */
.po-btn--primary {
    background: var(--accent-blue);
    border-color: var(--accent-blue);
    color: var(--on-accent);
}

.po-btn--primary:hover:not(:disabled) {
    background: var(--link-hover);
    border-color: var(--link-hover);
    color: var(--on-accent);
}

.po-btn--success {
    background: var(--accent-green);
    border-color: var(--accent-green);
    color: var(--on-accent-green);
}

/* The filled and tinted variants restate their own background on hover. The base hover rule
   above swaps in --surface-accent at the same specificity, so a variant that only set
   `filter` and `color` kept its text colour and lost its fill — white on near-white for
   --success, which is a button that goes blank the moment the pointer reaches it. */
.po-btn--success:hover:not(:disabled) {
    background: var(--accent-green);
    border-color: var(--accent-green);
    filter: brightness(1.12);
    color: var(--on-accent-green);
}

.po-btn--secondary {
    background: transparent;
    border-color: var(--border-strong);
    color: var(--text-muted);
}

.po-btn--secondary:hover:not(:disabled) {
    color: var(--text);
}

/* Tinted, not filled — for links out of an empty state and other low-emphasis
   affirmatives that would over-shout as --success. */
.po-btn--ghost {
    background: var(--success-surface);
    border-color: var(--success-border);
    color: var(--accent-green);
}

.po-btn--ghost:hover:not(:disabled) {
    background: var(--success-surface);
    border-color: var(--success-border);
    filter: brightness(1.08);
    color: var(--accent-green);
}

.po-btn--warn {
    background: var(--warn-surface);
    border-color: var(--warn-border);
    color: var(--accent-yellow);
}

.po-btn--warn:hover:not(:disabled) {
    background: var(--warn-surface);
    border-color: var(--warn-border);
    filter: brightness(1.08);
    color: var(--accent-yellow);
}

/* ── Filter chip ──────────────────────────────────────────────────────────
   A pill that toggles a list filter. There were FOUR implementations of this one control:
   Home's type filters (.po-btn + --success when active), Archive's .archive__filter-chip,
   PromptPicker's .prompt-picker__chip, and the RadzenDataGrid's own per-column filter row.
   The three hand-rolled ones disagreed on shape (pill vs --radius-sm), on what "active"
   looks like (filled green vs a coloured outline) and, more seriously, on whether the state
   reached assistive technology at all: only PromptPicker set aria-pressed, so on Home and
   Archive the selected filter was conveyed by colour alone (SC 1.4.1, SC 4.1.2).

   This is the same consolidation .po-btn itself came from, so it follows the same rule: a
   modifier here, not a class per surface, and every caller is a real <button> carrying
   aria-pressed. The active state changes BOTH fill and border weight, so it survives a
   greyscale screenshot and prefers-contrast: more. */
.po-btn--chip {
    border-radius: var(--radius-full);
    padding: var(--space-xs) var(--space-smd);
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    color: var(--text-muted);
    background: var(--surface-1);
}

.po-btn--chip:hover:not(:disabled) {
    color: var(--text);
    background: var(--surface-2);
    border-color: var(--border-strong);
}

.po-btn--chip[aria-pressed="true"] {
    color: var(--accent-green);
    background: var(--success-surface);
    border-color: var(--accent-green);
    /* The second visual channel. Colour alone would leave the active chip indistinguishable
       from the rest for a viewer who cannot separate the two hues. */
    box-shadow: inset 0 0 0 1px var(--accent-green);
}

.po-btn--chip[aria-pressed="true"]:hover:not(:disabled) {
    color: var(--accent-green);
    background: var(--success-surface);
    border-color: var(--accent-green);
}

/* Data table — semantic <table>, horizontally scrollable inside .po-table-wrap. */
.po-table-wrap {
    width: 100%;
    overflow-x: auto;
}

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

.po-table th {
    text-align: left;
    font-weight: var(--weight-semibold);
    font-size: var(--text-2xs);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--text-muted);
    padding: var(--space-sm) var(--space-smd);
    border-bottom: 1px solid var(--border-strong);
    white-space: nowrap;
}

.po-table td {
    padding: var(--space-sm) var(--space-smd);
    border-bottom: 1px solid var(--border);
    color: var(--text);
    vertical-align: middle;
}

.po-table tbody tr:last-child td {
    border-bottom: none;
}

/* Only grids whose rows are actually clickable opt into the affordance. */
.po-table--selectable tbody tr {
    cursor: pointer;
}

.po-table--selectable tbody tr:hover td {
    background: var(--surface-2);
}

.po-table--selectable tbody tr:focus-visible {
    outline: 2px solid var(--accent-blue);
    outline-offset: -2px;
}

/* ── Layout ──────────────────────────────────────────────────────────────── */

.content {
    background-color: transparent;
    color: var(--text);
    /* NO min-height here — and this is a bug fix, not a simplification. This article sits
       INSIDE <main>, which already begins below the 56px sticky nav. A `min-height: 100dvh`
       therefore made every page 56px taller than the viewport (72px once this padding-top
       was added), which measured as scrollHeight 972 against innerHeight 900 on all six
       routes: a scrollbar on pages that fitted perfectly well.

       The viewport-height guarantee belongs one level up and is already there: `.page` is
       min-height: 100dvh with main as its flex: 1 child, so the two together fill the
       viewport exactly once and no descendant needs to restate it. If you ever do need a
       floor here, it must be calc(100dvh - var(--nav-height)) — never a bare 100dvh. */
}

.app-content > * {
    animation: fadeIn 240ms ease-out;
}

/* ── Blazor error UI ──────────────────────────────────────────────────────── */

#blazor-error-ui {
    background: #1a0000;
    border-top: 1px solid var(--accent-red);
    color: var(--accent-red);
    bottom: 0;
    box-shadow: 0 -1px 2px rgba(239, 68, 68, 0.3);
    box-sizing: border-box;
    display: none;
    left: 0;
    padding: var(--space-sm) var(--space-mlg) var(--space-smd) var(--space-mlg);
    position: fixed;
    width: 100%;
    z-index: 1000;
}

    #blazor-error-ui .dismiss {
        cursor: pointer;
        position: absolute;
        right: 0.75rem;
        top: 0.5rem;
    }

.loading-progress {
    position: absolute;
    display: block;
    width: 8rem;
    height: 8rem;
    inset: 20vh 0 auto 0;
    margin: 0 auto 0 auto;
}

    .loading-progress circle {
        fill: none;
        stroke: #e0e0e0;
        stroke-width: 0.6rem;
        transform-origin: 50% 50%;
        transform: rotate(-90deg);
    }

        .loading-progress circle:last-child {
            stroke: var(--accent-blue);
            stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
            transition: stroke-dasharray 0.05s ease-in-out;
        }

.loading-progress-text {
    position: absolute;
    text-align: center;
    font-weight: bold;
    inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
}

    .loading-progress-text:after {
        content: var(--blazor-load-percentage-text, "Loading");
    }

code {
    color: var(--accent-yellow);
}

/* ── Dev-mode error panel (T087) ──────────────────────────────────────────── */

.dev-error-panel {
    background-color: #1a0000;
    border: 2px solid var(--accent-red);
    border-radius: 0.5rem;
    color: var(--accent-red);
    margin-top: var(--space-xl);
    padding: var(--space-lg);
}

.dev-error-panel h4 {
    color: var(--accent-red);
    margin-bottom: var(--space-smd);
}

.dev-error-panel pre {
    background-color: #0d0000;
    border-radius: 0.25rem;
    color: #ff9999;
    font-size: var(--text-xs);
    margin-top: var(--space-sm);
    overflow-x: auto;
    padding: var(--space-smd);
    white-space: pre-wrap;
    word-break: break-all;
}

.prod-error-panel {
    background-color: var(--surface-1);
    border: 1px solid var(--border);
    border-radius: 0.5rem;
    color: var(--text-muted);
    margin-top: var(--space-xl);
    padding: var(--space-lg);
    text-align: center;
}

/* ── Accessibility — WCAG 2.2 Level AA ────────────────────────────────────── */

:root {
    --focus-ring: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-blue);
    --focus-ring-offset: 2px;
    /* Sticky topnav is 3.5rem tall; keep focused targets clear of it (SC 2.4.11). */
    --sticky-nav-clearance: 4.5rem;
}

/* The nav is one 3.5rem row at every width since 2026-10-26. Below 768px it used to be two
   (6.25rem), and this block raised both tokens to match; NavMenu.razor.css now fits the
   brand mark, four links and the identity initial on a 360px bar. */

:root {
    /* SC 2.5.8 Target Size (Minimum) — 24px is the AA requirement, and it is a FLOOR,
       not a target. It is the right value for a mouse, which is a pixel-accurate
       pointing device; it is much too small for a fingertip, which is roughly 9mm of
       contact area with no visual feedback about where its centre landed. */
    --target-min: 24px;
}

/* Touch and pen get 44px — the size Apple's HIG and Material both specify, and what
   SC 2.5.5 (Target Size (Enhanced), AAA) asks for. Gated on the POINTER, not on a
   width breakpoint, because the two are not the same question: a touchscreen laptop
   at 1400px needs the large target and a 500px-wide desktop browser window does not.
   Raising the token lifts every control that consumes it — .po-btn, the bare-element
   rule below, PromptPicker's chips and TelemetryHud's cells — in one place. */
@media (pointer: coarse) {
    :root {
        --target-min: 44px;
    }
}

/* SC 2.4.7 Focus Visible + SC 1.4.11 Non-text Contrast — one ring everywhere,
   keyboard-only so pointer users keep the unringed look. */
:focus-visible {
    outline: 2px solid var(--accent-blue);
    outline-offset: var(--focus-ring-offset);
    box-shadow: var(--focus-ring);
    border-radius: var(--radius-sm);
}

/* h1 gets programmatic focus on navigation; the ring would be noise there. */
h1:focus, h1:focus-visible {
    outline: none;
    box-shadow: none;
}

/* SC 2.4.11 Focus Not Obscured (Minimum) — scrolling a focused control into
   view must not tuck it under the sticky navbar. */
a, button, [role="button"], input, select, textarea, summary, [tabindex]:not([tabindex="-1"]) {
    scroll-margin-block-start: var(--sticky-nav-clearance);
}

/* SC 2.5.8 Target Size (Minimum) — 24×24 CSS px for pointer targets.
   Inline links in prose are exempt, so they opt out below. */
button, [role="button"], input[type="button"], input[type="submit"],
input[type="reset"], input[type="checkbox"], input[type="radio"], summary {
    min-block-size: var(--target-min);
    min-inline-size: var(--target-min);
}

p a, li a, td a {
    min-block-size: auto;
    min-inline-size: auto;
}

/* Reachable by assistive tech, absent from the visual layout. Global because three
   components need it and each had grown its own byte-identical copy. */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

/* SC 2.4.1 Bypass Blocks — keyboard escape hatch past the nav. */
.skip-link {
    position: absolute;
    inset-block-start: -100vh;
    inset-inline-start: var(--space-sm);
    z-index: 200;
    padding: var(--space-sm) var(--space-md);
    border-radius: var(--radius-sm);
    background-color: var(--surface-2);
    border: 1px solid var(--accent-blue);
    color: var(--text);
    font-weight: 600;
    text-decoration: none;
}

.skip-link:focus, .skip-link:focus-visible {
    inset-block-start: var(--space-sm);
}

/* Honour the OS motion preference — this UI leans on continuous animation. */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }
}
/* ── Boot fallback ────────────────────────────────────────────────────────────
   Shown by index.html when the WASM runtime has not mounted after 45 s. Styled here
   rather than inline; it renders before Blazor boots, so it cannot use scoped CSS.
   Colours are literal because this panel must stay legible even if the token layer
   itself failed to load — that is one of the failures it exists to report. */
#boot-timeout-ui {
    max-width: 720px;
    margin: var(--space-md) auto;
    padding: var(--space-md);
    border: 1px solid #5f1f1f;
    background: #1b0f0f;
    color: #ffdada;
    border-radius: 8px;
    font-family: ui-sans-serif, system-ui, sans-serif;
}

#boot-timeout-ui h2 {
    margin: 0 0 var(--space-sm);
    font-size: var(--text-lg);
    color: inherit;
}

#boot-timeout-ui p {
    margin: 0 0 var(--space-smd);
    line-height: 1.4;
}

#boot-timeout-ui p:last-child {
    margin-bottom: 0;
}

#boot-timeout-ui a {
    color: #93c5fd;
}

.boot-timeout__sep {
    opacity: 0.6;
}

/* ══════════════════════════════════════════════════════════════════════════
   Motion and depth
   ══════════════════════════════════════════════════════════════════════════

   Everything below is compositor work — transform and opacity only — with no JavaScript
   frame loop behind any of it. That is a hard constraint rather than a preference: browser
   models run WebLLM inference over WebGPU in this same tab, and the tok/s the race reports is
   measured while that happens. A render loop competing for the GPU would not merely drop
   frames, it would make the number the app exists to report wrong. Continuous motion therefore
   stays on the compositor, and the only canvas work in the app (fx.js) is one-shot and fires
   when inference has already finished.

   Every effect here is off under prefers-reduced-motion. */

/* ── Aurora: a slow drift over the static --page-gradient already on <body> ────
   A separate fixed layer rather than animating the body's own background, because animating
   background-position repaints the whole page every frame; translating a composited layer
   does not. The body gradient stays underneath as the reduced-motion and no-support base. */
body::before {
    content: "";
    position: fixed;
    /* Oversized and offset so the drift never exposes an edge. */
    inset: -25%;
    z-index: -1;
    pointer-events: none;
    background:
        radial-gradient(circle at 20% 25%, rgba(45, 220, 132, 0.10), transparent 42%),
        radial-gradient(circle at 78% 18%, rgba(83, 166, 255, 0.09), transparent 40%),
        radial-gradient(circle at 55% 82%, rgba(74, 91, 212, 0.07), transparent 45%);
    will-change: transform;
    animation: aurora-drift 42s ease-in-out infinite alternate;
}

@keyframes aurora-drift {
    from { transform: translate3d(-2%, -1%, 0) scale(1.00) rotate(0deg); }
    to   { transform: translate3d(2%, 1.5%, 0) scale(1.06) rotate(2deg); }
}

/* ── Registered colours ───────────────────────────────────────────────────
   @property gives these a type, and a typed custom property can be TRANSITIONED — which is
   the only way a gradient's stops animate (an untyped one flips between values). Used for
   the phase edge on the Arena's status band and the readiness edge on Home's Compare panel.
   inherits: false, so each is set on the pseudo-element that paints with it. */
@property --po-edge-a {
    syntax: "<color>";
    inherits: false;
    initial-value: transparent;
}

@property --po-edge-b {
    syntax: "<color>";
    inherits: false;
    initial-value: transparent;
}

/* ── Glass ────────────────────────────────────────────────────────────────
   Opt-in utility for new surfaces. The three existing consumers of --glass-bg set the blur
   in their own scoped stylesheets alongside the background they already had.

   The first background layer is a specular glint at --gx/--gy, which wow.js sets from the
   pointer (defaults park it off the top edge, so a touch device sees a soft top light). A
   white glint on purpose, not a token: it is light hitting the surface, the same in either
   theme. */
.po-glass {
    background:
        radial-gradient(circle at var(--gx, 50%) var(--gy, -25%), rgba(255, 255, 255, 0.10), transparent 45%),
        var(--glass-bg);
    border: 1px solid var(--glass-border);
    box-shadow: var(--glass-shadow);
    backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
}

/* ── Liquid glass ─────────────────────────────────────────────────────────
   Real refraction: the SVG filter in index.html (#po-liquid, fractal-noise displacement)
   bends whatever is behind the panel before the blur frosts it. Two gates:

   - html.po-liquid — Chromium only, decided by wow.js. Other engines parse url() inside
     backdrop-filter and then render nothing, which would lose the plain blur as well, so
     @supports cannot be trusted with this.
   - not [data-gpu="busy"] — an SVG filter over the backdrop is real GPU work every frame the
     panel composites. util.js sets that attribute while a WebLLM worker holds the GPU lease,
     and the panel drops back to the plain blur until it is released. */
html.po-liquid:not([data-gpu="busy"]) .po-glass {
    backdrop-filter: url(#po-liquid) blur(var(--glass-blur)) saturate(1.5);
}

/* Holds the filter definitions; must stay in the render tree for url(#po-liquid) to resolve,
   so it is collapsed rather than display:none. */
.po-svg-defs {
    position: absolute;
    width: 0;
    height: 0;
    overflow: hidden;
    pointer-events: none;
}

/* ── Scroll-driven reveal ─────────────────────────────────────────────────
   Rows and cards rise into place as they scroll into view, driven by the scroll position
   itself (animation-timeline: view()) — no script, no observer, compositor-only. A view
   timeline belongs to the NEAREST scroll container, which is why this works inside Home's
   picker and the virtualised grids as well as on the page. Engines without scroll timelines
   show everything in place, which is the pre-existing look; so does reduced motion. */
@supports (animation-timeline: view()) {
    @media (prefers-reduced-motion: no-preference) {
        .po-reveal {
            animation: po-reveal linear backwards;
            animation-timeline: view();
            animation-range: entry 0% entry 90%;
        }
    }
}

/* translate/scale, not transform, and fill backwards only: a card's own hover transform and
   any opacity rule are untouched once the reveal has played. */
@keyframes po-reveal {
    from { opacity: 0; translate: 0 1rem; scale: 0.98; }
    to   { opacity: 1; translate: none; scale: none; }
}

/* ── Lift ─────────────────────────────────────────────────────────────────
   Shared hover treatment for card-like surfaces. Transform and shadow only, so it composites. */
.po-lift {
    transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
}

.po-lift:hover,
.po-lift:focus-visible {
    transform: translateY(-2px);
    box-shadow: var(--glass-shadow);
}

/* ── Rank glow ────────────────────────────────────────────────────────────
   Marks the leader on a board. Opacity-pulsed on a pseudo-element rather than animating
   box-shadow on the element itself, which cannot composite. */
.po-glow {
    position: relative;
}

.po-glow::after {
    content: "";
    position: absolute;
    inset: -3px;
    z-index: -1;
    border-radius: inherit;
    background: radial-gradient(circle, var(--accent-green), transparent 70%);
    opacity: 0.25;
    pointer-events: none;
    animation: po-glow-pulse 2.8s ease-in-out infinite alternate;
}

@keyframes po-glow-pulse {
    from { opacity: 0.14; }
    to   { opacity: 0.42; }
}

/* ── Particle overlay ─────────────────────────────────────────────────────
   Created and removed by fx.js. Non-interactive is load-bearing: the verdict burst paints
   directly over the vote buttons, and must never swallow a click meant for them. */
.fx-overlay {
    position: fixed;
    inset: 0;
    z-index: 9999;
    width: 100%;
    height: 100%;
    pointer-events: none;
}

/* gpu-fx.js's verdict nebula sits under the confetti: it is the atmosphere, the paper is the
   foreground. It is created after an await, so DOM order alone would put it on top. */
.fx-overlay--under {
    z-index: 9998;
}

/* The champion storm draws over everything for its seven seconds, confetti included. */
.fx-overlay--storm {
    z-index: 10000;
}

/* The verdict's shockwave (fx.js impact). Positioned by custom properties from JS; only
   transform and opacity animate. */
.fx-ring {
    position: fixed;
    left: var(--fx-x);
    top: var(--fx-y);
    z-index: 9998;
    width: var(--fx-size);
    height: var(--fx-size);
    border: 3px solid var(--accent-green);
    border-radius: var(--radius-full);
    box-shadow:
        0 0 24px color-mix(in srgb, var(--accent-green) 55%, transparent),
        inset 0 0 24px color-mix(in srgb, var(--accent-cyan) 45%, transparent);
    pointer-events: none;
}

/* ── State-modifier primitives ───────────────────────────────────────────
   Pair with `.po-bg--{palette}` (background+border) or `.po-fg--{palette}` (text-only)
   on any element that needs a status colour. The previous design had ~15 per-surface
   modifiers that each re-stated the same palettes — and they drifted on which token they
   read from. Centralising means a colour-token rename touches one rule, not twelve.

   Only --err is declared: every other palette (--ok, --warn, --info…) was added speculatively
   and nothing applied it. Add a modifier back when a surface needs it.

   Usage:
     <div class="po-bg--err po-fg--err">Failed</div> */
.po-bg--err {
    background: var(--danger-surface);
    border: 1px solid var(--danger-border);
}

.po-fg--err {
    color: var(--accent-red);
}

@media (prefers-reduced-motion: reduce) {
    body::before {
        animation: none;
    }

    .po-lift,
    .po-lift:hover,
    .po-lift:focus-visible {
        transform: none;
        transition: none;
    }

    .po-glow::after {
        animation: none;
        opacity: 0.25;
    }
}

/* ══════════════════════════════════════════════════════════════════════════
   Shared surface primitives
   ══════════════════════════════════════════════════════════════════════════

   These exist because the same eight jobs were being done by ~55 per-surface classes:
   11 different __title, 9 __error, 8 __status, 7 __header, 6 __empty, 5 __subtitle,
   5 __section-title and 4 __section-hint. That is precisely the failure CLAUDE.md already
   records once — twelve per-surface button classes that each reimplemented the same thing
   locally and drifted apart, folded into .po-btn — recurring in typography instead.

   The rule is the same as for buttons: a surface that needs a tweak adds a LAYOUT-ONLY class
   alongside the primitive (.leaderboard__row-btn is the pattern). New visual variants go here as
   a modifier, never in a .razor.css. */

/* ── Page shell ───────────────────────────────────────────────────────────
   Seven different container max-widths were in use (1000, 1080, 1100, 1200, 1360, 1400 …),
   so the content edge visibly jumped as you navigated between pages. */
/* ── The gutter belongs to ONE element, and that element is article.app-content ──
   This used to add its own --space-md inline padding on top of the article's responsive
   gutter, and the two composed rather than replacing each other: on a 390px phone that was
   12px (article) + 16px (here) per side, and /archive — which also had a shell of its own —
   reached 36px per side. Measured content widths on one 390px screen were 358px on `/`,
   334px on /leaderboard and 318px on /archive: three different text columns, and 18% of the
   screen spent on nested padding before a pixel of content.

   So the horizontal gutter is now declared once, by the article, and .po-page contributes
   none. The safe-area inset is composed into the article's rule with max() for the same
   reason and does not need restating here either. */
.po-page {
    width: 100%;
    max-width: 1200px;
    margin-inline: auto;
    padding-inline: 0;
    /* No top padding: article.content already contributes --space-md above this, and the
       two stacked put 32px between the nav and every page title. */
    padding-block: 0 var(--space-lg);
}

/* Data-dense surfaces: wide tables and the tournament bracket. */
.po-page--wide { max-width: 1400px; }

/* ── Page-title hooks ─────────────────────────────────────────────────────
   home__title, archive__title, arena__title and leaderboard__title are E2E-UI selector
   hooks, passed to <PageHeader TitleHook="…"> and carrying NO styling. The gap under a page
   title belongs to .page-header (PageHeader.razor.css). They each drifted into full rulesets
   once (43px/43px/31px h1s on one desktop, a synthesised 800 weight) — don't restart that. */

/* ── Text primitives ──────────────────────────────────────────────────────*/

.po-title {
    margin: 0;
    font-size: var(--text-2xl);
    font-weight: var(--weight-bold);
    line-height: var(--leading-tight);
    letter-spacing: -0.015em;
    color: var(--text);
}

.po-subtitle {
    max-width: 60ch;                     /* prose stops being readable past ~75ch */
    margin: var(--space-xs) 0 0;
    font-size: var(--text-md);
    line-height: var(--leading-normal);
    color: var(--text-muted);
}

.po-section-title {
    margin: 0 0 var(--space-xs);
    font-size: var(--text-lg);
    font-weight: var(--weight-semibold);
    line-height: var(--leading-snug);
    color: var(--text);
}

.po-hint {
    max-width: 60ch;
    margin: 0 0 var(--space-sm);
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
    color: var(--text-muted);
}

/* Transient "loading…" / "nothing yet" text. Italic is the whole distinction from --hint:
   it reads as a temporary state rather than as permanent guidance. */
.po-status {
    margin: 0;
    font-size: var(--text-md);
    font-style: italic;
    color: var(--text-muted);
}

.po-error {
    margin: 0;
    font-size: var(--text-md);
    font-weight: var(--weight-medium);
    line-height: var(--leading-normal);
    color: var(--accent-red);
}

/* ── Empty state ──────────────────────────────────────────────────────────
   A dashed box with a heading, a line of guidance and a way out. Six surfaces had each
   invented their own; the shape was the same every time. */
.po-empty {
    padding: var(--space-lg) var(--space-md);
    border: 1px dashed var(--border);
    border-radius: var(--radius-lg);
    text-align: center;
}

/* ── Page header ──────────────────────────────────────────────────────────
   Title, optional status chip on the same baseline, subtitle underneath. */
.po-header {
    padding-bottom: var(--space-md);
    border-bottom: 1px solid var(--border);
}

.po-header__row {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--space-smd);
}

/* ── Section ──────────────────────────────────────────────────────────────*/
.po-section {
    margin-top: var(--space-md);
}

/* ── Responsive tables ────────────────────────────────────────────────────
   Below --bp-sm a data table stops being a table and becomes a stack of cards.

   The previous behaviour was `overflow-x: auto` on .po-table-wrap and nothing else, so on a
   phone a six-column leaderboard was a horizontally-scrolling strip: you could see the rank
   or the ELO but never both, and the column you needed was always off-screen. Scrolling
   sideways to read one row is the failure mode this replaces.

   How it works: each <td> is turned into a labelled row using the header text carried in a
   data-label attribute on the cell. The table keeps its semantics — it is still a real
   <table> with real <th> scope attributes, so a screen reader is unaffected; only the visual
   presentation changes. Cells opt out with .po-cell--bare when the label would be noise
   (an actions cell, a rank badge).

   Requires markup support: cells need data-label="…". A table without it degrades to the
   old horizontal scroll rather than breaking, because the label is rendered from an
   attribute that simply is not there.

   ── THE table strategy ─────────────────────────────────────────────────────────
   There is exactly ONE responsive strategy for hand-rolled tables, and it is this one:
   `.po-table--cards` at the 640px breakpoint. Do not add a second mechanism — no
   per-surface `overflow-x: auto` wrapper, no per-page "hide a few columns on mobile"
   block, no bespoke card markup. Those were the implementations this replaced, and they
   disagreed with each other about both the breakpoint and what a phone should see.
   Every table in the app, the Archive and Catalog included, uses it. */
@media (max-width: 640px) {
    .po-table--cards thead {
        /* Not display:none — that removes it from the accessibility tree too. Clipped instead,
           so the header cells still exist for anything reading the table structure. */
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip-path: inset(50%);
        white-space: nowrap;
    }

    .po-table--cards,
    .po-table--cards tbody,
    .po-table--cards td {
        display: block;
        width: 100%;
    }

    /* The row is a wrapping flex container, not a block, so two cells CAN share a line with
       a flex basis (inline-flex at 50% does not pair — markup whitespace takes width). The
       .po-cell--half modifier that did this went on 2026-09-26 when the leaderboard's W and
       L merged into one Record cell; re-add it here if another pair of narrow cells appears. */
    .po-table--cards tr {
        display: flex;
        flex-wrap: wrap;
        margin-bottom: var(--space-sm);
        padding: var(--space-sm) var(--space-smd);
        border: 1px solid var(--border);
        border-radius: var(--radius-md);
        background: var(--surface-1);
    }

    .po-table--cards td {
        display: flex;
        /* Full width by default, so the wrapping row still reads as one cell per line. */
        flex: 1 1 100%;
        align-items: baseline;
        justify-content: space-between;
        gap: var(--space-md);
        /* 2xs, not xs. One card per row times one row per column adds up fast: a ten-opponent
           kill list was 2170px tall on a phone, and the model profile as a whole came to
           4292px — mostly whitespace between label and value. Halving the vertical padding and
           tightening the line height below takes roughly a third off without making the rows
           hard to separate, because the card border already does that work. */
        padding: var(--space-2xs) 0;
        border-bottom: 1px solid var(--border);
        line-height: var(--leading-snug);
        text-align: right;
    }

    .po-table--cards tr td:last-child {
        border-bottom: none;
    }

    /* The header text, restated per cell. content: attr() cannot be read by most screen
       readers, which is exactly right here — the real <th> is still in the tree, so voicing
       this too would say every column name twice. */
    .po-table--cards td[data-label]::before {
        content: attr(data-label);
        flex: 0 0 auto;
        color: var(--text-muted);
        font-size: var(--text-2xs);
        font-weight: var(--weight-semibold);
        text-transform: uppercase;
        letter-spacing: 0.04em;
        text-align: left;
    }

    /* Cells the surface marks as secondary are dropped entirely at card size rather than
       stacked. On a phone a kill list wants opponent, record and win rate; the exact timestamp
       of the last meeting is available on the row's own page. */
    .po-table--cards td.po-cell--secondary {
        display: none;
    }

    /* Cells where a label adds nothing — an actions row, a rank badge. */
    .po-table--cards td.po-cell--bare {
        justify-content: flex-start;
        text-align: left;
    }

    /* The card is the row; the wrapper no longer needs to scroll. */
    .po-table-wrap {
        overflow-x: visible;
    }
}

/* ══════════════════════════════════════════════════════════════════════════
   View transitions and scroll-driven reveals
   ══════════════════════════════════════════════════════════════════════════

   Both are native CSS with no JavaScript behind them, which is what makes them safe under
   the constraint recorded in CLAUDE.md: they run on the compositor and never touch the
   WebGPU device WebLLM generates on, so they cannot skew tok/s or a MaxSeconds budget.

   Both degrade to nothing on a browser without support — the page simply appears, which is
   what it did before. Neither is load-bearing for any content. */

/* ── View transitions ─────────────────────────────────────────────────────
   @view-transition covers cross-document navigations only — a full reload, /diag and back.
   It does NOT cover the Router's route swaps: Blazor navigation is same-document pushState,
   which the browser never treats as a navigation to transition. (The comment here used to
   claim it did.) Same-document morphs are started explicitly by wow.js navigate(), for links
   marked data-vt and for Compare → Arena; the root fade below applies to both kinds. */
@view-transition {
    navigation: auto;
}

/* Shared elements that morph between routes: a Home slot into an Arena panel, a leaderboard
   link into the profile card. .po-vt-hero is put on the clicked element for one transition
   only, because a view-transition-name must be unique on the page at capture time. */
.po-vt-hero {
    view-transition-name: po-hero;
}

::view-transition-group(po-hero),
::view-transition-group(po-fighter-left),
::view-transition-group(po-fighter-right) {
    animation-duration: 460ms;
    animation-timing-function: cubic-bezier(.2, .9, .25, 1);
}

/* The two snapshots are different content (a link vs a card), so cross-fade them rather than
   stretching one into the other's box. */
::view-transition-old(po-hero),
::view-transition-new(po-hero) {
    height: 100%;
    object-fit: cover;
}

::view-transition-old(root) {
    animation: vt-fade-out 90ms ease-out both;
}

::view-transition-new(root) {
    animation: vt-fade-in 160ms ease-out both;
}

@keyframes vt-fade-out {
    to { opacity: 0; }
}

@keyframes vt-fade-in {
    from { opacity: 0; transform: translateY(4px); }
    to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
    ::view-transition-old(root),
    ::view-transition-new(root),
    ::view-transition-group(*) {
        animation: none;
    }

}

/* ── Safe-area insets (notches, home indicators, curved corners) ──────────────
   Paired with the dvh switch above. dvh gets the HEIGHT right; these keep content
   out of the physically obstructed strips at the edges of a modern phone screen.
   env() resolves to 0px on every device without a cutout, so this is inert on
   desktop and needs no media query. The max() keeps the existing padding as a
   floor — without it, a device reporting 0px would collapse the padding entirely.

   Only the BOTTOM inset is applied here now. The left/right pair moved out with the
   rest of the horizontal gutter (see the .po-page rule above): it is composed into
   article.app-content's padding-inline, which every page sits inside, so restating it
   here would rebuild exactly the double gutter that rule removed. */
.po-page {
    padding-bottom: max(var(--space-lg), env(safe-area-inset-bottom));
}

/* ── SC 1.4.6 / prefers-contrast: more ───────────────────────────────────────
   The default palette clears AA (4.5:1) on every foreground/surface pair — verified
   by measurement on 2026-09-02, which is how the --text-dim failure above was found.
   AA is not always enough: low vision, glare, and cheap panels all eat contrast that
   a ratio calculation says is present.

   This block raises the muted text tokens to full --text and strengthens borders,
   which is where contrast actually gets lost — a 1px border at --border is nearly
   invisible on a washed-out screen, and it is the only thing separating table rows
   and card edges. Backgrounds go flat: the aurora gradient and the glass blur are
   both decoration that reduces the contrast of whatever sits on top of them. */
@media (prefers-contrast: more) {
    :root {
        --text-muted: var(--text);
        --text-dim: var(--text);
        --border: var(--border-strong);
        --glass-bg: var(--surface-1);
        --glass-blur: 0px;
        --page-gradient: none;
    }

    /* Decorative only, and the single largest source of low-contrast area in the app. */
    body::before {
        display: none;
    }

    /* Refraction bends text that sits behind a panel's edge; flat surfaces here. */
    html.po-liquid .po-glass {
        backdrop-filter: none;
    }

    /* A border that exists to be subtle is a border that disappears first. */
    .po-glass,
    .po-table th,
    .po-table td {
        border-color: var(--border-strong);
    }

    /* Focus is the one indicator a keyboard user cannot do without. */
    :focus-visible {
        outline-width: 3px;
    }
}
