/* =========================================================================
   D&D Character Sheets — phone first, widened for the laptop.
   Designed at 375px. Every breakpoint below is min-width.
   ========================================================================= */

/* Three text weights, and all three are contrast-audited (#118): `probe.html` walks every text
   node in every scene, composites the layers actually behind it, and fails under WCAG AA's 4.5:1.
   Run it in BOTH themes — headless Chrome reports dark by default, so an unflagged pair of runs
   is two dark runs agreeing. The quiet end of this scale is where it went wrong: `--ink-faint`
   was #8b7d6d, which is 3.85:1 on a card and 3.13:1 on the roster's own tint — and it is the
   colour on the small uppercase tile labels, i.e. the ones you read at a table in bad light.
   The hierarchy is intact, just moved up: faint 5.88:1, soft 7.27:1, ink 16:1 on `--card`. */
:root {
  --ink:        #241d16;
  --ink-soft:   #5f5245;
  --ink-faint:  #6c6053;   /* 4.79:1 on the darkest tint it lands on, 5.88:1 on --card */
  --parchment:  #f4ede0;
  --card:       #fffaf1;
  --line:       #ded1bc;
  --line-soft:  #ece1cd;
  --accent:     #7b2d26;   /* oxblood — proficiency, active state */
  /* Text ON a filled accent button. A variable and not `#fff`, because the two themes disagree:
     white clears 9.35:1 on the light oxblood but only 2.90:1 on the lighter accent dark mode
     needs, and the two requirements pull in opposite directions — accent-as-text wants a LIGHTER
     accent, white-on-accent wants a darker one. Flipping the text instead settles it. */
  --on-accent:  #fff;
  --good:       #2f6b46;
  --bad:        #a33b2c;
  --gold:       #b08340;

  /* Spacing scale (#120). Every padding/margin/gap in this stylesheet resolves to one of
     these, so two things that look like they should line up actually do.

     Before this, spacing was 23 hand-typed rem decimals — .15/.2/.25/.3/.35/.4/.45/.5/.55/
     .6/.7/.75/.8/.85/.9 — which painted as 11 distinct off-grid pixel values across ~580
     elements (2.4, 3.2, 4.8, 5.6, 6.4, 7.2, 9.6, 11.2, 12.8, 13.6). Nothing sat on a grid,
     so elements that should have matched differed by 0.8–1.6px: too small to read as
     intentional, too large to read as aligned. That gap IS the "uneven margins" complaint —
     the layout itself measured symmetric (0 asymmetric gutters, 0 ragged left edges at both
     widths). Use a token; if none fits, the right move is almost always the nearer token,
     not a new value. tools/probe-spacing.html is the check. */
  --space-0: 2px;          /* hairline — chip rows, pip clusters, icon-button gaps */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 20px;
  --space-6: 24px;
  --space-7: 32px;
  --space-8: 48px;

  /* Type scale (#121). Six steps, and body is --text-md.

     Before this there were 21 distinct font sizes, five of them inside a 1.6px band
     (14.4 / 14.08 / 13.6 / 13.12 / 12.8). A 0.32px step is not a level of hierarchy — it is
     noise that stops things lining up. Three more sat under 10px (8.96 on the HP sublabel,
     9.28 on the skills legend, 9.92 on every tile label), which is below the point where a
     label is comfortably readable on a phone; those were raised, not rounded.

     16.8px — card titles, the brand, the topbar name — rounds DOWN to 16 by distance, which
     would erase the only thing separating a heading from body text. It is mapped up to 18 on
     purpose. Hierarchy beats nearest-neighbour. */
  --text-2xs: 11px;        /* the floor — captions and tile labels, nothing smaller */
  --text-xs:  12px;
  --text-sm:  14px;
  --text-md:  16px;        /* body */
  --text-lg:  18px;        /* headings, and icon glyphs */
  --text-xl:  22px;        /* the big display numbers: HP, ability mods, tile values */

  /* Stacking order (#126), as names rather than five ad-hoc numbers. The VALUES don't matter;
     the ORDER does, and it was previously only discoverable by grepping for z-index and
     sorting the results in your head — 29, 30, 35, 40, 50, with the 29 existing purely to sit
     one below the topbar. Spaced by 10 so something can be slipped between two layers without
     renumbering the rest. */
  --z-below-topbar: 10;    /* sticky card heads — must pass UNDER the topbar */
  --z-topbar: 20;
  --z-tabbar: 30;
  --z-scrim: 40;
  --z-drawer: 50;          /* the off-canvas sidebar, above its own scrim */
  --z-skip-link: 60;       /* reachable from anywhere, so above everything */

  --radius: 10px;
  --tap: 44px;             /* minimum comfortable touch target */
  /* Fixed so the sticky stack below the topbar is arithmetic, not guesswork. */
  --topbar-h: 3.5rem;
  --shadow: 0 1px 2px rgba(60, 44, 26, .09), 0 4px 14px rgba(60, 44, 26, .06);

  --serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ink:        #ece3d6;
    --ink-soft:   #b3a693;
    --ink-faint:  #a49889;   /* 4.97:1 on the lightest tint it lands on, 5.78:1 on --card */
    --parchment:  #1a1613;
    --card:       #241f1a;
    --line:       #3d352c;
    --line-soft:  #322b23;
    /* Lighter than the #c8695c it was, because dark mode is the theme where the accent is used as
       TEXT and it failed there: the active tab label measured 3.77:1 on the tab bar's own
       accent-12% tint, and the arrange-mode "Move here" lines 4.38:1 on a card. Now 4.69:1 and
       5.64:1. Light mode is untouched — it passes at 7.6:1 and up. */
    --accent:     #d08477;
    --on-accent:  #1a1613;   /* 6.21:1 on the accent above; white would be 2.90:1 */
    --good:       #5da97b;
    --bad:        #d4705f;
    --gold:       #c9a161;
    --shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 4px 14px rgba(0, 0, 0, .22);
  }
}

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

/* The browser's own [hidden] rule is a UA style, so ANY author `display` — like the
   `display: flex` on .tabbar — silently beats it and the element stays on screen.
   Everything the app hides via the hidden attribute depends on this line (including
   inactive tab panels). The laptop breakpoint deliberately overrides it for panels. */
[hidden] { display: none !important; }

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

body {
  margin: 0;
  background: var(--parchment);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.45;
  overscroll-behavior-y: contain;
}

h1, h2, h3 { font-family: var(--serif); font-weight: 600; letter-spacing: .01em; }

button, input, select, textarea {
  font: inherit;
  color: inherit;
  /* 16px minimum: anything smaller makes iOS Safari zoom on focus and never zoom back. */
  font-size: max(16px, 1rem);
  /* An input's min-content width comes from its default `size` (~20 characters), and
     a grid/flex track will not shrink below that. Without this the whole page grows
     wider than a 390px phone and everything on the right is cut off. */
  min-width: 0;
}

button { touch-action: manipulation; -webkit-tap-highlight-color: transparent; }

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

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

/* ------------------------------------------------------------- controls */

input[type="text"], input[type="number"], select, textarea {
  width: 100%;
  min-height: var(--tap);
  padding: var(--space-2);
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 8px;
  font-variant-numeric: tabular-nums;
}

/* field-sizing grows the box to fit its text so a long Feature/Notes entry never needs
   an inner scrollbar (painful on a phone). Where unsupported, min-height + resize:vertical
   are the fallback. The `rows` attributes stay as the non-supporting default height. */
textarea { min-height: 5rem; resize: vertical; line-height: 1.5; field-sizing: content; }

/* Notes would otherwise collapse to the 5rem floor when empty; keep it roomy to write in. */
#f-notes { min-height: 12rem; }

/* The Proficiencies blobs run from "Light" to a five-item tool list, and the 5rem floor
   gave all four the same 80px box — around 50px of dead space each for the short ones.
   field-sizing was already growing them; this is what lets it shrink them. The floor stays
   at one tap target, and `rows="2"` is still the height where field-sizing is unsupported. */
[data-editcard="proficiencies"] textarea { min-height: var(--tap); }

input[type="number"] { text-align: center; }

/* The spinners are useless at this size and steal tap area. */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input[type="number"] { -moz-appearance: textfield; }

/* One reusable checkbox used everywhere a checkbox appears (skill proficiency/expertise,
   conditions, heroic inspiration) so none fall back to the raw browser tick box: a themed
   box that fills accent with a white check when on. */
input[type="checkbox"] {
  appearance: none; -webkit-appearance: none;
  /* No `display` here on purpose: `input[type=checkbox]` (0,1,1) would outrank the
     `.skill__prof{display:none}` (0,1,0) that hides prof/exp in the collapsed skills
     view. Keep the UA inline-block and centre the check via absolute positioning. */
  position: relative;
  width: 22px; height: 22px;
  flex: none; margin: 0;
  vertical-align: middle;
  /* `--ink-faint`, not `--line` (#159). A control's boundary needs 3:1 under WCAG 1.4.11, and
     `--line` on `--card` measures 1.38:1 in dark — so even with the `:read-only` bug above fixed,
     the box this rule declares would have been a smudge. Measured with this token: 5.78:1 dark,
     5.88:1 light. `--line` stays right for a divider between two things; it is not enough for the
     edge of something you are meant to find and tap. */
  border: 2px solid var(--ink-faint);
  border-radius: 6px;
  background: var(--card);
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease;
}
input[type="checkbox"]::before {
  content: "";
  position: absolute; inset: 0; margin: auto;
  width: 62%; height: 62%;
  transform: scale(0);
  transition: transform .1s ease;
  background: #fff;
  clip-path: polygon(14% 44%, 0 65%, 45% 100%, 100% 20%, 82% 6%, 43% 62%);
}
input[type="checkbox"]:checked { background: var(--accent); border-color: var(--accent); }
input[type="checkbox"]:checked::before { transform: scale(1); }
input[type="checkbox"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
input[type="checkbox"]:disabled { opacity: .5; cursor: default; }

/* Heroic inspiration is a lone, prominent toggle — size it up a touch. The selector MUST
   out-specify the base `input[type="checkbox"]` (0,1,1) or none of this applies: a bare
   `.toggle` (0,1,0) silently lost the cascade, so this rendered at the base 22px — half the
   minimum, and the smallest control on the Combat tab (#77). */
input[type="checkbox"].toggle { width: 26px; height: 26px; }
/* 26px still reads as a checkbox; ::after carries the 44px tap target, the same trick the
   pips use. ::before is taken by the tick, and the base rule already sets position:relative. */
input[type="checkbox"].toggle::after { content: ""; position: absolute; inset: -9px; }

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--space-2);
  min-height: var(--tap);
  padding: var(--space-2) var(--space-4);
  background: var(--card);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: 8px;
  font-family: var(--sans);
  font-weight: 500;
  cursor: pointer;
}
.btn:hover { border-color: var(--ink-faint); }
.btn:active { transform: translateY(1px); }
.btn--primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.btn--danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, var(--line)); }
.btn--good { color: var(--good); border-color: color-mix(in srgb, var(--good) 40%, var(--line)); }
/* "Small" means smaller text and padding, NOT a smaller target. This used to set 36px, which
   beat the base .btn's var(--tap) on source order and put every card Edit / + Add button —
   including the only route to Max HP — under the minimum (#77). */
.btn--small { min-height: var(--tap); padding: var(--space-1) var(--space-3); font-size: var(--text-sm); }

.btn-row { display: flex; gap: var(--space-2); }
.btn-row > .btn { flex: 1; }

.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--tap); height: var(--tap);
  background: none; border: 0; border-radius: 8px;
  font-size: var(--text-lg); cursor: pointer;
}

.rule { border: 0; border-top: 1px solid var(--line); margin: var(--space-3) 0; }

.hint { margin: var(--space-2) 0 0; font-size: var(--text-xs); color: var(--ink-faint); line-height: 1.4; }

.field { margin: 0; min-width: 0; display: flex; flex-direction: column; gap: var(--space-1); }
.field > label {
  font-size: var(--text-xs); font-weight: 600; letter-spacing: .07em;
  text-transform: uppercase; color: var(--ink-faint);
}


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

/* Skip link (#126). First thing in the tab order, off-screen until focused. With a permanent
   roster column on a laptop and a five-tab bar on a phone, a keyboard user otherwise traverses
   the whole character list before reaching the sheet. Moved with `transform` rather than
   `display`/`visibility`, which would take it out of the tab order and defeat the point. */
.skip-link {
  position: absolute;
  left: var(--space-2);
  top: var(--space-2);
  z-index: var(--z-skip-link);
  display: inline-flex;
  align-items: center;
  min-height: var(--tap);
  padding: var(--space-2) var(--space-3);
  background: var(--card);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  text-decoration: none;
  transform: translateY(-250%);
}
.skip-link:focus { transform: none; }
/* The target is focused programmatically by the jump, so it must not paint a ring like a
   control would — it is a landmark, not something you interact with. */
.main:focus { outline: none; }

/* No JavaScript (#126). Everything below the shell is built by ES modules, so without them the
   page is an empty frame with no explanation. Says the data is untouched on purpose: someone
   arriving with a script blocker on has not lost anything. */
.noscript {
  margin: var(--space-4);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--card);
  color: var(--ink);
  line-height: 1.5;
}


.app { min-height: 100dvh; }

.main {
  display: flex;
  flex-direction: column;
  min-width: 0;
  /* clear the fixed tab bar plus the iPhone home indicator */
  padding-bottom: calc(64px + env(safe-area-inset-bottom));
}

.topbar {
  position: sticky; top: 0; z-index: var(--z-topbar);
  display: flex; align-items: center; gap: var(--space-2);
  height: calc(var(--topbar-h) + env(safe-area-inset-top));
  padding: var(--space-1) var(--space-2);
  padding-top: calc(.35rem + env(safe-area-inset-top));
  /* Landscape on a notched phone: keep the menu button clear of the cutout. */
  padding-left: calc(.6rem + env(safe-area-inset-left));
  padding-right: calc(.6rem + env(safe-area-inset-right));
  background: var(--parchment); /* fallback: a sticky bar must never be see-through */
  background: color-mix(in srgb, var(--parchment) 92%, transparent);
  -webkit-backdrop-filter: saturate(140%) blur(8px); /* older iOS Safari only ships the prefix */
  backdrop-filter: saturate(140%) blur(8px);
  border-bottom: 1px solid var(--line);
}

.topbar__id { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar__name {
  font-family: var(--serif); font-size: var(--text-lg); font-weight: 600;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.topbar__sub {
  font-size: var(--text-xs); color: var(--ink-faint);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.saved {
  flex: none; min-width: 3.5rem; text-align: right;
  font-size: var(--text-xs); color: var(--ink-faint);
  transition: opacity .2s;
}
.saved[data-tone="error"] { color: var(--bad); }

.banner {
  margin: var(--space-2); padding: var(--space-3) var(--space-4);
  background: color-mix(in srgb, var(--bad) 12%, var(--card));
  border: 1px solid color-mix(in srgb, var(--bad) 35%, var(--line));
  border-radius: var(--radius);
  font-size: var(--text-sm);
}

/* The banner is red by default because it normally carries a failure. A waiting
   update is good news, so it gets the accent instead — a red bar that means
   "nothing is wrong" trains you to ignore the red bar that means something is. */
.banner--info {
  display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
  background: color-mix(in srgb, var(--accent) 10%, var(--card));
  border-color: color-mix(in srgb, var(--accent) 32%, var(--line));
}
.banner--info .btn { margin-left: auto; }

.empty { padding: var(--space-8) var(--space-6); text-align: center; color: var(--ink-soft); }
.empty h2 { margin: 0 0 var(--space-2); }
.empty p { margin: 0 0 var(--space-5); }

/* ------------------------------------------------------------- sidebar */

.sidebar {
  position: fixed; inset: 0 auto 0 0; z-index: var(--z-drawer);
  display: flex; flex-direction: column;
  width: min(84vw, 320px);
  padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
  background: var(--card);
  border-right: 1px solid var(--line);
  transform: translateX(-100%);
  transition: transform .22s ease;
}
.drawer-open .sidebar { transform: none; }

.sidebar__head {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-2); padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
  border-bottom: 1px solid var(--line);
}
.brand { margin: 0; font-size: var(--text-lg); }

.roster { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: var(--space-2); }
.roster__item { margin-bottom: var(--space-1); }
.roster__btn {
  display: flex; flex-direction: column; gap: var(--space-0);
  width: 100%; min-height: var(--tap);
  padding: var(--space-2) var(--space-3);
  background: none; border: 1px solid transparent; border-radius: 8px;
  text-align: left; cursor: pointer;
}
.roster__btn:hover { background: var(--parchment); }
.roster__btn[aria-current="true"] {
  background: color-mix(in srgb, var(--accent) 12%, var(--card));
  border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
}
.roster__name { font-family: var(--serif); font-size: var(--text-md); }
.roster__meta { font-size: var(--text-xs); color: var(--ink-faint); }

.sidebar__foot { padding: var(--space-3); border-top: 1px solid var(--line); }
.sidebar__foot > .btn--primary { width: 100%; margin-bottom: var(--space-2); }

.scrim { position: fixed; inset: 0; z-index: var(--z-scrim); background: rgba(20, 14, 8, .45); }

/* --------------------------------------------------------------- tiles */

/* A segmented readout inside the Combat card (it used to be the sticky top strip).
   Twelve `1fr` tracks, no minimum: the tracks shrink with the card, so this still packs
   sensibly whether the card is a full-width phone column or a narrow multicol column on a
   laptop — which is what the `auto-fit, minmax(4.5rem, 1fr)` it replaced was for. Fixed
   twelve rather than auto-fit because the layout config now names a width in twelfths, and
   auto-fit's track count varies with the container. Every old width survives to the pixel:
   see the GRID_COLUMNS note in layout-registry.js. */
/* ---- tile: the one uniform box. Shell = label, Body = value ⇄ input | live control.
   Every box shares this grid, so any arrangement stays aligned — the #54 groundwork. ---- */
.tiles {
  display: grid; grid-template-columns: repeat(12, 1fr);
  gap: var(--space-2); margin: 0;
  /* The unit the height slider counts in. Small on purpose: a tile is naturally 6–8 of these,
     so the slider's 4..16 is about half to double its own size, with useful travel each way.
     Declared here rather than on :root so it reads beside the grid it sizes — layout-view
     measures it rather than parsing it, since it is authored in rem. */
  --tile-step: .75rem;
}
/* The Spells card's two stat tiles are the one `.tiles` grid with no layout objects in it, so
   nothing writes them an inline span. Half each, which is what auto-fit's collapsing empty
   tracks gave them before. */
.spell-stats > .tile { grid-column: span 6; }
.tile {
  display: flex; flex-direction: column; gap: var(--space-0);
  padding: var(--space-2) var(--space-1);
  background: var(--card);
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  /* isolated boxes with a gap, so a partial last row just leaves clean empty space
     (no hairline-grid strip) — and they read as the movable units #54 will rearrange. */
}
/*
 * Config-owned height (item 1). Written inline as a `calc()` of --tile-step, and ONLY on a tile
 * whose height a player has actually set — untouched, a tile is as tall as its contents, exactly
 * as before.
 *
 * An explicit `height`, not a `min-height`. A minimum can only add space, so the shortest a tile
 * could be was already the tallest thing inside it and the bottom half of the slider did
 * nothing. Which means this half has to absorb a tile deliberately made SHORTER than its
 * contents: `overflow: hidden` keeps the excess inside its own box rather than spilling across
 * the tile below, and the label gives back the second line it reserves for wrapping, which is
 * the largest piece of slack a small tile has.
 */
.tile.is-sized {
  height: var(--tile-h);
  min-height: 0;
  overflow: hidden;
  /* Short or tall, the contents sit in the middle of whatever room there is. */
  justify-content: center;
}
/* A sized tile has a FIXED height, so the label cannot reserve two lines it may not have room
   for. Reserving them anyway added ~13px to every sized tile's content (a one-line "PB" label
   went 13.2 -> 26.4) and pushed it past the height the player had chosen, cropping the header
   off the top of the Combat tiles. Alignment across a row is handled by `.tile__body`'s auto
   top margin instead, which can only ever consume space that is already free. */
.tile.is-sized .tile__label { min-height: 0; }
/* Tile children fill the tile's width — EXCEPT screen-reader-only helpers. `.visually-hidden`
   pins width to 1px, but it and this rule share specificity (0,1,0), so without the :not()
   this later rule wins and a visually-hidden label/heading inside a `.tile` (Rest's label, the
   Conditions heading — both since #65) resolves width:100% against the viewport (it is
   position:absolute with no positioned ancestor) → a ~467px invisible box → horizontal
   overflow, empty scroll space, and a clipped tab bar. */
.tile > *:not(.visually-hidden) { width: 100%; }
/* Object width (1× / 2× / full) is owned by the layout config (#54 Phase 6): applyObjects
   writes `grid-column` inline per object, so there is no static width class here anymore. */
.tile__label {
  /* Explicitly sans, because half these labels are <h3> and half are <span>, and the global
     `h1, h2, h3 { font-family: var(--serif) }` was silently giving Death Saves, Conditions,
     Hit Point Dice and Exhaustion a serif face while Hit Points, AC, Initiative, Speed and the
     rest stayed sans. Same size, same weight, same tracking — different typeface, in one row of
     tiles. The class outranks the element selector, so this is all it takes. */
  font-family: var(--sans);
  /* Half these labels are <h3>, which carries the UA's `1em 0` block margin — ~20px of extra
     height on Death Saves, Conditions, Hit Point Dice and Exhaustion that the <span> labels
     never had. Third silent difference between the two, after the typeface and the centring. */
  margin: 0;
  font-size: var(--text-2xs); font-weight: 600; letter-spacing: .07em;
  text-transform: uppercase; color: var(--ink-faint);
  /* Deliberately NO `width: 100%` here. A tile's direct children already get it from
     `.tile > *:not(.visually-hidden)`, and they are blockified as flex items so `text-align`
     centres against the tile. Setting it on the class instead defeats that `:not()`: the Rest
     and Conditions labels are `.visually-hidden`, i.e. `position: absolute` with no positioned
     ancestor, so 100% resolves against the VIEWPORT — two invisible 375px boxes off the right
     edge and the whole document scrolling sideways into empty space. The label that genuinely
     needs stretching is the one inside a `.list__head`, handled there as a flex basis. */
  text-align: center; line-height: 1.2;
  /* Reserve two lines (#65 symptom 1) so a one-line label (AC) and a two-line one
     (Prof. Bonus) leave their tile bodies on the same baseline across a row. */
  min-height: 2.4em;
  /* Arrange mode lets you rename any tile to arbitrary text, and a 1× tile is only ~4.5rem
     wide — without this a single long word escapes the tile and scrolls the page sideways. */
  overflow-wrap: anywhere;
}
.tile__body {
  display: flex; align-items: center; justify-content: center; flex-wrap: wrap;
  gap: var(--space-1); min-height: var(--tap);   /* 44px, so a solo action tile's button clears the minimum (#77) */
  /* Sit at the bottom of whatever room is left, so a one-line label ("Conc.") and a two-line
     one ("Heroic Insp.") still leave their controls level across a row — the guarantee
     `.tile__label`'s two-line reservation gives unsized tiles (#65 symptom 1), but which a
     sized tile cannot afford to buy with height. An auto margin only ever absorbs space that
     is ALREADY free, so unlike a min-height it can never make a tile overflow: on an unsized
     tile (height = content) it resolves to 0 and changes nothing. */
  margin-top: auto;
}
/* Action tiles (Rest): the body grows to the tile's stretched height (flex:1) and stretches
   its child on the cross axis, so the button fills the tile in BOTH dimensions instead of a
   lonely centred pill (#65 symptom 3). flex:1 1 auto on the button owns the width. */
/* The button fills the tile in BOTH dimensions rather than sitting as a lonely centred pill
   (#65 symptom 3). #75 briefly removed `flex: 1` to de-emphasise Long Rest; that left a
   half-empty tile next to full ones and was reverted — Long Rest is instead de-emphasised by
   being ordered last. */
.tile__body--actions { gap: var(--space-2); flex: 1; align-items: stretch; }
.tile__body--actions .btn { flex: 1 1 auto; }
.tile__value {
  font-family: var(--serif); font-size: var(--text-xl); font-weight: 600;
  font-variant-numeric: tabular-nums; line-height: 1.1;
}
/* Typography only. The box — full width, .4rem/.6rem padding, border, fill and the 44px
   min-height — comes from the base `input[type=…]` rule, which at (0,1,1) outranks this
   (0,1,0). This used to declare `width:3.2rem; min-height:34px; background:none; border:0`;
   none of it ever applied, and it read as a touch-target violation that wasn't there (#77). */
.tile__input {
  font-family: var(--serif); font-size: var(--text-xl); font-weight: 600; text-align: center;
  /* Same line box as .tile__value, or the two do not line up in a row of tiles.
     Without it the input takes the UA's `normal` line-height (~31.9px at 22px serif), which
     plus the base input padding makes the box 49.9px — taller than the 44px `.tile__body`
     min-height. The body grows to fit, and the number inside it lands ~3px lower than the
     <output> in the tile beside it. That is the "17 sits lower than PB, Init and Speed" report:
     AC and Speed keep a real field in view mode (#76), the others render an <output>, and only
     the field was pushing its own row down. At 1.1 the natural box is 42.2px, so min-height
     wins at exactly 44 and the glyphs centre where the outputs' do. */
  line-height: 1.1;
}
/* The focus tint is for a field you are TYPING in. `:not(:read-only)` matters since #76 made
   AC and Speed keep their field in view mode: locked, it still took focus on a tap and painted
   `--parchment`, which in dark mode is the near-black page background — a black box filling the
   tile. A locked value is display-only and must look identical focused or not. */
.tile__input:not(:read-only):focus { background: var(--parchment); border-radius: 6px; }

/* ---- what a tap reaches (#76) ------------------------------------------------------
   The card used to run three editability rules behind one appearance, with two different
   Edit-gating mechanisms inside it: AC and Speed hid their field and swapped in an <output>,
   while Max HP stayed put and merely flattened. A locked Max HP was then indistinguishable
   from a derived readout, and Initiative/PB blanked entirely on Edit with nothing to explain
   why. One rule replaces all of that:

     Edit-gated STORED value (AC, Max HP)   the field is always here. Flat + dashed underline
                                            when locked, boxed when unlocked.
     DERIVED readout (Initiative, PB,       no box, no underline, no field — ever. It no
     Spell save DC, Spell attack)           longer disappears in Edit mode either; the
                                            absence of an underline is what says "computed".
     BOTH (Speed)                           the computed value stays on screen and Edit
                                            reveals the base field beneath it.

   For a player it is one sentence: underlined means Edit reaches it.
   No `display` on the base input selector — that would outrank class hides, as the checkbox
   bug proved; live inputs ([data-live]) always show regardless. */
/* No marker on Edit-gated values. #76 added a dashed underline under AC, Speed and Max HP to
   say "Edit reaches this"; it read as clutter on a sheet that is meant to look like a sheet,
   and was reverted. The flatten-in-place treatment itself stays: a locked value is flat text,
   Edit gives it a box. What responds to a tap is therefore still not signposted in view mode
   — see #76's first acceptance bullet, deliberately left open rather than solved with dashes. */

/* Speed's base field is the one input still revealed rather than flattened, because its tile
   already shows the derived value and two numbers in view mode would be a puzzle. Editing
   SWAPS them for the same reason: the tile showed the Exhaustion-adjusted 20 stacked above the
   base 30, two numbers for one row with nothing saying which was which. Base is what you edit;
   the derived readout returns on Done. */
.tile__input--base { display: none; }
.is-editing .tile__input--base { display: block; }
.is-editing .tile__value--effective { display: none; }

/* --------------------------------------------------------------- cards */

.cards { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3); }

/* Each tab panel stacks its own cards. Only the active panel is shown; the rest carry
   the [hidden] attribute. On a laptop this is overridden to display:contents so every
   panel's cards flow into one multi-column layout (see § laptop). */
.tabpanel { display: flex; flex-direction: column; gap: var(--space-3); }

.card {
  padding: var(--space-3);
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}
.card--muted { opacity: .72; }

.card__title {
  margin: 0 0 var(--space-3);
  font-size: var(--text-lg);
  padding-bottom: var(--space-2);
  border-bottom: 1px solid var(--line-soft);
}

.subhead {
  margin: var(--space-4) 0 var(--space-2);
  font-size: var(--text-xs); font-weight: 700; letter-spacing: .1em;
  text-transform: uppercase; color: var(--ink-faint);
  font-family: var(--sans);
}
.subhead:first-child { margin-top: 0; }

/* `.slots__head` dropped alongside these: the slots got their own card, so their heading is a
   `.card__title` in a `.list__head` like every other card's, and the sub-head that used to sit
   inside Spellcasting no longer exists. */
.list__head {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-2); margin-bottom: var(--space-2);
}
/* A head that belongs to a TILE rather than a card (Features, Feats). Its label is that tile's
   header and has to read like every other tile's — full width, centred — so it claims its own
   line and the + Add button drops below it, centred too. Left as a flex child it centred inside
   its own ~92px box at the left edge of a 310px tile, which is the "some headers are on the
   left" half of the uniformity problem. */
.tile > .list__head { flex-wrap: wrap; justify-content: center; }
.tile > .list__head > .tile__label { flex: 1 0 100%; }
/* Hit Point Dice drops the head entirely: its + Add sits after the pools it appends to. The
   tile is already a flex column, so centring is one property and the button's existing
   display:none gating removes it (and its margin) outside Edit.

   Selector written as `.tile > .tile__add`, not `.tile__add`: `.tile > *:not(.visually-hidden)`
   forces every tile child to `width: 100%` and scores (0,2,0), so a bare class loses to it and
   the button stretches the full tile with `align-self: center` having nothing left to centre. */
.tile > .tile__add { align-self: center; width: auto; margin-top: var(--space-2); }
.list__head .card__title { margin: 0; border: 0; padding: 0; }
.list__head .subhead { margin: 0; }

/* ----------------------------------------------------- layout arrange (#54) */

/* The reorder ↑/↓ controls injected into a card's head while arranging. They only exist in
   the DOM during arrange mode (layout-view injects/removes them), so no is-arranging gate. */
/* Wraps (#71): at 44px per control the group no longer always fits beside the rename field on
   a 390px head, and an inline-flex that can't wrap would overflow the card instead. */
.card__move { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-0); margin-left: auto; }
.card__move .icon-btn {
  width: var(--tap); height: var(--tap); font-size: var(--text-lg);
  border: 1px solid var(--line); border-radius: 8px;
}
.card__move .icon-btn:disabled { opacity: .3; cursor: default; }
.card__movetab {
  min-height: var(--tap); max-width: 9rem; min-width: 0;
  padding: 0 var(--space-2); font-size: var(--text-xs);
  color: var(--ink); background: var(--card);
  border: 1px solid var(--line); border-radius: 8px;
}

/* Editable card title (#54): while arranging, an input stands in for the static .card__title
   (hidden below), sitting where the title was and growing to fill the head. */
body.is-arranging .card__title { display: none; }
/* Wraps to its own line rather than crushing the title (#71): at 44px the ↑/↓ pair plus the
   "Move to…" select no longer share a 390px head with a readable name field — measured, the
   old `min-width:0` collapsed this to 21px. The floor forces .card__move onto line two. */
body.is-arranging .list__head { flex-wrap: wrap; }
.card__rename {
  flex: 1 1 8rem; min-width: 8rem; min-height: var(--tap);
  padding: 0 var(--space-2); font-size: var(--text-md); font-weight: 600;
  color: var(--ink); background: var(--card);
  border: 1px solid var(--line); border-radius: 8px;
}

/* The arrange toolbar: hidden except in arrange mode (no `hidden` attr — it would collide
   with the global [hidden] kill-switch; a plain display swap is safe). */
.arrange-bar { display: none; }
body.is-arranging .arrange-bar {
  display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap;
  position: sticky; top: calc(var(--topbar-h) + env(safe-area-inset-top)); z-index: var(--z-below-topbar);
  padding: var(--space-2) var(--space-3);
  background: color-mix(in srgb, var(--accent) 10%, var(--card));
  border-bottom: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line));
}
/* `flex: 0 1 auto` (not `1` with an 8rem floor) so the label and Done share ONE row instead of
   being forced apart — the mode name doesn't need to claim a line of a phone screen (#73). */
.arrange-bar__label { flex: 0 1 auto; min-width: 0; font-size: var(--text-xs); color: var(--ink-soft); }
.arrange-bar__actions { display: flex; gap: var(--space-2); margin-left: auto; }

/* The rare half of arrange mode, folded away (#73). Tab editing alone is ~290px — 75% of what
   the bar used to be — for work you do once and then never again, and it sat open the whole
   time you were nudging tiles. */
.arrange-more { flex-basis: 100%; }
.arrange-more__summary {
  display: flex; align-items: center; gap: var(--space-2);
  min-height: var(--tap); cursor: pointer; list-style: none;
  font-size: var(--text-xs); color: var(--ink-soft);
}
.arrange-more__summary::-webkit-details-marker { display: none; }
/* A real element (aria-hidden in the markup) rather than generated content, so the glyph can't
   leak into the summary's accessible name — same reasoning as `.conditions__marker`. */
.arrange-more__marker { display: inline-block; color: var(--ink-faint); transition: transform .12s; }
.arrange-more[open] > .arrange-more__summary .arrange-more__marker { transform: rotate(90deg); }
.arrange-more__body { padding-bottom: var(--space-1); }
.arrange-more__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); }

.tablist-edit { flex-basis: 100%; }
.tablist-edit__rows { list-style: none; margin: 0 0 var(--space-2); padding: 0; display: flex; flex-direction: column; gap: var(--space-1); }
.tabrow { display: flex; align-items: center; gap: var(--space-1); }
.tabrow__name {
  flex: 1; min-width: 0; min-height: var(--tap);
  padding: 0 var(--space-2); font-size: var(--text-sm);
  color: var(--ink); background: var(--card);
  border: 1px solid var(--line); border-radius: 8px;
}
.tabrow .icon-btn {
  width: var(--tap); height: var(--tap); flex: none; font-size: var(--text-md);
  border: 1px solid var(--line); border-radius: 8px;
}
.tabrow .icon-btn:disabled { opacity: .3; cursor: default; }

/* Per-object arrange controls (#71/#72/#73). They used to be a 22px cluster and a rename
   field pinned INSIDE each tile: the cluster could not reach 44px (four buttons need 176px in
   a ~72px tile, which the old comment stated outright) and the two together covered ~45% of
   the tile you were arranging, twelve times over on the Combat card.

   Now the tile itself is the target — tap to select — and one full-size control set lives in
   the arrange bar. Nothing overlays the content, every control clears 44px, and there is room
   for words instead of `↑ ↓ ↔ 👁` plus a visible width readout. */
body.is-arranging [data-object] {
  position: relative;
  /* The card is pointer-inert while arranging; objects opt back in so a tap can select one. */
  pointer-events: auto;
  cursor: pointer;
}
/* ...and every descendant opts straight back OUT, so the tile is the tap target rather than
   merely the outermost one. `pointer-events` is inherited, so the line above handed the whole
   subtree back to the pointer along with the tile — which is how a <summary> kept opening its
   <details> and a chip's <label> kept ticking its checkbox while the player was moving furniture.
   Making the children transparent resolves every tap in the tile to the tile itself, so those
   default actions have nothing to act on and a bound field can no longer take focus and show a
   caret in a mode that will not accept typing. The JS guards stay the authority (they also cover
   the keyboard, which hit-testing never sees); this is what makes the tile feel like one button. */
body.is-arranging [data-object] * { pointer-events: none; }
body.is-arranging [data-object]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The selected tile reads as picked without hiding anything: an accent ring plus a faint wash. */
body.is-arranging [data-object].is-selected {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  background: color-mix(in srgb, var(--accent) 8%, var(--card));
}

.objbar {
  flex-basis: 100%;
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
}
/* Shown until a tile is picked, so the mode opens saying what to do rather than showing an empty
   strip. It is also the only place the OBJECT controls are explained — the bar's own label only
   ever described the card ones, which is #72's third complaint. */
.objbar__hint { font-size: var(--text-xs); color: var(--ink-soft); }
/* The rename field shares row 1 with the two buttons. It used to claim a row of its own,
   because at `8rem` it sat beside Up and Down and pushed Width and Hide onto row 2 — four
   controls reading as two unrelated pairs. There are two buttons left (Up/Down became "Move
   to…" in #73, Width became a slider), so the pair fits beside it and the row it was spending
   is what pays for the size sliders below. */
.objbar .obj-rename {
  /* `max-width` stops it stretching to 1100px of empty field on a laptop, where arrange mode
     still forces the single-column phone layout. */
  flex: 1 1 8rem; min-width: 6rem; max-width: 24rem; min-height: var(--tap);
  padding: 0 var(--space-2); font-size: var(--text-sm); font-weight: 600;
  color: var(--ink); background: var(--card);
  border: 1px solid var(--line); border-radius: 8px;
}
/* --- "pick it up, tap where it goes" (#73) --- */

/* One column for the duration of a move. Objects are 1×/2×/full wide, so a full-width insertion
   line between two side-by-side tiles would mean nothing — and adding cells to the grid would
   reflow it anyway. One column makes the order you are inserting into the order you can see. */
body.is-arranging .is-placing { grid-template-columns: 1fr; }
/* The span has to be neutralised too, or a `grid-column: span 2` tile creates an IMPLICIT second
   track and the grid is two columns again despite the single explicit one above. `!important` is
   the only lever that reaches it: applyObjects writes the width INLINE on every object, because
   the layout config owns it. Nothing here is persisted — the next applyLayout rewrites the real
   spans, so there is no state to restore when the move ends. */
body.is-arranging .is-placing > [data-object] { grid-column: 1 / -1 !important; }
/* The tile being moved reads as picked up rather than merely selected. */
body.is-arranging [data-object].is-lifted {
  opacity: .55;
  outline: 2px dashed var(--accent);
  outline-offset: 2px;
}
/* A real 44px button, not a hairline: this is the touch target the whole gesture rests on. */
.dropslot {
  grid-column: 1 / -1;
  display: flex; align-items: center; justify-content: center;
  min-height: var(--tap); padding: 0;
  font: inherit; font-size: var(--text-xs); letter-spacing: .06em; text-transform: uppercase;
  color: var(--accent); background: none;
  border: 2px dashed color-mix(in srgb, var(--accent) 45%, transparent);
  border-radius: 8px; cursor: pointer;
}
.dropslot:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
/* The two gaps either side of the tile's current home: shown (so the row of choices doesn't
   jump) but inert, and quiet enough not to read as somewhere to go. */
.dropslot:disabled {
  color: var(--ink-faint); border-color: var(--line); border-style: dotted; cursor: default;
}
.dropslot__text { pointer-events: none; }

.objbar__btn { flex: 0 1 auto; }
.objbar__btn:disabled { opacity: .35; cursor: default; }
.objbar__btn.is-off { border-style: dashed; }   /* the hide toggle once the object is hidden */
.objbar__pos {
  flex: none; margin-left: auto;
  font-size: var(--text-xs); color: var(--ink-soft); font-variant-numeric: tabular-nums;
}

/* The two size sliders (item 1). One per line and full width: eleven width stops across ~300px
   is ~27px of travel each, which is a comfortable drag; side by side it would be ~12px, and
   the whole point of replacing the three-step cycle button was to make the in-between sizes
   reachable rather than nominally available. */
.objbar__size {
  flex: 1 1 100%;
  display: flex; align-items: center; gap: var(--space-2);
  min-height: var(--tap);
}
.objbar__size-cap {
  flex: 0 0 3.4rem;
  font-size: var(--text-xs); font-weight: 600; letter-spacing: .06em;
  text-transform: uppercase; color: var(--ink-faint);
}
/* The readout sits at a fixed width so the slider's track doesn't resize under the thumb when
   the text goes from "a third" to "5/12 wide" mid-drag. */
.objbar__size-out {
  flex: 0 0 7.5rem;
  font-size: var(--text-xs); color: var(--ink-soft); text-align: right;
}
.objbar__range {
  flex: 1 1 auto; min-width: 0;
  /* The full 44px is the tap target — the visible track is drawn inside it, so the control is
     comfortable to hit without looking like a 44px-thick bar. */
  height: var(--tap);
  margin: 0; padding: 0;
  background: none; border: 0;
  accent-color: var(--accent);
  cursor: pointer;
}

/* Drag-and-drop reorder (#54 Phase 7). The ⠿ grip is a MOUSE affordance — hidden by default and
   revealed only on a fine pointer, since native HTML5 drag doesn't work on touch (arrows remain
   the touch path there). With the grip shown, the object cluster widens to fit 3 controls a row. */
.drag-grip { display: none; }
@media (pointer: fine) {
  body.is-arranging .drag-grip {
    display: inline-flex; align-items: center; justify-content: center; flex: none;
    width: 22px; height: 22px; font-size: var(--text-xs);
    color: var(--ink-soft); background: var(--card);
    border: 1px solid var(--line); border-radius: 6px;
    cursor: grab; user-select: none;
  }
  body.is-arranging .card__move .drag-grip { width: var(--tap); height: var(--tap); font-size: var(--text-md); }
  body.is-arranging .drag-grip:active { cursor: grabbing; }
}
/* Objects drag from the tile itself now, so the cursor has to say so (mouse only — native
   drag does nothing on touch, where Up/Down in the toolbar is the path). */
@media (pointer: fine) {
  body.is-arranging [data-object] { cursor: grab; }
  body.is-arranging [data-object]:active { cursor: grabbing; }
}

/* The item being dragged dims; the drop target shows an accent bar on the edge where it will land
   (top/bottom for the vertical card list, left/right for the inline object grid). */
.card.is-dragging, [data-object].is-dragging { opacity: .4; }
body.is-arranging .card.drop-before { box-shadow: 0 -3px 0 0 var(--accent); }
body.is-arranging .card.drop-after { box-shadow: 0 3px 0 0 var(--accent); }
body.is-arranging [data-object].drop-before { box-shadow: -3px 0 0 0 var(--accent); }
body.is-arranging [data-object].drop-after { box-shadow: 3px 0 0 0 var(--accent); }

/* A hidden object stays visible-but-dimmed (dashed) while arranging so it can be unhidden;
   outside arrange the `hidden` attribute removes it via the global [hidden] kill-switch. */
body.is-arranging [data-object].is-hidden {
  opacity: .5;
  outline: 1px dashed color-mix(in srgb, var(--ink-faint) 70%, transparent);
  outline-offset: 1px;
}

/* Arrange mode forces the phone-like single-tab view at EVERY width, so cards are reordered
   on one clean vertical list (on a laptop the panels otherwise merge into multi-column). The
   compound `body.is-arranging` selectors outrank the ≥900px rules on specificity alone. */
body.is-arranging .cards { display: flex; flex-direction: column; }
body.is-arranging .tabpanel { display: flex; flex-direction: column; gap: var(--space-3); }
body.is-arranging .tabpanel[hidden] { display: none !important; } /* beat the desktop contents-reveal */
body.is-arranging .tabbar { display: flex; }                      /* keep tab-switching on desktop */

/* Card content is inert to the pointer while arranging — only the ↑/↓ controls stay live —
   so a tap can't edit a field mid-reorder. Plus a subtle "this is movable" outline, and the
   per-card content Edit / + Add are hidden (you're arranging, not typing). */
body.is-arranging .card {
  pointer-events: none;
  outline: 2px dashed color-mix(in srgb, var(--accent) 40%, transparent);
  outline-offset: 2px;
}
body.is-arranging .card__move, body.is-arranging .card__move *,
body.is-arranging .card__rename { pointer-events: auto; } /* the rename field must stay interactive */
/* Drop targets sit inside the card, so they need the same opt-out of its pointer-inertness that
   [data-object] gets — without this the whole "tap where it goes" gesture is unclickable (#73). */
body.is-arranging .dropslot { pointer-events: auto; }
body.is-arranging .card__edit,
body.is-arranging [data-action="add-row"] { display: none; }

/* The Identity card: one field per row, labels in one column and values in the other.

   Side-by-side fields cannot be made to line up here, and two goes at it proved it. The
   original `auto-fit, minmax(6.5rem)` gave all eight the same 106px track at 390px, which
   cropped four of them while Level — two digits — sat in a box it could never fill. Sizing
   each field by its content instead (Name 4 tracks, Level 2, the rest 3) stopped the
   cropping but broke the column: the right-hand field of the first row started at a
   different x from every row below it. And in both, each value sat ~10px right of its own
   label, because an input carries side padding that a bare <label> does not.

   Two real columns end all three. `max-content` sizes the label column to the longest label
   and hands the rest to the value — about 240px at 390px, against the 164px that cropped
   "Emerald Enclave Captain" at eighteen characters. `display: contents` promotes each
   field's label and input to grid items so the columns are real, with no wrapper markup and
   nothing for renderSheet to know about. */
.field-grid {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: var(--space-1) var(--space-3);
  align-items: center;
}
.field-grid > .field { display: contents; }

/* Every value in this card is a word, so the base centring for input[type=number] made
   Level read as a different kind of field. Centred stays correct where the number IS the
   value — ability scores, currency, the tiles. */
.field-grid input[type="number"] { text-align: start; }

.inline-fields { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.inline-fields > .field { flex: 1 1 7rem; }

/* ----------------------------------------------------------- abilities */

/* Six abilities wrap to fit whatever column the card lands in. A fixed
   repeat(6, 1fr) clipped WIS and CHA straight out of the card at laptop width. */
/* Block flow, NOT flex: WebKit cannot fragment flex containers when printing, so a
   flex .abilities would make the six-group stack monolithic in Safari/iOS and slice
   it at the raw column edge instead of breaking between groups. */
.abilities { display: block; }
.ability-group + .ability-group { margin-top: var(--space-2); }

/* One group per ability (#14): a header row with the score and modifier, its skills below. */
.ability-row {
  display: flex; align-items: center; gap: var(--space-2);
  min-height: var(--tap);
  padding: var(--space-1) var(--space-2);
  background: var(--parchment);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
}
.ability-row__name {
  flex: 1; margin: 0; /* h3: kill the UA heading margin inside the flex row */
  font-size: var(--text-xs); font-weight: 700; letter-spacing: .08em;
  text-transform: uppercase; color: var(--ink-soft);
}
.ability-row__score {
  /* The global input[type="number"] rule wins width:100% on specificity; the cap
     has to come from max-width, the same dodge the old ability tile used. */
  width: 100%; max-width: 3.4rem;
  font-size: var(--text-sm); text-align: center;
  display: none;
}
.is-editing .ability-row__score { display: inline-block; }
.ability-row__score-display {
  min-width: 3.4rem; text-align: center;
  font-size: var(--text-sm);
}
.is-editing .ability-row__score-display { display: none; }
.ability-row__mod {
  min-width: 2.6rem; text-align: center;
  font-family: var(--serif); font-size: var(--text-xl); font-weight: 600;
  font-variant-numeric: tabular-nums;
}
/* Passive Perception sits under the ability grid on the Abilities tab. */
.passive {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-2); margin-top: var(--space-3); padding-top: var(--space-3);
  border-top: 1px solid var(--line-soft);
}
.passive__label {
  font-size: var(--text-xs); font-weight: 600; letter-spacing: .07em;
  text-transform: uppercase; color: var(--ink-faint);
}
.passive__value {
  font-family: var(--serif); font-size: var(--text-xl); font-weight: 600;
  font-variant-numeric: tabular-nums; line-height: 1.1;
}

/* -------------------------------------------------------------- skills */

/* Collapsed by default: name / marker / total. "Edit" reveals prof/exp/misc to
   change them — the marker is a real (not decorative) readout of the same state,
   so the collapsed view stays fully accessible with the checkboxes hidden. */
.skills__legend, .skill {
  display: grid;
  grid-template-columns: 1fr 1.8rem 2.6rem;
  align-items: center;
  gap: var(--space-1);
}
/* The three edit-only columns are a full --tap wide so the controls in them can carry a
   44px target without colliding (#86). At the previous 2.4rem they sat 43.19px centre to
   centre — 0.81px too close for two 44px hit areas, and overlapping targets silently steal
   taps for whichever element paints last. At --tap the centres are 48.8px apart. The cost
   is ~17px taken from the 1fr name column, which measurement says is there: the widest
   label ("Saving Throw") still fits unclipped and the document does not overflow 390px. */
.is-editing .skills__legend, .is-editing .skill {
  grid-template-columns: 1fr var(--tap) var(--tap) var(--tap) 2.6rem;
}
/* One compact legend per ability group, sharing the skill grid so it can never
   misalign — and never scrolls far from the checkboxes it labels. */
.skills__legend {
  padding: var(--space-0) var(--space-1) 0;
  font-size: var(--text-2xs); font-weight: 700; letter-spacing: .08em;
  text-transform: uppercase; color: var(--ink-faint);
  text-align: center;
}
.skills__legend > :first-child { text-align: left; }

.skills { list-style: none; margin: 0; padding: 0; }
.skill { min-height: var(--tap); border-bottom: 1px solid var(--line-soft); padding: 0 var(--space-1); }
.skill:last-child { border-bottom: 0; }
.skill__name { font-size: var(--text-sm); }

.skill__prof, .skill__exp, .skill__bonus,
.legend__prof, .legend__exp, .legend__bonus, .legend__total {
  display: none;
}
.is-editing .skill__prof, .is-editing .skill__exp, .is-editing .skill__bonus,
.is-editing .legend__prof, .is-editing .legend__exp, .is-editing .legend__bonus,
.is-editing .legend__total {
  display: inline-block;
}
.is-editing .skill__marker { display: none; }

/* Init bonus / All-skills bonus are edit-only too — nothing to tap when the row
   below is just a name, a marker and a total. */
#panel-abilities .inline-fields { display: none; }
#panel-abilities .is-editing .inline-fields { display: flex; }

.skill__prof, .skill__exp, .skill__bonus { justify-self: center; }

/* The box stays 22px — the same idiom as .pip and .toggle — and ::after carries the 44px
   target: (44-22)/2 = 11px on each side. The base `input[type=checkbox]` rule already sets
   position:relative, and ::before is taken by the tick, so ::after is the free hook.
   Grow the COLUMN to change the target, never the inset: the clearance to the neighbour is
   half the gap plus half the spare column width, and widening the inset past that
   re-introduces exactly the overlap this comment exists to prevent (see .pip).

   Qualified with `input`, and that is load-bearing (#115). A save row has no expertise, so its
   Exp cell is an empty `<span class="skill__exp">` spacer — same class, no checkbox. A span gets
   no `position: relative` from the rule above, so on the bare class this inset resolved against
   the INITIAL CONTAINING BLOCK instead of the control: a 397x907 invisible sheet per ability
   group, six of them, anchored at the document origin and covering every control in the first
   screenful of the document. Ability scores above the fold went dead, and so did Done on cards
   the player had never opened — the Abilities Edit button poisoned Inventory and Feats too.
   The touch-target audit had no chance of catching it: an empty spacer span is not a control, so
   nothing measured it, and a 44px audit asks how big each control is rather than whether anything
   is sitting on top of it. probe.html answers both questions now. Same family as
   the `.visually-hidden`/`width:100%` trap guarded at `.tile > *:not(.visually-hidden)`: in this
   stylesheet an absolutely-positioned box whose owner might not be positioned is a bug waiting
   for the day someone reuses the class on a plain element. */
input.skill__prof::after, input.skill__exp::after { content: ""; position: absolute; inset: -11px; }

.skill__bonus {
  /* input[type="number"] elsewhere sets width:100% — cap it to the column, which is now a
     full --tap so the field clears the minimum on its short side too (#86; it measured
     38.4px wide while its column was 2.4rem). */
  width: 100%; max-width: var(--tap);
  font-size: var(--text-xs);
}
.skill__marker {
  justify-self: center;
  font-size: var(--text-xs); font-weight: 700;
  color: var(--ink-faint);
}
.skill__total {
  justify-self: center;
  font-family: var(--serif); font-size: var(--text-lg); font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* The saving throw is the first row of each ability group — same grid as the
   skills below it, lightly emphasized as a different kind of row. */
.skill--save .skill__name { font-weight: 600; }

/* ---------------------------------------------------------------- pips */

.pips { display: flex; flex-wrap: wrap; gap: var(--space-2); }
/* Centred only inside a TILE, where a left-packed pip row sits under a centred header and reads
   as crooked. Spell-slot pips are deliberately excluded: they live in a labelled row beside
   their level and total, not under a centred tile header, and centring them there was scope
   creep — the request was about the status tiles. */
.tile > .pips, .deathsaves .pips { justify-content: center; }

.pip {
  position: relative;
  width: 36px; height: 36px;
  padding: 0;
  background: var(--card);
  border: 2px solid var(--line);
  border-radius: 50%;
  cursor: pointer;
  transition: background .12s, border-color .12s;
}
/* The circle reads as 36px but the tap target is 44px (#77 — it was 30px/38px). The inset
   still matches half the gap exactly, so neighbouring pips extend up to each other and never
   overlap — an overlap would silently steal taps for the pip drawn last. Grow the CIRCLE to
   change the target, never the inset: widening the inset past half the gap re-introduces the
   overlap this comment exists to prevent. */
.pip::after { content: ""; position: absolute; inset: -4px; }
.pip.is-on { background: var(--accent); border-color: var(--accent); }
.pips--bad .pip.is-on { background: var(--bad); border-color: var(--bad); }
.pips--slot .pip.is-on { background: var(--gold); border-color: var(--gold); }

/* Successes and Failures side by side (was label-left / pips-right, stacked). Each column is
   its own centred stack, so the tile is one pip row tall instead of two and nothing sits off
   to one side under a centred header. Wraps back to two rows if the tile is made narrow. */
.deathsaves { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-2) var(--space-5); }
.deathsaves__col {
  flex: 1 1 8rem; min-width: 0;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-1);
}
.deathsaves__label {
  font-size: var(--text-xs); letter-spacing: .04em; color: var(--ink-soft);
}

/* ---------------------------------------------------------------- chips */

.chips { display: flex; flex-wrap: wrap; gap: var(--space-2); }

.chip {
  display: inline-flex; align-items: center; gap: var(--space-1);
  min-height: var(--tap); padding: var(--space-1) var(--space-2);   /* the label IS the tap target (#77) */
  background: var(--parchment);
  border: 1px solid var(--line);
  border-radius: 999px;
  font-size: var(--text-sm);
  cursor: pointer;
}
.chip:has(:checked) {
  background: color-mix(in srgb, var(--bad) 15%, var(--card));
  border-color: var(--bad);
  color: var(--bad);
  font-weight: 600;
}
/* Match the checkbox to the chip's "active" colour so a set condition reads as one red
   unit rather than a red pill wrapping an accent box. */
.chip:has(:checked) .chip__box { background: var(--bad); border-color: var(--bad); }
/* No size here on purpose: an 18px `.chip__box` (0,1,0) never applied — the base
   `input[type="checkbox"]` (0,1,1) wins and paints it at 22px (#77). */

/* `.status__block { margin-top: .9rem }` and its `.tiles > ` neutraliser were removed here
   (#75). The margin was a seam from the #14 card merge, for a time when these were separate
   sections. Since #54 Phase 5 every status block is a child of `.tiles` and since #65 they
   carry `.tile` too, so the margin was cancelled on every element that had it — a dead pair.
   The class stays: it is still the hook the print rules use. */

/* Conditions disclosure inside the Combat card (#14): closed, the summary line
   lists the active conditions. Print always shows exactly that line — full and
   wrapped, never the chip grid — see the @media print block. */
/* Centred like every other tile header. The marker, the title and the active-conditions
   summary travel as one centred group rather than packing left — this tile was the last
   header still sitting on the left edge under the uniformity pass. */
.conditions__summary {
  display: flex; align-items: center; justify-content: center; gap: var(--space-2);
  min-height: var(--tap); cursor: pointer; list-style: none;
}
.conditions__summary::-webkit-details-marker { display: none; }
/* A real element, aria-hidden in the markup, so the glyph can't leak into the
   summary's accessible name the way CSS-generated content does. */
.conditions__marker { display: inline-block; color: var(--ink-faint); transition: transform .12s; }
details[open] > .conditions__summary .conditions__marker { transform: rotate(90deg); }
/* Conditions was the last object still on the old `.subhead` component while Hit Point Dice,
   Death Saves and Exhaustion had moved to `.tile__label` (#75) — so the object already
   ordered last also rendered its title unlike every sibling. It uses `.tile__label` now; the
   label's 2.4em min-height exists to align tile BODIES across a grid row and would only pad
   this inline summary, so drop it here. */
.conditions__summary .tile__label { min-height: 0; }
.conditions__active {
  /* `0 1 auto`, not `1`: growing to fill the row would push the marker and title back to the
     left edge and undo the centring above. It still shrinks and ellipsizes when a lot of
     conditions are active, which is the case this readout exists for. */
  flex: 0 1 auto; min-width: 0;
  font-size: var(--text-xs); color: var(--ink-soft);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.conditions .chips { margin-top: var(--space-2); }

/* ---------------------------------------------------------- spellcasting */

.spell-stats { margin-bottom: var(--space-2); }

.slots { display: flex; flex-direction: column; gap: var(--space-2); }

.slots__actions { display: flex; gap: var(--space-2); }

.slot__count {
  font-size: var(--text-sm); font-weight: 600; color: var(--ink-soft);
  text-align: right; font-variant-numeric: tabular-nums;
}

.slot {
  display: grid;
  grid-template-columns: 3rem 1fr 4rem;
  align-items: center; gap: var(--space-2);
  min-height: var(--tap);
  padding: var(--space-1) 0;
  border-bottom: 1px solid var(--line-soft);
}
.slot:last-child { border-bottom: 0; }
.slot__level { font-size: var(--text-xs); font-weight: 600; color: var(--ink-soft); }
.slot__total { margin: 0; }
/* Typography only. The box — full width, padding, border, fill and the 44px min-height —
   comes from the base `input[type=…]` rule, which at (0,1,1) outranks this (0,1,0). This
   used to declare `min-height: 36px`; it never applied (the field measures 44px), and it
   read as a touch-target violation that wasn't there (#87). `font-size` DOES apply — the
   base rule sets none — so it stays. */
.slot__total-input { font-size: var(--text-sm); }

/* ------------------------------------------------------ repeatable rows */

.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.rows--tight { gap: var(--space-2); }

/* ------------------------------------------------------------- spell list (#141) */

/* One collapsible section per spell level. The disclosure mechanics copy the Conditions tile
   exactly — same marker element, same rotation, same list-style kill — because that is the
   app's one collapsible idiom and a second one would be a second thing to learn.

   What differs is the alignment: Conditions centres its summary inside a tile, where the title
   is the only content. Here the summary is a row heading with a count on the far right, so it
   packs left and lets the count sit at the end, the way `.list__head` already does. */
.spelllevel + .spelllevel { margin-top: var(--space-2); }
/* #155: the heading row. The button owns the title and the marker; the count sits OUTSIDE it, so
   ticking Prepared repaints a number rather than silently renaming a focusable control. */
.spelllevel__head { display: flex; align-items: center; gap: var(--space-2); }
.spelllevel__heading { margin: 0; flex: 1 1 auto; min-width: 0; font: inherit; }
.spelllevel__summary {
  display: flex; align-items: center; gap: var(--space-2); width: 100%;
  min-height: var(--tap); cursor: pointer;
  /* It is a <button> now (#155), so the button chrome has to come off explicitly or it inherits
     `.btn`-shaped borders from the base rule. Left-packed, full width: the whole heading line is
     the target, which is a 44px-tall strip rather than the width of the word "Cantrips". */
  padding: 0; background: none; border: 0; color: inherit; text-align: left;
}
/* A real element, aria-hidden in the markup, so the glyph can't leak into the button's
   accessible name the way CSS-generated content does — the reason Conditions does it too. */
.spelllevel__marker { display: inline-block; flex: none; color: var(--ink-faint); transition: transform .12s; }
.spelllevel__summary[aria-expanded="true"] .spelllevel__marker { transform: rotate(90deg); }
.spelllevel__title { font-weight: 600; font-size: var(--text-sm); }
/* `margin-left: auto` rather than `flex: 1` on the title: the count goes to the far end without
   the title claiming the gap, so a section reads as "Level 3 ··· 2 of 5" and the two numbers
   line up down the card whatever the headings are called. */
.spelllevel__count {
  margin-left: auto; flex: 0 1 auto; min-width: 0;
  font-size: var(--text-xs); color: var(--ink-soft);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Indented under its heading, so the sections read as groups rather than as one flat list with
   headings scattered through it. Bordered on the leading edge only — a box per level would nest
   a card in a card, which is the mistake `.row`'s slab already made inside tiles. */
.spelllevel__rows {
  margin: 0 0 var(--space-2) var(--space-1);
  padding-left: var(--space-2);
  border-left: 2px solid var(--line);
}
.spelllevel__add { margin-left: var(--space-1); }

/* COLLAPSED MEANS "SHOW ME WHAT I HAVE PREPARED" (#155), not "hide this".
   A shut level keeps its prepared spells on screen and drops the rest — the state a druid or a
   cleric wants during play, where the full list is a long-rest concern. `display: none` rather
   than a visual collapse on purpose: a hidden row must leave the tab order and the accessibility
   tree, or every unprepared spell's name field, level select and ✕ stay reachable behind a
   control that says "collapsed". That came free with <details> and has to be asked for here.

   The Search button goes with them: adding to a level you have folded away is not a thing anyone
   means to do, and it would sit under the prepared rows looking like part of the list. */
.spelllevel:not([data-level="0"]).is-collapsed .row--spell:not(.is-prepared) { display: none; }
/* ...except Cantrips, which are ALWAYS ready (#158), so a collapsed Cantrips section shows all
   of them. Without this exemption the section with no ticks to set would collapse to a bare
   heading over nothing — the exact failure #155 stopped being a <details> to avoid. `data-level`
   is already on the section (renderSpells writes it), so the rule reads off what is there. */
.spelllevel.is-collapsed .spelllevel__add,
.spelllevel.is-collapsed .rows__empty { display: none; }
/* With every row hidden, the rail would be a stray 2px stub under the heading. */
.spelllevel:not([data-level="0"]).is-collapsed .spelllevel__rows:not(:has(.row--spell.is-prepared)) {
  margin: 0; padding: 0; border-left: 0;
}

/* No tick on a cantrip (#158): there is no decision behind it, so the control should not be
   there to invite one.

   `visibility: hidden` rather than `display: none`, and that is the whole design of this rule.
   It takes the box out of the tab order and off the screen while KEEPING its 44px of layout, so
   cantrip names stay on the same left edge as every other level's — measured, dropping the box
   entirely moves them 52px left and Cantrips becomes the one section that does not line up with
   the card. The gutter is dead space by choice: nothing to tap, nothing to see, one column. */
.spelllevel[data-level="0"] .row__prepared { visibility: hidden; }
/* The card total, beside the title in the head. `margin-left: auto` would fight the head's own
   space-between, so it simply sits where the flex puts it: after the title, before Edit. */
.spells__count { font-size: var(--text-xs); color: var(--ink-soft); }

/* The prepared tick. `.row__prepared` is the 44px target — the checkbox inside it is smaller,
   exactly as `.chip` puts its target on the wrapping <label> rather than the input. */
.row__prepared {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  min-width: var(--tap); min-height: var(--tap); cursor: pointer;
}
/* The level select sits first in the detail block and stays narrow — it is a closed set of ten
   short strings, so letting it flex like the free-text fields beside it would spend a third of
   the line on whitespace. */
.row__levelpick { flex: 0 0 auto; display: inline-flex; }
.row__levelselect { min-height: var(--tap); }
/* A detail field holding a sentence takes the line, rather than a share of one. Six controls on
   a wrapping flex line is exactly the width arithmetic #139 warns about: at `1 1 6rem` the two
   longest were measured cropping (228px of text in 141px, 339px in 291px), and widening the
   basis only moves which pair loses. Declaring which fields are long is the part that doesn't
   drift when the row gains a seventh control. */
.row--spell .row__detail > input { flex: 1 1 8rem; }
.row--spell .row__detail > .row__wide { flex: 1 1 100%; }
/* The base `textarea{min-height:5rem}` is sized for a Notes block; Components is usually one
   short line and only occasionally two, so it starts at a single tap target and grows from
   there on `field-sizing: content`. The Notes textarea below it keeps the taller floor. */
.row--spell .row__detail > .row__components { min-height: var(--tap); }

.rows__empty {
  padding: var(--space-3); text-align: center;
  color: var(--ink-faint); font-size: var(--text-sm); font-style: italic;
}

/* Wrapping flex, not grid.
   Grid was overlapping these: when some children have an explicit grid-column and
   the rest are auto-placed, the placement cursor can drop an auto item into a cell
   an explicit item already spans. Flex simply cannot overlap — it wraps. */
.row {
  display: flex; flex-wrap: wrap; align-items: flex-start;
  gap: var(--space-2);
  padding: var(--space-2);
  background: var(--parchment);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
}

/* min-width: 0 stops a flex item refusing to shrink below its content width,
   which is what pushes siblings out of the row. */
.row > input,
.row > textarea { flex: 1 1 7rem; min-width: 0; }
.row > textarea { flex-basis: 100%; }
.row > .row__narrow { flex: 0 1 4.5rem; }

/* Multi-value rows (#64): a PRIMARY line of three columns — name / to-hit / damage (item / qty
   for inventory) — that line up down the card, each given enough width for real values (damage
   was a squeezed fraction before, which is what cropped). Below it, a NOTES field hidden by
   default and revealed by tapping the entry (main.js flips .is-expanded). No caret, no reflow:
   the whole row is the tap target. */
.row--attack, .row--inventory, .row--feature, .row--feat {
  display: flex; flex-direction: column; align-items: stretch; gap: var(--space-1);
}
/* The primary line lays its fields out by CONTENT width — each takes only what its value needs
   (field-sizing, exactly as the notes textarea already does) and the NAME/item absorbs whatever
   is left, so a short damage ("1d8 Radiant") hands its slack to a long name instead of reserving
   a fixed column that starves it. name grows into free space and ellipsizes only when a row
   genuinely has none. Tight side padding (like .currency) reclaims the default .6rem inset. The
   `size` attrs on to-hit/damage/qty are the width fallback where field-sizing is unsupported. */
/* `flex-wrap` + `min-width: 0` so a long name can never push the page sideways.
   A flex item defaults to `min-width: auto`, which refuses to shrink below min-content — and
   this line's min-content is the name's full text (`field-sizing: content`) plus a to-hit and a
   damage field that are both `flex: 0 0 auto`. Measured at 390px: "Longbow of the Deep Wood"
   made the line 386px inside a 304px row, so the document scrolled to 423 against a 375px
   viewport, past the right edge of every card, into empty space. Wrapping spends a line instead
   of the viewport, and never truncates the name to do it. */
.row__primary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); min-width: 0; }
/* `:not([type="checkbox"])` is load-bearing (#146), not tidiness. This selector is (0,1,1) and so
   is the base `input[type="checkbox"]` block — but this one is 1100 lines further down, so it won
   the tie and painted the spell row's Prepared tick 44 wide by 22 tall: a content-sized text field
   with a tick drawn in it. Sizing here is for the row's TEXT fields; a checkbox is already sized
   by its own rule, and `.row__prepared` around it already carries the 44px target. */
.row__primary input:not([type="checkbox"]) {
  /* 2.75rem = 44px: the floor is a touch target, not just a legibility minimum (#77). */
  field-sizing: content; width: auto; min-width: 2.75rem;  /* width:auto so field-sizing wins over the base input{width:100%} */
  text-overflow: ellipsis; padding-inline: var(--space-1);
  /* Leave room for the ▸ caret on the same line. `field-sizing: content` makes this input as
     wide as its value, and a flex container decides line breaking from HYPOTHETICAL sizes —
     before any shrinking — so once the input alone fills the line the caret wraps to a second
     one and the row gets ~24px taller. Measured at exactly the knife edge: input 277.8 + gap 8
     + margin 2 + caret 9.2 = 297.0 against a 297.0 container, which is a tie decided by
     sub-pixel rounding. That is why some rows in a list were taller than others with no pattern
     a player could infer — the first Feature and the first Feat came out tall, the rest didn't.
     24px is the caret plus its gap and margin, rounded up. */
  max-width: calc(100% - 1.5rem);
}
.row__primary > input:first-child { flex: 1 1 4rem; min-width: 0; }  /* name / item: grows into the free space */
.row__primary > input:not(:first-child) { flex: 0 0 auto; }          /* to-hit / damage / qty: exactly content width */

/* An attack row is ONE line, and spends a second only when a name genuinely cannot fit on it.
   The line has ~304px at 390px. "Starry Wisp" plus its numbers needs ~270 and fits with room;
   "Longbow of the Deep Wood" needs 212 for the name alone, and against a to-hit and a damage
   field it does not fit at any allocation — that row was rendering "Warham…".

   flex-basis: min-content is what distinguishes the two cases. The name's hypothetical size
   becomes its full text, so the flex line breaks only when the text really has nowhere to go;
   below that threshold nothing changes and the row is the single line it has always been.
   min-width stays 0, so a name too long even for a line of its own shrinks and ellipsizes
   rather than pushing the document sideways.

   Attacks only: an inventory row's second field is a 4-character quantity and always fits. */
.row--attack .row__primary > input:first-child { flex: 1 1 min-content; }

/* ...and the same answer for the three rows that carry #147's reveal button, one control wider.
   A 44px button plus its gap takes 52px off every name on the line, and in Edit the ✕ takes
   another 52: measured at 390px, "Improved Divine Smite (Oath of Devotion)" needed 305px and was
   given 253 in play mode and 201 in Edit — a player could not read the name of their own feature.
   The pseudo-caret this replaced was only ~25px kinder, so most of this was already true; it had
   simply never been measured, because probe.html seeded no features or feats until #145 and the
   lists rendered their empty state under a PASS.

   `flex-basis: min-content` makes the name's hypothetical size its full text, so the line breaks
   only when the name genuinely cannot share it — and then the BUTTONS drop to a second line
   rather than every name in the card paying 52px for them. `max-width: 100%` because the base
   rule's `calc(100% - 1.5rem)` reserves room for the ::after caret these rows no longer have.
   min-width stays 0, so a name too long even for a whole line ellipsizes rather than pushing the
   document sideways. */
.row--feature .row__primary > input,
.row--feat .row__primary > input { flex: 1 1 min-content; max-width: 100%; }

/* The numbers are one flex item, so the line can only break BEFORE them — never between the
   to-hit and the damage, which is where it landed when they were loose children. */
.row__stats { display: flex; align-items: center; gap: var(--space-2); flex: 0 0 auto; }
.row__stats > * { flex: 0 0 auto; }
.row__primary input:read-only { outline: none; }   /* view fields are display-only — no focus box on tap */

/* #84: to-hit as a derived readout. The <output> is a grandchild, so the `.row__primary >
   input` sizing above skips it — give it the same fixed-width, tabular treatment by hand. */
.row__hit {
  flex: 0 0 auto; min-width: 2.25rem;
  padding-inline: var(--space-1); text-align: center;
  font-variant-numeric: tabular-nums; font-weight: 600;
}

/* The ability picker is the MODE SWITCH, so exactly one of the readout and the free-text
   field is ever on the primary line — there is never a visible field whose value is silently
   ignored (the two-write-contracts shape logged in #74). option[value=""]:checked tracks the
   select live, so this costs no JS and no extra render pass.
   Order matters: the baseline is "edit mode shows the typed field", and :has() only REFINES
   it. Where :has() is unsupported both rules simply drop and edit mode shows the field plus
   the readout — cluttered, never unreachable. */
.row__bonus { display: none; }
.is-editing .row--attack .row__bonus { display: inline-block; }
/* Ability chosen → the number is derived; the typed field would be dead, so hide it. */
.is-editing .row--attack:not(:has(.row__ability option[value=""]:checked)) .row__bonus { display: none; }
/* Custom → the typed field IS the to-hit; it takes the readout's place while editing. */
.is-editing .row--attack:has(.row__ability option[value=""]:checked) .row__hit { display: none; }

/* What the to-hit is derived from: edit-only, like the per-skill prof/exp/misc controls.
   Not `.row__detail` on purpose — that class is what makes main.js expand a row on tap, and
   these are editing controls, not content worth revealing in view mode.
   flex-basis, not width: `select`/`input[type=number]` are width:100% from the base rule, so
   a width here would lose to it — the same trap noted on .skill__bonus. */
/* Reserve the quantity field's slot on the item name (#137). An inventory row's primary line is
   name + qty, and the two came to exactly the container width — name 270 + gap 8 + qty 44 = 322
   against a 322px primary. A flex container decides line breaking from hypothetical sizes before
   any shrinking, so at a dead tie the quantity went to a second line and every inventory row
   stood 114px tall to show one item name. Feature rows with the same one-line content were 62px.
   Same knife-edge as the caret in #135, one field along; 6rem covers the 4.5rem quantity, its
   gap and the caret.
   `flex-wrap: nowrap` on `.row__primary` looks like the tidier fix and is not: it stops the
   attack rows' primary from wrapping too, and probe.html reports 6 scenes scrolling sideways at
   390px. Reserving width is the change that fits in one row type. */
.row--inventory > .row__primary > input:first-child { max-width: calc(100% - 6rem); }

.row--attack > .row__hitcalc { display: none; }
/* wrap, not shrink: these three are fixed-basis, so a narrower phone must drop one to a
   second line rather than squeeze "Custom" out of the picker. */
.is-editing .row--attack > .row__hitcalc {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
}
.row__ability { flex: 0 0 6.25rem; }   /* fits "Custom" plus the dropdown arrow at 16px */
.row__misc { flex: 0 0 3.5rem; }
.row__prof {
  flex: 0 0 auto; display: inline-flex; align-items: center; gap: var(--space-1);
  min-height: var(--tap); font-size: var(--text-xs); color: var(--ink-faint);
}

/* Notes: hidden by default; revealed when the entry is tap-expanded (.is-expanded), and always
   shown in edit mode (still editable there). Neutralise the base `.row > textarea{flex-basis:100%}`
   (it assumed a wrapping flex row; here the row is a column and it would blow the box up). */
.row--attack > .row__notes, .row--inventory > .row__notes { flex: none; min-height: 0; display: none; }
.row.is-expanded > .row__notes { display: block; }
.is-editing .row--attack > .row__notes, .is-editing .row--inventory > .row__notes { display: block; min-height: var(--tap); }

/* Which rows respond to a tap (#76). Tapping an entry reveals its hidden part — but an
   attack/inventory row with no note has nothing to reveal, so the identical gesture worked on
   one row and did nothing on the next. `.row--expandable` is set by renderDerived when a note
   exists. Edit mode shows an attack's note inline anyway, so the affordance would be a lie there.

   #147 took Feature/Feat/Spell rows OFF this pseudo-caret and gave them a real button below —
   their hidden half is a block of fields that has to be openable one row at a time in Edit too,
   and a pseudo-element cannot be tabbed to, cannot say whether it is open, and cannot be given a
   44px target without covering the ✕ beside it. */
.row--expandable > .row__primary::after {
  content: "▸";
  flex: none; align-self: center;
  margin-left: var(--space-0);
  color: var(--ink-faint); font-size: var(--text-2xs);
  transition: transform .12s;
}
.row.is-expanded > .row__primary::after { transform: rotate(90deg); }
.is-editing .row > .row__primary::after { content: none; }

/* The reveal control for the three rows whose hidden half is a block of fields (#147). Sized and
   shaped like `.row__remove` beside it — same 44px box, same quiet border-on-hover — because
   they are the two row-level buttons and a player should not have to learn two shapes. Unlike ✕
   it exists in BOTH modes: Edit is exactly where opening one row at a time matters. */
.row__expand {
  flex: none;
  width: var(--tap); height: var(--tap);
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: 1px solid transparent; border-radius: 8px;
  color: var(--ink-faint); font-size: var(--text-2xs); cursor: pointer;
  transition: transform .12s, color .12s, border-color .12s;
}
.row__expand:hover { color: var(--ink-soft); border-color: var(--line); }
.row.is-expanded .row__expand { transform: rotate(90deg); }
/* ✕ carries `margin-left: auto` and is `display:none` outside Edit, so in play mode the caret has
   to take the auto margin itself or it sits against the name field and the column of carets down
   a card wanders with the name lengths. In Edit the ✕ is back and takes it, and a SECOND auto
   margin here would split the free space between the two buttons and strand the ✕ mid-row.
   (Not `:first-of-type` — the hidden ✕ is still a <button> in the DOM, so the caret is never the
   first one of its type.) */
.row__expand { margin-left: auto; }
.is-editing .row__expand { margin-left: 0; }

/* #67: the same reveal for Features/Feats, whose hidden part is a BLOCK of fields (source /
   level / text) rather than one note — so it flexes rather than displaying as a block.
   #141's spell row is the same shape again (level / casting time / range / duration /
   components / notes) and joins every rule here rather than restating them.

   #147: `.is-editing` is deliberately NOT a second way in. Edit used to force every detail block
   open, so unlocking a card of eight features unfolded eight blocks of three fields at once and
   the row you wanted was a screen and a half down. `is-expanded` — one row, set by the player —
   is now the only thing that opens one, in both modes. */
.row--feature > .row__detail, .row--feat > .row__detail,
.row--spell > .row__detail { display: none; }
.row.is-expanded > .row__detail { display: flex; }
.row__detail { flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.row__detail > input { flex: 1 1 6rem; min-width: 0; }
.row__detail > .row__level { flex: 0 1 4rem; }
/* Neutralise the base `.row > textarea{flex-basis:100%}` reasoning one level down: here the
   textarea is a grandchild, and it should take the whole second line of the detail block.

   `min-height` is the tap floor rather than 0 (#141): at `rows="2"` this measured 42px, two
   short of the minimum, and it is a control you tap into like any other. It was never caught
   before because probe.html seeds no features or feats, so no detail textarea had ever been
   measured — seeding the spell list is what put one in front of the audit. */
.row__detail > textarea { flex: 1 1 100%; min-height: var(--tap); }

/* Centred and content-width, not left-packed across the tile. The three controls total ~220px
   in a 310px tile, so left-packing left a third of the row empty on the right — the "fuck ton
   of empty space" half of this tile's problem. `flex: 0 1 auto` stops the numbers stretching.

   The slab goes too. `.row` paints `var(--parchment)` with a border and padding because it is
   built for list items that need to read as separate cards — but this row is one line of three
   controls inside a tile that is already a bounded box, so the treatment nested a card in a
   card. In dark mode `--parchment` is the near-black PAGE background against a lighter
   `--card` tile, which is what made it read as a black box rather than as a panel.

   The resource row (#140) shares all of this: it is the same one-line remaining/total group
   inside the same kind of tile, so it wants the same de-slabbing and the same narrow counts. It
   differs in one way, handled below — a name field that grows, which means it has no free space
   for `justify-content: center` to distribute and the declaration is simply inert there. */
.row--hitdie, .row--resource {
  align-items: center; justify-content: center;
  padding: 0; background: none; border: 0;
}

/* #154: the spell row loses the slab for the same reason — and NOT by joining the rule above,
   which also centres its content. Centring is right for one row alone in a tile and wrong for a
   column of eighteen, which has to share a left edge or the card reads as a ransom note.

   Dark mode was the worst case of the pattern this rule keeps undoing. Measured: `--parchment`
   is the PAGE colour, ten RGB units DARKER than the `--card` the row sits in, and the name field
   inside then paints back to card colour — so every spell was a hole punched in the card with a
   smaller card inside it, two visible borders deep. Row height went 62px → 44px, the same 44 as
   the Hit Point Dice row it now matches.

   The rows stay legible as separate things without a box: they are one line each, 8px apart
   (`.rows--tight`), under the level's left rule. */
.row--spell { padding: 0; background: none; border: 0; }

/* Both halves take the full width of the row rather than shrinking to their content.
   `.row__remove`'s `margin-left: auto` can only push against free space, and a shrink-to-fit
   primary line has none — which is why the ✕ and the caret sat hard against the end of the name
   instead of lining up down the card, and why every row's controls landed somewhere different. */
.row--spell > .row__primary,
.row--spell > .row__detail { flex: 1 1 100%; min-width: 0; }

/* ONE LINE PER SPELL (#154). A wrapped row is 96px instead of 44 and puts two lone controls
   under a name; in a list of eighteen that is a ragged column, and the sheet's whole point is
   scanning it. So the line never wraps and the name gives way instead — `flex: 1 1 0` with
   `min-width: 0` lets it shrink below its content, where `text-overflow: ellipsis` (already on
   `.row__primary input`) takes over.

   This deliberately reverses #147's trade FOR SPELLS ONLY, and the numbers are why. Measured at
   390px: tick 44 + name 194 + ✕ 44 + caret 44 + three 8px gaps = 350 against a 308px box. In
   PLAY mode — where a player spends essentially all of their time and the ✕ is hidden — the name
   still gets ~220px and nothing in a normal spell list truncates. Only Edit, which is transient
   and where the field is editable and scrollable, gives up the last few characters of a very long
   name. A Feature row keeps #147's behaviour: that card holds a handful of rows, not eighteen,
   and has no 44px tick eating the line. */
.row--spell > .row__primary { flex-wrap: nowrap; }
.row--spell .row__primary > input { flex: 1 1 0; min-width: 0; max-width: none; }

/* #154's per-tick border override lived here and is gone (#159): the base checkbox rule now
   carries `--ink-faint`, so every box in the app is visible rather than this one alone.

   Worth recording, because the comment that stood here was WRONG and someone will read this file
   before they read the issue: it blamed `--line` on `--card` at 1.38:1, which is what the base
   rule DECLARES. The computed value was 1.00:1 — fully transparent — because `input:read-only`
   matches checkboxes and was overriding it. The override worked either way, by out-specifying
   both, which is exactly why the wrong diagnosis survived: the fix succeeded for a reason its
   author had not measured. */
.row--hitdie > .row__narrow { flex: 0 1 3.6rem; }
/* The size is the qualifier, not the value: quieter than the counts it follows, and read-only
   in play (it is not `data-live`), so it flattens to plain text there like Max HP does. */
.row--hitdie > .row__die { flex: 0 1 4.2rem; color: var(--ink-soft); }
/* Drop the `margin-left: auto` that pins ✕ to the right on list rows: here it ate all the free
   space, so `justify-content: center` had none left and the numbers packed left again. On a
   one-line row the remove button travels with the group it removes. */
.row--hitdie > .row__remove { margin-left: 0; }
.row__of { flex: none; color: var(--ink-faint); font-variant-numeric: tabular-nums; }

/* #140: the resource name, and the reason it is not just `.row > input`.
   A plain input's min-content width comes from its `size` attribute (~20 characters), NOT from
   its value — see the `min-width: 0` note on the base control rule — so a flex line has no way
   to know the text is longer than the box and the value is silently cropped. Measured at 390px
   with the probe's seeded row: "Channel Divinity: Turn Undead" needed 233px in the 146px it was
   given (96px once Edit reveals the ✕), rendering "Channel Divini…".

   `field-sizing: content` is what makes the intrinsic size track the VALUE, and `flex-basis:
   min-content` then makes the name's hypothetical size its full text — so the flex line breaks
   before `.row__stats` only when the text genuinely has nowhere to go. "Rage" is unaffected and
   stays the single line it was; a long name spends a second line and keeps every character.
   `width: auto` so field-sizing beats the base `input[type="text"]{width:100%}` (0,1,1) — this
   selector is (0,2,0), which is the specificity trap this stylesheet keeps setting.
   The 2.75rem floor is 44px: a touch target, not just legibility, and small enough against a
   ~304px row that a long name can never push the document sideways. */
.row--resource > .row__name {
  field-sizing: content; width: auto;
  flex: 1 1 min-content; min-width: 2.75rem;
  text-overflow: ellipsis; padding-inline: .35rem;
}
/* The counts sit inside `.row__stats` now, so the `.row > .row__narrow` rule no longer reaches
   them (they are grandchildren) and `.row__stats > *` gives them `flex-basis: auto` — which
   resolves to `width`, and the base rule's `width: 100%` would size them against the group
   rather than to two digits. Two classes (0,2,0) to outrank `input[type="number"]` (0,1,1). */
.row--resource .row__narrow { width: 3.6rem; }

/* `.row__prepared` was removed here (#87): it styled the spell row's "prepared" checkbox,
   and the spell-row UI went away in #9. The `prepared` FIELD still exists in the model so
   saved data keeps round-tripping (constants.js ROW_TEMPLATES, and `spells` is deliberately
   absent from render.js's ROW_TEMPLATE_IDS) — but nothing has rendered the class since, so
   the rule matched no element and its sub-44px min-height read as a violation that wasn't
   there. If the spell list comes back, style it then. */

.row__remove {
  flex: none;
  width: var(--tap); height: var(--tap);   /* #77 */
  margin-left: auto;
  background: none; border: 1px solid transparent; border-radius: 8px;
  color: var(--ink-faint); font-size: var(--text-md); cursor: pointer;
}
.row__remove:hover { color: var(--bad); border-color: var(--bad); }

/* ---- per-card Edit mode --------------------------------------------------------
   Each editable card carries `data-editcard` + a header Edit button (render.js
   applyEditState), which flips `.is-editing` on that card — locking its authored fields
   in view mode and revealing its Add/remove controls in edit mode. */

/* Title-row action group: the +Add and Edit buttons sit together, right of the title. */
.head-actions { display: flex; align-items: center; gap: var(--space-2); }

/* Quiet by default so it doesn't fight the sheet — a low-weight text button that turns
   into a clear accent "Done" chip once its card is unlocked.

   Quiet, but not invisible: both states failed WCAG AA (4.5:1 for 12.8px/600) and both are
   measured now rather than eyeballed. `--ink-faint` on `--card` is 3.85:1 in light and 3.80:1 in
   dark, so this is `--ink-soft` instead — 7.27:1 and 6.84:1, still clearly secondary to the card
   title. This button is the one control that says whether the sheet is locked, and when a player
   cannot find it, a card behaving exactly as designed is indistinguishable from a broken one.
   That is not hypothetical: #115 was reported partly as "Edit does nothing".

   "Done" was worse than it looks, and only in dark: the accent's own 10% tint LIGHTENS the
   background there, so accent-on-tint measured 3.86:1 (against 4.38:1 on the bare card, which is
   the number you get if you forget the tint — and 7.61:1 in light, which passes). Mixing the
   accent toward `--ink` fixes both themes with one expression instead of a per-theme override,
   because `--ink` is whichever end of the scale has contrast to give: 8.70:1 light, 4.84:1 dark.
   80% and not 85% (4.57:1) so an accent tweak doesn't silently drop it back under. */
.card__edit {
  border-color: transparent;
  background: none;
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: .02em;
}
.card__edit:hover { color: var(--ink); }
.is-editing .card__edit {
  color: color-mix(in srgb, var(--accent) 80%, var(--ink));
  border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
  background: color-mix(in srgb, var(--accent) 10%, transparent);
}

/* "+ Add" and per-row remove (✕) exist only in edit mode; view mode is uncluttered.
   #149's spell "Search" is the same control wearing a different name — it is how a spell gets
   added now — so it is gated with them rather than becoming the one way to change the sheet
   from play mode. */
[data-action="add-row"], [data-action="spell-search"], .row__remove { display: none; }
.is-editing [data-action="add-row"],
.is-editing [data-action="spell-search"] { display: inline-flex; }
.is-editing .row__remove { display: inline-flex; align-items: center; justify-content: center; }

/* View mode renders locked fields as flat read-only text, so the sheet reads as something
   you look at; Edit restores the input chrome. The always-live play controls (data-live:
   HP, currency, hit-dice current, heroic inspiration, conditions) are never locked, so they
   keep their editable boxes. Selects/checkboxes ignore readOnly, so they lock via :disabled;
   -webkit-text-fill-color keeps disabled values legible on iOS. */
/* A locked field is display-only, so tapping one must not paint a focus ring — an accent box
   around a value you cannot change reads as an error, and it appeared on AC, Speed, Max HP and
   the Hit Dice total. `.row__primary input:read-only` already made this call for entry rows;
   this is the same rule everywhere. Nothing is lost for keyboard users: there is nothing to
   type into a read-only field, and its value is still announced. */
input:read-only:focus-visible, textarea:read-only:focus-visible { outline: none; }

/* `input:not([type="checkbox"], [type="radio"])` is the whole of #159, and it is not tidiness.
   In CSS a checkbox is ALWAYS `:read-only` — the pseudo-class means "not a read-write text
   control", not "locked" — so this rule, written for flattening locked TEXT fields in play mode,
   was painting `border-color: transparent; background: none` onto every checkbox in the app. It
   is (0,1,1), the base `input[type="checkbox"]` block is (0,1,1), and this one is 1550 lines
   further down, so it won the tie everywhere.

   Measured on the running app before this line changed: every unchecked box in both themes at
   1.00:1 — a transparent boundary, not a faint one. The PROF and EXP columns of Abilities & Skills
   in Edit mode were headers over nothing. Ticked boxes survived only by accident, because
   `input[type="checkbox"]:checked` is (0,2,1) and outranks this. */
input:not([type="checkbox"], [type="radio"]):read-only, textarea:read-only, select:disabled {
  border-color: transparent;
  background: none;
  cursor: default;
  resize: none;
  color: var(--ink);
  opacity: 1;
  -webkit-text-fill-color: var(--ink);
}
select:disabled { appearance: none; -webkit-appearance: none; }

/* auto-fit rather than a fixed 5 columns: at 375px five fixed columns leave each
   input too narrow to show a four-digit number. */
.currency { display: grid; grid-template-columns: repeat(auto-fit, minmax(3.6rem, 1fr)); gap: var(--space-2); }
.currency .field > label { text-align: center; }
.currency input { padding-left: var(--space-1); padding-right: var(--space-1); }

/* ----------------------------------------------------------- hit points */

/* The HP readout + damage/heal workflow, absorbed into the Combat tab from the
   old sticky bottom bar. */
/* Hit Points tile (#65/#74/#75): three labelled fields — Current / Max / Temp — laid out as
   one row inside the tile body, each column separated by a hairline. No buttons and no
   steppers: Current is the sole HP-change control (a bare number sets it; a signed value
   damages/heals — see main.js + rules.applyHpInput). */
.tile__body.hp {
  display: flex; align-items: stretch; justify-content: center;
  flex-wrap: nowrap; gap: 0;
}
.hp__field {
  flex: 1 1 0; min-width: 0;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-0);
  padding: 0 var(--space-1);
}
.hp__field + .hp__field { border-left: 1px solid var(--line-soft); } /* the "separated" seam */
.hp__sublabel {
  font-size: var(--text-2xs); font-weight: 600; letter-spacing: .05em;
  text-transform: uppercase; color: var(--ink-faint); line-height: 1;
}
/* The box, padding, full width and 44px min-height all come from the base `input[type=…]`
   rule (specificity 0,1,1, which outranks these class selectors), so Current/Temp read as
   boxed editable fields like currency, while the Edit-gated Max flattens via `input:read-only`
   — the app's existing "live field = boxed, locked = flat" language. All three share one serif
   treatment so Current / Max / Temp read as one row. */
.hp__cur, .hp__max, .hp__temp {
  font-family: var(--serif); font-weight: 700; font-size: var(--text-xl);
  text-align: center; line-height: 1.1;
}
/* Current / Max / Temp share ONE size on purpose. #75 briefly gave Current a 1.9rem display
   size to weight it above its siblings; it read as unbalanced against the rest of the card and
   was reverted. Frequency-of-use is encoded by ORDER and by tile span, not by type scale. */


/* Current HP reddens at or below half — and, since #75, does NOT switch off at 0. isBloodied
   required current > 0, so the app's only HP-conditional cue vanished at exactly the moment
   things got worst; `.is-dying` carries it from there. */
.is-bloodied .hp__cur, .is-dying .hp__cur { color: var(--bad); }

/* At 0 HP the Death Saves tile stops being one more always-on block and says so. It is not
   hidden when healthy — this is a tracker, and arrange mode lets a player order or hide it
   themselves — it just carries no emphasis until it matters. */
.is-dying [data-object="deathsaves"] {
  border-color: var(--bad);
  background: color-mix(in srgb, var(--bad) 7%, var(--card));
}
.is-dying [data-object="deathsaves"] .tile__label { color: var(--bad); }

/* --------------------------------------------------------------- tab bar */

/* Fixed to the bottom thumb zone on a phone; hidden on a laptop (§ laptop). */
.tabbar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-tabbar);
  display: flex; gap: var(--space-1);
  /* Horizontal insets so the end tabs clear a landscape notch/rounded corners. */
  padding: .3rem calc(.4rem + env(safe-area-inset-right)) calc(.3rem + env(safe-area-inset-bottom)) calc(.4rem + env(safe-area-inset-left));
  background: var(--card); /* fallback: the bar sits over scrolling content */
  background: color-mix(in srgb, var(--card) 96%, transparent);
  -webkit-backdrop-filter: saturate(140%) blur(10px); /* older iOS Safari only ships the prefix */
  backdrop-filter: saturate(140%) blur(10px);
  border-top: 2px solid var(--line);
  box-shadow: 0 -4px 18px rgba(60, 44, 26, .12);
}

.tab {
  flex: 1 1 0; min-width: 44px; min-height: 44px;   /* comfortable touch target */
  display: flex; align-items: center; justify-content: center;
  padding: var(--space-1);
  background: none; border: 0; border-radius: 8px;
  font-family: var(--sans);
  font-size: var(--text-xs); color: var(--ink-soft); cursor: pointer;
}
.tab[aria-selected="true"] {
  color: var(--accent); font-weight: 600;
  background: color-mix(in srgb, var(--accent) 12%, var(--card));
}

/* =============================================================== tablet */

@media (min-width: 640px) {
  /* Wide enough that a feature's name and body sit on one line. */
  .row--feature > input { flex: 0 1 14rem; }
  .row--feature > textarea { flex: 1 1 16rem; }
}

/* =============================================================== laptop */

@media (min-width: 900px) {
  .app { display: grid; grid-template-columns: 280px 1fr; }

  /* The drawer becomes a permanent column. width must drop back to auto or the
     320px drawer width overflows the 280px grid column and covers the sheet. */
  .sidebar {
    position: sticky; top: 0; inset: auto;
    width: auto; height: 100dvh;
    transform: none;
  }
  .sidebar__close, .topbar__menu, .scrim { display: none; }

  .main { padding-bottom: var(--space-7); }

  /* No thumb zone on a laptop: the tab bar goes away and every panel shows at once. */
  .tabbar { display: none; }

  /* Reveal every panel and dissolve its wrapper so the cards flow straight into the
     .cards column layout. The [hidden] variant needs a higher-specificity !important
     to beat the global `[hidden]{display:none!important}` kill-switch. */
  .tabpanel { display: contents; }
  .tabpanel[hidden] { display: contents !important; }

  /* Balanced multi-column card flow. break-inside keeps a whole card — and its inner
     grid — intact, so the WIS/CHA clipping and screen-height gaps the old fixed .col
     grid fought never arise. Same technique the print block already uses. */
  .cards { display: block; column-count: 2; column-gap: var(--space-4); padding: var(--space-4); }
  .card { break-inside: avoid; margin-bottom: var(--space-4); }
}

@media (min-width: 1280px) {
  .cards { column-count: 3; }
}

/* ================================================================ dialog */

.dialog {
  max-width: min(92vw, 26rem);
  padding: var(--space-5);
  background: var(--card);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: 0 12px 40px rgba(0, 0, 0, .3);
}
.dialog::backdrop { background: rgba(20, 14, 8, .5); }
.dialog__title { margin: 0 0 var(--space-2); font-size: var(--text-lg); }
.dialog__body { margin: 0 0 var(--space-2); font-size: var(--text-sm); }
.dialog__actions {
  display: flex; flex-wrap: wrap; gap: var(--space-2); justify-content: flex-end;
  margin: var(--space-4) 0 0; padding: 0;
}

/* ---- the spell picker (#149) ----------------------------------------------------
   A near-full-screen sheet on a phone, a wide panel on a laptop. The import dialog above is a
   question with three answers and sizes to its text; this one is a search over 339 spells with
   three filter axes and a description to read, so it takes the screen. Capped at 92vh rather
   than 100 so it still reads as a layer over the sheet rather than a new page. */
.dialog--picker {
  width: min(96vw, 40rem);
  max-width: min(96vw, 40rem);
  height: min(92vh, 52rem);
  padding: 0;                     /* the .picker grid owns the padding, so the list can scroll edge to edge */
  overflow: hidden;
}
/* Grid, not flow: the results are the only part that scrolls, and everything else has to stay
   put while it does. `minmax(0, 1fr)` on the list row is what allows a grid child to be SMALLER
   than its content — without it the list grows the dialog and the whole thing scrolls, taking
   the search box and the filters off screen with it. */
.picker {
  display: grid;
  grid-template-rows: auto auto auto auto minmax(0, 1fr) auto auto auto;
  height: 100%;
  padding: var(--space-4);
  gap: var(--space-2);
}
.picker__head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
.picker__head .dialog__title { margin: 0; }
.picker__search { width: 100%; min-height: var(--tap); font-size: var(--text-md); }

.picker__filtersummary {
  display: flex; align-items: center; gap: var(--space-1);
  min-height: var(--tap);              /* a summary is a control (#77) */
  font-size: var(--text-sm); font-weight: 600; color: var(--ink-soft);
  cursor: pointer; list-style: none;
}
.picker__filtersummary::-webkit-details-marker { display: none; }
.picker__filtermarker { transition: transform .12s; display: inline-block; }
.picker__filters[open] .picker__filtermarker { transform: rotate(90deg); }
.picker__filterbody { display: flex; flex-direction: column; gap: var(--space-2); padding-block: var(--space-2); }
.picker__axis { border: 0; margin: 0; padding: 0; min-width: 0; }
.picker__axis legend { padding: 0 0 var(--space-1); font-size: var(--text-xs); color: var(--ink-soft); }
/* The condition chip is red because a condition is bad news. A filter is not — it is on or off,
   so it takes the accent the rest of the app uses for "this is selected". */
.chip--filter:has(:checked) {
  background: color-mix(in srgb, var(--accent) 15%, var(--card));
  border-color: var(--accent);
  color: color-mix(in srgb, var(--accent) 80%, var(--ink));
}
.chip--filter:has(:checked) .chip__box { background: var(--accent); border-color: var(--accent); }

.picker__bar { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-2); }
.picker__count, .picker__status { font-size: var(--text-xs); color: var(--ink-soft); }
.picker__status { text-align: right; }

.picker__results { overflow-y: auto; margin: 0; padding: 0; list-style: none; -webkit-overflow-scrolling: touch; }
.picker__more { justify-self: center; }
.picker__foot { display: flex; flex-wrap: wrap; gap: var(--space-2); justify-content: space-between; }
.picker__attrib { margin: 0; font-size: var(--text-2xs); color: var(--ink-faint); }

/* A result: name + summary on the left, Add on the right, description underneath when opened.
   Grid rather than flex so the description spans both columns without a wrapper. */
.result {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--space-1) var(--space-2);
  align-items: center;
  padding: var(--space-2) 0;
  border-bottom: 1px solid var(--line);
}
/* The whole left side is the reveal control, so the target is the row rather than a 9px caret —
   and it is a real <button>, so it is tabbable and says whether it is open. */
.result__main {
  display: flex; flex-direction: column; gap: 2px;
  min-height: var(--tap); padding: var(--space-1) 0;
  background: none; border: 0; text-align: left; cursor: pointer; color: inherit;
  min-width: 0;
}
.result__name { font-weight: 600; font-size: var(--text-sm); }
.result__meta { font-size: var(--text-xs); color: var(--ink-soft); }
.result__add { flex: none; min-width: 4rem; }
.result__add--done { opacity: .6; }
/* Hidden until the result is opened. `display:none` rather than `hidden`, because the element
   is a grid child either way and the attribute would fight the grid's implicit row. */
.result__detail { display: none; grid-column: 1 / -1; }
.result.is-expanded .result__detail { display: block; }
.result__stats { margin: 0 0 var(--space-1); font-size: var(--text-xs); color: var(--ink-soft); }
/* pre-wrap: the description is plain text with real paragraph breaks in it (the generator keeps
   them), and collapsing them would run "Using a Higher-Level Spell Slot" onto the end of the
   sentence before it. */
.result__text { margin: 0; font-size: var(--text-sm); white-space: pre-wrap; }

/* ================================================================= print */

/* A predictable paper margin instead of whatever the browser defaults to. */
@page { margin: 1.5cm; }

@media print {
  /* #140: `[data-action="add-row"]` joins this list, and `.rows__empty` with it. Both are
     instructions to press something, and paper has nothing to press — the empty line literally
     reads "tap Edit to add". Neither was reachable on paper before because both are edit-mode
     or empty-list states nobody printed, but the Resources tile makes the empty one the COMMON
     case (most characters have no class resources), so without this every printed sheet gains a
     line of screen chrome. Unconditional, like the #84 attack rules below: a card left in Edit
     mode still prints the finished sheet. An empty tile then prints as its heading over blank
     space, which is a better thing to hand a player than an instruction they cannot follow. */
  .sidebar, .scrim, .topbar, .tabbar, .row__remove, .row__expand, [data-action="add-row"], .rows__empty,
  .list__head .btn,
  /* .obj-ctl / .obj-rename dropped here (#72): the in-tile overlay is gone, and the object
     controls now live inside .arrange-bar, which this line already hides. */
  .sidebar__foot, .banner, .arrange-bar, .card__move, .card__rename,
  .dropslot { display: none !important; }
  body { background: #fff; color: #000; font-size: 11pt; }
  .main { padding: 0; }
  /* Reveal every tab panel, or the print is only the active tab — a fifth of a sheet. */
  .tabpanel { display: contents; }
  .tabpanel[hidden] { display: contents !important; }
  .cards { display: block; column-count: 2; column-gap: var(--space-4); padding: 0; }
  .card { break-inside: avoid; box-shadow: none; margin-bottom: var(--space-2); border-color: #999; }
  /* The merged #14 cards exceed a print column — break-inside:avoid on a card taller
     than the column is defeated anyway and splits at an arbitrary row. Let those two
     fragment deliberately, keeping each ability group / status block intact. */
  .card--flow { break-inside: auto; }
  .ability-group, .card--flow .status__block, .card--flow .tile,
  .card--flow .hp, .card--flow .inline-fields { break-inside: avoid; }
  /* Never strand a title at a column foot with its card body on the next column. */
  .card--flow .card__title { break-after: avoid; }
  /* Conditions print as the summary line only — full and wrapped, never the chip
     grid — no matter whether the disclosure was left open on screen. Without this,
     a closed details truncates the list to one ellipsized line and an open one
     prints it twice. */
  .conditions .chips { display: none !important; }
  .conditions__active { white-space: normal; overflow: visible; text-overflow: clip; }
  /* Notes and feature text otherwise print only their visible rows and clip the rest.
     field-sizing grows the control to fit its content where supported (Chromium, recent
     Safari); where it isn't, the textarea prints at its natural height — no worse than
     before. break-inside keeps a note from splitting mid-line across a page. */
  textarea { field-sizing: content; height: auto; overflow: visible; break-inside: avoid; }
  /* Multi-value rows (#64): the tap-collapsible notes must still print — reveal every note and
     clip rather than ellipsize the single-line fields so a printed sheet carries the full entry,
     not just what happened to be expanded on screen. */
  .row--attack > .row__notes, .row--inventory > .row__notes { display: block !important; }
  /* #84: paper always gets the derived readout — never the raw `bonus` field (in derived mode
     it holds a stale string) and never the derivation controls, which are a screen affordance.
     Unconditional, so a card left in Edit mode still prints the finished sheet. */
  .row--attack .row__hit { display: inline-block !important; }
  .row--attack .row__bonus, .row--attack > .row__hitcalc { display: none !important; }
  /* #67: paper has no tap, so every collapsed detail block prints expanded. #141's spell rows
     join it, and their SECTIONS need the same treatment one level up: a <details> the player
     left shut would otherwise print as a heading with the spells missing underneath, which is
     the one failure mode a printed sheet cannot recover from. Forcing the box open is not
     enough on its own — `details:not([open]) > *` stays unrendered — so the content is
     revealed explicitly. The marker goes with the caret below: nothing on paper folds. */
  .row--feature > .row__detail, .row--feat > .row__detail,
  .row--spell > .row__detail { display: flex !important; }
  /* #155: the SECTIONS are revealed from here now, in one rule, and the beforeprint handler that
     used to do it in JS is gone. It existed because a closed <details> is content containment —
     `details:not([open]) > *` stays unrendered whatever `display` you give it, the same trap
     CLAUDE.md documents for measuring these sections — so the reveal had to be scripted and then
     undone afterwards. A collapsed level is now an ordinary section with `display: none` rows,
     which CSS can simply turn back on. The one thing a printed sheet cannot survive is a level
     heading with the spells missing underneath.

     `!important` is load-bearing: it is beating another `display: none`, not a UA default. */
  .spelllevel.is-collapsed .row--spell { display: flex !important; }
  .spelllevel.is-collapsed .spelllevel__rows {
    margin: 0 0 var(--space-2) var(--space-1) !important;
    padding-left: var(--space-2) !important;
  }
  /* Nothing folds on paper, so the marker and the add button go — and #155's toggle is a real
     <button> now, so it joins them rather than being CSS-generated content that vanishes with
     the caret rule below. */
  .spelllevel__marker, .spelllevel__add { display: none !important; }
  .spelllevel__summary { min-height: 0; }
  .spelllevel { break-inside: avoid; }
  /* ...and for the same reason the #76 expand caret is noise on paper: there is nothing to
     tap, and the content it would reveal is already printed above. #147's button is the same
     control in a form that can be tapped and tabbed to, so it goes the same way — it is listed
     with ✕ and "+ Add" in the strip-the-chrome rule at the top of this block. */
  .row > .row__primary::after { content: none !important; }
  .row__primary input { text-overflow: clip; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition: none !important; animation: none !important; }
}
