/* 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;
}

/* The Bit form's prose field takes the whole row — "the Bits have a
   description text box that is unnecessarily small (should take both columns
   instead of only the left column) on desktop" (§12 pair 1, U12). Scoped to
   the Bit form's grid rather than every .form-grid on purpose: the entity
   Details/Sharing grids also hold textareas and their two-column layout is
   the one signed off on device — widening a selector here is a layout change
   on screens nobody complained about. */
.bit-form-grid > label:has(textarea) {
  grid-column: 1 / -1;
}

.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 {
    /* **Three equal columns, and every panel spans 1, 2 or 3 of them**
       (Gail's layout, 2026-08-18). Two columns could only say wide-or-narrow;
       this says how wide, which is what let the pairing below be chosen rather
       than fallen into. */
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  /* **Named, never positional, and the positional version shipped broken.**
     This was `nth-child(1), nth-child(6)`, which is a rule about where a panel
     sits in the list rather than about what it is — so deleting the Your Tags
     panel on 2026-08-17 shifted every panel below it up one and silently
     re-pointed both selectors. Starting Character Fields, which had been in the
     wide column, landed in the 320px one; Starting Reserves quietly lost its
     full width to the Bit Catalog. Nothing looked broken in the CSS and nothing
     failed, which is exactly why it took a GM opening the panel to find it.

     Keyed on data-section, so these travel with the panels they are about and
     an added or removed panel cannot move them. verify_backstage_sections.php
     holds every key named here against backstageSectionKeys().

     The four are the ones that hold a *form* rather than a list of controls —
     Starting Character Fields most of all, which is a save-all over every
     template in the campaign and the reason this was noticed. */
  /* Full width: the three that are a *form* over a whole campaign rather than a
     list of controls. Starting Character Fields most of all — in edit mode it
     is a save-all over every template, and 320px is not a form, which is what a
     GM with 91 of them reported. */
  .backstage-grid > [data-section='starting-fields'],
  .backstage-grid > [data-section='entity-fields'],
  .backstage-grid > [data-section='danger-zone'] {
    grid-column: 1 / -1;
  }

  /* Two thirds, each paired with a one-third panel beside it. **The pairing is
     produced by source order, not declared here** — auto-placement fills each
     row because every row's spans sum to exactly 3, which is why
     verify_backstage_sections.php asserts that sum rather than the pixels. Get
     the order or a span wrong and the rows go ragged with nothing failing. */
  .backstage-grid > [data-section='players'],
  .backstage-grid > [data-section='starting-reserves'],
  .backstage-grid > [data-section='center-stage-setup'] {
    grid-column: span 2;
  }
}

.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);
  }
}

/* packs.php's "Build your own" rail, and admin-packs.php's upload rail
   (2026-08-12): plain .entity-layout for the two-column split on both pages
   (was .backstage-grid, which is Backstage's own shape — its
   nth-child(1)/(6) full-span rule doesn't mean anything here). Both
   sidebars come SECOND in source order already, unlike the character
   sheet's rail, so no grid-column/row repositioning is needed — this only
   adds the same sticky-on-desktop treatment .sheet-rail already
   established, at the same 981px breakpoint .entity-layout itself collapses
   at. */
@media (min-width: 981px) {
  .packs-byo,
  .admin-packs-sidebar {
    position: sticky;
    top: 1rem;
    max-height: calc(100vh - 2rem);
    overflow-y: auto;
  }
}

.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));

  /* The brand mark in the opposite corner (2026-08-24). Same derived shape as
     --chrome-* above and for the same reason: the mark's size, the h1's left
     padding and the back link's all come out of one number.

     **The mark, not the wordmark.** The logo is 1000x299 and a full one here
     would be 5rem wide before the offset; the corner cluster already takes
     8.7rem off every title on a phone, and the two together left about 7rem of
     a 360px screen for a campaign name. Cropping to the dodecahedron — the left
     262 of 1000, which is where the art's own gutter falls — costs 3.35rem
     instead. See docs/decisions/2026-08-24-logo-ships-unmeasured-on-light-themes.md
     for the asset, and site-brandmark.php for why the crop is CSS.

     **1.6rem and 0.4rem are measured, not chosen.** At 2rem/0.6rem the mark
     came to 53.6px and dashboard.php's "The Table" wrapped to two lines at
     360px — the h1 is that panel's first row, so it pays the indent in full,
     and it was the only heading in the app that did. Headless Chrome,
     getComputedStyle plus a same-document A/B that zeroes the padding and
     re-measures the box:

       2.0rem mark -> h1 66px with the indent, 33px without   (costs a line)
       1.6rem mark -> h1 33px with the indent, 33px without   (costs nothing)

     Campaign and character titles were two lines either way at 360px — they are
     long names on a narrow screen — so the dashboard was the whole finding.
     Raising these numbers puts that wrap back, on the one screen with a short
     enough title to notice. */
  --brand-offset: var(--chrome-offset);
  --brand-mark-height: 1.6rem;
  --brand-gap: 0.4rem;
  --brand-width: calc(
    var(--brand-offset) + var(--brand-mark-height) * 262 / 299 + var(--brand-gap)
  );
}

/* Fixed, so it costs no vertical space — the batch 8 nav refactor existed to
   get a row of chrome back off every campaign screen, and a brand bar in the
   flow would have spent it again. z-index matches .page-chrome rather than
   beating it: on the tie the later element in the DOM paints on top, and
   site-brandmark.php is included first precisely so an open nav drawer covers
   this instead of the other way round. */
.site-brandmark {
  position: fixed;
  top: var(--brand-offset);
  left: var(--brand-offset);
  z-index: 50;
  display: block;
  width: calc(var(--brand-mark-height) * 262 / 299);
  height: var(--brand-mark-height);
  overflow: hidden;
}

/* Full-width image in a mark-width box: the overflow above does the cropping,
   so the image must be free to be wider than its container.

   `max-width: none` is belt-and-braces rather than a fix for anything here
   today — this stylesheet has no global `img` rule, checked. It is written down
   because the usual first move when images misbehave is to add one
   (`img { max-width: 100% }`), and the day someone does, this box would squash
   the whole logo into the mark's width instead of cropping to it — which looks
   almost right, and is the kind of wrong nobody files. */
.site-brandmark img {
  display: block;
  height: 100%;
  width: auto;
  max-width: none;
}

/* Both clearances are **always on** wherever the mark renders, never keyed on
   :first-child. That is the rule the note above this block already argues for
   on the right-hand side: a :first-child rule stops applying the moment
   anything is inserted above the element, and it fails by silently restoring
   the overlap it was added to fix.

   Scoped with :has() rather than applied everywhere, because index.php and
   help.php render no mark — they carry the full logo in .site-header — and
   3.35rem of indent under nothing is the same bug in the other direction.

   The back link takes padding rather than margin deliberately: the strip it
   gains sits *under* the fixed mark, which paints above the panel and swallows
   the taps that land on it. Margin would leave the same gap unclickable and
   cost an extra derivation against the panel's own padding, which changes at
   480px. */
body:has(> .site-brandmark) .panel h1 {
  padding-left: var(--brand-width);
}

body:has(> .site-brandmark) .panel > .back-link {
  padding-left: var(--brand-width);
}

.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);
}

/* Account, at the foot of the drawer — the way to a Log out that used to live
   only on dashboard.php.

   Deliberately NOT a .nav-pill. The rule directly above is
   `background: var(--mod, var(--text))` against `color: var(--button-text)`,
   and the fallback half of that pair is the 1.32:1 defect verify_button_ink.php
   was written for. It never fires in this nav because every pill carries a
   .mod-* class; Account is not a module and has no colour to carry, so it would
   be the one element that takes the fallback branch. Plain --text on the
   drawer's --panel-bg instead, which theme-contrast.py already measures as a
   panel pair — no new surface, no new harness entry.

   The rule above it is a separator, not decoration: it says the pills stopped
   and this is a different kind of destination. */
.module-nav .nav-aside {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  margin-top: 0.2rem;
  padding: 0.5rem 0.9rem 0.2rem;
  border-top: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  color: var(--text);
  text-decoration: none;
  font-weight: 600;
  font-size: 0.88rem;
}

.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;
}

/* .field-control-checkbox is the sheet's checkbox field (U10, batch 17): it
   left .checkbox-field so the base label rule could stack name-over-box like
   every other field, but the box itself keeps this themed treatment — the
   base input rule's :not([type="checkbox"]) means nothing else styles it, and
   a browser-default checkbox is off-theme on all eleven. */
.checkbox-field input[type="checkbox"],
.shared-tag-item input[type="checkbox"],
.field-control-checkbox 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;
}

/* The stacked layout would stretch the box to the label's cross axis;
   flex-start keeps it under the name's first character, where the other
   controls' left edges are. */
.field-control-checkbox input[type="checkbox"] {
  align-self: flex-start;
}

.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;
}

/* The Custom word and its colour well read as ONE control (batch 16, U9 —
   §12 pair 1: "Shouldn't the color be part of the custom button?"). Fused
   visually, never nested: the pill loses its right edge and the well its
   left, so the pair presents a single button whose right half is the value.
   The well rule mirrors the base rule's :not()s AND carries the group class,
   scoring (0,5,1) over the standalone well rule's (0,4,1) above — same trap,
   same answer.

   Placed BELOW that rule on purpose: verify_number_inputs.php reads the
   file's *first* `input:not([type="hidden"]...` block as the base field
   rule's stand-in when counting declarations, and a two-declaration rule
   ahead of it reads as "the base rule changed shape". Order costs nothing
   here — specificity decides, and these two never tie. */
.colour-picker-custom-group {
  display: flex;
  align-items: center;
}

.colour-picker-custom-group .colour-picker-custom {
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
  border-right: none;
}

.colour-picker-custom-group
  .colour-picker-custom-value
  input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]) {
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
}

/* The hex field combatant-colour.js builds beside the well (batch 17 day 7).
   Styled here but CREATED there, per the convention: it carries no name and
   posts nothing — the well stays the server's one custom value — so with
   scripting off it simply isn't on the page. The :not()s mirror the base
   field rule, (0,4,1) over (0,3,1), same trap as the well's rule above.
   font-size is left alone on purpose: the base rule's max(1rem, 16px) is the
   iOS zoom floor, and seven monospace-ish characters fit at 16px in 6.5rem. */
.colour-picker-hex {
  margin: 0 0 0 0.3rem;
}

.colour-picker-hex input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]) {
  width: 6.5rem;
  margin: 0;
  padding: 0.35rem 0.5rem;
  text-align: center;
}

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;
}

/* :not([multiple]) on every rule in this block, and it is load-bearing, not a
   tidy-up target: this chrome — the drawn dropdown arrows, `display: flex` on
   the closed box, `appearance: base-select` — describes a select that opens a
   picker. A `<select multiple>` has no picker: its options render in-page,
   so `display: flex; flex-direction: row` laid the OPTIONS out in one
   overflowing row. That shipped, on both the GM's sheet and the player's, and
   the first discovery-track pair found it (§12, pair 1 — batch 16 R2). A
   multiple select takes the shared field rules above and its native listbox,
   nothing from here. */
select:not([multiple]) {
  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:not([multiple]),
::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;
}

/* `font-size` is load-bearing and was missing for the life of the app. This
   rule styles two different elements — a real <button> and an <a class="button">
   that navigates — and only ONE of them inherits the page font: a <button> gets
   the UA's own `font: 400 13.333px`, an <a> gets the document's 16px. The rule
   set font-FAMILY (so both lettered correctly) and never font-size, so the pair
   silently disagreed by 20% everywhere they sat together. Measured on the real
   login page: `<button class="button">Log in</button>` 43px tall at 13.333px,
   directly above `<a class="button secondary">Continue with Google</a>` at 47px
   and 16px. Same mismatch behind "the edit button is bigger than other buttons
   in the same space" (Gail, 2026-08-14).

   0.85rem, the size this app already uses for button-ish chrome in a dozen
   places (the theme picker's own buttons measure exactly 13.6px), so the fix
   moves real buttons by 0.27px — invisible — and brings the anchors DOWN to
   what every button beside them already looked like. Matching the other way
   would have restyled every page in the app to fix a bug in the anchors.
   verify_button_ink.php's neighbour, tests/verify_button_sizing.php, asserts
   the declaration is here so a tidy-up cannot drop it back to two sizes. */
button,
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  width: auto;
  min-width: 8rem;
  font-size: 0.85rem;
  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;
}

/* An icon-only action (Duplicate, Archive — a button whose accessible name is
   its aria-label because nothing is drawn but the icon) shrinks to its icon
   instead of the base rule's 8rem minimum, which on a detail page renders as
   a wide empty button around a small glyph. Keyed on [aria-label] because
   that attribute is exactly what marks a button as icon-only here — a
   labelled button never carries one. (0,2,1) over the base button rule's
   (0,0,1); .entity-list button already does this for cards. */
.entity-actions button[aria-label],
.entity-actions .button[aria-label] {
  min-width: 0;
}

/* The GM's answer to a proposal (spec 2026-08-13, reshaped 2026-08-14).
   Deliberately NOT .entity-actions: that is a wrapping flex row of peer
   actions, and these two are not peers. Accept is one press and sits on top;
   the note belongs to Decline alone, and in the flex row it rendered BETWEEN
   the two buttons, reading as if it applied to both — worst on a phone, where
   the row wrapped and left Accept beside the note field.

   `.proposal-answer form .button` is (0,2,1) on purpose, not the shorter
   `.proposal-answer .button` (0,2,0): the block renders inside .entity-list,
   whose own `.entity-list .button` rule is also (0,2,0) and sets width:auto.
   At equal specificity the later rule in the file would win, so the shorter
   selector would leave this width silently unapplied — or worse, applied
   today and un-applied by a future reorder. The element in the middle is free
   and makes it order-independent; every button in here is inside a form. */
.proposal-answer {
  display: grid;
  gap: 0.6rem;
  margin: 0.6rem 0;
}

.proposal-answer form {
  margin: 0;
}

.proposal-answer form .button {
  width: 100%;
}

/* Favorites & Archive (spec 2026-08-12; restyled 2026-08-13). The star sits
   on the name line, not in the actions row — it marks the item rather than
   operating on it — so it sheds the .button chrome and reads as a toggle
   beside the name. The name row is a flex line: name (and Shared badge where
   the card has one) on the left, star pushed to the right edge. */
.entity-name-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
}

/* min-width: 0 on the CONTAINER does nothing for its flex items — they keep
   the default min-width: auto, so one unbroken long name would shove the star
   and Shared badge past the card edge on a phone instead of wrapping. Same
   container-vs-item split as batch 16's .shared-tag-item. The name is the
   only child that can grow; the badge and star size to content. */
.entity-name-row > a,
.entity-name-row > strong {
  min-width: 0;
  overflow-wrap: anywhere;
}

.entity-name-row .favorite-star-form {
  margin-left: auto;
}

/* Written as `.favorite-star-form .favorite-star` (0,2,0) on purpose: the
   base card-button rule `.entity-list button` is (0,1,1), so a bare
   `.favorite-star` (0,1,0) would LOSE to it and every declaration below would
   sit unapplied — the number-stepper specificity trap. Do not "simplify" the
   selector; verify_entity_favorites.php asserts the comparison. The padding
   is the phone tap target the .button chrome used to provide (icon is 1.1em;
   0.65rem a side lands the hit box near 40px). */
.favorite-star-form .favorite-star {
  background: none;
  border: none;
  min-width: 0;
  width: auto;
  margin: 0;
  padding: 0.65rem;
  color: inherit;
  align-self: auto;
}

/* Outline until favorited, filled once on; the fill is currentColor so the
   control keeps whatever ink its surface already established — no new theme
   pair, nothing new for theme-contrast.py's inventory. aria-pressed carries
   the same state for readers who can't see the fill. */
.favorite-star svg {
  fill: none;
}

.favorite-star.is-favorited svg {
  fill: currentColor;
}

/* The archived count row — a LINK that reloads with ?archived=1, not a
   <details>: the core page renders one COUNT however large the archive grows,
   and no native marker is hidden, so this stays out of the
   disclosure-affordance census. */
.archived-link-row {
  margin-top: 1.2rem;
}

/* 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;
}

/* The frozen-card badge (batch 20; batch 3 finding 3): the Live badge's exact
   shape in the opposite colour, so the two board-state badges are siblings in
   form and opposites in meaning — gold outlined word = active, red outlined
   word = waiting on you. "Frozen must never read as active" is satisfied
   structurally, not just tonally.

   Ink is --error-text, not a minted warning token: the semantic hue space is
   fully allocated (--combat-active is banned here by decision, --danger is a
   fill), and a new orange would sit BETWEEN gold and red — harder to tell
   from either at badge size, dichromats included. The word carries the
   meaning; colour is redundancy. Outlined means no badge-fill pair, and the
   card background is one surface across both fold states and the you-card,
   so theme-contrast.py carries exactly one new pair for this.

   Hidden at rest and revealed by combat-live.js's data-live-frozen marker on
   the card — the [hidden] idiom: the working half ships in markup, the state
   only a script can create never shows without one. Deliberately no
   animation: the state can persist for minutes (the forgotten-edit case IS
   the feature), and minutes of pulsing on a twenty-card board is an
   attention tax the density redesign just paid off.

   THE COMPOUND SELECTOR IS THE HIDE. The rule this one has to beat is not
   its own reveal — it is `.badge { display: inline-flex }`, which sits LATER
   in this file at the same (0,1,0) a bare .combat-frozen-badge would score,
   so a bare selector here loses the cascade and the badge renders on every
   card, at rest, permanently (batch 20 review finding 1 — measured, and the
   first write-up analyzed the wrong pair). `.badge.combat-frozen-badge` is
   (0,2,0); the reveal below ties it and wins by order. A tidy-up that drops
   the "redundant" .badge un-hides the badge everywhere while every rule
   still reads correctly alone — verify_frozen_badge.php asserts the
   compound and the rule order. */
.badge.combat-frozen-badge {
  display: none;
  background: transparent;
  border: var(--border-width, 1px) var(--border-style, solid)
    color-mix(in srgb, var(--error-text) 55%, transparent);
  color: var(--error-text);
}

[data-live-frozen] .combat-frozen-badge {
  display: inline-flex;
}

/* An Out card dims to 0.55, which would take the badge below any measured
   bar exactly in the case that matters — the forgotten, unfocused edit
   (:focus-within already restores full opacity, but forgotten means not
   focused). A card holding your unsaved value gets the same treatment it
   gets under your pointer. */
.combat-set-aside[data-live-frozen] {
  opacity: 1;
}

/* ...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 merged once-per-fight tail (2026-08-13). Its children keep the inset
   details boxes they always had; this just restores the card's 0.5rem rhythm
   one level down, since `.combat-list .combatant > * + *` reaches direct
   children only. */
.combatant-tools > * + * {
  margin-top: 0.5rem;
}

/* The card-body collapse (2026-08-13 density). A structural fold, not an
   inset panel, so it opts out of the details box chrome the same way
   .reserve-set does. Its summary is a slim line on purpose — the header
   above it is NOT the summary, because the reorder arrows and Out live
   there (batch 3 item 4's tap-a-summary coin flip). */
.combat-list .combatant-body {
  border: none;
  background: none;
  box-shadow: none;
  padding: 0;
}

/* `details.` prefix on all three fold-summary rules, not tidiness: they
   override `.combat-list summary`'s (0,1,1) weight/size/color, and a bare
   `.combatant-body > summary` TIES it at (0,1,1) — winning by source order
   alone, the exact defect this same day's work fixed for .is-skipping. The
   element type takes them to (0,1,2); verify_center_stage_density.php
   asserts the comparisons. The padding is the phone tap target (2026-08-13
   pre-push review): opening a fold is now a primary per-turn interaction,
   and a bare text line is a ~20px target on the device this app lives on. */
details.combatant-body > summary {
  font-size: 0.8rem;
  color: var(--text-muted);
  cursor: pointer;
  padding: 0.45rem 0.2rem;
}

.combatant-body > * + * {
  margin-top: 0.5rem;
}

/* An attribute at rest is one glance line that opens into its editor
   (2026-08-13 density; the #1 Backstage-ordered attribute never folds).
   Structural like .combatant-body, so no box chrome. The summary previews
   the value, so the [open] rule hides the summary's copy while the editor
   below shows the authoritative one — the .field-tags duplicate-render
   rule, paid on arrival. Native marker kept: no census entry. */
.combat-list .attr-line {
  border: none;
  background: none;
  box-shadow: none;
  padding: 0;
}

details.attr-line > summary {
  font-size: 0.9rem;
  font-weight: 400;
  color: var(--text);
  padding: 0.45rem 0.2rem;
}

.attr-line[open] > summary .attr-line-value {
  display: none;
}

/* The reserve row's folded set-editor opts out of the details box chrome —
   at rest it is one small word on the meter line, not a panel. (0,2,0) over
   `.combat-list details`' (0,1,1). */
.combat-list .reserve-set {
  border: none;
  background: none;
  box-shadow: none;
  padding: 0;
}

/* 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 became a plain container, and the 2026-08-13 density work gave
   its BODY a fold instead (.combatant-body above — header out, open by
   default). The box-drawing rule above now covers five disclosure kinds in
   here: the inset sheet/stats/colour blocks keep the panel look, while the
   structural folds (.combatant-body, .attr-line, .reserve-set) opt out of it.

   The <details>-specific caveat that used to live here — don't set `display`,
   it kills the ::marker — applies again one level down: none of the fold
   rules set `display`, so every native marker survives. The header stays a
   flex row and is never a summary. */
.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;
}

/* One column while dragging, whatever the screen (batch 17 day 7). The drag
   is one-dimensional by design — translateY, and a rebase by the neighbour's
   height on each swap — and .entity-list's auto-fill grid put the collapsed
   cards side by side on a wide screen, where a vertical drag animated a
   *horizontal* reflow: "the card to the left of it animates flickering left
   and right" (Gail's device pass). Stacked, the math is exact, so the mode
   stacks the list the same way it collapses the cards: one rule keyed on the
   container, nothing to get out of step. (0,2,0) over .entity-list's (0,1,0). */
[data-drag-mode] .combat-list {
  grid-template-columns: minmax(0, 1fr);
}

[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));
}

/* The swap settle (batch 20, Decision B — W10's "popping"): a just-swapped
   NEIGHBOUR glides into its new slot instead of teleporting; the dragged card
   itself always tracks the pointer raw. Keyed on a class only combat-drag.js
   writes (the number-stepper scoping rule) and scoped under the list so a
   stray class elsewhere animates nothing. The script guards its geometry
   against the glide (__settling) — an animating rect is an interpolation, not
   a position — so this rule is free to be pure presentation.

   The duration is the number the device pass tunes (batch 20 Tier 3): change
   it HERE, once. 140ms starting point — long enough to read as motion, short
   enough that a fast drag's catch-up (one settle per slot) stays behind the
   finger by less than a beat. */
[data-drag-mode] .combat-list > li.drag-settle {
  transition: transform var(--drag-settle-duration, 140ms) ease;
}

@media (prefers-reduced-motion: reduce) {
  /* Belt to the script's own matchMedia brace: no glide, instant reflow —
     which is simply the pre-batch-20 behaviour. */
  [data-drag-mode] .combat-list > li.drag-settle {
    transition: none;
  }
}

/* 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;
}

/* Their own full-width row under the header (2026-08-13): inside
   .combatant-identity they shared the header row with the arrows group, so a
   reserve-heavy combatant stacked its glances down a half-width column
   beside empty space — a 25-reserve header measured 431px tall. The row gap
   replaces the spans' own margin-left, which existed for the inline-in-the-
   name-line life and would double the spacing here. */
.combat-glance-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 0.6rem;
}

.combat-glance-row .combat-glance {
  margin-left: 0;
}

.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);
}

/* **A refusal reads as an answer, not as page furniture** (batch 23).

   Gail typed a wrong claim code, got the right message, and reported "there was
   no feedback" — then confirmed from a screenshot that it HAD rendered and she
   read straight past it. In body weight, with nothing but hue separating it
   from the prose around it, an error is furniture. This was the claim form's
   own rule for one commit; it is the base rule now, because every other refusal
   in the app has the same problem and being louder in exactly one place is an
   inconsistency rather than a design.

   **No new colour, deliberately.** The rule-off is `currentColor`, which IS
   `--error-text` — already the ink of this paragraph, already measured wherever
   it renders. A tinted background would be a new surface owing
   theme-contrast.py an entry; this forms no pair that did not already exist. */
.error {
  color: var(--error-text);
  margin: 0 0 1rem;
  font-weight: 600;
  border-inline-start: 3px solid currentColor;
  padding-inline-start: 0.6rem;
}

.error:empty {
  display: none;
}

/* **A refusal that reads as an answer, not as page furniture** (batch 23).

   Gail typed a wrong claim code, got this exact message, and reported "there
   was no feedback" — then confirmed from a screenshot that it had rendered and
   she read straight past it. The reason is visible in the shot: at the section
   heading it was the same size and weight as the body text and sat *above* the
   explanatory line, so it looked like part of the page rather than a response
   to the button she had just pressed. It now renders inside the form, at the
   control that caused it.

   **No new colour enters the app here, deliberately.** The rule adds weight and
   a rule-off in `currentColor` — which is `--error-text`, already the ink of
   this very paragraph and already measured. Inventing a tinted background would
   be a new surface, and a theme colour is only safe on the surfaces
   theme-contrast.py has measured it against; this change needs no entry there
   because it forms no new pair. */
/* The required marker (batch 23). currentColor deliberately — this inherits the
   label's own ink, so it forms no new colour pair and theme-contrast.py needs
   no entry for it. A tinted "error red" here would be both a new surface to
   measure and a lie: nothing has gone wrong yet. */
.required-mark {
  color: currentColor;
  font-weight: 700;
  margin-inline-start: 0.15rem;
}

/* **The caption and its mark are ONE flex item, and that is the whole reason
   this wrapper exists.** The base rule is `label { display: flex;
   flex-direction: column }`, so a bare <span> beside the caption text is a
   *second* flex item and lands on its own row under it — the "*" sat alone on
   the line below the field name, which is how Gail found it. The text node and
   the mark have to be inside one box for them to sit together.

   Third time this rule has bitten in one batch: the radio options above, and
   this. When you put an element inside a <label>, ask what the column does to
   it — it is not `display: block`, and anything you add becomes a row. */
.field-caption {
  display: inline;
}

.required-legend {
  margin: 0 0 0.75rem;
  font-size: 0.85rem;
}

.claim-form .error {
  /* **flex-basis, because .claim-form is a flex ROW.** It is `display: flex;
     align-items: flex-end; flex-wrap: wrap` so the code box and its button sit
     on one line — which made this paragraph a fourth flex item and put the
     refusal *beside* the field instead of above it. 100% with the parent's
     wrap on is what forces its own line; a `display: block` here would not,
     because a flex item's display is blockified anyway. */
  flex-basis: 100%;
  margin-bottom: 0.75rem;
}

/* §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;
}

/* The row's other two controls carry the base field rule's trailing
   `margin-bottom: 0.4rem` (select directly; `.number-stepper` mirrors it for
   the input it wraps) — correct for a *stacked* field, invisible weight in
   this *side-by-side* one. `align-items: end` on the row sizes its line to
   the tallest item's MARGIN box, so that trailing 6.4px became part of the
   line's height, and the remove button above — whose own margin really is
   zero — bottom-aligned to that taller line instead of to where the visible
   controls end. Measured in headless Chrome, both signatures: the select's
   and stepper's rendered bottom at 115.6px, the remove button's at 122.0px,
   a 6.39px gap — the same number this file already names twice for the same
   underlying cause, moved to a new pair of controls.

   The fix mirrors `.field-list li .number-stepper { margin-bottom: 0 }`
   below: zero the trailing margin for the two controls that don't need it in
   *this* row, rather than giving the remove button one to match — a button
   with a manufactured bottom margin would misalign the moment this row's
   layout changes again, where a field with none is simply correct.
   `.field-row.bit-cost-row select` is (0,2,1) against bare `select`'s
   (0,0,1); `.field-row.bit-cost-row .number-stepper` is (0,3,0) against
   `.number-stepper`'s (0,1,0) — both win outright.

   Two rules, not one shared by a comma, on purpose: verify_number_inputs.php
   reads a rule's body by matching the text immediately after its own
   selector, and a comma-joined list puts the sibling selector in between. */
.field-row.bit-cost-row select {
  margin-bottom: 0;
}

.field-row.bit-cost-row .number-stepper {
  margin-bottom: 0;
}

/* The combatant card's add-a-stat form: Field + Value + the add button, three
   children borrowing two-column .field-row, so the button wrapped onto its
   own grid line (measured 54px below the inputs, 2026-08-13) — the
   .bit-cost-row shape again, fixed the same way. The phone override below
   is required, not tidiness: the 760px media rule resets .field-row to one
   column at (0,1,0), which this (0,2,0) selector would silently beat,
   keeping three columns on the one width that can't afford them. */
.field-row.add-stat-row {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
}

@media (max-width: 760px) {
  .field-row.add-stat-row {
    grid-template-columns: 1fr;
  }
}

/* The third recurrence of the trailing-margin shape (2026-08-13), so the fix
   is now general to .field-row instead of another per-row patch. Center
   Stage's attribute/HP rows are `label > (name line + input)` beside a ✓💾
   submit; the base button rule's `align-self: end` pins that button to the
   grid row's bottom, and the field's trailing 0.4rem — inside the label, so
   part of the label's box — put the row bottom 6.4px below the input's
   painted bottom. Measured: the button rode 13.4px below the input's centre
   on every number row of every combatant card. Same reasoning as
   .bit-cost-row above: in a side-by-side row the stacked-field margin is
   invisible weight, so zero it on the field, never manufacture a matching
   margin on the button. The input selector mirrors the base field rule's
   three :not()s to out-specify it — (0,4,2) over (0,3,1) — the same
   load-bearing pattern as `.number-stepper input[type="number"]` above; the
   stepper rule is (0,2,1) over `.number-stepper`'s (0,1,0). */
.field-row label input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]) {
  margin-bottom: 0;
}

.field-row label .number-stepper {
  margin-bottom: 0;
}

/* 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;
}

/* A label wrapping its own radio or checkbox is a control *beside* its caption,
   against the base `label` rule's column — which is right everywhere a caption
   sits above a text input, and wrong here.

   **`.field-control` is not a tidy-up of the `.field-row` pair above it; it is
   the half that was missing** (batch 23, AS-02.03). The character sheet does
   not render its fields as `.field-row` — a sheet row is a bare <li> in a grid,
   and a radio field's options live in `fieldset.field-control` inside it — so
   the `.field-row` selector matched nothing on that screen and the base rule
   won uncontested. Measured at 390px before the fix: each option label computed
   `flex-direction: column` at **328x41**, with the radio sitting above its own
   text, three full-width rows for a three-option field.

   Note what this was NOT: a specificity fight. `.field-row label:has(…)` is
   (0,2,2) against the base rule's (0,0,1) and would have won easily had it
   applied. The declaration was right, the specificity was right, and the
   selector described a structure this screen does not use — so reading the
   cascade could not find it and `label.matches('.field-row label')` returning
   false is what did. tests/verify_radio_option_layout.php holds the pairing
   between the container the partial renders and the container named here,
   because renaming either one alone is silent.

   Deliberately scoped rather than widened to `label:has(> input[type="radio"])`
   app-wide: the colour picker's options are a swatch *above* its name and
   depend on the base rule. */
.field-row label:has(> input[type="radio"]),
.field-row label:has(> input[type="checkbox"]),
.field-control label:has(> input[type="radio"]),
.field-control label:has(> input[type="checkbox"]) {
  flex-direction: row;
  align-items: center;
  gap: 0.5rem;
}

.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;
}

/* First-run contextual tips
   (docs/specs/2026-08-10-onboarding-hints-design.md). Third
   styling, each correcting the last on device. A full --border-soft box read
   as the same surface as the .section-card around it; the "flagged sidebar"
   (--border-soft left accent, transparent fill) separated it from the cards
   but was too quiet to find at all — "needs to stand out more, maybe take it
   out of theme or make it have a red border all the way around" (Gail, on
   the live campaigns page, 2026-08-11). So: a full border in --danger — red
   on every theme while still each theme's own red, which is why it is not
   the out-of-theme fixed colour she floated as the alternative. A fixed 2px
   rather than var(--border-width): the flag must not thin to a hairline on
   1px themes (the too-quiet failure again), and 3px themes already shout.
   --danger lands on a new surface here (a border against the card), so
   theme-contrast.py gains the pair in this same change, at the 3:1 non-text
   bar — the border IS the flag. Fill stays transparent; the text tokens are
   unchanged and already measured. */
.onboarding-hint {
  display: flex;
  align-items: flex-start;
  gap: 0.6rem;
  margin: 0 0 1rem;
  padding: 0.75rem 0.9rem 0.75rem 0.75rem;
  background: transparent;
  border: 2px var(--border-style, solid) var(--danger);
  border-radius: var(--border-radius, 0.7rem);
  corner-shape: var(--corner-shape, round);
}

.onboarding-hint > .icon {
  flex-shrink: 0;
  margin-top: 0.15rem;
}

.onboarding-hint p {
  flex: 1;
  margin: 0;
}

.onboarding-hint form {
  flex-shrink: 0;
  margin: 0;
}

.onboarding-hint-dismiss {
  min-width: 0;
  margin: 0;
  padding: 0.3rem 0.45rem;
}

/* 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;
}

/* A label that carries a muted hint puts the hint on the same line as its
   name, wrapping naturally — `block`, not the base rule's flex column (which
   gives the hint a row of its own) and **not `display: inline`**, which is
   what shipped 2026-07-27: an inline label flows into the inline label before
   it, so Backstage's Description label rendered on the same line as the
   Required checkbox (§12 pair 1, batch 16 R4). Inside a block the name and
   hint still share a line — they are inline content — and the control below
   still breaks, because the base field rule makes every input display:block.
   This also covers .checkbox-field labels with hints: their flex row would
   refuse to wrap a long hint on a phone, which is the squeeze the inline rule
   was presumably reached for in the first place. */
label:has(span.muted) {
  display: block;
}

label > span.muted {
  display: inline;
  width: fit-content;
}

/* Markdown prose (batch 18, renderProseMarkdown()).

   **The markers are restored explicitly, and that is the whole reason this
   rule exists.** There is no base `ul`/`ol` rule anywhere in this file — every
   list in the app is a class that zeroes its own `list-style` and `padding`
   (`.note-list`, `.field-list`, `.entity-list`, `.campaign-list`,
   `.combat-sheet-list`, `.attachment-list`). `list-style-type` **is
   inherited**, so a markdown list nested inside any of them draws no marker at
   all: unmarked, unindented lines, identical to the plain newlines it
   replaced. The feature would look broken on the most-used surface in the app
   with nothing thrown and nothing logged — the same invisible-missing-half
   shape as the icon map and the .field-tags disclosure.

   The margins are not free-chosen: 0.4rem is what a paragraph already spends
   in the same slot (`.note-list p` and `.bit-description`), so a list occupies
   the rhythm of the paragraph it replaced rather than adding to it.
   1.25rem keeps the disc inside the card with a readable hanging indent —
   the UA default is 40px, which is ~12% of a 390px phone's remaining width
   inside a .panel and a .section-card. */
.prose > :first-child { margin-top: 0; }
.prose > :last-child { margin-bottom: 0; }

.prose ul,
.prose ol {
  margin: 0.4rem 0;
  padding-inline-start: 1.25rem;
  list-style-position: outside;
}

.prose ul { list-style-type: disc; }
.prose ol { list-style-type: decimal; }

.prose li + li { margin-top: 0.15rem; }

/* A markdown list can render nested inside a card-list row — `.entity-list
   li` (props/locations/groups notes) and `.field-list li` (character sheet
   prose fields) each turn *every* `li` in their subtree into a bordered card:
   `display:flex`/`grid`, padding, a border, a left/top accent border,
   background and box-shadow, all via a plain unscoped `li` descendant
   selector with no `>` and no extra class. A markdown bullet is exactly such
   an `li`, so it inherited the card look wholesale — one bordered box per
   bullet point — and `display:flex`/`grid` additionally suppressed the
   marker even though `list-style-type` was already computing to `disc`
   correctly (inherited from `.prose ul` above, then re-defeated by the same
   card rule's own `display`). `.field-list-read li` (below) does the same
   thing to padding on its own, independently of the base `.field-list li`
   rule, which is why this is a reset rather than a narrowing of one
   selector: any current or future unscoped `… li` rule in this file is
   neutralised the same way, not just the two that were measured.

   This resets exactly the properties those rules set — nothing else — and
   restores `display: list-item` so the marker draws. Properties the card
   rules set that only affect flex/grid containers (`gap`, `align-items`,
   `grid-template-columns`) need no explicit undo: they're inert once
   `display` is back to `list-item`.

   **The extra class is load-bearing — do not simplify to `.prose li`.**
   `.prose ul li, .prose ol li` is (0,1,2): one class plus two type
   selectors. `.entity-list li` / `.field-list li` are (0,1,1). (0,1,2) beats
   (0,1,1) regardless of source order, which is the whole point — it must
   keep winning no matter where a future card-list rule lands in the file.
   Drop to `.prose li` and it falls to (0,1,1), a tie with the card rules
   that source order would then decide, silently un-fixing this depending on
   which rule happens to come last. */
.prose ul li,
.prose ol li {
  display: list-item;
  padding: 0;
  border: none;
  border-left: none;
  background: none;
  box-shadow: none;
}

/* The formatting hint's specimens. Mono and a field-fill chip, the same
   treatment the invite code and claim code already use (and the same
   --field-bg-under-inherited-ink pair, so no new surface is introduced). */
.prose-hint-syntax {
  font-family: monospace;
  font-size: 0.95em;
  background: var(--field-bg);
  padding: 0.05rem 0.3rem;
  border-radius: var(--border-radius, 0.35rem);
  margin-inline-start: 0.5rem;
}

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;
}

/* session.php's Session Log fold (batch 24, §3.1). Native marker kept —
   same call as details.backstage-section: a heading with a triangle beside
   it already reads as "there is more here," and
   tests/verify_disclosure_affordances.php's census is only disclosures that
   hide theirs. The <h2> sits inside the <summary> now, so it must not force
   its own line, same reason as details.backstage-section > summary > h2. */
details.session-log-fold > summary {
  cursor: pointer;
  list-style-position: outside;
}

details.session-log-fold > summary > h2 {
  display: inline;
}

/* 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;
}

/* An image attachment previews itself (batch 16, F3). Block, so the filename
   link keeps its own line below; bounded height, so a full-resolution photo
   cannot take the page — the link is still the full-size view. */
.attachment-thumb {
  display: block;
  margin-bottom: 0.4rem;
}

.attachment-thumb img {
  display: block;
  max-width: 100%;
  max-height: 12rem;
  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);
}

.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` is NOT a tidy-up of the element selector beside it —
   it is the fix for a card action rendered as a link. A `<a class="button">`
   (the proposals list's Edit, which must be a link because it navigates) never
   matched `.entity-list button`, so it kept the base .button rule's 8rem
   min-width and full padding while every real <button> in the same row got the
   compact card treatment: measured in headless Chrome at 128x47 against a
   sibling's 110x33, which is the "the edit button is bigger than other buttons
   in the same space" report of 2026-08-14.

   Specificity: `.entity-list .button` is (0,2,0), which beats the base
   `.button` (0,1,0) — an element-selector-only rule here reads as covering the
   row and silently does not. Keep BOTH selectors: the element half still
   carries bare <button>s inside a card that have no .button class. */
.entity-list button,
.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;
}

/* The recap archive's filter row (batch 19 device pass): layout only, on
   purpose. The pills are a.nav-pill — an existing measured pair, themed per
   theme alongside .tag-chip — so this wrapper adds no colour surface for
   theme-contrast.py's inventory, and the active state is announced by the
   page's own "Showing recaps you tagged" banner, never by an unmeasured
   emphasis pair here. */
.recap-filter-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  margin: 0 0 1rem;
}

.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.

   `flex-direction: row` is load-bearing, not the initial value restated: the
   base `label` rule sets `flex-direction: column`, and this class only ever
   overrode `display` — so the checkbox rendered stacked ABOVE the name, a
   tall two-line target where a row was designed (§12 pair 1, batch 16 U2).
   Different property, so specificity never entered into it. */
.shared-tag-item {
  display: flex;
  flex-direction: row;
  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;
  /* The one number this header is laid out from. The attribution's indent below
     is derived from it, so resizing the logo moves both together instead of
     leaving the second line hanging under nothing. */
  --site-logo-height: 3rem;
}

/* The logo, which replaced the icon-and-words .site-wordmark used to hold.
   Sized by height because the header's rhythm is vertical; the width follows
   from the intrinsic 1000x299 the <img> declares, which is also what stops the
   header reflowing while it loads. max-width is the 320px-phone case, where
   10rem of logo has to fit inside .site-header's 1.5rem of padding. */
.site-logo {
  height: var(--site-logo-height);
  width: auto;
  max-width: 100%;
}

/* Still styles the wordmark's *words*: with the image broken or still loading,
   the alt text renders here and takes this font and this ink. That is the case
   these declarations were always right for — it just used to be the only one. */
.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 mark so it reads as belonging to the name
   above it rather than starting a new column. That was a flat 1.9rem clearing
   the scroll-text icon; the logo's dodecahedron now stands in the same place
   and is a different width, so the indent is measured off the asset rather than
   re-guessed. The mark ends at x=261 of 1000 and the "A" of ACT starts at
   x=268, so the text edge sits at 26.8% of the logo's width — and that width is
   height * 1000/299. 1000 / 299 * 0.268 = 0.896. Re-measure if the art
   changes; nothing here can tell that it has. */
.site-attribution {
  margin: 0;
  padding-left: calc(var(--site-logo-height, 3rem) * 0.896);
  font-size: 0.8rem;
  font-style: italic;
  color: var(--text);
}

.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-pips .reserve-pip`, never bare `.reserve-pip` — the extra class
   is load-bearing, exactly as `.prose ul li`'s comment argues above. A pip is
   an `li`, and Center Stage's board is a `.entity-list`, so the unscoped
   card-row rule `.entity-list li` (0,1,1) reached every pip on the board:
   card padding (which floors a 12.8px pip at a 34px pill), card background,
   card border and card shadow all beat bare `.reserve-pip`'s (0,1,0).
   Found by measurement 2026-08-13 — on the character sheet, outside any
   entity-list, the same markup had been drawing correctly the whole time.
   (0,2,0) wins regardless of source order; `padding: 0` and
   `box-shadow: none` are the explicit undos of properties the pip rule
   never set itself. */
.reserve-pips .reserve-pip {
  width: 1.05rem;
  height: 1.05rem;
  padding: 0;
  box-shadow: none;
  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-pips .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.

   One line at rest since 2026-08-13 (Center Stage density): meter + [-][+] +
   the folded set-editor's summary must all fit a 390px phone without
   wrapping, which is why the meter's basis is 8rem rather than the 12rem it
   had while the row was allowed to be two lines. Measured before the change:
   five Reserves cost 582px of a 1,679px card — the largest single block on
   the board — and ~58px of every row was input chrome for the rare
   exact-set case. */
.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 8rem;
  min-width: 0;
}

/* The folded exact-set editor. Native marker kept (so this stays out of
   verify_disclosure_affordances.php's census), and the summary does not
   preview the value — the meter beside it already shows it — so no [open]
   duplicate rule is owed. Open, the editor takes the whole row width and
   drops below the meter line. */
.reserve-set {
  align-self: center;
}

details.reserve-set > summary {
  font-size: 0.82rem;
  color: var(--text-muted);
  cursor: pointer;
  white-space: nowrap;
  padding: 0.45rem 0.2rem;
}

.reserve-set[open] {
  flex-basis: 100%;
}

.reserve-set-body {
  display: flex;
  align-items: stretch;
  gap: 0.3rem;
  margin-top: 0.2rem;
}

.reserve-set-body form {
  display: flex;
  margin: 0;
}

/* Width and trailing margin for the set field now that it lives outside
   .reserve-stepper. The three :not()s mirror the base field rule and are
   load-bearing — (0,5,1) over the base rule's (0,3,1); see .number-stepper's
   field rule for the full argument. The native spinner is NOT hidden here:
   outside the segmented [-][field][+] group there is nothing replacing it,
   and the scoping rule is that an affordance is only removed by the thing
   that stands in for it. */
.reserve-set-body
  input[type="number"]:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]) {
  width: 5rem;
  padding-inline: 0.5rem;
  margin-bottom: 0;
}

.reserve-set-body .button {
  width: auto;
  min-width: 0;
  margin: 0;
  padding-inline: 0.7rem;
  align-self: stretch;
}

.reserve-row form {
  margin: 0;
}

.reserve-controls {
  display: flex;
  /* Stretch, not flex-end (batch 17 day 6). flex-end aligned each form's
     MARGIN box, and the old Set form's field kept the base rule's trailing
     0.4rem — so the Save inside it sat 7.0px below the -/+ bottoms and
     9.6px shorter, measured: the fourth appearance of this stylesheet's
     6.39px family. Stretch makes the Save match the [-][field][+] group's
     height by construction instead of by margins happening to agree. */
  align-items: stretch;
  gap: 0.3rem;
  flex-wrap: wrap;
}

.reserve-controls .button {
  width: auto;
  margin-top: 0;
  margin-bottom: 0;
  padding-inline: 0.7rem;
  /* The base .button rule says align-self: end, which beats the container's
     align-items: stretch — measured as the Save sitting 2px shy of the
     group's top while its bottom happened to agree. Stated outright so the
     row's equal heights are a rule, not a coincidence of paddings. */
  align-self: stretch;
}

/* `.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. Superseded batch 17 day 6: both copies of the control are
   sized by `.reserve-stepper`'s field rule below, which mirrors the base
   rule's :not()s and wins outright. */

/* 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;
}

/* The refresh cadence on the compact meter is the icon alone — but only on
   the GM's combatant cards (2026-08-13, twice revised): first the whole
   "per Beat" span was hidden for space (the one-line reserve row has ~198px
   of head on a phone and the words cost a wrapped line), then the cadence
   icons gave the information back at glyph cost. Scoped to `.combatant`
   deliberately, so the pinned you-card KEEPS icon + word: a Player's Center
   Stage has no Call-a-Break row (that's the GM's) and mobile browsers do not
   show `title` on touch, so without this exception a sighted touch player
   would meet the glyphs with no legend anywhere on the screen they play
   from. One card pays the possible wrap; N cards get the glyph. The full
   phrase rides the span's aria-label everywhere. */
.combatant .reserve-meter.is-compact .reserve-meter-rate .reserve-meter-rate-word {
  display: none;
}

/* The cadence ink (2026-08-13): on content surfaces the time-vocabulary
   glyphs take --mod-INK — the mod-pulled-toward-text companion token, the
   same ink .reserve-meter-count uses on the same line and .bit-cost uses on
   the same chip, both of which ARE in theme-contrast.py's inventory ("mod-ink
   as text on card"). Not raw --mod: that token is a fill, exists in the
   palette as one, and was never measured as a foreground on these surfaces —
   the first draft cited "the pip-fill precedent" for it and review found the
   pips aren't in the harness inventory at all (the harness-blind trap,
   verbatim). The glyph is the ONLY visible carrier of the cadence on a chip
   or compact meter, so it rides the measured ink or it doesn't ship.
   Deliberately NOT applied inside the refresh buttons: a button's fill
   already chose its ink (CLAUDE.md), so their copy rides currentColor. */
.cadence-badge .icon,
.reserve-meter-rate > .icon {
  color: var(--mod-ink, var(--accent));
}

.cadence-badge {
  display: inline-flex;
  align-items: center;
  gap: 0.15rem;
}

.reserve-meter-rate {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
}

.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 spend controls as one segmented [-][field][+] group (batch 17 day 6),
   matching the control number-stepper.js builds for every other number field
   — except this one is SERVER-RENDERED, which is what makes hiding the
   native spinner safe without a script: the -/+ that replace it are three
   real forms in the markup, on screen with JS off. (`.number-stepper`'s copy
   of these rules keys on a wrapper only the script writes, for exactly the
   same reason — each scope names the thing that replaced the affordance.)
   The group's height comes from the field, `.stepper-button.button`'s
   align-self: stretch hands it to the -/+, and `.reserve-controls`' stretch
   hands it to the Save sitting beside the group. */
.reserve-stepper {
  display: flex;
  align-items: stretch;
}

.reserve-stepper > form {
  display: flex;
  margin: 0;
}

/* The three :not()s mirror the base field rule and are load-bearing, not
   decoration to be tidied away — see `.number-stepper`'s field rule, which
   carries the full argument. The base rule scores (0,3,1); this scores
   (0,5,1) and wins where `.reserve-controls input[type="number"]` (0,1,1)
   famously never did (the tombstone above). 4.2rem with slim padding is
   batch 16 R3's number: 16px font + three digits fit, and the font size
   itself is untouchable (the iOS zoom floor, batch 11). */
.reserve-stepper
  input[type="number"]:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]) {
  /* Both spellings, same as .number-stepper: -moz-appearance still drives
     Firefox's spinner, the ::-webkit- pseudo-elements below drive Chrome's
     and Safari's, and the missing one is invisible on the other browser. */
  appearance: textfield;
  -moz-appearance: textfield;
  width: 4.2rem;
  padding-inline: 0.5rem;
  margin: 0;
  /* Square: the buttons either side supply the group's outer radius. */
  border-radius: 0;
  text-align: center;
}

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

/* Outer corners and the seam, exactly as .number-stepper draws them: the
   inner border goes to the field, which is the box with the focus state.
   (0,3,1) against `.stepper-button.button`'s (0,2,0) border-radius: 0, so
   the corners win here without leaning on document order. */
.reserve-stepper > form:first-child .stepper-button {
  border-start-start-radius: var(--border-radius, 0.7rem);
  border-end-start-radius: var(--border-radius, 0.7rem);
  border-inline-end: 0;
}

.reserve-stepper > form:last-child .stepper-button {
  border-start-end-radius: var(--border-radius, 0.7rem);
  border-end-end-radius: var(--border-radius, 0.7rem);
  border-inline-start: 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;
  /* Not the default stretch: a chip whose name wraps to two lines would
     stretch every chip on its flex line to match. This also closed a real
     bug in the retired `.is-group-end` design — a flex line's height
     includes an item's margin, so the separator's 1.2rem margin-bottom
     stretched every row-mate 19.2px (measured 45.19px beside 26.00px,
     2026-08-11), reading as a Firefox bug because where a boundary fell
     depended on wrap width. The margin is gone; the lesson stays. */
  align-items: flex-start;
}

/* One <ul> per Bit category since batch 17 day 7, so a group boundary is a
   line break by construction and the rule between lists cannot land mid-chip.
   The predecessor — a wider gap on each group's last chip — separated
   nothing on a wide card, where several groups flow onto one line and the
   extra margin reads as ordinary spacing ("in multiple columns it is all
   evenly spaced", Gail's device pass). Sibling selector, so a lone group
   draws no rule and renders exactly as it always has. --border-soft is the
   same divider combat-turn-controls already uses: no new pair for the
   contrast harness. Later in the file than `.combat-you .overview-bits`
   (margin-top: 0.5rem), which it must out-cascade at equal (0,2,0)
   specificity — the boundary between groups belongs to this rule. */
.overview-bits + .overview-bits {
  margin-top: 0.6rem;
  padding-top: 0.6rem;
  border-top: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
}

/* `.overview-bits .overview-bit`, never bare `.overview-bit` — the same
   nested-li trap as `.reserve-pips .reserve-pip` above, found the same day: a
   chip is an `li`, Center Stage's board is an `.entity-list`, and the
   unscoped card-row rule `.entity-list li` (0,1,1) beat this rule's old
   (0,1,0) for every shared property. On the board — and only there — chips
   rendered as flex COLUMNS (the × forced onto its own row, doubling every
   chip's height), wearing card padding, the card border, the module
   left-stripe and the card shadow. The sheet's overview strip drew them
   correctly the whole time, which is what kept it invisible.
   `flex-direction`, `border-left`, `box-shadow` and `margin` are the
   explicit undos of properties the card rule sets and this rule never did. */
.overview-bits .overview-bit {
  display: inline-flex;
  flex-direction: row;
  align-items: center;
  gap: 0.3rem;
  margin: 0;
  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-left: var(--border-width, 1px) var(--border-style, solid) var(--border-soft);
  border-radius: 999px;
  corner-shape: var(--corner-shape, round);
  box-shadow: none;
}

/* `.overview-bit.is-group-end` — the per-chip margin that marked a group
   boundary from batch 10 to batch 17 day 7 — used to sit here. Retired, not
   tuned: the boundary lives on `.overview-bits + .overview-bits` above,
   because a gap can only separate what shares a line and a wide card flowed
   whole groups onto one. Its long rationale (both axes, the 0.4rem-too-small
   device finding, trailing-not-leading) is in git with the rule. */

/* 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;
}

/* 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-bits` prefix (0,3,0): the base chip rule grew to (0,2,0) with
   the nested-li fix and sets the border shorthand, so a bare
   `.overview-effect.is-skipping` (0,2,0) kept its dashes only by sitting
   later in the file — a source-order win a reordering would silently undo.
   verify_center_stage_density.php asserts the comparison. */
.overview-bits .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.
   The perform-chip rule 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.

   `.overview-bits` prefix plus both classes: the perform-chip rule became
   `.overview-bits .overview-bit:has(> form)` (0,2,1) when the nested-li trap
   was fixed, so this must score above it — (0,3,1) — to keep the effect
   chip's padding, the same make-the-reason-visible arithmetic the original
   two-class version carried. */
.overview-bits .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 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-bits` prefix to stay ahead of the base chip rule above, which
   grew to (0,2,0) when the nested-li trap was fixed — a bare
   `.overview-bit:has(> form)` at (0,1,1) would lose the padding strip to it. */
.overview-bits .overview-bit:has(> form) {
  padding: 0;
}

.overview-bits .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;
}

/* The Center Stage toggle sits beside Perform as its own real form (batch 24,
   §12 2026-08-23) — .checkbox-field's own margin is meant for a form-stack
   layout and would misalign it in this flex row. */
.bit-combat-toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.bit-combat-toggle .checkbox-field {
  margin: 0;
}

/* The three Choreography sections (Reserves, Bits, FX) fold as whole
   <details> too, copying Backstage's pattern (§12, Andrew: "the character
   sheet is collapsible per bits/reserves/attributes, but to be able to
   collapse the whole bits section or reserves section would be nice.
   Backstage already works like this."). Unlike Backstage they default open —
   see choreographySectionOpenAttribute()'s docblock — but the disclosure
   chrome itself is identical, including this: the native marker is
   deliberately KEPT, exactly as Backstage's own sections keep theirs.
   tests/verify_disclosure_affordances.php holds the census of disclosures
   that hide their marker and owe something in its place, and this is not one
   of them — a heading with a triangle beside it already says "there is more
   here" on a phone. */
details.choreography-section > summary {
  cursor: pointer;
  list-style-position: outside;
}

/* The <h2> sits inside the <summary> now, so it must not force its own line —
   that would push the marker onto a line of its own and leave a heading that
   looks unrelated to the triangle above it. Same rule as
   details.backstage-section > summary > h2, for the same reason. */
details.choreography-section > summary > h2 {
  display: inline;
}

/* Closed, a section is a table-of-contents row and should read as one rather
   than as a card with nothing in it — matches details.backstage-section. */
details.choreography-section:not([open]) {
  padding-bottom: 0.75rem;
}

/* Task 7 (§2.3, batch 24): a per-section "Add" that stays reachable when its
   section is folded — a closed <details> hides everything but its <summary>,
   so the section's own "Add a Reserve"/"Write your own Bit"/"Write your own
   FX" disclosures go with it. This sits OUTSIDE the <details> entirely, as a
   plain sibling, rather than inside the <summary> row beside the heading —
   see the comment at the first one in choreography-sections.php for why: a
   real <a href> nested in a <summary> also fires the browser's native
   click-to-toggle, and nothing here can preventDefault that away without
   JavaScript, which a no-JS reader does not have.
   .button.secondary rather than a bespoke link colour: it is the ink recipe
   theme-contrast.py already measures for every module, so this introduces no
   new surface for that harness to have missed. */
.choreo-quick-add {
  margin: 0 0 0.4rem;
}

.choreo-quick-add .button {
  margin: 0;
}

/* 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;
}

/* Task 7 (§2.3): the floating "+" and its menu, built entirely by
   assets/js/quick-add.js — nothing here is reachable from markup with
   scripting off, which is why this file has no fallback rule the way
   .number-stepper's spinner-hiding CSS does (that one is keyed on a class
   only the script writes too, for the same reason).

   .quick-add-fab doubles the class weight (0,2,0) to beat the base .button
   rule's min-width/padding/pill shape (0,1,0) — same trap CLAUDE.md records
   for .number-stepper input and .tag-add input: a plain `.quick-add-fab`
   selector would tie on specificity and lose on source order the day
   somebody moves it.

   Fixed at --chrome-offset from the corner, the same offset the top-right
   chrome cluster (.page-chrome) uses — see docs/playtest/quick-add-capture.mjs
   and the batch 24 Task 7 report for the measured clearance from the nav rail
   and a mid-scroll sheet. Bottom-right, not top-right: .page-chrome is
   already three controls wide there (theme, help, campaign nav), and Andrew's
   own reference (HQ's +) was bottom-right. */
.button.quick-add-fab {
  position: fixed;
  right: var(--chrome-offset);
  bottom: var(--chrome-offset);
  z-index: 45;
  width: 3.25rem;
  height: 3.25rem;
  min-width: 0;
  padding: 0;
  margin: 0;
  border-radius: 999px;
  corner-shape: var(--corner-shape, round);
  box-shadow: 0 0.35rem 1rem rgb(0 0 0 / 35%);
}

.quick-add-fab .icon {
  width: 1.5rem;
  height: 1.5rem;
}

/* Same recipe as .module-nav (styles.css ~2242): --panel-bg composited over
   the body gradient (this is position: fixed at body level, exactly like the
   nav drawer, never inside .panel), with --text as ink — the pair
   .module-nav .nav-aside already established is safe on that surface
   ("Plain --text on the drawer's --panel-bg instead, which theme-contrast.py
   already measures as a panel pair — no new surface, no new harness entry").
   Reused rather than a new token, for the same reason. */
.quick-add-menu {
  position: fixed;
  right: var(--chrome-offset);
  bottom: calc(var(--chrome-offset) + 3.25rem + 0.6rem);
  z-index: 45;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  min-width: 11rem;
  max-width: calc(100vw - 2 * var(--chrome-offset));
  padding: 0.4rem;
  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));
}

.quick-add-menu[hidden] {
  display: none;
}

.quick-add-menu-item {
  display: block;
  padding: 0.6rem 0.8rem;
  border-radius: var(--border-radius, 0.6rem);
  corner-shape: var(--corner-shape, round);
  color: var(--text);
  text-decoration: none;
  font-weight: 600;
  font-size: 0.9rem;
}

.quick-add-menu-item:hover,
.quick-add-menu-item:focus-visible {
  background: var(--field-bg);
}

/* Same rationale as .sheet-save-actions' short-viewport rule directly above
   it in this file: a fixed control is not worth keeping on screen once the
   viewport is short enough that it would sit over the content it is meant to
   help reach. */
@media (max-height: 26rem) {
  .button.quick-add-fab,
  .quick-add-menu {
    display: none;
  }
}

/* Fix round 2 (batch 24 Task 7 review): above 75rem wide, .module-nav stops
   being an on-demand drawer and becomes a permanently rendered rail sharing
   the fab's own bottom-right corner (styles.css ~2367). Below the rail's own
   `max-height: calc(100vh - chrome-height - chrome-offset)` cap, rail.bottom
   and fab.top both move in lockstep with viewport height and the gap between
   them is a CONSTANT 52px overlap — exactly the fab's own height — not a
   tapering near-miss: `docs/playtest/quick-add-capture.mjs`'s desktop pass
   measured 1280x560 landing squarely inside it. That band runs from the
   already-hidden 26rem up through a measured ~617px breakeven for this app's
   real GM pill set (the worst case — Cast/Callback/Center Stage/Backstage
   are GM-only pills a player never gets), and it covers 1366x768 minus
   ordinary browser chrome — mainstream laptop hardware, not an edge case.
   40rem (640px) clears the measured breakeven with a ~23px margin and is
   still comfortably inside the 26rem rule's already-established territory of
   "the + is a convenience, not the only way in" — the per-section Adds and
   the roomy desktop sheet layout are what's actually reachable here, exactly
   the edit-mode reasoning above the script tag in character.php. */
@media (min-width: 75rem) and (max-height: 40rem) {
  .button.quick-add-fab,
  .quick-add-menu {
    display: none;
  }
}

/* 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;
}

/* Backstage's ten sections are disclosures (§12, 2026-08-09: a real campaign
   renders that page ~35,900px tall). The native marker is deliberately KEPT —
   `tests/verify_disclosure_affordances.php` holds a census of the disclosures
   that hide theirs and owe something in its place, and this is not one of them.
   A heading with a triangle beside it is already the clearest thing a phone can
   show for "there is more here". */
details.backstage-section > summary {
  cursor: pointer;
  list-style-position: outside;
}

/* The <h2> sits inside the <summary> now, so it must not force its own line —
   that would push the marker onto a line of its own and leave a heading that
   looks unrelated to the triangle above it. */
details.backstage-section > summary > h2 {
  display: inline;
}

/* Closed, a section is a table-of-contents row and should read as one rather
   than as a card with nothing in it. */
details.backstage-section:not([open]) {
  padding-bottom: 0.75rem;
}

/* The contact form's honeypot wrapper. Off-screen rather than display:none —
   some bots skip anything hidden, and the point is that they fill it. The
   *wrapper* is positioned and never the input: the base control rule is
   input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]),
   which scores (0,3,1), and any rule targeting the input from here would lose
   while still looking applied.

   Deliberately NOT .visually-hidden, even though the two rules share four of
   five declarations — that similarity is what invites "these are duplicates,
   fold one into the other," which is exactly the tidy-up that would break
   the honeypot. .visually-hidden exists to stay reachable by a screen reader
   while invisible to a sighted user; a honeypot wants the opposite; it must
   be unreachable by anyone, assistive tech included, or a screen-reader user
   fills in the one field meant to catch bots. And .visually-hidden's
   clip-path: inset(50%) is a well-known accessibility idiom — the kind of
   thing a spam-aware bot's own checklist looks for — where left: -9999px is
   not. contact.php marks this wrapper with class="hp-field"; if either half
   ever stops agreeing with the other, the field stops being hidden from
   anyone, on the one page a stranger with no account can reach.
   tests/verify_contact_form.php asserts both halves still hold. */
.hp-field {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
