/*
 * Collected — "Ledger" design system (design spec V1, July 2026).
 *
 * A single hand-rolled stylesheet (no framework, no build step) implementing the
 * editorial-archive visual language from the Collected Design Spec:
 *
 *   - warm ink surfaces, dark-first with a first-class light pair
 *   - Newsreader (serif) for page & game titles, Public Sans for UI/body,
 *     IBM Plex Mono for labels, data, counts, and badges
 *   - two accents: leaf (ownership, success, active nav, links) and brass
 *     (in-progress, warnings) — one accent per screen wherever possible
 *   - hairlines over boxes; covers and screenshots supply the color
 *
 * Everything the old Pico.css layer provided (element defaults, buttons, forms,
 * tables) is defined here instead, scoped through design tokens below.
 */

/* ---------------------------------------------------------------------------
 * Fonts (vendored — no CDN)
 * ------------------------------------------------------------------------- */

/* Newsreader — display serif, variable weight (400–700), optical sizing. */
@font-face {
  font-family: "Newsreader";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../fonts/newsreader-latin.0a301b6df4c7.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Newsreader";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../fonts/newsreader-latin-ext.40f89707c10e.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: "Newsreader";
  font-style: italic;
  font-weight: 400 600;
  font-display: swap;
  src: url("../fonts/newsreader-italic-latin.6d201901085f.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Newsreader";
  font-style: italic;
  font-weight: 400 600;
  font-display: swap;
  src: url("../fonts/newsreader-italic-latin-ext.045b9f0ec267.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* Public Sans — UI and body, variable weight (400–700). */
@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../fonts/public-sans-latin.45ac3f4b61c1.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../fonts/public-sans-latin-ext.587f1fa8d04e.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* IBM Plex Mono — labels, data, counts, badges. */
@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/plex-mono-400-latin.814fe1615d4d.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/plex-mono-400-latin-ext.17a5b2a15bf5.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../fonts/plex-mono-500-latin.6708425c446b.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../fonts/plex-mono-500-latin-ext.216ee1a35b06.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../fonts/plex-mono-600-latin.e71a597e376e.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../fonts/plex-mono-600-latin-ext.6041b3192dc3.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* ---------------------------------------------------------------------------
 * Design tokens
 * ------------------------------------------------------------------------- */

/* Dark — the primary theme ("warm ink"). Each custom property that carries an
   oklch value is declared twice: the hex line is the fallback for engines
   without oklch support, the spec-canonical oklch line wins where valid. */
:root {
  --bg: #16140f;
  --surface: #1d1a14;
  --surface-2: #211e16;
  --surface-3: #2a261c;
  --border: rgba(236, 232, 221, 0.12);
  --border-strong: rgba(236, 232, 221, 0.25);
  --fill: rgba(236, 232, 221, 0.04);
  /* Inline-code chips (spec 05.5): a touch stronger than --fill so chips still
     read when they sit on a raised card surface. */
  --chip: rgba(236, 232, 221, 0.07);

  --text: #ece8dd;
  --text-muted: #a09a8a;
  /* Faint must still clear WCAG 1.4.3 (4.5:1) on --bg and --surface — it is
     used for real copy (captions, footnotes), not decoration (issue #528). */
  --text-faint: #8a8375;

  /* Home portrait chart steps (design spec 23, issue #1646). Four ink alphas,
     never hues: the histogram separates its bars by lightness alone, so the
     peak's accent is the only color in the chart and the ranking survives any
     color-vision deficiency. UNDATED is the faintest and is never accented —
     it is the one bucket that can never be the story. */
  --portrait-bar-rest: rgba(236, 232, 221, 0.22);
  --portrait-bar-runner-up: rgba(236, 232, 221, 0.3);
  --portrait-bar-undated: rgba(236, 232, 221, 0.12);
  --portrait-track: rgba(236, 232, 221, 0.1);
  --portrait-spread-fill: rgba(236, 232, 221, 0.35);
  --onboard-tick: rgba(236, 232, 221, 0.13);

  /* Leaf = ownership, success, active nav, links. Fill (--accent) paints leaf
     surfaces (add-to-collection buttons, OWNED badges, checked boxes); text
     (--accent-text) is leaf-colored text/links/focus rings. In dark both roles
     share the same leaf; the light theme darkens only the text role so accent
     text keeps AA contrast on near-white. */
  --accent: #60bb83;
  --accent: oklch(0.72 0.12 155);
  --accent-strong: #6dc88f;
  --accent-strong: oklch(0.76 0.12 155);
  --accent-ink: #16140f;
  --accent-dim: rgba(96, 187, 131, 0.12);
  /* Leaf borders: the ownership status bar's edge and the stronger owned-cover
     outline (design "Catalog Card States" 05.2). */
  --accent-border: rgba(96, 187, 131, 0.3);
  --accent-border: oklch(0.72 0.12 155 / 0.3);
  --accent-border-strong: rgba(96, 187, 131, 0.55);
  --accent-border-strong: oklch(0.72 0.12 155 / 0.55);
  --accent-text: var(--accent);
  --accent-text-strong: var(--accent-strong);

  /* Grid action bar (design "Catalog Action Bar Spec" 09 §C). The spec quotes
     the dark theme's raw values, so these ARE those numbers; the light block
     restates each one against paper rather than letting a leaf-on-ink tint
     wash out on near-white. Three tint steps and three edge steps, because the
     tinted primary moves through rest → hover → open. */
  /* Locked controls (design "Signed Out Action Bar" spec 13 §C, issue #1291).
     The dashed edge is the WHOLE of the "you need an account" signal — no
     padlock, no dimming, no cursor: not-allowed — so it gets real tokens
     rather than a borrowed --border-strong: the two strengths (the primary
     control's and the quieter wishlist's) and their hovers are the component's
     entire visual language, and a 0.01 drift would flatten the pair into one.
     Same convention as --bar-* above: the spec quotes the dark theme's raw
     values, so these ARE those numbers, restated on paper in the light block. */
  --lock-edge: rgba(236, 232, 221, 0.24);
  --lock-edge-hover: rgba(236, 232, 221, 0.38);
  --lock-edge-quiet: rgba(236, 232, 221, 0.18);
  --lock-edge-quiet-hover: rgba(236, 232, 221, 0.3);
  /* The primary label's hover tone, a step brighter than --text-muted without
     going all the way to --text (which would read as "now it's enabled"). */
  --lock-text-hover: #c4bfb1;

  /* The assertion control (design "Suggest Edit Panes" spec 18 §B, issue
     #1433). The spec quotes the dark theme's raw values, so these ARE those
     numbers, restated on paper in the light block. Both assertions are leaf —
     "Not available" is not an error — and the selected-silence skin is its
     own quiet pair so a pane at rest shows settled cells, not lit ones. */
  --assert-fill: rgba(96, 187, 131, 0.18);
  --assert-fill: oklch(0.72 0.12 155 / 0.18);
  --assert-edge: rgba(96, 187, 131, 0.7);
  --assert-edge: oklch(0.72 0.12 155 / 0.7);
  --assert-text: #8fd8ab;
  --assert-text: oklch(0.82 0.13 155);
  --assert-silence-fill: rgba(236, 232, 221, 0.08);
  --assert-silence-edge: rgba(236, 232, 221, 0.28);
  --assert-silence-text: #c4bfb1;
  --assert-quiet: #6f695d;
  --assert-hairline: rgba(236, 232, 221, 0.06);

  --bar-tint: rgba(96, 187, 131, 0.1);
  --bar-tint: oklch(0.72 0.12 155 / 0.1);
  --bar-tint-hover: rgba(96, 187, 131, 0.16);
  --bar-tint-hover: oklch(0.72 0.12 155 / 0.16);
  --bar-tint-open: rgba(96, 187, 131, 0.18);
  --bar-tint-open: oklch(0.72 0.12 155 / 0.18);
  --bar-edge: rgba(96, 187, 131, 0.42);
  --bar-edge: oklch(0.72 0.12 155 / 0.42);
  --bar-edge-hover: rgba(96, 187, 131, 0.55);
  --bar-edge-hover: oklch(0.72 0.12 155 / 0.55);
  --bar-edge-open: rgba(96, 187, 131, 0.6);
  --bar-edge-open: oklch(0.72 0.12 155 / 0.6);
  /* The hairline splitting the solid primary from its caret: ink over leaf,
     so it follows --accent-ink, not the page text. */
  --bar-divider: rgba(22, 20, 15, 0.28);
  /* Wish, switched on: a neutral raised plate, deliberately NOT leaf — the
     heart means "wanted", and leaf is reserved for ownership. */
  --bar-wish-on: rgba(236, 232, 221, 0.12);
  --bar-wish-on-hover: rgba(236, 232, 221, 0.18);
  --bar-wish-on-edge: rgba(236, 232, 221, 0.42);

  /* Brass = in-progress (play state), warnings, secondary highlights. */
  --brass: #d5b36a;
  --brass: oklch(0.78 0.1 85);
  /* Brass for *small* text, the sibling of --accent-text (issue #829). On ink
     the plain brass already clears AA with room (8.8:1 even over a hovered
     row), so this is an alias here; on paper it has to step darker. Reach for
     it whenever brass carries text below ~14px. */
  --brass-text: var(--brass);
  --brass-dim: rgba(213, 179, 106, 0.14);
  --brass-border: rgba(213, 179, 106, 0.4);

  /* Chart series 2 (issue #719): the quieter partner wherever a chart pairs
     a second series with the leaf accent. A lightness step of the leaf, not
     a second hue — this palette's hues sit too close in lightness to stay
     apart under color-vision deficiency (validated, deutan/protan ΔE). */
  --chart-2: #307a4f;
  --chart-2: oklch(0.52 0.1 155);

  --danger: #e08b74;
  --danger-dim: rgba(224, 139, 116, 0.1);
  --danger-border: rgba(224, 139, 116, 0.35);
  --success: #60bb83;
  --success: oklch(0.72 0.12 155);

  /* Theme-dependent atmosphere: the sticky header's translucent tint,
     floating-panel shadows, table row hover. */
  --header-tint: rgba(22, 20, 15, 0.85);
  --shadow-panel: 0 12px 32px rgba(0, 0, 0, 0.45);
  --row-hover: rgba(236, 232, 221, 0.025);

  /* Native-select caret: the registry chevron (see --icon-caret), matching
     every other caret since #1383. A background image rather than a mask —
     a data-URI cannot read CSS custom properties, so each theme supplies its
     own muted-tone image. */
  --select-caret: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='4.5 7.5 11 6'%3E%3Cpath d='M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z' fill='%23a09a8a'/%3E%3C/svg%3E");

  --font-display: "Newsreader", "Iowan Old Style", Georgia, serif;
  --font-body: "Public Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  --radius: 6px;
  --radius-sm: 4px;
  --container: 76rem;
  --speed: 160ms;

  /* A stroked ✕, used as a MASK so the glyph takes its colour from the rule
     that applies it (and so follows the theme). Currently the search field's
     native clear button; keep it colour-free for that reason. */
  --icon-clear: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 4l8 8M12 4l-8 8' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round'/%3E%3C/svg%3E");
  /* The registry's chevron-down (apps/web/icons.py), viewBox cropped to its
     ink, as a MASK for the pseudo-element carets that used to draw clip-path
     triangles — one caret shape site-wide now that the ▾ text glyphs are the
     same chevron via the icon tag (issue #1383). Colour-free like
     --icon-clear: the applying rule's background supplies it. */
  --icon-caret: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='4.5 7.5 11 6'%3E%3Cpath d='M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z'/%3E%3C/svg%3E");
  /* The page gutter, shared by .container and anything that full-bleeds past
     it (the shelf rails at handheld). Compact pins it to 16px (§06).
     The desktop cap is the refined Home spec's 48px; 4vw only exceeds the old
     40px above a ~1000px viewport, so handheld and tablet gutters are
     unchanged by the bump. */
  --gutter: clamp(1rem, 4vw, 3rem);
  /* Header height (§06 keeps it constant at every width, so every sticky
     offset stays valid). Tokenised because the full-screen nav panel starts
     exactly where the header ends — the two must never drift apart. */
  --header-h: 3.6rem;

  /* Type-size tokens (mobile layer spec §02): six sizes carry every sub-1rem
     font-size in the sheet. Desktop values here; one bump inside the handheld
     query below is the entire phone type scale — there is no second scale.
     - mono-badge: uppercase tracked mono inside a bordered/filled badge (the
       one 12px-on-phone exemption to the 13px floor)
     - mono: micro-labels, counts, captions, facts labels, mono data
     - ui-sm: control text, chips, tabs, table cells, .btn-sm
     - ui: buttons, nav, card titles, form labels
     - body: prose, descriptions, entry notes
     - field: input/select/textarea — pinned to 16px on phones so iOS Safari
       never zooms the viewport on focus */
  --fs-mono-badge: 0.6875rem;
  --fs-mono: 0.75rem;
  --fs-ui-sm: 0.8125rem;
  --fs-ui: 0.875rem;
  --fs-body: 0.9375rem;
  --fs-field: 0.875rem;

  color-scheme: dark;
}

/* The phone type scale (mobile layer spec §02): the same tokens, each bumped
   one step up the same rem ladder — 13px floor for reading matter, 12px only
   inside badges, 16px fields (the iOS zoom floor). Queries are px so the
   layout never moves with the user's root font size; type stays rem so it
   still does. */
@media (max-width: 720px) {
  :root {
    --fs-mono-badge: 0.75rem;
    --fs-mono: 0.8125rem;
    --fs-ui-sm: 0.875rem;
    --fs-ui: 0.9375rem;
    --fs-body: 1rem;
    --fs-field: 1rem;
  }
}

/* Light — the first-class pair (spec 06: dark first, light always). Same design
   language on warm paper surfaces. Selected by the bootstrap script in
   base.html, which sets data-theme from the stored preference or
   prefers-color-scheme before first paint. */
html[data-theme="light"] {
  --bg: #faf9f5;
  --surface: #ffffff;
  --surface-2: #f2f0e9;
  --surface-3: #e9e6dc;
  --border: rgba(28, 26, 21, 0.12);
  --border-strong: rgba(28, 26, 21, 0.25);
  --fill: rgba(28, 26, 21, 0.04);
  --chip: rgba(28, 26, 21, 0.06);

  --text: #1c1a15;
  --text-muted: #6f6a5e;
  /* Same 4.5:1 floor as dark (issue #528): on paper, faint has to darken to
     the muted tone to clear it — the two roles converge in light. */
  --text-faint: #6f6a5e;

  /* The portrait's chart steps invert to ink-on-paper at the same alphas, so
     the bar ranking reads identically in both themes (issue #1646). */
  --portrait-bar-rest: rgba(28, 26, 21, 0.22);
  --portrait-bar-runner-up: rgba(28, 26, 21, 0.3);
  --portrait-bar-undated: rgba(28, 26, 21, 0.12);
  --portrait-track: rgba(28, 26, 21, 0.1);
  --portrait-spread-fill: rgba(28, 26, 21, 0.35);
  --onboard-tick: rgba(28, 26, 21, 0.13);

  --accent: #267b4c;
  --accent: oklch(0.52 0.11 155);
  --accent-strong: #156f41;
  --accent-strong: oklch(0.48 0.11 155);
  --accent-ink: #faf9f5;
  --accent-dim: rgba(38, 123, 76, 0.1);
  --accent-border: rgba(38, 123, 76, 0.3);
  --accent-border: oklch(0.52 0.11 155 / 0.3);
  --accent-border-strong: rgba(38, 123, 76, 0.55);
  --accent-border-strong: oklch(0.52 0.11 155 / 0.55);
  --accent-text: var(--accent);
  --accent-text-strong: var(--accent-strong);

  /* Action bar on paper: same alphas, but mixed from the light theme's darker
     leaf, so a 10% tint still reads as a tint rather than disappearing. The
     divider inverts — over a dark leaf fill the hairline has to be light —
     and the Wish plate switches to ink-on-paper. */
  /* Locked controls on paper: the same four steps inverted to warm ink, so the
     dashed edge keeps the identical weight relative to its surface. The hover
     label darkens toward --text instead of brightening. */
  --lock-edge: rgba(28, 26, 21, 0.24);
  --lock-edge-hover: rgba(28, 26, 21, 0.38);
  --lock-edge-quiet: rgba(28, 26, 21, 0.18);
  --lock-edge-quiet-hover: rgba(28, 26, 21, 0.3);
  --lock-text-hover: #3d3a33;

  --assert-fill: rgba(38, 123, 76, 0.13);
  --assert-fill: oklch(0.52 0.11 155 / 0.13);
  --assert-edge: rgba(38, 123, 76, 0.6);
  --assert-edge: oklch(0.52 0.11 155 / 0.6);
  --assert-text: #267b4c;
  --assert-text: oklch(0.42 0.1 155);
  --assert-silence-fill: rgba(28, 26, 21, 0.06);
  --assert-silence-edge: rgba(28, 26, 21, 0.2);
  --assert-silence-text: #3d3a33;
  --assert-quiet: #8a8375;
  --assert-hairline: rgba(28, 26, 21, 0.06);

  --bar-tint: rgba(38, 123, 76, 0.1);
  --bar-tint: oklch(0.52 0.11 155 / 0.1);
  --bar-tint-hover: rgba(38, 123, 76, 0.16);
  --bar-tint-hover: oklch(0.52 0.11 155 / 0.16);
  --bar-tint-open: rgba(38, 123, 76, 0.18);
  --bar-tint-open: oklch(0.52 0.11 155 / 0.18);
  --bar-edge: rgba(38, 123, 76, 0.42);
  --bar-edge: oklch(0.52 0.11 155 / 0.42);
  --bar-edge-hover: rgba(38, 123, 76, 0.55);
  --bar-edge-hover: oklch(0.52 0.11 155 / 0.55);
  --bar-edge-open: rgba(38, 123, 76, 0.6);
  --bar-edge-open: oklch(0.52 0.11 155 / 0.6);
  --bar-divider: rgba(250, 249, 245, 0.32);
  --bar-wish-on: rgba(28, 26, 21, 0.08);
  --bar-wish-on-hover: rgba(28, 26, 21, 0.13);
  --bar-wish-on-edge: rgba(28, 26, 21, 0.4);

  /* Brass darkens well past the spec's fill value so brass *text* keeps AA
     contrast on the paper background. */
  --brass: #8a6a1e;
  --brass: oklch(0.55 0.1 85);
  /* On paper the plain brass lands at 4.66:1 on the page and 4.38:1 over a
     hovered row — under AA for small text. This one steps down to ~5.4:1 even
     hovered, so brass micro-labels stay legible (issue #829). */
  --brass-text: #7d5e07;
  --brass-text: oklch(0.5 0.1 85);
  --brass-dim: rgba(193, 160, 87, 0.16);
  --brass-border: rgba(138, 106, 30, 0.4);

  /* Chart series 2 on paper steps lighter instead of darker, so the leaf
     accent stays the stronger of the pair in both themes. */
  --chart-2: #429e68;
  --chart-2: oklch(0.63 0.12 155);

  --danger: #b3402a;
  --danger-dim: rgba(179, 64, 42, 0.08);
  --danger-border: rgba(179, 64, 42, 0.35);
  --success: #267b4c;
  --success: oklch(0.52 0.11 155);

  --select-caret: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='4.5 7.5 11 6'%3E%3Cpath d='M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z' fill='%236f6a5e'/%3E%3C/svg%3E");

  --header-tint: rgba(250, 249, 245, 0.88);
  --shadow-panel: 0 12px 32px rgba(30, 26, 18, 0.14);
  --row-hover: rgba(28, 26, 21, 0.03);

  color-scheme: light;
}

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

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

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

body {
  margin: 0;
  /* svh, not dvh (mobile layer spec §06): dvh reflows the page every time
     mobile browser chrome hides. The vh line is the fallback for engines
     without small-viewport units. */
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  background-color: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  font-size: var(--fs-body);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

/* Page & game titles: Newsreader 500, tight tracking (spec 03). */
h1,
h2,
h3,
h4 {
  font-family: var(--font-display);
  font-weight: 500;
  line-height: 1.1;
  letter-spacing: -0.015em;
  margin: 0 0 0.5em;
  text-wrap: balance;
}

h1 {
  font-size: clamp(1.9rem, 1.3rem + 2.2vw, 2.5rem);
}

h2 {
  font-size: 1.5rem;
}

h3 {
  font-size: 1.15rem;
}

p {
  margin: 0 0 1rem;
}

a {
  color: var(--accent-text);
  text-decoration: none;
  transition: color var(--speed) ease;
}

a:hover {
  color: var(--accent-text-strong);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* In-text links (issue #528, WCAG 1.4.1): inside prose, color alone must not
   be what marks a run of words as a link, so links in text blocks carry a
   persistent underline — a quiet accent-tinted hairline that goes full
   strength on hover. Standalone links (nav, buttons, controls) keep the
   underline-free base treatment above. `li a` alone would also catch the
   <li>-built menus — header nav, the mobile/account dropdown panels,
   pagination — which the spec keeps underline-free (nav marks its active
   item with a border, never a text underline), so those are excluded here;
   axe's link-in-text-block rule only concerns links surrounded by prose. The
   shelves view's cover rows (issue #717) are cover tiles in an <ul>, not
   prose, so they stay underline-free too — as are the filter chips, which are
   bordered controls in a <ul> and which the design draws with no underline at
   all. (`.chip--removable` carries its own `text-decoration: none`, but a lone
   class loses to this selector's two type selectors plus its :not() argument,
   so the exclusion has to happen HERE.)

   The account page's settings rows are excluded for the same reason the shelf
   rows are: a row is a bordered navigation target carrying a title, a sub-line,
   a status chip and an arrow — not a link inside a paragraph — and design
   "Account Refined" draws it with no underline at all. Adding the argument
   costs no specificity: :not() takes the highest of its arguments, and
   `.settings-rows a` (0,1,1) ties the ones already there.

   `.head-meta-import` is excluded from the `p a` arm for the chips' reason,
   and the symptom is worth naming: the collection head's mono stats line is a
   <p>, so this rule reached the IMPORT › link inside it and beat the link's
   own `text-decoration: none` (a lone class against two type selectors plus a
   :not() argument). Because that link is `display: inline-flex` — it is a 44px
   tap target, not a run of words — the decoration drew TWICE: once propagated
   into each blockified flex item (a line under "Import", a second under "›",
   broken across the 5px gap between them) and once across the whole atomic
   inline box. Design "Collection Grid Spec" 06 §C draws exactly one rule, a
   1px leaf hairline under the words, which `.head-meta-import-label` carries
   as a border-bottom of its own.

   `.card-bar-main` and `.bar-menu-row` join the list for the same reason
   (design "Catalog Action Bar Spec" 09): both grid cards are <li>, and the
   bar's "Edit copy" primary and its <a> menu rows are inline-flex targets, not
   runs of prose — an underline there is the same double-painted artefact.
   `.rec-bar-main` is that same "Edit copy" primary one design later, on the
   recommendation ledger's rows, which are <li> too (spec 10).

   `.region-suppressed-out` is the same shape again (issue #1271): the catalog
   readout's "Show all regions" is a <p>-borne mono line's 44px target, so it
   would have drawn the identical double underline — one propagated into the
   flex item, one across the 44px box, the latter a half-line below the words.
   `.region-suppressed-out-label` carries the design's single hairline instead.

   `.ref-platform` is the shelves-cover-row case exactly (spec 17, issue
   #1220): the detail page's platform rows are two-line targets in a <ul>, not
   runs of prose, and being flex columns they would take the decoration TWICE —
   once into each blockified line, once across the box. They are not
   colour-marked either (the row takes the panel's text colour, not the accent),
   so nothing here relies on colour alone; hover fill and the focus outline are
   what mark them, as with every other control row in this list.

   Note the exclusions cost NO specificity: `:not()` takes that of its most
   specific argument, and `.btn` already sets it at one class. */
p a:not(.btn, .head-meta-import, .region-suppressed-out),
li a:not(
    .btn,
    nav a,
    .nav-panel a,
    .account-panel a,
    .shelf-row a,
    .settings-rows a,
    .chip--removable,
    .import-hist-open,
    .entry-menu-row,
    .card-bar-main,
    .bar-menu-row,
    .rec-bar-main,
    .ref-platform
  ),
dd a,
small a,
.footer-attribution a {
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  text-decoration-color: color-mix(in srgb, currentColor 55%, transparent);
}

p a:not(.btn, .head-meta-import, .region-suppressed-out):hover,
li a:not(.btn, nav a, .nav-panel a, .account-panel a, .settings-rows a, .chip--removable, .import-hist-open, .ref-platform):hover,
dd a:hover,
small a:hover,
.footer-attribution a:hover {
  text-decoration-color: currentColor;
}

/* The footer's IGDB credit tightens the same way the nav underline does
   (refined spec): the same accent-tinted hairline, pulled up against the text
   instead of standing 3px clear of it. Ties the rule above on specificity, so
   this must stay after it. */
.footer-attribution a {
  text-underline-offset: 1px;
}

small {
  font-size: 0.85em;
  color: var(--text-muted);
}

/* Inline code chips (spec 05.5: scopes and endpoints render as quiet mono chips). */
code {
  font-family: var(--font-mono);
  font-size: 0.82em;
  background: var(--chip);
  border-radius: 3px;
  padding: 0.08em 0.45em;
}

strong {
  color: var(--text);
}

hr {
  border: 0;
  border-top: 1px solid var(--border);
  margin: 2rem 0;
}

::selection {
  background: var(--accent);
  color: var(--accent-ink);
}

:focus-visible {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
  border-radius: 2px;
}

/* Accessibility utilities (issue #468).
   .sr-only removes an element from the visual layout while keeping it in the
   accessibility tree (used for the skip link's off-screen resting state and any
   screen-reader-only text). */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Skip link (WCAG 2.4.1 Bypass Blocks): the first focusable element in <body>,
   pulled off-screen until it receives keyboard focus, then it slides into the
   top-left so a keyboard/SR user can jump straight to <main>. */
.skip-link {
  position: fixed;
  top: 0.5rem;
  left: 0.5rem;
  z-index: 1000;
  padding: 0.5rem 0.9rem;
  background: var(--surface);
  color: var(--accent-text);
  border: 1px solid var(--accent-text);
  border-radius: 4px;
  font-weight: 600;
  text-decoration: none;
  transform: translateY(-200%);
}
.skip-link:focus {
  transform: translateY(0);
}

/* Programmatic focus targets (skip-link's <main>, the results region after a
   paginated swap) are focused by script, never tabbed to — suppress the ring so
   moving focus there doesn't box the whole region. */
[tabindex="-1"]:focus {
  outline: none;
}

/* Mono micro-label used for eyebrows, stat captions, table headers (spec 03:
   labels 10–11px, +8–14% tracking, often muted). */
.eyebrow {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--accent-text);
}

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

/* Three rails, not one (refined Home spec). The chrome frames the content by
   sitting OUTSIDE it, and each band has its own rail:

     header   full-bleed — one gutter in from the viewport edge (widest)
     footer   the content rail plus one gutter on each side
     content  --container INCLUDING its gutters (narrowest)

   `box-sizing: border-box` is what makes --container the OUTER measure: at
   76rem/48px that leaves 1120px of content, and the footer's rail lands
   exactly on this container's outer edge. Measured against the spec at a
   1704px viewport: header 48→1656, footer 244→1460, content 292→1412. */
.container {
  box-sizing: border-box;
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

main.container {
  flex: 1;
  padding-block: clamp(1.5rem, 4vw, 2.5rem) 4rem;
}

/* Site footer (spec 04): hairline top, single line, no background. Three
   zones — wordmark / attribution / nav — share one row and wrap to centered
   stacked rows on narrow screens. */
.site-footer {
  border-top: 1px solid var(--border);
  padding-block: 1.1rem 1.25rem;
}

/* The footer rail sits one gutter outside the content rail, so the wordmark
   and the Privacy/Terms/API links hang past the column they close off (see
   the three-rail note on .container). */
.site-footer .container {
  max-width: calc(var(--container) + var(--gutter) * 2);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem 1.75rem;
}

/* The footer wordmark drops to muted — only ".games" keeps leaf. */
.footer-brand {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 500;
  font-size: 1rem;
  letter-spacing: -0.01em;
  color: var(--text-muted);
  white-space: nowrap;
}

.footer-brand .tld {
  font-style: normal;
  color: var(--accent-text);
}

.footer-brand:hover {
  color: var(--text-muted);
  text-decoration: none;
}

.footer-attribution {
  flex: 1;
  min-width: 18rem;
  text-align: center;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  color: var(--text-faint);
}

/* The IGDB credit is one contracted sentence (Commercial Usage Addendum §4,
   issue #1232), so the handheld break has to land BETWEEN the two credits —
   and no measure can do that with a single text run: keeping "Games" off the
   Wikidata line needs <38ch, while fitting "Games metadata is powered by
   IGDB.com ↗" on one line needs ≥39ch. inline-block resolves it structurally:
   the sentence is atomic for line PLACEMENT (it moves to the next line whole)
   yet still wraps internally on a viewport too narrow for it at all (≤320px),
   so it never forces a horizontal scroll. Mirrored in _error_shell.html. */
.footer-attribution-igdb {
  display: inline-block;
}

.footer-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.15rem;
  font-size: var(--fs-ui-sm);
}

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

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

/* The footer, on a phone (Mobile Error Screens spec 1C, option A "the
   colophon"): one desktop row does three jobs, and wrapped-to-center it did
   none of them — the attribution centred against a left wordmark and broke
   mid-phrase, the links inherited a centring meant for flex: 1, and no target
   was 44px. Stacked on one left edge instead: wordmark, attribution in a 42ch
   measure (so it breaks after a "·", never mid-phrase — flex: 1 +
   text-align: center is exactly the rule that produced the ragged wrap),
   then the legal links in a 44px row behind a hairline. --fs-mono already
   carries the attribution to the 13px floor here; muted, not faint, because
   13px reading matter needs the contrast. Bottom padding clears the home
   indicator; >720 keeps today's row untouched. */
@media (max-width: 720px) {
  .site-footer {
    padding-block: 18px calc(20px + env(safe-area-inset-bottom));
  }

  .site-footer .container {
    flex-direction: column;
    align-items: flex-start;
    gap: 12px;
  }

  .footer-attribution {
    flex: none;
    min-width: 0;
    /* 42ch, not 34ch: the measure now only has to hold the longer of the two
       credits on one line (the 39ch IGDB sentence, plus slack for the "↗",
       which falls back out of the mono face) — .footer-attribution-igdb
       places the break, so a wider box can no longer pull words up. */
    max-width: 42ch;
    text-align: left;
    line-height: 1.55;
    /* Tracking off: ch ignores letter-spacing, so 42 tracked characters
       overflow a 42ch box and the line breaks a word early — the desktop
       0.04em costs ~21px across this line. */
    letter-spacing: normal;
    color: var(--text-muted);
  }

  .footer-nav {
    width: 100%;
    margin-top: 2px;
    border-top: 1px solid var(--border);
    gap: 22px;
    font-size: var(--fs-ui);
  }

  .footer-nav a {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
}

/* ---------------------------------------------------------------------------
 * Header + navigation
 * ------------------------------------------------------------------------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  background: var(--header-tint);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--border);
}

/* The header is full-bleed: the wordmark sits one gutter from the viewport
   edge and the tools cluster one gutter from the right, both well outside the
   content rail (see the three-rail note on .container). */
.site-header .container {
  max-width: none;
  display: flex;
  align-items: center;
  gap: 1rem;
  min-height: var(--header-h);
}

/* Wordmark (spec 01): always lowercase, italic Newsreader 500 for "collected",
   upright for ".games"; the ".games" takes the leaf accent, never the name.
   Never letter-spaced, never bolded beyond 500. */
.brand {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 500;
  font-size: 1.2rem;
  letter-spacing: -0.01em;
  color: var(--text);
  white-space: nowrap;
}

.brand .tld {
  font-style: normal;
  color: var(--accent-text);
}

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

/* Right side of the header: theme toggle, account menu, mobile menu. The nav
   links sit outside this group, left-aligned beside the wordmark (spec 05
   headers), so only the controls get pushed to the right edge. */
.header-tools {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-left: auto;
}

/* Anonymous session controls (Log in + Register), sitting where .account-menu
   sits when signed in — so the theme toggle keeps the same position in both
   states. Hidden at handheld, where the hamburger panel carries its own copy,
   mirroring .site-header .nav-links below. Outside .nav-links the Register
   pill needs no specificity restatement: .btn-primary (0,1,0) already outranks
   the bare `a` colour rule. */
.header-auth {
  display: flex;
  align-items: center;
  gap: 0.35rem;
}

.header-auth a:not(.btn) {
  padding: 0.45rem 0.7rem;
  color: var(--text-muted);
  font-size: var(--fs-ui);
  font-weight: 500;
}

.header-auth a:not(.btn):hover {
  color: var(--text);
  text-decoration: none;
}

@media (max-width: 720px) {
  .header-auth {
    display: none;
  }
}

/* {% icon %} output (apps/web/templatetags/icons.py): a Heroicons <svg> with
   fill="currentColor", so it takes the color of the span it sits in the way
   the Unicode glyphs it replaced did. Block display kills the inline baseline
   gap that would otherwise open under it inside its 20px icon slot. */
svg.icon {
  display: block;
  flex-shrink: 0;
}

/* An icon riding in running text or a control label ("← Account", "Next →",
   a toast's leading ✓): inline so it keeps the line box, em-sized so it tracks
   the surrounding type (overriding the width/height attributes), and seated
   just below the baseline — with Heroicons' ~3px of 20-box padding that puts
   the ink's optical middle at the cap band's, where the text glyph it
   replaced sat. */
svg.icon-text {
  display: inline-block;
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
}

/* The stroke-drawn state of a solid icon: the boundary of a 20/solid
   silhouette IS its outline, so tracing it draws the hollow variant (the
   not-yet-wishlisted ♡ beside the wishlisted ♥) without importing a second
   icon style into the registry — which stays one fill-drawn vocabulary
   (test_sheet_icon_vocabulary). 1.5 user units on the 20-box matches the
   optical weight of Heroicons' own outline set. */
svg.icon-outline {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
}

/* Icon-only header button: round ghost treatment (the spec's header controls
   are circular). The icon reflects the current theme *mode* (data-theme-mode,
   set by the bootstrap script): half-circle for auto, sun for light, moon for
   dark. */
.theme-toggle {
  width: 2.2rem;
  height: 2.2rem;
  padding: 0;
  background: transparent;
  border: 1px solid var(--border-strong);
  border-radius: 50%;
  color: var(--text-muted);
}

.theme-toggle:hover {
  background: var(--fill);
  border-color: var(--accent-text);
  color: var(--accent-text);
}

.theme-toggle svg {
  display: none;
}

html[data-theme-mode="auto"] .theme-toggle .icon-auto,
html[data-theme-mode="light"] .theme-toggle .icon-sun,
html[data-theme-mode="dark"] .theme-toggle .icon-moon {
  display: block;
}

/* The button rule's display: inline-flex outweighs the UA sheet's [hidden]
   rule — the trap .platforms-discard[hidden] records. base.html ships the
   toggle hidden and only the theme bootstrap reveals it, so without this a
   no-JS page shows a dead control: an empty circle when nothing sets
   data-theme-mode, an iconed-but-inert button when the server renders a
   stored preference. */
.theme-toggle[hidden] {
  display: none;
}

.nav-links {
  display: flex;
  align-items: center;
  gap: 0.2rem;
  list-style: none;
  /* Extra offset from the wordmark on top of the container gap (spec 05
     headers give the link group its own margin beyond the row gap). */
  margin: 0 0 0 0.75rem;
  padding: 0;
}

/* Flex-center each item so the account button doesn't sit on a shifted
   baseline next to the text links. */
.nav-links li {
  display: flex;
  align-items: center;
}

.nav-links a {
  position: relative;
  display: inline-block;
  padding: 0.45rem 0.7rem 0.35rem;
  color: var(--text-muted);
  font-size: var(--fs-ui);
  font-weight: 500;
}

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

/* Active nav link: full text colour + a leaf underline that HUGS the label.
   The refined spec draws the rule hard against the bottom of the text and no
   wider than the text; the old transparent border-bottom ran the anchor's
   full padded width and sat a further 0.35rem below it.

   Insetting the pseudo-element by the anchor's own horizontal padding is what
   trims it to the text — the padding is symmetric and the inline-block
   shrink-wraps, so left/right: 0.7rem is exactly the label's width. It is an
   ::after rather than text-decoration for two reasons: skip-ink would notch
   the rule around the descender in "Catalog", and nav keeps its
   no-text-underline convention (see the prose-underline exclusion list above,
   guarded by tests/ui/test_home.py). The padding is left intact so the
   pointer target does not shrink with the underline.

   Declared after :hover, which it ties on specificity (0,2,1) — source order
   is what stops a hovered active link from losing its underline. */
.nav-links a[aria-current="page"] {
  color: var(--text);
}

.nav-links a[aria-current="page"]::after {
  content: "";
  position: absolute;
  left: 0.7rem;
  right: 0.7rem;
  bottom: 0.2rem;
  height: 2px;
  background: var(--accent);
}

/* The anonymous desktop nav's Register pill: `.nav-links a` above is more
   specific than `.btn-primary`, so its --text-muted label would win — a
   washed-out grey on the ivory fill at rest, and on hover `.nav-links a:hover`
   repaints the label --text (the same ivory as the fill), erasing it. Restate
   the primary label treatment at nav specificity, the desktop twin of the
   .nav-menu .nav-panel a.btn-primary fix below. */
.nav-links a.btn-primary {
  color: var(--bg);
}

.nav-links a.btn-primary:hover {
  color: var(--bg);
}

.nav-links form {
  margin: 0;
}

/* Account dropdown (issue #150): one avatar button gathers the signed-in
   user's session controls — email, account settings, connect an agent, log out
   — behind a CSS-only <details>. Mirrors the .nav-menu disclosure beside it.

   ⚠️ This is the account surface at EVERY width — it is NOT hidden at handheld.
   Design Spec §05 "Site header / nav" draws the ≤720 header as wordmark + theme
   toggle + account + ☰, and sizes "theme toggle and account" to 44px at
   (max-width: 720px), (pointer: coarse) — see the touch-target block. Only the
   nav LINKS collapse into the hamburger panel.

   This comment used to claim the opposite ("hidden below the mobile
   breakpoint, where the hamburger carries the same rows"). The hide it
   described was never written, while _nav_links.html emitted the rows into the
   panel on that promise — so signed-in phones showed all four twice
   (issue #1178). The panel copy is the one that went; do not add the hide. */
.account-menu {
  position: relative;
}

/* Avatar trigger: a round ghost button matching the theme toggle, with a
   chevron that signals the menu and flips when open. */
.account-menu > summary {
  list-style: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.25rem;
  height: 2.2rem;
  padding: 0 0.55rem;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  color: var(--text-muted);
}

.account-menu > summary::-webkit-details-marker {
  display: none;
}

.account-menu > summary:hover,
.account-menu[open] > summary {
  background: var(--fill);
  border-color: var(--accent-text);
  color: var(--accent-text);
}

.account-menu .account-caret {
  transition: transform var(--speed) ease;
}

.account-menu[open] .account-caret {
  transform: rotate(180deg);
}

.account-menu .account-panel {
  position: absolute;
  right: 0;
  top: calc(100% + 0.6rem);
  /* Grow to fit the widest row (issue #190: the signed-in email) rather than
     wrapping it inside a fixed width. Clamped between a comfortable minimum and
     a cap so the panel never runs off the viewport; an over-long email gets an
     ellipsis via .account-email below. Anchored right: 0, so it expands left. */
  width: max-content;
  min-width: 14rem;
  max-width: min(26rem, calc(100vw - 2rem));
  padding: 0.4rem;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: var(--shadow-panel);
  list-style: none;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}

.account-menu .account-panel a {
  padding: 0.55rem 0.75rem;
  border-radius: var(--radius-sm);
  color: var(--text);
  font-size: var(--fs-ui);
}

.account-menu .account-panel a:hover {
  background: var(--fill);
  text-decoration: none;
}

.account-menu .account-panel form {
  margin: 0;
}

/* The log-out control reads as a full-width menu row, sized to match the link
   rows above it rather than the compact .btn-sm pill it ships elsewhere.
   Geometry alone doesn't get it there: .btn-ghost's own border-color and
   muted color survive per-property, drawing a boxed, dimmed control that
   reads as disabled among plain rows — the same reasoning as the hamburger
   panel's button rule. */
.account-menu .account-panel .btn-ghost {
  width: 100%;
  justify-content: flex-start;
  padding: 0.55rem 0.75rem;
  font-size: var(--fs-ui);
  border-radius: var(--radius-sm);
  border: 0;
  background: none;
  color: var(--text);
}

.account-menu .account-panel .btn-ghost:hover {
  background: var(--fill);
}

/* Account-settings / connect-an-agent rows (icon + text). */
.account-link {
  display: flex;
  align-items: center;
  gap: 0.55rem;
}

/* Signed-in email: a non-interactive caption heading the account rows. The
   panel widens to fit it (issue #190), so keep it on one line and only truncate
   with an ellipsis once it hits the panel's max-width; the full address stays
   available via the trigger's title tooltip.

   The .account-menu dropdown is the only render site (issue #1178 removed the
   hamburger-panel copy), so this is one rule — it used to be a break-all base
   for the fixed-width mobile panel plus a nowrap override for the dropdown. */
.account-email {
  padding: 0.35rem 0.75rem 0.5rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Mobile menu: a CSS-only <details> disclosure that drops a panel below the
   header. Hidden on desktop, where the inline .nav-links list shows instead. */
.nav-menu {
  position: relative;
  display: none;
}

.nav-menu > summary {
  list-style: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.2rem;
  height: 2.2rem;
  border: 1px solid var(--border-strong);
  border-radius: 50%;
  color: var(--text);
}

.nav-menu > summary::-webkit-details-marker {
  display: none;
}

.nav-menu[open] > summary {
  background: var(--fill);
}

/* Hamburger closed, ✕ open — the summary is the panel's close control. */
.nav-menu .nav-icon-close,
.nav-menu[open] .nav-icon-open {
  display: none;
}

.nav-menu[open] .nav-icon-close {
  display: block;
}

/* The open menu is a full-screen surface, not an anchored dropdown: it fills
   everything below the header, so the search row gets the whole screen width
   and the links become full-width list rows. The header stays put above it —
   the hamburger is the panel's close control (it swaps to an ✕), which is
   what keeps this a CSS-only <details> with no extra dismissal wiring. */
.nav-menu .nav-panel {
  position: fixed;
  inset: calc(var(--header-h) + 1px) 0 0 0;
  z-index: 60;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* 2B draws the open menu on the page background, with the search field's
     --surface reading as a raised control against it. */
  background: var(--bg);
  list-style: none;
  margin: 0;
  padding: 16px max(var(--gutter), env(safe-area-inset-right))
    calc(24px + env(safe-area-inset-bottom))
    max(var(--gutter), env(safe-area-inset-left));
  display: flex;
  flex-direction: column;
  gap: 0;
}

/* 52px rows at 16px (2B), flush to the panel's gutter so each hairline spans
   the full inner width the way the mock draws it. */
.nav-menu .nav-panel a {
  display: flex;
  align-items: center;
  min-height: 52px;
  padding: 0.55rem 0;
  border-radius: var(--radius-sm);
  color: var(--text);
  font-size: var(--fs-body);
}

/* Hairline above every row — 2B gives all the links a top rule, including the
   first one under the search field. The separator rides the row, not the link,
   so the opt-out below can key off the *preceding* sibling and still
   out-specify it: the Register pill stays a pill rather than growing a rule
   across its top.

   The account-divider and email-caption opt-outs went with the panel's account
   rows (issue #1178) — the panel is nav links only now, plus the signed-out
   Log in / Register pair. */
.nav-menu .nav-panel > li + li {
  border-top: 1px solid var(--border);
}

.nav-menu .nav-panel > li + li:has(> .btn-primary) {
  border-top: 0;
}

.nav-menu .nav-panel a:hover {
  background: var(--fill);
  text-decoration: none;
}

/* The anonymous menu's Register pill: the row rules above would paint its
   label the same ivory as the .btn-primary fill (and hover would strip the
   fill from under it), leaving the text invisible — restate the primary
   treatment at panel specificity. */
.nav-menu .nav-panel a.btn-primary {
  color: var(--bg);
  justify-content: center;
  margin-top: 12px;
}

.nav-menu .nav-panel a.btn-primary:hover {
  background: var(--text);
  color: var(--bg);
  opacity: 0.9;
}

.nav-menu .nav-panel form {
  margin: 0;
}

/* The pre-launch gate's Log out (_nav_links.html's content-free branch): the
   same full-width 52px row as every link above it. The search row's Go submit
   is excluded — it sits inside the field, not on its own row. Ungated users log
   out from the .account-menu dropdown, not from here (issue #1178). */
.nav-menu .nav-panel button:not(.nav-search-go) {
  width: 100%;
  min-height: 52px;
  justify-content: flex-start;
  text-align: left;
  padding-inline: 0;
  font-size: var(--fs-body);
  /* .btn-ghost's border would make the one button in the panel read as a boxed
     control among plain rows — 2B draws every panel row the same. */
  border: 0;
  background: none;
  color: var(--text);
}

.nav-menu .nav-panel button:not(.nav-search-go):hover {
  background: var(--fill);
}

@media (max-width: 720px) {
  .site-header .nav-links {
    display: none;
  }

  .nav-menu {
    display: block;
  }

  /* ⚠️ backdrop-filter makes .site-header the containing block for every
     position: fixed descendant — the full-screen .nav-panel nested inside it
     would otherwise be trapped in the header's own 3.6rem box. While the menu
     is open the blur has nothing left to blur (the panel covers the page), so
     dropping it hands the panel the viewport as its containing block. The
     raised z-index keeps the panel over the page-level pickers (90).

     Both rules — and the scroll lock — are scoped to the handheld stop on
     purpose: above 720 the .nav-menu is display: none but its [open] state
     survives, so an unscoped :has() would strand a resized-to-desktop page
     with a locked body and a mangled header. */
  .site-header:has(.nav-menu[open]) {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    z-index: 100;
  }

  /* The page behind the menu holds still (the filter sheet's rule, §05A). */
  body:has(.nav-menu[open]) {
    overflow: hidden;
  }
}

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

button,
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.45rem;
  padding: 0.55rem 1.15rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  font-family: var(--font-body);
  font-weight: 500;
  font-size: var(--fs-ui);
  line-height: 1.3;
  cursor: pointer;
  text-decoration: none;
  transition:
    background var(--speed) ease,
    border-color var(--speed) ease,
    color var(--speed) ease,
    opacity var(--speed) ease,
    transform var(--speed) ease;
}

button:hover,
.btn:hover {
  background: var(--fill);
  border-color: var(--border-strong);
  color: var(--text);
  text-decoration: none;
}

button:active,
.btn:active {
  transform: translateY(1px);
}

/* Primary neutral (spec 04): text-color fill — the default emphasis everywhere
   except add-to-collection actions, which use the leaf .btn-accent below. */
button[type="submit"],
.btn-primary {
  background: var(--text);
  border-color: var(--text);
  color: var(--bg);
  font-weight: 600;
}

button[type="submit"]:hover,
.btn-primary:hover {
  background: var(--text);
  border-color: var(--text);
  color: var(--bg);
  opacity: 0.9;
}

/* Leaf fill (spec 04): "＋ Add to collection" and other ownership actions.
   Declared after the submit styling so it wins on submit buttons too. */
.btn-accent,
button.btn-accent {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
  font-weight: 600;
}

.btn-accent:hover,
button.btn-accent:hover {
  background: var(--accent-strong);
  border-color: var(--accent-strong);
  color: var(--accent-ink);
  opacity: 1;
}

/* Quiet button: hairline outline, no fill. Wins over the submit styling above
   so secondary submit-buttons (e.g. "Log out", "Remove") stay quiet. */
.btn-ghost,
button.btn-ghost {
  background: transparent;
  border-color: var(--border-strong);
  color: var(--text-muted);
  font-weight: 500;
}

.btn-ghost:hover,
button.btn-ghost:hover {
  border-color: var(--accent-text);
  color: var(--accent-text);
  background: var(--accent-dim);
  opacity: 1;
}

/* Destructive button: danger-tinted outline. Wins over the submit styling so
   destructive submit-buttons (e.g. "Delete account") read as warnings. */
.btn-danger,
button.btn-danger {
  background: transparent;
  border-color: var(--danger);
  color: var(--danger);
  font-weight: 600;
}

.btn-danger:hover,
button.btn-danger:hover {
  background: var(--danger-dim);
  border-color: var(--danger);
  color: var(--danger);
  opacity: 1;
}

.btn-sm,
button.btn-sm {
  padding: 0.28rem 0.65rem;
  font-size: var(--fs-ui-sm);
  border-radius: 3px;
}

/* ---------------------------------------------------------------------------
 * Forms
 * ------------------------------------------------------------------------- */

label {
  display: block;
  margin-bottom: 1rem;
  font-size: var(--fs-ui);
  font-weight: 500;
  color: var(--text-muted);
}

input,
select,
textarea {
  font-family: var(--font-body);
  font-size: var(--fs-field);
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  padding: 0.5rem 0.75rem;
  width: 100%;
  margin-top: 0.35rem;
  transition: border-color var(--speed) ease, background var(--speed) ease;
}

input::placeholder {
  color: var(--text-faint);
}

/* Focused fields take the accent border in every case; the focus-ring outline is
   dropped only for pointer focus (:focus without :focus-visible). Keyboard focus
   still matches the global :focus-visible rule above and keeps its outline —
   without the :not(), the bare :focus here (higher specificity) stripped the ring
   for keyboard users too, failing WCAG 2.4.7 Focus Visible (issue #468). */
input:focus,
select:focus,
textarea:focus {
  border-color: var(--accent-text);
}
input:focus:not(:focus-visible),
select:focus:not(:focus-visible),
textarea:focus:not(:focus-visible) {
  outline: none;
}

select {
  appearance: none;
  background-image: var(--select-caret);
  background-repeat: no-repeat;
  background-position: right 0.7rem center;
  padding-right: 2rem;
}

input[type="checkbox"] {
  width: 0.9rem;
  height: 0.9rem;
  margin: 0;
  accent-color: var(--accent);
}

/* Inline checkbox label (e.g. "Show unreleased games"). */
label.check {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0;
  font-size: var(--fs-ui-sm);
  cursor: pointer;
}

.field-error {
  display: block;
  margin: -0.5rem 0 1rem;
  color: var(--danger);
  font-size: var(--fs-ui);
}

.help-text {
  display: block;
  margin: -0.5rem 0 1rem;
  color: var(--text-faint);
  font-size: var(--fs-ui-sm);
}

/* ---------------------------------------------------------------------------
 * Cards
 * ------------------------------------------------------------------------- */

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: clamp(1.25rem, 3vw, 1.75rem);
  margin-bottom: 1.5rem;
}

.card > header {
  margin-bottom: 1rem;
}

.card > footer {
  margin-top: 1.25rem;
  padding-top: 1.25rem;
  border-top: 1px solid var(--border);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem;
}

/* Narrow centered card for auth and other single-purpose forms. */
.auth-card {
  max-width: 26rem;
  margin: clamp(1rem, 6vh, 4rem) auto 0;
}

.auth-card h1 {
  font-size: 1.6rem;
}

/* "Back to …" text link under a form: inline-block with vertical padding so
   the touch target clears WCAG 2.2 AA target-size (24px) even right below a
   submit button (issue #468's axe gate flagged the bare inline link). */
.back-link {
  display: inline-block;
  padding-block: 0.4rem;
}

/* Wide centered card for the account-adjacent management pages (API tokens,
   connected agents). */
.settings-card {
  max-width: 56rem;
  margin: 1.5rem auto 0;
}

/* Their per-row revoke forms sit inside table cells; no form margins so the
   rows stay tight. */
.settings-card table form {
  margin: 0;
}

/* Social login (issue #198): provider buttons under the auth forms. */
.social-login {
  margin: 1rem 0;
}

.social-login form {
  margin: 0 0 0.5rem;
}

.social-login button {
  width: 100%;
}

/* "── or ──" rule between the password form and the provider buttons. */
.social-login-divider {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin: 0 0 1rem;
}

.social-login-divider::before,
.social-login-divider::after {
  content: "";
  flex: 1;
  border-top: 1px solid var(--border);
}

/* Connected third-party accounts on the connections page. */
.connections-list {
  list-style: none;
  padding: 0;
}

.connections-list li {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.5rem 0;
  border-bottom: 1px solid var(--border);
}

.connections-list li form {
  margin: 0 0 0 auto;
}

/* ---------------------------------------------------------------------------
 * My account (design "Account Refined", on spec 05.6): an identity head over
 * grouped settings link rows in two columns. Rust (--danger) is reserved for
 * the danger group — never used for emphasis elsewhere on the page.
 * ------------------------------------------------------------------------- */

/* Identity head: avatar · name + facts · log out. The middle column carries
   min-width: 0 so a long address can shrink it rather than widening the row
   past the viewport (issue #1032). */
.account-head {
  display: flex;
  align-items: flex-start;
  gap: 1.4rem;
  margin-bottom: 2.2rem;
  padding-bottom: 1.9rem;
  border-bottom: 1px solid var(--border-strong);
}

/* The initial is centred on its CAP HEIGHT, not on its line box. This rule used
   to set `line-height` to the disc's own height, which centres the LINE BOX —
   and Newsreader's box is not symmetric about its capitals. Per 2000 upem its
   ascent is 1470 and its descent 530, so the em box's midpoint sits
   (1470 - 530) / 2 = 470 above the baseline while a capital's midpoint sits
   capHeight / 2 = 670 above it: the letter drew 200/2000 = 0.1em high, a
   measured 3.1px at the 30px desktop size and 1.7px at the phone's 20px.

   Flex centring alone does NOT fix it — it centres that same lopsided box — so
   the padding is what puts it right: with the content centred, padding-top P
   lowers it by P / 2, and 0.2em is twice the 0.1em error. Being in em the
   correction rides the font-size, which is why the <=720 rule below restates
   neither it nor a line-height (under flex centring line-height no longer
   places the glyph at all). Residual after the fix: 0.1px at both sizes.

   Recompute if --font-display ever changes: 2 x (capHeight - ascent + descent)
   / upem, all three from the font's own OS/2 table. */
.account-avatar {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 3.75rem;
  height: 3.75rem;
  padding-top: 0.2em;
  margin-top: 0.25rem;
  border-radius: 50%;
  background: var(--brass);
  color: var(--accent-ink);
  font-family: var(--font-display);
  font-size: 1.875rem;
  font-weight: 500;
  line-height: 1;
}

.account-identity {
  flex: 1;
  min-width: 0;
}

/* The facts that used to be Profile rows: address · member since · how you get
   in. Mono caps, except the address — an email is not a label and keeps its
   true case, the same split the old "SIGNED IN AS max@…" head-note made. It is
   also one unbreakable token, so it needs the break-anywhere escape hatch
   (issue #1032). */
.account-facts {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.35rem 0.7rem;
  margin: 0.7rem 0 0;
  min-width: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.account-facts .account-email-fact {
  text-transform: none;
  letter-spacing: 0;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

.account-facts .account-fact-sep {
  color: var(--border-strong);
}

/* The head's alert slot (a bounced or reported address). Level drives the
   border and the dot only: rust when nothing is arriving, brass when the
   consequence is marketing-only — the same severity ordering the email
   settings banner uses. The body stays --text-muted rather than taking the
   level colour: a tint under small text is where contrast quietly fails. */
.account-alert {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.4rem 0.75rem;
  margin-top: 0.9rem;
  padding: 0.55rem 0.85rem;
  border: 1px solid var(--brass-border);
  border-radius: var(--radius);
  background: var(--brass-dim);
}

.account-alert--bad {
  border-color: var(--danger-border);
  background: var(--danger-dim);
}

.account-alert-dot {
  align-self: center;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--brass);
}

.account-alert--bad .account-alert-dot {
  background: var(--danger);
}

.account-alert-label {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--brass-text);
}

.account-alert--bad .account-alert-label {
  color: var(--danger);
}

.account-alert-body {
  flex: 1;
  min-width: 12rem;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  text-wrap: pretty;
}

.account-alert-cta {
  margin-left: auto;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  white-space: nowrap;
  color: var(--brass-text);
}

.account-alert--bad .account-alert-cta {
  color: var(--danger);
}

/* Log out sits at the head's far edge: it acts on the session, not on any of
   the account settings the groups below own. */
.account-head .account-logout {
  flex-shrink: 0;
  margin: 0.4rem 0 0;
}

/* 🚨 The ≤520 repair block that used to sit here is gone (spec 16 §G). It
   wrapped the head and dropped Log out to its own line because the flex line
   could not hold at 320 — the phone head is now drawn, does not wrap, and
   carries no Log out at all, so there is nothing left for it to repair. The
   whole area is keyed to 720 now; 520 appears nowhere in it. */

/* minmax(0, …) rather than a bare 1fr / implicit auto column: a grid track's
   automatic minimum is min-content, so one nowrap descendant (a settings row's
   mono tag) would otherwise widen the column past the viewport and push the
   whole document sideways at 320 (issue #1032). */
.account-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0 4rem;
  align-items: start;
}

@media (min-width: 721px) {
  .account-grid {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  }
}

.settings-group {
  margin-bottom: 2.5rem;
}

/* Group header line: mono kicker, with an optional action ("Edit profile")
   pushed to the right edge. */
.settings-head {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
}

.settings-head .settings-action {
  margin-left: auto;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
}

.settings-label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.3rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* Leaf dot marking the agent-facing group (spec 05.6: AGENTS & API). Hollow
   when nothing is attached, so the dot and the line beside it agree. */
.settings-label .leaf-dot {
  flex-shrink: 0;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
}

.settings-label .leaf-dot--off {
  background: none;
  border: 1px solid var(--text-faint);
}

/* The group header's hairline and its right-hand meta ("342 GAMES", the agent
   status line). Scoped to the account page: .settings-label is also the plain
   kicker on the Connect an agent page, which has no rule under it.

   The hairline moves from .settings-rows' border-top onto the header itself,
   because a group can now open with something other than a row list (Your
   data leads with prose) and the line belongs to the heading either way. */
.account-grid .settings-label {
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.35rem 0.75rem;
  margin-bottom: 0;
  padding-bottom: 0.7rem;
  border-bottom: 1px solid var(--border-strong);
  letter-spacing: 0.14em;
  color: var(--text);
}

.account-grid .settings-label .leaf-dot {
  align-self: center;
}

/* The heading holds its line whole: as an anonymous flex item the bare text
   node would shrink and wrap word-by-word against the meta beside it, which is
   how "AGENTS & API" ended up stacked over two lines at 390 with its status
   line interleaved. Wrapping it in a nowrap span makes the meta the item that
   gives way — it drops to its own line and wraps there instead. */
.settings-label-text {
  flex: 0 0 auto;
  white-space: nowrap;
}

/* 🚨 No nowrap here. The meta carries user-supplied text (an API token's
   name), and a nowrap descendant sets a width floor its grid track cannot go
   under — the exact shape that pans the document sideways at 320 (issue
   #1032). It wraps within its own line instead. */
.account-grid .settings-meta {
  flex: 0 1 auto;
  min-width: 0;
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 400;
  letter-spacing: 0.05em;
  text-align: right;
  overflow-wrap: anywhere;
  color: var(--text-faint);
}

.account-grid .settings-rows {
  border-top: 0;
}

/* NOTE: .data-export and .row-sub--after-action are gone (spec 16 §B). Your
   data was the one group that spoke in prose and a pill button; it is two link
   rows in the group's own grammar now, at every width, so the styles that laid
   the paragraph out beside the button have nothing left to lay out. */

/* Link rows: title (+ optional sub-line and mono status tag) with a quiet
   arrow, hairline separators, strong hairline opening the group. */
.settings-rows {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--border-strong);
}

/* The empty account's lead row (spec 16 §B2): the one action a collection-less
   account actually has, in leaf so it reads as the invitation it is. It is the
   only accent title on the page — everything else in a settings row is ink,
   and rust stays reserved for the danger group. */
.settings-rows .settings-row--lead .row-title {
  color: var(--accent-text);
}

/* A row that leads nowhere yet (the export with nothing to export). Dimmed at
   every width, not just on a phone: the row announces aria-disabled, and a
   desktop row that announces it while looking live is the mismatch. */
.settings-rows a[aria-disabled="true"] {
  opacity: 0.55;
}

.settings-rows a {
  display: flex;
  align-items: center;
  gap: 1.25rem;
  padding: 0.95rem 0.75rem;
  /* The row's hit area and hover tint reach into the page gutter rather than
     stopping at the text, so the whole band lights up. -0.75rem is inside the
     narrowest gutter this page ever has (16px at compact), so the bleed never
     reaches the viewport edge and cannot pan the document. */
  margin-inline: -0.75rem;
  border-radius: var(--radius);
  border-bottom: 1px solid var(--border);
  color: var(--text);
  font-size: var(--fs-ui);
  font-weight: 500;
}

/* Hairlines separate rows; the group's own header carries the line above the
   first, and the last row closes on whitespace. */
.settings-rows li:last-child a {
  border-bottom: 0;
}

.settings-rows a:hover {
  text-decoration: none;
  color: var(--accent-text);
  background: var(--row-hover);
}

/* min-width: 0 so the row can never set a width floor wider than the page;
   `break-word` (NOT `anywhere`) is the last-resort break for a title with no
   break opportunity — `anywhere` would also collapse this item's min-content
   to one character, which the compact rule below sizes against (issue #1032). */
.settings-rows .row-text {
  flex: 1;
  min-width: 0;
  overflow-wrap: break-word;
}

.settings-rows .row-sub {
  display: block;
  margin-top: 2px;
  font-size: var(--fs-ui-sm);
  font-weight: 400;
  color: var(--text-muted);
}

/* Tag and arrow travel as one item. Without the wrapper they are two separate
   flex items, and a row whose text still fits beside its tag could push only
   the arrow onto the next line — which read as an empty row with a stray → in
   it at 390. */
.settings-rows .row-end {
  display: flex;
  align-items: center;
  gap: 1.25rem;
  flex-shrink: 0;
  margin-left: auto;
}

/* Status tags are bordered chips, so a row's state reads as one object rather
   than as a second sentence trailing the title.

   🚨 --text-muted, NOT --text-faint: faint is tuned to clear 4.5:1 against the
   bare page, and a chip puts a tint underneath it. The same swap the shelf
   captions needed (design "Collection Refined") — a tint under small text is
   always worth the arithmetic. */
.settings-rows .row-tag {
  padding: 0.2rem 0.45rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  font-weight: 400;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
  white-space: nowrap;
}

/* 🚨 --accent-text-strong, NOT --accent-text. Measured, not guessed: plain
   leaf on the --accent-dim tint composites to 4.35:1 on paper, which axe fails
   at this size (11px needs 4.5:1 — only ≥18pt / 14pt-bold gets the 3:1 rule).
   The strong step takes it to 5.2:1 on paper and 7.5:1 on ink. Any future chip
   putting accent text on an accent tint owes the same arithmetic. */
.settings-rows .row-tag--on {
  border-color: var(--accent-border);
  background: var(--accent-dim);
  color: var(--accent-text-strong);
}

/* The tag never takes the row's hover colour — it is state, not a link. */
.settings-rows a:hover .row-tag {
  color: var(--text-muted);
}

.settings-rows a:hover .row-tag--on {
  color: var(--accent-text-strong);
}

.settings-rows .row-arrow {
  color: var(--text-faint);
}

/* Compact: a long status tag ("1 SIGN-IN · 0 LIBRARIES") no longer squeezes the
   title into a sliver — the tag and arrow drop to a second line instead, the
   same way the email settings rows already wrap (issue #1032). Sizing the text
   against `min-content` is what makes that conditional: a row whose tag still
   fits (NONE, 1 OF 2 ON) keeps its single line and the text grows to fill it.

   🚨 The 55% floor that used to live here is gone (spec 16 §D). It made the
   wrap conditional — a tag only shared the line when the title could still
   read as a title — but the wrap itself was the defect: the status landed
   under its own description and the arrow two lines below the title it points
   away from. Below 721 the row is a grid now, the status is a column rather
   than a chip on the right edge, and nothing wraps at any width. */

/* The danger group takes rust for its heading and its hairline — which lives
   on the header now, not on the row list's border-top. */
.settings-group--danger .settings-label,
.account-grid .settings-group--danger .settings-label {
  color: var(--danger);
  border-bottom-color: var(--danger-border);
}

.settings-group--danger .settings-rows a:hover {
  background: var(--danger-dim);
  color: var(--danger);
}

.settings-group--danger .settings-rows .row-title {
  color: var(--danger);
}

.settings-group--danger .settings-rows a:hover .row-title {
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* The log-out form lives in the identity head (design "Account Refined"); the
   head's own rule places it. This resets the margin a bare <form> would carry
   into any other context that reuses the class. */
.account-logout {
  margin: 0;
}

/* OAuth 2.1 consent screen (issue #213): the MCP/agent authorization prompt at
   /identity/o/authorize, on the shared auth-card chrome. The consent + error
   templates live in apps/accounts/templates/idp/oidc/. */
.consent-card {
  max-width: 28rem;
}

.consent-head {
  text-align: center;
  margin-bottom: 1.25rem;
}

/* The "c." app-icon mark as a centered focal point (spec 01: the italic "c."
   carries the identity at small sizes; the green period is the constant).
   Fixed ink colors — the tile is the dark app icon in both themes. */
.consent-mark {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin: 0 auto 0.9rem;
  background: #16140f;
  border: 1px solid var(--border-strong);
  border-radius: 9px;
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 500;
  font-size: 1.5rem;
  line-height: 1;
  color: #ece8dd;
}

.consent-mark .tld {
  font-style: normal;
  color: #60bb83;
  color: oklch(0.72 0.12 155);
}

.consent-lede {
  margin: 0.4rem 0 0;
  color: var(--text-muted);
  font-size: var(--fs-body);
}

/* Which account the grant is bound to — shown small and monospaced under the lede. */
.consent-account {
  display: block;
  margin-top: 0.35rem;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
}

.consent-scopes {
  background: var(--fill);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 1rem 1.1rem;
  margin-bottom: 1.25rem;
}

.consent-scopes-title {
  margin: 0 0 0.75rem;
  font-size: var(--fs-ui);
  color: var(--text-muted);
}

.consent-scope-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.65rem;
}

/* Each requested permission: a pre-checked, deselectable row. Overrides the
   block-label defaults so the checkbox sits inline with its description. */
.consent-scope {
  display: flex;
  align-items: flex-start;
  gap: 0.6rem;
  margin: 0;
  color: var(--text);
  font-size: var(--fs-ui);
  font-weight: 400;
  cursor: pointer;
}

.consent-scope input[type="checkbox"] {
  margin-top: 0.15rem;
  flex: none;
}

.consent-scope-text {
  line-height: 1.4;
}

.consent-email {
  margin-bottom: 1.25rem;
}

.consent-actions {
  display: flex;
  gap: 0.75rem;
}

.consent-actions button {
  flex: 1;
}

.consent-foot {
  margin: 1.25rem 0 0;
  text-align: center;
}

/* --- Device grant (RFC 8628) ---------------------------------------------
   The same consent chrome above, for the flow a device with no browser uses:
   the user reads a code off the device, types it here, then confirms. Four
   templates in apps/accounts/templates/idp/oidc/ (code form, confirm, and the
   granted/denied twins) plus the RP-initiated logout page share it; only the
   code itself needs styling of its own.

   Both the entry field and the readout are the same object seen twice — the
   code as the device prints it — so they share the mono/tracking/uppercase
   treatment and differ only in chrome. Uppercasing is display-only and safe
   in both places: allauth compares user codes with case folded and
   punctuation stripped (core.internal.cryptokit.compare_user_code), so what
   the user typed still matches whatever case they typed it in. */
.device-code-field {
  display: block;
  margin-bottom: 1.25rem;
}

/* (0,1,1) — clears the bare `input` base rule without needing a class on the
   widget itself, which allauth's ConfirmCodeForm builds (it sets the
   placeholder and autocomplete="one-time-code"; we do not re-declare them).
   The explicit min-height is because the handheld touch floor is a selector
   list of buttons and links — no bare <input> is named in it, so a field that
   wants 44px has to say so here, at every width. */
.device-code-field input {
  min-height: 48px;
  margin-top: 0.5rem;
  font-family: var(--font-mono);
  font-size: 1.25rem;
  letter-spacing: 0.18em;
  text-align: center;
  text-transform: uppercase;
}

/* The placeholder is prose ("Code"), not a code, so it opts back out of the
   tracking the field carries — otherwise the empty field reads as a stray
   fragment rather than a hint. */
.device-code-field input::placeholder {
  letter-spacing: normal;
  text-transform: none;
}

/* Step 2's echo of the code, framed like the scope list: a quiet panel whose
   job is "check this against the screen in front of you". */
.device-code-check {
  background: var(--fill);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 1rem 1.1rem;
  margin-bottom: 1.25rem;
  text-align: center;
}

.device-code-check-title {
  margin: 0 0 0.5rem;
  font-size: var(--fs-ui);
  color: var(--text-muted);
}

.device-code-readout {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 1.35rem;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text);
  /* The tracking adds a trailing space after the last glyph; the padding
     offsets it so the code still reads as centred in the panel. */
  padding-left: 0.18em;
  overflow-wrap: anywhere;
}

/* ---------------------------------------------------------------------------
 * Your platforms (design "Your Platforms", issue #771): the batch editor for
 * stated platform preferences. A settings form, so it holds the design's
 * narrower measure rather than the site container — and it is the one page
 * with a sticky action bar, because it is the one page where every control
 * defers its effect until Save.
 *
 * The three chips are checkbox+label pairs, not buttons: the state IS form
 * state, so the browser owns it (keyboard, reset, resubmit) and JS only
 * decorates. Own wears leaf and Collect-for wears brass, per the design's
 * legend; Not-interested wears rust because it is the page's one destructive
 * statement, matching the account page's reservation of --danger.
 * ------------------------------------------------------------------------- */

/* Centred, like every other account-settings column (.tokens-page,
   .connections-page). Left-aligned it sat 160px off the save bar, which was
   centred on the viewport instead — the two rails never met. */
.platforms-page {
  max-width: 55rem;
  margin-inline: auto;
}

.platforms-legend {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.75rem 1.25rem;
  padding-bottom: 1rem;
  border-bottom: 1px solid var(--border);
}

.platforms-key {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.25rem;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--fs-ui-sm);
}

.platforms-key li {
  display: flex;
  align-items: center;
  gap: 0.45rem;
}

.platforms-key-gloss {
  color: var(--text-faint);
}

.platforms-swatch {
  width: 0.8rem;
  height: 0.8rem;
  flex: none;
  border-radius: var(--radius-sm);
}

.platforms-swatch--own {
  background: var(--accent);
}

.platforms-swatch--collect {
  background: var(--brass);
}

.platforms-swatch--exclude {
  background: var(--danger);
}

/* Sort pills — revealed by app.js, since sorting is a client-side shuffle. */
.platforms-sort {
  display: flex;
  gap: 0.25rem;
  margin-left: auto;
  padding: 0.2rem;
  background: var(--fill);
  border-radius: 999px;
}

.platforms-sort-pill {
  padding: 0.3rem 0.8rem;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--text-faint);
  font: inherit;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  cursor: pointer;
  transition: color var(--speed);
}

.platforms-sort-pill:hover {
  color: var(--text);
}

.platforms-sort-pill[aria-pressed="true"] {
  background: var(--chip);
  color: var(--text);
}

.platforms-tally {
  margin: 1rem 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.platforms-tally-own {
  color: var(--accent-text);
}

.platforms-tally-collect {
  color: var(--brass);
}

.platforms-panel {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  overflow: hidden;
}

.platforms-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.platform-row {
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
  padding: 0.8rem 1rem;
  border-bottom: 1px solid var(--border);
}

.platform-row:hover {
  background: var(--row-hover);
}

/* Removing is undoable until Save, so the row stays in the DOM with its
   inputs disabled (a disabled control does not post — which is exactly how
   the view learns the platform left the list). */
.platform-row[hidden] {
  display: none;
}

.platform-row-main {
  flex: 1 1 12rem;
  min-width: 0;
}

.platform-row-name {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
  font-weight: 600;
}

.platform-row-title {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Curated short code beside the name (issue #775) — the release-class/condition
   subdued-pill idiom. A verbose official name truncates (the title span above);
   the code never does, so the scannable part survives narrow rows. No `display`
   on purpose: a flex item blockifies anyway, and an author display rule would
   out-cascade the UA [hidden] rule the clone template's badge relies on. */
.platform-code {
  flex: none;
  padding: 0.1rem 0.45rem;
  border: 1px solid var(--border-strong);
  border-radius: 3px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  font-weight: 400;
  letter-spacing: 0.03em;
  color: var(--text-muted);
}

.platform-row-meter {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-top: 0.35rem;
}

/* How much of the collection already sits on this platform, relative to the
   busiest one — the evidence behind an inferred row (width is server-computed,
   the one thing a stylesheet cannot know; issue #450). */
.platform-meter {
  width: 7.5rem;
  height: 0.25rem;
  flex: none;
  background: var(--surface-3);
  border-radius: 999px;
  overflow: hidden;
}

.platform-meter > span {
  display: block;
  width: 0;
  height: 100%;
  background: var(--accent-border-strong);
  border-radius: inherit;
}

.platform-row-count {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

.platform-row-chips {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex-wrap: wrap;
}

/* .sr-only parks the real checkbox off-screen (the label is the visible
   control), but input[type="checkbox"] out-specifies it on size — restate the
   clip box so the chip's hit area is the label and nothing else. */
.platform-chip-input {
  width: 1px;
  height: 1px;
}

/* The chips are labels, so they inherit the form-label rule's block display
   and 1rem bottom margin — both wrong for a row of inline controls. */
.platform-chip {
  display: inline-flex;
  align-items: center;
  margin: 0;
  height: 2rem;
  padding: 0 0.85rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  color: var(--text-muted);
  font-size: var(--fs-ui-sm);
  font-weight: 500;
  white-space: nowrap;
  cursor: pointer;
  transition:
    color var(--speed),
    border-color var(--speed),
    background var(--speed);
}

.platform-chip:hover {
  color: var(--text);
  border-color: var(--text-faint);
}

/* Checked = the statement is on: the chip fills with its own colour and the
   label goes to ink, so the row reads at a glance without needing the legend. */
.platform-chip-input:checked + .platform-chip {
  border-color: transparent;
  color: var(--accent-ink);
  font-weight: 600;
}

.platform-chip-input:checked + .platform-chip--own {
  background: var(--accent);
}

.platform-chip-input:checked + .platform-chip--collect {
  background: var(--brass);
}

.platform-chip-input:checked + .platform-chip--exclude {
  background: var(--danger);
}

/* The input is .sr-only, so its focus ring has to be drawn on the label. */
.platform-chip-input:focus-visible + .platform-chip {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
}

/* The × and the add-options are submit buttons (they are the no-JS paths),
   so button[type="submit"]'s primary fill out-specifies a lone class — these
   selectors carry the extra weight to stay quiet controls. Save platforms is
   left alone: it *is* the page's primary action. */
.platform-row-chips .platform-remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  flex: none;
  padding: 0;
  border: 0;
  border-radius: var(--radius);
  background: transparent;
  color: var(--text-faint);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  transition:
    color var(--speed),
    background var(--speed);
}

.platform-row-chips .platform-remove:hover {
  background: var(--danger-dim);
  color: var(--danger);
}

.platform-row-empty,
.platform-add-note {
  padding: 1rem;
  margin: 0;
  color: var(--text-faint);
  font-size: var(--fs-ui);
}

/* Add panel — a native <details>, so the disclosure works before app.js. */
.platform-add {
  background: var(--fill);
}

.platform-add-summary {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  padding: 0.85rem 1rem;
  color: var(--accent-text);
  font-size: var(--fs-ui);
  font-weight: 600;
  cursor: pointer;
  list-style: none;
}

.platform-add-summary::-webkit-details-marker {
  display: none;
}

.platform-add-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.35rem;
  height: 1.35rem;
  border: 1px dashed var(--accent-border-strong);
  border-radius: var(--radius);
}

.platform-add-panel {
  padding: 0 1rem 1rem;
}

.platform-search {
  width: 100%;
  margin-bottom: 0.5rem;
}

.platform-add-list {
  max-height: 14rem;
  margin: 0;
  padding: 0;
  overflow-y: auto;
  list-style: none;
}

.platform-add-list .platform-add-option {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  width: 100%;
  padding: 0.5rem 0.7rem;
  border: 0;
  border-radius: var(--radius);
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: var(--fs-ui);
  text-align: left;
  cursor: pointer;
}

/* 🚨 The cascade resolves PER PROPERTY, so winning `background` is not enough.
   This selector (0,3,0) out-specifies `button[type="submit"]:hover` (0,2,1) —
   but it used to declare only `background`, and the primary fill's own
   `color: var(--bg)` and `opacity: 0.9` still landed on every other property.
   In the dark theme that painted near-black text on the hovered chip. Restate
   both, the way .platform-remove:hover above already does. */
.platform-add-list .platform-add-option:hover {
  background: var(--chip);
  color: var(--text);
  /* Moot while the rest rule keeps `border: 0`, and that is the point: the
     fill's var(--text) edge would appear the day anyone gives it a width. */
  border-color: transparent;
  opacity: 1;
}

.platform-add-cue {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--accent-text);
}

/* Sticky save bar: the batch editor's whole contract in one line — what is
   pending, and the button that commits it. Sticky inside the form, not fixed
   to the viewport, for two reasons a fixed bar cannot give:
     - it rides the content rail. A viewport-centred bar is only aligned with
       the column by coincidence; this page's is 55rem inside a 76rem shell, so
       the two edges never matched.
     - it stops. Sticky bottoms out at the end of its containing block (the
       form), which leaves main's 4rem tail between the bar and the footer —
       a fixed bar sat on top of the footer at full scroll.
   It still holds above the list while the rows scroll behind it, which is what
   the fixed positioning was for. The inner box keeps its own rule so the blur
   and the shadow stay on the bar, not on the sticky wrapper. */
.platforms-savebar {
  position: sticky;
  bottom: 1rem;
  /* Over the panel it floats above; well under the sticky header's 50. */
  z-index: 2;
  margin-top: 1.25rem;
}

.platforms-savebar-inner {
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
  padding: 0.7rem 0.8rem 0.7rem 1.2rem;
  border: 1px solid var(--border-strong);
  border-radius: 10px;
  background: var(--header-tint);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  box-shadow: var(--shadow-panel);
}

.platforms-status {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

.platforms-status.is-dirty {
  color: var(--brass);
}

.platforms-status.is-saved {
  color: var(--accent-text);
}

.platforms-savebar-actions {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-left: auto;
}

.platforms-discard {
  padding: 0.55rem 0.9rem;
  border: 0;
  border-radius: var(--radius);
  background: transparent;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--fs-ui);
  cursor: pointer;
}

.platforms-discard:hover {
  color: var(--text);
}

/* The button rule's display: inline-flex outweighs the UA sheet's [hidden]
   rule, so restore it — app.js hides Discard whenever nothing is pending. */
.platforms-discard[hidden] {
  display: none;
}

/* Idle is a real disabled button, not a dimmed live one: with nothing pending
   there is nothing to submit. app.js sets it; without JS the button stays
   live, because then a submit is the only way to save. */
.platforms-savebar-actions .platforms-save[disabled] {
  background: var(--fill);
  border-color: transparent;
  color: var(--text-faint);
  cursor: default;
}

/* The phone layer squeezes this bar from both ends, and the bar had no phone
   rule at all until now. The type scale at the top of this file bumps the
   status to 13px and the button text to 15px, and the touch floor takes the
   button to 44px — all exactly where the column narrows to ~324px. Measured at
   390px the one row wanted 325.2px, so it missed by a single pixel, wrapped,
   and the bar went 68px -> 106px: a bar that only ever needed one row, sitting
   over a whole platform row. With JS off it is worse still (three rows, 127px),
   because the server's status line is a whole sentence.

   The status is the only item here that can yield, so let it: nowrap keeps the
   bar one row by construction, and the height is then the 44px touch target
   plus padding in every state and at every phone width. Guarded in
   apps/web/tests/test_platforms_page.py. */
@media (max-width: 720px) {
  .platforms-savebar-inner {
    flex-wrap: nowrap;
    gap: 0.5rem;
    padding-inline: 0.8rem;
  }

  .platforms-status {
    min-width: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* Discard is type="button", so the global 44px selector list — which keys on
     button[type="submit"] — misses it, and it drew 42.3px beside a 44px Save.
     The touch audit never caught that: it skips [hidden] elements, and Discard
     ships hidden and is only revealed once the form is dirty. */
  .platforms-discard {
    min-height: 44px;
    padding-block: 0;
    padding-inline: 0.7rem;
    font-size: var(--fs-ui-sm);
  }
}

@media (max-width: 520px) {
  /* The chips need the full width once the name column can no longer share
     a line with them. */
  .platform-row-main {
    flex-basis: 100%;
  }

  .platform-row-chips {
    width: 100%;
  }

  .platform-remove {
    margin-left: auto;
  }
}

/* ---------------------------------------------------------------------------
 * Page furniture
 * ------------------------------------------------------------------------- */

.page-head {
  margin-bottom: 1.5rem;
}

.page-head h1 {
  margin-bottom: 0.25rem;
}

.page-head p {
  color: var(--text-muted);
  margin: 0;
}

/* Heading + mono note sharing one baseline (design 05.2/05.4). */
.page-head--row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.35rem 1rem;
}

.page-head--row h1,
.page-head--row h2 {
  margin-bottom: 0;
}

/* One muted sentence under a title row (spec 05.5); flex-basis wraps it onto
   its own line inside the row container. */
.page-head--row .page-lede {
  flex-basis: 100%;
  max-width: 37rem;
  font-size: var(--fs-body);
}

/* Mono annotation beside a page title (spec 05.2: "42,793 games · built
   from Wikidata & Wikipedia"). */
.page-head .head-note {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  color: var(--text-muted);
}

/* Caps variant (spec 05.5: "SCORED BY YOUR OWN AGENT · NOTHING RUNS
   SERVER-SIDE"); also usable on a span inside a head-note so literals like
   kind="backlog" keep their case. */
.head-note--caps {
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

.back-link {
  display: inline-block;
  margin-bottom: 1.25rem;
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  letter-spacing: 0.05em;
  color: var(--text-muted);
}

.back-link:hover {
  color: var(--accent-text);
}

/* Long-form legal/prose pages (Terms of Service #153, Privacy Policy #154).
 * Constrains the measure for readability and gives headings/lists comfortable
 * rhythm. */
.legal {
  max-width: 70ch;
  color: var(--text);
  line-height: 1.7;
}

.legal h2 {
  margin-top: 2rem;
  margin-bottom: 0.5rem;
  font-size: 1.2rem;
}

.legal p,
.legal ul {
  margin-bottom: 1rem;
  color: var(--text-muted);
}

.legal ul {
  padding-left: 1.25rem;
}

.legal li {
  margin-bottom: 0.4rem;
}

.legal a {
  color: var(--accent-text);
}

/* Bold inline labels (e.g. the Privacy Policy's "What we collect" list) lift
 * back to full-strength text against the muted body copy. */
.legal strong {
  color: var(--text);
}

/* About page (spec 22, "marginalia"): a single reading column with a real
 * margin. Four sections of prose sit in one text column; to their left a
 * 206px rail carries one mono note per section, the way a reference book
 * annotates itself. Headings live in the text column, not the rail, so there
 * is ONE reading line. The hairline under the site masthead is the only rule
 * the page draws — every other separation is space. Nothing sticky, no
 * dividers, no cards, no buttons.
 *
 * One page-level grid (206px / 44px / 1fr) on the <article>; the head and each
 * <section> lay out on a SUBGRID of it, so every note shares one right edge no
 * matter how long it runs. Do not give a section its own grid — the rail
 * drifts. At <=720 the grid becomes one column and the asides are hidden
 * (display:none — nothing moves into the flow and nothing replaces them), so
 * the notes may never carry a fact the body lacks.
 *
 * Tokens only: --text / --text-muted / --text-faint, --accent-text, the
 * three font stacks. Nothing new in the system.
 *
 * Spec 23 ("voice revision") changed the copy, not the frame: the lede is two
 * paragraphs, the last section carries two paragraphs and a mono signoff
 * line. Three measures came with it (lede gap, paragraph gap, signoff) and
 * every other value below is spec 22's. */
.about {
  --about-rail: 206px;
  --about-gap: 44px;
  display: grid;
  grid-template-columns: var(--about-rail) minmax(0, 1fr);
  column-gap: var(--about-gap);
  box-sizing: border-box;
  max-width: 54.5rem; /* 872px: 206 + 44 + 622 — the prose column is 622 */
  margin-inline: auto;
}

/* The page's vertical frame replaces main's generic padding: 58px from the
 * masthead rule to the head, 60px from the last paragraph to the footer
 * (34px / 44px at <=720). The `:has()` sits at (0,2,1), above the bare
 * `main.container` rule, and its phone arm below carries the same selector so
 * the media query still wins on order. */
main.container:has(> .about) {
  padding-block: 3.625rem 3.75rem;
}

.about-head,
.about-section {
  display: grid;
  grid-template-columns: subgrid;
  grid-column: 1 / -1;
}

/* Rail occupants: the head slug and each section's note, right-aligned to
 * the rail's edge. Both are set in mono at 12px — never smaller — and one
 * step quieter than body copy (--text-faint), deliberately. No accent in the
 * margin. */
.about-slug,
.about-note {
  grid-column: 1;
  grid-row: 1 / 3;
  align-self: start;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
  text-align: right;
}

/* Slug: two lines on desktop (ABOUT / COLLECTED.GAMES), the dot hidden.
 * The -4px lifts its cap top onto the H1's. Measured with canvas metrics on
 * the real faces (the recipe in line-height-centres-the-line-box): 12px/1.9
 * Plex Mono inks its cap 7.5px into the line box, 52px/1.08 Newsreader inks
 * its own 3.8px in. Recompute if either size or line-height changes. */
.about-slug {
  line-height: 1.9;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin-top: -4px;
}

.about-slug > span {
  display: block;
}

/* (0,2,0): the block-per-span rule above is (0,1,1) and would otherwise win. */
.about-slug > .about-slug-sep {
  display: none;
}

.about-title,
.about-lede,
.about-section > h2,
.about-section > p {
  grid-column: 2;
}

/* H1: Newsreader 500, 52px/1.08, tighter than the site's default title
 * tracking; the italic "video game" is the one place the display face
 * changes voice on the page. 18ch caps it at two lines. */
.about-title {
  font-size: 3.25rem;
  line-height: 1.08;
  letter-spacing: -0.02em;
  max-width: 18ch;
  margin: 0 0 2.125rem;
  color: var(--text);
}

.about-title em {
  font-style: italic;
  font-weight: inherit;
}

.about-lede {
  font-size: 1.125rem;
  line-height: 1.55;
  color: var(--text);
  margin: 0;
}

/* Two lede paragraphs, 20px apart, and one voice: the second is NOT stepped
 * down to body size or body colour to signal hierarchy. */
.about-lede + .about-lede {
  margin-top: 1.25rem;
}

/* 46px of air under the lede, then 38px from the last line of each paragraph
 * to the next heading. Space is the only separator. */
.about-head + .about-section {
  margin-top: 2.875rem;
}

.about-section + .about-section {
  margin-top: 2.375rem;
}

.about-section > h2 {
  font-size: 1.5rem;
  line-height: 1.2;
  letter-spacing: -0.015em;
  margin: 0 0 0.625rem;
  color: var(--text);
}

.about-section > p {
  font-size: 1rem;
  line-height: 1.62;
  color: var(--text-muted);
  margin: 0;
}

/* A section with more than one paragraph (the close) keeps them 16px apart,
 * at body size and body colour: prose, not a second opening. */
.about-section > p + p {
  margin-top: 1rem;
}

/* Note: 12px/1.8 mono. The -3px aligns the note's first cap top with the
 * heading's: 12px/1.8 Plex Mono inks its cap 6.9px into the line box,
 * 24px/1.2 Newsreader inks its own 4.2px in (measured as for the slug; the
 * residual is 0.3px). At 0 the note falls to the heading's midline. A note
 * taller than its section's prose is a copy bug, not a layout case: five
 * lines is the cap. */
.about-note {
  line-height: 1.8;
  letter-spacing: 0.04em;
  margin-top: -3px;
}

/* Signoff: the last line of the last paragraph block, 16px under it, set in
 * the margin-note token exactly — mono 12px, --text-faint — which is what
 * ties it to the rail rather than to a footer. No rule above it, no
 * background, no box; space is what separates it. (0,2,0) so it outranks the
 * `.about-section > p` body sizing at (0,1,1) and the `p + p` gap at (0,1,2).
 * The CONTACT link is the accent's one use in the close — still none in the
 * margin. */
.about-section > .about-signoff {
  margin-top: 1rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.8;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

/* Links: accent, 1px underline at the 55% border strength, 3px off the
 * baseline. Hover takes the underline to full strength; the colour holds. */
.about a {
  color: var(--accent-text);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-decoration-color: var(--accent-border-strong);
  text-underline-offset: 3px;
}

.about a:hover {
  color: var(--accent-text);
  text-decoration-color: currentColor;
}

@media (max-width: 720px) {
  main.container:has(> .about) {
    padding-block: 2.125rem 2.75rem;
  }

  /* One step, no intermediate layout: the margin either exists or it
   * doesn't. The head and sections collapse onto a single column; the
   * asides leave the page (and the accessibility tree) with it. */
  .about,
  .about-head,
  .about-section {
    display: block;
  }

  .about-note {
    display: none;
  }

  /* The slug turns into one left-aligned line, ABOUT · COLLECTED.GAMES,
   * 16px above the title. */
  .about-slug {
    text-align: left;
    margin: 0 0 1rem;
  }

  .about-slug > span {
    display: inline;
  }

  .about-slug > .about-slug-sep {
    display: inline;
    margin-inline: 0.5em;
  }

  .about-title {
    font-size: 2.125rem;
    line-height: 1.1;
    margin-bottom: 1.5rem;
  }

  .about-lede {
    font-size: 1.0625rem;
  }

  .about-lede + .about-lede {
    margin-top: 1.125rem;
  }

  .about-head + .about-section {
    margin-top: 2.125rem;
  }

  /* 34px between sections: 4px tighter than desktop, since there is no rail
   * to keep pace with. Body stays 16px — never smaller — on a touch more
   * leading. */
  .about-section + .about-section {
    margin-top: 2.125rem;
  }

  .about-section > h2 {
    font-size: 1.375rem;
  }

  .about-section > p {
    line-height: 1.65;
  }

  /* 20px from the last paragraph to the signoff, and 1.9 leading so the
   * lines breathe when it wraps: at 390px the mono line breaks into two or
   * three lines on its middot separators, and that is fine — never a <br>,
   * never a list, never below 12px to force one line. The one
   * mobile-specific value spec 23 added. */
  .about-section > .about-signoff {
    margin-top: 1.25rem;
    line-height: 1.9;
  }
}

/* Print is the layout this was borrowed from: the grid and the notes stay
 * (a printed page is wider than 720 CSS px, so the phone arm never fires).
 * The site chrome drops — the footer keeps its attribution line — and the
 * external links print where they go. */
@media print {
  body:has(.about) .site-header {
    display: none;
  }

  .about a[href^="http"]::after {
    content: " (" attr(href) ")";
    font-family: var(--font-mono);
    font-size: 0.8em;
  }
}

/* Flash messages, styled like the spec's post-add toast: hairline box with a
   3px leaf lead edge (danger red for errors). */
.alert {
  display: block;
  padding: 0.7rem 1rem;
  margin-bottom: 1.25rem;
  border: 1px solid var(--border-strong);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-sm);
  background: var(--surface);
  font-size: var(--fs-ui);
}

.alert.message-success {
  border-left-color: var(--accent);
}

.alert.message-error,
.alert.alert-danger {
  border-left-color: var(--danger);
  background: var(--danger-dim);
}

/* Contact page (spec 23, "routing table"): a header block that states the
 * general address once, then a three-column table — IF IT'S ABOUT / BEST
 * ROUTE / WHAT HAPPENS — in the same table grammar as the game-detail facts
 * block. One page-level grid (232 / 250 / 1fr, 30px gaps) on `.contact-table`,
 * shared by the column-label row and all four rows via subgrid, so the
 * columns cannot drift. Hairlines under the header block, under the labels
 * and between rows; space does the rest. Nothing sticky, no cards on desktop,
 * exactly one filled button on the page (Suggest an edit).
 *
 * At <=720 the columns are not faked: the grid collapses to one column, the
 * labels hide (replaced once, above the first row, by SOME MESSAGES HAVE A
 * BETTER ROUTE over a hairline) and the route element becomes the row — a
 * full-width 48px bordered tap target, accent-edged for routes inside the
 * app, neutral for the email routes, brass for security. The address plate
 * becomes a 56px accent-filled button, the page's primary action at that
 * width. One template, one string set, two layouts.
 *
 * Tokens only: --bg / --surface / --fill, --border / --border-strong, the
 * three text tones, --accent* and --brass*, the three font stacks. Nothing
 * new in the system. */
.contact {
  --contact-col-subject: 232px;
  --contact-col-route: 250px;
  --contact-col-gap: 30px;
  box-sizing: border-box;
  max-width: 55.5rem; /* 888px: the spec's 1000px page less its 56px gutters */
  margin-inline: auto;
}

/* The page's vertical frame replaces main's generic padding: 52px from the
 * masthead rule to the slug, 26px from the last row to the footer (34px /
 * 30px at <=720). The `:has()` sits at (0,2,1), above the bare
 * `main.container` rule, and its phone arm repeats the selector so the media
 * query still wins on order. */
main.container:has(> .contact) {
  padding-block: 3.25rem 1.625rem;
}

/* Header block: intro 1fr + the 330px plate, 48px apart, bottoms aligned so
 * the lede sits on the plate's baseline block. 34px below, then the first
 * hairline (the label row's top edge). */
.contact-head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 330px;
  column-gap: 3rem;
  align-items: end;
  padding-bottom: 2.125rem;
}

/* Mono labels — the slug, the plate's two lines, the column labels and the
 * phone rule: 12px, 0.12em caps, one step quieter than body copy. */
.contact-slug,
.contact-plate-label,
.contact-plate-kicker,
.contact-label,
.contact-label-phone {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.6;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.contact-slug {
  margin-bottom: 1.125rem;
}

/* H1: Newsreader 500, 44px/1.12, 24ch cap. Lede: 16px/1.6, 54ch. */
.contact-title {
  margin: 0 0 1.125rem;
  max-width: 24ch;
  font-size: 2.75rem;
  line-height: 1.12;
  letter-spacing: -0.015em;
  color: var(--text);
}

.contact-lede {
  margin: 0;
  max-width: 54ch;
  font-size: 1rem;
  line-height: 1.6;
  color: var(--text-muted);
}

/* The plate: a hairline box on --surface, 22/24 padding, three stacked lines
 * 10px apart — mono label, the address in Newsreader 23px, mono kicker. */
.contact-plate {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.625rem;
  box-sizing: border-box;
  padding: 1.375rem 1.5rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
}

/* Links on the page — the address and every consequence-paragraph link:
 * accent, 1px underline at the 55% border strength, hover to full strength,
 * colour unchanged. */
.contact-plate-address,
.contact-consequence a {
  color: var(--accent-text);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-decoration-color: var(--accent-border-strong);
  text-underline-offset: 3px;
}

/* (0,2,0) over the site's `a:hover`, which would otherwise re-seat the
 * underline at its own 0.2em offset on hover. */
.contact-plate-address:hover,
.contact-consequence a:hover {
  color: var(--accent-text);
  text-decoration-color: currentColor;
  text-underline-offset: 3px;
}

.contact-plate-address,
.contact-plate-address:hover {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 1.4375rem;
  line-height: 1.2;
  letter-spacing: -0.01em;
  text-underline-offset: 4px;
  overflow-wrap: anywhere;
}

/* Affordance marks (› ↗ ↓) are phone-only: the desktop table carries the
 * distinction in its BEST ROUTE column. */
.contact-mark {
  display: none;
}

/* The table. */
.contact-table {
  display: grid;
  grid-template-columns: var(--contact-col-subject) var(--contact-col-route) minmax(0, 1fr);
  column-gap: var(--contact-col-gap);
}

.contact-labels,
.contact-row {
  display: grid;
  grid-template-columns: subgrid;
  grid-column: 1 / -1;
}

/* Column labels: 16px vertical padding, hairlines above (the header block's
 * rule) and below. The phone replacement line is hidden here. */
.contact-labels {
  padding-block: 1rem;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
}

.contact-label-phone {
  display: none;
}

/* Rows: 26px vertical padding, a hairline between rows, none after the last
 * (the page foot is main's padding). */
.contact-row {
  padding-block: 1.625rem;
}

.contact-row + .contact-row {
  border-top: 1px solid var(--border);
}

/* Row head: Newsreader 500 21px, 5px above its subject line — one clause,
 * 14px/1.5, quiet. This is the string the reader scans for. */
.contact-subject > h2 {
  margin: 0 0 0.3125rem;
  font-size: 1.3125rem;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--text);
}

.contact-subject > p {
  margin: 0;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--text-faint);
}

/* The route cell: route(s) stacked 8px apart, the kicker 8px under the last.
 * Secondary routes are underlined accent links at 15px; the primary route
 * (Suggest an edit) is the one filled button on the page. */
.contact-route {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.5rem;
}

.contact-route-link {
  display: block;
  color: var(--accent-text);
  font-size: 0.9375rem;
  line-height: 1.4;
  text-decoration: none;
}

.contact-route-link:hover {
  color: var(--accent-text);
  text-decoration: none;
}

/* The underline lives on the label span, never on the kicker riding in the
 * same link. */
.contact-route-label {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-decoration-color: var(--accent-border-strong);
  text-underline-offset: 3px;
}

.contact-route-link:hover .contact-route-label {
  text-decoration-color: currentColor;
}

/* Security is brass, not accent — "handle differently", not "warning". */
.contact-row--security .contact-route-link {
  color: var(--brass-text);
}

.contact-row--security .contact-route-link:hover {
  color: var(--brass-text);
}

.contact-row--security .contact-route-label {
  text-decoration-color: var(--brass-border);
}

/* The filled button, desktop only (the phone arm restyles the whole link as
 * the row, so the button look is scoped to the widths where it exists rather
 * than painted and then reset). 34px min-height, 6px radius, accent fill,
 * ink label; hover to the strong accent like every leaf button. */
@media (min-width: 721px) {
  .contact-route-link--primary .contact-route-label {
    display: inline-flex;
    align-items: center;
    box-sizing: border-box;
    min-height: 2.125rem;
    padding: 0 0.875rem;
    border: 1px solid var(--accent);
    border-radius: var(--radius);
    background: var(--accent);
    color: var(--accent-ink);
    font-weight: 600;
    font-size: 0.875rem;
    line-height: 1.3;
    text-decoration: none;
  }

  .contact-route-link--primary:hover .contact-route-label {
    background: var(--accent-strong);
    border-color: var(--accent-strong);
  }
}

/* Kicker: mono 12px/1.6, 0.06em caps, quiet. Names where the route lives or
 * what it excludes — never restates the route. Inside a single-route link it
 * stacks 8px under the label; the account row's kicker is a sibling under
 * its two routes and takes the cell's 8px gap instead. */
.contact-kicker {
  display: block;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.6;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.contact-route-link > .contact-kicker {
  margin-top: 0.5rem;
}

/* Consequence: 15px/1.62, two sentences maximum. */
.contact-consequence {
  margin: 0;
  font-size: 0.9375rem;
  line-height: 1.62;
  color: var(--text-muted);
}

@media (max-width: 720px) {
  main.container:has(> .contact) {
    padding-block: 2.125rem 1.875rem;
  }

  /* One step, no intermediate layout. Between 720 and 1000 the page shrinks
   * from the gutters and the 1fr column absorbs it; at 720 everything
   * becomes one column. Source order is already the phone order, so nothing
   * moves. */
  .contact-head,
  .contact-table,
  .contact-labels,
  .contact-row {
    display: block;
  }

  .contact-head {
    padding-bottom: 0;
  }

  .contact-slug {
    margin-bottom: 1rem;
  }

  .contact-title {
    margin-bottom: 1rem;
    font-size: 1.9375rem;
    line-height: 1.14;
  }

  .contact-lede {
    font-size: 0.9375rem;
  }

  /* The plate sheds its box; the address link alone becomes the 56px
   * accent-filled button, full width, Newsreader 19px on ink with a ↗. The
   * two mono lines around it leave with the box. */
  .contact-plate {
    display: block;
    margin-top: 1.75rem;
    padding: 0;
    border: 0;
    border-radius: 0;
    background: none;
  }

  .contact-plate-label,
  .contact-plate-kicker {
    display: none;
  }

  .contact-plate-address {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    box-sizing: border-box;
    width: 100%;
    min-height: 3.5rem;
    padding: 0.75rem 1.125rem;
    border-radius: 7px;
    background: var(--accent);
    color: var(--accent-ink);
    font-size: 1.1875rem;
    text-decoration: none;
  }

  .contact-plate-address:hover {
    background: var(--accent-strong);
    color: var(--accent-ink);
    text-decoration: none;
  }

  .contact-plate-address > .contact-mark {
    display: inline-flex;
    flex: none;
    font-size: 0.875rem;
  }

  /* The rule above row one: a hairline 34px under the address button, then
   * SOME MESSAGES HAVE A BETTER ROUTE. Without it the button and the first
   * route row read as one list of five equal options. */
  .contact-labels {
    margin-top: 2.125rem;
    padding: 1.375rem 0 0;
    border-bottom: 0;
  }

  .contact-label {
    display: none;
  }

  .contact-label-phone {
    display: block;
  }

  /* Row rhythm: 16px top, 18px bottom, hairline between; head 20px, subject
   * and consequence 14px — one step down from desktop, and the floor. */
  .contact-row {
    padding: 1rem 0 1.125rem;
  }

  .contact-subject > h2 {
    font-size: 1.25rem;
  }

  /* The route becomes the row: full-width, 48px min-height, 7px radius, a
   * faint surface tint and a coloured hairline — accent for a route inside
   * the app, neutral for one that opens a mail client, brass for security.
   * Hover lifts the edge to the label's own colour. The label and kicker
   * wrap as a pair; the mark is pinned to the right edge so a wrapped kicker
   * never pushes it onto a second line. */
  .contact-route {
    align-items: stretch;
    margin-top: 0.875rem;
  }

  .contact-route-link {
    position: relative;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.25rem 0.75rem;
    box-sizing: border-box;
    min-height: 3rem;
    padding: 0.625rem 2.5rem 0.625rem 1rem;
    border: 1px solid var(--accent-border);
    border-radius: 7px;
    background: var(--fill);
    font-weight: 600;
    line-height: 1.3;
  }

  .contact-route-link:hover {
    border-color: var(--accent);
  }

  .contact-route-link[href^="mailto:"] {
    border-color: var(--border-strong);
    color: var(--text);
  }

  .contact-route-link[href^="mailto:"]:hover {
    border-color: var(--text-muted);
    color: var(--text);
  }

  .contact-row--security .contact-route-link {
    border-color: var(--brass-border);
    color: var(--brass-text);
  }

  .contact-row--security .contact-route-link:hover {
    border-color: var(--brass);
  }

  .contact-route-label {
    text-decoration: none;
  }

  /* In-row kicker: the badge size (12px on the phone scale — the site's
   * floor inside a chip), no stacking gap. The account row's sibling kicker
   * stays at the mono size under its pair. */
  .contact-route-link > .contact-kicker {
    margin-top: 0;
    font-size: var(--fs-mono-badge);
  }

  .contact-route-link > .contact-mark {
    position: absolute;
    top: 50%;
    right: 1rem;
    display: inline-flex;
    transform: translateY(-50%);
    font-size: 0.875rem;
    color: var(--text-faint);
  }

  .contact-consequence {
    margin-top: 0.75rem;
    font-size: 0.875rem;
    line-height: 1.6;
  }
}

/* A coarse-pointer tablet (a 1024px iPad) gets the DESKTOP table, where the
 * address and the secondary routes are bare 15px links — 21–28px tall, under
 * the 44px handheld floor (tests/ui/test_touch_targets.py measures this
 * width). They cannot join the floor's selector list: it sets inline-flex and
 * padding-block 0, which would lay a link's label and kicker side by side and
 * un-stretch the phone row. So the band is bought here instead, on the
 * desktop layout only: the link's box grows to 44px around its stacked
 * content, the text inside does not move. */
@media (pointer: coarse) and (min-width: 721px) {
  .contact-plate-address {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }

  .contact-route-link {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    min-height: 44px;
  }
}

/* Print keeps the three columns (a printed page is wider than 720 CSS px, so
 * the phone arm never fires); the site chrome drops, and the mail and in-app
 * routes print with their target in parentheses. */
@media print {
  body:has(.contact) .site-header {
    display: none;
  }

  .contact-route-link::after,
  .contact-plate-address::after {
    content: " (" attr(href) ")";
    display: block;
    font-family: var(--font-mono);
    font-size: 0.75rem;
    font-weight: 400;
  }
}

/* Post-add toast (issue #356; spec 04): the .alert treatment above, floated as
   a fixed overlay stack in the lower corner. The region itself ignores the
   pointer so stray clicks fall through to the page; the toasts accept them. It
   sits above the sticky header (z-index 50) and the column pickers. */
.toast-region {
  position: fixed;
  right: 1.25rem;
  bottom: 1.25rem;
  z-index: 90;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  max-width: min(26rem, calc(100vw - 2.5rem));
  pointer-events: none;
}

.toast {
  pointer-events: auto;
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0 0.45rem;
  /* The region's gap spaces stacked toasts; .alert's stacking margin would double it. */
  margin-bottom: 0;
  box-shadow: 0 10px 28px rgb(0 0 0 / 0.18);
  transition: opacity 200ms ease;
}

/* "✓ Added …" / "✓ Marked as owned" leads in the leaf ownership voice, like the
   row's owned status bar. */
.toast-lead {
  color: var(--accent-text);
}

.toast-sep {
  color: var(--text-faint);
}

.toast-undo-form {
  display: inline;
  margin: 0;
}

/* The Undo submit reads as a link, matching the "Add details…" anchor beside it. */
.toast button.toast-undo {
  display: inline;
  padding: 0;
  border: 0;
  background: none;
  color: var(--accent-text);
  font-family: var(--font-body);
  font-size: inherit;
  font-weight: 400;
  line-height: inherit;
}

.toast button.toast-undo:hover {
  background: none;
  color: var(--accent-text-strong);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* Set by the auto-dismiss script in base.html just before removal. */
.toast-leaving {
  opacity: 0;
}

/* Enter rise (motion-safe only, mirroring the hero stagger opt-in; the global
   reduced-motion kill-switch would zero it regardless). */
@media (prefers-reduced-motion: no-preference) {
  .toast {
    animation: toast-in 0.25s cubic-bezier(0.2, 0.7, 0.2, 1) both;
  }
}

@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

.text-danger {
  color: var(--danger);
}

/* Facts rows (spec 04): mono label left, value right, hairline separators.
   Rendered from the existing <dl class="meta-list"> markup — grid places each
   dt/dd pair on one row, and both cells carry the row's bottom hairline so the
   rule runs the full width. */
.meta-list {
  display: grid;
  grid-template-columns: minmax(8rem, max-content) 1fr;
  margin: 1.5rem 0 0;
  border-top: 1px solid var(--border-strong);
  max-width: 44rem;
}

.meta-list dt {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-muted);
  padding: 0.95rem 1.5rem 0.78rem 0;
  border-bottom: 1px solid var(--border);
}

/* The value cell holds user data — a display name, an email address. An email
   is one long unbreakable token, so without `anywhere` it neither wraps nor
   shrinks: it runs off the right edge and the value the row exists to show is
   the part that gets cut (issue #1032). */
.meta-list dd {
  margin: 0;
  min-width: 0;
  padding: 0.78rem 0;
  border-bottom: 1px solid var(--border);
  text-align: right;
  font-size: var(--fs-ui);
  overflow-wrap: anywhere;
}

/* Compact keeps the ledger shape (mobile screens 2C): mono label left, value
   right-aligned on the same row — "unchanged from desktop except size". A
   long value (the regional release list) wraps to a second line rather than
   shrinking; only the label's wide desktop gutter tightens. */
@media (max-width: 520px) {
  .meta-list {
    grid-template-columns: max-content minmax(0, 1fr);
  }

  .meta-list dt {
    padding-right: 1rem;
  }
}


/* ---------------------------------------------------------------------------
 * Catalog search bar (spec 04: one line + advanced drawer) + filter bars
 * ------------------------------------------------------------------------- */

.filter-panel {
  margin-bottom: 1.5rem;
}

/* The one-line search bar: text field + filter segments + submit, separated by
   hairlines inside a single bordered row. On narrow viewports each segment
   becomes its own full-width row. overflow must stay visible so the facet
   menus' dropdown panels (issue #354) aren't clipped — the rounded container
   corners are matched on the first/last segments instead. */
/* The bar's chrome is painted by its segments, not the row box: the filter
   sheet wrapper (mobile layer spec §05A) sits between the row and its
   segments as display: contents, and the advanced drawer + extras rows now
   live inside it too — a border on the row itself would wrap them. The
   search field caps the left edge, the Search button the right; both carry
   the top/bottom hairlines, as does every segment between them. */
.filter-row {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  padding: 0;
  margin: 0;
}

.filter-row > .search-field,
.filter-row .filter-sheet-panel > .filter-menu,
.filter-row .filter-sheet-panel > .search-seg {
  background: var(--surface);
  border-block: 1px solid var(--border-strong);
}

.filter-row > .search-field {
  border-inline-start: 1px solid var(--border-strong);
  border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}

/* The right cap. It used to be the Search submit's own edges; issue #1464 took
   that button off the bar (every control applies live, and it was never even
   the Enter target — see the template), so the bar now ends on the region
   segment and that cell carries the closing hairline and radius. Exactly the
   arrangement the collection has had since issue #399, and the twin of its
   `.more-menu` rule further down this sheet — the last difference between two
   bars this file has always described as one component is gone with it.

   Unconditional, like the collection's: inside the sheet these segments are
   stripped to `background: none; border: 0`, so a radius left on a cell with
   no border and no fill paints nothing.

   🚨 Scoped AWAY from the collection, which is the half #1465 missed. Region
   is the catalog's only `.search-seg`, so naming the element was unambiguous
   on this bar — but the selector never said "this bar", and the collection has
   a `.search-seg` of its own: Format, which is NOT its last cell. "＋ More" is,
   and it carries that bar's cap in its own rule further down this sheet. So
   Format drew a closing hairline and a rounded right edge in the MIDDLE of the
   row, splitting the bar in two a segment early. The `:not()` is what makes
   "the catalog's last cell" mean it. */
.filter-row:not(.filter-row--collection) .filter-sheet-panel > .search-seg {
  border-inline-end: 1px solid var(--border-strong);
  border-start-end-radius: var(--radius-sm);
  border-end-end-radius: var(--radius-sm);
}

/* …except on the no-JS path, where a submit still follows that segment and the
   bar has to close on IT instead (issue #1468). #1464 wrapped the button in
   <noscript>, which took it out of .filter-sheet-panel's CHILD list — its
   parent is the <noscript> now — so the cap rules above stopped reaching it
   and it drew 36px tall, square and unbordered at the end of a 40px bar. The
   ≤720 rule that stands it down inside the sheet had to be re-pointed at
   <noscript> for exactly this reason; that half was caught, this half was not.

   🚨 The <noscript> ELEMENT is off limits as a styling hook. With scripting
   ON its content is never parsed — it is one raw TEXT node — and the UA sheet
   is all that hides it. Any author `display` here beats that UA rule and
   paints the markup as literal text in the bar: Chromium renders
   `<button type="submit" data-testid="…">Apply filters</button>` mid-row, on
   the path every scripted visitor takes. So the wrapper is left alone and the
   button is sized from the inside: the <noscript> is already a flex item that
   .filter-row's `align-items: stretch` grows to the line, and `height: 100%`
   hands that height down. Below 721 the wrapper's own `display: none` (further
   down this sheet) hides the button with it, so the two rules never race.

   The selector needs a `> button` — an element that only exists when scripting
   is off — which is also what keeps it inert on the scripted path.

   Unscoped on purpose, as the ≤720 rule is: the collection grew the same
   noscript submit in issue #399 and it sits in the same slot, after that bar's
   "＋ More" cap. Both bars, one rule.

   🚨 It states its WHOLE segment costume, because it is the last consumer of
   one. A `.filter-row button` cap rule used to supply the first half of it —
   `border: 0`, a left hairline, the squared start corners and the 1.3rem
   inline padding — but at (0,1,1) that selector reached every button anywhere
   inside either filter bar, including the ones in the facet menus' and the
   panels' popovers, which are not segments of a bar. See the note where it
   used to sit for what that cost. */
.filter-row .filter-sheet-panel noscript > button[type="submit"] {
  height: 100%;
  padding-inline: 1.3rem;
  border: 0;
  border-block: 1px solid var(--border-strong);
  border-inline-start: 1px solid var(--border);
  border-inline-end: 1px solid var(--border-strong);
  border-start-start-radius: 0;
  border-end-start-radius: 0;
  border-start-end-radius: var(--radius-sm);
  border-end-end-radius: var(--radius-sm);
}

/* …and the cell before it gives the cap up, or the bar closes twice. #1465
   moved the closing hairline and radius onto the region segment on the premise
   that it is the bar's last cell — true with scripting on, false without it,
   which is the other half of what issue #1468 describes: the bar rounds off,
   and THEN a separate block follows. Dropping both here restores exactly the
   pre-#1464 arrangement on this path (segments separated by their own
   `border-left`, the submit's fill closing the row), rather than leaving a 4px
   notch of page background between a rounded region cell and a square-
   shouldered button.

   `:has(~ noscript > button)` is what makes it conditional, and it reads as
   the no-JS test it is: with scripting on the <noscript> holds a raw text node
   and has no element children at all, so nothing matches and the rule above
   stands. Same reason the submit's own rule names `noscript > button`. The
   collection's cap needs the identical exception; it is written beside that
   bar's own cap rule further down.

   ⚠️ That reasoning holds because this bar is rendered ONCE, by the browser's
   own parser. It would NOT hold for markup arriving in a swap: htmx parses its
   fragments inside a <template>, with the scripting flag off, so <noscript>
   content comes back as real elements (issue #1470). If this panel or its
   submit ever joins an out-of-band response, this rule starts matching for
   scripted visitors — app.js's emptyNoscripts is what keeps that safe. */
.filter-row:not(.filter-row--collection) .filter-sheet-panel > .search-seg:has(~ noscript > button[type="submit"]) {
  border-inline-end: 0;
  border-start-end-radius: 0;
  border-end-end-radius: 0;
}

/* The filter meta row is a full-width line under the bar. It is a child of the
   sheet panel (issue #1264), which is display: contents here — so this is still
   a flex item of .filter-row, laid out exactly where it was when it hung off
   the row directly. Below 721 that parentage is the point: the panel becomes
   the bottom sheet and takes the row's one phone occupant, the column picker,
   in with it. The chips are a readout of what is applied and still have to stay
   on the page when the sheet is shut (design "Catalog Refined" + mobile layer
   spec §2A) — the twin under the control row is the copy that draws there, and
   this one is hidden below 721 (#1281), so the row folds away with nothing to
   say. */
.filter-row .filter-sheet-panel > .filter-meta {
  flex: 1 1 100%;
}

/* The sheet apparatus at desktop widths: the panel dissolves into the row,
   the trigger/scrim/header don't exist. The toggle checkbox is never itself
   visible — its focus ring paints on the trigger (mobile layer). */
.filter-sheet-panel {
  display: contents;
}

.filter-sheet-trigger,
.filter-sheet-scrim,
.filter-sheet-head {
  display: none;
}

.filter-sheet-toggle {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Magnifying glass + text input (spec 04). The field takes the leftover
   width; the segments and Search stay content-sized. */
.search-field {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  flex: 1 1 10rem;
  min-width: 8rem;
  padding-left: 1rem;
  color: var(--text-muted);
}

.search-field svg {
  flex: none;
}

.search-field input[type="search"] {
  flex: 1;
  min-width: 0;
  margin: 0;
  border: 0;
  border-radius: 0;
  background: transparent;
  padding: 0.7rem 1rem 0.7rem 0;
  font-size: var(--fs-field);
}

.search-field input[type="search"]:focus {
  background: transparent;
}

/* The clear affordance the mocks draw at the field's right edge is the native
   search cancel button — it already appears in WebKit/Blink once the field has
   a value, so there is no custom control to build. What it needs is the
   palette: the UA default is a system-blue glyph that belongs to neither
   theme. Masking a stroked ✕ with currentColor tints it like every other leaf
   mark and follows light/dark automatically. The data: URI is a mask, not a
   script, and img-src already allows data: (issue #348). Firefox renders no
   cancel button at all, which is why nothing else depends on it. */
.search-field input[type="search"]::-webkit-search-cancel-button {
  -webkit-appearance: none;
  appearance: none;
  width: 16px;
  height: 16px;
  margin-right: 14px;
  cursor: pointer;
  background-color: var(--text-muted);
  -webkit-mask: var(--icon-clear) center / 16px 16px no-repeat;
  mask: var(--icon-clear) center / 16px 16px no-repeat;
}

.search-field input[type="search"]::-webkit-search-cancel-button:hover {
  background-color: var(--text);
}

/* The focus ring belongs to the field, not to the bare input inside it. The
   input's box starts after the magnifier and stops short of the next segment's
   hairline, so the global :focus-visible ring (2px *outside* that box) drew a
   box that cut the icon out on the left, spilled over the bar's top and bottom
   hairlines, and — on the right — landed under the neighbouring segment's
   opaque background, leaving only the two stubs that poked past the bar
   visible. Painting it on .search-field instead, inset, matches .search-seg
   and .filter-menu below: the ring wraps the whole left cap, follows its
   rounded corners (squared when the advanced drawer docks), and stays inside
   the bar. :focus-within, not :focus-visible, because a text input matches
   :focus-visible however it was focused — the ring must not change on click. */
.search-field:focus-within {
  outline: 2px solid var(--accent-text);
  outline-offset: -2px;
}

.search-field input[type="search"]:focus-visible {
  outline: none;
}

/* Filter segment (spec 04): descriptive label + current value in leaf mono +
   caret, with the native <select> stretched invisibly across the whole
   segment so interaction and accessibility stay native. */
.search-seg {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0;
  padding: 0 0.95rem;
  border-left: 1px solid var(--border);
  font-size: var(--fs-ui-sm);
  font-weight: 500;
  color: var(--text);
  white-space: nowrap;
  cursor: pointer;
}

.search-seg:focus-within {
  outline: 2px solid var(--accent-text);
  outline-offset: -2px;
}

.search-seg .seg-value {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--accent-text);
  max-width: 9rem;
  overflow: hidden;
  text-overflow: ellipsis;
}

.search-seg .seg-value:empty {
  display: none;
}

/* Drawer segments show a muted "any" when no value is applied (spec 04). */
.search-seg--any .seg-value:empty {
  display: inline;
}

.search-seg--any .seg-value:empty::before {
  content: "any";
  color: var(--text-faint);
}

.search-seg .seg-caret {
  font-size: var(--fs-mono);
  color: var(--text-muted);
}

.search-seg select {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  opacity: 0;
  cursor: pointer;
}

/* 🚨 `.filter-row button` used to live here — the bar's right-cap costume,
   written for the "Search" segment that closed the catalog bar until issue
   #1464 took it off. It is gone, and nothing replaces it: its one remaining
   consumer, the no-JS submit, states the whole costume itself above.

   It had to go because a bar is not the only thing inside `.filter-row`. At
   (0,1,1) it reached EVERY button in either bar's subtree — and both bars
   carry popovers full of them. What it did to those, measured: each facet
   menu's Apply and Clear drew square on the start side and rounded on the end
   (a right cap in the middle of a footer), with a stray 1px hairline down the
   left edge of each and 1.3rem of padding neither asked for; "Clear group" and
   the "＋ N more" pager took the same half-radius; and the ＋ More and Advanced
   footers' filled buttons lost their border on three sides. Four separate
   rules had already been written to fight it off one control at a time (the
   rail rows, the two link-shaped buttons, the sheet's own action row) — each
   one a prefix added for specificity rather than for meaning. Deleting the
   cause retires the whole class of them; the survivors are harmless.

   The bar's chrome is painted by its segments, which is what the cap rules
   above and the collection's `.more-menu` cap further down actually do. */

/* The ≤860px "stack the filter row into full-width segments" block that lived
   here is gone (mobile layer spec §05A / §08): it pushed every result below
   the fold. On handheld widths the filter apparatus folds into the bottom
   sheet instead — see the mobile layer at the end of this sheet. */

/* The filter meta row (design "Catalog Refined"): the Advanced disclosure, the
   active-filter chips and the reset control on one line under the search bar,
   with the display prefs pushed to the right end. It replaces two bands — the
   drawer's own summary line and the old .filter-extras row — and adopts the
   chip row from the results meta line, so what used to be three stacked strips
   is one. */
.filter-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 0.9rem;
  margin-top: 0.75rem;
  min-width: 0;
}

.filter-meta .spacer {
  flex: 1;
}

/* The reset fallback's slot: a stable id for the out-of-band refresh that
   re-evaluates the control's render condition on every live swap (see
   _catalog_results.html). display: contents keeps the wrapper out of the flex
   row entirely, so the usually-empty slot never claims a gap slot of its own
   between the column picker and the advanced panel. */
.filter-meta-clear-slot {
  display: contents;
}

/* The .filter-extras band that lived here — the pre-Refined secondary row the
   COLLECTION list kept after the catalog moved to .filter-meta — is gone:
   issue #1521 brought the collection onto the meta row above, so both lists
   place their display prefs identically and one set of rules draws them. */

/* The Advanced disclosure. The checkbox is visually hidden but stays focusable
   (the mobile sheet's pattern): its focus ring paints on the trigger label, so
   keyboard users see the control they are actually on. */
.advanced-toggle {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.advanced-trigger {
  margin: 0;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  color: var(--accent-text);
  cursor: pointer;
  white-space: nowrap;
}

.advanced-trigger:hover {
  color: var(--accent-text-strong);
}

/* The checkbox moved up to the .filter-row level (issue #1163) so it can drive
   a label on each surface — the meta-row trigger here, the sheet's Advanced
   row on phones — which is why these are general-sibling descents rather than
   the adjacent + they used to be. The descent now goes THROUGH the sheet
   wrapper: the meta row moved inside it (#1264), so the checkbox's sibling is
   the panel and the trigger is a grandchild. */
.advanced-toggle:focus-visible ~ .filter-sheet-panel .filter-meta .advanced-trigger {
  outline: 2px solid var(--accent-text);
  outline-offset: 3px;
  border-radius: 2px;
}

/* Only one of the two labels shows; which one is the disclosure's state. */
.advanced-trigger .drawer-close-label,
.advanced-toggle:checked ~ .filter-sheet-panel .filter-meta .advanced-trigger .drawer-open-label {
  display: none;
}

.advanced-toggle:checked ~ .filter-sheet-panel .filter-meta .advanced-trigger .drawer-close-label {
  display: inline;
}

/* The sheet's Advanced accordion row (issue #1163): phone furniture, gone from
   the desktop bar — the meta-row trigger owns the panel there. Shown by the
   ≤720 sheet block. */
.sheet-advanced-row {
  display: none;
}

/* The applied count beside "Advanced", so a closed panel still says how much is
   on. Mirrors the filter sheet's own trigger badge; empty renders nothing. */
.advanced-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.05rem;
  height: 1.05rem;
  margin-left: 0.35rem;
  padding: 0 0.25rem;
  border-radius: 999px;
  background: var(--accent-dim);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1;
  color: var(--accent-text);
}

.advanced-count:empty {
  display: none;
}

/* The hairline between the disclosure and the chips, per the mock. It carries
   no meaning, so it goes when the row wraps to a phone's single column. */
.filter-meta-sep {
  width: 1px;
  height: 0.875rem;
  background: var(--border);
}

/* --- The Advanced panel --------------------------------------------------- */

/* A full-width line under the meta row, revealed by the row-level disclosure
   checkbox through a general-sibling selector. The panel lives inside the
   sheet wrapper (issue #1163) so phones reach it from the sheet; at desktop
   that wrapper is display: contents, so the panel is a .filter-row flex item
   whose DOM slot is up in the bar — order: 1 is what still lays it out under
   the meta row (every other item keeps order 0), the one place this row's
   source order and visual order part ways. margin-top stands in for the meta
   row's flex gap, which no longer reaches it.
   display (not removal) so its inputs keep serializing while it is shut. */
.filter-row .filter-sheet-panel > .advanced-panel {
  display: none;
  flex: 1 1 100%;
  order: 1;
  min-width: 0;
  box-sizing: border-box;
  margin-top: 0.75rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  padding: 1rem 1.15rem 0.85rem;
}

.filter-row > .advanced-toggle:checked ~ .filter-sheet-panel > .advanced-panel {
  display: block;
}

/* Parallel columns of counted options on a three-track grid. Five groups when
   the theme/mode vocabularies exist (the updated "Catalog Refined" moves both
   into this panel), so the grid wraps to a second row rather than growing a
   fourth track. minmax(0, 1fr) so a long option label ellipsizes inside its
   column instead of widening the track. */
.advanced-groups {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1.25rem 1.75rem;
}

.advanced-group {
  min-width: 0;
}

.advanced-group-label {
  margin: 0 0 0.55rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* The one column that can be inapplicable states why, under its own rows
   (issue #1456): exclusivity needs a platform to be exclusive TO, so with none
   chosen its options render disabled and this line names the precondition.

   🚨 --text-muted, NOT --text-faint: the panel paints var(--surface) under this
   text, and faint is tuned to clear 4.5:1 against the BARE page — the same swap
   the status tags needed above. A tint under small text is always worth the
   arithmetic. */
.advanced-group-hint {
  margin: 0.4rem 0 0;
  padding: 0 0.5rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.35;
  color: var(--text-muted);
}

/* A disabled option must not read as clickable — ticking it would do nothing.
   Deliberately signalled by the hover affordance and the native control rather
   than by a dimmer colour: colour alone is not a state (WCAG 1.4.1), and going
   dimmer here would land in exactly the contrast trap the hint above dodges.
   The sentence under the rows is what actually explains the state. */
.advanced-options label.check:has(input:disabled) {
  cursor: default;
}

.advanced-options label.check:has(input:disabled):hover {
  background: none;
  color: var(--text-muted);
}

/* Each column scrolls on its own: the language vocabulary is far longer than
   the three rows the mock draws, and a column that grows without bound would
   push the results off the page. */
.advanced-panel .advanced-options {
  display: flex;
  flex-direction: column;
  gap: 1px;
  max-height: 11.5rem;
  overflow-y: auto;
  overscroll-behavior-y: contain;
}

.advanced-options label.check {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  min-height: 1.75rem;
  margin: 0;
  padding: 0 0.5rem;
  border-radius: var(--radius-sm);
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  cursor: pointer;
}

.advanced-options label.check:hover {
  background: var(--fill);
  color: var(--text);
}

.advanced-options label.check:has(input:checked) {
  background: var(--fill);
  color: var(--text);
}

/* The base `input, select, textarea` rule dresses EVERY input as a full-width
   field, and only `input[type="checkbox"]` opts back out — the stylesheet has
   never carried a radio reset, because until this panel no radio in the app was
   drawn as itself (the view toggles hide theirs behind segment labels). So the
   availability radios arrived 100% wide with field chrome, which squeezed their
   own labels to zero. Reset them here rather than at the base rule: the import
   and copy-panel radios have been living with that inherited sizing and are
   dressed around it, so a global fix belongs in its own change, not this one. */
/* The collection's ＋ More panel draws availability as radios for the same
   reason and inherits the same trap, so it shares the reset rather than
   carrying a second copy of it — one definition of "a radio drawn as itself".
   Its rows are .menu-options (the facet-menu shape it sits among) rather than
   .advanced-options, which is why the selector has to name both. */
.advanced-options label.check input[type="radio"],
.more-panel label.check input[type="radio"] {
  width: 0.9rem;
  height: 0.9rem;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  accent-color: var(--accent);
}

.advanced-options label.check input,
.more-panel label.check input[type="radio"] {
  margin: 0;
  flex: none;
}

.advanced-options .menu-option-label,
.more-panel label.check .menu-option-label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.advanced-options .menu-option-count {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* The panel's own group headings (Common / Other languages) inside a column. */
.advanced-options .menu-group-label {
  margin: 0.35rem 0 0.2rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.advanced-group .menu-filter {
  margin-bottom: 0.45rem;
}

/* Footer: the applied tally on the left, the panel-scoped reset and Apply on
   the right, over a hairline. */
.advanced-foot {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem 1rem;
  margin-top: 0.9rem;
  padding-top: 0.75rem;
  border-top: 1px solid var(--border);
}

.advanced-applied {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.advanced-reset {
  margin-left: auto;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

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

/* Column picker: <details> disclosure as a popover of checkboxes. */
.column-picker {
  position: relative;
}

.column-picker > summary {
  list-style: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.3rem 0.7rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

.column-picker > summary::-webkit-details-marker {
  display: none;
}

.column-picker > summary::after {
  content: "";
  width: 9px;
  height: 5px;
  background: currentColor;
  -webkit-mask: var(--icon-caret) center / contain no-repeat;
  mask: var(--icon-caret) center / contain no-repeat;
  transition: transform var(--speed) ease;
}

.column-picker[open] > summary {
  border-color: var(--accent-text);
  color: var(--accent-text);
}

.column-picker[open] > summary::after {
  transform: rotate(180deg);
}

/* The popover panel, styled like the spec's multi-select filter menu. */
.column-picker .picker-panel {
  position: absolute;
  left: 0;
  top: calc(100% + 0.5rem);
  z-index: 30;
  min-width: 13rem;
  padding: 0.6rem 0.75rem;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: var(--shadow-panel);
}

/* ...except the catalog's, which is the tail of the meta row: the trigger is
   pinned to the row's RIGHT edge by the spacer, so a panel hung from its left
   edge opens INTO the margin and off the page. Measured before this rule: 13rem
   of panel from a trigger 92px wide overflowed the viewport at every width from
   721 to 1280 (+36 at 1280, +88 at 721) and put a horizontal scrollbar on the
   document each time — the options were unreachable without scrolling the page
   sideways. Only ~1440 up had the slack to fit it.

   Same correction the two other right-hand pickers already carry: .sort-picker
   below, and .results-toolbar .column-picker in the ≤720 block. Scoped to the
   meta row because anchoring is a function of where the trigger sits — and
   since issue #1521 the collection's picker sits at ITS meta row's right end
   too, so the same anchoring is what keeps its panel on the page. Inside the
   sheet the panel is position: static and both offsets are inert, so this
   does not reach the phone layer. */
.filter-meta > .column-picker .picker-panel {
  left: auto;
  right: 0;
}

.column-picker .picker-panel label.check {
  display: flex;
  margin-bottom: 0.45rem;
}

.column-picker .picker-panel label.check:last-child {
  margin-bottom: 0;
}

/* "Reset columns to default" link at the bottom of the picker (issue #174). */
.column-picker .picker-panel .picker-reset {
  display: block;
  margin-top: 0.6rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--border);
  text-align: center;
  white-space: nowrap;
}

/* The column picker configures the list table's columns, so it is meaningless in
   grid mode — and in the caption-less shelves mode (issue #717) — hide it
   whenever either radio is checked (the mirror image of the
   grid-only Sort menu, which the swapped results partial omits in list mode). The
   picker sits in the static filter form OUTSIDE the HTMX swap region, so a template
   conditional can't re-evaluate it on a live toggle; keying off the swapped
   toolbar's data-view attribute covers first paint, live swaps, and the no-JS
   path alike. Scoped to the row the picker sits in — .filter-meta, since
   issue #1521 the one row both lists draw — so the toolbar's Sort menu (also
   a .column-picker) is unaffected, and display:none (not removal) keeps the
   column checkboxes serializing so the selection survives a round-trip through
   grid mode. */
main:has(.results-toolbar:is([data-view="grid"], [data-view="shelves"]))
  .filter-meta
  > .column-picker {
  display: none;
}

/* ---------------------------------------------------------------------------
 * Multi-select filter menus + removable filter chips (issue #354)
 * ------------------------------------------------------------------------- */

/* The facet menus reuse the column-picker <details> popover, but their trigger
   reads as a filter segment (seg-label + leaf mono seg-value + caret) so the
   multi-select menus sit seamlessly beside the single-select search-segs. The
   overrides below undo the picker's pill chrome (same specificity, later in
   the file) and mirror the .search-seg rules for the summary's spans. */
.filter-menu > summary {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  border: 0;
  border-radius: 0;
  padding: 0 0.95rem;
  font-size: var(--fs-ui-sm);
  font-weight: 500;
  color: var(--text);
  white-space: nowrap;
}

.filter-menu > summary::after {
  content: none;
}

.filter-menu > summary:focus-visible {
  outline: 2px solid var(--accent-text);
  outline-offset: -2px;
}

.filter-menu > summary .seg-value {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--accent-text);
  max-width: 9rem;
  overflow: hidden;
  text-overflow: ellipsis;
}

.filter-menu > summary .seg-value:empty {
  display: none;
}

.filter-menu > summary .seg-caret {
  font-size: var(--fs-mono);
  color: var(--text-muted);
}

/* In the search bar the menu is a full-height segment behind a hairline. */
/* The bar's facet menus. This also matches the Advanced panel, which carries
   .filter-menu for its BEHAVIOUR (app.js batches changes inside .filter-menu
   behind Apply) rather than for this chrome — so the panel's own display rules
   below out-specify it. Do NOT narrow this selector to `details.filter-menu`
   instead: that raises it to (0,2,1), above the sheet's
   `.filter-sheet-panel .filter-menu { display: block }` (0,2,0) which wins only
   on source order, and every facet menu inside the mobile sheet then lays out
   as a row — summary beside its own panel. */
.filter-row .filter-menu {
  display: flex;
  align-items: stretch;
}

.filter-row .filter-menu > summary {
  border-left: 1px solid var(--border);
}

.filter-row .filter-menu[open] > summary {
  background: var(--fill);
}

.filter-menu .picker-panel {
  min-width: 16rem;
}

/* Long facets (all platforms, all languages) scroll inside the panel. */
.filter-menu .menu-options {
  max-height: 16rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-right: 0.25rem;
}

.filter-menu .menu-options .menu-option-label {
  flex: 1;
  min-width: 0;
}

/* The per-option result count ("SNES 41"), mono per the design system. */
.filter-menu .menu-options .menu-option-count {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* Group headings inside a menu (the language facet's Common / Other languages),
   and the fold's summary below, which wears the same type under its own class
   — a disclosure is not a section heading, and the platform-menu tests hold
   `menu-group-label` to the headings alone. */
.filter-menu .menu-group-label,
.filter-menu .menu-fold-label {
  margin: 0.55rem 0 0.35rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.filter-menu .menu-group-label:first-child {
  margin-top: 0;
}

/* A folded group — the Missing menu's "More fields" (issue #1639). Native
   <details>, so it opens with JS off; the summary reuses the group-heading
   type above and adds the shared caret, turned to point right while closed.
   The rail's grid rules further down span the fold across the value columns
   and re-establish the tracks inside it, so its rows keep the columns of the
   rows above. */
.filter-menu .menu-fold {
  margin-top: 0.55rem;
}

.filter-menu .menu-fold > summary {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  cursor: pointer;
  list-style: none;
}

.filter-menu .menu-fold > summary::-webkit-details-marker {
  display: none;
}

.filter-menu .menu-fold > summary::before {
  content: "";
  flex: none;
  width: 0.6em;
  height: 0.6em;
  background: currentColor;
  -webkit-mask: var(--icon-caret) center / contain no-repeat;
  mask: var(--icon-caret) center / contain no-repeat;
  transform: rotate(-90deg);
}

.filter-menu .menu-fold[open] > summary::before {
  transform: none;
}

/* Type-to-filter box atop the long menus (issue #789). Ships hidden; app.js
   reveals it — without JS it is a dead control, and the full list still
   scrolls. Compact overrides of the base input rules, so the box reads as
   part of the panel rather than a form field. */
.filter-menu .menu-filter {
  margin-bottom: 0.55rem;
}

.filter-menu .menu-filter-input {
  margin-top: 0;
  padding: 0.35rem 0.55rem;
  font-size: var(--fs-field);
}

/* label.check's display: flex (the column-picker panel rules) outweighs the
   UA sheet's [hidden]; restore it for rows the type-to-filter hides.

   🚨 The catalog's Advanced panel needed its own arm and never had one. It is
   not a .picker-panel — it is a CSS-only disclosure, not a <details> popover —
   so the rule above never reached it, while `.advanced-options label.check
   { display: flex }` (0,2,1) did. Every row the type-to-filter "hid" in that
   panel therefore stayed PAINTED and focusable: measured on the language
   group, typing "engl" left all 36 rows in layout and all 34 checkboxes in the
   tab order, with only the panel's own `overflow` deciding which of them a
   sighted reader happened to see. Silent since the facet columns moved into
   the panel, because the list is capped and scrolls — clipping looked exactly
   like filtering. Design "Filter Panel Rail" spec 11 made it visible: the
   pager hides rows the same way, and a "page" that only clips is not a page. */
.column-picker .picker-panel label.check[hidden],
.advanced-options label.check[hidden] {
  display: none;
}

/* The filter box's zero-match state (issue #789). */
.filter-menu .menu-no-match {
  margin: 0.2rem 0 0.35rem;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

/* Apply/Clear footer, separated by the same hairline as the picker-reset. */
.filter-menu .menu-actions {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.6rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--border);
}

/* Clear is JS-only, so it ships `hidden` and app.js reveals it (issue #1469) —
   but the button rule's display: inline-flex out-cascades the UA sheet's
   [hidden] rule, so restore it here. Exactly the trap .platforms-discard[hidden]
   and .pane-filter[hidden] record. Both footers are covered: the collection's
   ＋ More Clear is a direct child of its own .menu-actions too. */
.menu-actions > .btn[data-menu-clear][hidden] {
  display: none;
}

/* Removable filter chips in the results meta line (design spec 05.2:
   "14 results · platform: Virtual Boy ✕"). */
.results-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem 0.6rem;
}

/* "· N in your collection" appended to the result count (design "Catalog Card
   States" 05.2): how many matching games the user already tracks, in leaf so
   ownership is scannable before the grid. */
.results-meta .in-collection-count {
  color: var(--accent-text);
}

/* "50 hidden by region · Show all regions" (issue #779, redrawn by design
   "Region Chip Spec" 15 §G): the region filter defaults to NTSC-U, so a title
   search can hide games that match the text. Two spans — report, then action —
   in the readout row's own mono voice (`.results-toolbar p` above supplies the
   font; this only has to hold them on one line with the target inside it).

   This is the DESKTOP form. Below 721 the whole cell stands down and the region
   chip under the control row carries the same two facts (issue #1271). */
.results-meta .region-suppressed {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  color: var(--text-muted);
}

/* The house's "one live 44px link in a mono line" idiom (.head-meta-import,
   .load-more-all): an inline-flex box tall enough to hit, with negative block
   margins so it spends none of the row's height, and nowrap because a target
   that breaks across lines is the defect issue #1271 is named for. It draws no
   rule of its own — a border on THIS box lands at the bottom of the 44px, a
   half-line clear of the words. */
.region-suppressed-out {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  margin-block: -14px;
  color: var(--accent-text);
  text-decoration: none;
  white-space: nowrap;
}

/* The design's underline: one 1px accent hairline hugging the words. A border
   on an inner span rather than text-decoration on the target, for the reason
   spelled out in "In-text links" at the top of the sheet. */
.region-suppressed-out-label {
  padding-bottom: 1px;
  border-bottom: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
}

.region-suppressed-out:hover {
  color: var(--accent-text-strong);
  text-decoration: none;
}

.region-suppressed-out:hover .region-suppressed-out-label {
  border-bottom-color: var(--accent-text-strong);
}

/* …and the hairline that keeps it off the end of the count. The two readouts
   before it are chained with "·" because they are the same kind of thing (two
   numbers about this result set); the note is a different kind — a footnote
   about the filter, carrying its own sentence and its own link — so it takes
   the stronger divider, the same 1px rule .filter-meta-sep draws between
   Advanced and the chips one row up. Without it the line read as one run-on
   sentence ("…55 in your collection 50 more matches in other regions").

   flex: none because a 1px flex item is otherwise free to shrink to nothing
   once the row is tight, which is exactly when the divider is needed most. */
.results-meta-sep {
  flex: none;
  width: 1px;
  height: 0.875rem;
  background: var(--border);
}

.filter-chips {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The row is always in the DOM now (it is an out-of-band swap target), so with
   no filters applied it would still occupy the row. :empty is no use here: the {% for %} that draws no chips still leaves whitespace inside
   the <ul>, so the test has to be "holds no chip", not "holds no nodes". */
.filter-chips:not(:has(li)) {
  display: none;
}

/* …and the hairline before it would divide the disclosure from nothing.
   NB: the test has to hang off .filter-meta rather than reading
   `:has(+ .filter-chips:not(:has(li)))` off the separator — :has() may not be
   nested inside :has(), so that selector is invalid and the whole rule is
   dropped. */
.filter-meta:not(:has(.filter-chips li)) .filter-meta-sep {
  display: none;
}

.filter-chips li {
  display: inline-flex;
}

/* The catalog's chip rail is drawn twice (issue #1281): the canonical row in
   .filter-meta, and this phone twin under §C's Filters/Sort/view control row.
   That row lives in the swapped #catalog-results region while the meta row
   lives in the filter form, and `order` cannot reach across two DOM parents —
   the same constraint §C already documents for the Filters trigger itself — so
   the rail is rendered in both places and each width shows exactly one, the way
   the result count already is. This is the copy desktop does not draw.

   Two classes, not one, so the hide outranks the `.filter-chips` display the
   ≤720 block sets 15,000 lines below rather than depending on which of two
   equally specific rules the cascade happens to read last. */
.filter-chips.filter-chips--handheld {
  display: none;
}

/* The applied-filter chip (design "Catalog Refined"): a squared, filled tag —
   the axis in tracked mono caps, then the value it applies — with the ✕
   quieter than either. ONE chip serves both list pages: the catalog draws it
   under the search bar and the collection in its results row, but a chip that
   changed shape between two pages of the same filter apparatus read as two
   different controls (the collection's arrived with its own fill, radius and
   body-font value in #1058, the catalog kept a mono pill from #354).

   It overrides `.chip` wholesale — the markup carries both classes, and
   `a.chip--removable` outweighs the bare `.chip` pill on every property here. */
a.chip--removable {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  /* min-height, NOT height: the handheld block lifts the chip to a 36px touch
     target through min-height, which a fixed height silently outranks — which
     is how the collection's 26px chip went on missing that target. */
  min-height: 1.75rem;
  box-sizing: border-box;
  padding: 0 0.4rem 0 0.55rem;
  border: 1px solid var(--border);
  border-radius: 3px;
  background: var(--fill);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  /* --text-muted, not the --text-faint the mock draws (#a09a8a vs #6f6a5e):
     the chip's own --fill tint lifts the background out from under the faint
     tone, compositing to 4.48:1 against it — under the 4.5 floor for 12px
     text, and lower again on the --chip hover fill. Muted clears it at 6:1 on
     both. Carried over from #1058, which measured it. */
  color: var(--text-muted);
  white-space: nowrap;
  text-decoration: none;
  transition:
    border-color var(--speed) ease,
    background var(--speed) ease,
    color var(--speed) ease;
}

/* Hover lifts the chip toward the page's own text colour rather than the
   accent: the accent means "applied" elsewhere in this row (the facet counts,
   the Apply link), and a chip that turned green under the cursor read as
   turning ON the filter it is there to remove. */
a.chip--removable:hover,
a.chip--removable:focus-visible {
  border-color: var(--border-strong);
  background: var(--chip);
  color: var(--text);
  text-decoration: none;
}

/* Axis and value share one mono run — the caps and the tracking are what tell
   them apart, which is the design's whole chip in one line ("PLATFORM SNES").
   Both pages emit the same two spans, so this needs no per-page variant. */
.chip--removable .chip-axis {
  text-transform: uppercase;
}

/* The ✕ keeps the faint tone the label could not: it is aria-hidden decoration
   beside a link whose accessible name already says "Remove filter …", so the
   4.5:1 floor for text does not bind it, and the design wants it quieter than
   the label it follows. */
.chip--removable .chip-x {
  color: var(--text-faint);
}

.chip--removable:hover .chip-x,
.chip--removable:focus-visible .chip-x {
  color: inherit;
}

/* --- The region chip (issue #1271, design "Region Chip Spec" 15) ------------
   The #779 region-suppression call-out, said by the chip that causes it:
   "REGION NTSC-U · 50 HIDDEN ✕". It is the ordinary removable chip plus three
   things — a stronger resting border, a separator, and a count segment — so it
   follows the row for free if the row is ever restyled, which is the whole
   argument for the chip over a bespoke readout line.

   🚨 The hook is on the <li>, not on the chip: `a.chip.chip--removable` is
   already a known-short entry in tests/ui/touch_baseline.json (36px pill, 44px
   row band), and a modifier on the anchor would mint a second signature for the
   same box — a NEW finding against the #1289 ratchet, for a control whose
   measurements nothing here changes.

   Two classes, so it outranks the base `a.chip--removable` (0,1,1) it
   overrides. */
.chip-region .chip--removable {
  /* Louder at rest than an inert chip: this one is reporting something, and on
     a phone the resting state is the only appearance most people ever see
     (there is no hover on touch). */
  border-color: var(--border-strong);
  color: var(--text);
}

/* The design sets the label brighter than the count, and the tokens that carry
   that relationship here are --text over --text-muted, NOT the mock's
   --text-secondary over --text-faint: the chip has its own `--fill` tint, and
   #1058 measured --text-faint compositing to 4.48:1 against it — under the 4.5
   floor for text this size. Same trap the base chip's own colour note records;
   the spec's 4.9:1 is measured against the page, on a chip drawn with no fill. */
.chip-region .chip-count {
  color: var(--text-muted);
}

/* Decorative, aria-hidden: the separator's job is to keep "NTSC-U" and
   "50 HIDDEN" from reading as one string, not to be legible itself. */
.chip-region .chip-sep {
  color: var(--border-strong);
}

/* In flight. htmx puts .htmx-request on the element that issued the request, so
   this is the pending state without a line of JS: the chip is NOT removed
   optimistically — if the swap fails it has to still be there, with the count
   it had. app.js sets the matching aria-busy. */
.chip-region .chip--removable.htmx-request {
  opacity: 0.55;
}

/* ---------------------------------------------------------------------------
 * Results toolbar + tables + pagination
 * ------------------------------------------------------------------------- */

.results-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  /* The hairline under the readout row (design "Catalog Refined") separates the
     filter apparatus from the results it describes — the one rule that tells
     you where the controls stop and the grid starts. */
  margin-bottom: 0.85rem;
  padding-bottom: 0.7rem;
  border-bottom: 1px solid var(--border);
}

/* Matches the count both as a direct child (collection) and nested inside the
   catalog's .results-meta wrapper, which pairs it with the filter chips. */
.results-toolbar p {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  color: var(--text-muted);
}

.results-toolbar select {
  width: auto;
  margin: 0;
  border: 0;
  padding-block: 0.3rem;
  font-size: var(--fs-field);
  color: var(--text-muted);
  background-color: transparent;
}

/* Right-hand toolbar cluster: the grid-mode Sort menu + the page-size selector. */
.toolbar-controls {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

/* ---- The page-size selector's no-JS twin (issue #1470) -------------------
 * A <select> cannot navigate on its own, and this one lives in the swapped
 * toolbar, which holds no submit — so without JavaScript choosing a size did
 * nothing until the visitor found "Apply filters" back up in the filter bar.
 * The template ships the sizes again as links inside a <noscript>, which commit
 * themselves; the same trick the view switcher's twin uses, and the same test
 * for "no JavaScript": the links are in the DOM only when scripting is off, so
 * their presence stands the select down. The select is hidden rather than
 * dropped, so the filter form keeps serializing per_page as it always has.
 *
 * 🚨 As with the view switcher: never give the <noscript> element itself a
 * `display` — see the note by .view-switch. */
.toolbar-controls:has(.page-size-links) select[name="per_page"] {
  display: none;
}

/* The mono readout voice of the row it joins — the count on its left and
   GRID | LIST on its right — with the sizes as the only ink that changes. */
.page-size-links {
  display: inline-flex;
  align-items: center;
  gap: 0.15rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  color: var(--text-faint);
}

.page-size-link {
  display: inline-flex;
  align-items: center;
  /* The app's touch floor. These rows are 40–48px tall, so it costs nothing
     here and makes the links real targets on a no-JS phone. */
  min-height: 44px;
  padding-inline: 0.4rem;
  border-radius: 3px;
  color: var(--text-muted);
}

.page-size-link:hover {
  color: var(--text);
  text-decoration: none;
}

.page-size-link[aria-current] {
  background: var(--border);
  color: var(--text);
}

.page-size-link:focus-visible {
  outline: 2px solid var(--accent-text);
  outline-offset: -2px;
}

.page-size-links-unit {
  padding-inline-start: 0.25rem;
}

/* ---- The shelves grouping segs' no-JS twins (issue #1483) ----------------
 * "Shelves by" and "Order" are .search-seg controls — an invisible <select>
 * stretched over a .seg-value the SERVER renders — and app.js is what keeps the
 * two in step. So with scripting off they failed the #1470 way (no submit in the
 * swapped toolbar) AND misreported: the span went on naming the old axis while
 * the select held the new one, which reads as a rejection rather than as "press
 * something". The template ships each one again as a <details> of plain links
 * inside a <noscript> — links commit themselves, and can only state what they
 * will apply, so both halves go at once.
 *
 * Sort's shape, one cell along, not the page-size twin's inline row: eight
 * option labels laid out flat would outgrow the row well above 721. The links
 * exist only when scripting is off, which is the same test #1470 uses to stand
 * the real control down — the select is HIDDEN, not dropped, so the filter form
 * keeps serializing the grouping and "Apply filters" still round-trips it.
 *
 * 🚨 The <noscript> element itself takes no `display` here (`none` at ≤720 is
 * the one safe value) — see the note by .view-switch. */
.toolbar-controls:has(.seg-picker) .search-seg--shelves {
  display: none;
}

/* The summary wears .search-seg itself (see the template's note), so the twin
   inherits `.toolbar-controls .search-seg` and cannot drift from the control it
   stands in for. That leaves exactly one thing to undo: .column-picker draws a
   caret of its own from summary::after, and the seg already draws one as an
   icon-text SVG in .seg-caret. The two are different sizes, and two carets is
   one too many.

   Everything else .column-picker > summary asks for is either wanted (the
   marker reset) or already out-specified by the seg rule, (0,2,0) over (0,1,1)
   — including the bordered-button padding. Its border-radius survives and is
   inert: nothing paints a border or a background behind it. */
.seg-picker > summary::after {
  content: none;
}

/* The panel is the sort menu's list, narrower: these options are single words
   where a sort is a phrase. */
.seg-picker .picker-panel {
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
  min-width: 11rem;
}

.seg-picker .picker-panel a,
.seg-picker .picker-panel .seg-option-off {
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  white-space: nowrap;
}

.seg-picker .picker-panel a:hover {
  color: var(--accent-text);
  text-decoration: none;
}

.seg-picker .picker-panel a[aria-current="true"] {
  color: var(--accent-text);
  font-weight: 600;
}

/* An order this axis doesn't offer (issue #860). The <option> draws disabled
   and the phone sheet draws it dimmed; here it is a <span>, so there is no href
   to follow and nothing in the tab order. */
.seg-picker .picker-panel .seg-option-off {
  color: var(--text-faint);
  cursor: default;
}

/* Enough active filter chips and .results-meta's hypothetical width outgrows the
   row on its own. Flex breaks lines on hypothetical sizes (before any shrinking),
   so the meta block takes a line to itself and the controls drop to a line below
   — where justify-content: space-between has a single item to distribute and
   leaves it flush LEFT, dragging Sort / per-page / GRID|LIST under the count.
   The auto margin eats whatever space is left on whichever line the cluster
   lands on, so it stays at the row's right edge either way; on the unwrapped
   line it resolves to the same place space-between already put it.

   Desktop only: on the handheld layer the chips claim their own full-bleed line
   (§2A) and the controls read as a left-aligned strip under them, which is the
   shape those screens were drawn in. */
@media (min-width: 721px) {
  .toolbar-controls {
    margin-left: auto;
  }
}

/* "Sort" menu (issue #240): quiet text control — "Sort Title A–Z ▾" — whose
   <details> popover lists the sort links (design 05.2). */
.sort-picker > summary {
  border: 0;
  padding: 0.3rem 0;
  color: var(--text-muted);
}

.sort-picker .sort-current {
  color: var(--text);
}

.sort-picker .picker-panel {
  left: auto;
  right: 0;
  min-width: 10.5rem;
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
}

.sort-picker .picker-panel a {
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  white-space: nowrap;
}

.sort-picker .picker-panel a:hover {
  color: var(--accent-text);
  text-decoration: none;
}

.sort-picker .picker-panel a[aria-current="true"] {
  color: var(--accent-text);
  font-weight: 600;
}

/* Results table: hairlines only — no surrounding box (spec 06: hairlines over
   boxes, alignment over decoration). */
.table-wrap {
  overflow-x: auto;
  border-top: 1px solid var(--border-strong);
}

table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-ui);
}

/* The catalog row's folded-platforms suffix ("+1 more"). Kept unbreakable so
   the line never splits between "+1" and "more" — it sits on the platform
   line or moves to the next line whole. */
.more-suffix {
  white-space: nowrap;
}

thead th {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  text-align: left;
  color: var(--text-muted);
  padding: 0.75rem 0.9rem 0.75rem 0;
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}

/* Sort-header links fill the whole header cell so a click anywhere on the
   column header hits the link — the mono header text is narrow, and a
   text-width anchor would leave most of the cell inert. */
thead th a {
  display: block;
  color: inherit;
}

thead th a:hover {
  color: var(--accent-text);
  text-decoration: none;
}

thead th[aria-sort="ascending"] a,
thead th[aria-sort="descending"] a {
  color: var(--accent-text);
}

tbody td {
  padding: 0.68rem 0.9rem 0.68rem 0;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}

tbody tr {
  transition: background var(--speed) ease;
}

tbody tr:hover {
  background: var(--row-hover);
}

tbody td a {
  color: var(--text);
  font-weight: 500;
}

tbody td a:hover {
  color: var(--accent-text);
}

/* Physical/digital availability badge in the catalog's Availability column. */
.availability-badge {
  display: inline-block;
  padding: 0.15rem 0.55rem;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.03em;
  color: var(--text-muted);
  white-space: nowrap;
}

/* No distribution-format evidence (issue #222): visually de-emphasized so an
   honest "we don't know" reads differently from an evidence-backed format. */
.availability-badge--unknown {
  border-style: dashed;
  color: var(--text-faint);
}

/* Browse-hiding flag badge next to a list row's title (issues #249, #1380): marks
   WHY an opted-in catalog row was hidden, and why a flag-narrowed collection row
   matched. Drawn by the {% browse_flags %} tag from Game.browse_flags — the flag
   vocabulary lives there, not here. These rules key on the flag FAMILY (the
   modifier is the family; data-flag names the flag), so a new label in an
   existing family needs no stylesheet change and a per-label selector is never
   written. Same color scheme as the detail page's flag chips (.chip--flag-*) —
   one vocabulary, two pill scales: brass = provenance (spec 02: brass =
   secondary highlights/warnings; unlicensed was subdued before #1380, but one
   family reads as one color), danger = shovelware (the quality judgment), and
   the dashed muted ghost = unreleased (the absence idiom, same as
   .availability-badge--unknown). */
.flag-badge {
  display: inline-block;
  padding: 0.1rem 0.45rem;
  border: 1px solid var(--border-strong);
  border-radius: 3px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.03em;
  color: var(--text-muted);
  white-space: nowrap;
  vertical-align: middle;
}

.flag-badge--provenance {
  border-color: var(--brass);
  color: var(--brass);
}

.flag-badge--shovelware {
  border-color: var(--danger-border);
  color: var(--danger);
}

.flag-badge--unreleased {
  border-style: dashed;
}

/* The notability tier (issue #1671): absence of evidence, not a quality
   judgment — the ghost family's other member, dotted where unreleased is
   dashed so the two absences stay tellable apart at a glance. */
.flag-badge--low_traction {
  border-style: dotted;
}

/* Copy-condition badge next to a collection entry's title (issues #226/#229):
   a subdued pill marking loose / complete (CIB) / sealed copies in the list. */
.condition-badge {
  display: inline-block;
  padding: 0.1rem 0.45rem;
  border: 1px solid var(--border-strong);
  border-radius: 3px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.03em;
  color: var(--text-muted);
  white-space: nowrap;
  vertical-align: middle;
}

/* The cancelled-port badge that lived here (issue #88) is gone with the one-line
   platform chip that carried it (spec 17, issue #1220): the panel's only
   consumer now states the fact as "Cancelled Platforms" 1a — a muted name and a
   mono "(cancelled)" — so a pill with a border and a nowrap is neither drawn nor
   wanted. See .ref-platform--cancelled. */

/* "Collector details" disclosure on the entry edit form (issues #226/#229). */
.collector-details {
  margin: 0.5rem 0 1rem;
}

.collector-details > summary {
  cursor: pointer;
  color: var(--text-muted);
  font-size: var(--fs-body);
}

/* Pagination: bare mono line — "← Previous · Page 1 of 12 · Next →" (spec 05.2:
   no boxes). */
.pagination ul {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.25rem;
  list-style: none;
  margin: 1.75rem 0 0;
  padding: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

/* Numbered pages (design "Catalog Refined"). Prev/Next keep the sentence voice;
   the numbers between them are bordered mono cells, the current one in leaf.
   The tighter gap is what lets a run of numbers read as one control rather than
   as separate links. */
.pagination ul {
  gap: 0.5rem;
}

.pagination a,
.pagination .pagination-current {
  display: inline-flex;
  align-items: center;
  min-height: 1.9rem;
  padding: 0.25rem 0.7rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  white-space: nowrap;
}

.pagination a:hover,
.pagination a:focus-visible {
  border-color: var(--border-strong);
  color: var(--text);
  text-decoration: none;
}

/* The page you are on: leaf, and not a link. */
.pagination .pagination-current {
  border-color: var(--accent-border-strong);
  background: var(--accent-dim);
  color: var(--accent-text);
}

/* Prev/Next are the row's ends, in the UI font rather than mono. */
.pagination [data-testid="pagination-prev"],
.pagination [data-testid="pagination-next"] {
  font-family: var(--font-body);
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  color: var(--text);
}

/* The elision marker is inert text, so it carries no cell chrome. */
.pagination .pagination-gap {
  padding: 0 0.1rem;
  color: var(--text-faint);
}

.empty-state {
  padding: 2.5rem 1rem;
  text-align: center;
  color: var(--text-muted);
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
}

/* Quiet empty states (spec 05.5): dashed hairline, mono caption, one sans
   sentence — never illustrated, never apologetic. */
.empty-state .empty-caption {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
  margin: 0 0 0.5rem;
}

.empty-state--stack p:not(.empty-caption) {
  font-size: var(--fs-ui);
  max-width: 20rem;
  margin: 0 auto 0.75rem;
}

.empty-state--stack p:last-child {
  margin-bottom: 0;
}

/* Inline variant: caption and sentence share one baseline (spec 05.5 backlog). */
.empty-state--row {
  display: flex;
  align-items: baseline;
  justify-content: center;
  flex-wrap: wrap;
  gap: 0.65rem;
  padding: 1.6rem 1.5rem;
}

.empty-state--row p {
  margin: 0;
  font-size: var(--fs-ui);
}

.empty-state--row .empty-caption {
  margin: 0;
}

/* --- The region filter hid everything (issue #1271, design "Region Chip Spec"
   15 §D, carrying Spec 14 §E) ------------------------------------------------
   Not a variant of `.empty-state` above: that one is the quiet dashed box for
   "there is nothing here", and this state is the opposite — there ARE matches,
   they are one tap away, and the block exists to say so and carry the tap. So
   it is a solid card in the surface fill with a real action in it, and it
   replaces "No games found." rather than decorating it.

   Capped at 520px rather than run to the column's full width: it is a statement
   about the filter, and a 1,200px-wide sentence is not a statement. Same block
   at every width — desktop states the same contradiction, just with more air
   around it.

   🚨 CENTRED, which reverses design spec 15 §D's "left-aligned" (Max's call,
   2026-08-10, after seeing it merged): a 520px card pinned to the left edge of a
   ~1,120px results column leaves 600px of nothing beside it and reads as a
   layout that failed to finish, not as a deliberate cap. Do not "restore" the
   left alignment to match the spec text.

   `margin-inline: auto` needs no media query to stay phone-correct: below 721
   the column is narrower than the cap, so the block already fills it and the
   auto margins resolve to zero. The text inside stays left-aligned at every
   width — the block reads eyebrow → statement → aside → action, and centring
   the ragged lines of that stack would make it a poster instead. */
.results-empty-region {
  max-width: 520px;
  margin-top: 12px;
  margin-inline: auto;
  padding: 20px 18px;
  border: 1px solid var(--border);
  border-radius: 7px;
  background: var(--surface);
}

/* The cause, in the readout voice the results line beside it uses. */
.results-empty-region-eyebrow {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The one sentence that replaces the two contradicting ones, in display type
   because it IS the page's statement here, not a caption on one. */
.results-empty-region-line {
  margin: 8px 0 4px;
  font-family: var(--font-display);
  font-size: 1.3125rem;
  line-height: 1.35;
  letter-spacing: -0.01em;
  color: var(--text);
  text-wrap: pretty;
}

/* --fs-ui-sm (13/14px), the reading token nearest the mock's 13.5: one step
   below the action it sits above, so the block reads cause → statement →
   aside → way out rather than as two competing sentences. */
.results-empty-region-sub {
  margin: 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.6;
  color: var(--text-faint);
}

/* The way out, named in words — the chip's ✕ above is the same request said
   silently. Outline rather than the solid accent: solid is reserved for account
   and commit actions (design "Signed Out Action Bar Spec" 13) and this is a
   filter change. Full width and 46px at every width: it is the only control in
   the block, and it clears the §03 touch floor on its own rather than by being
   named in that block's selector list — which it could not join anyway, since
   that rule's `display: inline-flex` would take the full-width box away. */
.results-empty-region-action {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 46px;
  margin-top: 16px;
  padding: 0 1rem;
  border: 1px solid color-mix(in srgb, var(--accent-text) 55%, transparent);
  border-radius: 4px;
  font-size: var(--fs-ui);
  font-weight: 600;
  color: var(--accent-text);
  text-align: center;
  text-decoration: none;
}

.results-empty-region-action:hover {
  border-color: var(--accent-text);
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  color: var(--accent-text-strong);
  text-decoration: none;
}

/* ---------------------------------------------------------------------------
 * Quick-add control (catalog rows + game detail) + status pills
 * ------------------------------------------------------------------------- */

.quick-add {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-size: var(--fs-ui-sm);
  white-space: nowrap;
}

.quick-add-form {
  display: inline;
  margin: 0;
}

/* Ownership status bar (design "Catalog Card States" 05.2): the tracked states
   render as a fixed-height bar occupying the exact slot the Add/Wish buttons
   do — 30px tall including the border, mono status label left, exactly one
   action right, one line, never wrapping. Owned = leaf-tinted; wishlist = the
   quieter neutral variant below. */
.quick-add-bar {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 30px;
  box-sizing: border-box;
  padding: 0 8px;
  border-radius: 3px;
  background: var(--accent-dim);
  border: 1px solid var(--accent-border);
  min-width: 0;
}

/* Status label, in the mono data voice (spec 04: leaf = ownership). It
   truncates before the action does (the action is flex-shrink: 0). */
.quick-add-bar-label {
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0;
  text-transform: uppercase;
  color: var(--accent-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Same trap as the action below, one step worse: on paper the plain leaf sits
   at 4.34:1 against the bar's own leaf-tinted fill (issue #1028's 4.5:1 floor)
   — 11px mono has no large-text allowance to fall back on. Light steps the
   label to the darker leaf (5.10:1); dark already clears it at 6.4:1, and the
   wishlist bar paints its label --text, so neither is touched. */
html[data-theme="light"] .quick-add-bar--owned .quick-add-bar-label {
  color: var(--accent-text-strong);
}

/* The bar's single right-side action ("Manage" link / "＋ Own" button). The
   wrapping form (wishlist state) or the link itself is the flex item pinned
   right; neither ever shrinks or wraps. */
.quick-add-bar > a.quick-add-bar-action,
.quick-add-bar > .quick-add-bar-form {
  margin-left: auto;
  flex-shrink: 0;
}

.quick-add-bar-form {
  display: flex;
  margin: 0;
}

.quick-add-bar-action {
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  flex-shrink: 0;
}

a.quick-add-bar-action {
  color: var(--text-muted);
}

a.quick-add-bar-action:hover {
  color: var(--text);
  text-decoration: none;
}

/* The muted tone sits a hair under AA (≈4.49:1) on the light theme's
   leaf-tinted bar (issue #528's 4.5:1 floor), so light promotes the action to
   the full text tone — still quiet at this size. */
html[data-theme="light"] a.quick-add-bar-action {
  color: var(--text);
}

/* "＋ Own" converts wishlist → owned in one click: a leaf text action, resetting
   the global button/submit chrome (like .toast-undo). */
button.quick-add-bar-action {
  display: inline;
  padding: 0;
  border: 0;
  background: none;
  color: var(--accent-text);
  font-family: var(--font-body);
}

button.quick-add-bar-action:hover {
  background: none;
  color: var(--accent-text-strong);
}

/* Wishlist: same bar geometry, quieter voice — faint neutral fill, hairline
   edge, text-colored label; the leaf lives on the "＋ Own" action instead. */
.quick-add-bar--wishlist {
  gap: 4px;
  padding: 0 6px;
  background: var(--fill);
  border-color: var(--border);
}

.quick-add-bar--wishlist .quick-add-bar-label {
  color: var(--text);
}

/* Mark-as-owned disambiguation picker (issue #75): a game wishlisted more than
   once asks which copy was acquired instead of upgrading every entry. It holds
   more buttons than the other states, so let it wrap instead of overflowing the
   table cell's nowrap layout. */
.quick-add-picker {
  flex-wrap: wrap;
  white-space: normal;
}

.quick-add-picker .quick-add-prompt {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-muted);
}

/* The shared control rendered above the add-to-collection form on the game
   detail page needs block spacing it doesn't get inside a table cell. */
.card .quick-add {
  margin-bottom: 1.1rem;
}

/* ==========================================================================
   GRID CARD ACTION BAR (design "Catalog Action Bar Spec" 09)
   ==========================================================================
   One bar shape across all three states, on both the catalog grid card
   (_grid_action_bar.html) and the My Collection grid card
   (_entry_action_bar.html), so the eye never re-parses a card:

     not owned   [＋ Add | ▾]  [♡ Wish]
     wishlisted  [＋ Add | ▾]  [♥ Wish]
     owned       [Edit copy   | ▾]        full width, no sibling

   §B's measurements are the contract: 36px bar, 6px gap, 28px caret joined to
   the primary behind a 1px divider, 3px outer radius with the joined edges
   square, Wish at min-width 36px / padding 0 9px.

   These are NEW classes rather than a rewrite of .quick-add-bar: the catalog's
   list rows, the game detail page and the recommendation rows all still render
   that control at its 30px geometry, and the two must not collide. */
.card-bar {
  display: flex;
  align-items: stretch;
  gap: 6px;
  height: 36px;
  min-width: 0;
}

/* The primary and its caret are one object — they share an edge, so the group
   owns the outer radius and the halves square off where they meet. */
.card-bar-group {
  display: flex;
  flex: 1;
  min-width: 0;
}

/* Owned: "primary spans the full card, no gap, no sibling" (§B). Nothing to
   size against, so the group simply is the bar. */
.card-bar-group--full {
  flex: 1 1 100%;
}

.card-bar-form {
  display: flex;
  flex: 1;
  min-width: 0;
  margin: 0;
}

/* 🚨 The Wish switch's wrapping form is a DIRECT child of the bar, so it picks
   up the `flex: 1` above — which is right for the ＋ Add form nested inside
   .card-bar-group, and wrong here. Both of the bar's children then grew from a
   0 basis, so the bar split 50/50 no matter how wide the card was, and the
   switch (`flex: 0 0 auto`, correctly content-sized) sat stranded inside a
   half-width form: 23px of dead space at the card's right edge on §B's own
   186px card, 80px at 300px, and worse again below 721 where the switch is
   pinned to 44px. §B draws Wish as a no-flex span at min-width 36px /
   padding 0 9px, with the primary group taking every remaining pixel — so the
   form must hug its button and let .card-bar-group absorb the slack.

   The child combinator is what separates the two roles, and it costs one class
   of specificity (0,2,0 over 0,1,0) rather than a modifier on both templates.
   `flex-shrink: 0` is deliberate: the switch carries the bar's touch target
   (36px, 44 below 721) and must not be squashed under it. The primary is the
   half that yields — .card-bar-main already ellipsizes. */
.card-bar > .card-bar-form {
  flex: 0 0 auto;
}

/* --- The primary half ---------------------------------------------------- */
.card-bar-main {
  flex: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  height: 100%;
  padding: 0 8px;
  border: none;
  border-radius: 3px 0 0 3px;
  font-family: inherit;
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  text-align: center;
  text-decoration: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
}

/* Solid (＋ Add / ＋ Own) — the one-click path into the collection.

   These carry the .card-bar prefix on purpose. The site's base button rule is
   `button[type="submit"], .btn-primary { background: var(--text) … }` — an
   attribute selector, so specificity (0,1,1), which OUTRANKS a lone class and
   would paint every submit-backed control in this bar as the black primary
   button. Same trap as the `li a:not(…)` rule that has bitten the chip styles.
   Two classes (0,2,0) beat it outright, without depending on source order. */
.card-bar .card-bar-main--solid {
  background: var(--accent);
  color: var(--accent-ink);
}

.card-bar .card-bar-main--solid:hover {
  background: var(--accent-strong);
  color: var(--accent-ink);
}

.card-bar .card-bar-main--solid:active {
  /* §C's active step: one stop darker than rest, in both themes. */
  filter: brightness(0.92);
}

/* Tinted (Edit copy) — already owned, so the bar states a fact rather than
   offering a fill-coloured action. */
.card-bar-main--tinted {
  background: var(--bar-tint);
  border: 1px solid var(--bar-edge);
  border-right: none;
  color: var(--accent-text-strong);
}

.card-bar-main--tinted:hover {
  background: var(--bar-tint-hover);
  border-color: var(--bar-edge-hover);
  color: var(--accent-text-strong);
}

/* Lone: the owned primary with NO caret beside it — the several-copies bar,
   which is one link to My Collection and has no menu to join (issue #1199).
   .card-bar-main squares its right corners and --tinted drops its right border
   because the caret carries both; with nothing there to carry them, they come
   back. Source order matters: this is a single class like the two rules it
   corrects, so it has to sit after them. The :hover above is (0,2,0) and sets
   border-color on all four sides, so the hover edge needs nothing here. */
.card-bar-main--lone {
  border-right: 1px solid var(--bar-edge);
  border-radius: 3px;
}

/* --- The caret half ------------------------------------------------------ */
/* 28px, joined to the primary, divided by a hairline (§B). It is a <summary>,
   so the default marker has to go or the triangle doubles up. */
.card-bar-caret {
  flex: 0 0 28px;
  width: 28px;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 0 3px 3px 0;
  cursor: pointer;
  list-style: none;
}

.card-bar-caret::-webkit-details-marker,
.card-bar-caret::marker {
  display: none;
  content: "";
}

/* The ▾ itself is the registry chevron as a mask, the idiom every caret
   shares since #1383 (this was a clip-path triangle while the text carets
   around it were ▾ glyphs). */
.card-bar-caret::after {
  content: "";
  width: 9px;
  height: 5px;
  background: currentColor;
  -webkit-mask: var(--icon-caret) center / contain no-repeat;
  mask: var(--icon-caret) center / contain no-repeat;
}

/* Open flips it to ▴ (§C). */
details[open] > .card-bar-caret::after {
  transform: rotate(180deg);
}

/* On the not-owned bar the caret continues the solid primary. */
.card-bar-popover > .card-bar-caret {
  background: var(--accent);
  color: var(--accent-ink);
  border-left: 1px solid var(--bar-divider);
}

.card-bar-popover > .card-bar-caret:hover {
  background: var(--accent-strong);
}

/* On the owned bar it continues the tinted one, and carries the group's own
   right-hand border (the primary dropped its border-right to make the join). */
.bar-menu > .card-bar-caret {
  background: var(--bar-tint);
  border: 1px solid var(--bar-edge);
  border-left: 1px solid var(--bar-edge);
  color: var(--accent-text-strong);
}

.bar-menu > .card-bar-caret:hover {
  background: var(--bar-tint-hover);
  border-color: var(--bar-edge-hover);
}

/* Open state (§C): the whole tinted object steps up together, so the primary
   half tracks its sibling caret rather than staying at rest. */
.bar-menu[open] > .card-bar-caret {
  background: var(--bar-tint-open);
  border-color: var(--bar-edge-open);
}

.card-bar-group:has(.bar-menu[open]) .card-bar-main--tinted {
  background: var(--bar-tint-open);
  border-color: var(--bar-edge-open);
}

/* --- The Wish switch ----------------------------------------------------- */
/* Two classes again, for the same reason as the solid primary above. */
.card-bar .card-bar-wish {
  flex: 0 0 auto;
  min-width: 36px;
  height: 100%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  padding: 0 9px;
  border: 1px solid var(--border-strong);
  border-radius: 3px;
  background: none;
  color: var(--text-muted);
  font-family: inherit;
  font-size: 11.5px;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
}

/* 🚨 `background` and `opacity` are restated here on purpose, and removing
   either brings the bug back. The rest rule above is (0,2,0), which loses to
   `button[type="submit"]:hover` (0,2,1) — the SAME trap the solid primary is
   guarded against, except a hover rule cannot be guarded by prefixing alone:
   the base rule only applies while hovered, so it out-specifies the REST rule,
   not this one, and every property this rule leaves undeclared falls through to
   it. Unstated, the plate took the base primary's `background: var(--text)`
   while `color` below (which does win at (0,3,0)) painted the ink var(--text)
   too — Wish went to a solid block with no label and no ♡. `opacity: .9` leaks
   the same way and dims the control against the 0.6 pending state. */
.card-bar .card-bar-wish:hover {
  background: none;
  border-color: var(--text-muted);
  color: var(--text);
  opacity: 1;
}

/* Switched on: the plate fills and the label bolds, so the state survives a
   greyscale print and never rests on the glyph alone. */
.card-bar .card-bar-wish--on {
  background: var(--bar-wish-on);
  border-color: var(--bar-wish-on-edge);
  color: var(--text);
  font-weight: 600;
}

/* Hovering the ON switch keeps its fill and steps up one stop, the way the
   tinted primary does — it must not fall back to the off switch's transparent
   plate. Same (0,3,0) as the rule above, so source order decides: this stays
   below it. */
.card-bar .card-bar-wish--on:hover {
  background: var(--bar-wish-on-hover);
  border-color: var(--bar-wish-on-edge);
}

.card-bar-wish-glyph {
  font-size: 14px;
  line-height: 1;
}

/* --- Focus (§C): 2px leaf ring, 2px offset, on EVERY control including the
   caret halves. The offset needs the ring to escape the joined group, so the
   focused half lifts above its sibling. --------------------------------- */
.card-bar-main:focus-visible,
.card-bar-caret:focus-visible,
.card-bar-wish:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  position: relative;
  z-index: 1;
}

/* --- Pending (§E) -------------------------------------------------------- */
/* The spec asks for optimistic repaint; this is the server-rendered half of
   that contract. Every mutating form points hx-indicator at the bar, so the
   whole bar dims and stops taking clicks for the length of the request rather
   than showing a spinner inside a 36px button. */
.card-bar.htmx-request {
  opacity: 0.6;
  pointer-events: none;
}

/* --- The owned menu (§D) ------------------------------------------------- */
/* Card-width, anchored under the bar with a 4px offset, flipping above near
   the viewport bottom — all of which .add-popover--row already does (app.js
   places it `fixed`, which is what lets it escape the grid column's edges).
   What is added here is the menu's own surface and rows. */
/* §D: "card-width", not the 360px the add-with-details form panel needs for
   its field grid — this shares .add-popover-panel's surface and must override
   that width. app.js writes the exact bar width when it places the panel; this
   pair is the no-JS floor (100% of the 28px caret, so the min-width wins). */
.bar-menu-panel {
  width: 100%;
  min-width: 186px;
  padding: 5px;
  display: flex;
  flex-direction: column;
}

/* The copy summary — "PS5 · SEALED", or "3 COPIES". A label, not a target. */
.bar-menu-head {
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-faint);
  padding: 6px 7px 5px;
}

.bar-menu-rows {
  display: flex;
  flex-direction: column;
}

/* .bar-menu-panel prefix: the destructive rows are submit buttons, so they hit
   the same `button[type="submit"]` (0,1,1) rule as the primary above. */
.bar-menu-panel .bar-menu-row {
  display: flex;
  align-items: center;
  /* The global `button { justify-content: center }` silently centres any
     flex-display button that doesn't restate this, which would leave the
     form-backed rows centred while the <a> rows sat left. */
  justify-content: flex-start;
  width: 100%;
  padding: 8px 7px;
  border: none;
  border-radius: 3px;
  background: none;
  font-family: inherit;
  font-size: 11.5px;
  font-weight: 400;
  line-height: 1.3;
  text-align: left;
  text-decoration: none;
  color: var(--text);
  cursor: pointer;
}

.bar-menu-panel .bar-menu-row:hover,
.bar-menu-panel .bar-menu-row:focus-visible {
  background: var(--chip);
  color: var(--text);
}

.bar-menu-panel .bar-menu-row:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* The only red row, and the only one that asks anything (§D). */
.bar-menu-panel .bar-menu-row--danger {
  color: var(--danger);
}

.bar-menu-panel .bar-menu-row--danger:hover,
.bar-menu-panel .bar-menu-row--danger:focus-visible {
  background: var(--danger-dim);
  color: var(--danger);
}

.bar-menu-rule {
  height: 1px;
  background: var(--border);
  margin: 4px 0;
}

/* The wrapping forms are layout-transparent: the rows are the targets. */
.bar-menu-rows form {
  margin: 0;
  display: contents;
}

/* The menu's phone chrome is in the markup at every width (the server cannot
   know the viewport), and none of it belongs to the desktop dropdown: the grab
   handle, the identity header, the leading row icons and Cancel are the ≤720
   action sheet's (issue #1198, and see the block beside the collection sheet's
   own rules). Hidden HERE, in a min-width query, rather than by a base rule
   plus a ≤720 un-hide: the sheet's styling is the collection's single-class
   .entry-* rules, and a (0,2,0) base hide would out-specify every one of them.
   The .bar-menu-panel prefix keeps this off the collection card's ⋯ sheet,
   which shares the class names and must keep painting them. */
@media (min-width: 721px) {
  .bar-menu-panel .entry-sheet-handle,
  .bar-menu-panel .entry-sheet-identity,
  .bar-menu-panel .entry-menu-icon,
  .bar-menu-panel .entry-menu-cancel {
    display: none;
  }
}

/* The bar fills the card's action slot, which pins to the card's bottom edge
   so bars align across a grid line regardless of title length. */
.browse-card .card-bar {
  width: 100%;
}

/* …and the ledger's action slot on the recommendations page, which is a flex
   item rather than a block (_rec_desktop_action_bar.html). Same bar, same 36px
   geometry, only the width context differs — as it does between the catalog
   card and the collection card. */
.rec-control-desktop .card-bar {
  flex: 1 1 0;
  min-width: 0;
}

/* …and the LIST rows on both browse pages, where the slot is a table cell
   (the catalog's Actions column and My Collection's). One rule for the two:
   the bars differ in what they post, never in how they are sized.

   An explicit width rather than the cell's own, and it is load-bearing in a
   way the card and ledger rules are not. A table column is as wide as its
   widest cell, so with `width: auto` the Actions column would be sized by
   whichever state happened to land on the page — a page of untracked games
   would draw a narrower column than one holding a single "Edit 3 copies ↗" —
   and the column would then resize under the reader on every htmx swap that
   changed a row's state, which is the one thing a fixed-height, fixed-slot
   bar exists to prevent.

   172px is the recommendations ledger's slot (--rec-cols' 10.75rem), reused
   because it is the width this same markup is already proven at: it holds
   "Edit 3 copies ↗" and it clears §F's 160px floor, so neither the narrow-card
   bands nor the stack can ever be the right answer here.

   ≥721 only. Below it the table is .table-cards — every row a stacked record,
   every cell full width — so the bar's own auto width is already the right
   one, and pinning 172px there would strand it against a much wider record. */
@media (min-width: 721px) {
  .row-actions-cell .card-bar {
    width: 172px;
  }
}

/* --- Narrow cards, 140–159px (§F) ---------------------------------------
   "The Wish label drops; the toggle becomes a 36px icon-only square with an
   aria-label. Primary keeps its label."

   These are DESKTOP widths here, not handheld ones — the band is reachable at
   both of the wide breakpoints, so it needs implementing rather than deferring
   with the rest of §F's mobile row:

     721–1024  → 4 columns. card = (0.92·vw − 3·14.4) / 4
                 → 160px at vw ≈ 743, so 721–742 lands in the band.
     ≥1025     → 6 columns. card = (0.92·vw − 5·14.4) / 6
                 → 160px at vw ≈ 1122, so 1025–1121 lands in the band.

   (0.92·vw is the container's content width: --gutter is 4vw through this
   whole range, and --container's 76rem cap only bites past 1200. 14.4px is
   .browse-grid's 0.9rem column gap.)

   A container query would express this directly, but `container-type` makes
   the element a containing block for FIXED descendants, and the popover panel
   is fixed and placed from viewport coordinates by app.js — so it would move
   the panel to the wrong place. Media queries keep the panel intact; the
   arithmetic above is the price.

   🚨 .browse-card scopes it, and that prefix is load-bearing rather than
   tidiness. The arithmetic above is .browse-grid's — it says what a CARD
   measures at these viewport widths, and nothing about anyone else's bar. The
   recommendations ledger renders this same bar in a fixed 10.75rem action slot
   (--rec-cols), which is 172px at every one of these widths and has room for
   the label throughout; unscoped, these two bands would blank "Wish" on a row
   that had 60px of space for it. Any future surface that adopts .card-bar
   inherits the same question, and the answer is its own width, not the
   catalog's.

   The prefix pulls double duty: at two classes it also ties the base rest rule
   `.card-bar .card-bar-wish` and wins on source order, so all four
   declarations below actually apply. Bare, only `width` would have — see the
   ≤720 block for the full account of that trap. */
@media (min-width: 721px) and (max-width: 742px), (min-width: 1025px) and (max-width: 1121px) {
  .browse-card .card-bar-wish-label {
    display: none;
  }

  .browse-card .card-bar-wish {
    flex: 0 0 36px;
    width: 36px;
    padding: 0;
    gap: 0;
  }
}

/* --- Handheld (≤720) ------------------------------------------------------
   §F's mobile treatment, in the form its own sentence gives first: "Bar grows
   to 44px and the two controls stack." The alternative in the same sentence —
   "or, where the grid is 2-up, the bar is replaced by the ⋯ sheet already
   specified for the collection grid" — is the catalog's handheld layer, which
   is separate work: spec 09 draws only the OWNED menu, so the untracked and
   wishlisted sheets would have to be invented rather than built (issue #1225).
   Stacking is the spec's other branch, not a holding pattern in disguise, and
   it is what makes both targets real:

     one line, 44px tall (what this used to be)   stacked
       [＋ Add|▾] [♡]                               [＋ Add        | ▾ ]
       primary 50.8 at 320, needing 52              [♡ Wish            ]
       caret pinned to 36 to buy that back

   Pinning the caret to 36 was the only way the one-line bar fitted: giving it
   the 44 the rest of the bar has cost the primary 8px it did not have at 320,
   where ＋ Add already clipped (scrollWidth 52 in 50.8). Stacked, the primary
   gets the whole card less one 44px caret — 93px at 320 — so both defects go
   at every width instead of being traded against each other. The Wish label
   comes back with the room (§B: "label always shown"); the icon-only switch
   was this block's compression, never the spec's.

   🚨 The stack is scoped to .browse-card and the touch sizes are not, and that
   split is the point. 44px targets are a fact about the INPUT DEVICE — any
   surface rendering this bar on a phone wants them, which is why they live in
   the touch-floor block whose query carries the (pointer: coarse) arm (issue
   #1485), not here. Stacking is a fact about WIDTH, exactly like the 140–159px
   bands above, and those bands are scoped for a reason recorded there:
   unscoped, they blanked "Wish" on a recommendations row that had 60px of
   space for it. A future surface adopting .card-bar on a phone inherits the
   same question and answers it from its own width.

   The collection grid needs none of this: its .browse-card-actions is already
   display:none below 721, where the ⋯ sheet takes over. The recommendations
   ledger hides its desktop half there too (spec 10's .rec-bar takes over), so
   today the catalog card is the only surface this block reaches. */
@media (max-width: 720px) {
  /* The bar's 44px height and 44px caret used to open this block, under a
     comment that said "Touch, not width" — and the comment was right where the
     query was wrong: a width query never reaches the coarse-pointer tablet, so
     the 1024px iPad drew the 36px bar (issue #1485). Both rules live in the
     touch-floor block now, which carries exactly that device arm. What remains
     here really is width: the stack below is the catalog card running out of
     room at 320, not a fact about the pointer. */

  /* --- The stack (width) ------------------------------------------------- */
  .browse-card .card-bar {
    flex-direction: column;
    justify-content: flex-start;
    height: auto;
    /* Two 44px bands and the 6px gap the bar already carries between them.
       🚨 min-height, not height, and it is what keeps a grid line straight:
       .browse-card-actions pins to the card's bottom edge, so an owned card —
       one band, no Wish — would otherwise hang its single row 50px below its
       neighbours' primaries. Reserving the stacked height lets every bar start
       at the same y, which is the alignment the fixed-height slot bought when
       both controls shared one line. */
    min-height: 94px;
  }

  /* Each row is one 44px band. In a column the flex-basis is the HEIGHT, so
     these three say "44px tall", not "44px wide" — the width comes from the
     bar's own align-items: stretch. --full is here because the owned group
     declares `flex: 1 1 100%` for the one-line bar, which in a column would
     resolve against the bar's height instead. */
  .browse-card .card-bar-group,
  .browse-card .card-bar-group--full,
  .browse-card .card-bar > .card-bar-form {
    flex: 0 0 44px;
  }

  /* The switch spans its own band. 🚨 Three classes on purpose: the base rest
     rule is `.card-bar .card-bar-wish` (two, because a lone class loses to
     `button[type="submit"]` at (0,1,1)), so a two-class override would only
     tie it and win on source order — and a rule that depends on source order
     against a rule it also has to out-specify is how the #1227 half-applied
     override happened. Padding and gap are deliberately NOT restated: the base
     rule's `0 9px` / `5px` are what §B draws, and they are correct again now
     that the label is back. */
  .browse-card .card-bar .card-bar-wish {
    flex: 1 1 auto;
    width: 100%;
  }
}

/* ==========================================================================
   RECOMMENDATION ROW ACTION BAR (design "Recommendation Row Action Bar Spec" 10)
   ==========================================================================
   Spec 09's grammar one design later, scaled for touch and with ONE
   substitution: the slot the catalog gives to Wish becomes a ⋯ button holding
   what this page adds on top — wishlisting and the two teach signals.

     not owned   [＋ Add    | ▾]  [⋯]
     wishlisted  [＋ Add    | ▾]  [⋯]      same bar; the badge is on the thumb
     owned       [Edit copy | ▾]  [⋯]      both sections, "play next" included

   §B's measurements: 48px bar in every state, 6px gap, a 48px caret joined to
   the primary behind a 1px divider, a 48px ⋯ with a border of its own, 4px
   outer radius with the joined edges square, label 16px/600 and never
   truncated (at 320 the bar is 288 and the label cell 186 — nothing stacks at
   any width). Two fills only: solid green ＋ Add, tinted green Edit copy. No
   amber anywhere on this row any more.

   PHONE ONLY. The desktop ledger keeps its ranked table, its 260px action
   slot and its ▶ Start playing, so the row ships both markups and these rules
   are the gate — the same arrangement the collection grid card uses for its ⋯
   sheet. .rec-bar is a new class rather than a rewrite of .card-bar: that one
   is 36px and lives on grid cards at desktop widths, and the two must not
   collide. */
.rec-bar {
  display: none;
}

/* §E's teach receipt is part of the phone bar, so it is gated the same way —
   and for the same 🚨 reason the badge strip needs its hide: a class styled
   ONLY inside a media block still renders as TEXT outside it, so without this
   the desktop ledger would print "FEWER LIKE THIS — SENT TO YOUR AGENT'S NEXT
   RUN  Undo" into the row's action slot. */
.rec-receipt {
  display: none;
}

/* Backlog rows have their own desktop control (▶ Start playing, rendered
   beside the cluster), so the pre-spec bar the cluster always ships is dead
   markup up here — see _rec_action_bar.html for why it is rendered at all.

   🚨 The whole CLUSTER has to go, not just its desktop half. .rec-actions is a
   flex row and the cluster is `flex: 1 1 0`, so an emptied-but-present one
   still grows — it took half the 260px action slot and clipped "▶ Start
   playing" to "Start playin". Hidden is safe for the swap target: htmx
   resolves hx-target by id through display: none. */
@media (min-width: 721px) {
  .rec-actions--backlog .rec-control {
    display: none;
  }
}

.rec-control,
.rec-control-desktop,
.rec-control-play {
  display: flex;
  flex: 1 1 0;
  min-width: 0;
}

/* The FIT caption is drawn per row only where the ledger's column header is
   gone (§B); up here that header names the column once for the whole list. */
.rec-fit-label {
  display: none;
}

/* --- The thumbnail status strip (§B/§C) -----------------------------------
   Full width across the artwork's bottom edge, not the catalog's top-right
   chip: at 62px a corner chip covers a third of the cover.

   This is the only place the word OWNED appears on a row now — at BOTH widths
   since the desktop bar became spec 09's (_rec_desktop_action_bar.html), which
   deleted the "✓ OWNED · Manage" and "♡ WISHLIST · ＋ Own" bars that used to
   carry it up here. Spec 10 §F's rule is the reason it is a word at all:
   ownership is never the cover hairline's tint alone.

   These rules used to live inside the ≤720 block with a `display: none` up
   here. 🚨 That hide was not belt-and-braces — a class styled ONLY inside a
   media block still renders as TEXT outside it, so without it the strip
   printed a bare "Owned"/"Wishlist" into the flow above the cover art. Moving
   the box out of the media block is what makes it safe to delete. */
.rec-badge {
  display: block;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1;
  padding: 3px 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  /* 🚨 §C draws this at 10px/0.06em, where WISHLIST measures ~52 in the 62px
     phone strip. Our smallest mono token is 12px and §F's own floor is "no
     mono label below 12", so the spec's two numbers cannot both hold: at 12
     the word measures 63 and clips its final letter against a 62px thumbnail.
     The type size is the one with an accessibility reason behind it, so the
     tracking gives way. Anything ≥ 0.04em overflows again — this is measured,
     not chosen. The desktop cover is 5.125rem (82px), which has room to
     spare, but the strip keeps one set of numbers: two would be two things
     that resemble each other rather than one badge. */
  letter-spacing: 0.03em;
  text-transform: uppercase;
  text-align: center;
  line-height: 1.2;
  overflow: hidden;
}

.rec-badge--owned {
  background: var(--accent);
  color: var(--accent-ink);
}

.rec-badge--wishlist {
  background: rgba(22, 20, 15, 0.85);
  color: #ece8dd;
  border-top: 1px solid rgba(236, 232, 221, 0.24);
}

/* The play state's swap carrier is an id, not a box. It only exists so "Mark
   as finished" can re-render that one fact out of band, and .rec-chips is a
   flex row: as a real flex item the carrier's own line box — struck at the
   chip line's font-size by the whitespace inside it, not the play state's —
   made the line 0.6px taller and shifted the whole row off the pixel grid,
   which re-rendered every chip's text at a different antialiasing. contents
   keeps the play state itself the flex item, exactly as before. */
.rec-play-state {
  display: contents;
}

/* The same carrier trick for the receipt's slot inside the bar — the one
   fragment a vote still has to keep truthful outside the cluster's own swap
   (issue #1208). It exists only to hold a stable id, and 🚨 would otherwise
   become a box (the trap .rec-play-state above is here for). Its twin, the
   "▲ RATED UP" chip's slot in the flex title line, is gone with the chip. */
.rec-receipt-slot {
  display: contents;
}

@media (max-width: 720px) {
  .rec-control-desktop,
  .rec-control-play {
    display: none;
  }

  .rec-control {
    display: block;
    width: 100%;
  }

  .rec-bar {
    display: flex;
    align-items: stretch;
    gap: 6px;
    height: 48px;
    width: 100%;
    min-width: 0;
  }

  /* The primary and its caret are one object — they share an edge, so the
     group owns the outer radius and the halves square off where they meet. */
  .rec-bar-group {
    display: flex;
    flex: 1;
    min-width: 0;
  }

  .rec-bar-form {
    display: flex;
    flex: 1;
    min-width: 0;
    margin: 0;
  }

  .rec-bar-main {
    flex: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 0;
    height: 100%;
    padding: 0 10px;
    border: none;
    border-radius: 4px 0 0 4px;
    font-family: inherit;
    font-size: 16px;
    font-weight: 600;
    line-height: 1;
    text-align: center;
    text-decoration: none;
    white-space: nowrap;
    cursor: pointer;
  }

  /* Solid ＋ Add — the same green button the user has already learned, which
     §A2 is emphatic should not change colour because the page around it did.

     🚨 Two classes on purpose. The site's base rule is `button[type="submit"],
     .btn-primary { background: var(--text) … }` at (0,1,1), which outranks a
     lone class and would paint this as the black neutral primary. */
  .rec-bar .rec-bar-main--solid {
    background: var(--accent);
    color: var(--accent-ink);
  }

  /* 🚨 And every property the base submit-hover rule declares is restated
     here, because `button[type="submit"]:hover` is (0,2,1) and beats the rest
     rule above at (0,2,0) — a hover rule out-specifies it only for what it
     actually declares, so an unstated `background` falls through to the black
     plate. That is precisely how the grid card's Wish switch shipped a 1.00:1
     block in #1197; the property set is background/border-color/color/opacity. */
  .rec-bar .rec-bar-main--solid:hover {
    background: var(--accent-strong);
    border-color: var(--accent-strong);
    color: var(--accent-ink);
    opacity: 1;
  }

  .rec-bar .rec-bar-main--solid:active {
    filter: brightness(0.92);
  }

  /* Tinted Edit copy — already owned, so the bar states a fact rather than
     offering a fill-coloured action. An <a> or a plain button, never a submit,
     so no guard is needed here. */
  .rec-bar-main--tinted {
    background: var(--bar-tint);
    border: 1px solid var(--bar-edge);
    border-right: none;
    color: var(--accent-text-strong);
  }

  .rec-bar-main--tinted:hover {
    background: var(--bar-tint-hover);
    border-color: var(--bar-edge-hover);
    color: var(--accent-text-strong);
  }

  /* Lone: the owned primary with NO caret beside it — the several-copies bar,
     which is one link to My Collection and has no menu to join (issue #1199,
     ported to this bar). .rec-bar-main squares its right corners and --tinted
     drops its right border because the caret carries both; with nothing there
     to carry them, they come back. Source order matters: this is a single
     class like the two rules it corrects, so it has to sit after them. The
     :hover above is (0,2,0) and sets border-color on all four sides, so the
     hover edge needs nothing here. */
  .rec-bar-main--lone {
    border-right: 1px solid var(--bar-edge);
    border-radius: 4px;
  }

  /* --- The caret half: 48 square, joined, divided by a hairline (§B) ------ */
  .rec-bar-caret {
    flex: 0 0 48px;
    width: 48px;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 0 4px 4px 0;
    list-style: none;
    cursor: pointer;
  }

  .rec-bar-caret::-webkit-details-marker,
  .rec-bar-caret::marker {
    display: none;
    content: "";
  }

  /* The registry chevron as a mask — the shared caret idiom since #1383,
     as on spec 09's caret. */
  .rec-bar-caret::after {
    content: "";
    width: 10px;
    height: 6px;
    background: currentColor;
    -webkit-mask: var(--icon-caret) center / contain no-repeat;
    mask: var(--icon-caret) center / contain no-repeat;
  }

  /* Open flips it to ▴ (§C). */
  details[open] > .rec-bar-caret::after {
    transform: rotate(180deg);
  }

  /* On the not-owned bar the caret continues the solid primary. */
  .rec-bar-popover > .rec-bar-caret {
    background: var(--accent);
    color: var(--accent-ink);
    border-left: 1px solid var(--bar-divider);
  }

  .rec-bar-popover > .rec-bar-caret:hover {
    background: var(--accent-strong);
  }

  /* On the owned bar it continues the tinted one and carries the group's own
     right-hand border (the primary dropped its border-right to make the join). */
  .rec-bar-menu > .rec-bar-caret {
    background: var(--bar-tint);
    border: 1px solid var(--bar-edge);
    color: var(--accent-text-strong);
  }

  .rec-bar-menu > .rec-bar-caret:hover {
    background: var(--bar-tint-hover);
    border-color: var(--bar-edge-hover);
  }

  /* Open: the whole tinted object steps up together, so the primary half
     tracks its sibling caret rather than staying at rest. */
  .rec-bar-menu[open] > .rec-bar-caret,
  .rec-bar-group:has(.rec-bar-menu[open]) .rec-bar-main--tinted {
    background: var(--bar-tint-open);
    border-color: var(--bar-edge-open);
  }

  /* --- ⋯ : 48 square, 18px glyph, 6px off the primary group (§B/§C) ------ */
  .rec-bar-more {
    flex: 0 0 48px;
    width: 48px;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--border-strong);
    border-radius: 4px;
    font-size: 18px;
    line-height: 1;
    color: var(--text-muted);
    list-style: none;
    cursor: pointer;
  }

  .rec-bar-more::-webkit-details-marker,
  .rec-bar-more::marker {
    display: none;
    content: "";
  }

  .rec-bar-more:hover {
    background: var(--fill);
    color: var(--text);
  }

  .rec-bar-extras[open] > .rec-bar-more {
    background: var(--chip);
    border-color: var(--text-muted);
    color: var(--text);
  }

  /* §C: 2px leaf ring at 2px offset, on the label half, the caret half and ⋯
     separately. The offset needs the ring to escape the joined group, so the
     focused half lifts above its sibling. */
  .rec-bar-main:focus-visible,
  .rec-bar-caret:focus-visible,
  .rec-bar-more:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
    position: relative;
    z-index: 1;
  }

  /* §E: no spinners inside a 48px button — the whole bar drops to 60% and
     ignores taps for the length of the request. The spec asks for optimistic
     repaint; this is the server-rendered half of that contract, the same call
     made for spec 09's bar. */
  .rec-bar.htmx-request {
    opacity: 0.6;
    pointer-events: none;
  }

  /* --- §E's teach receipt (issue #1208, drawn in Mobile Recommendations §4F)
     A 48px line under the bar, stating the signal just taught and offering it
     back. §4F draws it at 44 with an 11.5px mono label and a 14px Undo,
     because turn 3's bar was a 44px object; spec 10 scaled that bar to 48 and
     §F set the floors this row obeys — no interactive text below 16, no mono
     label below 12 — so the receipt scales with it. Our smallest mono token is
     12 at this width (--fs-mono is 13 on phones, --fs-mono-badge is the 12 the
     specs are drawn at), and Undo takes the 16 §F asks of every control.

     min-height rather than height: the label is 47 mono characters and wraps
     to two lines in every phone width (278px of label cell at 390, 208 at
     320), which fits 48 twice over — but a longer translation or a larger
     user font must push the line down rather than be clipped by it. */
  .rec-receipt {
    display: flex;
    align-items: center;
    gap: 6px;
    min-height: 48px;
    margin-top: 6px;
  }

  .rec-receipt-line {
    flex: 1;
    min-width: 0;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.03em;
    line-height: 1.5;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  .rec-receipt-form {
    display: flex;
    margin: 0;
  }

  /* 🚨 TWO classes AND the element, never the lone class — (0,2,1), and every
     digit of that is load-bearing. Undo is a <button type="submit">, so it is
     matched by two generic rules that are BOTH (0,1,1): the site's neutral
     primary `button[type="submit"], .btn-primary` (which fills it black), and
     — much later in this file — the handheld block's
     `button[type="submit"] { min-height: 44px }`. A `button.rec-receipt-undo`
     at (0,1,1) beats the first only by source order and LOSES to the second by
     source order, which is exactly what shipped on the first cut of this rule:
     a correct-looking bordered leaf cell measuring 44 tall instead of §F's 48.
     Its :hover twin is (0,2,1), so the hover rule needs a digit more again —
     and, having beaten it, has to restate every property it is taking back
     rather than only the ones it changes (the #1197 / #1188 family).

     test_rec_receipt_cascade.py resolves this cascade for real rather than
     asserting a selector shape, because the shape is not what the browser
     reads and the 44px bug proves it.

     Bordered like ⋯ above it, which is what "matching the teach cells" means
     now that spec 10 has deleted the teach cells §4F drew it against. */
  .rec-receipt button.rec-receipt-undo {
    flex: 0 0 auto;
    min-height: 48px;
    padding: 0 16px;
    background: transparent;
    border: 1px solid var(--border-strong);
    border-radius: 4px;
    font-size: 16px;
    font-weight: 600;
    color: var(--accent);
  }

  .rec-receipt button.rec-receipt-undo:hover,
  .rec-receipt button.rec-receipt-undo:focus-visible {
    background: var(--fill);
    border-color: var(--border-strong);
    color: var(--accent);
    text-decoration: none;
    opacity: 1;
  }

  .rec-receipt button.rec-receipt-undo:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }

  /* §E's pending contract, applied to the line rather than to .rec-bar: Undo
     is the one control in this cluster that sits outside the bar. */
  .rec-receipt.htmx-request {
    opacity: 0.6;
    pointer-events: none;
  }

  /* §E: "Fewer like this" is the only control on this row that changes the row
     itself. ▲ is the mirror and recedes nothing, so the modifier does the
     asking.

     🚨🚨 §E asks for the row's CONTENT at 55%, and this row cannot give it —
     measured with axe, not argued. Every text token on the row is tuned to sit
     just above the 4.5:1 floor (see --text-faint in the light block: "on paper,
     faint has to darken to the muted tone to clear it", issue #528), so there
     is no headroom for an alpha of any size:

                          full    0.9    0.8    0.7   0.55
       dark  meta/why/rank 6.56   5.53   4.62   3.83   2.83   ← axe: 2.84:1
       light meta/why/rank 5.11   4.16   3.42   2.84   2.19   ← axe: 2.19:1
       light title        16.50  12.46   8.87   6.26   3.83   ← axe: 3.84:1

     At 55% that is 7 serious color-contrast violations in dark and 6 in light,
     against an axe baseline that is EMPTY and whose comment says "do not add
     ids to silence a new failure". The strongest dim that clears AA in both
     themes is about 0.93 — visually nothing. Spec 10's own §F ("all text on
     #16140f clears 4.5:1") contradicts §4F here, the same way §C's 10px badge
     contradicted §F's 12px floor, and it gets the same answer: the rule with
     the accessibility reason wins and the drawing gives way.

     So the recede is carried by the row's only two NON-text elements — the
     artwork and the FIT meter, both decoration, neither owing a contrast
     ratio. Every word stays exactly as legible as it was, and the receipt line
     is what actually says the row was taught down. Flagged for Max on the PR:
     restoring the full 55% is a decision to ship a known AA failure.

     :has() on the row rather than a class on the <li>, because 🚨 that <li>
     sits outside every swap target the row has: a class there would state
     page-load truth forever, exactly as .rec-cover--owned did before #1224
     keyed the owned hairline off the badge instead. The receipt IS inside a
     swap target, so asking the row whether it contains a down one is the same
     fact, kept honest for free.

     🚨 Declared INSIDE this block on purpose. `.rec-row:has(…)` is (0,2,0) and
     would outrank any later single-class rule at every width, so an unscoped
     copy of this would reach the desktop ledger too and no media query below
     could take it back — the #1200 family, where a desktop widen leaked to
     phones for exactly this reason. */
  .rec-row:has(.rec-receipt--down) .game-cover,
  .rec-row:has(.rec-receipt--down) .rec-fit-bar {
    opacity: 0.55;
  }

  /* --- The two sheets (§D) ------------------------------------------------
     Bottom sheets at every phone width, never anchored popovers. The
     .add-popover family already IS that below 720 — fixed to the viewport's
     bottom edge, with the scrim that swallows its own dismissing tap (issue
     #1185) — and app.js layers the rest on: outside-click and Escape
     dismissal, focus trap, focus back on the control that opened it,
     aria-expanded sync, one open at a time. The anatomy (grab handle, 52px
     rows, destructive row behind a rule) is the collection card's sheet,
     shared by class rather than restated, so the two cannot drift. */
  .rec-sheet {
    border-radius: 14px 14px 0 0;
    padding: 8px 8px 12px;
    max-height: calc(100dvh - 40px);
    overflow-y: auto;
    overscroll-behavior: contain;
    display: flex;
    flex-direction: column;
  }

  .rec-bar-menu[open]::before,
  .rec-bar-extras[open]::before {
    background: rgba(10, 9, 6, 0.62);
  }

  /* "NOT IN COLLECTION · RANK 07 · FIT 78", or the copy's own facts on the
     caret menu. A label, not a target — §F's rule that ownership is a WORD
     lives here as much as on the badge. */
  .rec-sheet-head {
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: var(--text-muted);
    padding: 6px 12px 8px;
  }

  /* §A2's mitigation for wishlisting costing two taps here: the sheet opens
     under the thumb with its wishlist action first AND pre-highlighted. */
  .entry-menu-row.rec-menu-row--lead {
    background: var(--fill);
  }

  /* 🚨 The sheet rows are <button type="submit"> as often as <a>, so their
     hover rule needs the element prefix to reach (0,2,1) and tie with
     `button[type="submit"]:hover` — source order then wins it. Without this
     the base neutral primary paints a black plate under a form-backed row on
     hover (the #1197 family). Fixes the collection card's own sheet too: its
     "Move to wishlist" and "Remove" rows share these rules. */
  button.entry-menu-row:hover,
  button.entry-menu-row:focus-visible {
    background: var(--fill);
    border-color: var(--border);
    color: var(--text);
    opacity: 1;
  }

  button.entry-menu-row--danger:hover,
  button.entry-menu-row--danger:focus-visible {
    background: var(--danger-dim);
    border-color: var(--danger-dim);
    color: var(--danger);
    opacity: 1;
  }

  /* Motion, matching the collection sheet: slide up 180ms, scrim fades 120ms. */
  @media (prefers-reduced-motion: no-preference) {
    .rec-bar-menu[open] > .rec-sheet,
    .rec-bar-extras[open] > .rec-sheet {
      animation: entry-sheet-in 180ms ease-out;
    }

    .rec-bar-menu[open]::before,
    .rec-bar-extras[open]::before {
      animation: entry-scrim-in 120ms ease-out;
    }
  }

  /* The thumbnail status strip's box is declared at the top level now that it
     paints at both widths — see the .rec-badge block up there. Below 720 it
     also badges the play-next section, where every row is owned; the ledger
     keeps that marker for the ranked list alone (.rec-cover--backlog). */
  .rec-badge-slot--owned ~ .game-cover {
    border-color: var(--accent-border-strong);
  }
}

/* Status badges (spec 04): OWNED = leaf fill; WISHLIST = outlined on a dim ink
   scrim so it stays readable over cover art. Fixed colors — the scrim reads
   the same over covers in both themes. */
.status-pill {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  border-radius: 3px;
  padding: 0.15rem 0.4rem;
  background: var(--accent);
  color: var(--accent-ink);
  white-space: nowrap;
}

.status-pill--wishlist {
  background: rgba(22, 20, 15, 0.8);
  color: #ece8dd;
  border: 1px solid rgba(236, 232, 221, 0.35);
}

/* Previously owned (issue #422): the wishlist scrim with a dashed outline and
   dimmed text — a copy that was here and isn't anymore. */
.status-pill--former {
  background: rgba(22, 20, 15, 0.8);
  color: rgba(236, 232, 221, 0.75);
  border: 1px dashed rgba(236, 232, 221, 0.35);
}

/* Cover overlay placement on browse cards. */
.cover-badge {
  position: absolute;
  top: 6px;
  right: 6px;
  z-index: 1;
}

/* Catalog-card cover-state carrier (issue #689): the stable-id fragment the
   HTMX row-control responses re-render out-of-band so the badge and owned
   frame track in-card quick-add state changes. Inset over the whole cover so
   the badge inside anchors to the same box as the cover link; pointer events
   pass through to that link beneath. */
.cover-state {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}

/* ---------------------------------------------------------------------------
 * Genre / metadata chips (game detail)
 * ------------------------------------------------------------------------- */

.chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0 0 1.25rem;
}

.chip {
  display: inline-block;
  padding: 0.25rem 0.7rem;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  font-size: var(--fs-ui-sm);
  color: var(--text);
  white-space: nowrap;
}

/* Catalog flag chips (issue #1380): the browse-hiding flags share the genre
   chip's pill but take a tint per flag FAMILY so they never read as genres.
   Drawn by the {% browse_flags %} tag from Game.browse_flags — the vocabulary
   (which flags, their words, their families) lives there; the modifier here is
   the family, never the flag, so a new label in an existing family needs no
   rule. One color scheme site-wide (the list rows' .flag-badge--* use the same
   assignments): brass = provenance (homebrew / unlicensed / aftermarket —
   spec 02's secondary-warning tint), danger = shovelware (the quality flag),
   and a dashed muted ghost = unreleased (the absence idiom, same as
   .availability-badge--unknown). --brass-text / --danger are the theme-tuned
   text values, so both palettes keep AA over their -dim tints (the light theme
   darkens each for exactly this). */
.chip--flag-provenance {
  background: var(--brass-dim);
  border-color: var(--brass-border);
  color: var(--brass-text);
}

.chip--flag-shovelware {
  background: var(--danger-dim);
  border-color: var(--danger-border);
  color: var(--danger);
}

.chip--flag-unreleased {
  background: var(--fill);
  border-style: dashed;
  color: var(--text-muted);
}

/* The notability tier (issue #1671): the ghost family's other member —
   absence of evidence, dotted where unreleased is dashed. */
.chip--flag-low_traction {
  background: var(--fill);
  border-style: dotted;
  color: var(--text-muted);
}


/* ---------------------------------------------------------------------------
 * Collection status tabs + refine dropdowns + play state (issue #355, spec 05.4)
 * ------------------------------------------------------------------------- */

/* The tab bar (design "Collection Refined"): the status tabs alone on their
   row now — the title search moved into the filter bar and the view toggle
   down to the results row, so nothing else shares this line. */
.collection-tabbar {
  display: flex;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
  border-bottom: 1px solid var(--border);
  margin-bottom: 0.9rem;
}

/* All / Owned / Wishlist tabs: underline tabs, the active one taking the leaf
   underline + full text color exactly like the active nav link (spec 05). */
.collection-tabs {
  display: flex;
  flex-wrap: wrap; /* four tabs don't fit ~320px viewports; wrap over overflow */
  gap: 2px;
}

.collection-tab {
  padding: 0.5rem 1rem;
  margin-bottom: -1px;
  border-bottom: 2px solid transparent;
  color: var(--text-muted);
  font-size: var(--fs-ui);
  font-weight: 500;
}

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

.collection-tab[aria-current="page"] {
  color: var(--text);
  border-bottom-color: var(--accent);
  font-weight: 600;
}

/* The count reads as data: mono, dimmed, riding the label's baseline. */
.collection-tab .tab-count {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-muted);
}

/* ---------------------------------------------------------------------------
 * The collection filter bar (design "Collection Refined")
 *
 * Structurally the catalog's search bar — .filter-row / .search-field /
 * .filter-menu / .search-seg, and the mobile sheet apparatus that wraps them —
 * so almost everything it needs is already defined above. What is left is the
 * two ways the collection's bar differs: it has no Search submit to cap its
 * right edge (every control applies live), and its "＋ More" segment opens a
 * wider, sectioned panel rather than one facet's checkbox list.
 * ------------------------------------------------------------------------- */

/* The right cap. On the catalog it is the Search submit; the collection has no
   submit (every control applies live), so the bar ends on "＋ More" and that
   segment carries the closing hairline and radius. The rule sits on the
   <details> because that is where the bar's top/bottom hairlines are painted —
   the summary only carries the vertical separator between segments. */
.filter-row--collection .filter-sheet-panel > .more-menu {
  border-inline-end: 1px solid var(--border-strong);
  border-start-end-radius: var(--radius-sm);
  border-end-end-radius: var(--radius-sm);
}

/* Except without JS, where this bar does have a submit — the noscript-only one
   from issue #399, sitting right after "＋ More" — and the cap belongs on it
   instead. The twin of the catalog's exception (issue #1468); the reasoning,
   and why `noscript > button` is a safe stand-in for "scripting is off", is
   written out there. */
.filter-row--collection .filter-sheet-panel > .more-menu:has(~ noscript > button[type="submit"]) {
  border-inline-end: 0;
  border-start-end-radius: 0;
  border-end-end-radius: 0;
}

/* The bar's own segments never grow: the search field takes the slack, as it
   does on the catalog. Without this the four menus split the leftover width
   between them and the field collapses to its 8rem minimum. */
.filter-row--collection .filter-sheet-panel > .filter-menu,
.filter-row--collection .filter-sheet-panel > .search-seg {
  flex: 0 0 auto;
}

/* The full-width line break the extras row took here now comes from the
   shared `.filter-row .filter-sheet-panel > .filter-meta` rule — the
   collection's row joined the catalog's (issue #1521). */

/* The handheld-only furniture of the "Collection Grid Spec" 06 layer: the
   head meta line's IMPORT › segment (desktop keeps the head's Export/Import
   button pair instead), the "24 of 717" results line over its hairline, and
   the Show-more / end-of-run block (desktop keeps the pagination nav). The
   shelves chips ("Collection Shelves Controls" spec 10) join them: the same
   row, in the view that has two settings to put there instead of a count.
   All four render at every width — the server can't know the viewport — and
   these base rules keep them phone-only; the handheld block below turns
   them on.

   .load-more and .results-count-line are page-agnostic on purpose (issue
   #1265): BOTH browse pages draw them now — the catalog's block lives in
   web/partials/_catalog_load_more.html, the collection's in
   collection/partials/_collection_load_more.html, and only the wrapper's id
   differs — so one definition styles the pair and they cannot drift. */
.head-meta-import-seg,
.results-count-line,
.shelf-controls,
.load-more {
  display: none;
}

/* The shelves row's pinning wrapper (design "Shelf Jump Menu" spec 12): the
   box app.js freezes at the row's measured height a frame before the row
   leaves flow, so nothing under it moves at the pin point. It has no box of
   its own at any width where the row is not a phone row — display: contents
   means the markup above is exactly what it was before the wrapper existed. */
.shelf-chrome {
  display: contents;
}

/* ---- The "＋ More" panel ------------------------------------------------ */

/* Wider than a facet menu's: it stacks three axes, and the Released chips need
   a line they can wrap on. Right-anchored — it is the bar's last segment, so a
   left-anchored panel would hang off the page. */
/* Two columns, like the catalog's Advanced panel uses three, and for the same
   reason: this panel now stacks up to eight axes, and stacked in one column
   they are a scroll rather than a panel you can read. Right-anchored — it is
   the bar's last segment, so a left-anchored panel would hang off the page —
   and capped at the viewport so the wider box can't push past the gutter on a
   narrow desktop window. */
.more-menu > .picker-panel {
  width: min(42rem, calc(100vw - 2 * var(--gutter)));
  right: 0;
  left: auto;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: 1.5rem;
  align-content: start;
}

/* The Apply/Clear footer spans both columns: it acts on the whole panel, not
   on whichever column it happened to land under.

   🚨 A DESCENDANT selector, not the child one this was. Spec 11 wraps the
   groups and the footer in .filter-rail-main / .filter-rail-body so the rail
   has a column to sit beside, and although both are display: contents — which
   keeps the footer a grid ITEM of the panel — `>` matches the DOM tree, not
   the box tree, so the child selector stopped matching and the footer dropped
   into one column. */
.more-menu > .picker-panel .menu-actions {
  grid-column: 1 / -1;
}

.more-group {
  min-width: 0;
  padding-bottom: 0.75rem;
  margin-bottom: 0.75rem;
  border-bottom: 1px solid var(--border);
}

.more-group:last-of-type {
  padding-bottom: 0.25rem;
  margin-bottom: 0;
  border-bottom: 0;
}

.more-group-label {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  margin: 0 0 0.4rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The per-axis applied badge, in leaf like every other applied-state echo. */
.more-group-count {
  margin-left: auto;
  color: var(--accent-text);
  letter-spacing: 0.04em;
  text-transform: none;
}

.more-group-count:empty {
  display: none;
}

/* Released: single-valued, so radios — drawn as the design's chips. The input
   is visually hidden rather than display:none so it stays focusable and the
   form still serializes the checked decade. */
.more-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.375rem;
}

.more-chip {
  margin: 0; /* cancel the global label margin-bottom */
}

.more-chip input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.more-chip span {
  display: inline-flex;
  align-items: center;
  height: 1.625rem;
  padding: 0 0.7rem;
  border: 1px solid var(--border-strong);
  border-radius: 3px;
  box-sizing: border-box;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  white-space: nowrap;
  cursor: pointer;
}

.more-chip:hover span {
  border-color: var(--text-muted);
  color: var(--text);
}

.more-chip input:checked + span {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
  font-weight: 600;
}

.more-chip input:focus-visible + span {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
}

/* ---------------------------------------------------------------------------
 * The rail filter panel (design "Filter Panel Rail" spec 11)
 * -------------------------------------------------------------------------
 *
 * ONE component, two surfaces: the catalog's "− Advanced" block and the
 * collection's "＋ More" popover. Both had grown into walls of parallel
 * columns — ~640px and ~1,180px tall — that got taller every time an axis was
 * added, and the collection's covered the grid it filters. Spec 11 replaces
 * the INSIDE of each with a group rail on the left and one value pane on the
 * right. The pane sets the height and the pane never changes height, so panel
 * height is a constant per surface (421 catalog, 399 collection) whichever
 * group is open and however many groups exist. The next twelve facets cost
 * twelve rail rows and 0px.
 *
 * 🚨 EVERY rule here is gated on [data-rail="on"], which app.js sets at ≥721
 * and removes below it. That gate is load-bearing three times over:
 *
 *   - The rail is a real tablist (spec §G) and there is no such thing as a
 *     tablist without JS, so a no-JS reader must never be handed one. Without
 *     the attribute this whole section is inert and both panels render the
 *     column grid they always did.
 *   - The ≤720 filter sheet keeps its stacked accordion (mobile layer spec
 *     §05A); spec 11 §F puts <900 explicitly out of scope. Those handheld
 *     rules live ~13,000 lines below this one, and gating on an attribute no
 *     phone ever carries is what keeps the two layers from arguing about
 *     specificity at all.
 *   - Only one group's values are in the pane at a time, which is a lie on a
 *     scrolling surface where the whole point is to see the stack.
 *
 * 🚨 The selectors are deliberately over-qualified. Both panels' base rules
 * are three classes deep (`.filter-row .filter-sheet-panel > .advanced-panel`,
 * `.column-picker .picker-panel label.check`), so a tidy `.filter-rail-panel[…]`
 * at (0,2,0) would silently lose half its declarations. Each rule below names
 * enough of its surface to out-specify the base rule it replaces — the shape
 * this stylesheet has been bitten by before (see the card-bar note at ~5290).
 *
 * COLUMN COUNT is a container query on the pane, not a viewport breakpoint,
 * because the same component sits in a 1120px block and a 912px popover: the
 * question is how wide the VALUES are, and only the pane knows that.
 */

/* Shared skin tokens. Declared on the panel so the container queries and the
   per-surface blocks below can both read them, and so light mode is one
   override rather than a parallel copy of every rule. */
.filter-rail-panel {
  /* A recess, so the rail reads as behind the pane. --fill lightens, which is
     the wrong direction on an ink surface, so this one is stated. */
  --fp-rail-bg: rgba(0, 0, 0, 0.16);
  --fp-foot-bg: rgba(0, 0, 0, 0.14);
  --fp-head: 52px;
  --fp-foot: 55px; /* 1 border-top + 12 + 30 Apply + 12 */
  --fp-row: 30px;
  --fp-rail-row: 32px;
  /* The Released chips' DRAWN height — its own token rather than --fp-row,
     because the two answer different questions. --fp-row is a target band, and
     the touch-floor block raises it to 44 on a coarse pointer (issue #1485);
     the chip is a #1287 drawing, a bordered pill whose radius is half its
     height, and inflating it to the band deforms the artwork. The label buys
     the band instead — see .more-chip in the touch-floor block. */
  --fp-chip: 30px;
  --fp-col-gap: 22px;
}

html[data-theme="light"] .filter-rail-panel {
  --fp-rail-bg: rgba(28, 26, 21, 0.04);
  --fp-foot-bg: rgba(28, 26, 21, 0.03);
}

/* --- Inert scaffolding ---------------------------------------------------- */

/* The three wrappers spec 11 needs are display: contents until the rail is on,
   so the markup the panels render outside rail mode is exactly what they
   rendered before this spec: heading, filter box, options, footer, all direct
   children of their group or panel as far as layout is concerned. */
.filter-rail-main,
.filter-pane-head,
.filter-pane-area {
  display: contents;
}

/* ...and the collection's body wrapper too — its grid is the picker panel
   itself, so the wrapper must not introduce a box between them. The catalog's
   body IS its grid (it carries .advanced-groups), so it has no such rule. */
.more-panel .filter-rail-body {
  display: contents;
}

/* Both JS-only head controls and the pager ship hidden; these are what they
   look like once app.js turns them on. */
.filter-pane-selected {
  font-size: var(--fs-ui-sm);
  color: var(--text-faint);
  white-space: nowrap;
}

/* 🚨 `.filter-rail-panel` is specificity, not scoping, on all three of the
   link-shaped buttons in this component. Both panels sit inside the search
   bar's .filter-row, whose cap rule `.filter-row button { border: 0;
   border-left: 1px solid …; padding-inline: 1.3rem }` — written for the
   catalog's "Search" segment — is (0,1,1) and outranks a bare class. Measured
   before this: a stray vertical hairline in front of "Clear group", in front
   of "＋ 18 more", and down the left edge of EVERY idle rail row, each of them
   1.3rem further right than drawn. Exactly the trap the sheet's own
   .menu-actions rules document at ~19740. */
.filter-rail-panel .filter-pane-clear {
  padding: 0;
  border: 0;
  background: none;
  font-family: inherit;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  white-space: nowrap;
  cursor: pointer;
}

.filter-rail-panel .filter-pane-clear:hover {
  color: var(--text);
}

.filter-pane-clear:focus-visible {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
}

/* 🚨 Every control in this component that app.js toggles needs its hide
   spelled out, because something already outranks the UA sheet's `[hidden]`
   for each of them: `button, .btn { display: inline-flex }` (0,0,1)/(0,1,0)
   reaches Clear group and the pager link, and this section's own rules reach
   the pager row and the empty note. Measured before this rule: "Clear group"
   sat in the head of a group with nothing staged, which is precisely the dead
   control spec 11 §E says must not be there. Same trap as `.column-picker
   .picker-panel label.check[hidden]` above. */
.filter-pane-clear[hidden],
.filter-pane-empty[hidden] {
  display: none;
}

/* The "＋ 6 more themes" / "Show fewer" pager that used to live here is gone
   with the paging it drove: the value area scrolls now (see its rule below),
   so there is no next page to ask for. */

/* The one-line empty state (spec 11 §E): a group whose values are all ruled
   out by the other filters keeps its rail row and says so. Never hidden — a
   group that disappears reads as a bug. */
.filter-pane-empty {
  display: flex;
  align-items: center;
  height: var(--fp-row);
  margin: 0;
  padding: 0 0.5rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* --- Rail mode: the shell ------------------------------------------------- */

/* 🚨 The catalog's arm repeats the disclosure's whole opening selector. That
   rule — `.filter-row > .advanced-toggle:checked ~ .filter-sheet-panel >
   .advanced-panel { display: block }` — is (0,5,0), so a tidier selector here
   loses `display` ALONE and half-applies: measured before this, the rail and
   the pane stacked as blocks, the rail spread to the full 1120 and the panel
   stood 539px tall instead of 421. Everything else had applied, which is
   exactly what makes the shape hard to read (see the losing-override note in
   the rec action bar). This is (0,6,0). */
/* 🚨 `gap: 0` is load-bearing on the collection's arm. Outside rail mode that
   panel is a two-column GRID with `column-gap: 1.5rem` between the axes;
   `display: flex` here changes what the gap separates but not that there is
   one, so those 24px reappeared between the rail and the pane — as a band of
   panel background running the full height of the popover, widest where it
   sat beside the footer and read as a notch in front of "N filters applied".
   The rail's own `border-inline-end` is the only separation this layout
   wants. */
.filter-row > .advanced-toggle:checked ~ .filter-sheet-panel > .advanced-panel[data-rail="on"],
.more-menu > .picker-panel[data-rail="on"] {
  display: flex;
  align-items: stretch;
  gap: 0;
  padding: 0;
  overflow: hidden;
}



.filter-rail-panel[data-rail="on"] > .filter-rail-main {
  display: flex;
  flex-direction: column;
  /* A zero basis, not auto: an auto basis would size this column from its
     CONTENT — the widest option label in whichever group happens to be open —
     so the pane would change width as you switched groups. */
  flex: 1 1 0;
  min-width: 0;
}

/* --- Rail mode: the rail -------------------------------------------------- */

.filter-rail {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: 1px;
  flex: 0 0 var(--fp-rail-w);
  /* 🚨 The rail can NEVER set the panel height (spec 11 §D). align-items:
     stretch would otherwise let a 15-row rail size the flex line and the panel
     with it, which is the one thing this layout exists to prevent. The cap is
     the pane's own height, and past it the rail scrolls inside itself. */
  max-height: calc(var(--fp-head) + var(--fp-area) + var(--fp-foot));
  overflow-y: auto;
  /* 🚨 NOT `overscroll-behavior: contain`. Both panels are in the page, not
     over it — the catalog's is a block in the filter bar and the collection's
     a popover anchored to it — so a wheel that this rail cannot use belongs to
     the page, exactly as it does over any other part of the panel. `contain`
     swallowed it instead: with six or seven groups the rail never overflows
     (measured: scrollHeight === clientHeight === 419), so it had no scroll to
     give and refused to pass one on, and a trackpad over the whole left column
     did nothing whatsoever. `contain` is for a layer the page behind must not
     move under — the bottom sheets — and this is not one. */
  padding: 10px 8px;
  border-inline-end: 1px solid var(--border);
  background: var(--fp-rail-bg);
}

/* 🚨 .filter-rail is specificity, not scoping — see the note on
   .filter-pane-clear above: `.filter-row button` is (0,1,1) and would win
   `border`, `border-radius` and `padding` off a bare `.filter-rail-tab`. */
.filter-rail .filter-rail-tab {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
  width: 100%;
  height: var(--fp-rail-row);
  margin: 0;
  padding: 0 10px;
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  font-family: inherit;
  font-size: 0.84375rem;
  line-height: 1.2;
  color: var(--text-muted);
  text-align: start;
  white-space: nowrap;
  cursor: pointer;
}

.filter-rail-tab:hover {
  background: var(--fill);
  color: var(--text);
}

.filter-rail-tab:focus-visible {
  outline: 2px solid var(--accent-text);
  outline-offset: -2px;
}

/* The current row takes the same spine as the owned block in "Platforms Panel
   Spec": a 2px accent edge pulled out over the rail's padding, a tinted fill,
   and the radius squared off.

   🚨 Squared off on BOTH sides, not just the spine side. Squaring only the
   start corners left the row square where it meets the spine and rounded where
   it meets the pane — a shape that reads as a half-finished tab rather than a
   block, and the one asymmetry nothing else in this component has. The end
   corners were never a deliberate choice: they are what `--radius-sm` from the
   idle rule leaves behind once the start pair is overridden. */
.filter-rail-tab[aria-selected="true"] {
  margin-inline-start: -2px;
  border-inline-start: 2px solid var(--accent);
  border-radius: 0;
  background: var(--accent-dim);
  font-weight: 600;
  color: var(--text);
}

.filter-rail-tab-label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The staged-selection badge. Decorative — the count is spelled out for
   screen readers in the tab's sr-only text — and absent entirely at zero. */
.filter-rail-tab-badge {
  flex: none;
  padding: 0 5px;
  border: 1px solid var(--accent-border);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  line-height: 1.45;
  color: var(--accent-text);
}

.filter-rail-tab[aria-selected="true"] .filter-rail-tab-badge {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--accent-ink);
}

/* --- Rail mode: the pane -------------------------------------------------- */

/* The container the value columns are counted against. Its inline size is the
   panel minus the rail, which is exactly the width the values get. */
.filter-rail-panel[data-rail="on"] .filter-rail-body {
  display: block;
  flex: none;
  container-type: inline-size;
  container-name: fp;
}

/* Outside rail mode each group is a cell in a column grid, separated from the
   next by its own padding, margin and hairline. In the rail there IS no next
   group on screen, and those 25px landed on the pane's fixed height instead —
   the collection's popover measured 424 rather than 399. */
.filter-rail-panel[data-rail="on"] .filter-pane {
  display: flex;
  flex-direction: column;
  margin: 0;
  padding: 0;
  border: 0;
}

/* 🚨 (0,4,0). The display: flex above is (0,3,0) and would otherwise outrank
   the UA sheet's [hidden] rule, leaving every group's values on screen at
   once — the same trap `.column-picker .picker-panel label.check[hidden]`
   above documents for option rows. */
.filter-rail-panel[data-rail="on"] .filter-pane[hidden] {
  display: none;
}

.filter-rail-panel[data-rail="on"] .filter-pane-head {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 12px;
  flex: none;
  height: var(--fp-head);
  padding: 0 var(--fp-pad);
}

/* The group name, in the mono voice both panels already gave their headings —
   only its margins change, since it is a flex item now rather than a block
   with a rule under it. */
.filter-rail-panel[data-rail="on"] .filter-pane-head [data-pane-title] {
  margin: 0;
  flex: none;
}

/* The collection's per-axis applied badge stands down inside the rail: the rail
   row's own badge says the same thing, in the place spec 11 puts it. Kept in
   the DOM, not dropped from the template — it is an out-of-band swap target. */
.filter-rail-panel[data-rail="on"] .filter-pane-head .more-group-count {
  display: none;
}

/* Everything after the count is the head's right-hand tail. */
.filter-rail-panel[data-rail="on"] .filter-pane-selected {
  margin-inline-end: auto;
}

.filter-rail-panel[data-rail="on"] .filter-pane-head .menu-filter {
  flex: none;
  width: 11rem;
  margin: 0;
}

.filter-rail-panel[data-rail="on"] .filter-pane-head .menu-filter-input {
  height: 28px;
  padding: 0 0.5rem;
  font-size: var(--fs-ui-sm);
}

/* The value area: a fixed height that SCROLLS. The height is the measurement
   the whole spec rests on — it is what makes panel height independent of how
   many values a group has, and of how many groups there are — and it is
   untouched. What changed is what happens past it.

   It used to be `overflow: hidden` with a "＋ N more" pager swapping one
   screenful of values for the next. Two things were wrong with that. The
   pager cycled: reaching a value meant clicking through pages counting rows,
   with no way to see the vocabulary at once and no scrollbar to say how much
   of it was left. And `overflow: hidden` is not merely "no scrollbar" — a
   wheel over this box did nothing at all, neither scrolling the list (there
   was nothing to scroll) nor the page behind it, so a trackpad over the
   biggest target in the panel felt broken. A scroll container answers both:
   every value is present and reachable, and the gesture people already make
   is the one that works.

   `scrollbar-gutter: stable` so switching to a group that overflows cannot
   nudge the values left by a scrollbar's width. */
.filter-rail-panel[data-rail="on"] .filter-pane-area {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  flex: none;
  height: var(--fp-area);
  padding: 0 var(--fp-pad) 6px;
  overflow-y: auto;
  scrollbar-gutter: stable;
}

/* 🚨 (0,3,0), which is what it takes to beat `.advanced-panel
   .advanced-options` (0,2,0) and `.filter-menu .menu-options` (0,2,0) — the
   per-column scroll caps both panels use outside rail mode. A cap left
   standing here would put a scrollbar inside a pane that is already exactly
   as tall as it needs to be. */
.filter-rail-panel[data-rail="on"] .filter-pane-values {
  --fp-cols: 1;
  display: grid;
  grid-template-columns: repeat(var(--fp-cols), minmax(0, 1fr));
  align-content: start;
  column-gap: var(--fp-col-gap);
  height: auto;
  max-height: none;
  overflow: visible;
  padding: 0;
}

/* The folded group (issue #1639) is one grid item, so it spans the row and
   lays its own rows out on the same tracks — otherwise its values would
   stack in a single column while the rows above run three across. */
.filter-rail-panel[data-rail="on"] .filter-pane-values .menu-fold {
  grid-column: 1 / -1;
  margin: 0;
}

.filter-rail-panel[data-rail="on"] .filter-pane-values .menu-fold-values {
  display: grid;
  grid-template-columns: repeat(var(--fp-cols), minmax(0, 1fr));
  column-gap: var(--fp-col-gap);
}

/* 🚨 A row-major GRID, where this was CSS multicol.

   Multicol was the right answer for a paged box and the wrong one for a
   scrolling box, and the reason is the same fact in both cases: multicol lays
   its content out DOWN each column in turn. Paged, that read correctly —
   column 1 held the first nine values, and the page was one screenful. Scrolled
   it does not: reading the list in order would mean scrolling to the bottom of
   column 1, back to the top for column 2, and again for column 3, with the
   scrollbar telling you nothing about where in the vocabulary you are.

   A grid reads across then down, so one scroll position is one contiguous
   slice of the list, which is what a scrollbar's position means everywhere
   else. It also gives the section headings somewhere to be: they span the full
   row (below), so a hardware family or the COMMON / OTHER LANGUAGES split
   starts a row of its own instead of appearing once in column 1 while its
   values flow on through the other two. */

/* 🚨 (0,4,1): `.column-picker .picker-panel label.check` is (0,3,1) and
   `.advanced-options label.check` is (0,2,1). */
.filter-rail-panel[data-rail="on"] .filter-pane-values label.check {
  min-height: var(--fp-row);
  margin: 0;
  padding: 0 0.5rem;
}

.filter-rail-panel[data-rail="on"] .filter-pane-values .menu-group-label,
.filter-rail-panel[data-rail="on"] .filter-pane-values .menu-fold-label {
  /* Full width, so a section starts a row rather than sitting in one track
     while its own values run on through the others — see the grid note above. */
  grid-column: 1 / -1;
  margin: 0;
  display: flex;
  align-items: center;
  height: var(--fp-row);
}

/* Released is chips, not rows (spec 11 §D row 06): one wrapping line of them,
   so the value grid above stands down for this one group. */
.filter-rail-panel[data-rail="on"] .more-chips {
  display: flex;
  height: auto;
}

.filter-rail-panel[data-rail="on"] .more-chip span {
  height: var(--fp-chip);
  border-radius: 15px;
}

/* --- Rail mode: the footer ------------------------------------------------ */

/* 🚨 (0,3,0) beats `.filter-menu .menu-actions` (0,2,0), whose margin-top and
   border-top belong to a footer that follows a stack rather than capping a
   fixed-height pane. */
.filter-rail-panel[data-rail="on"] .advanced-foot,
.filter-rail-panel[data-rail="on"] .menu-actions {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  flex: none;
  flex-wrap: nowrap;
  gap: 14px;
  height: var(--fp-foot);
  margin: 0;
  padding: 12px var(--fp-pad);
  border-top: 1px solid var(--border);
  background: var(--fp-foot-bg);
}

.filter-rail-panel[data-rail="on"] .more-foot-tally {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
  white-space: nowrap;
}

/* The catalog's reset already carries margin-left: auto; the collection's
   footer needs its own spacer, and Clear is the first of the two buttons.
   Clear reads as text beside the filled Apply, the way "Reset advanced" does
   on the catalog — which also means undoing the .filter-row cap rule's left
   hairline, the same (0,1,1) leak the rail rows meet. */
.filter-rail-panel[data-rail="on"] .menu-actions > .btn[data-menu-clear] {
  margin-inline-start: auto;
  border: 0;
  color: var(--text-muted);
}

.filter-rail-panel[data-rail="on"] .advanced-foot .btn,
.filter-rail-panel[data-rail="on"] .menu-actions > .btn {
  height: var(--fp-row);
  min-height: 0;
  padding: 0 1rem;
  border-radius: var(--radius-sm);
}

/* --- Rail mode: per-surface measurements ---------------------------------- */

/* Catalog (spec 11 §B): 1 + 52 head + 312 area + 55 footer + 1 = 421px,
   always. The area is sized for its tallest configuration — two value columns
   of nine rows plus the pager — which is why three columns of six leave air
   below them rather than a shorter panel. */
.filter-row .filter-sheet-panel > .advanced-panel[data-rail="on"] {
  --fp-rail-w: 206px;
  --fp-pad: 18px;
  --fp-area: 312px;
}

/* Collection (spec 11 §C): 1 + 52 + 290 + 55 + 1 = 399px, always — down from
   ~1,180 and no longer covering the grid it filters. */
/* …and the floating shell it sits in: wider than the two-column panel it
   replaces (912 vs 672), lifted, still right-anchored because ＋ More is the
   bar's last segment, and capped at the viewport so the wider box cannot push
   past the gutter on a narrow desktop window — spec §C's "pin to the container
   and keep 10px gutters". */
.more-menu > .picker-panel[data-rail="on"] {
  --fp-rail-w: 198px;
  --fp-pad: 16px;
  --fp-area: 290px;
  width: min(57rem, calc(100vw - 2 * var(--gutter)));
  border-color: var(--border-strong);
}

/* --- Rail mode: column count (container query on the pane) ----------------- */

/* Two columns. The collection's pane is ~682px here and never reaches three;
   the catalog's does only at its full width. The companion `--fp-rows` that
   used to sit beside each of these is gone with the pager: how many rows fit
   was a page size, and there are no pages — the area holds what it holds and
   scrolls past it. */
@container fp (min-width: 34rem) {
  .filter-rail-panel[data-rail="on"] .filter-pane-values {
    --fp-cols: 2;
  }
}

/* Three columns — the catalog at its full 1120px block. */
@container fp (min-width: 51rem) {
  .filter-rail-panel[data-rail="on"] .filter-pane-values {
    --fp-cols: 3;
  }
}

/* The shelves grouping segs (issue #717) render in every mode but show only in
   shelves mode. Hidden, not dropped: their selects have to keep serializing so
   a round trip through grid/list doesn't silently reset the grouping. They
   live in the swapped results row now, so the state they key off is the
   toolbar's own data-view — no :has() reaching out of the swap region. */
.results-toolbar:not([data-view="shelves"]) .search-seg--shelves {
  display: none;
}

/* ---------------------------------------------------------------------------
 * The collection results row (design "Collection Refined"): active chips left,
 * display controls right. The generic .results-toolbar / .toolbar-controls
 * geometry above already draws the row; these rules are what the collection's
 * own occupants need inside it.
 * ------------------------------------------------------------------------- */

/* The row carries no result count any more (it moved to the page head's meta
   line), so the chip list is the meta cell's whole content and should take the
   slack rather than sitting at its hypothetical width. */
.results-toolbar--collection .results-meta {
  flex: 1;
  min-width: 0;
}

/* Five controls share the right group in shelves mode (grouping ×2, Sort,
   page size, the view toggle), which is more than a rail width can hold on one
   line — they wrap rather than pan the document sideways. */
.results-toolbar--collection .toolbar-controls {
  flex-wrap: wrap;
  row-gap: 0.5rem;
}

/* Shelve-by / Order read as plain text dropdowns here, not as bar segments:
   the bar's hairline separator and its leaf applied-value badge both belong to
   a bordered row this one doesn't have. */
.toolbar-controls .search-seg {
  padding: 0;
  border: 0;
  gap: 0.3rem;
  font-size: var(--fs-ui-sm);
  font-weight: 400;
  color: var(--text-faint);
}

.toolbar-controls .search-seg .seg-value {
  font-family: var(--font-body);
  font-size: var(--fs-ui-sm);
  font-weight: 500;
  color: var(--text);
}

.toolbar-controls .search-seg .seg-caret {
  color: var(--text-faint);
}

/* The seg draws the control and the native <select> covers it invisibly. The
   bare `.results-toolbar select` reset above (which exists for the page-size
   picker) would otherwise strip that overlay's geometry — it is later in the
   sheet than .search-seg's own rule and equally specific. */
.toolbar-controls .search-seg select {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  background: none;
  opacity: 0;
  cursor: pointer;
}

/* ---- The chips ---------------------------------------------------------- */

/* The chips themselves are the shared `.chip--removable` (see "Removable
   filter chips" above) — the collection's row draws the same control the
   catalog's does. "Clear all" closes both pages' rows too now (the collection
   grew it first, the catalog's meta row followed for parity), so this pair of
   rules dresses each.

   It closes the row as a text action, not a bordered button — the row is
   chips, and a third box would read as a fourth filter. Selected through the
   list so it outranks the global `li a` underline (which counts two type
   selectors plus its :not() argument, and so beats a lone class). */
.filter-chips .chip-clear-all {
  padding: 0 0.15rem;
  font-size: var(--fs-ui-sm);
  color: var(--text-faint);
  text-decoration: none;
}

.filter-chips .chip-clear-all:hover {
  color: var(--text);
  text-decoration: none;
}

/* ---- The page head's action pair ---------------------------------------- */

/* Export CSV as a quiet text action, then Import as the outlined one — the
   design's weighting: exporting is housekeeping, importing is what fills the
   shelves. */
.page-head-actions {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  margin-left: auto;
}

.head-action {
  font-size: var(--fs-ui-sm);
  font-weight: 500;
  color: var(--text-muted);
  white-space: nowrap;
}

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

/* Play state in the mono data voice (spec 04): "▶ Playing" and "↻ Recurring"
   in brass — both are active engagement (issues #422/#742) — "✓ Completed" in
   leaf, "Backlog" plain, "✕ Abandoned" muted.
   Sized relative to its context so it sits right in table cells, card meta
   lines, and the entry detail list. */
.play-state {
  font-family: var(--font-mono);
  font-size: 0.95em;
}

.play-state--playing,
.play-state--recurring {
  color: var(--brass);
}

.play-state--completed {
  color: var(--accent-text);
}

.play-state--abandoned {
  color: var(--text-muted);
}

/* ---------------------------------------------------------------------------
 * Home / landing page (spec 05.1)
 * ------------------------------------------------------------------------- */

/* The refined spec tops the panel and the copy out rather than centring them:
   the panel's head sits level with the eyebrow, and the column is 404px wide
   against a 72px gutter. */
.hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 25.25rem;
  gap: clamp(2rem, 5vw, 4.5rem);
  align-items: start;
  padding-block: clamp(2rem, 6vw, 4.75rem) clamp(2rem, 5vw, 3rem);
}

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

.hero .eyebrow {
  margin-bottom: 1.25rem;
  letter-spacing: 0.16em;
}

.hero h1 {
  font-size: clamp(2.4rem, 1.4rem + 4.5vw, 3.75rem);
  line-height: 1.02;
  letter-spacing: -0.02em;
  margin-bottom: 1.4rem;
}

.hero h1 .accent {
  font-style: italic;
  color: var(--accent-text);
}

.hero .lede {
  font-size: 0.9375rem;
  line-height: 1.62;
  color: var(--text-muted);
  max-width: 38rem;
  margin-bottom: 1.9rem;
  text-wrap: pretty;
}

.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}

/* The hero's closing credentials line: mono micro-caps, dot-separated. The
   separator is a ::before on every item but the first, so it is decoration the
   markup never has to carry (and never reaches the accessibility tree). */
.hero-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem 0.65rem;
  list-style: none;
  margin: 1.65rem 0 0;
  padding: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.hero-meta li {
  display: flex;
  align-items: center;
  gap: 0.65rem;
}

.hero-meta li + li::before {
  content: "";
  width: 3px;
  height: 3px;
  flex: none;
  border-radius: 50%;
  background: currentColor;
}

/* The librarian panel (spec 05.1): the BYO-LLM workflow (#234) — the user's
   own assistant filing games over the API/MCP server. Live agent activity
   when there is any, the worked example otherwise (#366).

   The refined spec rebuilds it as a banded surface: a titled head, a run of
   hairline-separated rows, and a footer bar — instead of a single padded box
   holding free-floating pill rows. The panel itself carries no padding now
   (each band supplies its own) and clips its children so the full-bleed bands
   square off cleanly against the corner radius. */
.librarian-panel {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  box-shadow: var(--shadow-panel);
}

.librarian-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 1rem 1.15rem 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-muted);
}

.librarian-head::before {
  content: "";
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
  /* The spec haloes the live dot; the ring is transparent-tinted so it reads
     on the panel fill in either theme. */
  box-shadow: 0 0 0 3px var(--accent-border);
}

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

.librarian-status {
  margin-left: auto;
  letter-spacing: 0.08em;
  color: var(--text-faint);
}

/* Closes the head band: the hairline sits under the sub-line, so the rule
   divides the whole title block from the rows rather than splitting it. */
.librarian-sub {
  padding: 0.45rem 1.15rem 0.85rem;
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.5;
  color: var(--text-faint);
  margin: 0;
}

.librarian-rows {
  display: flex;
  flex-direction: column;
}

.librarian-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.8rem 1.15rem;
  /* Fainter than --border: these separate peers inside a band, where the head
     and footer rules divide the bands themselves. */
  border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}

.librarian-row:last-child {
  border-bottom: 0;
}

/* Tiny cover placeholder: the spec's diagonal-stripe stand-in. */
.librarian-row .mini-cover {
  flex: none;
  width: 32px;
  height: 43px;
  border-radius: 2px;
  background: repeating-linear-gradient(
    45deg,
    var(--border) 0 5px,
    var(--fill) 5px 10px
  );
}

/* min-width: 0 lets this flex child shrink below its content width — without
   it the title's ellipsis below can never trigger and a long game name shoves
   the verb column off the panel instead. */
.librarian-row .row-body {
  min-width: 0;
}

.librarian-row .row-title {
  display: block;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  color: var(--text);
  line-height: 1.3;
  /* Real catalog titles run long ("The Legend of Zelda: The Wind Waker"); the
     spec keeps each row one line and clips (issue #366's rows are a feed, not
     a reading surface). */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.librarian-row .row-note {
  display: block;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

/* Verb over elapsed time, right-aligned in its own column (refined spec). */
.librarian-row .row-state {
  display: flex;
  flex: none;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.2rem;
  margin-left: auto;
  text-align: right;
}

.librarian-row .row-verb {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  color: var(--accent-text);
}

.librarian-row .row-verb--brass {
  color: var(--brass);
}

.librarian-row .row-when {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
  white-space: nowrap;
}

/* Footer band: tinted strip closing the panel, flush to its edges.

   The label is --text-muted, not the --text-faint this line used to carry:
   --fill lightens the band, and composited over --surface that leaves faint
   text at 4.22:1 in dark — under WCAG AA, which tests/ui/test_a11y_axe.py
   catches. (The spec's own 0.02 tint measures 4.42:1, so the tint is not the
   thing to trade away — the text colour is.) */
.works-with {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  padding: 0.7rem 1.15rem;
  border-top: 1px solid var(--border);
  background: var(--fill);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* Agent names are lowercase brand strings ("claude", "chatgpt") — they opt out
   of the band's uppercasing rather than being shouted. */
.works-with .mcp-chip {
  border: 1px solid var(--border);
  border-radius: 3px;
  padding: 0.1rem 0.45rem;
  text-transform: none;
  letter-spacing: 0.02em;
  color: var(--text-muted);
}

/* Live-panel additions (#366): real cover thumbnails keep the placeholder's
   footprint; row titles link to the game / recommendations page. */
img.mini-cover {
  display: block;
  object-fit: cover;
}

.librarian-row .row-title a {
  color: inherit;
  text-decoration: none;
}

.librarian-row .row-title a:hover {
  text-decoration: underline;
}

/* Zero state: the head's status dot goes neutral until an agent connects. */
.librarian-head--idle::before {
  background: var(--border);
}

/* The zero-state CTA is a band like .works-with, so it carries its own gutters
   now that the panel has none. */
.librarian-cta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.6rem;
  padding: 0.85rem 1.15rem;
  border-top: 1px solid var(--border);
  background: var(--fill);
}

.librarian-cta .librarian-cta-note {
  flex: 1 1 12rem;
  font-size: var(--fs-ui-sm);
  font-style: italic;
  color: var(--text-muted);
}

.librarian-cta .btn {
  padding: 0.45rem 0.85rem;
  font-size: var(--fs-ui-sm);
}

/* Staggered load-in for the hero (motion-safe only). */
@media (prefers-reduced-motion: no-preference) {
  .hero > * {
    animation: rise 0.55s cubic-bezier(0.2, 0.7, 0.2, 1) both;
  }

  .hero > *:nth-child(2) {
    animation-delay: 70ms;
  }

  .hero > *:nth-child(3) {
    animation-delay: 140ms;
  }

  .hero > *:nth-child(4) {
    animation-delay: 210ms;
  }
}

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

  to {
    opacity: 1;
    transform: none;
  }
}

/* ---------------------------------------------------------------------------
 * Signed-out home hero: the marketing pitch and demo panel (design spec 23
 * §B–D, issue #1894)
 *
 * One flat grid; every child carries a grid-area so the ≤720 reorder (CTA
 * above the panel, chips below it as one bled scrolling row) is decided here
 * while the markup keeps desktop reading order. The 1fr tail row stops the
 * panel's span from inflating the rows between the left column's items.
 *
 * The demo panel cycles under app.js (initDemoPanel): --demo-progress is set
 * on the section each frame and drives both the 2px rail and the active
 * chip's gradient fill, so the two clocks can never disagree. At rest — no
 * JS, or reduced motion — the var falls back to 100% and scenario one sits
 * fully revealed: cycling, not content, is the enhancement.
 * ------------------------------------------------------------------------- */

.hero-out {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 26.5rem;
  grid-template-rows: repeat(5, auto) 1fr;
  grid-template-areas:
    "kicker panel"
    "headline panel"
    "lede panel"
    "chips panel"
    "actions panel"
    "trust panel";
  column-gap: 3rem;
  align-items: start;
  /* The top pad is .portrait/.onboard's exact clamp: all three heroes sit on
     the same nav, so signing in must not move the page's first line. */
  padding-block: clamp(2rem, 6vw, 2.75rem) clamp(2rem, 5vw, 3.5rem);
}

.hero-out-kicker {
  grid-area: kicker;
  margin-bottom: 1.1rem;
  letter-spacing: 0.16em;
}

.hero-out-headline {
  grid-area: headline;
  margin-bottom: 1.25rem;
  font-size: clamp(2.375rem, 1.9rem + 2.2vw, 3.5rem);
  line-height: 1.03;
  letter-spacing: -0.02em;
}

/* The second line is the headline's only italic and one of the page's few
   accent uses. A hard <br> in the markup keeps it two lines; it never wraps
   on its own. */
.hero-out-headline em {
  color: var(--accent-text);
}

.hero-out-lede {
  grid-area: lede;
  max-width: 30.625rem;
  margin-bottom: 1.4rem;
  font-size: var(--fs-body);
  line-height: 1.62;
  color: var(--text-muted);
  text-wrap: pretty;
}

/* The lede's closing sentence comes in two lengths; display:none keeps the
   unshown one out of the accessibility tree (the portrait-across pattern),
   so a screen reader hears one sentence. */
.hero-out-lede-short {
  display: none;
}

/* Four example phrases wrapping to two rows inside the text column. JS-only
   controls: the row ships [hidden] until app.js reveals it. */
.hero-out-chips {
  grid-area: chips;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  max-width: 32.5rem;
  margin-bottom: 1.75rem;
}

.demo-chip {
  /* position anchors the touch overlay the floor block buys (§C TAP
     TARGETS): a 44px pill is not the same object, so the target arrives as
     a transparent ::after there instead of height here. */
  position: relative;
  padding: 0.55rem 0.8rem;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: transparent;
  color: var(--text-muted);
  font-family: var(--font-body);
  font-size: var(--fs-ui-sm);
  line-height: 1.35;
  cursor: pointer;
  transition:
    color 0.18s ease,
    border-color 0.18s ease;
}

/* The global button:hover fill reads as a pressed pill here; a hover only
   sharpens the label. */
.demo-chip:hover {
  background: transparent;
  border-color: var(--border-strong);
  color: var(--text);
}

/* The active chip wears the cycle's progress as a hard-edged gradient fill —
   the same --demo-progress the rail reads, resting at 100%. */
.demo-chip.is-active {
  border-color: color-mix(in srgb, var(--accent) 55%, transparent);
  color: var(--text);
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--accent) 22%, transparent) var(--demo-progress, 100%),
    color-mix(in srgb, var(--accent) 5%, transparent) var(--demo-progress, 100%)
  );
}

/* The ≤720 chip row's affordance line — a bled row is easy to miss on a
   short screen. Desktop never shows it. */
.hero-out-hint {
  display: none;
  margin: 0.6rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.hero-out-actions {
  grid-area: actions;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.625rem;
  margin-bottom: 1.4rem;
}

.hero-out-actions .btn {
  padding: 0.7rem 1.3rem;
}

/* Three claims, all currently true, no fourth. The dot separator is the
   hero-meta idiom: decoration on the <li>, never in the markup. */
.hero-out-trust {
  grid-area: trust;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem 0.65rem;
  list-style: none;
  margin: 0;
  padding: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

/* No gap between the li's own flex items: "No ads" and its ≤720-trimmed
   ", no tracking" span are siblings inside one li, and a gap would print as
   a stray space before the comma. The dot spaces itself instead. */
.hero-out-trust li {
  display: flex;
  align-items: center;
}

.hero-out-trust li + li::before {
  content: "";
  width: 3px;
  height: 3px;
  flex: none;
  margin-right: 0.65rem;
  border-radius: 50%;
  background: currentColor;
}

/* --- The demo panel (§D): the only elevated surface in the hero ---------- */

.demo-panel {
  grid-area: panel;
  align-self: start;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  box-shadow: var(--shadow-panel);
}

.demo-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0;
  padding: 0.8rem 1.05rem;
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text);
}

/* The live dot, haloed like the librarian panel's. */
.demo-head::before {
  content: "";
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-border);
}

/* Not optional and not hoverable: the badge (plus the foot line) is what
   keeps a second-person demo from reading as the visitor's own data. */
.demo-badge {
  margin-left: auto;
  padding: 0.1rem 0.4rem;
  border: 1px solid var(--border-strong);
  border-radius: 3px;
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.1em;
  color: var(--text-faint);
}

/* The 2px rail under the head: width = elapsed share of the current scenario's
   cycle, resting at 100%. It is the only clock on screen. The cycle is not a
   constant — app.js sizes each scenario's dwell from its own word count (issue
   #1899) — but the rail reads a share, so it needs no number here. */
.demo-rail {
  height: 2px;
  background: var(--fill);
}

.demo-rail-fill {
  height: 100%;
  width: var(--demo-progress, 100%);
  background: color-mix(in srgb, var(--accent) 55%, transparent);
}

/* min-height holds the three staged elements' tallest layout, so neither the
   cycle nor a scenario without a note line ever resizes the panel. */
.demo-body {
  display: flex;
  flex-direction: column;
  gap: 0.875rem;
  min-height: 18.75rem;
  padding: 1.1rem 1.05rem 1.25rem;
}

/* What the collector typed, in their own words — a right-aligned bubble. */
.demo-said {
  align-self: flex-end;
  max-width: 88%;
  margin: 0;
  padding: 0.7rem 0.9rem;
  background: var(--fill);
  border: 1px solid var(--border);
  border-radius: 10px 10px 3px 10px;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text);
}

/* The reply arrives whole — no typing indicator, no character-by-character
   reveal: that is what the real thing looks like. */
.demo-reply {
  display: flex;
  align-items: flex-start;
  gap: 0.7rem;
}

.demo-reply p {
  margin: 0;
  min-width: 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text);
}

.demo-avatar {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 22px;
  height: 22px;
  margin-top: 2px;
  border: 1px solid var(--accent-border);
  border-radius: 50%;
  background: var(--accent-dim);
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  color: var(--accent-text);
}

/* The payoff: the collection row that changed — a database row, not a chat
   message. */
.demo-result {
  padding: 0.75rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--text) 3%, transparent);
}

.demo-result-head {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  margin: 0 0 0.7rem;
  font-family: var(--font-mono);
  text-transform: uppercase;
}

.demo-result-kicker {
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.1em;
  color: var(--accent-text);
}

/* --text-muted, not the faint the mock draws: the card sits on a tint, and
   faint text composited over one falls under 4.5:1 in dark — the librarian
   footer's AA lesson, and axe red the same way here. */
.demo-result-label {
  margin-left: auto;
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.06em;
  color: var(--text-muted);
}

.demo-result-row {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  margin: 0;
}

.demo-result-body {
  min-width: 0;
}

.demo-result-title {
  display: block;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  color: var(--text);
}

.demo-result-meta {
  display: block;
  margin-top: 3px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  text-transform: uppercase;
  color: var(--text-muted);
}

.demo-note {
  margin: 0.7rem 0 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text-muted);
}

/* The third sign-up affordance, and the only one below the fold-line. The
   band tint is --fill with --text-muted copy — the librarian footer's AA
   lesson: faint text composited over the tint falls under 4.5:1. */
.demo-foot {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0;
  padding: 0.7rem 1.05rem;
  border-top: 1px solid var(--border);
  background: var(--fill);
}

.demo-foot-note {
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

.demo-foot-link {
  /* position anchors the touch overlay the floor block buys: the panel's
     overflow: hidden clips past the band, so the link cannot simply grow. */
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent-text);
  white-space: nowrap;
}

/* The staged reveal (§D timing), motion-safe only: app.js flags the panel
   is-live before starting the clock, so without it — no JS, or reduced
   motion — the resting rules above keep scenario one fully revealed. The
   exit rule sits after is-in: all three stages fade out together over
   900ms, and the swap happens while they are invisible.

   🚨 Both durations below are mirrored in initDemoPanel as FADE_MS (0.42s) and
   EXIT_FADE_MS (0.9s), which is how it knows when a stage became legible and
   how much of the cycle is fade-out rather than reading time (issue #1899).
   Change one here and change it there, or the reading budget silently drifts. */
@media (prefers-reduced-motion: no-preference) {
  .demo-panel.is-live .demo-stage {
    opacity: 0;
    transform: translateY(7px);
    transition:
      opacity 0.42s ease,
      transform 0.42s cubic-bezier(0.2, 0.7, 0.3, 1);
  }

  .demo-panel.is-live .demo-stage.is-in {
    opacity: 1;
    transform: none;
  }

  .demo-panel.is-live.is-exiting .demo-stage {
    opacity: 0;
    transform: none;
    transition: opacity 0.9s ease;
  }
}

/* The one breakpoint (§C): a single column, and the only place in the hero
   where visual order leaves source order — the CTA moves above the panel
   (stacked at 390px, anything above the button is a toll gate), the chips
   drop below it as one scrolling row bled into both gutters, and the hint
   line appears because the cut-off row is the only affordance. */
@media (max-width: 720px) {
  .hero-out {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: none;
    grid-template-areas:
      "kicker"
      "headline"
      "lede"
      "actions"
      "trust"
      "panel"
      "chips"
      "hint";
  }

  .hero-out-kicker {
    margin-bottom: 0.9rem;
  }

  .hero-out-lede {
    margin-bottom: 1.25rem;
  }

  .hero-out-lede-full {
    display: none;
  }

  .hero-out-lede-short {
    display: inline;
  }

  .hero-out-actions {
    flex-direction: column;
    align-items: stretch;
    gap: 0.5625rem;
    margin-bottom: 0.875rem;
  }

  .hero-out-actions .btn {
    width: 100%;
    min-height: 3rem;
  }

  .hero-out-trust {
    justify-content: center;
    margin-bottom: 1.6rem;
  }

  .hero-out-trust-full {
    display: none;
  }

  .demo-body {
    gap: 0.75rem;
    min-height: 14.75rem;
    padding: 0.875rem;
  }

  /* The exchange and the card survive; the note line and the card's label
     give way to the width. */
  .demo-note,
  .demo-result-label {
    display: none;
  }

  /* 🚨 The 4px of block padding is hit-area plumbing, not rhythm: a scroll
     container (overflow-y: hidden included) clips hit-testing at its padding
     box, so the touch floor's ::after overlay on the chips only lands if the
     row leaves room for it. The margins give the 4px straight back. */
  .hero-out-chips {
    flex-wrap: nowrap;
    gap: 0.5rem;
    max-width: none;
    margin: calc(0.75rem - 4px) calc(var(--gutter) * -1) -4px;
    padding: 4px var(--gutter);
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
  }

  .hero-out-chips::-webkit-scrollbar {
    display: none;
  }

  /* Chips hold the row's rhythm at ~38px; their extra hit area is bought in
     the touch-floor block (§C TAP TARGETS), which also reaches the coarse
     tablet no width query does. */
  .demo-chip {
    flex: none;
    white-space: nowrap;
  }

  .hero-out-hint {
    display: block;
  }
}

/* Signed out is not a page anybody prints; the panel is dropped rather than
   frozen mid-cycle, and the JS-only chip row goes with it. */
@media print {
  .demo-panel,
  .hero-out-chips,
  .hero-out-hint {
    display: none;
  }
}

/* Last, so they outrank the display rules above at equal specificity: a
   JS-only control that ships [hidden] must actually be none — an author
   display beats the UA's [hidden] rule (the #1642 lesson). */
.hero-out-chips[hidden],
.hero-out-hint[hidden] {
  display: none;
}

/* ---------------------------------------------------------------------------
 * Signed-in home hero: the collection portrait (design spec 23, issue #1646)
 *
 * Replaces the .hero-grid marketing hero for a signed-in shelf at or above the
 * floor. The stat strip and "Popular now" rail below it are unaffected — spec
 * 23's "the hero is the whole page" line is overreach, not a layout note.
 *
 * Accent budget is five uses on this page: the wordmark dot, the headline's era
 * clause, the peak bar, the spread leader bar, and the strip's live dot (plus
 * the one link arrow). Nothing else here is green.
 *
 * No motion at any setting — bars, numbers and facts paint at final value. The
 * portrait has no controls: nothing here is hoverable or clickable except the
 * links.
 * ------------------------------------------------------------------------- */

.portrait {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 20rem;
  gap: 2.5rem;
  align-items: start;
  padding-block: clamp(2rem, 6vw, 2.75rem) clamp(2rem, 5vw, 3rem);
}

.portrait-kicker {
  margin: 0 0 1rem;
  font-family: var(--font-mono);
  font-size: 0.71875rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* An h2, not an h1: it reports rather than titling the site, and it changes
   week to week. Newsreader at 40px holds a five-digit count on one line to
   1064px; beyond 99,999 it wraps, which is acceptable. */
.portrait-headline {
  margin: 0 0 1.625rem;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 2.5rem;
  line-height: 1.08;
  letter-spacing: -0.02em;
  color: var(--text);
  text-wrap: pretty;
}

.portrait-headline em {
  font-style: italic;
  color: var(--accent-text);
}

/* "across" on desktop, a comma at ≤720 — display:none keeps the unused one out
   of the accessibility tree, so a screen reader hears exactly one sentence. */
.portrait-across-short {
  display: none;
}

.portrait-chart {
  display: flex;
  align-items: flex-end;
  gap: 6px;
  height: 7.5rem;
  margin-bottom: 0.625rem;
}

.portrait-bar {
  flex: 1 1 0;
  min-width: 0;
  height: var(--bar-height);
  background: var(--portrait-bar-rest);
  border-radius: 2px 2px 0 0;
}

/* The peak drives the accent AND the headline's era clause from one value, so
   the sentence and the chart cannot disagree. */
.portrait-bar--peak {
  background: var(--accent);
}

.portrait-bar--runner-up {
  background: var(--portrait-bar-runner-up);
}

/* UNDATED is always the faintest step and is never accented: it is the one
   bucket that can never be the story. */
.portrait-bar--undated {
  background: var(--portrait-bar-undated);
}

.portrait-axis {
  display: flex;
  gap: 6px;
  margin-bottom: 1.75rem;
  font-family: var(--font-mono);
  font-size: 0.65625rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Each label shares its bar's flex, so labels stay centred under their column
   however the chart is compressed. */
.portrait-axis-label {
  flex: 1 1 0;
  min-width: 0;
}

.portrait-axis-label--peak {
  color: var(--text);
}

.portrait-axis-short {
  display: none;
}

/* ≤720 only: the abbreviated axis costs two counts and this restores them. */
.portrait-peak-line {
  display: none;
  margin: 0 0 1.5rem;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.portrait-facts {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1.5rem;
}

.portrait-fact {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.portrait-fact-label {
  font-family: var(--font-mono);
  font-size: 0.65625rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
  margin-bottom: 0.5625rem;
}

/* One line, ellipsised at the column edge. A truncated series name is a worse
   fact than no fact, which is why the serialiser prefers the shorter of two
   duplicate series rows before it ever reaches here. */
.portrait-fact-value {
  font-family: var(--font-display);
  font-size: 1.375rem;
  line-height: 1.2;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.portrait-fact-qualifier {
  margin-top: 0.3125rem;
  font-size: 0.8125rem;
  color: var(--text-muted);
}

/* The rail holds one card and nothing else ever goes in it. Sized to run level
   with the facts row rather than with the whole portrait column. */
.portrait-rail {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 7px;
  padding: 1rem 1.0625rem;
}

.portrait-rail-label {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.65625rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
  margin-bottom: 0.875rem;
}

.spread-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.spread-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  column-gap: 0.75rem;
  align-items: baseline;
}

.spread-name {
  font-size: 0.8125rem;
  color: var(--text);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.spread-count {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  color: var(--text-muted);
}

.spread-track {
  grid-column: 1 / -1;
  margin-top: 5px;
  height: 3px;
  background: var(--portrait-track);
}

/* Proportional to the LEADER, never to the total: scaling against the total
   flattens every bar below first place into a stub, and the tail is the
   interesting part of a wide collection. */
.spread-fill {
  display: block;
  width: var(--spread-width);
  height: 100%;
  background: var(--portrait-spread-fill);
}

.spread-fill--leader {
  background: var(--accent);
}

/* Text, not a link — the remainder is context, not a destination. */
.spread-remainder {
  margin: 0.75rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Desktop hides this; at ≤720 it becomes the 44px route onward, because on a
   phone the spread card is the natural place to leave from. */
.spread-all {
  display: none;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent-text);
}

/* Full width beneath both columns. flex-wrap so the link drops below the
   sentence rather than squeezing it. */
.portrait-strip {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  gap: 0.875rem;
  flex-wrap: wrap;
  margin: 1.75rem 0 0;
  padding: 0.75rem 1rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--fill);
}

.portrait-dot {
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-dim);
}

/* Takes the leftover width and is allowed to shrink, so the sentence wraps
   inside its own column instead of pushing itself onto a line below the dot —
   the dot has to stay level with the sentence's FIRST line. */
.portrait-strip-text {
  flex: 1 1 0;
  min-width: 0;
  font-size: 0.8125rem;
  color: var(--text-muted);
}

.portrait-strip-text strong {
  font-weight: 400;
  color: var(--text);
}

/* FIX THE GAPS → (issue #1639): the rail link's type, pushed to the far end.
   `flex: none` + nowrap so a long sentence wraps inside its own column and
   the link drops whole onto the next line rather than breaking mid-phrase. */
.portrait-strip-link {
  flex: none;
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--accent-text);
  text-decoration: none;
}

.portrait-strip-link:hover,
.portrait-strip-link:focus-visible {
  text-decoration: underline;
}

@media (max-width: 720px) {
  /* One step, and the two columns linearise in source order: portrait, then
     rail, then the strip. Nothing is hidden and nothing is added — the facts
     finish the sentence the headline starts, so the rail follows them. */
  .portrait {
    grid-template-columns: 1fr;
    gap: 1.75rem;
  }

  .portrait-across {
    display: none;
  }

  .portrait-across-short {
    display: inline;
  }

  .portrait-headline {
    font-size: 1.875rem;
    line-height: 1.1;
  }

  .portrait-chart {
    height: 6rem;
    gap: 5px;
  }

  .portrait-axis {
    gap: 5px;
    font-size: 0.625rem;
    margin-bottom: 1rem;
  }

  .portrait-axis-long {
    display: none;
  }

  .portrait-axis-short {
    display: inline;
  }

  .portrait-peak-line {
    display: block;
  }

  /* Three stacked rows with a hairline above each and below the last. The
     qualifier goes beneath the value, never beside it: the longest pair
     overflows 354px on one line. */
  .portrait-facts {
    grid-template-columns: 1fr;
    gap: 0;
  }

  .portrait-fact {
    padding-block: 0.875rem;
    border-top: 1px solid var(--border);
  }

  .portrait-fact:last-child {
    border-bottom: 1px solid var(--border);
  }

  .portrait-fact-value {
    font-size: 1.3125rem;
  }

  .spread-name {
    font-size: 0.84375rem;
  }

  .spread-remainder {
    display: none;
  }

  .spread-all {
    display: flex;
    align-items: center;
    min-height: 44px;
    margin-top: 0.25rem;
  }

  /* The dot aligns to the first line of the sentence, and the link drops to its
     own 44px row beneath. Same copy as desktop, verbatim. */
  .portrait-strip {
    align-items: flex-start;
  }

  .portrait-dot {
    margin-top: 0.4rem;
  }
}

/* The rail prints beneath the facts and links print as plain text. */
@media print {
  .portrait {
    grid-template-columns: 1fr;
  }
}

/* Onboarding hero (design spec 24, issue #1642): the signed-in home under the
   portrait's floor. One state, two widths, three tracks. Every figure is a
   count and the counts come from where their destinations count
   (apps/collection/onboarding.py). No motion at any setting: the rail renders
   at its final value on first paint.

   Accent budget, spec section B: leaf on the kicker, IN PROGRESS, the filled
   ticks, the connected line and the browse link; brass on NOT CONNECTED only.
   Nothing else on the page is coloured. */
.onboard {
  padding-block: clamp(2rem, 6vw, 2.75rem) clamp(2rem, 5vw, 2.75rem);
}

/* One grid so the lede can move between widths without a second copy: on
   desktop kicker / headline / lede stack in a 35rem measure with the figure
   beside them on the lede's row, bottom-aligned; the rail and its caption run
   full width beneath. At ≤720 (below) the figure goes and the lede drops
   under the caption. */
.onboard-head {
  display: grid;
  grid-template-columns: minmax(0, 35rem) 1fr;
  grid-template-areas:
    "kicker   ."
    "headline ."
    "lede     figure"
    "rail     rail"
    "caption  caption";
  column-gap: 2.75rem;
  align-items: end;
  margin-bottom: 1.875rem;
}

.onboard-kicker {
  grid-area: kicker;
  margin: 0 0 1rem;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent-text);
}

.onboard-headline {
  grid-area: headline;
  margin: 0 0 0.875rem;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 2.75rem;
  line-height: 1.05;
  letter-spacing: -0.02em;
  color: var(--text);
  text-wrap: pretty;
}

.onboard-lede {
  grid-area: lede;
  margin: 0;
  font-size: 0.9375rem;
  line-height: 1.62;
  color: var(--text-muted);
  text-wrap: pretty;
}

/* The figure: the only place the denominator appears as a number on desktop.
   Right-aligned, sat on the lede's baseline row. */
.onboard-figure {
  grid-area: figure;
  justify-self: end;
  margin: 0;
  text-align: right;
  white-space: nowrap;
}

.onboard-figure-count {
  font-family: var(--font-display);
  font-size: 4rem;
  line-height: 0.9;
  color: var(--text);
}

.onboard-figure-of {
  font-family: var(--font-display);
  font-size: 1.875rem;
  color: var(--text-faint);
}

.onboard-figure-label {
  display: block;
  margin-top: 0.625rem;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* 25 ticks, flex: 1 each, full bleed. Filled ticks stand 26px in leaf; empty
   ones 11px in the track tint. Bottom-aligned so the two heights read as one
   bar with a step in it, not two rows. */
.onboard-rail {
  grid-area: rail;
  display: flex;
  align-items: flex-end;
  gap: 4px;
  height: 26px;
  margin-top: 1.875rem;
}

.onboard-tick {
  flex: 1 1 0;
  height: 11px;
  border-radius: 1px;
  background: var(--onboard-tick);
}

.onboard-tick--filled {
  height: 26px;
  background: var(--accent);
}

/* Caption: "14 TO GO" on the right. The left item ("11 OF 25 FILED") is the
   phone's replacement for the figure and stays hidden here. */
.onboard-caption {
  grid-area: caption;
  display: flex;
  justify-content: flex-end;
  margin: 0.5625rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.onboard-caption-filled {
  display: none;
  color: var(--text);
}

/* Three equal tracks, stretched to the tallest. One accented card per row —
   Track 01, because it is the fastest route for a collector with a file, not
   because the others are optional. */
.onboard-tracks {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1.125rem;
  align-items: stretch;
}

.onboard-track {
  display: flex;
  flex-direction: column;
  min-width: 0;
  padding: 1.125rem 1.25rem 1.25rem;
  border: 1px solid var(--border);
  border-radius: 7px;
  background: var(--surface);
}

.onboard-track--accent,
.onboard-track--connected {
  border-color: var(--accent-border);
}

.onboard-track-head {
  display: flex;
  align-items: baseline;
  gap: 0.625rem;
  margin: 0 0 0.8125rem;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  text-transform: uppercase;
}

.onboard-track-n {
  letter-spacing: 0.12em;
  color: var(--text-faint);
}

.onboard-track-status {
  margin-left: auto;
  letter-spacing: 0.1em;
  color: var(--text-faint);
  white-space: nowrap;
}

.onboard-track-status--leaf {
  color: var(--accent-text);
}

.onboard-track-status--brass {
  color: var(--brass-text);
}

.onboard-live {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
}

.onboard-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-dim);
}

.onboard-track-title {
  margin: 0 0 0.6875rem;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 1.4375rem;
  line-height: 1.25;
  color: var(--text);
}

.onboard-track-lede {
  margin: 0 0 0.9375rem;
  font-size: 0.84375rem;
  line-height: 1.6;
  color: var(--text-muted);
}

/* Route rows (Track 01) and catalog rows (Track 03): hairline boxes that lift
   their border on hover. Nothing moves, scales or shadows. Track 01's rows
   stack a mono qualifier under the title — a second line, never a right-hand
   column, because the card is too narrow for both on one line; Track 03's
   name and count are short enough to share a line and do. */
.onboard-row {
  display: flex;
  box-sizing: border-box;
  padding: 0.625rem 0.8125rem;
  border: 1px solid var(--border);
  border-radius: 5px;
  color: var(--text);
  text-decoration: none;
}

.onboard-row + .onboard-row {
  margin-top: 0.5rem;
}

/* The border lift is the whole hover. Colour and decoration must be restated
   or the global `a:hover` (0,1,1) beats the resting class and repaints the
   row green with an underline — these are control rows like .settings-rows a,
   not links in prose. */
.onboard-row:hover {
  border-color: var(--border-strong);
  color: var(--text);
  text-decoration: none;
}

.onboard-row--stacked {
  flex-direction: column;
  gap: 3px;
}

.onboard-row--inline {
  align-items: center;
  gap: 0.6875rem;
}

.onboard-row-title,
.onboard-row-name {
  font-size: 0.84375rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.onboard-row-qualifier,
.onboard-row-count {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-faint);
  white-space: nowrap;
}

.onboard-row-count {
  margin-left: auto;
}

/* The one primary action on the page. */
.onboard-action {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5625rem;
  box-sizing: border-box;
  height: 44px;
  margin-top: 0.25rem;
  border-radius: 5px;
  background: var(--text);
  color: var(--bg);
  font-size: 0.84375rem;
  font-weight: 600;
  text-decoration: none;
  transition: opacity var(--speed) ease;
}

/* .btn-primary's hover, exactly — same fill, 0.9 opacity, and the label
   restated so `a:hover` cannot turn it accent-green or underline it. */
.onboard-action:hover {
  color: var(--bg);
  text-decoration: none;
  opacity: 0.9;
}

/* Card footers pin to the bottom so the three stretched cards end level. */
.onboard-track-foot {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  margin: auto 0 0;
  padding-top: 1rem;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.onboard-browse {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  margin-top: auto;
  padding-top: 1rem;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent-text);
  text-decoration: none;
}

/* Text-link hover, as .portrait-strip-link. The filled action is NOT here:
   a filled button never underlines — its focus mark is the global ring. */
.onboard-browse:hover,
.onboard-browse:focus-visible {
  text-decoration: underline;
}

/* ------------------------------------------------------------------------ */
/* Track 03's ask (design "Onboarding Track 03", issue #1719)                */
/* ------------------------------------------------------------------------ */
/* The cold-start card asks what the collector owns instead of guessing from
   catalog size. It is ~90px taller than the read card; .onboard-tracks is
   align-items:stretch, so tracks 01 and 02 grow with it and their content
   does not move. That is the only effect outside this card and it happens on
   the cold start only. */

.onboard-ask {
  display: flex;
  flex: 1;
  flex-direction: column;
  margin: 0;
}

/* A fieldset breaks subgrid and carries UA chrome; strip it to a plain wrap
   container. The legend is sr-only, so the visible question stays the h3. */
.onboard-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.375rem;
  min-width: 0;
  margin: 0 0 0.9375rem;
  padding: 0;
  border: 0;
}

/* The checkbox is real and visually hidden, never replaced: the chip is its
   label, so a click, Space and the focus ring are all native. */
.onboard-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  box-sizing: border-box;
  min-height: 28px;
  padding: 0 0.625rem;
  border: 1px solid var(--border);
  border-radius: 14px;
  background: none;
  color: var(--text-muted);
  font-family: inherit;
  font-size: 0.78125rem;
  cursor: pointer;
}

.onboard-chip-box {
  position: absolute;
  width: 1px;
  height: 1px;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Never colour alone: the check glyph is the non-colour signal. It appears
   only when checked (spec §02: an unchecked chip is just its name — a held
   blank slot reads as a missing icon), so ticking reflows the chip's width;
   the checked font-weight bump below already does, and the row wraps.
   🚨 Two classes, not one: `svg.icon` (0-1-1) sets display:block, so a lone
   `.onboard-chip-tick` (0-1-0) loses the display fight at any source order. */
.onboard-chip .onboard-chip-tick {
  display: none;
  width: 10px;
  height: 10px;
  color: var(--accent-text);
}

.onboard-chip:hover {
  border-color: var(--border-strong);
}

.onboard-chip:has(.onboard-chip-box:checked) {
  border-color: var(--accent-border);
  background: var(--accent-wash, transparent);
  color: var(--text);
  font-weight: 600;
}

.onboard-chip:has(.onboard-chip-box:checked) .onboard-chip-tick {
  display: block;
}

.onboard-chip:has(.onboard-chip-box:focus-visible) {
  outline: 2px solid var(--accent-border);
  outline-offset: 2px;
}

/* Visibly a different kind of object from a platform chip, because it is a
   submit and not a choice. */
.onboard-chip--escape {
  border-style: dashed;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent-text);
}

/* 🚨 (0,2,0), and only for the contested properties: the global
   `button[type="submit"]` primary styling is (0,1,1), so at one class this
   chip was repainting as a dark primary pill — fill, --text border, --bg
   label, 600 weight — burying the dashed hairline and accent label declared
   above, and outshouting the card's real submit. Font sizing stays on the
   lone class so the touch floor's own .onboard-chip--escape can still win. */
.onboard-chips .onboard-chip--escape {
  background: none;
  border-color: var(--border);
  color: var(--accent-text);
  font-weight: 400;
}

/* (0,3,0) over `button[type="submit"]:hover` (0,2,1): it hovers as its
   sibling chips do — border lift, nothing filled, nothing dimmed. */
.onboard-chips .onboard-chip--escape:hover {
  background: none;
  border-color: var(--border-strong);
  color: var(--accent-text);
  opacity: 1;
}

.onboard-ask-submit {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5625rem;
  box-sizing: border-box;
  width: 100%;
  min-height: 44px;
  margin-top: auto;
  border: 0;
  border-radius: 5px;
  background: var(--text);
  color: var(--bg);
  font-family: inherit;
  font-size: 0.84375rem;
  font-weight: 600;
  cursor: pointer;
}

/* No hover rule of its own: `button[type="submit"]:hover` already gives it
   the primary-button treatment every other submit on the site gets. */

/* Three facts, no reassurance. Described by the submit, so a screen-reader
   user hears where the data goes as part of the action. */
.onboard-consent {
  margin: 0.5rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  line-height: 1.6;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Not a validation error and not styled as one. */
.onboard-notice {
  margin: 0.5rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The read state's eyebrow names which cascade tier the rows came from — the
   honesty of this card. EDIT sits beside it only where the rows ARE the
   setting. */
.onboard-rows-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.625rem;
  margin: 0 0 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.onboard-rows-edit {
  flex: none;
  color: var(--accent-text);
  text-decoration: none;
}

.onboard-rows-edit:hover,
.onboard-rows-edit:focus-visible {
  text-decoration: underline;
}

/* Connected Track 02: the phrasebook. Two prompts is what fits without
   growing the row; the copy control is the whole row's purpose, so it sits on
   the prompt's own foot line, right-aligned, with the optional qualifier
   ("ATTACH THE PHOTO IN CHAT") on the left. */
.onboard-prompts {
  display: flex;
  flex-direction: column;
  gap: 0.5625rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.onboard-prompt {
  padding: 0.6875rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: 5px;
}

.onboard-prompt:hover {
  border-color: var(--border-strong);
}

.onboard-prompt-text {
  margin: 0 0 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.78125rem;
  line-height: 1.55;
  color: var(--text);
}

.onboard-prompt-foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.75rem;
  margin: 0;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.onboard-prompt-qualifier {
  margin-right: auto;
  color: var(--text-faint);
}

/* The COPY control reuses app.js's delegated .copy-btn listener (it swaps to
   "Copied" for 1.6s — uppercase here, so COPIED). Ships `hidden`; app.js
   reveals data-js-only controls, so with JS off the prompt is selectable text
   and no dead label is shown. */
.onboard-copy {
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: var(--accent-text);
  cursor: pointer;
}

/* A text link in button clothing: hover underlines and strengthens, as the
   site's links do — and restating background keeps the generic `button:hover`
   grey fill off it. */
.onboard-copy:hover {
  background: none;
  color: var(--accent-text-strong);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

.onboard-copy.copy-btn--done {
  color: var(--accent-text);
}

/* 🚨 The `hidden` attribute is only a UA-sheet display:none, and any author
   display rule on a button outranks it — so the no-JS state has to say it
   again, or COPY paints with JS off after all. */
.onboard-copy[hidden] {
  display: none;
}

.onboard-row:focus-visible,
.onboard-prompt:focus-within,
.onboard-copy:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (max-width: 720px) {
  /* One step: the tracks linearise in source order, the figure goes, and the
     head reorders to kicker, headline, rail, caption, lede — the caption's
     left item takes over the figure's job. Nothing hidden that carries
     information, nothing added, nothing reordered among the tracks. */
  .onboard-head {
    grid-template-columns: 1fr;
    grid-template-areas:
      "kicker"
      "headline"
      "rail"
      "caption"
      "lede";
    margin-bottom: 1.625rem;
  }

  .onboard-kicker {
    margin-bottom: 0.875rem;
    font-size: 0.6875rem;
  }

  .onboard-headline {
    margin-bottom: 1.125rem;
    font-size: 2.125rem;
  }

  .onboard-figure {
    display: none;
  }

  .onboard-rail {
    gap: 3px;
    height: 22px;
    margin-top: 0;
  }

  .onboard-tick {
    height: 9px;
  }

  .onboard-tick--filled {
    height: 22px;
  }

  .onboard-caption {
    justify-content: space-between;
    margin-bottom: 1.125rem;
  }

  .onboard-caption-filled {
    display: inline;
  }

  .onboard-tracks {
    grid-template-columns: 1fr;
    gap: 0.875rem;
  }

  .onboard-track {
    padding: 1.0625rem 1.0625rem 1.125rem;
  }

  .onboard-track-title {
    font-size: 1.375rem;
  }

  .onboard-track-lede {
    font-size: 0.875rem;
  }

  /* Touch floors: import rows 52px (two lines), catalog rows, the browse link
     and the prompt foot 44px exactly, the primary action 48px. Border-box
     throughout, so the numbers mean what they say. */
  .onboard-row--stacked {
    min-height: 52px;
    justify-content: center;
    padding-block: 0.5rem;
  }

  .onboard-row--inline {
    min-height: 44px;
    padding-block: 0;
  }

  .onboard-row-title,
  .onboard-row-name {
    font-size: 0.875rem;
  }

  .onboard-action {
    height: 48px;
    font-size: 0.875rem;
  }

  /* The ask on a phone. Twelve chips at the 44px floor is ~240px of chips
     before the collector reaches the button they are meant to press, so six
     show and the rest are hidden — one checkbox per platform at every width,
     never two, so a hidden unticked box simply posts nothing. Which six is a
     server decision (the spread rule's vintage clause is re-applied), not a
     CSS one; this only hides what the server did not mark. */
  /* 🚨 (0,2,0), not the lone class. The touch floor's selector list further
     down this stylesheet gives .onboard-chip `display: inline-flex` at the
     same (0,1,0) specificity, and it wins on source order — so a lone
     `.onboard-chip--wide { display: none }` here hides nothing at all and the
     phone silently draws all twelve. Scoping to the parent out-specifies it
     wherever the two blocks sit relative to each other. */
  .onboard-chips .onboard-chip--wide {
    display: none;
  }

  /* Two sentences here, not three: the third does not fit the width. */
  .onboard-consent-tail {
    display: none;
  }

  .onboard-browse {
    min-height: 44px;
    padding-top: 0;
  }

  .onboard-prompt-foot {
    min-height: 44px;
  }

  .onboard-copy {
    min-height: 44px;
  }
}

@media (max-width: 320px) {
  /* Below 320px the 25 ticks cannot hold: one 4px bar, same caption. */
  .onboard-rail {
    display: block;
    height: 4px;
    border-radius: 1px;
    background: var(--onboard-tick);
  }

  .onboard-tick {
    display: none;
  }

  .onboard-rail::before {
    content: "";
    display: block;
    width: var(--onboard-progress, 0%);
    height: 100%;
    border-radius: 1px;
    background: var(--accent);
  }
}

@media print {
  /* Nobody prints this; the phone layout with the rail as plain text. */
  .onboard-head {
    grid-template-columns: 1fr;
    grid-template-areas:
      "kicker"
      "headline"
      "caption"
      "lede";
  }

  .onboard-figure,
  .onboard-rail {
    display: none;
  }

  .onboard-caption-filled {
    display: inline;
  }

  .onboard-tracks {
    grid-template-columns: 1fr;
  }
}

/* Stat strip (spec 05.1): hairline rules above and below, Newsreader numerals,
   mono captions — no boxes. */
.stat-strip {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1px;
  background: var(--border);
  border-block: 1px solid var(--border-strong);
  margin-bottom: clamp(2.5rem, 6vw, 4rem);
}

.stat {
  background: var(--bg);
  padding: 1rem 1.75rem;
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
}

/* The strip's outer cells sit flush with the content rail — the spec runs the
   first number hard against the column edge rather than inset by a cell
   gutter. Keyed to the 3-column track, so the 6-stat collection strip's second
   row flushes the same way its first does. */
.stat:nth-child(3n + 1) {
  padding-left: 0;
}

.stat:nth-child(3n) {
  padding-right: 0;
}

.stat .stat-value {
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 500;
  line-height: 1.1;
  letter-spacing: -0.01em;
  color: var(--text);
}

/* The personal stat ("in your collection") takes the leaf. */
.stat--mine .stat-value {
  color: var(--accent-text);
}

.stat .stat-label {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--text-muted);
}

/* Trailing aside on a stat ("+12 this week"), pinned to the cell's right end. */
.stat .stat-note {
  margin-left: auto;
  align-self: center;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--text-faint);
}

/* Six full-width rows was the bug (mobile layer spec §05C): six numbers are a
   summary, so on handheld they read as a 2×3 grid — one screenful, not a
   scroll. Must stay AFTER the base rules above: same specificity, so source
   order is what lets the handheld padding and gap win. */
@media (max-width: 720px) {
  .stat-strip {
    grid-template-columns: repeat(2, 1fr);
  }

  /* An odd stat count (the home strip's three) fills its last row rather
     than leaving a ragged empty cell. */
  .stat:last-child:nth-child(odd) {
    grid-column: 1 / -1;
  }

  .stat {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.15rem;
    padding: 0.9rem 1rem;
  }

  /* Re-key the flush edges to the two-column track. */
  .stat:nth-child(3n + 1),
  .stat:nth-child(3n) {
    padding-left: 1rem;
    padding-right: 1rem;
  }

  .stat:nth-child(2n + 1) {
    padding-left: 0;
  }

  .stat:nth-child(2n) {
    padding-right: 0;
  }

  .stat .stat-note {
    margin-left: 0;
    align-self: flex-start;
  }
}

/* Collection stats dashboard: muted card sub-heading + distribution bars. */

.card-note {
  margin: 0.35rem 0 0;
  color: var(--text-muted);
  font-size: var(--fs-ui);
}

.dist-cell {
  width: 45%;
  min-width: 10rem;
}

.dist-bar {
  display: inline-block;
  vertical-align: middle;
  width: calc(100% - 4rem);
  height: 0.4rem;
  margin-right: 0.6rem;
  background: var(--surface-3);
  border-radius: 999px;
  overflow: hidden;
}

.dist-bar span {
  display: block;
  height: 100%;
  background: var(--accent);
  border-radius: inherit;
}

/* Added-over-time chart (issue #719): the stats page's server-rendered
   column chart — thin stacked bars growing from a hairline baseline, 2px
   surface gaps doing the separating (no strokes around marks). Acquired
   wears the leaf accent (ownership, as everywhere); wishlisted the
   --chart-2 leaf step, so the split reads by lightness under any color
   vision. */
.growth-legend {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin: 0.9rem 0 0.6rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-muted);
}

.growth-swatch {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 3px;
  vertical-align: -1px;
}

.growth-swatch--acquired {
  background: var(--accent);
}

.growth-swatch--wishlisted {
  background: var(--chart-2);
}

/* The peak count is the chart's whole y scale: the tallest stack = peak. */
.growth-peak {
  margin-left: auto;
  color: var(--text-faint);
}

.growth-plot {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  height: 160px;
  /* Headroom so the peak stack's segment gap never clips at the top. */
  padding-top: 6px;
  border-bottom: 1px solid var(--border-strong);
}

.growth-col {
  /* The band: flexes to fill the axis, so the first/last axis labels line
     up under the first/last bucket; the bar inside caps at 24px and the
     band's leftover stays air. */
  flex: 1 1 0;
  min-width: 0;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 2px;
}

.growth-seg {
  display: block;
  width: 100%;
  max-width: 24px;
  /* The template supplies the bucket's share as --seg; here it is the column
     height, in the handheld bar list (mobile layer) the same value is the
     row width. */
  height: var(--seg);
  /* A nonzero bucket always shows: segments only render when they count
     something, so the floor can't invent data. */
  min-height: 3px;
}

/* The touch-reachable twins of the columns' title= tooltip: per-bucket label
   and total, rendered as real text only in the handheld bar list. */
.growth-lab,
.growth-val {
  display: none;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
}

/* Rounded data-end on the stack's top segment only; square at the baseline. */
.growth-col .growth-seg:first-child {
  border-radius: 4px 4px 0 0;
}

.growth-seg--acquired {
  background: var(--accent);
}

.growth-seg--wishlisted {
  background: var(--chart-2);
}

.growth-axis {
  display: flex;
  justify-content: space-between;
  margin-top: 0.4rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-faint);
}

.growth-table {
  margin-top: 0.85rem;
}

.growth-table summary {
  cursor: pointer;
  color: var(--text-muted);
  font-size: var(--fs-ui);
}

/* The stats crosscut averages carry their sample size (issue #720): the
   "(n rated)" aside renders as quiet mono data so a shown average can't
   read as more consensus than it has. */
.rated-n {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-muted);
}

.home-section {
  margin-bottom: clamp(2.5rem, 6vw, 4rem);
}

/* The heading, an optional mono gloss, and the "View all" link share one
   baseline; the link is pushed right by margin rather than space-between, so
   the gloss stays tucked against the heading instead of being spread to the
   middle of the row. */
.home-section > .section-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.35rem 0.9rem;
  margin-bottom: 1.4rem;
}

.home-section > .section-head h2 {
  margin: 0;
}

/* Mono gloss beside the heading ("New to the catalog"). */
.home-section > .section-head .section-note {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-faint);
}

.home-section > .section-head a {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  letter-spacing: 0.05em;
  white-space: nowrap;
}

/* Recently-added strip on home: quiet hairline tiles.
 *
 * Fixed column counts at breakpoints (2/3/4/6) mirror the catalog's
 * .browse-grid rather than auto-fill, so the column count is known at every
 * viewport width. The view fetches up to 12 games (6 cols × 2 rows) and the
 * :nth-child rules below hide the overflow, leaving exactly two complete rows
 * at each breakpoint (2 cols → 4, 3 → 6, 4 → 8, 6 → 12) — never a ragged
 * partial second row. Keep these breakpoints in sync with the [:12] slice in
 * apps/web/views.py (home view). */
.game-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1.5rem 1.25rem;
}

/* Base (2 cols): two rows = the first 4 cards. */
.game-grid > .game-card:nth-child(n + 5) {
  display: none;
}

@media (min-width: 521px) {
  .game-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
  /* 3 cols: two rows = the first 6 cards. */
  .game-grid > .game-card:nth-child(-n + 6) {
    display: flex;
  }
  .game-grid > .game-card:nth-child(n + 7) {
    display: none;
  }
}

@media (min-width: 721px) {
  .game-grid {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
  /* 4 cols: two rows = the first 8 cards. */
  .game-grid > .game-card:nth-child(-n + 8) {
    display: flex;
  }
  .game-grid > .game-card:nth-child(n + 9) {
    display: none;
  }
}

@media (min-width: 1025px) {
  .game-grid {
    grid-template-columns: repeat(6, minmax(0, 1fr));
  }
  /* 6 cols: two rows = all 12 cards. */
  .game-grid > .game-card:nth-child(-n + 12) {
    display: flex;
  }
}

/* The refined spec makes these cover tiles, matching the catalog's
   .browse-card: the artwork carries the hairline edge, the title and mono meta
   sit beneath it, and the card itself has no box of its own. */
.game-card {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  background: transparent;
  color: var(--text);
}

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

.game-card .game-card-cover {
  display: block;
  border-radius: var(--radius-sm);
  overflow: hidden;
  margin-bottom: 0.25rem;
}

/* The shared cover partial is sized for the detail page (capped width,
   contain); in a tile it fills the column edge to edge, as on .browse-card. */
.game-card .game-cover {
  max-width: none;
  margin: 0;
  object-fit: cover;
  transition: border-color var(--speed) ease;
}

.game-card:hover .game-cover {
  border-color: var(--border-strong);
}

/* Owned tiles keep the leaf hairline even while hovered — it is state, not
   decoration. The mark explains itself the way it does on the catalog grid;
   there is deliberately no legend under the strip spelling it out. */
.game-card .game-card-cover--owned .game-cover,
.game-card:hover .game-card-cover--owned .game-cover {
  border-color: var(--accent-border-strong);
}

.game-card .game-title {
  display: block;
  font-weight: 600;
  font-size: var(--fs-ui-sm);
  line-height: 1.3;
}

.game-card:hover .game-title {
  color: var(--accent-text);
}

.game-card .game-meta {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* ---------------------------------------------------------------------------
 * Game detail (spec 05.3)
 * ------------------------------------------------------------------------- */

/* Three rails (design "Catalog Game Detail"): the cover rail and the centre
   column together form .detail-head, and .detail-ref is the 292px reference
   rail beside them. The outer grid is therefore two tracks, not three — the
   cover/centre split is .detail-head's own business, which is what lets the
   compact 2C stacking below keep working untouched.

   The rails collapse in two steps because they carry different weight. At 1024
   the reference rail drops under the centre column: it is look-up data, so it
   reads perfectly well after the facts ledger, and keeping a 292px card beside
   a squeezed centre column is what would actually hurt. Only at 720 does the
   cover rail fold in too. */
.detail-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 292px;
  gap: clamp(1.5rem, 3vw, 3rem);
  align-items: start;
}

@media (max-width: 1024px) {
  .detail-layout {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* The cover keeps its natural 3:4 (base .game-cover rule) and is never
   stretched or cropped. The 05.3 mockup's bottom-edge alignment with the
   Add/Wishlist row is emergent — its sample copy wraps to ~3 lines, making
   the columns near-equal — not specified (the spec grid is align-items:
   start). Forcing it with stretch + object-fit: cover distorted covers on
   long descriptions, so the description clamp below absorbs the length
   instead (issue #383), keeping the columns near-equal by default. */
.detail-head {
  display: grid;
  grid-template-columns: minmax(9rem, 14.75rem) 1fr;
  gap: clamp(1.25rem, 3vw, 3rem);
  align-items: start;
  margin-bottom: 1rem;
  /* The cover rail's sticky travel is bounded by this box, and the outer
     grid's align-items: start used to size it to the centre column alone — so
     beside a taller reference rail the cover parked at the centre column's
     bottom edge instead of riding down to where the columns (and the sticky
     region) actually end, just above the "More on <platform>" rail. Stretch
     restores the full row height: the internal auto row stretches with the
     box, which is what extends the cover's containing block. The reference
     rail must NOT get the same treatment — a stretched sticky item has no
     room to travel inside its own grid area, so it stays start-aligned. In
     the stacked layouts below 1025 each item sits in its own row and stretch
     is inert. */
  align-self: stretch;
}

/* The cover rail: art, then the ownership stamp, then the availability line —
   the three things that are true of *this copy of this game* rather than of the
   catalog row. Sticky against the site header so the cover stays with you down
   a long facts ledger; released from sticky as soon as the rails stack, where
   a pinned cover would just eat the viewport. */
.detail-cover-rail {
  display: flex;
  flex-direction: column;
  gap: 0.8rem;
  position: sticky;
  top: calc(var(--header-h) + 1.5rem);
}

/* The shared .game-cover caps itself at 12rem, which is right for a cover
   dropped into a text column; here the cover IS the rail, so it fills the 236px
   the design gives it. Scoped to this rail so every other surface keeps the cap.
   The compact block below re-caps it at the 96px thumb. */
.detail-cover-rail > .game-cover {
  max-width: none;
}

@media (max-width: 720px) {
  .detail-cover-rail {
    position: static;
  }
}

/* Ownership stamp — the page's one leaf "this is yours" mark. */
.detail-stamp {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin: 0;
}

.detail-stamp-mark {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent-text);
}

.detail-stamp-dot {
  width: 6px;
  height: 6px;
  flex: none;
  border-radius: 50%;
  background: var(--accent);
}

.detail-stamp-date {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* Availability, relocated out of the header chip row into the rail: label left,
   value right in the brass voice, one hairline row. */
.detail-availability {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  margin: 0;
  padding-top: 0.7rem;
  border-top: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.detail-availability-label {
  color: var(--text-faint);
}

.detail-availability-value {
  margin-left: auto;
  text-align: right;
  color: var(--brass-text);
}

/* Compact keeps the two-up shape but compresses the cover to a thumb beside
   the title column (mobile layer spec §04: identity is a 96px thumbnail,
   never a full-width cover) — so the release line, chips and the add control
   stay in the first viewport instead of below a screen-tall cover. */
@media (max-width: 520px) {
  .detail-head {
    grid-template-columns: 96px minmax(0, 1fr);
    column-gap: 1rem;
  }
}

.detail-head h1 {
  margin-bottom: 0.35rem;
}

/* Mono credit line under the title: "OCT 26, 2018 · ROCKSTAR GAMES · 3
   PLATFORMS". The separators are their own spans so the line can wrap between
   parts without stranding a middot at the start of the second line, and each
   part is emitted only when known — the gaps in the template's whitespace are
   collapsed by the flex row rather than showing as double spaces. */
.release-line {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
  margin: 0 0 1rem;
}

.release-line-sep {
  opacity: 0.45;
}

/* IGDB writes its summaries as plain text with real newlines — ~30% of the
   summaries behind the catalog's most-viewed games carry one, and ~26% carry a
   blank-line paragraph break. HTML collapses both to a single space, so those
   paragraphs used to fuse into one wall of prose. `pre-line` honours the
   newlines and still collapses every other whitespace run, which is exactly
   the shape of the data: the sample found no tabs, no CRs, and no markup at
   all (so nothing here wants a linebreaks filter — the text stays one escaped
   text node, and -webkit-line-clamp keeps counting line boxes below). */
.detail-head .description {
  font-size: var(--fs-body);
  line-height: 1.65;
  color: var(--text-muted);
  max-width: 38rem;
  white-space: pre-line;
}

/* Long descriptions clamp to four lines behind Show more (issue #383), so
   description length stops being the dominant variable in the header's
   height. app.js's initDescriptionClamp unhides the toggle only when text is
   actually clipped; a <noscript> rule on each page lifts the clamp for no-JS
   visitors.

   Unscoped, like .description-toggle right below it: the catalog detail
   header (.detail-head) and the collection entry's catalog column
   (.entry-catalog) render the same blurb behind the same control, so they
   share one rule rather than keeping two identical copies in step. The
   per-page <noscript> overrides stay page-scoped and so still outweigh it. */
.description--clamped {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 4;
  overflow: hidden;
}

/* The toggle reads as a link in the accent voice, like the toast's Undo —
   quiet enough not to compete with the Add/Wishlist actions below it. */
.description-toggle {
  display: inline-block;
  margin-top: 0.35rem;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  color: var(--accent-text);
  font-family: var(--font-body);
  font-size: var(--fs-ui-sm);
  font-weight: 600;
}

.description-toggle:hover {
  background: none;
  color: var(--accent-text-strong);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* display: inline-block above outweighs the UA sheet's [hidden] rule; restore
   it, or the not-yet-revealed toggle renders as a dead control anyway. */
.description-toggle[hidden] {
  display: none;
}

.detail-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.65rem;
  margin-top: 1.25rem;
}

/* Source links (facts SOURCES row): external references, in leaf. */
.source-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 1.25rem;
  font-size: var(--fs-ui);
}

/* Right-align inside the facts ledger (flex ignores text-align). */
.meta-list .source-links {
  justify-content: flex-end;
}

@media (max-width: 520px) {
  .meta-list .source-links {
    justify-content: flex-start;
  }
}

/* The tracked control's summary line — status · platform · play state · rating,
   the popover's contents read at a glance. It sits on the control's baseline on
   a wide row and wraps under it when the line no longer fits, which is why it is
   a sibling flex item of .detail-add rather than part of the control itself. */
.detail-state {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 1rem;
}

.detail-entry-summary {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5rem;
  margin: 1.25rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.detail-entry-sep {
  opacity: 0.4;
}

/* Play state is the brass axis site-wide (in-progress); the rating is the one
   number the reader is scanning for, so it takes full-strength ink. */
.detail-entry-play {
  color: var(--brass-text);
}

.detail-entry-rating {
  color: var(--text);
}

@media (max-width: 520px) {
  .detail-entry-summary {
    margin-top: 0.75rem;
  }
}

/* ---- Signed-out action bar (design "Signed Out Action Bar" spec 13) ------
   Issue #1291. The anonymous half of .detail-state, which until now was one
   sentence of body copy in the slot the phone layout re-orders the whole
   header to protect. The panel is the same block a signed-in collector sees,
   held back one step: the real controls in a locked treatment over an
   invitation, so the ask arrives after the demonstration rather than instead
   of it.

   The panel takes the full width of the state band rather than sitting inline
   the way .detail-add does — .detail-state is a wrapping flex row, so a
   flex-basis of 100% is what makes this a band and not a very wide button.

   Radius is the house var(--radius) (6px), not the spec's 8px: nothing else in
   the sheet draws an 8px corner, and a panel that is the only one of its
   radius reads as imported rather than designed. */
.signed-out-bar {
  flex: 1 1 100%;
  margin-top: 1.75rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  /* The full-bleed divider between the two rows is a border on the second
     row, so the panel has to clip its own corners for it to stop at the
     rounding. */
  overflow: hidden;
}

.signed-out-locked {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  padding: 20px 22px 18px;
}

/* Both rows wrap before they compress (spec §B): a long localisation takes a
   second line rather than squeezing the controls out of their own metrics. */
.signed-out-locked form {
  margin: 0;
}

/* 🚨 Two classes, deliberately. These are <button type="submit">, which the
   site's base rule fills solid at (0,1,1) — a lone .signed-out-lock would lose
   to it and the "locked" control would ship looking like the most emphatic
   button on the page. The pair ties at (0,2,0), which beats (0,1,1) outright.
   Declared here rather than layered onto .btn-ghost for the same reason: that
   rule's own `button.btn-ghost` arm is (0,2,0) and would tie us on source
   order from 6,000 lines earlier. */
.signed-out-lock,
button.signed-out-lock {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  padding: 10px 18px;
  border-radius: var(--radius-sm);
  font-family: var(--font-body);
  font-size: var(--fs-ui-sm);
  line-height: 1.3;
  cursor: pointer;
  /* Nothing moves on hover (spec §G), so there is no reduced-motion branch to
     write — colour and border only. */
  transition:
    border-color 120ms ease,
    color 120ms ease;
}

/* DASHED IS THE LOCK. No padlock glyph, no reduced opacity, no
   cursor: not-allowed — these are real buttons that really do something, and
   the condition is carried in their accessible names. */
.signed-out-lock--own,
button.signed-out-lock--own {
  border: 1px dashed var(--lock-edge);
  background: var(--chip);
  color: var(--text-muted);
  font-weight: 600;
}

.signed-out-lock--own:hover,
button.signed-out-lock--own:hover {
  border-color: var(--lock-edge-hover);
  background: var(--chip);
  color: var(--lock-text-hover);
}

.signed-out-lock--wish,
button.signed-out-lock--wish {
  border: 1px dashed var(--lock-edge-quiet);
  background: transparent;
  color: var(--text-faint);
  font-weight: 500;
}

.signed-out-lock--wish:hover,
button.signed-out-lock--wish:hover {
  border-color: var(--lock-edge-quiet-hover);
  background: transparent;
  color: var(--text-muted);
}

/* The tag is pushed to the far end of the row and is aria-hidden — the button
   names already carry the condition, so this is the sighted reader's copy of
   it and nothing else.

   ⚠️ The spec draws it at #5d584d, which measures ~2.6:1 on --surface. It is
   still readable UI text however quiet its job is, so it takes --text-faint
   instead — the token whose whole contract is clearing 4.5:1 on this surface
   (issue #528). The one deliberate departure from spec §C's numbers. */
.signed-out-tag {
  margin-left: auto;
  border: 1px solid var(--border);
  border-radius: 3px;
  padding: 4px 9px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.11em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--text-faint);
}

.signed-out-invite {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 18px;
  border-top: 1px solid var(--border);
  padding: 18px 22px 20px;
}

/* flex:1 with a zero basis would let the headline's max-content width evict
   the CTAs before the row ever wrapped; basis auto keeps one filled line where
   it fits and wraps where it does not. */
.signed-out-pitch {
  flex: 1 1 auto;
  min-width: 0;
}

.signed-out-headline {
  margin: 0 0 5px;
  font-family: var(--font-display);
  font-size: 1.25rem;
  font-weight: 400;
  letter-spacing: -0.01em;
  line-height: 1.25;
  color: var(--text);
}

.signed-out-sub {
  margin: 0;
  max-width: 44ch;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-faint);
  text-wrap: pretty;
}

/* The phone's shorter subline (spec §E) is the hidden one until the panel
   restacks. display: none keeps it out of the accessibility tree too, so only
   the visible string is ever announced. */
.signed-out-sub--narrow {
  display: none;
}

.signed-out-cta {
  display: flex;
  align-items: center;
  gap: 14px;
  flex: none;
}

/* ACCENT IS USED EXACTLY ONCE in this panel, on this fill — which is the whole
   reason the locked controls above are ink and not leaf.

   🚨 Tag-qualified on purpose. The touch-target block's `.btn` arm carries
   `padding-block: 0` at (0,1,0) — the same specificity as a lone class, from
   ~10,000 lines later, so it would win on source order and flatten this
   control's own metrics on every handheld and every coarse-pointer tablet.
   (0,1,1) settles it without a second class. */
a.signed-out-register {
  padding: 11px 22px;
  font-size: var(--fs-ui-sm);
}

/* 🚨 A lone class loses to the sheet's `p a:not(.btn)` in-text link rule
   (issue #528), which would underline this and repaint it accent — the same
   trap .chip--removable hit. It is not inside a <p>, so the rule does not
   reach it; the two classes are for the base `a` colour, which does. */
a.signed-out-login {
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  text-decoration: none;
  white-space: nowrap;
}

a.signed-out-login:hover {
  color: var(--text);
  text-decoration: underline;
}

/* ---- Game detail facts ledger (design 05.3) -----------------------------
   One column now that the studios, platforms, scores and sources live in the
   reference rail: a 126px mono label track and a value track. The base
   .meta-list right-aligns its values, which suits a two-cell row of short
   facts; these rows hold stacked lists and wrapping chip sets, so the ledger
   flips to left-aligned and drops the width cap. */
.detail-facts {
  grid-template-columns: 126px minmax(0, 1fr);
  max-width: none;
  margin-top: clamp(1.75rem, 3vw, 2.5rem);
}

.detail-facts dt {
  padding-right: 1.25rem;
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.11em;
  color: var(--text-faint);
}

.detail-facts dd {
  text-align: left;
}

@media (max-width: 520px) {
  .detail-facts {
    grid-template-columns: minmax(0, 1fr);
  }

  /* Stacked, the label is a heading over its value rather than a column beside
     it — 126px of mono label leaves nothing usable for a list of dated releases
     at 320. The row's hairline moves to the value cell, which is now the one
     that closes the row. */
  .detail-facts dt {
    padding: 0.9rem 0 0.3rem;
    border-bottom: 0;
  }

  .detail-facts dd {
    padding-top: 0;
  }
}

/* A stack of "value + mono note" lines: dated releases, linked other releases.
   The note sits after the value on the same line and wraps beneath it when the
   pair is too wide for the column. */
.fact-lines {
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.fact-lines li {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.3rem 0.6rem;
}

.fact-line-value {
  font-size: var(--fs-ui-sm);
  color: var(--text);
}

a.fact-line-value {
  color: var(--accent-text);
}

a.fact-line-value:hover {
  color: var(--accent-text-strong);
}

.fact-line-note {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The ledger's footer row (design refinement, epic #1076): the correction
   route in its own full-width row under the last fact — the "Not recorded"
   row it used to trail was removed by decision. */
.facts-suggest-row {
  display: flex;
  align-items: center;
  padding: 18px 0;
}

/* The anonymous outbound-IGDB variant wears the same voice as the signed-in
   summary below: accent, semibold, quiet. */
.fact-suggest {
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  white-space: nowrap;
}

/* ---- Alias chips + "+N more" (issue #243) --------------------------------
   Filled chips rather than the header's outlined .chip: these are data inside
   the ledger, not tappable facets, and an outline here would read as a control.
   The toggle is a link-voiced button in the flow, the description clamp's
   idiom. */
.alias-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.35rem;
}

.alias-chip {
  display: inline-block;
  padding: 0.15rem 0.5rem;
  border-radius: 3px;
  background: var(--chip);
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

/* display: inline-block above outweighs the UA sheet's [hidden] rule, so the
   collapsed chips would render anyway and the toggle would do nothing visible
   — the same trap .description-toggle[hidden] documents. The <noscript> rule in
   catalog_detail.html re-reveals them with matching specificity. */
.alias-chip[hidden] {
  display: none;
}

.alias-toggle {
  padding: 0.15rem 0.25rem;
  border: 0;
  border-radius: 0;
  background: none;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--accent-text);
}

.alias-toggle:hover {
  background: none;
  color: var(--accent-text-strong);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* ---- Languages disclosure (issue #136) ----------------------------------
   A native <details>: the headline is the count plus whichever languages carry
   audio, and the full support table opens beneath it with no JS. The marker is
   suppressed for the site's own caret cue, matching the column pickers. */
.lang-reveal > summary {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.3rem 0.75rem;
  list-style: none;
  cursor: pointer;
}

.lang-reveal > summary::-webkit-details-marker {
  display: none;
}

.lang-count {
  font-size: var(--fs-ui);
  color: var(--text);
}

.lang-voiced {
  font-size: var(--fs-ui-sm);
  color: var(--text-faint);
}

/* The caret cue: the registry chevron as a mask — the shared caret idiom
   since #1383 (column pickers, facet menus, split buttons). */
.lang-reveal-cue {
  width: 10px;
  height: 6px;
  margin-left: auto;
  flex: none;
  align-self: center;
  background: var(--accent-text);
  -webkit-mask: var(--icon-caret) center / contain no-repeat;
  mask: var(--icon-caret) center / contain no-repeat;
  transition: transform var(--speed) ease;
}

.lang-reveal[open] .lang-reveal-cue {
  transform: rotate(180deg);
}

.lang-reveal > summary:hover .lang-reveal-cue {
  background: var(--accent-text-strong);
}

/* Name left, support types right — the ledger shape again, one hairline row per
   language, so a 14-language table stays scannable. */
.lang-table {
  margin: 0.85rem 0 0;
  padding: 0;
  list-style: none;
}

.lang-table li {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  padding-bottom: 0.35rem;
  margin-bottom: 0.4rem;
  border-bottom: 1px solid var(--border);
}

.lang-table li:last-child {
  margin-bottom: 0;
}

.lang-name {
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.lang-support {
  margin-left: auto;
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* ---- Reference rail (design 05.3) ---------------------------------------
   The source-of-record card: critic score, studios, the platform ledger, the
   outbound sources. Sticky beside the centre column, released as soon as the
   rail stacks under it. */
.detail-ref {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  position: sticky;
  top: calc(var(--header-h) + 1.5rem);
}

@media (max-width: 1024px) {
  .detail-ref {
    position: static;
    margin-top: clamp(1.5rem, 3vw, 2rem);
  }
}

.ref-card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  overflow: hidden;
}

/* The compilation card is a footnote to the one above it, so it drops the
   filled surface and keeps only the hairline. */
.ref-card--quiet {
  padding: 0.95rem 1.15rem 1.1rem;
  background: none;
}

.ref-block {
  padding: 1rem 1.15rem;
  border-bottom: 1px solid var(--border);
}

/* Studios and platforms read as one group, so they stack inside a single
   padding band rather than each carrying its own hairline. */
.ref-group + .ref-group {
  margin-top: 0.9rem;
}

.ref-label {
  margin: 0 0 0.4rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.ref-label--spaced {
  margin-top: 0.9rem;
}

.ref-value {
  margin: 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text);
}

/* The critic score: one headline number in the display serif, its source
   linked at the end of the same line, a proportional bar beneath. */
.ref-score-line {
  display: flex;
  align-items: baseline;
  gap: 0.3rem;
  margin: 0;
}

.ref-score-value {
  font-family: var(--font-display);
  font-size: 2.5rem;
  font-weight: 500;
  line-height: 1;
  color: var(--text);
}

.ref-score-max {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

.ref-score-src {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
}

.ref-score-src--plain {
  color: var(--text-faint);
}

.ref-gauge {
  height: 4px;
  margin-top: 0.75rem;
  border-radius: 2px;
  background: var(--surface-3);
  overflow: hidden;
}

.ref-gauge-fill {
  display: block;
  height: 100%;
  background: var(--accent);
}

.ref-score-second {
  margin: 0.6rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* ===========================================================================
   Platforms panel (design "Platforms Panel" spec 17, issue #1220).

   🚨 THE WHOLE POINT: nothing in a row is positioned against anything else.
   The old panel was a one-line hairline chip asked to carry a wrapping
   platform name plus two `margin-left: auto` mono labels held at `nowrap`.
   304px of card content minus ~198px of labels left ~96px for the name, so
   "Xbox Series X and Series S" (168px at 13px) wrapped to three lines while
   the labels stayed vertically centred across the middle of them. No
   `margin-left: auto`, no `nowrap`, and no reserved right-hand track survives
   here: the name owns line one at 100% of the content width and the format is
   a sibling *below* it in a flex column, so the row's height simply grows with
   the name and there is nothing for it to grow into. Keep it that way — any
   element re-added to the right of the name reintroduces the bug.

   Ownership moved off the row and onto a group heading (said once, not printed
   on six rows of seven); the owned group carries a 2px accent spine and a 7%
   accent fill, pulled -2px left so the spine — not the block's padding — lands
   on the card's 18px content edge, shared with both headings.
   =========================================================================== */

/* The platform total, cancelled ports included. Kept at --text-faint rather
   than the spec's fainter #5d584d: it is a real number, and anything dimmer
   than faint stops clearing 4.5:1 on --surface. */
.ref-label-count {
  color: var(--text-faint);
}

/* Group heading: label, then a decorative hairline running to the card edge.
   --fs-mono-badge is the 11px step the spec draws (--fs-mono is 12px, the
   section label's size, and the two must not read as the same rank). */
.ref-platform-group {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.5rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.ref-platform-group-rule {
  flex: 1;
  height: 1px;
  background: var(--border);
}

.ref-platform-group--owned {
  margin-bottom: 0.44rem;
  color: var(--accent-text);
}

.ref-platform-group--owned .ref-platform-group-rule {
  background: color-mix(in srgb, var(--accent) 28%, transparent);
}

.ref-platforms {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The owned block. The tint is redundant with the "Your copies" heading above
   it — and when the split is not real enough to earn a heading (everything
   owned, or a one-platform game), the list carries an aria-label instead, so
   the statement never rests on the fill alone. */
.ref-platforms--owned {
  gap: 0.5625rem;
  margin: 0 0 1rem -2px;
  padding: 0.5625rem 0.75rem;
  border-left: 2px solid var(--accent);
  border-radius: 0 5px 5px 0;
  background: color-mix(in srgb, var(--accent) 7%, transparent);
}

/* Owned-only games have no catalog list under the block, so the 16px that
   separates the two groups would just pad the card's bottom edge. */
.ref-platforms--owned:last-child {
  margin-bottom: 0;
}

.ref-platform {
  display: flex;
  flex-direction: column;
  gap: 2px;
  color: inherit;
  transition:
    background-color 120ms ease,
    opacity 120ms ease;
}

/* 🚨 Two separate rules underline links, and dodging one leaves the other. The
   row is excluded from the `li a:not(…)` in-text list at the top of this file
   (it is a two-line control in a <ul>, not a run of prose, and a flex column
   takes the decoration twice — once per blockified line, once across the box).
   The GLOBAL `a:hover { text-decoration: underline }` is the second, and at
   (0,1,1) it beats a lone class — so the hover state has to restate it at
   (0,2,0) or the underline comes back on pointer-over only. */
.ref-platform,
.ref-platform:hover {
  text-decoration: none;
}

/* 🚨 Two different row paddings, deliberately. A catalog row carries its own
   box so the hover fill has a shape, bled -10px so it runs to the card's
   padding (and, on a phone, so the tap area does too). An owned row sits
   inside a block that already has padding and a fill; giving it a second one
   would nest two tinted boxes. The rhythm matches either way — 9px between
   rows inside the block, 7+2+7px between hover boxes outside it. */
.ref-platforms:not(.ref-platforms--owned) .ref-platform {
  margin: 0 -10px;
  padding: 7px 10px;
  border-radius: 5px;
}

.ref-platforms:not(.ref-platforms--owned) .ref-platform:hover {
  background: var(--fill);
}

/* A fill on top of the block's fill darkens to look disabled, so the owned
   row's hover is the row itself dimming instead. */
.ref-platforms--owned .ref-platform:hover {
  opacity: 0.8;
}

/* Focus is the global 2px accent outline at 2px offset — deliberately not
   overridden and never removed: this panel is seven to twelve tab stops on a
   keyboard user's way down the page. The outline follows the bled box. */

.ref-platform-name {
  font-size: var(--fs-ui);
  line-height: 1.35;
  color: var(--text-muted);
  text-wrap: pretty;
}

.ref-platforms--owned .ref-platform-name {
  color: var(--text);
}

/* How THIS platform's release was distributed (issue #1096) — rendered only
   when the link asserts it, on its own line under the name. */
.ref-platform-format {
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.ref-platforms--owned .ref-platform-format {
  color: var(--text-muted);
}

/* Cancelled ports wear "Cancelled Platforms" 1a (issue #88): muted name plus a
   mono "(cancelled)", no new colour and no badge — the word is always present,
   so the muting is redundant reinforcement rather than the carrier. There is
   no format to report for something that never shipped, so the second line
   says so. A cancelled association can never appear in "Your copies". */
.ref-platform--cancelled .ref-platform-name {
  color: var(--text-faint);
}

.ref-platform-note {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
}

/* ≤720 (spec 17 §G): the same component, wider. The type needs no rule at all
   — --fs-ui and --fs-mono-badge each step one rung up the phone scale on their
   own and land exactly on the spec's 15px name / 12px format — so the tracking
   is the only value stated here. The row's 44px floor and the padding that
   absorbs it live in the touch-target block instead, which is keyed on
   `pointer: coarse` as well: a coarse 1024px tablet gets this page's DESKTOP
   layout, and no max-width query ever reaches it. */
@media (max-width: 720px) {
  .ref-platform-format {
    letter-spacing: 0.08em;
  }
}

/* The sources row closes the card: label left, links right, no bottom hairline
   because the card border is already there. */
.ref-foot {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  padding: 0.85rem 1.15rem;
}

.ref-foot .ref-label {
  margin: 0;
}

.ref-source-links {
  margin-left: auto;
  justify-content: flex-end;
  font-size: var(--fs-ui-sm);
}

.ref-links {
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
}

.ref-links li + li {
  margin-top: 0.3rem;
}

/* A big anthology's member list folds past six rows (the alias row's idiom).
   The guard restates the UA's [hidden] rule so it keeps winning if these rows
   ever gain an authored display — the .alias-chip[hidden] trap. */
.ref-link--extra[hidden] {
  display: none;
}

.ref-links-toggle {
  margin-top: 0.45rem;
  padding: 0.15rem 0;
  border: 0;
  border-radius: 0;
  background: none;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--accent-text);
}

.ref-links-toggle:hover {
  background: none;
  color: var(--accent-text-strong);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

.ref-note {
  margin: 0.55rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The "More on <platform>" rail (issue #941) borrows the collection shelves'
   anatomy, and a shelf on the shelves page needs no top margin — it opens the
   page, or follows another shelf's own separator. Here it follows the facts
   ledger, so without a margin its title butted straight against the ledger's
   last hairline and read as one more fact row rather than a new section. The
   gap is the page's own section break, and it has to clear the *odd*-row case:
   the two-column ledger's final hairline stops mid-page, so the title needs
   enough air that it is plainly not attached to that half-width rule. */
.detail-rail {
  margin-top: clamp(2rem, 4vw, 3rem);
}

/* Unlike the shelves page, this rail captions its covers at every width
   (design 05.3): a row of anonymous covers under a page you reached by title
   gives the reader nothing to navigate by. .shelf-cap is display: none at
   desktop from its own base rule (it moved there when the collection's shelves
   grew captions of their own), so the captioned variant un-hides it
   and adds the mono release-year line under it — the same in-wrapper anatomy
   the ≤720 rules already use, so the caption is part of the link's hit target
   rather than a sibling of it. Unlike those rules it takes no fixed caption
   height: a two-line title here should show both lines, not be cropped to a
   uniform rail. */
.shelf-row--captioned .shelf-cap {
  display: block;
  margin-top: 0.5rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.35;
  color: var(--text-muted);
}

.shelf-row--captioned .shelf-cap-meta {
  display: block;
  margin-top: 0.2rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.shelf-row--captioned .shelf-cover:hover .shelf-cap {
  color: var(--text);
}

/* The caption sits inside the rounded, clipping cover wrapper, so the wrapper
   must stop being a fixed-ratio box once it also holds text. */
.shelf-row--captioned .shelf-cover {
  border-radius: 0;
  overflow: visible;
}

.shelf-row--captioned .shelf-cover .game-cover {
  border-radius: var(--radius-sm);
}

/* ---------------------------------------------------------------------------
 * Split Add button + details popover (design "Add Details Form Ideas" 2a) —
 * the game detail page's add control. ＋ Add quick-adds with defaults; ▾ opens
 * an anchored popover carrying the lean add form; once tracked, the same
 * control reads "✓ In collection ▾" and the popover is the edit surface.
 * ------------------------------------------------------------------------- */

.detail-add {
  margin-top: 1.25rem;
}

.detail-add .detail-actions {
  margin-top: 0;
}

/* One visual control, two segments: shared 4px radius on the wrapper, the ▾
   segment a step darker with a hairline divider between. Anchored panels
   position against this wrapper, so the popover aligns to the whole button. */
.split-add {
  position: relative;
  display: inline-flex;
  border-radius: var(--radius-sm);
}

.split-add-main,
button.split-add-main {
  height: 38px;
  padding: 0 1.3rem;
  border: 0;
  border-radius: var(--radius-sm) 0 0 var(--radius-sm);
  background: var(--accent);
  color: var(--accent-ink);
  font-size: var(--fs-ui);
  font-weight: 600;
}

.split-add-main:hover,
button.split-add-main:hover {
  background: var(--accent-strong);
  color: var(--accent-ink);
  opacity: 1;
}

.split-add .add-popover {
  display: inline-flex;
}

/* The ▾ half: a shade darker than the leaf fill (fallback hex first, oklch
   canonical second — the tokens pattern), split off by a translucent divider. */
.split-add-more {
  list-style: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  height: 38px;
  padding: 0 0.7rem;
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  background: #4fae74;
  background: oklch(0.66 0.12 155);
  border-left: 1px solid rgba(22, 20, 15, 0.35);
  color: var(--accent-ink);
  font-size: var(--fs-ui-sm);
}

.split-add-more::-webkit-details-marker {
  display: none;
}

/* The ▾ itself: the registry chevron as a mask, like the column-picker's —
   the shared caret idiom since #1383. */
.split-add-more::after {
  content: "";
  width: 10px;
  height: 6px;
  background: currentColor;
  -webkit-mask: var(--icon-caret) center / contain no-repeat;
  mask: var(--icon-caret) center / contain no-repeat;
}

.split-add-more:hover,
.add-popover[open] > .split-add-more {
  background: #439c66;
  background: oklch(0.6 0.12 155);
}

html[data-theme="light"] .split-add-more {
  background: #1f6b41;
  background: oklch(0.47 0.11 155);
  border-left-color: rgba(250, 249, 245, 0.35);
}

html[data-theme="light"] .split-add-more:hover,
html[data-theme="light"] .add-popover[open] > .split-add-more {
  background: #185c36;
  background: oklch(0.43 0.11 155);
}

/* The detail page's ♡ Wish rides at the split button's height. */
.detail-add-wish,
button.detail-add-wish {
  height: 38px;
  padding: 0 1.15rem;
  font-size: var(--fs-ui);
  border-radius: var(--radius-sm);
}

/* Tracked state: outlined pill, leaf check, same split silhouette. The whole
   summary is the click target — the left half has no separate action. */
.split-add-status {
  list-style: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  height: 38px;
  border: 1px solid var(--accent-border-strong);
  border-radius: var(--radius-sm);
  box-sizing: border-box;
  background: transparent;
  color: var(--text);
  font-size: var(--fs-ui);
  font-weight: 500;
}

.split-add-status::-webkit-details-marker {
  display: none;
}

.split-add-status:hover,
.add-popover[open] > .split-add-status {
  background: var(--accent-dim);
}

.split-add-status-label {
  padding: 0 1rem;
  white-space: nowrap;
}

.split-add-check {
  color: var(--accent-text);
  font-weight: 600;
}

.split-add-caret {
  display: inline-flex;
  align-items: center;
  align-self: stretch;
  padding: 0 0.6rem;
  border-left: 1px solid var(--accent-border);
  color: var(--text-muted);
}

.split-add-caret::after {
  content: "";
  width: 10px;
  height: 6px;
  background: currentColor;
  -webkit-mask: var(--icon-caret) center / contain no-repeat;
  mask: var(--icon-caret) center / contain no-repeat;
}

/* --- The popover panel ----------------------------------------------------
   Anchored below-left of the split button with an 8px gap and a caret
   pointing at the ▾ segment; absolutely positioned so the page never
   reflows. app.js adds .add-popover--up when the viewport below is short. */
.add-popover-panel {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  z-index: 40;
  width: 360px;
  box-sizing: border-box;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}

html[data-theme="light"] .add-popover-panel {
  box-shadow: 0 18px 44px rgba(30, 26, 18, 0.2);
}

/* The "Your copy" edit surface runs 12px wider than the add popover (design
   "Collection Popover Spec" §B). Scoped to the --copy modifier rather than
   widened family-wide on purpose: this same .add-popover-panel class dresses
   the catalog row popover — position: fixed and placed by app.js against a
   measured ▾ — and this family's ⋯ menus, none of which the spec looked at.

   🚨 Same specificity as the base rule above (0,1,0), so it only wins by
   sitting after it. Moving it earlier in the file silently restores 360px. */
.add-popover-panel--copy {
  width: 372px;
}

.add-popover--up .add-popover-panel {
  top: auto;
  bottom: calc(100% + 8px);
}

.add-popover-caret {
  position: absolute;
  top: -5px;
  left: var(--caret-x, 92px);
  width: 8px;
  height: 8px;
  background: var(--surface);
  border-left: 1px solid var(--border-strong);
  border-top: 1px solid var(--border-strong);
  transform: rotate(45deg);
}

/* Under the tracked pill the ▾ sits further right. */
.split-add--tracked .add-popover-caret {
  --caret-x: 128px;
}

.add-popover--up .add-popover-caret {
  top: auto;
  bottom: -5px;
  transform: rotate(225deg);
}

/* --- The anchored panel's viewport cap (issue #1781) ----------------------
   The game page's two popovers — Add with details, and "Your copy" — are the
   only surfaces in this family whose head, form and foot are DIRECT children
   of the panel (_detail_add_control.html has no .add-popover-body wrapper),
   which is what `:has(> .add-popover-head)` picks out. The five wrapped bodies
   are all `.add-popover--row`, placed `fixed` by app.js and capped in their own
   block below; the ⋯ menus that share .add-popover-panel draw no head at all.

   🚨 The cap goes on the panel and the SCROLL goes on the form, never the
   other way round. `overflow` on the panel would clip .add-popover-caret,
   which is drawn OUTSIDE the panel's box at `top: -5px`, and would clip the
   ±14px ::after hit overlays the coarse-pointer arm gives the head's ✕ and the
   foot's Cancel. Scrolling the field grid instead leaves head and foot pinned,
   which is the arrangement the sheet and the suggest-edit modal (#1503) both
   arrived at, and it is what keeps Add to collection on screen.

   min-height: 0 is the load-bearing half of that: without it a flex item's
   automatic minimum size is its content height, the form refuses to be shorter
   than its fields, and the panel overflows the cap it was just given.

   app.js caps the panel further when the ▾ has room for it neither below nor
   above — the case this issue was raised for, and the one CSS cannot see. */
@media (min-width: 721px) {
  .add-popover-panel:has(> .add-popover-head) {
    display: flex;
    flex-direction: column;
    max-height: calc(100vh - 16px);
  }

  .add-popover-panel:has(> .add-popover-head) > form {
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
  }
}

.add-popover-head {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  padding: 13px 20px;
  border-bottom: 1px solid var(--border);
}

.add-popover-title {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text);
}

.add-popover-note {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* The head's "full entry ↗". It used to carry .add-popover-note as well and
   took its mono treatment from there; the sheet's head sizes the hint and the
   link differently (mono-badge vs mono), so the link states its own. These are
   the exact values .add-popover-note was giving it — desktop is unchanged. */
a.add-popover-entry-link {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

a.add-popover-entry-link:hover {
  color: var(--accent-text);
  text-decoration: none;
}

/* ✕: a bare text button, reset from the global button chrome. */
.add-popover-close,
button.add-popover-close {
  margin-left: auto;
  padding: 0 2px;
  border: 0;
  background: none;
  color: var(--text-faint);
  font-size: var(--fs-ui-sm);
  line-height: 1;
  align-self: center;
}

.add-popover-close:hover,
button.add-popover-close:hover {
  background: none;
  color: var(--text);
  opacity: 1;
}

.add-popover-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
  padding: 14px 20px 16px;
}

.add-popover-field {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.add-popover-field--wide {
  grid-column: 1 / -1;
}

/* Mono micro-labels above each field (the ledger voice at popover scale). */
.add-popover-label {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 400;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* Compact 32px fields on the page background so they read sunken into the
   raised panel; overrides the global input/select block spacing.
   background-color, never the shorthand — the global select rule paints its
   ▾ caret as a background-image the shorthand would wipe. */
.add-popover-field select,
.add-popover-field input {
  height: 32px;
  margin-top: 0;
  padding: 0 10px;
  background-color: var(--bg);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  font-size: var(--fs-field);
  color: var(--text);
}

/* Keep the caret's clearance past the compact padding. */
.add-popover-field select {
  padding-right: 2rem;
}

/* The catalog-context platform prefill is visible state: leaf-tinted border
   at half strength (the spec's accent-at-50% edge). */
.add-popover-field--prefilled select {
  border-color: var(--accent-border-strong);
}

/* -------------------------------------------------------------------------- */
/* The add-to-collection platform picker (design "Add Platform Picker", #1718) */
/* -------------------------------------------------------------------------- */
/* One control at every option count: four options is four rows, eighteen is
   the same rows with a field above them and a cap on the height. Which of the
   optional parts render is decided server side and arrives as a class, so the
   geometry is right before any script runs and does not jump when one does. */

.platform-picker {
  display: block;
  box-sizing: border-box;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 5px;
  overflow: hidden;
}

/* The catalog-context prefill marks this control the way it marked the select
   it replaces. */
.add-popover-field--prefilled .platform-picker {
  border-color: var(--accent-border-strong);
}

/* One scroll region, from the server's own cap threshold (six options — NOT
   the filter's ten; while they were one threshold a nine-option game opened a
   taller popover than a twenty-option one). 212px is six and a half rows, or
   four and a half plus the two eyebrows, so the cap is always visibly a cap
   and never lands flush with a row edge — a row is cut mid-height, which is
   the only thing that says "there is more below" before you scroll. `contain`
   stops a wheel event chaining to the popover behind it. */
.platform-picker--capped .platform-picker-list {
  max-height: 212px;
  overflow-y: auto;
  overscroll-behavior: contain;
}

.platform-picker-eyebrow {
  margin: 0;
  padding: 9px 11px 4px;
  font-family: var(--font-mono);
  font-size: 0.65625rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The whole row is the hit area. `min-height`, not `height`: a two-line name
   grows its own row and nothing else moves.

   🚨 `margin: 0` is load-bearing, not tidiness. The row is a `<label>`, and
   the global `label { display: block; margin-bottom: 1rem }` ten thousand
   lines up is (0,0,1): this (0,1,0) rule beats it on every property it
   DECLARES and on no other, so leaving margin out drew every row at a 48px
   pitch instead of 32 — 76 instead of 46 on the phone — and 160px of unasked
   height on a nine-platform game. Invisible in a diff, because the rule that
   causes it is nowhere near this one. */
.platform-picker-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  box-sizing: border-box;
  min-height: 32px;
  margin: 0;
  padding: 0 11px;
  cursor: pointer;
}

.platform-picker-row:hover {
  background: var(--surface-hover, rgb(255 255 255 / 4%));
}

/* 🚨 `hidden` is only a UA default of `display: none`, and the rule above sets
   `display: flex` on the same element — author beats UA, so the attribute does
   nothing on its own and the filter would appear to match everything. The
   filter hides rows with the attribute, so it has to be re-stated here. */
.platform-picker-row[hidden] {
  display: none;
}

.platform-picker-radio {
  flex: none;
  width: 13px;
  height: 13px;
  margin: 0;
  accent-color: var(--accent-text);
}

/* 🚨 …and the height again, compounded, because the popover's own
   `.add-popover-field select, .add-popover-field input` pair above is (0,1,1)
   and reaches EVERY input in this control — the picker renders inside
   `.add-popover-field--wide`, so the pair's `height: 32px` beat the (0,1,0)
   rule above and the radio drew 13×32. The row's height then came from the
   radio rather than from its own `min-height`, which is why tuning that
   number appeared to do nothing at all.

   Out-specified rather than scoping the pair to `.add-popover-field > input`:
   the pair's exact selector text is what two stylesheet guards look the rule
   up by (test_add_details_sheet_1268, test_copy_popover), and a child
   combinator would silently drop the first field anyone wraps in a div. Same
   trick, same reason, as the copy popover's (0,3,1) aria-invalid rule. */
.add-popover-field input.platform-picker-radio {
  height: 13px;
}

/* The name gives up width first; the qualifiers shrink last and never
   truncate, because a truncated maker separates nothing. */
.platform-picker-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--fs-ui-sm);
  color: var(--text);
}

.platform-picker-maker,
.platform-picker-year {
  flex: none;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Reserved and right-aligned with tabular figures, so names never reflow when
   a year is missing. Empty is empty; the column stays. */
.platform-picker-year {
  width: 34px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* One visual change per state: a wash and a weight, no border and no left bar. */
.platform-picker-row:has(.platform-picker-radio:checked) {
  background: var(--accent-wash, rgb(255 255 255 / 6%));
}

.platform-picker-row:has(.platform-picker-radio:checked) .platform-picker-name {
  font-weight: 600;
}

.platform-picker-row:has(.platform-picker-radio:focus-visible) {
  outline: 2px solid var(--accent-border);
  outline-offset: -2px;
}

/* The dropped-platform group is the only brass thing in the add form: it marks
   a row that is true of the entry and no longer true of the catalog, which is
   not an ordinary selection and must not look like one. */
.platform-picker-row--recorded {
  box-shadow: inset 0 0 0 1px var(--brass-border, var(--border-strong));
}

.platform-picker-note {
  margin: 0;
  padding: 2px 11px 6px;
  font-size: 0.6875rem;
  line-height: 1.5;
  color: var(--text-faint);
}

/* Always present, always last, never inside the scroll region — the field is
   optional, so clearing it has to be possible without leaving the control. */
.platform-picker-row--none {
  border-top: 1px solid var(--border);
  background: rgb(0 0 0 / 18%);
}

/* The polite count app.js writes into ("3 of 9 platforms"), server-rendered
   and empty until a filter runs. It exists only to declare a margin: the same
   trap as the row above, one element up — `p { margin: 0 0 1rem }` reaches it,
   so an EMPTY live region was still 16px of popover on every game. Still in
   flow and still announced; it is simply zero-height until it has something
   to say. */
.platform-picker-live {
  margin: 0;
}

/* The filter field, its empty state and the search-mode OWNED mark. All three
   are added by app.js past the server's ten-option threshold, so every rule
   here describes something that simply is not in the DOM without JS. */

/* Inside the list border, above a divider, on the darker surface so it reads
   as chrome rather than as a row. */
.platform-picker-field {
  border-bottom: 1px solid var(--border);
  background: rgb(0 0 0 / 12%);
}

.platform-picker-filter {
  box-sizing: border-box;
  width: 100%;
  min-height: 34px;
  padding: 0 11px;
  border: 0;
  background: none;
  color: var(--text);
  font-family: inherit;
  font-size: 0.8125rem;
}

/* The same repair the radio needs, for the same pair: `height: 32px` fixed the
   field's box, and the border, radius and page background it also hands out
   drew a second framed input stacked on the framed list — the one thing the
   rule above exists to avoid. Everything here restates what the pair steals
   and nothing else; `margin-top: 0` and `color` are still the pair's. */
.add-popover-field input.platform-picker-filter {
  height: auto;
  padding: 0 11px;
  border: 0;
  border-radius: 0;
  background: none;
  font-size: 0.8125rem;
}

.platform-picker-filter:focus-visible {
  outline: 2px solid var(--accent-border);
  outline-offset: -2px;
}

/* One line inside the list border. The field, the No platform row and the
   height all stay put. */
.platform-picker-empty-hit {
  margin: 0;
  padding: 10px 11px;
  font-size: 0.75rem;
  line-height: 1.5;
  color: var(--text-faint);
}

/* Search collapses the groups into one ranked run, so an owned row carries its
   own mark where its eyebrow used to be. */
.platform-picker--searching .platform-picker-row--found .platform-picker-name::after {
  content: "Owned";
  margin-left: 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-text);
}

/* Not a disabled control and not an empty box: a fact about the catalog, and
   the form still submits. */
.platform-picker-empty {
  margin: 0;
  padding: 8px 0;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}


.add-popover-error {
  margin: 0;
  font-size: var(--fs-ui-sm);
  color: var(--danger);
}

.add-popover-error--form {
  padding: 10px 20px 0;
}

.add-popover-foot {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 13px 20px;
  border-top: 1px solid var(--border);
}

/* The design's padding-driven primary: wider than the old 34px pill so the
   footer's one filled action reads as the panel's anchor. */
.add-popover-submit,
button.add-popover-submit {
  height: 36px;
  padding: 0 22px;
  font-size: var(--fs-ui-sm);
  border-radius: var(--radius-sm);
}

/* Cancel and Remove: quiet text actions, reset from the button chrome. */
.add-popover-cancel,
button.add-popover-cancel,
.add-popover-remove,
button.add-popover-remove {
  padding: 0;
  border: 0;
  background: none;
  font-size: var(--fs-ui-sm);
  font-weight: 400;
  color: var(--text-muted);
}

.add-popover-cancel:hover,
button.add-popover-cancel:hover {
  background: none;
  color: var(--text);
  opacity: 1;
}

/* Remove states its danger at rest (the design's red link), brightening on
   hover rather than only revealing its color there. */
.add-popover-remove,
button.add-popover-remove {
  margin-left: auto;
  color: var(--danger);
  opacity: 0.85;
}

.add-popover-remove:hover,
button.add-popover-remove:hover {
  background: none;
  color: var(--danger);
  opacity: 1;
}

/* --- The "Your copy" receipt footer (design "Collection Popover Spec" §B) ---
   The edit popover's footer is not an action row. Save changes and Cancel are
   gone — the copy already exists, so editing it is not a transaction — leaving
   a status token on the left and Remove on the right.

   The band, not the padding, sets the row height: the foot's own vertical
   padding drops to 5px and Remove's 44px min-height is what the row measures.
   That is also the fix for the bug this spec was raised for — three actions no
   longer compete for one row, because there is only one left. */
.add-popover-foot--receipt {
  padding: 5px 20px;
}

/* 🚨 The live region is this wrapper, and app.js only ever replaces the
   .save-state INSIDE it. Announcing depends on the region node persisting: a
   live region that is itself swapped out announces nothing, because the node
   the screen reader is watching stops existing. */
.save-state-live {
  display: inline-flex;
  min-width: 0;
}

.save-state {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.save-state-dot {
  flex-shrink: 0;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--text-faint);
}

/* Nothing has been saved yet — the popover was opened and left alone — so the
   dot stays muted and no claim is made. */
.save-state[data-state="saved"] .save-state-dot {
  background: var(--accent);
}

.save-state[data-state="saving"] .save-state-dot {
  background: var(--text-muted);
}

/* The pulse is decoration on top of a label that already says "Saving…", so it
   lives behind the no-preference gate and its absence costs nothing. */
@media (prefers-reduced-motion: no-preference) {
  .save-state[data-state="saving"] .save-state-dot {
    animation: save-state-pulse 1.1s ease-in-out infinite;
  }
}

@keyframes save-state-pulse {
  0%,
  100% {
    opacity: 1;
  }

  50% {
    opacity: 0.35;
  }
}

/* 🚨 The dot is never the only signal: the label states the same thing in
   words, which is what carries it for a screen reader and for anyone who
   cannot separate the three dot colours. */
.save-state[data-state="error"] {
  color: var(--brass-text);
}

.save-state[data-state="error"] .save-state-dot {
  background: var(--brass-text);
}

.save-state-detail {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The refused field keeps the user's edit and says so (spec §C). app.js sets
   aria-invalid on the one control the write was about, so the amber is on the
   field itself rather than on the whole grid — "something here was rejected"
   is not a message anyone can act on.

   🚨 The attribute is the styling hook AND the a11y signal, deliberately: a
   class alone would paint a border no screen reader announces, and this is the
   only place in the popover where a control disagrees with the stored row.
   (0,3,1) — it has to clear both the base `.add-popover-field select, input`
   pair and the sheet's `.add-popover-panel .add-popover-field select`.) */
.add-popover-panel--copy .add-popover-field select[aria-invalid="true"],
.add-popover-panel--copy .add-popover-field input[aria-invalid="true"] {
  border-color: var(--brass-text);
}

/* ↻ replays the write that failed. A 44px target like every other control in
   this footer — it appears only in the error state, which is exactly when a
   missed tap is most expensive. */
.save-state-retry,
button.save-state-retry {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  margin: 0 -6px;
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  color: inherit;
}

@media (hover: hover) {
  .save-state-retry:hover,
  button.save-state-retry:hover {
    background: color-mix(in oklch, var(--brass-text) 12%, transparent);
    color: inherit;
    opacity: 1;
  }
}

/* Remove as a 44px pill rather than 14px of bare text (spec §B). The label
   stays 14px; what grows is its box. The negative margin cancels the side
   padding so the label still sits flush to the popover's edge — the pill is
   bigger than the text without looking inset.

   🚨 The base rule is the pair `.add-popover-remove, button.add-popover-remove`,
   which against this <button> lands at (0,1,1). This selector is (0,2,0), and
   two classes beat one class plus one element, so it wins — per property, as
   always. `margin-left: auto` is not restated here because it arrives from
   that base rule untouched: different property, no contest. */
.add-popover-foot--receipt .add-popover-remove {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 10px;
  margin-right: -10px;
  border-radius: 5px;
  opacity: 1;
}

@media (hover: hover) {
  .add-popover-foot--receipt .add-popover-remove:hover {
    background: color-mix(in oklch, var(--danger) 8%, transparent);
    color: var(--danger);
  }
}

/* The only place danger colour appears at rest in this popover, so its focus
   ring says so too rather than borrowing the global accent one. */
.add-popover-foot--receipt .add-popover-remove:focus-visible {
  outline: 2px solid var(--danger);
  outline-offset: 2px;
}

/* The no-JS Save (design decision, 2026-08-16). Present in the DOM at every
   width and `hidden`; the <noscript> rule in _detail_add_control.html un-hides
   it, which is why the selector there carries [hidden] — it has to out-specify
   the attribute, not race it. */
.add-popover-nojs {
  gap: 14px;
  padding: 0 20px 16px;
}

.add-popover-key-hint {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* The .add-popover-actions wrapper and its .add-popover-danger slot are gone
   (design "Collection Popover Spec"). They existed to hold Save changes,
   Cancel and Remove on one line above 721 and in two blocks below it — a
   problem that stopped existing when the edit popover lost its commit model:
   there is one action in that footer now, and the add popover never had a
   destructive slot to place. Nothing renders either class. */

/* --- The catalog row / card variant (issue #769) ---------------------------
   The same popover on a browse row's ＋ Add. Two things differ from the game
   page's control:

   1. The split button is row-scale. It shares the fixed action slot (design
      "Catalog Card States" 05.2) rather than growing it: the ▾ takes a sliver
      of the ＋ Add half, so every card's action row is still exactly 30px
      (32px in a recommendations row) and nothing reflows.
   2. The panel is `position: fixed`, not absolute. The list view's rows sit
      inside .table-wrap, whose overflow-x: auto computes overflow-y to auto
      and would clip an absolutely-positioned panel; and a 360px panel
      anchored inside a 4-up grid card would run past the viewport edge.
      Fixed positioning escapes every scroll container, and app.js measures
      the ▾ and places the panel under it (.add-popover--placed). Until then —
      and without JS — the CSS default is the phone bottom sheet, which needs
      no measurement to be usable. Placed row panels carry no caret: the flip
      logic works in absolute coordinates, so there is no anchor edge for one
      to point at. */
.split-add--row {
  align-items: stretch;
  min-width: 0;
}

/* Square off the Add half's inner corners so the pair reads as one control.
   The button keeps its own .btn-accent/.btn-sm geometry — the slot's height
   is defined by the card/row context, not by this rule. */
.split-add--row .quick-add-form button {
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
}

/* The ▾ half at row scale: same leaf fill and divider as the game page's,
   stretched to whatever height the Add button sets. min-width holds it at the
   WCAG 2.2 AA 24px target minimum — it sits flush against ＋ Add, so the
   spacing exception can't carry it and the padding alone lands at ~21px. */
.split-add--row .split-add-more {
  height: auto;
  min-width: 24px;
  align-self: stretch;
  justify-content: center;
  padding: 0 0.42rem;
  border-radius: 0 3px 3px 0;
}

.split-add--row .split-add-more::after {
  width: 7px;
  height: 5px;
}

.add-popover--row .add-popover-panel {
  position: fixed;
  top: auto;
  left: 0;
  right: 0;
  bottom: 0;
  width: auto;
  border-radius: 12px 12px 0 0;
  border-bottom: 0;
  z-index: 60;
}

/* app.js has measured and placed it: back to a floating panel, its top/left
   set inline from the ▾'s viewport rect. */
/* Same 360px as the game page's panel — the two-column field grid's `1fr`
   tracks are floored at min-content, and a narrower panel starves the
   right-hand column rather than splitting the row evenly. */
.add-popover--row.add-popover--placed .add-popover-panel {
  right: auto;
  bottom: auto;
  width: 360px;
  border-radius: var(--radius);
  border-bottom: 1px solid var(--border-strong);
  /* Never taller than the viewport (issue #1781). A placed panel is `fixed`,
     so one taller than the room under the ▾ used to hang past the bottom edge
     with its foot — Add to collection, the whole point of the surface — off
     screen, reachable only by scrolling the PAGE until the row rose far enough
     for the panel to fit. Since the platform picker landed (#1730) a
     9-platform panel is ~850px, which on a 13" laptop fits NO ▾ position at
     all, so this stopped being an edge case. The cap is the phone sheet's
     guarantee brought up here: the box stops at the viewport, the content
     scrolls inside it, and the foot below is sticky so the primary action is
     the last thing on screen either way. app.js clamps the placed `top` to
     match, and `contain` stops a wheel that runs out of panel from scrolling
     the page behind it — the same pairing the picker's own list box uses.

     Scoped to --placed, which is the desktop state: app.js strips the class
     below 721 (and never adds it without JS), so the sheet block's own
     `max-height: calc(100dvh - 40px)` keeps winning there even though this
     selector is (0,3,0) and would out-specify it. */
  max-height: calc(100vh - 16px);
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* The foot rides the cap above (issue #1781). Sticky against the panel — the
   row bodies wrap head/form/foot in .add-popover-body, which is `display:
   block` at this width and therefore not a scroll container of its own, so the
   nearest scrollport is the panel and the foot pins to its bottom edge.

   (0,4,0) and inside the placed scope on purpose: the standalone /suggest/
   page reuses .add-popover-foot on a PAGE with no panel around it, and an
   unscoped sticky rule would pin that page's footer to the viewport. */
.add-popover--row.add-popover--placed .add-popover-panel .add-popover-foot {
  position: sticky;
  bottom: 0;
  z-index: 1;
  background: var(--surface);
}

.add-popover--row .add-popover-caret {
  display: none;
}

/* The un-fetched / no-JS body: one quiet link to the game page, whose popover
   carries the same form. Sized so the sheet doesn't collapse to a sliver. */
.add-popover-fallback,
.add-popover-notice {
  margin: 0;
  padding: 16px 20px;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

/* Bottom sheet on phones: the same panel pinned to the viewport's bottom
   edge, full width, no caret. The scrim SWALLOWS the tap that dismisses the
   sheet (issue #1185): it used to be paint-only (pointer-events: none) so
   app.js's outside-click dismissal could work through it, but that let the
   very same tap reach whatever sat behind — a card cover link, another card's
   ⋯, the Filters trigger — so cancelling the sheet also fired a control the
   user never meant to touch. The dismissal is expressed in app.js instead: a
   click whose target IS the open <details> (which is what a click on this
   ::before reports) counts as outside. Matches .filter-sheet-scrim /
   .view-sheet-scrim, the two sheets that always used a real element here. */
/* --- The sheet, not a popover in a sheet's position (issue #1268) ----------
   Everything below this point in the block is the design "Add With Details
   Sheet Spec": grab handle, a 44px mono head with no ✕, 48px fields, the
   destructive action as its own row behind a rule, and a sticky stacked foot.
   Until #1268 this block moved the PANEL and stopped — head, foot, submit,
   cancel, remove and the key hint had exactly one definition each, written for
   the anchored desktop popover, and no phone override at all.

   🚨 Every override here is prefixed `.add-popover-panel` for two reasons, and
   both are load-bearing:

   1. SPECIFICITY. The base rules are doubled selector lists —
      `.add-popover-submit, button.add-popover-submit { … }` — so against a
      <button class="add-popover-submit"> they land at (0,1,1). A lone
      `.add-popover-submit` here is (0,1,0) and LOSES per property however late
      it sits; a media query adds nothing. The submit would have stayed a 36px
      pill, Remove would have stayed `margin-left: auto`, and Cancel would have
      taken width and height but not `border` — a full-width invisible box that
      reads as a rule that fired. The prefix makes these (0,2,0), which beats
      both (0,1,1) base rules and `button[type="submit"]`'s touch floor.
   2. SCOPE. The standalone /suggest/ page (contributions/suggest.html) reuses
      .add-popover-body, -head and -foot on a PAGE, with no panel around them.
      Unprefixed, the sticky foot below would pin that page's footer to the
      viewport. Its own floor is the one generic rule at the end of this block. */
@media (max-width: 720px) {
  .add-popover-panel,
  .add-popover--up .add-popover-panel {
    position: fixed;
    top: auto;
    left: 0;
    right: 0;
    bottom: 0;
    width: auto;
    border-radius: 12px 12px 0 0;
    border-bottom: 0;
    z-index: 60;
    /* The sheet carries its own tall content (issue #1257). Without this the
       panel is pinned to the bottom edge with no cap, so a form taller than
       the viewport runs off the TOP with nothing to scroll — the page behind
       never moved it, being fixed, and the body lock below now says so out
       loud. Every sibling sheet already caps itself the same way. */
    max-height: calc(100dvh - 40px);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* The sheet's own panel box, and ONLY the form sheets'.
     `.add-popover-panel` is shared with this family's ⋯ MENUS — the collection
     card's .entry-actions-panel, the grid bar's .bar-menu-panel, and BOTH
     recommendation .rec-sheet menus — which have their own 06 §D anatomy and
     are not being redrawn. Putting these on the shared class regressed two of
     them: `padding: 8px 8px 0` took 12px off the bottom of the rec ⋯ and extras
     sheets (which have no sticky foot to give it back), and `.rec-sheet`'s own
     (0,1,0) padding could not take it back from a later rule.

     :has(.add-popover-head) is what tells the two halves apart — the form
     bodies all draw a head, the menus draw none. It is also (0,2,0), which is
     what this needs to be: `.add-popover--row .add-popover-panel` above sets
     `border-radius: 12px` at (0,2,0), so a lone class here would lose and every
     catalog-row sheet would keep 12px corners while the game page took 14.

     ⚠️ A lazily-fetched body has no head until it lands, so a row's panel is a
     plain box for the length of that fetch. It is also empty for exactly that
     long — there is nothing in it to lay out — so the window is invisible. */
  .add-popover-panel:has(.add-popover-head) {
    border-radius: 14px 14px 0 0;
    /* 8px here + 12px on each row = the 20px gutter the head and foot already
       used, so the hairlines inset with the panel the way 1B's identity rule
       and 06 §D's divider do. No bottom padding: the foot is sticky and
       carries the sheet's bottom space itself, including the safe area. */
    padding: 8px 8px 0;
    display: flex;
    flex-direction: column;
  }

  .add-popover-caret {
    display: none;
  }

  /* Five of the six bodies nest head/form/foot inside .add-popover-body; the
     game page's two put them directly in the panel (_detail_add_control.html
     has no wrapper). Making BOTH a column means one set of rules lays out both
     shapes — and `order` below resolves against whichever is the real parent. */
  .add-popover-panel .add-popover-body {
    display: flex;
    flex-direction: column;
  }

  .add-popover[open]::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 55;
    /* 0.62, the weight every other sheet scrim carries. At 0.45 a ⋯ menu and
       the ▾ beside it on one recommendations row dimmed the page by different
       amounts. */
    background: rgba(10, 9, 6, 0.62);
    pointer-events: auto;
  }

  /* The page behind the sheet holds still (issue #1257) — the rule every other
     phone overlay already carries: the nav menu, suggest-edit, View, Filters,
     Sort, the shelf chip, the import card and the three stats menus. This
     family was the one without it, and an upward swipe ON THE PANEL therefore
     scrolled the page underneath an open ⋯ / ▾ menu.

     🚨 overscroll-behavior: contain on the panel does NOT stand in for it.
     WebKit ignores overscroll-behavior on an element that is not actually
     scrollable, where Chromium honours it, and these sheets are short — the
     recommendations ⋯ sheet is 269px of an 844px phone — so their content
     fits, the panel never becomes a real scroller, and Safari chains the swipe
     straight to the document. Chromium is why tests/ui/ never saw it.

     🚨 Scoped to ≤720 on purpose. The <details>'s [open] state survives a
     resize to desktop, so an unscoped :has() would strand a resized page with
     a locked body — the same trap called out on the .nav-menu lock. */
  body:has(.add-popover[open]) {
    overflow: hidden;
  }

  /* HEAD (§B) — one 44px mono row over a hairline, no ✕. The row was already
     the right height and empty on its right, so the entry link takes that
     slack as a real target rather than growing anything. */
  .add-popover-panel .add-popover-head {
    align-items: center;
    gap: 9px;
    min-height: 44px;
    padding: 0 12px;
  }

  .add-popover-panel .add-popover-note {
    font-size: var(--fs-mono-badge);
  }

  .add-popover-panel .add-popover-entry-link {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    margin-left: auto;
    color: var(--accent-text);
  }

  /* The ✕ and the ↵ hint are HIDDEN, not deleted: both are the desktop
     drawing's and both still render above 721 (§Q2, §Q7). The ✕'s job here is
     taken by the foot's Cancel/Close, which every body now has. */
  .add-popover-panel .add-popover-close,
  .add-popover-panel .add-popover-key-hint {
    display: none;
  }

  /* BODY (§B) — the grid keeps two columns at 390 (169px each is enough for
     "Unplayed ▾" at 16px) and collapses below 360. */
  .add-popover-panel .add-popover-grid {
    padding: 12px 12px 14px;
    gap: 14px 12px;
  }

  .add-popover-panel .add-popover-error--form {
    padding: 10px 12px 0;
  }

  .add-popover-panel .add-popover-fallback,
  .add-popover-panel .add-popover-notice {
    padding: 16px 12px;
    font-size: var(--fs-body);
  }

  /* 🚨 The visible bug this fixes: `.add-popover-field select, input` both
     declare `height: 32px`, and the touch block carries a bare
     `select { min-height: 44px }` with NO rule for input — so the three selects
     rendered 44 and the two number inputs 32, side by side in one grid. One
     rule now names both, at the 48px every other phone input on the site uses;
     16px text is also what stops iOS zooming the page on focus.
     .suggest-form-field is in the list because suggest-an-edit does NOT use
     .add-popover-field — only the labels are shared. */
  .add-popover-panel .add-popover-field select,
  .add-popover-panel .add-popover-field input,
  .add-popover-panel .suggest-form-field select,
  .add-popover-panel .suggest-form-field input {
    height: 48px;
    font-size: var(--fs-field);
    padding: 0 12px;
  }

  .add-popover-panel .suggest-form-field textarea {
    height: 96px;
    padding: 12px;
    font-size: var(--fs-field);
  }

  /* DESTRUCTIVE (§Q4) — the add popover has no destructive action, and the
     edit popover's Remove now lives in its receipt footer rather than a slot
     of its own, so the .add-popover-danger rules that used to sit here are
     gone with the wrapper. What survives is the 52px full-width shape below,
     which the receipt footer overrides back to a right-aligned band. */

  /* The 52px full-width destructive ROW that used to live here is gone with
     the slot that held it. There is exactly one `.add-popover-remove` in this
     family — the receipt footer's — and spec §E keeps it a right-aligned band,
     not a stacked row, so its only sheet rule is the one further down beside
     `.add-popover-foot--receipt`. A generic rule here would have described a
     layout no markup produces, and would have had to be out-specified by the
     real one on every property. */

  /* FOOT (§Q3) — stacked full-width rows, sticky to the sheet's bottom edge so
     the primary survives a scrolling body and the keyboard being up.

     🚨 `order` goes on the FOOT, not on the danger slot. Remove sits AFTER the
     foot in the DOM so desktop tab order is unchanged, and the obvious way to
     lift it — `.add-popover-danger { order: -1 }` — beats the default 0 on
     EVERY sibling, not just the foot: it would render above the grab handle. */
  .add-popover-panel .add-popover-foot {
    order: 1;
    position: sticky;
    bottom: 0;
    z-index: 1;
    flex-direction: column;
    align-items: stretch;
    gap: 8px;
    background: var(--surface);
    padding: 10px 12px calc(12px + env(safe-area-inset-bottom));
  }

  .add-popover-panel .add-popover-submit,
  .add-popover-panel .add-popover-cancel {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 52px;
    padding: 0;
    border-radius: var(--radius-sm);
    font-size: var(--fs-body);
  }

  .add-popover-panel .add-popover-cancel {
    border: 1px solid var(--border-strong);
    color: var(--text);
  }

  /* SHEET RECEIPT (design "Collection Popover Spec" §E) — the edit popover's
     footer keeps its ROW shape down here instead of joining the stacked
     full-width column above it: it holds one action and a status token that is
     not an action at all, so there is nothing to stack. Only the band grows,
     44 → 46px, matching the sheet's taller fields.

     `order`, `position: sticky` and the surface fill are NOT restated — they
     arrive from the `.add-popover-panel .add-popover-foot` rule above and are
     wanted unchanged.

     🚨 This is (0,2,0), the same as the rule it is correcting, so it wins only
     by sitting after it inside this same block. */
  .add-popover-panel .add-popover-foot--receipt {
    flex-direction: row;
    align-items: center;
    gap: 12px;
    padding: 4px 12px calc(4px + env(safe-area-inset-bottom));
  }

  /* (0,3,0), so it beats the 52px full-width shape above outright. */
  .add-popover-panel .add-popover-foot--receipt .add-popover-remove {
    justify-content: flex-end;
    gap: 0;
    width: auto;
    height: auto;
    min-height: 46px;
    padding: 0 12px;
    margin-right: -12px;
  }

  /* The discard guard (§F) takes over the sheet it is protecting rather than
     stacking a second overlay on it — same panel, same handle, so the sheet
     re-heights in place instead of re-entering. app.js appends it and hides
     everything else the panel holds, which is one child on the five surfaces
     that wrap their body and several on the game page's two that don't. */
  .add-popover-panel--guarded > *:not(.add-popover-guard):not(.entry-sheet-handle) {
    display: none;
  }

  .add-popover-panel .add-popover-guard-discard {
    border-color: var(--danger-dim);
    color: var(--danger);
  }

  /* The standalone /suggest/ page is a page, not a sheet, so none of the above
     reaches it — but its "Back to the game" / "Cancel" is the same class and was
     a ~17px target there (touch_baseline.json's a.add-popover-cancel). It needs
     the floor and nothing else. Doubled to (0,1,1) so it ties the base rule and
     wins on source order. */
  .add-popover-cancel,
  a.add-popover-cancel,
  button.add-popover-cancel {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
}

/* The coarse-pointer tablet (issue #1268, spec §G's right column). The block
   above is keyed to max-width: 720px, so a touch iPad at 1024 still gets the
   anchored popover — correctly, it has the room for it — but the touch floor's
   own `(pointer: coarse)` arm means its 13px ✕ and its text Cancel are the same
   defect there, and touch_baseline.json lists all three under `tablet-coarse:`.
   This is the cheap half of the spec's offer: the popover stays a popover, its
   controls stop being 13px.

   🚨 min-width: 721px is load-bearing. A phone IS a coarse pointer, so without
   it these overlays would also land on the sheet, where the rows are already
   52px and 8px apart — a ±14px overlay would then reach into the neighbouring
   row and steal its taps.

   🚨 Overlays rather than min-height, for the reason #1287 had to undo on
   .email-switch: the ✕'s box IS its drawing, and growing it would open the
   head. A transparent ::after buys the target without moving a pixel, and
   tests/ui/touch_targets.py hit-tests outward from the centre, so that is
   exactly what it measures. */
@media (pointer: coarse) and (min-width: 721px) {
  .add-popover-close,
  button.add-popover-close,
  .add-popover-cancel,
  a.add-popover-cancel,
  button.add-popover-cancel {
    position: relative;
  }

  /* 13px glyph + 2×16 = 45 tall; 17px box + 2×14 = 45 wide. */
  .add-popover-close::after,
  button.add-popover-close::after {
    content: "";
    position: absolute;
    inset: -16px -14px;
  }

  /* Vertical only: the submit sits beside this one and a horizontal expansion
     would overlap it. ~17px of text + 2×14 = 45. */
  .add-popover-cancel::after,
  a.add-popover-cancel::after,
  button.add-popover-cancel::after {
    content: "";
    position: absolute;
    inset: -14px 0;
  }
}

/* Motion (§F), matching the seven families that already do this: slide up
   180ms, scrim fades 120ms. 🚨 The keyframes are `entry-sheet-in` and
   `entry-scrim-in` — an unknown animation-name is not an error, it is a
   silent no-op. */
@media (max-width: 720px) and (prefers-reduced-motion: no-preference) {
  .add-popover[open] > .add-popover-panel {
    animation: entry-sheet-in 180ms ease-out;
  }

  .add-popover[open]::before {
    animation: entry-scrim-in 120ms ease-out;
  }
}

/* §D3: below 360 the two-column grid stops fitting and the form goes single
   column — which makes the edit body, not suggest, the tallest sheet. */
@media (max-width: 359px) {
  .add-popover-panel .add-popover-grid {
    grid-template-columns: 1fr;
  }
}

/* ---------------------------------------------------------------------------
 * Suggest an edit (epic #1076) — the ledger's correction route for signed-in
 * users: the add-popover anatomy re-voiced for catalog corrections. The panel,
 * head, foot, close/cancel, error and mobile bottom-sheet styles all come
 * from the add-popover blocks above; the suggest-specific pieces live here —
 * the link-voiced summary, the field rail, the free-text form, and the
 * desktop modal presentation (the design's SUGGEST EDIT state is a centred
 * dialog over a scrim, not a dropdown anchored to its trigger).
 * ------------------------------------------------------------------------- */
.suggest-edit {
  position: relative;
  display: inline-block;
  vertical-align: baseline;
}

/* The summary wears the .fact-suggest link's voice: accent-colored, semibold,
   leading the ledger's footer row — a control that reads as a link. The hover
   is the design's color lift, not an underline. */
.suggest-edit > summary {
  list-style: none;
  cursor: pointer;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  white-space: nowrap;
  color: var(--accent-text);
}

.suggest-edit > summary::-webkit-details-marker {
  display: none;
}

.suggest-edit > summary:hover {
  color: var(--accent-strong);
}

/* Desktop: the design's SUGGEST EDIT state is a modal, not an anchored
   popover — the panel leaves its trigger and centres over the page under a
   dimmed, blurred scrim, top-aligned at the design's 76px offset. Scoped to
   min-width so the base bottom-sheet rule keeps winning on phones; the --up
   twin pins the flip class app.js may still set, since a viewport-fixed
   panel has no anchor to flip around. */
@media (min-width: 721px) {
  .suggest-edit .add-popover-panel,
  .suggest-edit.add-popover--up .add-popover-panel {
    position: fixed;
    top: 76px;
    bottom: auto;
    left: 0;
    right: 0;
    margin-inline: auto;
    width: min(760px, calc(100vw - 48px));
    max-height: calc(100dvh - 116px);
    /* 🚨 clip, NOT auto (issue #1503). While the panel was the one scroller,
       a tall pane — Platforms lists every catalog platform the game does not
       have — grew the grid row past this cap and scrolled the WHOLE dialog:
       measured at 1280×900, the panel capped at 784px around 1549px of content,
       so the head and "Send for review" sat 766px BELOW the fold and the only
       route back to the submit was scrolling the list you had just filled in.
       The panel now clips, and the two columns scroll inside it (below).

       ⚠️ `clip` rather than `hidden` because the two are NOT the same here:
       `hidden` still makes a scroll container, one nothing can reach by wheel
       or key but the browser can still scroll to bring a focused control into
       view. Chromium reports 26px of scrollable range on this box even with
       the children measuring flush to its edges, so a Tab into the bottom of
       the form could have slid the pinned head 26px out from under itself.
       `clip` is not a scroll container at all, so that range does not exist. */
    overflow: clip;
    display: flex;
    flex-direction: column;
    z-index: 60;
    box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
  }

  /* A DEFINITE height once the fetched body arrives, so the dialog is the same
     box whichever field is selected — picking Platforms after Title must not
     make the modal jump from 300px to the full viewport. It is only ever as
     tall as it needs to be: the rail's seven rows plus the chrome are ~520px,
     so 660 gives the form column real room without the dialog going gangly on
     a tall display. The un-fetched fallback and the thank-you have no rail and
     stay content-sized — a 660px box holding one line of thanks reads broken.

     `:has()` is (0,3,0) here, tying `.suggest-edit.add-popover--up
     .add-popover-panel` above; it wins on source order, and neither declares
     `height`, so the tie decides nothing. */
  .suggest-edit .add-popover-panel:has(.suggest-fields) {
    height: min(660px, calc(100dvh - 116px));
  }

  /* The body is the panel's one growing child. min-height: 0 is what lets it
     be SHORTER than its content — without it the flex item's automatic minimum
     size is its content height, the panel overflows its own max-height, and
     `overflow: hidden` above turns the lost foot into an unreachable one. */
  .suggest-edit .add-popover-body {
    flex: 1 1 auto;
    min-height: 0;
    /* The panel no longer scrolls, so the bodies with no rail — the un-fetched
       fallback, the thank-you — need somewhere to put overflow of their own.
       Inert in the two-column state: that grid is sized to this box exactly,
       and its own tracks do the scrolling. */
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  html[data-theme="light"] .suggest-edit .add-popover-panel {
    box-shadow: 0 30px 90px rgba(30, 26, 18, 0.28);
  }

  /* The scrim: the bottom sheet's idiom (issue #1185 — it swallows the
     dismissing click rather than letting it through to the page), at modal
     weight and with the design's slight blur of the page behind. A modal's
     scrim especially cannot be paint-only: the ledger rows underneath are
     dense with links. */
  .suggest-edit[open]::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 55;
    background: rgba(10, 9, 6, 0.74);
    -webkit-backdrop-filter: blur(3px);
    backdrop-filter: blur(3px);
    pointer-events: auto;
  }

  /* A modal takeover, so the page behind holds still (the filter sheet's
     body lock); the panel's own overflow carries tall content. */
  body:has(.suggest-edit[open]) {
    overflow: hidden;
  }

  /* Two columns once the fetched body arrives: the field rail beside the
     form, head and foot spanning both. The thank-you and un-fetched bodies
     have no rail and keep the single column.

     The rows are stated as well as the columns: auto for the head, auto for
     the foot, and minmax(0, 1fr) for the rail/form row between them. The 1fr
     takes whatever the panel's height leaves, and the 0 minimum is the same
     escape as min-height above — a grid track's automatic minimum is its
     content, so without it the platform list would push the foot out of the
     panel again. Head and foot keep their intrinsic heights and are therefore
     always drawn: pinned by the grid, not by `position: sticky`. */
  .suggest-edit .add-popover-body:has(.suggest-fields) {
    display: grid;
    grid-template-columns: 238px minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr) auto;
  }

  .suggest-edit .add-popover-head,
  .suggest-edit .add-popover-foot {
    grid-column: 1 / -1;
  }

  /* The two independent scroll areas. Each column owns its own overflow, so a
     long platform list moves under a rail that holds still, and picking a
     field from a rail scrolled halfway down does not move the form.

     overscroll-behavior: contain is what the panel used to carry — it stops a
     wheel or trackpad flick that reaches either end from chaining to the page
     behind (which body:has(.suggest-edit[open]) has already frozen, so the
     chain would just deaden the gesture).

     🚨 min-height: 0 on BOTH, for the same reason as the body: these are grid
     items in a 1fr track, and an item's automatic minimum size would otherwise
     be its content height — the track would be told to fit every row and the
     `overflow-y` here would never have anything to scroll. */
  .suggest-edit .suggest-fields,
  .suggest-edit .suggest-form {
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  .suggest-edit .suggest-fields {
    border-bottom: 0;
    border-right: 1px solid var(--border);
  }

  /* Align the form column's inset with the head/foot's 20px gutter now that
     it shares the modal's full width. */
  .suggest-edit .suggest-form {
    padding: 16px 20px 18px;
  }
}

/* The field rail: every suggestible field with its current catalog value, or
   the EMPTY badge — catalog state, never submission status (epic #1076
   drops all per-user pending surfaces). */
.suggest-fields {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px;
  border-bottom: 1px solid var(--border);
}

.suggest-field {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  /* Wrapping puts the label (and the EMPTY badge beside it) on line one and
     the full-width current value below (spec 18 §G). */
  flex-wrap: wrap;
  gap: 2px 0.75rem;
  padding: 5px 8px;
  border-radius: var(--radius-sm);
  text-decoration: none;
  color: var(--text);
}

.suggest-field:hover {
  background: var(--fill);
  text-decoration: none;
}

.suggest-field--selected {
  background: var(--fill);
}

.suggest-field-label {
  font-size: var(--fs-ui-sm);
}

/* Two lines, then the clamp — and the registry writes its current-value
   strings for two lines (spec 18 §G): a platform list fits three names before
   the clamp bites; availability is a count because a count is true at every
   width. */
.suggest-field-current {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.5;
  color: var(--text-faint);
  flex-basis: 100%;
  overflow: hidden;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
}

.suggest-field-empty {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
  border: 1px solid var(--border);
  border-radius: 3px;
  padding: 0 0.35rem;
}

.suggest-form {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 14px 16px 16px;
}

/* "In the catalog now": the current value the suggestion corrects, in the
   ledger's label/value voice at popover scale. */
.suggest-current {
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
}

.suggest-current-label {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.suggest-current-value {
  font-size: var(--fs-ui-sm);
}

.suggest-form-field {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* Sunken fields on the page background, the add-popover treatment; the
   textarea is the one control that popover block has no precedent for.
   background-color, never the shorthand (the global select-caret rule). */
.suggest-form-field input,
.suggest-form-field textarea {
  margin-top: 0;
  background-color: var(--bg);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  font-size: var(--fs-field);
  color: var(--text);
}

.suggest-form-field input {
  height: 32px;
  padding: 0 10px;
}

.suggest-form-field textarea {
  padding: 6px 10px;
  min-height: 84px;
  resize: vertical;
  font-family: inherit;
}

.suggest-hint {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

.suggest-optional {
  text-transform: none;
  letter-spacing: normal;
  color: var(--text-faint);
}

/* The standalone no-JS page: the same body, framed as a raised card under
   the site chrome. */
.suggest-page {
  max-width: 560px;
}

.suggest-page-body {
  margin-top: 1rem;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
}

/* ---------------------------------------------------------------------------
 * The assertion control + availability matrix (design "Suggest Edit Panes"
 * spec 18, issue #1433). One component, two skins: the desktop matrix shows
 * glyph-only 132px cells under column heads; the phone stacks each row and
 * shows the labelled skin at the 44px touch floor. The law of the pane (spec
 * Q2): the catalog never appears inside a box; the suggestion never appears
 * outside one — current state is always the naked mono NOW line.
 * ------------------------------------------------------------------------- */

/* Three radios sharing a name render as one box; the inputs park off-screen
   (the platform-chip idiom) and the fills are pure :checked + label. The
   default segment is checked at rest, so a pane reads as settled statements,
   never unanswered questions (spec Q1). */
.assert {
  display: flex;
  gap: 2px;
  padding: 2px;
  border: 1px solid var(--border-strong);
  border-radius: 5px;
  background: var(--bg);
}

.assert input[type="radio"] {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

.assert__seg {
  flex: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  height: 26px;
  margin: 0;
  border-radius: 3px;
  cursor: pointer;
  /* 11px by design (spec 18 §B) — deliberately below --fs-mono-badge; the
     phone block lifts it back to the token. */
  font-family: var(--font-mono);
  font-size: 11px;
  white-space: nowrap;
  color: var(--assert-quiet);
}

.assert__seg:hover {
  color: var(--lock-text-hover);
}

.assert input[value=""]:checked + .assert__seg {
  background: var(--assert-silence-fill);
  box-shadow: inset 0 0 0 1px var(--assert-silence-edge);
  color: var(--assert-silence-text);
}

.assert input:not([value=""]):checked + .assert__seg {
  background: var(--assert-fill);
  box-shadow: inset 0 0 0 1px var(--assert-edge);
  color: var(--assert-text);
}

/* The ring draws on the segment, never the box (spec §B). */
.assert input:focus-visible + .assert__seg {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* The matrix: name column beside two fixed cell columns. The spec drew a
   subgrid, but a fieldset's anonymous content box breaks the subgrid chain
   (children lay out against the fieldset, not the outer grid) — so every row
   carries the identical explicit template instead; with two fixed tracks the
   columns resolve the same in every row, which is all the subgrid was for. */
.assert-matrix__head,
.assert-matrix .assert-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 132px 132px;
  column-gap: 12px;
  align-items: center;
}

.assert-matrix__head span {
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
  padding-bottom: 6px;
}

/* Fieldset-per-row is the a11y contract (spec §J); take back the UA chrome. */
fieldset.assert-row {
  min-width: 0;
  margin: 0;
  padding: 7px 0;
  border: 0;
  border-top: 1px solid var(--assert-hairline);
}

.assert-row__name {
  font-size: var(--fs-ui-sm);
  color: var(--text);
}

.assert-row__scope {
  display: block;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The only filled row and the only two-line name in the matrix — enough to
   say "different scope" without a heading (spec Q4). */
.assert-row--everywhere {
  background: var(--fill);
  border-bottom: 1px solid var(--assert-hairline);
}

/* The pre-settled Digital cells: two lines of mono text, never a disabled
   control (spec Q2 — a disabled control invites a click and implies it could
   be enabled). */
.assert-settled {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-muted);
}

.assert-settled__why {
  display: block;
  font-size: 10px;
  letter-spacing: 0.08em;
  color: var(--text-faint);
}

/* The NOW line: "Catalog now: <answer>" in the DOM for screen readers; the
   sighted see the mono tag form. */
.assert-now {
  margin: 4px 0 0;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.assert-legend {
  display: flex;
  gap: 14px;
  align-items: baseline;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  color: var(--text-faint);
}

.assert-legend__title {
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.suggest-crosslink {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

@media (min-width: 721px) {
  /* The desktop matrix wears the glyph skin: icon-only 132px cells (the word
     stays in the DOM sr-only, so the accessible name never changes), axis
     labels replaced by the column heads, uppercase settled cells. */
  .assert-matrix .assert__word,
  .assert-matrix .assert-cell__axis {
    display: none;
  }

  .assert-matrix .assert-settled {
    text-transform: uppercase;
  }

  .assert-matrix .assert-settled__why {
    text-transform: none;
  }
}

@media (max-width: 720px) {
  /* The row stacks; the axes never split into sections (spec Q3). Axis label
     and NOW line share the head row; the control fills the card width at the
     44px floor — 38px segments inside 2px padding and a 1px border. The
     matrix has no scroll of its own: the sheet body is the only scroll
     region. */
  .assert-matrix {
    display: block;
  }

  .assert-matrix__head,
  .assert-legend {
    display: none;
  }

  .assert-matrix .assert-row {
    display: block;
  }

  .assert-cell {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 2px 8px;
    align-items: baseline;
    margin-top: 8px;
  }

  /* The head line: "PHYSICAL   now · unknown" over the control (spec §E) —
     pinned, because auto-placement would drop the NOW line below the
     row-spanning control. */
  .assert-cell__axis {
    grid-row: 1;
    grid-column: 1;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-muted);
  }

  .assert-cell .assert-now {
    grid-row: 1;
    grid-column: 2;
    margin: 0;
    text-transform: none;
    letter-spacing: normal;
    font-size: var(--fs-mono-badge);
  }

  .assert-cell .assert,
  .assert-cell .assert-settled {
    grid-row: 2;
    grid-column: 1 / -1;
  }

  .assert {
    height: 44px;
    box-sizing: border-box;
  }

  .assert__seg {
    height: 38px;
    font-size: var(--fs-mono-badge);
  }

  .suggest-crosslink__more {
    display: none;
  }
}

@media (max-width: 359px) {
  /* 288px of content: the segment stacks glyph over label — the one place
     this spec goes below a token on purpose (spec §F). Nothing else moves. */
  .assert__seg {
    flex-direction: column;
    gap: 1px;
    font-size: 11px;
  }
}

/* ---------------------------------------------------------------------------
 * The remaining suggest panes (spec 18 §I, issue #1434): the additive/veto
 * checkbox groups the platforms, regions and languages panes share, and the
 * released pane's repeatable date rows.
 *
 * Two rules here are load-bearing and neither is decoration:
 *
 *   1. The native checkbox parks off-screen and the LABEL is the row — the
 *      assertion control's own idiom. It is what makes a checkbox row a 44px
 *      handheld target without a 44px checkbox, and it puts the input where a
 *      `:checked ~` sibling selector can open a reveal with no `:has()` and no
 *      round trip.
 *   2. The type-to-filter hides rows through an ATTRIBUTE, not `[hidden]`.
 *      `button`/`display:*` rules out-cascade the UA `[hidden]` sheet all over
 *      this file (see .description-toggle[hidden]) — but the deciding reason is
 *      that "never hide a CHECKED row" has to be expressible, and an exemption
 *      is a selector, not a property JS can set.
 * ------------------------------------------------------------------------- */

/* The suggest form's stated default button: `button`'s display: inline-flex
   outweighs the UA sheet's [hidden] rule, the trap .description-toggle[hidden]
   documents. See the template comment for why the button exists at all. */
.suggest-form > button[hidden] {
  display: none;
}

.pane-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.pane-group__title {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.pane-group__hint,
.pane-group__note,
.pane-no-match {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

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

/* The filter box ships `hidden` and JS reveals it, so no-JS never meets a dead
   control — but .suggest-form-field's `display: flex` out-cascades the UA
   [hidden] rule, which is exactly the trap .platforms-discard[hidden] records. */
.pane-filter[hidden] {
  display: none;
}

.pane-filter {
  margin-bottom: 2px;
}

/* The long list scrolls inside its own region and the filter box stays OUTSIDE
   it (spec §I): narrowing a list must never scroll away from the box doing the
   narrowing. */
.pane-list--scroll {
  max-height: 136px;
  overflow-y: auto;
  overscroll-behavior: contain;
}

.pane-option {
  position: relative;
  border-top: 1px solid var(--assert-hairline);
}

.pane-option:first-child {
  border-top: 0;
}

/* Off-screen, not hidden: it still takes focus, and the ring is drawn on the
   label below (the .assert radios' contract). */
.pane-box {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

.pane-check {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  min-height: 30px;
  margin: 0;
  padding: 3px 2px;
  cursor: pointer;
  font-size: var(--fs-ui-sm);
  color: var(--text);
}

.pane-check__mark {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  border: 1px solid var(--border-strong);
  border-radius: 3px;
  background: var(--bg);
  color: var(--assert-text);
}

.pane-check__mark .icon {
  opacity: 0;
}

.pane-box:checked + .pane-check .pane-check__mark {
  background: var(--assert-fill);
  border-color: var(--assert-edge);
}

.pane-box:checked + .pane-check .pane-check__mark .icon {
  opacity: 1;
}

.pane-box:focus-visible + .pane-check {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-radius: var(--radius-sm);
}

.pane-check__label {
  min-width: 0;
}

/* The dispute reading, quiet beside the value it disputes: a veto row must not
   compete with the adds for attention — it is the rarer, heavier statement. */
.pane-check__veto,
.pane-kept {
  margin-left: auto;
  padding-left: 0.6rem;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
  text-align: right;
}

/* 🚨 The filter may never hide a checked row (spec Q5) — a row carrying an
   answer must not vanish because the box no longer matches what is typed.
   Deliberately in the base cascade and NEVER inside the ≥721 block: a `:has()`
   there out-ranks every later media query (the standing trap).

   🚨 And the exemption is `> .pane-box:checked`, not the spec's bare
   `:has(:checked)`. A platforms row contains its availability pair, and an
   assertion control is CHECKED at rest by design (the silent default is a
   radio) — so a bare `:checked` matches every row in the pane and the filter
   hides nothing at all. Found by tests/ui/test_suggest_panes_1434.py. */
[data-filter-hidden]:not(:has(> .pane-box:checked)) {
  display: none;
}

/* …and the row that survived says why, instead of reading as a broken filter. */
.pane-kept {
  display: none;
}

[data-filter-hidden] .pane-kept {
  display: inline;
}

/* The per-platform availability pair, opened by the box above it. Pure
   `:checked ~` — the input is a SIBLING of this block for exactly that reason
   (spec Q5): no `:has()`, no JS, no repaint. */
.pane-reveal {
  display: none;
  padding: 2px 0 10px 26px;
}

.pane-box:checked ~ .pane-reveal {
  display: block;
}

.pane-reveal__ask {
  margin: 0 0 6px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* Outside .assert-matrix the cells have no column heads to lean on, so the
   axis label renders here at both widths. */
.pane-reveal .assert-cell {
  display: grid;
  grid-template-columns: 72px minmax(0, 1fr);
  align-items: center;
  gap: 6px 10px;
  margin-top: 6px;
}

.pane-reveal .assert-cell__axis {
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* The released rows: date, region, and a remove gutter reserved on row one as
   well, so adding a second row cannot shift the first one sideways (spec Q6). */
.released-head,
.released-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 26px;
  column-gap: 8px;
  align-items: center;
}

.released-head span {
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
  padding-bottom: 4px;
}

.released-row {
  padding: 3px 0;
}

.released-cell {
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.released-cell input,
.released-cell select {
  width: 100%;
}

/* The select is not covered by .suggest-form-field's input rule, so it states
   the same metrics; the phone block's 48px pair out-specifies both. */
.released-row .region-select {
  height: 32px;
  padding: 0 8px;
  background-color: var(--bg);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  font-size: var(--fs-field);
  color: var(--text);
}

/* The free-text escape, revealed by the option that asks for it. Base cascade
   again — same `:has()` trap as the filter rule above. */
.released-row .region-other {
  display: none;
  grid-column: 1 / -1;
}

.released-row:has(.region-select option[value="other"]:checked) .region-other {
  display: flex;
  padding: 6px 0 2px;
}

.released-row__errors {
  grid-column: 1 / -1;
}

.released-remove {
  width: 26px;
  height: 26px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--text-faint);
  justify-self: center;
}

.released-remove:hover {
  background: transparent;
  color: var(--text);
}

.released-remove--empty {
  display: block;
}

/* Dashed, because it makes a row that is not yet a row (spec Q6). */
.released-add {
  align-self: flex-start;
  margin-top: 8px;
  height: 32px;
  padding: 0 12px;
  border: 1px dashed var(--border-strong);
  background: transparent;
  color: var(--accent-text);
  font-size: var(--fs-ui-sm);
}

.released-add:hover {
  background: var(--fill);
  color: var(--accent-strong);
}

/* In-flight during a row repaint: the pane dims as one, never per-control
   spinners (spec §I) — one thing is happening, to the whole pane. */
.suggest-form.htmx-request {
  opacity: 0.55;
}

@media (max-width: 720px) {
  .pane-check {
    font-size: var(--fs-ui);
  }

  .pane-reveal {
    padding-left: 0;
  }

  /* Back to the phone matrix's stacked cell: axis + NOW on line one, the 44px
     control below. Restated because the base rule above out-specifies the
     phone `.assert-cell` rule it would otherwise inherit. */
  .pane-reveal .assert-cell {
    grid-template-columns: auto minmax(0, 1fr);
  }

  .pane-list--scroll {
    max-height: 220px;
  }

  /* The row stacks; the ✕ keeps its own 44px column beside both fields, so it
     never lands under a thumb aiming at the date. Placement is fully explicit
     — mixing auto and explicit placement in a two-column grid is how a row
     silently reorders. */
  .released-head {
    display: none;
  }

  .released-row {
    grid-template-columns: minmax(0, 1fr) 44px;
    row-gap: 8px;
    padding: 6px 0;
  }

  .released-cell--date {
    grid-row: 1;
    grid-column: 1;
  }

  .released-cell--region {
    grid-row: 2;
    grid-column: 1;
  }

  .released-remove,
  .released-remove--empty {
    grid-row: 1 / span 2;
    grid-column: 2;
    width: 44px;
    height: 44px;
  }

  .released-row .region-other {
    grid-row: 3;
    grid-column: 1 / -1;
  }

  .released-row__errors {
    grid-row: 4;
    grid-column: 1 / -1;
  }

  .released-add {
    height: 44px;
  }
}

/* ---------------------------------------------------------------------------
 * Collection game detail (design: collection detail + inline edit) — cover
 * rail, catalog facts, and the "Your copy" panel that owns all personal
 * metadata and flips into its edit state in place
 * ------------------------------------------------------------------------- */

/* Breadcrumb: the back-link plus the entry's status as a quiet mono crumb. */
.entry-crumb {
  margin: 0 0 1.5rem;
}

.entry-crumb .back-link {
  margin: 0;
}

.crumb-note {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* Named areas, not implicit placement (mobile layer spec §04): the DOM order
   is the phone stacking order — masthead, copy panel, facts — and these
   areas place the desktop columns regardless of it, so the promoted panel
   never splits visual order from focus order. Row gap stays 0 so the
   masthead and facts blocks read as one continuous column on desktop, as
   they did when they shared an element. */
.entry-layout {
  display: grid;
  grid-template-columns: minmax(9rem, 12.75rem) minmax(0, 1fr) minmax(19rem, 22.25rem);
  grid-template-rows: auto 1fr;
  grid-template-areas:
    "cover head panel"
    "cover facts panel";
  column-gap: clamp(1.25rem, 3vw, 3.25rem);
  row-gap: 0;
  align-items: start;
}

/* Editing widens the panel by ~9.5rem (design: Game Detail Refined). A form
   needs room a read-only ledger does not — two real controls per row instead
   of two short values — and the width comes out of the catalog column, which
   is receding anyway for exactly the same reason (see the :has() recede rule
   below). Same :has() hook, so it holds on both the full /edit render and the
   HTMX swap; engines without :has() just keep the view width and wrap.

   Scoped to the width where the three-up actually holds (issue #1200). This
   rule is (0,2,0) — `:has()` takes its argument's specificity — so unqualified
   it outranked the (0,1,0) `.entry-layout` inside the ≤1024 and ≤520 queries
   no matter how far below them it sat. Editing a copy on a phone then kept
   three desktop tracks against two columns of areas: the 1fr masthead column
   collapsed to 0, the panel and facts spanned only cols 1-2 (164px of a 358px
   page), and the unmapped 22rem third track ate the rest of the screen. */
@media (min-width: 1025px) {
  .entry-layout:has(.copy-panel--editing) {
    grid-template-columns: minmax(9rem, 12.75rem) minmax(0, 1fr) minmax(22rem, 31.75rem);
  }
}

.entry-layout > .entry-cover {
  grid-area: cover;
}

.entry-layout > .entry-head {
  grid-area: head;
}

.entry-layout > .entry-catalog {
  grid-area: facts;
}

.entry-layout > .copy-panel {
  grid-area: panel;
}

/* Both side rails hold still while the reading column scrolls (design: Game
   Detail Refined) — the cover and the copy panel are the page's fixed points
   and a long description should not carry them off the top of the screen.

   Only where the three-up layout actually holds: once the panel drops under
   the columns (≤1024) it IS the scrolling content, and sticking it would pin
   it over the facts. The editing panel is excluded too — it is far taller
   than the viewport, so a stuck top would just park its foot off-screen; it
   pins its own head and action bar instead (see .copy-panel-head--pinned). */
@media (min-width: 1025px) {
  .entry-layout > .entry-cover,
  .entry-layout > .copy-panel:not(.copy-panel--editing) {
    position: sticky;
    top: calc(var(--header-h) + 1rem);
  }
}

/* The panel drops below the catalog columns before the three-up gets tight,
   then everything stacks on phones. */
@media (max-width: 1024px) {
  .entry-layout {
    grid-template-columns: minmax(9rem, 12rem) minmax(0, 1fr);
    grid-template-rows: auto auto auto;
    grid-template-areas:
      "cover head"
      "cover facts"
      "panel panel";
    row-gap: 1.25rem;
  }
}

/* Compact (mobile layer spec §04): the cover compresses to a 96px thumb
   beside the title — the masthead — and the copy panel, the thing the user
   came to touch, lands directly beneath it. Facts are reading matter and
   read after acting. */
@media (max-width: 520px) {
  .entry-layout {
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas:
      "cover head"
      "panel panel"
      "facts facts";
    grid-template-rows: auto auto auto;
    row-gap: 1rem;
  }

  /* 72px, not the game page's 96 (mobile screens 2D): the entry masthead is
     a receipt header, not the subject — the copy panel below is. The status
     marker does not survive that width, so it stands down and the pill
     renders in the head instead (.entry-head-status below) while the date
     wraps under the thumb. */
  .entry-layout > .entry-cover {
    width: 72px;
  }

  .entry-layout .entry-status {
    display: none;
  }
}

/* While the panel is editing, the catalog facts recede (design: the inline
   edit state dims the game column, never the cover or the panel). Pure CSS via
   :has() so it holds on both the full /edit render and the HTMX swap; engines
   without :has() simply skip the dim.

   The recede demotes text to the muted token instead of fading the column:
   an `opacity` dim blends into the evaluated colors (issue #603 — 0.45 put
   the whole column below WCAG 1.4.3), while muted keeps its 4.5:1 floor in
   both themes (#528). Already-muted furniture and links keep their own
   AA-passing colors, so only the full-ink text steps down. */
.entry-layout:has(.copy-panel--editing) .entry-head,
.entry-layout:has(.copy-panel--editing) .entry-catalog,
.entry-layout:has(.copy-panel--editing) .entry-catalog .chip {
  color: var(--text-muted);
}

.entry-cover {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
}

/* Positioning context for the status badge overlay. The entry's own badge
   moved off the art (design: Game Detail Refined — see .entry-rail-line), but
   _game_cover.html is shared with the catalog cards, whose .cover-badge still
   overlays. */
.entry-cover .cover-frame {
  position: relative;
}

/* The rail caption: the status marker with when it joined the shelf on its
   own line beneath it — the same stacked anatomy as the catalog detail
   page's ownership stamp (.detail-stamp), so the two pages read as one. */
.entry-rail-line {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.25rem;
  margin: 0;
}

/* The status as a dotted mono marker (design: Game Detail Refined). The dot
   is the color-coded half and the word is the readable half — the pill's job,
   without the pill's fill competing with the cover right above it. Color is
   never the only channel: the word says the same thing (WCAG 1.4.1). */
.entry-status {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--accent-text);
  white-space: nowrap;
}

.entry-status::before {
  content: "";
  display: inline-block;
  width: 0.4rem;
  height: 0.4rem;
  margin-right: 0.45rem;
  border-radius: 50%;
  background: currentcolor;
  vertical-align: 0.05em;
}

.entry-status--wishlist {
  color: var(--brass-text);
}

/* Previously owned: the one status with no accent of its own — it is the
   absence of ownership, so it takes the furniture color and a hollow dot. */
.entry-status--former {
  color: var(--text-muted);
}

.entry-status--former::before {
  background: none;
  box-shadow: inset 0 0 0 1px currentcolor;
}

.entry-added {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* The way out to the catalog record, under the rail's own hairline. */
.entry-rail-out {
  margin: 0.15rem 0 0;
  padding-top: 0.7rem;
  border-top: 1px solid var(--border);
}

/* Same IGDB newlines as the detail header's blurb — see .detail-head
   .description for why `pre-line` is the whole fix. */
.entry-catalog .description {
  font-size: var(--fs-body);
  line-height: 1.65;
  color: var(--text-muted);
  max-width: 60ch;
  white-space: pre-line;
}

/* "Also on" renders its platforms as bordered tokens (design: Game Detail
   Refined) — the one ledger value that is a set rather than a sentence. Right
   aligned like every other value in the ledger, wrapping within its cell. */
.platform-tokens {
  display: inline-flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.35rem;
}

.platform-token {
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 0.1rem 0.45rem;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  white-space: nowrap;
}

/* Actions under the facts ledger: catalog link out, destructive link right
   beside it — links, not buttons, in the quiet voice of the design. */
.entry-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 1.4rem;
  margin-top: 1.4rem;
}

.entry-actions form {
  margin: 0;
}

.entry-catalog-link {
  font-size: var(--fs-ui-sm);
  font-weight: 600;
}

/* A destructive action styled as a link (Remove from collection). */
.link-danger,
button.link-danger {
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  color: var(--danger);
  font-size: var(--fs-ui-sm);
  font-weight: 500;
}

.link-danger:hover,
button.link-danger:hover {
  background: none;
  border: 0;
  color: var(--danger);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* --- The "Your copy" panel ------------------------------------------------ */

/* The panel itself carries no padding (design: Game Detail Refined): its
   sections do, so each one's hairline runs the full width of the panel
   instead of stopping short at a shared inner gutter. */
.copy-panel {
  /* One inset for every band in the panel, so the head, the sections, the
     form body and the action bar all sit on the same inner rail. */
  --copy-pad: 1.25rem;

  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0;
}

.copy-panel-head {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  padding: 0.95rem var(--copy-pad) 0.8rem;
}

/* A band of the view panel: hairline above, its own padding. Ordered by how
   often a value is read — the verdict, then what the copy is, then what it
   cost, then what you wrote. */
.copy-section {
  padding: 1.05rem var(--copy-pad) 1.15rem;
  border-top: 1px solid var(--border);
}

/* Section one: the rating and play-state heroes side by side. */
.copy-hero-row {
  display: flex;
  align-items: flex-end;
  gap: 1rem;
}

.copy-hero-field {
  flex: 1;
  min-width: 0;
}

.copy-hero-field--end {
  text-align: right;
}

/* The rating select wearing the display serif. Line-height 1 so the numeral
   sits on the label like a stat rather than inside a text line's worth of
   leading, and the negative margin hangs its hover box off the panel's inner
   rail so the numeral itself starts on it. */
.copy-panel .copy-hero-set {
  margin: 0.15rem 0 0 -0.4rem;
  padding-left: 0.4rem;
  font-family: var(--font-display);
  font-size: 2.1rem;
  font-weight: 500;
  line-height: 1;
}

/* Play state reads at value scale opposite it, pinned to the right hairline
   by the shared .copy-panel .inline-set rule. */
.copy-hero-field--end .inline-set {
  margin-top: 0.15rem;
  font-size: var(--fs-ui);
  font-weight: 500;
}

/* The rating as a shape. Decorative (aria-hidden on the container): the
   select above already states the value in text. */
.copy-ticks {
  display: flex;
  gap: 3px;
  margin-top: 0.85rem;
}

.copy-tick {
  flex: 1;
  height: 4px;
  border-radius: 1px;
  background: var(--border);
}

.copy-tick--on {
  background: var(--accent);
}

/* Section two: the copy's own facts, two per row — mono label over value, so
   a value gets half a panel instead of the sliver a dt/dd row left it. */
.copy-pairs {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.05rem 1.15rem;
}

.copy-pair {
  min-width: 0;
}

.copy-pair-value {
  display: block;
  margin-top: 0.2rem;
  font-size: var(--fs-ui-sm);
  color: var(--text);
  overflow-wrap: anywhere;
}

/* The pairs' live selects sit on the same optical line as the static values
   beside them: the base .inline-set hangs its hover box 0.4rem left of the
   text, which is exactly the padding it adds back. */
.copy-pairs .inline-set {
  margin-top: 0.05rem;
}

/* Section three: what it cost. */
.copy-money {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0.3rem 0 0;
}

.copy-money-value {
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 500;
  line-height: 1.1;
  color: var(--text);
}

.copy-money-unit {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.copy-money-parts,
.copy-acquired-line {
  margin: 0.4rem 0 0;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

.copy-acquired-ref {
  margin: 0.3rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
  overflow-wrap: anywhere;
}

.copy-kicker {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0;
}

.copy-kicker--accent {
  color: var(--accent-text);
}

.copy-edit {
  margin-left: auto;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  white-space: nowrap;
}

/* Unset values stay visible as quiet mono placeholders (design: "Not set",
   "Not tracked", "—") rather than dropping the row. Shared beyond the copy
   panel: the catalog and collection detail facts ledgers use it to keep every
   row in a fixed position for every game. */
.copy-unset {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* Notes are quoted, not just indented (design: Game Detail Refined): the rule
   down the left says this text is the user's own words, in a panel where
   everything else is a field the app filled in. */
.copy-notes {
  margin: 0.4rem 0 0;
  padding-left: 0.7rem;
  border-left: 2px solid var(--border-strong);
  font-size: var(--fs-ui-sm);
  line-height: 1.6;
  color: var(--text-muted);
}

/* --- Inline auto-save controls in the panel's VIEW state ------------------ */

/* The status / play-state / rating rows are live selects (design: collection
   detail + inline edit): a change POSTs the one field and swaps #entry-body
   back. The selects read at dd scale and sit quiet — transparent until
   hovered or focused — so the ledger keeps its facts voice; the caret stays
   visible as the "this one edits" affordance. */
.inline-set-label {
  display: inline;
  margin: 0;
  font: inherit;
  color: inherit;
}

.inline-set-form {
  margin: 0;
}

.inline-set {
  width: auto;
  margin-top: 0;
  padding: 0.2rem 1.5rem 0.2rem 0.4rem;
  margin-left: -0.4rem;
  font-size: var(--fs-ui-sm);
  background-color: transparent;
  border-color: transparent;
  background-position: right 0.4rem center;
}

.inline-set:hover {
  border-color: var(--border-strong);
  background-color: var(--surface);
}

/* A closed select is as wide as its WIDEST option, not its selected one, and
   the platform control's options are catalog names rather than a short fixed
   vocabulary — "Super Nintendo Entertainment System" would push the control
   out past the panel's own border. Capping it at the cell keeps a long
   console name ellipsized in the closed control while the open list still
   shows it whole. */
.copy-panel .inline-set {
  max-width: calc(100% + 0.4rem);
  text-overflow: ellipsis;
}

/* Play state is the one panel control opposite a right-hand edge (the hero
   row's second half), so it alone keeps the ledger's mirrored treatment: the
   value pins to the caret and the caret takes the negative margin the
   left-aligned controls spend on the other side, ending the text-plus-caret
   cluster on the hairline the way a static value would. */
.copy-panel .copy-hero-field--end .inline-set {
  margin-left: 0;
  margin-right: -0.4rem;
  text-align: right;
}

/* The same selects in the collection list's table cells (issue #686). Sized
   to the table's own type so an editable cell reads like its static
   neighbors, with the vertical padding shaved so the select's chrome doesn't
   inflate the row height. */
tbody td .inline-set {
  padding-top: 0.1rem;
  padding-bottom: 0.1rem;
  font-size: inherit;
}

/* In-flight: htmx stamps .htmx-request on the requesting form. */
.inline-set-form.htmx-request .inline-set {
  opacity: 0.55;
}

.inline-set-error {
  display: block;
  margin: 0.25rem 0 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--danger);
}

/* --- Inline name edit on the account page (issue #728) -------------------- */

/* Since design "Account Refined" the name is the page's <h1> and its own edit
   control: a click swaps the whole block for a one-field form. The wrapper is
   the swap target, so it lays out both states — display is [name][EDIT NAME],
   edit is [field][Save][Cancel]. */
.profile-name {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem 0.9rem;
  min-width: 0;
}

.account-name {
  margin: 0;
  min-width: 0;
  font-family: var(--font-display);
  font-size: clamp(1.75rem, 1.2rem + 2.4vw, 2.5rem);
  font-weight: 500;
  line-height: 1.05;
  letter-spacing: -0.015em;
  /* A name is arbitrary user text with no guaranteed break opportunity;
     break-word (not anywhere) keeps it from setting a width floor without
     collapsing min-content to a single character (issue #1032). */
  overflow-wrap: break-word;
}

/* The heading is the link. Baseline alignment keeps the mono hint sitting on
   the serif name's baseline rather than centred against its cap height. */
.account-name .profile-name-edit {
  display: inline-flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.75rem;
  color: inherit;
  text-decoration: none;
}

.account-name .profile-name-edit:hover .account-name-text,
.account-name .profile-name-edit:focus-visible .account-name-text {
  text-decoration: underline;
  text-decoration-style: dotted;
  text-underline-offset: 0.12em;
}

/* The standing "this one edits" affordance, in the design's mono caps. It
   carries its own rule so it reads as a control rather than as a caption. */
.profile-name-hint {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 400;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--text-faint);
  border-bottom: 1px solid var(--border-strong);
  padding-bottom: 1px;
}

.profile-name-edit:hover .profile-name-hint,
.profile-name-edit:focus-visible .profile-name-hint {
  color: var(--text);
  border-bottom-color: var(--text);
}

/* Edit state: the field is the heading, so it carries the heading's type. A
   rejected value's error wraps to its own full-width line under the controls. */
.profile-name-form {
  /* The form is the wrapper's only child while editing; taking the full basis
     stops the field sizing itself from its own content. */
  flex: 1 1 100%;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: 0;
  min-width: 0;
}

.account-name--editing {
  flex: 1 1 20rem;
  min-width: 0;
}

.profile-name-input {
  width: 100%;
  margin: 0;
  padding: 0.25rem 0.6rem;
  font-family: var(--font-display);
  font-size: inherit;
  font-weight: 500;
  line-height: 1.15;
  letter-spacing: -0.015em;
  border-color: var(--accent-border-strong);
}

.profile-name-form .profile-name-save {
  padding: 0.35rem 0.9rem;
  font-size: var(--fs-ui-sm);
}

.profile-name-cancel {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.profile-name-form .inline-set-error {
  flex-basis: 100%;
}

/* In-flight: htmx stamps .htmx-request on the requesting form. */
.profile-name-form.htmx-request .profile-name-input {
  opacity: 0.55;
}

/* "Saved" flash for the profile display-name edit — the one place left that
   needs one. The inline auto-save selects above deliberately have none: their
   swapped-in control already shows the new value, so a flash restated it (see
   _inline_set_errors.html).
   It arrives already in the swapped-in fragment (role="status" announces it)
   and CSS alone retires it — no JS timers, the CSP keeps behavior out of
   markup. The fade ends by dropping the span from flow (the discrete display
   flip in the last keyframe, held by forwards) rather than leaving a merely
   transparent span holding its width beside the field. Engines without
   discrete display animation just keep the transparent span.
   The fade lives behind no-preference so reduced-motion users keep the flash
   visibly parked instead of the global animation kill-switch (see the
   prefers-reduced-motion block) snapping it straight to transparent. */
.inline-saved {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--success);
  white-space: nowrap;
}

@media (prefers-reduced-motion: no-preference) {
  .inline-saved {
    animation: inline-saved-fade 2.4s ease forwards;
  }

  @keyframes inline-saved-fade {
    0%,
    70% {
      opacity: 1;
    }

    99% {
      display: inline;
    }

    100% {
      opacity: 0;
      display: none;
    }
  }
}

/* --- The panel's edit state (design: inline edit) ------------------------- */

.copy-panel--editing {
  border-color: var(--border-strong);
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  box-shadow: var(--shadow-panel);
}

/* The editing panel is taller than the viewport, so its two pieces of
   standing information — how to get out, and how to commit — pin to the top
   and bottom of the panel while its middle scrolls past (design: Game Detail
   Refined). Deliberately NOT wrapped in overflow:hidden for the rounded
   corners: an overflow ancestor becomes the sticky containing block, which
   would pin these to the panel instead of the viewport. The corners are
   rounded on the bands themselves. */
.copy-panel-head--pinned {
  position: sticky;
  top: var(--header-h);
  z-index: 2;
  background: var(--surface);
  /* The hairline belongs to the pinned head rather than the body below it:
     the body's own top border scrolls away, leaving fields sliding flush
     under an unbounded header. */
  border-bottom: 1px solid var(--border);
  border-radius: var(--radius) var(--radius) 0 0;
}

.copy-esc-hint {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
  white-space: nowrap;
}

/* The form's own bands, on the panel's shared inner rail. The head above
   draws the rule over the first one; the notes band draws its own, under the
   provenance fold. */
.copy-form-body {
  padding: 0.35rem var(--copy-pad) 1.15rem;
}

.copy-form-body--notes {
  padding-top: 1.15rem;
  border-top: 1px solid var(--border);
}

.copy-form-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.85rem 0.85rem;
  margin-top: 0.95rem;
}

/* Region · grading co. · grade share one row (design: Game Detail Refined):
   three short values that read as one statement about the graded copy. */
.copy-form-grid--thirds {
  grid-template-columns: 1fr 1fr 1fr;
}

.copy-field--wide,
.copy-field--notes {
  grid-column: 1 / -1;
}

.copy-field--notes {
  margin-top: 0;
}

/* Sentence case, for the sub-labels inside a block that already has a mono
   caption of its own (the as-acquired pair): two mono uppercase labels
   stacked would read as two separate sections. */
.copy-field-label--plain {
  font-family: var(--font-body);
  font-size: var(--fs-ui-sm);
  letter-spacing: 0;
  text-transform: none;
}

/* --- Rating as a tick bar (design: Game Detail Refined) ------------------- */

/* Label left, the current value read back at the right — the number the ticks
   encode, in words, for anyone who is not going to count them. */
.copy-rating-head {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
}

.copy-rating-read {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text);
}

/* Eleven radios wearing tick faces: the "unrated" one, then ten scored. Same
   idiom as .seg-control — the real input is parked off-screen (.sr-only) and
   its label is the visible control — so keyboard and screen readers get a
   plain radio group and the pointer gets the bar. */
.tick-control {
  display: flex;
  gap: 3px;
  margin-top: 0.4rem;
}

.tick {
  position: relative;
  flex: 1;
  margin: 0;
  cursor: pointer;
}

.tick--clear {
  flex: 0 0 1.75rem;
}

/* The real radio, full-bleed and invisible over its own tick — the pointer
   hits the input, so the whole tick is the hit target and no click handler is
   needed (the CSP forbids inline ones anyway). */
.tick input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  padding: 0;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.tick-face {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 1.6rem;
  border-radius: var(--radius-sm);
  background: var(--fill);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  /* Muted for the same reason as .copy-price-scope: the face's --fill tint
     composites to #25221C, where --text-faint is 4.22:1. */
  color: var(--text-muted);
  transition: background var(--speed) ease;
}

.tick:hover .tick-face {
  background: var(--border);
}

.tick--on .tick-face {
  background: var(--accent);
}

/* The checked tick keeps a visible edge of its own, so "which one did I
   actually pick" survives the run of identically-filled ticks to its left. */
.tick input:checked + .tick-face {
  box-shadow: inset 0 0 0 2px var(--accent-text);
}

.tick--clear input:checked + .tick-face {
  background: var(--surface-3);
  color: var(--text);
}

.tick input:focus-visible + .tick-face {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
}

.copy-field-label {
  display: block;
  margin: 0 0 0.05rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.copy-field input,
.copy-field select,
.copy-field textarea {
  font-size: var(--fs-field);
  padding: 0.42rem 0.6rem;
}

.copy-field select {
  padding-right: 1.8rem;
}

.copy-field .field-error {
  margin: 0.3rem 0 0;
  font-size: var(--fs-ui-sm);
}

/* Status as a segmented control (the design's Owned | Wishlist toggle, grown
   a third segment): radios visually hidden inside full-bleed segments, the
   checked one taking the leaf fill. */
.seg-control {
  display: flex;
  margin-top: 0.35rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  overflow: hidden;
}

.seg-control .seg {
  position: relative;
  flex: 1;
  margin: 0;
}

.seg-control .seg input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  margin: 0;
  cursor: pointer;
}

.seg-control .seg span {
  display: block;
  padding: 0.42rem 0.2rem;
  text-align: center;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  color: var(--text-muted);
  white-space: nowrap;
  transition:
    background var(--speed) ease,
    color var(--speed) ease;
}

.seg-control .seg + .seg span {
  border-left: 1px solid var(--border);
}

.seg-control .seg input:checked + span {
  background: var(--accent);
  color: var(--accent-ink);
}

.seg-control .seg input:focus-visible + span {
  outline: 2px solid var(--accent-text);
  outline-offset: -2px;
}

/* Provenance folds away (design: Game Detail Refined — where it came from,
   what it cost). Its own band of the panel, so the disclosure row spans the
   full width and reads as a section header rather than a control indented
   inside the form above it. */
.copy-provenance {
  border-top: 1px solid var(--border);
}

/* Grid, not flex-wrap: the hint is long enough to wrap, and under flex-wrap a
   wrapped hint carries the `margin-left:auto` caret down onto its last line,
   stranding the disclosure's state a row below the label whose state it
   reports. Three columns pin the caret to the first baseline and let the hint
   wrap inside its own track. */
.copy-provenance > summary {
  list-style: none;
  cursor: pointer;
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr) max-content;
  align-items: baseline;
  gap: 0.25rem 0.65rem;
  padding: 0.85rem var(--copy-pad);
}

.copy-provenance > summary:hover {
  background: var(--row-hover);
}

.copy-provenance > summary::-webkit-details-marker {
  display: none;
}

.copy-prov-summary {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-text);
  white-space: nowrap;
}

.copy-prov-hint {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* The caret moved to the right edge of the row (design: Game Detail Refined),
   where a disclosure's state belongs when its label is at the left edge. Its
   own element rather than a ::before on the summary text, so the grid above
   can place it in a track of its own. */
.copy-prov-caret {
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* Open flips the caret in place (was a ▾/▴ content swap when the caret was a
   text glyph; the icon is one chevron rotated). */
.copy-provenance[open] .copy-prov-caret .icon {
  transform: rotate(180deg);
}

.copy-prov-body {
  padding: 0 var(--copy-pad) 1.25rem;
}

.copy-prov-body > .copy-form-grid:first-child {
  margin-top: 0;
}

/* --- The price block (design: Game Detail Refined) ------------------------ */

/* Boxed, because the three figures in it are not three independent fields:
   the total is the whole sum and the two under it are parts OF it (#986).
   The border is what makes "of which" read as arithmetic. */
.copy-price {
  margin-top: 1.15rem;
  padding: 0.9rem 1rem 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--fill);
}

.copy-price-head {
  display: flex;
  align-items: baseline;
  gap: 0.65rem;
}

.copy-price-head .copy-field-label {
  margin: 0;
}

/* --text-MUTED, not faint: this caption sits on the block's --fill tint, which
   composites over --surface to #25221C and drops --text-faint to 4.22:1 (AA
   fail). The tint is the design; the text colour is what gives. Composite the
   rgba before doing contrast arithmetic — an uncomposited --fill reads as
   "transparent, so same as the panel", which is how this passes review and
   fails axe. */
.copy-price-scope {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* The total and its currency: one wide amount, one narrow code. */
.copy-price-total {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 5.5rem;
  gap: 0.6rem;
  margin-top: 0.55rem;
}

.copy-price-parts {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.75rem;
  margin-top: 0.75rem;
}

/* Inputs carry a 0.35rem top margin to clear the label above them; in this
   block the labels are either sr-only or inline beside the field, so that gap
   has nothing to clear and just knocks the row off its baseline. */
.copy-price-total :is(input, select),
.copy-price-part :is(input, select) {
  margin-top: 0;
}

/* The parts read as a phrase beside their box — "of which shipping [ ]" —
   rather than as two more labelled fields stacked under the total. */
.copy-price-part {
  display: flex;
  align-items: center;
  gap: 0.55rem;
}

.copy-price-part-label {
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  white-space: nowrap;
}

.copy-price-part input {
  min-width: 0;
  flex: 1;
}

/* The as-acquired snapshot, under its own caption and a dashed rule: these
   two fields repeat the names of two in the main grid and answer a different
   question (#893), so the caption is load-bearing, not decoration. */
.copy-asacquired {
  margin-top: 1.2rem;
  padding-top: 0.9rem;
  border-top: 1px dashed var(--border-strong);
}

.copy-asacquired > .copy-form-grid {
  margin-top: 0.55rem;
}

/* The action bar rides the bottom of the viewport while the form scrolls, so
   Save is reachable without hunting for the end of a fold-out form. */
.copy-form-actions {
  position: sticky;
  bottom: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.85rem var(--copy-pad);
  border-top: 1px solid var(--border);
  background: var(--surface);
  border-radius: 0 0 var(--radius) var(--radius);
}

.copy-form-actions .btn-accent {
  padding: 0.5rem 1.3rem;
}

.copy-cancel {
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

.copy-cancel:hover {
  color: var(--text);
}

/* Advertises the shortcut app.js binds on [data-submit-shortcut]. */
.copy-save-hint {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
  white-space: nowrap;
}

/* ---------------------------------------------------------------------------
 * HTMX swap polish + motion preferences
 * ------------------------------------------------------------------------- */

#catalog-results.htmx-swapping,
#collection-results.htmx-swapping {
  opacity: 0.4;
  transition: opacity 120ms ease;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/*
 * Game cover art on detail pages (spec 180).
 *
 * A fixed aspect ratio + object-fit keeps the footprint identical whether a real
 * cover or the neutral placeholder is shown, so the layout never shifts between
 * games with and without art. The image is capped so a large source image cannot
 * dominate the page, and aspect ratio is preserved (never stretched/cropped).
 */
.game-cover {
  display: block;
  width: 100%;
  max-width: 12rem;
  aspect-ratio: 3 / 4;
  height: auto;
  object-fit: contain;
  margin: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}

/* ---------------------------------------------------------------------------
 * Cover-art browse grid (issue #240) — the catalog/collection "Grid" view
 * ------------------------------------------------------------------------- */

/* Fixed column counts at breakpoints (issue #264), not auto-fill: every count
   (2/3/4/6) divides the grid page sizes (24/48/96), so each page renders only
   complete rows at any viewport width — no orphan one-card last row. Keep the
   counts and the page sizes in apps/catalog/pagination.py in sync. */
.browse-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1.5rem 0.9rem;
}

@media (min-width: 521px) {
  .browse-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

@media (min-width: 721px) {
  .browse-grid {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}

@media (min-width: 1025px) {
  .browse-grid {
    grid-template-columns: repeat(6, minmax(0, 1fr));
  }
}

/* Spec game card (04): no surrounding box — the cover carries a hairline edge,
   title and mono meta sit below, actions pin to the bottom (hairlines over
   boxes; the art supplies the color). */
.browse-card {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
}

/* Cover wrapper: positioning context for the status pill overlay. */
.browse-card .browse-card-cover {
  position: relative;
  display: block;
  border-radius: var(--radius-sm);
  overflow: hidden;
}

/* The shared cover partial is sized for the detail page (capped width, contain).
   Inside a card it fills the full card width edge to edge; object-fit: cover
   keeps the 3:4 tiles uniform across covers whose source ratios vary slightly
   (IGDB art is 3:4 already, Commons art is close). */
.browse-card .game-cover {
  max-width: none;
  margin: 0;
  object-fit: cover;
  transition: border-color var(--speed) ease;
}

.browse-card:hover .game-cover {
  border-color: var(--border-strong);
}

.browse-card .browse-card-body {
  display: flex;
  flex-direction: column;
  flex: 1;
  gap: 0.3rem;
}

/* Two lines, always (design "Catalog Refined"): the title block is a FIXED
   height so the mono meta line under it lands on the same baseline in every
   card of a row. Left to grow, a three-line title pushed its own meta and
   action row down while its neighbours' stayed put, and the row read as
   ragged even though the cards were the same height. */
.browse-card .browse-card-title {
  font-weight: 600;
  font-size: var(--fs-ui);
  line-height: 1.35;
  color: var(--text);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  min-height: calc(2 * 1.35em);
}

.browse-card .browse-card-title:hover {
  color: var(--accent-text);
  text-decoration: none;
}

.browse-card .browse-card-meta {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  color: var(--text-muted);
  /* One line; a long genre ellipsizes rather than wrapping the card taller
     than its neighbours (design "Catalog Refined"). */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Pin the quick-add / Edit-Remove cluster to the card's bottom edge so action
   rows align across a row of cards with different title lengths. */
.browse-card .browse-card-actions {
  margin-top: auto;
  padding-top: 0.45rem;
}

/* Card actions span the card: "＋ Add" and "♡ Wish" split the fixed 30px slot
   the status bar otherwise occupies (design "Catalog Card States" 05.2), each
   a centered flex button. Every card's action row is exactly 30px tall, so all
   rows of a grid line sit on one baseline. */
.browse-card .quick-add {
  width: 100%;
  height: 30px;
  gap: 6px;
}

/* The split ＋ Add | ▾ (issue #769) occupies the half-slot the bare Add form
   used to; the form inside it keeps flexing, the ▾ stays its own width. */
.browse-card .quick-add .split-add--row {
  flex: 1 1 0;
  display: flex;
  min-width: 0;
}

.browse-card .quick-add .quick-add-form {
  flex: 1 1 0;
  display: flex;
  min-width: 0;
}

.browse-card .quick-add .quick-add-form button {
  width: 100%;
  height: 30px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  padding-top: 0;
  padding-bottom: 0;
  white-space: nowrap;
}

/* The transient mark-as-owned disambiguation picker (issue #75) holds more
   buttons than the fixed slot fits; it alone may grow and wrap. */
.browse-card .quick-add-picker {
  height: auto;
  flex-wrap: wrap;
  white-space: normal;
}

.browse-card .quick-add-picker .quick-add-form button {
  height: auto;
  padding-top: 0.32rem;
  padding-bottom: 0.32rem;
}

/* Owned covers swap the default hairline for a leaf outline, alongside the
   badge (design 05.2). Collection cards set the modifier on the cover link
   itself (their whole card re-renders on entry changes); catalog cards key
   it off the .cover-state overlay — the stable-id fragment the HTMX
   row-control responses re-render out-of-band (issue #689) — which renders
   ahead of the image inside the cover link, hence the sibling combinator. */
.browse-card-cover--owned .game-cover,
.cover-state--owned ~ .game-cover {
  border-color: var(--accent-border-strong);
}

/* ---------------------------------------------------------------------------
 * Collection grid quick actions (design "Collection Grid Spec" 06) — a PHONE
 * design. ≤720 a card is a cover + title + one meta line + ONE ⋯ target
 * opening the bottom action sheet; state rides a chip on the cover and the
 * meta text, never colour alone. Wider viewports keep the pre-spec card
 * (status pill, full meta line, Mark-as-owned/Edit/Remove row) untouched —
 * the desktop redesign is a separate spec, not yet delivered. The card
 * template renders both variants; these rules gate them at the app's
 * handheld boundary.
 * ------------------------------------------------------------------------- */

/* Desktop (unchanged UI): the phone-only pieces don't exist here. */
@media (min-width: 721px) {
  .browse-card--entry .entry-chip,
  .browse-card--entry .entry-actions,
  .browse-card--entry .browse-card-meta--state {
    display: none;
  }
}

@media (max-width: 720px) {
  /* The phone card hides the desktop pieces (spec 06 §A: the button rows and
     the pill go; the chip and the ⋯ menu carry their jobs)… */
  .browse-card--entry .cover-badge,
  .browse-card--entry .browse-card-actions,
  .browse-card--entry .browse-card-meta--full {
    display: none;
  }

  /* …and is the ⋯ target's positioning context — the cover link clips its
     children (overflow: hidden), so the menu cannot live inside it. */
  .browse-card--entry {
    position: relative;
  }

  /* The collection grid runs at the spec's density (§B: 18px rows / 12px
     columns); desktop keeps the shared .browse-grid rhythm. */
  .browse-grid--entries {
    gap: 18px 12px;
  }

  /* The state chip (§B): mono caps inset 5px bottom-left of the cover. Fixed
     plate-over-artwork colours like the status pills — the chip sits on cover
     art, not on the page surface, so it doesn't follow the theme. */
  .entry-chip {
    position: absolute;
    bottom: 5px;
    left: 5px;
    z-index: 1;
    padding: 2px 5px;
    border-radius: 2px;
    border: 1px solid rgba(236, 232, 221, 0.3);
    background: rgba(22, 20, 15, 0.82);
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: #ece8dd;
    white-space: nowrap;
  }

  /* Ownership speaks leaf; the glyph (✓ / ▶ / ♡ / ◇) is the colour-free
     carrier. The literal dark-theme leaf, not var(--accent): the plate under
     it is fixed dark in both themes. */
  .entry-chip--live {
    color: oklch(0.72 0.12 155);
    border-color: oklch(0.72 0.12 155 / 0.45);
  }

  .entry-chip--former {
    border-style: dashed;
    color: rgba(236, 232, 221, 0.75);
  }

  /* Meta stays one line tall even when a card has no platform to name, so
     titles and meta keep a common baseline across the row. Always faint (§B:
     "always faint, platform only") — the leaf-voiced owned variant left with
     the state text itself; the cover chip carries ownership now. */
  .browse-card--entry .browse-card-meta--state {
    min-height: 1.4em;
  }

  /* --- The ⋯ target (§B) --------------------------------------------------
     48×48 hit area filling the cover's top-right corner, drawn as a 28×28
     plate. The dashed box in the spec is the hit area, not a border.
     display/margin are load-bearing resets, not decoration: the entry DETAIL
     page's `.entry-actions` rule (its actions-under-the-ledger row) matches
     this <details> too, and its `margin-top: 1.4rem` shifted the absolutely
     positioned target 22px below the corner it must occupy (its flex display
     rode along as well). */
  .browse-card--entry .entry-actions {
    position: absolute;
    top: 0;
    right: 0;
    display: block;
    margin: 0;
  }

  .entry-actions > summary {
    list-style: none;
    cursor: pointer;
    width: 48px;
    height: 48px;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .entry-actions > summary::-webkit-details-marker {
    display: none;
  }

  .entry-actions-plate {
    width: 28px;
    height: 28px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 3px;
    background: rgba(22, 20, 15, 0.72);
    border: 1px solid rgba(236, 232, 221, 0.18);
    color: #ece8dd;
    font-size: 14px;
    line-height: 1;
  }

  .entry-actions > summary:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
    border-radius: 3px;
  }

  /* Open (§D behaviour): the plate inverts and the invoking card's cover
     takes a 2px leaf ring; both lift over the sheet's scrim (z 55). */
  .entry-actions[open] .entry-actions-plate {
    background: var(--text);
    border-color: var(--text);
    color: var(--bg);
  }

  .entry-actions[open] > summary {
    position: relative;
    z-index: 56;
  }

  .browse-card--entry:has(.entry-actions[open]) .browse-card-cover {
    position: relative;
    z-index: 56;
    border-radius: var(--radius-sm);
    box-shadow: 0 0 0 2px var(--accent-border-strong);
  }

  /* --- The action sheet (§D) ----------------------------------------------
     The popover's ≤720 bottom sheet with the spec's anatomy: grab handle,
     identity header, 52px rows, destructive row behind a rule in brass,
     outlined Cancel, heavier scrim. */
  .entry-actions-panel {
    border-radius: 14px 14px 0 0;
    padding: 8px 8px 12px;
    max-height: calc(100dvh - 40px);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  .entry-actions[open]::before {
    background: rgba(10, 9, 6, 0.62);
  }

  .entry-sheet-handle {
    display: block;
    flex-shrink: 0;
    width: 38px;
    height: 4px;
    border-radius: 2px;
    background: var(--border-strong);
    margin: 2px auto 10px;
  }

  .entry-sheet-identity {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 0 10px 12px;
    border-bottom: 1px solid var(--border);
  }

  .entry-sheet-cover {
    width: 42px;
    height: 56px;
    flex-shrink: 0;
    border-radius: 3px;
    border: 1px solid var(--border);
    object-fit: cover;
  }

  .entry-sheet-cover--blank {
    display: block;
    background: var(--fill);
  }

  .entry-sheet-text {
    display: flex;
    flex-direction: column;
    gap: 3px;
    min-width: 0;
  }

  .entry-sheet-title {
    font-family: var(--font-display);
    font-size: 1.2rem;
    line-height: 1.2;
    color: var(--text);
  }

  .entry-sheet-state {
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .entry-sheet-state--owned {
    color: var(--accent-text);
  }

  .entry-menu {
    display: flex;
    flex-direction: column;
    padding: 4px 0 0;
  }

  /* Forms are row carriers only (role="none" in the markup); dissolving
     their boxes keeps every row a direct flex child of the menu. */
  .entry-menu > form {
    display: contents;
  }

  .entry-menu-row,
  button.entry-menu-row,
  a.entry-menu-row {
    display: flex;
    align-items: center;
    /* Left-aligned like the spec's rows: the global `button` rule's
       justify-content: center is never otherwise overridden here, so the
       <button> rows (Move to wishlist, Remove) centred while the <a> rows
       sat left. */
    justify-content: flex-start;
    gap: 14px;
    width: 100%;
    min-height: 52px;
    margin: 0;
    padding: 0 12px;
    border: 0;
    border-radius: 5px;
    background: transparent;
    font-size: var(--fs-body);
    font-weight: 400;
    text-align: left;
    color: var(--text);
    cursor: pointer;
  }

  a.entry-menu-row:hover {
    text-decoration: none;
  }

  .entry-menu-row:hover,
  .entry-menu-row:focus-visible {
    background: var(--fill);
    color: var(--text);
  }

  .entry-menu-row:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  .entry-menu-icon {
    width: 18px;
    flex-shrink: 0;
    text-align: center;
    color: var(--text-muted);
  }

  .entry-menu-icon--leaf {
    color: var(--accent-text);
  }

  /* Destructive last, behind a rule, in brass (§D). */
  .entry-menu-rule {
    height: 1px;
    flex-shrink: 0;
    background: var(--border);
    margin: 8px 12px;
  }

  .entry-menu-row--danger,
  button.entry-menu-row--danger {
    color: var(--brass-text);
  }

  .entry-menu-row--danger:hover,
  .entry-menu-row--danger:focus-visible {
    background: var(--brass-dim);
    color: var(--brass-text);
  }

  .entry-menu-cancel,
  button.entry-menu-cancel {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-height: 52px;
    margin: 6px 0 0;
    border: 1px solid var(--border-strong);
    border-radius: 5px;
    background: transparent;
    font-size: var(--fs-body);
    font-weight: 500;
    color: var(--text);
  }

  /* Motion (§D): slide up 180ms ease-out, scrim fades 120ms. */
  @media (prefers-reduced-motion: no-preference) {
    .entry-actions[open] .entry-actions-panel {
      animation: entry-sheet-in 180ms ease-out;
    }

    .entry-actions[open]::before {
      animation: entry-scrim-in 120ms ease-out;
    }
  }
}

@keyframes entry-sheet-in {
  from {
    transform: translateY(24px);
    opacity: 0.6;
  }
}

@keyframes entry-scrim-in {
  from {
    opacity: 0;
  }
}

/* ---------------------------------------------------------------------------
 * The CATALOG grid card's owned menu, ≤720 (issue #1198) — the same sheet.
 *
 * Below 721 the shared `.add-popover-panel` rule already pins this panel to the
 * viewport's bottom edge (app.js stops placing row popovers there), so the menu
 * always DID open as a bottom sheet. What it kept was the dropdown's chrome:
 * 11.5px rows about 31px tall, 5px of padding, no handle, no header, no Cancel,
 * a lighter scrim and no motion. Spec 09 §F asks for "the ⋯ sheet already
 * specified for the collection grid" and this is it — the rules above, reached
 * by the catalog's own class names.
 *
 * Everything the two sheets share is inherited rather than restated: the markup
 * reuses .entry-sheet-* / .entry-menu-icon / .entry-menu-cancel verbatim, so
 * only what collides with a desktop `.bar-menu-*` rule is re-declared below.
 * Each of those overrides is single-specificity-tied with the rule it beats and
 * wins on source order, which is why this block has to stay AFTER both the
 * .bar-menu-* block and the ≤720 .add-popover-panel one.
 *
 * NOT the bar itself: at ≤720 that is still §F's 44px stopgap, not the ⋯
 * target on the cover. Only the menu is the sheet.
 * ------------------------------------------------------------------------- */
@media (max-width: 720px) {
  /* The sheet surface (§D anatomy), over .bar-menu-panel's 5px dropdown pad.
     Width and inset come from the shared .add-popover-panel rules.

     🚨 `.bar-menu` prefix, and the radius is why: `.add-popover--row
     .add-popover-panel` (0,2,0) also pins this panel to the bottom edge, with
     the popover family's 12px corner. A lone `.bar-menu-panel` is (0,1,0) and
     loses to it on specificity no matter where it sits, so the sheet silently
     kept a 12px radius where §D draws 14. Measured, not assumed — the padding
     and max-height DID land, because that rule declares neither. */
  .bar-menu .bar-menu-panel {
    border-radius: 14px 14px 0 0;
    padding: 8px 8px 12px;
    max-height: calc(100dvh - 40px);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* The heavier sheet scrim, over .add-popover[open]::before's 0.45. */
  .bar-menu[open]::before {
    background: rgba(10, 9, 6, 0.62);
  }

  /* The mono copy summary and the trailing ↗ are the DROPDOWN's: on a phone the
     identity header states the same facts, and the ↗ leads the row as an icon
     like every other row's does. */
  .bar-menu-panel .bar-menu-head,
  .bar-menu-panel .bar-menu-row-tail {
    display: none;
  }

  /* 52px rows with the icon gutter, over the dropdown's 8px/7px and 11.5px.
     .bar-menu-panel-prefixed to match the rule being overridden — and, as ever
     on this bar, to clear `button[type="submit"]` (0,1,1) on the two rows that
     are submits (see test_card_bar_hover.py's header). */
  .bar-menu-panel .bar-menu-row {
    gap: 14px;
    min-height: 52px;
    padding: 0 12px;
    border-radius: 5px;
    font-size: var(--fs-body);
  }

  /* 🚨 `border-color` and `opacity` are declared here and nowhere else on this
     rule: `button[type="submit"]:hover` is (0,2,1), the SAME specificity as
     this selector, so it is beaten only on the properties this rule states —
     and only because it is earlier in the file. Anything omitted falls through
     to the base primary fill, which is the #1188 trap the Wish switch was
     bitten by (`opacity: .9` also collides with the 0.6 pending state). */
  .bar-menu-panel .bar-menu-row:hover,
  .bar-menu-panel .bar-menu-row:focus-visible {
    background: var(--fill);
    border-color: transparent;
    color: var(--text);
    opacity: 1;
  }

  /* Destructive in brass, as the collection sheet draws it (spec 06 §D) rather
     than the dropdown's red: on a phone this row and My Collection's are the
     same control, and the user reaches them by the same gesture. */
  .bar-menu-panel .bar-menu-row--danger {
    color: var(--brass-text);
  }

  .bar-menu-panel .bar-menu-row--danger:hover,
  .bar-menu-panel .bar-menu-row--danger:focus-visible {
    background: var(--brass-dim);
    border-color: transparent;
    color: var(--brass-text);
    opacity: 1;
  }

  /* The rule inset to the sheet's rows, over the dropdown's full-bleed 4px. */
  .bar-menu-rule {
    margin: 8px 12px;
  }

  /* Motion (§D): slide up 180ms ease-out, scrim fades 120ms. */
  @media (prefers-reduced-motion: no-preference) {
    .bar-menu[open] .bar-menu-panel {
      animation: entry-sheet-in 180ms ease-out;
    }

    .bar-menu[open]::before {
      animation: entry-scrim-in 120ms ease-out;
    }
  }
}

/* ---------------------------------------------------------------------------
 * The view switcher (design "Collection Grid Spec" 06 §E, phone half only):
 * ≤720 one radio group behind a labelled trigger ("▦ Grid ▾") that opens the
 * modes as a bottom sheet — the filter sheet's checkbox-disclosure idiom.
 * ≥721 the same radios render as the segmented control the toggle has always
 * been; the spec's desktop treatment ships with the separate desktop redesign.
 *
 * BOTH list pages draw it: the collection's three modes (Shelves/Grid/List)
 * and the catalog's two (Grid/List) render from one component, so everything
 * here is page-agnostic. Each page's template supplies the ids, URLs and the
 * mode rows themselves.
 * ------------------------------------------------------------------------- */

.view-switch {
  display: contents;
}

/* ---- The no-JS twin (issue #1470) --------------------------------------
 * The switcher's radios are form-associated, so without JavaScript they only
 * apply when the filter bar's submit — a screen away, and named after filters —
 * is pressed. The template ships a second copy of the modes as plain LINKS
 * inside a <noscript>, which need no submit at all. Being inside a <noscript>,
 * those links are in the DOM only when scripting is off, so their presence IS
 * the "no JavaScript" test: `:has(> noscript .view-mode-option)` stands the
 * scripted control down exactly when the twin has taken over, and matches
 * nothing otherwise (with JS on the parser leaves the block as a text node).
 *
 * 🚨 That holds for the INITIAL PARSE only. An htmx swap materialises
 * <noscript> content as real ELEMENTS — htmx parses its fragment inside a
 * <template>, whose contents belong to an inert document with the scripting
 * flag OFF — so one live filter change would make this selector match for a
 * SCRIPTED visitor and retire the controls below. app.js re-empties the
 * wrappers on htmx:afterSwap/oobAfterSwap, before paint (see emptyNoscripts);
 * these rules are only sound because it does.
 *
 * The radios are hidden, not dropped: a display: none form control still
 * serializes, so the filter bar's submit keeps carrying the view mode as it
 * always has. Hiding the checkbox is what keeps the dead sheet trigger out of
 * the tab order.
 *
 * 🚨 No rule anywhere may give a <noscript> element a `display` of its own —
 * overriding the UA's `display: none` makes a JS-ENABLED browser paint the
 * element's raw SOURCE as page text (measured in Chromium: a
 * `noscript { display: contents }` rule printed the markup, angle brackets and
 * all). That is why the twin carries its own checkbox/trigger/scrim rather than
 * asking the <noscript> to be transparent, and why the rows below are reached
 * with :has() instead. test_nojs_display_links.py holds the stylesheet to it. */
.view-switch:has(> noscript .view-mode-option) > .view-sheet-toggle,
.view-switch:has(> noscript .view-mode-option) > .view-sheet-trigger,
.view-switch:has(> noscript .view-mode-option) > .view-sheet-scrim,
.view-switch:has(> noscript .view-mode-option) > .view-switch-modes {
  display: none;
}

/* The twin's rows are anchors, so the base link treatment has to come off: each
   part sets its own colour below, and the global a:hover underline would draw
   straight across a segmented control. The :hover arm out-specifies `a:hover`
   rather than relying on order alone. */
a.view-mode-option,
a.view-mode-option:hover {
  color: inherit;
  text-decoration: none;
}

/* The checkbox holds the sheet's open state: visually hidden, still
   focusable — its focus ring paints on the trigger (issue #1036 idiom). */
.view-sheet-toggle {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.view-sheet-scrim {
  display: none;
}

@media (max-width: 720px) {
  .view-sheet-trigger {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 7px;
    min-width: 106px;
    /* 48, like its Filters/Sort row-mates (§C's control row is 48px tall). */
    min-height: 48px;
    margin: 0;
    padding-inline: 12px;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    font-size: var(--fs-ui);
    color: var(--text);
    cursor: pointer;
  }

  /* The mode icon is a {% icon %} SVG, sized by the tag (15px here). */
  .view-sheet-trigger-icon {
    color: var(--text-muted);
  }

  /* 🚨 This caret and the Sort button's (.column-picker > summary::after) sit
     SIDE BY SIDE in §C's control row, so they must draw the same mark — the
     one thing #1383 left unfinished here (issue #1404). It shipped as an
     em-sized {% icon %} SVG at --text-faint while its row-mate was the 9×5
     --icon-caret mask at currentColor: measured at 390px, 6.0×3.5px of pale
     ink beside 9.0×5.0px of full ink. Same chevron, two sizes and two tones,
     one row. An {% icon %} SVG draws on the whole 0 0 20 20 box, of which the
     chevron's ink is 11×6 units, so em-sizing it yields ~55%×30% of the
     font-size; the mask crops the viewBox to that ink and states its size.

     So this is the mask idiom too, at the mask idiom's size — .lang-reveal-cue
     is the same shape (an empty span carrying the caret). currentColor rather
     than a tone of its own is what keeps the pair together through the open
     state as well: both triggers invert to --bg, and their carets follow.
     test_control_row_carets.py holds the two rules to the same values. */
  .view-sheet-trigger-caret {
    width: 9px;
    height: 5px;
    flex: none;
    background: currentColor;
    -webkit-mask: var(--icon-caret) center / contain no-repeat;
    mask: var(--icon-caret) center / contain no-repeat;
    transition: transform var(--speed) ease;
  }

  .view-sheet-toggle:focus-visible ~ .view-sheet-trigger {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* Open: the trigger inverts and the ▾ flips (§E). */
  .view-sheet-toggle:checked ~ .view-sheet-trigger {
    background: var(--text);
    border-color: var(--text);
    color: var(--bg);
    font-weight: 600;
  }

  /* Only the icon needs restating: it carries a tone of its own (--text-muted),
     where the caret is currentColor and rides the inverted trigger by itself. */
  .view-sheet-toggle:checked ~ .view-sheet-trigger .view-sheet-trigger-icon {
    color: var(--bg);
  }

  .view-sheet-toggle:checked ~ .view-sheet-trigger .view-sheet-trigger-caret {
    transform: rotate(180deg);
  }

  /* Closed: the modes are folded entirely behind the trigger. */
  .view-switch-modes {
    display: none;
    margin: 0;
    padding: 0;
    border: 0;
  }

  /* Open: a bottom sheet (the filter sheet's geometry, the spec's chrome). */
  .view-sheet-toggle:checked ~ .view-switch-modes {
    display: flex;
    flex-direction: column;
    position: fixed;
    inset: auto 0 0 0;
    z-index: 60;
    max-height: 85svh;
    overflow-y: auto;
    overscroll-behavior: contain;
    background: var(--surface);
    border-top: 1px solid var(--border-strong);
    border-radius: 14px 14px 0 0;
    box-shadow: 0 -18px 44px rgba(0, 0, 0, 0.4);
    padding: 8px 8px 12px;
  }

  .view-sheet-toggle:checked ~ .view-sheet-scrim {
    display: block;
    position: fixed;
    inset: 0;
    z-index: 55;
    margin: 0;
    background: rgba(10, 9, 6, 0.62);
    cursor: pointer;
  }

  /* The page behind the sheet holds still. */
  body:has(.view-sheet-toggle:checked) {
    overflow: hidden;
  }

  .view-sheet-handle {
    display: block;
    flex-shrink: 0;
    width: 38px;
    height: 4px;
    border-radius: 2px;
    background: var(--border-strong);
    margin: 2px auto 10px;
  }

  .view-sheet-label {
    display: flex;
    align-items: center;
    padding: 0 12px 10px;
    border-bottom: 1px solid var(--border);
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  .view-mode-option {
    position: relative;
    display: flex;
    align-items: center;
    gap: 14px;
    min-height: 64px;
    margin: 0;
    padding: 8px 12px;
    border: 1px solid transparent;
    border-radius: 5px;
    box-sizing: border-box;
    cursor: pointer;
  }

  .view-mode-option input {
    position: absolute;
    inset: 0;
    opacity: 0;
    margin: 0;
    cursor: pointer;
  }

  .view-mode-icon {
    width: 20px;
    flex-shrink: 0;
    color: var(--text-muted);
  }

  .view-mode-text {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
  }

  .view-mode-name {
    font-size: var(--fs-body);
    color: var(--text);
  }

  .view-mode-desc {
    font-size: var(--fs-ui-sm);
    line-height: 1.45;
    color: var(--text-faint);
  }

  .view-mode-check {
    width: 20px;
    flex-shrink: 0;
    margin-left: auto;
    text-align: right;
    color: var(--accent-text);
    visibility: hidden;
  }

  /* Active row (§E): leaf tint + border + ✓. The no-JS twin's rows are links,
     so the state they can carry is aria-current, not :checked — the second arm
     of each pair (issue #1470). Same for focus: the anchor takes the ring
     itself where the radio hands it to its label. */
  .view-mode-option:has(input:checked),
  .view-mode-option[aria-current] {
    background: var(--accent-dim);
    border-color: var(--accent-border);
  }

  .view-mode-option:has(input:checked) .view-mode-name,
  .view-mode-option[aria-current] .view-mode-name {
    font-weight: 600;
  }

  .view-mode-option:has(input:checked) .view-mode-icon,
  .view-mode-option[aria-current] .view-mode-icon {
    color: var(--accent-text);
  }

  .view-mode-option:has(input:checked) .view-mode-check,
  .view-mode-option[aria-current] .view-mode-check {
    visibility: visible;
  }

  .view-mode-option:has(input:focus-visible),
  a.view-mode-option:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* No Done row (Max's call on spec 06 §C3): a mode pick applies immediately
     and the swap closes the sheet, so a button implying "confirm to apply"
     would misstate the contract. Scrim, swipe-down and Escape dismiss a
     no-change open. */

  @media (prefers-reduced-motion: no-preference) {
    .view-sheet-toggle:checked ~ .view-switch-modes {
      animation: entry-sheet-in 180ms ease-out;
    }

    .view-sheet-toggle:checked ~ .view-sheet-scrim {
      animation: entry-scrim-in 120ms ease-out;
    }
  }
}

/* ≥721: the sheet apparatus doesn't exist — the checkbox leaves the tab
   order and the fieldset renders as the mono segmented control both pages'
   toggles have always been (GRID | LIST). */
@media (min-width: 721px) {
  .view-sheet-toggle,
  .view-sheet-trigger,
  .view-sheet-scrim,
  .view-sheet-head,
  .view-sheet-handle,
  .view-sheet-label,
  .view-mode-icon,
  .view-mode-desc,
  .view-mode-check {
    display: none;
  }

  .view-switch-modes {
    display: inline-flex;
    margin: 0;
    padding: 0;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    overflow: hidden;
  }

  .view-mode-option {
    position: relative;
    margin: 0;
  }

  .view-mode-option input {
    position: absolute;
    inset: 0;
    opacity: 0;
    margin: 0;
    cursor: pointer;
  }

  .view-mode-name {
    display: block;
    padding: 0.3rem 0.75rem;
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-muted);
    transition:
      background var(--speed) ease,
      color var(--speed) ease;
  }

  .view-mode-option + .view-mode-option .view-mode-name {
    border-left: 1px solid var(--border);
  }

  .view-mode-option:hover .view-mode-name {
    color: var(--text);
  }

  /* The no-JS twin's second arm, as in the sheet above (issue #1470). */
  .view-mode-option:has(input:checked) .view-mode-name,
  .view-mode-option[aria-current] .view-mode-name {
    background: var(--border);
    color: var(--text);
  }

  .view-mode-option:has(input:focus-visible) .view-mode-name,
  a.view-mode-option:focus-visible .view-mode-name {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }
}

/* ---------------------------------------------------------------------------
 * Collection SHELVES view (issue #717; design "Collection Refined"): a sticky
 * shelf index beside a stack of captioned six-column shelf blocks — the wall
 * of covers a real game shelf mirrors, with the spines readable.
 * ------------------------------------------------------------------------- */

/* Index rail + shelves. The rail's width is the design's 184px; the 44px
   column gap is what separates a table of contents from a sidebar. */
.shelf-layout {
  display: grid;
  grid-template-columns: 11.5rem minmax(0, 1fr);
  gap: 2.75rem;
  align-items: start;
}

/* One shelf ⇒ no index (the template skips it), so the shelves take the whole
   width rather than leaving a 184px hole. */
.shelf-layout:not(:has(.shelf-index)) {
  grid-template-columns: minmax(0, 1fr);
}

.shelf-list {
  min-width: 0;
}

/* ---- The shelf index --------------------------------------------------- */

/* Sticky under the site header: --header-h plus the breathing room the design
   leaves above the first group. */
.shelf-index {
  position: sticky;
  top: calc(var(--header-h) + 1.5rem);
  display: flex;
  flex-direction: column;
  gap: 1rem;
  /* Long collections index ~27 shelves; cap the rail at the viewport and let
     it scroll inside itself rather than run off the bottom. */
  max-height: calc(100vh - var(--header-h) - 3rem);
  overflow-y: auto;
  /* The cap makes the rail a scroll container, and a scroll container clips
     its children at the padding box — which ate the rows' 0.5rem gutter bleed
     (.shelf-index-link below): the active fill sat flush against its own
     text, and the clipped-off right edge left the rail 16px of sideways
     scroll. Widen the rail by the bleed and pad the static content back onto
     the 184px column, so the fills overflow the rail's borders the way the
     design draws them while staying inside the scrollport. */
  margin: 0 -0.5rem;
  padding: 0 0.5rem;
}

/* The cap above hides rows — 897px of index in a ~720px box on a laptop — and
   said nothing about it: Chrome's overlay scrollbars are invisible at rest
   (the rail measures a 0px scrollbar gutter), so the fold read as the end of
   the list and the last shelves were simply gone (issue #1226). Fade whichever
   end has more past it, on the stats phone rail's rule: the fade always means
   "more that way", so each end stands down once you are at it. app.js sets the
   flags.

   A MASK rather than the positioned ::after that rail uses, because this
   scrollport has no wrapper to hang one on: an absolutely-positioned child of
   a scroll container is laid out against the scrolled padding box, so a ::after
   here would ride the rows out of view — the stats fade sits on .stats-menu
   precisely because .stats-menu-list is the thing that scrolls. A mask paints
   on the element's own box and stays put. 1.5rem is under one 31px row, so the
   fading row stays legible as a row that continues rather than dissolving. */
.shelf-index[data-more-above] {
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 1.5rem);
  mask-image: linear-gradient(to bottom, transparent 0, #000 1.5rem);
}

.shelf-index[data-more-below] {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5rem), transparent 100%);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5rem), transparent 100%);
}

.shelf-index[data-more-above][data-more-below] {
  -webkit-mask-image: linear-gradient(
    to bottom,
    transparent 0,
    #000 1.5rem,
    #000 calc(100% - 1.5rem),
    transparent 100%
  );
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    #000 1.5rem,
    #000 calc(100% - 1.5rem),
    transparent 100%
  );
}

.shelf-index-head {
  margin: 0;
  padding-bottom: 0.55rem;
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.shelf-index-total::before {
  content: "· ";
}

.shelf-index-group ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.shelf-index-label {
  margin: 0 0 0.3rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Name left, count right, the row bleeding into the rail's gutter on hover so
   the fill reads as a target rather than a highlight inside a column. */
.shelf-index-link {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  margin: 0 -0.5rem;
  padding: 0.25rem 0.5rem;
  border-radius: 3px;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  text-decoration: none;
}

.shelf-index-link:hover {
  background: var(--fill);
  color: var(--text);
  text-decoration: none;
}

.shelf-index-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.shelf-index-count {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* The shelf currently under the reading line, marked by app.js as you scroll
   (design "Collection Refined" draws the index's active row in the accent).
   Written as aria-current="location" — the row IS the current position in a
   table of contents, so the state a sighted reader gets from the accent is the
   same one a screen reader gets from the attribute. It comes after :hover on
   purpose: the mark outranks a hover fill at equal specificity, so passing the
   cursor over the rail can't blank the row you're on.

   --accent-dim over --accent-text-STRONG, not the plain accent text the mock
   pairs with its own tint: the tint lifts the row's background, and measured
   against it plain accent text is 4.34:1 on paper — an axe fail for 13px. The
   strong tone clears it at 5.16:1 there and 7.47:1 on ink. */
.shelf-index-link[aria-current] {
  background: var(--accent-dim);
  font-weight: 600;
  color: var(--accent-text-strong);
}

/* The count joins the accent rather than keeping the mock's 80%-opacity leaf,
   which composites to 4.81:1 on the same tint — over the line, but with no
   room left, and this text is 12px. Size and weight already rank it under the
   name; it doesn't need the extra fade to read as secondary. */
.shelf-index-link[aria-current] .shelf-index-count {
  color: inherit;
}

/* Hover leaves the current row exactly as it is. Every accent-on-tint pair
   here had to be measured, and a second fill under the same text would need
   its own measurement to buy an affordance the cursor already gives. */
.shelf-index-link[aria-current]:hover {
  background: var(--accent-dim);
  color: var(--accent-text-strong);
}

/* ---- A shelf ----------------------------------------------------------- */

/* The scroll target for an index row. Without the offset the sticky header
   lands on top of the shelf title the anchor just jumped to. */
.shelf {
  scroll-margin-top: calc(var(--header-h) + 1rem);
}

/* Shelf header: serif group name, mono "YEAR · N GAMES", a hairline running
   out to the accent "View all N →" link at the right end. */
.shelf-head {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  margin-bottom: 0.75rem;
}

.shelf-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.3125rem;
  font-weight: 500;
  letter-spacing: normal;
  color: var(--text);
}

.shelf-count {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
  white-space: nowrap;
}

/* The refined collection head sets its own measure: a slightly smaller serif
   title over a fainter, tighter-tracked meta, with more air under the row
   because a shelves page stacks many of these. The shared values above stay
   as the catalog detail rail (issue #1056) was drawn against them. */
.shelf-list .shelf-head {
  margin-bottom: 0.875rem;
}

.shelf-list .shelf-title {
  font-size: 1.25rem;
}

.shelf-list .shelf-count {
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

/* The hairline that fills whatever the header's text doesn't. Nudged off the
   baseline the flex row aligns it to, so it sits mid-cap-height rather than
   under the type. */
.shelf-rule {
  flex: 1;
  height: 1px;
  background: var(--border);
  transform: translateY(-0.25em);
}

/* The shared component, as the catalog game detail rail (issue #1056) draws
   it: an accent link pushed to the head's right end. That head has no
   .shelf-rule to do the pushing, so the auto margin stays here — on the
   collection's head the rule already absorbed the free space, so it resolves
   to zero and changes nothing. */
.shelf-view-all {
  margin-left: auto;
  font-size: var(--fs-ui-sm);
  color: var(--accent-text);
  text-decoration: none;
  white-space: nowrap;
}

.shelf-view-all:hover {
  text-decoration: underline;
}

/* The collection's shelves draw it quieter — a shelf head is furniture there,
   repeated once per console, and the accent is spent on the covers. */
.shelf-list .shelf-view-all {
  font-weight: 500;
  color: var(--text-muted);
}

.shelf-list .shelf-view-all:hover {
  color: var(--accent-text);
  text-decoration: none;
}

/* .shelf-row is shared with the catalog game detail page's "More on
   <platform>" rail (issue #1056's .shelf-row--captioned), which is a single
   scrolling eight-cell row at every width. The base below is that component;
   the collection's own six-column block is the .shelf-list override after it,
   so the two pages can't re-proportion each other. */
.shelf-row {
  list-style: none;
  margin: 0 0 1.375rem;
  padding: 0 0 1.125rem;
  border-bottom: 1px solid var(--border);
  display: grid;
  grid-template-columns: repeat(8, minmax(4.25rem, 1fr));
  gap: 0.75rem;
  overflow-x: auto;
}

/* Six columns, up to two rows deep: 11 covers + the "+N more" tile, or every
   cover when the group fits in 12. minmax(0, 1fr) — not a rem floor — because
   the captions below each cover are what set the readable minimum now, and a
   floor here would force the sideways scroll this layout replaced. */
.shelf-list .shelf-row {
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 1.5rem 1.125rem;
  /* No scroll container: the block wraps instead of overflowing, and a
     container here would clip the tiles' focus rings. */
  overflow-x: visible;
}

/* A tile is cover art over a two-line caption. The link is the whole tile;
   the art wrapper is what clips the corners and anchors the wishlist badge
   (the caption must not be clipped by that radius). */
.shelf-cover {
  display: flex;
  flex-direction: column;
  min-width: 0;
  text-decoration: none;
}

.shelf-cover-art {
  position: relative;
  display: block;
  border-radius: var(--radius-sm);
  overflow: hidden;
}

.shelf-cover .game-cover {
  display: block;
  width: 100%;
  max-width: none;
  aspect-ratio: 3 / 4.1;
  object-fit: cover;
  margin: 0;
  transition: border-color var(--speed) ease;
}

/* The collection's tiles take the design's 3:4 cover; the shared rail keeps
   the 3 / 4.1 it was drawn at (issue #1056). */
.shelf-list .shelf-cover .game-cover {
  aspect-ratio: 3 / 4;
}

.shelf-cover:hover .game-cover {
  border-color: var(--border-strong);
}

/* Ownership is the norm on this page, so an owned copy takes a leaf frame and
   a corner flag rather than the catalog's OWNED pill — a mark, not a label.
   Wishlist copies keep the pill: there they are the exception. */
.shelf-cover--owned .game-cover {
  border-color: var(--accent-border);
}

.shelf-cover--owned:hover .game-cover {
  border-color: var(--accent-border-strong);
}

.shelf-cover--owned .shelf-cover-art::after {
  content: "";
  position: absolute;
  top: 0;
  right: 0;
  width: 1.25rem;
  height: 1.25rem;
  background: linear-gradient(225deg, var(--accent) 50%, transparent 50%);
}

/* The caption. It carries the tile's accessible name, so it is real text at
   every width — the handheld rail (mobile screens 2I) clips it to one fixed
   36px box and drops the meta line; see the handheld layer. */
/* .shelf-cover / .shelf-cap are shared with the catalog game detail page's
   "More on <platform>" rail, which stays covers-only at desktop (mobile
   screens 2I) — so the caption is parked here and switched on by context
   below, rather than being on by default.

   position: relative is load-bearing, not decoration. At handheld the meta
   line is visually hidden the standard way (absolute + 1px + clip), and an
   absolutely positioned box resolves against its nearest POSITIONED ancestor —
   without one that is the initial containing block, which sits outside the
   rail's overflow clip. Every hidden meta line then landed at its tile's
   un-clipped x and stretched the document to the rail's full length (1076px at
   390), panning the page sideways. */
.shelf-cap {
  position: relative;
  display: none;
  min-width: 0;
  margin-top: 0.5625rem;
}

/* The collection's own shelves are the ones the refined design captions. */
.shelf-list .shelf-cap {
  display: block;
}

.shelf-cap-title {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  font-size: var(--fs-ui);
  font-weight: 600;
  line-height: 1.3;
  color: var(--text);
}

/* A wishlisted copy isn't owned yet, so its title reads a step quieter than
   the shelf around it. */
.shelf-cover--wish .shelf-cap-title {
  font-weight: 500;
  color: var(--text-muted);
}

.shelf-cover:hover .shelf-cap-title {
  color: var(--text);
}

.shelf-cap-meta {
  display: block;
  margin-top: 0.25rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  color: var(--text-faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.shelf-cap-meta:empty {
  display: none;
}

/* The "+N more" tile: dashed outline, serif count over a mono MORE → label,
   sized to the cover it stands in for so the grid row stays even. Same
   destination as the header's "View all" link. */
.shelf-more {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.25rem;
  aspect-ratio: 3 / 4;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-sm);
  text-decoration: none;
  transition:
    border-color var(--speed) ease,
    background var(--speed) ease;
}

.shelf-more:hover {
  border-color: var(--accent-border-strong);
  background: var(--fill);
  /* The tile signals hover with its outline/fill; no underline under "+N". */
  text-decoration: none;
}

.shelf-more-n {
  font-family: var(--font-display);
  font-size: 1.375rem;
  color: var(--text-muted);
}

.shelf-more-label {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Maker timeline order: a mono eyebrow opens each maker's run of shelves
   ("NINTENDO", then "NINTENDO · HANDHELDS" for the sub-run) with that run's
   own totals beside it, closed by the same hairline the shelf heads use. */
.shelf-section {
  display: flex;
  align-items: baseline;
  gap: 0.875rem;
  margin: 0.375rem 0 1.125rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text);
}

.shelf-section-meta {
  letter-spacing: 0.06em;
  color: var(--text-faint);
  white-space: nowrap;
}

/* Between the desktop layout and the handheld rail the index rail is what
   gives first: six captioned covers need the width more than a table of
   contents does, and the shelf heads still carry "View all N →". The rail is
   removed rather than stacked — a 27-row list above the shelves would push
   the first cover off the fold. */
@media (max-width: 1000px) {
  .shelf-layout {
    grid-template-columns: minmax(0, 1fr);
    gap: 0;
  }

  .shelf-index {
    display: none;
  }
}

/* ---------------------------------------------------------------------------
 * Recommendations page (design: recommendations ledger — the setup card
 * collapses to a status strip once an agent is connected; picks render as
 * full-width ledger rows; empty states stay quiet)
 * ------------------------------------------------------------------------- */

/* Agent-connected status strip: dot + one mono line, the fresh-run prompt
   hint, and the Manage-agent link. Replaces the setup card once a grant
   exists. */
.agent-strip {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.8rem;
  box-sizing: border-box;
  min-height: 2.875rem;
  padding: 0.65rem 1rem;
  background: var(--fill);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  margin-bottom: 2rem;
}

.agent-strip .status-dot {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
}

.agent-strip-label {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.agent-strip-strong {
  color: var(--text);
}

/* Zero flex-basis, not auto: flex lines are formed from hypothetical sizes,
   so a content-sized side wraps under the label the moment the strip gets
   tight instead of letting the hint give way. At basis 0 the side always
   shares the label's line and the hint below ellipsizes into whatever width
   is left — the strip stays one line at any desktop width (design:
   recommendations refined). */
.agent-strip-side {
  margin-left: auto;
  display: flex;
  align-items: baseline;
  justify-content: flex-end;
  gap: 0.4rem 1.1rem;
  flex: 1 1 0;
  min-width: 0;
}

.agent-strip-hint {
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.agent-strip-hint em {
  color: var(--text);
}

.agent-strip-manage {
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  white-space: nowrap;
}

@media (max-width: 720px) {
  /* Keep the strip one quiet line on phones: the hint is a nicety. With it
     gone the side is just the manage link — back to content size, so it sits
     beside a short label and wraps under a long one instead of squeezing it. */
  .agent-strip-hint {
    display: none;
  }

  .agent-strip-side {
    flex: 0 0 auto;
  }
}

/* Outgrown-run strip (design: recommendations refined): an agent is attached
   but the collection has moved on since it last scored. The one state on the
   page that asks for something, so it is the only one that takes the brass
   tint — the disconnected strip below deliberately gave it up. */
.agent-strip--stale {
  border-color: var(--brass-border);
  background: var(--brass-dim);
}

.agent-strip--stale .status-dot {
  background: var(--brass);
}

.agent-strip--stale .agent-strip-label,
.agent-strip--stale .agent-strip-strong {
  color: var(--brass-text);
}

/* Disconnected-after-results strip (#465, design: recommendations refined):
   the strip's own slot and height, untinted, with a hollow grey dot. This
   first shipped as a brass reconnect banner (design 2a); the refined spec
   cools it down, because a saved run sitting in your account without an agent
   attached is a fact about the page, not a fault to fix — the copy reassures
   rather than prompts, and the results stay in view below. */
.status-dot--hollow {
  background: transparent;
  border: 1.5px solid var(--text-faint);
}

/* BYO-LLM setup card (#234): dot + mono eyebrow, one sans paragraph, then the
   three-step contract between hairlines. Full-width since the latest-run rail
   moved into the ledger sections, so cap it at a readable measure. */
.byo-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.5rem 1.6rem;
  max-width: 46rem;
  margin-bottom: 2rem;
}

.byo-eyebrow {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.6rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.byo-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
}

.byo-lede {
  font-size: var(--fs-ui);
  line-height: 1.65;
  color: var(--text-muted);
  max-width: 40rem;
  margin: 0 0 1.25rem;
}

.contract-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--border);
}

.contract-steps li {
  display: flex;
  gap: 1rem;
  padding: 0.85rem 0;
  border-bottom: 1px solid var(--border);
  font-size: var(--fs-ui);
  line-height: 1.65;
}

.contract-steps .step-num {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  padding-top: 0.15rem;
  color: var(--accent-text);
}

.contract-steps .step-body {
  flex: 1;
  min-width: 0;
}

.contract-steps a {
  font-weight: 600;
}

.byo-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 1rem;
  margin-top: 1rem;
}

.byo-actions .docs-link {
  font-size: var(--fs-ui-sm);
  font-weight: 600;
}

/* Ledger sections: serif heading, mono meta on the shared baseline, and the
   scoring note holding the right edge. The backlog section sits behind a
   hairline. */
.backlog-picker {
  margin-top: 2.5rem;
  border-top: 1px solid var(--border);
  padding-top: 1.75rem;
}

.rec-section-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.4rem 0.9rem;
  margin-bottom: 0.3rem;
}

.rec-section-head h2 {
  margin: 0;
  font-size: 1.45rem;
  /* Collapse anchoring (design 2c, app.js data-collapse-anchor) lands the
     header below the sticky site header, not underneath it. */
  scroll-margin-top: 4.6rem;
}

.rec-section-meta {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.rec-section-note {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* "Agent since disconnected" chip beside a rail's meta (#465, design 2a):
   the run persists after its agent's grant is revoked. */
.rec-section-flag {
  align-self: center;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--brass);
  border: 1px solid var(--brass-border);
  border-radius: 3px;
  padding: 0.12rem 0.42rem;
  white-space: nowrap;
}

/* The sub-rail between a section head and its ledger (design: recommendations
   refined): the shown run's steering echo on the left, and on the right what
   the row action will actually do — read before the buttons, not after them. */
.rec-section-sub {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.3rem 1.25rem;
  margin-top: 0.6rem;
}

.rec-section-sub .run-steering {
  margin: 0;
}

.rec-section-sub .rec-footnote {
  margin: 0 0 0 auto;
}

/* Full-width ledger rows (design: recommendations refined): rank · cover ·
   title/reason · status · teach · fit, hairlines only — no cards.

   One column definition for the whole section, so the header rail, every row,
   and both folds sit on the same vertical lines. The action column is
   fixed-width, not max-content: each row is its own grid, so a content-sized
   column drifts per row and the teach/fit columns fall off the shared line
   (design 3a). The controls inside flex to fill the slot exactly.

   The bleed is what lets a row's hover tint and its hairline run a little
   wider than the text column, so the row reads as a band rather than as an
   underlined paragraph. */
.rec-section {
  --rec-cols: 1.375rem 5.125rem minmax(0, 1fr) 10.75rem 3.625rem 3.5rem;
  --rec-gap: 1.25rem;
  --rec-bleed: 0.625rem;
}

/* The column header: the ledger's one row of labels, and the rule the rows
   hang from (so .rec-ledger brings no top border of its own). */
.rec-ledger-head {
  display: grid;
  grid-template-columns: var(--rec-cols);
  gap: var(--rec-gap);
  align-items: end;
  margin: 1.25rem calc(-1 * var(--rec-bleed)) 0;
  padding: 0 var(--rec-bleed) 0.5rem;
  border-bottom: 1px solid var(--border-strong);
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* "Game · why your agent picked it" labels the cover and the prose together —
   they are one subject read left to right, not two columns. */
.rec-ledger-head-game {
  grid-column: span 2;
}

.rec-ledger-head-teach {
  text-align: center;
}

.rec-ledger-head-fit {
  text-align: center;
}

.rec-ledger {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Rows past the fifth stay in the document but out of view until the fold in
   the footer rail is opened. The toggle is not their ancestor — it sits down
   there on one line with the pager — so the section binds the two. :has() is
   what keeps this fold CSS-only, i.e. working under the no-eval CSP and with
   htmx absent, exactly as the <details>-wrapped version did. */
.rec-ledger--more {
  display: none;
}

.rec-section:has(.rec-more[open]) .rec-ledger--more {
  display: block;
}

.rec-row {
  display: grid;
  grid-template-columns: var(--rec-cols);
  gap: var(--rec-gap);
  align-items: start;
  margin: 0 calc(-1 * var(--rec-bleed));
  padding: 1.125rem var(--rec-bleed);
  border-bottom: 1px solid var(--border);
  border-radius: 4px;
}

.rec-row:hover {
  background: var(--fill);
}

.rec-rank {
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  color: var(--text-faint);
  padding-top: 0.25rem;
}

/* position: relative for the status strip, which is absolutely positioned
   across the artwork's bottom edge. The ≤720 block sets this too, for the
   layout it rebuilds there; up here it is what keeps the strip inside the
   cover instead of escaping to the nearest positioned ancestor. */
.rec-cover {
  position: relative;
}

.rec-cover .game-cover {
  max-width: none;
  border-radius: 3px;
}

.rec-title-line {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.3rem 0.7rem;
}

.rec-title {
  font-family: var(--font-display);
  font-size: 1.08rem;
  font-weight: 500;
  color: var(--text);
}

.rec-meta {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* A "▲ RATED UP" chip used to sit beside the meta once a pick was voted up.
   It is deleted: the TEACH column's cast thumb is already filled brass and
   aria-pressed on the only width the chip was drawn, so the row answered
   "did I rate this?" twice. Below 720 the answer is the teach receipt under
   the action bar, where the thumbs are folded into the ⋯ sheet and something
   has to state the vote. */

/* The chip line under the title (design: recommendations refined): where a
   catalog pick is playable, or which copy a backlog row means and how far
   through it the user is. Boxed mono, so it reads as data rather than as more
   of the sentence below it. */
.rec-chips {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.375rem;
  margin: 0.5rem 0 0;
}

.rec-chip {
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-muted);
  border: 1px solid var(--border);
  border-radius: 3px;
  padding: 0.125rem 0.375rem;
  white-space: nowrap;
}

/* The platforms folded away: a count, unboxed — it names no platform, so it
   must not look like one. */
.rec-chip-more,
.rec-hours {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

.rec-why {
  margin: 0.55rem 0 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
  max-width: 38rem;
}

/* The row's three trailing columns hang from the title's baseline, not the
   middle of a two-or-three-line reason: with align-items:start each nudges
   itself down to meet the first line instead. */
.rec-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.6rem;
  padding-top: 0.125rem;
}

.rec-teach {
  display: flex;
  justify-content: center;
  padding-top: 0.125rem;
}

/* The FIT column centers (updated spec): the numeral sits centered over the
   full-width track, and the pair rides vertically centered in the same 32px
   band the action and teach controls occupy, instead of hanging from the
   cell's top. Stretch keeps the bar spanning the track while the text
   centers. */
.rec-fit {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: center;
  min-height: 32px;
  padding-top: 0.125rem;
  text-align: center;
}

.rec-actions form {
  margin: 0;
}

/* What is left of the shared quick-add control on this page: the two "which
   copy?" pickers.

   The ledger's own three states are _rec_desktop_action_bar.html's now, so the
   "＋ Add / ♡ Wish" pair and the two `.quick-add-bar` status bars this block
   used to size are gone from these rows with the rules that sized them. But
   the PICKERS still swap into the slot — mark-as-owned's when a game is
   wishlisted more than once, and wishlist-remove's mirror — and they replace
   the whole `quick-add-<game.id>` cluster, at either width, so they need the
   fill and the button geometry the cluster had. */
.rec-actions .quick-add {
  flex: 1 1 0;
  height: 32px;
  min-width: 0;
}

.rec-actions .quick-add .quick-add-form {
  flex: 1 1 0;
  display: flex;
  min-width: 0;
}

.rec-actions .quick-add .quick-add-form button {
  width: 100%;
  height: 32px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  padding-top: 0;
  padding-bottom: 0;
  white-space: nowrap;
}

/* The backlog rows' one-click control fills the same fixed slot the Add/Wish
   pair does (design: recommendations refined draws it across the full action
   column), instead of floating as a small right-aligned pill. */
.rec-actions .start-playing-form {
  flex: 1 1 0;
  display: flex;
  min-width: 0;
}

.rec-actions .start-playing-form button {
  width: 100%;
  height: 32px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  padding-top: 0;
  padding-bottom: 0;
  white-space: nowrap;
}

/* The transient mark-as-owned disambiguation picker (issue #75) holds more
   buttons than the fixed slot fits; it alone may grow and wrap, as on cards. */
.rec-actions .quick-add-picker {
  height: auto;
  flex-wrap: wrap;
  white-space: normal;
}

.rec-actions .quick-add-picker .quick-add-form button {
  height: auto;
  padding-top: 0.32rem;
  padding-bottom: 0.32rem;
}

/* Owned rows also outline the cover in leaf, matching catalog and collection
   cards (design 3a) — and the play-next section deliberately does not, since
   every row there is owned by definition and a marker every row carries marks
   nothing.

   🚨 Keyed off the BADGE, not off a class on the row. The badge is what a bar
   swap re-renders out of band (_rec_cover_badge.html); a class on .rec-cover
   sits outside every swap target this page has, so it stated whatever the row
   was when the PAGE loaded. Adding a game from the ledger left the hairline
   off until a reload, and removing one left it on. Same sibling-combinator
   trick the catalog card's cover overlay uses (issue #689); ≤720 runs the
   unscoped version of this rule, which badges both sections. */
@media (min-width: 721px) {
  .rec-cover:not(.rec-cover--backlog) .rec-badge-slot--owned ~ .game-cover {
    border-color: var(--accent-border-strong);
  }

  .rec-cover--backlog .rec-badge {
    display: none;
  }
}

/* The score is the row's one display numeral; #1 takes the leaf. */
.rec-score {
  font-family: var(--font-display);
  font-size: 1.6rem;
  line-height: 1;
  color: var(--text);
  text-align: center;
  font-variant-numeric: tabular-nums;
}

.rec-score--top {
  color: var(--accent-text);
}

/* The fit bar (design: recommendations refined): the score's spread, drawn
   under its numeral. Two pixels and three-quarters opaque on purpose — a
   column of ten of these has to be readable at a glance without competing
   with the numbers it annotates. The fill's width is set per row inline; #1
   takes the leaf, matching its numeral. */
.rec-fit-bar {
  display: block;
  height: 2px;
  margin-top: 9px;
  background: var(--border);
  border-radius: 2px;
  overflow: hidden;
}

.rec-fit-bar-fill {
  display: block;
  height: 2px;
  background: var(--text);
  opacity: 0.75;
}

.rec-fit-bar-fill--top {
  background: var(--accent);
}

/* On narrow screens the cover goes, actions drop under the title block, and
   the score keeps the top-right corner. (The mobile layer, §2H below, then
   brings the cover back and squares the score chip — these are the rules for
   the band between.) */
@media (max-width: 720px) {
  .rec-row {
    grid-template-columns: 1.6rem minmax(0, 1fr) 2.6rem;
    row-gap: 0.5rem;
  }

  .rec-cover {
    display: none;
  }

  /* The column header labels columns this width does not have. */
  .rec-ledger-head {
    display: none;
  }

  .rec-main {
    grid-row: 1;
    grid-column: 2;
  }

  /* .rec-fit is a positioning wrapper for the desktop grid only; here its
     children take grid slots of their own (the score chip beside the title,
     its explainer as a row underneath), so it steps out of the way. The bar
     goes with it — the 44px chip is the score at this width. */
  .rec-fit {
    display: contents;
  }

  .rec-fit-bar {
    display: none;
  }

  .rec-score-details {
    grid-row: 1;
    grid-column: 3;
  }

  /* Stacked, not side by side — see the §2H note below: sharing a row costs
     the action cluster the width its touch targets need at 320px. */
  .rec-actions {
    grid-row: 2;
    grid-column: 2 / -1;
    justify-content: flex-start;
  }

  .rec-teach {
    grid-row: 3;
    grid-column: 2 / -1;
    justify-content: flex-start;
    align-items: center;
  }
}

/* The ledger's footer rail (design: recommendations refined): the show-all
   fold, the legend for the column the thumbs sit in, and the run pager, on one
   line under the last row. The fold keeps design 2c's promise — one toggle,
   always after the last visible row, in the same slot open or closed — while
   giving the pager a home that isn't a second centred block below it. */
.rec-ledger-foot {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.6rem 1.25rem;
  margin: 0 calc(-1 * var(--rec-bleed));
  padding: 1rem var(--rec-bleed) 0;
}

.rec-more {
  display: inline-flex;
}

.rec-more > summary {
  list-style: none;
  cursor: pointer;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  color: var(--text-muted);
}

.rec-more > summary:hover {
  color: var(--text);
}

.rec-more > summary::-webkit-details-marker {
  display: none;
}

.rec-more-open {
  display: none;
}

.rec-more[open] .rec-more-closed {
  display: none;
}

.rec-more[open] .rec-more-open {
  display: inline;
}

/* What the TEACH column is for, in the ledger's mono aside voice. */
.rec-foot-legend {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The pager holds the rail's right edge, in the same mono register as the
   legend rather than the centred block it is elsewhere on the site. */
.rec-ledger-foot .pagination {
  margin-left: auto;
}

.rec-ledger-foot .pagination ul {
  margin: 0;
  gap: 0.875rem;
  font-size: var(--fs-mono);
  letter-spacing: 0.07em;
  text-transform: uppercase;
}

/* The refined ledger's pager is plain mono text — "← NEWER RUN · RUN 1 OF 5 ·
   OLDER RUN →" — with no boxes (design: recommendations refined). The shared
   .pagination grew bordered mono cells for the catalog's numbered pages
   (design: Catalog Refined) after this override was written to strip only the
   padding; the leaked border then drew the run pager as boxed buttons with
   the text flush against their edges. Strip the whole cell here — but not its
   min-height, which the touch-target block below raises to 44px on coarse
   pointers and this selector would outrank; boxless, the height draws
   nothing anyway. */
.rec-ledger-foot .pagination a {
  padding: 0;
  border: 0;
  border-radius: 0;
}

/* The direction you cannot go stays in place, dimmed, so the rail does not
   reflow as you page through runs. It is aria-hidden in the markup — a
   destination that does not exist is not a link to announce.

   Dimmed means --text-faint and nothing further: the mock draws this at
   #5d584d, which is 2.65:1 on the page ground and fails AA, and axe checks
   contrast on aria-hidden text too because sighted users still read it. One
   step down from the live links' --text-muted is separation enough. */
.pagination-off {
  color: var(--text-faint);
}

/* Mono footnote under the backlog ledger ("Start playing" sets play state). */
.rec-footnote {
  margin: 0.8rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

.start-playing-form {
  margin: 0;
}

/* The shown run's steering label (#662/#664), under the section head when
   paging back to a steered run: mono but never uppercased — it quotes the
   user's own request verbatim — led by a brass "STEERED" tag. */
.run-steering {
  margin: 0.1rem 0 0.4rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-muted);
}

.run-steering-tag {
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--brass);
  border: 1px solid var(--brass-border);
  border-radius: 3px;
  padding: 0.12rem 0.42rem;
  white-space: nowrap;
}

.recs-feedback {
  display: inline-flex;
  gap: 0.25rem;
}

.recs-feedback form {
  display: inline;
  margin: 0;
}

/* Thumbs in the ledger's mono voice: ▲ / ▽ boxes; active up fills brass,
   active down takes the danger tint. */
.rec-thumb,
button.rec-thumb {
  font-family: var(--font-mono);
  padding: 0.28rem 0.5rem;
}

.rec-thumb--up,
button.rec-thumb--up {
  background: var(--brass);
  border-color: var(--brass);
  color: var(--accent-ink);
}

.rec-thumb--up:hover,
button.rec-thumb--up:hover {
  background: var(--brass);
  border-color: var(--brass);
  color: var(--accent-ink);
  opacity: 0.9;
}

.rec-thumb--down,
button.rec-thumb--down {
  background: var(--danger-dim);
  border-color: var(--danger-border);
  color: var(--danger);
}

.rec-thumb--down:hover,
button.rec-thumb--down:hover {
  background: var(--danger-dim);
  border-color: var(--danger-border);
  color: var(--danger);
}

/* ---------------------------------------------------------------------------
 * Connect-an-agent page (agent onboarding): live status strip, the server
 * address in a copy row, then per-client cards. Quiet by default; the accent
 * is reserved for the connected state and the two one-click install buttons.
 * ------------------------------------------------------------------------- */

.connect-status {
  display: flex;
  align-items: baseline;
  gap: 0.55rem;
  margin: 0 0 1.75rem;
  font-size: var(--fs-ui);
  color: var(--text-muted);
}

.connect-status .status-dot {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--border-strong);
  align-self: center;
}

.connect-status--on {
  color: var(--text);
}

.connect-status--on .status-dot {
  background: var(--accent);
}

.connect-endpoint {
  max-width: 44rem;
}

.copy-row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.5rem 0.5rem 0.5rem 0.8rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.copy-row code {
  flex: 1;
  min-width: 0;
  overflow-x: auto;
  white-space: nowrap;
  font-size: var(--fs-ui-sm);
}

.copy-btn--done {
  color: var(--accent-text);
}

.connect-note {
  margin: 0.6rem 0 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.6;
  color: var(--text-muted);
}

.connect-clients-label {
  margin-top: 2rem;
}

.connect-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
  gap: 1rem;
  margin-top: 0.75rem;
}

.client-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.15rem 1.3rem;
}

.client-name {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.1rem;
}

.client-tag {
  padding: 0.1rem 0.5rem;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-text);
}

.client-steps {
  margin: 0.75rem 0 0;
  padding-left: 1.15rem;
  font-size: var(--fs-ui);
  line-height: 1.65;
}

.client-steps li + li {
  margin-top: 0.35rem;
}

.client-card .copy-row {
  margin-top: 0.75rem;
  background: var(--surface-2);
}

.client-action {
  margin: 0.75rem 0 0;
}

.client-note {
  margin: 0.75rem 0 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.6;
  color: var(--text-muted);
}

.client-note--lede {
  font-size: var(--fs-ui);
  color: var(--text);
}

.connect-try {
  margin-top: 2.25rem;
  border-top: 1px solid var(--border);
  padding-top: 1.5rem;
}

.prompt-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  list-style: none;
  margin: 0.75rem 0;
  padding: 0;
}

.prompt-chip,
button.prompt-chip {
  padding: 0.3rem 0.75rem;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  cursor: pointer;
}

.prompt-chip:hover,
button.prompt-chip:hover {
  color: var(--text);
  border-color: var(--accent-text);
}

/* ---------------------------------------------------------------------------
   Collection import (issue #547; designs "Import Collection" / "Import Report")

   Two pages, one vocabulary. The start page is a three-step form beside a
   sticky rail; the report is count tiles + a proportion bar over bucketed row
   lists, then a receipt once committed. Selection state (source cards, mode
   cards, candidate chips) is pure CSS over native inputs via :has(), so both
   pages work identically with JS disabled — app.js only decorates.
--------------------------------------------------------------------------- */

.import-head {
  display: flex;
  align-items: baseline;
  gap: 1rem;
  flex-wrap: wrap;
}

.import-head h1 {
  margin: 0;
}

.import-head-tag {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-lede {
  max-width: 42rem;
  margin: 0.6rem 0 2rem;
  line-height: 1.6;
  color: var(--text-muted);
  text-wrap: pretty;
}

.import-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 18.5rem;
  gap: 2rem;
  align-items: start;
}

.import-form {
  display: flex;
  flex-direction: column;
  gap: 1.7rem;
}

/* -- step headers: mono number + caps title + hairline ------------------- */

.import-step-head {
  display: flex;
  align-items: baseline;
  gap: 0.8rem;
  margin-bottom: 0.85rem;
}

.import-step-head::after {
  content: "";
  flex: 1;
  align-self: center;
  height: 1px;
  background: var(--border);
}

.import-step-no {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.09em;
  color: var(--accent-text);
}

.import-step-title {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* -- step 01: source picker cards ---------------------------------------- */

.import-sources,
.import-modes,
.import-row-choices {
  border: 0;
  padding: 0;
  margin: 0;
  min-width: 0;
}

/* min(15rem, 100%) keeps the 15rem card floor on every screen wide enough to
   honour it, and lets the single column collapse to the container below that —
   a bare minmax(15rem, …) is a hard floor the compact layer cannot go under
   (issue #1032). */
.import-source-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
  gap: 0.6rem;
}

/* The cards, chips and dropzone title are <label>s; the global form rule
   paints those muted with a 1rem bottom margin — reset both here. */
.import-source-card,
.import-mode-card,
.import-choice,
.import-dropzone-title {
  margin-bottom: 0;
  color: var(--text);
}

.import-source-card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
  padding: 0.9rem 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  cursor: pointer;
  transition: border-color var(--speed), background var(--speed);
}

.import-source-card > input {
  position: absolute;
  inset: 0;
  opacity: 0;
  margin: 0;
  cursor: pointer;
}

.import-source-card:hover {
  border-color: var(--border-strong);
  background: var(--surface-2);
}

.import-source-card:has(> input:checked) {
  border-color: var(--accent-border-strong);
  background: var(--accent-dim);
}

.import-source-card:has(> input:focus-visible) {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
}

/* The selected mark, a real element since the check became {% icon %} SVG
   (a pseudo-element can't carry markup); aria-hidden like the ::after's
   empty alt-text was — the radio itself announces the selection. */
.import-source-card .import-source-check {
  position: absolute;
  top: 0.85rem;
  right: 0.95rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--accent-text);
  opacity: 0;
}

.import-source-card:has(> input:checked) .import-source-check {
  opacity: 1;
}

.import-source-name {
  font-size: var(--fs-body);
  font-weight: 600;
  padding-right: 1.2rem;
}

.import-source-hint {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  line-height: 1.5;
  color: var(--text-faint);
}

/* --accent-dim lightens the card behind it, and in the light theme muted/faint
   text lands at ~4.47:1 there — just under AA (the same trap the password-reset
   import hit at 4.34:1). Selected cards read their secondary text in full ink;
   the brightening doubles as a selection cue. */
.import-source-card:has(> input:checked) .import-source-hint,
.import-mode-card:has(> input:checked) .import-mode-desc {
  color: var(--text);
}

/* Per-source how-to panel; the checked radio picks which one renders. */
.import-source-help {
  display: none;
  margin-top: 0.8rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 1rem 1.1rem;
}

.import-form:has(input[name="source"][value="hltb"]:checked) [data-source-help="hltb"],
.import-form:has(input[name="source"][value="grouvee"]:checked) [data-source-help="grouvee"],
.import-form:has(input[name="source"][value="backloggery"]:checked) [data-source-help="backloggery"],
.import-form:has(input[name="source"][value="generic"]:checked) [data-source-help="generic"] {
  display: block;
}

.import-help-kicker {
  margin: 0 0 0.7rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.import-help-kicker--cols {
  margin-top: 1rem;
}

.import-help-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
}

.import-help-step {
  display: flex;
  gap: 0.7rem;
  align-items: baseline;
  font-size: var(--fs-ui);
  line-height: 1.5;
  color: var(--text-muted);
}

.import-help-num {
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

.import-col-map {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.2rem 1.6rem;
}

.import-col {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  padding: 0.3rem 0;
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
}

.import-col-from {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--text-muted);
}

.import-col-arrow {
  color: var(--text-faint);
}

.import-col-to {
  flex: 1;
  min-width: 0;
  color: var(--text);
}

.import-help-foot {
  display: flex;
  align-items: baseline;
  gap: 1rem;
  flex-wrap: wrap;
  margin: 0.9rem 0 0;
  padding-top: 0.8rem;
  border-top: 1px solid var(--border);
  font-size: var(--fs-ui-sm);
}

.import-help-foot a {
  font-weight: 600;
}

.import-help-accepts {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-nudge {
  display: flex;
  align-items: center;
  gap: 0.8rem;
  margin: 0.8rem 0 0;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  padding: 0.75rem 1rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text-muted);
}

.import-nudge-dot {
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--brass);
}

.import-nudge-link {
  margin-left: auto;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  white-space: nowrap;
}

/* A linked Steam account whose last sync couldn't read the library — the one
   nudge state that reports a fault rather than offering an option, so it takes
   a solid tinted border instead of the dashed one. Brass, not rust: --danger is
   reserved for the account page's danger group, and this is fixable, not
   destructive. --brass-text rather than --brass because the nudge is small text
   and the plain brass lands under AA on paper (see the token). */
.import-nudge--warn {
  border-style: solid;
  border-color: var(--brass-border);
  background: var(--brass-dim);
  color: var(--brass-text);
}

/* -- step 02: dropzone ---------------------------------------------------- */

.import-dropzone {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.7rem;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 1.7rem 1.4rem;
  text-align: center;
  transition: border-color var(--speed);
}

.import-dropzone:hover,
.import-dropzone:has(input:focus-visible) {
  border-color: var(--accent-border-strong);
}

.import-dropzone-title {
  font-size: var(--fs-body);
  font-weight: 600;
  cursor: pointer;
}

.import-dropzone input[type="file"] {
  width: 100%;
  max-width: 26rem;
  font-size: var(--fs-field);
  color: var(--text-muted);
}

.import-dropzone input[type="file"]::file-selector-button {
  font: inherit;
  font-weight: 500;
  color: var(--text);
  background: var(--fill);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  padding: 0.4rem 0.9rem;
  margin-right: 0.8rem;
  cursor: pointer;
}

.import-dropzone input[type="file"]::file-selector-button:hover {
  border-color: var(--accent-text);
}

.import-dropzone-hint {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-file-note {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--accent-text);
}

/* -- step 03: dedupe mode cards ------------------------------------------ */

.import-modes {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 0.45rem;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

.import-mode-card {
  position: relative;
  display: flex;
  gap: 0.75rem;
  align-items: flex-start;
  padding: 0.75rem 0.8rem;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background var(--speed);
}

.import-mode-card > input {
  position: absolute;
  inset: 0;
  opacity: 0;
  margin: 0;
  cursor: pointer;
}

.import-mode-card:hover {
  background: var(--row-hover);
}

.import-mode-card:has(> input:checked) {
  background: var(--accent-dim);
}

.import-mode-card:has(> input:focus-visible) {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
}

.import-mode-dot {
  flex: none;
  width: 15px;
  height: 15px;
  margin-top: 0.15rem;
  border: 1px solid var(--border-strong);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.import-mode-card:has(> input:checked) .import-mode-dot {
  border-color: var(--accent-text);
}

.import-mode-dot::after {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
  opacity: 0;
}

.import-mode-card:has(> input:checked) .import-mode-dot::after {
  opacity: 1;
}

.import-mode-label {
  display: block;
  font-size: var(--fs-ui);
  font-weight: 600;
}

/* The restore card (issue #1090): the one mode that writes over a value the
   user has. Brass, not danger — it is a legitimate choice being marked, not a
   mistake — and marked on the card itself so the difference is visible before
   the radio is clicked rather than only in the sentence underneath. */
.import-mode-card--warn:has(> input:checked) {
  background: var(--brass-dim);
  box-shadow: inset 0 0 0 1px var(--brass-border);
}

.import-mode-card--warn:has(> input:checked) .import-mode-dot {
  border-color: var(--brass-text);
}

.import-mode-card--warn:has(> input:checked) .import-mode-dot::after {
  background: var(--brass);
}

.import-mode-tag {
  margin-left: 0.45rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 400;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--brass-text);
}

.import-mode-desc {
  display: block;
  margin-top: 0.2rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text-faint);
}

/* -- action rows (both pages) -------------------------------------------- */

.import-actions {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  flex-wrap: wrap;
  border-top: 1px solid var(--border);
  padding-top: 1.3rem;
}

.import-actions-note {
  margin-left: auto;
  max-width: 20rem;
  text-align: right;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  line-height: 1.7;
  color: var(--text-faint);
}

/* -- restore confirmation (report page, issue #1090) ---------------------- */

/* The consent gate above the commit button. Loud on purpose — it sits between
   the reader and the one write that removes something, so it reads as a panel
   rather than as fine print, and the checkbox is never pre-ticked. */
.import-confirm {
  margin: 1.3rem 0 0;
  padding: 0.9rem 1rem;
  border: 1px solid var(--brass-border);
  border-radius: var(--radius-sm);
  background: var(--brass-dim);
}

.import-confirm-label {
  display: flex;
  gap: 0.65rem;
  align-items: flex-start;
  font-size: var(--fs-ui);
  line-height: 1.5;
  cursor: pointer;
}

.import-confirm-label > input {
  flex: none;
  margin-top: 0.2rem;
}

/* --text-muted, NOT --text-faint: the brass tint lifts the background, and
   faint over it measures 3.51:1 in dark (4.5 is the bar for text this size).
   Composited against the panel, muted holds 4.53–5.06 in both themes. */
.import-confirm-note {
  margin: 0.5rem 0 0 1.9rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.6;
  color: var(--text-muted);
}

/* The same panel in rust, for the second consent (issue #1101): deleting the
   entries a restored backup didn't contain. Brass says "this overwrites";
   rust is reserved for removal, so the two confirmations don't read alike. */
.import-confirm--danger {
  border-color: var(--danger-border);
  background: var(--danger-dim);
}

/* -- batched commit progress (report page, issue #1101) ------------------- */

/* A run too big for one request, writing itself in batches. Sized and placed
   like the receipt it becomes, so the page doesn't jump when it finishes. */
.import-progress {
  margin: 1.6rem 0 0;
  padding: 1.1rem 1.2rem;
  border: 1px solid var(--accent-border);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
  background: var(--surface);
}

.import-progress-head {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1rem;
  align-items: baseline;
  justify-content: space-between;
}

.import-progress-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 500;
  line-height: 1.3;
}

/* The moving number is a display numeral, like every other stat value. */
.import-progress-count {
  font-variant-numeric: tabular-nums;
  color: var(--accent);
}

.import-progress-tag {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-progress-track {
  margin-top: 0.9rem;
  height: 0.45rem;
  border-radius: 999px;
  background: var(--fill);
  overflow: hidden;
}

.import-progress-fill {
  height: 100%;
  border-radius: inherit;
  background: var(--accent);
  /* Each batch lands as a swap, so the width jumps; the ease makes the jump
     read as progress rather than as a re-render. */
  transition: width 200ms ease-out;
}

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

.import-progress-note {
  margin: 0.8rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.6;
  color: var(--text-muted);
}

/* -- extras preview (issue #1101) ----------------------------------------- */

/* The entries a restore didn't claim, listed so the deletion under them is a
   decision about titles rather than about a number. */
.import-extras-drift {
  margin: 0 0 1.2rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.6;
  color: var(--text-muted);
}

.import-extras-list {
  margin: 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid var(--border);
}

.import-extras-row {
  padding: 0.7rem 0.2rem;
  border-bottom: 1px solid var(--border);
}

.import-extras-title {
  font-family: var(--font-display);
  font-size: 1.05rem;
  color: var(--text);
  text-decoration: none;
}

.import-extras-title:hover {
  text-decoration: underline;
}

.import-extras-meta {
  margin: 0.2rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.5;
  color: var(--text-muted);
}

.import-extras-form {
  margin-top: 1.4rem;
}

/* -- discard (report page, issue #894) ------------------------------------ */

/* A quiet disclosure under the review actions: opening it is the confirm
   step, so the summary stays muted and only the button inside reads danger. */
.import-discard {
  margin-top: 1.3rem;
}

.import-discard > summary {
  list-style: none;
  cursor: pointer;
  display: inline-block;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-discard > summary::-webkit-details-marker {
  display: none;
}

/* Open turns the disclosure chevron down in place (was a ▸/▾ content swap
   when the marker was a text glyph; the icon is one chevron rotated). */
.import-discard[open] > summary .icon {
  transform: rotate(90deg);
}

.import-discard > summary:hover {
  color: var(--danger);
}

.import-discard-body {
  margin-top: 0.7rem;
  max-width: 34rem;
  border: 1px solid var(--danger);
  border-radius: var(--radius);
  padding: 0.9rem 1rem;
}

.import-discard-note {
  margin: 0 0 0.8rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

/* -- rail (start page + report done-phase cards) -------------------------- */

.import-rail {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
  position: sticky;
  top: 4.5rem;
}

.import-rail-card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 1.1rem 1.2rem;
}

.import-rail-card--ghost {
  background: transparent;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-faint);
}

.import-rail-card--ghost p {
  margin: 0;
}

.import-rail-kicker {
  margin: 0 0 0.8rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.import-rail-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.8rem;
}

.import-rail-step {
  display: flex;
  gap: 0.7rem;
  align-items: baseline;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

.import-rail-step .import-help-num {
  color: var(--accent-text);
}

.import-last-line {
  margin: 0;
  font-size: var(--fs-ui);
  font-weight: 600;
}

.import-last-stats {
  display: flex;
  gap: 1.5rem;
  margin: 0.8rem 0 0;
}

.import-last-stat dt {
  font-family: var(--font-display);
  font-size: 1.35rem;
  line-height: 1;
  color: var(--text);
}

.import-last-stat dt.import-last-stat-dim {
  color: var(--brass);
}

.import-last-stat dd {
  margin: 0.3rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-last-foot {
  margin: 0.9rem 0 0;
  padding-top: 0.75rem;
  border-top: 1px solid var(--border);
  font-size: var(--fs-ui-sm);
}

.import-last-foot a {
  font-weight: 600;
}

.import-rail-links {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.65rem;
}

.import-rail-link {
  display: flex;
  align-items: baseline;
  gap: 0.8rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text-muted);
}

.import-rail-link > span {
  flex: 1;
}

.import-rail-link a {
  font-weight: 600;
  white-space: nowrap;
}

/* -- report: stepper, tiles, bar ------------------------------------------ */

.import-stepper {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin-bottom: 1.4rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-stepper a {
  color: var(--text-faint);
}

.import-stepper a:hover {
  color: var(--text);
}

.import-stepper-crumb.is-current {
  color: var(--text);
}

.import-stepper-crumb.is-done {
  color: var(--accent-text);
}

.import-tally {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 1.2rem 1.4rem;
  margin-bottom: 1.6rem;
}

.import-tiles {
  display: flex;
  gap: 1.8rem;
  flex-wrap: wrap;
}

.import-tile + .import-tile {
  border-left: 1px solid var(--border);
  padding-left: 1.8rem;
}

.import-tile-value {
  display: block;
  font-family: var(--font-display);
  font-size: 2rem;
  line-height: 1;
  color: var(--text);
}

.import-tile-value--accent {
  color: var(--accent-text);
}

.import-tile-value--brass {
  color: var(--brass);
}

.import-tile-label {
  display: block;
  margin-top: 0.45rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* "N still waiting" under the review tile (issue #921): the one figure a
   decision moves, in the brass the tile's own value carries. */
.import-tile-sub {
  display: block;
  margin-top: 0.2rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  color: var(--brass);
}

/* "N already on your shelf" under the matched tile (issue #1033). Brass reads
   as "this needs you" on the two tiles that carry it; this line is the shape of
   the coming write, not a thing to attend to, so it keeps the tile calm. */
.import-tile-sub--plain {
  color: var(--text-faint);
}

.import-bar {
  display: flex;
  height: 5px;
  border-radius: 3px;
  overflow: hidden;
  background: var(--fill);
  margin-top: 1.1rem;
}

.import-bar-seg--matched {
  background: var(--accent);
}

.import-bar-seg--review {
  background: var(--brass);
}

.import-bar-seg--missing {
  background: var(--border-strong);
}

/* -- report: BYO-LLM hint -------------------------------------------------- */

.import-hint {
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
  border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 0.9rem 1.1rem;
  margin-bottom: 1.4rem;
}

.import-hint p {
  flex: 1;
  min-width: 16rem;
  margin: 0;
  font-size: var(--fs-ui);
  line-height: 1.55;
  color: var(--text-muted);
}

/* -- report: bucketed row lists ------------------------------------------- */

.import-bucket {
  margin-bottom: 1.8rem;
}

.import-bucket-head {
  display: flex;
  align-items: baseline;
  gap: 0.9rem;
  flex-wrap: wrap;
  margin-bottom: 0.7rem;
}

.import-bucket-tag {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  padding: 0.35rem 0.6rem;
}

.import-bucket-tag--review {
  color: var(--brass);
  border-color: var(--brass-border);
}

.import-bucket-tag--matched {
  color: var(--accent-text);
  border-color: var(--accent-border);
}

.import-bucket-sub {
  margin: 0;
  font-size: var(--fs-ui-sm);
  color: var(--text-faint);
}

/* "N of these are already on your shelf…" (issue #1033). The head is a wrapping
   flex row, so this would otherwise trail the sub beside it with a gap in
   between and read as one run-on line; it says what committing does, which
   earns its own row under the tag. */
.import-bucket-sub--split {
  flex-basis: 100%;
}

.import-rows {
  list-style: none;
  margin: 0;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  overflow: hidden;
}

.import-row {
  padding: 0.9rem 1.1rem;
}

.import-row + .import-row {
  border-top: 1px solid var(--border);
}

.import-row--check {
  display: flex;
  gap: 0.9rem;
  align-items: flex-start;
}

.import-include {
  flex: none;
  width: 1rem;
  height: 1rem;
  margin-top: 0.25rem;
  accent-color: var(--accent);
}

.import-row-body {
  flex: 1;
  min-width: 0;
}

.import-row-line {
  margin: 0 0 0.3rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.03em;
  color: var(--text-faint);
  overflow-wrap: anywhere;
}

.import-row-title {
  margin: 0;
  font-size: var(--fs-body);
  font-weight: 600;
}

.import-row-meta {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin: 0.35rem 0 0;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

.import-row-state {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-row-flag {
  margin: 0.4rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.5;
  color: var(--brass);
}

.import-row-choices {
  display: flex;
  gap: 0.45rem;
  flex-wrap: wrap;
  margin-top: 0.6rem;
}

.import-choice {
  position: relative;
  display: inline-flex;
  align-items: center;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  padding: 0.45rem 0.7rem;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  cursor: pointer;
  transition: border-color var(--speed), background var(--speed), color var(--speed);
}

.import-choice > input {
  position: absolute;
  inset: 0;
  opacity: 0;
  margin: 0;
  cursor: pointer;
}

.import-choice:hover {
  border-color: var(--accent-border-strong);
  color: var(--text);
}

.import-choice:has(> input:checked) {
  background: var(--accent-dim);
  border-color: var(--accent-border-strong);
  color: var(--text);
}

.import-choice:has(> input:focus-visible) {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
}

.import-choice--skip:has(> input:checked) {
  background: var(--fill);
  border-color: var(--border-strong);
  color: var(--text);
}

/* The option's two lines (issue #1753). The chip is an inline-flex row whose
   only visible child is this block, so the title and the meta line stack
   inside it without the chip itself changing shape; the chip grows to fit and
   the phone layer's 44px floor is a minimum it only ever exceeds. The meta
   line is the same mono micro-copy register as .import-row-line — platforms,
   publisher, then the tier badge (.flag-badge, the site-wide pill) — but in
   --text-muted rather than --text-faint, because it also has to read on the
   checked chip's --accent-dim tint. */
.import-choice-text {
  display: block;
  min-width: 0;
}

.import-choice-meta {
  display: block;
  margin-top: 0.15rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  color: var(--text-muted);
}

.import-choice-meta .flag-badge {
  margin-left: 0.15rem;
}

.import-row-foot {
  margin: 0.5rem 0 0;
  font-size: var(--fs-ui-sm);
}

/* The sequential decision flow (mobile screens 2F, issue #921) belongs to the
   handheld layer alone: from 721 up the "Need your call" bucket is the surface
   and this doesn't exist. Exactly one of the pair is on screen at any width —
   the same either/or the stats By-platform card uses. */
.import-cards {
  display: none;
}

.import-bucket-foot {
  margin: 0.6rem 0 0;
  font-size: var(--fs-ui-sm);
}

.import-bucket-foot a {
  font-weight: 600;
}

/* -- report: committed receipt -------------------------------------------- */

.import-done {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.import-receipt {
  border: 1px solid var(--accent-border);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 1.4rem 1.6rem;
}

.import-receipt-head {
  display: flex;
  align-items: baseline;
  gap: 0.9rem;
  flex-wrap: wrap;
}

.import-receipt-headline {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 500;
}

.import-receipt-tag {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-text);
}

.import-receipt-lines {
  list-style: none;
  margin: 1rem 0 0;
  padding: 1rem 0 0;
  border-top: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
}

.import-receipt-line {
  display: flex;
  gap: 0.9rem;
  align-items: baseline;
  font-size: var(--fs-ui);
  line-height: 1.55;
  color: var(--text-muted);
}

.import-receipt-num {
  flex: none;
  min-width: 2.6rem;
  text-align: right;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-muted);
}

.import-receipt-num--accent {
  color: var(--accent-text);
}

.import-receipt-num--brass {
  color: var(--brass);
}

.import-receipt-actions {
  display: flex;
  gap: 0.8rem;
  flex-wrap: wrap;
  margin-top: 1.3rem;
}

.import-done-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.9rem;
}

.import-keeps-link {
  margin: 0.4rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  line-height: 1.7;
  color: var(--text-faint);
}

/* The rail's link out to the full history (issue #829): a quiet foot line
   under the cards, not a card of its own — it is a pointer, not a report. */
.import-rail-foot {
  display: grid;
  gap: 0.3rem;
  margin: 0 0 0 0.15rem;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
}

.import-rail-foot-note {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 400;
  letter-spacing: 0.05em;
  color: var(--text-faint);
}

/* --- Import history (issue #829; design "Import History") ------------------
   One narrow column of runs. The four-track grid is declared once here and
   reused by the aria-hidden key strip above the list, so the labels and the
   rows can never drift apart. Each row is a single link — the hover tint and
   the arrow belong to the whole row, not to a nested control.
--------------------------------------------------------------------------- */

.import-hist {
  max-width: 58rem;
}

.import-hist-strip {
  display: flex;
  align-items: flex-end;
  gap: 2rem;
  flex-wrap: wrap;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  padding: 1.15rem 0;
  margin: 2rem 0 1.5rem;
}

.import-hist-stats {
  display: flex;
  gap: 2rem;
  flex-wrap: wrap;
  margin: 0;
}

.import-hist-stat dt {
  font-family: var(--font-display);
  font-size: 1.8rem;
  line-height: 1;
  color: var(--text);
}

.import-hist-stat dt.import-hist-stat--accent {
  color: var(--accent-text);
}

.import-hist-stat dd {
  margin: 0.45rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-hist-filters {
  display: flex;
  gap: 0.4rem;
  flex-wrap: wrap;
  margin-left: auto;
}

.import-hist-filter {
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  padding: 0.5rem 0.8rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  transition: color var(--speed) ease, border-color var(--speed) ease;
}

.import-hist-filter:hover {
  color: var(--text);
  border-color: var(--border-strong);
}

.import-hist-filter.is-on {
  background: var(--fill);
  border-color: var(--border-strong);
  color: var(--text);
}

.import-hist-filter-n {
  color: var(--text-faint);
}

.import-hist-filter.is-on .import-hist-filter-n {
  color: var(--text-muted);
}

/* The one run that still needs a decision, lifted above the list. */
.import-hist-pending {
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
  background: var(--surface);
  border: 1px solid var(--brass-border);
  border-left: 3px solid var(--brass);
  border-radius: var(--radius);
  padding: 1rem 1.1rem;
  margin-bottom: 1.5rem;
}

.import-hist-pending-tag {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--brass-text);
}

.import-hist-pending-line {
  flex: 1;
  min-width: 14rem;
  margin: 0;
  font-size: var(--fs-ui);
  line-height: 1.55;
  color: var(--text-muted);
}

/* The trailing 4rem track is the action gutter (issue #929): a fixed track on
   every row, drawn into only by runs that can still be discarded, so the four
   data columns land in the same place whatever a row's state. The key strip
   carries an empty fifth cell for the same reason — same tracks, same gaps. */
.import-hist-key,
.import-hist-row {
  display: grid;
  grid-template-columns: 7.5rem minmax(0, 1fr) 12.5rem 8rem 4rem;
  gap: 0.75rem;
}

.import-hist-key {
  padding: 0 0.25rem 0.6rem;
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* "Status", not the empty gutter cell that follows it. */
.import-hist-key > :nth-child(4) {
  text-align: right;
}

.import-hist-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Positioned because the row's link stretches over it (see .import-hist-open). */
.import-hist-row {
  position: relative;
  align-items: center;
  padding: 0.95rem 0.25rem;
  border-bottom: 1px solid var(--border);
}

/* The row reads as one target, not a run of prose: it carries the hover tint
   and the arrow as its affordance, so the global link underline would only
   stripe one cell of four (same reasoning as .shelf-row). */
.import-hist-row:hover {
  background: var(--row-hover);
}

.import-hist-when,
.import-hist-what,
.import-hist-result,
.import-hist-status {
  display: grid;
  gap: 0.25rem;
  min-width: 0;
}

.import-hist-date {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text);
}

.import-hist-time,
.import-hist-origin,
.import-hist-result-sub {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

.import-hist-time,
.import-hist-origin {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* "6 left out · 3 not in the catalog" is the one sub-line that legitimately
   runs long — it wraps rather than being clipped to an ellipsis, since the
   clipped half is exactly the part worth reading. */
.import-hist-result-sub {
  line-height: 1.45;
}

.import-hist-what-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
}

/* WEB / AGENT provenance chip. Agent runs take the brass outline — the same
   "an agent did this" signal the rest of the app uses. */
.import-hist-src {
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  padding: 0.15rem 0.4rem;
  border: 1px solid var(--border-strong);
  border-radius: 3px;
  color: var(--text-muted);
}

.import-hist-src--agent {
  border-color: var(--brass-border);
  color: var(--brass-text);
}

.import-hist-name {
  font-size: var(--fs-ui);
  font-weight: 600;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.import-hist-result-line {
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

.import-hist-status {
  justify-items: end;
  text-align: right;
}

.import-hist-state {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.import-hist-state--done {
  color: var(--accent-text);
}

.import-hist-state--review {
  color: var(--brass-text);
}

/* The run's one link — the report cell, stretched back over the whole row by
   its ::after (issue #929, same pattern as .leaving-door-cta). The row used to
   be the anchor itself, which left nowhere to put a form; this keeps the whole
   row clickable while the gutter's controls sit above the stretch. */
.import-hist-open {
  font-size: var(--fs-ui-sm);
  color: var(--text-faint);
}

.import-hist-open::after {
  content: "";
  position: absolute;
  inset: 0;
}

.import-hist-row:hover .import-hist-open {
  color: var(--accent-text);
}

/* -- per-row discard (issue #929) ----------------------------------------- */

/* The gutter's contents sit above the stretched link: without a z-index of
   their own, the ::after would swallow every click meant for them. A grid item
   takes z-index without needing position, which is what we want here — the
   confirm panel below anchors to the *row*, not to this cell. */
.import-hist-act {
  z-index: 1;
  justify-self: end;
}

/* That z-index makes each gutter its own stacking context, so the panel's own
   z-index only orders it *within its row* — a later row's gutter would paint
   its "Discard" over an earlier row's open panel. Lifting the whole row (it is
   already positioned) puts the panel and everything in it above the rows it
   hangs over. [open] is a real attribute state, so this holds without JS. */
.import-hist-row:has(.import-hist-discard[open]) {
  z-index: 2;
}

.import-hist-discard-summary {
  list-style: none;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-hist-discard-summary::-webkit-details-marker {
  display: none;
}

.import-hist-discard-summary:hover,
.import-hist-discard[open] > .import-hist-discard-summary {
  color: var(--danger);
}

/* Anchored to the row's right edge rather than expanding the row: a dense list
   shouldn't reflow under the reader when a confirm opens. The z-index lifts it
   over the *following* rows' stretched links, which are positioned too. */
.import-hist-discard-panel {
  position: absolute;
  z-index: 5;
  top: calc(100% + 0.4rem);
  right: 0;
  width: 21rem;
  max-width: calc(100vw - 2rem);
  border: 1px solid var(--danger);
  border-radius: var(--radius);
  padding: 0.9rem 1rem;
  background: var(--surface);
  box-shadow: var(--shadow-panel);
}

.import-hist-discard-note {
  margin: 0 0 0.8rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

.import-hist-foot {
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
  padding-top: 1.1rem;
}

.import-hist-count {
  flex: 1;
  min-width: 12rem;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.import-hist-pager {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.import-hist-empty {
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  padding: 2.1rem 1.6rem;
  text-align: center;
  margin-top: 1.5rem;
}

.import-hist-empty-head {
  margin: 0;
  font-size: var(--fs-body);
  color: var(--text-muted);
}

.import-hist-empty-sub {
  max-width: 28rem;
  margin: 0.5rem auto 1.1rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.7;
  color: var(--text-faint);
}

.import-hist-notes {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.9rem;
  margin-top: 2.6rem;
}

.import-hist-note {
  margin: 0;
  font-size: var(--fs-ui);
  line-height: 1.65;
  color: var(--text-muted);
}

.import-hist-note-foot {
  margin: 0.75rem 0 0;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
}

@media (max-width: 1024px) {
  /* minmax(0, …), not 1fr: the stacked column must be allowed to go under its
     own min-content, or the widest descendant sets the page width (#1032). */
  .import-layout {
    grid-template-columns: minmax(0, 1fr);
  }

  .import-rail {
    position: static;
  }
}

@media (max-width: 720px) {
  .import-col-map {
    grid-template-columns: minmax(0, 1fr);
  }

  .import-tiles {
    gap: 1.1rem;
  }

  .import-tile + .import-tile {
    border-left: 0;
    padding-left: 0;
  }

  .import-done-grid {
    grid-template-columns: 1fr;
  }

  .import-nudge {
    flex-wrap: wrap;
  }

  .import-nudge-link {
    margin-left: 1.5rem;
  }

  /* The history's five tracks stack: the key strip has nothing left to label,
     so it goes, and each row reads as a small block with the status last and
     any discard control under it. */
  .import-hist-key {
    display: none;
  }

  .import-hist-row {
    grid-template-columns: 1fr;
    gap: 0.5rem;
  }

  .import-hist-status {
    justify-items: start;
    text-align: left;
  }

  /* No gutter to hang off once the row is a column: the trigger joins the
     stack at its left edge, and the confirm expands the row inline rather
     than floating over the row below (nothing is beside it to obscure). The
     cell keeps the full width so the panel does too. */
  .import-hist-act {
    justify-self: stretch;
  }

  .import-hist-discard-panel {
    position: static;
    width: auto;
    max-width: none;
    margin-top: 0.5rem;
    box-shadow: none;
  }

  .import-hist-notes {
    grid-template-columns: 1fr;
  }
}

/* ---------------------------------------------------------------------------
   htmx in-flight indicators (issue #577)

   Vendored copy of the three rules htmx injects at startup — we ship them here
   because base.html sets includeIndicatorStyles:false (htmx's injected <style>
   carries no nonce, so the CSP would block it; see the htmx-config comment in
   base.html and test_csp.py). First user: the Steam-library "Syncing…" line.
--------------------------------------------------------------------------- */

.htmx-indicator {
  opacity: 0;
}

.htmx-request .htmx-indicator {
  opacity: 1;
  transition: opacity 200ms ease-in;
}

.htmx-request.htmx-indicator {
  opacity: 1;
  transition: opacity 200ms ease-in;
}

/* ---------------------------------------------------------------------------
 * API tokens (design "API Tokens", issue #232)
 *
 * The account area's one long-form settings page: a narrow editorial column
 * instead of a card, with hairline section rules doing the work the card
 * border used to. The token list is rows, not a table — name, fingerprint,
 * scopes, and expiry stack into two columns that survive a phone, where the
 * eight-column table wrapped into soup.
 * ------------------------------------------------------------------------- */

.tokens-page {
  max-width: 54rem;
  margin-inline: auto;
}

.tokens-lede {
  max-width: 40rem;
  font-size: var(--fs-body);
  line-height: 1.65;
}

/* Brass-edged aside: the one caveat (claude.ai won't take a pasted token) that
   otherwise costs the user a failed connection attempt. Brass, not leaf — it
   is a warning, and the page's leaf is spoken for by active credentials.

   --surface, not --fill: a translucent ink wash *darkens* paper, which drags
   the brass kicker below 4.5:1 in the light theme (measured 4.3). --surface is
   the raised tone in both themes — indistinguishable from the wash on ink,
   and it lifts the kicker to 5.2:1 on paper. */
.tokens-note {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  max-width: 40rem;
  margin: 0 0 2.75rem;
  padding: 0.8rem 1rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-left: 2px solid var(--brass);
  border-radius: var(--radius-sm);
}

.tokens-note-kicker {
  flex: none;
  padding-top: 0.15rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--brass);
}

/* A <p>, not a <span>: the in-text underline rule (issue #528, WCAG 1.4.1)
   keys off `p a`, and the leaf link against this muted body sits at 1.03:1 —
   far under the 3:1 that would let color alone mark it. */
.tokens-note-body {
  margin: 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.6;
  color: var(--text-muted);
}

/* Section rule: serif heading and an optional mono count sharing one baseline
   over a hairline (spec 05.2's title row, scaled down to a section). */
.tokens-section-head {
  display: flex;
  align-items: baseline;
  gap: 0.9rem;
  margin-bottom: 0.5rem;
  padding-bottom: 0.7rem;
  border-bottom: 1px solid var(--border);
}

.tokens-section-head h2 {
  margin: 0;
}

.tokens-section-note {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent-text);
}

/* --- The token list ------------------------------------------------------ */

.token-list {
  list-style: none;
  margin: 0 0 3rem;
  padding: 0;
}

.token-row {
  display: flex;
  align-items: flex-start;
  gap: 1.25rem;
  padding: 1.1rem 0;
  border-bottom: 1px solid var(--border);
}

.token-main {
  flex: 1;
  min-width: 0;
}

.token-title {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: 0 0 0.35rem;
}

.token-name {
  font-size: var(--fs-body);
  font-weight: 600;
}

.token-state {
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 0.15rem 0.4rem;
  border-radius: 3px;
  background: var(--accent);
  color: var(--accent-ink);
}

/* Revoked and expired rows stay on the page as an audit trail (the credential
   may still sit in an agent's config), so their badge drops the leaf fill —
   nothing that no longer authenticates should read as live. */
.token-state--revoked,
.token-state--expired {
  padding: 0.08rem 0.35rem;
  background: transparent;
  border: 1px solid var(--border-strong);
  color: var(--text-faint);
}

.token-meta {
  margin: 0 0 0.6rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* The prefix is already mono in a mono line; the chip fill would only add
   noise to the fingerprint. */
.token-meta code {
  padding: 0;
  background: none;
  font-size: inherit;
}

.token-scopes {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  margin: 0;
}

.token-scope {
  padding: 0.1rem 0.45rem;
  border: 1px solid var(--border);
  border-radius: 3px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  color: var(--text-muted);
}

.token-side {
  flex: none;
  text-align: right;
}

.token-expiry {
  margin: 0 0 0.6rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.token-side form {
  margin: 0;
}

/* Revoking is destructive, so the ghost button's default leaf hover (which
   reads as an affirmative action everywhere else) is swapped for rust. */
.token-revoke:hover,
button.token-revoke:hover {
  border-color: var(--danger);
  background: var(--danger-dim);
  color: var(--danger);
}

/* The dashed empty box. Shared with the connected-agents page (spec 16 §E2),
   which had a bare sentence floating in an 844px screen — the same absence,
   drawn the same way, on the two pages that sit next to each other in the
   Agents & API group. */
.token-empty,
.settings-empty {
  margin: 0.5rem 0 3rem;
  padding: 1.6rem;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  text-align: center;
  font-size: var(--fs-ui);
  color: var(--text-muted);
}

.token-empty span,
.settings-empty span {
  display: block;
  margin-top: 0.35rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* --- The minted secret, shown once --------------------------------------- */

.new-token {
  margin: 0 0 2.5rem;
  padding: 1.1rem 1.25rem;
  background: var(--surface);
  border: 1px solid var(--accent-border);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
}

.new-token-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.75rem;
  font-size: var(--fs-body);
  font-weight: 600;
}

.new-token-check,
.new-token-head strong {
  color: var(--accent-text);
}

/* The secret drops to the page background inside the raised panel so it reads
   as a field to copy out of rather than as body copy. */
.new-token-secret {
  padding-block: 0.35rem;
  background: var(--bg);
}

/* The one action in the panel, so it takes the neutral fill rather than the
   ghost outline the connect page's copy buttons use. */
.new-token-copy,
button.new-token-copy {
  flex: none;
  background: var(--text);
  border-color: var(--text);
  color: var(--bg);
  font-weight: 600;
}

.new-token-warning {
  margin: 0.65rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.5;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--brass);
}

.new-token-warning::before {
  content: "\26A0";
  margin-right: 0.4rem;
}

/* --- Create-a-token form -------------------------------------------------- */

.tokens-field {
  margin-bottom: 1.6rem;
}

.tokens-field--narrow {
  max-width: 19rem;
}

/* The shared help/error rules pull up (-0.5rem) to tuck under a widget that
   its <label> wraps. Here the label is a sibling, so that offset lands the
   text on top of the field — restore normal flow. */
.tokens-field .help-text,
.tokens-field .field-error {
  margin: 0.45rem 0 0;
}

/* An inline <code> chip raises the surface beneath it, which drops the faint
   help tone to 4.17:1 in dark (WCAG 1.4.3 wants 4.5). The muted tone clears it
   on the chip and is unchanged from faint on paper, where the two converge. */
.tokens-field .help-text code {
  color: var(--text-muted);
}

/* Mono kicker label, sitting beside the widget rather than wrapping it — see
   the template comment on why this page doesn't use _form_field.html. */
.tokens-label {
  display: block;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text);
}

/* Label left, live count right, on one baseline. Applied to a <legend>, whose
   default inline-ish box has to be widened before the flex row can spread. */
.tokens-label--row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  width: 100%;
  padding: 0;
  margin-bottom: 0.55rem;
}

.scope-count {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 400;
  letter-spacing: 0;
  text-transform: none;
  color: var(--text-muted);
}

.tokens-scopes {
  min-width: 0;
  margin: 0 0 1.6rem;
  padding: 0;
  border: 0;
}

.scope-list {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

/* Area heading inside the picker (CATALOG / COLLECTION / …): a band, not a
   row, so the read/write pair below it reads as one decision. */
.scope-group {
  margin: 0;
  padding: 0.45rem 1rem;
  background: var(--fill);
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.scope-row {
  display: flex;
  align-items: flex-start;
  gap: 0.7rem;
  margin: 0;
  padding: 0.7rem 1rem;
  border-bottom: 1px solid var(--border);
  font-size: var(--fs-ui-sm);
  font-weight: 400;
  cursor: pointer;
  transition: background var(--speed) ease;
}

.scope-row:last-child {
  border-bottom: 0;
}

.scope-row:hover {
  background: var(--row-hover);
}

.scope-row input[type="checkbox"] {
  flex: none;
  width: 1rem;
  height: 1rem;
  margin-top: 0.15rem;
}

.scope-text {
  min-width: 0;
}

.scope-name {
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  font-weight: 500;
  color: var(--text);
}

/* Granted scopes turn leaf, so the shape of what this token can do is legible
   without re-reading every description. */
.scope-row:has(input:checked) .scope-name {
  color: var(--accent-text);
}

.scope-desc {
  color: var(--text-muted);
}

.tokens-submit {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem 1.25rem;
  margin-bottom: 2.5rem;
}

.tokens-submit-hint {
  font-size: var(--fs-ui-sm);
  color: var(--text-faint);
}

/* Phones: the credential becomes a record — see the account phone layer
   (spec 16 §E), which folds these rows into the same shape the agents table
   takes, at ≤720 rather than the retired ≤520. */

/* ---------------------------------------------------------------------------
 * Connected accounts (design "Connected Accounts" + "Accounts Restructure" 1a)
 *
 * One editorial column like the API tokens page, split into two groups by what
 * a credential can *do*: Sign-in methods log you in; Game libraries only import
 * games. The libraries group carries the Steam sync controls that used to be a
 * card on the account page — a linked library is a <details> row that expands
 * in place rather than a page of its own, so six providers would need no new
 * routes (the 1a/1b trade-off).
 *
 * Panels use --surface, never --fill: the translucent ink wash *darkens* paper,
 * which drags brass and faint text under AA in the light theme.
 * ------------------------------------------------------------------------- */

.connections-page {
  max-width: 48rem;
  margin-inline: auto;
}

.conn-lede {
  max-width: 38rem;
  font-size: var(--fs-body);
  line-height: 1.65;
}

/* Section rule: serif heading and its mono count sharing one baseline over a
   hairline — the same figure the tokens page cuts. */
.conn-section-head {
  display: flex;
  align-items: baseline;
  gap: 0.9rem;
  margin-bottom: 0.4rem;
  padding-bottom: 0.7rem;
  border-bottom: 1px solid var(--border);
}

.conn-section-head--spaced {
  margin-top: 3rem;
}

.conn-section-head h2 {
  margin: 0;
}

.conn-section-note {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.conn-section-sub {
  margin: 0 0 1rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

/* --- Sign-in method rows ------------------------------------------------- */

.conn-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.conn-row {
  display: flex;
  align-items: flex-start;
  gap: 1rem;
  padding: 1.1rem 0;
  border-bottom: 1px solid var(--border);
}

/* The provider's initial in the serif italic, matching the wordmark's voice —
   a stand-in for logos we have no licence to ship. */
.conn-mark,
.library-mark {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.6rem;
  height: 2.6rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface);
  font-family: var(--font-display);
  font-style: italic;
  font-size: 1.25rem;
  color: var(--text);
}

.conn-mark--sm {
  width: 2.2rem;
  height: 2.2rem;
  font-size: 1.05rem;
  color: var(--text-muted);
}

.conn-ident,
.library-ident {
  flex: 1;
  min-width: 0;
}

.conn-name {
  display: block;
  font-size: var(--fs-body);
  font-weight: 600;
}

.conn-uid {
  display: block;
  margin-top: 0.35rem;
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

.conn-note {
  display: block;
  margin-top: 0.35rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text-faint);
}

/* Brass, not leaf: losing your last sign-in method is the one thing this page
   can do that you cannot undo yourself. */
.conn-warn {
  display: block;
  margin-top: 0.45rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--brass);
}

.conn-action {
  flex: none;
  margin: 0;
  margin-left: auto;
}

/* --- "Add a way to sign in" / "Connect a library" offers ------------------ */

.conn-add-list {
  list-style: none;
  margin: 1rem 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.conn-add {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.9rem 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
}

.conn-disclosure {
  padding: 0 0.25rem;
  /* Same measure as .conn-footnote below: without it the consent copy runs
     wider than the cards around it and reads as escaped from its card. */
  max-width: 36rem;
}

.conn-unavailable {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.conn-footnote {
  margin: 1.25rem 0 2rem;
  max-width: 36rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.6;
  color: var(--text-faint);
}

/* --- A linked library: the row that expands in place --------------------- */

.library-row {
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
}

/* The whole header is the <summary>, so the row opens from anywhere on it and
   stays keyboard-operable with no JS. The default disclosure triangle is
   dropped in favour of the "Options" label the design names. */
.library-head {
  display: flex;
  align-items: flex-start;
  gap: 1rem;
  padding: 1.1rem 1.15rem;
  cursor: pointer;
  list-style: none;
}

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

.library-name {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.6rem;
  font-size: 1rem;
  font-weight: 700;
}

.library-uid {
  display: block;
  margin-top: 0.4rem;
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

.library-sub {
  display: block;
  margin-top: 0.35rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text-faint);
}

.library-status {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.library-status--synced {
  color: var(--accent-text);
}

.library-status--failed {
  color: var(--danger);
}

.library-status--never {
  color: var(--text-faint);
}

.library-toggle {
  flex: none;
  margin-left: auto;
  padding-top: 0.15rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.05em;
  color: var(--text-muted);
}

.library-row[open] .library-toggle-show,
.library-toggle-hide {
  display: none;
}

.library-row[open] .library-toggle-hide {
  display: inline;
}

/* Indented to the identity column, so the panel reads as belonging to the row
   above it rather than to the group. */
.library-panel {
  padding: 0 1.15rem 1.3rem 4.75rem;
  display: flex;
  flex-direction: column;
  gap: 1.1rem;
}

@media (max-width: 520px) {
  .library-panel {
    padding-left: 1.15rem;
  }
}

.sync-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.6rem 1rem;
}

.sync-actions form {
  margin: 0;
}

.sync-when {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  color: var(--text-muted);
}

.sync-options {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
}

.sync-options form {
  margin: 0;
}

.sync-option {
  display: flex;
  align-items: flex-start;
  gap: 0.7rem;
  margin: 0;
  cursor: pointer;
}

.sync-option input[type="checkbox"] {
  flex: none;
  width: 1rem;
  height: 1rem;
  margin-top: 0.15rem;
}

.sync-option-text {
  min-width: 0;
}

.sync-option-title {
  display: block;
  font-size: var(--fs-ui);
  color: var(--text);
}

.sync-option-desc {
  display: block;
  margin-top: 0.2rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text-muted);
}

/* --- The folded last-sync report ----------------------------------------- */

/* The OOB-swap target the "Sync now" response aims at. It exists only to carry
   an id, so it must not be a box: .library-panel is a flex column with a gap,
   and an empty wrapper would otherwise add a phantom gap on rows that have no
   report yet. */
.sync-report-slot {
  display: contents;
}

.sync-report {
  padding-top: 1rem;
  border-top: 1px solid var(--border);
}

.sync-report-head {
  display: flex;
  align-items: baseline;
  gap: 0.7rem;
  cursor: pointer;
  list-style: none;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.sync-report-head::-webkit-details-marker {
  display: none;
}

/* The caret is the only affordance on a mono label, so draw it explicitly. */
.sync-report-head::before {
  content: "\25B8";
  color: var(--text-faint);
}

.sync-report[open] .sync-report-head::before {
  content: "\25BE";
}

.sync-report-counts {
  color: var(--text-faint);
}

.sync-report-body {
  margin-top: 0.75rem;
  padding-left: 0.85rem;
  border-left: 2px solid var(--border-strong);
}

.sync-report-body p {
  margin: 0 0 0.35rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

.sync-report-detail {
  color: var(--text-faint);
}

.library-note {
  margin: 0;
  max-width: 34rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-faint);
}

.library-unlink {
  margin: 0;
}

/* --- Sync status messages (swapped in by HTMX) --------------------------- */

.sync-msg {
  margin: 0 0 0.4rem;
  font-size: var(--fs-ui);
  line-height: 1.55;
}

.sync-msg--ok {
  color: var(--text);
}

.sync-msg--warn {
  color: var(--brass);
}

.sync-msg--error {
  color: var(--danger);
}

.sync-detail {
  margin: 0.4rem 0 0;
  padding-left: 1.1rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.6;
  color: var(--text-muted);
}

/* ---------------------------------------------------------------------------
 * Connected accounts: the in-place flows (design "Connected Accounts")
 *
 * Arming a disconnect, setting a password, and starting a connect all expand
 * the row they belong to rather than navigating away. Each is driven by a query
 * parameter (see ConnectionsView), so the row that owns the state stacks its
 * panel underneath its own header line — hence the --stack rows below.
 * ------------------------------------------------------------------------- */

.conn-row--stack,
.conn-add--stack {
  flex-direction: column;
  align-items: stretch;
  gap: 0.9rem;
}

/* The header line inside a stacking row keeps the original horizontal figure. */
.conn-line {
  display: flex;
  align-items: flex-start;
  gap: 1rem;
}

/* Keep / Disconnect sit together once a disconnect is armed. */
.conn-confirm {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.conn-confirm form {
  margin: 0;
}

/* Armed, the destructive action stops being an outline and commits to fill —
   the visual weight arrives only at the point where the click is real. */
.btn-danger--solid {
  background: var(--danger);
  border-color: var(--danger);
  color: var(--accent-ink);
}

/* The mono "connect →" / "cancel ×" cue the design puts at the row's edge. */
.conn-cue {
  flex: none;
  margin-left: auto;
  padding-block: 0.4rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
}

.conn-cue--open {
  color: var(--text-faint);
}

/* --- "Set a password before disconnecting" -------------------------------- */

/* Brass, and the only brass-edged panel on the page: this is the one moment
   where continuing without reading costs the user their way back in. */
.secure-panel {
  margin-left: 3.6rem;
  padding: 1rem 1.1rem;
  background: var(--surface);
  border: 1px solid var(--brass-border);
  border-left: 2px solid var(--brass);
  border-radius: var(--radius-sm);
}

@media (max-width: 520px) {
  .secure-panel {
    margin-left: 0;
  }
}

.secure-head {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0 0 0.35rem;
  font-size: var(--fs-body);
  font-weight: 600;
  color: var(--text);
}

.secure-dot {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--brass);
}

.secure-body {
  margin: 0;
  max-width: 30rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

.secure-form,
.pw-panel form {
  margin: 0.9rem 0 0;
  max-width: 22rem;
}

.secure-actions,
.pw-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem 0.75rem;
  margin-top: 0.9rem;
}

/* --- The password row's inline set/change form ---------------------------- */

.pw-panel {
  margin-left: 3.6rem;
  padding: 1rem 1.1rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}

@media (max-width: 520px) {
  .pw-panel {
    margin-left: 0;
  }
}

.pw-panel-head {
  margin: 0;
  font-size: var(--fs-body);
  font-weight: 600;
}

.pw-field {
  margin-bottom: 0.7rem;
}

.pw-field input {
  width: 100%;
}

.pw-label {
  display: block;
  margin-bottom: 0.25rem;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

/* Django's password help text is a four-item <ul>; keep it quiet and compact so
   the panel doesn't turn into a wall of rules. */
.pw-help {
  display: block;
  margin: 0.3rem 0 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
}

.pw-help ul {
  margin: 0;
  padding-left: 1rem;
}

/* --- "You'll continue on {provider}" -------------------------------------- */

.connect-intro {
  margin-left: 3.2rem;
  padding: 0.9rem 1rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}

@media (max-width: 520px) {
  .connect-intro {
    margin-left: 0;
  }
}

.connect-intro-body {
  margin: 0;
  max-width: 30rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

.connect-intro-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem 0.75rem;
  margin: 0.8rem 0 0;
}

/* --- Unlinking a library --------------------------------------------------- */

.library-unlink-ask {
  margin: 0 0 0.6rem;
  max-width: 30rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

/* ---------------------------------------------------------------------------
 * Split auth pages (designs "Register" and "Log in"): a two-panel pitch + form
 * layout instead of the narrow .auth-card. The pitch panel sells the shelf —
 * mono kicker, serif headline, a list — and the form panel gets you into it;
 * they split 50/50 over a shared hairline and stack pitch-first on narrow
 * screens.
 *
 * Everything named `auth-*` here is shared by both pages. The page-specific
 * runs follow: `.register-step*`/`.pw-*` for sign-up, `.login-*` for log-in.
 * ------------------------------------------------------------------------- */

/* The form panel is first in source (mobile layer spec §04/§08) so it leads
   when the split stacks; these named areas keep the pitch on the left at
   desktop widths regardless of the DOM order. */
.auth-split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-areas: "pitch panel";
}

.auth-split-pitch {
  grid-area: pitch;
}

.auth-split-panel {
  grid-area: panel;
}

.auth-split-pitch {
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: clamp(1.5rem, 6vh, 4rem) clamp(1.5rem, 4vw, 4rem) clamp(1.5rem, 6vh, 4rem) 0;
  border-right: 1px solid var(--border);
}

.auth-split-panel {
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: clamp(1.5rem, 6vh, 4rem) 0 clamp(1.5rem, 6vh, 4rem) clamp(1.5rem, 4vw, 4rem);
}

.auth-split-panel-inner {
  width: 100%;
  max-width: 26rem;
}

.auth-kicker {
  margin: 0 0 1.1rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent-text);
}

.auth-headline {
  margin: 0 0 1.1rem;
  text-wrap: pretty;
}

.auth-headline em {
  font-style: italic;
  color: var(--accent-text);
}

.auth-lede {
  margin: 0 0 2rem;
  max-width: 27rem;
  line-height: 1.6;
  color: var(--text-muted);
  text-wrap: pretty;
}

.register-steps {
  margin: 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid var(--border-strong);
}

.register-step {
  display: flex;
  gap: 1rem;
  padding: 0.95rem 0;
  border-bottom: 1px solid var(--border);
}

.register-step-num {
  flex: none;
  width: 1.25rem;
  padding-top: 0.2rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  color: var(--text-faint);
}

.register-step-body strong {
  display: block;
  font-size: var(--fs-ui);
  font-weight: 600;
}

.register-step-body span {
  display: block;
  margin-top: 0.2rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

.auth-fineprint {
  margin: 1.6rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.auth-form-title {
  margin: 0 0 1.4rem;
  font-size: 1.9rem;
}

/* The provider button leads the panel (buttons-first include), sized up to
   read as the CTA-weight alternative the design gives it. */
.auth-split-panel .social-login {
  margin: 0 0 1.25rem;
}

.auth-split-panel .social-login form {
  margin: 0 0 1.25rem;
}

.auth-split-panel .social-login button {
  padding-block: 0.75rem;
  font-size: var(--fs-ui);
  font-weight: 600;
  color: var(--text);
}

.auth-split-panel .social-login-divider {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Mono initial roundel on each sign-in provider button — "G" for Google, "T"
   for Twitch (design "Register"); aria-hidden in the template, so it never
   joins the accessible name. */
.social-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.05rem;
  height: 1.05rem;
  border-radius: 50%;
  background: var(--text);
  color: var(--bg);
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  font-weight: 600;
}

.auth-field {
  margin-bottom: 1.15rem;
}

/* Mono kicker label beside (not wrapping) the widget — see the template
   comment on why this page skips _form_field.html. */
.auth-label {
  display: block;
  margin: 0 0 0.45rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.auth-label-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
}

.auth-field input {
  margin-top: 0;
  padding: 0.68rem 0.8rem;
}

/* The shared help/error rules pull up (-0.5rem) to tuck under a widget that
   its <label> wraps; with sibling labels that lands the text on the input —
   restore normal flow (same reset as the API tokens page). */
.auth-field .field-error {
  display: block;
  margin: 0.45rem 0 0;
}

/* Show/Hide: a bare text control, stripped of the button chrome. The padding
   + negative margin keep the hit area at target size (WCAG 2.5.8) without
   disturbing the label row's baseline. */
.pw-reveal {
  padding: 0.4rem 0.2rem;
  margin: -0.4rem -0.2rem;
  border: 0;
  background: none;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.pw-reveal:hover {
  background: none;
  border-color: transparent;
  color: var(--text);
}

.pw-meter {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-top: 0.5rem;
}

.pw-bars {
  display: flex;
  flex: 1;
  gap: 4px;
}

.pw-bar {
  flex: 1;
  height: 3px;
  border-radius: 2px;
  background: var(--border);
}

/* 1–2 rules met light warm (brass), 3–4 light strong (leaf); the level word
   beside the bars carries the same information without the hue. */
.pw-meter[data-level="warm"] .pw-bar.is-on {
  background: var(--brass);
}

.pw-meter[data-level="strong"] .pw-bar.is-on {
  background: var(--accent);
}

.pw-strength {
  min-width: 4rem;
  text-align: right;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.pw-meter[data-level="warm"] .pw-strength {
  color: var(--brass);
}

.pw-meter[data-level="strong"] .pw-strength {
  color: var(--accent-text);
}

/* The rule checklist doubles as password1's help text node (issue #527). */
.pw-rules {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.25rem 0.9rem;
  margin: 0.55rem 0 0;
  padding: 0;
  list-style: none;
}

.pw-rule {
  display: flex;
  align-items: baseline;
  gap: 0.45rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.4;
  color: var(--text-faint);
}

.pw-rule-mark {
  flex: none;
  width: 0.6rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
}

.pw-rule.is-met {
  color: var(--accent-text);
}

/* The mark ships both states — the placeholder dot and the {% icon %} check —
   and is-met picks one, so the JS never writes markup into the span. */
.pw-rule .pw-rule-mark .icon,
.pw-rule.is-met .pw-rule-dot {
  display: none;
}

.pw-rule.is-met .pw-rule-mark .icon {
  display: inline-block;
}

.pw-match {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--brass);
}

.pw-match.is-matched {
  color: var(--accent-text);
}

/* Its leading check is server-rendered too, revealed only once the confirm
   field matches. */
.pw-match .pw-match-check {
  display: none;
}

.pw-match.is-matched .pw-match-check {
  display: inline;
}

.auth-submit {
  width: 100%;
  margin-top: 0.35rem;
  padding-block: 0.8rem;
  font-size: var(--fs-ui);
}

/* Everything reads good client-side: the CTA turns leaf. Advisory only — the
   button submits either way and the server stays the validator. */
.auth-submit.is-ready {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

.auth-submit.is-ready:hover {
  background: var(--accent-strong);
  border-color: var(--accent-strong);
  color: var(--accent-ink);
  opacity: 1;
}

.register-terms {
  margin: 1rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.8;
  color: var(--text-faint);
}

/* Ruled fact list, shared by the pitch panels that state rather than pitch
   (log-in's catalog counts, password reset's three promises). Same ruled
   rhythm as .register-steps, but a value/description pair instead of a step
   number. */

.auth-facts {
  margin: 0;
  border-top: 1px solid var(--border-strong);
}

.auth-fact {
  display: flex;
  align-items: baseline;
  gap: 1rem;
  padding: 0.9rem 0;
  border-bottom: 1px solid var(--border);
}

/* Newsreader, like every other display-scale value on the site: the home
   page's .stat-value quotes these exact counts (same view, same numbers), and
   .import-tile-value / .import-last-stat dt / .import-hist-stat dt / .rec-score
   all render their value in the display face with a mono caption beneath. This
   rule was the site's only display-scale numeral set in mono, so log-in's
   "73,546" and home's "73,546" were the same number in two typefaces. */
.auth-fact dt {
  flex: none;
  min-width: 5.5rem;
  font-family: var(--font-display);
  font-size: 1.3rem;
  line-height: 1.2;
  color: var(--text);
}

/* The MCP row's value is a word, not a count — the accent marks it as the
   one fact that is a capability rather than a quantity. */
.auth-fact dt.is-accent {
  font-size: 1.15rem;
  color: var(--accent-text);
}

.auth-fact dd {
  margin: 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

/* "Keep me signed in" and the reset link share one row and one baseline. */
.login-options {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.5rem 1rem;
  margin-bottom: 1.15rem;
}

.login-options .check {
  color: var(--text-muted);
}

.login-options a {
  font-size: var(--fs-ui-sm);
}

/* The "no password handy" one-time-link panel (issue #819). Dashed, quieter
   than everything above it: an aside to the form, not a rival CTA. Renders
   only when LOGIN_LINK_ENABLED is on. */
.login-magic {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.6rem 0.75rem;
  margin-top: 1.15rem;
  padding: 0.8rem 0.85rem;
  border: 1px dashed var(--border-strong);
  border-radius: 4px;
}

/* The pitch line owns the first row; the address and its action share the
   second, so the panel reads as one aside rather than three stacked controls. */
.login-magic-copy {
  flex: 1 1 100%;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.7;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Typed only: `{% csrf_token %}` puts a hidden input in this form too, and a
   bare `input` selector would flex-size that as well. */
.login-magic input[type="email"] {
  flex: 1 1 8rem;
  min-width: 0;
  margin: 0;
  padding: 0.5rem 0.7rem;
  font-size: var(--fs-field);
}

/* A text control, like .pw-reveal — the leaf colour is the emphasis, not a
   filled button that would compete with "Log in". Carries a descendant's extra
   weight on purpose: this is a submit, and button[type="submit"]'s primary fill
   out-specifies a lone class (same trap as .platform-remove above). */
.login-magic .login-magic-submit {
  flex: none;
  padding: 0.45rem 0.3rem;
  border: 0;
  background: none;
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  color: var(--accent-text);
  white-space: nowrap;
}

.login-magic .login-magic-submit:hover {
  background: none;
  border-color: transparent;
  color: var(--accent-strong);
  opacity: 1;
}

/* Register / reactivate links below the CTA — hidden entirely in prelaunch
   mode, so this run only ever renders post-launch. */
.login-alt {
  margin: 1.15rem 0 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.7;
  color: var(--text-muted);
}

.login-alt p {
  margin: 0;
}

/* --- Password reset (design "Password Reset") -----------------------------
   Four Django views, one page: the split shell above, a progress rail at the
   top of the form panel, and a step under it. Everything here is `reset-*`;
   the password meter, rules and Show/Hide are the shared `pw-*` run sign-up
   already defines. */

/* The pitch panel's facts are short label-caps ("1 EMAIL", "SAFE"), not the
   counts log-in shows, so they read at label size. Both rules have to restate
   the accent variant: `.auth-fact dt.is-accent` outweighs a bare descendant.

   The mono is restated on purpose, not inherited: these are label-caps, and
   letter-spaced uppercase at label size is the mono voice everywhere else on
   the site. `.auth-fact dt` above is the display face because it carries a
   count; nothing here is one. */
.reset-facts dt,
.reset-facts dt.is-accent {
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* 01 REQUEST ——— 02 EMAIL ——— 03 NEW PASSWORD. Each step but the last grows
   to push a hairline into the space after its label. */
.reset-rail {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0 0 1.6rem;
  padding: 0;
  list-style: none;
}

.reset-rail-step {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.reset-rail-step:not(:last-child) {
  flex: 1;
}

.reset-rail-step:not(:last-child)::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border);
}

/* Steps behind you are leaf, the one you are on is full-strength text, the
   rest stay faint — and aria-current plus the off-screen "(done)" carry the
   same three states without the colour. */
.reset-rail-step.is-done {
  color: var(--accent-text);
}

.reset-rail-step.is-current {
  color: var(--text);
}

.reset-lede {
  margin: 0 0 1.6rem;
  line-height: 1.6;
  color: var(--text-muted);
  text-wrap: pretty;
}

/* "✓ Link sent" / "✓ Password updated": a state stamp above the step title.
   The label takes the *strong* accent, not the usual --accent-text: the leaf
   tint underneath it lifts the background enough that the ordinary token lands
   at 4.34:1 on paper, under the 4.5:1 this size needs (caught by the axe scan
   on the sent step). Darkening the text clears it and leaves dark alone. */
.reset-badge {
  display: inline-block;
  margin: 0 0 1rem;
  padding: 0.3rem 0.6rem;
  border: 1px solid var(--accent-border);
  border-radius: var(--radius-sm);
  background: var(--accent-dim);
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-text-strong);
}

/* Addresses set in mono so they can be read character by character — which is
   the whole reason the page repeats them back. */
.reset-address {
  font-family: var(--font-mono);
  font-size: 0.88em;
  color: var(--text);
}

.reset-hints {
  margin: 0 0 1.5rem;
  padding: 0;
  list-style: none;
  border-top: 1px solid var(--border-strong);
}

.reset-hint {
  display: flex;
  gap: 0.9rem;
  padding: 0.8rem 0;
  border-bottom: 1px solid var(--border);
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-muted);
}

.reset-hint-num {
  flex: none;
  width: 1.25rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  color: var(--text-faint);
}

.reset-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.9rem;
}

.reset-actions form {
  margin: 0;
}

.reset-actions a {
  font-size: var(--fs-ui-sm);
}

.reset-fineprint {
  margin: 0.9rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.reset-or {
  margin: 1.6rem 0;
}

/* The "no password to reset" way out: dashed, so it reads as an aside to the
   form rather than a second form. */
.reset-social {
  padding: 0.9rem 1rem;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-sm);
}

.reset-social-note {
  margin: 0 0 0.75rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.7;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.reset-social .social-login,
.reset-social .social-login form:last-child {
  margin: 0;
}

/* A CTA that is a link, not a submit (steps 3-invalid and 4): the same block
   shape, minus the ready/busy states a form would drive. */
a.auth-submit {
  width: 100%;
}

@media (max-width: 1024px) {
  /* Stacked: the form leads, the pitch (and its live catalog counts) follows
     under the divider (mobile layer spec §08 — someone here has already
     decided). */
  .auth-split {
    grid-template-columns: 1fr;
    grid-template-areas:
      "panel"
      "pitch";
  }

  .auth-split-pitch {
    padding: 1.75rem 0 0;
    margin-top: 1.75rem;
    border-right: 0;
    border-top: 1px solid var(--border);
  }

  .auth-split-panel {
    padding: 0;
  }
}

/* Three labels plus their connecting rules stop fitting well before the panel
   does; drop the rules and let the rail wrap into a plain step list. */
@media (max-width: 520px) {
  .reset-rail {
    flex-wrap: wrap;
    gap: 0.35rem 0.9rem;
  }

  .reset-rail-step:not(:last-child) {
    flex: none;
  }

  .reset-rail-step:not(:last-child)::after {
    content: none;
  }
}

/* ---------------------------------------------------------------------------
 * Leaving collected.games (design "Leaving Collected")
 *
 * The chooser page (facts strip + two door cards), the pause / delete
 * confirmation pages, and the logged-out receipt screens. Rust is the delete
 * door's color the same way it is the account page's danger group.
 * ------------------------------------------------------------------------- */

.leaving-page {
  max-width: 52.5rem;
  margin: 0 auto;
}

.leaving-page--narrow {
  max-width: 37.5rem;
}

.leaving-back {
  /* .back-link carries its own bottom margin. */
  margin: 0;
}

.leaving-head {
  display: flex;
  align-items: baseline;
  gap: 1rem;
  flex-wrap: wrap;
}

.leaving-head h1,
.leaving-title {
  margin: 0;
}

.leaving-head-tag {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.leaving-lede {
  max-width: 39rem;
  margin: 0.7rem 0 1.9rem;
  font-size: var(--fs-body);
  line-height: 1.6;
  color: var(--text-muted);
  text-wrap: pretty;
}

/* Facts strip: mono kicker with a trailing hairline, then a 1px-gap grid of
   cells — the gap paints the grid's inner borders (design "Leaving"). */
.leaving-kicker {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin: 0 0 0.6rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.leaving-kicker::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border);
}

.leaving-facts {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 1px;
  margin: 0 0 2.2rem;
  background: var(--border);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}

.leaving-fact {
  /* dt (label) before dd (value) in the markup for screen readers; the value
     reads first visually. */
  display: flex;
  flex-direction: column-reverse;
  gap: 0.45rem;
  margin: 0;
  padding: 1rem 1.1rem;
  background: var(--surface);
}

.leaving-fact dt {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.leaving-fact dd {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.55rem;
  font-weight: 500;
  line-height: 1.1;
  color: var(--text);
}

/* The two doors. Each card's CTA is the real link, stretched over the whole
   card by its ::after; prose links inside the card sit above the stretch. */
.leaving-doors {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  align-items: stretch;
}

.leaving-door {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
  padding: 1.35rem;
  border: 1px solid var(--border-strong);
  border-radius: 10px;
  background: var(--surface);
  transition: border-color var(--speed) ease;
}

.leaving-door--delete {
  border-color: var(--danger-border);
}

.leaving-door--pause:hover {
  border-color: var(--text-faint);
}

.leaving-door--delete:hover {
  border-color: var(--danger);
}

.leaving-door-head {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
}

.leaving-door-flag {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent-text);
}

.leaving-door-flag--danger {
  color: var(--danger);
}

.leaving-door-note {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-align: right;
  color: var(--text-faint);
}

.leaving-door-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.35rem;
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--text);
}

.leaving-door-sub {
  margin: 0.35rem 0 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.6;
  color: var(--text-muted);
}

.leaving-door-rows {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
}

.leaving-door-rows li {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text-muted);
}

.leaving-door-rows a {
  position: relative;
  z-index: 1;
}

.leaving-verb {
  flex-shrink: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--accent-text);
}

.leaving-verb--danger {
  color: var(--danger);
}

.leaving-door-cta {
  align-self: flex-start;
  margin-top: auto;
}

.leaving-door-cta::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 10px;
}

.leaving-alt {
  max-width: 40rem;
  margin: 1.5rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.8;
  color: var(--text-faint);
}

/* Pause confirmation: the two-row while-paused / coming-back card. */
.leaving-info {
  margin: 0 0 1.6rem;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
}

.leaving-info-row {
  display: flex;
  gap: 1rem;
  padding: 0.95rem 1.1rem;
}

.leaving-info-row + .leaving-info-row {
  border-top: 1px solid var(--border);
}

.leaving-info-row dt {
  flex-shrink: 0;
  width: 7.5rem;
  padding-top: 0.15rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.leaving-info-row dd {
  margin: 0;
  font-size: var(--fs-ui);
  line-height: 1.6;
  color: var(--text-muted);
}

.leaving-label {
  display: block;
  margin: 0 0 0.45rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.leaving-form input:not([type="hidden"]) {
  max-width: 21rem;
  margin-top: 0;
}

.leaving-actions {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin-top: 1.6rem;
}

.leaving-cancel {
  font-size: var(--fs-ui);
  font-weight: 500;
  color: var(--text-muted);
}

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

/* Delete confirmation: three numbered steps. */
.leaving-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 1.1rem;
}

.leaving-step {
  display: flex;
  gap: 0.9rem;
}

.leaving-step-num {
  flex-shrink: 0;
  width: 1.3rem;
  padding-top: 1.05rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

.leaving-step-card {
  flex: 1;
  min-width: 0;
  padding: 1.05rem 1.1rem;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
}

.leaving-step-card--danger {
  border-color: var(--danger-border);
  background: var(--danger-dim);
}

.leaving-step-title {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--fs-body);
  font-weight: 600;
  color: var(--text);
}

.leaving-step-sub {
  margin: 0.3rem 0 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.6;
  color: var(--text-muted);
}

.leaving-step-card input:not([type="hidden"]) {
  margin-top: 0.75rem;
}

.leaving-step-split {
  display: flex;
  align-items: flex-start;
  gap: 1rem;
}

.leaving-step-split .btn {
  flex-shrink: 0;
}

.leaving-email {
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  color: var(--text);
}

.leaving-phrase {
  font-family: var(--font-mono);
  font-size: var(--fs-ui-sm);
  padding: 0.1rem 0.35rem;
  border-radius: 4px;
  /* Solid surface, not the translucent --chip: on the danger-tinted step
     card the overlay muddied to ~4.3:1 against the rust text in light —
     the same axe trap as leaf-on-accent-dim (password reset import). */
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--danger);
}

/* Live phrase feedback (app.js): leaf border once the phrase matches, rust
   while a non-empty attempt doesn't. Advisory — the server re-validates. */
.leaving-form input[name="confirm_phrase"] {
  font-family: var(--font-mono);
  font-size: var(--fs-field);
}

.leaving-form input.is-phrase-match {
  border-color: var(--accent-text);
}

.leaving-form input.is-phrase-mismatch {
  border-color: var(--danger);
}

/* The delete CTA rests muted and arms rust-tinted once phrase + password look
   plausible client-side. Never disabled: without JS it stays muted but live,
   and the server remains the only validator. `button.` wins the specificity
   race against the global button[type="submit"] fill, like btn-ghost. */
.leaving-delete-submit,
button.leaving-delete-submit {
  background: transparent;
  border-color: var(--border-strong);
  color: var(--text-faint);
  font-weight: 600;
}

.leaving-delete-submit:hover,
button.leaving-delete-submit:hover {
  background: var(--fill);
  border-color: var(--border-strong);
  color: var(--text-muted);
  opacity: 1;
}

.leaving-delete-submit.is-ready,
button.leaving-delete-submit.is-ready {
  background: var(--danger-dim);
  border-color: var(--danger);
  color: var(--danger);
}

.leaving-grace-note {
  max-width: 33rem;
  margin: 1.2rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  line-height: 1.8;
  color: var(--text-faint);
}

/* Logged-out receipt screens (paused / deletion scheduled). */
.leaving-done {
  max-width: 35rem;
  margin: clamp(2rem, 8vh, 4rem) auto 0;
  text-align: center;
}

.leaving-done-kicker {
  margin: 0 0 0.9rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent-text);
}

.leaving-done-kicker--danger {
  color: var(--danger);
}

.leaving-done-title {
  margin: 0 0 0.9rem;
}

.leaving-done-copy {
  margin: 0 0 1.6rem;
  font-size: var(--fs-body);
  line-height: 1.65;
  color: var(--text-muted);
  text-wrap: pretty;
}

.leaving-receipt {
  margin: 0 0 1.6rem;
  padding: 0.9rem 1.1rem;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  text-align: left;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.leaving-receipt-value {
  color: var(--text);
  text-transform: none;
}

@media (max-width: 1024px) {
  .leaving-doors {
    grid-template-columns: 1fr;
  }

  .leaving-facts {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (max-width: 520px) {
  .leaving-step-split {
    flex-direction: column;
  }
}

/* ---------------------------------------------------------------------------
 * Email settings (issue #838; design "Email Preferences Final")
 *
 * One card owning outbound mail: header (address + optional-messages tally),
 * a mono column key, optional groups with save-on-change switches, and the
 * required transactional block. Side column + rows echo the design's ledger
 * grid; every mono label is uppercased here so the markup stays readable to
 * screen readers in sentence case.
 * ------------------------------------------------------------------------- */

.email-page {
  max-width: 64rem;
  margin: 0 auto;
}

.email-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: clamp(1.25rem, 3vw, 2.25rem);
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

.email-head {
  display: flex;
  align-items: flex-end;
  gap: 1.5rem;
  flex-wrap: wrap;
  padding-bottom: 1.4rem;
  border-bottom: 1px solid var(--border);
}

/* min(18rem, 100%) so the "keep the heading block at least 18rem before the
   status column wraps" intent survives a screen narrower than 18rem — a bare
   min-width: 18rem is a floor the card cannot honour at 320 and it pushed the
   whole document 5px past the viewport (issue #1032). */
.email-head-main {
  flex: 1;
  min-width: min(18rem, 100%);
}

.email-head h1 {
  margin: 0 0 0.55rem;
  font-size: 2rem;
}

.email-address-line {
  margin: 0;
  font-size: var(--fs-body);
  color: var(--text-muted);
}

.email-address {
  font-family: var(--font-mono);
  font-size: var(--fs-ui);
  color: var(--text);
  overflow-wrap: anywhere;
}

.email-head-meta {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.4rem;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.07em;
  text-transform: uppercase;
}

.email-summary {
  color: var(--text-muted);
}

.email-summary-sub {
  color: var(--text-faint);
}

/* Deliverability state (issue #857): a dot plus its label in the card header,
   and — for anything other than "delivering normally" — a banner under it.
   The dot is aria-hidden decoration; the label always spells the state out, so
   the colour is reinforcement rather than the message. Level names are
   severity, not hue, so a state can change colour without renaming anything:
   ok = leaf, warn = brass (marketing stopped), bad = rust (nothing arrives). */
.email-deliver {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
}

.email-deliver-dot {
  width: 0.4rem;
  height: 0.4rem;
  border-radius: 50%;
  background: currentcolor;
}

.email-deliver--ok {
  color: var(--accent-text);
}

.email-deliver--warn {
  color: var(--brass-text);
}

.email-deliver--bad {
  color: var(--danger);
}

.email-banner {
  display: flex;
  align-items: flex-start;
  gap: 1rem;
  flex-wrap: wrap;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: 0.9rem 1.1rem;
}

.email-banner--warn {
  border-color: var(--brass-border);
  background: var(--brass-dim);
}

.email-banner--bad {
  border-color: var(--danger-border);
  background: var(--danger-dim);
}

.email-banner-text {
  flex: 1;
  min-width: 14rem;
}

.email-banner-body {
  margin: 0;
  font-size: var(--fs-ui);
  line-height: 1.6;
  color: var(--text-muted);
  text-wrap: pretty;
}

/* --text-muted, not --text-faint: the banner's tinted background lifts the
   effective floor, and faint mono at 0.66rem fails AA over brass-dim on ink
   (caught by the axe scan, not by eye). */
.email-banner-when {
  margin: 0.4rem 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* button.<class>, like .email-allbtn: a bare class loses to the primary
   button[type="submit"] fill. */
button.email-testbtn {
  font-family: var(--font-body);
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  color: var(--text);
  background: none;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: 0.45rem 0.85rem;
  cursor: pointer;
  white-space: nowrap;
}

button.email-testbtn:hover {
  background: var(--row-hover);
  border-color: var(--text-faint);
}

/* Visual key over the rows (aria-hidden): mirrors each row's grid. */
.email-colkey {
  display: flex;
  gap: 2rem;
  padding-bottom: 0.55rem;
  border-bottom: 1px solid var(--border-strong);
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.email-colkey-group {
  width: 12rem;
  flex-shrink: 0;
}

.email-colkey-message {
  flex: 1;
  min-width: 0;
}

.email-colkey-when {
  width: 7.5rem;
  flex-shrink: 0;
}

.email-colkey-sending {
  width: 4rem;
  flex-shrink: 0;
  text-align: right;
}

/* One group: side column (kicker, title, blurb, tally) beside its rows. */
.email-group {
  display: flex;
  gap: 2rem;
  padding: 0.4rem 0 1.4rem;
  border-bottom: 1px solid var(--border);
}

.email-group--required {
  border-bottom: 0;
  padding-bottom: 0;
}

.email-side {
  width: 12rem;
  flex-shrink: 0;
  padding-top: 0.85rem;
}

.email-kicker {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.email-side-title {
  margin: 0.55rem 0 0;
  font-family: var(--font-body);
  font-size: var(--fs-body);
  font-weight: 700;
  line-height: 1.35;
  color: var(--text);
}

.email-side-blurb,
.email-side-foot {
  margin: 0.5rem 0 0;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-faint);
  text-wrap: pretty;
}

.email-side-foot {
  margin-top: 0.9rem;
  padding-top: 0.85rem;
  border-top: 1px solid var(--border);
}

.email-side-state {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.5rem;
  margin-top: 0.9rem;
  padding-top: 0.85rem;
  border-top: 1px solid var(--border);
}

.email-side-count {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.email-side-count--all {
  color: var(--accent-text);
}

.email-side-count--some {
  color: var(--brass-text);
}

.email-side-count--none {
  color: var(--text-faint);
}

/* A spam complaint cleared this group, not the user (issue #857): brass, the
   same warning weight the header dot uses for the same fact. */
.email-side-count--blocked {
  color: var(--brass-text);
}

/* button.<class> like button.btn-ghost below the base rules: the primary
   button[type="submit"] text-fill would out-specify a bare class. */
button.email-allbtn {
  font-family: var(--font-body);
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  color: var(--text);
  background: none;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: 0.42rem 0.8rem;
  cursor: pointer;
  transition: background var(--speed) ease;
}

button.email-allbtn:hover {
  background: var(--fill);
  border-color: var(--border-strong);
  color: var(--text);
}

.email-rows {
  flex: 1;
  min-width: 0;
  list-style: none;
  margin: 0;
  padding: 0;
}

.email-row {
  display: flex;
  align-items: center;
  gap: 1.1rem;
  padding: 0.75rem 0;
  border-bottom: 1px solid var(--border);
}

.email-rows .email-row:last-child {
  border-bottom: 0;
}

.email-row-text {
  flex: 1;
  min-width: 0;
}

.email-row-name {
  display: block;
  font-size: var(--fs-ui);
  font-weight: 500;
  color: var(--text);
}

.email-row-name--off {
  color: var(--text-faint);
}

.email-row-detail {
  display: block;
  margin-top: 0.25rem;
  font-size: var(--fs-ui-sm);
  line-height: 1.55;
  color: var(--text-faint);
  text-wrap: pretty;
}

.email-row-when {
  width: 7.5rem;
  flex-shrink: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The switch: a real submit button with role="switch"; the whole card
   re-renders on flip, so state always comes from the server. */
.email-switch-form {
  width: 4rem;
  flex-shrink: 0;
  display: flex;
  justify-content: flex-end;
  margin: 0;
}

button.email-switch {
  position: relative;
  width: 44px;
  height: 25px;
  flex-shrink: 0;
  border-radius: 13px;
  border: 1px solid var(--border-strong);
  background: var(--fill);
  padding: 0;
  cursor: pointer;
  transition: background var(--speed) ease, border-color var(--speed) ease;
}

button.email-switch:hover {
  background: var(--fill);
  border-color: var(--border-strong);
}

button.email-switch[aria-checked="true"],
button.email-switch[aria-checked="true"]:hover {
  background: var(--accent);
  border-color: var(--accent);
}

.email-knob {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 17px;
  height: 17px;
  border-radius: 50%;
  background: var(--text-faint);
}

.email-switch[aria-checked="true"] .email-knob {
  left: 22px;
  background: var(--accent-ink);
}

@media (prefers-reduced-motion: no-preference) {
  .email-knob {
    transition: left var(--speed) ease, background var(--speed) ease;
  }
}

/* Required rows' right column: ALWAYS over the one-word reason. */
.email-always {
  width: 4rem;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.15rem;
  font-family: var(--font-mono);
  text-transform: uppercase;
}

.email-always-tag {
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  color: var(--text-muted);
}

.email-always-why {
  font-size: var(--fs-mono);
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

@media (max-width: 1024px) {
  .email-colkey {
    display: none;
  }

  /* Once the header wraps, the meta block reads as a second line, not a
     right-hand column. */
  .email-head-meta {
    align-items: flex-start;
  }

  .email-group {
    flex-direction: column;
    gap: 0.75rem;
  }

  /* Stacking the group erases the cue that told row and group hairlines
     apart: on desktop the row rules span only .email-rows beside the 12rem
     side column while the head/group rules run card-wide. Stacked, all of
     them render full-width in the same var(--border), and a group boundary
     reads as a mis-spaced row (#1361). So on phone the section rules — the
     head's and each group's — step up to the strong tier instead, the same
     two-strength ladder used for structural-vs-row rules elsewhere. The
     required group keeps border-bottom: 0 from its base rule: this sets
     only the color, and a color has no width to paint. */
  .email-head,
  .email-group {
    border-bottom-color: var(--border-strong);
  }

  .email-side {
    width: auto;
    padding-top: 0;
  }

  .email-row {
    flex-wrap: wrap;
  }

  .email-row-text {
    flex-basis: 100%;
  }

  .email-row-when {
    width: auto;
    flex: 1;
  }
}

/* --- Emailed-link landings (signed out): unsubscribe + email confirm ----- */

.unsub-page,
.confirm-page {
  max-width: 38rem;
  margin: 2rem auto 0;
}

.unsub-lede,
.confirm-lede {
  line-height: 1.65;
  color: var(--text-muted);
}

.unsub-address,
.confirm-address {
  font-family: var(--font-mono);
  font-size: 0.9em;
  color: var(--text);
}

.unsub-actions,
.confirm-actions {
  display: flex;
  gap: 0.75rem;
  flex-wrap: wrap;
  margin: 1.5rem 0 0;
}

.unsub-foot,
.confirm-foot {
  margin-top: 1.5rem;
  font-size: var(--fs-ui);
  color: var(--text-faint);
}

/* ===========================================================================
 * The mobile layer (issue #914 — "Mobile Layer Spec", part 1)
 *
 * Three stops, one type floor, one target size, four house patterns. The
 * breakpoint scale is: compact ≤520px (one column, no exceptions), handheld
 * ≤720px (patterns change shape: sheet, record cards, bar lists, rails),
 * tablet ≤1024px (desktop layout, one column poorer) — with the card-grid
 * steps at 521/721/1025 so a width is never in two states at once. The type
 * tokens and their one-bump phone scale live with the design tokens at the
 * top of this sheet. Everything below is the handheld/compact expression of
 * patterns defined earlier in the file; it sits last so its overrides win
 * ties.
 * ======================================================================== */

/* --- Fit-score explainer (spec §05C) — every width -----------------------
   title= is never the only carrier of a number: the score chip is a per-row
   <details> whose one line of prose a tap reveals. Clickable on desktop too
   (the flagged, endorsed desktop change). */
.rec-score-details {
  position: relative;
}

.rec-score-details > summary {
  list-style: none;
  cursor: pointer;
}

.rec-score-details > summary::-webkit-details-marker {
  display: none;
}

.rec-score-note {
  position: absolute;
  right: 0;
  top: calc(100% + 6px);
  z-index: 20;
  white-space: nowrap;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  padding: 0.35rem 0.6rem;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
  box-shadow: var(--shadow-panel);
}

/* --- Nav-panel title search (spec §00 ARGUE 01) --------------------------
   The collapsed nav's first row is a catalog title search: two taps from any
   page to "do I already own this?". The panel only renders below 720. */
/* 2B draws a 14px gap between the field and the first link row, and no divider
   of its own — the field's border is the separation, and the first link brings
   its own top hairline like every row below it. */
.nav-panel .nav-search {
  margin-bottom: 14px;
}

/* --- Touch targets (spec §03): 44px, from min-height, never font size ----
   Keyed on the handheld stop AND on pointer: coarse — the same block catches
   a 1024px iPad no width query ever will. .btn-sm stops existing as a size
   (the class stays; it inherits base metrics); selects join so every inline
   <select> in the list views is tappable.

   This selector list is the whole of the touch floor — a control that is not
   named here ships under it silently, however button-shaped it looks. That is
   how .back-link stayed at 35.9px: its 0.4rem padding-block was sized for the
   WCAG 2.2 AA 24px minimum (issue #468), which it clears, and nothing said the
   handheld floor is 44. On the auth cards it lands directly beneath the
   card's own submit — a 44px band above a 35.9px one, two controls that read
   as the same kind of thing measuring differently.

   ⭐ `make touch-audit` (issue #1289) is the answer to "who else is missing?" —
   it walks every route in page_index.py at 390 and at 1024-with-touch and
   measures the tap area of every interactive box. The members added below came
   out of its first run; `tests/ui/touch_baseline.json` carries the rest, and
   the guard fails on anything that is in neither. Before adding a name here,
   ask the #1287 question: does this control's box wrap a LABEL, or IS it the
   drawing? The floor grows the first kind and deforms the second. */
@media (max-width: 720px), (pointer: coarse) {
  /* Track 03's ask, sized for touch (issue #1719). Height for .onboard-chip
     arrives from the floor's selector list above; these are the rest of the
     same decision. Which chips SHOW is a width decision and lives in the
     max-width block instead — an iPad has room for all twelve. */
  .onboard-chips {
    gap: 0.5rem;
  }

  .onboard-chip {
    padding: 0 0.9375rem;
    border-radius: 22px;
    font-size: 0.90625rem;
  }

  .onboard-chip--escape {
    font-size: 0.75rem;
  }

  /* The one target that must not be missed. 🚨 (0,2,0) for the same reason as
     .onboard-chips .onboard-chip--wide above: the touch floor's selector list
     gives every button[type=submit] `min-height: 44px` at (0,1,0) and wins on
     source order, so a lone class here draws 44 and not 48. */
  .onboard-ask .onboard-ask-submit {
    min-height: 48px;
    font-size: 0.9375rem;
  }

  .btn,
  .btn-sm,
  button[type="submit"],
  .back-link,
  .collection-tab,
  /* The wordmarks are the site's "go home" control, in the header on every
     page and again in the footer, and both were bare text: 28.0px and 26.4px
     (#1289). The header one sits in a 44px-tall header row, so the shortfall
     was invisible — the ROW was the right height and the link inside it was
     not, which is the .pagination-current shape from #1278 over again. Both
     are text wordmarks, so growing the box is the right fix for them. */
  .brand,
  .footer-brand,
  /* "Clear all" closes the filter chip row, sitting in it beside
     .chip--removable — which IS given a floor further down this same block. So
     the row drew chips next to a 20.8px text action (#1289). A non-member
     inside an otherwise-covered family is where this hides; the pager's
     current cell (#1278) was found the same way.
     🚨 Its own base rule is `.filter-chips .chip-clear-all` at (0,2,0), which
     out-specifies this lone class and declares `padding: 0 0.15rem`. That is
     harmless — the floor wants padding-block 0 anyway and the base rule gives
     it — but it means the cascade resolves this control per property, so only
     min-height/display/align-items arrive from here. */
  .chip-clear-all,
  /* Home's rail headings each end in a "View all →" link — 23.1px, and the
     only control in that band (#1289). Selected by position because it carries
     no class of its own; the base rule at the top of this file uses the same
     shape, so this competes with that one and nothing else. */
  .home-section > .section-head a,
  /* The catalog panel's "Reset advanced" — 24.1px in the phone sheet's foot,
     21.8px in the rail's, beside an Apply that clears the floor (#1485). A
     text link, so growing the box is the right fix; its base rule's
     margin-left: auto survives (different property, no contest). */
  .advanced-reset,
  /* The rail's "Clear group" (spec 11 §E) — 17.8px, a JS-only control the
     audit first met when #1481 taught it to open panels. Its [hidden] hide at
     (0,2,0) still out-specifies the display here, so it stays absent until a
     group has something staged. */
  .filter-pane-clear,
  /* The Platform filter panel's "Select all 8" / "Clear group" (issue #1674).
     Exactly .filter-pane-clear's shape one component over — a link-styled
     text button in a pane head, ~18px on its own — and the panel is a
     full-height sheet on a phone, where it sits beside 46px rows. */
  .platform-group-toggle,
  /* Track 03's ask chips (issue #1719). The label WRAPS a visually-hidden
     checkbox — #1287's first kind — so the LABEL is the control and the box
     it draws is 28px on its own. 🚨 The sibling escape chip is a
     `button[type="submit"]` and so is already a member: without this the two
     kinds of chip in one wrap row would draw at different heights on a
     coarse pointer, which is how this defect announces itself. Its phone
     look (radius, padding, font) travels with the height below, because a
     44px chip at radius 14 is not the same object. */
  .onboard-chip,
  /* The Released decade chips — the label is 27.0px in the phone sheet
     (#1485). The label WRAPS a drawing (#1287's first kind): the pill is the
     <span> inside, whose own height rules hold it at 26px in the sheet and
     --fp-chip in the rail, so the band grows around the artwork rather than
     inflating it. */
  .more-chip,
  /* The signed-out detail bar's "Log in" — 21.8px on a coarse-pointer tablet.
     The ≤640 block already grants it this exact band (spec 13 §E); this arm is
     what reaches the desktop rendering a 1024px iPad gets. */
  .signed-out-login,
  .filter-menu > summary,
  .column-picker > summary,
  .account-menu > summary,
  .advanced-trigger {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    padding-block: 0;
  }

  .btn-sm,
  button.btn-sm {
    padding-inline: 14px;
    font-size: var(--fs-ui-sm);
  }

  /* Flex layout discards the whitespace between items, and the tab's label is
     a bare text node sitting next to <span class="tab-count"> — so the moment
     the rule above made the tab a flex container the source space collapsed
     and the tabs read "All60" (issue #1024). The gap puts it back as layout
     rather than markup (an &nbsp; would still be dropped). This belongs to the
     inline-flex above, so it stays in THIS query: the pill styling in the
     handheld block is width-only, and the count also runs into the label on a
     1024px touch iPad, which no max-width query ever reaches. */
  .collection-tab {
    gap: 0.35rem;
  }

  select {
    min-height: 44px;
  }

  .pagination a,
  a.chip--removable {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }

  /* 🚨 The current page is a <span>, not a link (the page you are on is not a
     link to anywhere), so the floor above never reached it: its cell drew
     33.1px against the links' 44 and floated with 5.5px of daylight top and
     bottom in a row of otherwise flush boxes — the ul is align-items: center,
     so the shortfall splits. This is HEIGHT PARITY with the row it sits in,
     not a touch target, which is why it is a rule of its own rather than a
     third member of the list above: nothing here is tappable. Two classes, so
     it ties the base cell rule's (0,2,0) and wins on source order; display
     and align-items already come from that same base rule. */
  .pagination .pagination-current {
    min-height: 44px;
  }

  /* Icon-only controls are 44×44 squares/circles. */
  .theme-toggle,
  .nav-menu > summary {
    width: 44px;
    height: 44px;
  }

  .account-menu > summary {
    height: 44px;
    min-width: 44px;
  }

  /* The dropdown's rows are the phone's ONLY account controls since issue #1178
     retired the hamburger panel's 52px copies, so they take the 44px target
     here — the summary rule above sizes the trigger, not what it opens. (The
     log-out control already qualifies as button[type="submit"] above.) */
  .account-menu .account-panel a {
    min-height: 44px;
    display: flex;
    align-items: center;
  }

  .rec-score-details > summary {
    min-height: 44px;
    min-width: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: flex-end;
  }

  /* 🚨 The platform panel's rows take the floor as a rule of their own and are
     deliberately NOT members of the selector list at the top of this block:
     that rule declares `display: inline-flex; align-items: center`, which
     would lay a row's name and format out SIDE BY SIDE again — reinstating the
     exact one-line collision spec 17 (issue #1220) exists to remove. The row
     stays a flex column and centres its two lines in the taller box; a wrapped
     two-line name simply makes a taller target, never a smaller one. The
     catalog row's padding drops to absorb the floor rather than stack on it,
     and the owned block's does the same for the rows inside it. */
  .ref-platform {
    min-height: 44px;
    justify-content: center;
  }

  /* 🚨 (0,3,0) to match the base rule's `padding` shorthand: a lone-class
     `padding-block` here would lose to it and the 7px would silently stay. */
  .ref-platforms:not(.ref-platforms--owned) .ref-platform {
    padding-block: 4px;
  }

  .ref-platforms--owned {
    gap: 0;
    padding-block: 6px;
  }

  .ref-platforms--owned .ref-platform {
    padding-block: 4px;
  }

  /* The history row's discard trigger (issue #929) — inline-flex so the tap
     target grows downward from the text, not the row's gutter outward. */
  .import-hist-discard-summary {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }

  /* 🚨 The email switch is a DRAWN object, not a text control, and it is a
     `button[type="submit"]` — so the min-height at the top of this block was
     stretching it (issue #1287; the same floor left the pager's current cell
     11px short one control over, issue #1278). Every other member of that
     selector list grows a box around a LABEL, which is what a target wants —
     this one IS the drawing it would inflate. The button is the 44×25 track,
     and the knob is absolutely positioned at `top: 3px` against it, so the
     track went to 44×44 (a rounded square) while the knob stayed 17px in the
     corner. On a phone the control stopped reading as a switch at all.

     Give the track back its own height and re-buy the target as a transparent
     overlay. That is the only shape that works here: the repo's usual
     padding + negative margin trick (`.pw-reveal`) grows a target on a control
     with NO background, and this one paints its own — padding would just
     inflate the pill again. `min-height: 0` is enough because the base rule
     (`button.email-switch`) still declares `height: 25px`; it is not restated
     here so the pill's size stays declared in exactly one place. Ties the
     generic rule's (0,1,1) and wins on source order — hence `button.` rather
     than a bare class, which would lose per property and half-apply. */
  button.email-switch {
    min-height: 0;
  }

  /* 🚨 The suggest panes' checkbox rows (issue #1434). They belong HERE, not in
     the pane's own phone block: `make touch-audit` measured them at 30px on the
     1024px coarse-pointer tablet, which no `max-width` rule reaches — and this
     file keeps exactly ONE block with this query, so a second one would make
     "which block is the floor?" unanswerable (four guards ask it).

     A rule of their own rather than a member of the selector list at the top,
     for the .ref-platform reason: that list declares `display: inline-flex`,
     which would shrink each row to its text and strand the veto / KEPT tags
     that sit at the far end on `margin-left: auto`. The label IS the row (the
     native box parks off-screen), so this is the #1287 "wraps a label" kind and
     growing the box is the right fix. */
  .pane-check {
    min-height: 44px;
  }

  .pane-check__mark {
    width: 20px;
    height: 20px;
  }

  button.email-switch::after {
    content: "";
    position: absolute;
    /* The track is already 44px wide, so only the height needs buying:
       25 + 2×10 = 45px. Rows are far taller than that at every width the
       switch renders in, so no two overlays can overlap. */
    inset: -10px 0;
  }

  /* The signed-out hero's demo chips (spec 23 §C TAP TARGETS, issue #1894):
     ~38px pills whose row rhythm is the point — a 44px pill is not the same
     object — so the target is bought the email-switch way: 38.5 + 2×4 ≈ 46
     on a phone, ~38 + 2×4 = 46 on the coarse tablet no width query reaches.
     Vertical only: the chips sit 8px apart in one row. 🚨 The ≤720 scroll
     row clips hit-testing at its padding box, so .hero-out-chips carries
     4px of block padding there for this overlay to land in. */
  .demo-chip::after {
    content: "";
    position: absolute;
    inset: -4px 0;
  }

  /* The demo panel's foot link — ~21px of mono text beside a sentence, the
     .advanced-reset shape, but the panel's overflow: hidden clips anything
     past the foot band, so growing the box 44 tall would not measure. The
     band steps up to hold the overlay instead: 21.4 + 2×13 = 47.4, inside
     0.85rem (13.6px) of band padding. */
  .demo-foot {
    padding-block: 0.85rem;
  }

  .demo-foot-link::after {
    content: "";
    position: absolute;
    inset: -13px 0;
  }

  /* --- The grid card action bar (spec 09), moved in from the ≤720 block ----
     These two shipped behind `@media (max-width: 720px)` under a comment that
     correctly said "Touch, not width" — so the coarse-pointer tablet, which no
     width query reaches, drew the 36px bar and its 28px caret (issue #1485:
     `a.card-bar-main--tinted` at 36.6px). Rules of their own rather than list
     members above: the bar's controls are `height: 100%`, so the BAR is the
     box that decides, and the list's inline-flex/min-height grammar is about
     the control itself. The ≤720 stack (`.browse-card .card-bar`) still
     releases this height at (0,2,0) — specificity, not order, settles that.
     The caret keeps `flex` and `width` agreeing for test_card_bar_touch_target
     .py's reason: either alone measures 44 today, and the next edit deletes
     the other believing it redundant. */
  .card-bar {
    height: 44px;
  }

  .card-bar-caret {
    flex: 0 0 44px;
    width: 44px;
  }

  /* The game page's split-add ▾ — 38.9px on a coarse-pointer tablet, beside a
     ＋ Add and a ♡ Wish that both clear the floor as button[type="submit"]
     (#1485). min-height rather than height, so the ≤720 detail rules and the
     row variant's `align-self: stretch` keep deciding the exact box; the base
     `.split-add-more { height: 38px }` is a different property and no longer
     the floor's business. Tag-qualified for the base rule's reason: it ties
     (0,1,1) and wins on source order from down here. */
  summary.split-add-more {
    min-height: 44px;
  }

  /* The connections page's "connect →" / "cancel ×" cues — 35.2px on phones,
     32.9px on a coarse tablet (#1485). NOT a member of the list above, though
     the box wraps a label: the cue is an <a> inside a <li>, so the prose
     underline rule at the top of this file reaches it, and display:inline-flex
     would paint that underline twice — once into the blockified text item,
     once across the atomic box (the .head-meta-import artefact). It also
     carries an icon-text SVG the flex grammar would re-seat. So the band is
     bought as the email switch buys its own: a transparent overlay, which the
     audit hit-tests at its real size. 33 + 2×7 = 47. Nothing sits within 7px:
     the row's own padding-block is 17.6px and the armed panel opens 14.4px
     below the cue's line. */
  a.conn-cue {
    position: relative;
  }

  a.conn-cue::after {
    content: "";
    position: absolute;
    inset: -7px 0;
  }

  /* --- The rail filter panel on a coarse pointer (issue #1485) -------------
     The one #1485 finding that was not a missing name: the rail's foot rule
     declares `min-height: 0` to hold Apply/Clear at the drawn 30px row, which
     out-specifies the floor's bare `.btn` at EVERY width — the same .btn-sm
     that computes 44px everywhere else on the page. The compact rows are a
     fine-pointer drawing, so the fix is the component's own tokens: on a
     coarse pointer every row-height consumer — value rows, rail tabs, the
     Apply/Clear foot — takes the 44px band, and the panel stays a constant
     height per surface exactly as spec 11 promises, one step taller (catalog
     435, collection 413). The foot's arithmetic mirrors the base token's:
     1 border-top + 12 + 44 Apply + 12. --fp-chip deliberately does NOT move —
     the chip is a drawing, and its label buys the band via .more-chip above.
     [data-rail="on"] keeps this inert on the phone arm of this query, where
     app.js never sets the attribute and the sheet's stacked accordion owns
     the layout. */
  .filter-rail-panel[data-rail="on"] {
    --fp-row: 44px;
    --fp-rail-row: 44px;
    --fp-foot: 69px;
  }

  /* The add form's platform picker, sized for touch (issue #1718). 🚨 It lives
     in THIS block, not a second one with the same query: several guards
     identify the touch floor by that query and a duplicate makes it
     unidentifiable. Width alone would also be the wrong query for a TARGET — a
     coarse-pointer tablet is wider than 720px and would take the 32px desktop
     row, which is the defect #1727 records against the filter panel's field.

     🚨 (0,2,0) on purpose: the floor's selector list above declares
     `min-height: 44px` AND `display: inline-flex` on its members at (0,1,0)
     and wins on source order, so a lone-class rule would draw 44px and
     shrink-wrap the row. For the same reason .platform-picker-row is
     deliberately NOT a member of that list — it needs 46px and a full-width
     flex row, and inheriting inline-flex would break the layout to gain
     nothing the explicit height here does not already give. */
  .platform-picker .platform-picker-row {
    min-height: 46px;
    padding-block: 6px;
  }

  .platform-picker .platform-picker-radio {
    width: 19px;
    height: 19px;
  }

  /* 🚨 …and the height once more at (0,3,1), because the sheet's own
     `.add-popover-panel .add-popover-field select, input` pair further up is
     (0,2,1) and declares `height: 48px`: the rule above wins `width` and
     loses `height`, so the radio drew 19×48 and the row measured 60px against
     its own 46px `min-height`. Not folded into the rule above — that one is
     the picker's size wherever it renders; this one is only the repair for
     the panel that takes it, which today is the only place it renders. */
  .add-popover-panel .add-popover-field input.platform-picker-radio {
    height: 19px;
  }

  /* On a phone the list is a scroll region in its own right, whatever the
     option count — the desktop 212px cap only arrives past six options, and
     five 46px rows would otherwise be 230px of column on a 390px screen.

     🚨 30vh, not more: the add-details sheet is capped at `100dvh - 40px`, so
     an unbounded control pushes it to its maximum and the sheet starts 40px
     from the top of the screen with no context left behind it. That is also
     what broke the sheet's own scrim test, whose "click well above the panel"
     coordinate assumed a sheet that does not fill the screen — the test was
     right and the control was too tall. */
  .platform-picker .platform-picker-list {
    max-height: 30vh;
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* 🚨 Non-negotiable: below 16px iOS Safari zooms the page on focus and does
     not zoom back. A pointer decision, not a width one. */
  .platform-picker .platform-picker-filter {
    min-height: 46px;
    font-size: 1rem;
  }

  /* And the same repair for the field, where the two arms of this query
     diverge. Below 721 the handheld layer's own `input:not([type=checkbox])…`
     floor already holds every field at 48px, so the pair's `height` changed
     nothing there. On a COARSE-POINTER TABLET that floor is out of scope and
     the pair was the whole story: a fixed 48px box the `min-height` above
     could not reach, `padding: 0 12px` a pixel off the rows' 11px inset, and
     — because `var(--fs-field)` is only bumped to 1rem inside the 720px
     query — 0.875rem text, which quietly undid the 16px floor above at the
     one width the phone UI test never opens. */
  .add-popover-panel .add-popover-field input.platform-picker-filter {
    height: auto;
    padding: 0 11px;
    font-size: 1rem;
  }

  /* Names wrap to a second line here rather than truncating: the row grows and
     nothing else moves. */
  .platform-picker .platform-picker-name {
    overflow: visible;
    white-space: normal;
    text-overflow: clip;
    font-size: 0.9375rem;
  }
}

/* --- The handheld layer (≤720): where patterns change shape -------------- */
@media (max-width: 720px) {
  /* Prose relaxes a step on phones (spec §02). */
  body {
    line-height: 1.65;
  }

  /* ---- The filter sheet (spec §05A) ------------------------------------
     One "Filters (N)" trigger; the panel opens as a bottom sheet pinned to
     the viewport's bottom edge. The disclosure is the checkbox rendered by
     the filter forms; :has() does the rest. */

  /* Both triggers lead the results toolbar's control row, beside Sort and the
     view button (design "Collection Grid Spec" 06 §C). */
  .filter-sheet-trigger {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
    min-height: 44px;
    padding-inline: 14px;
    font-size: var(--fs-ui);
    font-weight: 600;
    color: var(--text);
    cursor: pointer;
    margin: 0;
  }

  .filter-panel > .filter-sheet-trigger {
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
  }

  .filter-sheet-count {
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    color: var(--accent-text);
  }

  .filter-sheet-count:empty {
    display: none;
  }

  .filter-sheet-toggle:focus-visible ~ .filter-sheet-trigger {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* Both triggers live in the results toolbar, across the DOM from their
     checkbox (design "Collection Grid Spec" 06 §C — the control row Filters
     shares with Sort and the view button is inside the swap region, the
     sheet's state outside it), so the sibling rule above can't reach them:
     the ring crosses over via :has() on the form the checkbox sits in. */
  #collection-filter-form:has(.filter-sheet-toggle:focus-visible) ~ #collection-results .filter-sheet-trigger,
  #catalog-search-form:has(.filter-sheet-toggle:focus-visible) ~ #catalog-results .filter-sheet-trigger {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* Closed: the whole apparatus is gone — pre-roll above the first result is
     the search bar, the trigger and the chip row. */
  .filter-sheet-panel {
    display: none;
  }

  /* Open: a bottom sheet (the add-popover precedent, generalized). Radius,
     shadow and scrim weight match the view/action sheets (design "Collection
     Grid Spec" 06 — one sheet chrome across the page). */
  .filter-panel:has(.filter-sheet-toggle:checked) .filter-sheet-panel {
    display: flex;
    flex-direction: column;
    position: fixed;
    inset: auto 0 0 0;
    z-index: 60;
    max-height: 85vh;
    max-height: 85svh;
    overflow-y: auto;
    overscroll-behavior: contain;
    background: var(--surface);
    border-top: 1px solid var(--border-strong);
    border-radius: 14px 14px 0 0;
    box-shadow: 0 -18px 44px rgba(0, 0, 0, 0.4);
    /* The sticky footer (mobile screens 2A) carries the bottom safe-area
       padding so it can pin flush to the sheet's bottom edge. */
    padding: 0 16px;
  }

  .filter-panel:has(.filter-sheet-toggle:checked) .filter-sheet-scrim {
    display: block;
    position: fixed;
    inset: 0;
    z-index: 55;
    /* labels carry a global margin-bottom that would nibble the fixed box */
    margin: 0;
    background: rgba(10, 9, 6, 0.62);
    cursor: pointer;
  }

  /* The page behind the sheet holds still. */
  body:has(.filter-sheet-toggle:checked) {
    overflow: hidden;
  }

  /* Every rule in the sheet runs edge to edge (spec §05 mock A) — head, the
     48px rows, the open facet's fill, the footer. They all cancel the panel's
     16px gutter and re-add it as padding, the idiom the footer already uses.

     The head is the shared sheet chrome now (design "Collection Grid Spec"
     06): grab handle over a mono label, stacked — the ✕ dismiss button left
     with the handle's arrival (swipe-down, scrim, Escape and the footer all
     close the sheet). Sticky, so the handle stays reachable however deep the
     accordion below scrolls. */
  .filter-sheet-head {
    display: flex;
    flex-direction: column;
    position: sticky;
    top: 0;
    z-index: 2;
    margin-inline: -16px;
    padding: 8px 16px 0;
    background: var(--surface);
    border-bottom: 1px solid var(--border);
  }

  .filter-sheet-handle {
    flex-shrink: 0;
    width: 38px;
    height: 4px;
    border-radius: 2px;
    background: var(--border-strong);
    margin: 2px auto 10px;
  }

  /* The label wears the view sheet's voice (mono badge, 0.1em, faint) so the
     two sheets read as one family. */
  .filter-sheet-head-label {
    padding-bottom: 10px;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  /* Inside the sheet every control is a full-width 48px list row: the bar
     segments lose their bar chrome (painted by the desktop rules above),
     facet menus and pickers become accordions — their panels go static, the
     16rem inner scroll goes away (spec: the sheet is the scroll region). */
  .filter-row .filter-sheet-panel > .filter-menu,
  .filter-row .filter-sheet-panel > .search-seg {
    background: none;
    border: 0;
  }

  /* The desktop bar's right cap survives that strip: the cap is (0,4,0) (the
     `:not()` counts its argument) against the strip's (0,3,0), and the cascade
     resolves per PROPERTY — so its `border-inline-end` beat `border: 0` here
     and the full-bleed Region row painted a 1px --border-strong hairline down
     the sheet's right edge (#1512). The cap rule's own comment assumed these
     strips cover it ("a radius left on a cell with no border and no fill
     paints nothing") — true for the radius, false for the border the cap
     itself adds. Same selector, later in source, so it wins the tie; the
     radii go with it for the day the row regains a fill. */
  .filter-row:not(.filter-row--collection) .filter-sheet-panel > .search-seg {
    border-inline-end: 0;
    border-start-end-radius: 0;
    border-end-end-radius: 0;
  }

  .filter-sheet-panel .filter-menu,
  .filter-sheet-panel .column-picker {
    display: block;
    width: 100%;
  }

  /* An explicit width, not `auto`: these rows are inline-flex (the touch
     block) and flex items of the Advanced drawer's wrapping row, and both of
     those are shrink-to-fit — only a stated width keeps one row per line.

     The colour is stated here so every row in the sheet reads as the same list
     row. Two desktop rules would otherwise paint the Columns row alone (the
     picker is not a .filter-menu, so the [open] correction below never reached
     it): the pill's muted label at rest, and .column-picker[open] > summary's
     accent title when it is open — the very thing §05 mock A rules out, three
     rows under facet titles that stay in --text. Measured on a 390 sheet
     (#1264): 111,106,94 at rest and accent open, against the facets' --text. */
  .filter-sheet-panel .filter-menu > summary,
  .filter-sheet-panel .column-picker > summary {
    width: calc(100% + 32px);
    min-height: 48px;
    margin-inline: -16px;
    padding: 0 18px;
    border: 0;
    border-bottom: 1px solid var(--border);
    border-radius: 0;
    background: none;
    color: var(--text);
    font-size: var(--fs-ui);
  }

  .filter-sheet-panel .filter-menu > summary .seg-label {
    flex: 1;
  }

  .filter-sheet-panel .search-seg {
    width: calc(100% + 32px);
    min-height: 48px;
    margin: 0 -16px;
    padding: 0 18px;
    border: 0;
    border-bottom: 1px solid var(--border);
    border-radius: 0;
    font-size: var(--fs-ui);
  }

  /* The refine row's shelve-by pan fix that main carries here is dropped, not
     merged: .refine-row / .refine-seg--shelve-by no longer exist. The shelves
     grouping controls moved to the results row (design "Collection Refined"),
     which the sheet does not reach, so there is nothing left inside it for that
     rule to correct. The Advanced-drawer inset rule went the same way — #1060
     replaced <details class="advanced-drawer"> with a checkbox disclosure, so
     the class survives only as a data-testid. */
  .filter-sheet-panel .search-seg .seg-label {
    flex: 1;
  }

  /* An open facet reads as a filled block BELOW its title, never as a filled
     title (spec §05 mock A): the title row stays on the sheet's own surface in
     --text, and the options underneath carry --fill. Three desktop rules paint
     the open trigger instead and all three outrank the sheet's base summary
     rule above — the search bar's segment fill, the refine row's pill fill,
     and the column-picker's accent text (the facet menus are column-pickers).
     The seg-value count keeps its accent; it is the row's one leaf. */
  .filter-sheet-panel .filter-menu[open] > summary {
    background: none;
    color: var(--text);
  }

  /* The options are indented 10px past their title's 18px so the open facet
     reads as a block hanging off its own row. */
  .filter-sheet-panel .picker-panel {
    position: static;
    min-width: 0;
    width: auto;
    margin-inline: -16px;
    padding: 0.45rem 18px 0.7rem 28px;
    background: var(--fill);
    border: 0;
    border-bottom: 1px solid var(--border);
    border-radius: 0;
    box-shadow: none;
  }

  .filter-sheet-panel .filter-menu .menu-options {
    max-height: none;
    overflow: visible;
    padding-right: 0;
  }

  .filter-sheet-panel .picker-panel label.check {
    display: flex;
    align-items: center;
    min-height: 44px;
    margin: 0;
  }

  /* The ＋ More panel is an accordion inside the sheet like any other facet
     menu, so its own sections stack rather than sitting in a floating box —
     and back to ONE column: the desktop panel's two columns exist to stop a
     tall stack, but the sheet is a scrolling surface where a stack is the
     right shape, and two columns of checkboxes at this width would be two
     columns of wrapped labels. (0,3,0) beats the two-column rule's (0,2,0).
     Vertical rhythm comes from .more-group's own margins, so the row gap the
     grid would otherwise add is dropped with it. */
  .filter-sheet-panel .more-menu > .picker-panel {
    display: block;
    width: auto;
  }

  .filter-sheet-panel .more-group {
    border-bottom: 0;
    padding-bottom: 0.35rem;
    margin-bottom: 0.35rem;
  }

  .filter-sheet-panel button[type="submit"] {
    width: 100%;
    min-height: 48px;
    margin: 0.85rem 0 0.25rem;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    justify-content: center;
  }

  /* A facet accordion's Apply/Clear footer takes the sheet's action-row shape
     (the spec's one drawn action row, §05A / 2A: primary on flex: 1, Clear a
     fixed 88px cell beside it, 10px apart, both 48px).

     It reached here broken: the rule above widened Apply because it is a
     submit, while Clear is a type="button" (it unchecks this menu's boxes via
     app.js) that no sheet rule reached, so it kept its content width on the
     SAME line as a 100%-wide Apply and was pushed clean off the right edge of
     a 390px sheet — invisible to the page-level no-sideways-pan guard, because
     the sheet is position: fixed and scrolls inside itself.

     Both cells state their own chrome: on the catalog this pair sits inside
     the search bar's .filter-row, whose `.filter-row button` cap rule
     (border: 0 + a left hairline + a right-capped radius, meant for the Search
     segment) reached Clear and left it a borderless stub. The collection
     sheet has no .filter-row and kept the border, so stating it here also
     makes the two surfaces match. */
  .filter-sheet-panel .menu-actions {
    gap: 10px;
    padding-top: 0.85rem;
  }

  .filter-sheet-panel .menu-actions > .btn {
    flex: 1;
    width: auto;
    min-height: 48px;
    margin: 0;
    justify-content: center;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
  }

  .filter-sheet-panel .menu-actions > .btn[data-menu-clear] {
    flex: 0 0 auto;
    min-width: 88px;
  }

  /* ...but a sheet that re-queries live has no use for either (Mobile Layer
     Spec §05A, Mobile Screens 2A/2B: "Apply" appears in neither file). Every
     tick already applies — app.js re-issues it as `filterchange` and the
     footer's count follows — so a per-facet Apply would submit filters that
     are on already, and a per-facet Clear duplicates both the chip row (one
     ✕ per value) and the footer's own Clear. The sheet is left with exactly
     the pair the spec draws: [Show N results][Clear].

     Both filter sheets opt in now. The collection's used to keep the pair,
     because its facets batched and its footer primary was a close label
     rather than a submit — so hiding Apply would have stranded a platform
     pick with no way to apply it. That combination was the bug: the footer
     looked like the way out and did nothing. Its footer is a real submit
     today, so the per-facet rows can go the same way the catalog's did.

     The stronger selector (0,3,1) outranks the rules above. Kept in the DOM
     rather than dropped from the template so desktop — where the same menus
     are dropdowns and batching is still the right behaviour — is untouched by
     this file's handheld layer, and so a no-JS visitor still meets a footer
     submit that applies the whole form in one go.

     With both sheets opted in, the shaping rules above are currently
     unexercised. They stay because they fix a real bug (a content-width Clear
     pushed off the right edge of a 390px sheet, invisible to the page-level
     no-sideways-pan guard) that would return the moment a sheet opts out. */
  form[data-sheet-live] .filter-sheet-panel .menu-actions {
    display: none;
  }

  /* A tick is one round-trip from being true, so the count dims rather than
     asserting a number we are about to replace. htmx puts .htmx-request on
     the element that issued the request — here the form itself. */
  form.htmx-request .filter-sheet-results {
    opacity: 0.55;
  }

  /* The bar's submit stands down in the sheet (mobile screens 2B): the
     footer's "Show N results" is the same form's submit, pinned where a
     sheet's primary action belongs.

     Since issue #1464 the only thing left for this to hide is the no-JS
     "Apply filters" — which is why the selector names <noscript> rather than
     the button. Wrapping the submit moved it out of .filter-sheet-panel's
     CHILD list (its parent is the <noscript> now), so the old
     `> button[type="submit"]` stopped matching it entirely; without the
     re-point a no-JS phone would draw "Apply filters" in the sheet directly
     above the footer submit that already does the same job. Scripting on, the
     element and its contents never render at all and this rule is inert.

     Deliberately NOT scoped to the catalog: the collection grew the same
     noscript submit in issue #399 and its sheet footer is a real submit too,
     so the duplicate it has been drawing on a no-JS phone goes with this. Both
     bars, one rule — which is the whole point of the two finally matching. */
  .filter-row .filter-sheet-panel > noscript {
    display: none;
  }

  /* The row that carries the column picker stacks inside its sheet — since
     issue #1521 the one .filter-meta row both lists draw (issue #1264 moved
     it into the panel: same control, same chrome). Every other occupant of
     the row is already hidden at this width, so what stacks here is the
     picker on its own; the row is a plain full-width block whose child can
     full-bleed to the sheet's edges. */
  .filter-sheet-panel .filter-meta {
    flex-direction: column;
    align-items: stretch;
    margin-top: 0.5rem;
  }

  .filter-sheet-panel .filter-meta .spacer {
    display: none;
  }

  .filter-sheet-panel .picker-panel .picker-reset {
    width: 100%;
    justify-content: center;
  }

  /* The results-toolbar pickers (sort, page size) sit at the row's right
     end; their left-anchored popovers would run off a 390px screen, so they
     hang from their right edge instead. */
  .results-toolbar .column-picker .picker-panel {
    left: auto;
    right: 0;
  }

  /* ---- Record cards (spec §05B): the table stops being a table ---------
     Opted in by .table-cards (the catalog + collection list views). Every
     enabled column is still on screen — each cell prints its own label —
     so the phone shows MORE data than the clipped table did. One hairline
     per record; no fills, no shadows, no radius. */
  .table-cards table,
  .table-cards tbody,
  .table-cards tr,
  .table-cards td {
    display: block;
    width: 100%;
  }

  .table-cards thead {
    display: none;
  }

  .table-cards tr {
    padding: 0.9rem 0;
    border-bottom: 1px solid var(--border);
  }

  .table-cards td {
    border: 0;
    padding: 0.18rem 0;
  }

  .table-cards td:first-child {
    font-size: var(--fs-ui);
    font-weight: 600;
    line-height: 1.4;
    padding-bottom: 0.45rem;
  }

  /* Flex, not grid: a cell's value is often several inline nodes (platform
     lists with a "+N more" tail, select + feedback spans) and a grid would
     scatter them into cells of their own. The label is a fixed-basis first
     item; the value nodes share the rest of the line and wrap together. */
  .table-cards td[data-label] {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    column-gap: 0.9rem;
    row-gap: 0.1rem;
  }

  .table-cards td[data-label]::before {
    content: attr(data-label);
    flex: 0 0 6.5rem;
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  /* The actions cell is a stacked record's last band. Its contents are the
     spec-09 action bar now (design "Catalog Action Bar Spec" 09, on both
     browse pages' list rows), which brings its own 44px touch geometry and
     needs no width answer here: a stacked record is full-width, so the bar's
     own auto width is right, and the 172px desktop pin is scoped to ≥721.
     What the older Edit/Remove cluster needed — a flex row, equal shares, a
     gap — went with it. */
  .table-cards .row-actions-cell {
    padding-top: 0.6rem;
  }

  /* ---- Pannable tables (spec §05B, option A) ---------------------------
     The fallback for tables nobody restyles (stats, import column map, the
     agents table): pannable on purpose, not clipped by accident — a pinned
     right-edge shadow that a locally-scrolling cover erases at the end of
     the pan, plus a thin scrollbar. */
  .table-wrap:not(.table-cards) {
    background-image:
      linear-gradient(to left, var(--bg) 26px, transparent 40px),
      linear-gradient(to left, color-mix(in srgb, var(--text) 16%, transparent), transparent 26px);
    background-position:
      right center,
      right center;
    background-size:
      40px 100%,
      26px 100%;
    background-repeat: no-repeat;
    background-attachment: local, scroll;
    scrollbar-width: thin;
  }

  /* ---- Charts become bar lists (spec §05C) -----------------------------
     The growth chart rotates 90°: label · bar · number, one row per bucket,
     newest first, the last six buckets shown and the rest behind the
     existing "View as table" disclosure. --seg flips from column height to
     row width. */
  .growth-plot {
    display: flex;
    flex-direction: column-reverse;
    align-items: stretch;
    height: auto;
    padding-top: 0;
    border-bottom: 0;
    gap: 0;
  }

  .growth-col {
    display: none;
  }

  /* 82px label / bar / count (mobile screens 2E, the flagged retune of Part
     1's 74px guess): "GAME BOY COLOR" and "JUL 2026" both need 82 at 13px
     mono, and the count column never exceeds four characters. The same grid
     draws the By-platform bar list (.bar-row below). The card's show-all
     checkbox (2E) unfolds every bucket past the newest six, in place. */
  .growth-col:nth-last-child(-n + 6),
  .card:has(.growth-show-all-toggle:checked) .growth-col {
    display: grid;
    grid-template-columns: 82px minmax(0, 1fr) minmax(40px, max-content);
    grid-auto-rows: auto;
    gap: 2px 12px;
    align-items: center;
    height: auto;
    min-height: 32px;
  }

  .growth-seg {
    grid-column: 2;
    width: var(--seg);
    height: 8px;
    max-width: none;
    min-height: 0;
    min-width: 3px;
    border-radius: 2px;
  }

  .growth-col .growth-seg:first-child {
    border-radius: 2px;
  }

  .growth-lab {
    display: block;
    grid-column: 1;
    grid-row: 1 / span 2;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  .growth-val {
    display: block;
    grid-column: 3;
    grid-row: 1 / span 2;
    text-align: right;
    color: var(--text);
  }

  .growth-axis {
    display: none;
  }

  /* ---- Shelves are rails, with the trap closed (spec §05D) -------------
     88px tiles so 390px shows three full tiles and a clear half of the
     fourth — the clipped tile is the scroll affordance. Full-bleed past the
     page gutter; snap; overscroll contained so a rail never captures the
     page's vertical scroll. */
  /* BOTH selectors, deliberately: .shelf-list .shelf-row is (0,2,0) at desktop
     and specificity beats a media query, so a bare .shelf-row here would lose
     to it and the collection's shelves would stay a six-column grid — and,
     worse, keep the overflow-x: visible that lets 88px tiles pan the page. */
  .shelf-row,
  .shelf-list .shelf-row {
    grid-template-columns: none;
    grid-auto-flow: column;
    grid-auto-columns: 88px;
    grid-template-rows: auto;
    /* The rail IS the scroll container — without this the tiles overflow and
       pan the whole document instead of scrolling inside it. */
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    overscroll-behavior-x: contain;
    touch-action: pan-x pan-y;
    scroll-padding-inline: var(--gutter);
    margin-inline: calc(var(--gutter) * -1);
    padding-inline: var(--gutter);
  }

  .shelf-row > * {
    scroll-snap-align: start;
  }

  /* ---- The ownership bar grows a real target (spec §03) ----------------
     44px bar; the single right-side action becomes a bordered cell filling
     the bar's full height. Same markup, +1 border. */
  .quick-add-bar {
    height: 44px;
    padding: 0 0 0 10px;
    gap: 8px;
  }

  .quick-add-bar > a.quick-add-bar-action,
  .quick-add-bar > .quick-add-bar-form {
    align-self: stretch;
  }

  .quick-add-bar .quick-add-bar-form button {
    height: 100%;
  }

  .quick-add-bar .quick-add-bar-action {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    height: 100%;
    padding: 0 12px;
    border-left: 1px solid;
    border-left-color: inherit;
    background: var(--fill);
    color: var(--text);
  }
}

/* NOTHING hides the toolbar's Sort menu in the desktop list view. A rule here
   used to, on the grounds that the mode's clickable column headers (spec 150)
   were its sort surface — which only held while every sort token had a column
   to click. `popular` (issue #941, the browse default) and the contextual
   `relevance` have none, so that mode could not choose the ordering it was
   showing. Menu and headers now draw together, both from the same resolved
   token; test_catalog_sort_surfaces.py fails if a display: none comes back. */

/* --- The compact layer (≤520): one column, no exceptions ----------------- */
@media (max-width: 520px) {
  :root {
    --gutter: 16px;
  }

  /* Landscape notch: the page wrapper alone clears the safe areas. */
  .container {
    padding-left: max(var(--gutter), env(safe-area-inset-left));
    padding-right: max(var(--gutter), env(safe-area-inset-right));
  }

  .brand {
    font-size: 1.0625rem;
  }

  /* Labelled controls never fit two-up on a phone (spec §08). The opt-out is
     .copy-field--half, for a pair whose values are ≤3 characters (mobile
     screens 2D) — nothing claims it since Rating became a full-width tick bar
     and Hours lost its short partner. */
  .copy-form-grid,
  .copy-form-grid--thirds,
  .copy-price-parts,
  .copy-price-total {
    grid-template-columns: 1fr 1fr;
  }

  .copy-form-grid > .copy-field:not(.copy-field--half),
  .copy-price-parts > .copy-field,
  .copy-price-total > .copy-field {
    grid-column: 1 / -1;
  }

  /* The view panel's static pairs are values, not controls, and two short ones
     ("42 hours" / "WATA 9.4") still read fine side by side — but a pair holding
     a live select is a control, so it takes the full width (spec §08). Since
     #1202 that is six of them rather than two, which is what the rule was
     always going to mean once the copy facts became editable: a phone gets one
     labelled control per row and the leftover static pairs still double up. */
  .copy-pairs {
    gap: 0.9rem 1rem;
  }

  .copy-pairs > .copy-pair:has(.inline-set) {
    grid-column: 1 / -1;
  }
}

/* ============================================================================
 * The mobile screens (issue #914 — "Mobile Screens", part 2)
 *
 * Nine Tier-1 screens assembled from Part 1's four patterns — nothing new is
 * invented here, each block below is a per-screen expression of §05 A–D plus
 * the pass's four NEW CALLs: the ≤360 control-row gap step, the 82px bar-list
 * label column (applied to the Part 1 growth grid above), the 36px fixed rail
 * caption, and the sheet's pinned footer. Base rules first (the phone-only
 * apparatus stays out of the desktop render), then handheld ≤720, compact
 * ≤520, and the one sanctioned off-scale query at ≤360.
 * ======================================================================== */

/* --- Phone-only apparatus, parked at every width ------------------------- */

/* NOTE: .shelf-cap is no longer phone-only. The refined shelves caption every
   cover at every width (design "Collection Refined"), so its base rule lives
   with the rest of the shelf styles above; the handheld layer below only
   re-shapes it into the rail's fixed 36px box. */

/* The tracked bar's "Manage" cell text (2C): desktop keeps the ▾ caret. */
.split-add-manage {
  display: none;
}

/* The entry masthead pill (2D): drawn only at compact, where the 72px cover
   thumb loses its badge overlay. */
.entry-head-status {
  display: none;
}

/* The stats bar lists (2E): the handheld reading of the By-platform card. */
.bar-list {
  display: none;
}

.bar-lab-short {
  display: none;
}

/* The growth chart's show-all fold (2E): handheld-only — the desktop column
   chart always draws every bucket. The checkbox reuses the sheet toggle's
   visually-hidden treatment; its <label> is the visible control. */
.growth-show-all-toggle {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.growth-show-all {
  display: none;
}

/* The sheet footer (2A/2B): phone furniture, gone from the desktop bar. */
.filter-sheet-foot {
  display: none;
}

/* The nav-panel search (2B): the field and its Go submit are ONE 48px bordered
   box — leaf magnifier, the input, then the submit flush against the right
   edge. The form carries the chrome and the input goes bare inside it, so the
   focus ring can wrap the whole control (#934: ring the field, not a child —
   an inset ring on the bare input would paint behind the Go cell). The panel
   itself only renders below 721. */
.nav-panel .nav-search form {
  margin: 0;
  display: flex;
  align-items: stretch;
  height: 48px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  overflow: hidden;
}

.nav-search-icon {
  flex: 0 0 auto;
  align-self: center;
  margin-inline-start: 14px;
  color: var(--accent-text);
}

.nav-panel .nav-search input {
  flex: 1;
  min-width: 0;
  margin: 0;
  height: 100%;
  min-height: 0;
  background: none;
  border: 0;
  border-radius: 0;
  padding: 0 0 0 10px;
}

.nav-panel .nav-search input:focus,
.nav-panel .nav-search input:focus-visible {
  outline: none;
}

.nav-panel .nav-search form:focus-within {
  outline: 2px solid var(--accent-text);
  outline-offset: -2px;
}

/* 2B draws Go in the leaf accent, not the neutral submit fill. ⚠️ It must be
   scoped past `button[type="submit"]` (0,1,1) — a bare `.nav-search-go` (0,1,0)
   loses, and the cell silently renders in the neutral ink fill instead. */
.nav-panel .nav-search-go,
.nav-panel .nav-search-go:hover {
  flex: 0 0 auto;
  border: 0;
  border-radius: 0;
  padding: 0 18px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: var(--fs-ui);
  font-weight: 600;
}

/* The sheet footer's Clear twin: below 721 it is the footer row's second cell,
   above it the desktop copies take over — the chip row's "Clear all" while
   chips show, the "Clear filters" fallback (the collection's extras row, the
   catalog's meta-row tail) while an active filter draws no chip. (The
   collection Sort twin 2A drew alongside it is gone — with the refine row
   replaced by the design's filter bar, Sort lives on the results row at every
   width, so there is one instance rather than a desktop copy and a phone
   twin.) */
@media (min-width: 721px) {
  .filter-sheet-clear {
    display: none;
  }
}

/* --- Handheld (≤720): the nine screens ----------------------------------- */
@media (max-width: 720px) {
  /* ---- 2B · The catalog's search bar -----------------------------------
     The search bar gets a row to itself. Filters and Sort used to share a
     second row under it; since design "Collection Grid Spec" 06 §C both live
     on the results toolbar's one control row instead, beside the view button
     (see "The control row" below), so the form's own control row is gone. */

  /* Active filters tint the trigger (2A/2B draw it accented whenever the
     count badge is populated) — the badge is emptied and refilled with the
     results swap, so :has() tracks it live with no JS. Both pages' triggers
     sit in the results toolbar now (§C); .filter-panel is the collection's
     older full-width placement, kept for the states that still render it. */
  .filter-panel > .filter-sheet-trigger:has(.filter-sheet-count:not(:empty)),
  .toolbar-controls > .filter-sheet-trigger:has(.filter-sheet-count:not(:empty)) {
    border-color: var(--accent-text);
    border-color: color-mix(in srgb, var(--accent) 45%, transparent);
    background: color-mix(in srgb, var(--accent) 8%, transparent);
  }

  /* Nothing caps the bar's right edge any more, so the search field is a
     whole rounded box.

     48px is the drawn height of the whole box (2B). The global handheld field
     rule sizes the INPUT to 48, which the wrapper's hairlines then push to
     50, so the inner control gives back the 2px — the target stays 48, well
     over the 44 floor, and the 16px font that guards against iOS focus zoom
     is untouched. */
  .filter-row > .search-field {
    min-height: 48px;
    border-inline: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
  }

  /* The id is load-bearing, not decoration: the global handheld field rule is
     `input:not(…)` five times over — specificity (0,5,1) — so a class-only
     selector here silently loses and the box stays 50px. */
  #catalog-search-form .search-field input[type="search"] {
    min-height: 46px;
  }

  /* ---- The control row (design "Collection Grid Spec" 06 §C) -------------
     One 48px row: Filters and Sort split the slack, the labelled view button
     keeps its content width (its own rules above). BOTH list pages draw it —
     the collection since §C, the catalog since it adopted the same switcher —
     so everything page-agnostic below is scoped to .results-toolbar and only
     the per-page occupants (the collection's shelves segs, the catalog's page
     size) stay scoped to their own page.

     The Filters trigger lives inside the swap region, beside its row-mates,
     reaching its checkbox in the static form by id; the base
     .filter-sheet-trigger rules give it its typography, this block its row
     geometry and the border its old .filter-controls parent used to supply.

     The toolbar's own hairline stands down: the results line below the row
     ("24 of 717" over its rule) is the separator the design draws between
     the controls and the results. */
  .results-toolbar {
    border-bottom: 0;
    padding-bottom: 0;
    margin-bottom: 11px;
  }

  /* The collection's meta cell stands down WHOLESALE at this width (issues
     #1364, #1363), the way the catalog's does below. The chip row is all it
     holds on this page, and the chips now have a phone home of their own in
     every view: under the control row in grid and list, under the grouping row
     in shelves.

     The cell renders ABOVE that control row, so a chip appearing in it pushed
     the whole page down — the Filters/Sort/view row, the "27 SHELVES BY …"
     sentence in shelves, the results line and the run itself. Every control
     moved to make room for a report about them. Empty, it was worse than
     nothing twice over: it still cost a flex line, and because
     .toolbar-controls is flex-basis 100% here the toolbar's 12px row-gap then
     fell between the search field and the control row on top of the form's own
     11 — the 11 + 12 gap that opened under the search box where §C draws 11.

     This replaces the two rules that were approximating it (the empty cell in
     every view, and the whole cell in shelves): at this width the cell has no
     occupants at all. Desktop keeps it either way — it is what holds the
     controls against the row's right edge, and where the chips belong beside
     the controls that set them. */
  .results-toolbar--collection > .results-meta {
    display: none;
  }

  /* The catalog's meta cell stands down WHOLESALE at this width (issue #1271).
     Both of the things it holds have a phone home of their own now: the result
     count moved to the hairline results line under the control row (design
     "Collection Grid Spec" 06 §C, ported), and the region-suppression call-out
     is the region chip in the row under the controls (design "Region Chip Spec"
     15). Neither belongs in a cell that draws ABOVE the controls — which is
     exactly what the call-out did until #1271: a footnote printed before the
     count it qualified, with Filters/Sort/View wedged between the two.

     This replaces three rules (the count's hide, the hairline's, and the cell's
     `:not(:has(.region-suppressed))` stand-down) with the one they were
     approximating: at this width the cell has no occupants at all. */
  #catalog-results > .results-toolbar > .results-meta {
    display: none;
  }

  /* ONE row on both pages (issue #1265). The catalog used to need an explicit
     two-row grid here because its row carried a fourth member the collection's
     did not — the page-size select, which belonged on a line of its own rather
     than squeezed in beside the three named controls. §F's "no per-page
     setting anywhere in the UI" now holds site-wide, so that member is hidden
     at this width and the row collapses to the three controls the design
     draws: Filters and Sort split the slack, the labelled view button keeps
     its content width. Which is the collection's row exactly — hence one
     rule for both. */
  .results-toolbar .toolbar-controls {
    flex: 1 1 100%;
    gap: 8px;
  }

  /* Filters and Sort split the slack EVENLY — which takes a SHARED flex basis,
     not `auto` (issue #1459). Under `flex: 1 1 auto` each half's base size is
     its own content width — "Filters" at 600 plus its count badge, against
     "Sort ▾" at 400 — and grow only shares what is left OVER, so the two
     finish exactly as far apart as their two labels happen to render. That is
     a font-metric accident rather than a layout: the same page puts them
     0.03px apart locally and 2.59px apart on CI's browser, which is what kept
     `ui-test` red on every push to main. Give both halves the same base size
     and grow alone sizes them, so they come out equal by construction in any
     rendering environment.

     That shared basis has to be a PERCENTAGE, though — 0 will not do, because
     the two halves are not the same kind of box. Filters is the button itself,
     carrying the row's 14px padding and 1px border on the flex item; Sort is a
     `<details>` WRAPPER whose padding and border sit on its inner `> summary`
     (they have to: the shared .column-picker component draws and inverts that
     summary). Under `box-sizing: border-box` a basis of 0 cannot resolve to a
     border box smaller than its own padding + border, so it floors at 30px for
     Filters and 0px for Sort — leaving them 30px apart, WORSE than `auto`.
     A percentage is padding-independent, so both resolve to the same number.

     20% is picked to sit mid-band between the two constraints, at every width
     this block covers (verified 320–720):
       · it must clear that 30px floor, or the clamp above returns — 20% of the
         narrowest row (288px at a 320 viewport) is 57.6px;
       · the row must stay in GROW, never shrink: shrinkage is scaled by each
         item's CONTENT box, which is where the padding asymmetry lives, so a
         shrinking row goes uneven again (50% measures 11px apart at 390).
         Two 20% halves plus the view button and gaps ask 237 of that 288.
     `test_the_control_row_halves_share_one_basis` pins both rules to one
     value, since ui-test does not run on pull requests.

     The note this replaces kept `auto` for a reason that has since expired:
     #1172 wrote it when the Shelve-by / Order segs shared this row in SHELVES
     mode, where a basis-0 item shrank to min-content and five members
     overflowed 390px instead of wrapping (scrollWidth 404). #1192 then moved
     those segs out — spec 10 draws them in the `.shelf-controls` chip row
     below, and `.results-toolbar--collection .search-seg--shelves` hides them
     in this same block — so every view on both pages now draws exactly the three
     members §C specifies, on a row that is `nowrap` anyway. */
  .results-toolbar .toolbar-controls > .filter-sheet-trigger {
    flex: 1 1 20%;
    min-width: 0;
    min-height: 48px;
    justify-content: center;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
  }

  .results-toolbar .toolbar-controls > .sort-picker {
    flex: 1 1 20%;
    min-width: 0;
  }

  /* The Sort button matches its row-mates: bordered 48px box reading
     "Sort ▾" (the shared .column-picker caret), regular weight against the
     trigger's 600 — Filters is the row's primary. */
  .results-toolbar .sort-picker > summary {
    justify-content: center;
    width: 100%;
    min-height: 48px;
    padding-inline: 14px;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    font-size: var(--fs-ui);
    font-weight: 400;
    color: var(--text);
  }

  /* Open: the button inverts like the view trigger's open state (§C3 draws
     the pressed member of this row in full ink). Restated over the shared
     .column-picker[open] accent, which would otherwise win on color. */
  .results-toolbar .sort-picker[open] > summary {
    background: var(--text);
    border-color: var(--text);
    color: var(--bg);
    font-weight: 600;
  }

  /* The caret must keep its width when the label runs long. */
  .results-toolbar .sort-picker > summary::after {
    flex: none;
  }

  /* The button reads "Sort ▾" — no value echo (the row is three-up on a
     phone width; a variable-length value is what breaks the arithmetic). It
     stays in the accessibility tree (clipped, not display:none) so the
     summary still announces the active sort. */
  .results-toolbar .sort-picker .sort-current {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  /* The Sort panel opens as a bottom sheet in the view sheet's chrome (§C:
     Filters, Sort and View all present the same way): handle + mono label
     (the .view-sheet-head in _collection_sort.html), 52px rows, leaf ✓ on the
     active sort, heavier scrim. The <details> re-renders closed with the
     sorted results, so picking a row dismisses the sheet by itself. */
  .results-toolbar .sort-picker .picker-panel,
  .shelf-chip .picker-panel {
    display: flex;
    flex-direction: column;
    gap: 0;
    position: fixed;
    inset: auto 0 0 0;
    z-index: 60;
    width: auto;
    min-width: 0;
    max-height: 85svh;
    overflow-y: auto;
    overscroll-behavior: contain;
    background: var(--surface);
    border: 0;
    border-top: 1px solid var(--border-strong);
    border-radius: 14px 14px 0 0;
    box-shadow: 0 -18px 44px rgba(0, 0, 0, 0.4);
    padding: 8px 8px calc(12px + env(safe-area-inset-bottom));
  }

  /* ...except the Sort menu takes a shorter cap (issue #1622). 85svh is the
     family's ceiling, not its size: every sheet above is shorter than that and
     just draws to its content. The collection's Sort menu is the first that
     isn't — issues #1613/#1615 took it from 9 rows to 17, and 17 × 52px REACHES
     the cap, at which point the sheet is 717 of an 844px phone and the dim
     above it is 127px. A sheet has to leave enough of the page behind it to
     show what you are about to re-order; at that height it reads as a page.
     68svh is the number .shelf-jump already picked for the same reason (its
     comment: short of the bottom), and it puts the panel back at 574px with a
     270px strip of page in the dim — against the 536px/308px the 9-row menu
     drew.

     Same selector as the shared rule above and the same (0,3,0), so ONLY its
     later position carries it; the shelf chips keep 85svh, which their four
     rows have never come near. The menu still scrolls — 17 rows do not fit any
     sheet — which is what the sticky head below is for.

     The top padding moves INTO that head with it. A sticky top: 0 anchors on
     the scrollport's CONTENT edge, not its border edge, so with the 8px left
     here the head pinned 8px low and the rows kept scrolling through the band
     above it — under the rounded corner, which is the artifact this was meant
     to end. Held by the head, the same 8px is above the handle at rest and
     opaque once pinned. */
  .results-toolbar .sort-picker .picker-panel {
    max-height: 68svh;
    padding-top: 0;
  }

  /* The chrome has to stay put now that the panel scrolls (issue #1622). The
     panel IS the scroll container here — unlike .shelf-jump, which moves its
     scroll onto an inner list — so the head sticks to the top of it rather
     than being pinned by the layout: sticky is the phone sheet's idiom, and it
     keeps the handle where the swipe-to-dismiss gesture (app.js SHEET_PANELS)
     advertises it. The background is load-bearing twice over: rows pass UNDER
     the head instead of through it, and the panel's rounded top corner clips a
     plate of --surface instead of the first row's letters. */
  .results-toolbar .sort-picker .picker-panel > .view-sheet-head {
    position: sticky;
    top: 0;
    z-index: 1;
    padding-top: 8px;
    background: var(--surface);
  }

  .results-toolbar .sort-picker .picker-panel a {
    display: flex;
    align-items: center;
    min-height: 52px;
    padding: 0 12px;
    border: 1px solid transparent;
    border-radius: 5px;
    font-size: var(--fs-body);
    color: var(--text);
    white-space: normal;
  }

  .results-toolbar .sort-picker .picker-panel a[aria-current="true"] {
    background: var(--accent-dim);
    border-color: var(--accent-border);
    font-weight: 600;
    color: var(--text);
  }

  .results-toolbar .sort-picker .picker-panel a[aria-current="true"]::after {
    content: "\2713";
    margin-left: auto;
    color: var(--accent-text);
  }

  .results-toolbar .sort-picker[open]::before,
  .shelf-chip[open]::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 55;
    background: rgba(10, 9, 6, 0.62);
    /* Like the add-popover scrim, it swallows the dismissing tap (issue
       #1185). Paint-only let that tap through to the tabs / card covers /
       Filters trigger it was drawn over, so cancelling the Sort sheet also
       navigated. app.js reads a click targeting the open <details> itself —
       which is what a click on this ::before reports — as an outside click. */
    pointer-events: auto;
  }

  body:has(.results-toolbar .sort-picker[open]),
  body:has(.shelf-chip[open]) {
    overflow: hidden;
  }

  @media (prefers-reduced-motion: no-preference) {
    .results-toolbar .sort-picker[open] .picker-panel,
    .shelf-chip[open] .picker-panel {
      animation: entry-sheet-in 180ms ease-out;
    }

    .results-toolbar .sort-picker[open]::before,
    .shelf-chip[open]::before {
      animation: entry-scrim-in 120ms ease-out;
    }
  }

  /* No page-size setting on phones (§F: "no per-page setting anywhere in the
     UI") — the end-of-run block below is the paging control. BOTH browse
     pages, since issue #1265: the catalog's select was the only per-page
     control left in the phone UI, and the only undesigned control in its
     row (borderless, transparent, a type step LARGER than its bordered
     row-mates, alone on a line of its own). Hidden, not dropped: the select
     keeps serializing with the form, so a desktop-chosen size survives a
     narrow viewport and still sets the Show-more batch size.

     The no-JS twin (issue #1470) stands down with it, for the same reason and
     not merely to match: §F is a statement about the phone UI, not about the
     select. Left in, the sizes claimed a fourth cell in a three-cell row and
     pushed the view button off the screen edge. */
  .results-toolbar select[name="per_page"],
  .results-toolbar .page-size-links {
    display: none;
  }

  /* The pagination nav yields to Show-more on phones (§F) — on both pages
     since issue #1265, which is what makes the site's paging vary on the
     VIEWPORT rather than on which browse page you are reading.

     🚨 Hiding it is also the fix for a real defect: `.pagination ul` is
     `display: flex` with the default `nowrap` and every cell is
     `white-space: nowrap`, so the row's min-content width is a hard floor.
     On a 358px content column the catalog's row needed 369px at page 2 of 12
     and 521px at page 1,205 of 2,241 — it panned the whole document sideways
     from page 2 onward, on every run longer than four pages. No test caught
     it because no phone-width test ever loaded ?page=2.

     Hidden, not dropped: the ?page= link set stays in the markup at every
     width, so crawlers and JS-off readers keep every path into the catalog
     (and the sitemap, issue #467, already decoupled discoverability from
     this control anyway). */
  #collection-results .pagination,
  #catalog-results .pagination {
    display: none;
  }

  /* ---- The results line (§C): "24 of 717" against a hairline ------------- */
  .results-count-line {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0 0 14px;
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  .results-count-line .results-count-rule {
    flex: 1;
    height: 1px;
    background: var(--border);
  }

  /* The catalog's line carries the "· N in your collection" call-out as well as
     the count, which is longer than the collection's bare "24 of 717". Flex
     items floor at min-content, so without this the pair would push the
     hairline off the row at 320; with it the text wraps inside its own cell. */
  #catalog-results .results-count-line .results-count {
    min-width: 0;
  }

  /* ---- The shelves controls (design "Collection Shelves Controls" spec 10)
     The same row, in shelves view: the count becomes "27 SHELVES BY" and the
     two settings only this view has ride in it as chips. Its trailing
     hairline is dropped (§A) — the first maker rule sits 14px below and is
     the rule the eye reads.

     The two "Shelves by" / "Order" segs in the control row above stand down
     here. Hidden, not dropped: their selects are what serialize the grouping
     with the filter form, so a filter change on a phone would otherwise reset
     the shelves to console. The chips compose their own URLs instead. */
  .results-toolbar--collection .search-seg--shelves {
    display: none;
  }

  /* And their no-JS twins stand down with them (issue #1483): the grouping is
     the chip pair below at this width, whose sheet rows are already plain
     links, so the twins have nothing to add and §C's row has no cell to spare.

     🚨 The WRAPPER, not the <details> inside it. With scripting off a
     <noscript> is a real element, so an emptied one is still a flex item and
     still claims a gap in a three-cell row — which is the row #1470's page-size
     twin was pushed off the edge of. `none` is the one display a <noscript> may
     safely take (the ban is on revealing its raw source to a SCRIPTED visitor),
     and :has(> .seg-picker) can only match when the parse was non-scripted. */
  .results-toolbar--collection noscript:has(> .seg-picker) {
    display: none;
  }

  .shelf-controls {
    display: flex;
    align-items: center;
    /* §B5: a chip too wide for the line wraps to a second 48px line rather
       than truncating — the VALUE is the reason the chip exists. Flex wrap
       does that on its own; the zero row-gap keeps the two lines reading as
       one block. */
    flex-wrap: wrap;
    gap: 0 7px;
    min-height: 48px;
    /* §B: the shelf run starts 14 below the row, as the grid does under the
       results line. */
    margin: 0 0 14px;
  }

  .shelf-controls-lead {
    flex-shrink: 0;
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  /* THE TRICK (§B3): the <summary> is the 48×48 target and the focus ring;
     the 30px plate inside it is only paint. Padding the plate to reach 48
     would break the sentence rhythm the row is built on (§F). */
  .shelf-chip > summary {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0;
    height: 48px;
    min-width: 48px;
    padding: 0;
    border: 0;
    border-radius: 0;
    background: none;
    color: inherit;
  }

  /* The shared .column-picker caret sits OUTSIDE the plate; this chip draws
     its own inside it (§B), so the pseudo-element stands down. */
  .shelf-chip > summary::after {
    display: none;
  }

  /* The open <details> keeps the picker's accent border/ink by default; here
     the plate carries the whole pressed state, so the summary stays neutral. */
  .shelf-chip[open] > summary {
    border-color: transparent;
    color: inherit;
  }

  .shelf-chip-plate {
    display: flex;
    align-items: center;
    gap: 6px;
    box-sizing: border-box;
    height: 30px;
    padding: 0 9px;
    border: 1px solid var(--border-strong);
    border-radius: 4px;
    background: var(--surface);
    font-family: var(--font-mono);
    /* BADGE mono (12px), not the lead's 13px — the one exemption the phone
       type scale (§02) carves out, and the size §E's arithmetic is drawn at.
       At --fs-mono the two plates measure 92 + 151 against a lead of 101, so
       the row overflowed 358 by 0.7px and wrapped to two 48px lines at 390 —
       the width the spec draws it fitting on one. At badge size they measure
       81 + 141, which is §E's "347 of 358" to the pixel. The plate is a
       control label, which is exactly what that exemption is for; the lead is
       reading matter and keeps the results line's 13px. */
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--text);
  }

  .shelf-chip-caret {
    font-size: 0.8em;
    color: var(--text-faint);
    transition: transform var(--speed) ease;
  }

  /* Pressed / open (§B4): the plate inverts and the ▾ flips to ▴ — a rotation,
     so one glyph draws both states and the caret takes the plate's ink. */
  .shelf-chip[open] .shelf-chip-plate {
    background: var(--text);
    border-color: var(--text);
    font-weight: 600;
    color: var(--bg);
  }

  .shelf-chip[open] .shelf-chip-caret {
    transform: rotate(180deg);
    color: var(--bg);
  }

  /* The ring traces the 48px box, not the plate (§F). */
  .shelf-chip > summary:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* ---- The two sheets (§C): the view sheet's rows, relabelled ------------
     Panel chrome, scrim, swipe and dismissal are shared with the Sort sheet
     above (one definition, both consumers); what differs is the row, which is
     the VIEW sheet's — icon, name, description, leaf ✓ — because these rows
     pick a setting the way the view rows pick a mode. */
  .shelf-sheet-option {
    display: flex;
    align-items: center;
    gap: 14px;
    min-height: 64px;
    padding: 8px 12px;
    border: 1px solid transparent;
    border-radius: 5px;
    box-sizing: border-box;
    color: var(--text);
  }

  /* `a:hover` (0,1,1) outranks a lone class, so the underline has to be
     turned off explicitly — and on a flex-display link it would paint twice
     over, once per blockified item (the #1180 trap). */
  .shelf-sheet-option:hover {
    color: var(--text);
    text-decoration: none;
  }

  .shelf-sheet-icon {
    width: 20px;
    flex-shrink: 0;
    color: var(--text-muted);
  }

  .shelf-sheet-text {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
  }

  .shelf-sheet-name {
    font-size: var(--fs-body);
  }

  .shelf-sheet-desc {
    font-size: var(--fs-ui-sm);
    line-height: 1.45;
    color: var(--text-faint);
  }

  .shelf-sheet-check {
    width: 20px;
    flex-shrink: 0;
    margin-left: auto;
    text-align: right;
    color: var(--accent-text);
    visibility: hidden;
  }

  /* Applied row (§C2): leaf tint + border + ✓, and the description warms to
     muted — it is the one row whose copy is worth reading twice. */
  .shelf-sheet-option[aria-current="true"] {
    background: var(--accent-dim);
    border-color: var(--accent-border);
  }

  .shelf-sheet-option[aria-current="true"] .shelf-sheet-name {
    font-weight: 600;
  }

  .shelf-sheet-option[aria-current="true"] .shelf-sheet-icon,
  .shelf-sheet-option[aria-current="true"] .shelf-sheet-check {
    color: var(--accent-text);
  }

  .shelf-sheet-option[aria-current="true"] .shelf-sheet-desc {
    color: var(--text-muted);
  }

  .shelf-sheet-option[aria-current="true"] .shelf-sheet-check {
    visibility: visible;
  }

  .shelf-sheet-option:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* An order this axis doesn't offer (Maker timeline off console shelves,
     issue #860): drawn, dimmed, and not a control at all — it is a <span>,
     so there is nothing to tap and nothing in the tab order. */
  .shelf-sheet-option--off {
    opacity: 0.45;
  }

  /* ---- The shelf jump menu (design "Shelf Jump Menu" spec 12) ------------
     The row above gains a second job. At rest it is spec 10 unchanged — the
     sentence and the two chips. When it reaches the header it PINS and swaps
     its contents for a shelf rail: an NN/27 ▾ hinge, then one tab per shelf,
     active tab pulled to the left edge. Same 48px row, same page height, no
     new control anywhere (§A).

     ⭐ ≤720, not the spec's ≤1024 — Max's call, and the same one spec 10 took:
     the row this pins only exists at ≤720, because that is where the app's
     handheld layer flips. 721–1000 keeps the desktop segs and no rail; 1001+
     keeps the sticky .shelf-index column.

     The hinge, the rail and the sheet are spec 11's stats rail, re-pointed:
     one component the user only has to learn once, and one set of rules
     rather than two. What differs is that there is no contents index at rest
     — the row is already saying something — so the rail exists only while
     pinned. */

  :root {
    --shelf-rail-h: 48px;
    /* §C: header + rail, and it stays that in every state, because the sheet
       opens OVER the page rather than expanding the row. Shelf heads clear
       it by 8. */
    --shelf-stack-h: calc(var(--header-h) + var(--shelf-rail-h));
    /* The chip foot: 8 top + a 48px hit box + 2 bottom + its rule (§C). The
       jump panel stops here, so the two read as one sheet. */
    --shelf-foot-h: calc(59px + env(safe-area-inset-bottom));
  }

  /* 🚨 flow-root, not block: .shelf-controls' own 14px bottom margin would
     COLLAPSE THROUGH a padding-less wrapper, so the height app.js measures
     and freezes would come up 14px short of the space the row actually holds
     — and the page would jump by exactly that at the pin point. flow-root
     contains the margin; nothing moves visually. (Spec 11's stats wrapper
     lost 22px to the same trap.) */
  .shelf-chrome {
    display: flow-root;
  }

  /* Every shelf head clears the pinned stack, so the row that lights is the
     one a jump lands on, by construction (§C). Only ever exercised while
     pinned — at rest this band has no in-page shelf links at all. */
  .shelf {
    scroll-margin-top: calc(var(--shelf-stack-h) + 8px);
  }

  /* At rest the chips are the row's own flex items, exactly as spec 10 left
     them: the foot is a box only once it is a foot. */
  .shelf-chip-foot {
    display: contents;
  }

  .shelf-nav,
  .shelf-chip-foot-lead {
    display: none;
  }

  /* ---- The row, pinned (§B②, §C) ----------------------------------------
     🚨 z-index 46 at rest and 56 for the row that owns an open sheet.
     `position: fixed` ALWAYS establishes a stacking context, and the chips'
     scrim (55) and panels (60) are DESCENDANTS of this row — so they are
     clamped into whatever layer it occupies, and a scrim that cannot
     out-paint the site header (50) leaves the header live under it. The
     dismissing tap then lands on .brand or on a rail tab and navigates:
     issue #1185 again, the same way spec 11's pinned rows found it. Only one
     picker is ever open (app.js closes the rest), so the :has() lifts exactly
     one row. */
  .shelf-chrome.is-pinned > .shelf-controls {
    position: fixed;
    top: var(--header-h);
    left: 0;
    right: 0;
    z-index: 46;
    align-items: stretch;
    box-sizing: border-box;
    height: var(--shelf-rail-h);
    min-height: 0;
    margin: 0;
    gap: 0;
    flex-wrap: nowrap;
  }

  .shelf-chrome.is-pinned > .shelf-controls:has(.column-picker[open]) {
    z-index: 56;
  }

  /* 🚨 The tint and blur ride a pseudo-element, never the row itself: a
     backdrop-filter makes its element the containing block for FIXED
     descendants, which would re-anchor all three sheet panels to a 48px rail
     instead of the viewport.

     🚨 And the rule under the row rides the pseudo too, because `inset: 0`
     resolves against the PADDING box — a border-bottom on the row itself
     lands OUTSIDE both the tint and the blur, reading as a sharp 1px strip of
     the scrolling page. (Extending the pseudo over it would not do: a
     negative-z child paints AFTER its parent's border, so it would cover the
     rule rather than back it.) */
  .shelf-chrome.is-pinned > .shelf-controls::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    border-bottom: 1px solid var(--border-strong);
    background: var(--header-tint);
    -webkit-backdrop-filter: blur(8px);
    backdrop-filter: blur(8px);
  }

  /* The fade always means "more ahead" (§B4c), so it stands down at the end of
     the run rather than reading as "you missed some" — app.js sets the flag. */
  .shelf-chrome.is-pinned > .shelf-controls::after {
    content: "";
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: 44px;
    background: linear-gradient(90deg, rgba(0, 0, 0, 0), var(--bg));
    pointer-events: none;
  }

  .shelf-chrome.is-pinned > .shelf-controls[data-rail-end]::after {
    display: none;
  }

  .shelf-chrome.is-pinned .shelf-controls-lead {
    display: none;
  }

  .shelf-chrome.is-pinned .shelf-nav {
    display: flex;
    align-items: stretch;
    flex: 1;
    min-width: 0;
  }

  /* ---- The rail (§B②, §C) ----------------------------------------------- */
  .shelf-rail {
    display: flex;
    flex: 1;
    min-width: 0;
    list-style: none;
    margin: 0;
    padding: 0;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
  }

  .shelf-rail::-webkit-scrollbar {
    display: none;
  }

  .shelf-rail > li {
    flex: none;
  }

  /* §B4a: 48 tall, no gaps between tabs, never truncating and never wrapping
     — the label is the whole point of the tab, and it is the same label the
     sheet uses (§F). BADGE mono, because §C draws the rail at 12px and the
     phone type scale bumps plain --fs-mono to 13 — the same exemption the
     spec 10 chip plate takes, and for the same reason: this is a control
     label, not reading matter. */
  .shelf-rail-tab {
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    min-width: 52px;
    height: var(--shelf-rail-h);
    padding: 0 13px;
    border-bottom: 2px solid transparent;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--text-muted);
  }

  /* `a:hover` (0,1,1) outranks a lone class — and on a phone it also fires on
     tap, which would underline the tab you just left. */
  .shelf-rail-tab:hover {
    color: var(--text-muted);
    text-decoration: none;
  }

  .shelf-rail-tab:active {
    background: var(--fill);
  }

  /* Never colour alone (§E): the leaf underline is paired with 600 weight and
     the brightest ink.

     🚨 This rule and `.shelf-rail-tab:hover` above are BOTH (0,2,0) — an exact
     tie, so source order alone decides it. Moved above hover, a cursor (or a
     lingering tap) crossing the rail blanks the tab you are on. Same shape as
     the stats rail's own note (#1215): a state selector that ties with :hover
     is a source-order bug waiting for someone to reorder the file. */
  .shelf-rail-tab[aria-current="location"] {
    border-bottom-color: var(--accent-text);
    font-weight: 600;
    color: var(--text);
  }

  /* ---- The hinge (§C) ---------------------------------------------------
     NN/27 ▾, 48 tall and never narrower than 64. The padding is what sets the
     width rather than a flat 64: §C sizes the plate around mono at 9.5px
     while this band's badge mono is 12, and at a fixed 64 the fraction sits
     hard against the viewport edge on one side and the divider on the other.
     §E's 48×64 is a floor for the tap target, not a ceiling. */
  .shelf-chrome.is-pinned .shelf-jump {
    display: block;
    position: static;
    flex: none;
  }

  .shelf-chrome.is-pinned .shelf-jump > summary {
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    min-width: 64px;
    height: var(--shelf-rail-h);
    padding: 0 9px;
    border: 0;
    border-right: 1px solid var(--border);
    border-radius: 0;
    background: none;
    color: var(--text-faint);
  }

  /* The hinge draws its own caret inside the NN/27 plate, so the shared
     .column-picker pseudo-caret stands down. */
  .shelf-jump > summary::after {
    display: none;
  }

  /* Two flex items, so the 6px lands once — between the fraction and the
     caret. The tracking sits on the fraction, not the container: on the
     container it trails the caret too, which reads as lopsided padding. */
  .shelf-hinge {
    display: flex;
    align-items: center;
    gap: 6px;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
  }

  .shelf-hinge-count {
    letter-spacing: 0.08em;
  }

  .shelf-hinge-caret {
    font-size: 0.8em;
    color: var(--text-muted);
    transition: transform var(--speed) ease;
  }

  .shelf-chrome.is-pinned .shelf-jump[open] > summary {
    background: var(--fill);
    color: var(--text);
  }

  .shelf-chrome.is-pinned .shelf-jump[open] .shelf-hinge-caret {
    transform: rotate(180deg);
    color: var(--text);
  }

  /* ---- The jump sheet (§C) ----------------------------------------------
     The spec 10 sheet with different rows: same panel, handle, scrim,
     dismissals, instant apply, no Done. Two things are its own — it stops
     short of the bottom so the chip foot can sit under it, and its scroll is
     on the LIST rather than the panel, so the head stays put and the maker
     labels have something to stick to. */
  .shelf-jump .picker-panel {
    display: flex;
    flex-direction: column;
    gap: 0;
    position: fixed;
    inset: auto 0 var(--shelf-foot-h) 0;
    z-index: 60;
    width: auto;
    min-width: 0;
    max-height: calc(68svh - var(--shelf-foot-h));
    overflow: hidden;
    background: var(--surface);
    border: 0;
    border-top: 1px solid var(--border-strong);
    border-radius: 14px 14px 0 0;
    box-shadow: 0 -18px 44px rgba(0, 0, 0, 0.4);
    padding: 8px 8px 0;
  }

  .shelf-jump-total {
    margin-left: auto;
    color: var(--text-faint);
  }

  .shelf-jump-list {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding-bottom: 8px;
  }

  /* §C: 30 tall, tracked mono, sticky to the top of the scroll area. Not a
     target — a heading (§E) — so it has no hover, no focus and no href. */
  .shelf-jump-group {
    display: flex;
    align-items: center;
    position: sticky;
    top: 0;
    z-index: 1;
    box-sizing: border-box;
    height: 30px;
    margin: 4px 0 0;
    padding: 0 10px;
    background: var(--surface);
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    font-weight: 500;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  /* 48px rows, mono, at §C's "pad 0 18" — 10 here plus the panel's own 8. */
  .shelf-jump-row {
    display: flex;
    align-items: center;
    gap: 12px;
    box-sizing: border-box;
    min-height: 48px;
    padding: 0 10px;
    border-radius: 5px;
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-muted);
  }

  .shelf-jump-row:hover {
    color: var(--text-muted);
    text-decoration: none;
  }

  .shelf-jump-row:active {
    background: var(--fill);
  }

  .shelf-jump-name {
    min-width: 0;
  }

  .shelf-jump-count {
    margin-left: auto;
    font-size: var(--fs-mono-badge);
    color: var(--text-faint);
  }

  /* The shelf you are on: tint plus a leaf edge, never colour alone. A tint
     rather than the chips' accent fill because the row's own ink has to stay
     --text — an accent wash under 13px mono is the pairing the shelf index
     had to measure twice before it landed.

     🚨 Ties with `.shelf-jump-row:hover` at (0,2,0), like the rail tab above:
     it has to stay BELOW hover or a tap that lingers on the way down the sheet
     un-marks the row you are on. */
  .shelf-jump-row[aria-current="location"] {
    background: var(--fill);
    box-shadow: inset 2px 0 0 var(--accent-text);
    font-weight: 600;
    color: var(--text);
  }

  /* ---- The chip foot (§C) -----------------------------------------------
     The one place this sheet diverges from spec 10's (§A "CHANGED"): the
     grouping and order chips ride at the foot of the jump sheet rather than
     in the pinned row. Grouping is set once a session and the jump menu is
     used continuously, so the chips are one tap from the control you already
     opened and never compete for the row.

     They do not MOVE in the DOM — a second copy would mean duplicate ids and
     two controls posting one setting. The foot is display: contents at rest
     and a fixed strip once pinned, and it paints only while the jump sheet is
     open. 🚨 Its box has to stay in the layout even when it is invisible:
     tapping a chip closes the jump sheet (app.js's one-picker-at-a-time), so
     a foot that went display: none on that transition would take the chip's
     own panel down with it, mid-open. Hiding the CHILDREN instead leaves the
     <details> in place with its fixed panel free to paint. */
  .shelf-chrome.is-pinned .shelf-chip-foot {
    display: flex;
    align-items: center;
    gap: 7px;
    position: fixed;
    inset: auto 0 0 0;
    z-index: 60;
    box-sizing: border-box;
    padding: 0;
  }

  .shelf-chrome.is-pinned .shelf-chip-foot-lead,
  .shelf-chrome.is-pinned .shelf-chip-foot > .shelf-chip > summary {
    display: none;
  }

  .shelf-chrome.is-pinned:has(.shelf-jump[open]) .shelf-chip-foot {
    height: var(--shelf-foot-h);
    padding: 8px 18px calc(2px + env(safe-area-inset-bottom));
    border-top: 1px solid var(--border);
    background: var(--surface);
  }

  .shelf-chrome.is-pinned:has(.shelf-jump[open]) .shelf-chip-foot-lead {
    display: block;
    flex: none;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  .shelf-chrome.is-pinned:has(.shelf-jump[open]) .shelf-chip-foot > .shelf-chip > summary {
    display: flex;
  }

  /* ---- Scrim, lock and focus (§C, §E) ----------------------------------- */
  .shelf-jump[open]::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 55;
    background: rgba(10, 9, 6, 0.62);
    /* Swallows its own dismissing tap (issue #1185): paint-only would let the
       cancel land on the rail tab drawn under it and navigate. */
    pointer-events: auto;
  }

  /* 🚨 The page behind holds still — and it is this, not the panel's
     overscroll-behavior, that makes it hold on WebKit: Safari ignores
     overscroll-behavior on a box that is not actually scrollable, which a
     short shelf list is not (issue #1257). Scoped inside this block on
     purpose: a <details>'s [open] survives a resize, so an unscoped :has()
     would strand a resized-to-desktop page with a locked body. */
  body:has(.shelf-jump[open]) {
    overflow: hidden;
  }

  .shelf-rail-tab:focus-visible,
  .shelf-jump-row:focus-visible,
  .shelf-jump > summary:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* ---- Motion (§D) ------------------------------------------------------
     A crossfade of the row's contents at fixed height when it pins, the
     sheet's own entry, and the rail's pull to the left edge — app.js asks for
     the scroll and CSS decides whether it animates. All three are dropped
     under reduced motion; the rail still tracks position. */
  @media (prefers-reduced-motion: no-preference) {
    .shelf-chrome.is-pinned > .shelf-controls {
      animation: pin-row-in 180ms ease-out;
    }

    .shelf-jump[open] .picker-panel {
      animation: entry-sheet-in 180ms ease-out;
    }

    .shelf-jump[open]::before {
      animation: entry-scrim-in 120ms ease-out;
    }

    .shelf-rail {
      scroll-behavior: smooth;
    }
  }

  /* ---- The end of the run (§F) ------------------------------------------
     Both browse pages, since issue #1265 unified the catalog's phone paging
     on this block. Nothing below names either page: the two templates draw
     the same markup under the same classes, differing only in the wrapper's
     id and — deliberately — the catalog's missing Show-all row. */
  .load-more {
    display: flex;
    flex-direction: column;
    gap: 10px;
    margin-top: 8px;
  }

  .load-more-button {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 48px;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    font-size: var(--fs-ui);
    color: var(--text);
  }

  .load-more-button:hover {
    text-decoration: none;
    color: var(--text);
  }

  /* Loading (§F): the label swaps but the box holds, so nothing under the
     finger moves; htmx's own .htmx-request class is the state carrier. */
  .load-more-loading {
    display: none;
    align-items: center;
    gap: 9px;
    color: var(--text-faint);
  }

  .load-more-dots {
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    color: var(--accent-text);
  }

  .load-more-button.htmx-request {
    border-color: var(--border);
  }

  .load-more-button.htmx-request .load-more-label {
    display: none;
  }

  .load-more-button.htmx-request .load-more-loading {
    display: flex;
  }

  .load-more-count {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    margin: 0;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  /* The Show-all link is the line's one live control: leaf-voiced, and
     padded out to a 44px target without disturbing the mono line's height. */
  .load-more-all {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    margin-block: -14px;
    color: var(--accent-text);
  }

  .load-more-end {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 48px;
    margin: 0;
    border-top: 1px solid var(--border);
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  .load-more-end:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* ---- 2A/2B · The sheet footer ----------------------------------------
     [Show N results][Clear] is the sheet's pinned primary row; Clear keeps
     its place in the extras directly above, so the pair reads together at
     the sheet's end. margin-top: auto pins the footer to the sheet's bottom
     edge even when the accordion content runs short. */
  .filter-sheet-panel .filter-sheet-foot {
    display: flex;
    gap: 10px;
    position: sticky;
    bottom: 0;
    z-index: 2;
    margin: auto -16px 0;
    padding: 10px 16px calc(12px + env(safe-area-inset-bottom));
    background: var(--surface);
    border-top: 1px solid var(--border);
  }

  /* Outranks the panel's generic full-width submit styling ((0,2,1) from the
     Part 1 sheet block) for the catalog's footer button. */
  .filter-sheet-panel .filter-sheet-foot .filter-sheet-apply {
    flex: 1;
    width: auto;
    min-height: 48px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.35rem;
    margin: 0;
    border: 0;
    border-radius: var(--radius-sm);
    background: var(--accent);
    color: var(--accent-ink);
    font-size: var(--fs-ui);
    font-weight: 600;
    cursor: pointer;
  }

  .filter-sheet-panel .filter-sheet-foot .filter-sheet-apply:hover {
    background: var(--accent-strong);
    color: var(--accent-ink);
  }

  /* The footer's second cell: the spec's fixed 88px [Clear] beside the
     primary — an outlined ghost, not a second fill, so the row reads as one
     action plus an escape hatch. This is the handheld twin of the extras
     row's "Clear filters" link (see _*_clear_filters.html); the desktop copy
     is hidden below so exactly one is ever on screen. It is a link with its
     own hx-get, so tapping it swaps the results and lets the count above it
     refresh out-of-band without closing the sheet. */
  .filter-sheet-panel .filter-sheet-foot .filter-sheet-clear {
    flex: 0 0 auto;
    width: auto;
    min-width: 88px;
    min-height: 48px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    margin: 0;
    padding-inline: 14px;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    background: transparent;
    color: var(--text);
    font-size: var(--fs-ui);
  }

  /* Clear now lives in the sheet's footer row at this width — one rule for
     both lists' meta-row copies (issue #1521). */
  .filter-meta-clear {
    display: none;
  }

  /* The collection trigger reads as the control row's full-width primary. */
  .filter-panel > .filter-sheet-trigger {
    width: 100%;
    justify-content: center;
  }

  /* ---- 2A · Status tabs: navigation scrolls, it never folds ------------
     The tab bar stacks (mock order: tabs, then search + view toggle); the
     tabs stay out of the sheet and ride the same full-bleed rule as the
     rails; each becomes a bordered 44px segment (the underline idiom has no
     room to breathe in a scroll row), the active tab in full ink. */
  /* flex-wrap: wrap is the ROW layout's device for dropping the tools onto
     their own line; stacking makes it not just redundant but harmful, so it is
     switched off here rather than left to be counteracted downstream. A
     wrapping COLUMN container sizes its flex line to the items' max-content,
     and .collection-tabs is a nowrap flex row whose min-content IS the whole
     strip — so the line took 479px, both children stretched to it, and the
     document panned sideways while the rail measured clientWidth ==
     scrollWidth and never scrolled inside itself (issue #1023; the same rule
     the .results-meta note below states for #948). nowrap makes the single
     line take the container's inner width instead, which is also what keeps
     the rail's full-bleed margins reaching both viewport edges — clamping the
     rail with max-width: 100% stops the pan too, but leaves it two gutters
     short of the right edge. */
  .collection-tabbar {
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: stretch;
    row-gap: 0.75rem;
    /* No rule under the tabs on phones (design "Collection Grid Spec" 06
       §C draws the All/Owned/Wish segments floating free): the base rule's
       border-bottom belongs to the desktop underline-tab idiom these
       bordered segments replaced. */
    border-bottom: 0;
    padding-bottom: 0;
    /* §C stacks the tabs, the title search and the Filters/Sort/view row as
       one column with an even 11px between members (the frames are 390×812 at
       1:1 — the same 11 the toolbar already carries below). Both this margin
       and the form's below were left at their desktop values, so the search
       field sat 14 under the tabs and 24 over the controls; the rhythm read as
       a gap that had opened under the search box. */
    margin-bottom: 11px;
  }

  /* The other half of that pair. Scoped to the collection's form by id because
     .filter-panel is the catalog's form too, and its bar carries a meta row
     under it that this page has no twin of — so the catalog states its own 11
     beside that row's (see "The rhythm under the search bar" below). Both pages
     draw §C's control row now; only the stack above it differs. */
  #collection-filter-form {
    margin-bottom: 11px;
  }

  .collection-tabs {
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    gap: 8px;
    margin-inline: calc(var(--gutter) * -1);
    padding-inline: var(--gutter);
    scrollbar-width: none;
  }

  .collection-tab {
    flex: none;
    white-space: nowrap;
    padding: 0 14px;
    margin-bottom: 0;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
  }

  .collection-tab[aria-current="page"] {
    background: var(--text);
    border-color: var(--text);
    color: var(--bg);
  }

  .collection-tab[aria-current="page"] .tab-count {
    color: inherit;
  }

  /* ---- The collection head on phones (design 06 §C) ----------------------
     The stats line ends in the IMPORT › link — the phone's import home — and
     the head's Export/Import button pair stands down (export has no entry
     point on this screen below desktop widths; it lives on the import page
     and in nav). The line is a 44px flex row that wraps AT MOST once at
     320px: the link segment drops whole to a second line inside the same
     box, never breaking internally (inline-flex). */
  .page-head-actions--collection {
    display: none;
  }

  #collection-head-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    column-gap: 6px;
    min-height: 44px;
  }

  .head-meta-import-seg {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    white-space: nowrap;
  }

  /* The link is the target, not the row (§C): a 44px-tall inline box whose
     negative block margins keep the mono line's height honest. It draws no
     rule of its own — a border here lands at the bottom of the 44px, a whole
     half-line clear of the words, which is the second underline the target
     grew when it was drawn on this box. The label inside carries it instead;
     the browser default text-decoration stands down (the `p a` in-text rule
     that used to overrule this one excludes the class now — see "In-text
     links" at the top of the sheet). */
  .head-meta-import {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    margin-block: -14px;
    color: var(--accent-text);
    text-decoration: none;
  }

  /* The design's underline: one continuous 1px leaf hairline under "Import ›",
     1px clear of the text. A border rather than text-decoration on purpose —
     the label is an inline-flex box (the › keeps its 5px of air), and a
     decoration on a flex container propagates into each item separately, so it
     would break across that gap. */
  .head-meta-import-label {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    padding-bottom: 1px;
    border-bottom: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
  }

  .head-meta-import:hover {
    text-decoration: none;
    color: var(--accent-text);
  }

  .head-meta-import:hover .head-meta-import-label {
    border-bottom-color: var(--accent-text);
  }

  /* The scroll rail below only holds the viewport if the box it measures
     itself against does (issue #948). Both the chip row's container and the
     results meta block are flex items, so their automatic minimum size is
     their MIN-CONTENT size — and a nowrap chip's min-content is the whole
     pill. One long label ("platform: Super Nintendo Entertainment System ✕")
     therefore widened the block past the viewport, the rail full-bled off
     that inflated box, and the document panned sideways with nothing
     scrolling inside itself. Flooring the minimum lets the block shrink to
     the row, which is what the rail needs before its own overflow can do any
     work. The chips moved up into .filter-meta (design "Catalog Refined"), so
     that is the box that has to be floored now; .results-meta keeps the floor
     because the region-suppressed note it still holds is a long line too. */
  .filter-meta,
  .results-meta {
    min-width: 0;
  }

  /* ---- The Advanced filters at handheld widths (issue #1163) -------------
     On phones the Advanced panel lives INSIDE the filter sheet, behind its
     own accordion row — the sheet is where the spec folds every filter
     (§05A), and #1060 had left the long-tail filters out of it. The meta
     row's "＋ Advanced" trigger is desktop furniture here (a sub-44px text
     link the scrim covered whenever the sheet was open), so it stands down
     along with its hairline. The panel ships CLOSED at every width (the
     count badges and the chip row carry applied state). */
  .filter-meta > .advanced-trigger,
  .filter-meta > .filter-meta-sep {
    display: none;
  }

  /* The checkbox leaves the tab order with its desktop label: with the meta
     trigger hidden, focusing it would paint a ring on nothing. The sheet's
     Advanced row is its own focusable control (tabindex + app.js), so
     keyboard access moves there. visibility, not display — a label's
     activation still reaches a visibility-hidden checkbox. */
  .advanced-toggle {
    visibility: hidden;
  }

  /* ---- The rhythm under the search bar (design "Collection Grid Spec" 06 §C)
     §C stacks the search field and the Filters/Sort/view row 11 apart, and the
     catalog draws that row now — it is the collection's, ported. Three desktop
     values were stacking up in that gap instead, and NONE of them belongs to
     anything drawn at this width:

       12  .filter-meta's top margin — a row whose every phone occupant has
           moved out from under it. The Advanced trigger and its hairline stand
           down just above, Clear went to the sheet's footer, and the chip rail
           and the column picker are each already hidden in the states where
           they have nothing to say. So on the grid view with no filters on,
           this margin was 12px of air over a box of height 0.
        8  its row-gap, spent on a line holding only .spacer — the chip rail is
           `width: 100% + both gutters` (it full-bleeds), so nothing can share
           its line and the spacer wraps below it as a zero-height flex line
           that the gap then paid for anyway.
       24  the form's own bottom margin, the .filter-panel default.

     36px under an unfiltered bar where §C draws 11 — the same three-value pile
     the collection's bar carried before #1180, and the same 36. The fix (#1281)
     was the mirror of that one: the row's spacing moved onto the children that
     ACTUALLY draw, so an empty row cost exactly nothing and every member that
     appeared sat the same 11 below the last.

     Two of the three were the row's own (a 12px top margin and an 8px row-gap
     over a box of height 0), and they are gone with the row: since #1264 the
     whole of .filter-meta lives inside the sheet panel, which is display: none
     at this width until the sheet opens. So the row costs the page nothing here
     without a rule having to say so, and the column picker — the last occupant
     that still drew, and the reason the spacing used to have to ride the
     children rather than the row — draws as an accordion row inside the sheet
     instead. What is left of the pile is the form's own bottom margin, below. */

  /* The meta row's chip rail stands down with the rest of its desktop
     occupants (issue #1281): below 721 the rail the page draws is the twin
     under the control row, further down this sheet. Hidden rather than
     dropped from the template — it is the hx-swap-oob target every live swap
     re-sends, so it has to stay in the document at every width. */
  .filter-meta > .filter-chips {
    display: none;
  }

  /* The last of the three. Scoped by id: .filter-panel is also the collection's
     form, which sets its own 11 below (#1180). */
  #catalog-search-form {
    margin-bottom: 11px;
  }

  /* The sheet's Advanced row: a 48px full-bleed list row like the facet
     summaries above it (same explicit-width reasoning as their rule). */
  .filter-sheet-panel > .sheet-advanced-row {
    display: flex;
    align-items: center;
    width: calc(100% + 32px);
    min-height: 48px;
    margin: 0 -16px;
    padding: 0 18px;
    border-bottom: 1px solid var(--border);
    font-size: var(--fs-ui);
    color: var(--text);
    cursor: pointer;
  }

  .filter-sheet-panel > .sheet-advanced-row .seg-label {
    flex: 1;
  }

  .filter-sheet-panel > .sheet-advanced-row .seg-caret {
    margin-left: 0.5rem;
  }

  .advanced-toggle:checked ~ .filter-sheet-panel > .sheet-advanced-row .seg-caret {
    transform: rotate(180deg);
  }

  .sheet-advanced-row:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* The open panel reads as the row's filled accordion block, exactly like an
     open facet (spec §05 mock A): full bleed, --fill, indented off its title,
     card chrome gone. No inner scroll cap — the sheet is the scroll region
     (the same rule the facet menus follow) — except the genuinely long
     language and theme lists below. flex/order reset the desktop layout
     devices: in the sheet's flex COLUMN a 100% basis is a full-viewport
     HEIGHT claim, and order: 1 would sort the panel past the pinned foot,
     divorcing it from the row that opens it. The .filter-row prefix is
     specificity, not scoping — the desktop rule is (0,3,0), so a shorter
     selector here silently loses every one of these declarations. */
  .filter-row .filter-sheet-panel > .advanced-panel {
    flex: none;
    order: 0;
    margin: 0 -16px;
    padding: 0.85rem 18px 0.9rem 28px;
    border: 0;
    border-bottom: 1px solid var(--border);
    border-radius: 0;
    background: var(--fill);
  }

  /* One column per group: three desktop tracks at 390px ellipsized every
     label down to "Ho…" and "P.." — the counts survived, the words did not. */
  .advanced-groups {
    grid-template-columns: minmax(0, 1fr);
    gap: 1rem;
  }

  /* Each group is short enough to read whole once it has the full width, so
     the desktop per-column scroll cap only gets in the way — except for the
     language and theme lists, which are genuinely long at any width (~100 and
     ~20 rows at 44px each). Mode's handful of rows stays uncapped with the
     rest. overflow-y is restated because the panel now sits inside the sheet
     (issue #1163), where the accordion rule `.filter-sheet-panel .filter-menu
     .menu-options { overflow: visible }` outranks the base auto — a cap
     without a scroll paints the tail of the list straight over the next
     group. */
  .advanced-panel .advanced-options {
    max-height: none;
  }

  .advanced-panel .advanced-group:has(#language-menu-options, #theme-menu-options) .advanced-options {
    max-height: 13rem;
    overflow-y: auto;
  }

  /* 44px rows: the whole option is the target (mobile layer spec §03). */
  .advanced-options label.check {
    min-height: 44px;
  }

  /* Inside the sheet the panel's Apply is redundant twice over: changes are
     live there (data-sheet-live re-issues them as filterchange), and the
     no-JS path already has the sheet foot's own submit. Reset advanced and
     the applied tally stay. */
  .filter-sheet-panel .advanced-foot .advanced-apply {
    display: none;
  }

  /* ---- The chip rail's phone home: under the control row (issue #1281) ----
     The rail names what "Filters (N)" is holding, so it reads below that row
     rather than above it — this copy is the one on screen at this width, the
     meta row's having stood down above. It is a block child of
     #catalog-results, not a flex item, so the full bleed needs no explicit
     width (the `.filter-chips` rule below supplies the negative margins and
     `width: auto` fills what is left); that is what the meta row's copy had to
     spell out, and the reason its rule is gone rather than re-pointed.

     `:has(li)` is doing two jobs. It keeps an empty rail from claiming its
     44px band — the base `.filter-chips:not(:has(li))` hide is (0,1,1) and
     could not have held off a bare (0,2,0) show — and it makes this the
     STRONGEST of the three rules that touch the twin's display, so the order
     of the two-class hide above and the plain `.filter-chips` display below
     stops mattering.

     margin-block: nothing above (the toolbar's own 11 draws that gap), 11
     below to the results line — §C's rhythm, the same 11 the rail took in its
     old home above the controls. */
  .filter-chips.filter-chips--handheld:has(li) {
    display: flex;
    margin-block: 0 11px;
  }

  /* The collection draws the same twin, and in GRID and LIST it takes the rule
     above unchanged: one control row, the rail under it, §C's 11 either side
     (issue #1363). Its toolbar supplies the 11 above exactly as the catalog's
     does, and the "24 of 717" results line follows it.

     SHELVES is the exception — there the rail sits one row further down, under
     the "27 SHELVES BY …" sentence rather than under the control row, so that a
     chip appearing moves nothing above it. That row's rhythm is spec 10 §B's
     14, not §C's 11: the row above already sets 14 below itself
     (`.shelf-controls`), and §B has the shelf run start 14 under whatever
     precedes it. Matching the two keeps the rail evenly spaced between them
     instead of reading as attached to the shelves.

     Keyed off the TOOLBAR's data-view rather than a class of its own, because
     that attribute is already how this page's CSS reads the active mode; `~`
     because the grouping row sits between the two in the view this matches.
     (1,3,0) against the (0,2,1) above, so it outranks it wherever the two meet
     rather than depending on being read second. The display and the full bleed
     stay that rule's — this amends the one value the shelves placement
     changes. */
  #collection-results > .results-toolbar--collection[data-view="shelves"] ~ .filter-chips--handheld {
    margin-block: 0 14px;
  }

  /* ---- 2A · Chips: 36px pills on their own scroll line -----------------
     The ✕ is a 36px cell inside the pill; the 44px row band is the target
     buffer (§03 note in the mock). One line, full bleed, clipped-pill scroll
     affordance — same rule as the rails. */
  .filter-chips {
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
    /* Its own full line of whichever flex row holds it, so the full-bleed
       margins never pull it up alongside a sibling control. That is the
       COLLECTION's rail now (it rides .results-meta, beside the toolbar
       controls); the catalog's phone copy is a block child of #catalog-results
       and ignores this, and the meta row's copy no longer draws at this width
       at all — see the two rules above. */
    flex: 1 0 100%;
    min-width: 0;
    min-height: 44px;
    gap: 8px;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    margin-inline: calc(var(--gutter) * -1);
    padding-inline: var(--gutter);
    scrollbar-width: none;
  }

  .filter-chips::before {
    content: none;
  }

  .filter-chips li {
    flex: none;
  }

  /* `gap: 0` is for the ✕ ONLY: .chip-x below is a 36px cell flush with the
     pill's right edge, and a gap before it would open a strip of fill the tap
     target does not cover. It has to be paid for on the axis side, though —
     the axis and value are two flex items separated by nothing but source
     whitespace, which generates no box inside a flex container, so zeroing the
     row's gap fused them into "PLATFORMSNES" (issue #1181). The margin below
     puts the separation back where only that pair can spend it. */
  a.chip--removable {
    min-height: 36px;
    gap: 0;
    padding: 0 0 0 13px;
    border-radius: 18px;
    white-space: nowrap;
  }

  /* The 0.4rem the base rule's gap drew, restated as a margin so it stops at
     the value instead of reaching the ✕ (both pages draw this chip, so this
     restores the catalog's row as well as the collection's). */
  .chip--removable .chip-axis {
    margin-inline-end: 0.4rem;
  }

  /* Same repair for the region chip's two extra members (issue #1271). The
     row's `gap: 0` above is there so the ✕ cell stays flush, and whitespace
     between flex items generates no box — which is what fused "PLATFORM SNES"
     into "PLATFORMSNES" in #1181. Left alone it would read "NTSC-U·50 HIDDEN".
     Symmetrical around the separator, and nothing before the ✕: the 36px cell
     centres its glyph, so the count already has air on that side and a margin
     there would open a strip of pill the tap target does not cover. */
  .chip-region .chip-sep {
    margin-inline: 0.4rem;
  }

  .chip--removable .chip-x {
    width: 36px;
    min-height: 36px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  /* ---- 2B · Catalog cards: the ownership slot joins the 44px floor ------
     Part 1 grew the tracked bar; the untracked "＋ Add / ♡ Wish" pair kept
     the desktop's 30px slot. One height for every state. */
  .browse-card .quick-add {
    height: 44px;
  }

  .browse-card .quick-add .quick-add-form button {
    height: 44px;
  }

  /* ---- 2C · Game detail: the ownership bar ------------------------------
     Tracked: the split control becomes a full-width 44px bar — status label
     left, a bordered "Manage" cell right (the quick-add-bar anatomy, so the
     two read as one family). Untracked: ＋ Add and ♡ Wish split the row
     2-up; the ▾ keeps its own cell. */
  .detail-add .detail-actions {
    gap: 8px;
  }

  .detail-add .split-add {
    flex: 1 1 auto;
    display: flex;
  }

  .detail-add .quick-add-form {
    display: flex;
    flex: 1 1 auto;
  }

  .detail-add .split-add .quick-add-form button {
    width: 100%;
    height: 44px;
  }

  .detail-add .split-add-more {
    height: 44px;
  }

  .detail-add .detail-actions > .quick-add-form button {
    width: 100%;
    height: 44px;
  }

  .detail-add .split-add--tracked,
  .detail-add .split-add--tracked .add-popover {
    width: 100%;
  }

  .detail-add .split-add-status {
    width: 100%;
    height: 44px;
  }

  .detail-add .split-add-status-label {
    flex: 1;
  }

  .detail-add .split-add-caret {
    min-width: 44px;
    justify-content: center;
    padding: 0 14px;
    background: var(--fill);
    color: var(--text);
    font-size: var(--fs-ui-sm);
    font-weight: 600;
  }

  .detail-add .split-add-caret::after {
    content: none;
  }

  .detail-add .split-add-manage {
    display: inline;
  }

  /* ---- 2D · Copy panel fields: 48px, above the 44 floor ----------------
     A field is the thing people miss most, and its 16px text needs the
     room. Radios/checkboxes keep their native size (the seg-control's
     inputs are visually hidden and must stay laid out as such). */
  input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]):not([type="file"]),
  select {
    min-height: 48px;
  }

  /* ---- 2E · Stats: the By-platform bar list -----------------------------
     label · bar · count on the growth chart's exact grid (the 82px NEW
     CALL); the seven-column table stands down below 721 — panning it told
     the phone nothing at a glance. */
  .card--bar-list .table-wrap {
    display: none;
  }

  .bar-list {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }

  .bar-row {
    display: grid;
    grid-template-columns: 82px minmax(0, 1fr) minmax(40px, max-content);
    gap: 12px;
    align-items: center;
    min-height: 32px;
  }

  .bar-lab {
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-faint);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .bar-track {
    display: block;
    height: 8px;
    border-radius: 2px;
    background: var(--surface-3);
    overflow: hidden;
  }

  .bar-fill {
    display: block;
    height: 100%;
    min-width: 3px;
    background: var(--accent);
  }

  .bar-val {
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    color: var(--text);
    text-align: right;
  }

  /* The growth fold's visible control: a 44px accent row under the bars. */
  .growth-show-all {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    min-height: 44px;
    margin-top: 0.25rem;
    border-top: 1px solid var(--border);
    color: var(--accent-text);
    font-size: var(--fs-ui);
    cursor: pointer;
  }

  .growth-show-all-toggle:focus-visible + .growth-show-all {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  .growth-show-all-less,
  .card:has(.growth-show-all-toggle:checked) .growth-show-all-more {
    display: none;
  }

  .card:has(.growth-show-all-toggle:checked) .growth-show-all-less {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
  }

  /* ---- 2F · Import report: the summary is a 2×2 hairline grid -----------
     and each ambiguous row reads as a decision card: full-width 44px radio
     rows, the whole option a target. The sequential flow those cards belong
     to arrives further down this block (issue #921); these rules dress the
     rows either way. */
  /* Hairlines drawn top/left on the grid and bottom/right per cell — not the
     stat-strip's 1px-gap trick, which would paint an odd count's empty cell
     as a solid block. An odd last tile spans the row instead. */
  .import-tiles {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0;
    border-top: 1px solid var(--border);
    border-left: 1px solid var(--border);
  }

  .import-tile {
    padding: 12px;
    border-right: 1px solid var(--border);
    border-bottom: 1px solid var(--border);
  }

  .import-tile + .import-tile {
    border-left: 0;
    padding-left: 12px;
  }

  .import-tile:last-child:nth-child(odd) {
    grid-column: 1 / -1;
  }

  .import-tile-value {
    font-size: 1.625rem;
  }

  .import-row-choices {
    flex-direction: column;
    gap: 6px;
  }

  .import-choice {
    width: 100%;
    min-height: 44px;
    box-sizing: border-box;
    padding: 0.45rem 0.8rem;
    font-size: var(--fs-ui);
  }

  .import-actions {
    flex-direction: column;
    align-items: stretch;
  }

  .import-actions .btn {
    justify-content: center;
  }

  /* ---- 2F · …and the decision flow itself (issue #921) ------------------
     The batch bucket stands down and the card flow takes its place: one row,
     its top three candidates, Confirm / Skip. Both stay in the DOM — the
     bucket's radios are what the final submit posts, and they mirror whatever
     the cards decided — but only one is ever on screen. */
  .import-bucket--review {
    display: none;
  }

  .import-cards {
    display: block;
    margin-bottom: 1.6rem;
  }

  .import-card {
    position: relative;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius);
    background: var(--surface);
    padding: 16px;
  }

  .import-card-count {
    margin: 0;
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: var(--accent-text);
  }

  .import-card-line {
    margin: 0.5rem 0 0;
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    color: var(--text-faint);
  }

  .import-card-title {
    margin: 0.25rem 0 0;
    font-family: var(--font-display);
    font-size: 1.25rem;
    line-height: 1.25;
    color: var(--text);
  }

  .import-card-choices {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin: 0.9rem 0 0;
    padding: 0;
    border: 0;
  }

  .import-card-foot,
  .import-card-empty,
  .import-card-done-sub {
    margin: 0.7rem 0 0;
    font-size: var(--fs-ui-sm);
    color: var(--text-muted);
  }

  .import-card-actions {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin-top: 0.9rem;
  }

  .import-card-actions .btn {
    justify-content: center;
  }

  /* The "More matches" sheet. Same apparatus as the filter sheet (spec §05A):
     a visually-hidden checkbox, a label trigger, :has() for the rest — the
     picker itself becomes the sheet rather than a second copy of the list, so
     a pick made in the sheet is the same input the card carries. */
  .import-card-more-toggle {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
  }

  .import-card-more {
    display: flex;
    align-items: center;
    min-height: 44px;
    margin: 0;
    color: var(--accent-text);
    font-size: var(--fs-ui);
    cursor: pointer;
  }

  .import-card-more-toggle:focus-visible ~ .import-card-more {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* Closed: the extras and the sheet's own chrome are simply absent. A chosen
     extra is the exception — it promotes itself into the card, so closing the
     sheet never leaves the card showing an empty choice. */
  .import-choice--extra,
  .import-card-scrim,
  .import-card-sheet-head {
    display: none;
  }

  .import-choice--extra:has(> input:checked) {
    display: inline-flex;
  }

  .import-card:has(.import-card-more-toggle:checked) .import-card-picker {
    position: fixed;
    inset: auto 0 0 0;
    z-index: 60;
    max-height: 85vh;
    max-height: 85svh;
    overflow-y: auto;
    overscroll-behavior: contain;
    background: var(--surface);
    border-top: 1px solid var(--border-strong);
    border-radius: 12px 12px 0 0;
    padding: 0 16px 16px;
  }

  .import-card:has(.import-card-more-toggle:checked) .import-choice--extra {
    display: inline-flex;
  }

  .import-card:has(.import-card-more-toggle:checked) .import-card-scrim {
    display: block;
    position: fixed;
    inset: 0;
    z-index: 55;
    /* labels carry a global margin-bottom that would nibble the fixed box */
    margin: 0;
    background: rgba(10, 9, 6, 0.45);
    cursor: pointer;
  }

  .import-card:has(.import-card-more-toggle:checked) .import-card-sheet-head {
    display: grid;
    grid-template-columns: 1fr auto;
    align-items: center;
    min-height: 52px;
    position: sticky;
    top: 0;
    z-index: 2;
    padding-top: 8px;
    background: var(--surface);
    border-bottom: 1px solid var(--border);
  }

  /* The shared sheet chrome's grab handle (design "Collection Grid Spec" 06,
     issue #1561) — the family pill, on its own grid row above the label and
     Done. It lives in the head rather than above it, as the filter sheet's
     does, because the head is what stays sticky while a long candidate list
     scrolls: a handle in panel flow would scroll away with the rows. */
  .import-card-sheet-handle {
    grid-column: 1 / -1;
    width: 38px;
    height: 4px;
    border-radius: 2px;
    background: var(--border-strong);
    margin: 2px auto 10px;
  }

  /* The page behind the sheet holds still. */
  body:has(.import-card-more-toggle:checked) {
    overflow: hidden;
  }

  .import-card-sheet-label {
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    font-weight: 500;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--text);
  }

  .import-card-sheet-close {
    margin-left: auto;
    min-width: 44px;
    height: 44px;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    color: var(--accent-text);
    font-size: var(--fs-ui);
    cursor: pointer;
  }

  /* Done is a focusable dismiss button now (issue #1045), so it needs the
     ring the "More matches" trigger already borrows from its checkbox. Inset,
     so it paints inside the 44px box rather than over the head's rule. */
  .import-card-sheet-close:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* ---- 2H · Recommendations: the row under design "Recommendation Row
     Action Bar Spec" 10 -----------------------------------------------------
     §B draws three parts on one line — the 62×84 thumbnail, the rank/title/
     meta column, and FIT — with the reason under them and ONE 48px action bar
     under that, spanning the full content width:

         [cover] [rank ]  [FIT ]
         [     ] [title]  [ 78 ]
         [     ] [meta ]  [meter]
         [ reason, in the title column                    ]
         [ ＋ Add | ▾            ⋯                        ]

     Two things came back from the pre-spec phone row for it. The RANK, which
     §A2 hands the job green gives up ("green can no longer also mean
     top-ranked… the rank number does that job instead"). And the SCORE, which
     goes back to exactly what desktop draws — Newsreader numeral over the
     meter — instead of the 44×44 leaf chip the mobile layer squared it into.
     Its colours are deliberately NOT touched, at either width: leaf still
     marks the #1 pick, so the two widths keep agreeing about what it means
     (§G's open item 2 asks for cream at both, which is a desktop change and
     out of scope this release). The 44px target the chip provided is kept by
     the .rec-score-details > summary rule in §01 above, and the explainer
     reverts to the popover desktop uses.

     The reason stays in the title column rather than going full-width with the
     bar: the spec's own header lists it UNCHANGED, and the bar is the part
     whose 358px measurement §B actually pins. */
  .rec-row {
    grid-template-columns: 62px minmax(0, 1fr) auto;
    column-gap: 12px;
    row-gap: 4px;
    align-items: start;
  }

  /* Two lines up the middle column, above the title — the rank is a fact
     about the row now, not a gutter number, so it reads with the title rather
     than beside the cover. */
  .rec-rank {
    display: block;
    grid-row: 1;
    grid-column: 2;
    padding-top: 0;
    font-size: var(--fs-mono);
    letter-spacing: 0.08em;
    color: var(--text-muted);
  }

  /* position: relative for the status strip, which is absolutely positioned
     across the artwork's bottom edge. */
  .rec-cover {
    display: block;
    position: relative;
    grid-row: 1 / span 2;
    grid-column: 1;
    width: 62px;
  }

  .rec-main {
    grid-row: 2;
    grid-column: 2;
  }

  /* FIT rides the full height of both head rows, right-aligned against the
     row's edge (§B). No display: contents here any more — the numeral, its
     caption and the meter are one stack again, and the explainer is the
     absolutely-positioned note the desktop ledger uses. */
  .rec-fit {
    display: flex;
    grid-row: 1 / span 2;
    grid-column: 3;
    flex-direction: column;
    align-items: flex-end;
    justify-content: flex-start;
    min-height: 0;
    padding-top: 0;
    text-align: right;
  }

  /* The column header is display: none at this width, so §B draws the label
     on every row instead. */
  .rec-fit-label {
    display: block;
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-muted);
  }

  .rec-fit-bar {
    display: block;
    width: 44px;
    margin-top: 6px;
  }

  /* The bar takes a full-width row of its own under the reason (§B: "the bar
     sits 10px under the reason", 358 of the 358 available). Its own row is
     also what keeps every control at 48px down to 320 without the teach
     column stealing width from it — the failure test_responsive_a11y_1037
     caught the last time these clusters shared a line. */
  .rec-actions {
    grid-row: 3;
    grid-column: 1 / -1;
    margin-top: 6px;
    justify-content: flex-start;
  }

  /* Teach moved into the bar's ⋯ sheet (§A): "the 26px teach arrows on their
     own line" are one of the things spec 10 deletes. The control stays in the
     DOM — it is the desktop ledger's, and a vote cast in the sheet swaps it
     out of band so it never goes stale behind the breakpoint. */
  .rec-teach {
    display: none;
  }

  /* The "▲ RATED UP" chip used to need a hide here, so the receipt line was
     the phone row's only vote trace. The chip is gone at every width now, so
     there is nothing left to hide: the receipt is the trace down here, and the
     thumbs themselves are it above 720. */

  /* The pickers keep a touch-sized row here; nothing else in .rec-actions is
     a .quick-add any more (see the desktop block). */
  .rec-actions .quick-add {
    height: 44px;
  }

  .rec-actions .quick-add .quick-add-form button {
    height: 44px;
  }

  /* ---- 2I · Shelves: mono headers, 44px See-all, 36px captions ----------
     The header drops to the rail's mono register; See-all keeps its 44px
     band. Captions are two clipped 13px lines in a fixed box so every tile
     in a rail sits at the same height mid-scroll (the NEW CALL) — the tile
     links to the full title, it isn't the title itself. */
  /* The head's three nowrap parts — the mono title, "1983 · 7 games", and the
     44px "View all N →" — total more than 288px of usable width at 320, and
     none of them may be clipped, so the row wraps instead of panning the page.
     The connecting rule is desktop furniture; on a wrapped head it would sit
     alone on a line. */
  .shelf-head {
    align-items: center;
    flex-wrap: wrap;
    row-gap: 0.25rem;
  }

  .shelf-head .shelf-rule {
    display: none;
  }

  .shelf-view-all {
    margin-left: auto;
  }

  .shelf-title {
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    font-weight: 500;
    letter-spacing: 0.06em;
    text-transform: uppercase;
  }

  .shelf-view-all {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    font-size: var(--fs-ui);
  }

  /* The catalog detail rail's head is only *two* of the parts the wrap above
     was drawn for — a title and "See all →" — and there that wrap was evicting
     the link against a width the title never uses (issue #1283). Flexbox
     breaks lines on each item's *hypothetical* main size, which for an
     auto-basis item is its max-content width — the title *unwrapped*:
     "More on Super Nintendo Entertainment System" at full stretch
     is wider than a 390px content box, so the link "doesn't fit" and starts a
     second line — and only *then* does the title wrap to two lines, freeing
     exactly the room the link was just pushed out of. The result was a short
     "ENTERTAINMENT SYSTEM" line with an empty band beside it and the link
     stranded on a third row.

     A zero flex-basis collapses that hypothetical size to the title's
     min-content width (its longest word), so the line-fitting decision is made
     against a width the title can actually take; flex-grow then hands it back
     everything "See all →" doesn't need, and the text wraps inside that box.
     min-width stays `auto` on purpose — a platform whose longest single word
     cannot share the row with the link still wraps, which is the honest
     fallback rather than an overflow. Scoped to .detail-rail because the
     shelves page's head carries a third part (.shelf-count) whose wrapping is
     the deliberate call documented above. */
  .detail-rail .shelf-title {
    flex: 1 1 0;
  }

  /* The index rail is a desktop affordance: at rail widths the shelves are
     already a horizontal scroll, and a second nav column would take a third
     of a 390px screen to duplicate what scrolling does. The shelves take the
     whole width instead. */
  .shelf-layout {
    grid-template-columns: minmax(0, 1fr);
    gap: 0;
  }

  .shelf-index {
    display: none;
  }

  /* The maker eyebrow's run totals wrap under the maker name rather than
     squeezing the rule to nothing. */
  .shelf-section {
    flex-wrap: wrap;
    row-gap: 0.25rem;
  }

  /* The corner flag is sized to a 3-column cover; on an 88px rail tile the
     desktop 20px reads as a quarter of the art. */
  .shelf-cover--owned .shelf-cover-art::after {
    width: 0.875rem;
    height: 0.875rem;
  }

  /* Both rails caption at handheld — the collection's shelves and the catalog
     detail page's "More on <platform>" run, whose caption is a bare text node
     rather than the collection's title + meta pair, so the type lives here as
     well as on .shelf-cap-title below. */
  .shelf-cap {
    display: block;
    height: 36px;
    margin-top: 6px;
    overflow: hidden;
    font-size: var(--fs-ui-sm);
    line-height: 1.35;
    color: var(--text-muted);
  }

  /* The rail tile is 88px wide: the title alone fills the fixed box, so the
     desktop caption's copy-state line sits the width out. Its text stays in
     the DOM — it is part of the tile's accessible name — but a rail caption
     that clipped mid-fact would read as a rendering bug. */
  .shelf-cap-title {
    font-size: var(--fs-ui-sm);
    font-weight: 400;
    line-height: 1.35;
    color: var(--text-muted);
  }

  /* Scoped to the collection: the catalog rail's own .shelf-cap-meta (the
     release year, issue #1056) is meant to show at every width, and hiding it
     this way would also send it out of flow with no positioned ancestor — the
     escape that pans the page (see .shelf-cap above). */
  .shelf-list .shelf-cap-meta {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  /* .shelf-cap covers the catalog rail's bare-text caption, .shelf-cap-title
     the collection's structured one. */
  .shelf-cover:hover .shelf-cap,
  .shelf-cover:hover .shelf-cap-title {
    color: var(--text);
  }

  /* The 88px tile can't fit "WISHLIST"; the pill keeps its head. */
  .shelf-cover .pill-tail {
    display: none;
  }
}

/* --- Signed-out action bar, narrow (design spec 13 §E) --------------------
   Issue #1291. The spec's own stop, 640 — between the sheet's handheld (720)
   and compact (520) steps, and deliberately its own: this is about the panel's
   two rows running out of horizontal room, which happens well before the page
   grid re-orders at 520.

   🚨 This block MUST sit after the touch-target block (~10,000 lines up), and
   its rules must out-specify `button[type="submit"]` / `.btn`. That block sets
   `min-height: 44px; padding-block: 0` on both, which is the floor talking —
   but the spec draws these controls at 46px from 13px of padding on 14px type,
   and a bare `.signed-out-lock` would lose the padding and land back on 44.
   Two classes (or a tag) settle it; see the base rules for the same fight.

   The panel is never sticky and never becomes a bottom sheet on scroll (§E):
   the detail page is short enough on a phone that a docked bar would compete
   with the platform tab bar, and a visitor who has scrolled past it has
   already declined once. */
@media (max-width: 640px) {
  .signed-out-locked {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 9px;
    padding: 16px 16px 14px;
  }

  .signed-out-lock,
  button.signed-out-lock {
    display: flex;
    width: 100%;
    /* 46, not the 44 floor: 13px of padding on --fs-ui-sm's phone step (14px)
       is what the spec draws, and the floor is a minimum, not a target. */
    min-height: 46px;
    padding: 13px 16px;
  }

  /* The stack already reads as inert, so the tag is dropped rather than given
     a line of its own (§E). aria-hidden already kept it out of the
     accessibility tree, so nothing is lost by hiding it here. */
  .signed-out-tag {
    display: none;
  }

  .signed-out-invite {
    display: block;
    padding: 16px;
  }

  .signed-out-headline {
    font-size: 1.1875rem;
  }

  /* Swap the subline for its shorter phone string. */
  .signed-out-sub--wide {
    display: none;
  }

  .signed-out-sub--narrow {
    display: block;
    margin-bottom: 14px;
  }

  .signed-out-cta {
    display: flex;
    align-items: center;
    gap: 14px;
  }

  /* The CTA takes the remaining width and Log in stays inline at its right
     (§E) — so the primary action is a full-width target and the secondary one
     never becomes a second stacked button competing with it. */
  a.signed-out-register {
    flex: 1 1 auto;
    justify-content: center;
    min-height: 46px;
    padding: 13px 16px;
    font-size: var(--fs-ui-sm);
  }

  /* An inline text link is not exempt from the floor just because it is text
     (the #1289 sweep's whole finding). It gets the 44px band as a box without
     becoming button-shaped. */
  a.signed-out-login {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    font-size: var(--fs-ui-sm);
  }
}

/* --- Compact (≤520) ------------------------------------------------------ */
@media (max-width: 520px) {
  /* "WISHLIST" → "WISH" on catalog cards (2B, the pass's one shortened
     string — open question 03, shipped as drawn). */
  .browse-card .pill-tail {
    display: none;
  }

  /* The entry masthead pill (2D): the badge the 72px cover thumb gave up. */
  .entry-head-status {
    display: inline-block;
    align-self: flex-start;
    margin-top: 0.35rem;
  }

  /* ---- 2C · Game detail: identity → state → facts -----------------------
     The mock stacks the header into three full-width bands in that order.
     Beside a 96px thumb only the title and release line fit, so the chips
     and description leave the second column for a band of their own, and
     the add control rises above them — state is second precisely so the
     one thing you came to tap is in the first viewport rather than below a
     four-line description.

     The DOM keeps the desktop 05.3 reading order (identity, blurb, state);
     display: contents hands the body's blocks — and the cover rail's — to
     .detail-head's own grid so the named areas can re-order them here. One
     DOM, both shapes — the Part 1 grid-area stacking idiom (entry detail,
     auth form-first).

     The cover rail is flattened rather than kept whole because only the art
     belongs in that 96px column: the ownership stamp and the availability
     line are full sentences, and squeezing them into a thumbnail's width
     would wrap "In collection" onto three lines. The stamp follows identity
     (it answers "do I have this?", the question the title raises) and the
     availability line falls to the bottom with the ledger, where it reads as
     the first fact rather than a caption on the cover. */
  .detail-head {
    grid-template-areas:
      "cover identity"
      "stamp stamp"
      "state state"
      "blurb blurb"
      "avail avail"
      "facts facts";
    row-gap: 1.25rem;
  }

  .detail-head-body,
  .detail-cover-rail {
    display: contents;
  }

  .detail-head .game-cover {
    grid-area: cover;
  }

  .detail-identity {
    grid-area: identity;
  }

  .detail-stamp {
    grid-area: stamp;
  }

  .detail-state {
    grid-area: state;
  }

  .detail-blurb {
    grid-area: blurb;
  }

  .detail-availability {
    grid-area: avail;
  }

  .detail-facts {
    grid-area: facts;
    margin-top: 0;
  }

  /* The band gap is the separation now; the control's own desktop offset
     would double it. Same for the signed-out action bar, which carries the
     equivalent offset for the same desktop reason (issue #1291). */
  .detail-state .detail-add,
  .detail-state .signed-out-bar {
    margin-top: 0;
  }
}

/* --- The 320 control row (≤360 — 2A's NEW CALL, the one off-scale query) --
   Two labelled controls plus an icon control total 296px of 288 at 8px gaps;
   4px gaps make the arithmetic exact. The stats bar lists swap to the
   curated platform short codes at the same stop. */
@media (max-width: 360px) {
  .collection-tabs,
  .filter-chips,
  .toolbar-controls,
  .detail-add .detail-actions {
    gap: 4px;
  }

  .bar-row:has(.bar-lab-short) .bar-lab-full {
    display: none;
  }

  .bar-row .bar-lab-short {
    display: inline;
  }
}

/* ---------------------------------------------------------------------------
 * Collection Stats dashboard (design "Collection Stats")
 *
 * The page's own layer over shared parts: the scope toolbar (platform menu =
 * the shared .column-picker, range group = plain links), numbered section
 * heads, the editorial lead + insight cards, the pace/money fact rails, and
 * single-series recolors of the growth chart pattern (ratings histogram,
 * spending by year). Series in any future two-series chart must separate by
 * lightness, not hue — the palette's second hue fails CVD checks (#719).
 * ------------------------------------------------------------------------ */

.stats-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem 1rem;
  padding: 0.6rem 0;
  border-block: 1px solid var(--border);
  margin-bottom: 1.25rem;
}

/* The platform menu's panel: one link per platform, all-time counts. */
.stats-scope-menu .picker-panel {
  min-width: 15rem;
  max-height: 21rem;
  overflow: auto;
}

.stats-scope-menu .picker-panel a {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.3rem 0.2rem;
  font-size: var(--fs-ui-sm);
  color: var(--text-muted);
}

.stats-scope-menu .picker-panel a:hover {
  color: var(--text);
}

.stats-scope-menu .picker-panel a[aria-current="true"] {
  color: var(--text);
  font-weight: 600;
}

.stats-option-count {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-faint);
}

/* The range group: three links in one bordered pill, current one inverted. */
.stats-ranges {
  display: inline-flex;
  gap: 2px;
  padding: 2px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
}

.stats-ranges a,
.stats-ranges .stats-range-current {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  padding: 0.25rem 0.65rem;
  border-radius: calc(var(--radius-sm) - 2px);
  color: var(--text-muted);
}

.stats-ranges a:hover {
  color: var(--text);
}

.stats-ranges .stats-range-current {
  background: var(--text);
  color: var(--bg);
}

.stats-clear {
  font-size: var(--fs-ui-sm);
}

.stats-showing {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.04em;
  color: var(--text-muted);
}

/* Jump links to the numbered sections. This nav is now the design's "sticky
   rail + mosaic" layout and nothing else: from 1000px up (the stats
   breakpoint, shared with .stats-duo) it is the pinned rail below, and under
   that width the section menu component owns the job entirely and hides this.
   It carried a wrapping mono bar as the narrow fallback until the menu grew
   past 720 — every declaration that state needed has gone with it. */
@media (min-width: 1000px) {
  .stats-body {
    display: grid;
    /* Zero-min content column for the same reason as .stats-duo (#1136):
       an auto track would let a table's min-content width prop the page
       open sideways instead of scrolling inside .table-wrap. */
    grid-template-columns: 150px minmax(0, 1fr);
    gap: 2.75rem;
    align-items: start;
  }

  /* The rail: a pinned vertical column that keeps pace with the scroll.
     Its top offset matches .stats-section's scroll-margin-top, so a jump
     lands the section heading level with the top of the rail. */
  .stats-nav {
    display: flex;
    position: sticky;
    top: calc(var(--header-h) + 1rem);
    flex-direction: column;
    gap: 1px;
    border-left: 1px solid var(--border);
    /* 13px, and it always was: it used to ride in on the base rule the
       wrapping bar shared. Stated here now that this block is the nav's only
       home — the rail is drawn at this size, and it is the size the marker's
       contrast was measured against. */
    font-size: var(--fs-ui-sm);
  }

  /* The 2px marker border overlays the rail's 1px hairline (hence the
     -1px pull) so hover/active states can light it without a layout
     shift. */
  .stats-nav a {
    padding: 0.45rem 0 0.3rem 0.85rem;
    margin-left: -1px;
    border-left: 2px solid transparent;
    font-weight: 500;
    color: var(--text);
  }

  .stats-nav a:hover {
    border-left-color: var(--border-strong);
    text-decoration: none;
  }

  /* The section under the reading line, marked by app.js as you scroll — the
     job the shelf index already does in My Collection, which until now the
     rail did only under the cursor.

     🚨 It comes after :hover on purpose. `.stats-nav a[aria-current]` and
     `.stats-nav a:hover` are BOTH (0,2,1) — an exact tie, so source order is
     the only thing deciding, and above this line a cursor crossing the rail
     would blank the row you are on. The shelf index carries the same note for
     the same reason.

     The rail's own vocabulary — a lit marker, not the shelf index's tinted
     fill: this rail marks with a border, and spec 11 already fixes the stats
     page's in-view tone as leaf ("numeral, in view · leaf"). An accent fill
     would also put 13px text on a tint, the pairing the shelf index had to
     measure twice before it landed on --accent-text-strong; a 2px border is
     not text, and leaf on ink is 7.9:1. */
  .stats-nav a[aria-current] {
    border-left-color: var(--accent);
    font-weight: 600;
    text-decoration: none;
  }

  /* Hover leaves the current row as it is — the cursor is already the
     affordance, and re-stating the marker keeps a later edit to the :hover
     rule from reaching this state. */
  .stats-nav a[aria-current]:hover {
    border-left-color: var(--accent);
  }
}

.stats-section {
  margin-bottom: clamp(2.5rem, 6vw, 4rem);
  scroll-margin-top: calc(var(--header-h) + 1rem);
}

.stats-section > .section-head {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  padding-bottom: 0.6rem;
  border-bottom: 1px solid var(--border-strong);
  margin-bottom: 1.4rem;
}

.stats-section > .section-head h2 {
  margin: 0;
}

.stats-section .section-num {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.14em;
  color: var(--accent-text);
}

.stats-section > .section-head .section-note {
  margin-left: auto;
  text-align: right;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-muted);
}

/* Card titles inside a numbered section demote to h3 for the document
   outline; visually they stay between the old card h2 and a default h3. */
.stats-section .card h3 {
  font-size: 1.3rem;
}

/* Two-up card grids: symmetric pair, or chart + narrow fact rail. Single
   column below 1000px, where the rail would squeeze the chart. The zero-min
   column matters: with the default auto track, a card's content-based
   minimum (a table's min-content width) props the track open and pans the
   whole page sideways at 320px (issue #1136) — minmax(0, 1fr) lets the card
   shrink so .table-wrap scrolls inside it instead. */
.stats-duo {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 1.5rem;
  align-items: stretch;
  margin-bottom: 1.5rem;
}

.stats-duo > .card,
.stats-cards > .card {
  margin-bottom: 0;
}

@media (min-width: 1000px) {
  .stats-duo {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .stats-duo--rail {
    grid-template-columns: minmax(0, 1fr) 21rem;
  }
}

/* The copy-metadata cards (format / completeness / condition / region) pack
   as many across as fit. */
.stats-cards {
  display: grid;
  gap: 1.5rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
  margin-bottom: 1.5rem;
}

/* Mono kicker over the lead paragraph, insight cards and fact rails. */
.stats-kicker {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--text-muted);
  margin: 0 0 0.6rem;
}

/* The editorial lead: display serif, numbers picked out in the leaf. */
.stats-lead-text {
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 1rem + 1vw, 1.6rem);
  line-height: 1.35;
  letter-spacing: -0.01em;
  margin: 0;
  text-wrap: pretty;
}

.stats-lead-text strong {
  font-weight: 500;
  color: var(--accent-text);
}

.stats-insights {
  display: grid;
  gap: 1rem;
  align-content: stretch;
}

.insight-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-left: 2px solid var(--accent);
  border-radius: var(--radius);
  padding: 1rem 1.25rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.insight-card .stats-kicker {
  margin-bottom: 0.35rem;
}

.insight-claim {
  font-family: var(--font-display);
  font-size: 1.15rem;
  line-height: 1.3;
  margin: 0;
  text-wrap: pretty;
}

.insight-support {
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text-muted);
  margin: 0.35rem 0 0;
}

/* Fact rails (growth pace, money). Display numerals are Newsreader; the
   caption beside them is mono — the site-wide value/caption convention. */
.stats-big-value {
  font-family: var(--font-display);
  font-size: 2.2rem;
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.01em;
  margin: 0 0 1rem;
}

.stats-big-unit {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-muted);
}

.fact-list {
  margin: 0;
  border-top: 1px solid var(--border);
}

.fact-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.55rem 0;
  border-bottom: 1px solid var(--border);
}

.fact-row dt {
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-muted);
  white-space: nowrap;
}

.fact-row dd {
  margin: 0;
  font-size: var(--fs-ui-sm);
  text-align: right;
  min-width: 0;
}

.stats-pace .card-note {
  margin-top: 0.8rem;
  margin-bottom: 0;
}

/* The growth section's fact rail stacks two cards (pace + wishlist funnel,
   issue #1127) inside the duo's second grid column. The explicit
   minmax(0, 1fr) matters: an auto track takes its content minimum, which is
   how the stats page once overflowed a 320px viewport (issue #1136). */
.stats-rail {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 1.5rem;
  align-content: start;
}

.stats-rail > .card {
  margin-bottom: 0;
}

/* Wishlist funnel split bar: converted vs still waiting, in the growth
   chart's acquired/wishlisted palette — the funnel's two populations are
   exactly those series. Decoration only (the rows carry the numbers), so the
   template hides it from assistive tech. */
.funnel-bar {
  display: flex;
  gap: 2px;
  height: 0.7rem;
  border-radius: 3px;
  overflow: hidden;
  margin-bottom: 0.9rem;
}

.funnel-seg--converted {
  background: var(--accent);
}

.funnel-seg--waiting {
  background: var(--chart-2);
  flex: 1;
}

/* The funnel's row labels run longer than the pace card's ("Bought since
   wishlisting") — let them wrap rather than inherit the fact-row nowrap,
   which is a content minimum a 320px viewport can't honor. */
.stats-funnel .fact-row dt {
  white-space: normal;
}

/* Single-series recolors of the growth chart pattern. */
.growth-seg--rating {
  background: var(--accent);
}

.growth-seg--spend {
  background: var(--brass);
}

@media (max-width: 720px) {
  /* The ratings histogram is a fixed ten-bucket scale: the growth chart's
     newest-six default would amputate half the scale, so its handheld bar
     list always draws every bucket. Same grid the shown growth columns use
     (mobile screens 2E); source order past that block breaks the
     equal-specificity tie. */
  .ratings-plot .growth-col {
    display: grid;
    grid-template-columns: 82px minmax(0, 1fr) minmax(40px, max-content);
    grid-auto-rows: auto;
    gap: 2px 12px;
    align-items: center;
    height: auto;
    min-height: 32px;
  }

}

/* ---------------------------------------------------------------------------
 * Stats · the section menu and its filters (design "Stats Section Menu"
 * spec 11)
 *
 * Below the stats breakpoint the page's five section links stop being a
 * wrapping run of green text and become ONE component at two densities: a
 * contents index in flow at rest, and the same list rendered horizontally as a
 * pinned 48px rail once it has scrolled away. The filter row rides with it —
 * the platform pill and a period chip, each onto the product's existing bottom
 * sheet — so nothing ever expands inside the pinned rows and the stack is
 * 160px in every state.
 *
 * ⭐ The band this owns is ≤999, not the spec's ≤720. Spec 11 drew phones, but
 * 721–999 had no design of its own: it kept the wrapping link run, and once
 * you scrolled it kept NOTHING — the desktop rail only exists from 1000 up, so
 * a tablet reading the page had no section nav at all. One breakpoint, one
 * handoff: under 1000 this component, from 1000 the sticky rail. There is no
 * third state to draw, and 999 is the same line .stats-duo and the rail's own
 * grid already turn on.
 *
 * 1000+ is untouched: the wrapper has no box (display: contents), every part
 * of this component is display:none, and the desktop rail, its 150px column
 * and the three-link range pill are exactly what they were.
 * ------------------------------------------------------------------------ */

.stats-chrome {
  display: contents;
}

.stats-menu,
.stats-range-chip,
.stats-scope-count,
.stats-sheet-search,
.bar-list-note {
  display: none;
}

@media (max-width: 999px) {
  /* §C's arithmetic, once: header + filters 56 + rail 48 is the pinned stack,
     and it stays that whatever is open, because the menus open OVER the page
     rather than growing these rows. Section headings clear it by 8. */
  :root {
    --stats-filters-h: 56px;
    --stats-rail-h: 48px;
    --stats-stack-h: calc(var(--header-h) + 56px + 48px);
  }

  /* 🚨 flow-root, not block: the wrapper has no padding or border, so its
     children's bottom margins would COLLAPSE THROUGH it and its measured
     height would come up 22px short of the space it actually holds — which
     app.js then writes as the placeholder, and the page jumps by exactly that
     on the pin. flow-root contains the margins so the measurement is honest.
     Nothing moves visually: the margin still occupies the same 22px, now
     inside the wrapper's box. */
  .stats-chrome {
    display: flow-root;
  }

  .stats-section {
    scroll-margin-top: calc(var(--stats-stack-h) + 8px);
  }

  /* The wrapping run of green links is what this component replaces (§A) —
     and with it the only place on the page where green meant "jump". */
  .stats-nav {
    display: none;
  }

  /* Clear filters belongs to the rail layout (Max's call): both sheets carry
     their own "all" row, so clearing is always one menu away, and §C pins this
     row at pill + chip + count. A wider band has the room for a third control
     but not the reason — the row would then say two different things at two
     widths, and the pinned state is the same 56px either way. */
  .stats-clear {
    display: none;
  }

  /* ---- The filter row, at rest (§B①, §C) ------------------------------- */
  .stats-toolbar {
    gap: 8px;
    padding: 14px 0 0;
    border-block: 0;
    border-top: 1px solid var(--border);
    margin-bottom: 18px;
  }

  .stats-toolbar .column-picker > summary {
    box-sizing: border-box;
    height: 48px;
    padding: 0 12px;
    gap: 8px;
    border: 1px solid var(--border-strong);
    border-radius: 5px;
    font-size: var(--fs-ui-sm);
    color: var(--text);
  }

  /* The open chip takes §C's pressed fill rather than the picker's default
     accent border — this row is chrome, not a filter in flight. */
  .stats-toolbar .column-picker[open] > summary {
    border-color: var(--text-muted);
    background: var(--fill);
    color: var(--text);
  }

  .stats-toolbar .column-picker > summary::after {
    color: var(--text-faint);
  }

  /* The period pill's three links become one chip onto the period sheet. */
  .stats-ranges {
    display: none;
  }

  .stats-range-chip {
    display: block;
  }

  .stats-range-chip > summary {
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.06em;
    text-transform: uppercase;
    white-space: nowrap;
  }

  /* "784 GAMES · ALL TIME" on its own line, in place of the desktop sentence. */
  .stats-showing {
    display: none;
  }

  .stats-scope-count {
    display: block;
    flex-basis: 100%;
    margin: 10px 0 0;
    font-family: var(--font-mono);
    font-size: var(--fs-mono);
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  /* ---- Pinned (§B②) ---------------------------------------------------- */
  /* Both rows leave flow together and sit under the header as the 56 + 48
     stack. app.js writes the wrapper's measured at-rest height inline before
     adding the class, so nothing below them moves as they go. */
  /* 🚨 position: fixed ALWAYS establishes a stacking context — z-index:auto
     does not save you — and the sheets' scrim (55) and panel (60) are
     descendants of the chips inside these rows. So both are clamped into
     whatever layer the row occupies, and a scrim that cannot out-paint the
     site header (50) leaves the header live under it. Measured: the
     dismissing tap landed on .brand and on a rail tab — issue #1185 again,
     with a different cause.

     Hence 46 at rest (over the page, under the header, where these rows
     belong) and 56 for whichever row currently owns an open sheet. Only one
     sheet is ever open — app.js's .column-picker family closes the others —
     so the :has() lifts exactly one row, and its scrim then covers the
     header, the other pinned row and the page alike. */
  .stats-chrome.is-pinned > .stats-toolbar,
  .stats-chrome.is-pinned > .stats-menu {
    position: fixed;
    left: 0;
    right: 0;
    z-index: 46;
    margin: 0;
    background: none;
  }

  .stats-chrome.is-pinned > .stats-toolbar:has(.column-picker[open]),
  .stats-chrome.is-pinned > .stats-menu:has(.column-picker[open]) {
    z-index: 56;
  }

  /* 🚨 The tinted blur rides a pseudo-element, never these rows themselves: a
     backdrop-filter makes its element the containing block for *fixed*
     descendants, which would re-anchor all three sheet panels to a 48px rail
     instead of the viewport. The pseudo has no descendants, so it can carry
     the filter safely.

     🚨 And the rule between the two rows rides the pseudo as well, because
     `inset: 0` on an absolutely positioned child resolves against the
     PADDING box: a 1px border-bottom on the row itself is left OUTSIDE both
     the tint and the blur (measured: a 56px row with a 55px tint). Since the
     rows sit over live page content, that 1px reads as a sharp, unblurred
     strip of the scrolling page running between the filters and the rail —
     the only place on the pinned stack where the page shows through
     undimmed. Drawing the rule on the pseudo puts it ON the tint instead of
     beside it. Extending the pseudo over the row's own border would not do:
     a negative-z-index child paints AFTER its parent's border (painting
     order steps 1 then 2), so it would cover the rule rather than back it. */
  .stats-chrome.is-pinned > .stats-toolbar::before,
  .stats-chrome.is-pinned > .stats-menu::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    border-bottom: 1px solid var(--pinned-rule, var(--border));
    background: var(--header-tint);
    -webkit-backdrop-filter: blur(8px);
    backdrop-filter: blur(8px);
  }

  .stats-chrome.is-pinned > .stats-toolbar {
    --pinned-rule: var(--border);
    top: var(--header-h);
    box-sizing: border-box;
    height: var(--stats-filters-h);
    padding: 4px var(--gutter);
    flex-wrap: nowrap;
    border-block: 0;
  }

  /* Condensed: the count keeps only its number, at the right end. */
  .stats-chrome.is-pinned .stats-scope-count {
    flex: none;
    flex-basis: auto;
    margin: 0 0 0 auto;
    font-size: var(--fs-mono-badge);
  }

  .stats-chrome.is-pinned .stats-scope-count-rest {
    display: none;
  }

  /* ---- The index, at rest (§B①) ---------------------------------------- */
  .stats-menu {
    display: block;
    margin: 0 0 22px;
    border-top: 1px solid var(--border-strong);
  }

  .stats-menu-lead {
    margin: 0;
    padding: 12px 0 4px;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  .stats-menu-list {
    list-style: none;
    margin: 0;
    padding: 0;
    border-bottom: 1px solid var(--border-strong);
  }

  .stats-menu-link {
    display: flex;
    align-items: center;
    gap: 12px;
    box-sizing: border-box;
    min-height: 48px;
    padding: 12px 0;
    border-top: 1px solid var(--border);
    color: var(--text);
  }

  .stats-menu-link:hover,
  .stats-menu-link:active {
    background: var(--fill);
    text-decoration: none;
  }

  .stats-menu-num {
    flex: none;
    width: 18px;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.08em;
    color: var(--text-muted);
  }

  /* The only green on the page at rest: the numeral of the section in view
     (§B①). Never colour alone — the rail pairs it with weight. */
  .stats-menu-link[aria-current="location"] .stats-menu-num {
    color: var(--accent-text);
  }

  .stats-menu-name {
    flex: 1;
    min-width: 0;
    font-family: var(--font-display);
    font-size: 1.25rem;
    line-height: 1.2;
    color: var(--text);
  }

  .stats-menu-short {
    display: none;
  }

  .stats-menu-figure {
    flex: none;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--text-faint);
  }

  /* ---- The rail, pinned (§B②, §C) -------------------------------------- */
  .stats-chrome.is-pinned > .stats-menu {
    --pinned-rule: var(--border-strong);
    top: calc(var(--header-h) + var(--stats-filters-h));
    display: flex;
    align-items: stretch;
    box-sizing: border-box;
    height: var(--stats-rail-h);
    border-block: 0;
  }

  .stats-chrome.is-pinned .stats-menu-lead {
    display: none;
  }

  .stats-chrome.is-pinned .stats-menu-list {
    display: flex;
    flex: 1;
    min-width: 0;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
    border-bottom: 0;
  }

  .stats-chrome.is-pinned .stats-menu-list::-webkit-scrollbar {
    display: none;
  }

  .stats-chrome.is-pinned .stats-menu-item {
    flex: none;
  }

  .stats-chrome.is-pinned .stats-menu-link {
    height: 48px;
    min-height: 0;
    padding: 0 13px;
    gap: 7px;
    border-top: 0;
    border-bottom: 2px solid transparent;
    white-space: nowrap;
  }

  .stats-chrome.is-pinned .stats-menu-name {
    flex: none;
    font-family: inherit;
    font-size: var(--fs-ui-sm);
    color: var(--text-muted);
  }

  .stats-chrome.is-pinned .stats-menu-figure {
    display: none;
  }

  .stats-chrome.is-pinned .stats-menu-link[aria-current="location"] {
    border-bottom-color: var(--accent-text);
  }

  .stats-chrome.is-pinned .stats-menu-link[aria-current="location"] .stats-menu-name {
    font-weight: 600;
    color: var(--text);
  }

  /* The fade always means "more ahead" (§B②), so it stands down once the rail
     is scrolled to its end — app.js sets the flag. */
  .stats-chrome.is-pinned > .stats-menu::after {
    content: "";
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: 44px;
    background: linear-gradient(90deg, rgba(0, 0, 0, 0), var(--bg));
    pointer-events: none;
  }

  .stats-chrome.is-pinned > .stats-menu[data-rail-end]::after {
    display: none;
  }

  /* ---- The hinge (§C) -------------------------------------------------- */
  .stats-menu-jump {
    display: none;
    position: static;
  }

  .stats-chrome.is-pinned .stats-menu-jump {
    display: block;
    flex: none;
  }

  /* §C's plate: NN/05 ▾, 48 tall and never narrower than 64. The padding is
     the point of the min-width rather than a fixed 64 — §C sizes the plate
     around mono at 9.5px, while the type scale sets badge mono to 12px below
     720 (the "12px only inside badges" step) and 11px above it. At a flat 64
     the fraction sits hard against the viewport edge on one side and the
     divider on the other. Sizing to content keeps §C's 9px of air on both
     sides at BOTH type sizes — this block runs to 999 but the type scale
     still turns over at 720 — and §E's 48×64 is a floor for the tap target,
     not a ceiling. */
  .stats-chrome.is-pinned .stats-menu-jump > summary {
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    min-width: 64px;
    height: 48px;
    padding: 0 9px;
    border: 0;
    border-right: 1px solid var(--border);
    border-radius: 0;
    background: none;
    color: var(--text-faint);
  }

  /* The hinge draws its own caret inside the NN/05 plate, so the shared
     .column-picker pseudo-caret stands down. */
  .stats-menu-jump > summary::after {
    display: none;
  }

  /* Two flex items, so the 6px lands once — between the fraction and the
     caret (§C). The tracking sits on the fraction, not the container: on the
     container it also trails the caret, which reads as lopsided padding. */
  .stats-menu-hinge {
    display: flex;
    align-items: center;
    gap: 6px;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
  }

  .stats-menu-hinge-count {
    letter-spacing: 0.08em;
  }

  .stats-menu-hinge-caret {
    font-size: 0.8em;
    transition: transform var(--speed) ease;
  }

  .stats-chrome.is-pinned .stats-menu-jump[open] > summary {
    background: var(--fill);
    color: var(--text);
  }

  .stats-menu-jump[open] .stats-menu-hinge-caret {
    transform: rotate(180deg);
  }

  /* ---- One overlay, three uses (§C) ------------------------------------
     Period, platform and jump-to-section all open the sheet the product
     already has (the Sort / shelves-chip panel): same panel, same 38×4
     handle, same scrim, same dismissals — outside-click and Escape from
     app.js's .column-picker family, swipe-down from SHEET_PANELS — and no
     commit button anywhere. Every row is a full page load or an in-page jump,
     so choosing one dismisses the sheet by itself. */
  .stats-scope-menu .picker-panel,
  .stats-range-chip .picker-panel,
  .stats-menu-jump .picker-panel {
    display: flex;
    flex-direction: column;
    gap: 0;
    position: fixed;
    inset: auto 0 0 0;
    z-index: 60;
    width: auto;
    min-width: 0;
    /* 68svh, not the 85svh this family started at (issue #1623, following
       #1622 and .shelf-jump). 85 was a ceiling nothing here ever touched:
       Period draws 212px and Jump 260px at 390×844, so both simply take their
       content and this number never reaches them. The platform menu is the one
       that grows, and at 85svh a 19-row collection drew 717px of an 844px
       phone — the dim above it was the site header and a sliver of the page
       title, and the sheet read as a page.

       What the extra 143px buys back is specific, not general: at 68svh the
       dim holds the platform pill you just tapped, the period chip beside it
       and the "18 GAMES · ALL TIME" count line — the scope this sheet is
       about to change, in view while you change it. It costs three rows of a
       list that now has a pinned search over it, which is the trade §D's
       search was added to make: past 12 entries you are meant to filter the
       list, not thumb it. */
    max-height: 68svh;
    overflow-y: auto;
    overscroll-behavior: contain;
    background: var(--surface);
    border: 0;
    border-top: 1px solid var(--border-strong);
    border-radius: 14px 14px 0 0;
    box-shadow: 0 -18px 44px rgba(0, 0, 0, 0.4);
    /* No top padding: it belongs to the sticky head below, because a sticky
       top: 0 anchors on the scrollport's CONTENT edge. Left here, the head
       would pin 8px low and the rows would scroll through the band above it,
       under the rounded corner (issue #1622 measured exactly this 8px). */
    padding: 0 8px calc(12px + env(safe-area-inset-bottom));
  }

  /* The chrome stays put while the rows scroll under it (issue #1623). All
     three panels ARE the scroll container — max-height + overflow-y above — so
     the handle, the mono label and, on the platform menu, the search were
     ordinary children scrolling away with the list.

     The platform sheet is why this is not just #1622 again. §D gives that menu
     a search above 12 entries, so on the very collections the field exists for
     it was the first thing the long list hid: at 390×844 with 19 rows it sat
     135px ABOVE the viewport once you reached the bottom. The control added
     because the list is long cannot be the one the long list takes away, so it
     is in the head rather than under it.

     display: block re-states the box the ≥721 rule takes away. That rule hides
     the sheet chrome for pages whose sheets stop at 720 — but these three run
     to 999, so between those widths the head (and the search inside it) would
     otherwise vanish. The handle and the label stay hidden there, as they were
     before this rule existed; the band gets a head holding just the search.

     flex-shrink: 0 because the panel is a flex column and a head is chrome,
     not slack: as a bare flex item the search was the one child that could
     give — min-height: auto floors an <input> at its one-line intrinsic
     height, so 48px of field drew 18px at 820px wide, while the rows held
     their own min-height: 48px. Pinned chrome that thins as the list grows is
     the same bug wearing a different hat.

     The background is load-bearing twice: rows pass UNDER the head instead of
     through it, and the panel's rounded top corner clips a plate of --surface
     instead of the first row's letters. */
  .stats-scope-menu .picker-panel > .view-sheet-head,
  .stats-range-chip .picker-panel > .view-sheet-head,
  .stats-menu-jump .picker-panel > .view-sheet-head {
    display: block;
    position: sticky;
    top: 0;
    z-index: 1;
    flex-shrink: 0;
    padding-top: 8px;
    background: var(--surface);
  }

  .stats-scope-menu .picker-panel a,
  .stats-range-chip .picker-panel a,
  .stats-jump-row {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: 12px;
    box-sizing: border-box;
    min-height: 48px;
    padding: 0 12px;
    border: 1px solid transparent;
    border-radius: 5px;
    font-size: var(--fs-body);
    color: var(--text);
    white-space: normal;
  }

  .stats-scope-menu .picker-panel a[aria-current="true"],
  .stats-range-chip .picker-panel a[aria-current="true"],
  .stats-jump-row[aria-current="location"] {
    background: var(--accent-dim);
    border-color: var(--accent-border);
    font-weight: 600;
    color: var(--text);
  }

  .stats-scope-menu .picker-panel a[aria-current="true"]::after,
  .stats-range-chip .picker-panel a[aria-current="true"]::after {
    content: "\2713";
    margin-left: auto;
    color: var(--accent-text);
  }

  .stats-scope-menu .picker-panel .stats-option-count,
  .stats-range-chip .picker-panel .stats-option-count {
    margin-left: auto;
    font-size: var(--fs-mono-badge);
    color: var(--text-faint);
  }

  /* The long-menu search (§D). --fs-field is the 16px phone floor, which is
     what stops iOS zooming the viewport on focus. */
  .stats-sheet-search {
    display: block;
    box-sizing: border-box;
    width: 100%;
    height: 48px;
    margin: 0 0 6px;
    padding: 0 12px;
    border: 1px solid var(--border-strong);
    border-radius: 5px;
    background: var(--bg);
    font-size: var(--fs-field);
    color: var(--text);
  }

  .stats-sheet-search:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* 🚨 The rows are display:flex at (0,2,1), which OUT-SPECIFIES the UA's
     [hidden] { display: none } — so a filtered-out row would keep its box and
     the search would appear to do nothing. Hide them at (0,3,1). Same trap the
     column-picker's own label.check[hidden] rule exists for. */
  .stats-scope-menu .picker-panel a[hidden] {
    display: none;
  }

  .stats-jump-num {
    flex: none;
    width: 18px;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.08em;
    color: var(--text-muted);
  }

  .stats-jump-row[aria-current="location"] .stats-jump-num {
    color: var(--accent-text);
  }

  .stats-jump-name {
    flex: 1;
    min-width: 0;
    font-family: var(--font-display);
    font-size: 1.1875rem;
    line-height: 1.2;
  }

  .stats-jump-figure {
    flex: none;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--text-faint);
  }

  /* The scrim swallows its own dismissing tap (issue #1185): paint-only would
     let a cancel land on the rail tab or chip drawn under it. app.js reads a
     click targeting the open <details> itself — which is what a click on this
     ::before reports — as the outside click. */
  .stats-scope-menu[open]::before,
  .stats-range-chip[open]::before,
  .stats-menu-jump[open]::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 55;
    background: rgba(10, 9, 6, 0.62);
    pointer-events: auto;
  }

  body:has(.stats-scope-menu[open]),
  body:has(.stats-range-chip[open]),
  body:has(.stats-menu-jump[open]) {
    overflow: hidden;
  }

  /* ---- Focus (§C) ------------------------------------------------------ */
  .stats-menu-link:focus-visible,
  .stats-jump-row:focus-visible,
  .stats-menu-jump > summary:focus-visible,
  .stats-toolbar .column-picker > summary:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
  }

  /* ---- Motion (§D) ----------------------------------------------------- */
  @media (prefers-reduced-motion: no-preference) {
    .stats-chrome.is-pinned > .stats-toolbar,
    .stats-chrome.is-pinned > .stats-menu {
      animation: pin-row-in 180ms ease-out;
    }

    .stats-scope-menu[open] .picker-panel,
    .stats-range-chip[open] .picker-panel,
    .stats-menu-jump[open] .picker-panel {
      animation: entry-sheet-in 180ms ease-out;
    }

    .stats-scope-menu[open]::before,
    .stats-range-chip[open]::before,
    .stats-menu-jump[open]::before {
      animation: entry-scrim-in 120ms ease-out;
    }

    /* The rail pulls the active tab to its left edge; app.js asks for the
       scroll and CSS decides whether it animates. */
    .stats-chrome.is-pinned .stats-menu-list {
      scroll-behavior: smooth;
    }
  }
}

/* The pinned rail bleeds to the viewport edge — it is a scroller, and a strip
   that stops short of the edge reads as "nothing more that way". Its CONTENT
   still belongs on the page's text column, though, like the filter row above
   it: that row is full-bleed with `padding: 4px var(--gutter)`. On a phone the
   two already agree, because --gutter is the compact 16px and the rail's first
   item lands within 3px of it. At tablet gutters (29–40px) the same 0 leaves
   the hinge hanging visibly left of every other thing on the page. Restating
   the phone's own relationship — gutter minus that 16 — aligns the band
   without touching a pixel of what shipped at ≤720. */
@media (min-width: 721px) and (max-width: 999px) {
  .stats-chrome.is-pinned > .stats-menu {
    padding-left: calc(var(--gutter) - 16px);
  }
}

/* ---- The helper line's phone home (§A, §B②) -----------------------------
   🚨 Stays at ≤720 while the rest of this component runs to 999, because it
   follows the BY-PLATFORM CARD, not the section menu: the bar list it sits
   under is itself display:none above 720 (mobile screens 2E — the table takes
   over there). Widened along with the menu, this would print the note over a
   card that is showing the table, and take the note out of the section head
   that still has it. Two different reasons, two different breakpoints. */
@media (max-width: 720px) {
  .stats-section > .section-head .section-note--rows {
    display: none;
  }

  .bar-list-note {
    display: block;
    margin: 16px 0 0;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    line-height: 1.7;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-faint);
  }
}

/* The index always spells section names in full; the rail takes the short form
   below 520 and its full names from there up (§E). Both spans are aria-hidden
   — the link's aria-label carries the full name at every width, so no width
   can leave it nameless (the issue #1027 lesson). */
@media (max-width: 519px) {
  .stats-chrome.is-pinned .stats-menu-name {
    display: none;
  }

  .stats-chrome.is-pinned .stats-menu-short {
    display: inline;
    font-size: var(--fs-ui-sm);
    color: var(--text-muted);
  }

  .stats-chrome.is-pinned .stats-menu-link[aria-current="location"] .stats-menu-short {
    font-weight: 600;
    color: var(--text);
  }
}

/* A row arriving at the top of the screen: the 180ms crossfade at fixed height
   that both pinned rails take (stats spec 11 §D, shelves spec 12 §D). One
   definition, two consumers — the two rails are the same component pointed at
   different content, and a second identical keyframe would be the place they
   start to drift. */
@keyframes pin-row-in {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* ==========================================================================
   THE ACCOUNT PHONE LAYER (design "Account Phone Layer Spec" 16)
   ==========================================================================
   The account area had zero declarations at ≤720 — nine at 520, six at 1024,
   one at 721 and nothing on the site's own phone breakpoint. This block is
   that layer, and it is mostly one ruling repeated: below 721 the five chrome
   families collapse into one column, and the settings row stops being a flex
   line with a right-hand column and becomes a two-line grid whose status sits
   under its own title. The hub goes 1,992 → ~1,436px without hiding a row.

   It sits at the end of the file on purpose. Almost every rule here ties the
   base rule it replaces on specificity (`.account-head` vs `.account-head`),
   so source order is what decides them — moving this block earlier silently
   reverts about half of it.

   🚨 Four corrections to the spec's own CSS, made deliberately:
   1. `var(--leaf)` is not a token in this repo. The green is `--accent` /
      `--accent-text`; an undefined custom property is invalid at computed
      value time, so the status colour would have fallen back to the title's
      and Q3's whole point — a distinct green column — would have shipped
      invisible.
   2. `.back-link` is the class on the crumb's OWN link as well as on the
      bottom one, and it is worn by signed-out pages this round does not
      touch. Hiding it wholesale would have deleted the crumb the same spec
      mandates, so the foot link is marked `.settings-back-foot` instead.
   3. `.leaving-cancel` and the primary beside it needed the same height, not
      `width: 100%` on one of them: the pair shares a flex row, and the
      leaving page's primaries are `.btn`, not `.account-form` submits, so the
      spec's selector never reached them.
   4. The status separator is suppressed on a row with no description rather
      than assumed away — see the `:has()` note on `.row-tag::after`.
   ========================================================================== */

@media (max-width: 720px) {
  /* --- The column (§E) ---------------------------------------------------
     A 1px border 16px from the screen edge is a card only in the sense that
     it is not one: it repeats the viewport's own shape one gutter in, and on
     /accounts/agents/ it wraps an 844px screen around a single sentence.
     Below 721 the phone is the card. Above it nothing moves — .settings-card
     keeps its 56rem and .auth-card its 26rem, which is why this block never
     names .auth-card at all (the fork is .account-form, added in the three
     signed-in templates). */
  .settings-card,
  .account-form,
  .email-page .email-card {
    max-width: none;
    margin: 0;
    padding: 0;
    background: none;
    border: 0;
    border-radius: 0;
  }

  /* --- Getting back (§Q6) ------------------------------------------------
     One rule for the whole area: a 44px crumb at the top, and the bottom
     "Back to my account" retires. On a 2,908px page a bottom-only back link
     is a 3.5-screen scroll to leave; on an 844px one it is a second door two
     lines from the first.

     🚨 The crumb keeps its own .back-link — that class is the link INSIDE the
     crumb as well as the one at the foot, and it is worn by login, password
     reset and the other signed-out pages this round leaves alone. Only the
     explicitly-marked foot link goes. */
  .account-crumb {
    display: flex;
    align-items: center;
    min-height: 44px;
    margin-bottom: 0.75rem;
  }

  .settings-back-foot {
    display: none;
  }

  /* --- The identity head (§C) --------------------------------------------
     208 → 80px with a one-line name. Three defects close here without a patch
     between them, because all three were the retired 520 rules colliding with
     a flex line: the orphaned "·" cannot happen because there is one fact and
     no separator; the head cannot change shape with the name because EDIT is
     a fixed-width sibling rather than a wrapped inline; and Log out cannot
     strand because it is not here. */
  .account-head {
    flex-wrap: nowrap;
    align-items: flex-start;
    gap: 1rem;
    margin-bottom: 1.375rem;
    padding-bottom: 1rem;
  }

  /* No line-height here: the base rule centres the initial with flex + a
     cap-height correction in em, which follows this font-size on its own. A
     `line-height: 40px` would be inert (and would read as if it did the
     centring, which is how the desktop rule came to draw the letter high). */
  .account-avatar {
    flex-shrink: 0;
    width: 40px;
    height: 40px;
    margin-top: 2px;
    font-size: 1.25rem;
  }

  .account-name {
    margin: 0;
    font-size: 1.375rem;
    line-height: 26px;
  }

  /* The anchor is the whole row, not the words — that is what takes the 44px
     floor (a.profile-name-edit was on the touch baseline at ~17px, at phone
     AND at coarse tablet). Ties the base rule's (0,2,0) and wins on order. */
  .account-name .profile-name-edit {
    display: flex;
    align-items: flex-start;
    flex-wrap: nowrap;
    gap: 10px;
    width: 100%;
    min-height: 44px;
  }

  /* Two lines, then ellipsis. A third line does not exist: the head may not
     change shape with the length of a name. */
  .account-name-text {
    display: -webkit-box;
    flex: 1;
    min-width: 0;
    padding-top: 9px;
    overflow: hidden;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
  }

  /* EDIT never moves: a fixed-basis sibling of the name, not an inline that
     wraps under it when the name grows.

     🚨 NOT the spec's `line-height: 44` — this control is underlined with a
     border-bottom, and a 44px line box puts that border at the bottom of the
     box rather than under the words, which draws a rule floating ~20px below
     the text it belongs to. The 44px target is the ANCHOR's job (it is
     min-height: 44 above, and the hint is inside it), so the hint only has to
     sit on the name's line. The padding is what puts it there: the name is
     26px of line at 9px of padding, so 13 lands the mono cap height beside
     the serif one. */
  .profile-name-hint {
    flex-shrink: 0;
    padding-top: 13px;
    font-size: var(--fs-mono-badge);
    line-height: 16px;
  }

  /* One fact — the address. MEMBER SINCE is already printed on
     /accounts/leaving/ and the sign-in summary is already the Connected
     accounts row's own status; neither is deleted from the product, both stop
     being printed twice. The separators go with them, which is the whole of
     the orphaned-"·" fix. */
  .account-facts {
    display: block;
    margin-top: 2px;
    overflow: hidden;
    white-space: nowrap;
    color: var(--text-muted);
  }

  .account-facts > * {
    display: none;
  }

  /* 🚨 The ellipsis belongs to the ADDRESS, not to the line that holds it. An
     address is one unbreakable token (issue #1032, which is why it carries
     overflow-wrap: anywhere at every other width); left to size itself inside
     a nowrap parent it lays out at its full width and only *looks* clipped,
     so its own box still runs past a 320px viewport — the exact measurement
     #1032's guard makes. Giving the span the max-width and the ellipsis keeps
     the box inside the rail, which is what "does not pan" means. */
  .account-facts > .account-email-fact {
    display: inline-block;
    max-width: 100%;
    overflow: hidden;
    vertical-align: bottom;
    text-overflow: ellipsis;
  }

  /* #1178 made the nav account menu the phone's canonical log-out and it is on
     every screen including this one. Two Log outs is one too many. */
  .account-head .account-logout {
    display: none;
  }

  /* --- The inline name editor (C4) ---------------------------------------
     The field IS the <h1> (the nesting is load-bearing markup, not styling),
     so it takes the field floor rather than the heading's type: 16px is what
     stops iOS zooming the viewport when an h1 becomes an input. */
  .profile-name-form .account-name--editing {
    flex-basis: 100%;
  }

  .profile-name-input {
    height: 48px;
    font-size: 16px;
    line-height: 1.2;
  }

  .profile-name-form .profile-name-save {
    flex: 1;
    min-height: 52px;
    padding: 0 1rem;
    font-size: var(--fs-ui);
  }

  .profile-name-form .profile-name-cancel {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    width: 104px;
    min-height: 52px;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
  }

  /* --- The alert band (C5/C6) --------------------------------------------
     Below the head's rule, not inside it: it is about the account, not the
     identity. Border only, no fill — this is the third round in this area to
     be bitten by a grey composited on a tint, and a band with no fill is one
     whose contrast can be measured against the bare page and stay measured. */
  .account-alert,
  .account-alert--bad {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin: 0.75rem -12px 0;
    padding: 12px;
    background: none;
  }

  .account-alert-body {
    min-width: 0;
  }

  .account-alert-cta {
    display: flex;
    align-items: center;
    min-height: 44px;
    margin-left: 0;
  }

  /* The band ledger's group gap is 28px, not the 40 the desktop drawing uses
     (436 → 464 between a group's last row and the next heading). Five groups,
     so this is 60px of the 556 the spec set out to take back. .settings-group
     is on the account page and nowhere else, so the narrower gap cannot reach
     another surface. */
  .settings-group {
    margin-bottom: 1.75rem;
  }

  /* --- The settings row (§D) ---------------------------------------------
     71px, two lines, never wraps. Line 1 is the title and the arrow; line 2 is
     the status followed by the description, clipped. The chips are the most
     useful thing on the page and also the only reason the row wrapped, so they
     keep their words, lose their box, and become a column that aligns down the
     page instead of drifting on the right edge.

     No markup moves: display:contents on the two decorative wrappers promotes
     their children to grid items, which keeps ≥721 pixel-identical and costs
     no template edit. Both are non-interactive spans, so the old Chrome
     accessibility bug around display:contents does not apply.

     13/12 rather than 13/13: box-sizing is border-box globally, so the row's
     1px separator is inside the 71. */
  .settings-rows a {
    display: grid;
    grid-template-columns: auto 1fr auto;
    grid-template-areas:
      "title title arrow"
      "tag   sub   arrow";
    align-items: center;
    row-gap: 3px;
    column-gap: 0;
    min-height: 71px;
    padding: 13px 12px 12px;
    margin-inline: -12px;
  }

  .settings-rows .row-text,
  .settings-rows .row-end {
    display: contents;
  }

  .settings-rows .row-title {
    grid-area: title;
    line-height: 22px;
  }

  .settings-rows .row-tag {
    grid-area: tag;
    padding: 0 6px 0 0;
    border: 0;
    border-radius: 0;
    background: none;
    font-size: var(--fs-mono-badge);
    line-height: 20px;
    color: var(--text-muted);
  }

  /* 🚨 --accent-text, NOT the spec's var(--leaf): no such token exists here.
     An undefined custom property is invalid at computed value time, so `color`
     would have fallen back to the inherited title colour and the status column
     would have shipped looking like body text. With the chip's tint gone this
     sits on the bare page, where the plain accent measures well clear of
     4.5:1 — the tint was the only reason the old chip needed the strong step. */
  .settings-rows .row-tag--on {
    color: var(--accent-text);
  }

  .settings-rows .row-tag::after {
    content: "·";
    margin-left: 6px;
    color: var(--text-faint);
  }

  /* 🚨 The separator belongs to the pair, not to the tag. A row may carry a
     status and no description (the password row's sub-line used to be behind
     {% if has_social_signin %}), and an unconditional ::after prints exactly
     the orphaned "·" this spec closed in the head. The copy ledger gives every
     row a description, and this keeps that true if one ever goes away again.
     .row-tag and .row-sub are NOT siblings — they sit in different wrappers,
     and display:contents changes boxes, not the DOM — so this has to ask the
     anchor, not use a sibling combinator. */
  .settings-rows a:not(:has(.row-sub)) .row-tag::after {
    content: none;
  }

  /* Absence reads as none: the bare "None" chip is not rendered on a phone, so
     a new account's hub is a list of names and descriptions rather than the
     word NONE nine times. */
  .settings-rows .row-tag--empty {
    display: none;
  }

  .settings-rows .row-sub {
    grid-area: sub;
    min-width: 0;
    margin: 0;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    line-height: 20px;
    color: var(--text-muted);
  }

  /* The status never truncates and the description always can: the tag's
     column is a shrink-to-fit auto track and .row-sub is the 1fr beside it. */
  .settings-rows .row-arrow {
    grid-area: arrow;
    align-self: center;
    margin-left: 12px;
  }

  /* --- Records (§E) ------------------------------------------------------
     The agents table becomes .table-cards (§05B) by template, so it needs no
     CSS here. The token rows are the same record drawn a second time, so they
     are pointed at the same shape rather than keeping a ≤520 block of their
     own: name, then label/value pairs, then a 52px destructive row. */
  .token-row {
    flex-direction: column;
    gap: 0.6rem;
  }

  .token-side {
    display: flex;
    align-items: center;
    gap: 0.9rem;
    width: 100%;
    text-align: left;
  }

  .token-expiry {
    margin: 0;
  }

  .token-side form {
    margin-left: auto;
  }

  /* A record's last band is a row, not a button-shaped box (§E1). Scoped
     through .settings-card — which is on connected_agents.html and nothing
     else — because `.table-cards .row-actions-cell` is ALSO the catalog's and
     the collection's list-row action bar, and that bar brings its own spec-09
     geometry. A rule written at that shape would silently restyle two browse
     pages this round never looked at.

     (0,3,0): `button.btn-ghost` is (0,2,0) and would otherwise tie and win on
     source order for anything it declares. */
  .settings-card .table-cards .btn-ghost {
    width: 100%;
    min-height: 52px;
  }

  /* --- The signed-in forms (§E3/E4) --------------------------------------
     .account-form only. Nothing in this block names .auth-card, so the 22
     signed-out templates that share it are provably out of range.

     (0,2,1) is not decoration: there are two generic `button[type="submit"]`
     rules ~15,000 lines apart in this file, and a lone class loses to both. */
  .account-form input,
  .account-form select,
  .tokens-page input,
  .tokens-page select {
    height: 48px;
    font-size: 16px;
  }

  .account-form textarea,
  .tokens-page textarea {
    font-size: 16px;
  }

  .account-form button[type="submit"] {
    width: 100%;
    min-height: 52px;
    font-size: 16px;
  }

  .pw-reveal {
    width: 44px;
    height: 44px;
  }

  /* --- Leaving: the pair, not just the link (§H) -------------------------
     🚨 The spec gave a.leaving-cancel `width: 100%` alongside the form
     submits, but this one shares a flex row with its primary — and that
     primary is a .btn, not an .account-form submit, so the spec's own
     selector never reached it. A 52px cancel beside a 44px button is the same
     mismatch drawn one size up. Size the pair together and let them share the
     row they were drawn in. */
  .leaving-actions {
    gap: 0.75rem;
  }

  .leaving-actions .btn,
  .leaving-actions .leaving-cancel {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 52px;
    padding-inline: 1rem;
  }

  .leaving-actions .leaving-cancel {
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
  }
}

/* The other half of the floor's query (#1289). A coarse 1024px tablet runs no
   max-width rule at all, so a phone-only fix leaves the `tablet-coarse:` half
   of every baseline entry exactly where it was. These three are the area's
   entries — a.profile-name-edit, a.leaving-cancel, button.pw-reveal — lifted
   with no sheet geometry attached, which closes all six baseline rows.
   Deliberately NOT the settings rows: those are 71px by their own padding at
   every width, and the desktop drawing is not this block's business. */
@media (pointer: coarse) {
  .account-name .profile-name-edit {
    display: flex;
    align-items: center;
    min-height: 44px;
  }

  .leaving-actions .leaving-cancel {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }

  .pw-reveal {
    width: 44px;
    height: 44px;
  }
}

/* Q8: the two-column flip moves 721 → 900. Two columns need ~360px of content
   each to hold the new row without clipping the description to nothing, and
   900 minus gutters minus the 4rem gap gives 386 a side. At 768 the page now
   takes one column of 736px rows — the phone drawing with more air, a width
   nobody has to test twice. Same band as the stats section menu (#1219), so
   the site has one tablet number instead of three.

   🚨 This has to out-order the `min-width: 721px` rule it corrects, not
   out-specify it: both are a lone `.account-grid`. */
@media (min-width: 721px) and (max-width: 899px) {
  .account-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* ---------------------------------------------------------------------------
 * The Platform filter panel (design "Platform Filter Panel" spec V1, issue
 * #1674, epic #1083)
 * -------------------------------------------------------------------------
 *
 * The Platform segment of the Catalog and My Collection filter bars. Its panel
 * used to be one 16rem column whose height was the platform count, with maker
 * headings inside the scroll and Apply below the fold; the vocabulary is on
 * its way from ~54 platforms to 249+ (the platform waves), so the panel is
 * now a MAKER RAIL beside ONE VALUE PANE. The rail lists makers and two saved
 * views; the pane lists the platforms of exactly one of them; typing in the
 * pane's field searches all platforms and replaces the pane with ranked
 * results.
 *
 * THE INVARIANT (spec §00): panel height is a constant, never a function of
 * content. 640 × 466 in every state — empty results, a one-platform group,
 * the 36-row Arcade systems. Two scroll regions, never three: the rail and
 * the list each own overflow-y: auto with min-height: 0; the panel, the pane
 * head and the footer never scroll, and neither region chains to the page.
 *
 * 🚨 EVERY rule here is gated on `.platform-menu[data-pp-mounted]`, which
 * app.js sets once it has built the rail from the source rows. Without it the
 * panel is the plain sectioned menu the sources render — the no-JS list, and
 * the phone sheet's accordion — and none of this chrome exists. The gate is
 * also what keeps this layer from arguing with the sheet's handheld rules
 * (~22300): those are (0,2,0)–(0,3,1) and never see the attribute.
 *
 * 🚨 The selectors are over-qualified on purpose (the spec-11 rail's lesson,
 * ~7600): `.column-picker .picker-panel` (0,2,0), `.column-picker
 * .picker-panel label.check` (0,2,1) and the bar's `.filter-row button`
 * (0,1,1) cap rule all outrank a bare class, so every rule below names the
 * mounted menu to out-specify the base rule it replaces.
 */

.platform-menu[data-pp-mounted] {
  /* Geometry (spec §01). Every number is fixed. */
  --pp-w: 640px;
  --pp-h: 466px;
  --pp-rail-w: 216px;
  --pp-rail-row: 31px;
  --pp-row: 30px;
  --pp-field: 34px;
  --pp-head: 33px;
  --pp-foot: 52px;
  /* Panel surface is the dark surface token; the rail is that surface over
     black at 20%. Border and shadow per §01, restated for light mode below. */
  --pp-rail-bg: color-mix(in srgb, var(--surface) 80%, #000 20%);
  --pp-border: rgba(236, 232, 221, 0.22);
  --pp-shadow: 0 24px 56px rgba(0, 0, 0, 0.62);
}

html[data-theme="light"] .platform-menu[data-pp-mounted] {
  --pp-rail-bg: color-mix(in srgb, var(--surface) 96%, #000 4%);
  --pp-border: var(--border-strong);
  --pp-shadow: var(--shadow-panel);
}

/* Once mounted the source rows are the model, not the view. */
.platform-menu[data-pp-mounted] .platform-sources {
  display: none;
}

/* The phone sheet head is display: none everywhere but the ≤720 mounted
   layer, stated here so the handheld rule below is the only place it shows. */
.platform-menu .platform-sheet-head {
  display: none;
}

/* --- Desktop: the 640 × 466 grid ------------------------------------------ */

@media (min-width: 721px) {
  .platform-menu[data-pp-mounted] > .platform-panel {
    box-sizing: border-box;
    display: grid;
    grid-template-columns: var(--pp-rail-w) minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr) auto;
    width: var(--pp-w);
    min-width: 0;
    height: var(--pp-h);
    /* 7px below the bar's bottom border, matching the Genre menu (§01). */
    top: calc(100% + 7px);
    left: 0;
    right: auto;
    padding: 0;
    overflow: hidden;
    border: 1px solid var(--pp-border);
    border-radius: var(--radius);
    box-shadow: var(--pp-shadow);
  }

  /* Left edge aligns to the trigger; if the panel would cross the container
     edge it right-aligns to the trigger instead, never shifts off-grid (§01).
     app.js measures on open and sets the attribute. */
  .platform-menu[data-pp-mounted][data-pp-align="end"] > .platform-panel {
    left: auto;
    right: 0;
  }

  /* Opening: opacity and a 4px translate over 120ms, no measurement (§09). */
  @media (prefers-reduced-motion: no-preference) {
    .platform-menu[data-pp-mounted][open] > .platform-panel {
      animation: platform-panel-in 120ms ease-out;
    }
  }

  /* The rail: full height of the panel, its own scroll. */
  .platform-menu[data-pp-mounted] .platform-rail {
    grid-column: 1;
    grid-row: 1 / -1;
    box-sizing: border-box;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
    padding: 7px;
    border-inline-end: 1px solid var(--border);
    background: var(--pp-rail-bg);
  }

  .platform-menu[data-pp-mounted] .platform-rail-tab {
    height: var(--pp-rail-row);
    font-size: 0.84375rem;
  }

  /* The current row: a 2px leaf left border pulled inside the 7px rail
     padding, radius 0 4 4 0 (§01). */
  .platform-menu[data-pp-mounted] .platform-rail-tab[aria-selected="true"] {
    margin-inline-start: -2px;
    border-inline-start: 2px solid var(--accent);
    border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  }

  /* The field row: 34px field inside 11px 14px 10px, above a 1px divider. */
  .platform-menu[data-pp-mounted] .platform-field {
    grid-column: 2;
    grid-row: 1;
    padding: 11px 14px 10px;
    border-bottom: 1px solid var(--border);
  }

  .platform-menu[data-pp-mounted] .platform-search {
    height: var(--pp-field);
  }

  .platform-menu[data-pp-mounted] .platform-pane {
    grid-column: 2;
    grid-row: 2;
  }

  .platform-menu[data-pp-mounted] .platform-pane-head {
    height: var(--pp-head);
  }

  /* The pinned footer: 52px, count line left, Clear and Apply right (§01).
     (0,3,0) beats `.filter-menu .menu-actions` (0,2,0), whose margin-top and
     padding-top would otherwise add to the fixed height. */
  .platform-menu[data-pp-mounted] .platform-foot {
    grid-column: 2;
    grid-row: 3;
    box-sizing: border-box;
    height: var(--pp-foot);
    margin: 0;
    padding: 0 14px;
  }
}

/* --- Coarse pointer above 720px: the desktop panel, sized for a finger ------
   Issues #1727 / #1728. The panel shipped 21 controls under the 44px floor on
   a coarse-pointer tablet: the search field at 35px, eight rail tabs at 32px,
   twelve platform rows at 30px. Every one of them is already correct at ≤720
   — the phone block below draws 46/46/44 — but that block is keyed on WIDTH
   alone, and a 1024px iPad runs no `max-width: 720px` rule. It takes this
   section's desktop branch, whose numbers are drawn for a mouse.

   🚨 Adding these classes to the touch floor's selector list (~22300) is NOT
   the fix, though it is the reflex #1289 teaches. The floor is (0,1,0) and
   every rule in this section names `.platform-menu[data-pp-mounted]`
   deliberately, to out-specify the base rules it replaces — so the floor
   reaches the rail tab and the field (whose heights it declares as a property
   they do not use) but is INERT against `.platform-list label.platform-row`
   (0,4,1), which declares its own `min-height`. That is the twelve-control
   half of the bug, and the half a selector-list fix would leave shipping.

   The seam that reaches all of them is the geometry tokens: every control in
   this section measures itself from one, so a coarse pointer is four numbers
   rather than twenty-one rules. The numbers are the phone sheet's on purpose
   — one vocabulary for a finger, whichever layout the viewport gets.

   ⭐ This is the SPACE/TARGET split (#1289): which layout the panel is — grid
   or two-level sheet — is a width decision and stays in the queries below.
   How big a thing you press is a pointer decision, and belongs here, where a
   wide touch device can see it.

   🚨 --pp-head grows past 44 because `.platform-group-toggle` ("Select all 8")
   IS a floor member: 44px of control inside a 33px head overflows into the
   list beneath it. A container holding a floor member cannot stay under the
   floor's own height. */
@media (pointer: coarse) and (min-width: 721px) {
  .platform-menu[data-pp-mounted] {
    --pp-rail-row: 46px;
    --pp-row: 46px;
    --pp-field: 44px;
    --pp-head: 48px;
  }

  /* Below 16px iOS Safari zooms the viewport on focus and does not zoom back.
     The phone sheet states this at ≤720; an iPad never reaches that rule and
     needs it just as much. */
  .platform-menu[data-pp-mounted] .platform-search {
    font-size: 1rem;
  }
}

@keyframes platform-panel-in {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
}

/* --- Shared: the rail ------------------------------------------------------- */

.platform-menu[data-pp-mounted] .platform-rail[hidden] {
  display: none;
}

.platform-menu[data-pp-mounted] .platform-rail-heading {
  margin: 6px 10px 8px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.platform-menu[data-pp-mounted] .platform-rail-tabs {
  display: flex;
  flex-direction: column;
  gap: 1px;
}

/* 🚨 `.platform-menu[data-pp-mounted]` is specificity, not scoping: the bar's
   `.filter-row button` cap rule (0,1,1) would otherwise win `border`,
   `border-radius` and `padding-inline` off a bare `.platform-rail-tab` — the
   stray hairline the spec-11 rail documents. */
.platform-menu[data-pp-mounted] .platform-rail-tab {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 8px;
  flex: none;
  width: 100%;
  margin: 0;
  padding: 0 10px;
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  font-family: inherit;
  line-height: 1.2;
  color: var(--text-muted);
  text-align: start;
  white-space: nowrap;
  cursor: pointer;
}

.platform-menu[data-pp-mounted] .platform-rail-tab:hover {
  background: var(--fill);
  color: var(--text);
}

.platform-menu[data-pp-mounted] .platform-rail-tab:focus-visible {
  outline: 2px solid var(--accent-text);
  outline-offset: -2px;
}

.platform-menu[data-pp-mounted] .platform-rail-tab[aria-selected="true"] {
  background: var(--accent-dim);
  font-weight: 600;
  color: var(--text);
}

/* Longer labels truncate with an ellipsis and a title attribute (§01). */
.platform-menu[data-pp-mounted] .platform-rail-tab-label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The leaf pill with the selected count — the only selection signal (§02):
   outlined on idle rows, filled on the active one; absent at zero. */
.platform-menu[data-pp-mounted] .platform-rail-tab-badge {
  flex: none;
  padding: 0 5px;
  border: 1px solid var(--accent-border);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: var(--fs-mono-badge);
  line-height: 1.45;
  color: var(--accent-text);
}

.platform-menu[data-pp-mounted] .platform-rail-tab[aria-selected="true"] .platform-rail-tab-badge {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--accent-ink);
}

/* Counts on the right are PLATFORMS ("how long is the list I am about to
   open"), never games (§02). */
.platform-menu[data-pp-mounted] .platform-rail-tab-count {
  flex: none;
  min-width: 1.5em;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  text-align: end;
  color: var(--text-faint);
}

.platform-menu[data-pp-mounted] .platform-rail-tab-chevron {
  display: none;
}

/* Search mode: the rail dims to 70% but keeps its badges (§04). */
.platform-menu[data-pp-mounted][data-pp-searching] .platform-rail {
  opacity: 0.7;
}

/* --- Shared: the field ------------------------------------------------------ */

.platform-menu[data-pp-mounted] .platform-field {
  display: flex;
  align-items: center;
  gap: 6px;
}

.platform-menu[data-pp-mounted] .platform-field[hidden] {
  display: none;
}

.platform-menu[data-pp-mounted] .platform-search {
  box-sizing: border-box;
  flex: 1;
  min-width: 0;
  margin: 0;
  padding: 0 10px;
  font-size: var(--fs-field);
}

.platform-menu[data-pp-mounted] .platform-back {
  display: none;
}

/* --- Shared: the pane ------------------------------------------------------- */

.platform-menu[data-pp-mounted] .platform-pane {
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* Maker name, platform count, and the group select-all link right aligned.
   Never scrolls (§01). */
.platform-menu[data-pp-mounted] .platform-pane-head {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 12px;
  flex: none;
  padding: 0 14px;
}

.platform-menu[data-pp-mounted] .platform-pane-head[hidden] {
  display: none;
}

/* "SONY · 8 PLATFORMS": maker name uppercase mono, count in the faint token. */
.platform-menu[data-pp-mounted] .platform-pane-title {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text);
}

.platform-menu[data-pp-mounted] .platform-pane-title-count {
  color: var(--text-faint);
}

/* "Select all 8" / "Clear group" is a link, not a checkbox (§03). Specificity
   again: `.filter-row button` would border and pad it. */
.platform-menu[data-pp-mounted] .platform-group-toggle {
  flex: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font-family: inherit;
  font-size: var(--fs-ui-sm);
  color: var(--accent-text);
  white-space: nowrap;
  cursor: pointer;
}

.platform-menu[data-pp-mounted] .platform-group-toggle:hover {
  text-decoration: underline;
}

.platform-menu[data-pp-mounted] .platform-group-toggle[hidden] {
  display: none;
}

/* The list: the pane's one scroll region. */
.platform-menu[data-pp-mounted] .platform-list {
  flex: 1 1 0;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  padding: 0 8px 8px 8px;
}

.platform-menu[data-pp-mounted] .platform-list[hidden] {
  display: none;
}

/* A row: checkbox, name, count. The whole row is the hit area (§01); min-height,
   not height, so a two-line name (phone only) grows its own row and nothing
   else. (0,3,1) beats `.column-picker .picker-panel label.check` (0,2,1). */
.platform-menu[data-pp-mounted] .platform-list label.platform-row {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: var(--pp-row);
  margin: 0;
  padding: 0 6px;
  border-radius: var(--radius-sm);
  cursor: pointer;
}

.platform-menu[data-pp-mounted] .platform-list label.platform-row:hover,
.platform-menu[data-pp-mounted] .platform-list label.platform-row[data-pp-active] {
  background: var(--fill);
}

/* 14px box: a 1px border at 32% text unchecked, leaf fill with an ink tick
   checked — accent-color gives the tick; the size is stated here. */
.platform-menu[data-pp-mounted] .platform-list label.platform-row > input[type="checkbox"] {
  flex: none;
  width: 14px;
  height: 14px;
  margin: 0;
}

.platform-menu[data-pp-mounted] .platform-list label.platform-row > .menu-option-label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 0.84375rem;
}

/* Sub-run headings inside a maker's pane (issue #1745): "Home consoles",
   "Handhelds", "Computers". Quiet — they separate two runs of one maker's
   hardware, they are not section headings competing with the rail. The first
   one sits flush so a pane does not open on a gap. */
.platform-menu[data-pp-mounted] .platform-sub-label {
  margin: var(--space-3) 0 var(--space-1);
  padding: 0 var(--space-2);
  font-size: var(--fs-ui-sm);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.platform-menu[data-pp-mounted] .platform-sub-label:first-child {
  margin-top: 0;
}

/* A platform the current filters leave nothing on. It KEEPS ITS PLACE in the
   run (issue #1745 stopped sorting zeros to the bottom, so a pane no longer
   reshuffles as you tick other filters) and is dimmed instead — still
   readable, still checkable, never hidden (spec §03). */
.platform-menu[data-pp-mounted] .platform-list label.platform-row[data-pp-zero] {
  opacity: 0.55;
}

/* The muted maker label between name and count, search mode only (§03/§04). */
.platform-menu[data-pp-mounted] .platform-row-maker {
  display: none;
  flex: none;
  max-width: 9rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--fs-ui-sm);
  color: var(--text-faint);
}

.platform-menu[data-pp-mounted][data-pp-searching] .platform-row-maker {
  display: inline;
}

/* Counts: mono, right aligned, tabular figures, a reserved column so names
   never reflow as counts change (§01). */
.platform-menu[data-pp-mounted] .platform-list label.platform-row > .menu-option-count {
  flex: none;
  min-width: 48px;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  font-variant-numeric: tabular-nums;
  text-align: end;
  color: var(--text-faint);
}

/* The empty state: one paragraph, footer unchanged, panel height unchanged. */
.platform-menu[data-pp-mounted] .platform-empty {
  margin: 0;
  padding: 12px 14px;
  font-size: var(--fs-ui-sm);
  line-height: 1.5;
  color: var(--text-muted);
}

.platform-menu[data-pp-mounted] .platform-empty[hidden] {
  display: none;
}

/* --- Shared: the footer ----------------------------------------------------- */

.platform-menu[data-pp-mounted] .platform-foot {
  display: flex;
  align-items: center;
  gap: 8px;
  border-top: 1px solid var(--border);
}

/* "3 selected · 5,247 games", mono, left of the buttons. */
.platform-menu[data-pp-mounted] .platform-tally {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: var(--fs-mono);
  color: var(--text-muted);
}

.platform-menu[data-pp-mounted] .platform-tally[hidden] {
  display: none;
}

/* --- Phone, at or below 720px: the two-level full-height sheet (§08) -------- */

@media (max-width: 720px) {
  /* A layer over the filter sheet (z 60), not an accordion row inside it:
     full height minus a strip of the page at the top, the sheet family's
     rounded top corners and its own grab handle. (0,4,0) beats the
     accordion's `.filter-sheet-panel .picker-panel` (0,2,0).

     🚨 `[open]` is load-bearing, not decoration. A closed <details> hides its
     content through the UA's own mechanism, which a `position: fixed` child
     with a stated `display` can escape — measured: the closed panel became a
     full-viewport fixed layer at z-index 61 over the filter sheet, so every
     control under it (both sheets' rows, their footers) reported not visible.
     The panel is a layer only while it is open. */
  .platform-menu[data-pp-mounted][open] > .platform-panel {
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    position: fixed;
    inset: 48px 0 0 0;
    z-index: 61;
    width: auto;
    min-width: 0;
    height: auto;
    margin: 0;
    padding: 0 16px;
    overflow: hidden;
    background: var(--surface);
    border: 0;
    border-top: 1px solid var(--border-strong);
    border-radius: 14px 14px 0 0;
    box-shadow: 0 -18px 44px rgba(0, 0, 0, 0.4);
  }

  /* The grab handle over a mono label — the chrome every bottom sheet on the
     site shares, drawn from this component's own classes (a second
     .filter-sheet-handle inside the filter sheet would make every strict
     locator for that class match two nodes). */
  .platform-menu[data-pp-mounted] .platform-sheet-head {
    display: flex;
    flex-direction: column;
    flex: none;
    position: static;
    margin-inline: -16px;
    padding: 8px 16px 0;
    border-bottom: 1px solid var(--border);
  }

  .platform-menu[data-pp-mounted] .platform-sheet-handle {
    flex-shrink: 0;
    width: 38px;
    height: 4px;
    margin: 2px auto 10px;
    border-radius: 2px;
    background: var(--border-strong);
  }

  .platform-menu[data-pp-mounted] .platform-sheet-head-label {
    padding-bottom: 10px;
    font-family: var(--font-mono);
    font-size: var(--fs-mono-badge);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  /* Sticky search at the top (§08), the field at 16px so iOS Safari never
     zooms the viewport on focus, and never autofocused (app.js). */
  .platform-menu[data-pp-mounted] .platform-field {
    flex: none;
    padding: 10px 0;
    border-bottom: 1px solid var(--border);
  }

  .platform-menu[data-pp-mounted] .platform-search {
    height: 44px;
    font-size: 1rem;
  }

  /* Level 2's back chevron: returns a level, never closes (§08). 44px target. */
  .platform-menu[data-pp-mounted][data-pp-level="2"] .platform-back {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: 44px;
    height: 44px;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: var(--text);
  }

  /* Level 1 is the rail; level 2 (and search) is the pane. Only one is on
     screen, and each is the sheet's scroll region while it is. */
  .platform-menu[data-pp-mounted] .platform-rail {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 4px 0;
  }

  .platform-menu[data-pp-mounted] .platform-rail-heading {
    margin: 8px 4px 4px;
  }

  .platform-menu[data-pp-mounted][data-pp-level="2"] .platform-rail {
    display: none;
  }

  .platform-menu[data-pp-mounted]:not([data-pp-level="2"]) .platform-pane {
    display: none;
  }

  .platform-menu[data-pp-mounted] .platform-pane {
    flex: 1 1 auto;
    min-height: 0;
  }

  /* Rail rows are 46px list rows with the count, the pill and a chevron. */
  .platform-menu[data-pp-mounted] .platform-rail-tab {
    min-height: 46px;
    padding: 0 4px;
    border-radius: 0;
    border-bottom: 1px solid var(--border);
    font-size: 0.9375rem;
    color: var(--text);
  }

  .platform-menu[data-pp-mounted] .platform-rail-tab[aria-selected="true"] {
    background: none;
    font-weight: 500;
  }

  .platform-menu[data-pp-mounted] .platform-rail-tab-chevron {
    display: inline-flex;
    flex: none;
    color: var(--text-faint);
  }

  .platform-menu[data-pp-mounted] .platform-pane-head {
    min-height: 40px;
    padding: 0 4px;
  }

  .platform-menu[data-pp-mounted] .platform-list {
    padding: 0;
  }

  /* Rows are 46px, above the 44px floor, with the checkbox at 18px and names
     at 15px; names wrap to two lines here rather than truncating (§08). */
  .platform-menu[data-pp-mounted] .platform-list label.platform-row {
    min-height: 46px;
    padding: 4px;
    border-radius: 0;
    border-bottom: 1px solid var(--border);
  }

  .platform-menu[data-pp-mounted] .platform-list label.platform-row > input[type="checkbox"] {
    width: 18px;
    height: 18px;
  }

  .platform-menu[data-pp-mounted] .platform-list label.platform-row > .menu-option-label {
    white-space: normal;
    font-size: 0.9375rem;
  }

  /* The sticky footer with Apply and Clear, safe-area padding at the bottom.
     🚨 (0,4,0): the live sheet hides every per-facet footer at
     `form[data-sheet-live] .filter-sheet-panel .menu-actions` (0,3,1) because
     its own "Show N results" applies; this layer is the one facet whose
     footer the spec draws, and Apply here is the same submit. */
  .filter-sheet-panel .platform-menu[data-pp-mounted] .platform-foot {
    display: flex;
    flex: none;
    gap: 10px;
    margin: 0;
    padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px));
    border-top: 1px solid var(--border);
    background: var(--surface);
  }

  .filter-sheet-panel .platform-menu[data-pp-mounted] .platform-foot > .btn {
    flex: 1;
    width: auto;
    min-height: 48px;
    margin: 0;
    justify-content: center;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
  }

  .filter-sheet-panel .platform-menu[data-pp-mounted] .platform-foot > .btn[data-menu-clear] {
    flex: 0 0 auto;
    min-width: 88px;
  }

  .filter-sheet-panel .platform-menu[data-pp-mounted] .platform-tally {
    flex: 1 1 100%;
    order: -1;
    padding: 0 0 6px;
  }

  .filter-sheet-panel .platform-menu[data-pp-mounted] .platform-foot {
    flex-wrap: wrap;
  }
}
