/* ============================================================================================
   PoSeeReview design system
   ============================================================================================

   CASCADE LAYERS — read this before adding anything.

   Layer order below is the whole point. `page` (Blazor's scoped-CSS bundle) sits BEFORE `shared`,
   which means a shared primitive like `.btn` can no longer be silently overridden by a page-level
   redefinition. That was a real bug, not a hypothetical: Diagnostics and the Hall of Fame each
   grew their own `.btn-primary` (a blue rectangle vs a purple pill) and their own error banner,
   because scoped sheets load after this file and carry a `[b-*]` attribute that outranks it.

   Scoped sheets are still free to style their own page-specific classes — those never appear in
   `shared`, so there is nothing to conflict with. What they can no longer do is quietly fork a
   shared component.

   The scoped bundle is pulled in here with @import ... layer() rather than its own <link>,
   because that is the only way to place framework-generated CSS into a layer.
   ============================================================================================ */

@layer reset, vendor, tokens, base, page, shared, utilities;

/* WHY THERE ARE TWO APP LAYERS, `base` AND `shared`:
     base   — bare element defaults (a, code, body). Pages MUST be able to override these.
     shared — component primitives (.btn, .glass, .state-card). Pages must NOT fork these.

   Layer order beats specificity, and that is easy to get wrong in exactly one direction: an
   `a { color: ... }` rule sitting in `shared` outranks even `.nav-item ::deep a` in `page`,
   because the layer wins no matter how specific the page selector is. That turned every nav
   label brand-purple on the dark bar. Element selectors go in `base`; class-based components
   go in `shared`. */

@import url("/_content/Radzen.Blazor/css/material-base.css") layer(vendor);
@import url("/PoSeeReview.Client.styles.css") layer(page);

/* ============================================================================================
   RESET — replaces Bootstrap (was 31KB gzipped for six classes we actually used, all of which
   this file already redefined). Nothing here is opinionated styling; it is normalization only.
   ============================================================================================ */

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

    html {
        -webkit-text-size-adjust: 100%;
        text-size-adjust: 100%;
        tab-size: 4;
    }

    body {
        margin: 0;
        min-height: 100dvh;
        line-height: 1.5;
        -webkit-font-smoothing: antialiased;
        text-rendering: optimizeLegibility;
    }

    /* Headings and paragraphs get their rhythm from the type scale, not from UA defaults. */
    h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd {
        margin: 0;
    }

    h1, h2, h3, h4, h5, h6 {
        font-weight: 700;
        line-height: 1.2;
        text-wrap: balance;   /* Avoids a single orphaned word on a wrapped heading. */
    }

    p {
        text-wrap: pretty;
    }

    ul, ol {
        margin: 0;
        padding: 0;
    }

    /* Media should never be able to force a horizontal scrollbar. */
    img, picture, video, canvas, svg {
        display: block;
        max-width: 100%;
    }

    /* Form controls do not inherit typography by default — the single most common cause of a
       select or input that looks nothing like the rest of the page. */
    input, button, textarea, select {
        font: inherit;
        color: inherit;
    }

    button {
        background: none;
        border: none;
        cursor: pointer;
    }

    a {
        color: inherit;
        text-decoration: none;
    }

    table {
        border-collapse: collapse;
    }

    /* Anything longer than its container wraps rather than overflowing. Restaurant names and
       review quotes are third-party text and can be arbitrarily long. */
    p, li, h1, h2, h3, h4, blockquote {
        overflow-wrap: break-word;
    }
}

/* ============================================================================================
   TOKENS
   ============================================================================================

   COLOUR — surface/ink split.

   The light palette used to fail WCAG AA outright: `--color-accent` #F59E0B measured 2.15:1 on
   a white card and `--color-success` #10b981 measured 2.54:1, against a 4.5:1 requirement.
   `--color-border` was 1.22:1, which is not a visible edge at all (1.4.11 wants 3:1).

   The fix is NOT to darken the brand colours until they pass. Bright amber on white is unreadable
   as text but perfectly good as a *surface* — #1e1e2e ink on #F59E0B measures 7.64:1. So each
   expressive colour now has two tokens:

       --color-accent          the surface. Use as a background, never as text on a light card.
       --color-accent-ink      the readable text/icon version. 4.5:1+ on card in both themes.

   Dark mode already passed everywhere (lowest was 4.89:1), so its values are largely unchanged;
   the -ink tokens simply alias the surface colour there.

   Verified by tests/PoSeeReview.Unit/Utilities/ColorContrastTests.cs — if you change a value
   here, that test tells you whether it still passes.
   ============================================================================================ */

@layer tokens {
    :root {
        color-scheme: light dark;

        /* ── Brand ───────────────────────────────────────────────────────────────────────── */
        --color-brand: #7C3AED;            /* 5.70:1 on white — safe as text and as a surface */
        --color-brand-ink: #7C3AED;
        --color-brand-dark: #1A0533;

        /* ── Expressive colours: surface vs ink ──────────────────────────────────────────── */
        --color-accent: #F59E0B;           /* surface only */
        --color-accent-ink: #9B6407;       /* 4.56:1 on the darkest light surface (#f5f5f5) */
        --color-accent-dark: #B45309;
        --color-on-accent: #1E1E2E;        /* 7.64:1 on the amber surface */

        --color-success: #10b981;          /* surface only */
        --color-success-ink: #0B7F58;      /* 4.60:1 on the darkest light surface */

        --color-danger: #D72536;           /* 4.58:1 on the darkest light surface */
        --color-danger-ink: #C42232;

        /* ── Neutrals ────────────────────────────────────────────────────────────────────── */
        --color-surface: #F8F7FF;
        --color-surface-alt: #f5f5f5;
        --color-card: #ffffff;
        --color-text-primary: #1e1e2e;     /* 16.40:1 */
        --color-text-secondary: #555;      /* 7.46:1  */
        --color-text-muted: #656F71;       /* 4.74:1 on the DARKEST light surface (#f5f5f5).
                                              Was #6D797A, which cleared 4.5:1 only against pure
                                              white and measured 4.26:1 on --color-brand-surface,
                                              where the leaderboard timestamp actually renders. */

        /* Two border weights. The old single token was invisible; making it 3:1 everywhere would
           have put a hard grey box around every resting card. Subtle for decoration, strong for
           anything that is genuinely a control boundary. */
        --color-border: #C9C9DC;           /* 1.63:1 — resting card edges, dividers */
        --color-border-strong: #8F8FB6;    /* 3.10:1 — inputs, focus targets, real affordances */

        --color-highlight: #fff9e6;
        --color-brand-surface: #f9f8ff;
        --color-comic-ink: #1e1e2e;

        --color-danger-surface: #f8d7da;
        --color-danger-text: #721c24;
        --color-success-surface: #d4edda;
        --color-success-text: #155724;
        --color-warning-text: #7A5C04;     /* was #856404 — lifted for AA on the highlight surface */

        /* ── Typography ──────────────────────────────────────────────────────────────────────
           A 7-step fluid ramp replacing the 22 ad-hoc font-size values that had accumulated.
           Every step is a clamp so there is no breakpoint at which type jumps. */
        --font-display: 'Bangers', cursive;
        --font-body: 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;

        --text-2xs: 0.6875rem;                                  /* 11px — badges, overlines */
        --text-xs:  0.75rem;                                    /* 12px — captions, notes */
        --text-sm:  0.875rem;                                   /* 14px — secondary body */
        --text-md:  1rem;                                       /* 16px — body */
        --text-lg:  clamp(1.0625rem, 0.4vw + 1rem, 1.25rem);    /* lead */
        --text-xl:  clamp(1.25rem,  1.2vw + 1rem, 1.75rem);     /* section heading */
        --text-2xl: clamp(1.75rem,  2.4vw + 1rem, 2.5rem);      /* page heading */
        --text-3xl: clamp(2.5rem,   6vw,          4rem);        /* hero */

        --leading-tight: 1.15;
        --leading-snug: 1.35;
        --leading-normal: 1.55;

        /* ── Spacing ────────────────────────────────────────────────────────────────────────
           An 8-step scale replacing 219 distinct padding/margin/gap values. */
        --space-3xs: 0.25rem;   /*  4px */
        --space-2xs: 0.375rem;  /*  6px */
        --space-xs:  0.5rem;    /*  8px */
        --space-sm:  0.75rem;   /* 12px */
        --space-md:  1rem;      /* 16px */
        --space-lg:  1.5rem;    /* 24px */
        --space-xl:  2rem;      /* 32px */
        --space-2xl: 3rem;      /* 48px */

        /* ── Geometry ────────────────────────────────────────────────────────────────────── */
        --radius-sm: 8px;
        --radius-card: 12px;
        --radius-lg: 20px;
        --radius-pill: 999px;

        /* Minimum comfortable touch target (WCAG 2.5.8 / platform HIG). */
        --tap-target: 2.75rem;  /* 44px */

        /* ── Motion ──────────────────────────────────────────────────────────────────────────
           Durations and easings, so a transition is chosen from a scale rather than typed from
           memory. Every hand-rolled `0.2s ease` in this app was a slightly different guess, and
           the result is that two adjacent controls settle at visibly different times.

           The easings are not interchangeable, and picking the wrong one is the most common way
           motion goes cheap:
             --ease-out    things ARRIVING. Fast at first, settling. The default for anything
                           entering, opening, or responding to a tap.
             --ease-in     things LEAVING. Nothing should enter with this; it starts slow, which
                           reads as hesitation.
             --ease-spring slight overshoot, for a single emphasised element (the score landing).
                           Applied to a list, overshoot reads as instability.

           Durations scale with distance travelled, not with importance: a 4px chip needs less
           time than a full-screen sheet or it looks sluggish. */
        --duration-instant: 80ms;    /* state flips: checked, pressed */
        --duration-fast: 140ms;      /* hovers, small chips, tooltips */
        --duration-base: 220ms;      /* the default — cards, buttons, most things */
        --duration-slow: 340ms;      /* sheets, page-level reveals */
        --duration-deliberate: 620ms;/* the score count-up and its landing */

        --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
        --ease-in: cubic-bezier(0.55, 0.06, 0.68, 0.19);
        --ease-in-out: cubic-bezier(0.65, 0.05, 0.36, 1);
        --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);

        /* ── Elevation ───────────────────────────────────────────────────────────────────────
           An elevation is a PAIR: a shadow and the surface tint that goes under it. Shadow
           alone is why the design drifted — in dark mode a black shadow on a dark ground is
           invisible, so depth there has to come from the surface getting *lighter* as it rises,
           which is the opposite of what happens in light mode. Using --elevation-N-surface
           together with --elevation-N-shadow gets both right in both themes; reaching past them
           for a raw --shadow-* only works in one.

           Four steps, matching the four things this app actually stacks:
             1  resting cards in a list
             2  a raised or hovered card
             3  popovers, the install nudge, the toast
             4  modal dialogs

           Light mode: surfaces stay the same and shadows deepen. */
        --elevation-1-shadow: var(--shadow-xs);
        --elevation-2-shadow: var(--shadow-sm);
        --elevation-3-shadow: var(--shadow-md);
        --elevation-4-shadow: var(--shadow-lg);
        --elevation-1-surface: var(--color-card);
        --elevation-2-surface: var(--color-card);
        --elevation-3-surface: var(--color-card);
        --elevation-4-surface: var(--color-card);

        --shadow-card: 0 2px 12px rgba(124, 58, 237, 0.08);
        --shadow-card-hover: 0 6px 24px rgba(124, 58, 237, 0.18);
        --gradient-brand: linear-gradient(135deg, #7C3AED 0%, #4F1D96 100%);
        --gradient-sidebar: linear-gradient(180deg, #1A0533 0%, #2D1052 60%, #3b0764 100%);

        /* ── Breakpoints ─────────────────────────────────────────────────────────────────────
           Custom properties cannot be used inside @media, so these are documentation, not
           machinery — but they are the ONLY four values any stylesheet in this app should use.
           There were 13 before (480/560/600/640/640.98/768/992/1200 plus Bootstrap's five .98s),
           which meant a card could reflow at one width and its container at another 8px away.

               --bp-sm: 40rem   (640px)  phone landscape / small tablet
               --bp-md: 48rem   (768px)  tablet
               --bp-lg: 64rem   (1024px) desktop
               --bp-xl: 80rem   (1280px) wide desktop

           Prefer a container query (see .cq-card below) over a media query for anything that is
           really about how much room a COMPONENT has rather than how big the screen is. */
        --bp-sm: 40rem;
        --bp-md: 48rem;
        --bp-lg: 64rem;
        --bp-xl: 80rem;

        /* ── Inverse ("on dark") surfaces ────────────────────────────────────────────────────
           The nav bar and the discovery hero are dark in BOTH themes — that is a deliberate
           design choice, not a light-mode default. So `color: white` on them was never the bug
           CLAUDE.md warns about; it was just unnamed. Naming it means a future theme change has
           one place to edit, and it stops a reviewer having to work out per-line whether a
           literal white is intentional or a dark-mode landmine.

           Do NOT use these on a card. Cards are token-driven and flip with the theme. */
        --surface-inverse: #140A28;
        --surface-inverse-veil: rgba(20, 10, 40, 0.92);
        --color-on-dark: #FFFFFF;
        --color-on-dark-secondary: rgba(255, 255, 255, 0.82);
        --color-on-dark-muted: rgba(255, 255, 255, 0.70);
        --border-on-dark: rgba(255, 255, 255, 0.15);
        --border-on-dark-subtle: rgba(255, 255, 255, 0.07);
        --overlay-on-dark: rgba(255, 255, 255, 0.12);
        --overlay-on-dark-strong: rgba(255, 255, 255, 0.20);

        --gradient-hero: linear-gradient(160deg, #5B21B6 0%, #7C3AED 40%, #be185d 100%);
        /* Darkens the outer edge of the hero so white type stays legible over the brightest
           part of the gradient. */
        --hero-scrim: radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.3) 100%);
        --text-shadow-on-dark: 0 2px 12px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);

        /* Secondary brand pink — the second stop in the logo glow and the "hot" end of the
           strangeness ramp. Was #f5576c / #f093fb pasted into four sheets. */
        --color-brand-alt: #f5576c;
        --color-brand-alt-soft: #f093fb;

        /* Medals. Deliberately NOT theme-flipped: gold is gold. Named so they are obviously
           intentional rather than looking like un-tokenised literals. */
        --medal-gold: #ffd700;
        --medal-gold-light: #ffe066;
        --medal-gold-dark: #b8860b;
        --medal-silver: #c0c0c0;
        --medal-silver-light: #f0f0f0;
        --medal-silver-dark: #808080;
        --medal-bronze: #cd7f32;
        --medal-bronze-light: #f0c080;
        --medal-bronze-dark: #8b4513;

        /* Status pills on the dark nav bar. Light-on-dark by construction, so they do not use
           the theme-flipping success/danger tokens. */
        --status-on-dark-ok: #86efac;
        --status-on-dark-info: #bae6fd;
        --status-on-dark-ok-border: rgba(34, 197, 94, 0.4);
        --status-on-dark-info-border: rgba(99, 179, 237, 0.4);
        --status-on-dark-danger: rgba(248, 113, 113, 0.5);
        --status-on-dark-danger-surface: rgba(153, 27, 27, 0.35);

        /* Comic letterbox. Intentionally near-black in both themes: it is a cinema frame around
           the artwork, and flipping it to a light surface would wash out the panel. */
        --surface-film: #0A0A0A;

        /* ── Alpha derivatives ───────────────────────────────────────────────────────────────
           color-mix lets a translucent tint follow --color-brand instead of freezing the
           light-mode purple into a scoped sheet. Every rgba(124,58,237,x) in the app was that
           frozen value, which is why brand-tinted borders and ring tracks stayed purple in dark
           mode while everything around them went lilac. */
        --brand-a08: color-mix(in srgb, var(--color-brand) 8%, transparent);
        --brand-a12: color-mix(in srgb, var(--color-brand) 12%, transparent);
        --brand-a18: color-mix(in srgb, var(--color-brand) 18%, transparent);
        --brand-a35: color-mix(in srgb, var(--color-brand) 35%, transparent);

        /* ── Elevation ───────────────────────────────────────────────────────────────────────
           Neutral shadows, so a dark theme does not try to cast a soft grey glow on a dark page. */
        --shadow-xs: 0 1px 4px rgba(0, 0, 0, 0.10);
        --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08);
        --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.12);
        --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.18);

        /* ── Glass material ──────────────────────────────────────────────────────────────── */
        --glass-tint: rgba(255, 255, 255, 0.58);
        --glass-edge: rgba(255, 255, 255, 0.85);
        --glass-shade: rgba(124, 58, 237, 0.10);
        --glass-blur: 14px;
    }

    /* Dark palette, applied twice on purpose:
         1. @media (prefers-color-scheme: dark) — follows the OS automatically.
         2. :root[data-theme="dark"] — an explicit choice that must beat the media query.
       Only colour flips; type, spacing and geometry stay shared. */
    @media (prefers-color-scheme: dark) {
        :root:not([data-theme="light"]) {
            --color-brand: #A78BFA;
            --color-brand-ink: #A78BFA;
            --color-brand-dark: #0F021F;

            --color-accent: #FBBF24;
            --color-accent-ink: #FBBF24;      /* 10.34:1 on the dark card — no separate ink needed */
            --color-accent-dark: #D97706;
            --color-on-accent: #1E1E2E;

            --color-success: #34d399;
            --color-success-ink: #34d399;     /* 8.98:1 */

            --color-danger: #f87171;
            --color-danger-ink: #f87171;      /* 6.24:1 */

            --color-surface: #12101A;
            --color-surface-alt: #1C1926;
            --color-card: #1C1926;
            --color-text-primary: #ECEAF5;
            --color-text-secondary: #B4AFC6;
            --color-text-muted: #8E89A3;   /* 4.59:1 on the lightest dark surface (#2A2416) */

            --color-border: #2C2838;
            --color-border-strong: #696189;   /* 3.02:1 on the dark card — was #4A4460 at 1.88:1 */

            --color-highlight: #2A2416;
            --color-brand-surface: #1D1533;
            --color-comic-ink: #ECEAF5;

            --color-danger-surface: #3A1A1E;
            --color-danger-text: #FCA5A5;
            --color-success-surface: #12301F;
            --color-success-text: #86EFAC;
            --color-warning-text: #FCD34D;

            --shadow-xs: 0 1px 4px rgba(0, 0, 0, 0.4);
            --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.45);
            --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.55);
            --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.65);
            --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.45);
            --shadow-card-hover: 0 6px 24px rgba(0, 0, 0, 0.6);

            /* Dark mode inverts how elevation is signalled. A black shadow on a near-black page
               conveys nothing, so height is carried by the SURFACE getting lighter as it rises —
               the physical model being that a raised surface catches more ambient light. The
               shadows stay, but only to anchor the edge; the tint does the actual work. */
            --elevation-1-surface: color-mix(in srgb, var(--color-card) 100%, white 0%);
            --elevation-2-surface: color-mix(in srgb, var(--color-card) 96%, white 4%);
            --elevation-3-surface: color-mix(in srgb, var(--color-card) 92%, white 8%);
            --elevation-4-surface: color-mix(in srgb, var(--color-card) 87%, white 13%);

            --gradient-brand: linear-gradient(135deg, #6D28D9 0%, #3B1078 100%);
            --gradient-sidebar: linear-gradient(180deg, #0F021F 0%, #1B0937 60%, #26104D 100%);

            --glass-tint: rgba(28, 25, 38, 0.55);
            --glass-edge: rgba(167, 139, 250, 0.28);
            --glass-shade: rgba(0, 0, 0, 0.40);
        }
    }

    :root[data-theme="dark"] {
        --color-brand: #A78BFA;
        --color-brand-ink: #A78BFA;
        --color-brand-dark: #0F021F;

        --color-accent: #FBBF24;
        --color-accent-ink: #FBBF24;
        --color-accent-dark: #D97706;
        --color-on-accent: #1E1E2E;

        --color-success: #34d399;
        --color-success-ink: #34d399;

        --color-danger: #f87171;
        --color-danger-ink: #f87171;

        --color-surface: #12101A;
        --color-surface-alt: #1C1926;
        --color-card: #1C1926;
        --color-text-primary: #ECEAF5;
        --color-text-secondary: #B4AFC6;
        --color-text-muted: #8E89A3;   /* 4.59:1 on the lightest dark surface (#2A2416) */

        --color-border: #2C2838;
        --color-border-strong: #696189;   /* 3.02:1 on the dark card — was #4A4460 at 1.88:1 */

        --color-highlight: #2A2416;
        --color-brand-surface: #1D1533;
        --color-comic-ink: #ECEAF5;

        --color-danger-surface: #3A1A1E;
        --color-danger-text: #FCA5A5;
        --color-success-surface: #12301F;
        --color-success-text: #86EFAC;
        --color-warning-text: #FCD34D;

        --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.45);
        --shadow-card-hover: 0 6px 24px rgba(0, 0, 0, 0.6);

        /* The media-query block above redefines these four and this one did not, so an EXPLICIT
           dark choice used to keep light mode's shadows — the 0.08-0.18 alphas, which are
           invisible on a dark ground. Only ThemeUiTests sets data-theme today, which is why it
           went unseen; that is exactly the asymmetry the layering note at the top warns about. */
        --shadow-xs: 0 1px 4px rgba(0, 0, 0, 0.4);
        --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.45);
        --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.55);
        --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.65);

        /* Elevation by surface tint — see the note in the media-query block. */
        --elevation-1-surface: color-mix(in srgb, var(--color-card) 100%, white 0%);
        --elevation-2-surface: color-mix(in srgb, var(--color-card) 96%, white 4%);
        --elevation-3-surface: color-mix(in srgb, var(--color-card) 92%, white 8%);
        --elevation-4-surface: color-mix(in srgb, var(--color-card) 87%, white 13%);

        --gradient-brand: linear-gradient(135deg, #6D28D9 0%, #3B1078 100%);
        --gradient-sidebar: linear-gradient(180deg, #0F021F 0%, #1B0937 60%, #26104D 100%);

        --glass-tint: rgba(28, 25, 38, 0.55);
        --glass-edge: rgba(167, 139, 250, 0.28);
        --glass-shade: rgba(0, 0, 0, 0.40);
    }
}

/* ============================================================================================
   SHARED PRIMITIVES
   Anything defined here renders identically on every page and CANNOT be forked by a scoped
   stylesheet — see the layer note at the top.
   ============================================================================================ */

@layer base {
    html, body {
        font-family: var(--font-body);
        font-size: var(--text-md);
        line-height: var(--leading-normal);
        background-color: var(--color-surface);
        color: var(--color-text-primary);
    }

    /* :where() keeps specificity at zero as well, so even a same-layer page rule wins easily. */
    :where(a) {
        color: var(--color-brand-ink);
    }

    :where(a:hover) {
        text-decoration: underline;
    }

    code {
        font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
        font-size: 0.9em;
        color: var(--color-brand-ink);
    }
}

@layer shared {
    .content {
        padding-top: var(--space-md);
    }

    /* ── Page shell ──────────────────────────────────────────────────────────────────────────
       One implementation of the page header that five pages used to hand-roll (`.app-header`
       appeared in 5 scoped sheets, `.subtitle` in 2). Used by the PageShell component. */
    .page-shell {
        width: 100%;
        max-width: 68rem;
        margin-inline: auto;
        padding-inline: var(--space-md);
        padding-block: var(--space-lg) var(--space-2xl);
    }

    .page-shell--narrow {
        max-width: 44rem;
    }

    .page-shell--wide {
        max-width: 84rem;
    }

    .page-shell-header {
        margin-bottom: var(--space-lg);
        text-align: center;
    }

    .page-shell-title {
        font-family: var(--font-display);
        font-size: var(--text-2xl);
        line-height: var(--leading-tight);
        letter-spacing: 0.02em;
        color: var(--color-text-primary);
    }

    .page-shell-subtitle {
        margin-top: var(--space-2xs);
        font-size: var(--text-lg);
        color: var(--color-text-secondary);
    }

    /* ── Page hero ────────────────────────────────────────────────────────────────────────────
       The brand gradient banner. Index and Leaderboard each carried their own copy of this —
       same gradient, same radius, subtly different padding and type sizes. */
    .page-hero {
        position: relative;
        overflow: hidden;
        margin-bottom: var(--space-xl);
        padding: clamp(2rem, 6vw, 3.5rem) var(--space-lg);
        text-align: center;
        border-radius: var(--radius-lg);
        background: var(--gradient-hero);
        color: var(--color-on-dark);
    }

    /* Scrim, so white type stays legible over the brightest part of the gradient. */
    .page-hero::after {
        content: "";
        position: absolute;
        inset: 0;
        background: var(--hero-scrim);
        pointer-events: none;
    }

    .page-hero > * {
        position: relative;
        z-index: 1;
    }

    .page-hero-title {
        font-family: var(--font-display);
        font-size: var(--text-3xl);
        line-height: var(--leading-tight);
        letter-spacing: 0.03em;
        color: var(--color-on-dark);
        text-shadow: var(--text-shadow-on-dark);
    }

    .page-hero-subtitle {
        margin-top: var(--space-sm);
        font-size: var(--text-lg);
        color: var(--color-on-dark-secondary);
    }

    /* ── Buttons ─────────────────────────────────────────────────────────────────────────── */
    .btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: var(--space-xs);
        min-height: var(--tap-target);
        padding: var(--space-sm) var(--space-lg);
        border: none;
        border-radius: var(--radius-pill);
        font-family: var(--font-body);
        font-size: var(--text-md);
        font-weight: 700;
        line-height: 1;
        /* Explicitly none: Radzen's Material base uppercases button labels, and since .btn never
           said otherwise the vendor layer won. "USE MY LOCATION" is not this app's voice. */
        text-transform: none;
        letter-spacing: normal;
        cursor: pointer;
        text-decoration: none;
        box-shadow: none;
        transition: transform 0.15s, box-shadow 0.15s, background 0.15s, border-color 0.15s;
    }

    .btn:hover {
        transform: translateY(-1px);
        text-decoration: none;
    }

    .btn:disabled {
        opacity: 0.6;
        cursor: not-allowed;
        transform: none;
    }

    .btn-primary {
        background: var(--gradient-brand);
        color: #ffffff;
        box-shadow: 0 4px 16px rgba(124, 58, 237, 0.3);
    }

    .btn-primary:hover {
        color: #ffffff;
        box-shadow: 0 8px 24px rgba(124, 58, 237, 0.45);
    }

    .btn-secondary {
        background: transparent;
        color: var(--color-text-secondary);
        /* border-strong, not border: this is a control boundary and needs to be perceivable. */
        border: 1.5px solid var(--color-border-strong);
    }

    .btn-secondary:hover {
        border-color: var(--color-brand);
        color: var(--color-brand-ink);
    }

    /* ── Icon button ─────────────────────────────────────────────────────────────────────────
       Small glyph controls (a chip's ×, a dismiss) still need a 44px hit area. The visual box
       stays small; the target is expanded with a pseudo-element so layout is unaffected. */
    .btn-icon {
        position: relative;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        border-radius: var(--radius-pill);
        color: var(--color-text-secondary);
        line-height: 1;
    }

    .btn-icon::after {
        content: "";
        position: absolute;
        inset: 50% auto auto 50%;
        width: var(--tap-target);
        height: var(--tap-target);
        transform: translate(-50%, -50%);
    }

    .btn-icon:hover {
        color: var(--color-text-primary);
    }

    /* ── Chip toggle ─────────────────────────────────────────────────────────────────────────
       A small on/off filter control. Shared rather than scoped because it had two callers at
       once — the discovery sort chips and the Hall of Fame scope chips — and this file's own
       history says what happens otherwise: Diagnostics and the Hall of Fame each grew their own
       `.btn-primary`, and they drifted. Discovery's sort chips are gone, so the Hall of Fame is
       the only caller left; it stays put because what made it shared has not changed, only the
       count.

       Not a `.btn` variant: a chip is a state toggle with `aria-pressed`, not an action, and it
       is deliberately smaller than the 44px action target. The hit area is restored with a
       pseudo-element so the visual size stays compact. */
    .chip-toggle {
        position: relative;
        display: inline-flex;
        align-items: center;
        gap: var(--space-3xs);
        padding: var(--space-2xs) var(--space-sm);
        border: 1.5px solid var(--color-border-strong);
        border-radius: var(--radius-pill);
        background: transparent;
        color: var(--color-text-secondary);
        font-size: var(--text-sm);
        font-weight: 600;
        line-height: 1.2;
        cursor: pointer;
        transition: background 0.15s, border-color 0.15s, color 0.15s;
    }

    /* Restores a full tap target without inflating the chip itself. */
    .chip-toggle::after {
        content: "";
        position: absolute;
        inset: 50% auto auto 50%;
        min-width: var(--tap-target);
        min-height: var(--tap-target);
        width: 100%;
        height: 100%;
        transform: translate(-50%, -50%);
    }

    .chip-toggle:hover {
        border-color: var(--color-brand);
        color: var(--color-brand-ink);
    }

    .chip-toggle--active {
        /* accent is a SURFACE token; on-accent is the paired readable ink (7.64:1). Using
           --color-accent as text on a light card would measure 2.15:1. */
        background: var(--color-accent);
        border-color: var(--color-accent-dark);
        color: var(--color-on-accent);
    }

    .chip-toggle--active:hover {
        color: var(--color-on-accent);
        border-color: var(--color-accent-dark);
    }

    /* ── Toast ───────────────────────────────────────────────────────────────────────────────
       Transient confirmation ("Shared!", "Link copied"). The Hall of Fame had `.copy-toast` and
       the comic actions needed the same thing; this is the one implementation. Always paired
       with role="status" in markup so it is announced. */
    .toast {
        position: fixed;
        left: 50%;
        bottom: calc(var(--space-xl) + env(safe-area-inset-bottom, 0px));
        z-index: 50;
        transform: translateX(-50%);
        max-width: min(90vw, 28rem);
        padding: var(--space-sm) var(--space-lg);
        border-radius: var(--radius-pill);
        background: var(--surface-inverse);
        color: var(--color-on-dark);
        font-size: var(--text-sm);
        font-weight: 600;
        text-align: center;
        box-shadow: var(--shadow-card-hover);
        animation: toast-in 0.25s ease-out both;
    }

    @keyframes toast-in {
        from { opacity: 0; transform: translate(-50%, 8px); }
        to   { opacity: 1; transform: translate(-50%, 0); }
    }

    /* NOTE: there is deliberately no shared `.nav-link` rule.
       Bootstrap used to provide one, and replacing it here looked obvious — but NavMenu's scoped
       `.nav-item ::deep a` already owns padding, tap target, radius AND colour for that element.
       A shared `.nav-link { color: inherit }` sat in the `shared` layer, which now outranks
       `page`, so it beat the scoped white and inherited the dark body text onto the dark nav bar:
       the labels vanished. One owner per element. NavMenu is that owner. */

    /* ── Alerts ──────────────────────────────────────────────────────────────────────────── */
    .alert {
        padding: var(--space-lg);
        border-radius: var(--radius-card);
        margin-bottom: var(--space-md);
    }

    .alert h3 {
        margin-top: 0;
    }

    .alert-danger {
        background: var(--color-danger-surface);
        border: 1px solid var(--color-danger);
        color: var(--color-danger-text);
    }

    /* Two pages needed a non-fatal notice — the mock-data banner on Insights and the hidden /
       suppressed states on Moderation. That is exactly the shape `.btn-primary` forked in
       last time, so it is defined here once rather than twice in scoped sheets.
       --color-accent is a SURFACE; the readable text pairing is --color-accent-ink. */
    .alert-warning {
        background: var(--color-highlight);
        border: 1px solid var(--color-accent);
        color: var(--color-accent-ink);
    }

    /* ── Error / empty state ─────────────────────────────────────────────────────────────────
       `.error-message` was defined in 4 scoped sheets and `.error-container` in 3, all slightly
       different. One definition now, and pages cannot fork it. */
    .state-card {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: var(--space-sm);
        max-width: 34rem;
        margin: var(--space-xl) auto;
        padding: var(--space-xl);
        text-align: center;
        background: var(--color-card);
        border: 1px solid var(--color-border);
        border-radius: var(--radius-card);
        box-shadow: var(--shadow-card);
    }

    .state-card-icon {
        font-size: var(--text-2xl);
        line-height: 1;
    }

    .state-card-title {
        font-family: var(--font-display);
        font-size: var(--text-xl);
        letter-spacing: 0.02em;
    }

    .state-card-body {
        font-size: var(--text-md);
        color: var(--color-text-secondary);
    }

    .state-card-actions {
        display: flex;
        flex-wrap: wrap;
        gap: var(--space-sm);
        justify-content: center;
        margin-top: var(--space-xs);
    }

    /* ── Glass material ───────────────────────────────────────────────────────────────────────
       Three guards, because backdrop-filter is the most reliable way to turn a smooth list into
       a janky one on mobile:
         1. @supports — without it, an ordinary solid card rather than a transparent one.
         2. [data-fx-tier] — a device that auto-downgraded stops paying for the blur too.
         3. .is-scrolling — dropped while a list is moving (see js/scroll-guard.js). */
    .glass {
        position: relative;
        background: var(--color-card);
        border: 1px solid var(--color-border);
        border-radius: var(--radius-card);
        box-shadow: var(--shadow-card);
    }

    @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
        :root[data-fx-tier="full"] .glass,
        :root[data-fx-tier="lite"] .glass {
            background: var(--glass-tint);
            border-color: var(--glass-edge);
            -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(150%);
            backdrop-filter: blur(var(--glass-blur)) saturate(150%);
            box-shadow:
                0 1px 0 0 var(--glass-edge) inset,
                0 8px 32px var(--glass-shade);
        }

        :root[data-fx-tier="full"] .glass::before {
            content: "";
            position: absolute;
            inset: 0;
            border-radius: inherit;
            padding: 1px;
            background: linear-gradient(140deg, var(--glass-edge), transparent 45%, transparent 60%, var(--glass-edge));
            -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
            -webkit-mask-composite: xor;
            mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
            mask-composite: exclude;
            pointer-events: none;
        }

        .is-scrolling .glass {
            -webkit-backdrop-filter: none;
            backdrop-filter: none;
            background: var(--color-card);
        }
    }

    /* ── Radzen design tokens ─────────────────────────────────────────────────────────────────
       Radzen reads plain --rz-* custom properties at render time, so mapping them onto the
       design tokens themes every Radzen control at once — including in dark mode, since these
       resolve to tokens that already flip. This is the whole reason the primary CTA was Material
       blue: nothing had told Radzen what "primary" means here. */
    :root {
        --rz-primary: var(--color-brand);
        --rz-primary-light: var(--color-brand);
        --rz-primary-dark: var(--color-brand-dark);
        --rz-on-primary: #ffffff;
        --rz-secondary: var(--color-text-secondary);
        --rz-on-secondary: var(--color-card);
        --rz-success: var(--color-success);
        --rz-on-success: var(--color-on-accent);
        --rz-danger: var(--color-danger);
        --rz-warning: var(--color-accent);
        --rz-on-warning: var(--color-on-accent);
        --rz-info: var(--color-brand);

        --rz-base-background-color: var(--color-surface);
        --rz-base-color: var(--color-text-primary);
        --rz-text-color: var(--color-text-primary);
        --rz-text-secondary-color: var(--color-text-secondary);
        --rz-text-disabled-color: var(--color-text-muted);
        --rz-border-radius: var(--radius-sm);
        --rz-card-background-color: var(--color-card);
        --rz-card-border: 1px solid var(--color-border);
        --rz-card-box-shadow: var(--shadow-card);
        --rz-input-background-color: var(--color-card);
        --rz-input-border: 1px solid var(--color-border-strong);
        --rz-input-color: var(--color-text-primary);
        --rz-body-font-family: var(--font-body);
        --rz-body-font-size: var(--text-md);
    }

    /* ── Location CTA ────────────────────────────────────────────────────────────────────────
       Shared rather than scoped so the primary CTA cannot be forked per page. Radzen renders a
       real <button>, so one rule now does what previously took a host rule plus a ::part()
       rule to reach through Fluent's shadow DOM. */
    .location-btn {
        width: 100%;
        min-height: var(--tap-target);
        justify-content: center;
        font-weight: 700;
    }

    /* ── Blazor framework UI ─────────────────────────────────────────────────────────────── */
    h1:focus {
        outline: none;
    }

    #blazor-error-ui {
        color-scheme: light only;
        background: lightyellow;
        bottom: 0;
        box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
        box-sizing: border-box;
        display: none;
        left: 0;
        padding: var(--space-sm) var(--space-lg);
        position: fixed;
        width: 100%;
        z-index: 1000;
    }

    #blazor-error-ui .dismiss {
        cursor: pointer;
        position: absolute;
        right: var(--space-sm);
        top: var(--space-xs);
    }

    .blazor-error-boundary {
        background: #b32121;
        padding: var(--space-md);
        color: #ffffff;
    }

    .blazor-error-boundary::after {
        content: "An error has occurred."
    }

    /* Boot UI: renders before the app starts, so it cannot depend on anything the app sets —
       but CSS variables are already parsed by then, so tokens are safe here.

       #app owns the full viewport and CENTRES the splash, rather than the splash pushing
       itself down with a top margin. That margin was a measured 0.20 (mobile) / 0.14 (desktop)
       CLS on every full page load: the splash sat at y=20dvh, and when Blazor swapped the app
       in, `body` snapped back to y=0. Login and logout both navigate with forceLoad, so users
       paid it repeatedly. Centring means splash and app occupy the same box and nothing
       translates on the swap. */
    #app:has(.loading-progress) {
        min-height: 100dvh;
        display: grid;
        place-content: center;
        justify-items: center;
    }

    /* Ring and percentage share one grid cell, so the text overlays the ring's centre without
       any dvh arithmetic to keep in sync between the two rules. */
    .loading-progress,
    .loading-progress-text {
        grid-area: 1 / 1;
    }

    .loading-progress {
        position: relative;
        display: block;
        width: 8rem;
        height: 8rem;
        margin: 0;
    }

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

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

    .loading-progress-text {
        align-self: center;
        justify-self: center;
        text-align: center;
        font-weight: bold;
        color: var(--color-text-primary);
    }

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

/* ============================================================================================
   UTILITIES — highest layer, so a utility always wins. That is what a utility is for.
   ============================================================================================ */

@layer utilities {
    .visually-hidden {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip-path: inset(50%);
        white-space: nowrap;
        border: 0;
    }

    /* One focus ring for the whole app. Two colours layered so it stays visible against both a
       light card and a dark hero — a single-colour ring disappears on one of them. */
    :focus-visible {
        outline: 3px solid var(--color-brand);
        outline-offset: 2px;
        box-shadow: 0 0 0 5px rgba(255, 255, 255, 0.85);
        border-radius: var(--radius-sm);
    }

    :root[data-theme="dark"] :focus-visible {
        box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.75);
    }

    @media (prefers-color-scheme: dark) {
        :root:not([data-theme="light"]) :focus-visible {
            box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.75);
        }
    }

    /* Container-query host. Put this on a card's wrapper and the card can size itself from the
       space it is actually given rather than from the viewport — the same component then works
       in the discovery grid, the leaderboard column, and a full-width slot without three sets of
       media queries. */
    .cq-card {
        container-type: inline-size;
        container-name: card;
    }

    .cq-list {
        container-type: inline-size;
        container-name: list;
    }

    /* ── Background gradient canvas (FX #2) ──────────────────────────────────────────────── */
    .fx-backdrop {
        position: fixed;
        inset: 0;
        z-index: -1;
        width: 100%;
        height: 100%;
        pointer-events: none;
        background: linear-gradient(160deg, var(--color-surface) 0%, var(--color-brand-surface) 100%);
    }

    /* ── Overlay canvases (FX #3, #4, #7) ────────────────────────────────────────────────── */
    .fx-overlay {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        pointer-events: none;
        border-radius: inherit;
    }

    @media (prefers-reduced-motion: reduce) {
        .btn:hover {
            transform: none;
        }

        /* Collapse the motion scale rather than hunting down each transition that uses it.
           Duration goes to zero, so anything written against these tokens becomes an instant
           state change — the end states stay identical, which is the point: reduced motion means
           no animation, not a different design. Easings are left alone; they are meaningless at
           zero duration and overriding them would only add noise to the computed styles. */
        :root {
            --duration-instant: 0ms;
            --duration-fast: 0ms;
            --duration-base: 0ms;
            --duration-slow: 0ms;
            --duration-deliberate: 0ms;
        }

        .elevation-raise:hover {
            transform: none;
        }

        .fx-backdrop,
        .fx-overlay {
            display: none;
        }

        .glass {
            -webkit-backdrop-filter: none;
            backdrop-filter: none;
            background: var(--color-card);
        }
    }
}

/* ============================================================================================
   VIEW TRANSITIONS (#8)
   Unlayered on purpose: ::view-transition pseudo-elements live on the document root, outside
   the normal cascade, so a layer buys nothing here.
   ============================================================================================ */

@media (prefers-reduced-motion: no-preference) {
    ::view-transition-old(root),
    ::view-transition-new(root) {
        animation-duration: 220ms;
        animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
    }

    /* The tapped card morphs into the comic panel. Slightly slower than the page cross-fade so
       the shared element reads as the thing being followed, not as a flicker.

       Both halves now carry the name — js/view-transitions.js tags the source on the click and
       the arriving .comic-strip-container in settle(). With the name on only one of them the
       browser has nothing to pair, and what looked like a morph was really the card fading out
       while the comic cross-faded in from nowhere. */
    ::view-transition-group(comic-morph) {
        animation-duration: 380ms;
        animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
    }

    /* Cross-fade the two snapshots while the group interpolates position and size. The old one
       leaves quickly and the new one arrives over the whole duration, so the middle of the
       transition is dominated by the destination rather than by a fifty-fifty blend of two
       differently-shaped images. */
    ::view-transition-old(comic-morph) {
        animation: posee-morph-out 190ms cubic-bezier(0.22, 1, 0.36, 1) both;
    }

    ::view-transition-new(comic-morph) {
        animation: posee-morph-in 380ms cubic-bezier(0.22, 1, 0.36, 1) both;
    }

    /* ── Direction of travel ──────────────────────────────────────────────────────────────
       A transition that always moves the same way is a cross-fade with extra steps. Depth is
       stamped on <html> as data-nav-direction before the snapshot: going deeper (a list to a
       comic) slides in from the right, coming back slides in from the left, and the pair reads
       as one stack rather than as unrelated pages.

       Small offsets — 3% — because this runs on every internal navigation. Anything larger
       becomes the app's dominant motion by repetition, and the reduced-motion block below
       removes it entirely for anyone who asked. */
    :root[data-nav-direction="forward"]::view-transition-new(root) {
        animation: posee-slide-from-right 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
    }

    :root[data-nav-direction="back"]::view-transition-new(root) {
        animation: posee-slide-from-left 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
    }
}

@keyframes posee-morph-out {
    to { opacity: 0; }
}

@keyframes posee-morph-in {
    from { opacity: 0; }
    to   { opacity: 1; }
}

@keyframes posee-slide-from-right {
    from { opacity: 0; transform: translateX(3%); }
    to   { opacity: 1; transform: translateX(0); }
}

@keyframes posee-slide-from-left {
    from { opacity: 0; transform: translateX(-3%); }
    to   { opacity: 1; transform: translateX(0); }
}

/* Belt and braces. js/view-transitions.js already refuses to start under reduced motion; this
   makes the CSS half independently true, so a future caller cannot reintroduce the animation. */
@media (prefers-reduced-motion: reduce) {
    ::view-transition-group(*),
    ::view-transition-old(*),
    ::view-transition-new(*) {
        animation: none !important;
    }
}

/* ============================================================================================
   CONTAINER QUERIES (#7)
   ============================================================================================

   These components respond to the width of the SLOT they are placed in, not the viewport. That
   is what actually matters: a restaurant card is narrow in a three-column grid on a wide desktop
   and wide in a single-column phone layout, and a viewport media query gets that exactly
   backwards. It also means the same card works in the discovery grid, in a leaderboard row, and
   in a full-width slot without three sets of breakpoints.

   Layered under `utilities` so a page can still opt out.
   ============================================================================================ */

@layer utilities {
    /* ── Elevation utilities ─────────────────────────────────────────────────────────────────
       Apply a step as a pair. Reaching for `box-shadow: var(--shadow-md)` on its own is what
       these exist to replace: it is correct in light mode and invisible in dark, because a black
       shadow on a near-black surface conveys no height. See the token block for the full note. */
    .elevation-1 { background: var(--elevation-1-surface); box-shadow: var(--elevation-1-shadow); }
    .elevation-2 { background: var(--elevation-2-surface); box-shadow: var(--elevation-2-shadow); }
    .elevation-3 { background: var(--elevation-3-surface); box-shadow: var(--elevation-3-shadow); }
    .elevation-4 { background: var(--elevation-4-surface); box-shadow: var(--elevation-4-shadow); }

    /* Raising on hover moves BOTH halves of the pair, or the tint and the shadow disagree about
       how high the card is. Pointer-only: on touch there is no hover state to leave, so the card
       would stick raised after a tap. */
    @media (hover: hover) {
        .elevation-raise {
            transition:
                box-shadow var(--duration-base) var(--ease-out),
                background-color var(--duration-base) var(--ease-out),
                transform var(--duration-base) var(--ease-out);
        }

        .elevation-raise:hover {
            background: var(--elevation-3-surface);
            box-shadow: var(--elevation-3-shadow);
            transform: translateY(-2px);
        }
    }

    /* Narrow slot: stack the card's header so the name is not squeezed against the rating. */
    @container card (max-width: 20rem) {
        .restaurant-card .card-header {
            flex-direction: column;
            align-items: flex-start;
            gap: var(--space-2xs);
        }

        .restaurant-card .card-footer {
            flex-direction: column;
            align-items: stretch;
            gap: var(--space-xs);
        }

        .restaurant-card .cta-btn {
            width: 100%;
        }
    }

    /* Roomy slot: let the CTA sit inline with the metadata instead of on its own row. */
    @container card (min-width: 30rem) {
        .restaurant-card .card-footer {
            flex-direction: row;
            align-items: center;
            justify-content: space-between;
        }

        .restaurant-card .cta-btn {
            width: auto;
        }
    }

    /* Leaderboard rows collapse to a stacked layout when their column is narrow — which happens
       on a phone AND on a desktop where the list shares space with something else. */
    @container list (max-width: 34rem) {
        .leaderboard-card {
            flex-direction: column;
            align-items: stretch;
            text-align: center;
        }

        .leaderboard-card .comic-thumbnail {
            margin-inline: auto;
        }
    }
}

/* ============================================================================================
   REGISTERED CUSTOM PROPERTIES

   @property is not decoration here. An unregistered custom property is an untyped token: the
   browser cannot interpolate it, so `transition: --x` does nothing and an @keyframes that moves
   it snaps between whole values. Registering the syntax makes them animatable, and gives each a
   defined initial value so a rule that reads one before JS has written it renders correctly
   rather than invalidly.

   These sit at the top level. @property registrations are global and are not affected by layer
   order, so nesting them inside @layer would only make them harder to find.
   ============================================================================================ */

@property --comic-reveal {
    syntax: "<number>";
    inherits: true;
    /* 1 = fully developed. The mask below is written so that the resting value shows the whole
       comic — if js/comic-reveal.js never runs, or fails partway, the strip is simply visible. */
    initial-value: 1;
}

@property --sweep-angle {
    syntax: "<angle>";
    inherits: false;
    initial-value: 0deg;
}

/* ============================================================================================
   INK DEVELOPMENT (the comic reveal)

   Lives in `shared` because it is a component behaviour, and because `page` sits below `shared`
   — a scoped sheet cannot silently fork it, which is exactly the rule the layer order exists to
   enforce.

   The mask is applied to the CONTAINER, so it covers the <img> and the post-process canvas
   together. Masking only one of them would develop the shader layer over an already-visible
   copy of the finished comic.

   Only while `data-comic-reveal` is present. js/comic-reveal.js removes the attribute before it
   clears the property, so there is never a frame where a masked element has no reveal value.
   ============================================================================================ */

@layer shared {
    @supports ((mask-image: linear-gradient(#000, #000)) or (-webkit-mask-image: linear-gradient(#000, #000))) {
        .comic-strip-container[data-comic-reveal] {
            /* The gradient stops slide upward as --comic-reveal rises, so the soft boundary
               travels the full height and then keeps going — at 1 the opaque region has passed
               the bottom edge and nothing is masked. Stops that only reached 100% would leave
               the last sliver permanently under the soft edge. */
            --_reveal-shift: calc((1 - var(--comic-reveal)) * 130%);

            -webkit-mask-image: linear-gradient(
                to bottom,
                #000 0%,
                #000 calc(60% - var(--_reveal-shift)),
                rgb(0 0 0 / 0.55) calc(68% - var(--_reveal-shift)),
                transparent calc(78% - var(--_reveal-shift)),
                transparent 100%);
            mask-image: linear-gradient(
                to bottom,
                #000 0%,
                #000 calc(60% - var(--_reveal-shift)),
                rgb(0 0 0 / 0.55) calc(68% - var(--_reveal-shift)),
                transparent calc(78% - var(--_reveal-shift)),
                transparent 100%);
            -webkit-mask-size: 100% 100%;
            mask-size: 100% 100%;
        }
    }

    /* Reduced motion never gets here — comic-reveal.js refuses to start below the `lite` tier,
       which reduced motion forces to `off`. This makes the CSS half independently true, so a
       future caller cannot reintroduce the effect by writing the attribute directly. */
    @media (prefers-reduced-motion: reduce) {
        .comic-strip-container[data-comic-reveal] {
            -webkit-mask-image: none;
            mask-image: none;
        }
    }
}

/* ============================================================================================
   SCROLL-DRIVEN ENTRANCES

   Every other motion in this app costs frame budget: it runs in the shared rAF loop, it is
   measured, and it can trip the auto-downgrade. These cost NONE of it. A scroll-driven animation
   is evaluated by the compositor off the main thread, so a hundred cards entering is free in the
   only place this app measures.

   Which is the whole reason to prefer it over an IntersectionObserver plus a class toggle — that
   costs an observer, a callback, a style recalculation and a DOM write per card, to produce the
   same fade.

   Progressive by construction: without @supports the cards are simply already visible. There is
   no polyfill and none is wanted.
   ============================================================================================ */

@keyframes posee-enter {
    from {
        opacity: 0;
        transform: translateY(1rem) scale(0.985);
    }
    to {
        opacity: 1;
        transform: none;
    }
}

@keyframes posee-sweep {
    to {
        --sweep-angle: 360deg;
    }
}

@layer utilities {
    @supports (animation-timeline: view()) {
        @media (prefers-reduced-motion: no-preference) {
            /* `both` matters: without it the card sits at its authored (visible) state before
               the range begins, and the entrance never plays for anything below the fold. */
            .scroll-enter {
                animation: posee-enter linear both;
                animation-timeline: view();
                /* Finished well before the card reaches the middle of the screen. An animation
                   still running once the reader has arrived at the content is a distraction
                   rather than an entrance. */
                animation-range: entry 2% cover 20%;
            }

            /* Staggering with nth-child would need a rule per position and breaks the moment the
               list is filtered or sorted. Ranges do it for free: each card animates against its
               OWN position in the scrollport, so the stagger falls out of the layout. */
            .scroll-enter-late {
                animation: posee-enter linear both;
                animation-timeline: view();
                animation-range: entry 8% cover 30%;
            }
        }
    }

    /* An animated conic sweep, used behind the score ring while the count-up runs. This is what
       registering the property buys: --sweep-angle is a real <angle>, so it interpolates, and
       the rotation is one compositor-side animation instead of a per-frame JS write to a
       background-image string. */
    @supports (background: conic-gradient(from 0deg, red, blue)) {
        @media (prefers-reduced-motion: no-preference) {
            .sweep-halo {
                background: conic-gradient(from var(--sweep-angle),
                    transparent 0deg,
                    color-mix(in srgb, var(--color-brand) 55%, transparent) 40deg,
                    transparent 120deg,
                    transparent 360deg);
                animation: posee-sweep 6s linear infinite;
            }
        }
    }
}

/* ============================================================================================
   DISCRETE-PROPERTY TRANSITIONS

   `@starting-style` plus `transition-behavior: allow-discrete` is the first way to animate an
   element that is being ADDED to the DOM without a JS mount hook — which matters here because
   Blazor adds and removes the toast, the report dialog and the install nudge by re-rendering,
   and there is no lifecycle point in between to hang a class on.

   Both halves are needed. `@starting-style` supplies the "before" frame; allow-discrete lets
   `display` participate, so the exit is not cut off the moment display flips.
   ============================================================================================ */

@layer shared {
    @supports (transition-behavior: allow-discrete) {
        @media (prefers-reduced-motion: no-preference) {
            .toast {
                transition:
                    opacity var(--duration-base) var(--ease-out),
                    transform var(--duration-base) var(--ease-out),
                    display var(--duration-base) allow-discrete;
            }

            @starting-style {
                .toast {
                    opacity: 0;
                    /* Rises into place. The toast is anchored to the bottom, and something
                       arriving from below reads as coming from the control that produced it. */
                    transform: translateY(0.75rem);
                }
            }

            /* The dialog settles DOWN into place rather than up: it is modal, so it should read
               as something placed in front of the page rather than emitted by it. */
            .report-dialog-panel,
            .install-prompt {
                transition:
                    opacity var(--duration-base) var(--ease-out),
                    transform var(--duration-base) var(--ease-out),
                    display var(--duration-base) allow-discrete;
            }

            @starting-style {
                .report-dialog-panel {
                    opacity: 0;
                    transform: translateY(-0.5rem) scale(0.98);
                }

                .install-prompt {
                    opacity: 0;
                    transform: translateY(0.75rem);
                }
            }
        }
    }
}

/* ============================================================================================
   FRESHNESS

   Comics expire in 24 hours, and /my-comics is mostly a list of things that are about to. A
   binary expired/not-expired styling tells the user nothing until the link is already dead;
   grading it means the list itself communicates the deadline.

   Three states, written as a data attribute from C# rather than as an inline style — this repo
   bans inline styles, and a computed enum on the element is the honest way to express "which of
   three states is this in".
   ============================================================================================ */

@layer utilities {
    /* Fading: past the halfway mark of its 24 hours. Desaturated and a little transparent —
       legible, obviously still usable, visibly on its way out. */
    [data-freshness="fading"] .history-thumb img {
        filter: saturate(0.55) contrast(0.94);
        opacity: 0.82;
        transition: filter var(--duration-slow) var(--ease-out),
                    opacity var(--duration-slow) var(--ease-out);
    }

    @media (hover: hover) {
        /* Hovering restores it. The decay is a status indicator, not a punishment, and not being
           able to see the thing you are deciding about would be the wrong trade. */
        [data-freshness="fading"]:hover .history-thumb img {
            filter: none;
            opacity: 1;
        }
    }

    /* Expired: the blob is gone, so there is no image — this is the placeholder tile. Muted to
       the point of reading as inert, because the only useful action left on it is Redraw. */
    [data-freshness="expired"] .history-thumb-expired {
        opacity: 0.7;
    }

    [data-freshness="expired"] .history-name {
        color: var(--color-text-muted);
    }

    /* ── Paper ageing ─────────────────────────────────────────────────────────────────────
       The desaturation above says "older". This says "on paper, and it is going" — grain, uneven
       foxing, and a curl at the corner.

       The texture is one small tileable PNG built once by js/paper.js and handed over as
       --paper-grain. That is the whole reason this is affordable on a grid: after the property is
       set there is no rAF task, no observer and nothing registered with the shared scheduler —
       it is `background-image` and `mix-blend-mode`, which is painting the browser was doing
       anyway. An feTurbulence filter would be re-evaluated on every paint of the thumbnails,
       which are the heaviest elements on the page.

       Every rule resolves `var(--paper-grain, none)`, so a browser where the texture could not be
       built simply keeps the desaturation. Nothing here is load-bearing. */
    [data-freshness="fading"] .history-thumb::after,
    [data-freshness="expired"] .history-thumb::after {
        content: "";
        position: absolute;
        inset: 0;
        pointer-events: none;
        border-radius: inherit;
        background-image: var(--paper-grain, none);
        background-repeat: repeat;
        /* Multiply so the grain darkens what is under it instead of laying a grey film over it.
           The texture is black-with-varying-alpha precisely so this works over either theme. */
        mix-blend-mode: multiply;
        opacity: 0.55;
    }

    [data-freshness="expired"] .history-thumb::after {
        opacity: 0.85;
    }

    /* The thumb has to be a containing block for the overlay above, and the anchor is not
       positioned by default. */
    [data-freshness="fading"] .history-thumb,
    [data-freshness="expired"] .history-thumb {
        position: relative;
    }

    /* The curl. A corner lifting off the page, drawn as a gradient rather than a transform,
       because transforming the thumbnail would move the link's hit area away from the pixels the
       user is aiming at. */
    [data-freshness="expired"] .history-thumb::before {
        content: "";
        position: absolute;
        inset: auto 0 0 auto;
        width: 34%;
        aspect-ratio: 1;
        pointer-events: none;
        z-index: 1;
        background: linear-gradient(
            315deg,
            var(--color-surface-alt) 0%,
            color-mix(in srgb, var(--color-surface-alt) 60%, transparent) 42%,
            transparent 52%);
    }

    @media (hover: hover) {
        /* Hover clears the grain along with the desaturation. The decay is a status indicator,
           not a punishment, and not being able to see the thing you are deciding about would be
           the wrong trade — the same reasoning as the filter rule above, and they have to agree
           or hovering half-restores a card. */
        [data-freshness="fading"]:hover .history-thumb::after {
            opacity: 0;
            transition: opacity var(--duration-slow) var(--ease-out);
        }
    }
}

/* ============================================================================================
   COMIC TINT — the page wears the comic's own colours

   Three hex values sampled from the finished artwork on the SERVER (ComicPaletteExtractor) and
   published here by js/comic-tint.js as --comic-tint-1..3. Not sampled in the browser: the comic
   blob is served without CORS headers, so a canvas that has drawn it is tainted and cannot be
   read back — the same constraint that stops comic-fx.js attaching for most visitors.

   WHAT THE TINT MAY TOUCH IS DELIBERATELY A SHORT LIST: accents only — ring stroke, ring glow,
   card rim, reaction chip edge. Never a text colour and never a text background. Every readable
   pair in this app is measured against WCAG by ColorContrastTests, which parses the real token
   values out of this file; a colour invented at runtime by an image model is exactly what that
   test cannot cover. So the tint decorates edges that carry no information on their own, and
   every pair a reader has to read stays on the audited tokens.

   `shared`, not `page`, on purpose: the score ring lives in a scoped sheet, and `page` sits
   BELOW `shared` in the layer order — so these win without a single !important, which is the
   whole reason that order exists.
   ============================================================================================ */

@layer shared {
    /* The SVG gradient the ring is stroked with. `stop-color` is a real CSS property, so var()
       resolves here — unlike an SVG presentation *attribute*, which does not, and which is why
       the Insights charts have to resolve their colours through theme-tokens.js instead. The
       fallbacks are the brand stops the markup already carries, so a comic with no palette is
       byte-identical to what shipped before. */
    :root[data-comic-tint] .strangeness-ring linearGradient stop:first-child {
        stop-color: var(--comic-tint-3, #7C3AED);
    }

    :root[data-comic-tint] .strangeness-ring linearGradient stop:last-child {
        stop-color: var(--comic-tint-2, #f5576c);
    }

    /* A transition on the stroke rather than a swap. The palette lands at the same moment the
       score does, and the backdrop underneath is easing over roughly a second — a ring that
       snapped would be the one element out of step with it. */
    :root[data-comic-tint] .ring-fill {
        transition: stroke-dasharray var(--duration-base) var(--ease-out),
                    filter var(--duration-deliberate) var(--ease-out);
        filter: drop-shadow(0 0 6px color-mix(in srgb, var(--comic-tint-3) 55%, transparent));
    }

    /* The comic card takes the artwork's rim. Kept to the border and a wash of shadow: the card
       is a text surface, so its background stays on --color-card and the contrast tokens hold. */
    :root[data-comic-tint] .comic-container {
        border-color: color-mix(in srgb, var(--comic-tint-3) 42%, var(--color-border));
        box-shadow:
            0 1px 0 0 color-mix(in srgb, var(--comic-tint-3) 20%, transparent) inset,
            0 10px 40px color-mix(in srgb, var(--comic-tint-1) 30%, transparent);
        transition: border-color var(--duration-deliberate) var(--ease-out),
                    box-shadow var(--duration-deliberate) var(--ease-out);
    }

    /* Reaction chips pick up the edge only. The "mine" state keeps its own token fill, because
       that one is a state a user has to be able to read at a glance and an arbitrary colour
       would make it unreliable. */
    :root[data-comic-tint] .reaction:not(.reaction--mine) {
        border-color: color-mix(in srgb, var(--comic-tint-3) 38%, var(--color-border));
    }

    @media (prefers-reduced-motion: reduce) {
        :root[data-comic-tint] .ring-fill,
        :root[data-comic-tint] .comic-container {
            transition: none;
        }
    }
}

/* ============================================================================================
   DISCOVERY: THE ARRIVAL SEQUENCE

   The landing page is the first screen every visitor sees and it had, in effect, no feedback of
   its own — a tap sound shared with every other button, then skeletons, then a grid. The three
   states below are the three things actually happening, and none of them costs frame budget:
   everything here animates transform and opacity, which the compositor evaluates off the main
   thread.
   ============================================================================================ */

@layer shared {
    /* A request in flight. Two rings leaving the card, offset by half the period so there is
       always one in view — one ring alone has a dead beat between repeats that reads as the
       animation having stopped rather than as a pulse.

       Anchored to the card and NOT to the button: the answer to this request is often a browser
       permission dialog, which can sit unanswered indefinitely. A spinner next to a disabled
       button says "stuck"; a ring still leaving says "waiting on you". */
    @media (prefers-reduced-motion: no-preference) {
        .prompt-card--searching {
            position: relative;
        }

        .prompt-card--searching::after,
        .prompt-card--searching::before {
            content: "";
            position: absolute;
            inset: 0;
            border-radius: inherit;
            border: 1px solid var(--color-brand);
            pointer-events: none;
            opacity: 0;
            animation: posee-sonar 2.4s var(--ease-out) infinite;
        }

        .prompt-card--searching::before {
            animation-delay: 1.2s;
        }
    }
}

@keyframes posee-sonar {
    from { transform: scale(1); opacity: 0.5; }
    to   { transform: scale(1.06); opacity: 0; }
}

/* ============================================================================================
   READY vs COSTS SOMETHING

   A cache hit opens instantly and spends nothing. A miss spends a paid image call and about ten
   seconds. Those are adjacent cards in one grid and, until this, the only place the app drew the
   distinction at all was pin colour on a map panel most people never open.

   The treatment is deliberately asymmetric. "Ready" gets a live sheen, because it is the one the
   app would rather you picked; "new" gets a quieter, static dashed edge rather than a warning
   colour, because spending a generation is the product working as intended and dressing it as a
   hazard would be a lie.
   ============================================================================================ */

@layer shared {
    .cache-pill {
        display: inline-flex;
        align-items: center;
        gap: var(--space-2xs);
        margin-left: auto;
        padding: 0.2rem 0.5rem;
        border-radius: 999px;
        font-size: var(--text-2xs);
        font-weight: 700;
        letter-spacing: 0.02em;
        white-space: nowrap;
        border: 1px solid var(--color-border);
        color: var(--color-text-muted);
    }

    .cache-dot {
        width: 0.45rem;
        height: 0.45rem;
        border-radius: 50%;
        background: currentColor;
        flex: none;
    }

    /* --color-success-ink, not --color-success: the token pair split exists because the bright
       one is a background and the ink one is the readable text version. Using the surface colour
       as text here is exactly the mistake the split was introduced to stop. */
    [data-cached="ready"] .cache-pill {
        color: var(--color-success-ink);
        border-color: color-mix(in srgb, var(--color-success) 45%, transparent);
        background: color-mix(in srgb, var(--color-success) 10%, transparent);
    }

    [data-cached="ready"] .cache-dot {
        background: var(--color-success);
    }

    /* The sheen. A pseudo-element translated across a clipped card — transform only, so it is a
       compositor animation. A background-position sweep would be the obvious way to write this
       and would repaint the whole card every frame, for every card in the grid at once. */
    @media (prefers-reduced-motion: no-preference) {
        /* `clip`, not `hidden`, and the margin is the reason. The sheen has to be clipped to the
           card or it sweeps across its neighbours — but `overflow: hidden` would also clip the
           CTA button's focus ring where it sits near the card edge, which trades a decoration in
           for a keyboard-accessibility regression. `overflow-clip-margin` clips the sheen and
           lets the ring bleed. */
        :root[data-fx-tier="full"] [data-cached="ready"]:not(.disabled) {
            overflow: clip;
            overflow-clip-margin: 6px;
        }

        :root[data-fx-tier="full"] [data-cached="ready"]:not(.disabled)::after {
            content: "";
            position: absolute;
            inset: 0 auto 0 0;
            width: 40%;
            pointer-events: none;
            background: linear-gradient(
                100deg,
                transparent,
                color-mix(in srgb, var(--color-success) 16%, transparent),
                transparent);
            transform: translateX(-120%);
            /* Long period on purpose. A grid of these at a fast cadence is a disco; at eleven
               seconds a card catches the light about once while you are reading the one above it. */
            animation: posee-sheen 11s var(--ease-in-out) infinite;
        }
    }

    [data-cached="new"] .cache-pill {
        border-style: dashed;
    }
}

@keyframes posee-sheen {
    0%   { transform: translateX(-120%); }
    /* The sweep occupies the first fifth of the period; the rest is the card sitting still. */
    22%  { transform: translateX(350%); }
    100% { transform: translateX(350%); }
}

/* ============================================================================================
   REFRACTIVE GLASS (js/glass.js)

   The pane canvas sits behind its card's content and standing in for the CSS `.glass` material,
   which it replaces only once the shader has actually started. That handshake is the whole
   safety of this feature: js/glass.js sets `data-glass-pane` on the parent AFTER a successful
   compile, and clears it unconditionally on teardown, so every path that does not produce a
   running shader leaves the blur exactly as it was.

   Note what is NOT here: no fallback, no @supports, no reduced-motion rule of its own. There is
   nothing to fall back to — the absence of the attribute IS the fallback, and the shader refuses
   to start under reduced motion or below the `full` tier before any of this is reached.
   ============================================================================================ */

@layer shared {
    .fx-glass-canvas {
        /* Behind the card's own content but above its background. Everything else in the card
           establishes no stacking context of its own, so a single negative-free z-index here is
           enough — and `isolation` on the card keeps that local. */
        z-index: 0;
    }

    .glass:has(> .fx-glass-canvas) {
        isolation: isolate;
    }

    .glass:has(> .fx-glass-canvas) > *:not(.fx-glass-canvas) {
        position: relative;
        z-index: 1;
    }

    /* Once a pane is running, the CSS material stands down. Both halves matter: the blur is the
       expensive one (it is why scroll-guard.js exists), and leaving the translucent tint on top
       of a pane that is already transmitting the backdrop double-tints it into mud. */
    @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
        :root[data-fx-tier="full"] .glass[data-glass-pane="on"] {
            -webkit-backdrop-filter: none;
            backdrop-filter: none;
            background: transparent;
        }

        /* The CSS rim would sit on top of the shader's own, which draws a real specular edge
           from the SDF gradient. Two rims read as a double border. */
        :root[data-fx-tier="full"] .glass[data-glass-pane="on"]::before {
            display: none;
        }

        /* And the scroll guard has nothing to switch off any more — a shader pass does not get
           cheaper by being told the page is moving, and dropping the background here would flash
           an opaque card in mid-scroll. */
        .is-scrolling .glass[data-glass-pane="on"] {
            background: transparent;
        }
    }
}

/* ============================================================================================
   BOARD MOVEMENT

   The live Hall of Fame churns — comics expire in 24 hours and the ranking turns over with them
   — so a returning visitor had no way to tell a board that had completely reshuffled from one
   that had not moved at all. A rank is a fact; a rank next to where it was is the only thing on
   the page that rewards coming back.

   `still` is deliberately the same as a first visit. "Nothing happened" and "we have no record"
   should look identical, because in both cases there is nothing to point at — and a first visit
   that marked all ten rows as new would be noise pretending to be information.
   ============================================================================================ */

@layer shared {
    .rank-delta {
        display: inline-flex;
        align-items: center;
        gap: 0.15rem;
        margin-top: var(--space-2xs);
        padding: 0.1rem 0.35rem;
        border-radius: 999px;
        font-size: var(--text-2xs);
        font-weight: 800;
        line-height: 1;
        font-variant-numeric: tabular-nums;
    }

    /* The ink tokens, not the surface ones. --color-success is a bright background and
       --color-success-ink is its readable text form; the split exists precisely so a badge like
       this cannot be written with the wrong half. */
    .rank-delta[data-direction="up"] {
        color: var(--color-success-ink);
        background: color-mix(in srgb, var(--color-success) 14%, transparent);
    }

    .rank-delta[data-direction="down"] {
        color: var(--color-text-muted);
        background: color-mix(in srgb, var(--color-border-strong) 22%, transparent);
    }

    @media (prefers-reduced-motion: no-preference) {
        /* Rows that moved arrive FROM the direction they moved, then settle. This runs once on
           mount rather than on the scroll timeline that .scroll-enter uses: a scroll-driven
           entrance replays whenever the row re-enters the viewport, and "this climbed two places"
           is a fact about the visit, not about where the page is scrolled. */
        [data-move="up"] .rank-badge,
        [data-move="down"] .rank-badge {
            animation: posee-rank-settle var(--duration-deliberate) var(--ease-spring) both;
        }

        [data-move="down"] .rank-badge {
            animation-name: posee-rank-settle-down;
            /* Not the spring. Overshoot on a fall reads as a bounce, and a restaurant losing
               rank should not look pleased about it. */
            animation-timing-function: var(--ease-out);
        }
    }
}

@keyframes posee-rank-settle {
    from { transform: translateY(14px); opacity: 0; }
    to   { transform: translateY(0); opacity: 1; }
}

@keyframes posee-rank-settle-down {
    from { transform: translateY(-10px); opacity: 0; }
    to   { transform: translateY(0); opacity: 1; }
}

/* Where a forgotten comic goes. Fixed rather than absolute: the grid scrolls and the bodies are
   simulated in viewport space, so an absolutely-positioned canvas would leave the dismissal
   behind as soon as the user scrolled. aria-hidden and pointer-events:none like every overlay
   canvas in this app — the list underneath is the real feature. */
@layer shared {
    .fx-dust-canvas {
        position: fixed;
        inset: 0;
        width: 100%;
        height: 100%;
        pointer-events: none;
        z-index: 5;
    }

    @media (prefers-reduced-motion: reduce) {
        .fx-dust-canvas {
            display: none;
        }
    }
}

/* ============================================================================================
   READING THE STRIP

   A travelling highlight down the comic as it scrolls past, so the panel at the middle of the
   screen is the one lit. The notes that go with it are js/panel-scrub.js; this half is the part
   that has to be free.

   `animation-timeline: view()` means the compositor drives it from the element's own position in
   the scrollport — no scroll handler, no observer, no rAF task, nothing in the shared 20ms
   budget. That matters here more than almost anywhere else in the app: this animates the largest
   image on the page while the user is scrolling, which is exactly when the main thread is busy.

   The sentinels panel-scrub.js appends are zero-size and non-interactive; they exist only to be
   observed and must never affect layout.
   ============================================================================================ */

@layer shared {
    .panel-marker {
        position: absolute;
        left: 0;
        width: 100%;
        height: 1px;
        pointer-events: none;
        visibility: hidden;
    }

    @supports (animation-timeline: view()) {
        @media (prefers-reduced-motion: no-preference) {
            /* On the CONTAINER, so the highlight covers the image and the post-process canvas
               together — the same reason the reveal mask is on the container. Lighting only the
               <img> would put a bright band under a shader layer drawn on top of it. */
            :root[data-fx-tier="full"] .comic-strip-container[data-panel-scrub]::after {
                content: "";
                position: absolute;
                inset: 0;
                pointer-events: none;
                z-index: 3;
                border-radius: inherit;
                /* A soft band, not a hard edge. The gradient stop positions are animated below,
                   which is a paint on one element rather than a filter over the artwork —
                   brightening the image itself would mean re-decoding it into a filtered layer. */
                background: linear-gradient(
                    to bottom,
                    transparent 0%,
                    color-mix(in srgb, var(--color-on-dark, #fff) 9%, transparent) 42%,
                    color-mix(in srgb, var(--color-on-dark, #fff) 14%, transparent) 50%,
                    color-mix(in srgb, var(--color-on-dark, #fff) 9%, transparent) 58%,
                    transparent 100%);
                mix-blend-mode: soft-light;
                animation: posee-reading-light linear both;
                animation-timeline: view();
                /* Spans the whole time the strip is in the scrollport, so the band travels the
                   full height of the artwork exactly once per pass. `both` is required or the
                   strip sits at the authored end state before it is ever scrolled to. */
                animation-range: entry 0% exit 100%;
            }
        }
    }
}

@keyframes posee-reading-light {
    from { transform: translateY(-100%); }
    to   { transform: translateY(100%); }
}
