/*
 * The UI typeface, drawn on the same grid as the creatures — see
 * scripts/pixel/uifont.mjs. The app used the system monospace, which sat beside
 * hand-placed sprites and made one screen read as two products.
 *
 * ⚠️ `font-size` MUST be a multiple of 9px. The em box is exactly the 9-row glyph
 * grid, so 18px renders each row at 2 screen pixels and 27px at 3. Anything else
 * lands rows between pixels and the font blurs — which defeats the only reason to
 * have drawn it. Every size in this file is 18 or 27 for that reason.
 *
 * It is also FIXED-ADVANCE: every glyph is 6 units wide, so it is a monospace font
 * and the stat card's column alignment survives unchanged.
 */
@font-face {
  font-family: "ScanlingsPixel";
  src: url("./font/scanlings-pixel.ttf") format("truetype");
  font-display: block; /* never flash a fallback; the layout is sized for this font */
}

:root {
  /* One type scale, two sizes. Pixel UIs do not have seven text sizes. */
  --type: 18px;
  --type-big: 27px;
  --bg: #14161a;
  --panel: #101216;
  --line: #2e3238;
  --fg: #e8e6df;
  --dim: #8a8f98;
  --accent: #c8b06a;
  /*
   * ⛔ **VERDICT COLOURS, AND `--bad` WAS REFERENCED IN FOUR PLACES AND DEFINED IN NONE.**
   * `.danger`, `button.primary.danger` and the requirement rows all read `var(--bad, #d9534f)`, so
   * every one of them had always run on its fallback and neither theme had ever coloured it.
   * Defined here at exactly that fallback, so nothing changes appearance and the token stops being
   * a hole. ⭐ `--good` is new, for a requirement the team already meets.
   */
  --good: #5aa469;
  --bad: #d9534f;

  /*
   * ⛔ **THE EIGHT ELEMENT COLOURS, AS TOKENS, BECAUSE THEY MUST CHANGE WITH THE THEME.**
   *
   * They used to be read straight out of `PALETTES[tone][4]` in JS and written as an inline
   * `style.color`. That palette is the CREATURE's ramp, tuned against a #14161a page, and an
   * inline colour cannot respond to a theme at all. Measured on light: every one of the eight
   * scored between **1.18 and 2.91** against #f4f2ed — unreadable, all of them. Alessandro
   * noticed the worst: *"AIR element is barely readable."*
   *
   * ⚠️ **THE LIGHT SET IS DERIVED, NOT PICKED.** Each keeps its hue and saturation and drops
   * only lightness, and the target contrast preserves each element's RELATIVE prominence from
   * the dark theme rather than flattening them all to 4.5.
   *
   * ⭐ That last part is the whole trick. Driving all eight to the same ratio makes them equally
   * readable and mutually indistinguishable — SACRED and EARTH came out the same brown, because
   * in the dark theme those two are separated by LIGHTNESS, not hue. Mapping the range instead
   * keeps the closest pair at 1.01 on light, which is exactly what it is on dark.
   */
  --el-earth: #e3b055;
  --el-lightning: #f6ea6a;
  --el-water: #8ed2e8;
  --el-fire: #f7a54a;
  --el-poison: #dced5c;
  --el-void: #a08bd0;
  --el-sacred: #f4ead0;
  --el-air: #dfe7ec;

  /*
   * The stat ramp — five bands of twenty, red through green.
   *
   * Alessandro, 2026-08-08: *"you see all red you know immediately it's bad, even
   * before reading the numbers."*
   *
   * ⚠️ Muted on purpose. Web-standard #f00/#0f0 would be the two most saturated
   * things on a screen whose brightest colour is a soft gold, and the creatures are
   * drawn from sixteen-colour ramps. These sit in the same register as the art.
   * `--stat-3` IS the accent gold, so the middle of the ramp is the colour the rest
   * of the UI already uses.
   */
  --stat-1: #b5483f;
  --stat-2: #c47a3d;
  --stat-3: #c8b06a;
  --stat-4: #8aa54a;
  --stat-5: #5f9e57;
  color-scheme: dark;
}

* {
  box-sizing: border-box;
}

/*
 * ⚠️ Load-bearing. The UA rule is `[hidden] { display: none }` at the lowest
 * possible specificity, so ANY class that sets `display` silently beats it —
 * `.gate { display: flex }` meant the name prompt never hid, and the app showed
 * the "what should we call you" screen and the scanner at the same time on a real
 * phone. Caught by Alessandro installing it, not by any test, because every test
 * asserted the `hidden` PROPERTY rather than what was on screen.
 */
[hidden] {
  display: none !important;
}

/* Light theme. `color-scheme` alone only restyles form controls, so the palette
   is swapped explicitly. */
:root[data-theme="light"] {
  --bg: #f4f2ed;
  --panel: #fffdf8;
  --line: #d9d4c9;
  --fg: #1d1f24;
  --dim: #6b6f78;
  --accent: #8a6d1f;
  /* ⚠️ Darkened for the light page — the dark set's green and red both fail contrast on it. */
  --good: #2f7d43;
  --bad: #a8322e;

  /* Derived from the dark set above — hue and saturation held, lightness dropped until each
     one clears 4.5:1 on #f4f2ed at its share of the original prominence. Measured after:
     4.60 (VOID) to 9.57 (SACRED), against 1.18–2.91 before. */
  --el-earth: #765313;
  --el-lightning: #484204;
  --el-water: #16576c;
  --el-fire: #894b06;
  --el-poison: #404708;
  --el-void: #795cbd;
  --el-sacred: #4c3c12;
  --el-air: #2f4350;
  /* Darkened for a light background — the dark-theme ramp washes out on #f4f2ed. */
  --stat-1: #9c342c;
  --stat-2: #a05a1c;
  --stat-3: #8a6d1f;
  --stat-4: #5f7a2a;
  --stat-5: #3d7238;
  color-scheme: light;
}


/* --- pixel frames --------------------------------------------------------
 *
 * ⚠️ A 1px square-cornered CSS box reads as a WEB UI, not as pixel art, however
 * pixel-perfect the font beside it is. Alessandro: *"the borders are too clean for a
 * pixel game."* Three things separate a drawn frame from a `border: 1px solid`:
 *
 * 1. **Weight.** Sprites are drawn with a near-black 1px contour and displayed at
 *    2×, so their outline reads as 2 screen pixels. A 1px UI border is visually
 *    thinner than everything it sits beside. Frames are 2px.
 * 2. **Notched corners.** Hand-drawn 8-bit frames cut the corner rather than turning
 *    a perfect right angle — it is what a rectangle looks like when a person places
 *    the pixels. The notch is a whole number of pixels so the diagonal steps land on
 *    the grid instead of anti-aliasing into a curve.
 * 3. **No curves anywhere**, which the border-radius purge already handled.
 *
 * `clip-path` cuts through the border itself, so the corners come out stepped rather
 * than mitred — which is exactly the look. One class, applied everywhere, so the
 * whole UI shares one frame language.
 */
.px-frame {
  --notch: 4px;
  border: 2px solid var(--line);
  clip-path: polygon(
    var(--notch) 0,
    calc(100% - var(--notch)) 0,
    100% var(--notch),
    100% calc(100% - var(--notch)),
    calc(100% - var(--notch)) 100%,
    var(--notch) 100%,
    0 calc(100% - var(--notch)),
    0 var(--notch)
  );
}

/* Small controls get a smaller notch, or the cut eats the whole corner. */
.px-frame-sm {
  --notch: 3px;
}

body {
  margin: 0;
  padding: 1.75rem 1rem 3rem;
  background: var(--bg);
  color: var(--fg);
  /*
   * Line height in WHOLE PIXELS, not a ratio. 1.5 × 18px = 27px per line, which
   * pushed the collection from seven visible creatures to three — the font grew and
   * the leading grew with it. 22px keeps 8px of air above a 14px cap height, which
   * is enough to read and half the wasted space.
   */
  font: var(--type)/22px "ScanlingsPixel", ui-monospace, SFMono-Regular, Menlo, monospace;
}

main {
  max-width: 34rem;
  margin: 0 auto;
}

/*
 * ⛔⛔ **A HEADING THAT WRAPS MUST NOT TOUCH ITSELF** — his report, 2026-09-23, on "Climb from floor 150?":
 * *"we need some space between the 2 lines of the wrapped title… this issue is repeated in many places."*
 * ⚠️ **The cause is one fixed number**: `body` carries `line-height: 22px`, which every heading INHERITS while
 * its own font is 27px — so two lines sit 22px apart under 27px glyphs. A ratio scales with the font; a pixel
 * value cannot. ⭐ Applies to every heading at once, which is where the "many places" go.
 */
h1, h2, h3, h4 { line-height: 1.25; }

h1 {
  margin: 0;
  font-size: var(--type-big);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
}

.sub {
  margin: 0.35rem 0 1.5rem;
  color: var(--dim);
}

button {
  padding: 0.7rem 1.15rem;
  border: 0;
  background: var(--accent);
  color: #14161a;
  font: inherit;
  font-weight: 700;
  letter-spacing: 0.06em;
  cursor: pointer;
}

button:disabled {
  background: var(--line);
  color: var(--dim);
  cursor: not-allowed;
}

.primary {
  display: block;
  width: 100%;
  padding: 0.95rem;
}

.ghost {
  background: transparent;
  border: 1px solid var(--line);
  color: var(--fg);
  font-weight: 400;
}

/* --- viewfinder --------------------------------------------------------- */

.viewfinder {
  position: relative;
  margin-top: 0.75rem;
}

.viewfinder video {
  display: block;
  width: 100%;
  max-height: 60vh;
  border: 1px solid var(--line);
  background: #000;
  object-fit: cover;
}

/* Guides the barcode into frame — a phone camera hunts otherwise. */
.reticle {
  position: absolute;
  top: 50%;
  left: 8%;
  width: 84%;
  height: 22%;
  transform: translateY(-50%);
  border: 2px solid var(--accent);
  opacity: 0.65;
  pointer-events: none;
}

.viewfinder .ghost {
  width: 100%;
  margin-top: 0.5rem;
}

/* --- manual entry ------------------------------------------------------- */

details {
  margin-top: 1rem;
}

summary {
  color: var(--dim);
  cursor: pointer;
}

form {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.75rem;
}

input {
  flex: 1 1 auto;
  min-width: 0;
  padding: 0.7rem 0.85rem;
  border: 1px solid var(--line);
  background: #1b1e23;
  color: var(--fg);
  font: inherit;
  letter-spacing: 0.08em;
}

input:focus-visible,
button:focus-visible,
summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* --- output ------------------------------------------------------------- */

pre {
  margin: 1.5rem 0 0;
  padding: 1.1rem 0.9rem;
  border: 1px solid var(--line);
  background: var(--panel);
  /*
   * The render guarantees no line exceeds 44 characters, so this should never
   * actually scroll. It stays as a safety net: a <pre> scrolled right clips its
   * LEFT edge, which once hid the creature's own name.
   */
  overflow-x: auto;
  /*
   * ⚠️ `pre-wrap`, NOT `pre`. The 44-character guarantee above holds for the RENDERED
   * creature block, and then the egg message — an ordinary sentence — was put in here
   * and scrolled the panel sideways. Alessandro caught it on his phone. `pre-wrap`
   * keeps the alignment of anything pre-formatted and lets prose wrap, so no future
   * message can do it again; `overflow-x` stays as the net for an unbreakable token.
   */
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

pre:empty {
  display: none;
}

.note {
  margin-top: 1.25rem;
  color: var(--dim);
  font-size: var(--type);
}

#scan-note {
  margin-top: 0.75rem;
}

.dim {
  color: var(--dim);
}

.status {
  margin: 1rem 0 0;
  font-size: var(--type);
  color: var(--dim);
}

.status[data-kind="warn"] {
  color: #e0a35c;
}

/* --- notes -------------------------------------------------------------- */

.note-editor {
  margin-top: 1rem;
}

.note-editor label {
  display: block;
  margin-bottom: 0.4rem;
  font-size: var(--type);
  color: var(--dim);
}

.private {
  color: var(--accent);
  opacity: 0.8;
}

textarea {
  width: 100%;
  padding: 0.65rem 0.8rem;
  border: 1px solid var(--line);
  background: #1b1e23;
  color: var(--fg);
  font: inherit;
  font-size: var(--type);
  resize: vertical;
}

textarea:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

#note-saved {
  margin-top: 0.35rem;
}

/* --- collection --------------------------------------------------------- */

.collection {
  margin-top: 2.25rem;
  padding-top: 1.25rem;
  border-top: 1px solid var(--line);
}

.collection-head {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: baseline;
  justify-content: space-between;
}

.collection h2 {
  margin: 0;
  font-size: var(--type);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent);
}

.collection-actions {
  display: flex;
  gap: 0.4rem;
}

.small {
  padding: 0.35rem 0.7rem;
  font-size: var(--type);
}

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

.row {
  display: flex;
  align-items: stretch;
  gap: 0.4rem;
  margin-bottom: 0.4rem;
}

/*
 * THE "NEW" BADGE — a creature that arrived and has not been opened.
 *
 * ⚠️ A left BAR plus a corner tag, not just a coloured border. The rows are already
 * colour-coded by tier along their whole frame, so another colour would read as a tier
 * and not as news. A bar in the gutter sits outside that language entirely.
 *
 * It persists until the card is opened — Alessandro's call. A creature you have not
 * looked at is still one you have not looked at, however old, and an expiry would be us
 * deciding on the player's behalf that they have seen it.
 */
.row.is-unseen {
  position: relative;
  padding-left: 0.35rem;
}

.row.is-unseen::before {
  content: "";
  position: absolute;
  left: 0;
  top: 2px;
  bottom: 2px;
  width: 3px;
  background: var(--accent);
}

/*
 * ⚠️ RESERVE THE SPACE. The tag is absolutely positioned, so without this it sat ON TOP
 * of the creature's title — "Zarudidex, the Brute of" with the rest under the badge.
 * Found by looking at the screen, which is the only place absolute positioning ever
 * confesses.
 */
.row.is-unseen .row-main {
  padding-right: 3rem;
}

.row.is-unseen .row-main::after {
  content: "NEW";
  position: absolute;
  top: 4px;
  right: 6px;
  font-size: 0.62rem;
  letter-spacing: 0.06em;
  color: var(--bg);
  background: var(--accent);
  padding: 1px 4px;
  pointer-events: none;
}

.chip-new[aria-pressed="true"] {
  background: var(--accent);
  color: var(--bg);
}

/*
 * "Mark all seen" — deliberately QUIETER than the other chips. It is an escape hatch beside
 * the NEW filter, not a peer of the tier chips: the common action is opening a creature, and
 * a button that clears every badge should not be the easiest thing on the row to hit.
 * Rendered only when the count is above zero, so it never sits there inert.
 */
.chip-seen-all {
  opacity: 0.75;
  font-size: 0.78rem;
}

.chip-seen-all:hover,
.chip-seen-all:focus-visible {
  opacity: 1;
}

/* Under the revealed creature: "4 more hatched — see them". */
.more-hatched {
  margin-top: 0.6rem;
}

.row-main {
  position: relative;
  width: 100%;
  min-width: 0;
  display: grid;
  /*
   * thumbnail | three stacked lines: name / barcode / tier · clan · re-rolled
   *
   * ⚠️ It used to be `auto 1fr auto` with the meta right-aligned in its own
   * column and `white-space: nowrap`. On a narrow phone a long clan name plus
   * "re-rolled" could not shrink and pushed the text outside the card — which is
   * exactly what Alessandro reported. Two columns and a wrapping meta line cannot
   * overflow, because nothing is pinned to the right edge any more.
   */
  grid-template-columns: auto 1fr;
  /*
   * ⚠️ THE ROWS MUST BE EXPLICIT, and this is not cosmetic.
   *
   * `.row-thumb` says `grid-row: 1 / -1` to span every text line so the sprite anchors
   * the whole row. But `-1` resolves against the EXPLICIT grid, and with no
   * `grid-template-rows` every row here is implicit — so the thumbnail spanned only the
   * FIRST line, sat at the top of the row, and its `align-self: center` had nothing to
   * centre within. Alessandro saw the result exactly: *"vertically it's not in the middle
   * but like 2/3 up."* Measured before the fix: a 40px thumbnail 10.6px from the top of an
   * 83px row, so its centre was 11px above the row's.
   *
   * Three rows because there are three lines — name, barcode, and the tier/clan/re-rolled
   * meta. A fourth line would be implicit again and re-break the span, so it would need a
   * fourth track here.
   */
  grid-template-rows: repeat(3, auto);
  align-items: start;
  gap: 0 0.6rem;
  padding: 0.6rem 0.75rem;
  /*
   * ⚠️ BORDER AND BACKGROUND CARRY THE TIER, and nothing else does.
   *
   * The reference's rule: bind colour to the axis that VARIES inside one player's
   * collection. Tier varies; clan and house do not — a shopping trip returns one
   * clan, so a clan-coloured card makes every card the same colour. This is the same
   * reasoning that moved the title off the clan.
   *
   * Hue and saturation come from --tier-h / --tier-s, set per grade below. The
   * border is the saturated statement; the background is the SAME hue pulled far
   * down in saturation so body text stays readable. Both themes derive from the same
   * two numbers, so a new tier needs one line, not two.
   */
  border: 2px solid hsl(var(--tier-h) var(--tier-s) var(--tier-l));
  background: hsl(var(--tier-h) calc(var(--tier-s) * 0.30) 11%);
  color: var(--fg);
  font-weight: 400;
  text-align: left;
  letter-spacing: normal;
  border-width: 2px;
  clip-path: polygon(
    4px 0, calc(100% - 4px) 0, 100% 4px, 100% calc(100% - 4px),
    calc(100% - 4px) 100%, 4px 100%, 0 calc(100% - 4px), 0 4px
  );
}

/*
 * The tier ladder: **gray → yellow → green → blue → gold**.
 *
 * ⚠️ THIRD VERSION, and the history matters because it keeps drifting. First an
 * invented ladder (grey-blue/green/blue/violet/gold/ember/rose). Then the STANDARD
 * rank colours Alessandro supplied — E gray, D white, C green, B blue, A pink,
 * S/SS/SSS gold, L red — with D moved off white, which on a dark UI shouted louder
 * than the gold above it and inverted the whole ladder. Now this: D takes the gray
 * the standard assigns to the E rank we do not have, yellow moves in at C, and green
 * and blue shift up a rung.
 *
 * The reason it is better than bronze-at-D: **gray reads as worthless, bronze reads
 * as a metal** — and D is the most common tier in the game. Gray is the honest
 * signal. Pink leaves the ladder entirely.
 *
 * ⚠️ THE YELLOW/GOLD CLASH DOES NOT GO AWAY BY MOVING IT. Whichever tier wears
 * yellow shares a hue with the gold S tiers, so the separation must come from
 * saturation and lightness — exactly the problem bronze-at-D had, relocated. C is
 * the most common tier and S is the rarest, so those two must never be confusable.
 *
 * Measured rather than eyeballed, in RGB distance: C sits **76** from S, **78** from
 * D and **119** from B. The first draft of C (hsl 46 45% 46%) came out at only 61
 * from gold and was rejected for it.
 *
 * E and L do not exist in GRADES — they are declared so extending the ladder later
 * needs no re-derivation.
 *
 * Three numbers per tier: hue, saturation, border lightness. Light mode needs its own
 * lightness, which is clamped further down.
 */
/*
 * ⭐ **THE ITEM CELLS JOIN THE TIER PALETTE — his ask, 2026-09-08.** `.keeper-slot` and
 * `.keeper-item` now carry `data-tier`, so they read the same `--tier-h/s/l` the collection has
 * used since day one. ⚠️ ONE palette, not a second copy that drifts.
 */
.row-main, .bars li[data-tier], .filter-chosen li[data-tier], .filter-option[data-tier], .mark-tier[data-tier], .keeper-slot[data-tier], .keeper-item[data-tier], .shop-slot[data-tier] { --tier-h: 0; --tier-s: 0%; --tier-l: 60%; }
.row-main[data-tier="E"], .bars li[data-tier="E"], .filter-chosen li[data-tier="E"], .filter-option[data-tier="E"], .mark-tier[data-tier="E"], .keeper-slot[data-tier="E"], .keeper-item[data-tier="E"], .shop-slot[data-tier="E"]   { --tier-h: 0;   --tier-s: 0%;  --tier-l: 34%; } /* dark gray — the ORIGIN tier: in-store barcodes, off the rolled ladder */
.row-main[data-tier="D"], .bars li[data-tier="D"], .filter-chosen li[data-tier="D"], .filter-option[data-tier="D"], .mark-tier[data-tier="D"], .keeper-slot[data-tier="D"], .keeper-item[data-tier="D"], .shop-slot[data-tier="D"]   { --tier-h: 0;   --tier-s: 0%;  --tier-l: 48%; } /* gray   */
.row-main[data-tier="C"], .bars li[data-tier="C"], .filter-chosen li[data-tier="C"], .filter-option[data-tier="C"], .mark-tier[data-tier="C"], .keeper-slot[data-tier="C"], .keeper-item[data-tier="C"], .shop-slot[data-tier="C"]   { --tier-h: 48;  --tier-s: 50%; --tier-l: 40%; } /* yellow */
.row-main[data-tier="B"], .bars li[data-tier="B"], .filter-chosen li[data-tier="B"], .filter-option[data-tier="B"], .mark-tier[data-tier="B"], .keeper-slot[data-tier="B"], .keeper-item[data-tier="B"], .shop-slot[data-tier="B"]   { --tier-h: 140; --tier-s: 55%; --tier-l: 45%; } /* green  */
.row-main[data-tier="A"], .bars li[data-tier="A"], .filter-chosen li[data-tier="A"], .filter-option[data-tier="A"], .mark-tier[data-tier="A"], .keeper-slot[data-tier="A"], .keeper-item[data-tier="A"], .shop-slot[data-tier="A"]   { --tier-h: 212; --tier-s: 65%; --tier-l: 52%; } /* blue   */
.row-main[data-tier="S"], .bars li[data-tier="S"], .filter-chosen li[data-tier="S"], .filter-option[data-tier="S"], .mark-tier[data-tier="S"], .keeper-slot[data-tier="S"], .keeper-item[data-tier="S"], .shop-slot[data-tier="S"]   { --tier-h: 45;  --tier-s: 70%; --tier-l: 50%; } /* gold   */
.row-main[data-tier="SS"], .bars li[data-tier="SS"], .filter-chosen li[data-tier="SS"], .filter-option[data-tier="SS"], .mark-tier[data-tier="SS"], .keeper-slot[data-tier="SS"], .keeper-item[data-tier="SS"], .shop-slot[data-tier="SS"]  { --tier-h: 45;  --tier-s: 85%; --tier-l: 58%; } /* gold+  */
.row-main[data-tier="SSS"], .bars li[data-tier="SSS"], .filter-chosen li[data-tier="SSS"], .filter-option[data-tier="SSS"], .mark-tier[data-tier="SSS"], .keeper-slot[data-tier="SSS"], .keeper-item[data-tier="SSS"], .shop-slot[data-tier="SSS"] { --tier-h: 45;  --tier-s: 98%; --tier-l: 66%; } /* gold++ */
.row-main[data-tier="L"], .bars li[data-tier="L"], .filter-chosen li[data-tier="L"], .filter-option[data-tier="L"], .mark-tier[data-tier="L"], .keeper-slot[data-tier="L"], .keeper-item[data-tier="L"], .shop-slot[data-tier="L"]   { --tier-h: 0;   --tier-s: 80%; --tier-l: 52%; } /* red    — unused */

/* The tier label picks up its own colour, so the letter and the frame agree. */
.row-meta {
  color: hsl(var(--tier-h) var(--tier-s) var(--tier-l));
}

.row-name {
  grid-column: 2;
  font-weight: 400;
  /*
   * No tracking. The 1px gap between glyphs is already drawn into the font's
   * advance, so `letter-spacing` here added a fractional pixel per character —
   * which a pixel font cannot land on, and which widened the ", the" gap further.
   */
  letter-spacing: normal;
  /* A syllable name can run to twelve letters; let it break rather than overflow. */
  overflow-wrap: anywhere;
}

.row-name strong {
  font-weight: 700;
}

/*
 * The title rides on the same line as the name, lighter, so the pair reads as one
 * phrase — "Goronedok, the Ox of Monsoon" — while the eye still lands on the name.
 * On its own line it would read as a subtitle and stop doing its job.
 */
.row-title {
  color: var(--dim);
  font-weight: 400;
}

.row-meta {
  grid-column: 2;
  color: var(--accent);
  font-size: var(--type);
  text-align: left;
}

#coverage {
  font-size: var(--type);
}

/*
 * Narrow phones: shrink the output just enough that a 44-character line fits
 * without scrolling. A monospace glyph advances ~0.6em, so 44 chars need about
 * 26em of width; on a 360px screen minus padding that lands near 11px.
 */
@media (max-width: 26rem) {
  pre {
    font-size: var(--type);
    padding: 0.9rem 0.7rem;
  }
}

/*
 * The portrait.
 *
 * `image-rendering: pixelated` is not decoration — a browser's default smoothing
 * turns a 32px sprite into exactly the soft-edged mush the whole art constraint
 * exists to avoid. The scale factors are integers (128/32=4, 128/64=2, 128/128=1)
 * so nearest-neighbour lands each source pixel on a whole number of screen pixels;
 * a fractional scale gives uneven pixel widths, which reads as a rendering bug.
 */
/*
 * The scan page's sprite. One canvas, centred — the "actual pixels" swatch beside
 * it is gone, along with the debug line under it. The card below is the same one
 * the collection shows, so a creature looks identical wherever you meet it.
 */
.portrait {
  display: flex;
  justify-content: center;
  padding: 1rem 0 0.4rem;
}

/*
 * ⚠️ --box-w / --box-h, not width / height. The three body plans have three
 * different canvas shapes (64×64, 64×96, 96×64, each plus its shadow margin), and a
 * canvas obeys no `object-fit` — naming a hard 128×128 here STRETCHED every biped
 * and quadruped to fit it. `fitBox` in main.ts reads these two numbers and sets the
 * real width and height at one uniform scale.
 *
 * The element still needs a size before the first paint, or the row reflows once the
 * sprite lands; the height below is that placeholder and is overwritten immediately.
 */
/*
 * 168×156, not 128×128 — Alessandro's call, 2026-08-05.
 *
 * The shadow margin made the canvas bigger (round 64×64 → 84×78), and fitting that
 * into the old box shrank the creature to about 1.5×. 168×156 is exactly 2× the
 * padded round plan, so the creature is back to the size it was and every plan comes
 * out at least as large as before:
 *
 *   round  84×78  → 2.00×   biped  84×110 → 1.42×   quad  116×78 → 1.45×
 *
 * ⚠️ 200×188 as of 2026-08-06, still exactly 2× the padded round plan — the canvases
 * grew (round 64×64 → 80×80) to give the tier-weight ramp headroom, and leaving the
 * box at 168×156 would have rendered every creature ~16% SMALLER at the same box.
 * The box is derived from the plan, so it moves whenever the plan does.
 *
 * ⚠️ 268 WIDE as of 2026-08-09, because the biped canvas went 80 → 160 to make room
 * for wings. `fitBox` scales the WHOLE bitmap, empty margin included, so widening a
 * plan silently shrinks every creature on it: biped fell from 1.49× to 1.11×, a
 * quarter smaller, with nothing on screen to explain why.
 *
 * I had assumed the assembled grid was trimmed to its content and said so in
 * plans.ts. It is not — `assemble` takes its canvas from the torso and keeps it. And
 * trimming would be WRONG here anyway: a fixed frame is exactly what makes the
 * tier-weight ramp legible, since a shrunken E only reads as pathetic when it is
 * measured against the same box as an SSS. Widening the box is the fix; trimming
 * would have destroyed the signal.
 *
 * 268 = the padded biped's 180 width at the 1.49× it used to get. Round is unchanged
 * (still height-limited at 2.00×) and quad IMPROVES from 1.52× to 2.00×.
 * ⚠️ 316×211 as of 2026-08-16, because the TIER RAMP pads the canvas. `tier-art.ts` adds 16px
 * of headroom on each side and on top so an SSS at 1.24× is not clipped, at EVERY grade so
 * that all tiers share one frame. `fitBox` scales the whole canvas into this box, so a
 * canvas that grew 180×126 → 212×142 renders every creature 15% smaller unless the box grows
 * with it. Derived, not guessed: the old box gave the binding biped k=1.489, and 212×142 at
 * that same k is 316×211.
 *
 *
 * ⚠️ The scan page and the expanded card get this; the collection row does NOT. A
 * list is a list, and fifty rows growing to fit a shadow is a real cost in scrolling
 * for something you are skimming past.
 */
.portrait-canvas {
  /* ⚠️ 2026-09-19: the ramp's room grew (TIER_PAD.top 30, canvas 212×156) — every box grew TALLER by the same k, rounded up; widths untouched. */
  --box-w: 316;
  --box-h: 232;
  height: 232px;
  image-rendering: pixelated;
  flex: 0 0 auto;
}


/* Sized in JS to the sprite's true pixel count — this is the honest answer to
   "how big is it really", and no CSS rule can know the number. */


.sizes {
  display: flex;
  gap: 0.3rem;
  margin-top: auto;
}

.size {
  padding: 0.35rem 0.5rem;
  border: 1px solid var(--line);
  background: transparent;
  color: var(--dim);
  font-size: var(--type);
  font-weight: 400;
  letter-spacing: 0.04em;
}

.size[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--accent);
  font-weight: 700;
}

/* The name gate. A label, not a login — styled as a first-run question, with
   nothing that borrows the visual language of a sign-in form (no lock, no
   "password", no "account"), because it protects nothing and shouldn't imply it. */
.gate {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 80vh;
  padding: 1rem;
}

/*
 * THE ENTRY SCREEN'S RHYTHM — Alessandro, 2026-08-20: *"increase space between each
 * element, they are all crampled together, title in the center and all the text too,
 * buttons are fine but leave more space."*
 *
 * ⭐ **SPACING IS A GRID GAP HERE, NOT A PILE OF MARGINS**, and that is what makes the
 * instruction actionable: `--gate-gap` and `--gate-step` are the only two numbers on the
 * screen, so "more space" is a one-line change rather than a hunt through twelve rules
 * that each contribute a bit. Every child's own margin is zeroed below for the same
 * reason — a margin surviving here would add to the gap and put one element out of step
 * with the rest for no visible reason.
 */
.gate-card {
  /* ⚠️ Opened from 2.25rem on his second pass: title→creature and creature→panel. */
  --gate-gap: 3.25rem;
  --gate-step: 1.4rem;
  width: 100%;
  max-width: 24rem;
  /* His instruction, and it applies to the whole card: title, creature and every line. */
  text-align: center;
  display: grid;
  gap: var(--gate-gap);
  justify-items: center;
}

/* The panels are full width; the creature is not, and centres itself. */
.gate-card > *:not(.gate-creature) {
  width: 100%;
}

.gate-card h1 {
  margin: 0;
}

/*
 * THE ENTRY SCREEN'S CREATURE — Ochremite, the same drawing as the app icon.
 *
 * ⚠️ `image-rendering: pixelated` is not enough on its own: the canvas is sized in
 * ART pixels and displayed at its own size, so one source pixel lands on one device
 * pixel. Scaling it in CSS to fill a box is what made a family of creatures the wrong
 * shape once already — an integer scale in the paint call, never a stretched canvas.
 */
.gate-creature {
  display: block;
  margin: 0;
  image-rendering: pixelated;
}

/*
 * ⚠️ The three panels are siblings and only one is ever shown. They need no `[hidden]`
 * rule of their own — the global `[hidden] { display: none !important }` near the top of
 * this file already beats a class that sets `display`, which is the bug that once left
 * the name prompt on screen beside the scanner.
 */
#landing,
#name-panel,
#import-panel {
  display: grid;
  gap: var(--gate-step);
  justify-items: center;
}

#landing > *,
#name-panel > *,
#import-panel > * {
  width: 100%;
  margin: 0;
}

.landing-welcome {
  margin: 0;
  font-size: 1.15rem;
  color: var(--accent);
  letter-spacing: 0.02em;
}

/*
 * One column, thumb-reachable, in the order they are meant to be read.
 *
 * ⚠️ Its own gap is deliberately TIGHTER than the screen's. The buttons are one group and
 * should read as one — *"buttons are fine"* — while what separates them from the sentence
 * above is a full `--gate-gap`, his second pass: *"from your collection text to play
 * button"*. The panel's step already contributes, so only the difference is added here.
 */
/*
 * ⚠️ SELECTOR CARRIES THE ID, and it has to. `#landing > * { margin: 0 }` above is
 * specificity (1,0,0) and beats a bare class wherever it sits in the file — a margin
 * written as `.landing-actions` here is silently discarded.
 */
#landing .landing-actions {
  display: grid;
  gap: 0.75rem;
  margin: calc(var(--gate-gap) - var(--gate-step)) 0 0;
}

/* Step two of naming — the rule sits inside the panel grid, so no margin of its own. */
#name-connect {
  margin: 0;
}

/*
 * ⚠️ The case-4 refusal is written with a blank line in it — the rule and the choice are two
 * thoughts — so the newline has to survive. Every other message here is one sentence.
 */
#name-connect-note {
  white-space: pre-wrap;
}

#import-gsi {
  min-height: 44px;
}

#import-progress {
  color: var(--accent);
}

/*
 * ⭐ BACK IS SET APART. It is not one of the choices on the screen, it is the way off it,
 * so it gets a step of its own rather than sitting in the rhythm with everything else.
 */
#name-back,
#import-back {
  margin-top: calc(var(--gate-step) * 0.5);
}

#name-form {
  display: grid;
  gap: 0.75rem;
  margin: 0;
}

/* --- the three-step first run ------------------------------------------- */

/*
 * ⭐ THE COUNTER EXISTS TO SAY "THIS IS SHORT". Alessandro's spec asked for one by name — a
 * new player will give you three questions if they can see there are three, and none at all
 * if it looks open-ended.
 */
/*
 * ⭐ THE COUNTER IS THE HEADING ON A STEP, and it earns the promotion twice over: it tells a new
 * player how much is left — which is the one thing a first run has to say — and it buys back
 * the height the wordmark was using to repeat something they already know.
 */
.gate-step {
  margin: 0;
  color: var(--accent);
  font-size: 1.15rem;
  letter-spacing: 0.1em;
}

.gate-choice {
  display: grid;
  gap: 0.6rem;
}

/*
 * ⛔ THE CONSENT BOXES — deliberately plain, and left-aligned in a centred gate.
 *
 * ⚠️ A checkbox someone has to TICK must read as a statement they are making, not as decoration:
 * centred text under a centred box is hard to scan and easy to tick without reading. The label
 * wraps the input so the whole line is a hit target, which matters most on a phone.
 *
 * ⭐ No colour and no emphasis. This is the one place in the app that must not persuade.
 */
.gate-consent {
  display: grid;
  gap: 0.55rem;
  margin: 0.9rem 0 1rem;
  text-align: left;
}

.consent {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.55rem;
  align-items: start;
  color: var(--dim);
  font-size: 0.92rem;
  line-height: 1.45;
  cursor: pointer;
}

/* ⚠️ Big enough to hit with a thumb; the browser's own control, so it is obviously a checkbox. */
.consent input[type="checkbox"] {
  width: 1.15rem;
  height: 1.15rem;
  margin: 0.1rem 0 0;
  accent-color: var(--accent);
  cursor: pointer;
}

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

.choice-row {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
}

/* Two equal halves: neither answer is the default-looking one. */
.choice {
  flex: 1 1 0;
  background: var(--panel);
  border: 2px solid var(--line);
  color: var(--dim);
}

.choice[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--accent);
}

/*
 * ⛔ **STACKED, NOT SIDE BY SIDE** — 64 above, 32 below, which is what `onboarding.md` §2 asked
 * for and what the comparison needs. Side by side forces each creature into half a phone's
 * width, and the whole point is to see them at the size they are actually looked at: the
 * collection card is 316×211, and two of those do not fit next to each other.
 */
.art-choices {
  display: grid;
  gap: 0.6rem;
  justify-items: stretch;
}

/*
 * ⚠️ THIS STEP RUNS TIGHTER THAN THE REST, and it earns the exception: two creatures at a size
 * worth judging plus a button is a lot of height, and a first-run step you must SCROLL to
 * finish is worse than one that is a little dense. Measured at 390×844 — the step fits with
 * room to spare, and the comparison is the thing that gets the space.
 */
#art-panel {
  gap: 0.9rem;
}

/*
 * ⚠️ THE CARD'S OWN RHYTHM TIGHTENS FOR THIS STEP TOO. At the normal 3.25rem gap the step came
 * to 851px against an 844px viewport — seven pixels, and "Continue" fell below the fold, which
 * on a first run is the difference between three questions and two.
 */
.gate.is-tight {
  /* ⚠️ The container's own padding counts too: 823 + 2rem was still 855 against 844. */
  padding: 0.5rem;
}

/*
 * ⛔ **THE GATE WAS PAYING RENT FOR FURNITURE IT DOES NOT HAVE.**
 *
 * `body` reserves 28px at the top and 88px at the bottom for the sticky topbar and the fixed
 * tab bar — both of which live INSIDE `#app`, which is hidden whenever the gate is up. So every
 * gate screen was giving away 116px of a phone's height to a bar that was not on it.
 *
 * ⚠️ Found while chasing seven pixels on the art step, and it is worth more than that step: it
 * is the difference between "Continue" being on screen and being below the fold, on ALL of
 * them. Measured at 390×844.
 */
body.on-gate {
  padding-top: 0.5rem;
  padding-bottom: 0.5rem;
}

.gate.is-tight .gate-card {
  --gate-gap: 1.5rem;
}

.art-choice {
  display: grid;
  justify-items: center;
  gap: 0.5rem;
  padding: 0.75rem 0.5rem 0.6rem;
  background: var(--panel);
  border: 2px solid var(--line);
  color: var(--dim);
}

.art-choice[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--accent);
}

/*
 * ⚠️ IT HAS TO LOOK TAPPABLE, because the tap is the only way forward on this step — there is
 * no Continue to fall back on. A creature sitting in a plain frame reads as an illustration.
 */
.art-choice:hover,
.art-choice:focus-visible {
  border-color: var(--accent);
  color: var(--accent);
}

/*
 * ⚠️ THE BOX IS FIXED AND BOTH CANVASES SHARE IT — that is what makes this a comparison of
 * DETAIL rather than of size. `blit` reads these as `--box-w` / `--box-h` and letterboxes the
 * creature into them, exactly as the collection card does.
 */
/*
 * ⚠️ **220 IS THE MEASURED CEILING, not a taste.** Two things bought the room: the wordmark
 * standing down in favour of "Step 2 of 3", and the Continue button going away when tapping
 * became the answer. At 390×844 with both: 200 leaves 65px spare, **220 leaves 25**, 240
 * overflows by 15. Every pixel of it goes to the comparison, because the comparison IS the
 * step.
 */
.art-demo {
  /* ⚠️ UNITLESS, like `.portrait-canvas` — `boxFrame` reads these with `parseFloat`. */
  --box-w: 220;
  --box-h: 220;
  height: 220px;
  image-rendering: pixelated;
}

.art-label {
  font-size: var(--type);
  letter-spacing: 0.08em;
}

/*
 * THE HOMEPAGE'S SPRITE ANIMATION, ported.
 *
 * ⚠️ A STRIP, NOT A GIF: one PNG of `--n` frames, stepped by `background-position-x`. The end
 * position is the FULL strip width so `steps(n)` lands on each frame exactly, and it reads
 * `--s` too — which is why changing the scale cannot desync the animation from the art.
 *
 * ⚠️ `image-rendering: pixelated` and an INTEGER `--s`. A fractional scale puts one source
 * pixel on a fraction of a screen pixel, which is how a family of creatures came out the wrong
 * shape once already.
 */
/*
 * ⚠️ **`--s` IS SET PER STRIP IN `showArtPanel`, NOT HERE**, and that is the whole fix. The 32
 * art is exactly half the pixels of the 64 art, so it is drawn at DOUBLE the scale to land in
 * the same box — same size on screen, half the resolution, which is precisely what the
 * collection page does and what is being chosen. A shared scale compares picture sizes
 * instead, which is the version he sent back.
 */
.creature-strip {
  --s: 2;
  display: block;
  width: calc(var(--w) * var(--s));
  height: calc(var(--h) * var(--s));
  background-size: calc(var(--w) * var(--s) * var(--n)) calc(var(--h) * var(--s));
  background-repeat: no-repeat;
  background-position-x: 0;
  image-rendering: pixelated;
  animation-name: gate-parade;
  animation-iteration-count: infinite;
}

@keyframes gate-parade {
  from { background-position-x: 0; }
  to { background-position-x: calc(-1 * var(--w) * var(--s) * var(--n)); }
}

@media (prefers-reduced-motion: reduce) {
  /* ⚠️ Still SHOWN, just still: the size comparison is the point and it survives stillness. */
  .creature-strip { animation: none !important; }
}

#name-form label {
  color: var(--dim);
  font-size: var(--type);
}

#name-form input {
  width: 100%;
  padding: 0.7rem 0.8rem;
  border: 1px solid var(--line);
  background: var(--panel);
  color: var(--fg);
  font: inherit;
}

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

.whobox {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
}

.gate-label {
  margin: 1.25rem 0 0.5rem;
  color: var(--dim);
  font-size: var(--type);
}

/* "Who's playing?" and the names are one group, so they get their own inner grid. */
#roster {
  display: grid;
  gap: 0.75rem;
}

#roster > * {
  margin: 0;
}

.roster {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.5rem;
}

/*
 * Whole row is the target — this gets used one-handed, often by a child.
 *
 * ⚠️ Centred with the rest of the screen (his instruction, 2026-08-20). It is optically
 * off by half the ✕ button beside it; centring within the row rather than within the card
 * is the honest reading of "all the text", and his eye decides if it wants the other one.
 */
.roster button {
  width: 100%;
  text-align: center;
  padding: 0.7rem 0.8rem;
  border: 1px solid var(--line);
  background: var(--panel);
  color: var(--fg);
  font-weight: 400;
  letter-spacing: 0.02em;
}

.roster button:hover {
  border-color: var(--accent);
  color: var(--accent);
}

/* Deliberately understated: it's a label you can change, not an account menu. */
.who {
  padding: 0.3rem 0.6rem;
  border: 1px solid var(--line);
  background: transparent;
  color: var(--dim);
  font-size: var(--type);
  font-weight: 400;
  letter-spacing: 0.04em;
  max-width: 45%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.who {
  display: inline-block;
}

/* Storage warnings are the one note in the app that must not be ignorable —
   the failure it describes is silent and irreversible. */
.warn-note {
  padding: 0.5rem 0.65rem;
  border-left: 2px solid var(--accent);
  background: var(--panel);
  color: var(--fg);
}


/* --- the header bar ------------------------------------------------------ */

/*
 * ⛔ **FULL-BLEED, LIKE THE TAB BAR — it used to stop 300px short of it on a wide window.**
 *
 * Alessandro, 2026-08-30: *"the headbar never fills all the space like the navbar, I wanted to
 * tell you since long time but always forget."* ⚠️ **Measured before touching it:** at a 1200px
 * viewport the topbar ran 312→888 (576px) while `.tabs` ran 0→1200. The two bars bracket the app
 * and only one of them reached the edges.
 *
 * ⭐ **THE CAUSE IS THAT THEY ESCAPE DIFFERENTLY.** `.tabs` is `position: fixed; left: 0; right: 0`
 * and answers to the viewport. The topbar is in flow inside `main`, which is `max-width: 34rem`,
 * so its old `-1rem` side margins could only cancel the BODY's padding — never the column cap.
 *
 * ⚠️ **SO THE BAR GOES FULL WIDTH AND THE PADDING PUTS THE CONTENT BACK.** `max()` is what keeps
 * it honest on a phone: below 34rem the calc goes negative and the 1rem floor wins, so a narrow
 * screen is exactly as it was.
 */
.topbar {
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.6rem 1rem;
  margin: -1.75rem -1rem 1rem;
  background: var(--panel);
  border-bottom: 1px solid var(--line);
}

/*
 * ⛔ **ONLY ABOVE 36rem, AND THE SCOPE IS THE FIX RATHER THAN A DETAIL.**
 *
 * ⭐ **A PHONE WAS ALREADY FULL-BLEED**: `main`'s 34rem cap does not bind below it, so the `-1rem`
 * margins above cancel the body padding exactly and the bar reaches both edges. The gap only opens
 * once the column stops growing — at `34rem + 2rem`, which is this breakpoint, so the two rules
 * meet exactly: `(36rem − 34rem) / 2` is the same 1rem the narrow case uses.
 *
 * ⛔ **AND APPLYING IT EVERYWHERE BROKE THE PHONE, measured:** at 375px the bar came out 19→356
 * instead of 0→375, because `zoom: 0.9` below 380px scales the element while `100vw` still
 * resolves to 375 — 375 × 0.9 = 337.5, centred, which is exactly the 19px inset that appeared.
 * **`vw` and `zoom` do not agree, so the zoomed widths must never see a `vw` calculation.**
 */
@media (min-width: 36rem) {
  .topbar {
    width: 100vw;
    padding-left: calc((100vw - 34rem) / 2);
    padding-right: calc((100vw - 34rem) / 2);
    margin-left: calc(50% - 50vw);
    margin-right: calc(50% - 50vw);
  }
}

.topbar-name {
  font-size: var(--type);
  letter-spacing: 0.06em;
  color: var(--accent);
  font-weight: 700;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.burger {
  flex: 0 0 auto;
  width: 2.5rem;
  height: 2.5rem;
  padding: 0;
  border: 1px solid var(--line);
  background: transparent;
  color: var(--fg);
  font-size: var(--type);
  line-height: 1;
}

.menu {
  position: sticky;
  top: 3.4rem;
  z-index: 19;
  display: grid;
  gap: 0.3rem;
  margin: -0.5rem 0 1rem;
  padding: 0.6rem;
  border: 1px solid var(--line);
  background: var(--panel);
}

.menu button {
  width: 100%;
  text-align: left;
  padding: 0.7rem 0.8rem;
  border: 1px solid transparent;
  background: transparent;
  color: var(--fg);
  font-weight: 400;
  letter-spacing: 0.03em;
}

.menu button:hover,
.menu button[aria-current="true"] {
  border-color: var(--accent);
  color: var(--accent);
}

.menu hr {
  border: 0;
  border-top: 1px solid var(--line);
  margin: 0.3rem 0;
}

h1 {
  font-size: var(--type-big);
}


/* Sprite thumbnails in the collection list. Nearest-neighbour, and sized in CSS
   rather than by canvas width so one source pixel is always a whole number of
   screen pixels. */
/*
 * Light mode. Same hues, capped lightness, a pale wash instead of a dark one.
 *
 * ⚠️ THIS BLOCK WAS DELETED ONCE and the symptom was a BLACK card on a light page.
 * The dark 11%-lightness background is declared on `.row-main` unconditionally, so
 * losing this override leaves light mode wearing the dark theme's card. It vanished
 * inside the slice while a stale duplicate ladder was being cut out. If black cards
 * ever reappear in light theme, this is the first thing to check.
 *
 * The lightness clamp is load-bearing too: several tier borders are bright enough to
 * disappear on a pale page, so none may exceed 45%.
 */
:root[data-theme="light"] .row-main {
  border-color: hsl(var(--tier-h) var(--tier-s) min(var(--tier-l), 45%));
  background: hsl(var(--tier-h) calc(var(--tier-s) * 0.35) 95%);
}

:root[data-theme="light"] .row-meta {
  color: hsl(var(--tier-h) var(--tier-s) min(var(--tier-l), 38%));
}

.row-thumb {
  /* Spans every text line, so the sprite anchors the whole row. */
  grid-row: 1 / -1;
  /* See .portrait-canvas — the plans are three shapes, so the box is a bound and
     not a size. A fixed 40×40 squashed every biped and quadruped in the list.
     ⚠️ 58 wide since the biped canvas doubled: at 40 the row thumb was width-limited
     and every biped in the list shrank by a third. 58 puts the bound back on HEIGHT,
     where a list wants it — rows stay 40px tall whatever shape the creature is. */
  /* ⚠️ 67×45 as of 2026-08-16 — the tier ramp's canvas padding, see .portrait-canvas. The
     row is HEIGHT-bound by design, and 58×40 against the padded canvas flips it to
     width-bound, which is the bug the paragraph above records happening once already.
     67×45 keeps k at 0.316, so a list of creatures is the size it was. */
  /* ⚠️ 2026-09-19: the ramp's room grew (TIER_PAD.top 30, canvas 212×156) — every box grew TALLER by the same k, rounded up; widths untouched. */
  --box-w: 67;
  --box-h: 50;
  height: 50px;
  image-rendering: pixelated;
  align-self: center;
}

/*
 * ⭐ **THE POOL ROW'S CREATURE DOUBLES WHERE THERE IS ROOM** — *"I'm talking about the battle
 * page card also, especially that one"*, 2026-08-21.
 *
 * ⚠️ **THE ROW DOES NOT GET TALLER, because the space was already there.** Measured at 390px:
 * the row is 358×115 and the thumb was 67×45, spanning all three text rows and leaving **70px
 * of its own column unused**. This spends that, and the row height does not move.
 *
 * ⛔ **THE STAT ICONS ARE WHAT BOUNDS IT, not the row.** The icon row is the widest thing in
 * the text column at 184px (5 × 32 + 4 × 6), so the text column may never go below that:
 *   thumb ≤ row − 9.6 gap − 184.
 * At 358 that allows 164. ⚠️ Raising `.stat-icons` gap raises 184 and eats the slack — the two
 * numbers are linked, which nothing but this comment will tell you.
 *
 * ⛔ **TWO STEPS, NOT ONE, AND 381px IS WHY.** `zoom: 0.9` below 380px hands the layout MORE
 * room, so 381 — the first unzoomed width — is tighter than the 375 below it. Measured slack
 * between the icon row and its column:
 *
 * | viewport | thumb (rendered) | slack |
 * |---|---|---|
 * | 320–359 | 60×41 (base) | 33–72 |
 * | 360–375 | 101×68 | 33–48 |
 * | 381–389 | 112×75 | 17–25 |
 * | 390+ | 128×86 | 10–196 |
 *
 * ⚠️ The 360–375 rendered sizes are the 112 box times `zoom: 0.9`, not a third step.
 *
 * One step of 128 from 360 up was tried first: it fits at every width and left **1.4px** at
 * 381. That passes a measurement and fails the standing rule, which is his — *"we need to make
 * sure we don't cut anything."* A second step costs three lines and buys real room.
 *
 * ⚠️ **360px, and the number is derived:** the first step needs a row of at least 184 + 9.6 +
 * 112 = 306, which is a 360px viewport (row = viewport − 32) or a zoomed narrower one, where `zoom:
 * 0.9` hands the layout MORE room. Below that the small thumb stays, because the alternative is
 * a wrapped icon row.
 */
@media (min-width: 360px) {
  .row-thumb {
    --box-w: 112;
    --box-h: 83;
    height: 83px;
  }
}

@media (min-width: 390px) {
  .row-thumb {
    --box-w: 128;
    --box-h: 95;
    height: 95px;
  }
}


/* The expanded creature card, inside its list row. */
.row {
  display: block;
}

.row-card {
  padding: 0.5rem 0 0.75rem;
}

.row-card pre {
  margin: 0.5rem 0 0;
}

/* The expanded card — you tapped to open it, so the creature is the point here too.
   One card is open at a time, so this costs no scrolling. See .portrait-canvas. */
.row-art {
  display: block;
  /* 316, matching .portrait-canvas — same reason, plus the tier ramp's padding. */
  /* ⚠️ 2026-09-19: the ramp's room grew (TIER_PAD.top 30, canvas 212×156) — every box grew TALLER by the same k, rounded up; widths untouched. */
  --box-w: 316;
  --box-h: 232;
  height: 232px;
  margin: 0 auto;
  image-rendering: pixelated;
}

/*
 * ⭐ **THE CARD'S CREATURE IS 13% BIGGER WHERE THERE IS ROOM** — asked for by a friend of
 * Alessandro's, relayed 2026-08-21: *"my friend would like to see the creature bigger in the
 * card… we have plenty of horizontal space, we are limited vertically."*
 *
 * ⛔ **WIDTH ALONE BUYS NOTHING, AND THIS IS THE THING TO UNDERSTAND BEFORE TOUCHING IT.** The
 * box is already BALANCED: the widest canvas any creature produces is 212×142 (the padded
 * biped — measured, and it is what every card reserves), and 316/212 = 1.491 against
 * 211/142 = 1.486. The two constraints agree to within 0.3% because the box was derived that
 * way. So the creature is height-bound by a hair, and widening the box while holding the height
 * leaves it EXACTLY the same size. The 42px of slack on a 390px phone is page margin, not room
 * inside the frame.
 *
 * ⚠️ **SO THE HEIGHT HAS TO MOVE, on the axis he called scarce.** 358×239 keeps the same 1.498
 * aspect, uses every pixel of that margin, and costs 28px of vertical: scale goes 1.49× → 1.69×.
 *
 * ⛔ **NOT A TRIM, AND NEVER A TRIM.** The obvious alternative — crop each canvas to its content
 * so a small creature stops carrying empty margin — would destroy the tier-weight signal. A
 * shrunken E only reads as pathetic when it is measured against the same frame as an SSS. The
 * note above `.portrait-canvas` records that this was already established once.
 *
 * ⚠️ **390px, because 358 is what a 390px phone HAS** (viewport minus 32 of page padding). The
 * gate is deliberately on the raw viewport, so a zoomed narrow phone keeps the smaller box even
 * where it would have fitted — the failure it avoids is a canvas wider than the page, which
 * scrolls the whole thing sideways, and he has hit that before.
 */
@media (min-width: 390px) {
  .row-art {
    --box-w: 358;
    --box-h: 263;
    height: 263px;
  }
}

/* --- the creature card ---------------------------------------------------
 *
 * ⚠️ Replaces a `<pre>` holding a 44-column text dump. At 18px a 44-character
 * monospace line is 528px — wider than any phone — so the card scrolled sideways.
 * The old stylesheet shrank the `pre` font on narrow screens purely to make those
 * columns fit, and the pixel font killed that trick: it is only crisp at multiples
 * of 9px, and 9px is unreadable. Stacking the label above its value removes the
 * columns entirely, which is why nothing here sets a width.
 */

.creature {
  border: 1px solid var(--line);
  padding: 0.75rem;
  background: var(--panel);
  border-width: 2px;
  clip-path: polygon(
    4px 0, calc(100% - 4px) 0, 100% 4px, 100% calc(100% - 4px),
    calc(100% - 4px) 100%, 4px 100%, 0 calc(100% - 4px), 0 4px
  );
}

.creature-name {
  margin: 0;
  font-size: var(--type-big);
  letter-spacing: 0.08em;
  overflow-wrap: anywhere;
}

.creature-title {
  margin: 0 0 0.35rem;
  color: var(--dim);
}

.creature-quote {
  margin: 0 0 0.75rem;
  color: var(--accent);
}

.section {
  margin: 0.9rem 0 0.35rem;
  font-size: var(--type);
  letter-spacing: 0.12em;
  color: var(--dim);
}

/*
 * ONE FACT PER LINE — TIER, ELEMENT, ARCHETYPE, CLAN, FAMILY, each on its own row.
 *
 * ⚠️ IT USED TO BE TWO COLUMNS, and on a phone that put two label-and-value pairs side by side
 * with their notes wrapping underneath — so "ARCHETYPE COLOSSUS" sat next to "CLAN Sunspine"
 * and their second lines ("VIT · FOR · GRD · SPK" and "Mediterranean south") ran together into
 * one unreadable strip. Alessandro: *"put them all on new lines, so they don't become hard to
 * read on small devices when they are on the same line."*
 *
 * ⭐ The height this costs is cheap: five short rows, and the card already scrolls for its stat
 * bars. Two columns was saving space the card was not short of.
 *
 * ⚠️ THE COVERAGE REPORT KEEPS ITS COLUMNS via `.report .fields` — every value there is "8/32",
 * and one tall column of short numbers wastes half the panel. Do not "tidy" the two rules into
 * one; they are different problems.
 */
.fields {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.5rem 0.75rem;
}

/* ⚠️ LABEL AND VALUE SIT ON ONE LINE — "TIER C", not TIER above C.
   Alessandro, 2026-08-08, looking at a real card: three lines for one fact, and a
   card that ran off the bottom of the phone.

   The label USED to sit above the value, which was also his suggestion at the time
   and was right then: it removed the fixed-width padded columns that made the old
   <pre> scroll sideways. Now that the values are short — a tier letter, a clan word
   — the column is gone and stacking only costs height.

   `flex-wrap` is what keeps the old fix intact: a long value (`pearl white / blue
   sheen`) wraps under its label instead of forcing the card wider. */
/*
 * ⚠️ **THE ROW GAP MATCHES `.fields`, or a wrapped NOTE is glued to the line above it.**
 *
 * It was `0 0.6rem` — no row gap at all — which nothing revealed until the archetype's note
 * became a row of 32px icons. Measured on a real card: every field sat 8px from its neighbour
 * and the icons sat **0px** below the ARCHETYPE line. Alessandro: *"icons are closer to element
 * text and have a lot of space at the bottom, let's make all equal."*
 *
 * ⭐ 0.5rem is not a new number — it is the same 8px `.fields` already uses between fields, so
 * the card has ONE vertical rhythm rather than one for fields and another for their notes.
 */
.field {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5rem 0.6rem;
  min-width: 0;
}

.field-label {
  color: var(--dim);
  letter-spacing: 0.12em;
}

.field-value {
  overflow-wrap: anywhere;
}

/* The note is genuinely subordinate — a clan's region, a cap's reason — so it takes
   its own line under the pair rather than competing with the value. */
.field-note {
  color: var(--dim);
  flex-basis: 100%;
}

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

/*
 * ⚠️ Two columns, and BOTH rows must agree on where the split is.
 *
 * The first version let the value and the bar find their own widths, so the number
 * sat hard right on row 1 while the bar sat somewhere else on row 2 and the role
 * label floated alone on the left. Nothing lined up with anything —
 * Alessandro: *"alignment of the stats is wonky."*
 *
 * The bar is a fixed 118px (10 cells of 8px + 9 gaps of 2px), so the right column
 * is pinned to exactly that and the number sits above its own bar rather than
 * above whitespace.
 */
/*
 * ⭐ **ONE LINE PER STAT: ICON · NAME · NUMBER · BAR** — his layout.
 *
 * ⚠️ It was a two-row grid, name and number above, bar wrapped underneath — which made every
 * stat two lines tall and stood the five bars in a column away from the numbers they describe.
 * A flex row puts each number against its own bar.
 *
 * ⚠️ The name is the only part allowed to shrink: the icon is fixed art, the number and the
 * bar are fixed widths, and a name that ellipsises is better than a bar that wraps.
 */
.stats li {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-bottom: 0.5rem;
}

.stats .stat-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.stats .stat-value {
  flex: 0 0 auto;
  /* ⚠️ Fixed width, so five different numbers do not put five bars at five different starts. */
  min-width: 2.5ch;
  text-align: right;
}

.stat-name {
  letter-spacing: 0.06em;
}

/* The icon rides with the name — see the note at the call site about the two-column grid. */
.stats .stat-name {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

.stat-value {
  text-align: right;
}

/*
 * The bar, in blocks rather than characters.
 *
 * Ten cells for ten tiers. Sized in whole pixels so it lands on the same grid as
 * the font and the sprites — a bar with fractional cells is the one thing on screen
 * that would betray that this is CSS and not pixel art.
 */
/* One pixel per point — see statBar() in main.ts for why the segments went. */
.stat-bar {
  /* ⚠️ Fixed, and last on the line — see `.stats li`. */
  flex: 0 0 100px;
  width: 100px;
  height: 10px;
  background: var(--line);
}

.stat-bar i {
  display: block;
  height: 100%;
  background: var(--stat-3);
}

/*
 * Colour by absolute value, in five bands of twenty.
 *
 * ⚠️ REDUNDANT WITH LENGTH AND THE NUMBER, WHICH IS WHY IT IS SAFE. Roughly one man
 * in twelve cannot separate red from green, and for them the bar still reads
 * correctly because its LENGTH carries the same fact and the value is printed beside
 * it. Colour here is a third encoding of one thing, never the only one.
 */
.stat-bar i[data-band="1"] { background: var(--stat-1); }
.stat-bar i[data-band="2"] { background: var(--stat-2); }
.stat-bar i[data-band="3"] { background: var(--stat-3); }
.stat-bar i[data-band="4"] { background: var(--stat-4); }
.stat-bar i[data-band="5"] { background: var(--stat-5); }

/* Only ever visible with ?debug=1 — see DEBUG in main.ts. */
.debug {
  margin-top: 0.9rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--line);
  color: var(--dim);
}

/* --- achievements --------------------------------------------------------
 *
 * ⚠️ NO BADGE ART EXISTS YET. `.badge-art` reserves the box a drawing will occupy so
 * the layout can be judged first — the opposite order to the part library, where
 * twenty torsos were drawn before the arrangement was settled. When art arrives it
 * replaces the contents of this box and nothing around it moves.
 *
 * The box is 40px because that is what a 20×20 badge at 2× occupies, and doubling a
 * whole number is the only scaling that keeps pixel art crisp.
 */

/*
 * The filter row and the collapsible sub-sections — added 2026-08-25 with the 192-badge
 * catalogue. ⛔ THE CREATURES alone is 132 of them, so the page has to open as a handful of
 * headings rather than a wall: `achievements.md` §3p.
 */
/*
 * The badge close-up — 128px is 4× of 32, and only integer multiples are legal.
 * ⚠️ A native <dialog>, so focus trapping, Escape and the backdrop are the browser's job.
 */
/*
 * ⛔ A FIXED WIDTH, NOT A MAX-WIDTH. A <dialog> is shrink-to-fit, so `max-width` alone let the box
 * size itself to its longest line: "Not found yet." produced a visibly narrower dialog than "Find
 * a hazed creature." — the same dialog changing shape depending on which badge you clicked, which
 * is what he caught in two screenshots.
 *
 * ⚠️ `min()` so it still fits a narrow phone; the width is fixed, not rigid.
 */
.badge-modal {
  border: 2px solid var(--accent);
  background: var(--bg);
  color: var(--fg);
  width: min(22rem, calc(100vw - 2rem));
  padding: 1.25rem;
  text-align: center;
}

/*
 * ⚠️ The close button sits in a <form method="dialog">, which is a block — so the button lands
 * hard left however the dialog is aligned. `text-align: center` on the dialog does not reach it.
 */
.badge-modal form { display: flex; justify-content: center; margin-top: 0.25rem; }

.badge-modal::backdrop { background: rgb(0 0 0 / 0.6); }

/*
 * ⛔ A FIXED 128px BOX, EARNED OR NOT. His ask: "make them all the same size, discovered or not."
 * `min-height` alone let an unearned badge — whose contents may be a single `?` glyph — collapse
 * the box narrower than a drawing, so the dialog changed shape depending on what you clicked.
 */
.badge-modal-art {
  display: grid;
  place-items: center;
  width: 128px;
  height: 128px;
  margin: 0 auto 0.75rem;
  font-size: 3rem;
  color: var(--dim);
}

.badge-modal-art img { image-rendering: pixelated; display: block; }

.badge-modal-detail { margin: 0.25rem 0 0.5rem; }

/* ⚠️ 192 rows have to look activatable, or the close-up is a feature nobody finds. */
.badge { cursor: pointer; }
.badge:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/*
 * ⭐ THE ARROWS HAVE TO LOOK LIKE ARROWS YOU CAN PRESS. His ask: "in those pages with the
 * pagination we could make the arrows glow slightly to show them."
 *
 * ⚠️ A PULSE THAT NEVER STOPS IS A NAG. This runs three times and settles — long enough to catch
 * the eye on arrival, short enough that it is not still moving while somebody reads.
 */
.board-pager button {
  color: var(--accent);
  border-color: var(--accent);
  animation: pager-hint 1.4s ease-in-out 3;
}

@keyframes pager-hint {
  0%, 100% { opacity: 0.55; }
  50%      { opacity: 1; }
}

/* ⛔ Somebody who asked for less motion gets none. The colour still marks them as controls. */
@media (prefers-reduced-motion: reduce) {
  .board-pager button { animation: none; opacity: 1; }
}

.awards-kicker {
  text-align: center;
  margin: 0 0 -0.25rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 0.7rem;
}

.badge-filter {
  display: flex;
  gap: 6px;
  margin: 0 0 1rem;
}

.badge-filter button {
  font: inherit;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 4px 12px;
  border: 2px solid var(--line);
  background: var(--bg);
  color: var(--dim);
  cursor: pointer;
}

/* ⚠️ aria-pressed IS the state, so the styling cannot drift from what a screen reader says. */
.badge-filter button[aria-pressed="true"] {
  color: var(--accent);
  border-color: var(--accent);
}

.badge-sub {
  border-top: 1px solid var(--line);
  margin-top: 0.5rem;
}

.badge-sub > summary {
  cursor: pointer;
  padding: 0.5rem 0;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--dim);
}

.badge-sub[open] > summary { color: var(--accent); }

.badge-group {
  margin-bottom: 1.25rem;
}

.badge-blurb {
  margin: 0 0 0.6rem;
  color: var(--dim);
}

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

.badge {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 0 0.6rem;
  padding: 0.5rem;
  margin-bottom: 0.4rem;
  border: 1px solid var(--line);
  background: var(--panel);
  /* Unearned badges are present but quiet: a goal you can see is the point, and a
     hidden one cannot motivate anyone. */
  opacity: 0.55;
  border-width: 2px;
  clip-path: polygon(
    3px 0, calc(100% - 3px) 0, 100% 3px, 100% calc(100% - 3px),
    calc(100% - 3px) 100%, 3px 100%, 0 calc(100% - 3px), 0 3px
  );
}

.badge.earned {
  opacity: 1;
  border-color: var(--accent);
}

/* Tier badges wear the tier's own colour — one ladder, reused, never a second
   colour language. See the tier ramp above. */
.badge.earned[data-tier] {
  border-color: hsl(var(--tier-h) var(--tier-s) var(--tier-l));
}

/*
 * ⛔ 32px, NOT 40. A badge is drawn at 32×32 and shown at an INTEGER multiple — 32, 64 or 128.
 * `box-sizing: border-box` is global and this box carries a 2px border, so a 40px box left
 * 36px of usable space: a 32px drawing would have been resampled to 1.125×, the exact ragged
 * mush the whole art constraint exists to prevent. `achievements.md` §3n.
 */
.badge-art img { image-rendering: pixelated; display: block; }

/*
 * ⛔ THE SILHOUETTE AND DIM TREATMENTS WERE REMOVED 2026-09-07 — his call.
 *
 * An unearned badge had FOUR looks depending on its family: dimmed art, a silhouette, a "?" and a
 * "·". He saw all four on one screen — "we have some with the silhouette, some with ? and now some
 * with a dot… some still show the badge dimmed" — and nothing on that screen taught a player what
 * any of them meant.
 *
 * ⭐ TWO STATES NOW, and they are in `main.ts` rather than here because there is no longer anything
 * to style: locked is a "?", unlocked is the art, and the star is only for an unlocked badge nobody
 * has drawn yet. `reveal` still decides what the NAME and DETAIL give away; it no longer touches
 * the picture, and those were two different questions tangled into one branch.
 */

.badge-art {
  width: 36px;
  height: 36px;
  display: grid;
  place-items: center;
  /* ⚠️ The art is 32px inside a 2px border: 32 + 2 + 2 = 36, so it sits flush and unscaled. */
  padding: 0;
  border: 1px solid var(--line);
  color: var(--dim);
  /* A drawing goes here. Until then, the frame shows the space is reserved. */
  background: var(--bg);
  border-width: 2px;
  clip-path: polygon(
    3px 0, calc(100% - 3px) 0, 100% 3px, 100% calc(100% - 3px),
    calc(100% - 3px) 100%, 3px 100%, 0 calc(100% - 3px), 0 3px
  );
}

.badge.earned .badge-art {
  color: var(--accent);
  border-color: var(--accent);
}

.badge.earned[data-tier] .badge-art {
  color: hsl(var(--tier-h) var(--tier-s) var(--tier-l));
  border-color: hsl(var(--tier-h) var(--tier-s) var(--tier-l));
}

.badge-text {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.badge-name {
  letter-spacing: 0.06em;
}

.badge-detail {
  color: var(--dim);
  overflow-wrap: anywhere;
}

.badge-progress {
  color: var(--dim);
  white-space: nowrap;
}

/* --- three-line tier frames ----------------------------------------------
 *
 * Alessandro: *"if we make them 3px border we can use the middle line for colour and
 * the 2 external shade of the main, so it looks more pixel style and we use more the
 * tier colours."* Right on both counts — it is the classic 8-bit window frame, and it
 * puts the tier colour on three times the pixels a 1px border did.
 *
 * ⚠️ BUILT FROM INSET RINGS, NOT A BORDER, and that is forced rather than chosen: a
 * CSS border cannot carry three colours, and the obvious alternative — stacked outer
 * `box-shadow`s — is CLIPPED AWAY by the `clip-path` that gives us notched corners.
 * Inset shadows are drawn inside the box, so they survive the clip and get cut
 * diagonally at each corner, which is exactly the stepped look we want.
 *
 * Ring order matters and is counter-intuitive: the FIRST shadow listed paints on top,
 * so they run narrowest-first — 1px shade, then 2px colour, then 3px shade — leaving
 * one pixel of each, outside in.
 *
 * Shades are the same hue at lower lightness, never grey. A grey outer line would
 * read as a drop shadow; a darker tint reads as the same painted line curving away.
 */
.tier-frame {
  border: none;
  box-shadow:
    inset 0 0 0 1px hsl(var(--tier-h) var(--tier-s) calc(var(--tier-l) - 20%)),
    inset 0 0 0 2px hsl(var(--tier-h) var(--tier-s) var(--tier-l)),
    inset 0 0 0 3px hsl(var(--tier-h) var(--tier-s) calc(var(--tier-l) - 20%));
}

.row-main {
  border: none;
  /* The rings occupy 3px that the border used to, so the padding absorbs them. */
  padding: calc(0.6rem + 1px) calc(0.75rem + 1px);
  box-shadow:
    inset 0 0 0 1px hsl(var(--tier-h) var(--tier-s) calc(var(--tier-l) - 20%)),
    inset 0 0 0 2px hsl(var(--tier-h) var(--tier-s) var(--tier-l)),
    inset 0 0 0 3px hsl(var(--tier-h) var(--tier-s) calc(var(--tier-l) - 20%));
}

/*
 * Light mode keeps the same construction; only the lightnesses move. The shade goes
 * DARKER here too — on a pale page a lighter outer ring would vanish into the
 * background and the frame would read as 1px again.
 *
 * ✅ Verified across all 7 tiers × 2 themes: every combination resolves to three
 * distinct rings with the shade genuinely darker than the mid, including the nested
 * `min()` inside `calc()` that the light-mode clamp needs. The bronze D is the tight
 * one — 45% clamped, minus 15%, still reads against a #f4f2ed page.
 */
:root[data-theme="light"] .row-main {
  box-shadow:
    inset 0 0 0 1px hsl(var(--tier-h) var(--tier-s) calc(min(var(--tier-l), 45%) - 15%)),
    inset 0 0 0 2px hsl(var(--tier-h) var(--tier-s) min(var(--tier-l), 45%)),
    inset 0 0 0 3px hsl(var(--tier-h) var(--tier-s) calc(min(var(--tier-l), 45%) - 15%));
}

/* The sort control. A native <select> on purpose: it gets the platform picker on a
   phone, which is a better wheel than anything we would draw, and it is one tap. */
/* --- sort ----------------------------------------------------------------
 *
 * Its own row, and no <select>.
 *
 * Two bugs in one control. It shared a line with three buttons, which pushed the
 * collection header wider than a phone and made the page scroll sideways — the
 * third time that has happened, hence the rule: one control group per line.
 *
 * And it was a native dropdown. A <select> paints its popup from the OS: rounded
 * corners, anti-aliasing, system font, none of it reachable from CSS. Every other
 * pixel on this page is on our grid. Five options fit as a segmented row, so the
 * popup simply stops existing — and it costs one tap instead of two.
 */
.sort {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem 0.5rem;
  margin-top: 0.7rem;
}

.sort-label {
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.sort-options {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
}

/*
 * The sort and filter chips.
 *
 * ⚠️ `min-height`, because these are the controls a player taps most often and they were
 * the smallest thing on the screen. Padding alone left them around 27px tall — over
 * WCAG 2.2's 24px floor but under every platform convention, and they sit in a tightly
 * packed row where a near miss hits the neighbouring chip rather than nothing.
 *
 * ⚠️ Remember the zoom when judging this: on a phone under 380px the whole UI renders at
 * 0.9, so 36 CSS pixels reach the thumb as about 32. That is the trade Alessandro chose
 * — one zoom rule instead of a breakpoint per component — and it applies to every size
 * in this file, so read them all as "×0.9 on a small phone".
 */
.sort-options button {
  font: inherit;
  color: var(--dim);
  background: var(--panel);
  border: 2px solid var(--line);
  padding: 0.2rem 0.6rem;
  min-height: 36px;
  cursor: pointer;
}

/* The pressed one is filled, not merely outlined — an outline-only "selected"
   state is invisible at this contrast on a phone in daylight. */
.sort-options button[aria-pressed="true"] {
  color: var(--bg);
  background: var(--accent);
  border-color: var(--accent);
}

/* --- reports: the stats panel and the global page -------------------------
 *
 * Both were `<pre>` blocks holding the CLI's text report. That is why they scrolled
 * sideways and why they looked like a terminal dropped into a game made of pixel
 * art: `padStart` columns are a fixed-width layout in disguise, so the widest line
 * sets the page width and the phone gets no vote.
 */
.report {
  margin-top: 1.2rem;
  padding: 0.9rem;
  border: 1px solid var(--line);
  background: var(--panel);
}

.report-section {
  margin-top: 1rem;
}

.report-section:first-child {
  margin-top: 0;
}

.report h3 {
  margin: 0 0 0.5rem;
  font-size: var(--type);
  font-weight: normal;
  letter-spacing: 0.12em;
  color: var(--accent);
}

/* Two columns on a phone. The card's 11rem minimum is right for a value like
   "the Leaden of Oakmeade"; here every value is "8/32", and one tall column of
   short numbers wastes half the panel. */
.report .fields {
  grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
  gap: 0.5rem 0.75rem;
}

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

.bars li {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 0 0.5rem;
  margin-bottom: 0.3rem;
}

.bar-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.bar-n {
  color: var(--dim);
  min-width: 2.2em;
  text-align: right;
}

/* Same block grammar as the creature's stat bar — whole pixels, 2px gaps, no
   fractional cells. A bar with sub-pixel steps is the one thing on screen that
   would betray that this is CSS and not pixel art. */
.bar {
  display: flex;
  gap: 2px;
}

.bar i {
  width: 6px;
  height: 10px;
  background: var(--line);
}

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

/* Tier rows reuse the card frames' ladder rather than inventing a second colour
   language for the same five letters. */
.bars li[data-tier] .bar i.on {
  background: hsl(var(--tier-h) var(--tier-s) var(--tier-l));
}

/* --- the global page ------------------------------------------------------ */
/* Number, then what it counts, always on separate lines. Sharing a line looks
   tidy until a label wraps — then two of the three rows are one shape and the
   third is another, which reads as a layout bug rather than a long sentence. */
.tally {
  display: grid;
  gap: 0.15rem;
  margin-bottom: 0.9rem;
}

.tally-n {
  font-size: var(--type-big);
  line-height: 1;
  color: var(--fg);
}

.tally-label {
  color: var(--dim);
  min-width: 0;
}

/* First discoveries get the accent: it is the only number here that nobody can
   grind, buy or take from you. */
.tally-first .tally-n {
  color: var(--accent);
}

/* --- account -------------------------------------------------------------
 *
 * ⚠️ #gsi holds GOOGLE's button, not ours. Their branding rules require their
 * rendered widget, so this is the one element in the app that is deliberately not
 * on our pixel grid — a hand-drawn "Sign in with Google" would be both a policy
 * violation and the exact shape of a phishing control. We style around it and
 * leave it alone.
 */
.account {
  display: grid;
  gap: 0.6rem;
  padding: 0.2rem 0;
}

.account .note {
  margin-top: 0;
}

#gsi {
  /* Google's iframe sets its own height; this stops the menu jumping while it
     loads, which on a phone reads as the page breaking. */
  min-height: 44px;
}

/* --- sign-in on the gate ------------------------------------------------- */
/*
 * ⚠️ NO DIVIDER ON THE NAMING SCREEN. This block used to sit UNDER the name form with a
 * rule above it, separating two things on one screen. It is now step two of its own
 * screen — the form is hidden by then — so a border-top would draw a line under nothing.
 */
.gate-account {
  display: grid;
  gap: 0.9rem;
  margin: 0;
}

.gate-account .note {
  margin-top: 0;
}

/*
 * ⚠️ CENTRED BY FLEX, not by `text-align`. Google draws its button inside an iframe, so
 * the inline-alignment the rest of the card inherits does not reach it — the container
 * has to place it. Without this the one control on the screen sits left of everything.
 */
#gate-gsi,
#import-gsi {
  min-height: 44px;
  display: flex;
  justify-content: center;
}

/* In the panel grid now, so its spacing comes from the step like everything else. */
#name-check {
  margin-top: 0;
}

/* --- news ----------------------------------------------------------------- */
.news-item {
  padding: 0.9rem;
  margin-bottom: 0.6rem;
  border: 1px solid var(--line);
  background: var(--panel);
}

.news-item h3 {
  margin: 0;
  font-size: var(--type);
  font-weight: normal;
  letter-spacing: 0.06em;
  color: var(--accent);
}

.news-when {
  margin: 0.2rem 0 0.5rem;
}

/*
 * ⭐ **`pre-line` IS WHAT MAKES A NEWS ENTRY'S PARAGRAPHS REAL.**
 *
 * The bodies are written in `db.mjs` as concatenated chunks that are already logical paragraphs,
 * and every surface used to throw that structure away and render one slab. ⚠️ Alessandro asked for
 * it on the Discord cast — *"just make it more readable, divide in paragraphs"* — and the honest
 * fix is one blank line in the SOURCE honoured everywhere, rather than a splitter that guesses
 * where paragraphs go and only Discord can see.
 *
 * ⚠️ `pre-line` collapses runs of spaces but keeps newlines, which is exactly right for text that
 * is hand-wrapped in a source file: the wrapping disappears, the deliberate blank lines survive.
 */
.news-body {
  white-space: pre-line;
  margin: 0;
}

/*
 * The unread count. A square, because a circle would be the only curve in the app — and at
 * this size a pixel-grid square reads as more deliberate anyway.
 *
 * ⚠️ **THE PIPS BELOW ARE GONE — his stat icons replaced them — but keep this name.** The
 * lesson is about the COLLISION, not about the component that lost it, and the next global
 * class name will find the same trap.
 *
 * ⛔ IT WAS CALLED `.pip` AND SILENTLY RESIZED THE ARCHETYPE PIPS FOR WEEKS. Two unrelated
 * components wore one class name: this badge, and the five squares that encode an archetype.
 * Its `min-width: 18px` beat their `width: 6px`, so the "five squares" on every card and every
 * pool row have never actually been square — they were 18px bars, and nobody could see why.
 *
 * ⚠️ IT ONLY SURFACED WHEN THE PIPS MOVED SOMEWHERE TIGHT. Shrinking them for the filter
 * changed the height and not the width, and Alessandro read the result exactly right: *"they
 * are thinner instead of narrower, I feel you adjusted the wrong side."* The height was mine;
 * the width was never mine to set.
 *
 * ⭐ Same failure this project has had twice before under different names — `render.ts` saying
 * HOUSE while `main.ts` said FAMILY, and ARCHETYPE printed twice by two functions. A shared
 * name for two different things is the bug; renaming is the fix, not a more specific selector.
 */
.count-pip {
  display: inline-block;
  min-width: 18px;
  padding: 0 4px;
  text-align: center;
  color: var(--bg);
  background: var(--accent);
}

/* --- the what's-new overlay ----------------------------------------------
 *
 * Over whatever the player was doing, dismissing back to it. Zero radius and a
 * hard border like every other surface — a rounded modal would be the one curve
 * in the app.
 */
/*
 * ⭐⭐⭐ **AN OPEN MODAL FREEZES THE PAGE BEHIND IT — every modal, without any of them asking.**
 *
 * **His report, 2026-09-09:** *"also make it that the background doesn't scroll, it's super
 * annoying, every open modal locks the background and makes it not scrollable when modal is
 * open."*
 *
 * ⛔ **`touch-action: none` ON `.overlay` WAS ALREADY THERE AND DOES NOT COVER THIS.** It stops a
 * FINGER, and says nothing about a mouse wheel or a trackpad — so on a desktop the page behind
 * scrolled away under an open card while the same gesture on a phone did nothing. Two input
 * methods, one of them tested.
 *
 * ⭐ **`:has()` RATHER THAN A CLASS THE JS TOGGLES, and that is the whole point.** Overlays are
 * opened from two dozen places by setting `hidden = false`; a lock that had to be switched on
 * would be forgotten by the next dialog somebody adds. This one is a fact about the DOM: if any
 * overlay is showing, the page is frozen. `:has()` is already used in this file.
 *
 * ⚠️ **`scrollbar-gutter` STOPS THE PAGE JUMPING SIDEWAYS** as the bar disappears — without it,
 * opening any modal shifts everything behind it by the scrollbar's width.
 */
/*
 * ⚠️ **THREE SELECTORS, BECAUSE THIS APP HAS THREE KINDS OF MODAL WRAPPER** — and listing only
 * `.overlay` is how the ACCOUNT panel was missed: its `role="dialog"` sits on an INNER div, and
 * the thing that is shown and hidden is a `.modal` around it. `modal-scroll-lock.test.ts` walks
 * every dialog's ancestors and fails if one is wrapped in a fourth kind nobody added here.
 */
body:has(.overlay:not([hidden])),
body:has(.modal:not([hidden])),
body:has(.intro:not([hidden])) {
  overflow: hidden;
  scrollbar-gutter: stable;
}

.overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  /* Dark enough to focus attention, translucent enough that the page behind is
     still visible — it is an interruption, not a new destination. */
  background: rgba(0, 0, 0, 0.72);
  /* ⚠️ The backdrop must EAT the taps. Without this the menu and the scan button
     stayed live underneath a modal. */
  touch-action: none;
  overscroll-behavior: contain;
}

.overlay-card {
  width: 100%;
  max-width: 30rem;
  /* The list can be long. The CARD scrolls; the page behind never does. */
  max-height: 84vh;
  overflow-y: auto;
  padding: 1.1rem;
  border: 2px solid var(--accent);
  background: var(--bg);
}

.overlay-card h2 {
  margin: 0 0 0.9rem;
  font-size: var(--type-big);
  color: var(--accent);
}

/*
 * ⭐⭐ A LONG MODAL PINS ITS TITLE AND ITS BUTTONS — his rule, 2026-09-19: *"the content scrollable but close
 * button and title always fixed."* The shared card scrolls AS A WHOLE, so on a phone the Close button of
 * "What your items do" rode off the bottom with the list. Opt in with `is-pinned` and wrap the middle in
 * `.overlay-body`; the sell dialog below is the same shape, written by hand before this existed.
 *
 * ⛔ `min-height: 0` ON THE BODY IS LOAD-BEARING — a flex item refuses to shrink below its content without it,
 * and the body pushes the buttons out exactly as before. See `#sell-overlay .overlay-card`.
 */
.overlay-card.is-pinned {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.overlay-card.is-pinned > h2,
.overlay-card.is-pinned > button { flex: none; }
.overlay-card.is-pinned > .overlay-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  margin-bottom: 0.9rem;
  /*
   * ⛔ **NO SCROLLBAR — his call, 2026-09-20: *"can we hide that horrendous vertical sidebar."*** The card
   * still scrolls; only the desktop bar goes. ⚠️ Safe here because the pinned title and buttons already say
   * the card is a scrolling panel — a list with no edge and no bar would be the thing to avoid.
   */
  scrollbar-width: none;
}
.overlay-card.is-pinned > .overlay-body::-webkit-scrollbar { width: 0; height: 0; }

/*
 * A DIALOG'S TWO BUTTONS SHARE ONE BOX. Alessandro: *"if we stack they need to be the
 * same size."*
 *
 * ⚠️ TWO differences had to be removed, and finding the second took three attempts, so
 * both are written down rather than left to be rediscovered:
 *
 *   PADDING — `.primary` sets `0.95rem` while the base `button` sets `0.7rem`, so the
 *   secondary stayed 6px shorter however else it was equalised. One custom property feeds
 *   both now; change it once and the pair stays matched.
 *
 *   BORDER — `.ghost` is outlined and `.primary` is not, which on its own makes the
 *   outlined one 2px taller. The primary carries a TRANSPARENT 1px border so the two box
 *   identically, and the secondary only supplies a colour.
 *
 * Failed attempts, for anyone tempted to reach for one: `min-height: 47px` — 47 was read
 * off a 375px viewport where `@media (max-width: 380px) { zoom: 0.9 }` scales everything,
 * so it was the right observation in the wrong unit; and deleting the `small` class from
 * the markup — correct and necessary, but not sufficient on its own.
 *
 * ⚠️ EQUAL SIZE, NEVER EQUAL WEIGHT. Two identical stacked rectangles are a mis-tap
 * waiting to happen when one of them is destructive. The pair is safe to match in size
 * precisely BECAUSE the primary is filled and the secondary is outlined; if someone later
 * tidies them into matching styles, the size match becomes a trap rather than a courtesy.
 *
 * ⚠️ STRUCTURAL, NOT PER-ID. This was `#ask-no { width: 100% }` and `#claim-later {
 * width: 100% }` — one hand-copied rule per dialog — so every NEW dialog began life with
 * a full-width primary and a secondary hugging its own text. The hatch dialog did exactly
 * that: *"we have again the same issue as always."* Selecting the ghost that FOLLOWS a
 * primary describes the real relationship — these two are the choice — so a dialog
 * written next year gets it without anyone remembering.
 */
.overlay-card {
  --dialog-button-pad: 0.95rem;
}

/*
 * ⛔⛔ **AND IT WAS STILL NOT STRUCTURAL ENOUGH — 2026-09-09, his fourth report of the same fault:**
 * *"always this issue with button, we discussed this, we can never have 1 full width and one
 * shorter there, both full stacked!"*
 *
 * ⚠️ **THE SELECTOR NAMED ONE ORDER AND ONE CLASS: `.primary + .ghost`.** Overlays in this file use
 * FOUR shapes — `primary`, `secondary + primary`, `primary + secondary`, `ghost + primary` — and
 * three of them matched nothing. The sell dialog was written with `primary + secondary` and came
 * out with a full-width Sell above a Cancel hugging its own text, **exactly as the comment above
 * predicted a new dialog would.**
 *
 * ⭐ **SO THE RELATIONSHIP IS "A BUTTON THE CARD ITSELF OWNS", not a class pair.** A direct child of
 * the card IS the choice; anything else — a filter chip, a row in a list, an equip option — is
 * nested in a container and untouched. That cannot be got wrong by writing the buttons in the
 * other order.
 */
.overlay-card > button {
  width: 100%;
  padding: var(--dialog-button-pad);
  border: 1px solid transparent;
}

.overlay-card > button + button {
  /*
   * ⚠️ WIDER THAN THE OLD 0.4rem. Two full-width stacked rectangles need somewhere
   * harmless for a sloppy thumb to land.
   */
  margin-top: 0.7rem;
}

.overlay-card .primary {
  margin-top: 0.9rem;
}

/*
 * ⛔⛔ **`.secondary` WAS NEVER A CLASS — 2026-09-09, and three dialogs were wearing it.**
 *
 * His: *"so cancel is not ghost button? It's the same design as sell, full primary colour, isn't
 * the rule different?"* ⚠️ **There is no `.secondary` rule anywhere in this file**; the word exists
 * only in the prose above, meaning *"the other button"*. So `class="secondary"` fell through to the
 * base button — filled — and the dialog showed **two identical filled rectangles**, which is the
 * mis-tap trap the comment above warns about in as many words.
 *
 * ⭐ **THERE IS ONE OUTLINED CLASS AND IT IS `.ghost`.** `item-wear` and `equip-off` were wearing
 * the phantom too and are fixed with it — he had not reached those screens yet.
 */
.overlay-card > .ghost {
  border-color: var(--line);
}


@media (prefers-reduced-motion: no-preference) {
  .overlay-card {
    /* Whole-pixel rise only. No scale, no fade on the card itself — the same
       rule the idle animation obeys, for the same reason. */
    animation: overlay-rise 120ms steps(3, end);
  }
}

@keyframes overlay-rise {
  from { transform: translateY(12px); }
  to { transform: translateY(0); }
}

/* The naming overlay's field, matching the gate's. */
#claim-input {
  width: 100%;
  margin-bottom: 0.5rem;
}



/* --- the sign-in button ---------------------------------------------------
 *
 * ⚠️ The BUTTON is Google's and untouched. Their branding rules fix the font
 * (Google Sans Medium, no alternatives) and the fill (#FFFFFF / #131314 / #F2F2F2
 * only), so a pixel-art sign-in button is not permitted however well it would
 * match. What the rules DO allow is the icon on its own as an action button — so
 * we use their square dark "G", and the words beside it are ours.
 *
 * Do not style anything inside the iframe; it is cross-origin and the attempt
 * would only make the control unreliable.
 */
.gsi-row {
  display: flex;
  align-items: center;
  gap: 0.7rem;
}

/* Ours: our typeface, our colour. Not part of their button. */
.gsi-label {
  font-size: var(--type);
  color: var(--fg);
  letter-spacing: 0.04em;
}

/* Breathing room between the naming prompt's label, field and warning.
   Alessandro: "put some space between the input field and the text, it was very
   very close." */
#name-overlay .gate-label {
  display: block;
  margin-top: 1rem;
  margin-bottom: 0.4rem;
}

#claim-input {
  margin-bottom: 0.9rem;
}



/* The live name check inside the ask dialog. */
#ask-note[data-free="no"] {
  color: hsl(0 60% 62%);
}

#ask-note[data-free="yes"] {
  color: hsl(140 45% 58%);
}

#ask-input {
  width: 100%;
  margin-bottom: 0.5rem;
}

/* Removing a name from this device — deliberately quiet next to the name itself,
   because picking a player is the common action and forgetting one is rare. */
.roster li {
  display: flex;
  gap: 0.4rem;
}

.roster li > button:first-child {
  flex: 1;
}

/*
 * ⚠️ FIXED WIDTH, and it needs one. Buttons in this app stretch by default, so a
 * one-glyph button inherited a full-size hit area and pushed the roster past the
 * viewport — the fourth horizontal-scroll bug in this project, all from the same
 * cause: a control sized by its container rather than its content.
 *
 * 44px square is the minimum comfortable tap target, and no wider.
 */
.roster-forget {
  flex: 0 0 44px;
  width: 44px;
  padding: 0;
  text-align: center;
  color: var(--dim);
  background: var(--panel);
  border: 2px solid var(--line);
}

/*
 * THE TAB BAR.
 *
 * Fixed to the bottom, because the whole point is that Scan is one thumb-reach away
 * on a phone. `reference.md` sizes it: at 390px five tabs give 78px each, and a
 * 32px icon plus a label at half type size fits with room to spare.
 *
 * ⚠️ The icons are drawn at 32px and displayed at EXACTLY 32px. `image-rendering:
 * pixelated` keeps the edges hard, but only an integer size keeps one source pixel
 * on one screen pixel — the creature canvases had this exact bug and every biped
 * came out the wrong shape.
 *
 * ⚠️ `env(safe-area-inset-bottom)` because this is installed to the home screen on
 * iPhones, where the home indicator sits on top of anything flush to the bottom.
 */
/*
 * ⛔ **THE BAR SCROLLS, because eight tabs do not fit a phone and squeezing them was the
 * alternative.** `navigation.md` §1: the ORDER is the rule and nothing is pinned — Scan first
 * because it is the verb the game is built on, Settings last because it is used least.
 *
 * ⛔⛔ **NEVER `justify-content: center` HERE, AND NEVER `safe center` EITHER — AUTO MARGINS.**
 * A plain `center` on an overflowing flex row pushes the FIRST items past the scroll origin,
 * where no amount of scrolling reaches them, and **Scan is then unreachable.**
 *
 * ⛔ **THAT IS NOT HYPOTHETICAL AND `safe center` DID NOT SAVE IT.** Alessandro's wife, on an
 * iPhone 8 in Safari, 2026-09-13: *"the navbar hides the scan icon on portrait, I have to put it
 * in landscape and I can see barely half but I can click it."* ⚠️ **`safe` is the problem.**
 * Firefox has it, Chromium has it, and **Safari did not until 17.3** — an iPhone 8 stops at iOS
 * 16, so it can never get it. The declaration fell back to the plain `center` on the line above
 * and she got the original bug at full strength. Measured at 375px with `center` forced: Scan's
 * left edge sits at **−144px** with the bar scrolled fully left, i.e. off-screen and unreachable;
 * with the rule below it sits at **0** whether `safe` is understood or not.
 *
 * ⭐ **AUTO MARGINS DO THE SAME JOB WITH NO FEATURE TO DETECT.** Free space is absorbed by the
 * first and last child, so the row CENTRES when it fits; on overflow auto margins resolve to zero,
 * so the row starts at the scroll origin and every tab is reachable. Supported everywhere, which
 * is the entire point — the previous fix was correct CSS that the affected browser could not read.
 *
 * ⚠️ **The `-webkit-` mask below still fades the outer 18px**, so the first tab is dimmed at the
 * edge. That is the deliberate scroll affordance, not this bug; do not "fix" it by removing the
 * mask without reading its own note.
 */
.tabs {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
  display: flex;
  justify-content: flex-start;
  overflow-x: auto;
  overflow-y: hidden;
  /* ⚠️ The bar is 60-odd pixels tall; a scrollbar inside it would eat a fifth of that. */
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  gap: 0;
  background: var(--bg);
  border-top: 2px solid var(--line);
  padding-bottom: env(safe-area-inset-bottom, 0);
}

.tabs::-webkit-scrollbar { display: none; }

/*
 * ⭐ **THE CENTRING, done with margins so it cannot outrun the scroll origin.** See the note on
 * `.tabs` above: this replaces `justify-content: safe center`, which an iPhone 8 cannot read.
 * ⚠️ `:first-of-type`/`:last-of-type` rather than `:first-child`, because the bar's markup carries
 * HTML comments between the buttons and a stray text or comment node must not be able to steal the
 * margin. The buttons are the only elements in there.
 */
.tabs button:first-of-type { margin-inline-start: auto; }
.tabs button:last-of-type { margin-inline-end: auto; }

/*
 * ⛔ THE HALF-CUT TAB WAS NOT ENOUGH, AND A REAL PLAYER PROVED IT. The comment on `.tabs button`
 * says the partially-visible tab at the edge IS the scroll affordance — Alessandro, 2026-08-25:
 * *"my friend was not aware the navbar can be swiped."*
 *
 * ⭐ A FADE AT BOTH EDGES SAYS "THIS CONTINUES" where a clean cut says "this is the end." Static,
 * no animation, no script, and it costs nothing when the bar happens to fit — a mask over content
 * that reaches neither edge changes nothing.
 *
 * ⚠️ `mask-image` and not a gradient overlay: an overlay would need the bar's background colour
 * baked into it and would then be wrong in the other theme.
 */
.tabs {
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px,
                                      #000 calc(100% - 18px), transparent 100%);
  mask-image: linear-gradient(to right, transparent 0, #000 18px,
                              #000 calc(100% - 18px), transparent 100%);
}

.tabs button {
  /*
   * ⛔ `0 0 auto` WITH A FIXED WIDTH, not `1 1 0`. Sharing the bar between eight tabs put each
   * one at ~45px, which is narrower than the 32px icon plus its label and is what made this a
   * scrolling bar rather than a wider one. A fixed width also means the partially-visible tab
   * at the edge IS the scroll affordance — there is nothing else telling you the bar moves.
   */
  flex: 0 0 auto;
  width: 4.6rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: 6px 0 8px;
  border: 0;
  background: transparent;
  color: var(--dim);
  font: inherit;
  font-size: calc(var(--type) * 0.62);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  cursor: pointer;
}

.tabs button img {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
  /*
   * The unselected tabs are dimmed rather than greyed. Desaturating a pixel icon
   * collapses its ramp — the whole reason each icon got a material palette — so
   * the icon keeps its colours and simply sits back.
   *
   * ⚠️ **0.55 → 0.4 on 2026-08-22, at his ask: the off tabs were not sitting back far
   * enough.** This is a DIAL, not a finding — the only judge is his eye on a phone, and
   * the number moved once already. The floor is roughly 0.3: below that a 32px icon at
   * arm's length stops reading as a picture at all, which loses the thing the bar is for.
   */
  opacity: 0.4;
}

.tabs button[aria-current="true"] {
  color: var(--accent);
}

.tabs button[aria-current="true"] img {
  opacity: 1;
}

/*
 * The bar is fixed, so the page has to end above it or the last row of a
 * collection sits underneath and cannot be tapped.
 */
body {
  padding-bottom: calc(5.5rem + env(safe-area-inset-bottom, 0));
}

/*
 * SETTINGS, folded into Me.
 *
 * Not a tab: five is the decided shape, and a gear is not tapped often enough to
 * spend a thumb-reach slot on. It is a disclosure rather than a page because there
 * are two controls in it — a whole screen for two buttons is furniture.
 */
.settings {
  margin-top: 2rem;
  border-top: 2px solid var(--line);
  padding-top: 1rem;
}

.settings-head {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  width: 100%;
  padding: 0.4rem 0;
  background: transparent;
  color: var(--fg);
  font: inherit;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* Same rule as the tab bar: drawn at 32, shown at 32, or the grid goes ragged. */
.settings-head img {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
}

.settings-body {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.75rem;
}

/* --- topbar: wordmark + account pill ------------------------------------- */

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
}

.wordmark {
  color: var(--accent);
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

/*
 * The account pill. Identity only, and the ONLY thing left in the top bar — the
 * burger is gone, along with the bug where opening it pushed the whole page down
 * because it was a block element in normal flow.
 */
.pill {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.3rem 0.7rem;
  max-width: 60%;
  border: 2px solid var(--line);
  background: transparent;
  color: var(--fg);
  font: inherit;
  font-weight: 400;
}

.pill .topbar-name {
  overflow: hidden;
  white-space: nowrap;
}

/* --- the account modal ---------------------------------------------------- */

.modal {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 4rem 1rem 1rem;
  background: rgb(0 0 0 / 0.6);
}

/*
 * THE ACCOUNT PANEL — Alessandro, 2026-08-20: *"let's make this modal a bit bigger, give
 * more space to stuff and center also the Sign in with G."*
 *
 * ⭐ Same shape as the gate: one gap variable rather than a margin per child, so "more
 * space" stays a one-line change. Centred, because it is now a panel that STATES something
 * — which account you are linked to — rather than a stack of controls.
 */
.modal-card {
  --modal-gap: 1.1rem;
  width: 100%;
  max-width: 26rem;
  display: flex;
  flex-direction: column;
  gap: var(--modal-gap);
  padding: 1.75rem 1.5rem;
  background: var(--panel);
  text-align: center;
}

/*
 * ⚠️ CENTRED BY FLEX, not by `text-align`. Google draws its button inside an iframe, so the
 * inline alignment the card inherits does not reach it — the container has to place it. The
 * same fix the gate's two mounts needed.
 */
#gsi {
  display: flex;
  justify-content: center;
}

/*
 * The divider before News — it was a bare `hr` inheriting a browser default margin, which
 * fought the card's gap. Now it only draws a line and lets the gap do the spacing.
 */
.modal-card hr {
  width: 100%;
  height: 0;
  margin: 0;
  border: 0;
  border-top: 1px solid var(--line);
}

/* The full name, which is the whole reason tapping the pill exists. */
.who-full {
  margin: 0;
  color: var(--accent);
  font-size: var(--type-big);
  overflow-wrap: anywhere;
}

/* ⚠️ The state sentence carries real information now, so give it room to be read. */
#account-state {
  margin: 0;
  line-height: 1.5;
}

/* --- Collect's segmented control ------------------------------------------ */

.segments {
  display: flex;
  gap: 0.4rem;
  margin-bottom: 1rem;
}

.segments button {
  flex: 1 1 0;
  padding: 0.45rem 0.3rem;
  border: 2px solid var(--line);
  background: transparent;
  color: var(--dim);
  font: inherit;
  font-weight: 400;
}

.segments button[aria-current="true"] {
  border-color: var(--accent);
  color: var(--accent);
}

/*
 * SIX tabs, not five. At 390px that is 65px each and at 360px 60px, so the label
 * type is smaller here than elsewhere.
 *
 * ⚠️ At THIS size the full words fit — "Settings" is 59px and "Collect" 52px. The
 * abbreviations this file briefly carried came from measuring at the FIVE-tab font,
 * where "Settings" was 65px and would have filled a slot edge to edge. Measure at
 * the size you are shipping.
 */
.tabs button {
  max-width: none;
  font-size: calc(var(--type) * 0.56);
}

/* The chosen art size, marked the same way a segment is. */
.settings-body button[aria-current="true"] {
  border-color: var(--accent);
  color: var(--accent);
}

/*
 * SMALL PHONES: zoom out rather than reflow.
 *
 * Alessandro's call — "the easier solution is to zoom out, let's not try to create
 * the perfect adaptive CSS, we zoom to 90% and everything fits like a glove." He is
 * right that it is cheaper: one rule instead of a breakpoint for every component,
 * and the six-tab bar keeps identical proportions instead of becoming a second
 * layout to maintain.
 *
 * 380px, so a 390px iPhone is untouched and only genuinely narrow devices zoom.
 *
 * ⚠️ THE ONE COST, stated rather than hidden: the pixel typeface needs a font-size
 * that is a multiple of 9px, because the em box IS the 9-row glyph grid — 18px puts
 * each row on 2 screen pixels. At 90% that becomes 16.2px, so glyph rows land
 * between pixels and the font softens. Nothing else in the UI cares. If it reads
 * badly on a small phone, the fix is to zoom the LAYOUT and leave type alone.
 */
@media (max-width: 380px) {
  :root {
    zoom: 0.9;
  }
}

/* Pagination, above and below the list. */
.pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  margin: 0.75rem 0;
}

.pager-where {
  color: var(--dim);
  min-width: 4rem;
  text-align: center;
}

/* --- loading overlay ------------------------------------------------------ */

.loading {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgb(0 0 0 / 0.72);
}

.loading-word {
  margin: 0;
  color: var(--accent);
  font-size: var(--type-big);
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

/*
 * One bounce per letter, staggered. WHOLE-PIXEL steps and `steps()` timing, not a
 * smooth translate — a pixel typeface sliding through fractional positions is the
 * same blur the font rules exist to prevent.
 */
.loading-word span {
  display: inline-block;
  animation: loadbounce 0.9s steps(2, end) infinite;
}

.loading-word span:nth-child(2) { animation-delay: 0.08s; }
.loading-word span:nth-child(3) { animation-delay: 0.16s; }
.loading-word span:nth-child(4) { animation-delay: 0.24s; }
.loading-word span:nth-child(5) { animation-delay: 0.32s; }
.loading-word span:nth-child(6) { animation-delay: 0.40s; }
.loading-word span:nth-child(7) { animation-delay: 0.48s; }
.loading-word span:nth-child(8) { animation-delay: 0.56s; }
.loading-word span:nth-child(9) { animation-delay: 0.64s; }
.loading-word span:nth-child(10) { animation-delay: 0.72s; }

/* ⚠️ "Updating..." is eleven letters; the shared list above stops at ten. */
.loading-word span:nth-child(11) { animation-delay: 0.80s; }

/*
 * ⛔⛔ THE UPDATE OVERLAY BLOCKS THE WHOLE PAGE, on purpose — his ruling, 2026-09-16. `.overlay` already
 * carries the backdrop that EATS taps, which is the property being bought here.
 *
 * ⚠️ 45, BELOW THE STALE SCREEN'S 50. Both can be true at once, and "your creatures would not match
 * everyone else's" is the harder failure — it must never be covered by a transient wait.
 */
/*
 * ⭐⭐ AN ICON THAT FOLLOWS THE THEME — his requirement for the mute button, 2026-09-17: *"the colour
 * has to be set by dark/light mode, so whatever I use it's just to define the shape."*
 *
 * The sprite is a MASK, not a picture: its opaque pixels become ink and `currentColor` is the ink,
 * so one drawing reads correctly in both themes and follows the button's own state for free.
 *
 * ⛔ INTEGER SIZE AND `pixelated`, exactly like every other icon — `ui-rules.md` §4. A 32px drawing
 * shown at 32px; anything fractional resamples the grid the art rules exist to protect.
 */
.icon-mask {
  display: inline-block;
  width: 32px;
  height: 32px;
  background: currentColor;
  -webkit-mask-size: 32px 32px;
  mask-size: 32px 32px;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  vertical-align: middle;
}

/*
 * ⚠️ THE LABEL IS FOR SCREEN READERS AND FOR THE MOMENT BEFORE THE SPRITE EXISTS. Once the icon is
 * masked in, the word would make this button twice the width of Low/Med/High and break the row.
 */
/*
 * ⛔⛔ `:not(.icon-mask)` IS LOAD-BEARING — without it this rule hid the icon it was written beside.
 *
 * His report, 2026-09-17: *"I see icon is missing."* The button has TWO span children — the masked
 * icon this file styles directly above, and the text label that exists for screen readers. A bare
 * `.volume-mute span` matched both, so the icon computed to 1x1 and boxed at 0x0 with its mask
 * correctly loaded. ⚠️ Measured, not guessed: the element was in the DOM and painting nothing.
 */
.volume-mute span:not(.icon-mask) {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}
.volume-mute { display: inline-flex; align-items: center; justify-content: center; }

.updating-overlay { z-index: 45; }

/*
 * ⛔ CENTRED, AND NO CARD — his words, 2026-09-16: *"center updating text please it's on the left and it's
 * very sad."* It was sitting in `.overlay-card`, a 30rem bordered box with no text-align, so the word hugged
 * the left edge of a large empty container.
 *
 * ⭐ This is `.loading`'s presentation, not a new one: the app's existing full-screen wait is the big accent
 * word centred on the backdrop with no box around it, and that is a look he has already accepted. Reusing it
 * means the two waits in this app read as the same thing, because they ARE the same thing.
 */
.updating-box {
  max-width: 22rem;
  /*
   * ⛔ ROOM FOR THE BOUNCE — his report, 2026-09-16: *"when the text waves they go too high and they
   * disappear at the top."* Each letter lifts 6px (`loadbounce`), and a transform paints OUTSIDE the
   * element's own box, so the padding here is not decoration: it is the clearance the animation needs.
   * ⚠️ Kept comfortably above 6px so a future taller bounce does not silently start clipping again.
   */
  padding: 1.25rem 1rem;
  text-align: center;
}

/*
 * ⚠️ THE FALLBACK BANNER — pinned to the top, above the app, and never in the way: no backdrop, nothing
 * to dismiss, and the game underneath stays usable. It is what the blocking overlay DEGRADES TO when the
 * reload does not arrive, so that a failed update can never strand anybody on a dead screen.
 */
.updating {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 60;
  margin: 0;
  /*
   * ⛔ THE TOP PADDING IS THE BOUNCE'S HEADROOM, not spacing. Pinned at `top: 0`, a 6px hop took the
   * letters past the edge of the SCREEN, where there is nothing to clip them but nothing to show them
   * either. 0.35rem was 5.6px — less than the hop itself.
   */
  padding: 0.85rem 0.5rem 0.35rem;
  text-align: center;
  background: var(--panel);
  border-bottom: 1px solid var(--line);
}

@keyframes loadbounce {
  0%, 60%, 100% { transform: translateY(0); }
  30% { transform: translateY(-6px); }
}

@media (prefers-reduced-motion: reduce) {
  .loading-word span { animation: none; }
}


/* --- the scan button, pinned above the tab bar -------------------------

   Alessandro, 2026-08-08: "the scan button needs to sit on top of the nav bar and
   always be visible when scrolling the Scan page. When I scan now I have to scroll
   up to the top to click the button, I want to see it always on screen to scan
   faster."

   The loop is scan → look → scan, and a creature card is taller than a phone, so
   after the first scan the button is always off screen.

   ⚠️ `sticky` WAS TRIED FIRST AND CANNOT DO THIS. `position: sticky; bottom: N`
   only holds an element you are scrolling DOWN TOWARD — it pins the element before
   it leaves the bottom of the viewport. This button sits near the TOP of the page,
   so scrolling down carries it away and sticky never engages. Measured: at the
   bottom of a scanned page the button was at y = −772, entirely gone.

   So it is `fixed`, and the content below gets padding to scroll clear of it. */
/* ⚠️ `--tabbar-h` IS MEASURED AT RUNTIME in main.ts, not hardcoded. The bar is 72px
   today, the button was parked at 88px, and that 16px gap is exactly the "floating
   around" Alessandro saw — with page content showing through it. A hardcoded offset
   drifts the moment an icon or the UI font changes size; measuring cannot. */
.scan {
  position: fixed;
  left: 0;
  right: 0;
  bottom: var(--tabbar-h, 4.5rem);
  z-index: 15;
}

/* Full bleed, flush to the bar: no rounded inset, no page showing under it. */
.scan > .primary {
  width: 100%;
  border-radius: 0;
}

/* ⚠️ THE BACKGROUND GOES ON THE CONTAINER, NOT THE BUTTON. Setting it on `.primary`
   overrode the button's own accent fill and rendered it dark-on-dark — present,
   measurable, and completely invisible on screen. Caught by looking at a screenshot
   after the geometry assertions had all passed, which is the whole argument for
   looking. */
/* ⚠️ ON THE CONTAINER, NOT THE BUTTON. Setting it on `.primary` overrode the
   button's own accent fill and rendered it dark-on-dark — present, measurable, and
   invisible on screen. Caught by a screenshot after every geometry assertion had
   already passed. */
.scan {
  background: var(--bg);
}

/* Room to scroll past the button, so it never covers the end of the card. */
#view-scan {
  padding-bottom: 4.5rem;
}

/* ⚠️ While the camera is open the viewfinder lives inside .scan, and fixing the
   container pins the live video over the page. Sticking is only wanted for the
   button, so the whole thing returns to the flow while scanning. */
.scan:has(#viewfinder:not([hidden])) {
  position: static;
  transform: none;
  width: auto;
}

/*
 * The note on an expanded card, and the pencil that edits it.
 *
 * ⚠️ The pencil is a 44px tap target even though the glyph is small. It sits inside
 * the accordion BUTTON that opens the card, so a near-miss does not do nothing — it
 * collapses the card the player just opened. Cheap insurance against the most
 * annoying possible failure.
 */
.note-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  justify-content: space-between;
}

.note-empty {
  opacity: 0.6;
  font-style: italic;
}

.note-edit {
  flex: 0 0 auto;
  min-width: 44px;
  min-height: 44px;
  background: none;
  border: 1px solid var(--line, #3a3a44);
  border-radius: 4px;
  color: var(--accent);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
}

.note-edit:hover,
.note-edit:focus-visible {
  border-color: var(--accent);
}

#note-overlay-field {
  width: 100%;
  box-sizing: border-box;
  resize: vertical;
  /* The label sat directly on the box and read as part of it. */
  margin-top: 0.5rem;
  margin-bottom: 0.9rem;
  padding: 0.5rem 0.6rem;
}

/* Cancel and Save side by side, Save on the right where a thumb expects it. */
/*
 * ⚠️ COLUMN, NOT ROW, despite the name — kept so the note editor matches every other
 * dialog. Alessandro, on reach: side by side on a wide screen puts one button under the
 * wrong thumb, and a stack keeps both under the same one.
 *
 * ⚠️ `column-reverse`: the markup order is Cancel then Save, because a keyboard and a
 * screen reader follow source order and the destructive choice should not be the first
 * thing you tab into. Reversing here puts Save on TOP visually, where the primary sits in
 * every other dialog, without disturbing that.
 */
.button-row {
  display: flex;
  flex-direction: column-reverse;
  gap: 0.7rem;
  align-items: stretch;
}

.button-row .primary,
.button-row .ghost {
  width: 100%;
  /* Same padding as the base button, so the pair matches without a magic number. */
  padding: 0.7rem 1.15rem;
  margin: 0;
}

/* The placeholder must not read as saved text — "kitchen shelf" in the field's own
   colour looks exactly like a note that is already there. */
#note-overlay-field::placeholder {
  color: var(--dim);
  opacity: 0.55;
}

/*
 * THE EGG, in the portrait frame.
 *
 * Height matched to `.portrait-canvas` so the page does not jump between a scan that
 * hatched and one that did not. `pixelated` and an integer multiple of 32 — 192 is 6× —
 * because a fractional scale on a 32px sprite destroys the grid, which is the whole
 * point of drawing it by hand.
 */
/*
 * THE HATCH STAGE. 192px square — the egg is 32px of art at 6×, which keeps every
 * source pixel exactly six screen pixels and the grid intact.
 */
/*
 * --- THE BOX: an item's egg — `items-and-shop.md` §4y -----------------------
 *
 * ⭐ **THE SAME 192px STAGE THE EGG USES**, so a scan that turns out to be an item occupies exactly
 * the space a scan that turns out to be a creature does. ⚠️ Anything else and the page would jump
 * on the one beat the player is watching.
 */
.box-stage {
  position: relative;
  width: 192px;
  height: 192px;
  flex: 0 0 auto;
}

/*
 * ⛔⛔ **BOTH FRAMES ARE ALWAYS IN THE DOM WITH THEIR `src` SET — only opacity moves.** Swapping
 * `src` on one element decodes during the animation, and on a phone the open box had not finished
 * decoding before it faded out, so it never appeared. The egg's four `<img>` exist for exactly
 * this reason and its comment says so; the box's first version ignored it.
 */
.box-art {
  position: absolute;
  inset: 0;
  height: 192px;
  width: 192px;
  image-rendering: pixelated;
}

/* ⚠️ The open frame waits at zero opacity. It is decoded, laid out, and simply not shown. */
.box-opened { opacity: 0; }

/*
 * ⭐⭐ **THE SHAKE IS A TRANSFORM, WHICH IS WHY IT NEEDED NO ART.** A crack is new pixels and had to
 * be drawn; a shake is the same pixels moved. ⛔ **AND IT MUST STAY WHOLE-PIXEL AT 6×**: the sprite
 * is 32px shown at 192px, so one drawn pixel is 6 screen pixels. Translating by 6px moves the image
 * by exactly one pixel of the grid; translating by 4px would land the sprite off-grid and
 * `pixelated` would show it as a smeared edge — the same rule that forbids rotating the egg halves.
 */
@keyframes box-shake {
  0%, 100% { transform: translateX(0); }
  15% { transform: translateX(-6px); }
  30% { transform: translateX(6px); }
  45% { transform: translateX(-6px) translateY(-6px); }
  60% { transform: translateX(6px); }
  75% { transform: translateX(-6px); }
}

/*
 * ⛔⛔ **THE SHAKE IS A FIXED BURST AND THEN THE BOX SITS STILL — `main.ts` removes this class after
 * `BOX_MS`.** It used to loop until the answer arrived, and on a slow connection that read as a
 * machine stuck rather than a thing waiting. Alessandro: *"the animation of the box shaking offline
 * is too long."*
 *
 * ⭐ **THE EGG ALREADY WORKED THIS WAY and copying only half of it was the mistake**: its cracks
 * are a fixed 780ms timeline that HOLDS at the last frame until the answer lands.
 *
 * ⚠️ `infinite` is still right HERE — the class is what bounds it, so the burst lasts exactly as
 * long as JS says and the CSS needs no second copy of that number.
 */
.box-stage.is-waiting .box-closed {
  animation: box-shake 620ms ease-in-out infinite;
}

/*
 * ⭐ **OPENED: the lid is off and the box is HELD STILL so it can be seen.** His call, 2026-09-07 —
 * *"need a bit of delay to see it, like 300ms at least."* ⛔ No transition on the swap itself: this
 * is a cut between two hand-drawn frames, and cross-fading them would show both at once as a
 * double-exposure, which is not what either drawing is.
 */
.box-stage.is-opened .box-closed { opacity: 0; }
.box-stage.is-opened .box-opened { opacity: 1; }

/*
 * ⭐ **THEN IT LEAVES, and the fade is what overlaps the item's paint.** `opacity` and `transform`
 * are compositor properties, so this keeps running through the blocking draw of the card behind it
 * — the same trick that buys the egg its overlap.
 *
 * ⚠️ 260ms IS ALSO `BOX_FADE_MS` IN `main.ts`, which waits for it before hiding the stage.
 */
.box-stage.is-gone .box-art {
  animation: none;
  transition: opacity 260ms ease-out, transform 260ms ease-out;
}
.box-stage.is-gone .box-opened {
  opacity: 0;
  transform: translateY(-12px) scale(1.08);
}

/* ⚠️ `prefers-reduced-motion` kills the fidget, never the reveal — the frames still swap. */
@media (prefers-reduced-motion: reduce) {
  .box-stage.is-waiting .box-closed { animation: none; }
  .box-stage.is-gone .box-art { transition: none; }
}

.egg-stage {
  /*
   * ⚠️ DECLARED ON THE STAGE, NOT ON `.egg-half`. It was on the half, where a local
   * declaration beats the value inherited from the parent — so `main.ts` setting it on
   * the stage did nothing and the shell always flew apart at full speed while the JS
   * timeline was stretched. At `?hatchscale=8` the halves finished in 260ms and then sat
   * still for three seconds, which is how it was noticed.
   */
  --hatch-scale: 1;
  position: relative;
  width: 192px;
  height: 192px;
  flex: 0 0 auto;
}

.egg-art {
  position: absolute;
  inset: 0;
  height: 192px;
  width: 192px;
  image-rendering: pixelated;
}

/*
 * ⚠️ THE OFFSETS COME FROM `scripts/pixel/egg-frames.mjs`, WHICH PRINTS THEM.
 *
 * A rotated half has its hinge at the centre of its own 48px canvas, which is not where
 * the egg's hinge is — laid out naively the pieces jump the instant they appear. Re-run
 * the generator after changing the egg or the canvas size and paste the numbers it
 * prints; they are not derivable from anything visible in this file.
 *
 *   half offset from the egg's box — left -45px, top 21px   (egg 32px, half 48px, 6×)
 */
.egg-half {
  position: absolute;
  left: -45px;
  top: 21px;
  width: 288px;
  height: 288px;
  image-rendering: pixelated;
  opacity: 0;
  /*
   * ⚠️ TRANSLATE AND FADE ONLY — never `rotate`. The tilt is baked into the sprite by
   * the generator precisely so the browser never resamples it. A rotation here would
   * turn every hand-placed pixel into a tilted square.
   */
  /*
   * `--hatch-scale` is set by main.ts from `?hatchscale=` under DEBUG, so the CSS half of
   * the animation slows down with the JS half. Without it the images would step through
   * their frames slowly while the halves still flew apart at full speed.
   */
  transition:
    transform calc(260ms * var(--hatch-scale)) ease-out,
    opacity calc(200ms * var(--hatch-scale)) ease-in calc(120ms * var(--hatch-scale));
}

/*
 * ⚠️ THE ORDER OF THESE TWO RULES IS THE ANIMATION.
 *
 * `is-breaking` puts the halves on screen instantly — `transition: none`, because they
 * must APPEAR at the moment the shell gives way, not fade in from nothing.
 *
 * `is-open` then moves and fades them, and it MUST re-declare the transition. It did not,
 * and `is-breaking` is still on the element at that point, so its `transition: none` won
 * — the halves showed for a single frame and then teleported. That is why the hatch
 * looked "skipped or so fast I can't see" even at eight times slow: there was nothing to
 * see between the two states.
 */
.egg-stage.is-breaking .egg-half {
  opacity: 1;
  transition: none;
}

.egg-stage.is-open .egg-half {
  opacity: 0;
  transition:
    transform calc(260ms * var(--hatch-scale)) ease-out,
    opacity calc(220ms * var(--hatch-scale)) ease-in calc(90ms * var(--hatch-scale));
}

.egg-stage.is-open .egg-half-l {
  transform: translate(-26px, 10px);
}

.egg-stage.is-open .egg-half-r {
  transform: translate(26px, 10px);
}

.egg-stage.is-breaking .egg-art,
.egg-stage.is-open .egg-art {
  opacity: 0;
}

/*
 * The creature arriving. Paired with the halves fading out, so the two cross over rather
 * than the stage going briefly empty — an empty beat reads as a loading glitch.
 */
.portrait-canvas.is-hatching {
  animation: hatch-in 260ms ease-out;
}

@keyframes hatch-in {
  from {
    opacity: 0;
    transform: scale(0.88);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

@media (prefers-reduced-motion: reduce) {
  /*
   * ⚠️ Honoured properly: the creature still ARRIVES, it just does not perform. Someone
   * who asked for less motion still needs to see what they found.
   */
  .egg-half,
  .portrait-canvas.is-hatching {
    transition: none;
    animation: none;
  }
}

/*
 * The egg's message. A card, not a <pre>: it is prose and it must wrap.
 */
.egg-card {
  margin-top: 0.4rem;
  padding: 0.9rem 1rem;
  border: 1px solid var(--line);
  background: var(--panel);
}

.egg-card h2 {
  margin: 0 0 0.4rem;
  font-size: var(--type);
}

.egg-card p + p {
  margin-top: 0.6rem;
}

.egg-card .egg-pile {
  color: var(--ink);
}

.egg-card p {
  margin: 0;
  color: var(--dim);
  font-size: var(--type);
  line-height: 1.5;
}

/*
 * An EAN-8: it holds an item, not a creature. See `showItem()` in main.ts.
 *
 * ⚠️ SHARES THE EGG CARD'S SHAPE AND NOT ITS MEANING. Same frame, so it sits on the scan
 * page as a sibling rather than a foreign element — but it gets the big mark, because the
 * egg is a WAIT and this is a FIND, and a player must be able to tell those apart from
 * across the room.
 */
.item-card {
  margin-top: 0.4rem;
  padding: 0.9rem 1rem;
  border: 1px solid var(--line);
  background: var(--panel);
  text-align: center;
}

/*
 * ⚠️ `--type` scaled up rather than a pixel size: the whole interface is sized off that
 * one variable, so a hardcoded rem here would stop tracking the rest of the app the next
 * time it moves.
 */
.item-mark {
  font-size: calc(var(--type) * 3.2);
  line-height: 1;
  margin: 0.2rem 0 0.6rem;
  color: var(--accent, var(--ink));
  /* The font is already pixel; this stops any smoothing at large sizes. */
  -webkit-font-smoothing: none;
  font-smooth: never;
}

.item-card h2 {
  margin: 0 0 0.4rem;
  font-size: var(--type);
}

/*
 * The tier on an unrevealed item. ⚠️ Styled like a find, not like metadata — it is the only
 * thing distinguishing one mystery from another, so it carries the accent the same way a
 * creature's grade does.
 */
.item-tier {
  margin: 0 0 0.2rem;
  color: var(--accent);
  font-size: var(--type);
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.item-card p {
  margin: 0;
  color: var(--dim);
  font-size: var(--type);
  line-height: 1.5;
}

/*
 * ⚠️ THE BLANK LINES ARE THE POINT, not decoration — Alessandro asked for them by hand:
 * *"so it can read better."* The heading, the question and the reassurance are three
 * separate beats, and with no gap they read as one apologetic paragraph.
 */
.item-card h2 {
  margin-bottom: 0.8rem;
}

.item-card p + p {
  margin-top: 0.8rem;
}

/*
 * THE FILTER BAR — three columns, each a dropdown with its selection listed underneath.
 *
 * Alessandro's layout: TIER / ELEMENT / STATS side by side, and what is selected visible
 * WITHOUT opening anything. A filter you cannot see is a filter you forget you set, and then
 * the collection looks broken because half of it is missing.
 */
/*
 * ⛔⛔ **STACKED ALWAYS — his ruling, 2026-09-20:** *"on the app this issue doesn't exist as we force portrait
 * everywhere except battles, but on web and web app it is still there and the archetype menu is too big, so
 * let's simply keep them stacked like in portrait."* Side by side, the three controls each got a third of the
 * width and the archetype button had to hold a name, its icons and the word Change.
 */
.filter-bar {
  display: flex;
  flex-direction: column;
  /* 6, not 10: the three columns need the width more than they need the air between them. */
  gap: 6px;
  /* ⚠️ STRETCH, not flex-start: stacked, the old `align-items` left each control at its own content width. */
  align-items: stretch;
  margin: 10px 0;
  /* ⚠️ The panels float out of this, so it must not clip them. */
  position: relative;
}

.filter-col { flex: 1; min-width: 0; }

/*
 * ⛔ **THE PANEL IS EXACTLY AS WIDE AS ITS BUTTON, and each column is as wide as its panel
 * needs.** Those are two rules working together and neither is any use alone.
 *
 * Alessandro, 2026-08-20: *"element dropdown is few pixel narrower than the expanded menu…
 * we need to make sure we don't cut anything"* — and then, on the obvious fix: *"I don't want
 * the dropdown be 90 pixel then the panel is 50, looks horrible, so panel has to have the same
 * size as the dropdown itself."* A panel that bulges out is as wrong as one that tucks in.
 *
 * ⚠️ **MEASURED, AND THE SLACK WAS NOT WHERE EITHER OF US EXPECTED.** Cloning each option into
 * a `max-content` box gave what the three panels actually need:
 *
 * | axis | column had | panel needs | |
 * |---|---|---|---|
 * | TIER | 95 | **56** | its options are ONE LETTER and a tick — 39px was going spare |
 * | ELEMENT | 129 | **152** | LIGHTNING is the longest word in any filter |
 * | STATS | 122 | 124 | one icon now, not the card's five |
 *
 * 332 needed against 346 of column space: it always fitted, it was just distributed wrongly.
 * Narrowing STATS — the move that suggests itself, and the one tried first — would have MOVED
 * the overflow rather than removed it, because TIER was the column hoarding the room.
 *
 * ⭐ **SO THE GROW FACTORS *ARE* THE MEASUREMENTS.** With `flex-basis: 0` the whole row is
 * shared out in the ratio of these three numbers, which is the ratio the content needs. Fixed
 * `min-width`s in pixels would do the same thing at one screen size and overflow a 320px phone
 * — he has already hit that once, when a too-wide STATS panel *"makes the page scrollable"*.
 * These shrink together instead, keeping their proportions the whole way down.
 *
 * ⚠️ **If a longer word ever ships, raise ITS number here** — they are not percentages and do
 * not have to sum to anything.
 *
 * ⭐ **THE MARGINS ARE THIN AT TWO WIDTHS AND THAT IS MEASURED, NOT ASSUMED.** `zoom: 0.9`
 * below 380px makes width non-monotonic, so the tightest screens are not the smallest ones:
 *
 * | viewport | why it matters | least spare |
 * |---|---|---|
 * | 320 | stacks (see below) | 155px |
 * | 341 | first width that does NOT stack, and zoomed | **0.5px** |
 * | 381 | tightest width with no zoom to help it | **0.8px** |
 * | 390 | the common phone | 6.4px |
 *
 * Positive everywhere, and the panel need already counts its own padding — but there is no
 * room for a longer word at 341 or 381. That is the number to re-check, not 320.
 */
/*
 * ⚠️ **71px IS HIS FLOOR ON TIER, AND IT NEVER BINDS — KEEP IT ANYWAY.** These ratios size the
 * columns to what their DROPDOWNS need, and TIER's dropdown needs only 56; its BUTTON needs
 * about 90. Stacking below 576px (further down) is what actually guarantees that, so in the
 * row layout TIER always receives 90 or more and this floor is never the binding constraint.
 *
 * ⛔ It stays as a tripwire: anyone lowering the stacking breakpoint will hit 71px before they
 * hit a visibly broken chevron, which is the failure he had to report by eye.
 */
.filter-col[data-axis="tiers"] { flex: 56 1 0; min-width: 71px; }
.filter-col[data-axis="elements"] { flex: 152 1 0; }
.filter-col[data-axis="stats"] { flex: 124 1 0; }

/*
 * ⛔ The label and its chevron are one line, always. A wrapped arrow is what started this.
 *
 * ⭐ **AND THE CHEVRON SITS AT THE RIGHT EDGE, not against the word.** Inline, `::after` fell
 * wherever the text ended, so the arrow drifted left as the column grew — Alessandro: *"the
 * arrow should always be aligned to the right at the end."* A flex row with `space-between`
 * puts the word at one end and the arrow at the other whatever the column is doing.
 */
.filter-col summary {
  white-space: nowrap;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
}

/*
 * ⛔ THE PANEL HANGS OFF THE SUMMARY, NOT OFF THE COLUMN.
 *
 * Anchored to the column, `top: 100%` meant "below everything in it" — and the chosen list
 * lives in the column too, so every tick pushed the open dropdown further down the screen.
 * Alessandro caught it immediately: *"the selected item is added below the filter, but this
 * moves the dropdown with it."* `details` is only ever as tall as its summary, because the
 * panel is absolute and out of flow, so anchoring here holds it still.
 */
.filter-col details { position: relative; }

/*
 * ⚠️ **THERE IS NO LONGER A SHORT LABEL, AND NO 430px TWEAK.** Both existed to squeeze three
 * buttons across a phone: ELEMENT became "ELEM", letter-spacing went to 0 and the padding to
 * 4px. Stacking below 576px removes the squeeze at its source, so the buttons keep their
 * normal spelling and their normal padding at every width.
 */

.filter-col summary {
  cursor: pointer;
  font-size: var(--type);
  letter-spacing: 0.06em;
  padding: 6px 8px;
  list-style: none;
  border: 1px solid var(--line);
  /* A control should look like one: the bare text read as a heading, not a button. */
  background: var(--panel, #1b1e25);
}
.filter-col summary::-webkit-details-marker { display: none; }
.filter-col summary::after { content: " ▾"; opacity: 0.6; }
.filter-col details[open] summary { border-color: var(--accent); }

/*
 * ⛔ THE PANEL FLOATS, it does not push. Inline, opening a filter shoved the whole creature
 * list down the page — Alessandro: *"make it float instead of being inline and move all the
 * creature list when opened."*
 */
/*
 * ⚠️ **THIS RULE'S ORIGINAL REASON IS GONE, and it is kept as a belt only.**
 *
 * It was written when the panel was `min-width: 100%`: a panel wider than its column overhung
 * to the right, `.filter-bar` deliberately does not clip, and on the rightmost column the
 * overhang grew the PAGE — Alessandro on his phone, *"stats dropdown content is so wide it
 * makes the page scrollable."* Anchoring the last one to its right edge made it grow inward.
 *
 * The panel is now `width: 100%`, so it cannot overhang in either direction and `left: 0` and
 * `right: 0` place it identically. Kept because it costs nothing and re-earns itself the
 * moment anyone widens a panel again — but do not read it as evidence that panels overhang.
 */
.filter-col:last-child .filter-options { left: auto; right: 0; }

.filter-options {
  position: absolute;
  z-index: 5;
  top: 100%;
  left: 0;
  /*
   * ⛔ **`width`, NOT `min-width`.** With `min-width` a panel needing more than its button got
   * it, and hung out past the edge — *"looks horrible"*. It is now exactly the button's width,
   * and it is the grow factors above that make that width sufficient.
   */
  width: 100%;
  /*
   * ⚠️ **NO `max-width` HERE ANY MORE — it was a guard for a shape that no longer exists.**
   * It read `calc(100vw - 16px)`, from the days when a panel sized itself to its content and
   * could outgrow the screen. With `width: 100%` the panel cannot be wider than its button,
   * which is inside the page by construction. Left in place it did the very thing this whole
   * change is against: stacked on a 320px screen it clamped the panel ~18px NARROWER than the
   * button above it.
   */
  display: flex;
  flex-direction: column;
  gap: 0;
  padding: 4px;
  max-height: 230px;
  overflow-y: auto;
  /*
   * ⛔ **ONE DARK BACKGROUND IN BOTH THEMES, SO AN ELEMENT KEEPS ITS OWN COLOUR.**
   *
   * Alessandro, 2026-08-24: *"we need a background colour for the element filter panel so when it
   * opens the elements have their original colours — and the same background in all filter panels
   * so it's consistent."*
   *
   * ⚠️ **THE PROBLEM IT SOLVES IS REAL AND WAS SOLVED THE OTHER WAY FIRST.** `--el-*` has two
   * sets: the bright creature-ramp colours for the dark page, and DARKENED ones for the light page
   * because the bright set measured 1.18:1 to 2.91:1 there — unreadable, all eight. But darkening
   * costs the identity: light-theme FIRE became brown, and a filter list is exactly where the
   * colour IS the meaning.
   *
   * ⭐ **CONTROLLING THE BACKGROUND IS THE BETTER TRADE.** A dark panel on a pale page reads as a
   * popover, and inside it every element can wear the colour the creature actually is.
   */
  /*
   * ⭐ **#2b2b2b — A SOFT CHARCOAL, AND IT IS THE LIGHTEST GREY THAT WORKS.** He asked for
   * *"a darkish gray but not pitch black"*, and the number is measured rather than picked: at
   * this value all eight elements clear 4.5:1, and one step lighter (#3a3a3a) drops `void` to
   * 3.84:1.
   *
   * ⚠️ **THE BINDING COLOUR IS `void`, NOT THE PALE ONES.** He expected `sacred` and `air` to be
   * the problem; they are the SAFEST at 11.8:1 and 11.3:1, because pale ink is brilliant on
   * anything dark. Purple `void` at 4.79:1 is what sets the floor. ⛔ Lighten this background and
   * `void` is the first thing to go, not the creams.
   *
   * ⛔ **CREAM IS IMPOSSIBLE HERE, which is worth writing down so nobody tries it again:**
   * `sacred` IS a cream (#f4ead0). On a cream panel it measures 1.00:1 — the same colour.
   *
   * ⚠️ **THE SAME VALUE IN BOTH THEMES**, which is his consistency ask and also happens to be
   * right twice: on the dark page it sits slightly LIGHTER than the background, so the panel
   * reads as raised rather than as a hole; on the light page it reads as a popover.
   */
  background: #2b2b2b;
  border: 1px solid var(--accent);
  color: #e8e6e1;
}

/*
 * ⭐ THE BRIGHT SET, RE-DECLARED INSIDE THE PANEL, whatever the page theme is. Same variables, so
 * nothing that reads them needs to know — `elementInk()` still returns `var(--el-fire)` and stays
 * a CSS concern, exactly as its own comment demands.
 */
.filter-options {
  --el-earth: #e3b055;
  --el-lightning: #f6ea6a;
  --el-water: #8ed2e8;
  --el-fire: #f7a54a;
  --el-poison: #dced5c;
  --el-void: #a08bd0;
  --el-sacred: #f4ead0;
  --el-air: #dfe7ec;
}

/*
 * ⚠️ AND THE TIER LETTERS TOO — a tier is a colour in this game as much as an element is, and a
 * panel that fixed one and not the other would be half-consistent, which is worse than neither.
 * These are the dark-page lightnesses; the light-page clamp does not apply on a dark panel.
 */
.filter-options .filter-option[data-tier="E"]   { --tier-l: 45%; }
.filter-options .filter-option[data-tier="D"]   { --tier-l: 58%; }
.filter-options .filter-option[data-tier="C"]   { --tier-l: 52%; }
.filter-options .filter-option[data-tier="B"]   { --tier-l: 55%; }
.filter-options .filter-option[data-tier="A"]   { --tier-l: 62%; }
.filter-options .filter-option[data-tier="S"]   { --tier-l: 58%; }
.filter-options .filter-option[data-tier="SS"]  { --tier-l: 64%; }
.filter-options .filter-option[data-tier="SSS"] { --tier-l: 70%; }

/* ⚠️ The WORD first, the tick second — his order, and it reads as a list rather than a form. */
.filter-option {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: var(--type);
  padding: 6px 6px;
  cursor: pointer;
  white-space: nowrap;
}
.filter-option:hover { background: #ffffff10; }

/*
 * ⭐ **A STAT IS "VIT" IN A ROW AND "VITALITY" WHEN STACKED**, because stacked is the ROOMY
 * case — each filter gets the whole page below 576px. His ask: *"show the full stat name
 * instead of VIT, STR and so on, we have plenty of space once stacked."*
 *
 * ⚠️ Tied to the SAME 575px breakpoint as the stacking rule below. Two numbers that must agree
 * would drift; if the stacking width ever moves, this moves with it.
 */
.opt-long { display: none; }
@media (max-width: 575px) {
  .opt-short { display: none; }
  .opt-long { display: inline; }
}

/*
 * ⛔ **BELOW 576px THE THREE FILTERS STACK, and 576 is a measured number, not a bootstrap-ish
 * round one.** Alessandro: *"TIER with screens below 576 starts to get smaller and breaks,
 * that button cannot be less than 71px width, below that the down arrow goes out of bounds and
 * only gets worse"* — and stacking was his suggestion too.
 *
 * ⚠️ **HE WAS RIGHT ABOUT THE WIDTH AND GENEROUS ABOUT THE MINIMUM.** Measured, the TIER
 * BUTTON needs about 90px, not 71: the word is ~52, the chevron 14, the gap 6, the padding 16
 * and the borders 2. In a three-across row it only receives 90px at a 576px viewport — which
 * is exactly the width he named. At 390 it receives 71 and its content overflows by 9px.
 *
 * ⛔ **SO A `min-width` FLOOR CANNOT FIX THIS, AND TRYING ONE MADE IT WORSE.** The row has a
 * fixed pool; a floor on TIER takes its pixels from ELEMENT and STATS, whose panels then went
 * 5–6px short and began scrolling sideways — moving the cut rather than removing it. Three
 * honest buttons do not fit across a phone, full stop.
 *
 * ⭐ **STACKED, EVERY CONSTRAINT GOES AWAY AT ONCE.** Each filter gets the full page width, so
 * the chevron sits comfortably at the right edge, the panels match their buttons with ~150px
 * to spare, and ELEMENT no longer needs to be abbreviated to "ELEM".
 *
 * The panels float (`position: absolute`), so a stacked one lies over the filter beneath it
 * instead of shoving the page around, exactly as it does in a row.
 */
@media (max-width: 575px) {
  .filter-bar { flex-wrap: wrap; }
  /* ⚠️ `[data-axis]` selectors, to match the specificity of the grow factors they override. */
  .filter-col[data-axis="tiers"],
  .filter-col[data-axis="elements"],
  .filter-col[data-axis="stats"] { flex: 1 1 100%; }
}

/*
 * ⛔ A PIXEL TICK, NOT THE SYSTEM CHECKBOX. A native checkbox in the middle of pixel art is
 * the one control that looks borrowed from another program. The input still exists — it
 * carries the state and the keyboard behaviour — it is simply not drawn.
 */
.filter-option input { position: absolute; opacity: 0; pointer-events: none; }
.filter-option .tick {
  width: 14px;
  height: 14px;
  flex: none;
  border: 2px solid var(--line);
  background: transparent;
}
.filter-option input:checked + .tick {
  background: var(--accent);
  border-color: var(--accent);
}
.filter-option input:focus-visible + .tick { outline: 2px solid var(--accent); outline-offset: 2px; }

/*
 * ⚠️ ALIGNED TO THE SUMMARY'S TEXT, not to the column edge. The summary is inset by its 2px
 * border plus 8px of padding, so a list at padding 0 sits ten pixels left of the word above it
 * — the drift Alessandro could not stop seeing. Change the summary's border or padding and
 * this number changes with it.
 */
.filter-chosen { list-style: none; padding: 0 0 0 10px; margin: 4px 0 0; }
/*
 * ⛔ **AN EMPTY LIST STILL TOOK ITS SPACE — his report, 2026-09-20:** *"leave the same space between the 3
 * filters."* Stacked, the tier and element columns each carried an empty chosen-list with a 4px margin, so
 * the two gaps above the archetype button read as different sizes even though the flex gap is one number.
 */
.filter-chosen:empty { display: none; }

/*
 * ⭐ THE SELECTION CARRIES ITS OWN COLOUR — his idea, and it costs nothing because both
 * palettes already exist: the per-grade tier tokens, and the creature's own skin ramp for
 * elements (set inline from `PALETTES`).
 *
 * ⚠️ STATS STAY NEUTRAL, deliberately. The only stat palette here is `--stat-1..5`, a
 * red-to-green scale for a VALUE — on FOR and SWI it would say one stat is worse than another,
 * which is a lie colour tells very convincingly.
 */
.filter-chosen li[data-tier],
.filter-option[data-tier] span:first-child,
.mark-tier[data-tier] {
  color: hsl(var(--tier-h) var(--tier-s) var(--tier-l));
}
.filter-chosen li { font-size: var(--type); padding: 1px 0; }

/* The pool borrows the collection's row wholesale, so the two lists line up with each other. */
.pool-row.is-picked { opacity: 0.45; }

/*
 * ⛔ THE TEAM IS FULL AND THIS CREATURE CANNOT BE ADDED — see `markPickedRows`.
 *
 * ⚠️ IT MUST NOT LOOK LIKE `is-picked`, which is also dimmed. One means *"already in your team"*
 * and the other *"there is no room"*, and a player who cannot tell them apart learns neither.
 * So: dimmer, no pointer, and the greyscale says the row is out of play rather than chosen.
 */
.pool-row.is-locked { opacity: 0.3; filter: grayscale(1); }
.pool-row.is-locked button.row-main { cursor: not-allowed; }
/* Name, then the marks that encode the three facts, then the archetype's word. */
/*
 * ⚠️ **THREE LINES, WHICH IS WHAT `.row-main` DECLARES** — name, element, icons. It briefly had
 * a fourth (the archetype) and the archetype fell out from under the thumbnail, exactly as the
 * comment on `.row-main` predicts: `.row-thumb` spans `1 / -1`, `-1` resolves against the
 * EXPLICIT grid, and a fourth line lands outside it. **If a fourth line is ever added back
 * here, add the fourth track with it.**
 */
.pool-row .row-main { flex-wrap: wrap; }

/*
 * ⚠️ **BREATHING ROOM BETWEEN THE THREE READINGS** — his note with the layout: *"add few pixels
 * of space between name, icons and archetype."* They are three separate statements about one
 * creature (who it is, what it is made of, what that makes it) and with no gap they read as one
 * run-on block.
 */
.pool-row .row-name {
  display: inline-flex;
  align-items: baseline;
  /* Between NAME, TIER and ELEMENT — one line, three words, so they need less than the rows do. */
  gap: 8px;
}

/*
 * ⛔ **THE THREE READINGS GET EQUAL ROWS AND SIT IN THE MIDDLE OF THEM.**
 *
 * Alessandro, 2026-08-21, on the battle page: *"the icons have a lot of space at the bottom…
 * I think we need to have those 3 rows the same height and center all in the middle
 * vertically."*
 *
 * ⚠️ **THE SPACE HAD TWO SOURCES AND ONLY ONE WAS OBVIOUS.** Measured: the icon track was 44px
 * for a 32px icon. 6px of it was `--row-breath` as a bottom MARGIN on the last line, where it
 * buys nothing — the row's own padding already breathes. The other 6px was invisible: an
 * `inline-flex` icon row 32px tall sitting in a 22px text line box picks up leading beneath it.
 * `display: flex` on the container takes the icons out of the line box and the 6px goes.
 *
 * ⭐ **THE BREATHING MOVES TO `row-gap`, which is where it belonged.** As margins it applied
 * only BELOW two of the three lines, so the rhythm was never even and centring could not fix
 * it. On the grid it is one gap between every pair, and `1fr` rows make the three tracks equal
 * whatever they hold.
 *
 * ⚠️ **IT COSTS 14px OF ROW HEIGHT, and that is the honest trade rather than a free win.**
 * Tracks were 28 / 22 / 44 with no gaps (94); they are now 32 / 32 / 32 with two 6px gaps
 * (108), so the row goes 115 → 129. Equal tracks are set by the TALLEST content, which is the
 * 32px icon row, so the two text lines inherit its height by definition.
 *
 * ⭐ **`--row-breath` is the dial if that matters more than the rhythm**: at 0 the row is 117,
 * within 2px of where it started, and the equal tracks still give each text line 5px above and
 * below. ⛔ Do not go to 4 — the note on that variable records that 4px beside 32px icons reads
 * as touching.
 */
.pool-row .row-main {
  grid-template-rows: repeat(3, 1fr);
  align-items: center;
  row-gap: var(--row-breath);
}

.pool-row .row-meta {
  display: flex;
  align-items: center;
}

/*
 * ⭐ ONE NUMBER FOR THE ROW'S BREATHING, so "a bit more" or "a bit less" is a single edit
 * rather than a hunt. 6px beside 32px icons; 4 read as touching.
 */
.pool-row { --row-breath: 6px; }

/*
 * ⚠️ THE WAY OUT SITS BESIDE THE TITLE, where every other app puts it, rather than at the
 * bottom with the actions. Back is not an action on the team — it is how you leave the
 * screen — and putting it next to Save is what made saving feel like being ejected.
 */
/*
 * ⛔ **THE TITLE IS ALWAYS IN THE SAME PLACE — the Back button used to shove it sideways.**
 *
 * Alessandro, 2026-08-30: *"I can't stand the fact the button moves the title — can we move the
 * button to the right so the title can always be aligned to the left?"* ⚠️ With the button first
 * in the row, every screen's heading started at a different x depending on how wide its button
 * was, so moving between pages made the title jump.
 *
 * ⭐ **`order` FLIPS THE VISUAL ORDER AND LEAVES THE DOM ALONE**, which matters: Back stays FIRST
 * in the markup, so keyboard focus and a screen reader still reach the way out before the content,
 * exactly as they did.
 */
.team-head { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.team-head h2 { margin: 0; order: -1; }
.team-head button { width: auto; flex: none; margin-left: auto; }

.team-actions { display: flex; gap: 8px; margin: 4px 0 12px; }
.team-actions button { flex: 1; }
/*
 * ⚠️ A SAVED TEAM'S BUTTON MUST NOT LOOK BROKEN. Disabled is the honest state — there is
 * nothing to save — but the standard dim makes it read as "this feature is unavailable", so
 * it keeps its outline and says what it means instead.
 */
#team-done:disabled { opacity: 0.7; }

/*
 * THE BATTLE HUB — one column of reasons, not a grid of mysteries.
 *
 * ⚠️ A disabled button's REASON is the point of it (`battle-hub.md` §1), so the note must stay
 * readable when the button is dimmed — otherwise five grey rectangles say only "not for you".
 */
#battle-hub { display: flex; flex-direction: column; gap: 8px; }
/*
 * ⛔ **A HUB BUTTON IS NAVIGATION, SO IT IS NOT FILLED.**
 *
 * Alessandro, 2026-08-30: *"in the battle page Sparring, team build, tower, glossary are all
 * designed as Next Floor with the full background colour — that is wrong, only confirmation
 * buttons should be like that."*
 *
 * ⛔ **AND THE CAUSE IS STRUCTURAL, NOT SIX SLOPPY BUTTONS.** The bare `button` rule sets
 * `background: var(--accent)`, so **filled is the DEFAULT and un-filled is the exception** —
 * backwards from "filled means confirm", which is why the app drifted. Of 121 buttons, 44 carry
 * `.ghost`, 22 carry `.primary`, and **55 are filled purely because nothing said otherwise.**
 *
 * ⚠️ **INVERTING THE DEFAULT IS THE REAL FIX AND IT IS NOT DONE HERE** — it changes those 55 at
 * once and `ui-rules.md` §0 says that wants one deliberate pass, not a drive-by. This does the
 * category he named, as ONE RULE rather than six edits, so every hub button added later is right
 * without anybody remembering.
 */
.hub-button {
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
  width: 100%; padding: 12px 14px; text-align: left;
  /* The `.ghost` treatment, applied by category rather than by hand on each button. */
  background: transparent;
  border: 1px solid var(--line);
  color: var(--fg);
  font-weight: 400;
}
/* ⚠️ The label keeps its weight — a ghost button is not a whisper. */
.hub-button b { font-weight: 700; }
.hub-button b { font-size: var(--type); }
.hub-button span { font-size: var(--type); opacity: 0.75; }
.hub-button:disabled { opacity: 0.55; }
.hub-button:disabled span { opacity: 0.9; }

/*
 * ⛔ A SELECTION IS NOT A BUTTON, and treating it as one made this screen unreadable.
 *
 * Alessandro, 2026-08-17: *"don't make all cards yellow, make them empty and yellow only if
 * selected — this is a selection now, not a button, it's different and needs different colour
 * behaviour. Like this all seems selected."*
 *
 * He is right twice over. A plain `button` in this app is the gold slab used for the ONE
 * action a screen wants (Begin, here), so five of them stacked said "press all of these". And
 * the first version marked the chosen one by colouring its label gold — gold text on a gold
 * slab, which is why The Instructor and Normal were invisible rather than merely unclear.
 *
 * ⭐ SO: EMPTY BY DEFAULT, FILLED WHEN CHOSEN. Which is also the honest reading of the control
 * — an empty box is "not this one", and exactly one is filled at a time.
 *
 * ⚠️ Scoped to the two selection groups. The hub's own buttons ARE buttons, each one going
 * somewhere, and they keep looking like it.
 */
#spar-who, #spar-level { display: flex; flex-direction: column; gap: 8px; }

#spar-who .hub-button,
#spar-level .hub-button {
  background: transparent;
  border: 1px solid var(--line);
  color: var(--fg);
}

#spar-who .hub-button.is-chosen,
#spar-level .hub-button.is-chosen {
  background: var(--accent);
  border-color: var(--accent);
  /* ⚠️ Dark on gold. The whole bug was a label that matched what was behind it. */
  color: var(--bg);
}

#spar-who .hub-button.is-chosen b,
#spar-level .hub-button.is-chosen b { color: var(--bg); }

/* The note stays subordinate without going invisible against the fill. */
#spar-who .hub-button.is-chosen span,
#spar-level .hub-button.is-chosen span { color: var(--bg); opacity: 0.75; }

/* A disabled option is neither chosen nor pressable — it must not read as either. */
#spar-who .hub-button:disabled { opacity: 0.55; }

#spar-shape { margin: 10px 0 0; }

/* The team builder. Five slots above, the collection below. */
/*
 * THE COMPACT READOUT — tier, archetype, element in about eleven characters.
 *
 * ⭐ THE ARCHETYPE IS FIVE SQUARE PIPS in STAT_KEYS order (VIT FOR SWI GRD SPK), lit for the
 * stats in the top group. Thirty-one badges could never be told apart; five pips need no
 * learning at all, and they are the same shape the filter is ticked in.
 *
 * ⚠️ SQUARES, and sized in whole pixels. A round pip at 6px is an anti-aliased smudge, which
 * is the one thing pixel art cannot have.
 */
/*
 * ⚠️ **A COLUMN NOW, NOT A ROW** — tier and element on one line, the five stat icons under
 * them. It was `inline-flex` in a row, which suited five 6px squares and does not suit five
 * 32px drawings: they are the widest thing here, and inline they pushed the two words off a
 * phone.
 */
.marks { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 2px; }
/* ⭐ With the archetype NAMED, the row reads left to right — see `creatureMarks`. */
.marks.is-named { flex-direction: row; align-items: center; gap: 6px; }

/*
 * ⭐⭐ THE ARCHETYPE FILTER — a button that says its value, opening the picker (`archetypes.md` §3).
 * ⚠️ It carries the word CHANGE because a control that only names its value reads as a label.
 */
.filter-open {
  /*
   * ⚠️ **THE SAME 16px THE DROPDOWNS CARRY** — his diagnosis, 2026-09-20: that space exists so the chosen
   * chips under a filter do not crowd the control below it, and the archetype button was the only one
   * without it, so stacked it sat tight under ELEMENT while the others looked roomy.
   */
  margin-top: 16px;
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  padding: 8px 10px;
  background: #141720;
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--fg);
  font: inherit;
  text-align: left;
}
.filter-open small { margin-left: auto; color: var(--accent); }

/* ⚠️ One row per archetype: the name, then the stats that FORM it — his sketch. */
.arch-choice { display: flex; align-items: center; gap: 8px; }
.arch-choice[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
.arch-group {
  margin: 14px 0 4px;
  color: var(--dim);
  font-size: var(--type-small, 12px);
  letter-spacing: 0.08em;
}
#arch-search {
  width: 100%;
  margin-bottom: 8px;
  padding: 8px 10px;
  background: #141720;
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--fg);
  font: inherit;
}

.mark-facts { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }

/* The tie between a name and its tier, and between a tier and its element. */
.mark-dash { color: var(--dim); }

/* Its own line under the name, so it can be a word rather than three letters. */
.row-element { display: block; }
.mark-tier { font-size: var(--type); }
.mark-element { font-size: var(--type); }

/*
 * ⭐ THE STAT ICONS — his drawings, and they REPLACED the five squares rather than joining them.
 *
 * ⚠️ `vertical-align: middle`, inherited from the squares and still load-bearing: an inline-flex
 * box sits on the TEXT BASELINE by default, which put them level with the bottom of the letters
 * beside them rather than through the middle. Alessandro: *"align them vertically in the middle
 * not at the bottom."*
 *
 * ⚠️ **32px EXACTLY, because they are drawn at 32.** The tab icons carry the same rule for the
 * same reason: `image-rendering: pixelated` keeps the edges hard, but only an INTEGER size
 * keeps one source pixel on one screen pixel. A fractional size is what made a whole family of
 * creatures the wrong shape once already.
 *
 * ⚠️ **OFF IS DIMMER, NEVER SMALLER.** Shrinking an icon to say "off" resamples it, and every
 * pixel in these was placed by hand. Opacity leaves the grid alone.
 */
.stat-icons {
  display: inline-flex;
  /* ⚠️ 6px, not the 2px they shipped with — his call, and five icons at 2px read as one bar. */
  gap: 6px;
  vertical-align: middle;
  align-items: center;
}

/*
 * ⚠️ **OFF IS DIMMER, NEVER SMALLER.** Shrinking an icon to say "off" resamples it, and every
 * pixel in these was placed by hand. ⛔ And never HOLLOW either — the dotted silhouette was
 * built, refined and rejected on looks; `art-icons.md` records why, so it is not re-proposed.
 */
.stat-icon {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
  /*
   * ⚠️ 0.15, walked down from 0.3 in two steps on his eye — *"dim them a bit more"*, then
   * *"I meant darker, try a bit more, like an extra 5%."* The direction is always the same:
   * an off icon should not be mistakable for a lit one at a glance.
   */
  opacity: 0.15;
}

.stat-icon.is-on { opacity: 1; }



/*
 * ⭐ THE SLOTS STAND IN THE BATTLE FORMATION, not in a row.
 *
 * Alessandro: *"put the creatures boxed like we have in the stage, so 2 columns, but the rows
 * need to be the same as battle so the 2 front creatures sit between row 1 and 2 and between
 * row 2 and 3."* Two wins at once: the builder shows the shape the fight will use, and the
 * half-row stagger gives every name a half-width box instead of a fifth of one.
 *
 * Six half-rows do it — the back three span rows 1-2, 3-4, 5-6, and the front two are offset
 * by one half-row into 2-3 and 4-5.
 */
#team-slots {
  display: grid;
  /*
   * ⛔ **`minmax(0, …)`, NOT `1fr` — AND THAT IS WHY HIS BROTHER'S iPHONE OVERFLOWED.** A bare
   * `1fr` is `minmax(auto, 1fr)`, and `auto` floors the track at its content's MAX-CONTENT width.
   * `.mark-facts` is `white-space: nowrap`, so five 32px icons make a 176px unshrinkable row;
   * two of those plus the 10px column gap is 362px against 361px of content on a 393px iPhone.
   * ⚠️ **One pixel** — which is why it showed on a 393px phone and not on a narrower Android.
   * ⭐ With `minmax(0, 1fr)` the columns may shrink below their content, so a slot can never push
   * the PAGE wide again whatever ends up inside it.
   */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  /*
   * ⚠️ **TALLER SINCE THE SLOT GAINED A NAME AND ICONS.** It was 60 (×2 rows = a 126px box)
   * back when a slot held a creature and two short words. It now carries the name on its own
   * line, `TIER — ELEMENT` under it, and a 32px icon row — and at the old height the name was
   * pushed out of the box entirely: *"we have the name that is hidden below the creature."*
   */
  grid-auto-rows: 82px;
  gap: 6px 10px;
  list-style: none;
  padding: 0;
  margin: 12px 0;
}
/*
 * ⛔ SLOTS 0-1 ARE THE FRONT PAIR, so they are the TWO in the right-hand column — the side
 * facing the opponent. The first version put them in the column of three, which drew the
 * front row behind the back row: *"look at the team grid, front are in the back."* The
 * numbering is the squad's (`FRONT_ROW = 2`), not the grid's, and the two must agree.
 */
.team-slot:nth-child(1) { grid-column: 2; grid-row: 2 / span 2; }
.team-slot:nth-child(2) { grid-column: 2; grid-row: 4 / span 2; }
.team-slot:nth-child(3) { grid-column: 1; grid-row: 1 / span 2; }
.team-slot:nth-child(4) { grid-column: 1; grid-row: 3 / span 2; }
.team-slot:nth-child(5) { grid-column: 1; grid-row: 5 / span 2; }

/*
 * ⚠️ THE NAME GOES UNDER THE CREATURE, not beside it. Side by side, a half-width slot left
 * two or three letters of the name visible — Alessandro: *"we read 1-3 letters."* Stacked, the
 * name gets the slot's full width, which is the only way a name of ten to twelve characters
 * fits on a phone at all.
 */
.team-slot {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 2px; min-width: 0;
  padding: 4px; border-radius: 0;
  /*
   * ⛔ **`--dim`, NOT `#fff3`.** A white at 20% opacity composites to something visible on a
   * #14161a page and to nothing at all on a #f4f2ed one — measured 1.02:1, which is why
   * Alessandro reported *"the box for the team that cannot be seen unless selected."* An empty
   * slot has to be visible, since being empty is the thing it is telling you.
   */
  border: 2px dashed var(--dim);
}
/* ⚠️ The front pair takes the hits, so it is worth showing which two those are. */
.team-slot.is-front { border-style: solid; }
.team-slot.is-armed { outline: 2px solid #ffd666; }

/*
 * ⚠️ The name gets the slot's full width and is allowed to shrink to fit rather than push the
 * icons out — a clipped name is readable, a slot whose contents escape it is not.
 */
.slot-name {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--type);
}

/* ⚠️ Everything in a slot is one narrow column, so the facts line stays compact. */
.team-slot .mark-facts { font-size: var(--type); gap: 4px; }

/*
 * ⛔ **HALF-SIZE STAT ICONS, AND ONLY INSIDE A TEAM SLOT.** Five 32px icons with the default 6px
 * gaps is a **184px** row, and a slot on a 390px phone has **162px** inside it — so the row
 * overflowed its own card, heart clipped on the left and star past the border on the right.
 *
 * ⚠️ **THE FIRST ATTEMPT CHANGED THE WRONG GAP.** `.mark-facts` is the *text* row — "S — FIR" —
 * and the icons live in `.stat-icons`, which is a different element with its own 6px gap. The page
 * stopped overflowing and the card did not, which is how a fix can look like a fix and not be one.
 *
 * ⭐ **16px IS AN EXACT 2:1 DOWNSAMPLE**, so with `image-rendering: pixelated` each output pixel
 * takes one source pixel: crisp, not the mush a fractional scale gives. Detail is lost and nothing
 * is blurred — a real trade, and the reason it is confined to the compact slot.
 *
 * ⛔ **THE ALTERNATIVES WERE REJECTED FOR THEIR MARGIN, NOT THEIR LOOKS.** 32px at gap 0 fits in
 * 160px, leaving **2px**; raising the `zoom: 0.9` threshold to cover 390–393px leaves about
 * **1px**. Both "work". Today a 1px margin shipped a bug to his brother's phone, and then a second
 * one after the first fix. **96px in 162px is 66px of headroom** and survives any phone, any zoom
 * state, and whatever gets added to this row later.
 */
/*
 * ⚠️ **4px AT 16px IS LOOSER THAN 6px AT 32px, WHICH IS THE POINT.** The base rule records his
 * ruling that *"five icons at 2px read as one bar"* — so a small icon needs proportionally MORE
 * space, not less. 4/16 = 0.25 against the base's 6/32 = 0.19: the row is more separated here than
 * at full size, and it still costs only 16px of the 66px spare.
 */
.team-slot .stat-icons { gap: 4px; }
.team-slot .stat-icon { width: 16px; height: 16px; }
.team-slot b {
  font-size: var(--type);
  /* ⚠️ `max-width`, not `min-width: 0` — a column child is already full width, and the
     ellipsis only appears if the box is allowed to be narrower than its text. */
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.slot-empty { font-size: var(--type); opacity: 0.5; }
/* ⚠️ 57, was 52 — 2026-09-19 the ramp's canvas grew 142 → 156 tall; 52 × 156/142 keeps every creature its size. */
.team-slot canvas { image-rendering: pixelated; flex: none; max-height: 57px; max-width: 100%; }



/*
 * The glossary pages sideways rather than scrolling — see glossary.ts.
 *
 * ⚠️ THE HEIGHT IS SET IN JAVASCRIPT, not here, and that is deliberate: Alessandro asked for a
 * modal that never changes size, and *"the size must be measured from the real content, not
 * chosen"*. A CSS height would be a guess that breaks at a different font size or language.
 * `openGlossary` renders every page of a section, takes the tallest, and pins that.
 */
.glossary-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.glossary-title b { text-align: center; flex: 1; font-size: var(--type); }
#glossary-heading { display: block; text-align: center; font-size: var(--type); }
/*
 * ⚠️ A SECTION NEEDS A NAME, not just a pager. Alessandro: *"we don't have a title for each
 * section, we have these arrows with '1 stat', 'EARTH', 'Vitality — health'."* The paged
 * heading says WHICH ONE you are on; the section heading says which KIND it is, and without it
 * the modal is three anonymous carousels.
 */
.gl-section { margin: 12px 0; }
.gl-heading {
  margin: 0 0 4px;
  font-size: var(--type);
  color: var(--accent);
  letter-spacing: 0.06em;
}
/*
 * ⚠️ `hidden`, NOT `auto`. The height is pinned in JavaScript to the tallest page in the
 * section, so there is nothing to scroll — and `auto` would put a scrollbar on the one page
 * that measured a pixel over, which is movement of exactly the kind this is meant to stop.
 */
/*
 * ⭐ THE ATTRIBUTE RING — five rows of "this beats that", drawn in the same icons every other
 * surface uses. ⚠️ No numbers by design: the multipliers are still being balanced, and a number
 * published mid-tuning is a promise that gets broken.
 */
/*
 * ⚠️ CENTRED, not left-aligned — his call. Five short rows hard against the left edge read as a
 * list of settings; centred they read as the little diagram they are.
 */
.glossary-ring { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 8px 0; }
.ring-row { display: flex; align-items: center; justify-content: center; gap: 8px; }
.ring-arrow { color: var(--dim); font-size: var(--type); }

/*
 * ⭐ THE GLOSSARY IS A PAGE NOW, so a chapter SCROLLS instead of being pinned to a fixed height.
 * ⚠️ The old `overflow: hidden` and the height-pinning that went with it existed only because a
 * modal cannot grow; a page can. `navigation.md` §3b.
 */
/*
 * ⚠️ ONE GAP UNDER THE PAGER, THE SAME ON EVERY CHAPTER — his: *"give same room on all pages."*
 * It was 10px and the first line sat against the arrows. The margin lives HERE, on the body, so
 * a chapter cannot set its own and drift from the others.
 */
#gl-body { margin: 22px 0 0; }

/* ⛔ AND NOTHING INSIDE A CHAPTER MAY ADD ITS OWN TOP GAP, or the pages stop agreeing. */
#gl-body > :first-child { margin-top: 0; }

/*
 * ⭐ ICON AND TITLE ON ONE BASELINE-FREE ROW — his: *"align icons and title vertically centred,
 * now titles align at bottom instead of centre."* An `<img>` in a `<b>` is an inline box sitting
 * on the TEXT BASELINE, which puts a 16px icon low against its own label. A flex row centres it.
 */
.gl-entry {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 18px 0 2px;
  color: var(--accent);
}

/* ⭐ The grouping rule — "1 attribute", "2 attributes" — a label with a line through the row. */
.gl-rule {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 20px 0 6px;
  color: var(--dim);
  font-size: var(--type);
  white-space: nowrap;
}
.gl-rule::before,
.gl-rule::after {
  content: "";
  flex: 1 1 auto;
  height: 1px;
  background: var(--line);
}

/* ⚠️ A rule BETWEEN element blocks, never above the first — see the draw. */
.gl-block.is-ruled {
  border-top: 1px solid var(--line);
  margin-top: 16px;
  padding-top: 12px;
}

.glossary-skills { display: flex; flex-direction: column; gap: 10px; margin: 12px 0; }
/* ⭐ Centred, not top-aligned — his, and the same reason as `.gl-entry`. */
.skill-row { display: flex; align-items: center; gap: 8px; }

.gl-body { margin: 6px 0 0; overflow: hidden; }
.gl-body p { margin: 4px 0; font-size: var(--type); }
/*
 * ⚠️ THE HEADING IS PINNED TOO. A long element name wraps to two lines and a short one does
 * not, which moved everything below it — the body was pinned and the title was not.
 */
.glossary-title b { overflow: hidden; }

/*
 * ⛔ THE PAGE BEHIND A MODAL MUST NOT SCROLL. Alessandro: *"it doesn't block the page below and
 * I can scroll the content in the background — needs to be unreachable."* The backdrop already
 * ate the taps, but a touch that starts on the backdrop still scrolls the document underneath
 * it on a phone. The same lock the battle stage uses, for the same reason.
 */
body.is-modal { overflow: hidden; }

/*
 * ⚠️ ⛔ NOTHING MAY BE APPENDED BELOW THE CUTSCENE BLOCK.
 *
 * `intro.test.ts` scans from the `--- the cutscene ---` marker to the END OF THE FILE and
 * fails on any theme token, because the cutscene sits on a hard #000 that does not follow the
 * theme. That is right for the cutscene and wrong for everything else — so ordinary UI
 * appended after it is reported as a cutscene bug. Put new rules ABOVE this marker.
 */
/* --- the cutscene ---------------------------------------------------------
 *
 * A one-off screen that plays when a big update lands, over everything else —
 * hence z-index 70, above the loading overlay at 60. See src/lib/intro.ts.
 *
 * ⚠️ COMMITTED TO BLACK, not to `--bg`. This is the only surface in the app that
 * ignores the theme, and deliberately: it is a moment in the game's world rather
 * than a piece of its interface, and a light-mode purple fog is not a thing.
 *
 * ⚠️ WHICH IS WHY THE INK IS A LITERAL AND NOT `var(--fg)`. Committing the ground to
 * black while leaving the text on a themed token is the worst of both: in light mode
 * `--fg` is #1d1f24, so the entire cutscene was near-black text on a black screen —
 * invisible, for every player who picked light theme, with nothing failing anywhere.
 * These two values ARE the dark palette's `--fg` and `--dim`; they are written out
 * because this surface must not follow the theme, not because they are different.
 */
.intro {
  position: fixed;
  inset: 0;
  z-index: 70;
  background: #000;
  color: #e8e6df;
  display: grid;
  place-items: center;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

/*
 * ⚠️ NEAREST-NEIGHBOUR, and the canvas is a fraction of the screen's size — the fog
 * is drawn at one pixel per 8 screen pixels and blown up. A CSS blur gradient was the
 * obvious way to do this and is exactly wrong: soft-edged haze behind a pixel-true
 * font is the "AI pixel art" look the whole product is built against.
 */
.intro-fog {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  image-rendering: pixelated;
  pointer-events: none;
}

/*
 * ⚠️ **CENTRED, NOT LEFT — his call, 2026-08-31.** Every line in a cutscene is broken BY HAND
 * (`intro.ts` MAX_LINE) and none of them wrap, so centring is safe here in a way it would not be
 * in ordinary prose: the ragged edge is authored, and centring it makes the stanzas read as a
 * title card rather than as a paragraph that happens to be short.
 */
.intro-text {
  position: relative;
  padding: 0 24px 64px;
  text-align: center;
  font-size: var(--type);
  line-height: 1.9;
}

/*
 * ⛔ **HEIGHT IS A CONDITION TOO, and leaving it out broke the scene in landscape.**
 *
 * This asked for width alone, so rotating a phone made the type BIG at the exact moment there was
 * least room for it: the fourth stanza ran off the bottom, through "TAP TO CONTINUE", and the
 * scene could not be read at all. Alessandro, 2026-08-31: *"if I rotate landscape I can't read."*
 *
 * ⭐ **A cutscene cannot scroll and must not wrap** (`intro.ts` MAX_LINE), so the only thing left
 * that can give is the type size — which means every rule that grows it owes a height condition.
 * The gold fog already lost a stanza to this in portrait; the lesson did not travel to the CSS.
 */
@media (min-width: 560px) and (min-height: 640px) {
  .intro-text { font-size: var(--type-big); }
}

/* ⚠️ Lines are broken by hand in intro.ts and must never wrap — see MAX_LINE. */
.intro-line { white-space: nowrap; }

.intro-stanza { margin-bottom: 1.9em; }
.intro-stanza:last-child { margin-bottom: 0; }

/*
 * ⛔ **A SHORT VIEWPORT COMPRESSES THE SCENE, and the type size alone could not save it.**
 *
 * Measured on a 844x390 landscape phone with the small type already applied: nine hand-broken
 * lines plus three stanza gaps came to 410px in a 390px frame, so the last stanza still sat off
 * the bottom. ⭐ **The LEADING is the bulk of it, not the glyphs** — 1.9 line-height and 1.9em
 * between stanzas is most of that 410, and it is the part that can give without making the words
 * smaller than they should be.
 *
 * ⚠️ 560px is the same height the type rule uses, so a viewport is either roomy for both or
 * tight for both — two breakpoints that disagreed would leave a band that is compact AND big.
 */
/*
 * ⛔ **IT HAS TO SIT AFTER `.intro-stanza`, AND IT DID NOT.** Placed above that rule, the override
 * lost at equal specificity and the stanza gap stayed at 1.9em — the computed value came back
 * 34.2px against the 13.5px asked for, so the compression looked like it barely worked (410 →
 * 334px against a 321px ceiling) when in truth two thirds of it was being thrown away. ⚠️ Found by
 * reading the COMPUTED style rather than by re-reading the stylesheet.
 */
@media (max-height: 560px) {
  .intro-text { line-height: 1.25; padding-bottom: 28px; }
  .intro-stanza { margin-bottom: 0.75em; }
}


/*
 * ⚠️ OPACITY ONLY. A translate would land the glyph rows on fractional pixels and
 * blur the font, which is the one thing this typeface exists to avoid.
 */
.intro-tok {
  opacity: 0;
  transition: opacity 800ms ease-out;
}

.intro-tok.on { opacity: 1; }

/*
 * The highlighted word. ⚠️ IT TAKES THE FOG'S COLOUR, set as `--fog-ink` by `playIntro`, so
 * a gold fog cannot end up with a purple word in it. The literal is the fallback for the
 * original purple scene and for any cutscene that declares no kind.
 *
 * ⚠️ Still a literal rather than a theme token: the cutscene is committed to black and
 * ignores light mode entirely, so `var(--fg)` here would make the word invisible.
 */
.intro-purple {
  color: var(--fog-ink, #b183e0);
  text-shadow: 0 0 18px color-mix(in srgb, var(--fog-ink, #b183e0) 55%, transparent);
}

.intro-prompt {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 12%;
  margin: 0;
  text-align: center;
  color: #8a8f98;
  font-size: var(--type);
  opacity: 0;
  transition: opacity 900ms ease-out;
}

.intro-prompt.on {
  opacity: 1;
  /* steps(1) — a smooth pulse would fight the pixel grid. */
  animation: intro-pulse 2.4s steps(1, end) infinite;
}

@keyframes intro-pulse {
  0%, 60% { opacity: 1; }
  61%, 100% { opacity: 0.25; }
}

@media (prefers-reduced-motion: reduce) {
  .intro-tok,
  .intro-prompt { transition: none; }
  .intro-prompt.on { animation: none; }
}

/* --- the battle stage ------------------------------------------------------
 *
 * ⛔ **A SECTION MARKER, ADDED 2026-09-10, AND `intro.test.ts` IS WHY.** That test scans from
 * `--- the cutscene ---` to the NEXT `/* --- ` marker and fails on any theme token, because the
 * cutscene sits on a hard #000 that does not follow the theme. Without a marker here every stage
 * rule was inside the cutscene's block and judged by the cutscene's rule — so adding a correct
 * `background: var(--bg)` to `#stage-wrap` failed a test about a completely different surface.
 * ⚠️ The test's own note predicted this shape: *"every rule anyone appended after the cutscene was
 * judged by the cutscene's rule."* Bounding the scan fixed half of it; the missing marker was the
 * other half.
 *
 * ⚠️ The chrome OVERLAYS the beach rather than sitting below it: the stage is as big as the
 * screen, and on a landscape phone giving a bar its own strip costs a tenth of the picture.
 * `LAYOUT.footRow` is what keeps the creatures clear of the bottom bar.
 */
#stage-wrap {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  max-height: calc(100vh - 8px);
  overflow: hidden;
  margin-inline: auto;
}
/* ⚠️ `pixelated`, always: the stage is drawn at one integer scale and must never be smoothed. */
/*
 * ⛔ THE BOTTOM BAR RESERVES ITS SPACE; THE TOP BAR STILL HOVERS.
 *
 * The wrapper is a column: the playfield takes what is left, the bar sits under it. So nothing
 * can stand behind Leave or the speed dial, and `footRow` stops having to be tuned around
 * chrome it cannot see. The top bar keeps overlaying, because it carries names over SKY —
 * there is nothing up there to hide.
 *
 * ⚠️ `drawStage` measures `#stage-field`, not the wrapper. Measuring the wrapper would paint a
 * beach the height of the whole box and then hide its last rows behind the bar.
 */
#stage-wrap { display: flex; flex-direction: column; }
#stage-field { position: relative; flex: 1; min-height: 0; }

#stage-bg { display: block; width: 100%; height: 100%; image-rendering: pixelated; }
#stage-creatures { position: absolute; inset: 0; pointer-events: none; }
#stage-creatures canvas { position: absolute; image-rendering: pixelated; }
/*
 * ⭐⭐ **§4u'S BLINK — *"the item icon shines in the HUD row"* on an action that USES it.**
 *
 * ⚠️ **BRIGHTNESS AND A RING, NOT A SIZE CHANGE.** §4u constraint 1 bans resampling a 32×32 icon,
 * and a `transform: scale()` is a resample with extra steps — it would soften exactly the picture
 * the blink is trying to draw attention to. ⭐ `filter: brightness` and an outline leave every
 * pixel where it is.
 *
 * ⛔ **THE OUTLINE IS `outline`, NOT `border`.** A border would grow the 32px box (or, with
 * `content-box`, shift the row); an outline is drawn outside the layout and moves nothing.
 */
/*
 * ⛔⛔ **`--gear-ink`, NOT `var(--accent)` — and `intro.test.ts` caught the first version.**
 *
 * ⚠️ **THE HUD LIVES OVER THE STAGE ART, NOT OVER THE APP BACKGROUND**, so a token that follows
 * light mode can vanish against a bright beach — the rule `.hud-avatar` already obeys with its
 * fixed translucent grey, and the reason that test scans this whole region. ⭐ `--gear-ink` is set
 * from JS to the SAME ink the rays are drawn in, so an item's two tells cannot be different
 * colours, and the fallback is white rather than a theme value.
 */
@keyframes hud-blink {
  0%   { filter: brightness(2.4); outline-color: var(--gear-ink, #fff); }
  60%  { filter: brightness(1.4); outline-color: var(--gear-ink, #fff); }
  100% { filter: none; outline-color: transparent; }
}
.hud-gear img.is-firing {
  outline: 2px solid transparent;
  outline-offset: 1px;
  animation: hud-blink 420ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
  /*
   * ⚠️ The tell still has to HAPPEN — it just stops pulsing. A removed animation is a lost fact,
   * and `filter: brightness` carries it without motion even if the outline is missed.
   */
  .hud-gear img.is-firing { animation-duration: 1ms; outline-color: var(--gear-ink, #fff); }
}
#stage-hud {
  position: absolute; top: 0; left: 0; right: 0;
  display: flex; justify-content: space-between;
  /*
   * ⛔ **TOP, NOT CENTRE — §4u's icon row made your side TALLER than the other two.** Centring
   * three items of different heights pushes the tallest one's first line DOWN, so the avatar and
   * the Instructor's name stopped sharing a baseline the moment gear appeared. Aligning to the
   * start keeps the name line where it has always been and lets the icons hang below it.
   */
  align-items: flex-start;
  padding: 4px 8px; gap: 8px; font-size: var(--type); color: #fff;
  text-shadow: 0 1px 2px #0008;
}
.hud-side { display: flex; align-items: center; gap: 6px; }
/*
 * ⭐⭐ **§4u'S ICON ROW — *"under the avatar and name show the icons"*, his design 2026-09-04.**
 *
 * ⚠️ So your side is a COLUMN and the name line is its own row; the Instructor's side is untouched
 * because it wears nothing.
 */
.hud-mine { flex-direction: column; align-items: flex-start; gap: 2px; }
.hud-who { display: flex; align-items: center; gap: 6px; }
/*
 * ⭐⭐ **THE NAME AND ITS XP BAR — his ask, 2026-09-18** (*"a bar, I think below the name, still inside the height
 * of the avatar of course so it doesn't take any new extra space"*).
 *
 * ⛔ **THE 34px CEILING IS THE WHOLE CONSTRAINT.** That is the avatar's outer height (32 + 1px border twice), and
 * the rule above exists because anything taller makes the timer and the Instructor's name JUMP. So the pair is
 * capped, not merely "small": one text line plus a 4px bar plus the 2px between them fits under it with room, and
 * the HUD is exactly as tall as it was before the bar existed.
 *
 * ⚠️ **THE BAR IS HIDDEN UNTIL THERE IS A LEVEL TO SHOW.** A device that has never synced honestly knows nothing
 * (`keeperLevel()` answers 1), and an empty bar beside "You" reads as zero progress rather than as no data.
 */
.hud-name {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  max-height: 34px;
}
.hud-xp {
  /*
   * ⚠️ WIDTH IN `ch`, so the bar is as wide as the name above it reads — a pixel width would be a different
   * fraction of the label on every device the font falls back on.
   */
  width: 9ch;
  height: 4px;
  background: rgba(0, 0, 0, 0.45);
  border: 1px solid var(--dim);
  /* ⛔ Integer-only inside: a fractional fill on a pixel-art HUD shimmers (`ui-rules.md`). */
  overflow: hidden;
}
.hud-xp[hidden] { display: none; }
.hud-xp i { display: block; height: 100%; width: 0; background: var(--accent); }
/*
 * ⛔ **AND THE OTHER TWO KEEP THE LINE THEY ALWAYS HAD.** With the HUD aligned to the TOP, a
 * one-line span would sit at the top of a 34px avatar box instead of level with it — the timer and
 * the Instructor's name would both jump up the moment gear appeared. ⚠️ 34 is the avatar's own
 * outer height (32 + its 1px border, twice), so each centres itself in exactly the space the
 * avatar occupies and nothing on the top line moves at all.
 */
#hud-timer, .hud-side:not(.hud-mine) {
  display: flex; align-items: center; min-height: 34px;
}
/*
 * ⛔⛔ **32px EXACTLY, AND `flex-shrink: 0` IS WHAT ENFORCES IT.** §4u constraint 1: *"the icons
 * are 32×32 and must not be scaled… 0.5 and 0.7 both resample."* ⚠️ A flex child's default is to
 * SHRINK, so a narrow phone would have squeezed four icons into three icons' width — a fractional
 * scale arriving through the layout rather than through a number anybody wrote. ⭐ §4u's answer to
 * a row that is too wide is FEWER icons, never smaller ones, and `selective` is that filter.
 */
/*
 * ⭐⭐ **SEVEN PER ROW, THEN IT WRAPS — his call, 2026-09-08.**
 *
 * > *"I'm more concerned when we have sparring with other players and we have both players showing
 * > items — there's a chance they will meet in the middle of the screen (the 2 rows of icons). We
 * > may need to limit like 7 in a row and the rest below in a second row."*
 *
 * ⛔ **IT IS A CAP ON WIDTH, NOT ON COUNT — nothing is ever hidden.** §4u's rule is *"fewer icons,
 * never smaller ones"*, and dropping an icon to fit would be a third rule about which items matter;
 * wrapping keeps every one of them at 32×32 and spends height instead, which the HUD has.
 *
 * ⚠️ **THE MAXIMUM IS DERIVED, NOT TYPED.** Seven icons at 34px outer (32 + 1px border each side)
 * with six 2px gaps. Writing `250px` would be a number nobody could check against the box it came
 * from, and it would silently stop meaning "seven" the day the border changes.
 */
.hud-gear {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
  max-width: calc(7 * 34px + 6 * 2px);
}
/* ⛔ Hidden by the bar's toggle — `hidden` alone loses to `.hud-gear`'s `display: flex`. */
.hud-gear[hidden] { display: none; }
.hud-gear img {
  /* ⛔ `border-box` IS GLOBAL, so a bordered 32px box draws 30px of picture — see `.hud-avatar`. */
  box-sizing: content-box;
  width: 32px; height: 32px; flex: 0 0 auto;
  image-rendering: pixelated;
  display: block;
  /* ⚠️ The same ground the avatar sits on, so the two rows read as one plate over the sky. */
  background: #0000008c;
  border: 1px solid #ffffff59;
  border-radius: 2px;
}
/* ⛔ Nothing worn takes NO space — an empty bordered strip reads as a slot that failed to load. */
.hud-gear:empty { display: none; }
/*
 * ⚠️ 32px AT EXACTLY 1x — it was an 18px box, and no 32x32 sprite fills that at a whole-number
 * scale (`ui-rules.md`: a fractional scale gives uneven pixel widths and reads as a rendering bug).
 * ⛔ There is only ONE of these now, on your side: the Instructor has no portrait, and an empty
 * box beside a name reads as one that failed to load.
 */
/*
 * ⭐ **ON ITS OWN GROUND — his call, 2026-09-03**: *"maybe the avatar in a box with a grey
 * background so it's more visible."* The stage behind it is a painted scene, and a sprite with
 * transparent corners sitting straight on one has no edge at all.
 *
 * ⛔ **A FIXED TRANSLUCENT GREY, NOT A THEME TOKEN.** The HUD lives OVER the stage art, not over
 * the app background — `#stage-hud` is white text with a shadow for the same reason — so a token
 * that follows light mode would vanish against a bright beach.
 *
 * ⚠️ **NO PADDING AND NO SIZE CHANGE**, or the 1× rule above is broken by the frame meant to show
 * it off. The border sits outside the 32.
 *
 * ⛔⛔ **AND `content-box` IS WHAT MAKES THAT SENTENCE TRUE — it was a comment describing something
 * the code did not do.** `box-sizing: border-box` is GLOBAL here (line 88), so `width: 32px` plus a
 * 1px border gave the canvas a **30px** content box: a 32×32 backing store squeezed to 30, which is
 * a 0.94× resample — precisely the fractional scale `ui-rules.md` bans and this whole rule exists
 * to prevent. ⚠️ Nothing looked broken enough to notice, which is why it survived; the same trap is
 * written up at `.hud-gear img` and at the 36px badge box below.
 */
.hud-avatar {
  box-sizing: content-box;
  width: 32px; height: 32px; display: inline-block;
  image-rendering: pixelated;
  background: #0000008c;
  border: 1px solid #ffffff59;
  border-radius: 2px;
}
.keeper-avatar-pick { background: none; border: 0; padding: 0; cursor: pointer; line-height: 0; }
#stage-bar { flex: 0 0 auto; padding: 4px 8px; }
#stage-bar button { font-size: var(--type); padding: 4px 10px; }

/*
 * ⚠️ PORTRAIT HIDES THE STAGE AND ASKS FOR A TURN, rather than squeezing it.
 *
 * `stages.md` §2 settled landscape-only — *"the user has to rotate the phone, there's no way
 * around it"* — and the reason is arithmetic: two teams of five facing each other need about
 * 600px, and a portrait phone offers ~390. Squeezed in, the creatures overlap into a pile.
 *
 * ⚠️ A PROMPT, NOT A LOCK. `screen.orientation.lock()` needs fullscreen and iOS Safari does
 * not implement it at all, so a page that relies on it is a page that silently fails on the
 * phone this game is mostly played on. Asking works everywhere.
 */
@media (orientation: portrait) {
  #view-battle #stage-wrap { display: none; }
  #view-battle #stage-rotate { display: block; }
}
@media (orientation: landscape) {
  #view-battle #stage-rotate { display: none !important; }
}
#stage-rotate {
  padding: 24px 16px;
  text-align: center;
  line-height: 1.6;
}

/*
 * ⛔ THE BATTLE STAGE TAKES THE WHOLE SCREEN.
 *
 * Alessandro: *"we need to show the battle stage full screen, no navbar except the leave
 * button, no headbar or anything, otherwise I have to scroll up and down and it's not using
 * my whole screen."* Right — a beach with a topbar above it and a tab bar below it is a
 * beach in a letterbox, and on a landscape phone those two strips are most of the height.
 *
 * ⚠️ A BODY CLASS, not `display:none` on the elements themselves: the topbar and tabs are
 * shared furniture and every other view still wants them. `main.ts` sets `is-battle` when the
 * view opens and clears it on the way out, so nothing here has to know how to put them back.
 */
/*
 * ⛔ FULL SCREEN ONLY IN LANDSCAPE, and this is a TRAP FIX, not a refinement.
 *
 * Taking the tab bar away in portrait left Alessandro with a "turn your phone" message and no
 * way off it — and because the app remembers its last view, closing and reopening put him
 * straight back. In an installed app that still refused to rotate, that is a permanently
 * bricked game. *"I'm stuck there forever."*
 *
 * ⚠️ THE RULE: never hide the only means of navigation on a screen that cannot show its
 * content. Portrait keeps the tabs, shows the message, and lets him walk away.
 */
@media (orientation: landscape) {
  body.is-battle .topbar,
  body.is-battle #tabs,
  body.is-battle #segments,
  body.is-battle #view-battle .note { display: none; }

  body.is-battle { overflow: hidden; }
  body.is-battle #app { padding: 0; margin: 0; max-width: none; }
}

@media (orientation: landscape) {
body.is-battle #stage-wrap {
  position: fixed;
  inset: 0;
  width: 100vw;
  /*
   * ⛔ `dvh`, NOT `vh`. On Android Chrome `100vh` is the viewport WITHOUT the URL bar — the
   * large viewport — so a full-height stage runs underneath it and the bottom is unreachable.
   * Alessandro: *"the browser keeps the url bar and basically cuts the lower part of the
   * battle field and I can't leave."* `dvh` is the viewport as it is RIGHT NOW, bar included,
   * and it re-resolves as the bar hides and shows.
   *
   * ⚠️ `vh` first as the fallback: a browser too old for `dvh` ignores the second line and
   * keeps a usable — if slightly tall — stage, rather than getting no height at all.
   */
  height: 100vh;
  height: 100dvh;
  /*
   * ⛔ **OPAQUE, OR THE PAGE UNDERNEATH SHOWS THROUGH.** This is `position: fixed; inset: 0` with
   * no background of its own, so anywhere the canvas does not reach — the strip the speed bar sits
   * in — was a window onto the document below. On his iPhone the beta note bled through behind the
   * buttons: *"I see a text below the bottom bar, like 'Nothing sold', I have no clue where that
   * comes from."* It comes from `index.html`'s own footer note, 800px up the page.
   *
   * ⚠️ **IT ONLY SHOWS WHERE THE STAGE DOES NOT REACH**, which is why it took a phone to find: on
   * a desktop the canvas fills the wrap and there is no gap to see through.
   */
  background: var(--bg);
  /* ⚠️ No aspect-ratio here: the stage is GENERATED to whatever shape the screen is, which
     is the whole reason it is generated rather than drawn. Constraining it would letterbox. */
  aspect-ratio: auto;
  max-height: none;
}

/*
 * The one control that stays. Nothing else may cover the sand.
 *
 * ⚠️ AND IT MUST BE REACHABLE, which is not the same as being drawn. It sits inside the safe
 * area and above the on-screen furniture; a Leave button under a URL bar is a trap, because
 * this view has no scrolling to escape with.
 */
body.is-battle #stage-bar {
  /* ⚠️ In flow now, so this is padding rather than an offset — see the note on #stage-wrap. */
  padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 4px);
  pointer-events: auto;
  z-index: 3;
}
body.is-battle #stage-hud { top: calc(env(safe-area-inset-top, 0px) + 2px); z-index: 3; }
body.is-battle #stage-exit {
  /* Big enough to hit with a thumb, on a screen where it is the only way out. */
  min-height: 36px;
  padding: 6px 16px;
}
} /* end of the landscape-only full-screen block */

/* ⚠️ The way out of the rotate message, for a phone that will not turn at all. */
#stage-rotate button {
  margin-top: 14px;
  min-height: 40px;
  padding: 8px 18px;
}



/* --- the battle wipe --------------------------------------------------- */

/*
 * A BLOCK WIPE, hard-edged and stepped — the app has no soft gradients and this is not the
 * place to introduce one. Alessandro chose it over a fade: *"it's very abrupt, and going
 * fullscreen you see a sort of resize of the battlefield that is annoying."*
 *
 * ⭐ THE POINT IS WHAT IT HIDES. Fullscreen changes the viewport's shape, so the stage MUST be
 * rebuilt at a new size — under a cover that is just how long the transition takes; in the open
 * it reads as the screen glitching.
 *
 * ⚠️ `steps()`, so the edge jumps in whole blocks instead of sliding. A smooth wipe on a pixel
 * grid is the same mistake as a rounded corner.
 */
/*
 * ⚠️ THEMED ON PURPOSE, unlike the cutscene. That one sits on a hard `#000` ground, so a theme
 * token on it flips underneath a background that does not — `color: var(--fg)` once shipped
 * near-black text on a black screen for every player in light mode. This cover is the opposite
 * case: it covers the APP, whose background follows the theme, so a hard `#000` here would
 * flash black in light mode for exactly the same reason.
 */
#stage-wipe {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: var(--bg);
  pointer-events: none;
  display: none;
}

#stage-wipe.is-on { display: block; }

/* In from the left; out to the right — so entering and leaving are visibly opposite. */
#stage-wipe.wipe-in { animation: wipe-in 260ms steps(12) forwards; }
#stage-wipe.wipe-out { animation: wipe-out 260ms steps(12) forwards; }

@keyframes wipe-in {
  from { clip-path: inset(0 100% 0 0); }
  to { clip-path: inset(0 0 0 0); }
}

@keyframes wipe-out {
  from { clip-path: inset(0 0 0 0); }
  to { clip-path: inset(0 0 0 100%); }
}

/*
 * ⚠️ REDUCED MOTION STILL COVERS, it just does not travel. Removing the cover entirely would
 * hand exactly the people who asked for less motion the resize this exists to hide.
 */
@media (prefers-reduced-motion: reduce) {
  #stage-wipe.wipe-in, #stage-wipe.wipe-out { animation-duration: 1ms; }
}


/* --- the fight ---------------------------------------------------------- */

/*
 * A HEALTH BAR PER CREATURE, sitting just above its head.
 *
 * ⚠️ HARD EDGES AND WHOLE PIXELS. A rounded or gradient-filled bar would be the only such
 * shape on the stage; the fill is a flat block that shortens, which is also what makes a drop
 * readable at a glance on a small screen.
 */
/*
 * ⚠️ NO HEIGHT HERE — it is set inline from `HP_BAR_HEIGHT`, because the same number decides
 * where the bar sits above the head. Two sources for one measurement is how a bar ends up
 * seven pixels from the head everywhere except where it matters.
 */
.hp {
  position: absolute;
  background: #000a;
  border: 1px solid #0007;
  pointer-events: none;
  /* ⚠️ `border-box`, so the border is part of the height the placement maths assumed. */
  box-sizing: border-box;
}

.hp i {
  display: block;
  height: 100%;
  background: var(--accent);
  /* ⚠️ Width only. Animating anything else would move the bar off its creature. */
  transition: width 120ms linear;
}

/* Below a third, it is worth noticing without reading a number. */
.hp.is-low i { background: #c8553d; }
/*
 * ⭐⭐ **A FALLEN CREATURE HAS NO BAR — his call, 2026-09-08:** *"can we remove the empty health
 * bar too please?"*
 *
 * ⛔ **IT WAS DIMMED TO 0.35 WITH A GREY FILL, which was the right answer while the corpse stayed
 * on the sand** — a faded bar over a faded creature read as one thing that had stopped. ⚠️ Now the
 * body is gone and a GHOST stands there, so an empty bar floating over it belongs to nobody: the
 * ghost has no health to report and the bar says a creature is still being tracked.
 *
 * ⚠️ **`visibility`, NOT `display`, and not for layout** — the bar is absolutely positioned, so
 * neither would move anything. It matches `.stage-creature.is-down` so the two halves of a death
 * are switched off the same way, and either can be inspected in place while debugging.
 */
.hp.is-down { visibility: hidden; }

/*
 * THE PROJECTILE — a creature's gauge, made of pixels.
 *
 * ⚠️ NO TRANSITION AND NO SMOOTHING. It is redrawn every frame from the charge, so a CSS
 * transition would fight the redraw; and `pixelated` because it is drawn one pixel at a time
 * and a browser smoothing it into a grey smudge is the one thing this app cannot have.
 */
.orb {
  position: absolute;
  image-rendering: pixelated;
  pointer-events: none;
  /* ⚠️ ABOVE `.shield-fx` — a shot must arrive OVER a shield, not disappear behind it. */
  z-index: 2;
}

/*
 * ⭐ THE HEAL RISES ABOVE EVERYTHING, including a shot in flight. It is a status the player is
 * being told about, not an object in the scene — a `+` half behind a projectile reads as a glitch.
 */
.heal-fx { z-index: 3; }

/*
 * ⭐ THE SHIELD EFFECT — over the creature, under nothing. Drawn by `paintShield`.
 *
 * ⚠️ **ABOVE THE CREATURE, BELOW THE SHOT** — Alessandro, 2026-08-27: *"make the projectile go in
 * front of the shield, better I think."* The first version had it the other way on the reasoning
 * that a shot vanishing behind a shield reads as passing through a wall. He is right and that
 * reasoning was backwards: a shot travelling OVER the shield is a shot arriving, and the shield
 * answers it or does not. A shot hidden behind it just looks like a missing shot.
 */
/*
 * ⚠️ **THE SIZE IS A CSS SCALE, NOT A REDRAW — and it is a deliberate compromise.**
 *
 * Alessandro, 2026-08-28: *"at 64px it is too big still, we need to try to make it 25% smaller…
 * isn't there any way to do the same here without redrawing?"* There is, with a cost: pixel art
 * only scales cleanly by WHOLE numbers, so at 0.75 the browser drops some source pixels and a 1px
 * feature can break. ⭐ It survives here because this drawing is broad bands rather than fine
 * detail — and because the app already ships a fractional scale on creatures (`zoom: 0.9` under
 * 380px), so this is a concession the game has already made once.
 *
 * ⭐ **0.7 IS HIS, CHOSEN BY EYE ON STAGING**, 2026-08-28 — and the reasoning matters more than
 * the number: *"the shield doesn't have to hide the creature, it still needs to be visible and
 * just show there's a shield, not physically protect the creature."* It is a STATE BADGE worn by
 * the creature, not a barrier drawn in front of it.
 *
 * ⛔ **DO NOT USE THIS FOR THE 32px ART SETTING.** That is handled by halving the GRID
 * (`halveRows`), which is exact. This is only the taste adjustment on top.
 *
 * ⚠️ `transform-origin: center` keeps the shield on the creature's middle as it shrinks; scaling
 * from the default corner would slide it up and left by an eighth of its size.
 */
.shield-fx {
  position: absolute;
  image-rendering: pixelated;
  pointer-events: none;
  opacity: 0;
  z-index: 0;
  transform: scale(var(--shield-scale, 0.7));
  transform-origin: center;
}
.shield-fx.is-on { opacity: 1; }

/*
 * ⭐⭐ **A FALLEN CREATURE BECOMES A GHOST — his design, 2026-09-08.**
 *
 * *"When a creature dies, instead of showing it transparent we replace it with the ghost. It has to
 * float so we need the animation up and down and left and right."*
 *
 * ⛔ **THE BODY GOES ENTIRELY, rather than being dimmed underneath.** A 0.35-opacity corpse behind
 * a ghost is two creatures on one anchor, and the pale one reads as a rendering fault. ⚠️ It keeps
 * its SPACE — `visibility`, not `display` — because removing it would collapse the formation, which
 * is the reason the dimmed version existed at all.
 */
.stage-creature.is-down { visibility: hidden; }

.ghost-fx {
  position: absolute;
  image-rendering: pixelated;
  pointer-events: none;
  /* ⚠️ Under the HUD and the bars, over the sand — the same layer the shield sits on. */
  z-index: 0;
  opacity: 0;
}
/*
 * ⭐ **IT FADES IN RATHER THAN APPEARING.** Death is already marked by the blow that caused it; a
 * ghost that pops the same frame competes with the impact for the eye.
 */
/*
 * ⭐⭐ **A GHOST IS SEE-THROUGH — his call, 2026-09-08:** *"make the ghost a bit transparent,
 * like… a ghost."*
 *
 * ⚠️ **ON THE ELEMENT, NOT IN THE DRAWING.** Baking the alpha into the sprite would fix it at one
 * value and cost him a redraw to change; on the element it is one number, and the pixels stay the
 * greys he picked.
 *
 * ⛔ **AND IT IS THE `is-on` OPACITY, so the fade-in still lands on it** rather than reaching 1 and
 * dropping back — the transition runs 0 → this, once.
 */
.ghost-fx.is-on { opacity: var(--ghost-alpha, 0.5); transition: opacity 260ms linear; }

/*
 * ⭐⭐ **THE FLOAT — up and down AND left and right, his words.**
 *
 * ⛔ **WHOLE PIXELS AT EVERY STOP, AND `steps(1)` TO STAY ON THEM.** `battle-screen.md` rules
 * INTEGER OFFSETS ONLY; a smooth interpolation between two whole-pixel keyframes spends most of its
 * time on fractions, which on a pixelated canvas is a shimmer rather than a drift.
 *
 * ⚠️ **`translate`, NOT `transform`** — the same reason the knockback uses it: they are separate
 * properties, so a future transform on the ghost cannot silently cancel the float.
 */
@keyframes ghost-float {
  0%, 100% { translate: 0 0; }
  12.5%    { translate: 1px -2px; }
  25%      { translate: 2px -3px; }
  37.5%    { translate: 1px -4px; }
  50%      { translate: 0 -5px; }
  62.5%    { translate: -1px -4px; }
  75%      { translate: -2px -3px; }
  87.5%    { translate: -1px -2px; }
}
.ghost-fx.is-on { animation: ghost-float 2600ms steps(1, end) infinite; }

@media (prefers-reduced-motion: reduce) {
  /* ⚠️ The ghost still REPLACES the creature — that is the information. Only the drift stops. */
  .ghost-fx.is-on { animation: none; }
}

/*
 * ⭐ POISONED — a creature carrying a POISON shield's mark, until it swings.
 *
 * ⛔ THE ONLY TELL THIS EFFECT HAS. POISON weakens the marked creature's NEXT blow, so nothing on
 * screen moves for it at the moment it is applied and the smaller number, when it comes, is
 * indistinguishable from a smaller roll. Alessandro: *"we can simply have the targeted creature
 * glow green, simple as that."*
 *
 * ⚠️ A HUE-ROTATE WOULD RECOLOUR THE CREATURE — every pixel of it, including a green one that is
 * meant to be green. A drop-shadow adds a glow AROUND the silhouette and leaves the art alone,
 * which is also the only version that reads at 32×32.
 */
/*
 * ⛔ `is-poisoned` CARRIES NO FILTER — it is a flag the DRAW loop reads, not a CSS effect.
 * ⚠️ `is-crit-a` and `is-crit-b` are the same shape: two flags for the two beats of the crit
 * flash, painted into the bitmap by `blit` so the SHADOW is left alone. Neither has a rule here,
 * and adding one would be the bug this note exists to prevent.
 *
 * A filter here tints the canvas, and the shadow is painted into that same canvas, so the ground
 * went green with the animal. `blit` tints the creature's own pixels instead, after the shadow has
 * already been laid down. See `blit`'s `poisoned` argument.
 */

/*
 * ⭐ TWO BLINKS WHEN HIT, A SMALL HOP WHEN ACTING — Alessandro's: *"the attacking creature
 * jumps up 3-4px, the one getting hit blinks 2 times."*
 *
 * ⚠️ IT IS NOT DECORATION. Without it a fight is five bars changing length and there is no way
 * to tell who did what to whom — the brightness flash it replaces was too subtle to read at
 * all on a small screen against sand.
 *
 * ⚠️ `steps()` on both, so the blink snaps and the hop lands on whole pixels. A creature that
 * eases smoothly upward is the one thing on this stage not drawn on a grid.
 */
.stage-creature.is-hit { animation: hit-blink 110ms steps(2) 2, knockback 240ms steps(2); }

@keyframes hit-blink {
  from { opacity: 1; }
  to { opacity: 0.15; }
}

/*
 * ⭐ STRUCK CREATURES RECOIL — Alessandro: *"animate the creature like it's pushed back, it
 * jumps back and then goes back to the same position, like 5px in diagonal."* Back and UP, away
 * from whoever hit it; `--kb-x` carries the direction, because only the placement code knows
 * which side a creature is on.
 *
 * ⚠️ IT USES `translate`, NOT `transform`, and that is load-bearing rather than modern. The
 * attacker's hop already animates `transform`, and a creature can be hit in the same instant it
 * acts — two animations on one property means one of them silently does nothing. They are
 * separate CSS properties, so both play.
 */
@keyframes knockback {
  0% { translate: 0 0; }
  30% { translate: var(--kb-x, -5px) -5px; }
  100% { translate: 0 0; }
}

/*
 * ⚠️ AND THE COMBINED CASE NEEDS ITS OWN RULE. Two rules of equal specificity each setting
 * `animation` do not merge — the later one replaces the earlier wholesale, so a creature that
 * was hit while acting would lose one of the two entirely.
 */
.stage-creature.is-acting.is-hit {
  animation: hit-blink 110ms steps(2) 2, knockback 240ms steps(2), act-hop 200ms steps(2);
}

/* 4px, and back down. Enough to read as an intention, not enough to leave the formation. */
.stage-creature.is-acting { animation: act-hop 200ms steps(2); }

@keyframes act-hop {
  0% { transform: translateY(0); }
  50% { transform: translateY(-4px); }
  100% { transform: translateY(0); }
}

/*
 * ⚠️ REDUCED MOTION KEEPS THE BLINK AND DROPS THE HOP. The blink is information — it says who
 * was hit — and removing it would leave those players unable to follow the fight at all; the
 * hop is the part that actually moves something across the screen.
 */
/*
 * ⚠️ REDUCED MOTION KEEPS THE BLINK and drops what MOVES. The blink says who was hit, and
 * without it those players cannot follow a fight at all; the hop and the recoil are the parts
 * that actually travel across the screen.
 */
@media (prefers-reduced-motion: reduce) {
  .stage-creature.is-acting { animation: none; }
  .stage-creature.is-hit { animation: hit-blink 110ms steps(2) 2; }
  .stage-creature.is-acting.is-hit { animation: hit-blink 110ms steps(2) 2; }
  .hp i { transition: none; }
}

/*
 * ⭐ THE BAR'S MENUS OPEN UPWARDS — the bar is the bottom of the screen, so a menu below would be off it.
 * ⚠️ `[hidden]` must win over `display:flex`, or a closed menu still draws.
 */
.bar-menu { position: relative; margin-left: 10px; }
.bar-drop {
  position: absolute;
  bottom: calc(100% + 6px);
  left: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 6px;
  background: var(--bg);
  border: 1px solid var(--line);
  z-index: 6;
  min-width: max-content;
}
.bar-drop[hidden] { display: none; }
#stage-speed-open,
#stage-sound-open,
.bar-drop button {
  width: auto;
  padding: 4px 8px;
  background: transparent;
  border: 1px solid var(--line);
  color: var(--fg);
  font-size: var(--type);
  white-space: nowrap;
}
#stage-speed-open[aria-expanded="true"],
#stage-sound-open[aria-expanded="true"] { border-color: var(--accent); color: var(--accent); }
#stage-speed button.is-chosen,
#stage-sound button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--bg); }

#stage-bar { display: flex; align-items: center; }
#stage-bar button { width: auto; }

/*
 * ⭐ **THE GEAR TOGGLE — same shape as a speed button, because it is the same kind of control.**
 *
 * ⛔ **PRESSED MEANS SHOWING, and the state is on `aria-pressed` rather than in the label.** A
 * button whose text flips between "Show" and "Hide" is read by half of people as the CURRENT state
 * and half as what pressing it will do; one word plus a pressed state says it once.
 */
#stage-gear-toggle {
  margin-left: 10px;
  white-space: nowrap;
  padding: 4px 8px;
  background: transparent;
  border: 1px solid var(--line);
  color: var(--dim);
  font-size: var(--type);
}
#stage-gear-toggle[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--bg);
}

/*
 * ⚠️ OVER THE STAGE, NOT INSTEAD OF IT. The fight's last frame stays visible behind the panel —
 * being thrown back to the hub the moment somebody dies takes away the thing you were watching.
 */
#stage-result {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 4;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 14px 20px;
  /*
   * ⭐ **WIDE, BECAUSE WIDTH IS WHAT A LANDSCAPE STAGE HAS — and wide makes it SHORT.**
   * Alessandro, 2026-08-31: *"if we make it wider the 'you received some XP and some gold' goes to
   * 1 line and it's drawn properly, so wider is better… make them wide so we make them shorter too
   * by reflex."*
   *
   * ⛔ **IT HAD NO WIDTH AT ALL**, so it shrank to its widest child — a button — and every sentence
   * wrapped against that. The reward line broke across two lines and the panel grew tall enough to
   * cover the speed bar underneath it.
   *
   * ⚠️ A WIDTH rather than a max-width: shrink-to-fit is exactly the behaviour being removed.
   */
  width: min(92%, 36rem);
  /*
   * ⛔ **AND IT MUST NOT GROW PAST THE STAGE** — the same lesson the Stop card learnt. Centred with
   * `translate(-50%, -50%)`, anything too tall grows off BOTH ends at once and the buttons are
   * what is lost.
   */
  max-height: calc(100% - 16px);
  overflow-y: auto;
  overscroll-behavior: contain;
  background: var(--panel);
  border: 1px solid var(--accent);
  color: var(--fg);
  text-align: center;
}

#stage-result[hidden] { display: none; }
/*
 * ⭐ THE REWARD LINE — quieter than the result, louder than the note. It is the one part of the
 * panel that is a PAYOUT rather than a description, so it reads as its own beat.
 */
#stage-result-reward {
  color: var(--accent);
  font-size: var(--type);
}
#stage-result-reward[hidden] { display: none; }

#stage-result-title { font-size: var(--type); color: var(--accent); letter-spacing: 0.12em; }
#stage-result-note { font-size: var(--type); color: var(--dim); }
/*
 * ⛔ STACKED, NOT A ROW — four buttons side by side read as a toolbar rather than a choice.
 *
 * Alessandro, 2026-08-30: *"I think we should stack the buttons, all there feels weird."* The
 * panel gained Auto and Next floor for the tower, and a row that was fine with two is not with
 * four.
 *
 * ⚠️ THE ORDER IS THE READING ORDER — what just happened, a setting for what comes next, the way
 * on, the way out — and `ui-rules.md` §1 fixes the last one: *"a word, at the bottom, after the
 * content"*, unanimous across the app.
 */
.result-actions {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  margin-top: 6px;
  /* ⚠️ The panel sits over the fight's last frame, so it must not grow to the screen's width. */
  min-width: 12rem;
}

/*
 * ⛔⛔ **SPARRING'S RESULT PANEL IS A ROW OF FOUR, NOT A STACK — his instruction, 2026-09-18.**
 *
 * *"I always find myself clicking or rematch or leave by mistake, so we need square buttons in this order:
 * LEAVE - REPORT - REMATCH - NEW TEAM, this way my right thumb can easily click new team to progress… so buttons
 * are bigger and easier to tap."*
 *
 * ⛔ **IT REVERSES THE COMMENT ABOVE, WHICH IS ALSO HIS** (2026-08-30, *"all there feels weird"*). The stack was a
 * judgement about how it looked; this is a cost he keeps paying — stacked, the way OUT and the way ON sit under one
 * thumb travel, and a mistap ends the session. ⚠️ **Both quotes stay** so nobody reverts this as a regression.
 *
 * ⭐ **EQUAL WIDTHS ARE WHAT MAKE THEM BIG.** `flex: 1` over the panel's fixed `min(92%, 36rem)` gives four
 * targets of about 8rem each — far wider than the 9ch the stack allowed — and `min-width: 0` is what lets flex
 * shrink them evenly instead of honouring that 9ch floor.
 *
 * ⚠️ **THE TOWER IS EXCLUDED** (`.in-tower`, set in `main.ts` from `climbing`): it carries up to six buttons and
 * his ruling covers the four. Six across is a different question and `battle-screen.md` §12 leaves it OPEN.
 */
/*
 * ⭐⭐ **THE TOWER GETS THE SPARRING ROW TOO — his ruling, 2026-09-20**, which answers the question the note
 * below left open: *"the buttons should be reworked as we did for sparring, so squarish, and this order:
 * LEAVE · REPORT · AUTO · NEXT — when we lose: LEAVE · REPORT · YOUR CLIMB · RETRY."*
 *
 * ⚠️ Same reason as sparring's: stacked, the end of a session and the next floor sat under one thumb.
 */
#stage-result .result-actions {
  /*
   * ⛔ **A GRID, NOT A WRAPPING FLEX ROW — and the difference was measured, not guessed.** As flex with a 9ch
   * floor, a 568×320 phone packed *as many as fit*: **Leave · Report · Rematch** above and **New team** alone
   * below, which is neither his row nor a tidy pair. A grid can only be 4 columns or 2, so every screen gets one
   * of the two shapes he would recognise.
   */
  display: grid;
  grid-template-columns: repeat(2, minmax(9ch, 1fr));
  gap: 8px;
  /*
   * ⛔ **`align-self: stretch` IS LOAD-BEARING.** The panel is a centring column (`align-items: center`), so
   * without it the row shrinks to its CONTENT and leaves ~124px of panel unused beside it — which is how
   * "New team" came to be CUT: `scrollWidth` 111 against `clientWidth` 97.
   */
  align-self: stretch;
  min-width: 0;
}

/*
 * ⭐ **FOUR ACROSS ONCE THERE IS ROOM FOR THE LONGEST LABEL — 620px, and here is the arithmetic.**
 * "New team" needs **111px** of text plus 12px of padding = 123. Four of those with three 8px gaps need 516px of
 * panel content; the panel is `min(92%, 36rem)` with 40px of its own padding, so 516 + 40 = 556 of panel, i.e.
 * 556 / 0.92 ≈ **604px of viewport**. 620 is that with a little air.
 *
 * ⚠️ **So a 568×320 phone (the smallest landscape there is) gets 2×2 — Leave · Report above, Rematch · New team
 * below — and his instruction still holds**: read left to right, top to bottom the order is unchanged, and
 * *New team* is the bottom-right corner, exactly where a right thumb rests. Everything from a 667px phone up
 * gets the single row he asked for.
 */
@media (min-width: 620px) {
  #stage-result .result-actions {
    grid-template-columns: repeat(4, minmax(9ch, 1fr));
  }
}

/*
 * ⛔⛔ **THE LABEL WRAPS HERE, AND THE BOX IS TALL ENOUGH FOR TWO LINES WHETHER IT USES THEM OR NOT.**
 *
 * His instruction from an iPhone SE, 2026-09-18: *"the new team button needs to wrap the text, so make the button
 * taller maybe and also a bit wider is fine, because when we click new team the text change to finding… and it has
 * to fit too."*
 *
 * ⛔ **`white-space: nowrap` IS DELIBERATELY UNDONE FOR THIS ROW ONLY.** The base rule above sets it because a
 * label that went from two lines to one RESIZED the button and the panel around it mid-fetch. ⭐ That reason is
 * answered differently here: `min-height` reserves two lines permanently, so "New team" wrapping and "Finding…"
 * sitting on one line occupy the SAME box — nothing twitches, and the label is never cut.
 *
 * ⚠️ **"Finding…" IS ONE UNBREAKABLE WORD**, so the column has a `9ch` floor in the grid: wrapping cannot rescue
 * a token that does not fit, and a mid-word break would read as a bug. The floor is what makes the grid fall to
 * 2×2 on the narrowest phones instead of cutting it.
 */
#stage-result .result-actions button {
  /* ⭐ TALLER AND A LITTLE WIDER, HIS WORDS — a bigger thumb target, and room for a second line. */
  padding: 10px 10px;
  min-height: 3.6em;
  line-height: 1.25;
  white-space: normal;
  min-width: 0;
  width: auto;
}

/*
 * ⛔ **HIS ORDER, AND IT IS NOT THE DOM ORDER.** DOM order is sparring's, so a keyboard walks that; the tower
 * re-lays it with `order:` — LEAVE · REPORT · AUTO · NEXT on a win, LEAVE · REPORT · YOUR CLIMB · RETRY on a
 * defeat. ⚠️ AUTO and YOUR CLIMB share slot 3, NEXT and RETRY slot 4: only one of each pair is ever shown.
 */
#stage-result.in-tower .result-actions #stage-done { order: 1; }
#stage-result.in-tower .result-actions #stage-report { order: 2; }
#stage-result.in-tower .result-actions #stage-auto,
#stage-result.in-tower .result-actions #stage-run { order: 3; }
#stage-result.in-tower .result-actions #stage-next,
#stage-result.in-tower .result-actions #stage-retry { order: 4; }

/*
 * ⛔ THE TOWER'S FLOOR CARD — full-bleed black over the room, not a panel beside it.
 *
 * `tower-screens.md` §1b. It covers the stage on purpose: the floor number and five portraits
 * are the whole of what a player reads before a fight, and a small panel with the room showing
 * around it reads as an interruption rather than a title card.
 *
 * ⚠️ LANDSCAPE, ALWAYS. The stage is fullscreen landscape, so the five guards are a ROW. A
 * column would work on a phone held upright, which is a shape this screen never has.
 */
#floor-card {
  position: absolute;
  inset: 0;
  z-index: 5;
  /* ⭐ A tap anywhere on the card ends the hold early — see `holdFloorCard`. */
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  /*
   * ⚠️ NOT var(--panel). The card is the one surface in the app that is meant to read as the
   * lights going down — "a black screen with FLOOR 137 on it" is the design, from the first
   * line anybody wrote about the tower.
   */
  background: #000;
  color: var(--fg);
  text-align: center;
  /* ⚠️ The fade is the transition; `is-going` is what plays it — see `showFloorCard`. */
  opacity: 1;
  transition: opacity 320ms ease-out;
}

#floor-card[hidden] { display: none; }

/*
 * ⛔ THE STOP — over the room, like the result panel, because the floor you cannot enter is still
 * behind it. `tower-screens.md` §1f.
 */
#floor-stop {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 6;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 16px 22px;
  /*
   * ⭐ **TAKE THE WIDTH — the stage is always landscape, and width is what it HAS.** Alessandro,
   * 2026-08-30: *"if we make it wider it automatically becomes shorter, let's use the width we
   * have."* ⚠️ Exactly the trade: every line that stops wrapping is a line of height given back,
   * and height is the axis this card keeps running out of.
   *
   * ⚠️ The rem cap only bites on a desktop, where 94% would be an unreadable line length.
   */
  max-width: min(94%, 60rem);
  /*
   * ⛔ **IT MUST NOT GROW PAST THE STAGE, AND IT MUST SCROLL WHEN IT WANTS TO.**
   *
   * Alessandro, 2026-08-30, on a floor with three requirements: *"the modal is portrait and
   * unscrollable so unusable"* — `Leave` was off the bottom of the screen with no way to reach it.
   * ⚠️ The card is centred with `translate(-50%, -50%)`, so anything taller than the stage grows
   * off BOTH ends at once: the overflow is invisible, and the buttons are the part that is lost.
   *
   * ⭐ **A landscape phone is 400-odd pixels tall**, and a demand can carry five clauses plus a
   * lead, a title and three buttons. This is not an edge case, it is most deep floors.
   */
  max-height: calc(100% - 16px);
  overflow-y: auto;
  /* A scroll that reaches the end must not then scroll the page behind the stage. */
  overscroll-behavior: contain;
  background: var(--panel);
  border: 1px solid var(--accent);
  color: var(--fg);
  text-align: center;
}
#floor-stop[hidden] { display: none; }
#floor-stop-title { font-size: calc(var(--type) * 1.6); color: var(--accent); letter-spacing: 0.12em; }
#floor-stop-lead { font-size: var(--type); color: var(--dim); }
#floor-stop-list { list-style: none; margin: 4px 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
/*
 * ⛔ **THE FLEX ROW THAT USED TO BE HERE IS DELETED, NOT OVERRIDDEN — found 2026-09-20.** It was the
 * original cause of *"the comma goes to a new line alone"*, and the fix further down (`display: block`)
 * only beat it on source order. ⚠️ A superseded rule left in place is a bug waiting for a reorder or a
 * more specific selector to bring it back, and this exact symptom has now returned twice.
 */

/*
 * ⭐ A REQUIREMENT SAYS WHETHER YOU MEET IT — his: "OK greyed out, and a missing one like a red
 * FIX!" ⚠️ The state is on the ROW, so the whole line dims when it is satisfied rather than only
 * its tag: a met requirement should recede, and a missing one should be the thing you see.
 */
#tower-demand { margin: 12px 0 4px; }
#tower-demand-head { display: block; margin-bottom: 6px; }
#tower-demand-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
/*
 * ⛔ **A REQUIREMENT IS A SENTENCE, SO IT IS LAID OUT AS TEXT — it was a FLEX ROW and that was the
 * bug.**
 *
 * With `display: flex; gap`, every text node and every chip becomes its own flex item: the gap
 * stands in for word-spacing, so spaces between chips and spaces inside a phrase are different
 * sizes, and a comma sitting alone in its own text node can wrap onto a line by itself.
 * Alessandro: *"the comma goes to a new line alone… it's like there's a space and counts as a
 * separate character"* and *"1 WATER LANCER is much closer than 'and up to 2'."* Both are that one
 * cause.
 *
 * ⭐ **NORMAL INLINE FLOW FIXES BOTH AT ONCE**: real spaces come from the text, punctuation binds
 * to whatever precedes it, and the browser breaks lines where a sentence may break.
 */
#tower-demand-list li,
#floor-stop-list li {
  display: block;
  line-height: 1.7;
}
/*
 * ⛔ **NO BULLET — the card IS the item now.** Alessandro, 2026-08-30: *"remove the dot from the
 * list of the requirements since now they are in the card, doesn't really fit — it was fine when
 * it was a list, now not really."* ⚠️ A leading `·` also breaks the centring the cards ask for:
 * it sits inside the centred line, so every card is nudged right by one glyph.
 */

/*
 * ⚠️ INLINE-BLOCK AND MIDDLE-ALIGNED — a chip in running text sits on the baseline otherwise, and
 * drops below its own line the way the glossary icons did.
 */
#tower-demand-list .mark-element,
#floor-stop-list .mark-element,
#tower-demand-list .stat-icons,
#floor-stop-list .stat-icons {
  display: inline-flex;
  vertical-align: middle;
}

/*
 * ⛔⛔ **THE TIER LETTER IS A LETTER IN A SENTENCE, NOT A CHIP — and making it a chip brought the
 * comma bug back through a different door.** Alessandro, 2026-09-20, with a screenshot of floor
 * 101: *"again this issue, the comma is separated from the D, we had this issue and fixed but here
 * again."*
 *
 * ⛔⛔ **THE FIX ABOVE IS WHAT BROKE IT.** `display: inline-flex` was added to stop a chip sitting
 * below its own line — reasonable on its own, and it quietly undid the wrapping fix this block was
 * written for. **An inline-flex box is an ATOMIC INLINE**, and an atomic inline carries a line-break
 * opportunity on BOTH sides: the browser is then free to put `,` on a line of its own, which is
 * exactly the thing `display: block` on the row had just stopped. ⚠️ [[two-faults-stacked]] — the
 * second fault was introduced by the first fix, so the symptom came back looking identical.
 *
 * ⛔ **AND `padding: 0 4px` IS THE VISIBLE HALF.** Even on one line it put 4px of air between the
 * `D` and the comma, which reads as detached punctuation whether or not the line breaks. Measured:
 * a 4px gap at every width.
 *
 * ⭐ **PLAIN INLINE ANSWERS ALL THREE AT ONCE**: punctuation binds to the letter before it, there is
 * no atomic box to break around, and the alignment problem the chip was fighting does not arise —
 * it only existed because the chip was COLLECTION-SIZED. At the sentence's own `--type` the letter
 * sits on the sentence's own baseline, which is where it belongs.
 *
 * ⚠️ `.mark-element` and `.stat-icons` keep `inline-flex` above: one wraps its words as a unit and
 * the other is a real row of icons. Only the single letter is plain text.
 */
#tower-demand-list .mark-tier,
#floor-stop-list .mark-tier {
  display: inline;
  vertical-align: baseline;
  font-size: var(--type);
  padding: 0;
}

/*
 * ═══ THE NEXT FLOOR'S DEMANDS, IN THE TEAM BUILDER ═══════════════════════════════════════════
 *
 * Alessandro, 2026-08-30: *"it's very hard to remember each requirement."* ⛔ It sets the FILTER
 * and never the team — his line: *"I would like to avoid have 1 button to fix the team directly
 * because it kills the purpose of slowing down a bit the climb."*
 */
#team-demand {
  margin: 0 0 10px;
  border: 1px solid var(--line, var(--dim));
  padding: 8px 10px;
}
#team-demand[hidden] { display: none; }
#team-demand summary { cursor: pointer; color: var(--accent); font-size: var(--type); }
#team-demand > .note { margin: 6px 0; text-align: center; }
#team-demand-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }

/* ⚠️ A card you can press must look pressable — the two tower screens show the same cards inert. */
/*
 * ⭐⭐ **A CHEVRON, BECAUSE A TAPPABLE ROW MUST LOOK TAPPABLE** — his point, 2026-09-20: the requirement that
 * fills the filter for you *"may be not read by mistake or not clearly understood."* Only `:hover` said so,
 * and a phone has no hover. ⭐ The chevron is the app's own word for "this opens something" — the Keeper's
 * gear summary and the title button already use it.
 */
.req.is-tappable { cursor: pointer; }
.req.is-tappable::after {
  content: "\203a";
  margin-left: 8px;
  color: var(--accent);
  font-weight: 700;
}
.req.is-tappable:hover, .req.is-tappable:focus-visible { border-style: dashed; }

/*
 * ⛔ **ITS OWN ROW ABOVE THE BAR, FULL WIDTH** — Alessandro, 2026-08-30: *"move the reset on top of
 * the filters and make it wide as the space, not in line because it takes too much space and the
 * filters we spent ages to find the right width."*
 *
 * ⚠️ **THAT IS THE WHOLE REASON, and it is a constraint rather than a preference:** the three
 * filter columns are sized to fit a phone exactly, so a fourth item in the row steals width from
 * a layout that has none to give. Stacking costs one line and takes nothing from them.
 */
#team-filter-reset-row { margin: 0 0 8px; }
#team-filter-reset-row[hidden] { display: none; }
#team-filter-reset { width: 100%; }

/*
 * ⭐ FIND ALERTS — `find-alerts.ts`. The ON/OFF line sits under the count, at the top of the filtered
 * list; the chips variant (`?alerts=chips`) sits above the filters. ⚠️ Both wrap: a phone has no width
 * to spare in that column, and a worded alert name is longer than a chip usually is.
 */
/* ⭐ CENTRED — his, after testing it in production: *"center the text and button properly."* A wrapped
   line centres each row, so the sentence and its button stay together however the phone breaks them. */
/*
 * ⚠️ ROOM AROUND THEM. His, 2026-09-16, on the chips under the climb button: *"please a bit of padding, they
 * are all too close to each other."* The row sat flush against the button above it and its own buttons touched.
 */
.team-alert, .team-alert-chips { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; text-align: center; gap: 8px 10px; margin: 12px 0 10px; }
.team-alert[hidden], .team-alert-chips[hidden] { display: none; }
.team-alert-chip { display: inline-flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 8px; padding: 2px 4px; }

/*
 * ═══ A REQUIREMENT IS A CARD ═══════════════════════════════════════════════════════════════
 *
 * Alessandro, 2026-08-30, after climbing with the list version: *"wrap each requirement in a
 * card, passing one have green border and green hue as background, remove the OK, not passing
 * have red border, slightly red background hue and gently blink, remove the FIX, and center the
 * text."*
 *
 * ⛔ **THE WORD IS GONE, SO SOMETHING OTHER THAN HUE HAS TO CARRY IT.** `ui-rules.md` §1: a
 * colour is not a label — red against green is the one axis ~8% of men cannot separate, and the
 * blink is switched off by `prefers-reduced-motion`. ⭐ **So a met card RECEDES and an unmet card
 * is full-strength.** Brightness survives both, costs nothing, and is the hierarchy the screen
 * wants anyway: the unmet ones are the errand.
 */
.req {
  border: 1px solid transparent;
  border-radius: 2px;
  padding: 6px 10px;
  text-align: center;
  line-height: 1.6;
}
.req.is-ok {
  color: var(--dim);
  border-color: var(--good);
  /* ⚠️ A HUE, not a fill — the card sits on a panel over the stage and must not become a slab. */
  background: color-mix(in srgb, var(--good) 12%, transparent);
}
.req.is-missing {
  color: var(--fg);
  border-color: var(--bad);
  background: color-mix(in srgb, var(--bad) 14%, transparent);
  animation: req-pulse 1.8s ease-in-out infinite;
}
/*
 * ⚠️ **GENTLE, AND ON THE BACKGROUND ONLY.** A demand can fail three clauses at once, so three
 * of these blink together — anything sharp would be a strobe. The text never moves and never
 * fades, so the card stays readable at every point in the cycle.
 */
@keyframes req-pulse {
  0%, 100% { background: color-mix(in srgb, var(--bad) 14%, transparent); }
  50% { background: color-mix(in srgb, var(--bad) 30%, transparent); }
}
/* ⛔ NOT OPTIONAL — a repeating animation is exactly what this setting exists to stop. */
@media (prefers-reduced-motion: reduce) {
  .req.is-missing { animation: none; }
}
#floor-card.is-going { opacity: 0; }

#floor-card-number {
  font-size: calc(var(--type) * 2);
  color: var(--accent);
  letter-spacing: 0.18em;
}

/*
 * ⚠️ A ROW THAT MAY NOT FIT — floors 1 to 10 hold one to five guards, and above that always
 * five. `min-width: 0` on the items is what lets them shrink rather than overflow the stage
 * on a narrow phone in landscape.
 */
/*
 * ⛔ **FIVE MUST FIT, AND MEASURED THEY DO NOT AT FULL SIZE.**
 *
 * The widest drawn creature box is **160px** — biped, winged, quad and torso all reach it —
 * measured over 4,000 generated creatures on 2026-08-29. Five of those is **800px**, and a phone
 * in landscape is 667px on an iPhone SE. ⚠️ Alessandro found it as *"the card spaces too wide the
 * creatures, and when there are 3 they take already all the space, 4 and 5 are out of bounds."*
 *
 * ⭐ **SO THE ROW OWNS THE WIDTH AND THE CANVASES GIVE WAY** — `flex: 1 1 0` splits the card five
 * ways whatever is in it, `min-width: 0` is what actually lets a flex item shrink below its
 * content, and `max-width: 100%` on the canvas scales the drawing down to its share.
 *
 * ⚠️ **IT ONLY EVER SHRINKS.** `max-width` cannot upscale, so a wide screen shows the creatures at
 * their true pixel size and a narrow one shows them smaller — never a blown-up one. Same shape as
 * `.team-slot canvas`, which is the precedent he pointed at: *"we scale them down via CSS as we do
 * for other parts."*
 */
#floor-card-guards {
  /*
   * ⛔ **THE WIDEST DRAWN CREATURE, MEASURED — 160px.** Biped, winged, quad and torso all reach
   * it; measured over 4,000 generated creatures on 2026-08-29. ⚠️ Not a guess and not a round
   * number someone liked: change the art and this wants re-measuring, not nudging.
   */
  --guard: 160px;
  /*
   * ⛔ **THE SAME GAP AT EVERY RESOLUTION.** It was a `clamp()` on `vw`, which made the spacing
   * breathe with the screen and the creatures fight it for room. Alessandro: *"padding between
   * creatures is the same on all res, so do it properly."*
   */
  --guard-gap: 8px;

  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: var(--guard-gap);
  list-style: none;
  margin: 0;
  padding: 0;
  width: 100%;
  max-width: 100%;
}

/*
 * ⭐ **SCALING STOPS AT 832px, AND THAT NUMBER IS ARITHMETIC RATHER THAN TASTE:**
 * five guards at 160 plus four gaps at 8 is `5×160 + 4×8 = 832`. His: *"we stop scaling when the
 * modal is wider like 830px or more so we have some space between creatures; when less we scale
 * down… then we scale the art to make it fit the space."*
 *
 * ⚠️ **THE CAP IS WHAT MAKES IT STOP.** `flex: 1 1 0` alone would stretch each guard to a fifth
 * of any screen, so on a tablet the creatures would grow past their real pixel size and blur.
 * `max-width: var(--guard)` holds them at the size they are actually drawn, and the row simply
 * centres in whatever is left over.
 */
#floor-card-guards li {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  /* ⛔ All three are load-bearing: `flex` shares the row, `min-width: 0` is what actually lets a
     flex item shrink below its content, and `max-width` is what stops the growth at natural size. */
  flex: 1 1 0;
  min-width: 0;
  max-width: var(--guard);
}

/*
 * ⛔⛔ **THE ICON ROW MUST NEVER WRAP HERE — a regression from lit-only icons, 2026-09-20.** Every column used
 * to draw all five, so every column was the same height and the row's `align-items: flex-end` lined them up.
 * With only the lit ones, a four-icon archetype wraps inside the 160px guard column, that column grows, and
 * bottom alignment LIFTS its creature and its words above the others — his report, and production is fine.
 *
 * ⭐ 24px, so five icons and their gaps are 144 of 160: no wrap at any count, and the art keeps the room.
 */
#floor-card-guards .stat-icons {
  /*
   * ⛔ **ONE LINE OF HEIGHT, WHATEVER IT HOLDS — his instruction:** *"instead of keeping the creature level we
   * push it up; only the icons should go below the rest, so all is aligned."* The row is `align-items:
   * flex-end`, so any column that grows lifts its own creature and words above the others.
   * ⚠️ NOT `nowrap`: at 375px each guard column is about 65px, so icons wrap at any size — production only
   * looked right because every column drew all five and wrapped identically. Fixing the HEIGHT lets the
   * overflow fall below the card while every column stays the same height.
   */
  height: 24px;
  flex-wrap: wrap;
  align-content: flex-start;
  justify-content: center;
  overflow: visible;
  /*
   * ⛔ **LOAD-BEARING FOR THE ICON SIZING BELOW, AND IT MEASURED 0 WITHOUT IT.** `.stat-icons` is
   * `inline-flex` and the column is `align-items: center`, so the row is shrink-to-fit: its width
   * comes from its children. An icon sized as a `%` of that is circular, and the browser resolves
   * the circle to **zero** — every icon vanished, silently. This pins the row to the column, which
   * `flex: 1 1 0` has already made a definite width.
   */
  width: 100%;
}
/*
 * ⛔⛔ **THE ICON IS SIZED BY THE COLUMN, SO FIVE ALWAYS FIT ON ONE LINE — measured, 2026-09-20.**
 *
 * Alessandro asked the right question: *"did we measure if we find 2 or more paragon creature next
 * to each other in the floor screen if they fit? if by absurd coincidence we have 5 paragon
 * creature, do the icons fit or they overlap?"* ⭐ **Measured on the real card, five PARAGONs, and a
 * flat 24px does NOT fit** — the stage is landscape-only, so the number that matters is the stage's
 * width, not the phone's portrait one:
 *
 * | stage width | column | five 24px icons need | what happened |
 * |---|---|---|---|
 * | 568 (iPhone SE 1st gen, landscape) | 107px | 124px | wrapped, 25px of spill, **13px over the note** |
 * | 640 | 122px | 124px | wrapped — same |
 * | 653 | 124.2px | 124px | fits by 0.2px |
 * | 667 (iPhone SE 2/3) | 127px | 124px | fits by 3px |
 * | 844+ | 160px | 124px | comfortable |
 *
 * ⚠️ **SO IT WAS NOT A COINCIDENCE, IT WAS EVERY PARAGON ON A SMALL PHONE** — and the row's fixed
 * one-line height (above) meant the overflow landed ON `#floor-card-note` rather than pushing it.
 *
 * ⛔ **A MEDIA QUERY WOULD BE THE WRONG FIX** — it names widths, and the next phone is a width nobody
 * named. This derives the size from the room that actually exists: five icons and their four 1px
 * gaps, never larger than the 24px they are drawn at. ⭐ All five columns share a width, so every
 * guard on a screen gets the SAME icon size — it shrinks with the screen, never within a card.
 */
#floor-card-guards .stat-icon {
  width: min(24px, calc((100% - 4px) / 5));
  height: auto;
  aspect-ratio: 1;
}

#floor-card-guards canvas {
  image-rendering: pixelated;
  max-width: 100%;
  height: auto;
}

/*
 * ⭐ THE TIER UNDER EACH PORTRAIT, his layout. A floor can be mixed — 2D 3C — so one letter per
 * creature says more than a single band label, and the five letters together ARE the band.
 */
#floor-card-guards b {
  font-size: var(--type);
  color: var(--dim);
  letter-spacing: 0.1em;
  /*
   * ⚠️ `TIER D` IS TWO WORDS AND MUST NOT BECOME TWO LINES. A column that wraps is a column
   * taller than its four neighbours, and the five portraits stop sitting on one baseline.
   */
  white-space: nowrap;
}

/*
 * ⚠️ THE BRIEFING UNDER EACH PORTRAIT — element then archetype icons, and it must not widen the
 * column. Five guards already fill the card at 832px (see `--guard`), so these shrink with it
 * rather than pushing it.
 */
/* ⚠️ `LIGHTNING` is one word, so it cannot wrap — it would overflow instead. It fits: ~78px in
   a 160px column. If a longer element is ever added, this is where it will show. */
#floor-card-guards .mark-element { font-size: var(--type-small, 0.8em); letter-spacing: 0.08em; }
#floor-card-guards .stat-icons { display: flex; gap: 1px; justify-content: center; flex-wrap: wrap; }

#floor-card-note { font-size: var(--type); color: var(--dim); min-height: 1em; }

/*
 * ⭐ THE CALL TO ARMS — over the room, after the card has gone.
 *
 * ⚠️ NOT ON THE CARD. The point is that the room is already visible: the card lifts, the ten
 * creatures are standing there, and THEN the word lands. Putting it on the black card would just
 * be a longer card.
 */
#stage-callout {
  position: absolute;
  left: 50%;
  top: 42%;
  transform: translate(-50%, -50%);
  z-index: 4;
  font-size: calc(var(--type) * 2.2);
  color: var(--accent);
  letter-spacing: 0.2em;
  text-shadow: 0 2px 0 #000, 0 -2px 0 #000, 2px 0 0 #000, -2px 0 0 #000;
  pointer-events: none;
}

#stage-callout[hidden] { display: none; }

/*
 * ⭐ THE AUTO SWITCH SHOWS ITS POSITION — colour as well as words.
 *
 * ⚠️ Driven by `aria-pressed`, not by a class, per `ui-rules.md`: the state a screen reader is
 * told and the state a sighted player sees are then the same fact, and cannot drift apart.
 */
#stage-auto[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: #000;
  /*
   * ⚠️ BOLD, LIKE EVERY FILLED BUTTON. `.ghost` sets 400 for an outline; once ON it is filled, and a
   * thin filled label beside "Next floor" read as a different font (Alessandro, 2026-09-15, on the
   * countdown — the count only runs while ON, which is why that is where it showed).
   */
  font-weight: 700;
}

/*
 * THE GLOSSARY'S ARCHETYPE TABLE — thirty-one rows that only read as a table if they line up.
 *
 * ⭐ `max-content` ON THE FIRST COLUMN sizes it to the LONGEST archetype name, so every icon
 * column starts at the same x on every row — Alessandro's layout, and the reason the page can
 * be scanned rather than read.
 */
.glossary-archetypes {
  display: grid;
  grid-template-columns: max-content repeat(5, 1fr);
  align-items: center;
  gap: 4px 8px;
}
.glossary-archetypes b { font-size: var(--type); white-space: nowrap; }
/* ⚠️ Centred in its column, so an unlit pip holds the same place as a lit one. */
.glossary-archetypes .stat-icon { justify-self: center; }

/*
 * THE FIGHT REPORT — what each creature actually did.
 *
 * ⛔ A FIXED WIDTH, NOT A MAX-WIDTH — ui-rules.md §3. A <dialog> is shrink-to-fit, so a
 * max-width alone sizes it to its longest line and the box changes shape as its contents do.
 *
 * ⭐ WIDER THAN THE APP'S OTHER DIALOG ON PURPOSE. Alessandro, 2026-08-26: *"the report modal
 * can be bigger and cover more, since it's a thing the user decides to open."* Two columns of
 * five side by side is what landscape is for. The shell otherwise matches `.badge-modal`.
 */
.report-modal {
  /*
   * ⚠️ **60rem TO MATCH THE STOP CARD, 2026-08-31.** The stage's overlays are one family and a
   * player pages between them; three different widths read as three different screens. ⭐ And the
   * same trade applies: every line that stops wrapping is a line of height given back.
   */
  width: min(60rem, calc(100vw - 2rem));
  max-height: calc(100vh - 2rem);
  overflow-y: auto;
  border: 2px solid var(--accent);
  background: var(--bg);
  color: var(--fg);
  padding: 1rem;
}
.report-modal::backdrop { background: rgb(0 0 0 / 0.6); }
/*
 * ⚠️ THE DIALOG MUST SCROLL RATHER THAN OVERFLOW, and `.report-modal` already does — but the run
 * summary is opened over a FULLSCREEN stage, where `100vh` is the whole screen and the dialog is
 * the only thing on it. `100dvh` is what a phone's shrinking toolbar actually leaves.
 */
.report-modal { max-height: calc(100dvh - 2rem); overscroll-behavior: contain; }
.report-modal form { display: flex; justify-content: center; margin-top: 0.75rem; }

#report-title,
#run-title {
  display: block;
  text-align: center;
  font-size: var(--type);
  color: var(--accent);
  letter-spacing: 0.12em;
  margin-bottom: 12px;
}

/* One team at a time, so a side is simply a block that takes the whole dialog. */
.report-teams { min-width: 0; }
.report-side[hidden] { display: none; }

.report-pages { margin-bottom: 12px; }

/*
 * ⭐ THE ONE LINE THAT ANSWERS THE QUESTION. Reading five bars against five other bars is work;
 * "412 dealt · 380 taken" is not. It is also the only comparison that survives paging.
 */
/*
 * ⚠️ THE GAP ABOVE IS LOAD-BEARING, not spacing taste. This line sits under the two page
 * buttons, and when it hugged them it read as their column headers — see `fillReport`.
 */
.report-total {
  display: block;
  text-align: center;
  font-size: var(--type);
  color: var(--dim);
  margin: 16px 0 12px;
}
.report-rows { list-style: none; margin: 0; padding: 0; }
.report-row {
  display: grid;
  grid-template-columns: 36px 1fr;
  gap: 8px;
  align-items: center;
  margin-bottom: 8px;
}

/*
 * ⛔ 32px OF DRAWING IN A 36px BOX. `box-sizing` is border-box globally, so a 2px border eats
 * 4px — a 36px box holds a 32px canvas and a 32px box does not. ui-rules.md §4.
 */
.report-face {
  width: 36px;
  height: 36px;
  image-rendering: pixelated;
  border: 2px solid transparent;
}

.report-lines { min-width: 0; }
/*
 * ⛔ NEVER WRAPS, SO A ROW IS ALWAYS EXACTLY TWO LINES. `flex-wrap: wrap` was added to cure an
 * overflow and it bought a worse illness: a busy creature pushed its figures onto a second and
 * third line, so rows had different heights and the list read as ragged rather than as a
 * column you can scan down. Alessandro found it on an iPhone SE, where the vertical budget is
 * the scarce one.
 *
 * ⭐ THE NAME ABSORBS THE PRESSURE INSTEAD — it shrinks and ellipsises (see `.report-name`),
 * because a clipped name costs one creature's last few letters while a wrapped row costs the
 * shape of the whole list.
 */
.report-label {
  display: flex;
  flex-wrap: nowrap;
  justify-content: space-between;
  gap: 0 8px;
  font-size: var(--type);
}
/*
 * ⛔ `min-width: 0` IS WHAT STOPS THE HORIZONTAL SCROLL, and it is the classic flexbox trap.
 * A flex item will not shrink below its own content width without it, so on an iPhone SE the
 * name pushed the row wider than the dialog and the whole modal scrolled sideways. Alessandro
 * found it there: *"on small ones like iPhone SE we scroll both vertically and horizontally."*
 * Scrolling DOWN is fine and expected; sideways never is.
 */
.report-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/*
 * ⚠️ `flex: 0 0 auto` AND UNWRAPPED — the figures keep their full width and the NAME gives way,
 * which is the opposite of the earlier fix and deliberately so. It is safe from the sideways
 * scroll that bit twice only because the name beside it can now shrink to nothing.
 */
.report-marks { flex: 0 0 auto; color: var(--dim); white-space: nowrap; }
/*
 * ⛔ A FALLEN CREATURE IS SHOWN ON ITS FACE, NOT SPELLED OUT ON THE ROW. Every row in a lost
 * fight ended in "· fell", which is six characters of nothing repeated five times — and it was
 * the single longest thing pushing rows onto a third line.
 *
 * ⭐ A grey face among coloured ones reads instantly and costs NO width, which is the whole
 * point: this is the one piece of information that can leave the text line entirely.
 * ⏳ It becomes the ghost when Alessandro draws it — this is the placeholder for that drawing.
 */
.report-row[aria-disabled="true"] .report-name { color: var(--dim); }
.report-row[aria-disabled="true"] .report-face { filter: grayscale(1); opacity: 0.4; }

/*
 * ⛔ BOTH BARS ARE NORMALISED ACROSS ALL TEN CREATURES, not per team — that is the whole point
 * of the report. Per-team scaling makes each column fill its own width and hides the one fact
 * the player opened it for: that their best out-hit your best.
 *
 * ⚠️ The track is `--line` because it is the one neutral that is defined in BOTH themes. A
 * white-alpha track disappears entirely on the light one.
 */
.report-bar {
  display: flex;
  width: 100%;
  height: 10px;
  margin-top: 3px;
  background: var(--line);
  overflow: hidden;
}
.report-bar i { display: block; height: 100%; }

/*
 * ⛔ FOUR HUES THAT MUST SURVIVE SITTING FLUSH AGAINST EACH OTHER, which is a harder job than
 * four hues in a legend. Two failed rounds are recorded here so neither is tried again:
 *
 * 1. attack and skill as two tints of the same gold — the boundary between them was invisible,
 *    which defeats the whole point of splitting them.
 * 2. skill violet `#8b6fc9` beside shielding blue `#4a90b8` — 70° apart and still, in his
 *    words, *"super hard to distinguish."*
 *
 * ⭐ SO THEY ARE SEPARATED BY VALUE AS WELL AS HUE, not by hue alone: a bright cyan shield
 * against a deep violet skill differs in lightness even where the hues are read poorly, which
 * is also what makes them survive a colour-blind viewer and a phone in sunlight.
 *
 * ⚠️ Damage taken no longer has a colour, because it is no longer a bar — see `.report-marks`.
 */
.seg-attack { background: var(--accent); }
.seg-skill { background: #7b4fc7; }
.seg-heal { background: #4f9c55; }
.seg-shield { background: #46c8d4; }

/*
 * ⛔ A SWATCH NEVER WRAPS AWAY FROM ITS WORD. `white-space: nowrap` on the pair is the whole fix:
 * the legend still wraps, but only BETWEEN keys, never inside one. A stranded colour square is
 * precisely the unlabelled colour the legend was added to get rid of.
 */
.legend-key { white-space: nowrap; }

/*
 * ⭐ THE NEXT FLOOR'S GUARDS — one per line, so an archetype can show its NAME and its icons.
 * A single line could hold one or the other and the name alone is the half that means nothing
 * to a player who has not opened the glossary.
 */
.tower-guards { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.tower-guards li { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tower-guards b { min-width: 1.6em; color: var(--accent); }
.archetype-name { color: var(--dim); }

/*
 * ⭐ ONE RED, TWO PLACES — the team's total and each creature's figure, tied by colour so the
 * five rows visibly add up to the sentence. It is the only colour in the panel that appears
 * outside the bar, which is what lets it mean one thing unambiguously.
 */
.took { color: #c05a55; }
/*
 * ⚠️ SEPARATED BY BRIGHTNESS, NOT BY A SECOND HUE. `gave` appears twice too, so by the same
 * rule it wants marking — but every hue in this panel is already spoken for by a bar segment,
 * and gold in particular means ATTACK. Full-strength text against the dim footnotes around it
 * marks the figure without claiming a colour that already means something else.
 */
.gave { color: var(--fg); }

/*
 * THE LEGEND. Words first, swatches second — a colour cannot name itself, and the app's rule
 * everywhere else is that a word says what a glyph cannot (ui-rules.md §1).
 */
.report-legend {
  margin: 0 0 12px;
  text-align: center;
  color: var(--dim);
  font-size: calc(var(--type) * 0.8);
  line-height: 1.7;
}
.report-legend b { color: var(--fg); }
/* ⚠️ A swatch is decoration for a word that is already there, so it is hidden from the tree. */
.report-legend i {
  display: inline-block;
  width: 0.8em;
  height: 0.8em;
  margin-left: 6px;
  vertical-align: -0.05em;
}


/*
 * ═══ THE RUN SUMMARY ═════════════════════════════════════════════════════════════════════════
 *
 * ⭐ It BORROWS the fight report's shell — `.report-modal`, `.report-total`, `.report-rows` and
 * the bars — rather than owning a second set. `tower-screens.md` §1d asks for "the fight report
 * aggregated", so a summary that looked like a different screen would be describing the same
 * numbers in a second language.
 */

/*
 * ⛔ THE ARITHMETIC IS A LIST OF FACTS, NOT A PARAGRAPH. Four short numbers read as four lines
 * and do not read as a sentence — "climbed 12 floors from 127 to 138 in 6m 14s reaching a new
 * peak of 139" is one thing to parse where the list is four.
 */
/*
 * ⛔ **ONE WRAPPING ROW, NOT FOUR LINES.** The summary takes the whole width (see the markup),
 * and a fullscreen landscape stage is about 400px tall — four short facts stacked is four lines
 * spent on what fits in one. ⚠️ It still WRAPS, so a narrow phone gets two lines rather than an
 * overflow.
 */
.run-facts {
  list-style: none;
  margin: 0 0 4px;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 4px 16px;
  font-size: var(--type);
  line-height: 1.9;
}
/* ⚠️ A fact must not break mid-way — "Floor 121 → 130" split across lines reads as two facts. */
.run-facts li { color: var(--dim); white-space: nowrap; }
/* The numbers are what the eye lands on; the words around them are labels. */
.run-facts b { color: var(--fg); }

#run-gains { text-align: center; margin: 0 0 4px; }
@media (max-width: 40rem) {
  .report-modal { padding: 0.75rem; }
}
/*
 * ⚠️ NOWRAP AND A FLOOR ON THE WIDTH, and both are load-bearing. Alessandro: *"New team button,
 * put it all in 1 line — now we have New / team. And when you click it changes to 'Finding…' and
 * changes the size of the button and the box."*
 *
 * The wrap was the whole bug: the pixel font is monospace, and "New team" and "Finding…" are
 * both eight characters, so the label swap costs nothing — it was the two-line layout collapsing
 * to one that resized the button and the panel around it.
 *
 * The `min-width` is belt and braces: the ellipsis may fall back to a system glyph of another
 * width, and a result panel that twitches while it fetches is exactly what this is fixing.
 */
.result-actions button {
  width: auto;
  padding: 6px 14px;
  white-space: nowrap;
  min-width: 9ch;
}

/*
 * ⭐ **THE SAME CLASS, ADAPTED BY CONTEXT — not a second class and not per-button margins.**
 *
 * Alessandro, 2026-08-30: *"what's the point of CSS if we don't use classes properly? Do we have
 * to do it by hand for each button?"* ⚠️ `.result-actions` above is tuned for the small panel
 * FLOATING OVER THE STAGE, where a button must not grow to the screen's width. The tower's entry
 * screen is a full page, where the opposite is true — so the page states its own context once and
 * every button inside it follows, however many there end up being.
 */
#tower-entry .result-actions {
  margin-top: 18px;
  gap: 10px;
  min-width: 0;
}
#tower-entry .result-actions button {
  width: 100%;
  padding: 10px 14px;
}
/* ⚠️ A disabled primary must not still read as the thing to press. */
#tower-entry .result-actions button:disabled { opacity: 0.45; }
#tower-blocked { margin: 14px 0 0; }

/*
 * DELETING AN ACCOUNT — the one destructive action in the app, dressed as one.
 *
 * ⚠️ `linkish` rather than a button shape, and set apart at the bottom of the account modal:
 * Sign out and Switch user sit a thumb-width away and are both undoable. This is not.
 */
.linkish {
  width: auto;
  background: none;
  border: none;
  padding: 6px 2px;
  text-decoration: underline;
  font-size: var(--type);
}
.danger { color: var(--bad, #d9534f); }
button.primary.danger { background: var(--bad, #d9534f); color: #fff; border-color: var(--bad, #d9534f); }
#delete-confirm { width: 100%; margin: 4px 0 8px; }
/*
 * ⚠️ ONE CHOICE, TWO EQUAL HALVES. "Keep showing it" and "Hide it" are the same decision, and
 * `.settings-body` sizes buttons to their text — so the pair came out lopsided, which reads as
 * one option mattering more than the other. Alessandro: *"please make the buttons the same
 * size."*
 */
#delete-modal .settings-body button[data-delname] { flex: 1 1 0; }
#delete-modal .overlay-card { max-width: 26rem; }

/*
 * ⚠️ `pre-line` so a dialog can have a second paragraph. `ask()` sets `textContent`, so a blank
 * line in the message is a real line break only if the CSS honours it — otherwise the two halves
 * run together, which is how "this is not the delete button" ends up in the middle of a sentence.
 */
#ask-body { white-space: pre-line; }

/* --- the boards ----------------------------------------------------------
 *
 * ⭐ THE ONE THING THE WEBSITE'S BOARDS CANNOT DO IS SAY WHERE *YOU* STAND, so the viewer's own
 * row is the only styled thing here. Everything else is a list.
 */
.board { margin: 0 0 18px; }

.board-list {
  list-style: none;
  margin: 6px 0 0;
  padding: 0;
}

.board-row {
  display: grid;
  /* rank | name | number — the number right-aligned so the column reads as a column. */
  grid-template-columns: 3.2rem 1fr auto;
  align-items: center;
  gap: 0 0.6rem;
  padding: 5px 6px;
  font-size: var(--type);
}

.board-rank { color: var(--dim); }

/*
 * ⭐ GOLD, SILVER, BRONZE — his call, 2026-08-24: *"we could use colours for 1st, 2nd and 3rd so it
 * looks more like a proper board, classic gold, silver, bronze."*
 *
 * ⚠️ **TWO SETS, BECAUSE ONE CANNOT WORK.** Metallics are mid-lightness by nature, so a single
 * value is either washed out on the dark page or on the pale one. Measured against each background:
 * the dark set runs 8.9:1 / 10.3:1 / 6.0:1, the light set 4.8:1 / 4.7:1 / 6.2:1 — all clear 4.5:1.
 *
 * ⛔ **SILVER IS THE AWKWARD ONE.** True silver (#c0c0c0) is nearly white: brilliant on the dark
 * page and invisible on the light, where it has to become a warm grey to survive at all. It still
 * reads as second place because of what sits either side of it.
 *
 * ⚠️ The medal tints the RANK only, not the whole row — a coloured row would fight the tier and
 * element colours the same board already carries.
 */
.board-row[data-medal="1"] .board-rank { color: #d4af37; }
.board-row[data-medal="2"] .board-rank { color: #c0c0c0; }
.board-row[data-medal="3"] .board-rank { color: #cd7f32; }
:root[data-theme="light"] .board-row[data-medal="1"] .board-rank { color: #8a6d1f; }
:root[data-theme="light"] .board-row[data-medal="2"] .board-rank { color: #6f7378; }
:root[data-theme="light"] .board-row[data-medal="3"] .board-rank { color: #8a5320; }
/* ⚠️ A medal must not outrank "this is you" — the accent still wins on your own row. */
.board-row.is-me[data-medal] .board-rank { color: var(--accent); }

/* ⚠️ NEVER TRUNCATE A NAME — identity-and-leaderboards.md §3. It wraps instead. */
.board-name { overflow-wrap: anywhere; min-width: 0; }

.board-value { font-variant-numeric: tabular-nums; }

/*
 * ⚠️ A TAG IS DIMMER THAN A NAME, never hidden. `Anonymous04217` is an identity — it holds a
 * rank, it can take a title — but it is not somebody's chosen name, and the board reads better
 * when the two are told apart at a glance.
 */
.board-anon { color: var(--dim); }

/*
 * ⭐ THE TOMBSTONE AND THE WORN TITLE — his rule for a board row, 2026-08-24: tombstone for a
 * deleted player, title if they hold one, and the barcode when the name is private, all three
 * together and identical on the website.
 *
 * ⚠️ The title is held BACK from the name's weight on purpose: the name is who they are, the
 * title is something they are currently holding and can lose.
 */
/*
 * ⭐ A TOAST IS NOT ALL ONE COLOUR — his call, 2026-08-24: *"give the player and the title a
 * colour, so it's not all white sad text."* Two roles, two colours, and the prose between them
 * stays plain so the eye lands on the two things that matter: WHAT moved and WHO has it.
 *
 * ⚠️ The title takes `--accent`, the same gold the game already uses for the thing you are
 * chasing; a player takes the cooler tone, so a name never competes with a title for attention.
 */
/*
 * ⭐ THE TOAST WEARS THE TITLE'S FAMILY COLOUR — the same ladder the cards use, so "The Fire Lord"
 * is fire-orange whether you read it on the Titles page or watch it fly past in a toast. Without
 * this the two surfaces taught a player two different colours for one title.
 *
 * ⚠️ The default is the accent: the three specials belong to no family, and a toast with no title
 * in it never sets the attributes at all.
 */
.toast { --el-h: 45; --el-s: 30%; --el-l: 55%; }
.toast[data-tier="E"]   { --el-h: 0;   --el-s: 0%;  --el-l: 34%; }
.toast[data-tier="D"]   { --el-h: 0;   --el-s: 0%;  --el-l: 48%; }
.toast[data-tier="C"]   { --el-h: 48;  --el-s: 50%; --el-l: 40%; }
.toast[data-tier="B"]   { --el-h: 140; --el-s: 55%; --el-l: 45%; }
.toast[data-tier="A"]   { --el-h: 212; --el-s: 65%; --el-l: 52%; }
.toast[data-tier="S"]   { --el-h: 45;  --el-s: 70%; --el-l: 50%; }
.toast[data-tier="SS"]  { --el-h: 45;  --el-s: 85%; --el-l: 58%; }
.toast[data-tier="SSS"] { --el-h: 45;  --el-s: 98%; --el-l: 66%; }
.toast[data-element="earth"]     { --el-h: 35;  --el-s: 62%; --el-l: 47%; }
.toast[data-element="fire"]      { --el-h: 19;  --el-s: 75%; --el-l: 52%; }
.toast[data-element="air"]       { --el-h: 207; --el-s: 11%; --el-l: 67%; }
.toast[data-element="lightning"] { --el-h: 52;  --el-s: 56%; --el-l: 51%; }
.toast[data-element="void"]      { --el-h: 260; --el-s: 31%; --el-l: 48%; }
.toast[data-element="sacred"]    { --el-h: 42;  --el-s: 40%; --el-l: 65%; }
.toast[data-element="poison"]    { --el-h: 71;  --el-s: 64%; --el-l: 45%; }
.toast[data-element="water"]     { --el-h: 199; --el-s: 52%; --el-l: 47%; }

/*
 * ⚠️ FLOORED AT 55% LIGHTNESS ON THE DARK TOAST and clamped at 38% on the light one — the same
 * guard the cards need, and for the same reason: `air` and `sacred` are pale enough to vanish
 * against one, `SSS` bright enough to vanish against the other.
 */
.toast-title { color: hsl(var(--el-h) var(--el-s) max(var(--el-l), 55%)); font-weight: 600; }
:root[data-theme="light"] .toast-title { color: hsl(var(--el-h) var(--el-s) min(var(--el-l), 38%)); }
.toast-player { color: #7fb3d5; }
/*
 * ⭐ A PRIVATE PLAYER'S BARS, INLINE IN THE SENTENCE. Sized to the text around them the same way
 * the board rows do — 7/9 of an em is one capital's height in this font — so the name-shaped thing
 * in the sentence is name-height.
 */
.toast-barcode { display: inline-flex; align-items: baseline; }
.toast-barcode svg {
  fill: currentColor;
  color: #7fb3d5;
  height: calc(7em / 9);
  width: auto;
  max-width: 7rem;
  vertical-align: baseline;
  shape-rendering: crispEdges;
}
:root[data-theme="light"] .toast-barcode svg { color: #2f6f9f; }
:root[data-theme="light"] .toast-player { color: #2f6f9f; }

/*
 * ⚠️ THE SUMMARY IS SEVERAL SENTENCES NOW, not one. The counts come from the feed and each title
 * taken from you is its own line — §3: *"a title taken from you while you were away has to be said
 * in words, not folded into a number."* Without this they run together into one paragraph.
 *
 * ⛔ `pre-line`, not `pre`: the lines are built with `textContent` so a player's NAME in one of
 * them can never become markup, and `pre` would also preserve the source's own indentation.
 */
/*
 * ⭐ ONE LINE PER PIECE OF NEWS, EACH IN ITS TITLE'S COLOUR — his call: the summary reads better
 * coloured. Block display rather than `pre-line` newlines, because the lines are now ELEMENTS: a
 * title taken from you must be said in words, and those words carry the colour it wears elsewhere.
 */
#away-overlay-text { display: grid; gap: 4px; }
.away-line { display: block; --el-h: 45; --el-s: 30%; --el-l: 55%; }
.away-line[data-tier="E"]   { --el-h: 0;   --el-s: 0%;  --el-l: 34%; }
.away-line[data-tier="D"]   { --el-h: 0;   --el-s: 0%;  --el-l: 48%; }
.away-line[data-tier="C"]   { --el-h: 48;  --el-s: 50%; --el-l: 40%; }
.away-line[data-tier="B"]   { --el-h: 140; --el-s: 55%; --el-l: 45%; }
.away-line[data-tier="A"]   { --el-h: 212; --el-s: 65%; --el-l: 52%; }
.away-line[data-tier="S"]   { --el-h: 45;  --el-s: 70%; --el-l: 50%; }
.away-line[data-tier="SS"]  { --el-h: 45;  --el-s: 85%; --el-l: 58%; }
.away-line[data-tier="SSS"] { --el-h: 45;  --el-s: 98%; --el-l: 66%; }
.away-line[data-element="earth"]     { --el-h: 35;  --el-s: 62%; --el-l: 47%; }
.away-line[data-element="fire"]      { --el-h: 19;  --el-s: 75%; --el-l: 52%; }
.away-line[data-element="air"]       { --el-h: 207; --el-s: 11%; --el-l: 67%; }
.away-line[data-element="lightning"] { --el-h: 52;  --el-s: 56%; --el-l: 51%; }
.away-line[data-element="void"]      { --el-h: 260; --el-s: 31%; --el-l: 48%; }
.away-line[data-element="sacred"]    { --el-h: 42;  --el-s: 40%; --el-l: 65%; }
.away-line[data-element="poison"]    { --el-h: 71;  --el-s: 64%; --el-l: 45%; }
.away-line[data-element="water"]     { --el-h: 199; --el-s: 52%; --el-l: 47%; }
.away-title { color: hsl(var(--el-h) var(--el-s) max(var(--el-l), 58%)); font-weight: 600; }
.away-player { color: #7fb3d5; }
:root[data-theme="light"] .away-title { color: hsl(var(--el-h) var(--el-s) min(var(--el-l), 38%)); }
:root[data-theme="light"] .away-player { color: #2f6f9f; }

/*
 * THE WORLD SUMMARY on Boards — the same two facts the website's /stats opens with, so a player
 * gets the same picture without leaving the app.
 *
 * ⭐ EACH BAR WEARS ITS TIER'S COLOUR, using the ladder the collection already uses. The website's
 * version is all-accent; here the colour is free, because `data-tier` is already on the row.
 */
.world-summary { margin-bottom: 14px; }
.world-count { margin: 0 0 8px; color: var(--dim); }
/*
 * ⛔ **THE SECOND CENSUS NEEDS AIR, and without this it reads as one list with a stray line in
 * it.** Alessandro, 2026-09-14, looking at the Boards screen: *"see how the item title is
 * attached to the E tier bar of creatures."* ⚠️ The two blocks are deliberately separate
 * populations (`drawWorldSummary`), so the gap is not decoration — it is the thing that says
 * "a different count starts here".
 *
 * ⚠️ Sibling selector rather than a margin on every heading: the FIRST one must stay tight to
 * the top of the panel, and only a heading that follows a set of bars needs the space.
 */
.tierbars + .world-count { margin-top: 18px; }
.world-count b { color: var(--ink); font-variant-numeric: tabular-nums; }

.tierbars { display: grid; gap: 5px; }
.tierbar {
  display: grid;
  grid-template-columns: 2.6em 1fr 3.6em;
  align-items: center;
  gap: 8px;
  --tier-h: 0; --tier-s: 0%; --tier-l: 60%;
}
.tierbar[data-tier="E"]   { --tier-h: 0;   --tier-s: 0%;  --tier-l: 34%; }
.tierbar[data-tier="D"]   { --tier-h: 0;   --tier-s: 0%;  --tier-l: 48%; }
.tierbar[data-tier="C"]   { --tier-h: 48;  --tier-s: 50%; --tier-l: 40%; }
.tierbar[data-tier="B"]   { --tier-h: 140; --tier-s: 55%; --tier-l: 45%; }
.tierbar[data-tier="A"]   { --tier-h: 212; --tier-s: 65%; --tier-l: 52%; }
.tierbar[data-tier="S"]   { --tier-h: 45;  --tier-s: 70%; --tier-l: 50%; }
.tierbar[data-tier="SS"]  { --tier-h: 45;  --tier-s: 85%; --tier-l: 58%; }
.tierbar[data-tier="SSS"] { --tier-h: 45;  --tier-s: 98%; --tier-l: 66%; }

.tierbar .tier-name { color: hsl(var(--tier-h) var(--tier-s) max(var(--tier-l), 55%)); letter-spacing: 0.06em; }
.tierbar .track { background: var(--panel); border: 1px solid var(--line); height: 12px; }
.tierbar .fill { background: hsl(var(--tier-h) var(--tier-s) var(--tier-l)); height: 100%; }
.tierbar .tier-n { color: var(--dim); text-align: right; font-variant-numeric: tabular-nums; }
:root[data-theme="light"] .tierbar .tier-name { color: hsl(var(--tier-h) var(--tier-s) min(var(--tier-l), 38%)); }

.board-tomb { font-style: normal; }

/*
 * THE TITLES PANEL — nineteen standings with their copy, the fourth board.
 *
 * ⛔ A LIST OF EXPLANATIONS, NOT A RANK TABLE, so it does not borrow the board's columns: the
 * name leads, the joke sits under it, and the holder is the quiet part. A title nobody holds is
 * dimmed rather than hidden — an empty pedestal advertises itself.
 */
/*
 * ⛔ STACKED AND CENTRED, HIS CALL 2026-08-24: *"move the title holder below the description and
 * not next to it, so we have TITLE / DESCRIPTION / HOLDER, and centre all the text."* The side-by
 * -side version fought for width — a name, a joke and a barcode on one line left nothing room.
 *
 * ⏳ The badge goes NEXT TO THE TITLE once it is drawn — his words — which is why the title line
 * is a flex row rather than plain text: an icon drops in beside it without moving anything.
 */
/*
 * ⛔ A CARD EACH, AND EACH WEARS ITS FAMILY'S COLOUR — 2026-08-24. He asked for *"a colour for the
 * title, the letter for the tier in the tier's colour, same for elements"*, plus a background so
 * the colour survives BOTH themes: *"not like we did in other parts that are now indistinguishable
 * on the light one."*
 *
 * ⭐ **THE SAME FORMULA `.row-main` USES IN COLLECT, DELIBERATELY.** Dark: a dark card with a
 * border in the family's colour. Light: a pale tint of that same hue, with the border LIGHTNESS
 * CLAMPED — several of these colours are bright enough to vanish on a pale page, which is exactly
 * the failure he is pointing at. One formula, two families, no second scheme to drift.
 */
.titles-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }

/* Default, and what the three specials wear: the accent, since they belong to no family. */
.titles-list li {
  --el-h: 45; --el-s: 30%; --el-l: 55%;
  display: block;
  text-align: center;
  padding: 12px 10px;
  border: 1px solid hsl(var(--el-h) var(--el-s) var(--el-l));
  border-radius: 6px;
  background: hsl(var(--el-h) calc(var(--el-s) * 0.30) 11%);
}

/* THE TIERS — the same ladder the collection uses, so a letter means one thing everywhere. */
.titles-list li[data-tier="E"]   { --el-h: 0;   --el-s: 0%;  --el-l: 34%; }
.titles-list li[data-tier="D"]   { --el-h: 0;   --el-s: 0%;  --el-l: 48%; }
.titles-list li[data-tier="C"]   { --el-h: 48;  --el-s: 50%; --el-l: 40%; }
.titles-list li[data-tier="B"]   { --el-h: 140; --el-s: 55%; --el-l: 45%; }
.titles-list li[data-tier="A"]   { --el-h: 212; --el-s: 65%; --el-l: 52%; }
.titles-list li[data-tier="S"]   { --el-h: 45;  --el-s: 70%; --el-l: 50%; }
.titles-list li[data-tier="SS"]  { --el-h: 45;  --el-s: 85%; --el-l: 58%; }
.titles-list li[data-tier="SSS"] { --el-h: 45;  --el-s: 98%; --el-l: 66%; }

/* THE ELEMENTS — each element's own tone, taken from `art-data.ts` step 3, converted to HSL. */
.titles-list li[data-element="earth"]     { --el-h: 35;  --el-s: 62%; --el-l: 47%; }
.titles-list li[data-element="fire"]      { --el-h: 19;  --el-s: 75%; --el-l: 52%; }
.titles-list li[data-element="air"]       { --el-h: 207; --el-s: 11%; --el-l: 67%; }
.titles-list li[data-element="lightning"] { --el-h: 52;  --el-s: 56%; --el-l: 51%; }
.titles-list li[data-element="void"]      { --el-h: 260; --el-s: 31%; --el-l: 48%; }
.titles-list li[data-element="sacred"]    { --el-h: 42;  --el-s: 40%; --el-l: 65%; }
.titles-list li[data-element="poison"]    { --el-h: 71;  --el-s: 64%; --el-l: 45%; }
.titles-list li[data-element="water"]     { --el-h: 199; --el-s: 52%; --el-l: 47%; }

/*
 * ⛔ LIGHT THEME: a pale tint of the same hue, border clamped to 45%.
 * ⚠️ THE CLAMP IS LOAD-BEARING — `air` sits at 67% and `sacred` at 65%, both invisible on a pale
 * page without it. This is the block whose absence he described elsewhere as "indistinguishable".
 */
:root[data-theme="light"] .titles-list li {
  border-color: hsl(var(--el-h) var(--el-s) min(var(--el-l), 45%));
  background: hsl(var(--el-h) calc(var(--el-s) * 0.35) 95%);
}

.titles-name {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  /* ⭐ The title itself in its family's colour — clamped light so it reads on the dark card. */
  color: hsl(var(--el-h) var(--el-s) max(var(--el-l), 55%));
}
:root[data-theme="light"] .titles-name { color: hsl(var(--el-h) var(--el-s) min(var(--el-l), 38%)); }

/* ⛔ The RULE, above the joke — what to do, not only why it is funny. */
.titles-how { display: block; font-size: 0.85em; margin-top: 3px; }
/* The S in "Most S creatures", the Fire in "Most Fire creatures". */
.titles-token { color: hsl(var(--el-h) var(--el-s) max(var(--el-l), 55%)); font-weight: 600; }
:root[data-theme="light"] .titles-token { color: hsl(var(--el-h) var(--el-s) min(var(--el-l), 38%)); }

.titles-desc { display: block; font-size: 0.8em; color: var(--dim); margin-top: 2px; font-style: italic; }
.titles-who { display: block; margin-top: 4px; }
.titles-value { color: var(--dim); }
/* ⭐ An unclaimed title is dimmed and dashed — the empty pedestal advertises itself. */
.titles-open { opacity: 0.6; border-style: dashed; }
.titles-open .titles-who { font-style: italic; }
.board-gone { opacity: 0.75; }
.board-title {
  font-size: 0.8em;
  color: var(--dim);
  white-space: nowrap;
}

/*
 * ⚠️ `currentColor` so the bars dim with the row — they ARE the name, not an icon beside it, so
 * `.board-anon` above colours them exactly as it would colour the word they replace.
 *
 * ⭐ SEVEN NINTHS OF AN EM, ON THE BASELINE. The UI font is a 5×9 pixel grid with its baseline at
 * row 7, so a capital letter is seven rows tall; matching that makes the bars exactly as tall as
 * a name, at any text size. `crispEdges` keeps them hard-edged when that lands on a fraction of a
 * device pixel — a soft barcode looks like a rendering fault. Same rules as `site.css`.
 *
 * ⚠️ `max-width` because THIS ONE IS IN A PHONE-WIDTH GRID COLUMN, unlike the site's. A tag is
 * thirteen digits wide and the row is `3.2rem | 1fr | auto`; without it the bars push the number
 * off a narrow screen rather than scaling down.
 */
/*
 * ⛔ UNSCOPED ON PURPOSE, 2026-08-24. This rule used to be `.board-name .barcode-name`, so the
 * SAME SVG rendered correctly on a board row and as INVISIBLE BLACK anywhere else — an SVG with
 * no fill defaults to black, which on this theme is the background. It shipped that way in the
 * titles panel. Alessandro: *"we should use the same style for barcode as we use in the leader,
 * we need to be consistent."*
 *
 * ⚠️ A barcode is a barcode wherever it appears. Scoping bought nothing and cost a bug.
 */
.barcode-name {
  fill: currentColor;
  height: calc(7em / 9);
  width: auto;
  max-width: 100%;
  vertical-align: baseline;
  shape-rendering: crispEdges;
}

/*
 * ⛔ YOUR ROW IS MARKED BY A BAR AND A BACKGROUND, not by colour alone. The board is a list of
 * near-identical lines and "which one is me" is the question the whole view exists to answer.
 */
.board-row.is-me {
  /*
   * ⚠️ TWO BACKGROUNDS, and the first is not dead code. `hsl(from …)` is relative-colour
   * syntax; anything that does not understand it drops the whole declaration, so the flat wash
   * above is what that browser gets. The bar and the text weight carry the meaning either way —
   * the tint is the least load-bearing part of this on purpose.
   */
  background: var(--panel);
  background: hsl(from var(--accent) h s l / 0.14);
  box-shadow: inset 3px 0 0 var(--accent);
  color: var(--fg);
}

.board-row.is-me .board-rank { color: var(--accent); }

/*
 * ⚠️ The elision is a ROW, not a border, so the list keeps one rhythm. Centred, because it
 * stands for a span of the board rather than for any particular entry in it.
 */
.board-gap {
  padding: 2px 6px 6px;
  text-align: center;
  color: var(--dim);
  letter-spacing: 0.3em;
}

/*
 * ⛔ ONE BOARD AT A TIME. Alessandro: *"so we don't have a humongous page but we can navigate
 * them left and right."* Three boards stacked is a page nobody reaches the bottom of.
 */
.board-pager {
  display: grid;
  /* The arrows are fixed and the title takes the rest, so it does not shuffle between boards. */
  grid-template-columns: 2.4rem 1fr 2.4rem;
  align-items: center;
  gap: 0.4rem;
  margin: 10px 0 4px;
}

.board-pager h3 { margin: 0; text-align: center; }

.board-pager button {
  font: inherit;
  font-size: var(--type-big);
  line-height: 1;
  padding: 2px 0;
  color: var(--fg);
  background: var(--panel);
  border: 1px solid var(--line);
  cursor: pointer;
}

.board-pager button:hover { border-color: var(--accent); color: var(--accent); }

/* --- global goals --------------------------------------------------------
 *
 * ⭐ THE COPY IS THE FEATURE, so it gets the room a paragraph needs rather than being squeezed
 * beside a number. "The earth is rumbling" is a reason to scan a tin; "coming soon" is an
 * apology, and the difference is entirely in how much it looks like it means it.
 */
.goal {
  margin: 0 0 20px;
  padding: 10px 12px;
  border: 1px solid var(--line);
  background: var(--panel);
}

/* ⚠️ The NEAREST unmet goal wears the accent — it is the one a scan today actually moves. */
.goal.is-next { border-color: var(--accent); }

.goal-copy {
  margin: 4px 0 10px;
  font-size: var(--type);
  /* Not `--dim`: this is the voice of the game, not a footnote about it. */
  color: var(--fg);
}

/*
 * ⚠️ **A WORD WHERE A NUMBER WOULD GO** — the same rule and the same three lines as
 * `site.css`. It carries the accent because it is the only nearness signal left on the card
 * now that the counter and the bar are gone.
 */
.goal-state {
  margin: 0;
  font-size: var(--type);
  color: var(--accent);
}

/*
 * ⏸ **UNUSED SINCE 2026-08-22 AND DELIBERATELY KEPT.** The progress bar came off with the
 * counters — a track at 68% makes the same promise the number does — and `drawGoals` no longer
 * builds one. Restoring both is re-adding one branch, so the styling stays here rather than
 * being reconstructed from memory by whoever does it.
 */
.goal-bar {
  height: 10px;
  background: var(--bg);
  border: 1px solid var(--line);
}

.goal-bar i {
  display: block;
  height: 100%;
  background: var(--accent);
}

/* ---------------------------------------------------------------- the toast */

/*
 * ⛔ FROM ABOVE, which is his word for it: *"a toast message coming from above the screen."*
 * It sits over everything and belongs to no view — `notifications.md` calls it a CHANNEL.
 *
 * ⚠️ IT MUST CLEAR THE TOPBAR rather than land on it, so it starts below the safe area and the
 * account pill instead of covering the thing a player might be reaching for.
 */
.toast {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 8px);
  /*
   * ⚠️ **KEPT FOR THE FALLBACK, AND IT IS NOT WHAT WINS.** Where `popover` is supported the toast
   * is in the browser's TOP LAYER (see the markup), which no z-index can reach or be beaten by;
   * this number only matters on a browser too old for it.
   */
  z-index: 60;
  /*
   * ⛔ **NEUTRALISING THE POPOVER UA STYLES — every line here undoes one of them.** A `[popover]`
   * is given `inset: 0`, `margin: auto` and `width/height: fit-content`, which between them would
   * stretch this bar to the full height of the screen and then centre it. ⚠️ The authored
   * `top`/`left`/`right` above already win; `bottom` is the one that would otherwise survive.
   */
  bottom: auto;
  margin: 0;
  width: auto;
  height: auto;
  overflow: visible;
  /*
   * ⛔ **FULL WIDTH BAR SIX PIXELS EACH SIDE** — his call: *"as wide as the screen minus like
   * 5-10px from the edges."* A centred pill sized to its text made every notice a different
   * width, so a queue of them jittered; a bar of one width reads as one channel.
   *
   * ⚠️ `left`/`right` rather than a width, so it also respects a notch: the safe-area insets
   * push it in on a phone that has one and are zero on everything else.
   */
  left: calc(env(safe-area-inset-left, 0px) + 6px);
  right: calc(env(safe-area-inset-right, 0px) + 6px);
  padding: 10px 12px;
  border: 1px solid var(--accent);
  background: var(--panel);
  color: var(--fg);
  font-size: var(--type);
  /*
   * ⭐ THE BADGE SITS BESIDE THE WORDS, the same tile the Awards page draws. Centred as a pair
   * so a notice with no badge — a stranger's find — still reads as the same bar rather than as
   * text that slid left.
   */
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  text-align: left;
  /*
   * ⚠️ TRANSFORM AND OPACITY ONLY — both compositor properties, so the slide keeps playing even
   * while the main thread is busy drawing a creature. Animating `top` would stutter against
   * exactly the work a toast most often lands beside.
   */
  /* ⚠️ Y ONLY — both edges are pinned now, so there is no horizontal centring to preserve. */
  transform: translateY(-120%);
  opacity: 0;
  transition: transform 260ms ease-out, opacity 200ms ease-out;
  pointer-events: none;
}

.toast.is-in {
  transform: translateY(0);
  opacity: 1;
}

/* ⚠️ The badge must not be squeezed by a long sentence — it is a fixed 40px tile either way. */
.toast .badge-art { flex: 0 0 auto; }
.toast-text { min-width: 0; }

/*
 * ⭐ YOUR OWN NEWS WEARS THE ACCENT BORDER; a stranger's is quieter. `notifications.md` §4b
 * ranks yours above theirs in the QUEUE — this is the same ranking made visible, so a player
 * can tell at a glance whether a toast is about them.
 */
.toast.is-theirs {
  border-color: var(--line);
  color: var(--dim);
}

/* ⚠️ Motion is a preference, and a toast is exactly the kind of thing that triggers it. */
@media (prefers-reduced-motion: reduce) {
  .toast { transition: opacity 200ms ease-out; transform: translateY(0); }
}

/*
 * ⚠️ FOUR SWITCHES STACK RATHER THAN SIT IN A ROW. Their labels are sentences, not words —
 * "somebody finds an S or better" — and four of those across a phone would each wrap to three
 * lines and lose the shape the row is for.
 */
.settings-stack {
  display: grid;
  gap: 6px;
}

.settings-stack button {
  text-align: left;
}



/*
 * THE FALLING BACKGROUND — his animated backdrop, `src/lib/falling.ts`.
 *
 * ⛔ **z-index 0 AND NOTHING ELSE IN THE APP SITS BELOW IT.** Every other layer in this file is
 * 3 or higher (the stage HUD at 3, the tab bar at 20, the intro at 70), so a background at 0 is
 * behind all of them by construction rather than by luck.
 *
 * ⛔ **`pointer-events: none` IS LOAD-BEARING.** It is a full-screen fixed canvas: without this
 * it would swallow every tap on the app underneath it, which is the one way a decoration can
 * break a game.
 */
#falling {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
  display: block;
}

/* Hidden until the setting turns it on — the default is OFF, so nothing moves unasked. */
body:not(.falling-on) #falling { display: none; }

/*
 * ⛔ **NEVER DURING A FIGHT.** `tower-stage.md`: *"during a fight the only thing moving at
 * creature height should be a creature."* The class is set by `showView`, so this needs no
 * co-operation from the animation itself.
 */
body.is-battle #falling { display: none; }


/*
 * ⛔ **WHAT THE FALLING BACKGROUND BROKE, FIXED IN ONE PLACE.**
 *
 * Alessandro, 2026-09-02: *"the creature card is perfect, hides the object, but the new prev and
 * next buttons are see-through and it's a mess... I don't really wanna waste time hunting all
 * components and fix them."*
 *
 * ⭐ **HE DID NOT HAVE TO.** Every offender declares `background: transparent`, and there are
 * THIRTEEN of them sharing that one line — `.ghost` alone is 55 buttons. Anything that already
 * paints a panel (the creature card, the goal cards) was fine all along, which is exactly what he
 * observed.
 *
 * ⚠️ **SCOPED TO `body.falling-on`, SO IT CHANGES NOTHING WHEN THE BACKGROUND IS OFF.** A
 * transparent control on a flat page is a deliberate look and this must not quietly replace it —
 * the rule exists only for the case that broke.
 *
 * ⚠️ `--panel` rather than a new colour: it is what the components that READ WELL already use, so
 * this makes the transparent ones agree with them rather than inventing a third surface.
 */
/*
 * ⛔ **NOT DURING A FIGHT, AND THAT IS A CONTRAST BUG NOT A PREFERENCE.**
 *
 * `body.is-battle #falling { display: none }` already hides the canvas during a fight — but the
 * CLASS stays on the body, so these background overrides kept applying to controls that no longer
 * had anything moving behind them. On the speed control that was catastrophic:
 * `#stage-speed button.is-chosen` sets `--accent` with `--bg` text, and
 * `body.falling-on #stage-speed button` is MORE SPECIFIC (two elements against one), so it won and
 * repainted the chosen button `--panel` while the text stayed `--bg`.
 *
 * **Measured: 1.04:1 in dark and 1.10:1 in light — invisible in both.** Alessandro found it in
 * dark: *"the selected square is all black and you can't read anything."*
 *
 * ⭐ **SO THE WHOLE BLOCK IS SCOPED OUT OF BATTLE**, which is correct beyond this one control:
 * every rule here exists to stop gear painting through a transparent button, and no gear paints
 * during a fight.
 */
body:not(.is-battle).falling-on .ghost,
body:not(.is-battle).falling-on .size,
body:not(.is-battle).falling-on .who,
body:not(.is-battle).falling-on .burger,
body:not(.is-battle).falling-on .menu button,
body:not(.is-battle).falling-on .tabs button,
body:not(.is-battle).falling-on .settings-head,
body:not(.is-battle).falling-on .pill,
body:not(.is-battle).falling-on .segments button,
body:not(.is-battle).falling-on .hub-button,
body:not(.is-battle).falling-on #spar-level .hub-button,
body:not(.is-battle).falling-on #stage-speed button {
  background: var(--panel);
}

/*
 * ⛔ **AND THE TEXT THAT SITS ON NOTHING AT ALL.** A note or a heading has no box to fill, so it
 * cannot take a panel — what it needs is for the moving thing behind it to stop competing.
 * `--panel` at 82% keeps the page's own colour while letting the background through as texture.
 */
/*
 * ⛔ **THE SAME FIX FOR THE WELCOME SCREEN, WHICH IT NEVER REACHED.** Alessandro, 2026-09-03:
 * *"in the welcome page we still have the items falling down that covers the button, I think we
 * didn't fix all that part like we did in the app."*
 *
 * ⭐ **HE IS RIGHT AND THE REASON IS STRUCTURAL: `#gate` IS A SIBLING OF `#app`, NOT A CHILD.**
 * The rule below raises `#app` out of the canvas's way; the gate sits outside it, so it kept the
 * original bug in the one screen every new player sees first.
 *
 * ⚠️ **AUDITED RATHER THAN PATCHED.** The top level holds only four things — the canvas, `#gate`,
 * `#intro` and a `dialog`. `#intro` is `position: fixed` and a dialog lives on the browser's top
 * layer, so both were already immune; the overlays inside are fixed too. **`#gate` was the only
 * one left**, which is why this is the last time this bug can appear here.
 */
body.falling-on #gate {
  position: relative;
  z-index: 1;
  /*
   * ⚠️ The wash is needed here for the same reason as on `#app`: the card paints NO surface of its
   * own, so its headings and notes sit directly on whatever is moving behind them.
   */
  background: color-mix(in srgb, var(--bg) 82%, transparent);
}

body.falling-on #app {
  /*
   * ⛔ **`position` AND `z-index` ARE THE ACTUAL FIX, AND THE BACKGROUND ALONE WAS NOT.**
   *
   * `#app` is a plain in-flow block, and a POSITIONED element — the canvas, `position: fixed` —
   * paints ABOVE non-positioned content no matter how low its z-index is. So gear was drawn over
   * the sort buttons and through the headings, and it looked like the buttons were transparent
   * when the truth was the opposite: they were solid and the sprites were on top of them.
   *
   * ⚠️ Raising `#app` into the same stacking order is what a z-index of 0 on the canvas was
   * supposed to mean. Everything else in this file is 3 or higher and unaffected.
   */
  position: relative;
  z-index: 1;
  background: color-mix(in srgb, var(--bg) 82%, transparent);
}


/* --- the Keeper page -----------------------------------------------------
 *
 * `decisions.md` §0f. The figure is 64×96 and shown at WHOLE-NUMBER scales only, so its box is
 * sized in JS to the largest multiple that fits — never stretched. Everything here positions
 * against that box.
 */
.keeper-ident { display:flex; gap:10px; align-items:center; margin-bottom:12px; }

/*
 * ⛔ 64px IS 2× OF THE 32px SOURCE. A 44px avatar was drawn in the first mockup and is illegal —
 * a fractional scale puts one source pixel on a fraction of a screen pixel.
 * ⚠️ It is the SAME square the battle stage shows, so whatever goes in it must read at this size.
 */
.keeper-avatar {
  width: 64px;
  height: 64px;
  flex: 0 0 auto;
  border: 1px solid var(--accent);
  background: var(--panel);
  image-rendering: pixelated;
}

/*
 * ⛔ **THE TEXT IS CENTRED IN THE AVATAR'S HEIGHT, NOT ALIGNED TO ITS TOP.** Two lines beside a
 * 64px square sit high and read as unaligned; `justify-content: center` in a column is what makes
 * them share the square's centre line.
 */
.keeper-who {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 6px;
  /* ⚠️ Matches the avatar exactly, so the centring has something real to centre against. */
  min-height: 64px;
}
.keeper-name { font-weight: 600; font-size: var(--type-big); }
/* ⚠️ Below the row, not inside it — an explanation, not part of who you are. */
.keeper-xp-note { margin-top: 6px; }

.keeper-xp {
  height: 7px;
  background: var(--panel);
  border: 1px solid var(--line);
  overflow: hidden;
}
.keeper-xp span { display: block; height: 100%; background: var(--accent); width: 0; }

.keeper-title { width: 100%; display: flex; justify-content: space-between; margin-bottom: 14px; }
.keeper-title em { font-style: normal; color: var(--accent); }

.keeper-doll { display: grid; gap: 8px; justify-items: center; margin-bottom: 18px; }

/*
 * ⭐ THE SAME SHAPE AS THE TITLE BUTTON, deliberately — one gesture learnt once. Both are "a thing
 * you have, and a chevron that opens the detail", and they sit on the same page.
 */
.keeper-gear-summary { width: 100%; display: flex; justify-content: space-between; margin-bottom: 18px; }
.keeper-gear-summary em { font-style: normal; color: var(--accent); }

/*
 * ⛔ A LIST, NOT A TABLE.
 *
 * ⚠️ `grid-template-columns: 1fr auto` is what keeps a long label ("JUGGERNAUT creatures only")
 * from squeezing its number onto a second line, which is the failure the item bench had when it
 * was built as two columns of equal width: "everything is squeezed there, I can't read much".
 *
 * ⛔⛔ **THE COLUMN GAP IS ZERO, AND THAT IS WHAT MAKES THE SEPARATOR A LINE.** It was 12px, and a
 * `border-top` on two grid cells with a gap between them draws two SEGMENTS with a hole in the
 * middle — a broken line reads as a rendering fault, not a divider. ⚠️ The space is now the `dd`'s
 * own left padding, which lives INSIDE the bordered box and so is crossed by the line.
 */
.gear-summary {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 6px 0;
  margin: 0 0 14px;
  text-align: left;
}

/*
 * ⭐⭐ **THE SCOPE IS A CAPTION ACROSS BOTH COLUMNS** — `archetypes.md` §9a. A qualifier used to sit in the
 * LABEL column and wrapped to three lines beside its number at 375px; it now owns a line and the value rows
 * below it keep the ordinary two columns, so no two row styles ever mix.
 *
 * ⛔ `1 / -1` — a grid has no row element, so a full-width cell is the only way to caption a group.
 */
.gear-scope {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  color: var(--accent);
  border-top: 1px solid var(--line);
  padding-top: 8px;
  margin-top: 2px;
}
.gear-scope b { font-weight: 700; }

/* ⚠️ One line per scope in the equip picker — see the note in `openEquip`. */
.equip-does-row { display: block; }
/*
 * ⭐ THE ARCHETYPE'S NAME ON THE BUILDER'S CARD — `archetypes.md` §6-b. It sits with the icons rather than on
 * a line of its own, so every card in the list stays exactly as tall as the others (his rule).
 */
.mark-arch { color: var(--dim); letter-spacing: 0.04em; margin-right: 6px; }

/* ⚠️ The line nobody satisfies, in the mark's own red — see the note in `openEquip`. */
.equip-does-row.is-idle { color: var(--bad); }

/*
 * ⭐⭐ **AN ITEM THAT DOES NOTHING IS DIMMED AND CARRIES A `!`** — his ruling, `archetypes.md` §9b:
 * RED when no roll lands, YELLOW when only some do.
 *
 * ⛔ **THE COLOUR IS NOT ALONE, AND THAT WAS THE POINT OF THE `!`.** Red is also an element in this game —
 * his answer: *"if it's on an exclamation mark it's clear it has nothing to do with the element."*
 *
 * ⭐ **THE PULSE IS `req-pulse`, THE ONE THE TEAM BUILDER ALREADY USES** for a missing requirement: gentle,
 * 1.8s, on a background only, because several can blink at once. ⚠️ His instruction was to try it and look:
 * *"let's try once then let's see, maybe just being red is enough."*
 */
/*
 * ⛔⛔ **ABSOLUTE ONLY INSIDE A HOLDER WE KNOW IS POSITIONED — 2026-09-20, and it shipped to staging wrong.**
 * `.keeper-slot` is `position: absolute` (it is placed on the figure) but `.keeper-pocket` is a plain grid
 * cell, so a mark on a POCKET item found no positioned ancestor, stretched to the whole page and drew a
 * page-high `!` over a blacked-out Keeper — his screenshot, within a minute of the build landing.
 *
 * ⚠️ Scoping the rule to the three holders is what makes that impossible rather than unlikely: a mark
 * appended anywhere else stays in the flow, where it is wrong but harmless and visibly local.
 */
.keeper-slot > span > .gear-bang,
.keeper-pocket > span > .gear-bang,
.hud-item > .gear-bang {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  /* ⚠️ 34px, from 20 — his call, twice: at 20 it read as punctuation rather than a warning. */
  font-size: 34px;
  line-height: 1;
  text-shadow: 0 1px 0 #000, 0 -1px 0 #000, 1px 0 0 #000, -1px 0 0 #000;
  pointer-events: none;
  /*
   * ⛔ **NO SCRIM — his correction, 2026-09-20:** *"the overlay is covering the borders, can't we just dim
   * down the slot itself without adding a new layer?"* A filled box over the slot ate its border and read as
   * a second element. The DRAWING is dimmed instead (below) and the glyph sits on it, which is one layer
   * fewer and leaves the slot's own frame intact.
   */
  /*
   * ⛔⛔ **THE GLYPH IS NOT CENTRED INSIDE ITS OWN ADVANCE WIDTH** — his eye, 2026-09-20: *"the ! is a tiny
   * bit to the left."* Measured at 20px in ScanlingsPixel: the advance is 13.33px and the ink runs 4.13 →
   * 6.98, so the ink's centre sits 1.11px LEFT of the box centre that `justify-content` aligns. Flex centres
   * the BOX; the eye reads the INK.
   *
   * ⚠️ In `em`, so it survives a font-size change: 1.11 / 20 = 0.056em.
   */
  /*
   * ⛔ **AND DOWN 0.124em, for the same reason in the other axis** — his eye again, 2026-09-20: *"now it's
   * too high."* The font's box is ascent 26 / descent 8 at 34px, but the '!' glyph has almost no descender,
   * so its ink sits 4.22px above the centre of the box the flex layout aligns.
   */
  transform: translate(0.056em, 0.124em);
}
.gear-bang[data-mood="idle"] { color: var(--bad); }
/* ⚠️ The warning amber, not the accent gold: this is a caution, and the accent already means "yours". */
.gear-bang[data-mood="partial"] { color: #e8b73f; }

/*
 * ⛔ The `!` is positioned against its holder. ⚠️ `.keeper-slot` is ALREADY `position: absolute` — it is
 * placed on the figure — so it is a containing block already, and declaring `relative` here would fight the
 * layout for nothing. Only the HUD wrapper needs it.
 */
.hud-item, .keeper-pocket { position: relative; }
.hud-item { display: inline-flex; }

/*
 * ⚠️ DIMMED AS WELL AS MARKED — at 32px a colour alone is a few pixels. ⭐ Gentler now than it was: the
 * mark's own dark backdrop above does most of the work, and a greyed-out drawing on top of it read as broken.
 */
.keeper-slot[data-mood="idle"] img, .keeper-pocket[data-mood="idle"] img,
.hud-item[data-mood="idle"] img { opacity: 0.25; }
.keeper-slot[data-mood="partial"] img, .keeper-pocket[data-mood="partial"] img,
.hud-item[data-mood="partial"] img { opacity: 0.38; }

.keeper-slot[data-mood="idle"], .hud-item[data-mood="idle"] {
  animation: req-pulse 1.8s ease-in-out infinite;
}
/* ⛔ NOT OPTIONAL — a repeating animation is exactly what this setting exists to stop. */
@media (prefers-reduced-motion: reduce) {
  .keeper-slot[data-mood="idle"], .hud-item[data-mood="idle"] { animation: none; }
}

/*
 * ⚠️ **16px HERE, NOT THE CARD'S 32.** Five icons at 32px with their gaps are 184px — half a phone's width
 * before the archetype's own name. Astra measured it; the caption is a sentence, not the card's comparison row.
 */
.stat-icons.is-compact { gap: 3px; }
.stat-icons.is-compact .stat-icon { width: 16px; height: 16px; }

/*
 * ⭐⭐ **ONE LINE, BOTH JOBS — his ruling.** *"What if we use only 'Reaches X teammates', so when it's 0 it
 * can be red?"* The caption above names what the roll wants, so the count alone says what is missing and no
 * second "MISSING OX" sentence is needed.
 *
 * ⛔ IT DOES NOT BLINK. The pulse belongs to the mark on the item; several blinking lines in a scrolling
 * modal is a strobe — the same reason `req-pulse` is gentle and on a background only.
 */
/*
 * ⚠️ **IT BELONGS TO THE BLOCK ABOVE IT, so it sits INSIDE it** — his instruction, 2026-09-20: *"include
 * the reaches sentence BEFORE the horizontal line so we know which block it belongs to."* The separator is
 * the next caption's `border-top`, so the gap below this line is what makes the grouping read.
 */
/* ⚠️ `.gear-summary dt` is (0,1,1) — a bare class loses to it and the line came back. */
.gear-summary dt.gear-reaches {
  grid-column: 1 / -1;
  /* ⛔ NO LINE ABOVE IT — it is the last word of the block above, not the start of the next one. */
  border-top: 0;
  padding-top: 0;
  font-size: var(--type-small, 12px);
  color: var(--dim);
  padding-bottom: 10px;
}
.gear-summary dt.gear-reaches.is-none { color: var(--bad); }
/*
 * ⭐⭐ **A LINE BETWEEN EACH EFFECT — his ask, 2026-09-08:** *"add a line between each effect so
 * it's clearer, or wrap each effect in a card so it's absolutely clear what each does."*
 *
 * ⭐ **THE LINE, NOT THE CARD, AND THE REASON IS THE WRAPPING.** A value now wraps inside its own
 * column, so a two-line row and a one-line row sit next to each other and the eye loses which
 * value belongs to which label — exactly the confusion he is describing. ⚠️ A card would add two
 * borders and 8px of padding to EVERY row, and a four-roll SSS item is already tall.
 *
 * ⛔ **BOTH CELLS, BECAUSE A GRID HAS NO ROW ELEMENT TO BORDER.** On the `dt` alone the line stops
 * where the label does — half a line, which reads as a rendering fault rather than a divider. ⚠️
 * And the column gap had to go to ZERO for the two halves to meet; the space is the `dd`'s own
 * padding now, inside the bordered box.
 *
 * ⚠️ **AND NOT ABOVE THE FIRST ONE.** The list already sits under a heading; a line there would
 * fence the whole block off from the thing it belongs to.
 */
.gear-summary dt, .gear-summary dd {
  border-top: 1px solid var(--line);
  padding-top: 6px;
}
.gear-summary dt:first-of-type, .gear-summary dd:first-of-type {
  border-top: 0;
  padding-top: 0;
}
/*
 * ⛔⛔ **NO LINE INSIDE A GROUP — his report, 2026-09-20.** With a line on every row, one fell between a
 * caption and the rows it owns: *"we add too many separation lines, so we have no idea what belongs to
 * whom."* The caption carries the line now, so exactly one falls between groups.
 */
/*
 * ⭐⭐ **THE ONE LONG VALUE GETS ITS OWN ROWS** — his ruling, 2026-09-20: *"since it's so long and also the
 * value is long, it could make sense 2 rows, description and value."* ⛔ And it is what stops *"Damage taken"*
 * wrapping: the value column is sized to the widest value in the list, so leaving `×1.7 strong · ×0.3 weak`
 * inside it squeezed every label.
 */
.gear-summary dt.is-wide, .gear-summary dd.is-wide { grid-column: 1 / -1; }
.gear-summary dd.is-wide { padding-left: 0; padding-top: 2px; }
.gear-summary dt.is-wide + dd.is-wide { border-top: 0; }

.gear-summary dt.is-grouped, .gear-summary dd.is-grouped {
  border-top: 0;
  padding-top: 0;
}
/* ⚠️ Only the stats that FORM the archetype — five greyed icons wrapped the caption at 375px. */
/*
 * ⛔ **LIT ICONS ONLY — his ruling, 2026-09-20**, done in `statIcons` itself now rather than by hiding what
 * was drawn. This stays as the guard for any markup still built the old way.
 */
.stat-icons .stat-icon:not(.is-on) { display: none; }
/*
 * ⛔ **THE ICONS SAT ON THE TEXT BASELINE, which put them low against the words** — his report. An image is
 * inline by default, so it aligns on the baseline inside its inline-flex row; `block` hands the alignment
 * back to the flex box, which centres it.
 */
.stat-icons .stat-icon { display: block; }
/*
 * ⛔⛔ **THE CAPTION FONT HAS NO DESCENDER, so centring the BOXES does not centre what you SEE** — his
 * report, 2026-09-20: *"they have the same distance from bottom, but the text is taller and has very few
 * pixels from the top."* Measured at 18px: ascent 14, descent 4, line-height 22 — the ink occupies the top
 * of the line, so its centre sits 2px above the line's centre, which is where a centred icon lands.
 *
 * ⚠️ AN INTEGER PIXEL, never an em: these are pixel-art icons, and a fractional transform blurs them.
 */
/*
 * ⛔ **UP 0.11em ON EVERY SURFACE, BECAUSE THE PIXEL FONT HAS NO DESCENDER** — his report, twice: the words'
 * ink fills the TOP of the line box, so an icon centred on that box sits low against them. Measured 2px at
 * the caption's 18px; in `em` so a card, a filter button and a picker row all get the same optical result.
 */
.stat-icons { transform: translateY(-0.11em); }
.gear-summary dt { color: var(--dim); }

/*
 * ⭐⭐ **WHO YOUR GEAR LANDS ON — his layout, 2026-09-08.** Two headings; under the second, one
 * creature name per row of pictures.
 *
 * ⛔ **THE ICONS ARE 32×32 AND NOT SCALED**, the same rule the HUD row and the avatar follow
 * (§4u constraint 1). `box-sizing: content-box` because `border-box` is global here and a bordered
 * 32px box draws 30px of picture — a 0.94× resample, which is exactly what the rule forbids.
 */
.gear-reach { text-align: left; margin: 0 0 14px; }
.gear-reach-head {
  font-size: var(--type);
  color: var(--dim);
  margin: 14px 0 6px;
  font-weight: normal;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
/* ⚠️ A creature's NAME, not a section heading — indented so it reads as belonging to the one above. */
.gear-reach-who {
  font-size: var(--type);
  color: var(--accent);
  margin: 10px 0 4px 12px;
  font-weight: normal;
}
.gear-reach-row {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-left: 12px;
}
.gear-reach-item {
  box-sizing: content-box;
  width: 32px; height: 32px;
  flex: 0 0 auto;
  display: block;
  background: #0000008c;
  border: 1px solid #ffffff59;
  border-radius: 2px;
}
.gear-reach-item img.item-art { width: 32px; height: 32px; image-rendering: pixelated; }
/* ⛔ The `?` fallback still has to fit the box rather than inherit a 3.2× card font. */
.gear-reach-item:not(.has-art) { font-size: var(--type); line-height: 32px; text-align: center; }
/* ⚠️ Tabular figures so a column of numbers lines up on the decimal point. */
.gear-summary dd {
  margin: 0;
  /*
   * ⛔⛔ **RIGHT, OR SHORT VALUES CRAMP TO THE MIDDLE** — his report, 2026-09-20: *"the values should be on
   * the right to use the whole width, instead we use like half the modal."* The value column is sized to the
   * WIDEST value in the list — `×1.7 strong · ×0.3 weak` is 288px — so every short `+1.4` sat at the left of
   * that column, a long way from the edge. ⚠️ It got worse when one row per stat replaced the joined
   * `+2.4 STRENGTH, +0.3 VITALITY`, because almost every value became short.
   */
  text-align: right;
  /* ⚠️ The column gap, moved inside the box so the separator crosses it — see the note above. */
  padding-left: 12px;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  /*
   * ⛔⛔ **`white-space: nowrap` WAS HERE AND IT SCROLLED THE MODAL SIDEWAYS.** Alessandro:
   * *"still issues with text not going new line and makes the modal scroll horizontally."* A value
   * like `×1.7 strong · ×0.3 weak` simply ran off the right edge — the label wrapped, the number
   * could not, and the grid grew past the card.
   *
   * ⭐ **THE NBSP DOES THAT JOB PROPERLY AND ALREADY DID.** `summarise()` joins each part with a
   * non-breaking space (`+0.1\u00a0STRENGTH`), so a wrap can only land at a comma or a separator
   * and a bare number can never end a line. `nowrap` on the whole value was a blunt second copy of
   * a rule that was already enforced where the string is built.
   *
   * ⚠️ `min-width: 0` because a grid item's default `min-width: auto` refuses to shrink below its
   * content — without it the track still overflows however the text is allowed to wrap.
   */
  min-width: 0;
  overflow-wrap: break-word;
}

/*
 * ⛔⛔ **THERE IS ONE METHOD NOW — his call, 2026-09-08:** *"we need to find a way to make it
 * consistent: either the value is on a new line or next to it, but not mixing the two methods."*
 *
 * ⛔ **`wide` IS GONE.** It stacked a LIST value under its label while every other row kept its
 * value beside it, so one card showed both shapes at once — `BRUTE creatures only` with its
 * `+0.5 STRENGTH` underneath, directly above `Damage taken  −4.2%` on one line.
 *
 * ⭐ **TWO COLUMNS FOR EVERYTHING, AND THE VALUE WRAPS INSIDE ITS OWN.** Chosen over stacking
 * everything because a four-roll SSS item would otherwise be eight lines tall; `1fr auto` still
 * gives the label the room, and the NBSP still keeps each `+0.1 STRENGTH` atomic.
 */

/*
 * ⛔ THE FIGURE IS THE POSITIONING CONTEXT for all ten slots, which is why it is `relative` and
 * why its size is set in JS rather than here — the slots are placed in PERCENTAGES of it, so one
 * drawing serves 4×, 5× and 6× with no repositioning.
 */
.keeper-figure {
  position: relative;
  image-rendering: pixelated;
  background-repeat: no-repeat;
  background-size: 100% 100%;
}

.keeper-slot {
  position: absolute;
  width: 64px;
  height: 64px;
  display: grid;
  place-items: center;
  background: var(--panel);
  border: 1px solid var(--line);
  font-size: 0.6rem;
  color: var(--muted, #8b93a3);
  text-align: center;
  line-height: 1.15;
}
.keeper-slot span { padding: 2px; }

/*
 * ⭐⭐ **THE TIER, AS AN INNER RING — his ask, 2026-09-08.**
 *
 * > *"Use tier colours to colour a bit the cell where we have items (both the body and the
 * > inventory). We keep the external border as is, but we add an internal border of 1-2px of the
 * > colour of the tier, so you see immediately the tier — the item itself is already coloured with
 * > the element."*
 *
 * ⭐ **TWO FACTS, TWO CHANNELS.** The drawing carries the ELEMENT, the ring carries the TIER, and
 * neither gives anything up — which is exactly what the battle stage could not manage, where
 * colour was already spoken for three times over.
 *
 * ⛔ **`box-shadow: inset`, NOT A SECOND BORDER.** A real border would change the box: `box-sizing`
 * is `border-box` globally, so a 64px slot would lose 4px of its 32px picture to a 2px frame — the
 * same 0.94× resample the HUD avatar was quietly suffering. An inset shadow paints inside the
 * padding box and moves nothing.
 *
 * ⚠️ **ONLY WHEN SOMETHING IS ACTUALLY THERE.** `markTier` removes the attribute for an empty
 * slot and for a closed box — §4y says a box carries neither tone nor tier — so an unknown item
 * states no rarity rather than defaulting to one.
 */
.keeper-slot[data-tier], .keeper-item[data-tier] {
  box-shadow: inset 0 0 0 2px hsl(var(--tier-h) var(--tier-s) var(--tier-l));
}
/*
 * ⛔ A WORN ITEM'S ACCENT BORDER STAYS THE OUTER ONE. `[data-worn="yes"]` recolours the frame and
 * the tier ring sits inside it — two facts stacked, which is why the ring is INSET rather than
 * replacing what is already there.
 */

/*
 * ⛔ **A LOCKED SLOT IS DIMMER, NOT HIDDEN.** Seeing the shape of what you do not have yet is the
 * reason to keep levelling; an empty space says nothing. ⚠️ The lock is 32px at exactly 1×, like
 * every icon in this game.
 */
.keeper-slot[data-locked="yes"] { opacity: 0.55; border-style: dashed; }
.keeper-slot[data-locked="yes"] span { display: grid; place-items: center; gap: 1px; }
.keeper-slot img { image-rendering: pixelated; display: block; }
.keeper-slot small { font-size: 0.55rem; letter-spacing: 0.04em; }
.keeper-pocket[data-locked="yes"] { opacity: 0.55; }
/*
 * ⭐ **THE BACK POCKET IS MARKED APART.** It is the economy class, not a third pocket — one XP,
 * gold or discount item — and carrying one of each is the whole point of it having its own place.
 */
.keeper-back { border-style: solid; border-color: var(--accent); color: var(--accent); }

/* His arrangement. Centre column, then the sides, then the feet. */
.keeper-slot[data-slot="head"]     { top: 0;    left: calc(50% - 32px); }
.keeper-slot[data-slot="necklace"] { top: 22%;  left: calc(50% - 32px); }
.keeper-slot[data-slot="body"]     { top: 44%;  left: calc(50% - 32px); }
.keeper-slot[data-slot="belt"]     { top: 66%;  left: calc(50% - 32px); }
.keeper-slot[data-slot="weapon1"]  { top: 22%;  left: 0; }
.keeper-slot[data-slot="ring1"]    { top: 50%;  left: 0; }
.keeper-slot[data-slot="weapon2"]  { top: 22%;  right: 0; }
.keeper-slot[data-slot="ring2"]    { top: 50%;  right: 0; }
.keeper-slot[data-slot="gloves"]   { bottom: 0; left: 0; }
.keeper-slot[data-slot="boots"]    { bottom: 0; right: 0; }

.keeper-pockets { display: grid; grid-template-columns: repeat(3, 64px); gap: 6px; }
.keeper-pocket {
  height: 44px;
  display: grid;
  place-items: center;
  background: var(--panel);
  border: 1px dashed var(--line);
  font-size: 0.6rem;
  color: var(--muted, #8b93a3);
}
/*
 * ⚠️ **THE LOCK SITS BESIDE THE LEVEL HERE, NOT ABOVE IT.** A pocket is a 44px rectangle and the
 * lock is 32px at 1× — it cannot be scaled down, because half of 32 is not a whole-number scale
 * and an icon is the one drawing in this game that is never resampled. Side by side is what fits.
 */
.keeper-pocket[data-locked="yes"] span {
  display: flex;
  align-items: center;
  gap: 4px;
}
.keeper-pocket img { image-rendering: pixelated; display: block; }
.keeper-pocket small { font-size: 0.55rem; letter-spacing: 0.04em; }

/*
 * ⛔ **CENTRE, NOT BASELINE.** A flex container's baseline is taken from its FIRST item, and the
 * gold block's first item is a 32px coin — so "Inventory" was aligning to the bottom of an image
 * rather than to the number beside it. Alessandro: *"can we have all aligned on the same level?
 * It makes my eyes bleed."*
 */
/*
 * ⭐ **THE INVENTORY GETS ITS OWN GROUND, his ask** — it is a different KIND of thing from the
 * doll above it (things you have, not places to put them), and without a boundary the two ran
 * together into one long column.
 * ⚠️ A panel rather than a colour of its own: the app has one surface token and a second would be
 * a third surface nothing else uses.
 */
.keeper-inv {
  background: var(--panel);
  border: 1px solid var(--line);
  padding: 12px;
}

.keeper-inv-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  /* ⚠️ The row is as tall as the coin, so the label sits on the coin's centre line. */
  min-height: 32px;
}
/* ⭐ Gold sits BY THE INVENTORY, his call — it is the number you check while deciding what to buy. */
.keeper-gold {
  display: flex;
  align-items: center;
  gap: 5px;
  color: #d9b45a;
  font-variant-numeric: tabular-nums;
  font-size: 0.85rem;
}
/*
 * ⛔ **32px AT EXACTLY 1×, and no CSS may resize it.** An icon is the one drawing in this game
 * that is never magnified — `bundle-icons.mjs` bakes it at its drawn size and any other number
 * here lands source pixels on fractions of screen pixels.
 */
.keeper-coin { image-rendering: pixelated; display: block; }
/* ⭐ The shop's title and its balance on one line — the heading keeps its own margins. */
.shop-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}


/* --- the worn-title picker ----------------------------------------------- */
.title-choices { display: grid; gap: 8px; margin: 12px 0 16px; }

/*
 * ⭐ A CARD PER TITLE — his ask: the mark, the name, and nothing else. The description lives on
 * the Titles tab, which the button below this list goes to.
 */
.title-choice {
  display: flex;
  gap: 10px;
  align-items: center;
  width: 100%;
  text-align: left;
  background: var(--panel);
  border: 1px solid var(--line);
  padding: 9px 11px;
  font: inherit;
  color: var(--fg);
  cursor: pointer;
}
.title-choice:hover { border-color: var(--accent); }
/* ⚠️ The chosen one is marked by BORDER and a word, not colour alone. */
.title-choice[aria-pressed="true"] { border-color: var(--accent); background: var(--panel-2, var(--panel)); }

/* ⚠️ The mark is a `.badge-art` frame — see `openTitlePicker`. Only the shrink-guard is here. */
.title-choice .badge-art { flex: 0 0 auto; }
.title-choice-name { flex: 1; min-width: 0; }
.title-choice-worn { font-size: 0.7rem; color: var(--accent); }


/*
 * ⭐ **THE ITEM'S NAME AND ITS ONE LINE.** The name is the heading a museum label would print; the
 * line is dimmer and italic, so a player's eye goes name → what it does → flavour, and never
 * mistakes the joke for a number.
 */
.item-modal-name { margin: 0 0 0.2rem; font-size: var(--type); }
/*
 * ⛔ **THE GAP UNDER THE LINE IS BIG ON PURPOSE — his note, 2026-09-07:** *"add more space between
 * the punch line and the rolls, they are too close to each other."*
 *
 * ⚠️ **THEY ARE TWO DIFFERENT KINDS OF SENTENCE AND THE SPACE IS WHAT SAYS SO.** The line is
 * flavour and the rolls are the numbers a player acts on; sitting a few pixels apart they read as
 * one block, and the joke starts looking like a label for the figures under it.
 */
.item-punch {
  margin: 0.1rem 0 1.1rem;
  font-style: italic;
  color: var(--dim);
}
/* ⚠️ The scan card is narrower and has more paragraphs, so it needs a touch less. */
.item-card .item-punch { margin: 0.2rem 0 0.9rem; }

/*
 * --- the equip picker ------------------------------------------------------
 *
 * ⭐ **IT REUSES `.title-choice`**, which is already "a mark, a line of text, one row" — the same
 * shape the title and avatar pickers use. Only the MARK differs, and only in size.
 */

/*
 * ⛔ **32px, 1:1, LIKE EVERY OTHER ITEM SPRITE ON THIS PAGE.** `.item-mark` is sized for the
 * modal's big centred `?` (3.2× type); dropped into a row it would be three lines tall. ⚠️ The
 * fallback `?` is text, so the font-size has to come down with the box or the row grows anyway.
 */
.equip-mark {
  flex: 0 0 auto;
  width: 32px;
  height: 32px;
  display: grid;
  place-items: center;
  font-size: 1.1rem;
  line-height: 1;
  color: var(--dim);
}
.equip-mark img { image-rendering: pixelated; }

/* ⚠️ The stack, not a flex row: a tier line and its effects are two different weights. */
.equip-what { flex: 1; min-width: 0; }
.equip-what strong { display: block; font-weight: inherit; }
/*
 * ⭐ **THE EFFECTS ARE DIMMER AND SMALLER, and they WRAP.** A ring with two rolls is a long
 * sentence, and truncating it would hide exactly the half a player is choosing on.
 */
/*
 * ⛔ **BLOCK, OR IT RUNS ON FROM THE LINE ABOVE.** These were inline, so a row read
 * *"ANVIL creatures only +0.3 ENDURANCE**Worn on your pocket**"* with no break at all — two
 * different facts glued into one sentence. ⚠️ `.equip-what strong` and `.equip-grade` were already
 * block; these two were missed, which is why only the last pair collided.
 */
.equip-does { display: block; font-size: 0.7rem; color: var(--dim); }
/* ⚠️ Between the name and the effects: what it IS, in the same small type as what it does. */
.equip-grade { display: block; font-size: 0.7rem; color: var(--dim); }
/* ⚠️ Marked by a WORD as well as a colour — the same rule `.title-choice-worn` follows. */
.equip-here { display: block; font-size: 0.7rem; color: var(--accent); }

/*
 * ⭐ **A SLOT WITH SOMETHING IN IT DRAWS THE ITEM, and the art replaces the word.** The label span
 * is emptied and given the sprite, so no rule is needed to hide the text.
 */
.keeper-slot img.item-art, .keeper-pocket img.item-art { image-rendering: pixelated; }
/* ⛔ A filled slot is marked by its BORDER, never by colour alone — the art is the content. */
.keeper-slot[data-worn="yes"], .keeper-pocket[data-worn="yes"] { border-color: var(--accent); }

/* --- what you have found, and nothing about it ---------------------------- */
/*
 * ⛔⛔ **THE COLUMNS ARE SET IN JS, IN WHOLE PIXELS — see `fitItemGrid`.** `minmax(64px, 1fr)` gave
 * FRACTIONAL cells, and a 32px drawing centred in a 60.22px cell sits at 13.11px — 26.22 device
 * pixels at dpr 2, where nearest-neighbour samples one source column twice and skips another.
 * ⚠️ His report: *"the paperclip is not fully drawn… 60.22×60.22"*. Measured, 196 of 200 cell widths
 * in that range land off the device grid.
 *
 * ⚠️ **THE RULE BELOW IS THE FALLBACK ONLY**, for the moment before the first measure and for a
 * browser with no `ResizeObserver`. The 64px track is the honest default: it is the size a SLOT is,
 * and it is whole.
 */
/*
 * ⭐ THE INVENTORY'S SORT ROW — three buttons, the chosen one marked with `aria-current`.
 *
 * ⚠️ Its gap matches `.keeper-items`' own 6px and its top margin the 10px the grid already sits
 * on, so the row joins the block rather than floating above it.
 */
.keeper-sort {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 10px;
}
.keeper-sort button[aria-current="true"] {
  border-color: var(--accent);
  color: var(--accent);
}
/* ⚠️ The grid's own top margin would double the gap under the row. */
.keeper-sort:not([hidden]) + .keeper-items { margin-top: 6px; }

.keeper-items {
  display: grid;
  grid-template-columns: repeat(auto-fill, 64px);
  gap: 6px;
  margin: 10px 0 12px;
}
/*
 * ⚠️ 64px so an item sits in the same square a SLOT does — a found thing and an equipped thing
 * are the same size, which is the only hint this screen gives that they are related.
 */
/*
 * ⭐ **AN EMPTY CELL ON THE LAST PAGE — see `showKeeperItems`.** It holds the grid's height so the
 * pager underneath stays put between pages. ⚠️ **Invisible, not merely empty**: no border, no
 * background, nothing to read as a slot you could put something in.
 */
.keeper-hole {
  aspect-ratio: 1;
  visibility: hidden;
}

.keeper-item {
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  /*
   * ⛔⛔ **ZERO, AND IT IS LOAD-BEARING — his report, 2026-09-09, with the inspector open:**
   * *"looks like we give too little space to the image, that is centred but it overflows, we have
   * plenty of space in the box."*
   *
   * ⚠️ **A TILE IS A `<button>`, AND `button` IN THIS FILE CARRIES `padding: 0.7rem 1.15rem`** —
   * 11.2px and 18.4px, exactly what DevTools showed. Nothing here reset it, so a 64px tile with a
   * 1px border had **62 − 36.8 = 25.2px of content box for a 32px drawing**, and the art spilled
   * out of the space it was being centred in.
   *
   * ⛔ **THE GRID'S OWN CENTRING WAS A DIFFERENT BUG AND FIXING IT DID NOT FIX THIS ONE.** Two
   * faults, stacked: the grid sat left of centre AND every tile squeezed its picture. Neither was
   * visible as the other. [[two-faults-stacked]].
   *
   * ⭐ **THE ART IS SIZED, NEVER FLUID** (`.item-art` is 32×32), so the tile must simply get out of
   * its way: `place-items: center` does the rest.
   */
  padding: 0;
  /* ⚠️ The page ground, not the panel — the tiles sit ON the panel and would vanish into it. */
  background: var(--bg);
  border: 1px solid var(--line);
  color: var(--dim);
  font-size: 1.3rem;
  font: inherit;
  cursor: pointer;
}
.keeper-item:hover { border-color: var(--accent); color: var(--accent); }
/*
 * ⭐ **A WORN ITEM IS MARKED WHERE IT WAS FOUND**, or the grid and the doll are two unrelated
 * pictures. ⚠️ It is not removed from the list: a player hunting the ring they are wearing would
 * conclude they had lost it.
 */
.keeper-item[data-worn="yes"] { border-color: var(--accent); }

/*
 * ⭐ **ITEM ART — 32px drawn, shown at an INTEGER multiple or it turns to mush.**
 *
 * `image-rendering: pixelated` is the same rule badges and icons follow. ⚠️ **`width: 100%` would
 * be a non-integer scale in the 64px grid the moment the gap or a border moved**, so the size is
 * pinned rather than fluid: 64 is exactly 2×, and the tile is 64.
 *
 * ⛔ **DO NOT "IMPROVE" THIS TO `object-fit: contain` WITH A FLUID WIDTH.** `app.css` forbids
 * resampling a still sprite — `decisions.md` — and the falling background is the only place that
 * gets an exception, because motion and 47% opacity hide it.
 */
/*
 * ⛔ **32px — 1:1, AND THE TILE SIZE IS WHY IT IS NOT 64.** A `.keeper-item` is 64px *"so an item
 * sits in the same square a SLOT does"*, and it carries a 1px border — leaving a **62px content
 * box**. A 64px image overflowed it, which is what he saw: *"they look not centered at all."*
 *
 * ⚠️ **THE FIX IS THE ART, NOT THE TILE.** Widening the tile would have fixed the centring and
 * quietly broken the found-thing-is-slot-sized rule that tile size exists to state.
 *
 * ⚠️ Every item sprite is 32×32 (measured, all 46), so this is 1:1 with no resampling at all.
 * ⭐ **If it reads too small in the grid, the number to change is HERE and it must stay an integer
 * multiple of 32** — 64 needs a 68px tile, which is the trade above.
 */
.item-art {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
  display: block;
  margin: 0 auto;
}
/*
 * ⛔ **THE CONTAINER WAS STYLED FOR A CHARACTER AND NOW HOLDS AN IMAGE.** `.item-mark` sets
 * `font-size: calc(var(--type) * 3.2)` and `line-height: 1` for the old `?`, and that line box
 * survives the swap — it adds height above the picture and pushes it off centre. ⭐ The class is
 * set by `paintItemInto` rather than guessed with `:has()`, so the rule fires exactly when there
 * is art and never when the `?` is still showing.
 */
.item-mark.has-art { font-size: 0; line-height: 0; margin: 0.2rem 0 0.6rem; }
/* ⚠️ The modal draws it larger; still an integer multiple of 32. */
.item-modal .item-art { width: 96px; height: 96px; }
/* The scan card sits between the two. */
.item-card .item-art { width: 96px; height: 96px; }
.keeper-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }


/* --- one found thing, opened ----------------------------------------------
 *
 * ⚠️ **ART, TIER, LINE — centred, in that order.** His layout. The heading is gone because the
 * player opened this from the inventory, which is entirely things that are not creatures.
 */
.item-modal { text-align: center; }
.item-modal .item-mark { margin: 0.2rem 0 0.4rem; }
.item-modal-tier {
  margin: 0 0 0.9rem;
  color: var(--accent);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-size: 0.78rem;
}
/* ⚠️ Empty when a device cached before tiers existed — collapse rather than leave a gap. */
.item-modal-tier:empty { display: none; }

/*
 * ⛔ **A DIALOG NEEDS AN ACCESSIBLE NAME EVEN WHEN IT SHOWS NO HEADING**, or it is announced as
 * "dialog" and nothing else. This is the standard clip, not `display:none` — a hidden element is
 * not read at all, which defeats the purpose.
 */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}


/* ⚠️ Only shown when there is more than one page — see `showKeeperItems`. */
.keeper-pager {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-top: 10px;
}
.keeper-pager .note { margin: 0; font-variant-numeric: tabular-nums; }

/*
 * ⭐⭐ THE SHOP — `items-and-shop.md` §4z. His stall, 112×160, at a whole-number scale.
 *
 * ⛔ **THE SLOTS ARE PERCENTAGES OF THE FIGURE**, measured from his boards, so one set of numbers
 * places the goods at 2× and 3× alike. ⚠️ An item is 32px of a 112-wide drawing — 28.57% — and the
 * three across start at 7.14% and step by the same 28.57%, which is 96 of the 98px interior with a
 * pixel spare each side.
 */
.shop-stage { display: grid; justify-items: center; margin-bottom: 18px; }
.shop-figure {
  position: relative;
  image-rendering: pixelated;
  background-repeat: no-repeat;
  background-size: 100% 100%;
}
/* ⛔ A missing drawing looks PROVISIONAL, never broken — `decisions.md` §0f. */
.shop-figure[data-drawn="no"] {
  border: 1px dashed var(--line);
  border-radius: 2px;
}
/*
 * ⛔⛔ **THE POSITIONS ARE SET IN JS FROM `SHOP_BOARDS`, NOT WRITTEN HERE — and they WERE written
 * here, for one day.** They had been measured by hand from the drawing; he then added a beam above
 * the shutter and moved everything down, and every item was left floating in mid-air. ⚠️ A
 * measurement of the art belongs to the art: the bake takes it, `placeShopSlots` applies it.
 */
.shop-slot {
  position: absolute;
  display: grid;
  place-items: center;
}
/* ⚠️ The art is 32px and is NEVER resampled — it fills the slot exactly at any whole scale. */
.shop-slot img { width: 100%; height: 100%; image-rendering: pixelated; display: block; }
/* ⭐ A shelf item opens its card — so it has to look tappable. */
.shop-slot { cursor: pointer; }
/*
 * ⭐⭐ **SOMETHING YOU ALREADY OWN STAYS ON THE SHELF AND LOOKS SPENT — his ruling, 2026-09-10.**
 *
 * ⛔ **HIDING THEM STARVED THE SHOP.** He holds 49 of the 87 shelf-eligible items in the world, and
 * a nine-slot draw came back with three — worse than the ratio, because a high level leans the
 * draw on high tiers and those are exactly what a collector already has. His report: *"when I
 * refresh it's not filling back… are we hiding items owned by the player?"*
 *
 * ⚠️ **DIMMED AND DESATURATED, NOT HIDDEN AND NOT DEAD.** It still opens its card — knowing what a
 * thing IS is the reason to tap it, and the card is where it says you have one. ⛔ No opacity below
 * ~0.35: at 32px these drawings are a few dozen pixels and any less reads as an empty slot, which
 * is the state that has to stay distinct from this one.
 */
.shop-slot[data-owned="yes"] img {
  opacity: 0.38;
  filter: grayscale(1);
}
/*
 * ⭐⭐ **THE TIER AURA — his ask, 2026-09-23:** *"in the shop we need to add a sort of aura to the
 * items sold to show the tier, I've been asked about this."*
 *
 * ⛔ **THE TIER WAS INVISIBLE WHERE IT MATTERS MOST.** A shelf item's tier is on its card, one tap
 * away, so the shelf itself gave a player no way to see which of nine things is worth their gold.
 *
 * ⭐ **A GLOW BEHIND THE DRAWING, NOT A LETTER.** The drawings are 32px and a letter on top of one
 * covers a third of it. The colour is the SAME `--tier-h/s/l` the collection, the filters and the
 * Keeper's slots already use, so it is a vocabulary the player has met — not a new code to learn.
 *
 * ⚠️ **E AND D GET NOTHING, DELIBERATELY.** Together they are most of everything found (§4l: tier E
 * alone is ~47%), so lighting them up would make "glowing" mean "an item", and the whole shelf
 * would shimmer. The aura starts at C and grows — absence is the bottom of the scale.
 *
 * ⚠️ Behind, via an element under the art (`.aura`): `filter: drop-shadow` follows the sprite's own alpha
 * and would outline every pixel, which reads as a sticker rather than a light.
 *
 * ⭐ **ONE VALUE FOR BOTH THEMES, and that is measured, not assumed.** A slot is absolutely positioned over
 * the STALL DRAWING — its own background is transparent — so the aura lands on the same pixels in light and
 * dark, and a per-theme alpha would be tuning against a backdrop that never changes.
 */
/*
 * ⭐⭐ **AND IT MOVES — his ask, 2026-09-24:** *"not like a repetitive predictable animation, like a wavy
 * aura… the range of the aura increases and decreases differently for each 'slice'"*.
 *
 * ⭐ **A STATIC CORE PLUS THREE LOBES.** Each lobe is an ellipse whose centre sits OFF the slot's centre
 * by `--aura-wave`, so ROTATING it moves a bulge around the outline: the light reaches further in one
 * direction than in the next, and the directions drift. That is "each slice".
 *
 * ⛔ **WHAT MAKES IT UNPREDICTABLE IS INCOMMENSURATE PERIODS, NOT RANDOMNESS.** Each lobe spins and
 * breathes as two separate animations (the `rotate` and `scale` properties compose independently), and
 * the six periods share no common multiple, so the outline never visibly repeats.
 * ⚠️ Each item starts every animation somewhere else: `--aura-f1..6` come from its barcode (`aura.ts`).
 *
 * ⛔ **COMPOSITOR ONLY: `rotate` and `scale`, nothing else animates.** It sits behind nine items on a
 * phone. A filter or a canvas would repaint the drawing above it every frame.
 *
 * ⚠️ Every number that is his (intensity, reach, wave, pace) arrives as a custom property set by
 * `auraSlotVars`. Tune on `/aura` (nudge server, 4321), never here.
 */
.shop-slot .aura {
  position: absolute;
  /* ⚠️ Wider than the slot, so the light falls off OUTSIDE the drawing rather than inside it. */
  inset: calc(var(--aura-reach) * -1%);
  /* ⛔ Under the art and out of the way of the tap — the slot itself is the button. */
  z-index: 0;
  pointer-events: none;
  border-radius: 50%;
  background: radial-gradient(
    closest-side,
    hsl(var(--tier-h) var(--tier-s) var(--tier-l) / calc(var(--aura) * 0.40)),
    hsl(var(--tier-h) var(--tier-s) var(--tier-l) / 0) 72%);
}
.shop-slot .aura i {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: radial-gradient(
    ellipse 42% 36% at 50% calc(50% - var(--aura-wave) * 1%),
    hsl(var(--tier-h) var(--tier-s) var(--tier-l) / calc(var(--aura) * 0.30)),
    hsl(var(--tier-h) var(--tier-s) var(--tier-l) / 0) 100%);
  will-change: rotate, scale;
  animation-timing-function: linear, ease-in-out;
  animation-iteration-count: infinite, infinite;
  animation-direction: normal, alternate;
}
.shop-slot .aura i:nth-child(1) {
  animation-name: aura-spin, aura-breathe;
  animation-duration: calc(13.7s / var(--aura-pace)), calc(5.3s / var(--aura-pace));
  animation-delay: calc(-13.7s / var(--aura-pace) * var(--aura-f1)), calc(-10.6s / var(--aura-pace) * var(--aura-f2));
}
.shop-slot .aura i:nth-child(2) {
  animation-name: aura-spin, aura-breathe;
  animation-duration: calc(19.3s / var(--aura-pace)), calc(7.9s / var(--aura-pace));
  animation-delay: calc(-19.3s / var(--aura-pace) * var(--aura-f3)), calc(-15.8s / var(--aura-pace) * var(--aura-f4));
  /* ⚠️ One lobe turns the other way, or the three would read as one shape spinning. */
  animation-direction: reverse, alternate;
}
.shop-slot .aura i:nth-child(3) {
  animation-name: aura-spin, aura-breathe;
  animation-duration: calc(29.9s / var(--aura-pace)), calc(11.3s / var(--aura-pace));
  animation-delay: calc(-29.9s / var(--aura-pace) * var(--aura-f5)), calc(-22.6s / var(--aura-pace) * var(--aura-f6));
}
@keyframes aura-spin { from { rotate: 0deg; } to { rotate: 360deg; } }
@keyframes aura-breathe { from { scale: 0.78; } to { scale: 1.12; } }
/* ⚠️ Reduced motion keeps the light and stops the movement: the tier is information, the wave is not. */
@media (prefers-reduced-motion: reduce) {
  .shop-slot .aura i { animation: none; }
}
.shop-slot img { position: relative; z-index: 1; }
/*
 * ⚠️ **SOMETHING YOU OWN DOES NOT GLOW.** Its drawing is greyed to say "spent"; a coloured light
 * around a grey picture says the opposite in the same square. `fillShopShelves` adds no aura to it,
 * and this is the second guard.
 */
.shop-slot[data-owned="yes"] .aura { display: none; }

/* ⚠️ Centred under the stall, because the stall is centred and the button belongs to it. */
/* ⭐ One group per button, each with its own sentence under it — his, 2026-09-09. */
.shop-controls { display: grid; gap: 12px; margin-bottom: 14px; }
.shop-control { display: grid; justify-items: center; gap: 4px; }
.shop-control .note { margin: 0; text-align: center; }

/*
 * ⭐⭐ THE SELL MODAL — items-and-shop.md §4j. The list IS the selection.
 * ⚠️ It scrolls INSIDE the card: ten rows plus filters is taller than a phone in landscape, and a
 * card that grows past the viewport puts SELL and CANCEL where nobody can reach them.
 */
.sell-filters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 10px; }
.sell-filters button { padding: 4px 10px; font-size: 14px; }
.sell-filters button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
/*
 * ⛔⛔ **ONLY THE LIST SCROLLS — his report, 2026-09-09:** *"I have to scroll to see the
 * sell/cancel button."*
 *
 * ⚠️ **`.overlay-card` SCROLLS AS A WHOLE** (`max-height: 84vh; overflow-y: auto`), which is right
 * for a card of prose and wrong for one with a list and a decision at the bottom: the buttons ride
 * out of view with the content. ⭐ So this card becomes a COLUMN — heading, filters and note at the
 * top, buttons pinned at the bottom, and the list taking whatever is left.
 *
 * ⛔ **`min-height: 0` ON THE SCROLLING CHILD IS THE LOAD-BEARING LINE.** A flex item defaults to
 * `min-height: auto` and refuses to shrink below its content, so without it the list pushes the
 * buttons off the card exactly as before — the same default that made the row overflow sideways
 * two fixes ago, on the other axis.
 */
#sell-overlay .overlay-card {
  display: flex;
  flex-direction: column;
  /* ⚠️ Taller than the shared 84vh: this card has a header, a list AND a choice to fit. */
  max-height: 92vh;
  overflow: hidden;
}

/*
 * ⭐⭐ A ROW IS THREE LINES, AND THE LONG ONE GETS THE WHOLE WIDTH — his shape.
 *
 * ⛔ THE EFFECTS USED TO SIT IN A NARROW MIDDLE COLUMN between a tick, a picture and a price, so
 * the longest string in the row had the least room to wrap in. Ten rows of that is a modal taller
 * than the screen.
 *
 * ⚠️ `min-width: 0` still matters on every box in the chain: `summarise()` joins a value to its
 * label with a NON-BREAKING space, so `+2.4 STRENGTH` is one atom and a grid item's default
 * `min-width: auto` would rather grow the card than break it.
 */
.sell-list {
  display: grid;
  gap: 4px;
  align-content: start;
  /* ⭐ Takes what is left of the card, and scrolls inside it. */
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  margin-bottom: 10px;
  min-width: 0;
}
.sell-row {
  display: grid;
  gap: 1px;
  padding: 5px 6px;
  border: 1px solid var(--line);
  border-radius: 2px;
  text-align: left;
  background: none;
  color: inherit;
  font: inherit;
  /*
   * ⛔⛔ **`font: inherit` DOES NOT RESET `letter-spacing` — measured, 2026-09-09.** The base
   * `button` rule sets `letter-spacing: 0.06em` for the app's chunky labels, and the shorthand does
   * not include it, so every string in a row ran ~6% wide. **"Bramble Belt" wrapped to two lines in
   * a 141px column it should have fitted in**, which is a whole extra line on most rows.
   *
   * ⚠️ This is the same shape as the tile's padding: a `<button>` used as a container inherits
   * furniture meant for a label.
   */
  letter-spacing: normal;
  min-width: 0;
}
/*
 * ⭐⭐ HIS LAYOUT: "each row takes all the space, only the tick uses a full column and all rows."
 * The tick is the one column; everything else is a stack that owns the full width.
 */
.sell-row {
  grid-template-columns: 16px minmax(0, 1fr);
  align-items: center;
  gap: 8px;
}
.sell-words { display: grid; gap: 1px; min-width: 0; }
/* ⭐ Line one: the picture and the title together — the only line with two things on it. */
.sell-head {
  display: grid;
  grid-template-columns: 32px minmax(0, 1fr);
  align-items: center;
  gap: 8px;
  min-width: 0;
}
/*
 * ⭐⭐ THREE STATES — his, 2026-09-09: "we need 3 states, selected, disabled and unselected."
 *
 * ⛔ THE FIRST VERSION DIMMED EVERYTHING UNTICKED, so "you can pick this" and "you have used your
 * ten" looked the same — and unticking one to free a slot changed nothing visible.
 *
 * ⚠️ A ROW NEVER VANISHES in any state: one that disappeared would read as already sold.
 */
/* free — tappable, and it must LOOK it. Nothing dimmed. */
.sell-row[data-pick="free"] { border-style: dashed; }
/* on — chosen, and the accent is the same one the tick wears. */
.sell-row[data-pick="on"] { border-color: var(--accent); }
/* off — the cap, not a fault. Dim AND inert; the note under the list says why. */
.sell-row[data-pick="off"] { opacity: 0.4; }
/*
 * ⭐ **32px — THE SAME SIZE IT IS IN THE INVENTORY.** His, 2026-09-09: *"we have it at 32px in the
 * inventory, why do we have it huge here?"* ⛔ **One size for one thing across the app** — a
 * picture that changes size between screens reads as two different pictures.
 *
 * ⚠️ 32 is 1:1 with the sprite, so there is no resampling at all. Any size between 32 and 64 would
 * be a fractional scale, which `app.css` forbids outside the falling background.
 */
.sell-row img { width: 32px; height: 32px; image-rendering: pixelated; display: block; }

/* ⭐ The heading is centred like every other dialog's — his note, 2026-09-09. */
#sell-overlay-title, #confirm-sell-title, #confirm-trade-title { text-align: center; }
#confirm-sell-line, #confirm-trade-line { text-align: center; font-weight: 700; margin: 0 0 6px; }
#confirm-sell-warn, #confirm-trade-warn { text-align: center; margin: 0; }
/*
 * ⭐ THE PILE PICKER — §4m's 5 to 25. The count is the loudest thing on the card, because it is
 * the only decision being made and every number under it follows from it.
 */
.trade-count { text-align: center; font-size: 1.6rem; font-weight: 700; margin: 0 0 2px; }
.trade-range { width: 100%; margin: 0 0 10px; accent-color: var(--accent); }

/*
 * ⭐ WHAT A BETTER SHOP BUYS — two columns, because a claim is not evidence.
 * ⚠️ The arrow column is the point of the table: the eye reads the CHANGE, not the two numbers.
 */
#confirm-level-title { text-align: center; }
/*
 * ⛔⛔ **THIS CARD MUST NEVER SCROLL — his call, 2026-09-09.** The shared `.overlay-card` caps at
 * 84vh and scrolls, which is right for a card of prose and wrong for a TABLE: a comparison you have
 * to scroll is not a comparison, because the two columns stop being on screen together.
 *
 * ⚠️ **SO THE CONTENT IS MADE TO FIT INSTEAD** — tight rows, a small table font, and at most eight
 * rows because that is every tier plus the trade line. Measured at 375×667 and at 320×568.
 */
#confirm-level-overlay .overlay-card {
  max-height: none;
  overflow: visible;
}
/*
 * ⚠️ **TIGHTENED UNTIL IT FITS THE SMALLEST PHONE WE SUPPORT.** Measured: with the ordinary card
 * spacing, the worst case — every tier the game has, plus the trade row — came to 604px and
 * overflowed a 320×568 screen by 18px, putting CANCEL off the bottom. ⛔ That is worse than the
 * scrolling it replaced, so the content gives way rather than the viewport.
 */
#confirm-level-title { margin: 0 0 4px; font-size: 1.1rem; }
/*
 * ⭐ CENTRED, LIKE EVERY OTHER LINE ON A DIALOG — his note, 2026-09-09. It was left-aligned because
 * it was never added to the shared rule the sell and trade lines share.
 */
#confirm-level-line { margin: 4px 0 0; text-align: center; font-weight: 700; }
#confirm-level-warn { margin: 0 0 2px; }
.level-odds { width: 100%; border-collapse: collapse; margin: 0 0 6px; font-size: 13px; }
.level-odds th, .level-odds td { padding: 1px 4px; text-align: right; font-variant-numeric: tabular-nums; }
.level-odds th:first-child, .level-odds td:first-child { text-align: left; font-weight: 700; }
.level-odds thead th { font-size: 13px; color: var(--muted); font-weight: 400; }
.level-odds tbody tr + tr { border-top: 1px solid var(--line); }
/* ⭐ The better column is the one being sold, so it wears the accent. */
.level-odds td:last-child { color: var(--accent); font-weight: 700; }
/* ⭐ The price a shop item carries — the one thing this card gains when it is a shopfront. */
/*
 * ⚠️ **THE BOTTOM MARGIN IS NOT SYMMETRY, IT IS THE FIX.** This line had `0.6rem 0 0` — space
 * above, none below — so the price sat almost touching the Buy button under it. Alessandro,
 * 2026-09-09: *"add more padding between the gold line and the buy button, too close."*
 * ⭐ A price and the button that charges it are two different decisions and want a gap between
 * them; the price belongs to the item above, not to the button below.
 */
/*
 * ⭐⭐ **THE ITEM CARD HOLDS ITS NAME AND ITS BUTTONS STILL — his instruction, 2026-09-09:**
 * *"keep name and buttons fixed, make effects scrollable."* Same construction as
 * `#sell-overlay .overlay-card` above, and for the same reason: a card that scrolls as a whole
 * scrolls its own heading away, so the thing you are deciding about and the control that decides
 * it are never on screen together.
 *
 * ⚠️ **`overflow: hidden` ON THE CARD IS WHAT MAKES THE INNER SCROLL WORK.** Without it the card
 * grows past `max-height` and the page scrolls instead, which is the behaviour being replaced.
 */
#item-overlay .overlay-card {
  display: flex;
  flex-direction: column;
  /* ⚠️ Taller than the shared 84vh, like the sell card: heading, body AND up to three buttons. */
  max-height: 92vh;
  overflow: hidden;
}

.item-modal-body {
  /* ⭐ Takes what is left of the card, and scrolls inside it. */
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  /* ⛔ `min-width: 0` on a flex child, or a long effect line widens the card instead of wrapping. */
  min-width: 0;
  /*
   * ⚠️ **THE SCROLL STOPS HERE.** Without this, flicking past the end of the effects hands the
   * gesture to the page behind — which is the other half of what he reported.
   */
  overscroll-behavior: contain;
}

.item-price { text-align: center; font-weight: 700; margin: 0.7rem 0 1rem; }
.sell-row .sell-name { min-width: 0; overflow-wrap: anywhere; font-weight: 700; }
.sell-row .sell-what { font-size: 13px; color: var(--muted); min-width: 0; }
/*
 * ⭐ WHAT THE ITEM DOES — his: "how do I know if I'm selling the one I need to keep for a boss
 * fight?" ⚠️ It WRAPS rather than truncating, and now across the FULL width of the row, which is
 * what made the card short enough to hold its own buttons.
 */
.sell-row .sell-does { font-size: 13px; color: var(--accent); min-width: 0; overflow-wrap: anywhere; }
.sell-row .sell-gold { font-variant-numeric: tabular-nums; white-space: nowrap; font-weight: 700; }
.sell-total { text-align: center; font-weight: 700; margin: 0 0 10px; }

/*
 * THE TWO WORKED EXAMPLES INSIDE THE "Display name" PAIR.
 *
 * ⭐ Each button shows what that choice actually renders as — the player's own name, or their
 * barcode. `identity-and-leaderboards.md` §2b: private IS anonymous, drawn as the barcode, so
 * these are the two real outcomes rather than a description of them.
 *
 * ⚠️ **NO SIZING FOR THE BARS HERE.** `.barcode-name` is deliberately UNSCOPED (see its own note
 * — scoping it to board rows once made the same SVG render as invisible black elsewhere), and it
 * already sizes itself to the capital height of whatever text it sits beside. Adding a height
 * here would be a second opinion about the same thing.
 */
.anon-eg {
  opacity: 0.75;
  margin-left: 0.15em;
}

/*
 * ⭐ THE SYNC STATE LINE — identity plan Step 4. A refusal carries the accent bar, like every warning note, because
 * it is the one state that will not clear on its own; the other states read as plain notes.
 */
/*
 * ⛔⛔ **TIGHT INSIDE A BLOCK, LOOSER BETWEEN BLOCKS** — his report, 2026-09-23: *"the sign in button is glued to
 * the text below… sometimes we add the right padding, sometimes not."* Measured on the tower entry: 26px from the
 * message to its own Sign in button and 12px from that button to the NEXT block, so the button read as belonging
 * to what followed it. ⭐ The rhythm is the flex `gap` inside (8px) against the block's own bottom margin (18px,
 * the same as the paragraph above it). ⚠️ The note's own margins must go, or they add to the gap.
 */
.sync-status { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0 0 1.125rem; }

/*
 * ⭐ THE OVERLAP LINE BELONGS TO THE LIST ABOVE IT — same rhythm as `.sync-status`: 8px to what it explains,
 * and the block's own 18px to whatever follows. ⚠️ `.note`'s default 20px top margin reads as a new block.
 */
#tower-demand-note, #team-demand-note { margin: 0.5rem 0 0; }

/*
 * ⭐ **THE COLLAPSE CHOICE — A CARD EACH** (his ask, 2026-09-23: *"the options should be inside a card or
 * something more visible… the distance between 'climb from floor 1' and the text associated is too far and
 * doesn't seem related to it"*). ⭐ The rhythm of `ui-rules.md`: 8px from a button to its own words, 18px
 * between the two cards.
 */
/*
 * ⭐ **THE TOWER ENTRY'S ACTION BAR** — his ruling, 2026-09-23: the button comes to the player rather than the
 * player scrolling to the button. ⚠️ `sticky`, not `fixed`: it sits in the flow at the end of the entry, and only
 * pins itself while the entry is taller than the screen. ⛔ It clears the tab bar, which is measured at runtime
 * into `--tabbar-h` — a hard-coded height here would tuck the button under it on a phone with a home bar.
 */
.entry-actions {
  position: sticky;
  bottom: calc(var(--tabbar-h, 4.5rem) + 0.5rem);
  z-index: 5;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  margin: 1.125rem 0 0;
  padding: 0.75rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  /* ⚠️ Opaque: the guard list scrolls UNDER it. */
  background: var(--panel);
}
/*
 * ⚠️ **SCOPED WITH THE ID, because `#tower-entry .result-actions` and the entry's own note rules carry one** — a
 * plain `.entry-actions .note` loses to them and the bar inherits an 18px and a 14px margin it did not ask for.
 * ⭐ Inside the bar, spacing comes from its `gap` and nothing else.
 */
#tower-entry .entry-actions .note,
#tower-entry .entry-actions .result-actions { margin: 0; }
/* ⚠️ One gap in the bar: the two buttons sat 10px apart while everything else used 8. */
#tower-entry .entry-actions .result-actions { gap: 0.5rem; }

/*
 * ⭐ PIECE 6 — WHICH TOWER. A row of chips above the board pager; it wraps rather than scrolls, because after a
 * few years there could be several and a hidden chip is a tower nobody can reach. ⚠️ The rhythm of `ui-rules.md`:
 * 8px between chips, 18px to the board below.
 */
/*
 * ⭐ **THE CONTROL SITS INSIDE ITS SENTENCE** — his wording, 2026-09-23: "Currently showing the leaderboard for
 * [button]". ⚠️ The button keeps a line of its own under the words, so a long tower name never squeezes them.
 */
.tower-pick-line { display: flex; flex-direction: column; align-items: stretch; gap: 0.5rem; margin: 0.5rem 0; }
.tower-pick { display: block; width: 100%; margin: 0; }

/*
 * ⭐ **A LIST IN A MODAL — the title and the footer stay, the rows scroll.** His ruling, 2026-09-23. ⛔ The whole
 * dialog scrolling is what put a title off the top of the screen on the tower entry; a grid with one scrolling
 * row cannot do that. ⚠️ `min-height: 0` is what actually lets the middle row shrink and scroll.
 */
/*
 * ⛔⛔ **`[open]`, OR THE DIALOG IS ALWAYS ON SCREEN** — his screenshot, 2026-09-23: an empty "Which tower" box
 * sat under the landing screen. The browser hides a closed dialog with `dialog:not([open]) { display: none }`,
 * and ANY author rule that sets `display` beats it whatever its specificity, because author styles outrank the
 * user agent's. ⚠️ Every future dialog layout must carry `[open]` for the same reason.
 */
dialog.list-modal[open] {
  display: grid;
  grid-template-rows: auto 1fr auto;
  gap: 0.5rem;
  max-height: min(70dvh, 32rem);
  overflow: hidden;
}
.list-modal-rows { overflow-y: auto; min-height: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.list-modal-foot { display: flex; justify-content: center; margin: 0; }
.tower-row {
  display: block;
  width: 100%;
  padding: 0.75rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--panel);
  color: var(--fg);
  text-align: left;
}
.tower-row[aria-pressed="true"] { border-color: var(--accent); }
/* ⭐ A LINE PER SENTENCE, and the details smaller — his ruling: a paragraph in a list row reads as a block. */
.tower-row b { display: block; margin-bottom: 0.25rem; }
.tower-row span { display: block; color: var(--dim); font-size: var(--type-small, 0.8em); }
.tower-row span + span { margin-top: 0.15rem; }
#boards-tower-note { margin: 0 0 1.125rem; }

#tower-choice { display: flex; flex-direction: column; gap: 1.125rem; margin-bottom: 1.125rem; }
#tower-choice-head { margin: 0; }
.choice-card {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.75rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--panel);
}
.choice-card .note { margin: 0; }
#tower-demand { margin-bottom: 1.125rem; }
.sync-status .note { margin: 0; }
.sync-status[data-kind="refused"] .note {
  padding: 0.5rem 0.65rem;
  border-left: 2px solid var(--accent);
  background: var(--panel);
  color: var(--fg);
}
