﻿/* ── Ginger Ninjalytics design tokens (ADR-001 / DRIP ADR-0007) ──────────────────
   Tokens are defined only here, in the shell. Module RCLs consume these
   variables and must never define their own.

   Load order is load-bearing: this file must load AFTER bootstrap.min.css
   (see App.razor) — the Bootstrap remaps in section 2 beat Bootstrap's own
   :root/[data-bs-theme] blocks purely by source order at equal specificity.

   THREE ATTRIBUTES ON <html>, AND THEY ARE NOT THE SAME THING (ADR-044 §2):
     data-mode    the user's PREFERENCE — auto | light | dark. Server-rendered,
                  never written by script, and nothing in CSS selects on it.
     data-scheme  the RESOLVED ground — light | dark. This is what CSS keys on.
                  Server-rendered when the preference is not `auto`; the head
                  script in App.razor resolves `auto` against the OS before the
                  first paint, which is the whole reason that script is inline.
     data-theme   the palette key (ADR-003 claim). A palette block outranks the
                  light ground by source order, so §1b must stay BELOW §1a —
                  both are (0,2,0) and only order separates them.

   Token NAMES are a runtime contract, not just CSS: game.js:749, game.words.js:70
   and contact-gauntlet.js:186 read --gn-accent / --gn-text / --gn-text-muted /
   --gn-danger / --gn-bg / --gn-font / --gn-word-* off the computed style. Rewriting
   a value is free; renaming one silently repaints or blanks a game. */

/* ── 1. Token values — the dark default ground (ADR-044 §2: charcoal + verdigris)
   Every colour below is checked against the ground it actually sits on: body text
   and semantic hues ≥4.5:1 on --gn-bg / --gn-surface / --gn-surface-2, control
   boundaries ≥3:1, and --gn-on-accent ≥4.5:1 on every fill including its hover and
   active mixes. S2.2 authors the other ten palettes and re-checks these two. */
:root {
    color-scheme: dark;
    /* The type scale's root. A percentage (not rem) so it resolves against the
       reader's own browser default rather than against itself — everything below
       is in rem, so this single value is the user's type-scale step (ADR-044 §3). */
    --gn-fs-root: 100%;
    font-size: var(--gn-fs-root);
    --gn-bg: #15181b;
    --gn-surface: #1d2125;
    --gn-surface-2: #262c31;
    --gn-border: #333b41;
    /* Separators may be quiet; a CONTROL boundary may not (WCAG 1.4.11 wants 3:1).
       Two tokens rather than one loud border on every card and comment rule. */
    --gn-border-strong: #6c7883;
    --gn-text: #e2e7ea;
    --gn-text-muted: #98a3ab;
    --gn-accent: #2fa89f;
    --gn-accent-hover: #3ec4ba;
    /* THE SECOND HUE (owner, 2026-08-10). Derived, not picked — see the recipe below §1a.
       Used sparingly and only to draw an eye: selection controls, the DAFUQ glow, the
       selected state of a widget. Never a second brand colour, never a button fill. */
    --gn-accent-2: #a784c7;
    --gn-on-accent-2: #04120f;
    /* The ink for ANY filled control — accent or semantic. Dark here because this
       theme's fills are bright; the light ground flips it to white. */
    --gn-on-accent: #04120f;
    /* Which way a fill moves on hover/active. Darkening a bright fill drags it
       toward the dark ink and drops btn-danger's active state to 4.48:1 — so a
       dark ground lightens instead. One token, both directions correct. */
    --gn-fill-shade: white;
    --gn-link: #5accc2;
    --gn-link-hover: #84dcd4;
    --gn-success: #54b988;
    --gn-danger: #f0736f;
    --gn-warning: #e2b04a;
    --gn-info: #5cb8dc;
    /* Inline <code>. Bootstrap ships a fixed pink here and nothing routed it
       through the tokens, so it survived every persona theme unchanged. */
    --gn-code: #e9a06d;
    --gn-focus-ring: rgba(47, 168, 160, .32);
    /* ADR-044 §4's default, and it is only honest now that §1c self-hosts the face: naming
       Cascadia Mono in a stack used to mean Windows saw it, macOS fell through to SF Mono
       and Linux to whatever `monospace` resolves to. The fallbacks stay for the moment
       between first paint and the file arriving. Readers override this per §1c. */
    --gn-font: "Cascadia Mono", ui-monospace, Consolas, monospace;
    --gn-font-heading: var(--gn-font);
    /* L3's geometry, not a preference: the console look IS hard edges. Kept as three
       tokens rather than deleted, because Bootstrap's --bs-border-radius-* remap in §2
       needs something to point at and a later palette could soften them. */
    --gn-radius: 0;
    --gn-radius-sm: 0;
    --gn-radius-lg: 0;
    --gn-shadow: none;
    --gn-sidebar-bg: #1d2125;
    --gn-sidebar-text: #c8d0d5;
    /* Literal RGB triplets Bootstrap consumes via rgba(var(...)) — cannot be
       derived from the hex tokens; keep in sync with --gn-bg/--gn-text/--gn-danger. */
    --bs-body-bg-rgb: 21, 24, 27;
    --bs-body-color-rgb: 226, 231, 234;
    --bs-danger-rgb: 240, 115, 111;
    /* Spacing scale */
    --gn-space-1: .25rem;
    --gn-space-2: .5rem;
    --gn-space-3: 1rem;
    --gn-space-4: 2rem;
    --gn-space-5: 4rem;
    /* ── Type scale (ADR-044 §4) ───────────────────────────────────────────────
       There was none: sizes were literal rem values scattered through this file,
       so "make the text bigger" had nothing to turn. Every step is a rem, so
       --gn-fs-root above moves the whole scale at once. Ratio ≈1.2. */
    /* The step below xs, and it is ON the ratio (.75 / 1.2), not a special case: L3's console
       type leans on it for every micro-label — column heads, kickers, tag chips, status pills —
       and the alternative was a literal, which would sit outside --gn-fs-root and stop moving
       when the reader changes their type size (ADR-044 §4). Added by row S4.1. */
    --gn-fs-2xs: .625rem;
    --gn-fs-xs: .75rem;
    --gn-fs-sm: .875rem;
    --gn-fs-md: 1rem;
    --gn-fs-lg: 1.125rem;
    --gn-fs-xl: 1.35rem;
    --gn-fs-2xl: 1.6rem;
    --gn-fs-3xl: 2rem;
    --gn-fs-4xl: 2.5rem;
    --gn-lh-tight: 1.25;
    --gn-lh: 1.6;
    --gn-measure: 68ch;   /* ADR-042 §7 — ch, never px: every font has its own character width */
    /* ── Responsive floors (ADR-042 §8) ────────────────────────────────────────
       The numbers, in one place, so no later row re-derives them. Breakpoints are
       deliberately NOT here: custom properties cannot be used in @media. */
    --gn-touch: 44px;       /* minimum touch target on a coarse pointer */
    --gn-touch-gap: 8px;    /* minimum separation between two of them */
    --gn-bar-h: 56px;       /* fixed top bar — S1.2 consumes this */
    --gn-strip-h: 56px;     /* low strip, IDLE state; §3a raises it under body.gn-playing */
    /* The shell's horizontal gutter — bar, main and strip share it, so their
       contents line up vertically at every width. */
    --gn-gut: clamp(.75rem, 3vw, 2rem);
    /* The shell's outer width cap (.gn-wrap). A token and not a literal since 2026-08-16,
       because the bleed float has to know where the PAGE ends, and the page is not the
       viewport on any window wider than this — that mistake clipped bleeding images on
       wide screens for exactly as long as the number lived in one place only. */
    --gn-wrap-max: 1360px;
    /* Word-role highlight colours (ADR-008): the game glows categorized blog words in these.
       Intentionally constant across persona themes — a colour means one thing everywhere.
       In Paragraph Parkour the colour IS the material, so each one now looks like what it does:
       adjective is frost, verb is fire, and both of those spread to the words around them. */
    --gn-word-adjective: #dceeff;   /* frost — near-white blue, and it creeps */
    --gn-word-contraction: #35c8d6; /* sticky — the cling surface */
    --gn-word-jargon: #ff5c7a;      /* jargon — the towers you have to climb */
    --gn-word-noun: #5aa0ff;        /* springboard */
    --gn-word-verb: #ff6a1a;        /* fire — hotter than the old amber, and it spreads */
    --gn-word-reserved: #b57bff;    /* profanity — turrets */
    --gn-word-hedge: #9d94c4;       /* hedging — platforms that will not commit to existing */
    /* AI usage label (ADR-040). Theme-invariant for the same reason as --gn-word-* above: a
       disclosure about how something was made has to mean one thing under every persona, or it
       is not a disclosure. Absent from every :root[data-theme] block on purpose — that omission
       IS the invariance.
       The plate is doing real work, not decoration. It used to be justified against
       hot-dog-stand's #b71c1c body; that theme is retired, and ADR-044 §3 puts the lock on
       better grounds — twelve user-selectable palettes are twelve different grounds, and a
       token that must mean one thing in all of them needs its own plate more than ever, not
       less. The tuner overrides the two hues per-instance. */
    /* DAFUQ's glow (ADR-045 §10, row S3.8). NOT theme-invariant like the two families above,
       and derived rather than authored twelve times: --gn-link is each palette's secondary
       interactive colour and --gn-link-hover is its BRIGHT form — which is what a glow wants
       (owner, 2026-08-10). Already checked against that palette's ground, so this follows the
       theme for free and a palette that wants its own glow overrides this one line. The glow is
       a halo AROUND text that stays --gn-text, so it carries no text-contrast obligation of its
       own — the magnifier glyph, not the colour, is what satisfies §2 rule 11. */
    /* Now the second hue rather than a brighter primary — the glow's whole job is drawing an
       eye, and --gn-link-hover could only ever be the accent turned up (owner, 2026-08-10). */
    --gn-dafuq-glow: var(--gn-accent-2);
    --gn-c3-plate: #1b2029;         /* only the breakdown's rings still sit on this */
    /* THE TWO HUES NOW FOLLOW THE PALETTE (owner, 2026-08-11). This reverses ADR-040's
       theme-invariance for --gn-c3-human/--gn-c3-ai; the amendment on ADR-040 carries the
       reasoning and the cost. Two things made it defensible rather than merely asked for:
       the plate came off these rings in 2026-08-07, so they already sit on the page ground
       and the fixed cyan/pink were already under 3:1 on grounds near their own hue — the
       accents are the one pair that HAS been measured against every ground (ADR-044,
       accent2.py). And #14b0d9 sat 17deg off the default accent, which read as a failed
       match rather than as a distinct mark.
       WHAT THIS COSTS: the accents are 60deg apart by Material's harmony rule, where the
       old pair was 148deg. Human and AI are now closer in hue than they have ever been,
       and the glyph inside each ring — not the colour — is what a reader who cannot tell
       them apart has left. Do not remove the glyphs. */
    --gn-c3-human: var(--gn-accent);
    --gn-c3-ai: var(--gn-accent-2);
}

/* ── --gn-accent-2, and where its twelve values came from ────────────────────
   Material's rule, not a taste call. Their generator (material-color-utilities,
   core_palette.ts) builds the contrasting accent as hue + 60° off the primary at half its
   chroma, and M2 said the same thing in words: a secondary "can be analogous or
   complementary… but should not simply be a light or dark variation of your primary" and
   "should be applied sparingly as an accent". That last line is why --gn-link could never
   be it — --gn-link IS a light variation of --gn-accent in all twelve palettes, and in
   terminal-light it is literally the same hex.

   Two documented departures, both forced by things Material doesn't have to deal with:

   1. THE HUE STEP IS 60° BUT NOT ALWAYS THE FIRST ONE. Material reserves exactly one hue
      (error, HCT 25). We reserve six — accent, code, success, danger, warning, info — so
      +60 often lands on a hue that already means something here: +60 off terminal's green
      is cyan, which is --gn-info, and +60 off console's magenta is orange, which is
      --gn-code. So we keep Material's 60° step and skip occupied stops, taking the first
      that clears every reserved hue by 30°. Most palettes land on +120; blueprint and noir
      take Material's +60 unchanged; ember needs +240.

   2. CHROMA IS THE PRIMARY'S, NOT HALF. Material halves it — but only after flooring the
      primary at HCT chroma 48. Our accents are authored below that floor, so halving them
      produced greys (#7b97b3, #b6a6c4) rather than accents. Keeping the primary's own
      chroma is what preserves each palette's character: noir's stays restrained, terminal's
      stays electric.

   Tone is the free variable, because Material fixes a tone only by owning the background,
   and we have twelve. Each value is the lightness that DELIVERS the most chroma (asking
   green's chroma of a blue at green's lightness gamut-clips to near-white, which reads as
   disabled) while clearing 4.5:1 on --gn-bg, --gn-surface AND --gn-surface-2, and never
   exceeding its own primary's contrast by more than a quarter — a colour used rarely must
   not outshine the one used everywhere.

   To retune a palette or author a thirteenth, re-run the derivation rather than eyeballing
   a hex; the constraint set above is what makes these twelve mutually consistent. */

/* ── 1a. The light ground — ADR-044 §2's paper sibling of the default ────────
   Colours only: the scale, spacing, radii and floors above are ground-agnostic
   and are NOT repeated here. Two of the twelve palettes; S2.2 authors the rest.

   Selects on data-scheme (the resolved ground), never data-mode (the preference) —
   `auto` is a preference and can never be a ground. */
:root[data-scheme="light"] {
    color-scheme: light;
    --gn-bg: #f5f3ee;
    --gn-surface: #ffffff;
    --gn-surface-2: #e9e6df;
    --gn-border: #d8d3c8;
    --gn-border-strong: #7d7869;
    --gn-text: #20242a;
    --gn-text-muted: #585f66;
    --gn-accent: #0d6d66;
    --gn-accent-hover: #0a5852;
    --gn-accent-2: #72598b;
    --gn-on-accent-2: #ffffff;
    --gn-on-accent: #ffffff;        /* fills are deep on paper, so the ink flips to white */
    --gn-fill-shade: black;         /* …and hover/active darken rather than lighten */
    --gn-link: #0b625c;
    --gn-link-hover: #084b46;
    --gn-success: #1c6b46;
    --gn-danger: #a92620;
    --gn-warning: #7a5406;
    --gn-info: #0f5f79;
    --gn-code: #9a4a12;
    --gn-focus-ring: rgba(13, 109, 102, .28);
    --gn-shadow: 0 1px 2px rgba(31, 29, 24, .06), 0 4px 12px rgba(31, 29, 24, .07);
    --gn-sidebar-bg: #e9e6df;
    --gn-sidebar-text: #2f353b;
    --bs-body-bg-rgb: 245, 243, 238;
    --bs-body-color-rgb: 32, 36, 42;
    --bs-danger-rgb: 169, 38, 32;

    /* The word-role colours, re-toned for a light ground (S7.0 item C2, owner 2026-08-13).
       ADR-008's rule holds — a colour still means ONE role — but it now means it per GROUND:
       the dark set in §1 was measured at 1.05–2.74:1 on the light grounds (adjective was
       white-on-white) and no single hue can serve both. Keyed on data-scheme, NOT per
       palette (the owner's call: light/dark is the render state, twelve styles are not),
       so a light-ground palette worn in the dark slot still gets the dark set — a corner a
       reader can only reach on purpose. Derived in OKLCH: same hue, L walked down until
       ≥4.5:1 against every light palette's bg AND surface; both game engines and the
       power-up chips read these off computed style at level build, so no engine change.
       Verified by the same sweep that found the failure — re-run it before touching a hex. */
    --gn-word-adjective: #5d7081;
    --gn-word-contraction: #507478;
    --gn-word-jargon: #d71551;
    --gn-word-noun: #1a6cd1;
    --gn-word-verb: #ba4a0a;
    --gn-word-reserved: #9046de;
    --gn-word-hedge: #71669a;
}

/* ADR-042 §8: below a 560px viewport height the chrome shrinks, so a short
   viewport is not mostly bar and strip. A height query — the min-width-only rule
   governs width. */
@media (max-height: 560px) {
    :root {
        --gn-bar-h: 48px;
        --gn-strip-h: 48px;
    }
}

/* ── 1b. The palette catalogue (ADR-003 mechanism, ADR-044 §2 content) ──────
   data-theme on <html> comes from the user's theme_mode claim (App.razor).
   Twelve palettes in six families, each family a dark-ground and a light-ground
   member; the keys are seeded in BlogContext and the colours are authored HERE
   (ADR-044 §7 — the admin surface edits metadata only, never hues).
   Every block declares the FULL token set, colours and color-scheme alike. Anything a
   block omits leaks through from the ground underneath, which is how a dark palette
   parked in the light slot ends up with color-scheme: light.

   EACH BLOCK CARRIES A SECOND SELECTOR, [data-gn-palette="…"], so a palette can be worn
   by ONE ELEMENT instead of by the page. That is what lets /Account/Manage/Appearance
   show twelve swatch rows without a single hex leaving this file — the swatches are
   var(--gn-bg) and friends, resolved against whichever block the row opted into. The
   root selector is (0,2,0) and this one is (0,1,0); specificity is per-selector in a
   list, so adding it changes nothing about how the page-level palette resolves.

   Every value below was checked by script, not by eye: text and semantic hues ≥4.5:1
   on bg AND surface AND surface-2, --gn-border-strong ≥3:1 (WCAG 1.4.11), and
   --gn-on-accent ≥4.5:1 on every filled control including Bootstrap's 15%/20%
   hover and active shades in the --gn-fill-shade direction.

   MUST STAY BELOW §1a. :root[data-theme=…] and :root[data-scheme=…] are both
   (0,2,0); only source order decides, and a chosen palette has to beat the
   ground it is being shown on (ADR-044 §2 — all twelve are eligible for either
   slot, so a dark palette in the light slot is a legal choice, not a bug). */
/* The default, and the same values as the dark ground in §1 — repeated here in full
   because a palette block must declare the whole set (a partial block leaks the OTHER ground
   through when it is used in the opposite slot). */
:root[data-theme="verdigris-dark"],
[data-gn-palette="verdigris-dark"] {
    color-scheme: dark;
    --gn-bg: #15181b;
    --gn-surface: #1d2125;
    --gn-surface-2: #262c31;
    --gn-border: #333b41;
    --gn-border-strong: #6c7883;
    --gn-text: #e2e7ea;
    --gn-text-muted: #98a3ab;
    --gn-accent: #2fa89f;
    --gn-accent-hover: #3ec4ba;
    --gn-accent-2: #a784c7;
    --gn-on-accent-2: #04120f;
    --gn-on-accent: #04120f;
    --gn-fill-shade: white;
    --gn-link: #5accc2;
    --gn-link-hover: #84dcd4;
    --gn-success: #54b988;
    --gn-danger: #f0736f;
    --gn-warning: #e2b04a;
    --gn-info: #5cb8dc;
    --gn-code: #e9a06d;
    --gn-focus-ring: rgba(47, 168, 159, .30);
    --gn-shadow: none;
    --gn-sidebar-bg: #1d2125;
    --gn-sidebar-text: #c8d0d5;
    --bs-body-bg-rgb: 21, 24, 27;
    --bs-body-color-rgb: 226, 231, 234;
    --bs-danger-rgb: 240, 115, 111;
}

/* The paper sibling, and the same values as the light ground in §1a, repeated for the
   same reason. */
:root[data-theme="verdigris-light"],
[data-gn-palette="verdigris-light"] {
    color-scheme: light;
    --gn-bg: #f5f3ee;
    --gn-surface: #ffffff;
    --gn-surface-2: #e9e6df;
    --gn-border: #d8d3c8;
    --gn-border-strong: #7d7869;
    --gn-text: #20242a;
    --gn-text-muted: #585f66;
    --gn-accent: #0d6d66;
    --gn-accent-hover: #0a5852;
    --gn-accent-2: #72598b;
    --gn-on-accent-2: #ffffff;
    --gn-on-accent: #ffffff;
    --gn-fill-shade: black;
    --gn-link: #0b625c;
    --gn-link-hover: #084b46;
    --gn-success: #1c6b46;
    --gn-danger: #a92620;
    --gn-warning: #7a5406;
    --gn-info: #0f5f79;
    --gn-code: #9a4a12;
    --gn-focus-ring: rgba(13, 109, 102, .30);
    --gn-shadow: 0 1px 2px rgba(31, 29, 24, .06), 0 4px 12px rgba(31, 29, 24, .07);
    --gn-sidebar-bg: #e9e6df;
    --gn-sidebar-text: #2f353b;
    --bs-body-bg-rgb: 245, 243, 238;
    --bs-body-color-rgb: 32, 36, 42;
    --bs-danger-rgb: 169, 38, 32;
}

/* Near black with a magenta cursor. */
:root[data-theme="console-dark"],
[data-gn-palette="console-dark"] {
    color-scheme: dark;
    --gn-bg: #0c0c11;
    --gn-surface: #14141c;
    --gn-surface-2: #1d1d27;
    --gn-border: #2e2e3c;
    --gn-border-strong: #71718a;
    --gn-text: #e9e7f2;
    --gn-text-muted: #a4a1b8;
    --gn-accent: #ff5fb0;
    --gn-accent-hover: #ff8cc8;
    --gn-accent-2: #aeb408;
    --gn-on-accent-2: #14000c;
    --gn-on-accent: #14000c;
    --gn-fill-shade: white;
    --gn-link: #ff7ec0;
    --gn-link-hover: #ffa8d5;
    --gn-success: #52c48e;
    --gn-danger: #ff6f75;
    --gn-warning: #e8b24c;
    --gn-info: #6cbcf0;
    --gn-code: #e9a06d;
    --gn-focus-ring: rgba(255, 95, 176, .30);
    --gn-shadow: none;
    --gn-sidebar-bg: #14141c;
    --gn-sidebar-text: #cdc9dd;
    --bs-body-bg-rgb: 12, 12, 17;
    --bs-body-color-rgb: 233, 231, 242;
    --bs-danger-rgb: 255, 111, 117;
}

/* The console palette with the lights on: the magenta goes deep enough to be read as ink. */
:root[data-theme="console-light"],
[data-gn-palette="console-light"] {
    color-scheme: light;
    --gn-bg: #f7f5f9;
    --gn-surface: #ffffff;
    --gn-surface-2: #eceaf1;
    --gn-border: #d3cddd;
    --gn-border-strong: #7c7590;
    --gn-text: #1c1a22;
    --gn-text-muted: #57536a;
    --gn-accent: #a3005f;
    --gn-accent-hover: #820049;
    --gn-accent-2: #677001;
    --gn-on-accent-2: #ffffff;
    --gn-on-accent: #ffffff;
    --gn-fill-shade: black;
    --gn-link: #9c005b;
    --gn-link-hover: #7a0047;
    --gn-success: #1c6b46;
    --gn-danger: #b02020;
    --gn-warning: #7a5406;
    --gn-info: #0f5f79;
    --gn-code: #9a4a12;
    --gn-focus-ring: rgba(163, 0, 95, .30);
    --gn-shadow: 0 1px 2px rgba(31, 29, 24, .06), 0 4px 12px rgba(31, 29, 24, .07);
    --gn-sidebar-bg: #eceaf1;
    --gn-sidebar-text: #2c2937;
    --bs-body-bg-rgb: 247, 245, 249;
    --bs-body-color-rgb: 28, 26, 34;
    --bs-danger-rgb: 176, 32, 32;
}

/* Predates the catalogue and keeps its key — live theme_mode claims hold this string.
   Ratios re-checked here (ADR-044 §2), and the four semantic hues it never had are authored:
   before this block it inherited red/amber/blue from the ground, on a screen of phosphor green.
   --gn-font is deliberately NOT set: after ADR-044 §3 the font is the reader's, not the palette's,
   and S2.3 makes a mono face the site default anyway. */
:root[data-theme="terminal-green"],
[data-gn-palette="terminal-green"] {
    color-scheme: dark;
    --gn-bg: #0a0f0a;
    --gn-surface: #101810;
    --gn-surface-2: #162116;
    --gn-border: #1f4d1f;
    --gn-border-strong: #3f9e3f;
    --gn-text: #33ff33;
    --gn-text-muted: #23c223;
    --gn-accent: #33ff33;
    --gn-accent-hover: #66ff66;
    --gn-accent-2: #4481ff;
    --gn-on-accent-2: #000000;
    --gn-on-accent: #000000;
    --gn-fill-shade: white;
    --gn-link: #66ff66;
    --gn-link-hover: #99ff99;
    --gn-success: #4dff8f;
    --gn-danger: #ff6b6b;
    --gn-warning: #ffd24d;
    --gn-info: #4de0ff;
    --gn-code: #ffb86b;
    --gn-focus-ring: rgba(51, 255, 51, .30);
    --gn-shadow: none;
    --gn-sidebar-bg: #071207;
    --gn-sidebar-text: #33ff33;
    --bs-body-bg-rgb: 10, 15, 10;
    --bs-body-color-rgb: 51, 255, 51;
    --bs-danger-rgb: 255, 107, 107;
}

/* Phosphor ink on a pale ground. The green has to darken a long way to be readable
   as text rather than glow. */
:root[data-theme="terminal-light"],
[data-gn-palette="terminal-light"] {
    color-scheme: light;
    --gn-bg: #eef2ec;
    --gn-surface: #ffffff;
    --gn-surface-2: #e2e8df;
    --gn-border: #c9d2c5;
    --gn-border-strong: #6f7d6b;
    --gn-text: #10240f;
    --gn-text-muted: #46543f;
    --gn-accent: #146b14;
    --gn-accent-hover: #0f5210;
    --gn-accent-2: #325bb1;
    --gn-on-accent-2: #ffffff;
    --gn-on-accent: #ffffff;
    --gn-fill-shade: black;
    --gn-link: #146b14;
    --gn-link-hover: #0f5210;
    --gn-success: #1c6b46;
    --gn-danger: #a92620;
    --gn-warning: #6d4c05;
    --gn-info: #0f5f79;
    --gn-code: #8a4413;
    --gn-focus-ring: rgba(20, 107, 20, .30);
    --gn-shadow: 0 1px 2px rgba(31, 29, 24, .06), 0 4px 12px rgba(31, 29, 24, .07);
    --gn-sidebar-bg: #e2e8df;
    --gn-sidebar-text: #1c2f1a;
    --bs-body-bg-rgb: 238, 242, 236;
    --bs-body-color-rgb: 16, 36, 15;
    --bs-danger-rgb: 169, 38, 32;
}

/* Cyanotype blue under white rules. */
:root[data-theme="blueprint-dark"],
[data-gn-palette="blueprint-dark"] {
    color-scheme: dark;
    --gn-bg: #0b1d33;
    --gn-surface: #12283f;
    --gn-surface-2: #1a334c;
    --gn-border: #27455f;
    --gn-border-strong: #6b90ad;
    --gn-text: #e6eef5;
    --gn-text-muted: #9db3c6;
    --gn-accent: #6ecbff;
    --gn-accent-hover: #9adcff;
    --gn-accent-2: #c0abfd;
    --gn-on-accent-2: #041220;
    --gn-on-accent: #041220;
    --gn-fill-shade: white;
    --gn-link: #7fd2ff;
    --gn-link-hover: #a6e2ff;
    --gn-success: #56c68f;
    --gn-danger: #ff7b7b;
    --gn-warning: #e9b552;
    --gn-info: #79d8e8;
    --gn-code: #f0a878;
    --gn-focus-ring: rgba(110, 203, 255, .30);
    --gn-shadow: none;
    --gn-sidebar-bg: #12283f;
    --gn-sidebar-text: #cfe0ee;
    --bs-body-bg-rgb: 11, 29, 51;
    --bs-body-color-rgb: 230, 238, 245;
    --bs-danger-rgb: 255, 123, 123;
}

/* Drafting stock, blue rules, graphite type. */
:root[data-theme="blueprint-light"],
[data-gn-palette="blueprint-light"] {
    color-scheme: light;
    --gn-bg: #eef1f5;
    --gn-surface: #ffffff;
    --gn-surface-2: #e2e7ee;
    --gn-border: #cdd5df;
    --gn-border-strong: #6f7a88;
    --gn-text: #1e242b;
    --gn-text-muted: #545c66;
    --gn-accent: #1a5490;
    --gn-accent-hover: #133f6e;
    --gn-accent-2: #7d5095;
    --gn-on-accent-2: #ffffff;
    --gn-on-accent: #ffffff;
    --gn-fill-shade: black;
    --gn-link: #17558f;
    --gn-link-hover: #10406d;
    --gn-success: #1c6b46;
    --gn-danger: #a92620;
    --gn-warning: #74500a;
    --gn-info: #0f5f79;
    --gn-code: #92440f;
    --gn-focus-ring: rgba(26, 84, 144, .30);
    --gn-shadow: 0 1px 2px rgba(31, 29, 24, .06), 0 4px 12px rgba(31, 29, 24, .07);
    --gn-sidebar-bg: #e2e7ee;
    --gn-sidebar-text: #2b323a;
    --bs-body-bg-rgb: 238, 241, 245;
    --bs-body-color-rgb: 30, 36, 43;
    --bs-danger-rgb: 169, 38, 32;
}

/* Slate, smoke and a cold blue. */
:root[data-theme="noir-dark"],
[data-gn-palette="noir-dark"] {
    color-scheme: dark;
    --gn-bg: #121417;
    --gn-surface: #1a1d21;
    --gn-surface-2: #23272c;
    --gn-border: #31363d;
    --gn-border-strong: #6a727c;
    --gn-text: #dfe3e8;
    --gn-text-muted: #969da6;
    --gn-accent: #7fb2e6;
    --gn-accent-hover: #a3caf0;
    --gn-accent-2: #b694d1;
    --gn-on-accent-2: #08111c;
    --gn-on-accent: #08111c;
    --gn-fill-shade: white;
    --gn-link: #8dbcec;
    --gn-link-hover: #b0d3f4;
    --gn-success: #5cbb8c;
    --gn-danger: #ef7373;
    --gn-warning: #dfae4e;
    --gn-info: #64bcd9;
    --gn-code: #e0a17a;
    --gn-focus-ring: rgba(127, 178, 230, .30);
    --gn-shadow: none;
    --gn-sidebar-bg: #1a1d21;
    --gn-sidebar-text: #c5cbd2;
    --bs-body-bg-rgb: 18, 20, 23;
    --bs-body-color-rgb: 223, 227, 232;
    --bs-danger-rgb: 239, 115, 115;
}

/* Pale linen and deep indigo. */
:root[data-theme="noir-light"],
[data-gn-palette="noir-light"] {
    color-scheme: light;
    --gn-bg: #f4f2ee;
    --gn-surface: #fdfcfa;
    --gn-surface-2: #e8e5df;
    --gn-border: #d6d2ca;
    --gn-border-strong: #77726a;
    --gn-text: #1f2229;
    --gn-text-muted: #565a62;
    --gn-accent: #2f4478;
    --gn-accent-hover: #23335a;
    --gn-accent-2: #582c5a;
    --gn-on-accent-2: #ffffff;
    --gn-on-accent: #ffffff;
    --gn-fill-shade: black;
    --gn-link: #2c4074;
    --gn-link-hover: #1f2e55;
    --gn-success: #1c6b46;
    --gn-danger: #a92620;
    --gn-warning: #7a5406;
    --gn-info: #0f5f79;
    --gn-code: #9a4a12;
    --gn-focus-ring: rgba(47, 68, 120, .30);
    --gn-shadow: 0 1px 2px rgba(31, 29, 24, .06), 0 4px 12px rgba(31, 29, 24, .07);
    --gn-sidebar-bg: #e8e5df;
    --gn-sidebar-text: #2c3038;
    --bs-body-bg-rgb: 244, 242, 238;
    --bs-body-color-rgb: 31, 34, 41;
    --bs-danger-rgb: 169, 38, 32;
}

/* Warm dark browns around one live coal. --gn-code is pulled off the orange ramp so it
   stays tellable from the accent. */
:root[data-theme="ember-dark"],
[data-gn-palette="ember-dark"] {
    color-scheme: dark;
    --gn-bg: #17120f;
    --gn-surface: #1f1915;
    --gn-surface-2: #29211c;
    --gn-border: #3a2f27;
    --gn-border-strong: #8a7565;
    --gn-text: #efe6de;
    --gn-text-muted: #b2a294;
    --gn-accent: #ff8f4d;
    --gn-accent-hover: #ffae7d;
    --gn-accent-2: #9d8bf9;
    --gn-on-accent-2: #1a0c02;
    --gn-on-accent: #1a0c02;
    --gn-fill-shade: white;
    --gn-link: #ffa268;
    --gn-link-hover: #ffc19a;
    --gn-success: #64c08d;
    --gn-danger: #ff7a72;
    --gn-warning: #e6b74f;
    --gn-info: #66bcda;
    --gn-code: #e8c07d;
    --gn-focus-ring: rgba(255, 143, 77, .30);
    --gn-shadow: none;
    --gn-sidebar-bg: #1f1915;
    --gn-sidebar-text: #d5c8bc;
    --bs-body-bg-rgb: 23, 18, 15;
    --bs-body-color-rgb: 239, 230, 222;
    --bs-danger-rgb: 255, 122, 114;
}

/* Warm neutrals and a clay accent. */
:root[data-theme="ember-light"],
[data-gn-palette="ember-light"] {
    color-scheme: light;
    --gn-bg: #f7f2e9;
    --gn-surface: #fffdf8;
    --gn-surface-2: #ece5d8;
    --gn-border: #dcd3c2;
    --gn-border-strong: #7e7462;
    --gn-text: #241f19;
    --gn-text-muted: #5c5449;
    --gn-accent: #9c4213;
    --gn-accent-hover: #7c340e;
    --gn-accent-2: #615bb2;
    --gn-on-accent-2: #ffffff;
    --gn-on-accent: #ffffff;
    --gn-fill-shade: black;
    --gn-link: #96400f;
    --gn-link-hover: #74300b;
    --gn-success: #1c6b46;
    --gn-danger: #a92620;
    --gn-warning: #6f4c05;
    --gn-info: #0f5f79;
    --gn-code: #7a4a12;
    --gn-focus-ring: rgba(156, 66, 19, .30);
    --gn-shadow: 0 1px 2px rgba(31, 29, 24, .06), 0 4px 12px rgba(31, 29, 24, .07);
    --gn-sidebar-bg: #ece5d8;
    --gn-sidebar-text: #302a22;
    --bs-body-bg-rgb: 247, 242, 233;
    --bs-body-color-rgb: 36, 31, 25;
    --bs-danger-rgb: 169, 38, 32;
}
/* hot-dog-stand was here. Retired 2026-08-09 (ADR-044 §6): the goal is letting readers read
   comfortably, not offering hostility as a novelty. Its ThemeMode row is DEACTIVATED rather than
   deleted, so live users holding the key fall back to the default with no user-database migration.
   Do not reintroduce the block — a stored key with no active row is the intended dead end. */

/* ── 1c. The font list (ADR-044 §4) ─────────────────────────────────────────
   data-font on <html> comes from the reader's choice (App.razor). The list is short on
   purpose: every self-hosted face is bytes on the wire.

   PER-CHOICE LOADING IS NOT A MECHANISM, IT IS THE ABSENCE OF ONE. A browser fetches an
   @font-face file only when a rule actually renders text in that family, and exactly one
   --gn-font wins per document — so declaring all four families below costs nothing to a
   reader who picked none of them. Do not "optimise" this into per-choice <link>s or a
   conditional loader; that would make it worse and slower.

   ADR-044 §4's finding is that the evidence is about SPACING, not typefaces — the
   accessibility work is the type scale, --gn-lh and --gn-measure in §1, not this section.
   The honest labelling that goes with each entry lives in Data/FontChoices.cs, next to the
   keys, because a label that drifts from its font is worse than no label.

   Files are woff2 only (every browser that runs this site supports it) and named by US, not
   by upstream — see wwwroot/fonts/README.md. font-display: swap so text is readable in the
   fallback while the file arrives. No real italics: browsers synthesise a passable oblique
   and a second file per family is not worth the bytes for a preference feature. */
@font-face {
    font-family: "Cascadia Mono";
    src: url("fonts/cascadia-mono.woff2") format("woff2");
    font-weight: 200 700;   /* variable */
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "Atkinson Hyperlegible";
    src: url("fonts/atkinson-hyperlegible.woff2") format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "Atkinson Hyperlegible";
    src: url("fonts/atkinson-hyperlegible-bold.woff2") format("woff2");
    font-weight: 700;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "Lexend";
    src: url("fonts/lexend.woff2") format("woff2");
    font-weight: 100 900;   /* variable */
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "OpenDyslexic";
    src: url("fonts/opendyslexic.woff2") format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "OpenDyslexic";
    src: url("fonts/opendyslexic-bold.woff2") format("woff2");
    font-weight: 700;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "Source Sans 3";
    src: url("fonts/source-sans-3.woff2") format("woff2");
    font-weight: 200 900;   /* variable */
    font-style: normal;
    font-display: swap;
}

/* One block per FontChoices key. (0,2,0), so these beat §1's default whatever the order.
   Every stack keeps a system fallback: if a woff2 is missing the page is plain, not broken.

   EACH BLOCK CARRIES A SECOND SELECTOR, [data-gn-font="…"], for exactly the reason §1b's
   palettes carry [data-gn-palette]: so a choice can be worn by ONE ELEMENT instead of by
   the page, and /styleguide can show all eight specimens at once (row S4.5). The root
   selector stays (0,2,0) and the element one is (0,1,0) — specificity is per-selector in
   a list, so adding it changes nothing about how the page-level font resolves.
   Add a font and it needs BOTH selectors, or it is invisible to the preview.

   Cascadia has no block: it is the :root default in §1, and a [data-gn-font="cascadia"]
   element inherits it already. */
:root[data-font="system-sans"], [data-gn-font="system-sans"] { --gn-font: var(--bs-font-sans-serif); }
/* Bootstrap has no serif variable — this is the only stack written out by hand. */
:root[data-font="system-serif"], [data-gn-font="system-serif"] { --gn-font: Georgia, Cambria, "Times New Roman", serif; }
:root[data-font="system-mono"], [data-gn-font="system-mono"] { --gn-font: var(--bs-font-monospace); }
:root[data-font="atkinson"], [data-gn-font="atkinson"] { --gn-font: "Atkinson Hyperlegible", var(--bs-font-sans-serif); }
:root[data-font="lexend"], [data-gn-font="lexend"] { --gn-font: "Lexend", var(--bs-font-sans-serif); }
:root[data-font="opendyslexic"], [data-gn-font="opendyslexic"] { --gn-font: "OpenDyslexic", var(--bs-font-sans-serif); }
:root[data-font="source-sans"], [data-gn-font="source-sans"] { --gn-font: "Source Sans 3", var(--bs-font-sans-serif); }

/* --gn-font is a token, not a font-family rule: nothing reads it unless something says
   `font-family: var(--gn-font)`. On the page that is --bs-body-font-family in §2, which
   `body` picks up — but a [data-gn-font] specimen is a DIV, and it inherits whatever body
   already resolved. This is the one line that makes the attribute do anything on its own. */
[data-gn-font] {
    font-family: var(--gn-font);
}

/* ── 1d. The size and measure dials (ADR-044 §3/§4) ──────────────────────────
   Where ADR-044 §4's accessibility budget actually gets spent: the evidence is about
   spacing, not typefaces, so these two matter more than the list above them.

   THE PERCENTAGES AND THE ch VALUES LIVE HERE AND NOWHERE ELSE. Data/AppearanceChoices.cs
   holds the keys and the words; putting a number in both would be two places to disagree.

   data-size moves --gn-fs-root, which §1 declares as `font-size` on :root — so it moves
   EVERY step of the scale at once, headings included, because every step is a rem. That is
   also why no rule on this site may write a literal font-size: a literal resolves against
   nothing the reader can turn, and drops silently out of this control.

   A percentage, never a rem or a px: it resolves against the reader's own browser default,
   so "Default" honours someone who already set 20px in their browser instead of overriding
   them back down to 16. `md` therefore needs no block — it IS §1's 100%.

   No [data-gn-size] / [data-gn-measure] element twins, unlike §1b's palettes and §1c's
   fonts. Those exist so /styleguide can show twelve palettes at once; a size specimen is
   already the whole page, and a --gn-fs-root that resolved per element would mean rem
   meaning two different things in one document. */
:root[data-size="xs"] { --gn-fs-root: 80%; }
:root[data-size="sm"] { --gn-fs-root: 90%; }
:root[data-size="lg"] { --gn-fs-root: 115%; }
:root[data-size="xl"] { --gn-fs-root: 130%; }

/* ch, never px (ADR-042 §7): the line holds the same number of CHARACTERS whichever face
   §1c resolved, and a px width silently becomes a 95-character line on a narrow one. */
:root[data-measure="sm"] { --gn-measure: 56ch; }
:root[data-measure="lg"] { --gn-measure: 84ch; }

/* Selection controls, site-wide — M2's own list for the second hue ("selection controls
   like sliders and switches… progress bars"). One rule replaces every hand-set accent-color.

   On the ELEMENTS, deliberately, not on :root. accent-color: var(…) set at the root would
   compute to one colour there and inherit as that colour, so a [data-gn-palette] subtree
   could never show its own — which is exactly what the palette picker's rows need. Resolved
   per element, each one reads whichever palette it actually sits in. */
input[type="checkbox"],
input[type="radio"],
input[type="range"],
progress {
    accent-color: var(--gn-accent-2);
}

/* ── 2. Bootstrap 5.3 remaps — route Bootstrap through the tokens ─────────── */
:root {
    --bs-body-font-family: var(--gn-font);
    --bs-body-font-size: var(--gn-fs-md);
    --bs-body-line-height: var(--gn-lh);
    --bs-body-bg: var(--gn-bg);
    --bs-body-color: var(--gn-text);
    /* Bootstrap hardcodes #d63384 / #e685b5 here and nothing routed it through the
       tokens, so inline <code> stayed pink under every persona theme. */
    --bs-code-color: var(--gn-code);
    --bs-secondary-color: var(--gn-text-muted);
    --bs-secondary-bg: var(--gn-surface-2);
    --bs-tertiary-bg: var(--gn-surface);
    --bs-border-color: var(--gn-border);
    --bs-border-radius: var(--gn-radius);
    --bs-border-radius-sm: var(--gn-radius-sm);
    --bs-border-radius-lg: var(--gn-radius-lg);
    --bs-link-color: var(--gn-link);
    --bs-link-hover-color: var(--gn-link-hover);
    --bs-focus-ring-color: var(--gn-focus-ring);
    --bs-box-shadow: var(--gn-shadow);
    --bs-form-valid-color: var(--gn-success);
    --bs-form-valid-border-color: var(--gn-success);
    --bs-form-invalid-color: var(--gn-danger);
    --bs-form-invalid-border-color: var(--gn-danger);
}

/* 5.3 anchors read RGB-triplet vars (rgba(var(--bs-link-color-rgb),…));
   route them through the hex tokens instead so only hex needs maintaining.
   Costs the (unused) link-opacity utilities. */
a {
    color: var(--bs-link-color);
}

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

/* Bootstrap has no heading-font variable. Sizes come off the scale rather than
   Bootstrap's own ladder, so a type-scale step moves the headings too — otherwise
   the reader turns the dial and only the body text answers.
   h1/h2 keep a fluid middle term because Bootstrap's already were; the clamp
   BOUNDS are scale tokens, so the user's step still moves both ends. Anything
   fluid beyond these two is parked (PLAN Parking Lot). */
h1, h2, h3, h4, h5, h6 {
    font-family: var(--gn-font-heading);
    line-height: var(--gn-lh-tight);
}

h1 { font-size: clamp(var(--gn-fs-3xl), 1.375rem + 1.5vw, var(--gn-fs-4xl)); }
h2 { font-size: clamp(var(--gn-fs-2xl), 1.325rem + .9vw, var(--gn-fs-3xl)); }
h3 { font-size: var(--gn-fs-2xl); }
h4 { font-size: var(--gn-fs-xl); }
h5 { font-size: var(--gn-fs-lg); }
h6 { font-size: var(--gn-fs-md); }

/* Same problem as the headings, one class further down: Bootstrap's .lead is a bare
   `font-size: 1.25rem; font-weight: 300`, so a reader turning the type dial moved every
   paragraph on the page EXCEPT the standfirst. On the scale it becomes --gn-fs-lg. The
   weight goes back to 400 as well — 300 is a face this site does not load (Cascadia Mono
   ships one weight), so it was already synthesising or silently falling back. Row S4.5. */
.lead {
    font-size: var(--gn-fs-lg);
    font-weight: 400;
}

/* The ground, the ink and the face — on OUR side of the seam (S7.0 item B1, 2026-08-13).
   These three used to reach the page only through §2's remaps feeding Bootstrap's reboot
   `body` rule, which put the site's most identity-defining values on the wrong side of
   ADR-043 §6's test: pull the vendored sheet and the page rendered transparent Times New
   Roman. The remaps above stay — Bootstrap's own plumbing still reads them — this rule is
   what makes the identity survive without them. margin: 0 is here for the same reason;
   it is reboot's, and without it the seam test grows an 8px halo. */
body {
    margin: 0;
    background-color: var(--gn-bg);
    color: var(--gn-text);
    font-family: var(--gn-font);
    font-size: var(--gn-fs-md);
    line-height: var(--gn-lh);
}

/* ── Responsive floors that are genuinely global (ADR-042 §8) ────────────────
   Only the two that can be stated once for the whole site live here. The 44px
   touch target is a --gn-touch number for each component to honour, not a blanket
   min-height: growing every control in one rule would resize the entire site from
   a token row, and ADR-042 §8's own wording is "grow the target, keep the ink small". */

/* iOS zooms the page when a focused input computes under 16px and never zooms back.
   max() so the floor survives a reader dialling the type scale down. */
[data-gn-input="coarse"] :is(input, select, textarea) {
    font-size: max(16px, var(--gn-fs-md));
}

/* WCAG 1.4.11: a control's boundary needs 3:1, which a quiet card separator does
   not. --gn-border stays quiet for cards and rules; controls take the strong one. */
.form-control, .form-select, .form-check-input, .form-range {
    border-color: var(--gn-border-strong);
}

/* ── Buttons — OURS (ADR-043 §2, migrated by S7.0 item B2, 2026-08-13) ───────
   The .btn-* class NAMES stay Bootstrap's — ~150 call sites keep working and renaming
   them buys no reader anything. The RULES are ours: styled directly off the tokens,
   winning over the vendored sheet purely by source order (same specificity, later
   file), keeping the exact geometry Bootstrap gave them so nothing moved the day they
   landed. Before this, the blocks here only fed --bs-btn-* variables into rules that
   lived in bootstrap.min.css, so ADR-043 §6's test failed on every button (the fills
   went grey). Now: pull the vendored sheet and a button keeps its look.

   Two deliberate changes from Bootstrap's originals:
     - sizes sit ON THE SCALE (--gn-fs-md/sm/lg), not literal rems, so button text
       follows the reader's type dial like everything else (ADR-044 §4);
     - there are NO .btn-outline-* rules. Filled, never transparent (owner rule,
       2026-07-22) — the four outline remap blocks were deleted here, not migrated
       (S7.0 item B3). An outline class in markup now renders as a plain .btn.

   Hover/active shades reproduce the remap-era math: 15%/20% toward --gn-fill-shade,
   the same mix palette-contrast.py walks when it checks --gn-on-accent (its shade()
   is this color-mix in Python). gn-cta is always used as `btn gn-cta`. */
.btn {
    display: inline-block;
    padding: .375rem .75rem;
    font-family: var(--gn-font);
    font-size: var(--gn-fs-md);
    font-weight: 400;
    line-height: var(--gn-lh);
    text-align: center;
    text-decoration: none;
    vertical-align: middle;
    cursor: pointer;
    user-select: none;
    border: 1px solid transparent;
    border-radius: var(--gn-radius);
    background-color: transparent;
    color: var(--gn-text);
    transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out;
}

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

.btn:focus-visible {
    outline: 0;
    box-shadow: 0 0 0 .25rem var(--gn-focus-ring);
}

.btn:disabled, .btn.disabled, fieldset:disabled .btn {
    pointer-events: none;
    opacity: .65;
}

/* Sign-in T&C checkbox (ADR-016 amendment 2026-08-13): while the box in .gn-terms-check is
   unticked, the form's submit buttons wear the disabled look. The checkbox's `required`
   attribute is the real gate — pointer-events stays on so a click surfaces the browser's
   "check this box" message instead of dying silently. Static SSR pages, hence CSS not JS. */
form:has(.gn-terms-check input:not(:checked)) button[type="submit"] {
    opacity: .65;
    cursor: not-allowed;
}

.btn-sm {
    padding: .25rem .5rem;
    font-size: var(--gn-fs-sm);
    border-radius: var(--gn-radius-sm);
}

.btn-lg {
    padding: .5rem 1rem;
    font-size: var(--gn-fs-lg);
    border-radius: var(--gn-radius-lg);
}

.btn-primary, .gn-cta {
    color: var(--gn-on-accent);
    background-color: var(--gn-accent);
    border-color: var(--gn-accent);
}

.btn-primary:hover, .gn-cta:hover,
.btn-primary:active, .gn-cta:active {
    color: var(--gn-on-accent);
    background-color: var(--gn-accent-hover);
    border-color: var(--gn-accent-hover);
}

.btn-secondary {
    color: var(--gn-text);
    background-color: var(--gn-surface-2);
    border-color: var(--gn-border);
}

.btn-secondary:hover, .btn-secondary:active {
    color: var(--gn-text);
    background-color: var(--gn-border);
    border-color: var(--gn-border);
}

.btn-danger {
    color: var(--gn-on-accent);
    background-color: var(--gn-danger);
    border-color: var(--gn-danger);
}

.btn-danger:hover {
    color: var(--gn-on-accent);
    background-color: color-mix(in srgb, var(--gn-danger) 85%, var(--gn-fill-shade));
    border-color: color-mix(in srgb, var(--gn-danger) 85%, var(--gn-fill-shade));
}

.btn-danger:active {
    color: var(--gn-on-accent);
    background-color: color-mix(in srgb, var(--gn-danger) 80%, var(--gn-fill-shade));
    border-color: color-mix(in srgb, var(--gn-danger) 80%, var(--gn-fill-shade));
}

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

.btn-success:hover {
    color: var(--gn-on-accent);
    background-color: color-mix(in srgb, var(--gn-success) 85%, var(--gn-fill-shade));
    border-color: color-mix(in srgb, var(--gn-success) 85%, var(--gn-fill-shade));
}

.btn-success:active {
    color: var(--gn-on-accent);
    background-color: color-mix(in srgb, var(--gn-success) 80%, var(--gn-fill-shade));
    border-color: color-mix(in srgb, var(--gn-success) 80%, var(--gn-fill-shade));
}

/* Ink comes off --gn-on-accent like every other fill, so warning needs no special
   case: the dark ground already resolves it to dark ink. */
.btn-warning {
    color: var(--gn-on-accent);
    background-color: var(--gn-warning);
    border-color: var(--gn-warning);
}

.btn-warning:hover {
    color: var(--gn-on-accent);
    background-color: color-mix(in srgb, var(--gn-warning) 85%, var(--gn-fill-shade));
    border-color: color-mix(in srgb, var(--gn-warning) 85%, var(--gn-fill-shade));
}

.btn-warning:active {
    color: var(--gn-on-accent);
    background-color: color-mix(in srgb, var(--gn-warning) 80%, var(--gn-fill-shade));
    border-color: color-mix(in srgb, var(--gn-warning) 80%, var(--gn-fill-shade));
}

/* The one sanctioned non-filled variant: an inline text action wearing button bones. */
.btn-link {
    color: var(--gn-link);
    text-decoration: underline;
    background-color: transparent;
    border-color: transparent;
}

.btn-link:hover, .btn-link:active {
    color: var(--gn-link-hover);
    background-color: transparent;
    border-color: transparent;
}

.nav-pills {
    --bs-nav-pills-link-active-color: var(--gn-on-accent);
    --bs-nav-pills-link-active-bg: var(--gn-accent);
}

/* Bootstrap hardcodes these focus/checked colors — no variable exists. */
.form-control:focus, .form-select:focus, .form-check-input:focus {
    border-color: var(--gn-accent);
    outline: 0;
    box-shadow: 0 0 0 .25rem var(--gn-focus-ring);
}

.form-check-input:checked {
    background-color: var(--gn-accent);
    border-color: var(--gn-accent);
}

/* ── 3a. The shell frame — ADR-042 (bar · drawer · strip) ────────────────────
   Global, not scoped .razor.css, for three reasons that each rule out scoping on
   their own: the classes are `gn-*` (this file's job by the css-tokens contract);
   the drawer is driven by `#gn-nav:checked ~ …`, which crosses a component
   boundary the moment the drawer's contents are their own component; and `body`
   / `html` cannot be reached by scoped CSS at all — Blazor rewrites the selector
   to `body[b-xxxxx]`, which matches nothing.

   THE Z-STACK, DECIDED ONCE (ADR-042 Consequences). Nothing on the site may
   invent a new layer without changing this comment:

      1000   chrome: .gn-bar, .gn-strip
      1010   game canvas + overlay   (game.js #gn-game-overlay; its handle prompt at 1012)
             — ABOVE the chrome since 2026-08-13 (owner, S7.0 close-out): the ship flies
             over the strip, not under it. Chrome is inert while playing and the canvas is
             pointer-events:none, so the swap steals no clicks in either state. 900 is
             retired, not reassigned.
      1050   .gn-scrim
      1100   .gn-drawer
      1150   #gn-win — the cleared-the-run screen. Over the strip on purpose:
             it ends the run, so the readout behind it is finished with
      1150   .gn-toast — same reason, same clearance. The two never co-exist:
             a toast follows a form post, and a form post ends a run
      1150   the EasyMDE editor in full screen (blog.js raises it there, not here — the
             vendor stylesheet is injected at runtime and would win the tie). It ships at
             z-index 8/9, which put it UNDER the chrome: full screen with the bar sitting
             on top of its toolbar, and no visible way back out
      1200   .gn-skip, #blazor-error-ui — always reachable, over everything

   .gn-dafuq-panel is NOT on this list and does not need to be: a shown [popover] is in the
   browser's TOP LAYER, which sits above every stacking context here regardless of z-index.
   Row S3.8 added it without touching a number — the same is true of anything else that ever
   uses the popover API, which is the reason to prefer it.

   1090 used to hold the Bootstrap toast container. Row S4.7 retired it — the trophy earn
   announcement is a row in the document now, at no layer at all. Bootstrap's own .modal-backdrop
   (1050) and .toast-container (1090) still exist in the vendored CSS and DRIP's nine compiled
   modals still raise a backdrop on DRIP routes, which ADR-043 §4 states plainly rather than
   pretends away; nothing this repo authors goes there any more.

   The bar and the strip are opaque (--gn-surface), never translucent, so neither
   needs a gradient to read as chrome over the page. */

body {
    padding-top: var(--gn-bar-h);
    padding-bottom: calc(var(--gn-strip-h) + env(safe-area-inset-bottom));
}

/* WCAG 2.4.11 Focus Not Obscured: a focused element must never land under fixed
   chrome. FocusOnNavigate targets h1 on every navigation, so this is load-bearing,
   not a nicety. */
html {
    scroll-padding-top: calc(var(--gn-bar-h) + 8px);
    scroll-padding-bottom: calc(var(--gn-strip-h) + 8px);
    /* Reserve the scrollbar's gutter permanently. Without it, locking the scroll
       below hands those ~15px back to the layout and the whole page — bar, strip
       and prose — jumps sideways the instant the drawer opens. It also stops the
       same jump between a short page and a long one. */
    scrollbar-gutter: stable;
}

/* THE PAGE DOES NOT SCROLL BEHIND AN OPEN DRAWER (owner, 2026-08-08 — this
   reverses ADR-042 §3's "no body-scroll lock"). Two scrollbars side by side was
   the complaint and it is a fair one: shell.js already marks the bar, main and
   strip `inert` while the drawer is open, so the page behind it is not reachable
   — leaving it scrollable only offers a second bar that moves content nobody can
   touch. §3's other two refusals stand: still no aria-modal, still no focus trap.

   On the root, not on body: body's overflow only propagates to the viewport
   while the root's is `visible`, which is a condition another rule can quietly
   break. CSS-only, like the rest of the drawer — no JS to fall out of step with
   the checkbox. Known ceiling: iOS Safari ignores overflow:hidden here, and the
   fix for that is a position:fixed body plus scroll-position restore in JS —
   worth it only if the owner sees it on a real device. */
:root:has(.gn-nav-toggle:checked) {
    overflow: hidden;
}

.gn-skip {
    position: absolute;
    left: -9999px;
    top: 0;
    z-index: 1200;
    background: var(--gn-accent);
    color: var(--gn-on-accent);
    padding: .7rem 1rem;
    font-weight: 700;
    text-decoration: none;
}

.gn-skip:focus {
    left: 0;
    color: var(--gn-on-accent);
}

/* The drawer's ONE focusable control (ADR-042 §3). Visually hidden but NOT
   display:none — it keeps its place in the tab order and Space still toggles it.
   aria-label pins its name: the Menu button and the X are further <label>s for
   this same checkbox, so without it their text would concatenate into one name. */
.gn-nav-toggle {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
}

/* ── The bar ─────────────────────────────────────────────────────────────── */
.gn-bar {
    position: fixed;
    z-index: 1000;
    inset: 0 0 auto 0;
    height: var(--gn-bar-h);
    display: flex;
    align-items: stretch;
    background: var(--gn-surface);
    border-bottom: 1px solid var(--gn-border);
}

.gn-bar-inner {
    width: 100%;
    max-width: 1360px;
    margin: 0 auto;
    padding: 0 var(--gn-gut);
    display: flex;
    align-items: stretch;
    gap: var(--gn-space-3);
    min-width: 0;
}

.gn-brand {
    display: flex;
    align-items: center;
    gap: var(--gn-space-2);
    font-size: var(--gn-fs-sm);
    font-weight: 700;
    letter-spacing: .04em;
    color: var(--gn-text);
    text-decoration: none;
    white-space: nowrap;
}

.gn-brand:hover {
    color: var(--gn-text);
}

/* ── The brand mark — ADR-045 §9 (row S3.7) ─────────────────────────────────
   The mark itself is inlined in MainLayout.razor and ships its own paint and its own
   hover choreography. Everything here is host business the generated file cannot know:
   how big it is, what ground it is standing on, and the two things the ADR adds.

   IDLE MOTION OFF. gn-logo.svg bobs, blinks and sways on an infinite loop. In a fixed bar
   on every page that is perpetual motion in the reader's peripheral vision, which is the
   same thing §3a already refused for the New Game+ pulse, and WCAG 2.2.2 is about exactly
   this. ADR-045 §9 asks for a HOVER animation; that is what is left. Switched off here
   rather than edited out of the markup so the generated copy stays paste-compatible — one
   deleted rule brings the whole loop back if the owner wants it.

   THE TRIGGER IS THE WHOLE LINK, hover and focus-visible alike (ADR-045 §9). The mark
   ships `.gnlogo:hover`, which is the mark itself — and the mark is 32px of a link that is
   mostly wordmark, so a reader hovering "GINGER NINJALYTICS" got nothing. Measured, not
   assumed. Keying both states off .gn-brand makes the target the thing that looks like the
   target, and gives the keyboard user what the mouse user sees, which is the half §9 asks
   for by name. (0,3,0) + an id, so these beat the file's own (0,2,0) + id hover rules on
   specificity rather than on source order — the inlined <style> is in the body, so source
   order would go the other way.

   INK ON THE GROUND IT IS ACTUALLY ON. The hood is near-black and the bar is
   --gn-surface, which is dark in six of the twelve palettes. The mark ships a .gn-on-dark
   variant for this, but a class would have to be chosen server-side and data-scheme can
   still flip client-side under mode=auto. light-dark() reads the element's own
   color-scheme, which every palette block already declares correctly (css-tokens.md), so
   it lands right in all twelve with no enumeration and nothing for the server to know.
   These two literals are the mark's own paint, not theme colour — they are the
   .gn-on-dark values verbatim, and they belong to the brand the way --gn-c3-* does.

   Transform and opacity only, never a layout property (ADR-045 §9): the bar is a fixed
   56px and anything that reflows it moves the whole page. Size is off the spacing scale
   for that reason — it is a fixed box the animation plays inside, not a growing one. */
.gn-brand .gnlogo {
    display: block;
    flex: none;
    width: var(--gn-space-4);
    height: var(--gn-space-4);
    --ink: light-dark(#14141c, #2e2e3f);
    --mask: light-dark(#282836, #41415a);
}

.gn-brand .gnlogo #gn-head,
.gn-brand .gnlogo #gn-eyes,
.gn-brand .gnlogo #gn-hairL,
.gn-brand .gnlogo #gn-hairR {
    animation: none;
}

.gn-brand:hover .gnlogo #gn-head,
.gn-brand:focus-visible .gnlogo #gn-head {
    transform: translateY(-2.5px) rotate(-4deg);
}

.gn-brand:hover .gnlogo #gn-eyes,
.gn-brand:focus-visible .gnlogo #gn-eyes {
    transform: scaleY(0.4) translateY(3px);
}

.gn-brand:hover .gnlogo #gn-hairL,
.gn-brand:hover .gnlogo #gn-hairR,
.gn-brand:focus-visible .gnlogo #gn-hairL,
.gn-brand:focus-visible .gnlogo #gn-hairR {
    transform: rotate(-5deg);
}

/* §2 rule 10. The mark's own file has this guard, but these rules are ours and sit above
   it on specificity, so they need their own — otherwise reduced-motion still gets the
   pose, just without the 0.18s ride to it. */
@media (prefers-reduced-motion: reduce) {
    .gn-brand:hover .gnlogo #gn-head,
    .gn-brand:hover .gnlogo #gn-eyes,
    .gn-brand:hover .gnlogo #gn-hairL,
    .gn-brand:hover .gnlogo #gn-hairR,
    .gn-brand:focus-visible .gnlogo #gn-head,
    .gn-brand:focus-visible .gnlogo #gn-eyes,
    .gn-brand:focus-visible .gnlogo #gn-hairL,
    .gn-brand:focus-visible .gnlogo #gn-hairR {
        transform: none;
    }
}

.gn-brand small {
    display: none;   /* returns at md */
    font-size: var(--gn-fs-xs);
    letter-spacing: .1em;
    color: var(--gn-text-muted);
    border-left: 1px solid var(--gn-border);
    padding-left: var(--gn-space-2);
}

/* Combo nav (ADR-042 §2): primary links stay VISIBLE from lg, and that is what
   makes a right-hand hidden drawer acceptable rather than the worst-measured
   arrangement there is. The drawer holds the long tail. */
.gn-primary {
    display: none;
    min-width: 0;
}

.gn-primary a {
    display: flex;
    align-items: center;
    padding: 0 .8rem;
    font-size: var(--gn-fs-xs);
    letter-spacing: .08em;
    text-transform: uppercase;
    font-weight: 600;
    color: var(--gn-text-muted);
    text-decoration: none;
    white-space: nowrap;
    border-bottom: 2px solid transparent;
}

.gn-primary a:hover {
    color: var(--gn-text);
    background: var(--gn-surface-2);
}

.gn-primary a.active {
    color: var(--gn-text);
    border-bottom-color: var(--gn-accent);
}

.gn-bar-right {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: var(--gn-touch-gap);
    min-width: 0;
}

/* The two rules that used to live here — folding .gn-primary away on a post page,
   and clipping the bars inside .gn-bar-right — went with the reader bars when they
   moved to the strip (owner, 2026-08-08). The links now keep this row on every
   page, which is what ADR-042 §2 wanted in the first place; S1.2's amendment item 2
   was a symptom of the bars being up here, not a decision about the links. */

/* The drawer trigger: filled, ≥44px, and labelled "Menu" in text — never a bare
   icon (ADR-042 §2, the measured recognition position). */
.gn-menu {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--gn-space-2);
    min-height: var(--gn-touch);
    min-width: var(--gn-touch);
    padding: 0 .85rem;
    background: var(--gn-accent);
    color: var(--gn-on-accent);
    border: 0;
    cursor: pointer;
    font-size: var(--gn-fs-xs);
    font-weight: 700;
    letter-spacing: .12em;
    text-transform: uppercase;
    user-select: none;
    margin: 0;
}

.gn-menu:hover {
    background: var(--gn-accent-hover);
}

/* The real control is visually hidden, so its focus ring has to ride the label. */
.gn-nav-toggle:focus-visible ~ .gn-bar .gn-menu {
    outline: 2px solid var(--gn-text);
    outline-offset: 2px;
}

/* ── The drawer: a disclosure, not a dialog (ADR-042 §3) ─────────────────── */
.gn-scrim {
    position: fixed;
    inset: 0;
    z-index: 1050;
    background: #000;
    opacity: 0;
    visibility: hidden;
    transition: opacity .2s ease, visibility .2s ease;
    cursor: pointer;
    margin: 0;
}

.gn-nav-toggle:checked ~ .gn-scrim {
    opacity: .55;
    visibility: visible;
}

.gn-drawer {
    position: fixed;
    z-index: 1100;
    top: 0;
    right: 0;
    width: min(88vw, 360px);
    height: 100dvh;
    display: flex;
    flex-direction: column;
    background: var(--gn-sidebar-bg);
    color: var(--gn-sidebar-text);
    border-left: 2px solid var(--gn-accent);
    /* transform, never `left`: animating `left` thrashes layout every frame.
       visibility:hidden is what takes its links out of the tab order when closed. */
    transform: translateX(100%);
    visibility: hidden;
    transition: transform .22s ease, visibility .22s ease;
}

.gn-nav-toggle:checked ~ .gn-drawer {
    transform: translateX(0);
    visibility: visible;
}

@media (prefers-reduced-motion: reduce) {
    .gn-scrim, .gn-drawer {
        transition: none;
    }
}

.gn-drawer-head {
    flex: none;
    height: var(--gn-bar-h);
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 .75rem 0 1rem;
    border-bottom: 1px solid var(--gn-border);
    font-size: var(--gn-fs-xs);
    font-weight: 700;
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-drawer-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: var(--gn-touch);
    min-height: var(--gn-touch);
    cursor: pointer;
    background: var(--gn-surface-2);
    color: var(--gn-text);
    border: 0;
    font-size: var(--gn-fs-lg);
    margin: 0;
}

.gn-drawer-close:hover {
    background: var(--gn-accent);
    color: var(--gn-on-accent);
}

/* The single exception to "zero inner scrollers" (ADR-042 §7) — and only because
   the drawer is chrome. While it is open it is now the ONLY scroller: the root's
   scroll is locked up in §3a, so this is one scrollbar rather than two. */
.gn-drawer-body {
    flex: 1 1 auto;
    overflow-y: auto;
    padding-bottom: var(--gn-space-4);
}

.gn-navgroup {
    padding-top: var(--gn-space-3);
}

/* Deliberately NOT a heading (ADR-042 §3): the drawer sits before <h1> in the DOM,
   so a real heading here would open the page outline at h3. role="group" +
   aria-label on the wrapper carries the grouping and owes the outline nothing. */
.gn-navgroup-label {
    margin: 0 0 var(--gn-space-1);
    padding: 0 1rem;
    font-size: var(--gn-fs-xs);
    font-weight: 700;
    line-height: 1.4;
    letter-spacing: .18em;
    text-transform: uppercase;
    color: var(--gn-accent);
}

.gn-navgroup a,
.gn-navgroup button {
    display: flex;
    align-items: center;
    gap: .7rem;
    width: 100%;
    min-height: var(--gn-touch);
    padding: .4rem 1rem;
    color: var(--gn-sidebar-text);
    text-decoration: none;
    font-size: var(--gn-fs-sm);
    text-align: left;
    background: none;
    border: 0;
    border-left: 3px solid transparent;
    cursor: pointer;
}

.gn-navgroup :is(a, button):hover {
    background: var(--gn-surface-2);
    color: var(--gn-text);
    border-left-color: var(--gn-accent);
}

.gn-navgroup a.active {
    border-left-color: var(--gn-accent);
    background: var(--gn-surface-2);
}

.gn-navgroup :is(a, button) .bi {
    color: var(--gn-text-muted);
    width: 1.1em;
    flex: none;
}

.gn-navgroup :is(a, button):hover .bi {
    color: var(--gn-accent);
}

.gn-navgroup form {
    margin: 0;
}

.gn-navgroup-legal a {
    font-size: var(--gn-fs-xs);
    color: var(--gn-text-muted);
}

/* ── The head-start card (game.js .gn-cd) ────────────────────────────────────
   The controls, shown for the five seconds of the countdown and then gone. Lives inside
   #gn-game-overlay, so it inherits pointer-events:none and is removed with the run.

   ABOVE the number, not over it. The number is painted on the canvas at exactly 50%/50%
   at CD_FONT_PX (84px, game.humblecane.js) and pops to 1.18× on each tick, so its top
   reaches 50% − 50px. The offset below is px, not rem, BECAUSE the thing it clears is
   canvas pixels — a rem offset would drift away from it the moment a reader changes their
   type-scale step and the card would land on the number. Change CD_FONT_PX, change this.

   No scrim, unlike #gn-win: the head-start exists so you can watch the level come up, and
   dimming it would defeat the countdown it belongs to. */
.gn-cd {
    position: absolute;
    left: 50%;
    bottom: calc(50% + 62px);
    transform: translateX(-50%);
    max-inline-size: min(92vw, 26rem);
    padding: var(--gn-space-3);
    border: 1px solid var(--gn-border-strong);
    border-radius: var(--gn-radius);
    /* Translucent, so the level keeps showing through the one card that covers it. */
    background: color-mix(in srgb, var(--gn-surface) 86%, transparent);
    box-shadow: var(--gn-shadow);
    text-align: center;
    opacity: 0;
    transition: opacity .28s ease-out;
}

.gn-cd[data-on="1"] {
    opacity: 1;
}

/* ParPar has no countdown number to clear — it has a floor, and the empty band under it is the
   one place on a climb where nothing is happening. game.js writes --gn-cd-y off ParPar's own
   camera each frame, so the card holds its place under the floor rather than drifting when the
   view moves. */
.gn-cd[data-place="floor"] {
    top: var(--gn-cd-y);
    bottom: auto;
}

.gn-cd-head {
    margin: 0 0 var(--gn-space-2);
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-xs);
    text-transform: uppercase;
    letter-spacing: .12em;
}

/* Two columns rather than a run-on line: the keys line up under each other, so the shape of
   the list is readable at a glance in the second or two anyone actually looks at it. */
.gn-cd-list {
    display: grid;
    grid-template-columns: auto auto;
    justify-content: center;
    align-items: center;
    column-gap: var(--gn-space-3);
    row-gap: var(--gn-space-2);
}

.gn-cd-keys {
    display: flex;
    justify-content: flex-end;
    gap: var(--gn-space-1);
}

.gn-cd kbd {
    min-inline-size: 2.2ch;
    padding: .1em .45em;
    border: 1px solid var(--gn-border-strong);
    border-radius: var(--gn-radius-sm);
    background: var(--gn-surface-2);
    color: var(--gn-text);
    font-family: var(--bs-font-monospace);
    font-size: var(--gn-fs-md);
    line-height: 1.4;
}

.gn-cd-act {
    text-align: start;
    font-size: var(--gn-fs-lg);
    font-weight: 600;
    line-height: var(--gn-lh-tight);
    color: var(--gn-text);
}

@media (prefers-reduced-motion: reduce) {
    .gn-cd { transition: none; }
}

/* ── The cleared-the-run screen (game.js #gn-win) ─────────────────────────────
   Raised by onVictory at three stars, which is the end of the whole progression.
   The one overlay on the site besides the nav drawer that is allowed to be a real
   dialog: the page behind it is already inert while a game runs, it is the only
   thing you can act on, and its single button and Escape do the same thing.
   Removed on deactivate, so it never outlives the run that raised it. */
#gn-win {
    position: fixed;
    z-index: 1150;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: min(92vw, 30rem);
    padding: var(--gn-space-4) var(--gn-space-3);
    text-align: center;
    background: var(--gn-surface);
    border: 2px solid #ffc83d;
    box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .62);
}

.gn-win-stars {
    display: flex;
    justify-content: center;
    gap: var(--gn-space-2);
    font-size: var(--gn-fs-4xl);
    line-height: 1;
    margin-bottom: var(--gn-space-3);
}

/* Each star lands on its own beat (game.js staggers the delay). One shot, not a
   loop: it marks the moment and then stops. */
.gn-win-stars .gn-star {
    animation: gn-win-star .42s ease-out both;
}

@keyframes gn-win-star {
    from { transform: scale(0) rotate(-40deg); opacity: 0; }
    to   { transform: scale(1) rotate(0); opacity: 1; }
}

.gn-win-title {
    font-size: var(--gn-fs-xl);
    font-weight: 700;
    line-height: var(--gn-lh-tight);
    margin: 0 0 var(--gn-space-2);
    color: var(--gn-text);
}

.gn-win-sub {
    font-size: var(--gn-fs-sm);
    color: var(--gn-text-muted);
    margin: 0 0 var(--gn-space-3);
}

.gn-win-stat {
    font-size: var(--gn-fs-lg);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    color: #ffc83d;
    margin: 0 0 var(--gn-space-3);
}

.gn-win-exit {
    min-height: var(--gn-touch);
    padding: 0 var(--gn-space-4);
    font: inherit;
    font-weight: 700;
    letter-spacing: .08em;
    text-transform: uppercase;
    background: var(--gn-accent);
    color: var(--gn-on-accent);
    border: 0;
    border-radius: var(--gn-radius-sm);
    cursor: pointer;
}

.gn-win-exit:hover {
    background: var(--gn-accent-hover);
}

.gn-win-hint {
    font-size: var(--gn-fs-xs);
    color: var(--gn-text-muted);
    margin: var(--gn-space-2) 0 0;
}

@media (prefers-reduced-motion: reduce) {
    .gn-win-stars .gn-star {
        animation: none;
    }
}

/* ── Main column and the in-flow legal line ──────────────────────────────── */
/* Centring and the width cap only. The GUTTER sits on the children, not here, and
   that is load-bearing rather than tidy: Bootstrap's `.row` carries a negative
   inline margin that expects a padded parent to bite into. Put the gutter on the
   wrapper and every `.row` inside `.content` escapes it by half a gutter each side
   — 3px of horizontal page scroll on the home page, which is always a bug. */
.gn-wrap {
    max-width: var(--gn-wrap-max);
    margin: 0 auto;
}

.content {
    padding-inline: var(--gn-gut);
}

/* ADR-016 §6 wants the legal documents inspectable from every page. The drawer's
   Legal group is two taps away; this is the visible one. In flow, never fixed. */
.gn-legal {
    border-top: 1px solid var(--gn-border);
    margin-top: var(--gn-space-4);
    padding: var(--gn-space-3) var(--gn-gut);
    display: flex;
    flex-wrap: wrap;
    gap: var(--gn-space-2);
    font-size: var(--gn-fs-xs);
    color: var(--gn-text-muted);
}

.gn-legal a {
    color: inherit;
}

/* The bug-report trigger is a <button> because it opens a [popover], not a page — and it has
   to look like the three links beside it, so the button chrome comes off. */
.gn-legal-bug {
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
    text-decoration: underline;
    cursor: pointer;
}

.gn-legal-bug:focus-visible {
    outline: 2px solid var(--gn-focus-ring);
    outline-offset: 2px;
}

/* A centred sheet, no z-index: a shown [popover] is in the browser's TOP LAYER, above every
   stacking context on the ladder above. The UA stylesheet already gives it position:fixed,
   inset:0 and margin:auto — the centring is free, and only the surface is ours. Light
   dismiss and Esc come with popover="auto"; there is no JS behind any of this. */
.gn-modal {
    inline-size: min(92vw, 32rem);
    max-block-size: 80dvh;
    overflow: auto;
    padding: var(--gn-space-3);
    border: 1px solid var(--gn-border-strong);
    border-radius: var(--gn-radius);
    background: var(--gn-surface);
    color: var(--gn-text);
}

.gn-modal::backdrop {
    background: rgb(0 0 0 / .55);
}

.gn-modal > :last-child {
    margin-block-end: 0;
}

/* A success notice that removes itself. Row S4.7 retired the Bootstrap toast container at
   1090 and nothing here brings it back: this is one element, no container, no script, and it
   sits at 1150 with the win screen rather than at the retired layer. It clears the strip the
   same way .gn-dafuq-panel does, so the readout is never covered.

   THE FADE IS THE WHOLE MECHANISM. animation-fill-mode: forwards holds the last keyframe, so
   after the run the element is transparent, hidden and click-through for as long as the page
   lives. A timer would need JS; this needs nothing. */
.gn-toast {
    position: fixed;
    z-index: 1150;
    inset: auto var(--gn-gut) calc(var(--gn-strip-h) + var(--gn-gut) + env(safe-area-inset-bottom)) auto;
    max-inline-size: min(90vw, 26rem);
    display: flex;
    align-items: center;
    gap: var(--gn-space-2);
    padding: var(--gn-space-2) var(--gn-space-3);
    border: 1px solid var(--gn-border-strong);
    border-left: 3px solid var(--gn-accent);
    border-radius: var(--gn-radius);
    background: var(--gn-surface);
    color: var(--gn-text);
    font-size: var(--gn-fs-sm);
    /* NOT animated to none at the end, set here for the whole life of the element. Measured in
       the pane: after the fade, `visibility: hidden` in the keyframe does not take on the
       computed value, so the faded toast stayed hittable and ate every click in that corner —
       an invisible dead zone over the page. Nothing in a toast is interactive, so the base rule
       is the fix and the keyframe only has to carry opacity. */
    pointer-events: none;
    animation: gn-toast-in .18s ease-out, gn-toast-out .5s ease-in 5s forwards;
}

.gn-toast .bi {
    color: var(--gn-accent);
}

@keyframes gn-toast-in {
    from { opacity: 0; transform: translateY(.5rem); }
}

@keyframes gn-toast-out {
    to { opacity: 0; }
}

/* Reduced motion loses the slide, not the disappearance — the fade is what makes this a notice
   rather than a permanent fixture, and holding it on screen forever is the worse outcome. */
@media (prefers-reduced-motion: reduce) {
    .gn-toast {
        animation: gn-toast-out .5s ease-in 5s forwards;
    }
}

/* ── The strip: one element, two states (ADR-042 §4) ─────────────────────── */
.gn-strip {
    position: fixed;
    z-index: 1000;
    inset: auto 0 0 0;
    height: calc(var(--gn-strip-h) + env(safe-area-inset-bottom));
    padding-bottom: env(safe-area-inset-bottom);
    background: var(--gn-surface);
    border-top: 2px solid var(--gn-accent);
    display: flex;
    align-items: stretch;
}

.gn-strip-inner {
    width: 100%;
    max-width: 1360px;
    margin: 0 auto;
    padding: 0 var(--gn-gut);
    display: flex;
    align-items: stretch;
    min-width: 0;
}

.gn-strip-idle {
    display: flex;
    align-items: stretch;
    width: 100%;
    min-width: 0;
}

/* ── The strip, PLAYING (ADR-042 §4) ─────────────────────────────────────────
   The same element, the other state. game.js builds the cells; every style for
   them is here, because the strip is chrome and chrome is authored in §3a. The
   deleted #gn-game-hud injected its own stylesheet from JS, which is the whole
   reason its z-index, its gradient and its click-through scaffolding were
   invisible to anyone reading the frame.

   THREE THINGS THE OLD HUD NEEDED AND THIS DOES NOT, all of them consequences of
   floating over the canvas rather than being chrome beside it: a downward
   gradient to read as an overlay (§4 rule 4 — opaque, --gn-surface, 2px accent
   border, which .gn-strip above already carries), a text-shadow under every
   glyph, and pointer-events:none with two opt-ins punched back through it. The
   strip is z-1000 over a z-900 canvas and takes its own clicks.

   ARIA-HIDDEN IS NOT TOGGLED FROM JS, and §4 rule 1 is still satisfied. The two
   states are sibling elements rather than one element rewritten, so whichever is
   idle is display:none and out of the accessibility tree on its own; the playing
   half carries a static aria-hidden="true" (StatusStrip.razor) and its two
   controls carry tabindex="-1" (game.js). What the rule actually forbids — a
   focusable control inside a hidden-but-rendered subtree — cannot occur, and
   there is no toggle to fall out of step with the class. */

/* Taller while playing, and THIS IS THE REFLOW §4 RULE 2 IS ABOUT: adding the
   class resizes the document, and HumbleCane reads scrollHeight exactly once,
   after it. A token value rather than a measurement, so the reflow is settled
   the moment the class lands.

   On :root, not on body, so html's scroll-padding-bottom follows it too — set on
   body and body's own padding updates while the scroll padding stays a state
   behind.

   THE STRIP IS ONE HEIGHT (owner, 2026-08-08). There is no --gn-strip-h override
   for the playing state and there must not be one: the footer is the same size
   whether or not a game is running. The playing readout is the same row of cells
   as the idle one, so it costs the same height by construction — a key line over
   a value, exactly like the handle cell.

   Everything the readout puts in that row has to fit 56px, which is what sizes
   the chip type, the meta cell's two columns and the nudge slider below. The one
   piece that could not was the meta cell at widths where the chips wrap to two
   rows, and the answer is that the meta cell does not appear at those widths —
   see .gn-strip-play .gn-hud-meta.

   This also retires most of ADR-042 §4 rule 2. The strip no longer changes size
   when a game starts, so it no longer moves the document. The ORDERING still
   matters, because body.gn-playing also hides the comment region (ADR-010) and
   that does change the document height — game.js sets the class before extract
   for that reason, not this one. */

.gn-strip-play {
    display: none;
}

body.gn-playing .gn-strip-idle {
    display: none;
}

/* THE SAME ROW OF CELLS AS THE IDLE STATE (owner, 2026-08-08 — this replaces §4
   rule 5's centred three-column grid). Idle is handle · score · trophies ·
   persona · next up; playing is lives · time · score · power-ups · meta. Same
   `.gn-cell` key-over-value shape, same borders, same height — which is what
   lets --gn-strip-h stay one number for both states. */
body.gn-playing .gn-strip-play {
    display: flex;
    align-items: stretch;
    width: 100%;
    min-width: 0;
}

/* FIXED WIDTHS, same lesson as .gn-cell-score on the idle strip: every one of
   these values changes while you are reading it. A timer sized to content
   re-measures ten times a second and drags the whole row with it. */
.gn-cell-lives {
    width: 3.5rem;
    flex: none;
}

.gn-cell-time {
    width: 3.5rem;
    flex: none;
}

.gn-cell-gscore {
    width: 4rem;
    flex: none;
}

.gn-cell-stars {
    width: 3.25rem;
    flex: none;
}

/* Below md the stars lose their label and keep only the glyphs. Every pixel the
   five fixed cells take is one the chip row wraps onto another line with, and of
   the six keys "Round" is the one the row underneath it explains by itself. */
.gn-strip-play .gn-cell-stars .gn-cell-k {
    display: none;
}

@media (min-width: 768px) {
    .gn-strip-play .gn-cell-stars .gn-cell-k {
        display: block;
    }
}

/* The New Game+ stars. Three states, and the middle one is the point: `pending` is the tier you
   are playing for right now, so it breathes while `empty` just sits there. Gold is its own hex
   rather than a token — an earned star means the same thing under all twelve palettes, the same
   argument as --gn-word-* and --gn-c3-* (css-tokens.md). */
.gn-strip-play .gn-stars {
    display: flex;
    align-items: center;
    gap: 2px;
    overflow: visible;
    font-style: normal;
    line-height: 1;
}

.gn-star {
    font-style: normal;
    color: #4a4f57;
}

.gn-star-earned {
    color: #ffc83d;
}

.gn-star-pending {
    color: #b08a1e;
    animation: gn-star-breathe 1.4s ease-in-out infinite;
}

@keyframes gn-star-breathe {
    0%, 100% { opacity: .45; }
    50%      { opacity: 1; }
}

/* Accessibility basic, not polish — a persistent pulse is exactly the motion that triggers
   vestibular symptoms. Keep the distinction, drop the movement. */
@media (prefers-reduced-motion: reduce) {
    .gn-star-pending {
        animation: none;
        opacity: 1;
    }
}

/* Roomier once there is room. The narrow set above is not a preference — under
   768px these three plus the chip row share ~355px inside a 56px strip, and every
   pixel they take is one the chips wrap onto a second line with. */
@media (min-width: 768px) {
    .gn-cell-lives {
        width: 5.5rem;
    }

    .gn-cell-time {
        width: 5rem;
    }

    .gn-cell-gscore {
        width: 8rem;
    }

    /* Wide enough for the word. At 4.5rem "ROUND" clipped to "RO…" — the key is
       uppercase with .16em tracking, which is wider than it looks. */
    .gn-cell-stars {
        width: 5.75rem;
    }
}

/* EVERY RULE BELOW THAT TOUCHES A CELL IS SCOPED TO `.gn-strip-play`, and that is
   load-bearing rather than tidy. These elements carry `.gn-cell` / `.gn-cell-v`
   as well, and those shared rules are defined LATER in this file — at equal
   specificity the later one wins, so an unscoped `.gn-hud-meta { display: none }`
   lost to `.gn-cell { display: flex }` and the meta cell showed at every width.
   The descendant selector makes it (0,2,0) and settles it on specificity instead
   of on where in the file somebody happens to add the next rule. */

/* Ships left, drawn as the ship itself. Red only on the last one — a colour that
   means "danger" from the first frame stops meaning anything by the third.

   The value is a CONTAINER, not a text node: an <svg> is replacing the ▲ glyphs.
   Anything dropped in here is sized to the line box and inherits the colour, so
   `fill: currentColor` on the SVG is all it needs to follow gn-low. It also has
   to undo `.gn-cell-v`'s nowrap/ellipsis clipping, which would cut an SVG off. */
.gn-strip-play .gn-lives {
    display: flex;
    align-items: center;
    gap: 2px;
    letter-spacing: .1em;
    overflow: visible;
}

.gn-strip-play .gn-lives > svg {
    height: 1em;
    width: auto;
    display: block;
    fill: currentColor;
}

.gn-strip-play .gn-lives.gn-low {
    color: var(--gn-danger);
}

/* The round banner rides the score cell's key line. Same size and register as a
   key, accent-coloured because it is an event rather than a label. */
.gn-strip-play .gn-cell-gscore .gn-cell-k {
    color: var(--gn-accent);
}

/* One chip per power-up in its own word colour, wrapping inside its cell. Same
   override as .gn-lives: `.gn-cell-v` clips with an ellipsis, which would eat
   the chips. */
.gn-strip-play .gn-hud-power {
    display: flex;
    align-items: center;
    gap: var(--gn-space-1);
    flex-wrap: wrap;
    white-space: normal;
    overflow: visible;
    font-size: var(--gn-fs-xs);
    font-weight: 700;
    letter-spacing: .04em;
}

/* §4 rule 3: the chip's colour is a --gn-word-* token, painted inline by game.js
   off the same values the word glow reads, so a green chip and a green word are
   the same fact. Those tokens are locked (ADR-044 §3) and nothing here re-tones
   them — the chip is currentColor on the strip's own surface, with no plate of
   its own. The old translucent black plate existed to survive the canvas showing
   through; opaque chrome does not need one.

   The pill radius is the one place --gn-radius is deliberately ignored. §4
   relocates these verbatim and redesigns none of them; a chip is a chip.

   BELOW md THE CHIP IS ITS COLOUR AND ITS COUNT, and nothing else. Five labelled
   chips need ~370px; down there the whole strip has ~355px and three other cells
   in it. Dropping the word keeps all five on one row inside 56px, and ADR-019 §3
   says the colour is the lesson — a green chip beside a green word — so the word
   is the half that can go. game.js writes the count to data-n for exactly this;
   `font-size: 0` hides the label text without hiding the box. */
.gn-chip {
    padding: 0 .35rem;
    border: 1px solid currentColor;
    border-radius: 999px;
    white-space: nowrap;
    font-size: 0;
}

.gn-chip::before {
    content: attr(data-n);
    font-size: var(--gn-fs-xs);
}

@media (min-width: 768px) {
    .gn-chip {
        padding: 0 var(--gn-space-2);
        font-size: var(--gn-fs-xs);
    }

    .gn-chip::before {
        content: none;
    }
}

/* Held at zero: same hue, just quieter — the colour still teaches, and the ones
   you actually hold still pop. */
.gn-chip.gn-off {
    opacity: .42;
}

/* The meta cell: the small print, deliberately quiet, holding the right end of
   the strip. Two columns inside it — read-only print, then the two controls —
   because stacked in one column it is four lines, which is taller than the
   strip. Hidden below xl — see the --gn-strip-h note above: below that width its
   275px is what forces the chip row to wrap, and the chips outrank it. */
.gn-strip-play .gn-hud-meta {
    display: none;
    margin-left: auto;
    flex-direction: row;
    align-items: center;
    gap: var(--gn-space-3);
    text-align: right;
    line-height: var(--gn-lh-tight);
    font-size: var(--gn-fs-xs);
    color: var(--gn-text-muted);
}

@media (min-width: 1200px) {
    .gn-strip-play .gn-hud-meta {
        display: flex;
    }
}

.gn-meta-text {
    min-width: 0;
}

.gn-meta-ctl {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 2px;
    flex: none;
}

/* …and gone again in a short viewport, which can be wider than xl and still have
   no vertical room for it. Below the xl rule so it wins. */
@media (max-height: 560px) {
    .gn-strip-play .gn-hud-meta {
        display: none;
    }
}

.gn-hint,
.gn-specs {
    font-size: var(--gn-fs-xs);
    color: var(--gn-text-muted);
}

.gn-specs {
    max-width: 22rem;
}

/* The turn-feel slider. `.gn-turn`, NOT `.gn-nudge` — that name is the trophy
   button's pulse further down this file, and the two are now siblings inside
   .gn-strip. */
.gn-turn {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--gn-space-1);
    font-size: var(--gn-fs-xs);
    color: var(--gn-text-muted);
}

.gn-turn input {
    width: 130px;
    cursor: pointer;
}

.gn-turn-val {
    min-width: 3.2em;
    text-align: left;
    font-variant-numeric: tabular-nums;
}

/* A control, so --gn-border-strong: the quiet border does not clear WCAG 1.4.11's
   3:1 on a boundary you are meant to be able to find. */
.gn-adv {
    cursor: pointer;
    font: inherit;
    font-size: var(--gn-fs-xs);
    color: var(--gn-text);
    background: var(--gn-surface-2);
    border: 1px solid var(--gn-border-strong);
    border-radius: var(--gn-radius-sm);
    padding: 0 var(--gn-space-2);
}

.gn-cell {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 1px;
    padding: 0 clamp(.55rem, 2vw, 1.1rem);
    height: 100%;
    border-left: 1px solid var(--gn-border);
    min-width: 0;
    background: none;
    border-top: 0;
    border-right: 0;
    border-bottom: 0;
    text-align: left;
    color: inherit;
}

/* The strip's cart cell is hidden until the cart holds something (StatusStrip). The native
   `hidden` attribute cannot do it alone: `.gn-cell { display: flex }` above is an author rule
   and beats the UA sheet's [hidden], so the cell would stay visible with the attribute set.
   Same specificity trap the .gn-strip-play block documents. */
.gn-cell[hidden] {
    display: none;
}

/* The trophy cell is an <a> since row S4.7 (it navigates to /account/trophies now, rather than
   opening a modal), and every other cell is a <div> or a <button>. Reset the anchor to match:
   `color: inherit` above already handles the hue, this kills the underline. */
a.gn-cell {
    text-decoration: none;
}

.gn-cell:first-child {
    border-left: 0;
    padding-left: 0;
}

/* THE ONE CELL THAT MUST NOT SIZE TO ITS CONTENT. It rotates, and both its lines
   change with it — "Score" (45px) to "Humblebrag Hurricane" (182px) to "This
   article — high score" (205px), the value gaining and losing a rank. Sized to
   content it re-measured on every tick and shoved every cell to its right sideways
   with it (owner, 2026-08-08).

   Fixed, not sized to the longest label: 205px plus the cell's own padding is 240px
   of a strip that also has to hold the reader bars, and the rotation is a background
   readout, not the page. `.gn-cell-k` already clips with an ellipsis, so a long
   board name truncates — consistently, in the same place, without moving anything. */
.gn-cell-score {
    width: 7.5rem;
    flex: none;
}

@media (min-width: 768px) {
    .gn-cell-score {
        width: 11rem;
    }
}

/* A cell that is also the trophy-case trigger. Keeps the 44px target without
   growing the strip: the cell is already full height. */
button.gn-cell {
    cursor: pointer;
    min-width: var(--gn-touch);
    font: inherit;
}

button.gn-cell:hover {
    background: var(--gn-surface-2);
}

.gn-cell-k {
    font-size: var(--gn-fs-xs);
    line-height: 1;
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
    white-space: nowrap;
    /* At 375px a cell is ~77px wide and "TROPHIES" overruns it — clip rather than
       let it spill across the neighbour's border. */
    overflow: hidden;
    text-overflow: ellipsis;
}

.gn-cell-v {
    font-size: var(--gn-fs-sm);
    line-height: 1.15;
    font-weight: 700;
    color: var(--gn-text);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    font-variant-numeric: tabular-nums;
}

.gn-cell-v .gn-sig {
    color: var(--gn-accent);
}

.gn-cell-hint {
    font-size: var(--gn-fs-xs);
    line-height: 1.1;
    color: var(--gn-text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 30ch;
}

.gn-cell-hide {
    display: none;   /* restored at md */
}

/* Endowed progress, not a bare 6/24. */
.gn-meter {
    display: flex;
    gap: 2px;
    margin-top: 2px;
}

.gn-meter i {
    width: 4px;
    height: 6px;
    background: var(--gn-border);
}

.gn-meter i.on {
    background: var(--gn-accent);
}

/* ADR-042 §4's reserved region. It is no longer a stub: the owner folded the
   ADR-013 §7 reader bars into it (2026-08-08), down from the top bar where they
   cost the primary links their row. margin-left:auto is still the whole of its
   layout contract — it holds the right end of the strip. */
.gn-strip-nav {
    margin-left: auto;
    display: flex;
    align-items: center;
    min-width: 0;
}

/* ── The persona dials (ADR-013 §7, redesigned 2026-08-17) ───────────────────
   The reader bars became two logo-face buttons, one per axis. Click one and a
   popover opens: Auto on top, a VERTICAL slider (0 at the bottom, max at the top)
   whose THUMB is the face itself, the rank ladder beside it, and the "written at"
   note below. No Apply — releasing the slider or toggling Auto submits the form
   (ReaderBars.razor owns the markup and the inline JS). The face is the readout:
   the rules below swap overlay art on data-level, on the strip button and the
   slider thumb alike. Hovering a strip button doubles the face and flies a flag
   with the rank in words ("Rated R", never a number).

   [popover] is the overlay mechanism: top layer, light dismiss, zero JS, and no
   stacking-context negotiation with the fixed strip — the same problem that turned
   the trophy case into a page. This is the second written exception to "expand in
   flow, never overlay" (the nav drawer is the first): the owner asked for a popup
   here explicitly (2026-08-17). This also settles the old OPEN narrow-placement
   note: two 44px buttons fit where the ~546px form never did. */
.gn-dials {
    display: flex;
    align-items: center;
    gap: .5rem; /* the 8px touch separation floor, exactly */
    /* Breathing room before the game buttons: the ::before glow deliberately
       leaks ~10% past each dial's box, and at NC-17 it was washing over the
       neighbouring button (owner, 2026-08-17). */
    margin-right: 1.25rem;
}

.gn-dial-btn {
    position: relative;
    flex: none;
    width: var(--gn-touch);
    height: var(--gn-touch);
    padding: 0;
    border: 0;
    background: none;
    cursor: pointer;
}

.gn-dial-btn svg {
    position: relative; /* above the ::before glow */
    display: block;
    width: 100%;
    height: 100%;
    transition: transform .15s ease-out;
}

/* Hover (or keyboard focus) doubles the face so the level art is actually
   readable at strip size (owner, 2026-08-17). Origin bottom-centre: it grows UP
   out of the strip, which has no overflow clip, and never nudges layout. */
.gn-dial-btn:hover svg,
.gn-dial-btn:focus-visible svg {
    transform: scale(2);
    transform-origin: 50% 100%;
}

/* The glow lives on the host element, not in the SVG: a radial wash reads as
   light from behind the head, where an in-SVG disc would read as a sticker behind
   it. It leaks ~10% past the box on purpose — a glow that stops on a right angle
   isn't one. Doubles with the face on hover. Art colours, like the logo's own
   baked hexes — not tokens. */
.gn-dial-btn::before,
.gn-dial-thumb::before {
    content: "";
    position: absolute;
    inset: -10%;
    border-radius: 50%;
    pointer-events: none;
    transition: transform .15s ease-out;
}

.gn-dial-btn:hover::before,
.gn-dial-btn:focus-visible::before {
    transform: scale(2);
    transform-origin: 50% 100%;
}

/* The hover flag: the rank in words, flown above the doubled face out of
   attr(data-rank), which the slider's oninput keeps in step with the art. */
.gn-dial-btn::after {
    content: attr(data-rank);
    position: absolute;
    left: 50%;
    bottom: calc(100% + var(--gn-touch) + 6px);
    translate: -50%;
    padding: .25rem .5rem;
    background: var(--gn-bg);
    border: 1px solid var(--gn-border-strong);
    color: var(--gn-text);
    font-size: var(--gn-fs-2xs);
    letter-spacing: .12em;
    text-transform: uppercase;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity .12s;
}

.gn-dial-btn:hover::after,
.gn-dial-btn:focus-visible::after {
    opacity: 1;
}

/* An amber corner dot when the selected mix has no generated variant yet — the
   popover's note carries the words, this is just the "something to know" ping.
   Painted as a background so it neither collides with the two pseudo-elements
   nor scales with the hovered face. */
.gn-dial-btn.gn-dial-miss {
    background: radial-gradient(circle 5px at 82% 14%, var(--gn-warning) 4px, transparent 5px);
}

/* The face's own palette — the brand mark's values, minus its animation machinery
   (a dial face holds still; ReaderBars.razor carries no <style> block at all).
   --tuft-stroke exists so jargon 3+ can re-border the hair puffs without touching
   --ink, which also outlines the head. */
.gn-dialface {
    --ink: #14141c;
    --mask: #282836;
    --flesh: #ffffff;
    --hair: #bd3529;
    --hair-lo: #7d1d13;
    --eye: #0a0a10;
    --tuft-stroke: var(--ink);
}

.gn-dialface .gn-ov {
    display: none;
}

/* Vulgarity: G is haloed and innocent, PG is the plain mark, then brows tip, the
   glow ignites and NC-17 grows horns and both fingers. Brow variants swap per
   level; glow, red eyes, horns and fingers accumulate. */
.gn-dial-v[data-level="0"] :is(.gn-ov-halo, .gn-ov-brow-happy) { display: inline; }
.gn-dial-v[data-level="2"] .gn-ov-brow-angry1 { display: inline; }
.gn-dial-v:is([data-level="3"], [data-level="4"]) .gn-ov-brow-angry2 { display: inline; }
.gn-dial-v[data-level="4"] :is(.gn-ov-horns, .gn-ov-fingers) { display: inline; }

.gn-dial-v:is([data-level="3"], [data-level="4"]) .gn-dialface {
    --eye: #d92b2b;
}

.gn-dial-v:is([data-level="3"], [data-level="4"]) .gn-eyes {
    filter: drop-shadow(0 0 2px rgba(255, 60, 30, .85));
}

.gn-dial-v[data-level="2"]::before {
    background: radial-gradient(circle at 50% 52%,
        rgba(214, 40, 40, .30) 8%, rgba(214, 40, 40, .12) 34%, transparent 55%);
}

/* No drawn flames — a hot core cooling through orange to a red rim reads as fire
   burning behind the head without a single flame path. */
.gn-dial-v:is([data-level="3"], [data-level="4"])::before {
    background: radial-gradient(circle at 50% 58%,
        rgba(255, 190, 60, .60) 6%, rgba(240, 110, 25, .38) 26%,
        rgba(200, 40, 10, .18) 44%, transparent 62%);
}

/* Jargon: innocent brows throughout, then glasses, a book, silver-edged puffs and
   finally grey hair under an e=mc² arc. Everything accumulates. */
.gn-dial-j .gn-ov-brow-happy { display: inline; }
.gn-dial-j:not([data-level="0"]) .gn-ov-glasses { display: inline; }
.gn-dial-j:is([data-level="2"], [data-level="3"], [data-level="4"]) .gn-ov-book { display: inline; }
.gn-dial-j[data-level="4"] .gn-ov-emc { display: inline; }

.gn-dial-j:is([data-level="3"], [data-level="4"]) .gn-dialface {
    --tuft-stroke: #d8d8e0;
}

.gn-dial-j[data-level="4"] .gn-dialface {
    --hair: #9a9aa8;
    --hair-lo: #6f6f7d;
}

/* The popovers. Top layer, so no z-stack entry needed. The UA default is
   display:none + centered; :popover-open is the only state we lay out, and the
   position is re-declared from scratch because the UA sheet sets inset:0.

   Directly above the icon that opened it (owner, 2026-08-17): the popover's own
   ontoggle (ReaderBars.razor) sets `left` to the button's centre on open, and the
   translateX recentres — CSS anchor positioning replaces the JS the day Firefox
   ships it. */
.gn-dial-pop {
    position: fixed;
    inset: auto;
    bottom: calc(var(--gn-strip-h) + 10px);
    transform: translateX(-50%);
    margin: 0;
    padding: .75rem .9rem;
    border: 1px solid var(--gn-border-strong);
    background: var(--gn-bg);
    color: var(--gn-text);
}

.gn-dial-pop:popover-open {
    display: flex;
    flex-direction: column;
    gap: .6rem;
}

.gn-dial-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: .75rem;
}

.gn-dial-title,
.gn-dial-pop .form-check-label,
.gn-dial-ranks {
    font-size: var(--gn-fs-2xs);
    letter-spacing: .12em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-dial-scale {
    display: flex;
    gap: .75rem;
}

/* The slider. Vertical is the native writing-mode form: vertical-lr puts the
   track upright, direction:rtl puts min at the BOTTOM. appearance:none because
   the FACE is the thumb (owner, 2026-08-17): the native thumb becomes an unseen
   44px hit square, the track is painted onto the input as a centre stripe, and
   .gn-dial-thumb — a third Face instance — rides the thumb's position via
   --gn-val, set inline by the same oninput that repaints the strip button.

   ponytail: the overlay follows calc(), not the real thumb rect, so the face can
   sit a hair off the hit square between browsers. Both are 2.75rem with the same
   travel, which keeps the error under a couple of px — live with it. */
.gn-dial-track {
    position: relative;
    display: inline-flex;
}

.gn-dial-pop input[type="range"] {
    writing-mode: vertical-lr;
    direction: rtl;
    appearance: none;
    width: 2.75rem;
    height: 10.5rem; /* 4 stops × 2.25rem + one thumb of headroom */
    background: linear-gradient(var(--gn-border-strong), var(--gn-border-strong))
        center / 3px calc(100% - 1.375rem) no-repeat;
}

.gn-dial-pop input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 2.75rem;
    height: 2.75rem;
    background: transparent;
    border: 0;
}

.gn-dial-pop input[type="range"]::-moz-range-thumb {
    width: 2.75rem;
    height: 2.75rem;
    background: transparent;
    border: 0;
}

.gn-dial-pop input[type="range"]::-moz-range-track {
    background: transparent;
}

/* min at the bottom, so distance from the TOP is (4 - value) quarters of the
   travel; travel is the track minus one thumb. */
.gn-dial-thumb {
    position: absolute;
    left: 50%;
    translate: -50%;
    top: calc((4 - var(--gn-val, 0)) / 4 * (100% - 2.75rem));
    width: 2.75rem;
    height: 2.75rem;
    pointer-events: none;
    transition: top .1s linear;
}

.gn-dial-thumb svg {
    position: relative;
    display: block;
    width: 100%;
    height: 100%;
}

.gn-dial-ranks {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    padding: .2rem 0;
}

/* The old track notch's replacement: the rank the article was written at. */
.gn-dial-rank-org {
    color: var(--gn-text);
    font-weight: 600;
}

/* "Written at PG-13." always; the amber line joins it when the selected mix has
   no generated variant yet, because ADR-003's fallback otherwise degrades
   silently and the reader deserves to know what they're actually getting. */
.gn-dial-note {
    margin: 0;
    max-width: 13rem;
    font-size: var(--gn-fs-2xs);
    color: var(--gn-text-muted);
}

.gn-dial-warn {
    color: var(--gn-warning);
}

/* ── The game toggles, in the strip (owner, 2026-08-11) ─────────────────────
   Moved out of the post's top-right (ADR-013's 2026-07-21 amendment) to sit beside the reader
   bars. GameButtons.razor emits them on every page — every gate it can check is a claim — and
   THIS IS THE GATE IT CANNOT: a playable article on screen. `.blog-post` is the games' own
   applicability rule (ADR-042 §6), so reading it here means the button and the engine can never
   disagree about whether there is anything to play. game.js also disables them off a post; the
   two agree, and this one is what stops a pair of dead buttons riding the chrome site-wide. */
body:not(:has(.blog-post)) .gn-strip-games {
    display: none;
}

.gn-strip-games {
    display: flex;
    align-items: center;
    gap: var(--gn-space-1);
    margin-inline-start: var(--gn-space-2);
}

/* 60px was sized for a post's header; the strip is 56px tall and the glow needs room to breathe
   inside it. Overrides game.js's injected block on specificity (0,2,0 beats 0,1,0), which is what
   lets the animation and the disabled state stay where they are — only the geometry moves here.
   The glow itself is untouched: it is the one thing on the strip allowed to pulse. */
.gn-strip-games .gn-game-btn {
    width: 34px;
    height: 34px;
    border-radius: 10px;
    font-size: var(--gn-fs-md);   /* was a literal 1.05rem — the scale step is ~5% smaller
                                     in a 34px box, invisible, and now follows the dial */
}

/* The glow is a box-shadow spreading up to 6px past the button, and neither .gn-strip nor
   .gn-strip-inner sets overflow — so it spills over the strip's top edge instead of being
   clipped into a rectangle. Checked, not assumed: adding `overflow: hidden` anywhere up that
   chain would silently square off the pulse. */

.gn-strip-signin {
    display: inline-flex;
    align-items: center;
    gap: var(--gn-space-2);
    align-self: center;
    margin-left: auto;
    min-height: var(--gn-touch);
    padding: 0 .85rem;
    background: var(--gn-accent);
    color: var(--gn-on-accent);
    text-decoration: none;
    font-size: var(--gn-fs-xs);
    font-weight: 700;
    letter-spacing: .12em;
    text-transform: uppercase;
}

.gn-strip-signin:hover {
    background: var(--gn-accent-hover);
    color: var(--gn-on-accent);
}

@media (min-width: 768px) {
    .gn-brand small {
        display: inline;
    }

    .gn-cell-hide {
        display: flex;
    }
}

@media (min-width: 992px) {
    .gn-primary {
        display: flex;
    }
}

/* ── 3. gn component classes (shared — must NOT live in scoped .razor.css) ── */
.gn-hero {
    padding: var(--gn-space-5) var(--gn-space-3) var(--gn-space-4);
}

/* Row S4.5: was `.lead gn-tagline` in the markup, and .lead was a bare 1.25rem literal. The
   size belongs on the tagline itself now — one step up from body, off the scale, so the
   reader's type control moves it like everything else. */
.gn-tagline {
    color: var(--gn-text-muted);
    max-width: var(--gn-measure);
    margin: 0 auto;
    font-size: var(--gn-fs-lg);
}

.gn-card {
    background: var(--gn-surface);
    border: 1px solid var(--gn-border);
    border-radius: var(--gn-radius);
    box-shadow: var(--gn-shadow);
}

.gn-card .card-title {
    color: var(--gn-text);
}

.gn-badge {
    background: var(--gn-border);
    color: var(--gn-text-muted);
}

/* The C3 tuner's read-only colour chip, sized to match Bootstrap's .form-control-color so a
   following value and an overridden one occupy the same box and the two columns stay level. It
   exists because an <input type="color"> cannot HOLD a token — it coerces anything it does not
   understand to #000000 — but that is a reason to hide the INPUT, not the colour.
   --gn-border-strong because it reads as a control even though it is not one (WCAG 1.4.11).

   `.gn-swatch` WAS ALREADY TAKEN by Appearance.razor's palette picker (a 22px chip, further down
   this file), which silently won on source order and rendered this one at 22px. Third time this
   class of clash has bitten — see ADR-042's `.gn-bar` and `.gn-nudge`. Grep before you name. */
.gn-c3-swatch {
    display: block;
    width: 3rem;
    height: calc(1.5em + .75rem + 2px);
    border: 1px solid var(--gn-border-strong);
    border-radius: var(--gn-radius);
}

/* Rendered blog-post body (Markdig output injected as raw HTML, so scoped
   .razor.css can't reach it — shared class per the styling convention).

   .gn-prose is paired in here, as it already is on the grid and the typography below, because
   the blog editor's preview pane wears it: the pane renders the SAME Markdig HTML through the
   SAME pipeline, and anything blog-post-only is a way for the preview to lie. It cannot wear
   .blog-post itself — that class is what game.js isApplicable() gates the game overlay on. */
.blog-post img,
.blog-post iframe,
.blog-post video,
.gn-prose img,
.gn-prose iframe,
.gn-prose video {
    max-width: 100%;
    border-radius: var(--gn-radius);
}

.blog-post img,
.gn-prose img {
    height: auto;
}

/* ── Breakout content grid — ADR-045 §3 (row S3.1) ──────────────────────────
   A named-line grid that lets one block escape the reading measure without leaving the
   flow. Children sit in `main` by default; `.gn-wide` and `.gn-full` opt out.

   THIS REPLACES THE max-width CAP, it does not sit beside it. `min(var(--gn-measure),
   100%)` — S1.3's amendment to ADR-042 §7 — is now the `main` track, and the `100%` half
   is still the thing that stops horizontal page scroll, so it must survive any edit here.

   THE MEASURE IS IN `ch`, NEVER px (ADR-042 §7), and it is a game change as much as a
   reading one: a pixel width silently becomes a 95-character line the moment somebody
   picks a narrower typeface, and every font in ADR-044's list has a different one. But a
   68ch column also makes an article TALLER while HumbleCane's word cap stays at 700 and
   its pan speed stays put — an emptier, slower level. The dial for that is the cap or the
   speed, not the layout, and which one moves is a playtest answer (S1.3).

   THE GRID GOES ON .blog-post, NEVER ON .content (ADR-045 §3). --gn-gut lives on
   .content precisely so Bootstrap's .row has a padded parent to bite into. Make .content
   a grid and every .row on every non-article page becomes a grid item, its negative
   inline margin resolves against the wrong box, and rows escape the gutter — horizontal
   page scroll on pages that have nothing to do with this component. Prose outside an
   article opts in with .gn-prose instead.

   The article body is injected as a MarkupString, so Markdig's output blocks are direct
   children of .blog-post and become the grid items. That is the mechanism, not a side
   effect: raw HTML passthrough is on by design, so an author writes
   <figure class="gn-full"> for a full-bleed image.

   COST, PAID KNOWINGLY: a grid item establishes its own formatting context, so a float
   inside one no longer wraps the blocks after it. `![](url){.float-start .me-3}` (the
   Editor still documents it) now floats within its own paragraph only. No live post uses
   it. That is also why .blog-post::after — a clearfix for exactly those floats — was
   deleted rather than ported.

   `clip`, not `hidden`: both bound the article, but `hidden` makes a scroll container,
   and a scroll container between the viewport and a `position: sticky` descendant is how
   §6's illustration pane silently stops sticking. `clip` creates no scrollport. */
.blog-post,
.gn-prose {
    display: grid;
    grid-template-columns:
        [full-start] minmax(var(--gn-gut), 1fr)
        [wide-start] minmax(0, var(--gn-space-5))
        [main-start] min(var(--gn-measure), 100%) [main-end]
                     minmax(0, var(--gn-space-5)) [wide-end]
                     minmax(var(--gn-gut), 1fr) [full-end];
    overflow-x: clip;
    overflow-wrap: anywhere;
}

/* min-width: 0 is not defensive tidying — a grid item's `auto` minimum is its content, so
   one unbreakable string in a paragraph would widen the `main` track past the measure. */
.blog-post > *,
.gn-prose > * {
    grid-column: main;
    min-width: 0;
}

.blog-post > .gn-wide,
.gn-prose > .gn-wide {
    grid-column: wide;
}

.blog-post > .gn-full,
.gn-prose > .gn-full {
    grid-column: full;
}

/* ── Float sections — the 2026-08-16 amendment to ADR-045 §3 ────────────────
   The grid above makes every top-level block its own formatting context, so a floated image
   wrapped the text in its own paragraph and nothing after it — the paragraph below started
   under the picture instead of beside it, which is not what a float is for. FloatSections.cs
   wraps a heading-to-heading run of blocks in this div when one of them floats an image, so
   the whole section is ONE grid item and floats behave the ordinary way inside it.

   `flow-root` is the entire mechanism: it establishes a block formatting context, so the
   section contains its own floats and the next section cannot collide with them. It is the
   modern spelling of the ::after clearfix that ADR-045 §3 deleted rather than ported.

   A section holding .gn-wide or .gn-full is deliberately never wrapped (FloatSections.cs) —
   its children would stop being grid children and the breakout would silently resolve to the
   measure. Breakout wins, because breakout failing is invisible. */
.blog-post > .gn-floatsec,
.gn-prose > .gn-floatsec {
    display: flow-root;
}

/* The section owns the space above it, so its first block must not add its own on top — the
   same job .blog-post > :first-child does at the top of an article. */
.gn-floatsec > :first-child {
    margin-block-start: 0;
}

/* ── Bleed float, and markdown's way into the breakout tracks (2026-08-16) ──
   TWO SEPARATE THINGS, both about using the page margin.

   1. `.gn-bleed` on a FLOATED image lets it overhang out of the `main` track and into the
   margin, while the prose stays locked at the measure. `50%` in a margin resolves against the
   containing block — the `main` track — so `50% - 50vw` is exactly the distance from the
   float's own edge to the page edge, and `+ --gn-gut` stops it a gutter short. `min(0px, …)`
   is the mobile guard: once `main` is `100%` the expression turns positive, and a positive
   margin here would shove the picture INTO the text.

   Scrollbar: `100vw` includes it, so the value can overshoot by the scrollbar's width. That
   is why `.blog-post` clipping with `overflow-x: clip` is load-bearing here and not just
   tidy — it eats the overshoot instead of growing a horizontal page scroll.

   `max-width: none` is required: the `.blog-post img` cap above is `100%` of the main track,
   which would undo the whole thing before the margin ever applied.

   2. `:has()` promotes a paragraph whose image carries `.gn-wide`/`.gn-full` to the matching
   track. ADR-045 §3 assumed a hand-written `<figure class="gn-full">`, because generic
   attributes on `![](url){.gn-full}` land on the <img> INSIDE the <p> — and the <p> is what
   the grid sees. Without this the class silently did nothing from markdown. */
.blog-post .gn-bleed,
.gn-prose .gn-bleed {
    /* THE AUTHORED WIDTH IS A CEILING, NOT A SIZE. `width=700` from the editor's drag handle is
       what the author saw at their window; at 900px that same 700px left a 100px ribbon of text
       beside it, which is not a column, it is a mistake. max-width makes the picture yield to the
       viewport, and height:auto above keeps the ratio.

       The value is the space from the main track's left edge to the page edge — `50%` resolves
       against the containing main track, so `50% + 50vw - gut` is exactly that reach — MINUS the
       narrowest text column worth printing. Expressed in ch, never px (ADR-042 §7): the whole
       point is characters per line, and a px floor becomes a different number of characters the
       moment somebody picks another typeface. Below this the image gives way, not the prose. */
    max-width: calc(50% + var(--gn-half-page) - var(--gn-gut) - 24ch);
}

/* A float needs a column beside it to be a float. Under ~700px there is no such column left, and
   the honest layout is the picture as its own full-width block with the text above and below —
   the same shape the ▭ band produces. !important beats Bootstrap's .float-end/.float-start, which
   are themselves !important; the margin reset undoes the bleed, which has nothing left to bleed
   into once the gutters are all there is. */
@media (max-width: 700px) {
    .blog-post .gn-bleed,
    .gn-prose .gn-bleed {
        float: none !important;
        max-width: 100%;
        margin-inline: 0 !important;
    }
}

/* HALF THE PAGE, NOT HALF THE VIEWPORT, and the difference is the whole bug this replaced.
   `.gn-wrap` caps the shell at --gn-wrap-max and centres it, so past that width the page stops
   growing and the window keeps going. A bleed derived from 50vw therefore pushed the picture
   beyond the article's own edge on any wide screen, and `overflow-x: clip` dutifully sliced off
   the overhang — a cropped photo with empty space beside it, which reads as "the image is broken"
   rather than "the maths is". Clamping to half the wrap makes the two agree at every width.

   It is a variable rather than a repeated min() so the margin and the max-width below cannot
   drift apart: they are two halves of one measurement. */
.blog-post,
.gn-prose {
    --gn-half-page: min(50vw, calc(var(--gn-wrap-max) / 2));
}

.blog-post .gn-bleed.float-end,
.gn-prose .gn-bleed.float-end {
    margin-inline-end: min(0px, calc(50% - var(--gn-half-page) + var(--gn-gut)));
}

.blog-post .gn-bleed.float-start,
.gn-prose .gn-bleed.float-start {
    margin-inline-start: min(0px, calc(50% - var(--gn-half-page) + var(--gn-gut)));
}

/* text-align, not width:100% — a photo narrower than the band should sit centred in it, not be
   upscaled to fit. */
.blog-post > p:has(> img.gn-wide),
.gn-prose > p:has(> img.gn-wide) {
    grid-column: wide;
    text-align: center;
}

.blog-post > p:has(> img.gn-full),
.gn-prose > p:has(> img.gn-full) {
    grid-column: full;
    text-align: center;
}

.blog-post pre,
.gn-prose pre {
    max-width: 100%;
    overflow-x: auto;
}

.blog-post table,
.gn-prose table {
    display: block;
    max-width: 100%;
    overflow-x: auto;
}

/* An author's TAB, rendered as the editor renders it: about one character wide (AuthorSpacing.cs
   emits these). 1ch = the width of "0" in the post's own font, so it tracks persona themes and
   font-size changes instead of being a fixed pixel guess. THE calibration knob for
   editor-vs-post drift — measured at 8.63px against the editor's ~8.8px per tab. */
.blog-post .gn-tab,
.gn-prose .gn-tab {
    display: inline-block;
    width: 1ch;
}

/* Verbatim layout block. Markdown reflows prose and strips leading indentation by design;
   wrap a section in <pre class="gn-layout">…</pre> to keep your spaces, tabs, newlines and
   blank lines exactly as typed (Markdig passes a raw <pre> block through untouched — must be
   <pre>, since a <div> block would end at the first blank line). pre-wrap overrides <pre>'s
   no-wrap so the page stays bound to its width — used to spread words across the play area.
   Reset the code-ish chrome a bare <pre> would otherwise inherit; monospace stays for aligned
   columns. */
.blog-post pre.gn-layout,
.gn-prose pre.gn-layout {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    margin: 0;
    padding: 0;
    background: none;
    border: 0;
    color: inherit;
}

/* ── The article page — ADR-042 §7 (row S4.2) ───────────────────────────────
   THE MEASURE WAS NOT THIS ROW'S PROBLEM. S3.1 made `main` the `min(--gn-measure,
   100%)` track above and S1.3 signed it off; nothing here touches it. What this row
   found is that the measure was the ONLY thing on the page obeying it — the header, the
   colophon and the whole comment region were full-width children of `.content` while the
   prose sat in a centred 68ch column, so every boundary stepped ~280px left and the page
   read as two documents that had missed each other.

   `.gn-measured` is the whole fix, and it is deliberately NOT a grid. `.content` must
   never become one (see the breakout-grid note above: Bootstrap's `.row` needs a padded
   BLOCK parent, and every non-article page would lose its gutter). A max-width plus auto
   margins lands on exactly the same left edge, because the grid's two gutter tracks are
   symmetric `1fr`s and therefore centre `main`. Verified against the computed value, not
   assumed — `.blog-post` resolves to a 637.5px `main` at 1201px, and so does this. */
.gn-measured {
    max-width: min(var(--gn-measure), 100%);
    margin-inline: auto;
}

/* ── The header: kicker · title · dek · byline ──────────────────────────────
   L3's article head (design-explore/L3-console.html §8), resolved onto the type scale
   rather than copied as literals, the same way S4.1 did the index — every size here is a
   --gn-fs-* step so the reader's type control still moves it (ADR-044 §4).

   EVERYTHING EXCEPT THE TITLE CARRIES data-gn-noscan, in the markup. The extractor roots
   at `.content` and gamifies "title + body" on purpose (game.humblecane.js:370), so the
   title stays a target exactly as it was before this row — but the kicker, dek, byline
   and tags are new text inside that root, and without the mark ~20 words of chrome
   ("TECHNICAL", "min read", "By") would have been lifted into every level. That is
   ADR-042 §6's rule applied to metadata that did not exist when it was written. */
.gn-article-head {
    margin-block-end: var(--gn-space-4);
    padding-block-end: var(--gn-space-3);
    border-block-end: 1px solid var(--gn-border);
}

/* While a game runs, the head keeps its TITLE (a deliberate target — §6's "title + body")
   and loses its metadata: kicker, dek, byline and the hairline all read as floating chrome
   inside a level (owner, 2026-08-13, S7.0 close-out — "date, by line, break line are
   visible on the screen"). Safe against the reflow-before-extract rule for the same reason
   the comment region is: game.js sets body.gn-playing BEFORE extraction, so the level is
   built from the collapsed document. All four carry data-gn-noscan already, so no level
   words are lost — this is the visual half of the same exclusion. */
body.gn-playing .gn-article-kick,
body.gn-playing .gn-article-dek,
body.gn-playing .gn-article-byline {
    display: none;
}

body.gn-playing .gn-article-head {
    border-block-end: 0;
    padding-block-end: 0;
}

/* The micro-label row. Same .16em caps as the index's column heads — it is the same
   voice, and the two pages are seen one after the other. */
.gn-article-kick {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--gn-space-2);
    margin-block-end: .6rem;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

/* The category is the one loud thing in the row — it is the only part a reader uses to
   place the article before reading it. */
.gn-article-kick b {
    color: var(--gn-accent);
    font-weight: 700;
}

/* No font-size here on purpose: the global h1 rule already clamps between two scale
   steps, so this only tightens what a display line needs (css-tokens: never write a
   literal font-size — if no step fits, the scale is wrong). */
.gn-article-title {
    margin: 0 0 var(--gn-space-2);
    line-height: 1.08;
    letter-spacing: -.02em;
}

/* Narrower than the prose it introduces. A standfirst set to the full measure reads as
   a first paragraph, which is the one thing it must not be mistaken for. */
.gn-article-dek {
    margin: 0 0 var(--gn-space-3);
    max-width: 58ch;
    font-size: var(--gn-fs-lg);
    line-height: 1.45;
    color: var(--gn-text-muted);
}

.gn-article-byline {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--gn-space-2) var(--gn-space-3);
    font-size: var(--gn-fs-xs);
    color: var(--gn-text-muted);
}

/* L3's `By — Name` pair. A real <dl>, not two spans: it is a term and its value, and that is
   what makes the row read as a byline rather than as a caption. The label keeps the micro-cap
   voice; the name is body ink, because the name is the information. */
.gn-article-byline dl {
    display: flex;
    align-items: baseline;
    gap: .4rem;
    margin: 0;
}

.gn-article-byline dt {
    font-size: var(--gn-fs-2xs);
    letter-spacing: .14em;
    text-transform: uppercase;
}

.gn-article-byline dd {
    margin: 0;
    font-weight: 700;
    color: var(--gn-text);
}

/* ── Prose rhythm ───────────────────────────────────────────────────────────
   Before this row the article body had four rules — img, pre, table and .gn-tab — so
   every paragraph, heading, list and quote fell through to Bootstrap's reboot: a flat
   16px gap between everything, and a heading spaced identically to the paragraph above
   and the paragraph below it.

   THE ONE IDEA HERE: space belongs ABOVE a heading, not below it. A subhead sits with
   the text it introduces; an evenly-spaced one floats between two blocks and belongs to
   neither. Everything else is that principle plus the token scale.

   Scoped to .blog-post and .gn-prose together, exactly as the breakout grid above is —
   they are the two prose surfaces and they must not drift apart. */
.blog-post,
.gn-prose {
    line-height: var(--gn-lh);
}

.blog-post :is(h2, h3, h4, h5, h6),
.gn-prose :is(h2, h3, h4, h5, h6) {
    margin-block: var(--gn-space-4) var(--gn-space-2);
}

/* An article opening on a subhead must not push itself down the page. */
.blog-post > :first-child,
.gn-prose > :first-child {
    margin-block-start: 0;
}

.blog-post p,
.gn-prose p {
    margin-block: 0 var(--gn-space-3);
}

/* L3's console mark on the two subhead levels an author actually writes. Generated
   content, so it is invisible to the word extractor's TreeWalker (it walks text NODES)
   and cannot become a game target — checked, because a "##" target would be absurd. */
.blog-post :is(h2, h3)::before,
.gn-prose :is(h2, h3)::before {
    content: "## ";
    color: var(--gn-accent);
}

.blog-post h3::before,
.gn-prose h3::before {
    content: "### ";
}

/* ::marker rather than L3's `list-style: none` + a ::before glyph. Same accent bullet,
   and it keeps the list a list — Safari drops list semantics from VoiceOver the moment
   list-style goes to none, which would have made this a quiet accessibility regression
   in exchange for nothing. */
.blog-post :is(ul, ol) > li::marker,
.gn-prose :is(ul, ol) > li::marker {
    color: var(--gn-accent);
}

.blog-post li,
.gn-prose li {
    margin-block-end: .45rem;
}

/* L3's pull quote, as the plain <blockquote> an author gets from Markdown for free —
   no class to remember, so it is what they will actually type. */
.blog-post blockquote,
.gn-prose blockquote {
    margin-block: var(--gn-space-4);
    padding: var(--gn-space-3);
    border-inline-start: 3px solid var(--gn-accent);
    background: var(--gn-surface);
}

.blog-post blockquote > :last-child,
.gn-prose blockquote > :last-child {
    margin-block-end: 0;
}

/* Solid and themed, like .gn-section-break — Bootstrap's default hr is an opacity trick
   that all but vanishes on half the twelve palettes. */
.blog-post hr,
.gn-prose hr {
    margin-block: var(--gn-space-4);
    border: 0;
    border-block-start: 1px solid var(--gn-border);
    opacity: 1;
}

.blog-post figure,
.gn-prose figure {
    margin-block: var(--gn-space-4);
}

.blog-post figcaption,
.gn-prose figcaption {
    margin-block-start: var(--gn-space-2);
    font-size: var(--gn-fs-xs);
    color: var(--gn-text-muted);
}

/* ── Sticky illustration pane — ADR-045 §6 (row S3.6) ───────────────────────
   A two-column region where the prose scrolls and an illustration holds position beside
   it. Markup contract:

     <div class="gn-illus">
       <figure class="gn-illus-fig" data-gn-noscan>…</figure>   <-- FIRST in the DOM
       <div class="gn-illus-body">…prose…</div>
     </div>

   THE FIGURE COMES FIRST IN SOURCE and is placed into column 2 at lg. That ordering is
   the whole of the narrow-viewport rule (ADR-045 §6): below lg there is no placement, the
   two blocks stack in source order, and the illustration lands ABOVE the passage it
   illustrates. An illustration that arrives after the passage explaining it is not an
   illustration.

   align-self: start IS REQUIRED and is the single most common way this component silently
   does nothing: a grid item stretches to its row by default, and a full-height sticky
   element has no slack inside its container to move within, so it never unsticks from the
   top and never appears to stick at all.

   `top` clears the fixed bar. Stuck at 0 it would slide underneath it.

   `sticky`, NEVER `fixed` (ADR-042 §7): fixed leaves document coordinate space outright,
   and both games extract words once, in document coordinates. sticky is why the pane's
   text still carries data-gn-noscan (ADR-045 §2 rule 6) — it moves relative to the
   document after extraction, so its words would be shootable where they no longer are.
   The attribute is the author's job; CSS cannot add it.

   Nothing here creates a scroll container, and nothing may: any ancestor that scrolls
   between this and the viewport captures the stickiness. That is why §3 above clips with
   `clip` rather than `hidden` — a pane used inside an article depends on it.

   NO INNER SCROLLER (§2 rule 7). The pane is authored to fit the viewport; one taller
   than the viewport simply stops being sticky rather than growing a scrollbar.

   No :hover / :focus-visible / disabled block (§2 rule 9) and no motion (§2 rule 10): the
   pane is a region, never a control. Whatever it holds owns its own states.

   Column width is in `ch` (§2 rule 8), so it tracks the reader's type step and the twelve
   font choices instead of being a pixel guess. Inside an article, give the whole pane
   `.gn-wide` or `.gn-full` — at `main` there is no room for two columns. */
.gn-illus {
    display: grid;
    gap: var(--gn-space-3);
}

@media (min-width: 992px) {
    .gn-illus {
        grid-template-columns: minmax(0, 1fr) minmax(0, 34ch);
        gap: var(--gn-space-4);
    }

    .gn-illus-body {
        grid-column: 1;
        grid-row: 1;
    }

    .gn-illus-fig {
        grid-column: 2;
        grid-row: 1;
        align-self: start;
        position: sticky;
        top: calc(var(--gn-bar-h) + var(--gn-space-3));
        margin: 0;
    }
}

/* ── Progress ring — ADR-045 §8 (row S3.5) ──────────────────────────────────
   Drawn by ProgressRing.razor. Two concentric strokes on r=45; the arc length is a
   server-computed stroke-dasharray, so there is no script and nothing to measure.

   Sized in em, not on the spacing scale: the ring's job is to sit at the size of the
   text beside it, so it inherits the type step of whatever draws it rather than
   picking one. Geometry stays unitless inside the viewBox (ADR-045 §8) — it is a
   shape, not a spacing decision, so it is deliberately not tokenised.

   No :hover / :focus-visible / disabled block (§2 rule 9): the ring is never itself a
   control. Where it sits inside one — the trophy tile, the strip cell — that control
   owns the interactive states and the ring just rides along. No motion either, so
   nothing for prefers-reduced-motion to switch off.

   CONTRAST, AND THE ONE PLACE THIS FALLS SHORT OF §8. Both strokes clear their own leg
   against the ground in all twelve palettes: --gn-border-strong is held to 3:1 by
   palette-contrast.py, and --gn-accent measures 5.58–14.27:1. What does NOT clear is
   the fill against the track — 1.40:1 (ember-light) to 2.51:1 (terminal-green), where
   §8 asks for 3:1. That is not a tuning miss: a sweep of every token pair in the twelve
   palettes tops out at 2.51:1 (--gn-text on --gn-border-strong), so no existing pairing
   satisfies both legs and the fix is a palette change, not a CSS one. It is survivable
   because WCAG 1.4.11 exempts a boundary whose information is also available in text,
   which is exactly the Label-is-null case the component is built around — the number is
   stated beside the ring. A standalone ring (Label set) is the case that is genuinely
   short, so prefer a visible sibling. Re-run docs/projects/style/palette-contrast.py
   after any palette edit. */
.gn-ring {
    /* THE KNOB. Unitless because it is viewBox user units, not CSS px — it does not scale with
       the type step, it is a proportion of the ring itself, so one value holds at every size.
       Override per instance (a dense list may want it thinner) rather than editing this default:
           .gn-index-c-ai .gn-ring { --gn-ring-stroke: 10 }
       CEILING IS 20, set by ProgressRing.razor's Radius of 40: the stroke grows both ways from
       the centreline, so anything past 2 × (50 − Radius) is clipped by the viewBox. Want thicker?
       Lower Radius — the circumference and every dasharray follow it automatically. */
    --gn-ring-stroke: 13;

    width: 1em;
    height: 1em;
    flex: none;                 /* a meter beside text must not be squeezed by its flex parent */
    vertical-align: -0.15em;    /* optically centred on the x-height rather than sat on the baseline */
}

/* Per-instance size override, the pattern the block above describes. The trophy case's summary
   dial is a graphic rather than a ring sitting in a line of text: no type step reaches 4rem, and
   `1em` would tie it to whatever step the caption happens to use. It lives here and not in
   TrophyCase.razor.css because the <svg> is ProgressRing's markup and carries ProgressRing's
   scope — a scoped rule in the calling component never matches it. */
.gn-case-dial .gn-ring {
    inline-size: var(--gn-space-5);
    block-size: var(--gn-space-5);
    vertical-align: baseline;   /* the -.15em nudge is for a ring optically centred on x-height */
}

.gn-ring-track,
.gn-ring-fill {
    fill: none;
    stroke-width: var(--gn-ring-stroke);
    /* §8: butt, never round. A round cap at 0% draws a visible dot — a ring that reads as
       "started" for a reader who has not started. */
    stroke-linecap: butt;
}

.gn-ring-track {
    stroke: var(--gn-border-strong);
}

.gn-ring-fill {
    stroke: var(--gn-accent);
}

/* ── Article index table — ADR-042 §7 (row S4.1) ────────────────────────────
   /blog's list of articles, ported from design-explore/L3-console.html §02.

   THIS REPLACED A SUBGRID <ul> (S3.2), and the reversal is recorded in ADR-045 §4.
   Short version: a six-column data grid wants column headers, a hover row and zebra,
   and a list can give none of the three — a header inside a <ul> is a fake <li>, and a
   full-bleed row needs the row gap the list is built around. A table announces "Tags,
   column 3: themes" on every cell, which beats "list, 12 items" for this surface. The
   price is ADR-042 §8's card-below-md WITH ROLES RE-ASSERTED, which is written below
   and is the half that stops the card flip being an accessibility regression.

   Sizes are L3's, resolved onto the type scale rather than copied as literals: every
   font-size here is a --gn-fs-* step so the whole index still moves with the reader's
   size preference (ADR-044 §4). The steps land within ~7% of the mockup, and --gn-fs-2xs
   was added to §1 for the micro-labels L3 leans on. Spacing IS L3's, to the 0.05rem —
   padding is not on the type scale and has no reader control to preserve. */

/* L3's section head: label, meta on the right, accent rule under both.
   Shared with Gaslight Alley (row S4.2) rather than copied — it is the same head in the
   same voice, and the index and an article's comment section are seen one after the
   other. The `gn-index-` name stays because that is where it was authored; a second
   class on the same declarations is cheaper than renaming three rules and their call
   sites for tidiness. */
.gn-index-head,
.gn-comments-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--gn-space-2);
    margin-block: var(--gn-space-4) 0;
    padding-block-end: .35rem;
    border-block-end: 2px solid var(--gn-accent);
}

.gn-index-head h2,
.gn-comments-head h2 {
    margin: 0;
    font-size: var(--gn-fs-xs);
    font-weight: 700;
    letter-spacing: .16em;
    text-transform: uppercase;
}

.gn-index-head-meta,
.gn-comments-head-meta {
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-index {
    width: 100%;
    border-collapse: collapse;
    margin: 0;
    color: var(--gn-text);
}

/* Not display:block (L3 sets that and the caption collapses into the first column's
   width). table-caption spans the table, which is the whole point of a caption. */
.gn-index caption {
    caption-side: top;
    padding: 0 0 .5rem;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
    text-align: left;
}

/* Card mode has no visible header row — but NOT display:none, which would drop the
   column headers out of the accessibility tree, and the re-asserted role="columnheader"
   is the whole reason the card flip is safe. The visible replacement (td::before,
   generated content) is not an accessible name. Hide it visually, keep it announced.
   Undone in the md block below. */
.gn-index thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
}

.gn-index th,
.gn-index td {
    text-align: left;
}

/* ── base (< md): card up ─────────────────────────────────────────────────
   display:block + data-label, with the roles re-asserted in the markup. */
.gn-index,
.gn-index tbody,
.gn-index tr,
.gn-index td {
    display: block;
}

.gn-index tbody tr {
    border: 1px solid var(--gn-border);
    border-inline-start: 3px solid var(--gn-border);
    background: var(--gn-surface);
    padding: .7rem .85rem;
    margin-block-end: -1px;    /* collapse the shared edge between stacked cards */
}

.gn-index:not(.gn-index-soon) tbody tr:hover,
.gn-index tbody tr:focus-within {
    border-inline-start-color: var(--gn-accent);
}

.gn-index td {
    display: flex;
    gap: .75rem;
    align-items: baseline;
    padding: .18rem 0;
    font-size: var(--gn-fs-sm);
}

.gn-index td::before {
    content: attr(data-label);
    flex: none;
    width: 5.5em;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .14em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
    line-height: 1.9;
}

.gn-index td.gn-index-c-title {
    display: block;
    padding: .1rem 0 .35rem;
}

.gn-index td.gn-index-c-title::before {
    display: none;
}

/* ── the title cell ───────────────────────────────────────────────────────
   Thumb and text side by side. Flex is safe here in a way it was not in the subgrid
   list: a table cell aligns on `vertical-align: top`, so nothing hands its baseline to
   the picture. */
.gn-index-t {
    display: flex;
    gap: .6rem;
    align-items: flex-start;
}

.gn-index-thumb {
    flex: none;
    inline-size: 44px;
    block-size: 44px;
    background: var(--gn-surface-2);
    overflow: hidden;
}

.gn-index-thumb img {
    inline-size: 44px;
    block-size: 44px;
    object-fit: cover;
}

/* No cover image. A dashed empty box says "missing" and nothing else; this says who is
   at fault. No letter-spacing on this one — "SOMEONE" is 7 characters and 44px is all
   there is. */
.gn-index-thumb-none {
    display: grid;
    place-items: center;
    padding: 1px;
    border: 1px dashed var(--gn-border-strong);
    font-size: var(--gn-fs-2xs);
    line-height: 1.1;
    text-align: center;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-index-t-main {
    min-width: 0;
}

/* The category (BlogPost.Categories), not a tag: one top theme per article, and the
   accent is what makes it read as a section marker rather than another chip. */
.gn-index-kick {
    display: block;
    margin-block-end: 1px;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .14em;
    text-transform: uppercase;
    color: var(--gn-accent);
}

.gn-index-link {
    display: inline-block;
    font-size: var(--gn-fs-md);
    font-weight: 700;
    line-height: var(--gn-lh-tight);
    letter-spacing: -.01em;
    color: var(--gn-text);
    text-decoration: none;
}

/* --gn-text on hover too: the accent moves to the underline, so the pointer marks one
   row instead of every headline being coloured at rest.

   `a.` IS THE POINT (owner, 2026-08-15). ArticleCell renders an unreleased title as a
   <span class="gn-index-link"> — same look, no destination — so an unqualified selector
   painted a hover underline on a headline that goes nowhere, and /blog's Coming soon table
   read as clickable as the published one under it. FeatureRequests.razor had already dodged
   this by declining the class; scoping it to anchors is the fix that file was working around. */
a.gn-index-link:hover,
a.gn-index-link:focus-visible {
    color: var(--gn-text);
    text-decoration: underline;
    text-decoration-color: var(--gn-accent);
    text-decoration-thickness: 2px;
    text-underline-offset: .2em;
}

.gn-index-dek {
    display: block;
    margin: .2rem 0 0;
    font-size: var(--gn-fs-xs);
    font-weight: 400;
    letter-spacing: normal;
    line-height: 1.45;
    color: var(--gn-text-muted);
}

/* ── chips ────────────────────────────────────────────────────────────────
   The sub-taxonomy, open-ended where the kicker above is not. */
.gn-index-tags {
    display: flex;
    flex-wrap: wrap;
    gap: .3rem;
}

/* No "#" prefix — L3 had one, we are not doing hashtags (owner, 2026-08-11). The accent rides
   the chip's LEFT EDGE instead, which is where it belonged anyway: an edge is a non-text
   boundary and owes 3:1, where accent-coloured text this small would owe 4.5:1 and miss it in
   the light palettes. */
.gn-tag {
    font-size: var(--gn-fs-2xs);
    line-height: 1.6;
    letter-spacing: .04em;
    padding: 0 .35rem;
    background: var(--gn-surface-2);
    color: var(--gn-text-muted);
    box-shadow: inset 0 0 0 1px var(--gn-border);
    border-inline-start: 2px solid var(--gn-accent);
    white-space: nowrap;
}

/* ADR-045 §2 rule 11: every state says its own word — READ, UNREAD, DRAFT, SCHEDULED —
   so the fill is emphasis and never the carrier. */
.gn-pill {
    display: inline-block;
    padding: 1px 7px;
    font-size: var(--gn-fs-2xs);
    font-weight: 700;
    letter-spacing: .1em;
    text-transform: uppercase;
    white-space: nowrap;
}

.gn-pill-read {
    background: var(--gn-accent);
    color: var(--gn-on-accent);
}

/* Opened, not finished. --gn-accent-2 is the one thing it is for (css-tokens.md): the second
   hue exists to pull an eye to a single item, used sparingly, and there is at most a handful of
   half-read articles in a list. Its own ink token, because --gn-on-accent is tuned for the
   PRIMARY accent and the two fills are 60° apart. */
.gn-pill-started {
    background: var(--gn-accent-2);
    color: var(--gn-on-accent-2);
}

/* A status label, not a control: the word carries the state, so the chip edge is
   decorative and may sit on the hairline token. */
.gn-pill-quiet {
    background: var(--gn-surface-2);
    color: var(--gn-text);
    box-shadow: inset 0 0 0 1px var(--gn-border);
}

/* Taken down (/blog/admin). The one pill that should catch an eye crossing the table:
   a hidden article is a state somebody chose in a hurry and may have forgotten. */
.gn-pill-alarm {
    background: var(--gn-danger);
    color: var(--gn-on-accent);
}

/* Edit beside Hide. The form is a block, so without this the button drops below the
   link and the Action column doubles in height on every row. */
.gn-index-actions {
    display: flex;
    align-items: center;
    gap: var(--gn-space-1);
}

.gn-index-num {
    font-variant-numeric: tabular-nums;
}

/* Row P1.3 — the cart's quantity field, inside .gn-index-actions beside its Update button.
   A .form-control is width:100%, which in a flex row reads as "take everything"; the value is
   at most two digits plus a spinner. ch rather than px so it follows the type scale.
   Nothing else is authored for the cart: the table, the pills, the buttons and the card-below-md
   behaviour are all the .gn-index set already here. */
.gn-cart-qty {
    inline-size: 7ch;
    flex: none;
}

/* The release time under a Pending pill (/blog/admin). Under it rather than inside: a pill that
   grows a timestamp stops being scannable as a state. */
.gn-index-when {
    display: block;
    margin-block-start: 2px;
    font-size: var(--gn-fs-2xs);
    color: var(--gn-text-muted);
    font-variant-numeric: tabular-nums;
}

/* The admin table's Edit button. A .btn-sm is under the 44px floor, and css-tokens.md's touch
   rule applies to admin too — so grow it on the coarse pointers that need it and leave the dense
   version alone everywhere else. The predicate is the one the head script writes; never re-derive
   it from a media query. */
[data-gn-input="coarse"] .gn-index-c-action .btn {
    min-block-size: var(--gn-touch);
    display: inline-flex;
    align-items: center;
}

.gn-index-comments {
    display: inline-flex;
    align-items: baseline;
    gap: .35em;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

/* The C3PO mark sits in the last column, so its hover card's default `left: 0` hangs
   15rem off the right edge of the page and takes the horizontal scrollbar with it.
   The card is the component's; the edge it opens against is this table's problem. */
.gn-index-c-ai .gn-c3-peek {
    left: auto;
    right: 0;
}

/* ── The breakdown as an overlay, on the index ONLY (owner, 2026-08-11) ──────
   ADR-042 §7 is "expand in flow, never overlay", and ADR-045 §5 built this panel to that rule.
   It holds on an article, where the panel has the measure to itself. It fails here: the C3PO
   column is width:1%, so the breakdown rendered into ~150px of column and was unreadable.
   The index takes the exception; every other surface keeps the rule.

   SCOPED, AND DELIBERATELY CHEAP. No markup change, no per-instance id (S4.7 dropped those with
   Bootstrap and they stay dropped), no JS, no render mode — <details> still owns open/closed.

   THE SCRIM IS THE SUMMARY'S OWN ::after, AND THAT IS THE WHOLE TRICK. A pseudo-element belongs
   to the element that owns it for hit-testing, so a click anywhere on the dim IS a click on the
   <summary> — which is the toggle. That buys click-outside-to-close with no JS, no id and no
   dialog element. The first cut had the scrim on the panel with pointer-events:none, which left
   the only way out being a second click on a 16px mark hidden behind the dim: nobody would find
   it (owner, 2026-08-11 — "how the hell does it close?").

   Escape is the one part CSS cannot do; shell.js's existing Escape handler closes this before it
   considers the drawer. Still no focus trap and no scroll lock, so it is a light-dismiss popup
   rather than a true modal — which is the honest description of what a <details> can be.
   THE UPGRADE, when this is revisited: the [popover] attribute, already used by .gn-dafuq-panel
   (row S3.8). It gets light-dismiss, Escape and the top layer natively. Its cost is the
   per-instance id, which is why it is not here yet. See §3a's z-stack note.

   Z-STACK: both numbers below are bands §3a already documents, not new ones. 1050 is the scrim
   band and 1150 is #gn-win's — the win screen is raised only during a game and the blog index
   never has one, so the two can never be on screen together. */
.gn-index-c-ai .gn-c3-panel[open] .gn-c3-summary::after {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 1050;
    background: rgba(0, 0, 0, .55);
    cursor: default;   /* the mark's pointer would promise the dim was itself a control */
}

/* Positioned so the ::after above has a stacking context to sit in; the panel below shares this
   z-index and comes later in the DOM, so it paints over the scrim rather than under it. */
.gn-index-c-ai .gn-c3-panel[open] .gn-c3-summary {
    position: relative;
    z-index: 1150;
}

/* An OPACITY BELOW 1 CREATES A STACKING CONTEXT, so on a read row (60%, above) the fixed panel
   would be trapped inside the row: painted at 60% and underneath the top bar, with its own
   z-index meaningless. Dropping the fade while the breakdown is open removes the context — and
   un-fading the row you are inspecting is what you would want anyway. */
.gn-index tbody tr:has(.gn-c3-panel[open]) {
    opacity: 1;
}

.gn-index-c-ai .gn-c3-panel[open] .gn-c3-body {
    position: fixed;
    z-index: 1150;
    inset-block-start: 50%;
    inset-inline-start: 50%;
    transform: translate(-50%, -50%);
    inline-size: min(52ch, 92vw);
    /* The one inner scroller on the site besides the drawer body (ADR-042 §7). A three-phase
       breakdown with full notes can pass 80dvh, and a panel that cannot reach its own last line
       is worse than the rule it breaks. */
    max-block-size: 80dvh;
    overflow-y: auto;
    padding: var(--gn-space-3);
    background: var(--gn-surface);
    border: 1px solid var(--gn-accent);
    border-radius: var(--gn-radius);
    box-shadow: var(--gn-shadow);
    /* The cell's own alignment and the peek's nowrap must not follow it out here. */
    text-align: left;
    white-space: normal;
}

/* ── md and up: become a real table ───────────────────────────────────────
   min-width-only ladder (css-tokens.md) ⇒ the visually-hidden thead has to be unwound
   here rather than scoped away in a max-width query. */
@media (min-width: 768px) {
    .gn-index {
        display: table;
    }

    .gn-index thead {
        display: table-header-group;
        position: static;
        width: auto;
        height: auto;
        overflow: visible;
        clip: auto;
        white-space: normal;
    }

    .gn-index tbody {
        display: table-row-group;
    }

    /* EVERY row, not `tbody tr`. The header's <tr> is left at `display: block` by a tbody-scoped
       reset, and a block <tr> full of table-cells gets wrapped in an anonymous table of its own:
       the header lays out at its text widths in its own little grid while the body lays out the
       real columns, and the two rows share nothing. It looks exactly like a misaligned header,
       which sends you hunting for a width bug that is not there. */
    .gn-index tr {
        display: table-row;
    }

    /* Undoing the card look needs the SAME selector the card rule used. `.gn-index tr` is (0,1,1)
       and `.gn-index tbody tr` is (0,1,2), so a narrower-looking reset silently loses every
       property to it: the rows kept the card's border and, worse, its --gn-surface background,
       which is what the zebra stripe below was being drawn against. Even rows were the card
       surface and odd rows were the stripe, so the two alternated between two tints that are
       eight values apart and the striping read as "no striping". */
    .gn-index tbody tr {
        padding: 0;
        margin: 0;
        border: 0;
        background: none;
    }

    .gn-index td,
    .gn-index th {
        display: table-cell;
    }

    .gn-index td::before {
        display: none;
    }

    .gn-index th {
        padding: .35rem .7rem;
        border-block-end: 2px solid var(--gn-accent);
        font-size: var(--gn-fs-2xs);
        font-weight: 700;
        letter-spacing: .16em;
        text-transform: uppercase;
        color: var(--gn-text-muted);
        white-space: nowrap;
    }

    .gn-index td {
        padding: .5rem .7rem;
        border-block-end: 1px solid var(--gn-border);
        vertical-align: top;
        font-size: var(--gn-fs-sm);
        /* L3's rhythm, not the site's --gn-lh (1.6). A grid row is a line of data, not a passage
           of prose, and the reading leading adds ~3px per line to every row in the index. */
        line-height: 1.5;
    }

    /* Mixed off --gn-text rather than set to --gn-surface (which is what L3 does). A surface is
       only 8 values off the ground in the default palette and closer than that in others, so the
       stripe was landing somewhere between invisible and absent depending on the theme. A mix of
       the ink is guaranteed to have somewhere to go on any of the twelve, light or dark, because
       the ink is the one colour that must always contrast with the ground. */
    .gn-index tbody tr:nth-child(odd) {
        background: color-mix(in srgb, var(--gn-text) 4%, transparent);
    }

    .gn-index:not(.gn-index-soon) tbody tr:hover,
    .gn-index tbody tr:focus-within {
        background: var(--gn-surface-2);
    }

    /* Read articles recede so the eye lands on the unread ones (owner, 2026-08-11). :has() rather
       than a data- attribute because the pill already states the fact and two sources for one
       truth is one too many. Hover puts it back: a faded row is a hint, not a penalty, and the
       row you are pointing at is the one you want readable. */
    .gn-index tbody tr:has(.gn-pill-read) {
        opacity: .6;
    }

    .gn-index tbody tr:has(.gn-pill-read):hover,
    .gn-index tbody tr:has(.gn-pill-read):focus-within {
        opacity: 1;
    }

    .gn-index td:first-child,
    .gn-index th:first-child {
        padding-inline-start: 0;
    }

    .gn-index td:last-child,
    .gn-index th:last-child {
        padding-inline-end: 0;
        text-align: right;
    }

    .gn-index td.gn-index-c-title {
        display: table-cell;
        padding: .5rem .7rem;
    }

    /* Everything except the title is content-sized: width:1% collapses a table cell to
       its content, which is the table equivalent of max-content and needs no px guess. */
    .gn-index-c-date {
        width: 1%;
        white-space: nowrap;
        color: var(--gn-text-muted);
    }

    .gn-index-c-tags,
    .gn-index-c-ai {
        width: 1%;
    }

    .gn-index-c-talk,
    .gn-index-c-status,
    .gn-index-c-state,
    .gn-index-c-action {
        width: 1%;
        white-space: nowrap;
    }

    /* Dense at md; the dek returns at xl, where there is room for it. */
    .gn-index-dek {
        display: none;
    }
}

@media (min-width: 1200px) {
    .gn-index-dek {
        display: block;
        max-inline-size: 62ch;
    }
}

/* ── Coming soon, and folding a section away ─────────────────────────────────
   Owner, 2026-08-15: the scheduled table looked exactly like the published one under it, so
   it read as a set of articles you could open. Nothing here is a new colour or a new size —
   it is the removal of affordances the rows should never have had.

   The rows recede on `opacity: .6`, borrowed verbatim from the read-row rule above rather
   than invented, so "not for you right now" looks the same in both places it happens. They
   deliberately do NOT brighten on hover, which is where they part company with a read row: a
   read row restores because you might want to re-read it, and a scheduled row has nothing to
   restore to.

   The hover affordance itself is switched off at its source, by qualifying the two
   `:hover` selectors with `:not(.gn-index-soon)` — one per layout, card and table. That is
   why there is no background override here: restating a resting background would have meant
   duplicating the zebra rule, and a second copy of that is the thing that drifts.
   `:focus-within` is untouched on purpose — the C3PO badge in these rows IS focusable, and a
   keyboard user has to keep seeing where they are. */
.gn-index-soon tbody tr {
    opacity: .6;
}

/* A section head that folds its table away. <details>/<summary> is the whole mechanism: no
   ARIA to keep in step, no click handler, keyboard and find-in-page for free. shell.js only
   REMEMBERS the state — a section you have to re-collapse on every visit is not collapsible,
   and that is the only part the platform does not give us. */
.gn-fold-head {
    cursor: pointer;
    list-style: none;
}

.gn-fold-head::-webkit-details-marker {
    display: none;
}

/* Our own marker, because `display: flex` from .gn-index-head drops the native triangle.
   It rides the h2 rather than the flex row, so it sits beside the label instead of drifting
   to the far edge away from the thing it opens. */
.gn-fold-head h2::after {
    content: "▾";
    margin-inline-start: .5rem;
}

.gn-fold:not([open]) .gn-fold-head h2::after {
    content: "▸";
}

/* ── Series — ADR-054 ────────────────────────────────────────────────────────
   A 500-word cap means a big subject arrives as seven articles, and these are the marks that
   keep them legible as one set: the label on a row, the block that brackets a series in the
   index, the rail under an article, and the gate for landing in the middle of one.

   COLOUR, DECIDED ONCE, BECAUSE THIS FAMILY IS SEEN TWELVE TIMES. Everything a reader must
   READ here is --gn-text or --gn-text-muted; --gn-accent and --gn-accent-2 appear only as
   fills, edges and dots. That is not timidity, it is the contrast floor: small text owes
   4.5:1 on every one of the twelve palettes, and the second accent is authored to the 3:1
   NON-text floor (css-tokens: the C3PO family clears 3:1, worst case 4.98:1). So the words
   carry the fact and the hues carry the emphasis, which is also ADR-045 §2 rule 11.

   This block sits after the index's own rules on purpose: the series head row and the zebra
   stripe are both (0,2,2), and source order is the only thing that separates them. */

/* The mark itself — "Part 3 of 7" and a dot per part. Inline-flex so it sits in the kicker
   line beside the category without breaking it. */
.gn-part-label {
    display: inline-flex;
    align-items: center;
    gap: .4rem;
}

/* The series' name, where there is no group header above to say it (an article, a card, the
   admin table). Quiet: it is context for the number, not the number. */
.gn-part-name {
    color: var(--gn-text-muted);
}

.gn-part {
    color: var(--gn-text);
    font-weight: 700;
}

/* Between the kicker and the mark. A boundary, not a word — it sits on the hairline token. The
   padding is load-bearing: the Razor compiler drops whitespace-only text nodes between elements,
   so without it the row reads "Woodworking - Sanding/Part 1 of 4" with nothing either side. */
.gn-part-sep {
    padding-inline: .35em;
    color: var(--gn-border-strong);
}

/* One dot per part: filled = read, ringed = the one you are on, outline = ahead of you. The
   only thing on the page that says "two behind, four to go" at a glance, and aria-hidden in
   the markup because the label beside it already says the same thing in words. */
.gn-dots {
    display: inline-flex;
    gap: 3px;
    align-items: center;
}

.gn-dots i {
    display: block;
    inline-size: 6px;
    block-size: 6px;
    box-shadow: inset 0 0 0 1px var(--gn-border-strong);
}

.gn-dots i.done {
    background: var(--gn-accent);
    box-shadow: none;
}

/* --gn-accent-2 used for exactly what css-tokens reserves it for: pulling the eye to a single
   item. There is one of these per series, ever. */
.gn-dots i.here {
    background: var(--gn-accent-2);
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--gn-accent-2) 35%, transparent);
}

/* ── The index block (§2) ────────────────────────────────────────────────────
   A series' rows leave the date order and sit together under one header. It is ALWAYS
   EXPANDED: /blog is static SSR, so a toggle is either script or <details>, and <details>
   cannot wrap a <tbody>. Nothing is hidden, so nothing needs opening. */
.gn-ser-head td {
    background: color-mix(in srgb, var(--gn-accent) 10%, transparent);
    border-block-end: 1px solid var(--gn-accent);
}

/* The head row labels the block, not a column — so no data-label, and none of the card
   layout's generated column name. */
.gn-ser-head td::before {
    display: none;
}

.gn-ser-head-n {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--gn-space-2);
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-ser-head-n b {
    font-size: var(--gn-fs-xs);
    letter-spacing: .1em;
    color: var(--gn-text);
}

.gn-ser-head-n a {
    margin-inline-start: auto;
    color: var(--gn-link);
}

/* Card layout (below md): the row already carries a 3px leading edge, so a series part just
   changes its colour. The spine below is the table equivalent and only exists at md and up. */
.gn-index tbody tr.gn-ser {
    border-inline-start-color: var(--gn-accent);
}

@media (min-width: 768px) {
    /* The spine binding a series' rows, drawn down the first cell. Scoped to md and up because
       below it `td::before` is the generated column name, and this would overwrite it. */
    .gn-index tbody tr.gn-ser td:first-child {
        position: relative;
        padding-inline-start: .9rem;
    }

    .gn-index tbody tr.gn-ser td:first-child::before {
        content: "";
        display: block;
        position: absolute;
        inset-block: 0;
        inset-inline-start: 0;
        inline-size: 2px;
        background: var(--gn-accent);
    }

    /* Stops at the last part rather than running on into whatever the date order put under
       the block — a bracket that does not close reads as a rule, not a grouping. */
    .gn-index tbody tr.gn-ser-last td:first-child::before {
        inset-block-end: 50%;
    }
}

/* ── The rail (§3) ───────────────────────────────────────────────────────────
   The series in order under the article, so finishing part 3 does not mean going back to
   /blog to find part 4. Also /blog/admin's reorganising view, which is the same list with
   the sort keys shown — one set of rules, two callers. */
.gn-serrail {
    margin-block: var(--gn-space-4);
    border: 1px solid var(--gn-border);
    border-inline-start: 3px solid var(--gn-accent-2);
    background: var(--gn-surface);
}

.gn-serrail-h {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--gn-space-2);
    padding: .45rem .8rem;
    border-block-end: 1px solid var(--gn-border);
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-serrail-h b {
    font-size: var(--gn-fs-xs);
    letter-spacing: .1em;
    color: var(--gn-text);
}

.gn-serrail ol {
    margin: 0;
    padding: .3rem 0;
    list-style: none;
}

.gn-serrail li {
    display: flex;
    align-items: baseline;
    gap: var(--gn-space-2);
    min-block-size: var(--gn-touch);
    padding: .15rem .8rem;
    font-size: var(--gn-fs-sm);
    color: var(--gn-text-muted);
}

/* The part number, and the only place tabular figures matter here: a column of them that
   does not line up reads as a list of unrelated numbers. */
.gn-serrail li .num {
    flex: none;
    inline-size: 2.2em;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .1em;
    color: var(--gn-border-strong);
    font-variant-numeric: tabular-nums;
}

.gn-serrail li a {
    color: var(--gn-link);
}

/* Finished parts recede so the eye lands on what is left, the same move the index makes on a
   read row — and for the same reason. */
.gn-serrail li.read .num {
    color: var(--gn-accent);
}

.gn-serrail li.read a {
    color: var(--gn-text-muted);
}

.gn-serrail li.here {
    background: color-mix(in srgb, var(--gn-accent-2) 14%, transparent);
    color: var(--gn-text);
}

.gn-serrail li.here .num {
    color: var(--gn-accent-2);
}

/* Scheduled: listed, not linked — its body 404s until it releases. Kept in the list because a
   series that shows its own end is making a promise the reader can hold it to. */
.gn-serrail li.soon {
    opacity: .55;
}

.gn-serrail li .tail {
    margin-inline-start: auto;
    padding-inline-start: var(--gn-space-2);
    font-size: var(--gn-fs-2xs);
    letter-spacing: .12em;
    text-transform: uppercase;
    white-space: nowrap;
}

.gn-serrail li.next .tail {
    color: var(--gn-accent);
}

/* ── The mid-series gate (§5) ────────────────────────────────────────────────
   A <dialog> that ships `open`, so these rules dress the IN-FLOW banner: a reader without
   script gets exactly this, under the header, dismissible by its own <form method="dialog">.
   blog.js re-raises it with showModal(), and `:modal` below is the only rule that differs.

   Every property here is undoing a UA default (position, inline-size, margin, border,
   background, color) — a dialog is the one element in this sheet that arrives pre-styled. */
.gn-gate {
    position: static;
    inline-size: auto;
    margin-block: var(--gn-space-3);
    padding: .7rem .85rem;
    border: 1px solid var(--gn-border);
    border-inline-start: 3px solid var(--gn-warning);
    background: var(--gn-surface);
    color: var(--gn-text);
}

/* Raised. The top layer is above every layer in §3a's z-stack by definition — that is what
   the top layer IS, and it is why this needed no z-index and no new tier in that comment. */
.gn-gate:modal {
    position: fixed;
    margin: auto;
    max-inline-size: min(46ch, calc(100% - 2 * var(--gn-gut)));
}

.gn-gate::backdrop {
    background: color-mix(in srgb, var(--gn-bg) 78%, transparent);
}

.gn-gate p {
    margin: 0;
    font-size: var(--gn-fs-sm);
}

.gn-gate p + p {
    margin-block-start: .35rem;
    font-size: var(--gn-fs-xs);
    color: var(--gn-text-muted);
}

.gn-gate-acts {
    display: flex;
    flex-wrap: wrap;
    gap: var(--gn-touch-gap);
    margin-block-start: .6rem;
}

/* §4 — the strip's Next-up cell pointing BACKWARDS, at an earlier part left unread. The one
   state in that cell that is not an invitation, so it takes the semantic hue rather than the
   link one; --gn-warning is held to 4.5:1 as text on all three grounds, which --gn-accent-2
   is not (see this section's colour note). */
.gn-cell-hint.gn-cell-back a {
    color: var(--gn-warning);
}

/* ── Opinion poll — ADR-055 ─────────────────────────────────────────────────
   One widget, both homes (the article body via its marker, and /blog/opinions). Open it is a
   form with always-visible tallies; closed it changes costume entirely — no inputs, a winner
   banner, bars that grow in. Same frame family as the series rail: bordered surface, accent
   spine, micro-label head. The bar width rides --gn-poll-w, set inline per option; bars are
   relative to the LEADER, so the front-runner is always full and the rest read as distance. */
.gn-poll {
    margin-block: var(--gn-space-4);
    border: 1px solid var(--gn-border);
    border-inline-start: 3px solid var(--gn-accent);
    background: var(--gn-surface);
}

.gn-poll-kick {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--gn-space-2);
    margin: 0;
    padding: .45rem .8rem;
    border-block-end: 1px solid var(--gn-border);
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-poll-kick b {
    font-size: var(--gn-fs-xs);
    letter-spacing: .1em;
    color: var(--gn-text);
}

/* The way back to the article, Opinions page only. Pushed to the row's end so the state clause
   beside the title stays one uninterrupted phrase. */
.gn-poll-kick a {
    margin-inline-start: auto;
    color: var(--gn-link);
    text-transform: none;
    letter-spacing: normal;
}

.gn-poll-q {
    margin: 0;
    padding: .6rem .8rem .2rem;
    font-size: var(--gn-fs-lg);
    color: var(--gn-text);
}

.gn-poll-opts {
    list-style: none;
    margin: 0;
    padding: .3rem 0;
}

.gn-poll-opts li,
.gn-poll-podium li {
    display: flex;
    align-items: center;
    gap: var(--gn-space-2);
    min-block-size: var(--gn-touch);
    padding: .15rem .8rem;
    font-size: var(--gn-fs-sm);
    color: var(--gn-text);
}

/* Native inputs carry the theme through accent-color + the palette's color-scheme; nothing
   here re-invents a control (ADR-043: the platform's inputs, our colours). */
.gn-poll input[type="radio"],
.gn-poll input[type="checkbox"] {
    accent-color: var(--gn-accent);
}

.gn-poll-opts select {
    inline-size: 3.5rem;
    padding: .15rem .3rem;
    background: var(--gn-surface);
    color: var(--gn-text);
    border: 1px solid var(--gn-border-strong);
    border-radius: var(--gn-radius);
}

.gn-poll-opts label,
.gn-poll-opts .gn-poll-opt {
    flex: none;
    max-inline-size: 55%;
}

.gn-poll li.mine label,
.gn-poll li.mine .gn-poll-opt {
    color: var(--gn-accent);
}

/* The tally bar: a quiet track, an accent-2 fill (authored to the non-text 3:1 floor — no
   words ride it, ADR-054's colour note). Width is per-instance data, so it is inline. */
.gn-poll-bar {
    position: relative;
    flex: 1 1 auto;
    block-size: .45rem;
    min-inline-size: 3rem;
    background: color-mix(in srgb, var(--gn-border) 40%, transparent);
}

.gn-poll-bar::before {
    content: "";
    position: absolute;
    inset: 0;
    inline-size: var(--gn-poll-w, 0%);
    background: var(--gn-accent-2);
}

.gn-poll-n {
    flex: none;
    min-inline-size: 6.5em;
    text-align: end;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .08em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
    font-variant-numeric: tabular-nums;
}

.gn-poll-n em {
    display: block;
    font-style: normal;
    color: var(--gn-accent);
}

.gn-poll-hint {
    margin: 0;
    padding: 0 .8rem;
    font-size: var(--gn-fs-2xs);
    color: var(--gn-text-muted);
}

.gn-poll form > .btn {
    margin: .4rem .8rem .8rem;
}

/* ── Ranked: drag to order (ADR-055 §8 amendment) ──
   Two interfaces, one form. Default is the no-JS one: the selects. blog.js adds
   .gn-poll-rank-js and the roles swap — selects hide, the grip, the row number and the
   arrows appear, and :has() below picks the matching hint sentence. */
.gn-poll-rankwrap {
    display: flex;
    gap: var(--gn-space-2);
    padding-inline-end: .8rem;
}

.gn-poll-rankwrap .gn-poll-opts {
    flex: 1 1 auto;
    counter-reset: gn-poll-pos;
}

.gn-poll-rank li {
    counter-increment: gn-poll-pos;
}

.gn-poll-rank :is(.gn-poll-grip, .gn-poll-pos, .gn-poll-moves) {
    display: none;
}

.gn-poll-rank-js select {
    display: none;
}

.gn-poll-rank-js .gn-poll-grip {
    display: block;
    color: var(--gn-border-strong);
    cursor: grab;
}

/* The row's position, straight off document order — the counter cannot disagree with the
   ballot because blog.js writes the hidden selects from the same order. */
.gn-poll-rank-js .gn-poll-pos {
    display: block;
    inline-size: 1.8em;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .1em;
    color: var(--gn-accent);
    font-variant-numeric: tabular-nums;
}

.gn-poll-rank-js .gn-poll-pos::before {
    content: counter(gn-poll-pos);
}

.gn-poll-rank-js li {
    cursor: grab;
}

.gn-poll-rank-js li.gn-poll-dragging {
    opacity: .4;
}

/* Below the slot cut nothing posts; the rows recede the way a scheduled part does. */
.gn-poll-rank-js li.gn-poll-overcut {
    opacity: .55;
}

.gn-poll-rank-js li.gn-poll-overcut .gn-poll-pos::before {
    content: "-";
}

.gn-poll-rank-js .gn-poll-moves {
    display: flex;
}

/* Side by side, not stacked: two stacked buttons in a 44px row are 22px targets, and the
   touch floor (ADR-042 §8) is not negotiable. */
.gn-poll-move {
    inline-size: var(--gn-touch);
    min-block-size: var(--gn-touch);
    border: 0;
    background: none;
    color: var(--gn-text-muted);
    cursor: pointer;
}

.gn-poll-move:hover,
.gn-poll-move:focus-visible {
    color: var(--gn-accent);
}

/* The axis, named: highest at the top. Vertical type keeps the rail to one slim column;
   aria-hidden in markup because the hint says the same thing in words. */
.gn-poll-rail {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .3rem;
    padding-block: .3rem;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .12em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-poll-rail span:not(.gn-poll-rail-line) {
    writing-mode: vertical-rl;
}

.gn-poll-rail-line {
    flex: 1 1 auto;
    inline-size: 2px;
    background: linear-gradient(to bottom,
        var(--gn-accent),
        color-mix(in srgb, var(--gn-accent) 15%, transparent));
}

/* The hint that matches the interface actually on screen. */
.gn-poll form:has(.gn-poll-rank-js) .gn-poll-hint-nojs,
.gn-poll form:not(:has(.gn-poll-rank-js)) .gn-poll-hint-js {
    display: none;
}

/* ── Media in option rows (ADR-055 §7 amendment) ──
   Option text renders through the shared Markdig pipeline, so a row can hold an image, inline
   code, a link or raw HTML. Markdig's block wrappers are flattened back into the row; media
   is capped so a poll of pictures stays a list, and a code snippet scrolls inside its row. */
.gn-poll :is(label, .gn-poll-opt, .gn-poll-optname) p {
    display: inline;
    margin: 0;
}

/* The same 150px box the poll editor resizes uploads into (blog.js shrinkToFit), so an
   uploaded image renders at its stored size — this guards a hand-typed URL of a full-size
   article image, and the GIFs the resizer deliberately skips. No percentage in the cap: a
   percentage max is invisible to intrinsic flex sizing, so a 720px GIF would size the row
   as 720px wide, render at 150px, and leave the difference as phantom whitespace while the
   bar sat at its minimum. */
.gn-poll :is(label, .gn-poll-opt, .gn-poll-optname) img {
    max-block-size: 150px;
    max-inline-size: 150px;
    border-radius: var(--gn-radius);
}

.gn-poll :is(label, .gn-poll-opt, .gn-poll-optname) pre {
    margin: .2rem 0;
    max-inline-size: 100%;
    overflow-x: auto;
    font-size: var(--gn-fs-xs);
}

.gn-poll-optname {
    font-weight: 700;
}

/* Binary: two big buttons, no Vote step — the click is the ballot. */
.gn-poll-binary {
    display: flex;
    flex-wrap: wrap;
    gap: var(--gn-space-2);
    padding: .5rem .8rem .8rem;
}

.gn-poll-binary button {
    flex: 1 1 12rem;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: .35rem;
    min-block-size: var(--gn-touch);
    padding: var(--gn-space-2);
    border: 1px solid var(--gn-border-strong);
    border-radius: var(--gn-radius);
    background: var(--gn-surface);
    color: var(--gn-text);
    text-align: start;
    font-size: var(--gn-fs-sm);
}

.gn-poll-binary button:hover,
.gn-poll-binary button:focus-visible {
    border-color: var(--gn-accent);
}

.gn-poll-binary button.mine {
    border-color: var(--gn-accent);
    background: color-mix(in srgb, var(--gn-accent) 10%, var(--gn-surface));
}

/* ── The closed costume ── */
.gn-poll-winner {
    display: flex;
    align-items: baseline;
    gap: var(--gn-space-2);
    margin: 0;
    padding: .6rem .8rem 0;
    font-size: var(--gn-fs-lg);
    color: var(--gn-text);
}

.gn-poll-winner .bi {
    color: var(--gn-accent);
}

.gn-poll-podium {
    list-style: none;
    margin: 0;
    padding: .3rem 0;
}

.gn-poll-place {
    flex: none;
    inline-size: 1.8em;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .1em;
    color: var(--gn-border-strong);
    font-variant-numeric: tabular-nums;
}

/* Medals off the palette, not off a jeweller: accent carries first, plain text second, muted
   third — hard-coded golds are exactly what the twelve-palette rule exists to prevent. */
.gn-poll-podium li.gold .gn-poll-place { color: var(--gn-accent); font-weight: 700; }
.gn-poll-podium li.silver .gn-poll-place { color: var(--gn-text); }
.gn-poll-podium li.bronze .gn-poll-place { color: var(--gn-text-muted); }

.gn-poll-podium .gn-poll-opt {
    flex: none;
    max-inline-size: 55%;
}

/* The bars grow in once, on load — static SSR animates fine; it is script this page does not
   have. Fill mode holds the from-frame through the delay so late bars do not flash full. */
.gn-poll-closed .gn-poll-bar::before {
    animation: gn-poll-grow .9s ease-out backwards;
}

@keyframes gn-poll-grow {
    from { inline-size: 0; }
}

@media (prefers-reduced-motion: reduce) {
    .gn-poll-closed .gn-poll-bar::before {
        animation: none;
    }
}

/* Voted-but-open (ADR-055 §5 amendment): the results took the room, and the form waits behind
   this disclosure. Same summary voice as the boring numbers below. */
.gn-poll-change {
    padding: 0 .8rem .4rem;
}

.gn-poll-change summary {
    display: flex;
    align-items: center;
    min-block-size: var(--gn-touch);
    cursor: pointer;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

/* The nested form's rows already pad themselves; only the stray inline gutter doubles up. */
.gn-poll-change .gn-poll-opts li,
.gn-poll-change .gn-poll-binary {
    padding-inline: 0;
}

.gn-poll-change form > .btn {
    margin-inline: 0;
}

/* "The boring numbers" — the greater-data view behind a native disclosure. The wide ranked
   matrix scrolls inside the details, never the page. */
.gn-poll-data {
    padding: 0 .8rem .6rem;
    overflow-x: auto;
}

.gn-poll-data summary {
    display: flex;
    align-items: center;
    min-block-size: var(--gn-touch);
    cursor: pointer;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-poll-data table {
    border-collapse: collapse;
    font-size: var(--gn-fs-sm);
    color: var(--gn-text);
}

.gn-poll-data th,
.gn-poll-data td {
    padding: .25rem .6rem;
    border: 1px solid var(--gn-border);
    text-align: start;
}

.gn-poll-data td + td,
.gn-poll-data th + th {
    text-align: end;
    font-variant-numeric: tabular-nums;
}

.gn-poll-data caption {
    caption-side: bottom;
    padding-block: .3rem;
    font-size: var(--gn-fs-2xs);
    color: var(--gn-text-muted);
    text-align: start;
}

/* ── The console tab strip — row S4.6 ───────────────────────────────────────
   One hub per area (account · site admin · blog admin), ONE ROUTE PER TAB. The tabs are
   plain anchors, not `role="tablist"`: a real tab widget owes arrow-key roving focus and
   aria-selected, and it can only pay that with a render mode — which ADR-042 §5 forbids
   on chrome. Anchors to real routes get bookmarking, back, and middle-click for free,
   and they survive the form POST every one of these pages ends in. So: no roles, no
   script, `aria-current="page"` on the active one, and the strip is a <nav>.

   WRAPS, never scrolls (ADR-042 §7 — the document is the only scroller besides the
   drawer body). Six blog-admin tabs will take two lines on a phone; two lines of tabs is
   not a defect, an inner scrollport is.

   The type is the console's micro-cap step, same as the column heads and the section
   head it sits above, so a tab and a <th> read as the same register. */
.gn-tabs {
    display: flex;
    flex-wrap: wrap;
    gap: var(--gn-touch-gap);
    margin-block: var(--gn-space-3) var(--gn-space-4);
    border-block-end: 1px solid var(--gn-border);
}

/* 44px of target, and the ink stays small (css-tokens). The -1px pulls the active
   underline down onto the strip's own rule so the two are one line, not two. */
.gn-tabs a {
    display: inline-flex;
    align-items: center;
    min-block-size: var(--gn-touch);
    padding-inline: var(--gn-space-2);
    margin-block-end: -1px;
    border-block-end: 2px solid transparent;
    font-size: var(--gn-fs-2xs);
    font-weight: 700;
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
    text-decoration: none;
}

.gn-tabs a:hover {
    color: var(--gn-text);
    border-block-end-color: var(--gn-border-strong);
}

/* NavLink writes .active; aria-current is what a screen reader gets. Both are matched so
   the state is never carried by one of them alone — and never by colour alone (§2 rule
   11), which is what the underline is for. */
.gn-tabs a.active,
.gn-tabs a[aria-current="page"] {
    color: var(--gn-text);
    border-block-end-color: var(--gn-accent);
}

/* AN ADMIN TABLE IS FULL OF UNBREAKABLE TOKENS — email addresses, IP addresses, provider
   keys — and a table column cannot shrink below its longest one. Found by building /admin:
   the users table wanted 1304px inside a 1265px wrap and put HORIZONTAL SCROLL ON THE PAGE,
   which css-tokens forbids outright. It is on every .gn-index because the reader's index has
   the same failure mode waiting in a long unhyphenated title, and `anywhere` breaks a word
   ONLY when the alternative is overflow — so nothing that fits today changes. */
.gn-index td {
    overflow-wrap: anywhere;
}

/* The admin-only column shape. Inside the md query because in card mode a cell is a
   full-width block and the number means nothing there. min-width paired with
   max-inline-size, so this is a proportion with a floor rather than a fixed pixel width on
   a reader-facing surface (css-tokens). Without it the two Reason inputs collapse to ~20px:
   they are flex children of a `d-flex` row whose other child is a fixed-width button. */
@media (min-width: 768px) {
    .gn-index td.gn-admin-mod {
        min-inline-size: 16rem;
        max-inline-size: 100%;
    }
}

/* Ban / Unban and Total ban / Lift swap labels of very different lengths, and an
   unequal pair of buttons makes two rows of the table look misaligned when they are not. */
.gn-admin-act {
    inline-size: 6.5rem;
    max-inline-size: 100%;
    flex: none;
}

/* A user-agent string is one unbroken token that will blow a table column open. Clamped in
   ch (§7's rule — a px cap becomes a different number of characters in every font on
   ADR-044's list) and wrapped rather than truncated: a truncated UA is useless, and a
   `text-overflow` on a table cell needs a fixed layout the rest of the index does not use. */
.gn-admin-ua {
    max-inline-size: 42ch;
    overflow-wrap: anywhere;
    color: var(--gn-text-muted);
}

/* A prose cell in a data table — a language mode's rules, a theme mode's description. Capped
   at a short measure in ch so it stays one column rather than dictating the table's width,
   and quiet so it does not compete with the key beside it. Replaces three `text-muted small`
   + inline `max-width: 320px` pairings on the persona admin. */
.gn-admin-blurb {
    max-inline-size: 40ch;
    color: var(--gn-text-muted);
}

/* ── Trophy tile — ADR-045 §7 (row S3.4) ────────────────────────────────────
   Drawn by TrophyTile.razor. Three states on one element, switched by data-gn-state
   rather than an is-/has- class, so a server-rendered page sets it without building a
   class string (ADR-045 §1).

   NO :hover / :focus-visible / disabled block (§2 rule 9), and that is a decision, not
   an omission: the tile is not a control. The trophy case S4.4 builds may well wrap it
   in one — that wrapper owns the interactive states and the 44px target, because a
   focus ring belongs on the thing that takes focus, not on a box inside it.

   §2 rule 11 — the three states are never told apart by colour alone:
     hidden    no plate,     quiet dashed border,  glyph at --gn-text-muted
     revealed  no plate,     solid border,         glyph at --gn-border-strong
     earned    accent plate, solid accent border,  glyph at --gn-on-accent
   Plate presence and border style carry it visually; TrophyTile also writes a
   visually-hidden "earned" / "not yet earned" so the distinction survives with no
   styling at all. */
.gn-trophy-tile {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--gn-space-1);
    padding: var(--gn-space-3);
    text-align: center;
    background: var(--gn-surface);
    border: 1px solid var(--gn-border);
    border-radius: var(--gn-radius);
}

.gn-trophy-tile[data-gn-state="hidden"] {
    border-style: dashed;
}

/* data-gn-tier is emitted and DELIBERATELY UNSTYLED (row S4.4, owner 2026-08-11). Tier is a rank
   where higher is worth more; every trophy is 5 today and may stay there forever, but nothing is
   allowed to assume a single tier. When the catalog is graded, the tier's look is rules here —
   no change to TrophyTile, TrophyCase or TrophyDefinition. A hidden tile carries no tier at all
   (see TrophyTile's leak boundary), so any rule added here must read as a default without one. */

/* The one asset, sized off the type scale so the tile grows with the reader's setting. */
.gn-trophy-tile-art {
    display: grid;
    place-items: center;
    inline-size: 3em;
    block-size: 3em;
    font-size: var(--gn-fs-2xl);
    border-radius: 50%;
    color: var(--gn-border-strong);
}

/* The glyph's share of the plate, and it is a RATIO rather than a scale step on purpose — the
   plate is already 3em of --gn-fs-2xl, so a rem here would break the "the tile grows with the
   reader's type setting" line above. At the inherited 1em the trophy sat at a third of the
   circle and read as a speck (owner, 2026-08-11); 1.6 of 3 fills it without touching the rim. */
.gn-trophy-tile-art > i {
    font-size: 1.6em;
    line-height: 1;
}

.gn-trophy-tile[data-gn-state="hidden"] .gn-trophy-tile-art {
    color: var(--gn-text-muted);
}

/* Earned: the same glyph on the accent plate. --gn-on-accent is the token held to 4.5:1
   against every fill by palette-contrast.py, so this is the one pairing that is already
   proven across all twelve palettes. */
.gn-trophy-tile[data-gn-state="earned"] {
    border-color: var(--gn-accent);
}

.gn-trophy-tile[data-gn-state="earned"] .gn-trophy-tile-art {
    background: var(--gn-accent);
    color: var(--gn-on-accent);
}

.gn-trophy-tile-title {
    margin: 0;
    font-weight: 600;
}

.gn-trophy-tile-condition,
.gn-trophy-tile-rarity {
    margin: 0;
    font-size: var(--gn-fs-sm);
    color: var(--gn-text-muted);
}

.gn-trophy-tile-rarity {
    font-style: italic;
}

/* The ring and its count read as one phrase, so they share a line and never wrap apart. */
.gn-trophy-tile-progress {
    display: flex;
    align-items: center;
    gap: var(--gn-space-1);
    margin: 0;
    font-size: var(--gn-fs-sm);
    font-variant-numeric: tabular-nums;
}

/* ── In-flow expanding panel — ADR-045 §5 (row S3.3) ────────────────────────
   The general replacement for what is a modal today. Markup contract:

     <details class="gn-panel" name="…optional group…">
       <summary class="gn-panel-summary">What is behind this</summary>
       <div class="gn-panel-body">…</div>
     </details>

   <details>, NOT THE CHECKBOX HACK. The checkbox-and-<label for> pattern exists in this
   file for exactly one reason: ADR-042 §3's drawer has its trigger in the bar and its
   panel elsewhere in the DOM, so no single element can contain both. Everywhere the
   trigger CAN sit with its panel — every case in this set — <details> gives announced
   expanded state, keyboard operation and Esc semantics with nothing written, and `name=`
   gives exclusive-open accordion behaviour natively with no script.

   THE PANEL IS position: static AND THE DOCUMENT GROWS (ADR-042 §7). No focus trap, no
   aria-modal, no scroll lock, no render mode, no z-index — it is not in the z-stack §3a
   documents because it is not chrome.

   THE MARKER IS THE BROWSER'S. list-style is deliberately untouched: the native
   disclosure triangle already changes SHAPE on open, which is §2 rule 11 satisfied for
   free, and it inherits currentColor so it is correct in all twelve palettes with no
   token spend. Do not replace it with a glyph.

   THE HEIGHT ANIMATION IS THE ONLY REASON ::details-content APPEARS HERE.
   interpolate-size is what makes `auto` an interpolable keyword; without it the
   transition has no end value and simply snaps. content-visibility must transition
   `allow-discrete` or the closed content stays hidden for the whole duration and the
   panel animates an empty box. Where ::details-content is unsupported the whole block is
   dropped and <details> snaps open natively — degrade, never break (§2 rule 9's spirit,
   same as §4's @supports).

   PADDING LIVES ON .gn-panel-body, NOT ON ::details-content, for that same reason: the
   pseudo is the newest part of the platform here and the content must not lose its inset
   on an engine that has not shipped it. overflow: hidden also makes the pseudo a BFC, so
   the body's first and last child margins stay inside instead of collapsing out and
   leaving a gap above a closed panel.

   NO DISABLED STATE, and that is a decision rather than an omission (§2 rule 9): there is
   no disabled attribute on <details>, CSS cannot stop a summary being activated, and a
   panel whose content must not be reached is a panel that is not rendered. Locked/earned
   does not apply — that is the trophy tile.

   KNOWN INTERACTION WITH THE GAMES, recorded rather than fixed (ADR-045 §5): a closed
   panel's content has a 0×0 rect (js.md's trap), so its words are not in the level, and
   opening it later does not re-extract. Acceptable — a closed panel is not prose the
   reader is reading — but an author who habitually collapses half an article changes the
   level composition by doing it.

   Rung 1: a track of class names and no markup of ours, no script, no render mode. */
.gn-panel {
    interpolate-size: allow-keywords;
    border: 1px solid var(--gn-border-strong);   /* §2 rule 3 — a control boundary, not a separator */
    border-radius: var(--gn-radius);
    background: var(--gn-surface);
}

.gn-panel-summary {
    /* The touch target (§2 rule 8). min-block-size rather than a padding guess, and the
       summary stays display: list-item — flex or grid on a summary drops the marker in
       Chrome, which is how this component loses its open/closed shape. */
    min-block-size: var(--gn-touch);
    padding: var(--gn-space-2) var(--gn-space-3);
    cursor: pointer;
    font-weight: 600;
}

.gn-panel-summary:hover {
    background: var(--gn-surface-2);
}

.gn-panel-summary:focus-visible {
    outline: 2px solid var(--gn-focus-ring);
    outline-offset: -2px;   /* inset: the summary sits flush inside the panel border */
}

.gn-panel-body {
    padding: 0 var(--gn-space-3) var(--gn-space-3);
}

.gn-panel::details-content {
    block-size: 0;
    overflow: hidden;
    transition: block-size .25s ease, content-visibility .25s allow-discrete;
}

.gn-panel[open]::details-content {
    block-size: auto;
}

@media (prefers-reduced-motion: reduce) {
    .gn-panel::details-content {
        transition: none;
    }
}

/* The one variant so far: the ban notice (ADR-020 §2). --gn-danger on the boundary rather than a
   tinted fill — twelve palettes and a tinted ground behind body text is twelve contrast checks
   for one panel. The icon in the summary is the second, non-colour signal (§2 rule 11). */
.gn-panel-danger {
    border-color: var(--gn-danger);
}

/* ── DAFUQ — ADR-045 §10 (row S3.8) ─────────────────────────────────────────
   Descriptive Aside of Frequently Unanswered Questions. A jargon term glows in the prose
   and its explanation opens over it. Markup is GENERATED by Dafuq.cs from what the author
   wrote in the post — nothing here is hand-authored into a page:

     <button class="gn-dafuq" data-gn-loud popovertarget="gn-q-1"
             style="anchor-name:--gn-q-1">term<i class="bi bi-search"></i></button>
     …later, at the end of the article…
     <div popover id="gn-q-1" class="gn-dafuq-panel" data-gn-noscan
          style="position-anchor:--gn-q-1">…any HTML the author wrote…</div>

   THE GLOW IS NOT THE ONLY SIGNAL (§2 rule 11). Colour alone fails on some of the twelve
   palettes and on every reader who cannot tell the pair apart, so every mark also carries
   a magnifier glyph. The glyph is what says "these words do something"; the glow only says
   how badly you probably want it.

   LOUD vs QUIET IS THE READER'S JARGON LEVEL (owner, 2026-08-10). data-gn-loud is stamped
   server-side when the term's grade exceeds the reader's own level. Quiet is still visible
   — a fluent reader can see that an aside exists — and BOTH open. Nothing is withheld from
   anyone; §10's whole argument for leaving this ungated applies to the grade too.

   TOUCH TARGET: this is the one component in the set that cannot meet §2 rule 8, and it is
   a spec exemption rather than a miss. The target is a run of words inside a sentence, and
   growing it to 44px would either break the line box or overlap the lines above and below.
   WCAG 2.5.8 excludes inline targets in a block of text for exactly this reason.

   THE TRIGGER CANNOT WRAP MID-TERM, and that is the platform's call rather than ours.
   `display: inline` on a <button> is accepted as a specified value and thrown away as a used
   one — every engine display-normalizes a form control back to inline-block (measured in the
   browser pane, 2026-08-10, not assumed). So a multi-word term moves whole to the next line
   instead of breaking across it. Accepted; the alternative is giving up <button>, and with it
   popovertarget and the entire no-script story. max-inline-size is the part that matters: it
   is what stops a long term running past the measure into horizontal page scroll, by wrapping
   inside the button instead. */
.gn-dafuq {
    max-inline-size: 100%;
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
    text-align: inherit;
    cursor: help;
    /* The quiet state. em, not px: the halo tracks the reader's type step (§2 rule 8). */
    text-shadow: 0 0 .3em var(--gn-dafuq-glow);
}

.gn-dafuq[data-gn-loud] {
    text-shadow: 0 0 .25em var(--gn-dafuq-glow), 0 0 .7em var(--gn-dafuq-glow);
}

/* The non-colour half of the cue. aria-hidden in the generated markup — the term itself is
   the button's accessible name, and "search" read out mid-sentence is noise. */
.gn-dafuq .bi {
    font-size: .7em;
    margin-inline-start: .15em;
    vertical-align: .15em;
}

.gn-dafuq:hover,
.gn-dafuq:focus-visible {
    text-shadow: 0 0 .25em var(--gn-dafuq-glow), 0 0 .9em var(--gn-dafuq-glow);
}

.gn-dafuq:focus-visible {
    outline: 2px solid var(--gn-focus-ring);
    outline-offset: 2px;
}

/* No disabled state and no locked state, both deliberate (§2 rule 9). §10 puts this
   permanently ungated: it exists to equalise readers of different education levels, and a
   locked explanation withholds understanding from exactly the readers it is for. */

/* THE FALLBACK IS WRITTEN FIRST, and that ordering is the point of it (§10: "build and
   check the fallback path first, not last"). Without anchor positioning an unpositioned
   popover renders in the TOP-LEFT CORNER of the viewport — not near the word, not
   obviously broken, just wrong. These rules are what a browser without it gets. */
.gn-dafuq-panel {
    position: fixed;
    /* A sheet a gutter in from each edge, sitting ABOVE the strip rather than under it — the
       strip is fixed at the bottom at z 1000 and this is in the top layer, so without the
       clearance the sheet would cover the readout. inline-size: auto is not redundant: the UA
       stylesheet gives every [popover] width: fit-content, which would otherwise beat the two
       insets and leave the sheet hugging one corner. */
    inset: auto var(--gn-gut) calc(var(--gn-strip-h) + var(--gn-gut) + env(safe-area-inset-bottom)) var(--gn-gut);
    inline-size: auto;
    margin: 0;
    max-block-size: 60dvh;
    overflow: auto;
    padding: var(--gn-space-3);
    border: 1px solid var(--gn-border-strong);   /* §2 rule 3 — a control boundary */
    border-radius: var(--gn-radius);
    background: var(--gn-surface);
    color: var(--gn-text);
    font-size: var(--gn-fs-sm);
}

/* THE SECOND RECORDED EXCEPTION IN THIS COMPONENT, after §10's overlay. §2 rule 7 says the
   document is the scroller and no component gets an inner one — but a popover is in the top
   layer, so the document scroller is not available to it by construction, and the aside now
   holds author HTML: images, video, a list of links. The cap plus a scroller is what keeps a
   tall aside reachable at all. Media is capped too, so this is a safety valve rather than
   the normal case. */
.gn-dafuq-panel :is(img, video, iframe) {
    max-inline-size: 100%;
    block-size: auto;
}

.gn-dafuq-panel > :last-child {
    margin-block-end: 0;
}

@supports (anchor-name: --a) {
    .gn-dafuq-panel {
        position: absolute;
        /* Under the word by preference; flip above when there is no room below. The reader is
           looking at the term, so the aside belongs on the line they are already reading from. */
        position-area: block-end span-inline-end;
        position-try-fallbacks: flip-block;
        inset: auto;
        margin-block-start: var(--gn-space-1);
        inline-size: min(44ch, calc(100vw - 2 * var(--gn-gut)));
    }
}

/* ── Palette picker — /Account/Manage/Appearance ────────────────────────────
   A LIST OF RADIOS, NOT A <select>. A name in a dropdown does not tell you what a palette
   looks like, and a <select> cannot hold markup in an <option> — so the swatches could
   never live inside one. A radio list puts them in the control itself: nothing to open,
   nothing covering the examples you are comparing against, and keyboard arrow-navigation,
   the form post and the disabled state all come from the platform.

   Each row opts into one palette with [data-gn-palette="…"] (§1b's second selector), so
   every chip is a plain var(--gn-*) that resolves to THAT palette's value. No hex is
   repeated here, and a retuned palette retunes its swatches with it — the failure mode a
   hand-written legend would have had.

   THE ROW PAINTS ITS OWN GROUND, because a palette is its colours ON its ground: the same
   accent reads completely differently on paper and on charcoal, and chips floating on the
   current page's background would be a lie about half the catalogue.

   NO --gn-bg CHIP. It would be a chip the exact colour of the row it sits in, which is an
   empty box — the row IS the background swatch.

   NO "DEFAULT" ROW EITHER, for the same reason inverted: it was the one row with no palette
   to paint. The default is a palette in the list, so .gn-swatch-badge marks it there.

   Selection is the RADIO DOT first (§2 rule 11): the border is reinforcement, and it has to
   be, because on twelve different palettes a border colour alone is not a reliable signal.
   TWO COLOURS ON PURPOSE (owner, 2026-08-10) — the dot is --gn-accent-2 (from the site-wide
   accent-color rule above §2, which every selection control gets), the border is --gn-accent.
   The row already carries six chips of the palette it is advertising; painting its frame in
   the second hue too made the row's own sample harder to read against the frame. */
.gn-swatch-row {
    display: flex;
    align-items: center;
    gap: var(--gn-space-2);
    /* The touch target (ADR-045 §2 rule 8) — a whole row, not just the radio. */
    min-block-size: var(--gn-touch);
    padding: var(--gn-space-1) var(--gn-space-2);
    border: 1px solid var(--gn-border-strong);
    border-radius: var(--gn-radius);
    background: var(--gn-bg);
    color: var(--gn-text);
    font-size: var(--gn-fs-sm);
    cursor: pointer;
}

.gn-swatch-row + .gn-swatch-row {
    margin-block-start: var(--gn-touch-gap);
}

.gn-swatch-row:has(:checked) {
    border-color: var(--gn-accent);
    border-width: 2px;
    /* Keeps the row from growing 2px and nudging the list when selection moves. */
    padding: calc(var(--gn-space-1) - 1px) calc(var(--gn-space-2) - 1px);
}

.gn-swatch-row:has(:focus-visible) {
    outline: 2px solid var(--gn-focus-ring);
    outline-offset: 2px;
}

/* Locked (no persona-manual trophy). NOT dimmed: opacity on a swatch row would misreport
   every colour in it, and this list's whole job is reporting colours honestly. The cursor
   says it, and TrophyGateNotice above says it in words. */
.gn-swatch-row:has(:disabled) {
    cursor: not-allowed;
}

.gn-swatch-radio {
    flex: 0 0 auto;
}

.gn-swatch-name {
    flex: 0 0 auto;
    font-weight: 600;
}

/* Marks the palette you get when you pick nothing — one per ground. Tokens only, so it reads
   on all twelve grounds it can land on; --gn-surface-2 is the one fill guaranteed to differ
   from the row's own --gn-bg in every palette. */
.gn-swatch-badge {
    flex: 0 0 auto;
    padding: 0 var(--gn-space-1);
    border: 1px solid var(--gn-border);
    border-radius: var(--gn-radius);
    background: var(--gn-surface-2);
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-xs);
    text-transform: uppercase;
    letter-spacing: .05em;
}

.gn-swatch-desc {
    flex: 1 1 auto;
    min-inline-size: 0;
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-xs);
}

/* The ground headings between the two groups — ADR-044 §2's advice, not a restriction. */
.gn-swatch-group {
    margin: var(--gn-space-3) 0 var(--gn-space-1);
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-xs);
    text-transform: uppercase;
    letter-spacing: .05em;
}

.gn-swatch {
    flex: 0 0 auto;
    /* em, so the strip scales with the reader's type step like everything else. */
    inline-size: 1.4em;
    block-size: 1.4em;
    border: 1px solid var(--gn-border-strong);
    border-radius: var(--gn-radius);
}

.gn-swatch-surface { background: var(--gn-surface); }
.gn-swatch-text { background: var(--gn-text); }
.gn-swatch-accent { background: var(--gn-accent); }
.gn-swatch-accent2 { background: var(--gn-accent-2); }
.gn-swatch-link { background: var(--gn-link); }
.gn-swatch-code { background: var(--gn-code); }

/* ── Persona survey, one question at a time — A1 (row S4.3) ─────────────────
   design-explore/interactions.html §A1, resolved onto the token scale rather than copied as
   literals. The markup is the same static-SSR form it always was; blog.js adds .gn-tf-on and
   hides every fieldset but one, so everything gated on that class is the enhanced view and
   everything outside it also has to look right with JS off, where all 12 questions stack.

   No inner scroller and no overlay (ADR-042 §7): the stage holds a height and swaps its
   contents, which is the whole reason A1 was picked over a paged form. */
.gn-tf-top {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--gn-space-3);
    margin-block-end: var(--gn-space-4);
}

/* The same .16em micro-caps as the article kicker and the index column heads — one voice for
   every small label on the site. */
.gn-tf-count {
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
    white-space: nowrap;
}

.gn-tf-bar {
    flex: 1 1 12rem;
    height: 8px;
    overflow: hidden;
    background: var(--gn-surface-2);
    border-radius: var(--gn-radius-sm);
}

.gn-tf-bar > i {
    display: block;
    height: 100%;
    background: var(--gn-accent);
    transition: width .45s cubic-bezier(.22, 1, .36, 1);
}

/* The height is what stops the page jumping as questions of different lengths swap in, and it
   only applies once one question is on screen at a time. */
.gn-tf-on .gn-tf-stage {
    min-height: 22rem;
}

.gn-tf-on .gn-tf-card {
    animation: gnTfIn .32s cubic-bezier(.22, 1, .36, 1) both;
}

/* Going back animates from the other side, so the direction of travel is legible without a
   label. blog.js sets data-dir on the step it reveals. */
.gn-tf-on .gn-tf-card[data-dir="back"] {
    animation-name: gnTfBack;
}

@keyframes gnTfIn {
    from { opacity: 0; transform: translateY(14px); }
}

@keyframes gnTfBack {
    from { opacity: 0; transform: translateY(-14px); }
}

/* With JS off the cards stack, so they need the separation the enhanced view gets from the
   stage. Harmless when only one is visible. */
.gn-tf-card {
    margin-block-end: var(--gn-space-4);
}

/* Bootstrap's reboot floats a legend and gives it a viewport-derived font-size, which is off
   the type scale (css-tokens.md: never write a literal font-size). Both are undone here.

   The measure is --gn-measure, NOT the mockup's 34ch (owner, 2026-08-11). 34ch is a display-type
   convention and it broke a two-line question into seven on a desktop column with room to
   spare — and the options underneath run the full width, so the question was the only thing on
   the page holding itself in. ADR-042 §7's rule already answers this and it is the same token
   the article body uses. */
.gn-tf-q {
    float: none;
    width: auto;
    max-width: min(var(--gn-measure), 100%);
    margin-block-end: var(--gn-space-3);
    padding: 0;
    font-size: var(--gn-fs-2xl);
    font-weight: 600;
    line-height: var(--gn-lh-tight);
    letter-spacing: -.015em;
}

.gn-tf-opts {
    display: grid;
    gap: var(--gn-touch-gap);   /* §8's separation floor, not a decorative gap */
}

/* The whole row is the target. The radio inside it is real and focusable and simply not drawn —
   the number chip is its visual stand-in, which is also the keyboard affordance. */
.gn-tf-opt {
    display: flex;
    align-items: flex-start;
    gap: var(--gn-space-2);
    min-height: var(--gn-touch);
    padding: .6rem .75rem;
    background: var(--gn-bg);
    color: var(--gn-text);
    /* --gn-border-strong, not --gn-border: this row IS the control, and WCAG 1.4.11 holds a
       control boundary to 3:1. */
    border: 1px solid var(--gn-border-strong);
    border-radius: var(--gn-radius);
    cursor: pointer;
    transition: transform .12s ease, border-color .12s ease, background .12s ease;
}

.gn-tf-radio {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    opacity: 0;
}

.gn-tf-opt:hover {
    border-color: var(--gn-accent);
    transform: translateX(3px);
}

.gn-tf-opt:has(.gn-tf-radio:checked) {
    background: var(--gn-accent);
    color: var(--gn-on-accent);
    border-color: var(--gn-accent);
}

/* The ring goes on the row because the input it belongs to is 1px square. */
.gn-tf-opt:has(.gn-tf-radio:focus-visible) {
    outline: 2px solid var(--gn-focus-ring);
    outline-offset: 2px;
}

.gn-tf-key {
    flex: none;
    display: grid;
    place-items: center;
    width: 1.6rem;
    height: 1.6rem;
    background: var(--gn-surface-2);
    color: var(--gn-text-muted);
    border-radius: var(--gn-radius-sm);
    font-size: var(--gn-fs-xs);
    font-weight: 700;
}

/* Selected inverts the chip, so it stays readable on the accent fill it now sits on. */
.gn-tf-opt:has(.gn-tf-radio:checked) .gn-tf-key {
    background: var(--gn-on-accent);
    color: var(--gn-accent);
}

.gn-tf-opt-text {
    line-height: var(--gn-lh);
}

/* ADR-009's trophy-style icon, inline with the option text it belongs to. */
.gn-tf-opt-icon {
    margin-inline-end: var(--gn-space-2);
    vertical-align: text-bottom;
}

.gn-tf-nav,
.gn-tf-submit {
    display: flex;
    flex-wrap: wrap;
    gap: var(--gn-space-2);
}

.gn-tf-nav {
    margin-block-end: var(--gn-space-3);
}

.gn-tf-keys {
    margin-block: var(--gn-space-3);
    font-size: var(--gn-fs-xs);
    color: var(--gn-text-muted);
}

@media (prefers-reduced-motion: reduce) {
    .gn-tf-on .gn-tf-card { animation: none; }
    .gn-tf-bar > i { transition: none; }
    .gn-tf-opt:hover { transform: none; }
}

/* ── Blocked-action confirm — row S4.7 ──────────────────────────────────────
   What replaced the survey's Bootstrap modal. Not a .gn-panel: nobody discloses this, the
   script un-hides it when a submit is intercepted, so there is no summary and nothing to toggle.
   `hidden` is the entire mechanism; this only skins the box.

   --gn-warning on the boundary, and the block carries its own heading text — a reader who cannot
   see the colour still gets "It is only 12 questions!" (§2 rule 11). */
.gn-confirm {
    padding: var(--gn-space-3);
    border: 1px solid var(--gn-warning);
    border-radius: var(--gn-radius);
    background: var(--gn-surface);
}

/* ── Trophy earn strip — ADR-042 §7 (row S4.4) ──────────────────────────────
   The Bootstrap toast this replaces was position: fixed at z-index 1090, which is how it came to
   render inside the game canvas's rectangle mid-run. This is in the document, first child of
   .content, and the page moves down to make room for it.

   ONE strip, however many trophies land at once. S4.7's stand-in was a box per trophy, and three
   clears in a run pushed the article three screens down. The heading counts them and each one is
   a chip on the row below it.

   The chip's art is the TROPHY TILE'S — accent plate, same bi-trophy-fill glyph — because the
   case and the earn moment are the same trophy seen twice, and a .webp here would be ADR-045
   §7's two-files-to-keep-in-sync all over again.

   No animation and no auto-dismiss timer. A six-second toast is unreadable to anyone who looked
   away, and there is nothing here that motion would explain.

   NOT data-gn-noscan: it is static, in document coordinates, and both games extract words there —
   so unlike the sticky pane it is exactly where the reader sees it (§2 rule 6).

   Injected by trophies.js, so it CANNOT be scoped CSS — that is the whole reason this block is
   here and the case's own styles are in TrophyCase.razor.css. */
.gn-earn {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: var(--gn-space-1) var(--gn-space-3);
    padding: var(--gn-space-3);
    margin-bottom: var(--gn-space-3);
    border: 1px solid var(--gn-accent);
    border-left-width: 4px;
    border-radius: var(--gn-radius);
    background: var(--gn-surface);
}

.gn-earn-head {
    margin: 0;
    font-size: var(--gn-fs-sm);
    font-weight: 600;
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

/* Wraps to a second line rather than scrolling — zero inner scrollers (ADR-042 §7), and a strip
   that grew a scrollbar to hold two trophies would be worse than the stack it replaced. */
.gn-earn-list {
    grid-column: 1 / -1;
    display: flex;
    flex-wrap: wrap;
    gap: var(--gn-space-3);
    margin: 0;
    padding: 0;
    list-style: none;
}

.gn-earn-item {
    display: flex;
    align-items: center;
    gap: var(--gn-space-2);
}

/* The tile's earned plate, at body scale. --gn-on-accent is the token palette-contrast.py holds
   to 4.5:1 against every fill, so this pairing is already proven across all twelve palettes. */
.gn-earn-art {
    flex: none;
    display: grid;
    place-items: center;
    inline-size: 2em;
    block-size: 2em;
    border-radius: 50%;
    background: var(--gn-accent);
    color: var(--gn-on-accent);
}

/* Same ratio as the tile's glyph, for the same reason — a 1em trophy in a 2em circle is a speck. */
.gn-earn-art > i {
    font-size: 1.15em;
    line-height: 1;
}

.gn-earn-title,
.gn-earn-desc {
    display: block;
}

.gn-earn-title {
    font-weight: 600;
}

.gn-earn-desc {
    font-size: var(--gn-fs-sm);
    color: var(--gn-text-muted);
}

/* Top-right of the strip's grid, and 44px square on every pointer — this one is a real control
   with nothing else to grow (§2 rule 8), unlike the C3 badge whose size the date line dictates. */
.gn-earn-close {
    grid-area: 1 / 2;
    align-self: start;
    min-width: var(--gn-touch);
    min-height: var(--gn-touch);
    background: none;
    border: 0;
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-xl);
    line-height: 1;
    cursor: pointer;
}

.gn-earn-close:hover {
    color: var(--gn-text);
}

.gn-earn-close:focus-visible {
    outline: 2px solid var(--gn-focus-ring);
    outline-offset: 2px;
}

/* ── Shell pages — ADR-042 §7 (row S4.5) ────────────────────────────────────
   Home, What's Happening, Contact, the legal pages, Store, Banned, Nice Try, Choose
   Handle and Request Access. Eleven pages that predate the console and had never been
   given its vocabulary: they were Bootstrap `.row`/`.col`/`.card` feeds with `display-*`
   headings and literal font-sizes, sitting inside a shell that had learned a different
   language everywhere else.

   ALMOST NOTHING NEW IS AUTHORED HERE, and that is the point of the row. The page head
   IS `.gn-article-head` / `-kick` / `-title` / `-dek` (row S4.2), the section head IS
   `.gn-index-head` (row S4.1), the prose column IS `.gn-prose` (row S3.1), the status
   chip IS `.gn-pill` (row S4.1). The names keep the row they were authored in, exactly
   as `.gn-index-head` kept its when Gaslight Alley took it — a second class on the same
   declarations is cheaper than renaming a component and every call site for tidiness.
   Three things below did not exist and could not be borrowed. */

/* THE ASYMMETRIC GAP, and it is the one ADR-042 §7 rule the shell pages were breaking
   outright: row gap ≈ 1.4× column gap, so a card's own text sits closer to itself than
   to the card below it. Bootstrap's `.g-4` is symmetric and cannot express it.

   auto-fit rather than col-12/col-md-6/col-xl-4 triples, because the count is data:
   Home has three cards, "Coming up" has as many as the JSON holds, and a track list that
   reads "at least 18rem" needs no breakpoint to be told what to do at 900px. That also
   removes the .row negative-margin/.content-gutter interaction (css-tokens) from these
   pages entirely — there is no negative margin left to escape the gutter with. */
.gn-feed {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
    column-gap: var(--gn-space-3);
    row-gap: calc(var(--gn-space-3) * 1.4);
    align-items: stretch;
}

/* The demo/teaser cards. .gn-card is the surface; this is what a card CONTAINS, and it
   exists so the pages can drop Bootstrap's .card/.card-body/.card-title/.card-text stack
   (ADR-043 §2 — cards are ours). Column, so `margin-block-start: auto` on a badge or a
   meter pins it to the bottom and a row of cards keeps its footers level. */
.gn-card-body {
    display: flex;
    flex-direction: column;
    gap: var(--gn-space-2);
    padding: var(--gn-space-3);
    height: 100%;
}

/* Card titles take the index's kicker voice rather than an <h5>'s size: on a feed the
   title is a label for the block, and the blocks are what the eye is scanning. */
.gn-card-title {
    margin: 0;
    font-size: var(--gn-fs-lg);
    line-height: var(--gn-lh-tight);
    letter-spacing: -.01em;
}

/* The micro-cap caption over an inline demo chart or a meter. Same .16em caps as the
   index column heads and the article kicker — one voice for every label on the site. */
.gn-card-cap {
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

/* Pins a badge, meter or CTA to the bottom of a .gn-card-body. Replaces `.mt-auto`,
   which is the same declaration wearing Bootstrap's name. */
.gn-card-foot {
    margin-block-start: auto;
}

/* A label-and-status list — /request-access's "Your requests", and the shape any short
   "thing → state" list on a shell page wants. Replaces Bootstrap's .list-group, which
   ADR-043 §2 assigns to us and which was drawing its own borders and radii from a
   --bs-list-group-* block that no longer had anything to do with the console.

   Hairlines between rows, none around the set: the same separator logic as the comment
   tree, and it stops a two-item list reading as a heavyweight boxed component. */
.gn-statuslist {
    list-style: none;
    margin: 0;
    padding: 0;
}

.gn-statuslist li {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--gn-space-3);
    padding: var(--gn-space-2) 0;
    border-block-start: 1px solid var(--gn-border);
}

.gn-statuslist li:last-child {
    border-block-end: 1px solid var(--gn-border);
}

/* THE DEAD-END PAGES — /banned and /nice-try. One glyph, one line, no navigation, by
   ADR-020 §5 and ADR-027. Both were built with literal font sizes (12rem, 10rem) and
   Bootstrap's .display-1/.display-2, which is four values sitting outside --gn-fs-root:
   a reader who had turned the type scale down got a full-size finger anyway.

   A MULTIPLE OF THE TOP STEP, not a literal. calc() off --gn-fs-4xl lands within a few
   pixels of what was there and stays inside the reader's control, which is the whole
   reason css-tokens.md bans the literal. The two pages share every rule below so they
   cannot drift — they are one design with two sets of words. */
.gn-slam {
    text-align: center;
    margin-block: var(--gn-space-5);
    padding-block: var(--gn-space-5);
}

.gn-slam-art {
    font-size: calc(var(--gn-fs-4xl) * 4.8);
    line-height: 1;
}

/* /nice-try is an escalation, not the terminal page — it lands one step quieter so the
   final 🖕 in its own ladder still reads as the loudest thing the site has said. */
.gn-slam-art-quiet {
    font-size: calc(var(--gn-fs-4xl) * 4);
}

.gn-slam-title {
    margin-block: var(--gn-space-4) 0;
    font-size: calc(var(--gn-fs-4xl) * 1.8);
    font-weight: 700;
    line-height: var(--gn-lh-tight);
}

.gn-slam-line {
    margin-block: var(--gn-space-3) 0;
    font-size: var(--gn-fs-lg);
    color: var(--gn-text-muted);
}

/* ── 4. App globals ───────────────────────────────────────────────────────── */
.content {
    padding-top: 1.1rem;
}

h1:focus {
    outline: none;
}

.valid.modified:not([type=checkbox]) {
    outline: 1px solid var(--gn-success);
}

.invalid {
    outline: 1px solid var(--gn-danger);
}

.validation-message {
    color: var(--gn-danger);
}

/* Intentionally theme-exempt: must stay readable when the theme is broken. Moved here
   from MainLayout.razor.css in S1.2 (that file is gone). z-index 1200 — above the drawer,
   because an unhandled error has to be reachable whatever else is open. */
#blazor-error-ui {
    color-scheme: light only;
    background: lightyellow;
    color: #212529;
    bottom: 0;
    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
    box-sizing: border-box;
    display: none;
    left: 0;
    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
    position: fixed;
    width: 100%;
    z-index: 1200;
}

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

/* Intentionally theme-exempt: the error surface must stay readable even when
   the theme system itself is what broke. */
.blazor-error-boundary {
    background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
    padding: 1rem 1rem 1rem 3.7rem;
    color: white;
}

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

.darker-border-checkbox.form-check-input {
    border-color: var(--gn-text-muted);
}

.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
    color: var(--bs-secondary-color);
    text-align: end;
}

.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
    text-align: start;
}

/* ── Gaslight Alley (ADR-010) ─────────────────────────────────────────────────────────
   Lives here rather than in the module: the blog RCL ships no CSS of its own
   (CONVENTIONS module contract), same as gn-trophy-* and gn-cta. Tokens only, no
   hard-coded colours — these have to survive all twelve palettes on either ground. */

/* The page break between the article and the comment section used to be an <hr
   class="gn-section-break">. Row S4.2 deleted it and both its call sites: the section now
   opens with the shared .gn-comments-head, whose 2px accent rule IS the break, and two
   horizontal rules 40px apart read as a mistake. */

/* The section sits on the article's own measure (.gn-measured in the markup), so the
   composer, the thread and the gate all line up with the prose above them. */
.gn-comments-region {
    margin-block-start: var(--gn-space-5);
}

/* While a game is running (game.js sets body.gn-playing) the comment region is removed from the
   page. That shrinks the document to just the article, so the ship's edge-scroll can't drive down
   into a long thread and comment words are never pulled into the level (ADR-010 §7 amendment).
   Targets the shared class so it hides both the real section and the ADR-011 consent gate. */
body.gn-playing .gn-comments-region {
    display: none;
}

/* THE ARTICLE'S END MATTER GOES WITH THEM, for both of the same reasons: it is chrome about the
   article rather than the article, and the document has to shrink to the words the level was
   built from or the ship's edge-scroll drives down into it.
   `display` and not `visibility`, exactly as above: everything listed sits BELOW the prose, so
   collapsing it moves no word rect.
   The series rail shipped visible (owner, 2026-08-14 — "the table shows in the games"), and the
   ADR-040 colophon was already doing the same thing beside it. Written as one list so the next
   thing added after an article inherits the answer instead of finding this out in a screenshot. */
body.gn-playing :is(.gn-serrail, .gn-c3-colophon) {
    display: none;
}

/* The game pans a virtual camera instead of scrolling the page (2026-08-02), so anything still
   visible underneath would sit frozen while the words move over it. Words are already hidden
   one span at a time; media is not, so hide it here. Visibility, not display: removing them
   would reflow the document and move every word rect the level was built from. */
body.gn-playing .content :is(img, svg, video, iframe, figure, .gn-poll) {
    visibility: hidden;
}

/* ...and the shell's own furniture, for the same reason once the viewport zooms (owner,
   2026-08-06). The level is drawn at 3x while the chrome stays at 1x, so it reads as a
   different application bolted to the edge of the game. Nothing here is reachable mid-play
   anyway — the canvas is the whole interface.
   Visibility again, not display: the level is built out of the article's word rects, and
   collapsing this would widen the main column and move every one of them.
   S1.2: `.sidebar` and `.gn-footer` no longer exist. The strip is not hidden — under ADR-042
   §4 it BECOMES the game readout, which is S1.3's job; the bar stays because it carries the
   reader bars and sits where a HUD belongs. */
body.gn-playing .gn-legal {
    visibility: hidden;
}

/* ── Comment consent gate (ADR-011) ───────────────────────────────────────────────────
   The blurred area is a placeholder skeleton, not real comments — those aren't rendered
   until the user agrees. The overlay carries the message; the control that opens the terms is
   the <summary> of the .gn-panel below the gate (row S4.7 — a <details> cannot have its trigger
   inside the blurred stack and its panel outside it). */
.gn-gate {
    position: relative;
    border: 1px solid var(--gn-border);
    border-radius: var(--gn-radius, .5rem);
    overflow: hidden;
}

.gn-gate-blur {
    filter: blur(6px);
    pointer-events: none;
    user-select: none;
    padding: 1rem;
}

.gn-gate-overlay {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    gap: .5rem;
    padding: 1.5rem;
    /* Sit over the blurred skeleton with a slight scrim so the message stays legible in any theme. */
    background: color-mix(in srgb, var(--gn-bg, #212529) 55%, transparent);
}

.gn-skel-comment {
    padding: .75rem 0;
}

.gn-skel-comment + .gn-skel-comment {
    border-top: 1px solid var(--gn-border);
}

.gn-skel-line {
    height: .7rem;
    border-radius: .25rem;
    background: var(--gn-surface-2);
    margin-bottom: .5rem;
}

.gn-skel-author {
    width: 30%;
    height: .8rem;
    background: var(--gn-border);
}

/* Owner-authored terms text in the consent panel — comfortable reading measure. */
.gn-terms-body {
    line-height: 1.6;
}

/* ── The comment tree — L3 §10 (row S4.2) ─────────────────────────────────────────────
   L3 draws the thread as a hairline grid: `display: grid; gap: 1px` over a --gn-border
   background, so the rules between comments are the background showing through. That
   works for a flat list and NOT for this one — CommentNode recurses, and a nested
   <details> thread cannot be a grid item of its ancestor. The borders below are the same
   look reached the way a tree can reach it.

   The outer box, so the thread reads as one object rather than as loose paragraphs
   trailing off the end of the article. */
.gn-comments-list {
    border: 1px solid var(--gn-border);
    background: var(--gn-surface);
}

.gn-comment {
    padding: var(--gn-space-3);
}

/* The reply rail. Accent rather than the quiet hairline: it is the one line on the page
   that carries meaning (this comment answers that one) rather than just separating two
   things, and at depth it is the only cue that survives. */
.gn-comment-indent {
    margin-left: var(--gn-space-3);
    border-left: 2px solid var(--gn-accent);
    padding-left: var(--gn-space-2);
}

/* The meta line: micro-caps like every other label on the site, with the handle opting
   back out of the caps — a handle is a name the reader chose and shouting it changes
   what it says. */
.gn-comment-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--gn-space-2);
    margin-block-end: var(--gn-space-2);
    font-size: var(--gn-fs-2xs);
    letter-spacing: .12em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-comment-handle {
    font-size: var(--gn-fs-sm);
    font-weight: 700;
    letter-spacing: .04em;
    text-transform: none;
    color: var(--gn-text);
}

/* Author line breaks are meaningful; the body is plain text, never markdown.
   Long unbroken strings are a comment section's oldest layout attack. */
.gn-comment-body {
    margin: 0;
    font-size: var(--gn-fs-sm);
    line-height: 1.5;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

.gn-comment + .gn-comment,
.gn-comment-thread > div > .gn-comment {
    border-top: 1px solid var(--gn-border);
}

.gn-comment-thread > summary {
    cursor: pointer;
    padding: 0 var(--gn-space-3) var(--gn-space-2);
    font-size: var(--gn-fs-2xs);
    letter-spacing: .12em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
    list-style: revert; /* keep the native disclosure triangle; Bootstrap's reboot hides it */
}

/* Action row (vote / reply / edit / delete). flex-wrap so it never clips the last button on an
   indented reply or a narrow screen — a nowrap row silently cut off "Delete" before this. */
.gn-comment-actions {
    row-gap: 0;
}

/* Facebook-style: actions are quiet until you point at them. btn-link underlines by default;
   suppress it and bring it back on hover/focus only. Icon buttons (edit/delete) have no text to
   underline, so this just governs their text siblings. */
.gn-comment-actions .btn-link {
    text-decoration: none;
}

.gn-comment-actions .btn-link:hover,
.gn-comment-actions .btn-link:focus-visible {
    text-decoration: underline;
}

/* Info button with a CSS-only tooltip (content in data-gn-tip). Shows on hover AND focus, so a
   click/tap (which focuses the button) opens it too — no JS, so it survives enhanced nav.
   Click-away or Esc blurs the button and the tip closes itself. Scoped to button so it can't
   bleed into RequestLink.razor's page-scoped span.gn-info (rich-HTML tooltip variant). */
button.gn-info {
    position: relative;
    display: inline-block;
    padding: 0;
    border: 0;
    background: none;
    color: var(--gn-info);
    cursor: pointer;
    line-height: 1;
}

/* Corner variant: adornment at the top-right of a .form-floating input. Lives OUTSIDE the
   floating label (which clips overflow and would cut the tooltip off). */
button.gn-info-corner {
    position: absolute;
    top: .5rem;
    right: .75rem;
    z-index: 5;
}

button.gn-info:hover::after,
button.gn-info:focus::after {
    content: attr(data-gn-tip);
    position: absolute;
    left: 50%;
    bottom: calc(100% + .45rem);
    transform: translateX(-50%);
    z-index: 20;
    width: max-content;
    max-width: 16rem;
    padding: .35rem .6rem;
    font-family: var(--gn-font);
    font-size: var(--gn-fs-sm);
    font-style: normal;
    line-height: 1.35;
    text-align: start;
    white-space: normal;
    color: var(--gn-text);
    background: var(--gn-surface-2);
    border: 1px solid var(--gn-border);
    border-radius: var(--gn-radius-sm);
    box-shadow: 0 2px 8px rgba(0, 0, 0, .4);
}

/* Countdown pie for the comment rate limit (ADR-025 E1). A gn-info button so the tooltip and its
   hover/focus behaviour come for free; --gn-cooldown is the share still to wait, written inline by
   GaslightAlley on each tick, so the wedge drains to nothing as the Post button re-enables.
   AFTER button.gn-info — same specificity, source order is what puts this background back.
   No mask/clip-path here on purpose: either would take the ::after tooltip with it. */
button.gn-cooldown {
    width: 1.15rem;
    height: 1.15rem;
    border-radius: 50%;
    cursor: help;
    background: conic-gradient(var(--gn-accent) var(--gn-cooldown, 0%), var(--gn-surface-2) 0);
}

/* Right-align the corner tooltip with its button so it grows leftward, not off-screen.
   AFTER the generic rule above — same specificity, so source order is what wins here. */
button.gn-info-corner:hover::after,
button.gn-info-corner:focus::after {
    left: auto;
    right: 0;
    transform: none;
}

/* ── Trophy-button nudge ─────────────────────────────────────────────────────
   A new reader has no reason to look at the trophy icon, and the whole
   progression (ADR-013) hangs off noticing it. trophies.js adds .gn-nudge until
   the case has been opened once, then never again on this browser.
   Deliberately not a popup — the owner's call: highlight, don't interrupt. */
@keyframes gn-trophy-nudge {
    0%, 100% { box-shadow: 0 0 0 0 var(--gn-accent); }
    50%      { box-shadow: 0 0 0 .35rem transparent; }
}

.gn-nudge {
    animation: gn-trophy-nudge 1.8s ease-out infinite;
    border-color: var(--gn-accent);
    color: var(--gn-accent);
}

/* Accessibility basic, not an optional polish: a persistent pulse is exactly the
   kind of motion that triggers vestibular symptoms. Keep the emphasis, drop the
   movement. */
@media (prefers-reduced-motion: reduce) {
    .gn-nudge {
        animation: none;
        box-shadow: 0 0 0 .2rem var(--gn-accent);
    }
}

/* ── AI usage label (ADR-040) ────────────────────────────────────────────────
   Three glyphs under the article title and an info button; every word lives in
   the modal. The rings are inline SVG on a 100x100 viewBox, so there is little
   here beyond the plate, the row and the breakdown rows.
   Appended at the end of the file rather than filed into a section above,
   deliberately — a wholesale rewrite of this file was expected, and an append
   conflicts far more cleanly than an insertion. */
/* No plate (owner, 2026-08-07: "the background should be transparent... they should look
   like coloured icons"). The plate was carrying the 3:1 contrast floor — without it the two
   hues fall under it on any ground close to their own hue, and after ADR-044 there are twelve
   grounds rather than one, so the shortfall is no longer confined to a single theme. Accepted
   knowingly: no information is lost, because every ring carries its numbers in a title/aria-label
   and the breakdown states them in text. S2.2 re-measures both hues against all twelve.
   The modal's own rings keep their ground (see .gn-c3-detail below). */
/* Positions the L2 hover card. A <button> cannot contain one, so the badge and its card
   are siblings inside this wrapper. */
.gn-c3-wrap {
    position: relative;
    display: inline-flex;
}

/* L2 (ADR-040 §10): the summary, on hover or keyboard focus. CSS only — the page is static
   SSR and this needs no circuit. Hidden from assistive tech and from touch: the button's
   aria-label already reads the same words, and a coarse pointer has no hover, so a tap goes
   straight to L3 instead of lighting a card up under the finger. */
.gn-c3-peek {
    position: absolute;
    top: calc(100% + var(--gn-space-2));
    left: 0;
    z-index: 20;
    display: none;
    flex-direction: column;
    gap: 2px;
    min-width: 15rem;
    padding: var(--gn-space-2) var(--gn-space-3);
    /* --gn-surface-2, not --gn-c3-plate. The plate is a theme-invariant DARK ground that
       exists so the mark's two hues cannot be tuned away (ADR-040); this card is an ordinary
       tooltip that prints --gn-text on itself, and on a light ground that pairing is
       near-black on near-black. The plate stays where it belongs — under the rings. */
    background: var(--gn-surface-2);
    /* Accent, not the hairline (owner, 2026-08-11). This card floats over arbitrary page content
       — a table row, an article, a zebra stripe — and a --gn-border edge on --gn-surface-2 left
       it reading as part of whatever was behind it. The accent is what says "this is a layer". */
    border: 1px solid var(--gn-accent);
    border-radius: var(--gn-radius);
    color: var(--gn-text);
    font-size: var(--gn-fs-sm);
    line-height: 1.35;
    text-align: left;
    white-space: nowrap;
}

/* The focusable element is the <summary> since row S4.7, not the badge — a descendant selector
   rather than the sibling one this used to be. */
.gn-c3-wrap:hover .gn-c3-peek,
.gn-c3-summary:focus-visible .gn-c3-peek {
    display: flex;
}

/* Once the panel is open the badge has done its job; leaving the hover card floating over the
   breakdown it just revealed is the tooltip covering its own answer. */
.gn-c3-panel[open] .gn-c3-peek {
    display: none;
}

[data-gn-input="coarse"] .gn-c3-peek {
    display: none;
}

/* Accent rule under the title, matching the card's own accent border (owner, 2026-08-11): it
   separates the heading from the three readings, which at this size were running together. */
.gn-c3-peek-head {
    font-weight: 600;
    padding-bottom: var(--gn-space-1);
    margin-bottom: var(--gn-space-1);
    border-block-end: 1px solid var(--gn-accent);
}

.gn-c3-peek-row {
    display: flex;
    align-items: baseline;
    gap: var(--gn-space-2);
}

/* The medium, as its glyph instead of its name. currentColor so it inherits the row's ink and
   needs no token of its own — it is a label, not part of the two-hue mark, so ADR-040's
   theme-invariant --gn-c3-* rule does not reach it. */
.gn-c3-glyph {
    inline-size: 1em;
    block-size: 1em;
    flex: none;
    align-self: center;
    color: var(--gn-text-muted);
}

.gn-c3-peek-phase {
    min-width: 5.5rem;
}

.gn-c3-peek-foot {
    padding-top: var(--gn-space-1);
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-xs);
}

/* ── C3PO badge and its in-flow breakdown — ADR-045 §5 via row S4.7 ──────────
   The breakdown was a Bootstrap modal. It is now a <details> that wraps the badge, so the whole
   thing is one element: .gn-c3-panel > .gn-c3-summary > .gn-c3-wrap > (.gn-c3 + .gn-c3-peek),
   and .gn-c3-body underneath.

   NOT .gn-panel. The generic component (§3, row S3.3) is a bordered, surfaced box with a padded
   summary and a 44px target; this is a bare inline mark that has to sit on an article's date line
   without changing its height. Same MECHANISM, different skin — which is the whole reason ADR-045
   §5 specified a mechanism rather than a class.

   display: block, and every call site puts it in a flex row, so it is a flex item that grows
   downwards when opened. That IS "the document grows" (ADR-042 §7). */
.gn-c3-panel {
    display: block;
}

/* The <summary> is the control. list-style: none because this badge is not a paragraph
   disclosure — the three rings ARE the affordance and a triangle beside them reads as a fourth
   glyph in the set. Open/closed is still carried by shape, not colour (ADR-045 §2 rule 11): the
   breakdown is either under it or it is not. */
.gn-c3-summary {
    display: inline-block;
    list-style: none;
    cursor: pointer;
}

.gn-c3-summary::-webkit-details-marker {
    display: none;
}

.gn-c3-summary:focus-visible {
    outline: 2px solid var(--gn-focus-ring);
    outline-offset: 3px;
}

/* The badge itself: a plain span inside the summary since row S4.7. The button resets stay —
   .gn-c3-bare and the coarse-pointer overlay below both key off this box. */
.gn-c3 {
    display: inline-flex;
    align-items: center;
    gap: var(--gn-space-2);
    background: none;
    border: 0;
    padding: 0;
    margin: 0;
    font: inherit;
    color: inherit;
    cursor: pointer;
}

/* The breakdown. Width in ch (css-tokens.md — never a pixel width): a flex item sized by its
   content would otherwise be as wide as the longest note in it, which on an index card's date
   line is the whole card and then some. */
.gn-c3-body {
    width: min(46ch, 100%);
    padding-top: var(--gn-space-2);
}

.gn-c3-body-head {
    margin: 0 0 var(--gn-space-1);
    font-weight: 600;
}

/* Touch target without layout cost. The badge is 32px on an article and 16px on a card,
   both under ADR-028's 44px floor, but growing the button would push the date line down
   (and on the index would make a labelled card taller than an unlabelled one — the exact
   thing the 16px was measured to avoid). An overlay gives the finger 44px and the layout
   nothing. Width needs no help: three rings plus gaps clear 44px at both sizes. */
[data-gn-input="coarse"] .gn-c3 {
    position: relative;
}

[data-gn-input="coarse"] .gn-c3::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: 50%;
    height: 44px;
    transform: translateY(-50%);
}

/* --gn-c3-size is the tuner's Size knob, arriving inline on .gn-c3. A ceiling
   rather than a width: the ring shrinks with its column so a row of them fits
   any viewport with no media query (css-tokens.md, no fixed pixel widths). */
.gn-c3-ring {
    display: block;
    flex: 0 1 var(--gn-c3-size, 44px);
    width: var(--gn-c3-size, 44px);
    max-width: 100%;
    height: auto;
}

/* One phase in the breakdown: its ring, then the numbers and the evidence. */
.gn-c3-detail {
    display: flex;
    align-items: flex-start;
    gap: var(--gn-space-3);
    padding: var(--gn-space-3) 0;
    border-top: 1px solid var(--gn-border);
}

.gn-c3-detail:first-of-type {
    border-top: 0;
    padding-top: 0;
}

/* The breakdown's rings sit on the page, not on the plate, so they carry their own ground the
   same way the badge does. (Said "the modal surface" until row S4.7 put the breakdown in flow —
   the ring's need for its own plate is unchanged, only what is behind it.) */
.gn-c3-detail .gn-c3-ring {
    flex: none;
    width: 3rem;
    background: var(--gn-c3-plate);
    border-radius: 50%;
    padding: 2px;
}

.gn-c3-pct {
    margin-left: var(--gn-space-2);
    color: var(--gn-text-muted);
    font-variant-numeric: tabular-nums;
}

.gn-c3-medium {
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-sm);
}

.gn-c3-note {
    margin: var(--gn-space-2) 0 0;
    font-size: var(--gn-fs-sm);
}

/* The name, explained once at the foot of the breakdown. */
.gn-c3-about {
    margin: var(--gn-space-3) 0 0;
    padding-top: var(--gn-space-3);
    border-top: 1px solid var(--gn-border);
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-sm);
}

/* The editor's two disclosure blocks, side by side. Bordered so each reads as one unit
   and it is obvious which boxes belong to which claim. */
.gn-c3-edit {
    padding: var(--gn-space-3);
    border: 1px solid var(--gn-border);
    border-radius: var(--gn-radius);
}

/* The colophon: the shared rating for whatever an article is about, between the body and
   the comments. Headed, because a page carrying two ratings needs to say which is which. */
.gn-c3-colophon {
    display: flex;
    align-items: center;
    gap: var(--gn-space-3);
    flex-wrap: wrap;
    /* margin-BLOCK, not the shorthand: the shorthand's inline `0` sits below .gn-measured
       in this file and silently beat its `margin-inline: auto`, so the colophon was the one
       block on the article still hanging off the left gutter (row S4.2). */
    margin-block: var(--gn-space-4);
    padding-top: var(--gn-space-3);
    border-top: 1px solid var(--gn-border);
}

.gn-c3-colophon-label {
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-sm);
    letter-spacing: .04em;
    text-transform: uppercase;
}

/* Index cards: smaller still, and nudged off the comment count it follows. */
.gn-c3-bare {
    margin-left: var(--gn-space-2);
    gap: var(--gn-space-1);
    vertical-align: middle;
}

/* 1rem: the card's date line box is ~17px, and anything taller makes a labelled card a
   pixel or two deeper than an unlabelled one, all the way down the index. Measured. */
.gn-c3-bare .gn-c3-ring {
    flex: none;
    width: 1rem;
}

/* ── Leaderboard — ADR-052 (row S6.2) ────────────────────────────────────────
   /blog/leaderboard. The last reader-facing Bootstrap page; nav-tabs, btn-primary,
   btn-outline-secondary, table/table-responsive, alert, badge, col-md-8 and lead all
   left with this block, and ADR-043's seam test now passes on it — delete
   bootstrap.min.css and the layout suffers, the identity does not.

   It is mostly ARRANGEMENT: the two tables are .gn-index, the tabs are .gn-tabs, the
   heads are .gn-index-head, the annotations are .gn-pill, the tiers are .gn-star and the
   dial is .gn-ring. What is genuinely new is the summary card (a sibling of the trophy
   case's, which cannot be reused because .gn-case-* is scoped to TrophyCase and the blog
   is an RCL), the filter form, and two chrome-less buttons.

   Lives here and not in a .razor.css because the module ships no CSS at all (css-tokens). */

/* The handle-less notice. It replaced `alert alert-warning`: a rule and a colour, not a
   filled panel — the page has one of these and it is information, not an alarm. */
.gn-lb-note {
    margin-block: var(--gn-space-3);
    padding: var(--gn-space-3);
    border-inline-start: 3px solid var(--gn-accent);
    background: var(--gn-surface);
    font-size: var(--gn-fs-sm);
}

.gn-lb-note p {
    margin: 0;
}

.gn-lb-empty {
    margin-block: var(--gn-space-4);
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-sm);
}

/* ── Your card (§2) ──────────────────────────────────────────────────────────
   The trophy case summary's shape — dial, counts, computed next step — rebuilt under its
   own names. Wraps rather than a grid: the dial is optional (a reader with no clears gets
   the body alone) and a grid would leave its column behind. */
.gn-lb-card {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--gn-space-3) var(--gn-space-4);
    margin-block: var(--gn-space-4);
    padding: var(--gn-space-3);
    border: 1px solid var(--gn-border);
    background: var(--gn-surface);
}

/* Ring and count stacked in one cell: the number sits INSIDE the arc. */
.gn-lb-dial {
    display: grid;
    place-items: center;
    margin: 0;
    flex: none;
}

.gn-lb-dial > * {
    grid-area: 1 / 1;
}

/* The ring is ProgressRing's <svg> and carries ProgressRing's own scope, so this has to be
   here rather than beside the caller — the same reason .gn-case-dial .gn-ring is in this
   file. A graphic, not a meter beside text, so it is sized off the spacing scale and no
   type step decides how big it is. */
.gn-lb-dial .gn-ring {
    inline-size: var(--gn-space-5);
    block-size: var(--gn-space-5);
    vertical-align: baseline;
}

.gn-lb-dial-n {
    font-size: var(--gn-fs-sm);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}

.gn-lb-card-body {
    flex: 1 1 22rem;
    min-inline-size: 0;   /* a flex child defaults to min-content and refuses to wrap */
}

.gn-lb-line {
    margin: 0;
    font-size: var(--gn-fs-lg);
}

/* The three counts. A <dl> because that is what they are: three labelled values. */
.gn-lb-stat {
    display: flex;
    flex-wrap: wrap;
    gap: var(--gn-space-4);
    margin-block: var(--gn-space-3);
}

.gn-lb-stat div {
    margin: 0;
}

.gn-lb-stat dt {
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-lb-stat dd {
    margin: 0;
    font-size: var(--gn-fs-xl);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}

.gn-lb-next {
    display: flex;
    gap: var(--gn-space-2);
    margin: 0;
    color: var(--gn-text-muted);
    font-size: var(--gn-fs-sm);
}

.gn-lb-next strong {
    color: var(--gn-text);
}

.gn-lb-next-n {
    margin-inline-start: var(--gn-space-2);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* ── Standings and the record ────────────────────────────────────────────────
   .gn-index-head is authored for h2; the per-post heads are h3 because they sit under
   "The record", and a level is not a size. */
.gn-lb-section {
    margin-block-end: var(--gn-space-4);
}

.gn-lb-board {
    margin-block-end: var(--gn-space-4);
}

.gn-index-head h3 {
    margin: 0;
    font-size: var(--gn-fs-xs);
    font-weight: 700;
    letter-spacing: .16em;
    text-transform: uppercase;
}

/* §8's "showing the top N of M", and the one-handle line under Standings. Both are the
   page telling the truth about what it is not showing, in the same quiet register. */
.gn-lb-cap {
    margin-block: var(--gn-space-2) 0;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .14em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

/* You, on a board of handles. --gn-accent-2 is exactly what css-tokens reserves it for:
   one item on the page, to pull an eye. The rail is the row's existing 3px card border
   recoloured; at md the card border is gone, so it is redrawn as an inset shadow, which
   also keeps it out of the zebra/hover fight over `background`. */
.gn-index tbody tr.gn-lb-you {
    border-inline-start-color: var(--gn-accent-2);
}

.gn-pill-you {
    margin-inline-start: var(--gn-space-1);
    background: var(--gn-accent-2);
    color: var(--gn-on-accent-2);
}

/* The annotations — persona, vulgarity, jargon, fingerprint. Quiet pills on purpose: one
   per row times every row is not the sparing use --gn-accent-2 is for, and these are
   context rather than status. */
.gn-lb-annot {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--gn-space-1);
}

.gn-lb-fp {
    font-size: var(--gn-fs-2xs);
    color: var(--gn-text-muted);
    white-space: nowrap;
}

.gn-lb-stars {
    white-space: nowrap;
    letter-spacing: .1em;
}

/* Rank-by. Inside .gn-index-head-meta, so it inherits the micro-cap register and only has
   to lay the two links out and mark the live one. */
.gn-lb-sort {
    display: flex;
    align-items: center;
    gap: var(--gn-touch-gap);
}

.gn-lb-sort a {
    padding: 2px var(--gn-space-2);
    border: 1px solid var(--gn-border-strong);
    color: var(--gn-text-muted);
    text-decoration: none;
}

.gn-lb-sort a:hover,
.gn-lb-sort a:focus-visible {
    color: var(--gn-text);
}

.gn-lb-sort a[aria-current] {
    background: var(--gn-accent);
    border-color: var(--gn-accent);
    color: var(--gn-on-accent);
}

/* ── The filter form (§5) ────────────────────────────────────────────────────
   A GET form and nothing else — no circuit, no script. It wraps rather than scrolling,
   like the tab strip: six controls on a phone is three lines, and three lines is not a
   defect (ADR-042 §7 — the document is the only scroller). */
.gn-lb-filters {
    display: flex;
    flex-wrap: wrap;
    align-items: end;
    gap: var(--gn-space-2) var(--gn-space-3);
    margin-block: var(--gn-space-3) var(--gn-space-4);
    padding: var(--gn-space-3);
    border: 1px solid var(--gn-border);
    background: var(--gn-surface);
}

.gn-lb-filters label {
    display: block;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--gn-text-muted);
}

.gn-lb-filters select {
    min-block-size: var(--gn-touch);
    max-inline-size: 16rem;
    padding: var(--gn-space-1) var(--gn-space-2);
    border: 1px solid var(--gn-border-strong);
    background: var(--gn-bg);
    color: var(--gn-text);
    font-family: inherit;
    font-size: var(--gn-fs-sm);
}

/* css-tokens: a select under 16px makes Safari zoom the page on focus and never zoom back. */
[data-gn-input="coarse"] .gn-lb-filters select {
    font-size: var(--gn-fs-md);
}

.gn-lb-check {
    display: flex;
    align-items: center;
    gap: var(--gn-space-2);
    min-block-size: var(--gn-touch);
}

.gn-lb-check span {
    font-size: var(--gn-fs-sm);
    letter-spacing: 0;
    text-transform: none;
    color: var(--gn-text);
}

/* Filled, never outlined (css-tokens, owner rule) — including the quiet one, which is a
   surface fill rather than a transparent box with a border. */
.gn-lb-btn {
    display: inline-flex;
    align-items: center;
    min-block-size: var(--gn-touch);
    padding-inline: var(--gn-space-3);
    border: 1px solid var(--gn-accent);
    background: var(--gn-accent);
    color: var(--gn-on-accent);
    font: inherit;
    font-size: var(--gn-fs-2xs);
    font-weight: 700;
    letter-spacing: .16em;
    text-transform: uppercase;
    text-decoration: none;
    cursor: pointer;
}

.gn-lb-btn:hover,
.gn-lb-btn:focus-visible {
    background: var(--gn-accent-hover);
    border-color: var(--gn-accent-hover);
    color: var(--gn-on-accent);
}

.gn-lb-btn-quiet {
    background: var(--gn-surface-2);
    border-color: var(--gn-border-strong);
    color: var(--gn-text);
}

.gn-lb-btn-quiet:hover,
.gn-lb-btn-quiet:focus-visible {
    background: var(--gn-border);
    border-color: var(--gn-border-strong);
    color: var(--gn-text);
}

/* The permutation deep-link, one per row. Small and quiet, and still a 44px target on a
   coarse pointer — the ink stays small and the target grows (css-tokens). */
.gn-lb-play {
    padding: 2px var(--gn-space-2);
    border: 1px solid var(--gn-border-strong);
    background: transparent;
    color: var(--gn-text-muted);
    font: inherit;
    font-size: var(--gn-fs-2xs);
    letter-spacing: .12em;
    text-transform: uppercase;
    white-space: nowrap;
    cursor: pointer;
}

.gn-lb-play:hover,
.gn-lb-play:focus-visible {
    background: var(--gn-accent);
    border-color: var(--gn-accent);
    color: var(--gn-on-accent);
}

[data-gn-input="coarse"] .gn-lb-play {
    min-block-size: var(--gn-touch);
}

@media (min-width: 768px) {
    /* The card border is dropped at md (see the .gn-index block), so the "you" rail is
       redrawn as an inset shadow — a property no zebra or hover rule competes for. */
    .gn-index tbody tr.gn-lb-you {
        box-shadow: inset 3px 0 0 var(--gn-accent-2);
    }
}
