/* The 16 Google Fonts families this file names in --font-heading/--font-body are
   loaded by a <link> in partials/head-assets.php, not by an @import here. An
   @import cannot start downloading until this file has arrived and been parsed,
   which put two serial round trips in front of the first painted glyph — worst
   on the venue wifi this app is used on (UI/UX finding 18). Adding a family to a
   theme below means adding it to that <link> too; verify_head_assets.php fails
   if the two drift apart. */

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

:root {
  --black: #000;
  --white: #fff;
  --gray: #888;
  --red: #f15bb5;
  --orange: #ff8000;
  --yellow: #fbf540;
  --yellow-green: #9f0;
  --green: #0f0;
  --green-cyan: #09bbf9;
  --blue-green: #0ff;
  --blue: #09bbf9;
  /* Raised from #2e3192 in the UI/UX pass. Tavern had already overridden this
     for itself (see its block) because #2e3192 is too dark to carry Center
     Stage; measured across all eleven themes, so is every other theme —
     --button-text on a Center Stage fill failed on 8 of 10, including Dark at
     3.21:1, and --mod-center as text on a dark card ran 2.02–2.56:1. The
     variable feeds --mod-center and nothing else, in any theme, so this is the
     whole fix. */
  --blue-violet: #4b4fc0;
  --violet: #9b5de5;
  --magenta: #f09;
  --pink: #f6c;
}

:root,
:root option {
  color-scheme: dark;
  --bg: #07111f;
  --bg-2: #18223b;
  --text: #f4f7fb;
  --muted: rgb(212, 212, 212);
  --accent: #2dffff;
  --accent-solid: #5460cf;
  --panel-bg: rgba(255, 255, 255, 0.05);
  --panel-border: rgba(255, 255, 255, 0.1);
  --card-bg: rgba(255, 255, 255, 0.02);
  --border-soft: rgba(255, 255, 255, 0.08);
  --field-bg: rgba(255, 255, 255, 0.04);
  --field-border: rgba(255, 255, 255, 0.16);
  --field-focus: rgba(123, 140, 255, 0.35);

  --bg: #07111f;
  --bg-2: #18223b;
  --text: #f4f7fb;
  --muted: #9fb0cb;
  --accent: #7b8cff;
  --accent-solid: #5460cf;
  --panel-bg: rgba(255, 255, 255, 0.05);
  --panel-border: rgba(255, 255, 255, 0.1);
  --card-bg: rgba(255, 255, 255, 0.02);
  --border-soft: rgba(255, 255, 255, 0.08);
  --field-bg: rgba(255, 255, 255, 0.04);
  --field-border: rgba(255, 255, 255, 0.16);
  --field-focus: rgba(123, 140, 255, 0.35);
  --button-text: #211f2e;

  /* Three colours that used to be hardcoded in the rules that used them, and
     so assumed a dark background. Measured across all eleven themes, all three
     were unreadable on the six light ones — .error reached 1.02:1 on Golden
     Hour Sunset and the Center Stage turn marker 1.02:1 on Parchment. Base
     values here are the dark-theme ones (unchanged in appearance); every light
     theme overrides all three.

     --danger is deliberately NOT --red. --red is the Cast module's identity
     hue and is #f15bb5, a hot pink: a destructive button was rendering in the
     same pink as a Cast chip, which is the wrong signal twice over. Splitting
     them lets Cast stay pink and danger be red. */
  --error-text: #ff9a9a;
  --combat-active: #e8b339;
  --danger: #ff8a8a;

  /* Text drawn on an --accent or --danger fill, as opposed to --button-text
     which is text on a MODULE fill. On ten of the eleven themes these are the
     same colour and this just forwards. They part company on Light, where
     module fills are bright (so they need dark lettering) but the accent and
     danger fills are deep (so they need light lettering) — one "text on a fill"
     token cannot answer both. */
  --on-accent: var(--button-text);

  /* Ink for a button, matched to whichever fill that button actually got.
     A .button inside a .mod-* section is filled with --mod and takes
     --button-text; a .button with no module in scope is filled with --accent
     and takes --on-accent. The .mod-* rule below flips this to --button-text,
     so the default here is the mod-LESS case.

     This exists because `background: var(--mod, var(--text))` used to pair a
     --text fill with --button-text ink, which is neither of the two cases the
     token above describes — and on Light and Auto-light that is #1a1a1a on
     #303030, **1.32:1**. It reached the landing hero, the login card's submit,
     signup, forgot-password, join, and help.php's logged-out CTA: every
     button a logged-out visitor can see, illegible on the default light theme.

     Nothing noticed for the life of the app because the contrast harness only
     ever composited --button-text against a MODULE fill — the fallback branch
     had never been in the inventory. Same shape as findings 23 and 24, and the
     reason theme-contrast.py now carries the pair explicitly. */
  --button-ink: var(--on-accent);

  /* How far --mod-ink is pulled toward --text; see the .mod-* rule below.
     0% means the ink IS the module colour, which is correct wherever the module
     colour is already legible as text on a card — that is every theme except
     Light, whose fills are deliberately too bright to write with. */
  --mod-ink-mix: 0%;

  --font-heading: "Manrope", system-ui, sans-serif;
  --font-body: "Public Sans", system-ui, sans-serif;
  --border-width: 1px;
  --border-style: solid;
  --shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.25);
  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.2);
  --shadow-button: none;
  --shadow-popover: 0 0.8rem 2rem -0.5rem rgba(0, 0, 0, 0.5);
  --icon-stroke-width: 2;
  /* THE DARK-THEME MODULE SET — inherited by Dungeon Stone and Brass &
     Gaslight, overridden by Tavern (chalk) and Neon Circuit (magenta).

     Mixed toward --text, exactly like the light-theme rule further down, and
     for the same reason in mirror. These used to mix toward --muted, which is
     MID-TONE on every theme — so the darker hues (--blue-violet feeding Center
     Stage, --violet feeding Backstage) never got light enough to read on a dark
     card or to sit under a dark --button-text. Center Stage failed on 8 of 10
     themes and Backstage on 8.

     One principle for the whole app: a module colour is mixed toward the far
     end of its theme. Light themes mix toward a dark --text at 75%; dark themes
     mix toward a light --text at 60%. Tavern was already doing this by hand
     with --chalk before it was a rule, which is most of why it was the only
     healthy theme in the audit.

     60% is the minimum that clears 4.5:1 in both directions on all four themes
     that use it; Dungeon Stone is the binding one at 4.85:1 as text on its
     card. Below 60% it fails; above, the hues start converging on --text. */
  --mod-campaigns: color-mix(in srgb, var(--gray), var(--text) 60%);
  --mod-players: color-mix(in srgb, var(--white), var(--text) 60%);
  --mod-cast: color-mix(in srgb, var(--red), var(--text) 60%);
  --mod-locations: color-mix(in srgb, var(--orange), var(--text) 60%);
  --mod-props: color-mix(in srgb, var(--yellow), var(--text) 60%);
  --mod-groups: color-mix(in srgb, var(--green), var(--text) 60%);
  --mod-callback: color-mix(in srgb, var(--blue), var(--text) 60%);
  --mod-center: color-mix(in srgb, var(--blue-violet), var(--text) 60%);
  --mod-backstage: color-mix(in srgb, var(--violet), var(--text) 60%);
  --mod-tags: color-mix(in srgb, var(--blue-green), var(--text) 60%);
}

@media (prefers-color-scheme: light) {
  :root:not([data-theme]) {
    color-scheme: light;
  }

  :root:not([data-theme]),
  :root:not([data-theme]) option {
    --bg: #f7f5f2;
    --bg-2: #eef1f8;
    --text: #303030;
    --muted: #635f74;
    /* #5460cf was 5.03:1 as a link on this theme's panel but only 2.45:1 on
       the explicit Light theme, whose panel carries a module tint. One value
       for both, measured against the tinted one. */
    --accent: #3f49b0;
    --accent-solid: #3f49b0;
    --panel-bg: rgba(255, 255, 255, 0.55);
    --panel-border: rgba(20, 20, 35, 0.1);
    --card-bg: rgba(255, 255, 255, 0.5);
    --border-soft: rgba(20, 20, 35, 0.08);
    --field-bg: rgba(231, 231, 231, 0.788);
    --field-border: rgba(20, 20, 35, 0.18);
    --field-focus: rgba(84, 96, 207, 0.25);
    --error-text: #8a1f29;
    --combat-active: #714100;
    --danger: #96222a;
    --font-heading: "Manrope", system-ui, sans-serif;
    --font-body: "Public Sans", system-ui, sans-serif;
    --border-width: 1px;
    --border-style: solid;
    --shadow-panel: 0 1px 3px rgba(20, 20, 35, 0.1);
    --shadow-card: 0 1px 2px rgba(20, 20, 35, 0.08);
    --shadow-button: none;
    --shadow-popover: 0 0.8rem 2rem -0.5rem rgba(0, 0, 0, 0.25);
    --icon-stroke-width: 2;
    /* Same bright set as the explicit Light theme — see the shared rule below.
       Restated here because a rule inside this media query cannot be merged
       with one outside it, and kept identical on purpose: Auto and Light are
       the same palette, and a chip must not change colour when someone
       switches between them on a light-mode device. */
    --button-text: #1a1a1a;
    --on-accent: #f7f9ff;
    --mod-ink-mix: 75%;
    --mod-campaigns: color-mix(in srgb, var(--gray), var(--white) 25%);
    --mod-players: #c9c7d6;
    --mod-cast: color-mix(in srgb, var(--red), var(--white) 25%);
    --mod-locations: color-mix(in srgb, var(--orange), var(--white) 25%);
    --mod-props: color-mix(in srgb, var(--yellow), var(--white) 25%);
    --mod-groups: color-mix(in srgb, var(--green), var(--white) 25%);
    --mod-callback: color-mix(in srgb, var(--blue), var(--white) 25%);
    --mod-center: color-mix(in srgb, var(--blue-violet), var(--white) 40%);
    --mod-backstage: color-mix(in srgb, var(--violet), var(--white) 25%);
    --mod-tags: color-mix(in srgb, var(--blue-green), var(--white) 25%);
  }
}

:root[data-theme="light"],
:root[data-theme="light"] option {
  color-scheme: light;
  --bg: #f7f5f2;
  --bg-2: #eef1f8;
  --text: #303030;
  --muted: #635f74;
  --accent: #3f49b0;
  --accent-solid: #3f49b0;
  --error-text: #8a1f29;
  --combat-active: #714100;
  --danger: #96222a;
  --panel-bg: rgba(255, 255, 255, 0.55);
  --panel-border: rgba(20, 20, 35, 0.1);
  --card-bg: rgba(255, 255, 255, 0.5);
  /* §12: "the light gray chosen for Party notes is too close to the white
     text." Measured, the problem is not a gray anywhere in the party-notes
     block — it sets none — it is that on this theme a .section-card is white
     over a white panel over a near-white page: 1.02:1 card-to-panel, with a
     border at 1.18:1 against the card it outlines. The block had no edge, so
     it stopped reading as a block.

     The border is the fix rather than the card surface, because darkening the
     card would move every text contrast measured on it. 0.24 puts the edge at
     1.69:1 — visible as a hairline, still quiet. Every other theme has real
     card separation and is left alone. */
  --border-soft: rgba(20, 20, 35, 0.24);
  --field-bg: rgba(231, 231, 231, 0.788);
  --field-border: rgba(20, 20, 35, 0.18);
  --field-focus: rgba(84, 96, 207, 0.25);
  /* Dark, because this theme's module fills are bright — see the Light module
     rule below. Every other light theme fills dark and keeps a light one. */
  --button-text: #1a1a1a;
  --on-accent: #f7f9ff;
  --mod-ink-mix: 75%;
}

/* Five colorful, textured themes — alternatives to dark/light, not replacements.

   These used to share one module-color set "nudged to clear 4.5:1 against the
   tightest of the five card surfaces (Tavern's)". That claim expired twice
   over: Tavern has since moved to its own chalk recipe and left the set, and
   tuning colors for four LIGHT themes against a DARK theme's card is backwards
   regardless — a color light enough to read on Tavern's slate is exactly the
   color that disappears on Parchment's paper. Measured on their own surfaces,
   Parchment failed 56 of 64 checks and Meadow 42.

   Tavern is now grouped here only for `color-scheme`. The four genuinely light
   ones take the shared light-theme module rule further down, with Light and
   Auto-light. */
:root[data-theme="parchment"],
:root[data-theme="meadow"],
:root[data-theme="tavern"],
:root[data-theme="grove"],
:root[data-theme="cartographer"] {
  color-scheme: light;
}

:root[data-theme="parchment"],
:root[data-theme="parchment"] option {
  --red: rgb(182, 24, 0);
  --bg: #f6ecd6;
  --bg-2: #dcb877;
  --text: #3d2b20;
  --muted: #7a6552;
  --accent: #8a2733;
  --accent-solid: #6d1d27;
  /* Was #dbccaf, which is only a little lighter than the tan surfaces it sits
     on. Now that fills are dark (see the shared module rule below) the button
     text is doing real work and needs to be a true light. */
  --button-text: #fbf4e4;
  --error-text: #8a1f29;
  --combat-active: #714100;
  --danger: #96222a;
  --panel-bg: rgba(251, 245, 230, 0.92);
  --panel-border: rgba(61, 43, 32, 0.16);
  /* Lifted off the tan wash it used to share with --field-bg. A card at
     rgba(199,174,158,0.25) over this theme's panel is mid-tan, and a module
     colour written on it topped out at 3.61:1 no matter how dark the module
     went — the card was the limit, not the ink. */
  --card-bg: rgba(255, 250, 238, 0.55);
  --border-soft: rgba(61, 43, 32, 0.12);
  --field-bg: rgba(199, 174, 158, 0.25);
  --field-border: rgba(61, 43, 32, 0.2);
  --field-focus: rgba(156, 61, 71, 0.22);
  --font-heading: "Cormorant Garamond", serif;
  --font-body: "EB Garamond", serif;
  --shadow-panel: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 3px 10px rgba(61, 43, 32, 0.25);
  --shadow-card: inset 0 1px 0 rgba(255, 255, 255, 0.4), 0 1px 4px rgba(61, 43, 32, 0.18);
  --shadow-button: 0 2px 0 rgba(61, 43, 32, 0.3);
  --shadow-popover: 0 1rem 2rem -0.5rem rgba(61, 43, 32, 0.4);
  --icon-stroke-width: 1.5;
  --border-radius: 3px;
  --corner-shape: scoop;
}

:root[data-theme="meadow"],
:root[data-theme="meadow"] option {
  --red: rgb(202, 0, 51);
  --yellow: rgb(241, 189, 140);
  --bg: #f3d9c4;
  --bg-2: #8f7aa3;
  --text: #1b151d;
  /* Was #9771a8 — OKLCH lightness 0.606, where every other light theme's muted
     sits at 0.50–0.55. It was a decorative sunset purple being used as a text
     colour, and it put --text-muted at 3.62:1 and raw --muted at 1.05:1. Same
     hue and chroma, dropped to L 0.52. */
  --muted: #7d588d;
  --button-text: #fff1e6;
  --accent: #5a3a5f;
  --accent-solid: #402a44;
  --error-text: #8a1f29;
  --combat-active: #714100;
  --danger: #96222a;
  --panel-bg: rgba(250, 243, 240, 0.85);
  --panel-border: rgba(74, 51, 80, 0.14);
  --card-bg: rgba(255, 255, 255, 0.5);
  --border-soft: rgba(74, 51, 80, 0.1);
  --field-bg: rgba(255, 215, 188, 0.5);
  --field-border: rgba(74, 51, 80, 0.2);
  --field-focus: rgba(107, 76, 111, 0.22);
  --font-heading: "Playfair Display", serif;
  --font-body: "Poppins", sans-serif;
  --border-radius: 1.4rem;
  --corner-shape: round;
  --border-width: 1px;
  --border-style: solid;
  --shadow-panel: 0 0 24px rgba(200, 150, 180, 0.35);
  --shadow-card: 0 0 12px rgba(200, 150, 180, 0.28);
  --shadow-button: 0 0 14px rgba(107, 76, 111, 0.4);
  --shadow-popover: 0 0 30px rgba(200, 150, 180, 0.4);
  --icon-stroke-width: 1.75;
}

/* Tavern Common Room — a wood wall hung with blackboards, written in chalk.
   Reworked Batch 3 Day 6 against Vi's "it doesn't look like a tavern."

   Three things here were measured, not eyeballed, and undoing any of them
   brings back the thing that was wrong:

   1. The module colours mix toward --chalk, NOT toward --muted. Tavern is the
      only one of the five "light textured" themes with DARK surfaces, and it
      was still using their recipe — each identity hue mixed 75% into a pale
      sage --muted. That put five of the ten under 0.04 chroma: Campaigns,
      Players, Center Stage, Cast and Backstage all came out the same washed
      sage. Chalk sticks come in colours; those didn't read as any of them.
      Mixing 50% toward a chalk white keeps the hue and still clears the bar.
   2. --muted stays DIM (#a9a396) and is NOT the chalk. `.muted` and `label`
      mix --muted only 40% with --text, so a chalk-bright --muted would put
      muted text 0.02 apart in lightness from body text — invisible as a
      distinction. At #a9a396 it is 0.08 apart and still reads 7.8:1.
   3. --blue-violet used to be overridden for tavern alone: #2e3192 was dark
      enough that 50% chalk left Center Stage as the one module with no
      headroom, and #4b4fc0 raised both its contrast and its chroma. The
      UI/UX pass found every other theme had the same problem and promoted
      #4b4fc0 to the base :root, so tavern no longer needs its own copy — but
      tavern is still the theme with the least headroom here, so it is the one
      to re-measure first if that base value is ever moved again.

   Per PLAN §10's 4.5:1 bar, measured against the flattened card surface
   (#131917), the tightest module is now Center Stage at 6.36:1 — up from
   5.64:1 before — and the eight chromatic modules went from 0.027-0.127
   chroma to 0.070-0.209. Contrast went UP while saturation went up too;
   the old recipe was spending contrast it didn't need. Keep the slate DARK
   if you retune: lightening the card is what costs Center Stage its
   headroom, and it is the module that runs out first. */
:root[data-theme="tavern"],
:root[data-theme="tavern"] option {
  --chalk: #e2ddcc;
  --bg: #5c3f2a;
  --bg-2: #38251a;
  --text: #e8e4d8;
  --button-text: #1f2b22;
  --muted: #a9a396;
  --accent: #e0c468;
  --accent-solid: #b89a3f;
  --panel-bg: rgba(26, 33, 31, 0.96);
  --panel-border: rgba(226, 221, 204, 0.3);
  --card-bg: rgba(18, 24, 22, 0.92);
  --border-soft: rgba(226, 221, 204, 0.2);
  --field-bg: rgba(10, 14, 12, 0.55);
  --field-border: rgba(226, 221, 204, 0.32);
  --field-focus: rgba(224, 196, 104, 0.35);
  --font-heading: "Kalam", cursive;
  /* Handwriting for headings and labels, the regular face for values and
     paragraphs — which is what a real pub blackboard looks like, and it also
     removes 0.9rem cursive body copy on a phone. See the tavern font rules
     further down; inputs inherit `font: inherit`, so the label rule needs a
     matching rule putting the controls back. */
  --font-body: "Public Sans", system-ui, sans-serif;
  --border-width: 2px;
  --border-style: solid;
  --shadow-panel: 0 6px 14px rgba(0, 0, 0, 0.5);
  --shadow-card: 0 3px 8px rgba(0, 0, 0, 0.4);
  --shadow-button: 0 3px 0 rgba(0, 0, 0, 0.4);
  --shadow-popover: 0 1rem 2rem -0.4rem rgba(0, 0, 0, 0.6);
  --icon-stroke-width: 2.5;
  --border-radius: 3px;
  --corner-shape: notch;
  --mod-campaigns: color-mix(in srgb, var(--gray), var(--chalk) 50%);
  --mod-players: color-mix(in srgb, var(--white), var(--chalk) 50%);
  --mod-cast: color-mix(in srgb, var(--red), var(--chalk) 50%);
  --mod-locations: color-mix(in srgb, var(--orange), var(--chalk) 50%);
  --mod-props: color-mix(in srgb, var(--yellow), var(--chalk) 50%);
  --mod-groups: color-mix(in srgb, var(--green), var(--chalk) 50%);
  --mod-callback: color-mix(in srgb, var(--blue), var(--chalk) 50%);
  --mod-center: color-mix(in srgb, var(--blue-violet), var(--chalk) 50%);
  --mod-backstage: color-mix(in srgb, var(--violet), var(--chalk) 50%);
  --mod-tags: color-mix(in srgb, var(--blue-green), var(--chalk) 50%);
}

:root[data-theme="grove"],
:root[data-theme="grove"] option {
  --bg: #7f9e7a;
  --bg-2: #b2d6ca;
  /* Was #263329, which put the landing page's footer and attribution at 4.45:1
     on the darkest stop of the body gradient — the last failure left in
     theme-contrast.py, carried unowned out of batch 6. The harness's own note
     said no ink could fix it and that the surface had to move; that was wrong,
     and worth correcting rather than quietly deleting, because it is the reason
     nobody tried. Black on this gradient measures 7.07:1, so there was plenty of
     room — this is four steps darker along the same hue and lands at 4.82:1.
     Darkening ink on a light theme cannot cost contrast anywhere else: every
     other Grove surface (--panel-bg, --card-bg, --field-bg) is lighter than the
     body, and the one place --text is a *fill* rather than ink is a .button,
     whose --button-text is #f7f9f1 and only gains. All 867 pairs re-run. */
  --text: #222d24;
  --muted: #5f6f5c;
  --accent: #9c4a30;
  --accent-solid: #7d3a26;
  /* Grove and Cartographer never set --button-text, so they inherited the base
     dark theme's #211f2e — dark text, on a light theme, over what are now dark
     module fills. Both need a real light. */
  --button-text: #f7f9f1;
  --error-text: #8a1f29;
  --combat-active: #714100;
  --danger: #96222a;
  --panel-bg: rgba(245, 247, 238, 0.85);
  --panel-border: rgba(38, 51, 41, 0.16);
  --card-bg: rgba(235, 240, 224, 0.55);
  --border-soft: rgba(38, 51, 41, 0.1);
  --field-bg: #ebf0e044;
  --field-border: rgba(38, 51, 41, 0.2);
  --field-focus: rgba(193, 96, 63, 0.22);
  --font-heading: "Cinzel", serif;
  --font-body: "Nunito", sans-serif;
  --border-radius: 1.6rem;
  --corner-shape: round;
  --border-width: 1px;
  --border-style: solid;
  --shadow-panel: 0 0 18px rgba(120, 180, 100, 0.3);
  --shadow-card: 0 0 10px rgba(120, 180, 100, 0.22);
  --shadow-button: 0 0 12px rgba(156, 74, 48, 0.35);
  --shadow-popover: 0 0 26px rgba(120, 180, 100, 0.35);
  --icon-stroke-width: 2;
}

:root[data-theme="cartographer"],
:root[data-theme="cartographer"] option {
  --bg: #ecdfc2;
  --bg-2: #e3d1a8;
  --text: #3a3226;
  --muted: #7a6f58;
  --accent: #6f5322;
  --accent-solid: #574019;
  --button-text: #fdf8ec;
  --error-text: #8a1f29;
  --combat-active: #714100;
  --danger: #96222a;
  --panel-bg: rgba(250, 244, 228, 0.88);
  --panel-border: rgba(58, 50, 38, 0.16);
  /* Lifted for the same reason as Parchment's — the old tan wash made the card
     mid-tone and capped module ink at 3.77:1. */
  --card-bg: rgba(255, 252, 242, 0.5);
  --border-soft: rgba(58, 50, 38, 0.1);
  --field-bg: rgba(58, 50, 38, 0.05);
  --field-border: rgba(58, 50, 38, 0.2);
  --field-focus: rgba(138, 106, 47, 0.2);
  --font-heading: "Special Elite", monospace;
  --font-body: "EB Garamond", serif;
  --border-radius: 0.15rem;
  --corner-shape: bevel;
  --border-width: 1px;
  --border-style: dashed;
  --shadow-panel: none;
  --shadow-card: none;
  --shadow-button: none;
  --shadow-popover: 0 0.5rem 1.2rem -0.3rem rgba(58, 50, 38, 0.3);
  --icon-stroke-width: 1.5;
}

/* THE LIGHT-THEME MODULE SET.
   One rule for all five explicit light themes. `var()` inside a custom property
   resolves where it is USED, so each theme's own --text feeds this and each
   gets its own correctly-toned set from a single recipe.

   The principle, stated once because it is the thing the whole audit turned on:
   a module color is mixed toward the FAR END of its theme. On the dark themes
   the eight identity hues are mixed toward a light (Tavern mixes toward
   --chalk); on a light theme they must be mixed toward --text. The old light
   themes mixed toward --muted, which is mid-tone on every theme, so the hues
   stayed pale — simultaneously too pale to read as text on a pale card (down to
   1.09:1) and too pale to sit under --button-text (down to 1.44:1). Both
   directions failed at once, which is why fixing one end never helped.

   75% is the minimum that clears 4.5:1 in BOTH directions on all six light
   themes; measured, the binding pair is Light's "as text on card" at 5.06:1 and
   Parchment's at 4.88:1. Do not lower it. Raising it is safe for contrast but
   costs chroma — at 75% the eight hues hold 0.011–0.127 chroma and still read
   as distinct colors, which is the same trade the Tavern block documents.

   Grove and Cartographer previously defined NO --mod-* at all and silently
   inherited the base dark theme's recipe. They are in this list now.

   Light is NOT in this list — it fills bright and writes with --mod-ink
   instead. See the rule below it. */
:root[data-theme="parchment"],
:root[data-theme="meadow"],
:root[data-theme="grove"],
:root[data-theme="cartographer"] {
  --mod-campaigns: color-mix(in srgb, var(--gray), var(--text) 75%);
  --mod-players: color-mix(in srgb, var(--white), var(--text) 75%);
  --mod-cast: color-mix(in srgb, var(--red), var(--text) 75%);
  --mod-locations: color-mix(in srgb, var(--orange), var(--text) 75%);
  --mod-props: color-mix(in srgb, var(--yellow), var(--text) 75%);
  --mod-groups: color-mix(in srgb, var(--green), var(--text) 75%);
  --mod-callback: color-mix(in srgb, var(--blue), var(--text) 75%);
  --mod-center: color-mix(in srgb, var(--blue-violet), var(--text) 75%);
  --mod-backstage: color-mix(in srgb, var(--violet), var(--text) 75%);
  --mod-tags: color-mix(in srgb, var(--blue-green), var(--text) 75%);
}

/* ==========================================================================
   COMBATANT PALETTE (playtest fixes batch 9)

   Six colours a GM assigns to combatants at Center Stage, drawn as a 3px strip
   on the top of a card. §12: "This would need to be a pre-set list to make it
   fast at center stage for ad-hoc… maybe a 3px border at the top would give
   them that direct visual organization?"

   **DO NOT HAND-EDIT THESE VALUES, and do not reorder the keys.** They are the
   output of `python docs/playtest/combatant-palette.py`, which solves them
   against three constraints that no amount of looking at them can check:

     1. each colour ≥ 3:1 against the card it sits on (a 3px strip is a
        non-text mark, so 3:1 rather than 4.5:1);
     2. each colour separable from every OTHER colour — a colour-vs-colour
        question WCAG contrast cannot answer at all, since it is a luminance
        ratio and scores pure red against pure green at 1.0:1;
     3. still separable under deuteranopia and protanopia, which is ~1 in 12
        men and therefore likely at any table of five.

   Run the script to regenerate after ANY change to a theme's --card-bg or
   --bg; it audits the shipped table and exits non-zero. `--proof` draws the
   whole thing as a simulated turn order.

   **Why these are literals and not color-mix() like --mod-* is.** A swatch's
   lightness is solved per theme AND per hue to hit a contrast target — a
   binary search, which CSS cannot express. Pinning one lightness for all six
   (which CSS could do) was tried and ceilinged at three usable colours,
   because lightness is the one channel a dichromat keeps.

   **Why not reuse --mod-*.** Those carry module meaning — --mod-cast is Cast,
   --mod-tags is Tags — and a combatant bordered in Tags-cyan says something the
   app does not mean. They also measure 0.0000 against each other under
   protanopia on Dark (--mod-players and --mod-tags are literally identical),
   which is fine for a chip that carries a text label and fatal where colour is
   the only signal.

   The key list is mirrored by combatantPalette() in api/combat_helpers.php,
   which decides what is *accepted*; these decide what *draws*. A key in one
   and not the other renders a strip of nothing, silently — the same two-list
   hazard as combatAttributeIconOptions()/lucideIcon().
   tests/verify_combatant_colours.php fails on either direction.
   ========================================================================== */
:root {
  --combatant-green: #00932a;
  --combatant-indigo: #7a6cef;
  --combatant-chartreuse: #afcb00;
  --combatant-crimson: #ff6970;
  --combatant-cyan: #00cfff;
  --combatant-violet: #f1a3ff;
}

@media (prefers-color-scheme: light) {
  :root:not([data-theme]) {
    --combatant-green: #00a43c;
    --combatant-indigo: #887cff;
    --combatant-chartreuse: #4b5f00;
    --combatant-crimson: #d33948;
    --combatant-cyan: #005c8f;
    --combatant-violet: #7f30ab;
  }
}

:root[data-theme="light"] {
  --combatant-green: #00932a;
  --combatant-indigo: #796cef;
  --combatant-chartreuse: #3e5100;
  --combatant-crimson: #c2253a;
  --combatant-cyan: #004d80;
  --combatant-violet: #711f9b;
}

:root[data-theme="parchment"] {
  --combatant-green: #00942b;
  --combatant-indigo: #7b6df0;
  --combatant-chartreuse: #3f5200;
  --combatant-crimson: #c3273b;
  --combatant-cyan: #004e82;
  --combatant-violet: #72209d;
}

:root[data-theme="meadow"] {
  --combatant-green: #008f26;
  --combatant-indigo: #7769eb;
  --combatant-chartreuse: #3c4e00;
  --combatant-crimson: #be2137;
  --combatant-cyan: #004a7d;
  --combatant-violet: #6f1b98;
}

:root[data-theme="tavern"] {
  --combatant-green: #007a05;
  --combatant-indigo: #6554d5;
  --combatant-chartreuse: #93ae00;
  --combatant-crimson: #e44a56;
  --combatant-cyan: #00afe5;
  --combatant-violet: #cf81ff;
}

:root[data-theme="grove"] {
  --combatant-green: #00982f;
  --combatant-indigo: #7d70f4;
  --combatant-chartreuse: #425500;
  --combatant-crimson: #c62b3e;
  --combatant-cyan: #005185;
  --combatant-violet: #7524a0;
}

:root[data-theme="cartographer"] {
  --combatant-green: #00972f;
  --combatant-indigo: #7d70f3;
  --combatant-chartreuse: #425400;
  --combatant-crimson: #c62b3d;
  --combatant-cyan: #005184;
  --combatant-violet: #75249f;
}

:root[data-theme="dungeon"] {
  --combatant-green: #00a139;
  --combatant-indigo: #8579fd;
  --combatant-chartreuse: #bedc27;
  --combatant-crimson: #ff8388;
  --combatant-cyan: #00e4ff;
  --combatant-violet: #ffb7ff;
}

:root[data-theme="cyberpunk"] {
  --combatant-green: #007000;
  --combatant-indigo: #5d4aca;
  --combatant-chartreuse: #88a200;
  --combatant-crimson: #d93f4c;
  --combatant-cyan: #00a3d9;
  --combatant-violet: #c376f4;
}

:root[data-theme="steampunk"] {
  --combatant-green: #008f25;
  --combatant-indigo: #7668eb;
  --combatant-chartreuse: #aac700;
  --combatant-crimson: #fd616a;
  --combatant-cyan: #00c9ff;
  --combatant-violet: #eb9dff;
}

/* LIGHT IS THE COLOURFUL ONE. It exists for Vi, and the brief for it is gloss
   and rainbow, not restraint — so it is the one theme that does NOT take the
   shared light-theme module rule above.

   Everywhere else a module colour has to be both a fill and a piece of ink, and
   satisfying both is what forces it dark on a light theme. Light escapes that
   by refusing to do both with one colour:

     --mod       the identity hue lifted 25% toward white — bright, 0.27 chroma
                 at the top end, and read under a DARK --button-text, which is
                 how a vivid chip is normally built. This is what fills every
                 button, pill, chip and badge.
     --mod-ink   the same hue pulled 75% toward --text, used only where the
                 colour is written rather than filled with. --mod-ink-mix drives
                 this and is 0% on every other theme.

   25% is the minimum lift that clears 4.5:1 under #1a1a1a across nine of the
   ten modules, and it is still the right number for those nine: going brighter
   is safe for contrast and costs saturation, so don't.

   Center Stage is lifted 40% instead, and it is worth knowing why that is not
   the flattening this comment used to warn against. --blue-violet is the
   darkest of the ten source hues, so an equal lift does not buy it an equal
   result. Measured under #1a1a1a, the ten used to run:

       center 4.59 · backstage 6.27 · campaigns 7.15 · cast 7.44 ·
       locations 8.58 · callback 9.35 · players 10.46 · groups 12.93 ·
       tags 14.06 · props 15.58

   which is the actual defect: not that 4.59 fails — it passes — but that it is
   a third of the way to the floor while its nearest sibling is at 6.27 and the
   spread runs to 15.58. It was reported from the table as the Center Stage and
   Start-encounter buttons being hard to read on Light (§12, 2026-08-05), which
   is what a lone outlier looks like to someone who is not reading a table of
   ratios. 40% puts it at 6.25:1, level with Backstage, and moves nothing else.

   Checked in both directions, because --mod-center is a fill here and also
   feeds --mod-ink (--mod-ink-mix is 75% on this theme) where the same hue is
   *written* rather than filled with, and lifting a fill darkens nothing —
   it lightens the ink too, on an already-light card. --mod-ink goes 9.25:1 →
   8.44:1 on a composited card. Both ends clear, so this is a real fix and not
   a trade; if you retune it again, measure that second number too.

   Players is the exception and always will be: its source is --white, which has
   no hue to be bright with and would leave an invisible white chip on a white
   card. It gets a literal light silver instead. */
:root[data-theme="light"] {
  --mod-campaigns: color-mix(in srgb, var(--gray), var(--white) 25%);
  --mod-players: #c9c7d6;
  --mod-cast: color-mix(in srgb, var(--red), var(--white) 25%);
  --mod-locations: color-mix(in srgb, var(--orange), var(--white) 25%);
  --mod-props: color-mix(in srgb, var(--yellow), var(--white) 25%);
  --mod-groups: color-mix(in srgb, var(--green), var(--white) 25%);
  --mod-callback: color-mix(in srgb, var(--blue), var(--white) 25%);
  --mod-center: color-mix(in srgb, var(--blue-violet), var(--white) 40%);
  --mod-backstage: color-mix(in srgb, var(--violet), var(--white) 25%);
  --mod-tags: color-mix(in srgb, var(--blue-green), var(--white) 25%);
}

/* The gloss. Two numbers make this panel look wet rather than painted: the
   spread between the lightness stops, and the chroma.

   The audit flattened both — it took the spread from 0.30 to 0.07 and left
   chroma at 0.09, which is how a glossy sheet became a flat tint. But the
   original 0.6 stop at 40% was a genuine mid-tone band across the middle of
   every panel, and it is what held h3 to 3.91:1 and links to 2.45:1.

   So: keep a real sweep, but raise its floor and push the colour up to pay for
   it. Spread is back to 0.19 (0.97 down to 0.78) and chroma goes 0.09 -> 0.15,
   which is more colour than this theme has ever had. The light-dark-light-dark
   sweep across 0/40/75/90% is the sheen and is unchanged in shape.

   Only the HUE of --mod-bg survives into this gradient; the lightness and
   chroma here are literal. That is why brightening --mod above did not brighten
   the panel, and why these two have to be tuned separately. */
:root[data-theme="light"] .panel {
  background: linear-gradient(
    100deg,
    oklch(from var(--mod-bg) 0.97 0.15 h) 0%,
    oklch(from var(--mod-bg) 0.78 0.15 h) 40%,
    oklch(from var(--mod-bg) 0.92 0.15 h) 75%,
    oklch(from var(--mod-bg) 0.88 0.15 h) 90%
  );
}

:root[data-theme="parchment"] body .panel {
  background-image:
    url("/assets/images/paper_1.webp"),
    radial-gradient(ellipse 70% 50% at 15% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
    linear-gradient(160deg, #f4ead4 0%, #ecddb5 55%, #e3cca3 100%);
  background-position:
    top right,
    bottom left,
    center center;
  background-blend-mode: multiply, overlay, normal;
  background-size:
    100px 75px,
    cover,
    cover;
  background-repeat: repeat, repeat, repeat;
}

:root[data-theme="parchment"] body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/></svg>");
  opacity: 0.18;
  mix-blend-mode: multiply;
}

/* The sunset, compressed into a narrow light band.
   .panel is `min-height: 100vh; height: fit-content`, so this gradient does not
   sit behind a hero — it stretches over the WHOLE page, and its last stop is
   the bottom of every screen. --text is fixed at #1b151d, so a gradient that
   ran from L 0.89 down to L 0.61 meant readability fell off as you scrolled:
   at the old #8f7aa3 body copy was 4.68:1, h3 2.03:1 and muted text 1.05:1.

   Measured, the old bottom stop had luminance 0.224 — at which point even pure
   black only reaches 5.48:1, so no amount of darkening the *text* could have
   fixed it. The surface had to move.

   Same four hues in the same order, held between L 0.90 and L 0.82 with chroma
   capped at 0.06 so they stay sunset rather than going candy. Still warm at the
   top, still cool and violet at the bottom — it just no longer sets all the way.
   The three radial washes above are unchanged and still do most of the mood. */
:root[data-theme="meadow"] body .panel {
  background:
    radial-gradient(ellipse 55% 40% at 18% 12%, rgba(240, 208, 160, 0.65), transparent 60%),
    radial-gradient(ellipse 50% 38% at 82% 8%, rgba(184, 169, 201, 0.55), transparent 62%),
    radial-gradient(ellipse 65% 45% at 50% 100%, rgba(107, 76, 111, 0.28), transparent 65%),
    linear-gradient(180deg, #f3d9c4 0%, #f9c5c9 38%, #d9c3e4 72%, #cfbae3 100%);
}

/* Golden Hour Sunset fills light and letters dark — Gail's call: "lighter
   pills/buttons outlined and texted in darker."

   Structurally this is the Tavern rule with its polarity flipped. Tavern is a
   dark theme, so it washes the module colour down toward transparent and
   letters IN it; Meadow is light, so it washes the module colour into WHITE and
   letters in it at full strength. Both land in the same place: the colour moves
   off the fill and onto the line and the lettering.

   --mod itself is deliberately untouched. It still draws every card's left
   border, every Reserve pip, the active-pill ring and the Bit cost outline, and
   all of those want the dark solid value — lightening --mod would have bleached
   them. The wash happens here, at the point of use, and nowhere else.

   Measured: lettering on the washed fill runs 6.40–11.61:1, and the border
   holds 4.46–8.65:1 against both the panel and the card, which matters more
   than usual here — the fill sits at roughly 1:1 against the panel, so the
   outline is the only thing giving the chip a shape. */
:root[data-theme="meadow"] .button,
:root[data-theme="meadow"] .nav-pill,
:root[data-theme="meadow"] a.nav-pill,
:root[data-theme="meadow"] a.nav-pill:visited,
:root[data-theme="meadow"] .badge-mod,
:root[data-theme="meadow"] .tag-chip {
  background: color-mix(in srgb, var(--mod, var(--accent)) 14%, var(--white));
  border: var(--border-width, 1px) var(--border-style, solid) var(--mod, var(--accent));
  color: var(--mod, var(--accent));
  box-shadow: none;
}

/* The shared hover paints --mod-bg, a 45% alpha of the module colour. Over a
   fill this light that lands mid-tone and takes the dark lettering on it down
   to 2.02:1, so the hover has to be a wash on the same scale as the rest state
   rather than a tint of it. 26% is a visible step from 14% and holds 5.24:1. */
:root[data-theme="meadow"] .nav-pill:hover,
:root[data-theme="meadow"] a.nav-pill:hover,
:root[data-theme="meadow"] .tag-chip button:hover {
  background: color-mix(in srgb, var(--mod, var(--accent)) 26%, var(--white));
  color: var(--mod, var(--accent));
}

:root[data-theme="meadow"] .button.secondary {
  background: color-mix(in srgb, var(--accent) 14%, var(--white));
  border-color: var(--accent);
  color: var(--accent);
}

:root[data-theme="meadow"] .button.secondary:hover {
  background: color-mix(in srgb, var(--accent) 26%, var(--white));
}

/* Danger stays loud. With everything else on this theme reduced to an outline,
   one solid red button is the only thing that shouts — which is exactly the
   right amount of shouting for the action that leaves a campaign. */
:root[data-theme="meadow"] .button.danger {
  background: var(--danger);
  border-color: color-mix(in srgb, var(--danger), var(--black) 25%);
  color: var(--on-accent);
}

/* The wall.
   This used to be wood10.jpg, which is not a wood texture — it is a photo of
   a framed board, dark frame and all. Tiled at 75% it produced a grid of
   rectangles across the page, which is a large part of why the theme never
   read as a tavern. dark_wood.webp is a real, seamlessly tiling plank
   texture with actual seams and grain.

   It is also nearly black, so the brown is applied by SCREENing a warm
   gradient over it rather than multiplying one under it — multiply against
   near-black wood just stays near-black. The gradient darkens toward the
   bottom, which is the room falling off below the lamp. */
:root[data-theme="tavern"] body {
  background-color: #4a3120;
  background-image:
    radial-gradient(ellipse 130% 70% at 50% -10%, rgba(255, 206, 138, 0.5), transparent 62%),
    linear-gradient(180deg, #7a5334 0%, #5c3f2a 45%, #3d281a 100%),
    url("/assets/images/dark_wood.webp");
  background-blend-mode: soft-light, screen, normal;
  background-size:
    cover,
    cover,
    320px 320px;
  background-repeat: no-repeat, no-repeat, repeat;
  background-position: top center;
}

/* The slate. Two things it needs that a flat dark rectangle doesn't have:
   grain, and a frame. This rule is the grain — an inline SVG turbulence
   data-URI, the same no-extra-request technique parchment and steampunk
   already use. The frame is the pair of box-shadow rules below.

   background-COLOR here, not just background-image, and that is load-bearing:
   the unscoped `.panel` rule further down (~1061) paints every panel with its
   module colour at 45% alpha, so without an opaque colour the wall shows
   straight through the board and the slate reads as tinted wood. A blackboard
   is not pink. The module identity survives as the 12% wash below, plus the
   heading, chip and pill colours — which is where it does its work anyway. */
:root[data-theme="tavern"] body .panel,
:root[data-theme="tavern"] body .section-card {
  background-color: #1b211f;
  background-image:
    radial-gradient(ellipse 90% 55% at 16% 0%, rgba(226, 221, 204, 0.07), transparent 62%),
    radial-gradient(ellipse 70% 45% at 88% 6%, rgba(226, 221, 204, 0.05), transparent 60%),
    linear-gradient(
      160deg,
      color-mix(in srgb, var(--mod, transparent) 12%, transparent),
      transparent 70%
    ),
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='s'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='100%25' height='100%25' filter='url(%23s)' opacity='0.18'/></svg>");
  background-blend-mode: normal, normal, normal, soft-light;
  background-size:
    cover,
    cover,
    cover,
    160px 160px;
  background-repeat: no-repeat, no-repeat, no-repeat, repeat;
}

:root[data-theme="tavern"] body .section-card {
  background-color: #131917;
}

/* The frame, and it is the important half — a thin chalk line drawn a few
   pixels inside the edge is the cue that says "someone drew a box on a
   blackboard", and it does more for Vi's ask than any colour change did.

   Two stacked inset shadows: the dark one paints ON TOP of the chalk one and
   covers all but a 1px ring of it, which is what leaves the line floating
   inside the border instead of sitting on it. Don't collapse them into one. */
:root[data-theme="tavern"] body .panel {
  box-shadow:
    inset 0 0 0 6px rgba(0, 0, 0, 0.35),
    inset 0 0 0 7px rgba(226, 221, 204, 0.42),
    var(--shadow-panel);
}

:root[data-theme="tavern"] body .section-card {
  box-shadow:
    inset 0 0 0 4px rgba(0, 0, 0, 0.3),
    inset 0 0 0 5px rgba(226, 221, 204, 0.3),
    var(--shadow-card);
}

/* Chalk, not paint. Buttons, pills, chips and badges are solid `var(--mod)`
   fills everywhere else in the app — on a blackboard those read as pastel
   sticky notes, which is most of what was left of "it doesn't look like a
   tavern" once the wall and the slate were right. The module colour moves
   from the fill to the line and the lettering.

   This is exactly what the contrast work at the top of the tavern block
   measured: every module was checked as a FOREGROUND colour on the slate
   surfaces, so each of these clears 4.5:1 as chalk on the board. Reverting to
   fills would need that check redone the other way round, against
   --button-text on a light-ish fill. */
:root[data-theme="tavern"] .button,
:root[data-theme="tavern"] .nav-pill,
:root[data-theme="tavern"] a.nav-pill,
:root[data-theme="tavern"] a.nav-pill:visited,
:root[data-theme="tavern"] .badge-mod,
:root[data-theme="tavern"] .tag-chip {
  background: color-mix(in srgb, var(--mod, var(--accent)) 10%, transparent);
  border: var(--border-width, 2px) solid
    color-mix(in srgb, var(--mod, var(--accent)) 60%, transparent);
  color: var(--mod, var(--accent));
  box-shadow: none;
}

:root[data-theme="tavern"] .button.secondary {
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  border-color: color-mix(in srgb, var(--accent) 60%, transparent);
  color: var(--accent);
}

/* The hero's second action (SEO_PLAN.md Day 3).

   .button.secondary could not do this job any more. Once a mod-less .button
   fills with --accent (see --button-ink), .secondary — which is
   `color-mix(--mod 12%, --accent)`, i.e. --accent when there is no mod — is the
   *same colour as the primary*, and the hero's two CTAs become one control
   wearing two labels. Redefining .secondary app-wide was rejected on blast
   radius: it carries the number stepper, the tag select-all and "Continue with
   Google" on three pages, and its per-module contrast is checked ten times over
   in theme-contrast.py.

   --text on a transparent fill over the body gradient is the pair batch 7 day 1
   closed and Day 3b widened ('landing body-copy ink on body'), green on all
   eleven. --field-border is decoration at no bar, the same argument
   .auth-alt-rule already makes.

   **Placed after the Meadow and Tavern .button overrides deliberately.** Those
   are `:root[data-theme="…"] .button` — (0,2,0) with the attribute selector,
   same as `.button.quiet`'s two classes — so on equal specificity the later
   rule wins, and this has to be later or those two themes would paint their
   chalk/outline treatment over it and lose the distinction again. Moving this
   block up the file un-fixes it silently. */
.button.quiet {
  background: transparent;
  border-color: var(--field-border);
  color: var(--text);
}

.button.quiet:hover {
  background: var(--field-bg);
  opacity: 1;
}

/* Headings and labels in chalk handwriting; values, paragraphs and every form
   control in the reading face. `input`/`select`/`textarea` are `font: inherit`
   (styles.css ~1214), so without the second rule the handwriting would leak
   straight into everything anyone types. */
:root[data-theme="tavern"] label,
:root[data-theme="tavern"] .mod-heading,
:root[data-theme="tavern"] legend,
:root[data-theme="tavern"] th {
  font-family: var(--font-heading);
}

:root[data-theme="tavern"] input,
:root[data-theme="tavern"] select,
:root[data-theme="tavern"] textarea {
  font-family: var(--font-body);
}

:root[data-theme="grove"] body {
  background:
    radial-gradient(circle at 14% 18%, rgba(255, 244, 196, 0.45), transparent 11%),
    radial-gradient(circle at 42% 8%, rgba(255, 244, 196, 0.38), transparent 9%),
    radial-gradient(circle at 72% 22%, rgba(255, 244, 196, 0.32), transparent 13%),
    radial-gradient(circle at 88% 58%, rgba(255, 244, 196, 0.28), transparent 11%),
    radial-gradient(circle at 22% 78%, rgba(255, 244, 196, 0.28), transparent 11%),
    radial-gradient(circle at 55% 92%, rgba(255, 244, 196, 0.24), transparent 12%),
    linear-gradient(160deg, #b4e1ad 0%, #5f8471 55%, #3f6659 100%);
}

:root[data-theme="cartographer"] body .panel {
  background:
    repeating-radial-gradient(
      circle at 28% 32%,
      transparent 0 16px,
      rgba(122, 111, 90, 0.07) 16px 17px
    ),
    repeating-radial-gradient(
      circle at 78% 68%,
      transparent 0 20px,
      rgba(122, 111, 90, 0.06) 20px 21px
    ),
    radial-gradient(ellipse 48% 38% at 18% 28%, rgba(122, 150, 140, 0.4), transparent 62%),
    radial-gradient(ellipse 42% 32% at 82% 22%, rgba(216, 150, 110, 0.38), transparent 60%),
    radial-gradient(ellipse 50% 40% at 62% 82%, rgba(140, 150, 190, 0.32), transparent 62%),
    radial-gradient(ellipse 38% 30% at 30% 85%, rgba(200, 120, 120, 0.28), transparent 60%),
    linear-gradient(160deg, #f0e3c6 0%, #e9d8b0 100%);
}

:root[data-theme="dungeon"] {
  color-scheme: dark;
  --bg: #34383d;
  --bg-2: #1b1d20;
  --text: #e9e6dd;
  --muted: #a9a69c;
  /* #e08b3d was 4.46:1 as a link on this theme's panel — the only accent left
     under the bar once the panels stopped being the problem. Same hue and
     chroma, lifted to L 0.74. */
  --accent: #ea9447;
  --accent-solid: #a8661f;
  --panel-bg: rgba(233, 230, 221, 0.06);
  --panel-border: rgba(233, 230, 221, 0.14);
  --card-bg: rgba(233, 230, 221, 0.03);
  --border-soft: rgba(233, 230, 221, 0.1);
  --field-bg: rgba(233, 230, 221, 0.05);
  --field-border: rgba(233, 230, 221, 0.18);
  --field-focus: rgba(224, 139, 61, 0.32);
  --font-heading: "Almendra SC", serif;
  --font-body: "Oswald", sans-serif;
  --border-radius: 0.2rem;
  --corner-shape: notch;
  --border-width: 3px;
  --border-style: double;
  --shadow-panel: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 8px 18px rgba(0, 0, 0, 0.55);
  --shadow-card: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 4px 10px rgba(0, 0, 0, 0.45);
  --shadow-button: 0 3px 0 rgba(0, 0, 0, 0.5);
  --shadow-popover: 0 1.2rem 2.4rem -0.4rem rgba(0, 0, 0, 0.6);
  --icon-stroke-width: 2.5;
}

:root[data-theme="dungeon"] body .panel {
  background-color: #1b1d20;
  background-image:
    url("/assets/images/white_brick_wall.webp"),
    radial-gradient(ellipse 55% 40% at 20% 10%, rgba(224, 139, 61, 0.2), transparent 60%),
    linear-gradient(160deg, #34383d 0%, #292c30 55%, #1b1d20 100%);
  background-position:
    top right,
    bottom left,
    center center;
  background-blend-mode: multiply, overlay, normal;
  background-size:
    100px 50px,
    cover,
    cover;
  background-repeat: repeat, repeat, repeat;
}

:root[data-theme="cyberpunk"] {
  color-scheme: dark;
  --bg: #141127;
  --bg-2: #0a0817;
  --text: #eaf6ff;
  --muted: #8fa3c9;
  --accent: #ff2fb0;
  --accent-solid: #b8127e;
  --panel-bg: rgba(234, 246, 255, 0.05);
  --panel-border: rgba(41, 241, 224, 0.22);
  --card-bg: rgba(0, 0, 0, 0.712);
  --border-soft: rgba(41, 241, 224, 0.14);
  --field-bg: rgba(0, 0, 0, 0.412);
  --field-border: rgba(41, 241, 224, 0.26);
  --field-focus: rgba(255, 47, 176, 0.32);
  --font-heading: "Orbitron", sans-serif;
  --font-body: "Chakra Petch", sans-serif;
  --border-width: 1px;
  --border-style: solid;
  --shadow-panel: 0 0 24px rgba(255, 47, 176, 0.35), 0 0 44px rgba(41, 241, 224, 0.2);
  --shadow-card: 0 0 14px rgba(41, 241, 224, 0.25);
  --shadow-button: 0 0 16px rgba(255, 47, 176, 0.5);
  --shadow-popover: 0 0 30px rgba(41, 241, 224, 0.35);
  --icon-stroke-width: 1.5;
  /* Neon Circuit keeps its magenta wash — that shared pink cast across all ten
     identity colours is the theme — but the magenta step alone left them
     mid-tone, so Center Stage sat at 2.42:1 and Backstage at 3.63:1. The
     magenta happens first, then the result is lifted toward --text like every
     other dark theme. 45% rather than the base 60% because the magenta step has
     already done some of the lifting; measured, this holds 0.056-0.138 chroma,
     which is more saturation than the base set carries. */
  --mod-campaigns: color-mix(
    in srgb,
    color-mix(in srgb, var(--gray), var(--magenta) 30%),
    var(--text) 45%
  );
  --mod-players: color-mix(
    in srgb,
    color-mix(in srgb, var(--white), var(--magenta) 30%),
    var(--text) 45%
  );
  --mod-cast: color-mix(
    in srgb,
    color-mix(in srgb, var(--red), var(--magenta) 30%),
    var(--text) 45%
  );
  --mod-locations: color-mix(
    in srgb,
    color-mix(in srgb, var(--orange), var(--magenta) 30%),
    var(--text) 45%
  );
  --mod-props: color-mix(
    in srgb,
    color-mix(in srgb, var(--yellow), var(--magenta) 30%),
    var(--text) 45%
  );
  --mod-groups: color-mix(
    in srgb,
    color-mix(in srgb, var(--green), var(--magenta) 30%),
    var(--text) 45%
  );
  --mod-callback: color-mix(
    in srgb,
    color-mix(in srgb, var(--blue), var(--magenta) 30%),
    var(--text) 45%
  );
  --mod-center: color-mix(
    in srgb,
    color-mix(in srgb, var(--blue-violet), var(--magenta) 30%),
    var(--text) 45%
  );
  --mod-backstage: color-mix(
    in srgb,
    color-mix(in srgb, var(--violet), var(--magenta) 30%),
    var(--text) 45%
  );
  --mod-tags: color-mix(
    in srgb,
    color-mix(in srgb, var(--blue-green), var(--magenta) 30%),
    var(--text) 45%
  );
  --border-radius: 0 1rem 0 0;
  --corner-shape: bevel;
}

:root[data-theme="cyberpunk"] body .panel {
  background-color: rgb(165, 29, 113);
  background:
    linear-gradient(160deg, #14112767 0%, #100c22 55%, #0a0817 100%),
    radial-gradient(ellipse 60% 205% at 80% 90%, rgba(237, 41, 255, 0.534), transparent 60%),
    radial-gradient(ellipse 60% 205% at 20% 10%, rgba(30, 192, 178, 0.692), transparent 60%),
    repeating-linear-gradient(190deg, rgba(255, 0, 234, 0.336) 0 1px, rgba(0, 0, 0, 0.5) 2px 24px),
    repeating-linear-gradient(90deg, rgba(255, 0, 157, 0.685) 0 1px, transparent 2px 14px),
    repeating-linear-gradient(210deg, rgba(134, 255, 54, 0.363) 0 1px, transparent 2px 34px),
    url("/assets/images/carbon_fibre.png");
  background-position:
    top right,
    bottom left,
    center center,
    center center,
    top right,
    bottom left,
    center center;
  background-blend-mode:
    difference, hard-light, hard-light, soft-light, soft-light, soft-light, normal;
  background-size:
    cover,
    cover,
    cover,
    cover,
    cover,
    cover,
    50px 20px;
  background-repeat: repeat, repeat, repeat, repeat, repeat, repeat, repeat;
}

:root[data-theme="steampunk"] {
  color-scheme: dark;
  --bg: #33261a;
  --bg-2: #170f09;
  --text: #f0e4c8;
  --muted: #b39a72;
  --accent: #c8863a;
  --accent-solid: #8f5c22;
  --panel-bg: rgba(240, 228, 200, 0.06);
  --panel-border: rgba(200, 134, 58, 0.24);
  --card-bg: rgba(240, 228, 200, 0.03);
  --border-soft: rgba(200, 134, 58, 0.14);
  --field-bg: rgba(240, 228, 200, 0.05);
  --field-border: rgba(200, 134, 58, 0.26);
  --field-focus: rgba(200, 134, 58, 0.32);
  --font-heading: "Cinzel Decorative", serif;
  --font-body: "Crimson Text", serif;
  --border-radius: 0.35rem;
  --corner-shape: scoop;
  --border-width: 3px;
  --border-style: double;
  --shadow-panel: inset 0 1px 0 rgba(240, 228, 200, 0.15), 0 6px 16px rgba(0, 0, 0, 0.4);
  --shadow-card: inset 0 1px 0 rgba(240, 228, 200, 0.1), 0 3px 8px rgba(0, 0, 0, 0.35);
  --shadow-button: 0 3px 0 rgba(0, 0, 0, 0.45);
  --shadow-popover: 0 1rem 2.2rem -0.4rem rgba(0, 0, 0, 0.5);
  --icon-stroke-width: 2;
}

:root[data-theme="steampunk"] body .panel {
  background:
    radial-gradient(ellipse 50% 40% at 85% 5%, rgba(200, 134, 58, 0.22), transparent 60%),
    linear-gradient(160deg, #33261a 0%, #251b12 55%, #170f09 100%);
}

:root[data-theme="steampunk"] body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='260' height='260'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.55'/></svg>");
  opacity: 0.22;
  mix-blend-mode: overlay;
}

/* Removed: a hand-nudged --mod-campaigns (#9c8fbb) that these three shared
   because the old mix-toward---muted recipe left Campaigns at 4.01:1 on Dungeon
   Stone. The base recipe now mixes toward --text and Campaigns clears the bar
   on its own, so the override had become a hardcoded value silently winning
   over the rule that replaced it. Neon Circuit also gets its magenta back this
   way — the override was overwriting it there too. */

body {
  margin: 0;
  min-height: 100vh;
  font-family: var(--font-body, "Public Sans"), system-ui, sans-serif;
  background: linear-gradient(135deg, var(--bg), var(--bg-2));
  color: var(--text);
  transition:
    background 0.2s ease,
    color 0.2s ease;
}

a.nav-pill {
  color: var(--button-text);
}

a.nav-pill:visited {
  color: var(--button-text);
}

a.nav-pill:hover {
  color: var(--mod, var(--accent-solid));
}

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

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

a:hover {
  color: var(--mod-ink, var(--accent-solid));
}

/* A link styled as a button takes the button's ink, not the link palette's.
   Without this, `a:visited` and `a:hover` above both reach a `<a class="button">`
   and recolour its text against a fill chosen for different ink — visibly, on
   the landing CTAs, which are the app's only visited button links.

   Two things here are load-bearing and neither is visible in the declaration.
   **It has to sit after `a:visited`/`a:hover`**: all three score (0,1,1), so this
   wins on source order alone and moving it above them silently restores the bug.
   And **it must never gain `:visited`** — `a.button:visited` would score (0,2,1)
   and out-specify `.button.secondary` and `.button.danger` (both (0,2,0)), so
   every visited Delete would swap `--on-accent` for `--button-ink`: near-black
   ink on the danger fill on Light. It shipped as `a.button , a.button :visited`,
   where the stray space made the second half a descendant selector that matched
   nothing, which is the only reason that never landed. */
a.button {
  color: var(--button-ink);
}

.app-shell {
  min-height: 100vh;
  position: relative;
  z-index: 1;
  display: flex;
  justify-content: center;
}

.panel {
  width: min(100%, 640px);
  height: fit-content;
  min-height: 100vh;
  padding: 1.4rem;
  background: var(--panel-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--panel-border);
  box-shadow: var(--shadow-panel, none);
}

/*
 * Saves land on the row that was written rather than at the top of the page
 * (audit finding 20; api/redirect_helpers.php builds the fragment). Without a
 * scroll margin the browser puts that row flush against the top edge, where
 * .theme-picker — position: fixed, top: 1rem, ~2.3rem tall — sits over it, and
 * where a row has no visible context above it to read against.
 *
 * 4rem clears the theme button with room to spare and leaves the preceding row
 * partly visible, which is what makes the landing legible as "here is where you
 * were" rather than "here is a new top of page". Applied to [id] rather than to
 * a list of components so that anchoring a new element is a matter of giving it
 * an id, with no second edit here to forget.
 */
[id] {
  /* Was a flat 4rem; now derived, so the corner cluster growing a control moves
     this with it. See --chrome-* under .panel h1. */
  scroll-margin-top: var(--chrome-height);
}

.panel-wide {
  width: min(100%, 1000px);
}

.panel.login-card {
  width: min(100%, 480px);
  margin: 0 auto;
  min-height: auto;
}

/* The auth screens are one screen repeated — a form, sometimes a second way in,
   and a way out to the sibling page — and before finding 23 the last two were
   bare siblings of the <form> with nothing grouping them. Every .button is
   inline-flex with `min-width: 8rem` and an <a class="muted"> is inline text, so
   they flowed into whatever space was left: a text link and a button shared a
   line by accident, and "Continue with Google" read as a step *after* the
   password form rather than an alternative *to* it. Each of the three is now
   its own row, and the buttons go full-width so the alternative reads as a peer
   of the submit above it rather than a fragment beside it. */
.auth-panel .button {
  width: 100%;
}

/* "Forgot password?" lives inside the <form>, because it is about the password
   field above it. Left as an ordinary link rather than the .muted it used to be
   — on its own row it no longer competes with anything, and the thing you reach
   for when you are locked out should not be the quietest text on the screen. */
.auth-aside {
  display: block;
  margin-top: 0.8rem;
  font-size: 0.9rem;
}

.auth-alt {
  display: grid;
  gap: 0.7rem;
  margin-top: 1.5rem;
}

/* The rule either side of the word is what makes these read as two ways to do
   one thing. The word carries that meaning and the lines only assist it, so
   they are decoration — which is why --border-soft is fine here at no contrast
   bar, the same use it already has on every card edge. */
.auth-alt-rule {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 0.7rem;
  color: var(--text-muted);
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

.auth-alt-rule::before,
.auth-alt-rule::after {
  content: "";
  height: var(--border-width, 1px);
  background: var(--border-soft);
}

.auth-footer {
  margin: 1.5rem 0 0;
  padding-top: 1rem;
  border-top: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  text-align: center;
  font-size: 0.9rem;
  color: var(--text-muted);
}

.landing-grid {
  display: grid;
  grid-template-columns: 1fr minmax(300px, 380px);
  grid-template-areas: "intro auth";
  gap: 2rem;
  align-items: start;
}

.landing-intro {
  grid-area: intro;
  padding-top: 0.5rem;
}

.landing-intro h1 {
  margin-bottom: 0.5rem;
}

.landing-intro .tagline {
  color: var(--text-muted);
  font-size: 1.1rem;
  margin-top: 0;
}

.landing-auth {
  grid-area: auth;
}

.landing-features {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  margin: 1.5rem 0;
}

.landing-feature {
  display: flex;
  gap: 0.8rem;
  align-items: flex-start;
}

.landing-feature-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 2.4rem;
  height: 2.4rem;
  corner-shape: var(--corner-shape, round);
  border-radius: var(--border-radius, 0.5rem);
  background: var(--field-bg);
  color: var(--accent);
}

.landing-feature h3 {
  margin: 0 0 0.2rem;
  text-transform: none;
  letter-spacing: normal;
  color: var(--text);
  font-size: 1rem;
}

.landing-feature p {
  margin: 0;
  color: var(--text-muted);
  font-size: 0.92rem;
}

@media (max-width: 760px) {
  .landing-grid {
    grid-template-columns: 1fr;
    grid-template-areas: "auth" "intro";
  }
}

.form-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 0.9rem;
  align-items: start;
}

.form-grid > label,
.form-grid > .field-control,
.form-grid > .checkbox-field,
.form-grid > .icon-picker,
.form-grid > .field-row,
.form-grid > .actions {
  margin: 0;
}

.form-grid .actions {
  grid-column: 1 / -1;
  justify-content: flex-start;
}

.form-stack {
  display: grid;
  gap: 0.9rem;
}

.field-row {
  display: grid;
  gap: 0.75rem;
  align-items: center;
  grid-template-columns: minmax(0, 1fr) auto;
}

.confirm-dialog-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(4px);
}

.confirm-dialog-backdrop[hidden] {
  display: none;
}

.confirm-dialog {
  width: min(100%, 480px);
  padding: 1.3rem;
  background: var(--panel-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--panel-border);
  border-radius: var(--border-radius, 0.9rem);
  corner-shape: var(--corner-shape, round);
  box-shadow: var(--shadow-popover, 0 1rem 2rem -0.5rem rgba(0, 0, 0, 0.35));
}

.confirm-dialog p {
  margin: 0;
  color: var(--text);
  line-height: 1.5;
}

.confirm-dialog .dialog-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  justify-content: flex-end;
  margin-top: 1.15rem;
}

.confirm-dialog .button {
  min-width: 0;
}

.section-card {
  display: grid;
  align-content: start;
  gap: 0.9rem;
  padding: 1rem;
  background: var(--card-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  box-shadow: var(--shadow-card, none);
}

.section-card > h2 {
  margin-top: 0;
}

.section-card > :last-child {
  margin-bottom: 0;
}

/* One labelled block in the read view on Locations/Props/Ensembles (Notes,
   What players see). .section-card is a grid, so its own gap already separates
   these — an inner paragraph's bottom margin would stack on top of it and make
   the spacing between blocks uneven depending on what each one ends with. */
.entity-read-block > :last-child {
  margin-bottom: 0;
}

.entity-layout {
  display: grid;
  grid-template-columns: minmax(0, 1.55fr) minmax(280px, 0.95fr);
  gap: 1rem;
  align-items: start;
}

.backstage-grid {
  display: grid;
  gap: 1rem;
}

@media (min-width: 1040px) {
  .backstage-grid {
    grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
  }

  .backstage-grid > .section-card:nth-child(1),
  .backstage-grid > .section-card:nth-child(6) {
    grid-column: 1 / -1;
  }
}

.entity-column {
  display: grid;
  gap: 1rem;
}

.page-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  align-items: center;
  margin: 0.75rem 0 1.2rem;
}

.page-actions .button {
  width: auto;
}

@media (max-width: 760px) {
  .field-list li,
  .field-row,
  .entity-layout {
    grid-template-columns: 1fr;
  }
}

/* The panel's 1.4rem gutters cost ~45px of a 390px phone — real money on the
   device the table actually plays on, and on every page in the app. */
@media (max-width: 480px) {
  .panel {
    padding: 1rem 0.8rem;
  }
}

@media (max-width: 980px) {
  .entity-layout {
    grid-template-columns: 1fr;
  }
}

@media (min-width: 840px) {
  .panel-wide {
    width: min(100%, 1100px);
  }
}

.mod-campaigns {
  --mod: var(--mod-campaigns);
  --mod-fill: color-mix(in srgb, var(--mod-campaigns), var(--muted) 20%);
}

.mod-players {
  --mod: var(--mod-players);
  --mod-fill: color-mix(in srgb, var(--mod-players), var(--muted) 20%);
}

.mod-cast {
  --mod: var(--mod-cast);
  --mod-fill: color-mix(in srgb, var(--mod-cast), var(--muted) 20%);
}

.mod-locations {
  --mod: var(--mod-locations);
  --mod-fill: color-mix(in srgb, var(--mod-locations), var(--muted) 20%);
}

.mod-props {
  --mod: var(--mod-props);
  --mod-fill: color-mix(in srgb, var(--mod-props), var(--muted) 20%);
}

.mod-groups {
  --mod: var(--mod-groups);
  --mod-fill: color-mix(in srgb, var(--mod-groups), var(--muted) 20%);
}

.mod-callback {
  --mod: var(--mod-callback);
  --mod-fill: color-mix(in srgb, var(--mod-callback), var(--muted) 20%);
}

.mod-center {
  --mod: var(--mod-center);
  --mod-fill: color-mix(in srgb, var(--mod-center), var(--muted) 20%);
}

.mod-backstage {
  --mod: var(--mod-backstage);
  --mod-fill: color-mix(in srgb, var(--mod-backstage), var(--muted) 20%);
}

.mod-tags {
  --mod: var(--mod-tags);
  --mod-fill: color-mix(in srgb, var(--mod-tags), var(--muted) 20%);
}

.mod-campaigns,
.mod-players,
.mod-cast,
.mod-locations,
.mod-props,
.mod-groups,
.mod-callback,
.mod-center,
.mod-backstage,
.mod-tags {
  /* Inside a module, a button is filled with --mod, so its ink is the
     module-fill token rather than the accent-fill one. Set here rather than as
     a fallback because --on-accent and --button-text are the same colour on ten
     of eleven themes and part company on Light — see --button-ink above. This
     block already lists all ten module classes, so it costs one declaration and
     no new list. Every module button is byte-identical after the change. */
  --button-ink: var(--button-text);
  --mod-bg: color-mix(in srgb, var(--mod) 45%, transparent);
  /* The module colour as INK — for the handful of places it is written rather
     than filled with (.bit-cost, .reserve-meter-count, the collapsed-form
     summaries, link hovers). A fill and a piece of lettering want opposite
     things from a colour, and Light is the theme where that stops being a
     detail: its fills are bright rainbow, which is unreadable as 0.78rem text
     on a white card. --mod-ink-mix is 0% everywhere else, so on every other
     theme this resolves to exactly --mod and nothing moves. */
  --mod-ink: color-mix(in srgb, var(--mod), var(--text) var(--mod-ink-mix));
}

/* Removed in the UI/UX pass: an unscoped copy of the Light theme's
   module-tinted panel gradient (see `:root[data-theme="light"] .panel` above)
   with a leading `oklch(...)` colour layer. `background` only accepts a colour
   in its FINAL layer, so the whole declaration was invalid and every browser
   dropped it — Dark, Grove and Auto-light have always fallen through to the
   plain `var(--panel-bg)` on `.panel` further up.

   Deleted rather than repaired on purpose. Making it valid would newly paint
   those three themes with a 45%-alpha pastel at lightness 0.6–0.9; on Dark
   that puts #f4f7fb body text on a mid-tone surface. If module-tinted panels
   are wanted beyond Light, they need per-theme lightness and a re-measure,
   not this rule switched back on. */

.mod-heading {
  color: var(--text);
}

/* --button-text, not --bg. This is the Shared / Not-shared badge on Locations,
   Props and Ensemble — the visual signal for per-item GM→Player sharing — and
   it was the single most broken component in the app, failing on all ten themes
   for at least one module and reaching 1.12:1 on Enchanted Grove. --bg is the
   page background, i.e. the one colour guaranteed to sit close to the surface
   behind the badge on a light theme. Every other filled component already used
   --button-text; this one now matches them. */
.badge-mod {
  background: var(--mod, var(--text));
  color: var(--button-text);
}

/* The fixed corner cluster: theme picker, then the help button. Two controls
   as of the help track (2026-08-02), which is why the position moved off
   .theme-picker and onto a container — and why --chrome-width below is worked
   out from the count rather than typed.

   .theme-picker keeps `position: relative` because .theme-menu is absolutely
   positioned against it; without that the eleven-entry menu would anchor to the
   cluster and sit under the help button. */
.page-chrome {
  position: fixed;
  top: var(--chrome-offset);
  right: var(--chrome-offset);
  z-index: 50;
  display: flex;
  align-items: flex-start;
  gap: var(--chrome-gap);
}

.theme-picker {
  position: relative;
}

/* The help button borrows the picker trigger's styling wholesale rather than
   declaring its own: same size, same border, same fill, already measured on
   every theme. An <a> rather than a <button> because it navigates. */
.chrome-button {
  width: auto;
  min-width: fit-content;
  margin: 0;
  padding: 0.55rem;
  display: flex;
  align-items: center;
  justify-content: center;
  corner-shape: var(--corner-shape, round);
  border-radius: var(--border-radius, 999px);
  border: var(--border-width, 1px) var(--border-style, solid) var(--field-border);
  background: var(--card-bg);
  color: var(--text);
  cursor: pointer;
  backdrop-filter: blur(8px);
}

.chrome-button:hover {
  background: var(--field-bg);
}

.chrome-button:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--field-focus);
}

.chrome-button .icon {
  width: 1.15rem;
  height: 1.15rem;
}

/* Finding 17: nothing reserved room for the picker, so a long title ran under
   it. The trigger is ~2.1rem wide on a 1rem offset, and the panel's own gutter
   (1.4rem, or 0.8rem under 480px) covers only part of that — so the page title
   wraps 3rem early instead. Every page has exactly one <h1> and it is the
   title, so this needs no new hook on the markup.

   Not behind a media query, deliberately: .panel-wide runs to 1100px, so the
   panel still reaches the picker's corner on any viewport under ~1150px. This
   was never only a phone problem.

   The cost is real and worth naming: on the pages that open with a .back-link
   the title already sits below the picker, and there this spends 3rem of wrap
   width for nothing — on the very pages whose titles are longest, since those
   are campaign and character names. `.panel > h1:first-child` would have
   charged only the pages that collide, and was rejected: it stops applying the
   moment anything is inserted above the title, and it fails silently, looking
   exactly like the bug it fixed. A rule that is always on is worth 3rem.

   This is the second thing to reserve space for that button; `[id]`'s
   scroll-margin-top above is the first. A third wants the two turned into a
   shared token rather than two independent numbers. */
/* The third caller arrived (the help button, 2026-08-02) and did what the note
   above asked: the two independent numbers are now derived from one place.
   Add a fourth control and only --chrome-count changes. */
:root {
  --chrome-offset: 1rem;
  --chrome-gap: 0.4rem;
  --chrome-button-size: 2.3rem;
  /* Three: the campaign nav's trigger joined the theme picker and the help
     button in batch 8. The note above asked for a fourth caller to change only
     this number, and that is what happened — every page's reserved space, the
     h1 padding and the scroll-margin all followed with no other edit.

     It goes back to 2 inside the desktop rail media query below, where the
     trigger is hidden because the nav is already open. */
  --chrome-count: 3;
  /* The open rail's width above the 75rem media query below. Measured on Day 3
     against the .entity-layout two-column pages, which are the ones with the
     least room to give.

     There is no --nav-rail-breakpoint, which two comments here used to name: a
     custom property cannot be used in a media query condition, so the 75rem is
     a literal and has to be. Kept as a note rather than deleted because looking
     for that variable is the natural first move. */
  --nav-rail-width: 12.5rem;
  --chrome-width: calc(
    var(--chrome-offset) + var(--chrome-count) * var(--chrome-button-size) +
      (var(--chrome-count) - 1) * var(--chrome-gap)
  );
  --chrome-height: calc(2 * var(--chrome-offset) + var(--chrome-button-size));
}

.panel h1 {
  padding-right: var(--chrome-width);
}

.theme-picker-trigger {
  width: auto;
  min-width: fit-content;
  margin: 0;
  padding: 0.55rem;
  display: flex;
  align-items: center;
  justify-content: center;
  corner-shape: var(--corner-shape, round);
  border-radius: var(--border-radius, 999px);
  border: var(--border-width, 1px) var(--border-style, solid) var(--field-border);
  background: var(--card-bg);
  color: var(--text);
  cursor: pointer;
  backdrop-filter: blur(8px);
}

.theme-picker-trigger:hover {
  background: var(--field-bg);
}

.theme-picker-trigger:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--field-focus);
}

.theme-picker-trigger .icon {
  width: 1.15rem;
  height: 1.15rem;
}

/* The trigger is a <summary>, so the browser's own disclosure marker has to go
   — otherwise the icon sits beside a triangle. Both forms are needed: the
   ::-webkit-details-marker pseudo-element for WebKit, and `display` for
   everyone else, since a <summary> is `display: list-item` by default and that
   is what draws the marker. Setting display also means the flex centring below
   applies to it. */
.theme-picker > summary {
  list-style: none;
}

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

/* --- Layering inside .page-chrome ------------------------------------------
   `.page-chrome` sets z-index: 50 and so is a stacking context; everything
   below is ordered *within* it, against its siblings and nothing else.

   **The menu has to out-rank the nav explicitly, and DOM order will not do it.**
   `.module-nav` carries z-index: 1, which beats the `auto` this rule used to
   have no matter what order the two sit in — and the nav is deliberately first
   in the markup ("the nav is first, so it is leftmost", theme-toggle.php), so
   the one thing that could have saved this is spent on layout.

   Measured before the fix, at 1384px with both open: the rail overlapped the
   menu by 166 x 363px and `elementFromPoint` over the centre of each entry
   returned a `.nav-pill` for **11 of 11** of them. Not "sits on top of" — the
   theme picker was unusable, and pressing where *Dark* is drawn navigated to
   the Campaign page. §12 filed this as a desktop regression; at 500px, with
   both drawers open, it was 11 of 11 there too.

   The nav wins nothing by being on top: on desktop it is permanent chrome, and
   a transient popover belongs over permanent chrome, not under it. So the menu
   is raised rather than the rail moved, which also keeps the two widths telling
   the same story. tests/verify_chrome_layering.php asserts the ordering, since
   the failure is invisible in the markup and in each rule read on its own. */
.theme-menu {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  z-index: 2;
  /* Grid because the entries are <button>s in a <form> now rather than <li>s in
     a <ul>: buttons are inline-block, so without this the eleven of them would
     flow into a paragraph. */
  display: grid;
  margin: 0;
  padding: 0.4rem;
  min-width: 13rem;
  max-width: calc(100vw - 2rem);
  background: var(--panel-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--panel-border);
  border-radius: var(--border-radius, 0.8rem);
  backdrop-filter: blur(14px);
  corner-shape: var(--corner-shape, round);
  box-shadow: var(--shadow-popover, 0 0.8rem 2rem -0.5rem rgba(0, 0, 0, 0.35));
}

.theme-menu button {
  width: 100%;
  margin: 0;
  display: flex;
  align-items: center;
  gap: 0.55rem;
  padding: 0.5rem 0.6rem;
  border: none;
  border-radius: var(--border-radius, 0.5rem);
  corner-shape: var(--corner-shape, round);
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  text-align: left;
  justify-content: flex-start;
  cursor: pointer;
}

.theme-menu button:hover,
.theme-menu button:focus-visible {
  background: var(--field-bg);
  outline: none;
}

.theme-menu button[aria-current="true"] {
  background: var(--field-focus);
}

.theme-swatch {
  width: 0.85rem;
  height: 0.85rem;
  border-radius: var(--border-radius, 50%);
  flex-shrink: 0;
  corner-shape: var(--corner-shape, round);
  border: var(--border-width, 1px) var(--border-style, solid) rgba(0, 0, 0, 0.15);
}

/* The campaign nav, one markup and two presentations (batch 8, audit finding
   12). Below 75rem it is a drawer hanging off a trigger in the corner cluster;
   above it, the trigger goes away and the same <nav> is an open rail down the
   right-hand side. Nothing in partials/module-nav.php knows which of the two it
   is, and there is no JavaScript on any of it.

   Batch 10 day 5 asked whether the rail should be able to give its 12.5rem
   back, and measured the answer as "there is usually nothing to give": see the
   note on the 75rem query itself.

   .module-nav-disclosure keeps `position: relative` for the same reason
   .theme-picker does: the drawer is absolutely positioned against it, and
   without this it would anchor to .page-chrome and sit under the help button. */
.module-nav-disclosure {
  position: relative;
}

.module-nav-disclosure > summary {
  list-style: none;
}

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

/* The drawer. A column, not the old wrapping bar: it is a menu now, and nine
   pills across three ragged rows was the thing finding 12 was about.

   **`display` is deliberately not set here**, and that is not an omission. The
   UA rule is `details:not([open]) > *:not(summary) { display: none }`, so any
   `display` in this rule wins against it and the drawer renders permanently
   open — measured: at 500px the closed drawer was on screen at its full 442px,
   the trigger did nothing, and the disclosure disclosed nothing. It is set
   under `[open]` below, and unconditionally in the desktop rail. */
.module-nav {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  /* Floor, not ceiling — see the layering note over .theme-menu. This lifts the
     drawer over the static help button beside it, and the theme menu sits at 2
     so it lands over this. Raising this number un-fixes that overlap. */
  z-index: 1;
  flex-direction: column;
  gap: 0.4rem;
  margin: 0;
  padding: 0.4rem;
  min-width: 12.5rem;
  max-width: calc(100vw - 2rem);
  background: var(--panel-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--panel-border);
  border-radius: var(--border-radius, 0.8rem);
  corner-shape: var(--corner-shape, round);
  backdrop-filter: blur(14px);
  box-shadow: var(--shadow-popover, 0 0.8rem 2rem -0.5rem rgba(0, 0, 0, 0.35));
}

.module-nav-disclosure[open] > .module-nav {
  display: flex;
}

.nav-pill {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 0.9rem;
  border-radius: var(--border-radius, 0.6rem);
  corner-shape: var(--corner-shape, round);
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-left: var(--border-width, 3px) var(--border-style, solid) var(--mod, var(--border-soft));
  background: var(--card-bg);
  color: var(--button-text);
  cursor: pointer;
  text-decoration: none;
  font-weight: 600;
  font-size: 0.88rem;
  transition: background 0.15s ease;
}

.nav-pill {
  background: var(--mod, var(--text));
  color: var(--button-text);
}

.nav-pill:hover {
  background: var(--mod-bg, var(--field-bg));
}

.nav-pill.is-active {
  box-shadow: inset 0 0 0 2px var(--mod, var(--text));
}

/* The desktop rail. The <details> is *forced open* rather than defaulting to
   open, because "open" is a DOM attribute: a reader who closes the drawer on a
   phone and then opens the same page on a laptop would otherwise land on a page
   with no navigation at all.

   **Forcing it open takes two declarations, and the obvious one is not enough.**
   The old UA rule is `details:not([open]) > *:not(summary) { display: none }`,
   which `display: flex` beats. Current Chrome does not use that rule any more —
   it hides the contents with `content-visibility: hidden` on the
   `::details-content` pseudo-element, which `display` cannot touch. With only
   the display override, measured at 1280px with the drawer closed: the nav was
   not painted and neither was the trigger, because this block hides it. **A
   desktop page with no navigation whatsoever**, which is the exact failure this
   comment was already warning about when the measurement found it.

   So both are set, for the same reason the number stepper sets both
   `-moz-appearance` and the `::-webkit-` pseudo-elements: each covers a browser
   the other does not, and the missing one is invisible on the browser that
   needed it. Checked with `checkVisibility({contentVisibilityAuto: true})` —
   `getBoundingClientRect()` reports a live box for content that is not painted,
   so it says "visible" for a closed drawer and cannot see this class of bug at
   all. The shipped theme picker measures the same way, which is what proved the
   probe wrong rather than the CSS.

   --chrome-count drops back to 2 here because the trigger is hidden: the
   cluster really is two controls wide at this width, and the h1 padding and
   scroll-margin that derive from it should say so. */
@media (min-width: 75rem) {
  :root {
    --chrome-count: 2;
  }

  .module-nav-disclosure > summary {
    display: none;
  }

  /* The half `display` cannot do — see the note above. */
  .module-nav-disclosure::details-content {
    content-visibility: visible;
  }

  .module-nav-disclosure > .module-nav {
    display: flex;
    position: fixed;
    /* Below the corner cluster, which keeps its own top-right corner. */
    top: var(--chrome-height);
    right: var(--chrome-offset);
    width: var(--nav-rail-width);
    max-width: none;
    max-height: calc(100vh - var(--chrome-height) - var(--chrome-offset));
    overflow-y: auto;
  }

  /* The rail is fixed, so it is out of flow and the centred .panel would run
     underneath it. This is the only thing on the page that has to know the rail
     exists — and therefore the only thing a collapse control would have had to
     change.

     **Batch 10 day 5 measured what that would buy and closed without building
     it.** `.panel-wide` caps at 1100px, and this reserves 232px, so the rail
     costs the panel width only while the viewport is too narrow to hold both —
     a band 147px wide, from this query's own 75rem up to about 1347px:

       viewport   panel, rail on   rail off   gained   sheet height on/off
       1200px     953px            1100px     147px    8383 / 7948   (-5.2%)
       1250px     1003px           1100px      97px    8160 / 7948
       1300px     1053px           1100px      47px    8064 / 7948
       1347px+    1100px           1100px       0px    7948 / 7948   (nothing)

     So above ~1347px a collapse gives back exactly nothing but re-centring, and
     the whole case for it lives in a 147px band of viewport widths. Raising this
     query to ~84rem would close that band with one number and no new control —
     recorded as the alternative, not taken, because it trades a persistent nav
     for content width on the table's behalf and that is a call for the table. */
  .app-shell {
    padding-right: calc(var(--nav-rail-width) + 2 * var(--chrome-offset));
  }
}

label {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  margin-bottom: 0;
  color: color-mix(in oklch, var(--muted) 40%, var(--text));
  font-size: 0.9rem;
}

input[type="hidden"] {
  display: none;
}

.checkbox-field {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 0.5rem;
  margin: -0.3rem 0 0.9rem;
  color: color-mix(in oklch, var(--muted) 40%, var(--text));
  font-size: 0.85rem;
}

.checkbox-field input[type="checkbox"],
.shared-tag-item input[type="checkbox"] {
  display: inline-block;
  width: auto;
  height: auto;
  margin: 0;
  padding: 0;
  border: var(--border-width, 1px) var(--border-style, solid) var(--field-border);
  border-radius: var(--border-radius, 3px);
  corner-shape: var(--corner-shape, round);
  background: var(--field-bg);
  accent-color: var(--mod, var(--accent));
  flex-shrink: 0;
  cursor: pointer;
}

.icon-picker {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  border: none;
  padding: 0;
  margin: 0 0 0.9rem;
}

.icon-picker legend {
  padding: 0;
  margin-bottom: 0.4rem;
  color: color-mix(in oklch, var(--muted) 40%, var(--text));
  font-size: 0.9rem;
}

.icon-picker-option {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.4rem;
  height: 2.4rem;
  margin: 0;
  padding: 0;
  border: var(--border-width, 1px) var(--border-style, solid) var(--field-border);
  border-radius: var(--border-radius, 0.5rem);
  corner-shape: var(--corner-shape, round);
  background: var(--field-bg);
  color: var(--text);
  cursor: pointer;
}

.icon-picker-option:hover {
  background: color-mix(in srgb, var(--mod, var(--accent)) 12%, var(--field-bg));
}

.icon-picker-option:has(input:checked) {
  border-color: var(--accent);
  background: var(--field-focus);
}

.icon-picker-option input {
  position: absolute;
  width: 0;
  height: 0;
  opacity: 0;
  pointer-events: none;
}

/* The combatant colour picker (batch 9 day 3), on .icon-picker's model: a
   radio group whose labels are the controls and whose inputs are visually
   hidden but still focusable. Reusing that shape rather than inventing one
   keeps keyboard and screen-reader behaviour without reimplementing any of it,
   and it works with scripting off — which for this control is the whole
   design, since the custom branch is a radio plus a colour input rather than a
   colour input that quietly implies a choice. */
.colour-picker {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  border: none;
  padding: 0;
  margin: 0 0 0.9rem;
}

.colour-picker legend {
  padding: 0;
  margin-bottom: 0.4rem;
  color: color-mix(in oklch, var(--muted) 40%, var(--text));
  font-size: 0.9rem;
}

.colour-picker-option {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  border: var(--border-width, 1px) var(--border-style, solid) var(--field-border);
  border-radius: var(--border-radius, 0.5rem);
  corner-shape: var(--corner-shape, round);
  background: var(--field-bg);
  color: var(--text);
  cursor: pointer;
}

/* A swatch is the colour, so it is sized like the icon picker's cells and
   filled rather than labelled. Its name rides a .visually-hidden span: a
   swatch with no text is a colour nobody using a screen reader can pick. */
.colour-picker-swatch {
  width: 2.4rem;
  height: 2.4rem;
  padding: 0;
  background: var(--combatant-colour, var(--field-bg));
}

/* "Automatic" and "No strip" are words, not colours, and are sized to read as
   what they are rather than as two more swatches. */
.colour-picker-text {
  padding: 0 0.7rem;
  height: 2.4rem;
  font-size: 0.85rem;
  white-space: nowrap;
}

.colour-picker-option:hover {
  border-color: var(--accent);
}

/* The selected state is a ring rather than a background change, because on a
   swatch the background IS the value — recolouring it to show selection would
   overwrite the thing being selected.

   **`outline` with an offset, not a flush `box-shadow`.** The first attempt
   was `box-shadow: 0 0 0 2px var(--accent)` and it rendered as a slightly
   thicker border rather than a ring: with no gap, an accent-coloured band
   directly against a saturated swatch reads as part of the swatch. The offset
   is what makes it a ring. Outline also stays out of layout, so picking an
   option doesn't shuffle the row. */
.colour-picker-option:has(input:checked) {
  border-color: var(--accent);
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Focus is a wider ring than selection so the two are still distinguishable
   when they land on the same option, which is the usual case when arrowing
   through a radio group. */
.colour-picker-option:has(input:focus-visible) {
  outline-width: 3px;
  outline-offset: 3px;
}

.colour-picker-option input {
  position: absolute;
  width: 0;
  height: 0;
  opacity: 0;
  pointer-events: none;
}

/* The native colour input beside the Custom swatch. It gets the base field
   rule's padding and border stripped, because a 2.4rem well of colour is the
   control here and the field chrome around it just makes it look broken.

   **The `:not()`s mirror the base rule's on purpose.** That rule is
   `input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"])`,
   which scores (0,3,1); the obvious `.colour-picker-custom-value input` scores
   (0,2,1) and silently loses every declaration here while looking applied.
   This project has shipped that mistake twice. Removing these three `:not()`s
   as redundant would un-fix it and change nothing visible in the file. */
.colour-picker-custom-value {
  display: flex;
  margin: 0;
}

.colour-picker-custom-value
  input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]) {
  width: 2.4rem;
  height: 2.4rem;
  margin: 0;
  padding: 0.15rem;
  border-radius: var(--border-radius, 0.5rem);
  corner-shape: var(--corner-shape, round);
  border: var(--border-width, 1px) var(--border-style, solid) var(--field-border);
  background: var(--field-bg);
  cursor: pointer;
}

input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]),
select,
textarea {
  display: block;
  width: 100%;
  margin: 0 0 0.4rem;
  padding: 0.75rem 0.9rem;
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  border: var(--border-width, 1px) var(--border-style, solid) var(--field-border);
  background: var(--field-bg);
  color: var(--text);
  font: inherit;
  /* **iOS Safari zooms the page whenever a focused control computes under 16px,
     and does not zoom back out** — the reader is left on a page wider than the
     screen, thumbing sideways to reach the next field. It cannot be turned off
     from CSS; `user-scalable=no` is ignored on modern iOS and is an
     accessibility failure besides. The only fix is the size.

     `font: inherit` above is what caused it, and it is still right: a control
     should take the page's family and weight. What it must not take is
     `label`'s deliberate 0.9rem — labels are small on purpose, and the field
     inheriting that was the accident. Measured at **14.4px on every form in the
     app** (batch 11 day 5, `docs/playtest/input-zoom-capture.mjs`).

     Longhand *after* the shorthand, so family, weight and line-height still
     inherit and only the size is pinned. `max()` rather than a flat 16px
     because both halves are real: a reader who has turned their browser's text
     size up gets the larger value, and a theme that ever shrank the root cannot
     drop this back under Safari's floor. The 16px is not a taste — it is the
     threshold in the browser. */
  font-size: max(1rem, 16px);
  transition:
    border-color 0.15s ease,
    box-shadow 0.15s ease;
}

input:not([type="hidden"]):focus,
select:focus,
textarea:focus {
  outline: none;
  border-color: var(--mod, var(--accent));
  box-shadow: 0 0 0 3px var(--mod-bg, var(--field-focus));
}

input[type="checkbox"]:not([type="hidden"]) {
  display: inline;
  width: auto;
}

select {
  background-image:
    linear-gradient(45deg, transparent 50%, var(--muted) 50%),
    linear-gradient(135deg, var(--muted) 50%, transparent 50%);
  background-position:
    calc(100% - 1.1rem) center,
    calc(100% - 0.8rem) center;
  background-size: 5px 5px;
  background-repeat: no-repeat;
  accent-color: var(--mod, var(--accent));
  background: var(--field-bg);
  color: var(--text);
  display: flex;
  flex-direction: row;
}

select,
::picker(select) {
  appearance: base-select;
  transition: rotate 0.2s ease-out;
  border-radius: var(--border-radius);
  border: var(--border-width) var(--border-style) var(--field-border);
}

select option {
  background: var(--field-bg);
  color: var(--text);
}

select option:checked {
  background-color: var(--field-focus, rgba(0, 0, 0, 0.1));
  color: inherit;
}

/* ---------------------------------------------------------------------------
   The two controls the theme layer could never reach (batch 6, finding 5).

   A number field's spinner and a file field's button are drawn by the platform
   widget, not by us, so every token above stops at their edge — they render the
   operating system's grey on all eleven themes. They are the last two places in
   the app where the theme visibly gives up.

   They need different answers because the browser gives us different hooks: a
   file input exposes ::file-selector-button and can simply be styled, while a
   number input exposes nothing for its spinner, so the spinner has to be
   replaced. Both land on .button.secondary's existing recipe rather than a new
   colour, which is why theme-contrast.py gains two names in an existing check's
   surface inventory and no new pair.
   --------------------------------------------------------------------------- */

/* Built by assets/js/number-stepper.js. The wrapper exists ONLY when that
   script has run, which is what makes the next rule safe: with scripting off
   there is no .number-stepper, the native arrows are untouched, and the field
   behaves exactly as it does today. The spinner is never removed until its
   replacement is on screen. */
.number-stepper {
  display: flex;
  align-items: stretch;
  /* Joined, not spaced. The pair used to sit in a 0.3rem gap with a full radius
     on all three boxes, which put three rounded rectangles inside one small
     control — "a lot of circles in one small area" (§12, 2026-08-03). Squaring
     the inner corners only reads as deliberate when the boxes touch; with a gap
     it reads as a bug. So the group is a segmented control now, and the radius
     belongs to the group rather than to each of its parts. */
  gap: 0;
  /* Mirrors the base field rule's margin exactly, because this wrapper stands in
     for a field. When that rule moved its 0.4rem from the top to the bottom
     (`94820e9`), a wrapper still carrying `margin-top` spaced every stepper
     differently from every other control on the same form — above instead of
     below. Copy the base rule's margin here whenever it changes; the two are one
     decision spelled in two places, and only this comment says so. */
  margin: 0 0 0.4rem;
  width: 100%;
}

/* The three :not()s are load-bearing and mirror the base field rule exactly —
   they are what makes this rule win, not decoration to be tidied away.
   `input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"])`
   scores (0,3,1) because each :not() counts as a class, so the obvious
   `.number-stepper input[type="number"]` scores (0,2,1) and *loses*.
   Measured in headless Chrome 2026-08-03: without this the field kept the base
   rule's `margin-top: 0.4rem`, and since the buttons are align-self: stretch
   they sized to the wrapper while that margin ate into the field — the buttons
   came out 6.39px taller than the field and 6.39px higher, on every page that
   shows one. That is the reported "buttons taller than the field", and it was
   never the align-items value. tests/verify_number_inputs.php compares the two
   selectors' specificity so a tidy-up here fails rather than silently
   un-fixing it. */
.number-stepper
  input[type="number"]:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]) {
  /* Both spellings: -moz-appearance still drives Firefox's spinner, and the
     ::-webkit- pseudo-elements drive Chrome's and Safari's. Neither alone is
     enough, and the one that is missing is invisible on the other browser. */
  appearance: textfield;
  -moz-appearance: textfield;
  /* The base input rule sets width:100%, which as a flex item beside two
     buttons resolves to "as wide as the row and then shrink". Stated outright
     instead, so the field keeps the slack and the buttons keep their size. */
  flex: 1 1 auto;
  width: auto;
  min-width: 2.5rem;
  /* The wrapper carries the spacing the input used to, so the input itself has
     none. `margin: 0` and not `margin-top: 0`, and that is the whole lesson of
     this rule's second regression: the reset has to be axis-agnostic, because it
     is neutralising *whatever* the base field rule sets, and the base rule is
     free to change which side it sets. It did — `94820e9` moved 0.4rem from top
     to bottom — and this reset went on zeroing an axis nobody was using while
     6.39px of bottom margin pushed the field short inside a stretch container.
     Same gap, same page, same reported symptom as 2026-08-03, and
     verify_number_inputs.php passed throughout, because it asserts that this
     selector *wins* and never what it needs to win about. */
  margin: 0;
  /* Square, because the buttons either side supply the group's outer radius. */
  border-radius: 0;
  text-align: center;
}

.number-stepper input[type="number"]::-webkit-outer-spin-button,
.number-stepper input[type="number"]::-webkit-inner-spin-button {
  appearance: none;
  -webkit-appearance: none;
  margin: 0;
}

/* Sized to the field beside it rather than to a button's usual min-width, and
   stretched so a tall field doesn't leave the pair floating. Same fill and ink
   as Center Stage's Reserve controls, which is the control this was lifted
   from.

   `align-self: stretch` is what makes the three boxes the same height by
   construction rather than by their paddings happening to agree — which they do
   not, since .field-row gives a button 0.45rem of padding-block and the field
   keeps 0.75rem. It only *worked* once the field stopped carrying a top margin
   the wrapper was supposed to have taken; see the specificity note above. */
.stepper-button.button {
  flex: 0 0 auto;
  width: auto;
  min-width: 0;
  padding-inline: 0.7rem;
  margin-top: 0;
  margin-bottom: 0;
  align-self: stretch;
  /* Inner corners square; the two rules below put the radius back on the two
     edges that are actually the outside of the group. */
  border-radius: 0;
}

/* The inner border goes to the field, not to both. Touching boxes that each
   keep their own border draw a doubled seam, and --border-width is 3px on some
   themes, so "1px, nobody will notice" is not true here. The field is the box
   with the focus state, so it is the one that keeps its edge. */
.number-stepper > .stepper-button:first-child {
  border-start-start-radius: var(--border-radius, 0.7rem);
  border-end-start-radius: var(--border-radius, 0.7rem);
  border-inline-end: 0;
}

.number-stepper > .stepper-button:last-child {
  border-start-end-radius: var(--border-radius, 0.7rem);
  border-end-end-radius: var(--border-radius, 0.7rem);
  border-inline-start: 0;
}

/* At the top or bottom of its range. Dimmed rather than hidden: a + that
   vanishes at capacity moves the field under the reader's thumb mid-tap, and a
   + that stays lit reads as broken rather than as finished. */
.stepper-button.button[disabled] {
  opacity: 0.45;
  cursor: not-allowed;
}

/* The file field's own button — the one pseudo-element the platform hands over.
   Everything up to the border is ours already (the base input rule above); this
   is the part that was still grey. */
input[type="file"]::file-selector-button {
  margin-inline-end: 0.8rem;
  padding: 0.45rem 0.8rem;
  border: var(--border-width, 1px) var(--border-style, solid) var(--mod, var(--field-border));
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  background: color-mix(in srgb, var(--mod, var(--accent)) 12%, var(--accent));
  color: var(--on-accent);
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: opacity 0.15s ease;
}

input[type="file"]::file-selector-button:hover {
  opacity: 0.9;
}

button,
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  width: auto;
  min-width: 8rem;
  margin-top: 0.4rem;
  padding: 0.75rem 0.9rem;
  border-radius: var(--border-radius, 0.7rem);
  border: var(--border-width, 1px) var(--border-style, solid) transparent;
  corner-shape: var(--corner-shape, round);
  cursor: pointer;
  background: var(--mod, var(--accent));
  color: var(--button-ink);
  font-weight: 700;
  text-decoration: none;
  transition: opacity 0.15s ease;
  align-self: end;
  margin-bottom: 7px;
}

button:hover,
.button:hover {
  opacity: 0.9;
}

.button.secondary {
  background: color-mix(in srgb, var(--mod, var(--accent)) 12%, var(--accent));
  border-color: var(--mod, var(--field-border));
  color: var(--on-accent);
}

.button.secondary:hover {
  background: var(--mod-bg, var(--field-bg));
  opacity: 1;
}

/* --danger, not --red. --red is the Cast module's identity hue (#f15bb5 on the
   base themes), so "Leave campaign" and "Delete" were rendering in the same hot
   pink as a Cast chip — the wrong signal twice. Mixing --mod in on top of that
   made a destructive button change colour depending on which module's page it
   was on, which is not what the colour was communicating. Flat --danger now:
   one colour, one meaning, everywhere. */
.button.danger {
  background: var(--danger);
  border-color: color-mix(in srgb, var(--danger), var(--black) 25%);
  color: var(--on-accent);
}

.button.danger:hover {
  background: color-mix(in srgb, var(--danger), var(--black) 12%);
  opacity: 1;
}

.actions {
  display: flex;
  gap: 0.8rem;
  flex-wrap: wrap;
}

.actions .button {
  width: auto;
}

/* The character sheet's one save, kept reachable from anywhere in the sheet
   rather than only from the bottom of it — the diner's complaint was that it
   sits under everything, and this is the longest page in the app.
 *
   No bar behind it, and that is the point: every theme's --panel-bg is
   translucent (0.05 alpha on the dark ones), so a backdrop built from it would
   show the field rows straight through. The button's own fill is opaque and its
   label pair is already measured against it, so a bare floating button adds no
   text/surface pair at all. The shadow is what separates it from the rows.

   Sticky rather than fixed, so it settles at the end of the form and never
   covers the Add field form that follows. Pure CSS: nothing here needs a
   script, so there is no no-JS fallback to get wrong. */
.sheet-save-actions {
  position: sticky;
  bottom: 0.75rem;
  z-index: 2;
  justify-content: flex-end;
  pointer-events: none;
}

.sheet-save-actions .button {
  pointer-events: auto;
  box-shadow: 0 0.35rem 1rem rgb(0 0 0 / 35%);
}

/* A viewport short enough that a sticky button would eat most of it is better
   off without one — at that height the form is barely taller than the screen
   and the save is never far away. */
@media (max-height: 26rem) {
  .sheet-save-actions {
    position: static;
  }
}

/* Inline row of small entity-level action forms (Duplicate, Save to library),
   each a standalone <form> so they need a flex wrapper to sit side by side. */
.entity-actions {
  display: flex;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin: 0.6rem 0;
}

.entity-actions form {
  margin: 0;
}

.entity-actions .button {
  width: auto;
}

/* Grouped checkboxes on the Import/Export export form. */
.export-group {
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  padding: 0.5rem 0.9rem 0.7rem;
  margin: 0.8rem 0 0;
}

.export-group legend {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-weight: 600;
  padding: 0 0.3rem;
}

/* Library rows: inline rename + add-to-campaign controls. */
.library-add {
  display: flex;
  align-items: flex-end;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-top: 0.5rem;
}

.library-rename {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

.library-rename input {
  width: auto;
  min-width: 8rem;
}

.icon {
  width: 1.1em;
  height: 1.1em;
  flex-shrink: 0;
  vertical-align: -0.15em;
  stroke-width: var(--icon-stroke-width, 2);
}

h1,
h2,
h3,
button,
.button,
.nav-pill,
.tag-chip,
.badge,
.theme-picker-trigger {
  font-family: var(--font-heading, inherit);
}

h1 .icon,
h2 .icon,
h3 .icon {
  margin-right: 0.35rem;
}

/* Subsection headings stay on the body face. They were <h4>s until finding 11
   shifted the app's headings up a level, and h4 was never in the list above — a
   label introducing a handful of fields reads with the copy beneath it, not
   with the section title above it. Explicit rather than incidental because six
   themes swap both families: without this, these would start lettering in
   Special Elite or Cinzel Decorative on the shift alone. The landing page's
   cards are still true h3s and keep the heading face. */
.field-group-heading,
.section-card h3 {
  font-family: inherit;
}

a.back-link {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

.combat-list li {
  gap: 0.6rem;
}

/* The combatant's colour, as a 3px strip on the TOP edge (batch 9).
   §12: "I don't think we could color the whole card, but maybe a 3px border at
   the top would give them that direct visual organization?"

   **Top, and never left.** `.entity-list li`'s left border is the module
   colour, and `.combat-current-turn` overrides it with --combat-active so
   "it's my turn" reads the same in the card and in the list. Putting the
   combatant colour there would make a player's own colour vanish on their own
   turn — the one card, at the one moment, they are most likely to be looking
   at.

   **A 3px strip is a non-text mark, so the bar is 3:1, not 4.5:1**, and every
   --combatant-* value is solved against its theme's card at that bar. Colouring
   the whole card instead would have put arbitrary hues behind text whose
   contrast is measured against --card-bg, breaking every ink pair on the screen
   at once.

   Keyed on a class rather than on `var(--combatant-colour, transparent)`,
   because the width has to stay 1px when there is no colour: a transparent 3px
   top would leave an uncoloured card looking open along its top edge, and a
   3px --border-soft one would thicken every card in every campaign that never
   uses this. Only the *value* is inline, since only the value is per-row.

   All three surfaces take one rule because all three are the same thing
   underneath: `background: var(--card-bg)` with a 1px `--border-soft` edge.
   That is also why the swatches only ever had to be measured against the card
   — `.overview-strip` looked like it might be a fourth surface and is not.
   (It got a `padding-top` compensation first, written on the assumption it had
   no border of its own. It has one; the declaration did nothing but shift the
   contents 2px. Read the base rule, don't infer it.) */
.combat-list li.has-combatant-colour,
.combat-you.has-combatant-colour,
.overview-strip.has-combatant-colour,
.entity-list li.has-combatant-colour {
  border-top-width: 3px;
  border-top-style: solid;
  border-top-color: var(--combatant-colour);
}

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

.combat-current-turn {
  border-left-color: var(--combat-active) !important;
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--combat-active) 40%, transparent);
}

.combat-turn-marker {
  color: var(--combat-active);
  display: inline-flex;
}

/* The badge used to be its own colour written on an 18% wash of itself, which
   is inherently low-contrast: on the light themes it measured 1.02–1.63:1, so
   "Live" — the one word telling a GM an encounter is running — was invisible.
   The wash now carries a border to give the badge an edge, and the text is read
   against the panel rather than against a tint of itself. */
.combat-live-badge {
  background: transparent;
  border: var(--border-width, 1px) var(--border-style, solid)
    color-mix(in srgb, var(--combat-active) 55%, transparent);
  color: var(--combat-active);
  margin-left: 0.3rem;
}

/* ...except when it isn't on the panel. The rule above reads --combat-active
   against the page, which is true on Center Stage and the session pages — but
   this badge also renders *inside* three filled controls: the Center Stage nav
   pill (background: var(--mod)) on every campaign-scoped page, and campaign.php's
   Start/Resume Session button (var(--mod)) and Center Stage link
   (.button.secondary). Gold on an arbitrary module fill was never measured, and
   on most themes it isn't readable — found on a phone, 2026-07-29.

   This is finding 1's defect with its polarity flipped. There, a *background*
   token (--bg) was used as ink on a filled badge; here a *foreground-on-panel*
   token is used as ink inside a filled control. Same root cause: a colour that
   only holds against the surface it was chosen for.

   currentColor is the fix rather than a fourth token, because the control has
   already solved "what ink reads on this fill" for its own label — .button uses
   --button-text, .button.secondary uses --on-accent, the nav pill uses
   --button-text — and inheriting it covers all three plus Tavern's and Meadow's
   outlined treatments, which letter in the module colour instead. The badge
   keeps its border and its uppercase weight, so it still reads as a badge; it
   just stops insisting on a colour the surface can't carry. */
.button .combat-live-badge,
.nav-pill .combat-live-badge {
  border-color: color-mix(in srgb, currentColor 55%, transparent);
  color: currentColor;
}

.combat-controls {
  display: flex;
  gap: 0.4rem;
}

.combat-controls form {
  margin: 0;
  flex: 1;
}

.combat-controls button {
  min-width: 0;
  width: auto;
  padding: 0.5rem;
}

.combat-list details {
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-radius: var(--border-radius, 0.5rem);
  corner-shape: var(--corner-shape, round);
  padding: 0.5rem 0.7rem;
  background: var(--field-bg);
  box-shadow: var(--shadow-card, none);
}

.combat-list summary {
  cursor: pointer;
  font-weight: 600;
  color: var(--text-muted);
  font-size: 0.85rem;
}

.combat-list details[open] summary {
  margin-bottom: 0.5rem;
}

/* The GM's combatant card. It was a <details> until batch 7 day 4; §12
   (2026-08-02) has both GMs who ran encounters asking for the cards to stay
   open, so it is now a plain container and the <li> around it already carries
   the card treatment. The rule above still draws the inner sheet/stats blocks
   as inset boxes, and those are the only disclosures left in here.

   The <details>-specific caveat that used to live here — don't set `display`,
   it kills the ::marker — died with the element. The header below is a flex
   row precisely because there is no marker left to protect. */
.combat-list .combatant > * + * {
  margin-top: 0.5rem;
}

/* Name on the left, reorder arrows hard right, on one row.

   `flex-wrap: nowrap` and `align-items: flex-start` are both measured
   decisions, not defaults. Wrapping was the first attempt and it looked
   reasonable in the stylesheet: let a long name push the arrows to their own
   line rather than squeeze them. In headless Chrome it does that *always* —
   `flex-basis: auto` sizes the identity from its max-content width, and a name
   plus two badges plus its glances exceeds any phone, so the arrows landed on
   a third row on every card at both 390px and 1280px. Twelve combatants is
   twelve wasted rows, and finding 22's whole point is that the arrows stay
   under the same thumb on every card.

   Not wrapping puts them at a fixed top-right instead and lets the name wrap
   inside its own box, which is where wrapping belongs. flex-start rather than
   center so the arrows sit level with the *first* line of a name that wrapped,
   not floating halfway down it.

   Was `summary.combatant-summary`, left in inline flow because a flexed
   <summary> loses its disclosure triangle. There is no triangle now. */
.combatant-header {
  display: flex;
  flex-wrap: nowrap;
  align-items: flex-start;
  gap: 0.4rem;
}

/* min-width: 0 is load-bearing, not tidiness. A flex item defaults to
   `min-width: auto`, which floors it at its content's intrinsic minimum — so
   without this the identity block refuses to shrink below its longest
   unbreakable run and pushes the arrows off the row instead of wrapping. Batch
   7 day 3 shipped that bug once already, in .field-row.bit-cost-row. */
.combatant-identity {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 1rem;
  line-height: 1.8;
}

/* margin-inline-start: auto rather than `justify-content: space-between` on
   the header: with flex-wrap on, space-between would fling the arrows to the
   far edge of whatever line they land on, including a line of their own. */
.combatant-order {
  display: flex;
  flex: 0 0 auto;
  gap: 0.3rem;
  margin-inline-start: auto;
}

.combatant-order form {
  margin: 0;
}

/* Same square icon-button geometry .combat-controls uses, and for the same
   reason: the base button rule is 8rem wide with vertical margins, which on a
   header row would be two slabs beside the name. Both margins are reset here —
   .combat-controls leaves them, because there the buttons are the whole row. */
.combatant-order button {
  min-width: 0;
  width: auto;
  margin-top: 0;
  margin-bottom: 0;
  padding: 0.5rem;
}

/* --- drag mode (combat-drag.js) -------------------------------------------
   The app's drag convention, implemented once (CLAUDE.md): collapsed while
   dragging, and it cannot un-collapse mid-drag.

   The collapse is one rule keyed on an attribute the script sets on the panel,
   rather than per-card state — there is nothing to get out of step, and
   leaving the mode restores every card at once. Dragging a card that is
   roughly a phone screen tall over eleven more of them is the thing this
   avoids: measured on Center Stage, a card goes from ~450px open to one to
   three header lines here, depending only on how long a name is.

   The reorder arrows go with it. They save on press and drag saves on exit, so
   both live at once would be two save models on one screen — outside this mode
   the arrows are the whole control, and they are what works with no script. */
[data-drag-mode] .combatant > :not(.combatant-header),
[data-drag-mode] .combatant-order {
  display: none;
}

[data-drag-mode] .combat-list > li {
  /* touch-action is load-bearing on a phone: without it the browser claims the
     gesture as a scroll before pointermove ever reaches the script, and the
     card never moves. */
  touch-action: none;
  cursor: grab;
  user-select: none;
}

[data-drag-mode] .combat-list > li.is-dragging {
  cursor: grabbing;
  position: relative;
  z-index: 2;
  opacity: 0.9;
  box-shadow: var(--shadow-card, 0 2px 8px rgb(0 0 0 / 25%));
  border-left-color: var(--mod, var(--accent));
}

/* Enough of a combatant to recognise at a glance — HP, and each Reserve as
   current/capacity. Display only; every control is in the card body. */
.combat-glance {
  display: inline-flex;
  align-items: center;
  gap: 0.2rem;
  margin-left: 0.4rem;
  font-size: 0.85rem;
  color: var(--text-muted);
  white-space: nowrap;
}

.combat-open-sheet {
  margin: 0;
  font-size: 0.9rem;
}

.combat-open-sheet a {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

.combat-sheet-list {
  list-style: none;
  margin: 0 0 0.5rem;
  padding: 0;
  font-size: 0.9rem;
}

.combat-sheet-list li {
  padding: 0.2rem 0;
}

.combat-live-hint {
  margin-top: -0.3rem;
  margin-bottom: 1rem;
}

pre {
  white-space: pre-wrap;
  color: var(--text-muted);
}

.error {
  color: var(--error-text);
  margin: 0 0 1rem;
}

.error:empty {
  display: none;
}

/* §12: "in the 'muted' text description (the math tooltip), the text is the
   same size and color as all the other text in most of the themes." It was 40%
   of the way to --muted, which is a small step in every theme — measured across
   all ten, muted text landed at 5.79–13.30:1 against its card while body text
   sat at 7.45–18.15:1, i.e. most of the way to just being body text.

   70% is as far as it goes, and the limit is the floor rather than taste:
   --muted alone is 3.77:1 on Golden Hour Sunset and 3.82:1 on Dungeon Stone,
   both under §10's 4.5:1 bar, so the mix can never reach it. At 70% the worst
   theme (Dungeon Stone) is 4.73:1, with the rest 5.4–10.3:1. Numbers and method
   are in PLAN §10; re-measure before moving this.

   One token because two rules need the same answer, and because the next person
   to tune it should have one place to do it. var() inside a custom property
   resolves where it is *used*, so each theme's own --muted feeds this. */
:root {
  --text-muted: color-mix(in oklch, var(--muted) 70%, var(--text));
}

.muted {
  color: var(--text-muted);
  font-size: 0.9rem;
}

.campaign-list {
  list-style: none;
  margin: 0 0 1.5rem;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 0.75rem;
}

.campaign-list li {
  /* The positioning context for the stretched link below. Harmless on the
     dashed "Start new campaign" / "Join a campaign" cards, which have no
     .campaign-link inside them and so grow no overlay. */
  position: relative;
  padding: 0.5rem 1rem;
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  background: var(--card-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-left: var(--border-width, 3px) var(--border-style, solid) var(--mod, var(--field-border));
  box-shadow: var(--shadow-card, none);
}

.campaign-list a,
.campaign-link {
  color: var(--text);
  text-decoration: none;
}

/* Vi had to hit the campaign's name rather than its card (§12, 2026-08-03).
   The card is what reads as the button, so the card is what should take the
   tap — and on a phone the difference between a one-line target and a
   three-line one is most of whether it works first time.

   Stretched link rather than wrapping the whole <li> in an <a>: the accessible
   name stays the campaign's name alone, so a screen reader announces "Vi's
   Campaign, link" rather than reading the system tag and the invite code out as
   part of it. There is deliberately no other interactive inside these cards for
   the overlay to swallow. */
.campaign-link::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
}

/* The one thing that must stay above the overlay. The invite code exists to be
   selected and pasted into a message, and an overlay covering it turns a
   drag-to-select into a navigation — the card would open the campaign every
   time the GM tried to copy the code. Sized to the text so the rest of that
   line still opens the campaign. */
.campaign-invite {
  position: relative;
  z-index: 1;
  width: max-content;
  max-width: 100%;
  cursor: text;
}

.campaign-list a:hover,
.campaign-link:hover {
  color: var(--mod-ink, var(--accent));
}

.campaign-list .campaign-card-action {
  padding: 0;
  border: var(--border-width, 1px) var(--border-style, dashed) var(--field-border);
  background: transparent;
  display: flex;
  align-items: stretch;
  justify-content: center;
}

.campaign-card-action-trigger {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  width: 100%;
  margin: 0;
  padding: 1.5rem 1rem;
  background: transparent;
  border: none;
  border-radius: var(--border-radius, 0.65rem);
  corner-shape: var(--corner-shape, round);
  color: var(--text-muted);
  font-weight: 700;
  font-size: 0.9rem;
  text-decoration: none;
  text-align: center;
  cursor: pointer;
}

/* --mod-campaigns, not --mod-campaign: the typo made the declaration invalid,
   so "Start new campaign" and "Join a campaign" had no hover state at all.
   Toned to a wash rather than the solid fill the original asked for — these
   are dashed placeholder cards, and a solid module fill under --text is the
   same pale-fill problem the light themes have everywhere else. */
.campaign-card-action-trigger:hover {
  color: var(--text, var(--accent));
  background: color-mix(in srgb, var(--mod-campaigns) 16%, transparent);
}

.campaign-card-action-trigger .icon {
  width: 1.4rem;
  height: 1.4rem;
}

/* Same trap as .button[hidden] below: display: flex above beats the browser's
   own [hidden] rule, so the trigger stayed on screen next to the form it had
   just opened. It matters twice over now — finding 15 ships the trigger hidden
   and lets the script reveal it, so without this the no-JS reader gets both. */
.campaign-card-action-trigger[hidden] {
  display: none;
}

.campaign-inline-form {
  width: 100%;
  margin: 0;
  padding: 0.9rem 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.campaign-inline-form[hidden] {
  display: none;
}

/* The form is a flex column with its own gap, so .error's 1rem would sit on
   top of that and push the fields away from the reason they were rejected. */
.campaign-inline-form .error {
  margin-bottom: 0;
}

.campaign-inline-form .actions {
  margin-top: 0.2rem;
}

.campaign-inline-form .actions .button {
  flex: 1;
}

/* The app's heading scale. Finding 11 promoted every page's title from <h2> to
   <h1> and shifted the sections down with it, so each level below carries the
   look that used to sit one level under it: h1 is the page title (the browser's
   <h2> default, now spelled out), h2 is the section heading, h3 the subsection
   (the browser's <h4> default). Nothing moved on screen at the time — only the
   outline did, which was the entire point: heading-jump navigation had no top
   level to land on, on every page but the landing page.

   The sizes have moved since. h2 went 0.95rem → 1.15rem in `94820e9`, which also
   made the scale monotonic: it used to render *smaller* than the h3 beneath it
   (0.95rem against 1em), leaning on uppercase and tracking to read as the senior
   of the two. That worked, but only if you already knew it was deliberate. This
   rule is bare `h2`, so the change reached every section heading in the app, not
   the page it was committed for.

   The landing page keeps its own scale and these rules never reach it: every
   .hero / .section-head / .feature-card / .step-card / .pricing-card heading
   pins its own size, margin, colour, case and tracking. .login-card is the one
   exception and is handled where it is defined. */
h1 {
  font-size: 1.5em;
  margin: 0.83em 0;
}

h2 {
  margin-top: 1.5rem;
  margin-bottom: 0.5rem;
  color: color-mix(in oklch, var(--muted) 60%, var(--text));
  font-size: 1.15rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

h3 {
  font-size: 1em;
  margin: 1.33em 0;
}

/* Tracks are intrinsic, not a fixed count: this used to be a hardcoded
   `1fr 1fr 1fr` at every viewport, so the sheet rendered as three ~110px
   columns on the phones the table actually plays on. The `min(100%, …)`
   floor is load-bearing — a bare `minmax(15rem, 1fr)` can't shrink below
   15rem and gives a 390px screen a horizontal scrollbar, which is worse
   than the cramped columns it replaces.

   Edit mode uses auto-fit (empty tracks collapse, so inputs stretch and stay
   easy to tap); the read view below uses auto-fill (empty tracks stay, so a
   two-field category reads as two stat tiles instead of two half-page
   slabs). */
/* A category's fold trigger. Note there is deliberately no rule setting
   `display` on .field-category itself: character-sheet.js hides an emptied
   category by setting .hidden on that element, and [hidden] is inert on
   anything that declares its own display (finding 15). Both the <details> and
   the <div> forms of the block must keep the browser default. */
.field-group-summary {
  list-style: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

.field-group-summary::-webkit-details-marker {
  display: none;
}

/* The disclosure caret, drawn rather than borrowed from the UA marker so it can
   sit after the heading and rotate. currentColor throughout — it is the
   heading's own ink, so there is no new pair here. */
.field-group-summary::after {
  content: "";
  width: 0.45rem;
  height: 0.45rem;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg) translate(-0.1rem, -0.1rem);
  opacity: 0.65;
  transition: transform 0.15s ease;
}

.field-category[open] > .field-group-summary::after {
  transform: rotate(-135deg) translate(-0.15rem, -0.15rem);
}

.field-group-summary .field-group-heading {
  margin: 0;
}

/* Category up/down, in the heading row of the edit view (§12: Chas and Andrew
   both asked to rearrange categories). Pushed to the right of the heading but
   kept *before* the disclosure caret, so the caret stays the rightmost thing and
   the summary still reads as a fold.

   These are inside a <summary>, which is why character-sheet.js cancels the
   click — otherwise a press would move the category and fold it in one tap. The
   buttons ship `hidden` and the script reveals them; with no script there is no
   dead control in a heading. */
.field-category-move {
  margin-inline-start: auto;
  display: flex;
  gap: 0.3rem;
}

.field-category-move .button,
.attribute-move .button {
  min-width: 0;
  margin: 0;
  padding: 0.3rem 0.45rem;
}

/* The reorder pair sits in its own <form>, so unlike .field-category-move it is
   a flex row in its own right rather than a span inside a summary. */
.attribute-move {
  display: flex;
  gap: 0.3rem;
  align-items: center;
}

/* Backstage's Center Stage attribute reorder (batch 10 day 3a) joins the same
   rule rather than getting its own. It is the same control doing the same job on
   a different list, and two selectors sharing one recipe is what stops the pair
   drifting into looking like two different affordances. */
.field-category-move .button[disabled],
.attribute-move .button[disabled] {
  opacity: 0.45;
  cursor: not-allowed;
}

/* A folded category still has to say it is there, so its heading keeps its
   bottom margin only when open. */
.field-category:not([open]) > .field-group-summary {
  margin-bottom: 0.75rem;
}

.field-list {
  list-style: none;
  margin: 0 0 1.5rem;
  padding: 0;
  display: grid;
  grid-auto-flow: row;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
  gap: 0.75rem;
}

.field-list li {
  display: grid;
  /* Read mode uses 0.4rem / 0.5rem for the same job (`.field-list-read` below)
     and is the denser, more skimmable of the two by common consent. Edit needs
     a little more room than read because it has input chrome, but it did not
     need 50% more: batch 10 day 1b. */
  gap: 0.4rem;
  padding: 0.65rem 0.9rem;
  background: var(--card-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  box-shadow: var(--shadow-card, none);
  align-items: end;
}

/* The base control rule ends `margin: 0 0 0.4rem`, which exists to space a field
   from whatever follows it on an ordinary form. Inside a field-list row the grid
   already owns that spacing (`gap` above), so the 0.4rem is 6.4px of double
   spacing on every row — 20 grid rows on a desktop sheet, 39 on a phone.

   **The three `:not()`s are mirrored from the base rule deliberately, and
   removing them un-fixes this silently.** That rule is
   `input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"])` at
   specificity (0,3,1); each `:not()` counts as a class. The obvious
   `.field-list .field-control input` is (0,2,1) and **loses**, with every
   declaration still sitting there looking applied. This has shipped twice in
   this file (CLAUDE.md). `tests/verify_number_inputs.php` asserts the ordering.

   **`.number-stepper` is in the selector list because it stands in for a field
   and mirrors that same margin** — see the comment on `.number-stepper` above,
   which asks to be kept in step. Zero one and not the other and text rows lose
   6.4px while number rows keep it; since this is a grid and the tallest cell
   sets the row, a sheet of 20 Center Stage attributes would show almost no
   change and nothing would say why. */
.field-list li .field-control input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]),
.field-list li .field-control select,
.field-list li .field-control textarea,
.field-list li .number-stepper {
  margin-bottom: 0;
}

/* Read mode's rows are tighter partly because `label` ships a 0.35rem gap that
   a two-line name/control stack does not need. */
.field-list li .field-control {
  gap: 0.2rem;
}

/* --------------------------------------------------------------------------
   The edit row's chrome shares one line (batch 10 day 1b).

   Day 0 measured every text and number row at an identical 182.36px whatever
   its value held — a single-line <input> is one line — and found **61% of that
   was chrome**: a full-width band for the move/revert/remove buttons, a second
   full-width band for the tag disclosure, against 72px for the label and the
   control. Neither band needs a band. Four buttons and a tag summary fit on one
   line beside each other in a 299px cell with room to spare.

   Scoped to `.field-list-edit`, a positive hook rather than
   `:not(.field-list-read)`: Center Stage's ad-hoc stats (combat-panel.php) are
   also a bare `.field-list` **with their own `.field-delete` children**, so a
   negation would place that screen's remove buttons into this grid's second
   column. See the comment at the markup.

   The hidden inputs in each row (field_ids[], sort_order[], field_categories[])
   are `display: none`, so they generate no grid items and need no placement.
   -------------------------------------------------------------------------- */
.field-list-edit li {
  grid-template-columns: auto minmax(0, 1fr);
  /* `end` is right when every child is a full-width band; once the buttons and
     the tag summary share a line, it bottom-aligns two things of different
     heights against each other. */
  align-items: start;
}

.field-list-edit li > .field-control {
  grid-column: 1 / -1;
  grid-row: 1;
}

/* **`.field-hint` must be placed explicitly.** It is emitted *outside* the
   <label> (character-field-value-control.php), so it is a fourth grid child
   rather than part of the control — leave it to auto-placement and the GM's
   help text lands in a column beside the buttons. The density fixture had no
   help text until batch 10 day 1b, which is exactly why this was invisible:
   the campaign that asked for help text (Vi's) is the one that would have hit
   it. */
.field-list-edit li > .field-hint {
  grid-column: 1 / -1;
  grid-row: 2;
}

.field-list-edit li > .field-delete {
  grid-column: 1;
  grid-row: 3;
}

.field-list-edit li > .field-tags {
  grid-column: 2;
  grid-row: 3;
  justify-self: end;
  /* The 5.6rem-scale top margin existed to separate two stacked bands. They are
     no longer stacked. */
  margin-top: 0;
}

/* Open, the tag panel is a panel again — full width, its own row, below the
   buttons rather than squeezed into half a line. Same `:has()` this file
   already uses for `li:has(textarea)` two rules down.

   This is why the buttons keep `grid-column: 1` rather than being centred: the
   cluster does not move when the panel opens, so opening tags never shifts the
   control you were about to press. */
.field-list-edit li:has(> .field-tags[open]) > .field-tags {
  grid-column: 1 / -1;
  grid-row: 4;
  justify-self: stretch;
}

/* The field's name renders identically in read and edit now — same family, size,
   case and colour as `.field-display-name`. Two payoffs: the name line goes from
   ~17.3px to ~15px, and switching to edit changes only the *value's*
   presentation, from text to control, which is a better answer to §12's "differentiate
   view mode from edit mode" than decorating the edit view would have been.

   **No new colour pair.** `label` and `.field-display-name` already declare the
   same `color-mix(in oklch, var(--muted) 40%, var(--text))` on the same
   `--card-bg` surface, so `docs/playtest/theme-contrast.py` gains nothing to
   measure — size and case only. */
.field-list-edit li > .field-control {
  font-family: var(--font-heading);
  font-size: 0.8rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

/* The control itself is not a heading — only the label text around it is.

   **`1rem` is a deliberate 16px, not just "undo the 0.8rem".** Form controls are
   `font: inherit` app-wide, so these were rendering at `label`'s 0.9rem —
   **14.4px** — and iOS Safari zooms the page whenever a focused input is under
   16px. On a sheet whose whole point is being edited on a phone mid-session,
   every tap into a field was punting the layout around. Restoring 0.9rem here
   would have kept the row 2px shorter and kept that.

   Scoped to this list, so it is a claim about the surface that was measured.
   **Every other form in the app still inherits 14.4px and still zooms** — the
   login, Backstage, party notes. That is a real finding and it is not this
   day's; recorded in the batch doc rather than fixed in passing. */
.field-list-edit li > .field-control input,
.field-list-edit li > .field-control select,
.field-list-edit li > .field-control textarea,
.field-list-edit li > .field-control .number-stepper {
  font-family: var(--font-body);
  font-size: 1rem;
  letter-spacing: normal;
  text-transform: none;
}

/* `1 / -1`, not `1 / 4` — the track count varies with the viewport now, so a
   span that names line 4 spills out of a one- or two-column layout.

   **`:not(.field-wrap)` is the whole point of that class.** A full row is right
   for a *declared* textarea: the GM said prose goes here, so the line breaks it
   keeps want the width. It is wrong for a `text` field that merely grew past one
   line (batch 10 day 2a), because promoting those costs a whole grid row each —
   about +24% of list height — and measuring that cost is exactly what ruled
   promotion out in favour of wrapping. Drop the `:not()` and the fix pays the
   price it was chosen to avoid, while still looking like it works. */
.field-list li:has(textarea:not(.field-wrap)) {
  width: 100%;
  grid-column: 1 / -1;
}

.field-list li form {
  align-self: end;
}

/* Read view — the character sheet's default. Rows are tighter than the edit
   form's because there's no input chrome to make room for, which is most of
   what makes the sheet skimmable instead of a wall of fields. */
.sheet-mode-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  align-items: center;
  justify-content: space-between;
}

.sheet-mode-bar h2 {
  margin-bottom: 0;
}

/* The sheet's desktop rail. .entity-layout is already one column below 981px,
   so everything here is a desktop-only gain and a guaranteed no-op on the
   phones the table actually plays on — which is why it can land without a
   playtest first.

   Placement is explicit rather than source-order because the rail is FIRST in
   the DOM (see character.php): that's what keeps the overview strip above the
   fields once the layout collapses. This puts it back on the right when
   there's room for it. */
@media (min-width: 981px) {
  .sheet-layout > .sheet-main {
    grid-column: 1;
    grid-row: 1;
  }

  .sheet-layout > .sheet-rail {
    grid-column: 2;
    grid-row: 1;
    position: sticky;
    top: 1rem;
    /* Sticky needs an escape hatch: a rail taller than the viewport would pin
       its top and put its own bottom permanently out of reach. */
    max-height: calc(100vh - 2rem);
    overflow-y: auto;
  }
}

/* Nothing to summarise, so no rail was drawn. One column, and the sheet
   renders exactly as it did before the rail existed. */
.sheet-layout-solo {
  grid-template-columns: minmax(0, 1fr);
}

/* Choreography carries its own .entity-layout. Nested inside the narrower main
   column, its second track's 280px floor leaves too little for the first, so
   it stacks here instead. */
.sheet-main .entity-layout {
  grid-template-columns: minmax(0, 1fr);
}

/* Category heading. Only ever drawn when a category exists, so a campaign
   that has never set one sees no headings at all. */
.field-group-heading {
  margin: 1.1rem 0 0.5rem;
  font-size: 0.9rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--accent);
}

.field-group-heading:first-of-type {
  margin-top: 0.4rem;
}

/* A tighter track floor than the edit form's 15rem: a read tile is a name and
   a value with no input chrome to make room for, and packing more of them per
   row is most of what makes the sheet read as a stat block rather than a list.
   auto-fill (not auto-fit) so a two-field category stays two tiles instead of
   stretching into two half-page slabs. */
.field-list-read {
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
  gap: 0.4rem;
}

/* Prose has no <textarea> in read mode, so the edit form's :has(textarea) rule
   can't reach it — a paragraph gets the full row on its own class.

   `.field-display-prose` is now put there by *length* as well as by type
   (fieldDisplayWidth(), batch 8 day 5), which is what stops a long text field
   inflating the row it sits in: this is a grid, so every cell in a row is as
   tall as the tallest, and an 800-character value measured 1177px and took its
   two neighbours with it. The folded case is listed separately because its
   prose is nested inside a <details> and `:has()` would otherwise have to
   reach through it. */
.field-list-read li:has(.field-display-prose),
.field-list-read li:has(.field-display-folded) {
  grid-column: 1 / -1;
}

.field-list-read li {
  grid-template-columns: minmax(0, 1fr);
  padding: 0.5rem 0.9rem;
  align-items: center;
}

/* A value too long for even a full row (>1200 chars). The 8000-word field §12
   asked about measured 16,396px on a phone — about twenty-two screens for one
   field — so widening alone answered the wrong half of the question. */
.field-display-folded {
  width: 100%;
}

.field-display-folded > summary {
  list-style: none;
  cursor: pointer;
}

.field-display-folded > summary::-webkit-details-marker {
  display: none;
}

/* The opening, and the affordance under it. Both live in the <summary>, so the
   whole preview is the click target — a reader reaching for "more" aims at the
   text they are already reading rather than at a link beneath it. */
/* `flex`, not `inline-flex`: inline it runs on from the last words of the
   preview, which reads as part of the sentence rather than as the way out of
   it. Its own line, and the margin then applies. */
.field-display-more {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  margin-top: 0.35rem;
  font-size: 0.85rem;
}

.field-display-folded[open] > summary > .field-display-preview {
  display: none;
}

.field-display-folded[open] .field-display-more .icon {
  transform: rotate(180deg);
}

.field-display {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.15rem 0.9rem;
  flex-direction: column;
}

.field-display-name {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-family: var(--font-heading);
  font-size: 0.8rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: color-mix(in oklch, var(--muted) 40%, var(--text));
}

.field-display-value {
  font-size: 1.05rem;
  font-weight: 600;
  text-align: right;
  overflow-wrap: anywhere;
}

.field-display-value.is-empty {
  font-weight: 400;
}

/* Prose drops to its own full-width line: a paragraph read right-aligned
   against its label is unreadable at any width. */
.field-display-prose {
  flex-basis: 100%;
  font-size: 1rem;
  font-weight: 400;
  text-align: left;
}

/* A field the user has marked for removal on the character sheet. Its inputs
   stay live on purpose — the row is only struck through, not disabled, so
   "Keep" puts back exactly what was typed and a failed save can re-render it
   as typed. */
.field-list li.is-pending-delete .field-control,
.field-list li.is-pending-delete .field-hint {
  text-decoration: line-through;
  opacity: 0.6;
}

.field-list li.is-pending-delete input,
.field-list li.is-pending-delete select,
.field-list li.is-pending-delete textarea {
  text-decoration: none;
}

/* .button sets display: inline-flex, which would otherwise beat the browser's
   own [hidden] rule and leave hidden buttons on screen. */
.button[hidden] {
  display: none;
}

/* A data-submit-once form between the press and the page turning over. The
   button is deliberately still enabled and still carries its name and value —
   see app.js for why disabling it would change what some forms post — so this
   is appearance only, and the second submit is refused in script rather than
   by the control being dead. */
.is-submitting button[type="submit"],
.is-submitting .button[type="submit"] {
  opacity: 0.6;
  cursor: progress;
}

.field-delete {
  display: flex;
  gap: 0.4rem;
  align-items: end;
}

.field-row {
  flex: 1;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: 0.6rem;
}

.field-row[hidden] {
  display: none;
}

/* One of a Bit's cost rows: which Reserve, how much, and the x that takes the
   row away (batch 7 day 3).

   Its own columns for the same reason .field-row-numeric below has its own:
   .field-row means "a field and the control beside it" and is two columns wide,
   and this row has three children. Adding the remove button pushed it onto a
   second grid line, where it stretched to the full width of the fieldset and
   read as a separate control rather than as part of the row — measured in
   headless Chrome, which is the only reason it was caught before shipping.

   `end`, not `center`: each cell is a <label> with its text above its control,
   so centring lines the button up against the *labels* and leaves it floating
   above the fields it belongs to. */
/* Flex rather than a grid with named columns, and not because grid was hard:
   this row lives inside .entity-column, which is one column on a phone and two
   on a laptop, so how much width it actually gets has nothing to do with the
   viewport. A media query here sized the row against the *window* and left the
   Reserve select 57px wide inside a 256px row — measured, after the first
   attempt did exactly that. Flex-basis lets each cell ask for a width and the
   row wrap when the container cannot give it, whatever the page around it is
   doing. */
.field-row.bit-cost-row {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: 0.5rem;
}

/* `min-width: 0` is doing real work, not tidying. A flex item defaults to
   `min-width: auto`, which floors it at its *content's* minimum — and a bare
   `<input type="number">` reports an intrinsic minimum of about 280px, because
   browsers size a text-ish input to roughly twenty characters. So the "How much"
   cell ignored its 8rem basis and ate the row, pushing the x onto a second line.
   Measured: flex computed to 0/1/128px while the element rendered at 281px,
   which is the signature of this exact gotcha — the flex values are right and
   the box is not. */
.field-row.bit-cost-row > label {
  min-width: 0;
}

/* Spends: the one that needs room, because it holds a Reserve's name. */
.field-row.bit-cost-row > label:first-child {
  flex: 1 1 10rem;
}

/* How much: a small number, and it grows a -/+ stepper of its own. */
.field-row.bit-cost-row > label:nth-child(2) {
  flex: 0 1 8rem;
}

.field-row.bit-cost-row .bit-cost-remove {
  flex: 0 0 auto;
  min-width: 0;
  margin: 0;
  padding: 0.55rem 0.7rem;
}

/* Min / Max / Increment. .field-row means "a field and the control beside it" —
   two columns, the second sized to its content — and this row has always been
   three equal fields borrowing that class. It got away with it while each cell
   held a bare input that could shrink to nothing: three items auto-place into
   two columns, so Increment wrapped underneath and the widths came out lopsided
   but nothing collided.

   Steppers ended that. Measured on Backstage 2026-08-03: the 1fr column
   resolved to 118px while the wrapper's own minimum — field plus two buttons —
   is 136px, so the control overflowed its cell by 18px and ran into the field
   beside it. That is finding 7, and it is a grid that was never describing this
   row rather than anything wrong with the stepper.

   auto-fit rather than a fixed three, because 3 x 8.5rem does not fit a phone:
   this gives three across on a laptop, two then one on a narrow screen, and
   every track is at least as wide as a stepper is allowed to get. */
.field-row-numeric {
  grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
  align-items: start;
}

.field-row label,
.field-delete label {
  margin-bottom: 0;
  flex: 1;
}

.field-row label:has(> input[type="radio"]),
.field-row label:has(> input[type="checkbox"]) {
  flex-direction: row;
}

.field-row button,
.field-delete button {
  width: auto;
  /* **Both margins, not just the top one.** `.button` carries
     `margin-bottom: 7px` (it is `align-self: end` in a flex row and the 7px
     nudges it off the baseline of the field beside it); inside `.field-delete`
     there is no field beside it and nothing was resetting it. Every edit row on
     the character sheet therefore carried 7px of dead space under its button
     band, which is why batch 10 day 0 measured a 31px button as a 38.03px band.
     Resetting only `margin-top` is the shape of the bug: it reads as a complete
     reset and is half of one. */
  margin-top: 0;
  margin-bottom: 0;
  white-space: nowrap;
}

/* Typed character field controls (select/multiselect/radio can't all be a
   bare <label>, so .field-control carries the flex/spacing instead). */
.field-control {
  margin-bottom: 0;
  flex: 1;
}

fieldset.field-control {
  border: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
}

/* The GM's description of a field, under the control that fills it in — how
   she writes the math down, since the app deliberately doesn't do it (§12).
   Edit mode only: in read mode it's a title attribute instead, because a
   permanent second line under every value is exactly what Day 1 removed. */
/* The help text batch 3 shipped. It set a size and no colour at all, so it
   inherited full body colour — which is the other half of §12's "same size and
   color" complaint, and the half that was never about .muted. */
.field-hint {
  margin: 0.2rem 0 0;
  color: var(--text-muted);
  font-size: 0.8rem;
  line-height: 1.35;
}

/* Reachable by a screen reader, invisible to everyone else. Used for the read
   view's field descriptions, which are otherwise a title attribute — and a
   title is announced by nothing and reachable by no touch device. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

fieldset.field-control legend {
  padding: 0;
  margin-bottom: 0.2rem;
}

.field-template-form [data-field-types] {
  margin: 0.5rem 0;
}

label:has(span.muted),
label > span.muted {
  display: inline;
  width: fit-content;
}

textarea {
  resize: vertical;
  /* Progressive enhancement — auto-grows with content in browsers that
     support it; app.js's autoGrowTextareas() covers the rest. */
  field-sizing: content;
  /* **The bound belongs with the growth, not somewhere else.** `field-sizing:
     content` is unbounded, so the control is as tall as its value: batch 10
     day 0 measured the 8000-word field at **11,504px** in edit mode — 71% of a
     16,199px field list, for one field. The same value renders at 209px in read
     mode, because batch 8 folded it there and edit mode got none of that.

     A cap rather than a <details>, deliberately. Read mode folds because you are
     *reading* and a preview with an expand is enough; in edit mode the textarea
     **is the control you came for**, and putting it behind a disclosure hides
     the thing being used. Capping keeps every character reachable — the
     textarea scrolls natively — and takes nothing away, because `resize:
     vertical` above is still there and still drags it taller. It also avoids
     owing a seventh entry in verify_disclosure_affordances.php's census for a
     disclosure nobody asked for.

     min() so it is predictable on a desktop and proportionate on a phone: 24rem
     flat would be 45% of a 390x844 screen. Applied here, beside the declaration
     that causes the growth, rather than scoped to the sheet — an unbounded
     auto-growing textarea is wrong on every surface that has one, and a fix
     living apart from its cause is how an invariant becomes a comment. */
  max-height: min(24rem, 50vh);
}

/* A `text` field that grew past one line and is being wrapped rather than
   widened (batch 10 day 2a). **It must not auto-grow**, which is the one thing
   the rule above gives every other textarea.

   Measured, because the first attempt did the opposite and shipped nothing:
   with `field-sizing: content` inherited, a 1200/1600/2400-character value
   became a **471.78px** block each — the edit list went 4,334px to 6,027px,
   **+39%**. That is a worse price than the full-row promotion this whole
   approach was chosen to avoid (+24%), paid for showing the entire value in a
   control nobody asked to expand.

   Two rows is the point: it is what turns 38 visible characters into ~100 for
   about 24px, and the rest scrolls. `resize: vertical` from the base rule still
   drags it taller for anyone who wants the whole thing, and `max-height` above
   still bounds that. Wrapping is the ask (§12, Gail 2026-08-06 — *"wrapping
   text is much easier to read"*); expanding is not. */
.field-wrap {
  field-sizing: fixed;
}

.note-list {
  list-style: none;
  margin: 0 0 1rem;
  padding: 0;
}

.note-list li {
  padding: 0.8rem 0;
  border-bottom: 1px solid var(--border-soft);
}

.note-list p {
  margin: 0 0 0.4rem;
}

.note-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  /* A note can carry three controls now (vote, copy to the party, delete) and
     the table plays on phones — let them wrap rather than overflow. */
  flex-wrap: wrap;
}

/* Timestamp/byline takes the free space so the controls stay grouped together
   on the right however many of them a given note shows. */
.note-meta > span:first-child {
  margin-right: auto;
}

.note-meta form {
  margin: 0;
}

.note-meta button {
  width: auto;
  margin-top: 0;
}

.quick-capture {
  margin-bottom: 1.5rem;
}

/* Party notes — the shared board. Deliberately built out of the same note-list
   the private notes use, so the two read as the same kind of thing; the only
   visual difference is the byline and the vote button, which is the only
   difference that matters. */
.party-notes .quick-capture {
  margin-bottom: 0;
}

.party-note-vote {
  display: inline-flex;
  align-items: center;
  gap: 0.2rem;
  font-variant-numeric: tabular-nums;
}

/* A cast vote reads as pressed rather than as a different button — it's a
   toggle, and pressing it again takes the vote back. */
.party-note-vote.is-voted {
  border-color: var(--mod);
  background: color-mix(in srgb, var(--mod) 25%, transparent);
  color: var(--text);
}

.attachment-list {
  list-style: none;
  margin: 0 0 1rem;
  padding: 0;
}

.attachment-list li {
  padding: 0.6rem 0;
  border-bottom: 1px solid var(--border-soft);
}

.attachment-list a {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}

.attachment-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  margin-top: 0.2rem;
}

.attachment-meta form {
  margin: 0;
}

.attachment-meta button {
  width: auto;
  margin-top: 0;
}

.entity-list {
  list-style: none;
  margin: 0 0 1.5rem;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 0.75rem;
}

.entity-list li {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.5rem 1rem;
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  background: var(--card-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-left: var(--border-width, 3px) var(--border-style, solid) var(--mod, var(--field-border));
  box-shadow: var(--shadow-card, none);
}

.entity-list p {
  margin: 0.2rem 0 0;
}

.entity-list form {
  margin: 0;
}

.entity-list button {
  width: auto;
  margin-top: 0;
  white-space: nowrap;
  min-width: 0;
  padding: 0.45rem 0.7rem;
}

.field-row button,
.field-delete button,
.tag-chip button,
.tag-delete-form button,
.tag-rename-form button {
  min-width: 0;
  padding: 0.45rem 0.7rem;
  margin-top: 0;
}

.entity-list > li > form:last-child {
  align-self: flex-end;
}

/* line-height is load-bearing here, not tidiness. Badges take
   var(--font-heading), and the themes swap that whole family — so this box is
   asked to hold Inter, Kalam, Cinzel Decorative, Orbitron, Playfair, Cormorant
   and Special Elite. Without an explicit line-height the box is sized by each
   font's own `normal` leading and the glyphs sit wherever that font's ascent
   puts them: on Cartographer ("Special Elite", a typewriter face with tall
   metrics) the text jammed against the top edge with a gap beneath it, reported
   from a phone 2026-07-29. line-height: 1 collapses the half-leading that
   causes the offset, so the symmetric padding is actually symmetric.

   inline-flex + align-items: center matches what .button and .nav-pill already
   do, and keeps the text centred if a future theme's font has stranger metrics
   still. Any remaining offset is the font's own glyph placement inside its em
   box, which no amount of CSS centring can reach.

   Vertical padding went 0.15rem -> 0.25rem to put the height back where it was:
   at 0.75rem text, the default font's `normal` leading gave ~16.9px and
   12px + 2x0.25rem gives 16px. Themes whose font had *looser* leading than that
   get shorter badges, which is the bug being removed rather than a side effect —
   the surplus height was the gap. */
.badge {
  display: inline-flex;
  align-items: center;
  line-height: 1;
  font-size: 0.75rem;
  font-weight: 700;
  padding: 0.25rem 0.5rem;
  border-radius: var(--border-radius, 999px);
  corner-shape: var(--corner-shape, round);
  margin-left: 0.4rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

/* Decision B3's preview. Two columns where there is room and one where there is
   not, because "what they will see" and "what they will not" are a comparison —
   stacked, the second column reads as an afterthought, which is the opposite of
   why it is there. No colour and no new surface: it sits on .section-card, which
   the harness has already measured. */
.offer-preview-columns {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1rem;
}

@media (min-width: 40rem) {
  .offer-preview-columns {
    grid-template-columns: 1fr 1fr;
  }
}

.character-claim-code {
  margin: 0.25rem 0 0;
}

/* The player's half of the same object. Laid out like the other short forms on
   this screen rather than given a treatment of its own: it is one field and one
   button, and the reason it is first in its block is that a GM saying "yours is
   7QK4" needs it findable without reading, not decorated. */
.claim-form {
  display: flex;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 0.5rem;
}

/* Display only, and the server does not rely on it: claimCharacterByCode()
   upper-cases and trims what it is given, so a player typing in lower case is
   right rather than lucky. This makes the field *look* like the thing the GM
   read out; it is not what makes it match. */
.claim-form input {
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.invite-code-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
}

/* **The claim code borrows the invite code's presentation, on purpose.** It is
   the same object doing the same job one level down: a short string a GM reads
   out to somebody in the room. A second treatment would make the table learn
   two things that are one thing, and this one has already been measured on
   eleven themes — `--field-bg` under inherited ink, which is the pair the
   surrounding card already established. */
.invite-code-row code,
.character-claim-code code {
  font-family: monospace;
  font-size: 0.95rem;
  font-weight: 700;
  letter-spacing: 0.05em;
  background: var(--field-bg);
  padding: 0.15rem 0.5rem;
  border-radius: var(--border-radius, 0.35rem);
}

/* The other half of the sharing signal, and it had the same problem as
   .badge-mod from the opposite direction: --field-bg is barely distinct from
   the card it sits on, so on Cartographer and Parchment "Not shared" was an
   unbordered word rather than a badge. The border is what makes it read as the
   same kind of object as .badge-mod. */
.badge-draft {
  background: var(--field-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--field-border);
  color: var(--text-muted);
}

.tag-box {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  margin: 0.6rem 0 1.2rem;
}

.tag-chip,
.tag-chip-static {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  margin: 0;
  padding: 0.2rem 0.3rem 0.2rem 0.7rem;
  border-radius: var(--border-radius, 999px);
  corner-shape: var(--corner-shape, round);
  background: var(--mod, var(--text));
  color: var(--button-text, var(--bg));
  font-size: 0.85rem;
}

/* The read-only twin of .tag-chip, for a field's tags shown inside a closed
   disclosure. It shares the rule above rather than getting its own colours
   deliberately: same fill, same ink, so it is the *same* measured pair and the
   theme harness's existing chip check covers it. A visually similar chip with
   its own tokens would be a new pair nobody measured. */
.tag-chip-static {
  padding-right: 0.7rem;
}

.tag-chip a {
  color: inherit;
  text-decoration: none;
}

.tag-chip a:hover {
  text-decoration: underline;
}

.tag-chip button {
  width: auto;
  margin: 0;
  padding: 0 0.35rem;
  border: none;
  border-radius: var(--border-radius, 999px);
  corner-shape: var(--corner-shape, round);
  background: transparent;
  color: inherit;
  font-size: 0.9rem;
  line-height: 1.4;
}

.tag-chip button:hover {
  background: color-mix(in srgb, var(--mod, var(--accent)) 30%, transparent);
}

.tag-add {
  margin: 0;
}

/* --- A character field's own tags, in the sheet's read view ----------------
   A <details> per field. Closed it is the field's tags as chips, or just the
   icon when it has none; open it is the add/remove form. No script: the
   browser owns the disclosure, so this works with JS off, which is the whole
   reason it is a <details> and not a popover.

   No colour of its own anywhere in here. The icon and border take currentColor
   from the row, and the chips share .tag-chip's measured pair — so this adds no
   text/surface pair the theme harness has not already checked. */
.field-tags {
  margin-top: 0.35rem;
}

.field-tags-summary {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.3rem;
  padding: 0.15rem 0;
  list-style: none;
  cursor: pointer;
  /* The trigger is a quiet thing sitting under every field on the sheet, so it
     borrows the row's ink at reduced strength rather than an accent. */
  color: color-mix(in oklch, var(--muted) 40%, var(--text));
}

.field-tags-summary::-webkit-details-marker {
  display: none;
}

.field-tags-summary .icon {
  width: 0.95rem;
  height: 0.95rem;
}

/* The tap target is the summary, and on a phone it has to be reachable without
   hitting the field's value above it. */
.field-tags-summary:hover,
.field-tags[open] > .field-tags-summary {
  color: inherit;
}

.field-tags-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
}

/* "+2" when the summary shows fewer chips than the field has (batch 10 day 1b).

   **Deliberately not a `.tag-chip-static`.** Those carry
   `background: var(--mod, var(--text))` with `color: var(--button-text, …)`,
   which is the two-surfaces-one-fallback shape CLAUDE.md warns about — the
   `--button-ink` bug lived in exactly that pattern. This is a count, not a tag:
   no fill, no new token, `currentColor` at the summary's own opacity, so it
   adds no pair for `theme-contrast.py` to measure and cannot be legible in one
   theme and not another. */
.tag-chip-more {
  align-self: center;
  font-size: 0.8rem;
  font-variant-numeric: tabular-nums;
  color: currentColor;
  opacity: 0.7;
}

/* The caret, and the only reason it is a wrapper rather than a bare icon: the
   rotation below has to reach *this* icon and not the tag icon two nodes to its
   left, which shares .field-tags-summary .icon with it. `:last-child` would
   have worked today and broken the day anything is appended to the summary. */
.field-tags-caret {
  display: inline-flex;
}

/* --- The two rules that make a summary-plus-body disclosure behave -----------
   Both of these already existed on .field-display-folded, the *other* <details>
   on this same row, ~500 lines up. This control copied that structure — a
   preview in the <summary>, the real thing in the body — and did not copy
   either rule, so from the day it shipped (2026-08-01) until 2026-08-06 it had
   the shape of a disclosure and the behaviour of neither half:

   - every tag rendered **twice** while open, once as a .tag-chip-static in the
     summary and again as a removable .tag-chip in the body (§12, 2026-08-05:
     *"as I add tags, they show up twice… it is confusing"*), and
   - nothing rotated, moved or otherwise said the row had opened.

   This has to be CSS and not PHP. <details> toggles client-side with no round
   trip, so a reader who opens the disclosure without navigating gets no new
   markup — a server-side `if ($open)` would leave the duplicate on screen in
   the one case that matters most, which is every case except the redirect after
   a write. */
.field-tags[open] > .field-tags-summary > .field-tags-chips {
  display: none;
}

.field-tags[open] > .field-tags-summary .field-tags-caret .icon {
  transform: rotate(180deg);
}

/* Open, the body is a panel rather than more of the row (§12, 2026-08-05:
   *"it might be [intuitive] if the form was more obviously a separate entity
   from the custom fields box it is inside of"*).

   Still no colour of its own, which is what keeps the claim at the top of this
   block true: the border and wash are both currentColor — the row's own ink,
   already measured against this surface — so this adds no text/surface pair for
   docs/playtest/theme-contrast.py to check. Nothing renders text directly on
   the wash either; the chips, the input and the Add button all bring their own
   opaque fills, so the pairs inside are the ones the harness already knows. */
.field-tags-body {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  margin-top: 0.4rem;
  padding: 0.5rem 0.6rem;
  border: var(--border-width, 1px) var(--border-style, solid)
    color-mix(in srgb, currentColor 30%, transparent);
  border-radius: var(--border-radius, 0.6rem);
  corner-shape: var(--corner-shape, round);
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.field-tags-body .tag-add {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.3rem;
}

.field-tags-body .tag-combo-input {
  width: auto;
  min-width: 7rem;
  flex: 1 1 7rem;
}

.field-tags-body .button {
  margin-top: 0;
}

/* --- "Shared With You": bulk tagging on campaign.php ----------------------
   Layout only. There is not one colour declaration in this block, and that is
   the point: the fieldset is .export-group (already themed), the ink is the
   panel's own text, the checkbox joins .checkbox-field's rule above so it is
   the *same* measured pair rather than a lookalike with its own tokens, and
   the select-all is a .button.secondary the harness already checks. This
   screen therefore adds no text/surface pair to measure — see the theme rule
   in CLAUDE.md, and note that a "no new colours" claim is only worth anything
   if the markup really does reuse the measured classes. It does; grep the
   partial. */
.shared-tag-group {
  margin-top: 0.9rem;
}

.shared-tag-group legend {
  /* The select-all rides in the legend, so the legend is a row, not a word. */
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
}

.shared-tag-group .entity-list {
  margin: 0.2rem 0 0;
}

/* A tap target the size of the row, not the size of the box — this is a phone
   screen at a table, and the name is the thing the thumb is aimed at. */
.shared-tag-item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.35rem 0;
  cursor: pointer;
}

.shared-tag-all {
  width: auto;
  margin: 0;
  padding: 0.1rem 0.5rem;
  font-size: 0.75rem;
}

/* The three :not()s mirror the base field rule exactly and are load-bearing,
   not decoration to be tidied away — the same trap as .number-stepper's field
   rule, and the second rule in this stylesheet to fall into it. Each :not()
   counts as a class, so the base rule
   `input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"])`
   scores (0,3,1) while a plain `.tag-add input` scores (0,1,1) and loses every
   property the two have in common.

   It shipped as the plain selector and stayed that way for two batches. The
   declarations sat here looking applied while the control rendered 224px wide
   with 12px/14.4px padding and an 11.2px radius — an ordinary form field, on
   the seven pages that show one, where an 8rem pill was drawn. Nothing was
   wrong with any value in this block; nothing here had ever run.

   tests/verify_number_inputs.php compares this rule's specificity against the
   base rule's, per declaration, so removing the :not()s fails rather than
   silently restoring the bug. */
.tag-add input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]) {
  width: 8rem;
  margin: 0;
  padding: 0.3rem 0.6rem;
  /* **The one rule in this stylesheet that out-specifies the 16px floor**, at
     (0,4,1) against the base rule's (0,3,1) — because the `:not()`s that make it
     apply at all also make it win. It carried `font-size: 0.85rem`, so the tag
     field rendered at 13.6px and iOS zoomed the page on a control people press
     constantly, at the table, on a phone. Found batch 11 day 5 by
     `tests/verify_input_zoom.php`, not by looking.

     The pill gets slightly wider for it and that is the trade taken knowingly:
     `width: 8rem` still holds the shape, and a control that zooms the page is a
     worse pill than a wide one. */
  font-size: max(1rem, 16px);
  border-radius: var(--border-radius, 999px);
  corner-shape: var(--corner-shape, round);
}

.tag-combo {
  position: relative;
  display: inline-block;
}

.tag-suggest-menu {
  position: absolute;
  top: calc(100% + 0.35rem);
  left: 0;
  z-index: 40;
  list-style: none;
  margin: 0;
  padding: 0.35rem;
  min-width: 10rem;
  max-width: 16rem;
  max-height: 12rem;
  overflow-y: auto;
  background: var(--panel-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--panel-border);
  border-radius: var(--border-radius, 0.6rem);
  corner-shape: var(--corner-shape, round);
  backdrop-filter: blur(14px);
  box-shadow: var(--shadow-popover, 0 0.6rem 1.6rem -0.4rem rgba(0, 0, 0, 0.35));
}

.tag-suggest-menu[hidden] {
  display: none;
}

.tag-suggest-menu li {
  list-style: none;
}

.tag-suggest-menu button {
  width: 100%;
  margin: 0;
  display: block;
  padding: 0.35rem 0.55rem;
  border: none;
  border-radius: var(--border-radius, 0.4rem);
  corner-shape: var(--corner-shape, round);
  background: transparent;
  color: var(--mod-backstage);
  font: inherit;
  font-size: 0.8rem;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}

/* The hover used to set `background: var(--mod-backstage)` while leaving the
   text that same colour — 1:1, so the suggestion vanished under the cursor and
   keyboard focus was invisible. A wash of the same colour keeps the tint and
   leaves the lettering where it was. */
.tag-suggest-menu button:hover,
.tag-suggest-menu button:focus-visible {
  background: color-mix(in srgb, var(--mod-backstage) 18%, transparent);
  outline: none;
}

/* ---------------------------------------------------------------------------
   Category combo (batch 7, finding 5)

   Chas: "you have to start typing to get a list of existing categories to
   pop-up, but you may not remember what categories you used before." Every
   Category input in the app was a native <datalist>, which most browsers only
   open once you have typed — so the categories you already had were unreachable
   if you could not remember one.

   **Every selector here is keyed on a class assets/js/category-combo.js writes**
   (`.category-combo`, `.category-combo-menu`, `.category-combo-option`), and the
   script creates all three elements. That is the progressive-enhancement rule in
   CLAUDE.md pointing the easy way for once: nothing below can reach a reader
   with no script, because with no script none of these elements exist and the
   <datalist> is still doing its job. The input itself is deliberately left
   unstyled here — it is the same field it always was.

   No new colour pair. This is .tag-suggest-menu's recipe on the same surfaces,
   the same way the number stepper took .button.secondary's, so theme-contrast.py
   gains a name in an existing check's inventory and no new check.
   --------------------------------------------------------------------------- */
.category-combo {
  position: relative;
  display: block;
}

.category-combo-menu {
  position: absolute;
  top: calc(100% + 0.35rem);
  left: 0;
  z-index: 40;
  list-style: none;
  margin: 0;
  padding: 0.35rem;
  min-width: 10rem;
  max-width: 100%;
  max-height: 12rem;
  overflow-y: auto;
  background: var(--panel-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--panel-border);
  border-radius: var(--border-radius, 0.6rem);
  corner-shape: var(--corner-shape, round);
  backdrop-filter: blur(14px);
  box-shadow: var(--shadow-popover, 0 0.6rem 1.6rem -0.4rem rgba(0, 0, 0, 0.35));
}

.category-combo-menu[hidden] {
  display: none;
}

.category-combo-menu li {
  list-style: none;
}

/* --text rather than a module colour: unlike the tag menu, this control appears
   on Backstage, the character sheet and both Bit forms, so --mod is a different
   hue on each and a suggestion list has no business changing colour depending on
   which page you opened it from.

   **The repeated class is load-bearing, not a typo.** These are plain <button>
   elements, so `button, .button` (0,0,1) loses to a single class — but
   `.field-row button` scores (0,1,1) and would beat it, and Bit forms put
   category inputs inside .field-row. Doubling the class scores (0,2,0) and wins
   everywhere. This is the same trap the number stepper's field rule hit (see
   the .number-stepper block above), which is why it is spelled out here rather
   than left to be rediscovered: the failure is silent and looks like a padding
   bug on one page only. */
.category-combo-option.category-combo-option {
  width: 100%;
  min-width: 0;
  margin: 0;
  display: block;
  padding: 0.4rem 0.55rem;
  border: none;
  border-radius: var(--border-radius, 0.4rem);
  corner-shape: var(--corner-shape, round);
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}

/* A wash, not a fill — the same lesson the tag menu learned above: a solid
   background under text of the same colour makes the suggestion vanish exactly
   when the pointer or keyboard focus is on it. */
.category-combo-option:hover,
.category-combo-option:focus-visible {
  background: color-mix(in srgb, var(--mod, var(--accent)) 18%, transparent);
  outline: none;
}

.tag-manage {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.tag-manage .field-row {
  flex-wrap: wrap;
}

/* Rename is collapsed so a long tag list stays one line per tag. Matches
   .choreo-form-toggle, which does the same job on the character sheet. */
.tag-rename-toggle > summary {
  cursor: pointer;
  font-size: 0.9rem;
  color: var(--mod-ink, var(--accent));
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

.tag-rename-toggle[open] > summary {
  margin-bottom: 0.5rem;
}

.tag-checkbox {
  align-items: center;
  gap: 0.5rem;
  font-weight: 600;
}

#tag-section.merge-mode .tag-checkbox {
  display: flex;
}

#tag-section.merge-mode .tag-manage,
#tag-section.merge-mode .tag-delete-form {
  display: none;
}

#merge-form {
  padding-bottom: 1rem;
}

#merge-form:not(:has(~ .merge-mode)) #merge-bar {
  display: none;
}

.entity-list.merge-mode label.tag-checkbox {
  display: block;
}

.entity-list label.tag-checkbox input[type="checkbox"] {
  display: none;
}

.related-group-label {
  margin: 1.2rem 0 0.4rem;
  text-transform: uppercase;
  font-size: 0.75rem;
  letter-spacing: 0.05em;
}

.related-group-label:first-of-type {
  margin-top: 0.6rem;
}

/* Marketing landing page (index.php) */

.site-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  max-width: 1100px;
  margin: 0 auto;
  padding: 1.4rem 1.5rem 0;
}

/* join.php's "what is this?" line. Sits in .panel, so it takes the same ink as
   any other panel text and needs no new harness pair — --text and --text-muted
   on the panel are both already measured. */
.join-provenance {
  margin: 0 0 1rem;
  font-size: 0.9rem;
  color: var(--text);
}

.site-brand {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

.site-wordmark {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-heading, inherit);
  font-weight: 700;
  font-size: 1.15rem;
  color: var(--text);
}

/* "a bobble by GrailBot" — the attribution under the wordmark.
   Takes --text, NOT --text-muted, and that is measured rather than taste. The
   landing page has no .panel: its header, hero and footer sit directly on the
   body gradient, which is a much tighter surface than the card and panel that
   --text-muted was set against. Measured there it is 2.22:1 on Meadow and
   2.39:1 on Grove — far under §10's 4.5 bar. The quiet comes from the size and
   the italic instead, which is §10's own recorded lesson from .field-hint: the
   size half of "muted" was always doing more work than the colour half.
   Indented to clear the wordmark's icon so it reads as belonging to the name
   above it rather than starting a new column. */
.site-attribution {
  margin: 0;
  padding-left: 1.9rem;
  font-size: 0.8rem;
  font-style: italic;
  color: var(--text);
}

.site-wordmark .icon {
  color: var(--accent);
  width: 1.4rem;
  height: 1.4rem;
}

.hero {
  max-width: 1100px;
  margin: 0 auto;
  padding: 3.5rem 1.5rem 2rem;
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.hero-eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.4rem 0.9rem;
  border-radius: var(--border-radius, 999px);
  corner-shape: var(--corner-shape, round);
  background: var(--field-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--field-border);
  /* --text, not --text-muted. Ink on --field-bg composited over the body
     gradient — a surface field_surfaces() never covered, because it
     composites field over CARD over PANEL and the landing page has neither.
     Measured once the pair was declared: 3.26:1 on Grove, 3.76 Parchment,
     3.93 Meadow, 4.04 Cartographer. SEO_PLAN.md Day 3. */
  color: var(--text);
  font-size: 0.85rem;
  font-weight: 600;
  margin-bottom: 1.2rem;
}

.hero h1 {
  font-size: clamp(2.2rem, 5vw, 3.4rem);
  line-height: 1.1;
  margin: 0 0 1rem;
  max-width: 18ch;
}

.hero .hero-sub {
  /* --text, NOT --text-muted, and measured rather than taste: the landing
     page has no .panel, so this sits on the bare body gradient, where muted
     ink runs 2.22:1 on Meadow and 2.46:1 on Grove. Same finding and same fix
     as .site-attribution and .site-footer (batch 7 day 1) — those two moved
     and these did not, because the harness had only been told about the two.
     The quiet comes from size and weight instead, which is section 10s own
     recorded lesson from .field-hint. SEO_PLAN.md Day 3. */
  color: var(--text);
  font-size: 1.15rem;
  max-width: 56ch;
  margin: 0 0 2rem;
}

.hero-ctas {
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
  justify-content: center;
  margin-bottom: 1.6rem;
}

.hero-ctas .button {
  width: auto;
  padding: 0.9rem 1.6rem;
  font-size: 1.05rem;
}

/* One thumb-target each on a phone. Two side-by-side CTAs wrap at this width
   anyway and land ragged; the same call .auth-panel .button already took. */
@media (max-width: 30rem) {
  .hero-ctas {
    flex-direction: column;
    align-items: stretch;
  }

  .hero-ctas .button {
    width: 100%;
  }
}

/* The objection answered before it is asked, and the returning visitor's way
   past it. Both take --text on the body gradient — see .hero-sub — with the
   size carrying the quiet. */
.hero-reassure {
  margin: 0 0 0.6rem;
  font-size: 0.95rem;
  color: var(--text);
}

.hero-signin {
  margin: 0 0 1.6rem;
  font-size: 0.95rem;
  color: var(--text);
}

.theme-nudge {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.6rem 1rem;
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  border: var(--border-width, 1px) dashed var(--field-border);
  /* --text on the bare body gradient, as .hero-sub above — see its comment.
     The dashed border and the smaller size carry the quiet. */
  color: var(--text);
  font-size: 0.88rem;
}

.theme-nudge .icon {
  color: var(--accent);
}

.marketing-section {
  max-width: 1100px;
  margin: 0 auto;
  padding: 3rem 1.5rem;
}

.section-head {
  text-align: center;
  max-width: 60ch;
  margin: 0 auto 2.2rem;
}

.section-head h2 {
  font-size: clamp(1.6rem, 3vw, 2.1rem);
  text-transform: none;
  letter-spacing: normal;
  color: var(--text);
  margin: 0 0 0.6rem;
}

.section-head p {
  /* --text, NOT --text-muted, and measured rather than taste: the landing
     page has no .panel, so this sits on the bare body gradient, where muted
     ink runs 2.22:1 on Meadow and 2.46:1 on Grove. Same finding and same fix
     as .site-attribution and .site-footer (batch 7 day 1) — those two moved
     and these did not, because the harness had only been told about the two.
     The quiet comes from size and weight instead, which is section 10s own
     recorded lesson from .field-hint. SEO_PLAN.md Day 3. */
  color: var(--text);
  margin: 0;
}

.feature-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 1.2rem;
}

.feature-card {
  padding: 1.6rem;
  border-radius: var(--border-radius, 0.9rem);
  corner-shape: var(--corner-shape, round);
  background: var(--card-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-left: var(--border-width, 3px) var(--border-style, solid) var(--mod, var(--field-border));
  box-shadow: var(--shadow-card, none);
}

.feature-card .landing-feature-icon {
  color: var(--mod-ink, var(--accent));
  width: 4rem;
  height: 4rem;
  margin: 0 auto 0.8rem;
}

span.landing-feature-icon .icon {
  width: 2.5rem;
  height: 2.5rem;
}

.feature-card h3 {
  margin: 0 0 0.4rem;
  text-transform: none;
  letter-spacing: normal;
  font-size: 1.05rem;
  color: var(--text);
}

.feature-card p {
  margin: 0;
  color: var(--text-muted);
}

.steps-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 1.4rem;
}

.step-card {
  display: flex;
  flex-direction: column;
  gap: 0.8rem;
}

.step-card .step-image {
  width: 100%;
  height: auto;
  aspect-ratio: 5 / 8;
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  overflow: hidden;
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
}

.step-number {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.8rem;
  height: 1.8rem;
  border-radius: 999px;
  background: var(--accent);
  /* --on-accent, not --bg — same fix as .badge-mod, but this one sits on the
     ACCENT fill rather than a module fill. On Enchanted Grove --bg is a mid
     green (#7f9e7a) and the step number came out at 2.06:1. */
  color: var(--on-accent);
  font-weight: 700;
  font-size: 0.85rem;
  flex-shrink: 0;
}

.step-card h3 {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0;
  text-transform: none;
  letter-spacing: normal;
  font-size: 1rem;
  color: var(--text);
}

.step-card p {
  margin: 0;
  /* --text, NOT --text-muted, and measured rather than taste: the landing
     page has no .panel, so this sits on the bare body gradient, where muted
     ink runs 2.22:1 on Meadow and 2.46:1 on Grove. Same finding and same fix
     as .site-attribution and .site-footer (batch 7 day 1) — those two moved
     and these did not, because the harness had only been told about the two.
     The quiet comes from size and weight instead, which is section 10s own
     recorded lesson from .field-hint. SEO_PLAN.md Day 3. */
  color: var(--text);
  font-size: 0.92rem;
}

.pricing-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 1.2rem;
  max-width: 760px;
  margin: 0 auto;
}

.pricing-card {
  padding: 1.6rem;
  border-radius: var(--border-radius, 0.9rem);
  corner-shape: var(--corner-shape, round);
  background: var(--panel-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--panel-border);
  box-shadow: var(--shadow-card, none);
  text-align: center;
}

.pricing-card h3 {
  margin: 0 0 0.5rem;
  text-transform: none;
  letter-spacing: normal;
  font-size: 1.1rem;
  color: var(--text);
}

.pricing-card .price {
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--mod-ink, var(--accent));
  margin: 0.4rem 0;
}

.pricing-card p {
  color: var(--text-muted);
  margin: 0;
  font-size: 0.92rem;
}

.login-section {
  display: flex;
  justify-content: center;
  padding: 1rem 1.5rem 4rem;
}

.login-card {
  width: min(100%, 420px);
}

/* The landing page's own outline puts this at h2, where the app puts a panel
   title at h1 — so it needs the page-title look back rather than the small
   uppercase section treatment. Same declarations as the h1 rule; this is the
   one place the two scales meet. */
.login-card h2 {
  font-size: 1.5em;
  margin: 0.83em 0;
  color: var(--text);
  text-transform: none;
  letter-spacing: normal;
}

/* Same correction as .site-attribution above, and this one is a real
   long-standing defect rather than a new decision: the landing footer has used
   --text-muted on the bare body gradient since it was written, measuring
   2.22:1 on Meadow and 2.39:1 on Grove. It was never caught because the
   harness only measured --text-muted on the card and the panel, and the
   landing page has neither. Found 2026-08-02 by adding the body-surface pair
   the harness was missing — the same shape of gap as findings 23 and 24. */
.site-footer {
  text-align: center;
  padding: 2rem 1.5rem 3rem;
  color: var(--text);
  font-size: 0.88rem;
}

/* --text, not --accent. Mod-less here, so `var(--mod-ink, var(--accent))`
   resolved to --accent straight on the body gradient: **2.06:1 on Grove, 2.49
   on Meadow** — and it was the ink on "Create a free account", one of only two
   signup paths the page had. The underline is what says "link" now, which is
   what an underline is for. Same token and surface as .site-attribution
   directly above, already green on all eleven (SEO_PLAN.md Day 3). */
.site-footer a {
  color: var(--text);
  text-decoration: underline;
}

/* A row, not a sentence with links buried in it. */
.site-footer-links {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.5rem 1.4rem;
  margin: 0.6rem 0 0;
}

/* Google's failed sign-in comes back as a fresh GET at scroll top, and the
   login card is four screens down — so this sits above the hero, where the
   reader actually lands. Centred to match the page. --error-text had only ever
   been measured on the panel, so this is the first time it lands on the bare
   body gradient; theme-contrast.py gained the pair in the same change. */
.page-error {
  max-width: 1100px;
  margin: 0.5rem auto;
  padding: 0.8rem 1.5rem;
  text-align: center;
  background: var(--panel-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--panel-border);
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
}

/* ---------------------------------------------------------------------------
   Choreography — Reserve meters (Bits + Reserves; see choreography_helpers.php)

   Two interchangeable readings of the same number, chosen per Reserve:
   .reserve-pips for discrete slots you tick off, .reserve-bar for a
   continuous gauge. Both inherit --mod from whatever module panel they sit in,
   so a Reserve on a Cast sheet reads in Cast red and the same markup on
   Center Stage reads in Center Stage's color with no extra classes.

   Both are purely presentational — every meter is rendered next to real
   controls that do the mutating, so nothing here needs to be focusable.
   --------------------------------------------------------------------------- */
.reserve-meter {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  min-width: 0;
}

.reserve-meter-head {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  flex-wrap: wrap;
  font-size: 0.9rem;
}

.reserve-meter-name {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-weight: 600;
  color: color-mix(in oklch, var(--muted) 30%, var(--text));
}

.reserve-meter-count {
  font-variant-numeric: tabular-nums;
  color: var(--mod-ink, var(--accent));
  font-weight: 600;
}

.reserve-meter-rate {
  color: var(--text-muted);
  font-size: 0.8rem;
}

/* Discrete slots. A filled pip is available; a spent one is drawn hollow
   rather than removed, so the capacity stays legible at a glance — the
   question at the table is "how many have I got left out of how many", and
   that only works if the empties are still on screen. */
.reserve-pips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  padding: 0;
  margin: 0;
  list-style: none;
}

.reserve-pip {
  width: 1.05rem;
  height: 1.05rem;
  border: 2px var(--border-style, solid)
    color-mix(in srgb, var(--mod, var(--accent)) 60%, transparent);
  border-radius: var(--border-radius, 0.35rem);
  corner-shape: var(--corner-shape, round);
  background: var(--field-bg);
  flex-shrink: 0;
}

.reserve-pip.is-filled {
  background: var(--mod, var(--accent));
  border-color: var(--mod, var(--accent));
  box-shadow: 0 0 0.4rem color-mix(in srgb, var(--mod, var(--accent)) 45%, transparent);
}

/* Continuous gauge, for Reserves too large to read as pips (20 mana, 100 hit
   points) — see RESERVE_MAX_PIPS. */
.reserve-bar {
  position: relative;
  height: 0.7rem;
  border: var(--border-width, 1px) var(--border-style, solid)
    color-mix(in srgb, var(--mod, var(--accent)) 45%, transparent);
  border-radius: var(--border-radius, 0.5rem);
  corner-shape: var(--corner-shape, round);
  background: var(--field-bg);
  overflow: hidden;
}

.reserve-bar-fill {
  height: 100%;
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--mod, var(--accent)) 70%, transparent),
    var(--mod, var(--accent))
  );
  transition: width 0.18s ease-out;
}

/* An empty Reserve is the one state worth shouting about mid-session. */
.reserve-meter.is-empty .reserve-meter-count {
  color: var(--text-muted);
}

.reserve-meter.is-empty .reserve-bar {
  border-style: dashed;
}

/* A Reserve plus its inline spend/restore controls. The meter takes the slack
   so rows of different-length names still line their buttons up. */
.reserve-row {
  display: flex;
  align-items: flex-end;
  gap: 0.6rem;
  flex-wrap: wrap;
  padding: 0.55rem 0;
  border-bottom: 1px solid var(--border-soft);
}

.reserve-row:last-child {
  border-bottom: none;
}

.reserve-row .reserve-meter {
  flex: 1 1 12rem;
}

.reserve-row form {
  margin: 0;
}

.reserve-controls {
  display: flex;
  align-items: flex-end;
  gap: 0.3rem;
  flex-wrap: wrap;
}

.reserve-controls .button {
  width: auto;
  margin-top: 0;
  padding-inline: 0.6rem;
}

.reserve-controls label {
  margin-bottom: 0;
}

/* `.reserve-controls input[type="number"] { width: 4.5rem }` used to sit here
   and has never once applied: at (0,1,1) it loses to the base control rule's
   `input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"])`,
   which scores (0,3,1) because each :not() counts as a class. Found by batch
   7 day 2's repo-wide grep for the specificity trap and measured on day 4 —
   the character sheet's Set field renders at 64.78px, not the 72px the
   declaration asked for.

   Deleted rather than out-specified. The layout that shipped and was signed
   off on a phone is the one this rule was *not* producing, so making it apply
   would change an approved screen to honour a declaration nobody has ever
   seen take effect. The flex row does the sizing: the field shrinks to fit
   beside its Save, minus and plus, which is why two phone passes never noticed.
   Center Stage's copy of this control is sized by the rule below, which scores
   (0,3,2) and does win. */

/* Compact variant for Center Stage, where a combatant's Reserves sit under
   their HP row and must not out-shout the turn order. */
.reserve-meter.is-compact .reserve-meter-head {
  font-size: 0.82rem;
}

.reserve-meter.is-compact .reserve-pip {
  width: 0.8rem;
  height: 0.8rem;
  border-width: 1px;
}

.reserve-meter.is-compact .reserve-bar {
  height: 0.45rem;
}

.combat-reserves {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  margin: 0.4rem 0 0.2rem;
}

/* The "type the number" form, between the minus and plus buttons. Narrow
   enough that one-tap spending stays the obvious default and the row still
   fits a phone; wide enough for a three-digit pool. */
.reserve-controls form[data-reserve-set] {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  margin: 0;
}

.reserve-controls form[data-reserve-set] input[type="number"] {
  width: 3.6rem;
  margin: 0;
}

/* Out of initiative: on the stage, not taking turns. Dimmed rather than hidden,
   because §12's whole ask is "they need to exist in case someone resurrects
   them" — a GM has to be able to see the downed character to bring them back.
   Opacity only, so every control in the row stays usable at full contrast on a
   hover/focus; the "out" badge carries the meaning for anyone who can't see the
   dimming at all. */
.combat-set-aside {
  opacity: 0.55;
}

.combat-set-aside:hover,
.combat-set-aside:focus-within {
  opacity: 1;
}

/* The GM's round controls, lifted to the top of the encounter so ending a
   round never means scrolling the whole board (§12, "the beat button"). */
.combat-turn-controls {
  margin-bottom: 1rem;
  padding-bottom: 0.6rem;
  border-bottom: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
}

.combat-turn-controls > form {
  margin: 0 0 0.5rem;
}

.combat-turn-controls .choreo-refresh-actions {
  align-items: center;
  margin-bottom: 0;
}

.combat-break-label {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

/* The pinned "you" card: a player's own combatant lifted above the turn order,
   so their Reserves and Bits are readable without scrolling initiative to find
   their own name (§12, "players need actions"). Chips reuse .overview-bit from
   the character sheet's overview strip rather than introducing a Center Stage
   variant — same information, same look, one set of theme tokens to keep at
   4.5:1 across all eleven themes. */
.combat-you {
  margin: 0 0 1rem;
  padding: 0.8rem 0.9rem;
  background: var(--card-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-left: 3px solid var(--mod, var(--accent));
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  box-shadow: var(--shadow-card, none);
}

/* The same gold the turn order marks the active row with, so "I'm up" reads
   identically in the card and in the list. */
.combat-you.is-current-turn {
  border-left-color: var(--combat-active);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--combat-active) 40%, transparent);
}

.combat-you-head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-bottom: 0.35rem;
}

.combat-you-label {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-family: var(--font-heading);
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: color-mix(in oklch, var(--muted) 40%, var(--text));
}

.combat-you.is-current-turn .combat-you-label {
  color: var(--combat-active);
}

.combat-you .overview-bits {
  margin-top: 0.5rem;
}

/* ---------------------------------------------------------------------------
   Character sheet overview strip

   The at-a-glance block above the fields. Everything in it is read-only and
   repeated further down the page, so it's styled to be scanned from arm's
   length and then ignored — dense, quiet, no controls competing for a thumb.
   --------------------------------------------------------------------------- */
.overview-strip {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
  margin: 0 0 1.2rem;
  padding: 0.9rem;
  background: var(--card-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
  box-shadow: var(--shadow-card, none);
}

.overview-stats {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  /* Stat tiles wrap on their own rather than at fixed breakpoints, so four
     abilities fit a phone in two rows and a laptop in one. */
  grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
  gap: 0.5rem;
}

.overview-stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.1rem;
  padding: 0.4rem 0.3rem;
  background: var(--field-bg);
  border-radius: var(--border-radius, 0.5rem);
  corner-shape: var(--corner-shape, round);
  text-align: center;
}

.overview-stat-name {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  font-family: var(--font-heading);
  font-size: 0.7rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: color-mix(in oklch, var(--muted) 40%, var(--text));
}

.overview-stat-value {
  font-size: 1.3rem;
  font-weight: 700;
  line-height: 1.1;
  overflow-wrap: anywhere;
}

.overview-reserves,
.overview-bits {
  list-style: none;
  margin: 0;
  padding: 0;
}

.overview-reserves {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
  gap: 0.5rem 0.9rem;
}

.overview-bits {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}

.overview-bit {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  padding: 0.25rem 0.55rem;
  font-size: 0.85rem;
  background: var(--field-bg);
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-radius: 999px;
  corner-shape: var(--corner-shape, round);
}

/* The last chip of a Bit category, when another category follows (batch 10 day
   4). The grouping already shipped; this is the mark between the groups that
   did not, so four spells stop reading as four of the eight things beside them.

   0.75rem against the row's own 0.35rem gap: the boundary is a little over
   three times an ordinary gap, which is what reads as "these belong together"
   without a heading. It costs no vertical space at all, which is the whole
   constraint — a heading per category inside a combatant card would cost more
   room than the categorising saves on a phone, and that rationale is still
   binding.

   Marked on the group's last chip, not the next group's first: the row wraps,
   and at a line break a trailing margin falls off the end of the line and
   vanishes where a leading one would indent the new line by 0.75rem.

   **Both axes, because on a phone the horizontal one is usually not there.**
   Measured on the 390px fixture: nine chips wrap to six lines — Bit names carry
   a cost badge, so roughly one chip fits per line — and half the group
   boundaries landed at a wrap, where margin-right is off the end of the line and
   nothing marks the group at all. The bottom margin is what those boundaries
   get. It enlarges the line its chip sits on, so when a boundary *is* mid-line
   both appear; that reads as a slightly bigger break, which is not wrong.

   **1.2rem, and the first build's 0.4rem is why the device check exists.**
   0.4rem is ~6.4px, which cleared the "measurably more than an ordinary gap"
   bar it was chosen against — 11.98px across a wrap versus 5.59px — and read as
   a wobble rather than a boundary on every phone Gail tried it on
   (2026-08-06). A ratio can be right and the absolute value still be too small
   to see; the headless capture could answer the first and never the second.

   The vertical is deliberately larger than the horizontal 0.75rem rather than
   matched to it. Lines of chips sit closer together than chips on a line, so
   the same number of px reads as less of a break stacked than it does
   side-by-side. */
.overview-bit.is-group-end {
  margin-right: 0.75rem;
  margin-bottom: 1.2rem;
}

/* A Bit you can't currently pay for. Dimmed rather than hidden — knowing it's
   out of reach is the point. */
.overview-bit.is-unaffordable {
  opacity: 0.5;
}

/* Makeup FX chips (batch 11 day 3). They carry `.overview-bit` as well as
   `.overview-effect`, so every rule above applies unchanged and **no new colour
   pair enters the app** — the fill stays `--field-bg` and the ink stays
   inherited, which is the pair theme-contrast.py has already measured on this
   exact chip shape on all eleven themes. A dedicated token here would have been
   a new surface needing eleven values and an entry in the harness in the same
   change (CLAUDE.md), to arrive at a chip that looks like the one next to it.

   What is left is the one thing an effect has and a Bit does not: a combatant
   whose turn is being skipped. That is a border weight, not a colour, for the
   same reason — weight reads on every theme and needs measuring on none. */
.overview-effect.is-skipping {
  border-width: calc(var(--border-width, 1px) * 2);
  border-style: dashed;
}

/* **An effect chip contains a form and is not one**, unlike the Bit chip above.
   `.overview-bit:has(> form)` strips the padding because a performing Bit *is*
   its button edge to edge; here the form is a single × sitting beside a plain
   text label, so the chip keeps its own padding and the × is what submits.

   Two classes rather than one, and deliberately: `.overview-bit:has(> form)`
   scores (0,1,1) — `:has()` contributes its argument's specificity, and `form`
   is an element — so a bare `.overview-effect:has(> form)` would tie and win
   only by document order. Naming both classes scores (0,2,1) and wins outright,
   which is the whole point of CLAUDE.md's specificity rule: make the reason
   visible in the selector rather than in the line number. */
.overview-bit.overview-effect:has(> form) {
  padding: 0.25rem 0.35rem 0.25rem 0.55rem;
}

.overview-effect-label {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

/* The Makeup FX duration control (batch 11, reshaped 2026-08-07).

   **It has to survive a combatant card, which is the narrowest surface in the
   app.** It shipped as `.field-row`s — a radio label beside its control, two
   `flex: 1` children — which reads fine in a panel and overlaps in ~250px: flex
   children default to `min-width: auto`, so neither will shrink below its
   content and they lay on top of each other instead of wrapping. §12,
   2026-08-07 caught it on *every* device, which is the tell that it was never a
   breakpoint problem.

   Stacked now, so there is no second column to collide with, and everything
   inside is explicitly allowed to shrink. */
.effect-duration {
  min-width: 0;
  margin: 0 0 0.9rem;
  padding: 0.6rem 0.7rem;
  border: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
}

.effect-duration-option {
  min-width: 0;
  margin-bottom: 0.5rem;
}

.effect-duration-option:last-of-type {
  margin-bottom: 0;
}

.effect-duration-option .checkbox-field {
  margin: 0;
}

/* Indented under its radio, so the control reads as belonging to the mode above
   it rather than to the fieldset. `min-width: 0` is the load-bearing half: the
   number field is wrapped by `.number-stepper`, which is a flex row, and without
   this its buttons push the input past the card's edge instead of the input
   giving way. */
.effect-duration-control {
  display: block;
  min-width: 0;
  max-width: 100%;
  margin: 0.35rem 0 0 1.6rem;
}

.effect-duration-control .number-stepper {
  min-width: 0;
}

/* `currentColor`, not a token: the chip has already established what ink reads
   on its fill, on all eleven themes (CLAUDE.md). A × that picked its own colour
   would be a new surface needing its own measurement to say nothing new. */
.overview-effect-clear {
  min-width: 0;
  margin: 0;
  padding: 0 0.35rem;
  border: 0;
  background: none;
  color: currentColor;
  font: inherit;
  line-height: 1;
  opacity: 0.65;
  cursor: pointer;
}

.overview-effect-clear:hover,
.overview-effect-clear:focus-visible {
  opacity: 1;
}

/* A chip that performs. The chip keeps its own padding and border, so the
   button inside it is stripped back to nothing and the two versions read as
   the same object — which they have to, since one screen can show both. */
.overview-bit:has(> form) {
  padding: 0;
}

.overview-bit > form {
  margin: 0;
}

.overview-bit-perform {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  width: auto;
  margin: 0;
  padding: 0.25rem 0.55rem;
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  border-radius: 999px;
  corner-shape: var(--corner-shape, round);
  cursor: pointer;
}

/* A chip is a small target on a phone, so the pressed state has to be obvious
   without being a colour change the themes would each need to answer for. */
.overview-bit-perform:hover:not(:disabled),
.overview-bit-perform:focus-visible {
  background: var(--card-bg);
}

.overview-bit-perform:disabled {
  cursor: default;
}

.overview-bit-cost {
  font-size: 0.75rem;
  color: color-mix(in oklch, var(--muted) 40%, var(--text));
}

/* ---------------------------------------------------------------------------
   Choreography — Bits
   --------------------------------------------------------------------------- */
.bit-list {
  list-style: none;
  margin: 0 0 1rem;
  padding: 0;
}

.bit-list > li {
  padding: 0.7rem 0;
  border-bottom: 1px solid var(--border-soft);
}

.bit-list > li:last-child {
  border-bottom: none;
}

.bit-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.bit-name {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-weight: 600;
}

/* Cue (what triggers it / what it costs) and Reach (how far it carries) read
   as labelled facts rather than prose — they're what a player scans for
   mid-turn, so they get their own line above the description. */
.bit-facts {
  display: flex;
  gap: 0.4rem 1rem;
  flex-wrap: wrap;
  margin: 0.25rem 0 0;
  font-size: 0.85rem;
  color: var(--text-muted);
}

.bit-fact {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

.bit-fact strong {
  color: color-mix(in oklch, var(--muted) 40%, var(--text));
  font-weight: 600;
}

.bit-description {
  margin: 0.4rem 0 0;
  font-size: 0.92rem;
}

/* The cost badge doubles as the affordability signal: muted once the Reserve
   can no longer cover it, matching the disabled Perform button beside it. */
.bit-cost {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  padding: 0.1rem 0.5rem;
  border: var(--border-width, 1px) var(--border-style, solid)
    color-mix(in srgb, var(--mod, var(--accent)) 50%, transparent);
  border-radius: 999px;
  corner-shape: var(--corner-shape, round);
  font-size: 0.78rem;
  color: var(--mod-ink, var(--accent));
  white-space: nowrap;
}

.bit-cost.is-unaffordable {
  border-color: var(--border-soft);
  color: var(--text-muted);
}

.bit-actions {
  display: flex;
  gap: 0.4rem;
  flex-wrap: wrap;
  margin-top: 0.5rem;
}

.bit-actions form {
  margin: 0;
}

.bit-actions .button {
  width: auto;
  margin-top: 0;
}

.bit-actions .button[disabled] {
  opacity: 0.45;
  cursor: not-allowed;
}

/* Collapsed add/edit forms, so a sheet with a dozen Bits isn't a dozen open
   forms. Matches the <details> pattern Center Stage already uses. */
.choreo-form-toggle > summary {
  cursor: pointer;
  font-size: 0.9rem;
  color: var(--mod-ink, var(--accent));
  margin-top: 0.4rem;
}

.choreo-form-toggle[open] > summary {
  margin-bottom: 0.6rem;
}

.choreo-refresh-actions {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin: 0.2rem 0 0.9rem;
}

.choreo-refresh-actions form {
  margin: 0;
}

.choreo-refresh-actions .button {
  width: auto;
  margin-top: 0;
}

/* Inside the overview strip and the campaign dashboard's copy of it (batch 8
   day 5). The strip is a flex column with its own 0.7rem `gap`, so the block
   margin the section version carries would stack on top of that and open a
   hole between the meters and the chips — the same "nothing supplied the
   spacing / two things supplied it" trap campaign.php's own strip comment
   records from §12, 2026-08-03, just in the other direction. */
.choreo-refresh-compact {
  margin: 0;
}

/* Smaller than a section button, because these sit among compact meters rather
   than under a heading, and three full-size buttons would out-shout the thing
   they are beside. min-width: 0 is load-bearing: `button, .button` sets
   min-width: 8rem, and three of those wrap to three rows in the sheet's rail. */
.choreo-refresh-compact .button {
  min-width: 0;
  padding: 0.35rem 0.6rem;
  font-size: 0.8rem;
}

/* --- Help articles (public_html/help/) -------------------------------------

   Every answer is written twice: a short one anyone can read standing up, and
   a longer one folded behind a <details>. The fold is the whole design — a kid
   should be able to get the answer and leave, and someone who wants to know
   *why* should not have to go and ask.

   A <details> rather than a scripted accordion, for the third time in this
   codebase (the theme picker, a field's tags, and now this): the browser owns
   the disclosure including keyboard and screen-reader behaviour, and it works
   with scripting off, which is the whole of these pages.

   No new colour tokens anywhere below. The trigger, the caret and the rule
   down the open body are all currentColor — the prose's own ink on the panel
   it is already sitting on — so there is nothing here for
   docs/playtest/theme-contrast.py to measure that it is not already measuring.
   Note the rule is decoration, not text: it is allowed to be faint. */
.help-answer {
  font-size: 1.05rem;
  margin-left: 1rem;
}

.help-more {
  margin: 0.2rem 0 1.6rem 2rem;
}

.help-more > summary {
  list-style: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.9rem;
  opacity: 0.75;
}

.help-more > summary:hover,
.help-more[open] > summary {
  opacity: 1;
}

.help-more > summary::-webkit-details-marker {
  display: none;
}

/* Drawn rather than borrowed from the UA marker so it can sit after the label
   and rotate, exactly as .field-group-summary's does. */
.help-more > summary::after {
  content: "";
  width: 0.4rem;
  height: 0.4rem;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg) translate(-0.1rem, -0.1rem);
  transition: transform 0.15s ease;
}

.help-more[open] > summary::after {
  transform: rotate(-135deg) translate(-0.15rem, -0.15rem);
}

.help-more[open] {
  border-left: 2px solid color-mix(in srgb, currentColor 22%, transparent);
  padding-left: 1rem;
}

/* The trigger hangs back out past the rule, so the rule reads as belonging to
   the body it opened rather than to the label that opened it. */
.help-more[open] > summary {
  margin: 0 0 0.6rem -1rem;
}

.help-more > :last-child {
  margin-bottom: 0;
}
