/* ---- self-hosted fonts: Space Grotesk 400/600/700 (one variable face, wght 400-700), Space Mono 400/700 (latin subset) ---- */
@font-face{
  font-family:'Space Grotesk';font-style:normal;font-weight:400 700;font-display:swap;
  src:url('/fonts/space-grotesk-variable.woff2') format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face{
  font-family:'Space Mono';font-style:normal;font-weight:400;font-display:swap;
  src:url('/fonts/space-mono-400.woff2') format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face{
  font-family:'Space Mono';font-style:normal;font-weight:700;font-display:swap;
  src:url('/fonts/space-mono-700.woff2') format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}

:root{
  --ink:#131b25;
  --ink-2:#0e151d;
  --panel:#1c2734;
  --panel-2:#233040;
  --line:#2e3d4e;
  --line-soft:#25313f;
  --ivory:#efe6d4;
  --ivory-2:#dccfb7;
  --graphite:#141c26;
  --text:#dbe4ee;
  --dim:#7e8fa3;
  /* Not a text colour (#451): at 2.8-3.0:1 on every ground it sat on, it
     failed AA wherever it carried words, so all text that wore it now wears
     --dim. It stays for the marks that are not text, such as the eyebrow
     dot, and for the colophon's deliberately faint version stamp. */
  --dimmer:#546375;
  /* End round's penalty figure: --dim with a little --cold in it, so the
     cost reads as a cost while clearing AA on the footer (#451). It was
     #7a5f69, --dimmer's pink, at 3.2:1. */
  --pen:#8f8492;
  /* The two-tone focus ring (#507): a white ring between two dark halos.
     No single colour can clear 3:1 against every fill this interface paints
     (--struct against the lit Play word button was 1.00:1), but a pair can,
     because whatever the ring sits on, one of the two tones stands off it.
     Neither is a theme colour, so the ring is the same in both themes. */
  --focus-ring:#ffffff;
  --focus-halo:#0a0f16;
  --hot:#59b97e;
  --warm:#e0c04c;
  --mid:#df8b3e;
  --cold:#d1574d;
  --struct:#4a86c8;
  --dl:#2b5a86;
  /* --tl was #17466f: the same blue as --dl, four steps darker. DL and TL
     together are about half of every board's premium squares, so a pair
     separated only by lightness made a whole board read as one dreary
     colour — and worse, "darker" is not a cue a player can read at cell
     size in a dim room. Green separates them by hue (~129deg vs --dl's
     ~208deg) instead, and sits at the same luminance as --dw and --tw
     (contrast against white label text 6.2:1, versus 6.3:1 and 6.7:1), so
     the four classic squares now differ in hue while matching in weight —
     no square shouts, and no two whisper the same thing. Green also keeps
     the letter/word split legible: DL/TL are the two cool-to-neutral
     squares, DW/TW the two warm ones. */
  --tl:#286e33;
  --dw:#8c4560;
  --tw:#a83a33;
  --vb:#61479b;
  /* An Either tile's two letters (#583, John 2026-09-24): the first in red,
     the second in blue. Inks for the tile's ivory, not for the page, so no
     light-theme override: tiles are ivory in both themes (see the light
     theme block). Each clears 5:1 against the darker end of the tile's
     gradient, #e2d6bf, for letters and corner numbers alike. */
  --either-a:#a8231c;
  --either-b:#1a569a;
  --radius:0.5625rem;
  /* The column's side gutter: the inset of the header and footer rules, the
     board's area, the rack, the bag strip, the message line and the action
     rows. One token so they move together, which keeps the rules' ends on
     the board's edges (John, 2026-09-12); the narrow-screen rule below
     (#558) narrows it. */
  --gutter:1rem;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0;background:var(--ink-2);color:var(--text)}
/* viewport-fit=cover in index.html draws the page under the notch, the home
   indicator and the rounded corners, so the page pads itself clear of every
   one of them (#461). All four sides: in landscape the cutout and the corners
   move to the left and right edges, where a literal 0 used to be. */
body{
  font-family:'Space Grotesk',system-ui,-apple-system,'Segoe UI',sans-serif;
  min-height:100dvh;
  display:flex;justify-content:center;
  padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
/* Stops pull-to-refresh and the overscroll bounce from fighting touch-drag
   tile placement on the board (#454). On html, not only body: the viewport
   takes overscroll-behavior from the root element, never from body — Safari
   and Firefox always did, and Chromium stopped propagating it from body — so
   a body-only declaration left Android pull-to-refresh live, and a refresh
   ends the round. body keeps it too, for when the page is framed. */
html,body{
  /* eslint-disable-next-line css/use-baseline -- deliberate: the viewport reads this from the root; engines without it keep today's behaviour. */
  overscroll-behavior:none;
}
/* The unthemed page is dark (#520): until the settings module stamps
   data-theme on the root, the palette above is the dark one, so the canvas,
   the scrollbars and any form control must be dark too. The two
   [data-theme] rules under "settings" override this once boot resolves the
   player's theme. The <meta name="color-scheme"> in index.html says the same
   thing before this stylesheet has even arrived, which is what stops the
   white flash on a cold load. */
:root{color-scheme:dark}
/* No double-tap-to-zoom wait on anything tappable (#464). manipulation keeps
   panning and pinch-zoom and drops only double-tap zoom, which is what makes
   iOS hold every tap for ~300ms to see whether a second one follows. :where()
   keeps this at zero specificity so it can never outrank a component's own
   touch-action: .cell and .rtile take pinch-zoom, which the drag code in
   src/ts/ui/drag.ts depends on (see the rack section, #388). The scrollers
   and the sheet backdrop are listed as well as the
   controls, because a touch's allowed behaviours stop at the nearest
   scroller, so html's value alone never reaches a button inside .card. */
:where(html,button,a,input,.sheet,.card,.track,.btile){touch-action:manipulation}
/* The app is exactly one viewport tall and never scrolls (John, 2026-09-12).
   The board is the only thing that gives: #boardArea is a size container,
   and #board takes the smaller of its width and #boardArea's height, so on a short,
   wide screen the board shrinks to fit above the rack instead of pushing the
   controls off the bottom. Every label and tile letter is sized from the cell
   through container-query units, so the shrink is uniform and every border
   stays on a whole pixel — no transform, which is what puts non-uniform
   borders on scaled squares. The sheets are position:fixed and scroll inside
   their own card, so hiding the page's scrollbar costs them nothing. */
html,body{overflow:hidden}
#app{width:100%;max-width:28.75rem;display:flex;flex-direction:column;height:100dvh}
/* #app ships hidden and boot() reveals it (#579); the display:flex above
   outranks the browser's own [hidden]{display:none}, so without this the
   shell's controls would be on screen, inert, before the bundles run. */
#app[hidden]{display:none}
/* The interface scales with the window (John, 2026-09-12). Every length in
   this sheet is in rem, so one root font size sets the size of everything
   but the board, which fills whatever height is left. The root is the
   window's size relative to the layout's authored column, 460px wide by
   860px tall at 16px, whichever axis is tighter: 16px / 460px is 3.4783vw,
   16px / 860px is 1.8605dvh. The 860 is the column's measured 748px of
   chrome and board plus 112px of deliberate slack, 56px above the board and
   56px below, so that when height binds the board still sits with a margin
   of about 1.3 times the gap under the bag strip (John, same day: filling
   the height exactly left the board with a bare 1em around it). Floored at
   16px, so phones and short windows keep the authored layout exactly;
   capped at 40px. */
html{font-size:clamp(16px,min(3.4783vw,1.8605dvh),40px)}
/* eslint-disable-next-line css/use-baseline -- deliberate: ui-monospace is a valid system-font fallback ahead of the generic monospace keyword. */
.mono{font-family:'Space Mono',ui-monospace,monospace}

/* ---- header ---- */
/* One row (John, 2026-09-12): the score, then two lines beside it — the seed
   above, mode and difficulty below — mirroring the two lines on the far
   side, so the old eyebrow row above the score carries nothing the header
   does not already say and is gone. The leftover-tile penalty that used to
   trail the score now sits in parentheses on the End round button, where
   the number is about the decision it drives. */
/* The rules carry the gutter as margin, not padding, so their ends meet the
   board's edges instead of running the full column (John, 2026-09-12). */
header{padding:0.875rem 0 0.625rem;margin:0 var(--gutter);border-bottom:1px solid var(--line-soft)}
.readout{display:flex;align-items:flex-end;justify-content:space-between;gap:0.75rem}
.scoreblock{display:flex;align-items:flex-end;gap:0.75rem;min-width:0}
.score{font-family:'Space Mono',monospace;font-size:2.375rem;font-weight:700;line-height:1;letter-spacing:-.02em}
.tag{display:flex;flex-direction:column;font-size:0.6875rem;color:var(--dim);line-height:1.5;white-space:nowrap}
/* The seed's characters carry a faint blue, --dim leaning toward the 2L
   square's --dl (John, 2026-09-12), the way End round's figure leans pink. */
#seedLabel .seed{color:#6a9bd4}
/* Seed links (#699): every seed opens its board in a new tab. They keep the
   voice of the text around them (its colour, its face, the header seed's
   blue) and say "link" only with a dotted underline in that same colour,
   which firms to solid under a pointer, so the seed reads as something you
   can follow without shouting over the score beside it. The global
   :focus-visible ring marks keyboard focus. */
.seedlink{color:inherit;text-decoration-line:underline;text-decoration-style:dotted;text-underline-offset:0.1875em}
.seedlink:hover{text-decoration-style:solid}
.meta{text-align:right;font-size:0.6875rem;color:var(--dim);line-height:1.5}
.pips{display:flex;gap:0.25rem;justify-content:flex-end;margin-bottom:0.1875rem}
/* A pip that fills eases in over half a second, in its own ramp colour (John, 2026-09-12); pips.ts sets --pip per pip. */
.pip{width:0.5625rem;height:0.5625rem;border-radius:0.125rem;background:var(--line);border:1px solid var(--line);transition:background-color .5s ease,border-color .5s ease}
.pip.used{background:var(--pip,var(--struct));border-color:var(--pip,var(--struct))}

/* ---- board ---- */
/* min-height:0 lets a flex child shrink below its content; without it `main`
   or #boardArea would grow to fit the board and the page would scroll after
   all. `main` is the whole playing surface (#526): the board's area, then
   the rack, the bag, the replay, the message line and the action rows, the
   same column #app laid out before they moved inside it. It is a flex
   column with no padding of its own, so each child sits exactly where it
   sat as a child of #app. Its visually hidden h1 is absolutely positioned
   (.vh), so it is no flex item and takes no height. */
main{flex:1;min-height:0;display:flex;flex-direction:column}
/* The size container is #boardArea, not `main`, so that 100cqh below is the
   height left for the board alone, as it was when `main` held nothing but
   the board. #boardArea holds only #board: an element added beside the
   board would take height from it, and one added to `main` never can. */
#boardArea{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:center;align-items:center;padding:0.875rem var(--gutter);container-type:size}
/* 100cqh is #boardArea's content height. The board is square (border-box, symmetric
   padding, square cells), so its width is its height. --cell is one column's
   worth of the board's own content width, read through the inline-size
   container below; --n is set by round.ts when a round starts.
   The board ships empty and round.ts fills it, so its own aspect-ratio
   reserves the square before any cell exists (#418): without it the board
   paints 6px tall and jumps to full size when a round starts, which is
   layout shift. Every board is n x n with one uniform gap, so the ratio is
   1 at every difficulty, and border-box puts the padding inside it. */
#board{
  display:grid;gap:0.1875rem;width:min(100%,100cqh);aspect-ratio:1;container-type:inline-size;
  background:var(--line-soft);padding:0.1875rem;
  /* 7px, the gap plus a cell's own 4px radius, so a corner cell's corner meets the
     board's edge instead of poking past a larger curve (John, 2026-09-12). */
  border-radius:0.4375rem;
  /* --n has no default here on purpose: round.ts sets it inline before the
     first cell exists, and a CSS default would let the lint's var() resolver
     inline it and then reject the perfectly valid nested math. */
  --cell:calc(100cqw / var(--n));
  /* Type sizes as fractions of a cell, floored so a tiny board stays legible
     rather than shrinking its labels to nothing. At the 42px cell of a
     7-column board in the 460px column these reproduce the old fixed sizes:
     19px tile letters, 12px digraphs. The labels' floor is 9px at the 16px
     root (#409), the size most platform guidelines treat as the least a
     line of text should be, so a label is never smaller than that; on the
     42px cell it was 8px. */
  --label:max(0.5625rem,calc(var(--cell) * 0.19));
  --label-small:max(0.5625rem,calc(var(--cell) * 0.18));
  --letter:max(0.625rem,calc(var(--cell) * 0.45));
  --digraph:max(0.5rem,calc(var(--cell) * 0.29));
  /* iOS opens its copy-and-look-up callout on a long press, and user-select
     alone does not stop it; on the board that press is the start of a drag
     (#367). manipulation keeps the gaps between squares free of the
     double-tap wait; the squares' own pinch-zoom narrows it further. */
  -webkit-touch-callout:none;
  touch-action:manipulation;
}
/* The play surfaces are not text (#367): a drag across them must not become
   a selection, and a long press or a double tap must not highlight a letter.
   Only these, never the root, so the share text, seeds, scores and every
   sheet's prose stay selectable. A future drag surface joins this list by
   name rather than by widening it to a common ancestor. The bag strip has
   the same declaration on its own .track rule. */
#board,.rack,.alpha{
  -webkit-user-select:none;
  /* eslint-disable-next-line css/use-baseline -- deliberate: a drag or long press on a play surface must not select; see .track. */
  user-select:none;
}
/* The board grid's rows (#391): role="row" wrappers the ARIA grid pattern
   needs around its cells, and no box of their own, so the squares stay items
   of #board's CSS grid and the layout is what it was without them. */
.brow{display:contents}
.cell{
  aspect-ratio:1;border-radius:0.25rem;background:var(--panel);
  display:flex;align-items:center;justify-content:center;position:relative;
  /* eslint-disable-next-line css/no-invalid-properties -- --label is declared on #board from container-query math the rule cannot resolve; the value is valid CSS. */
  font-size:var(--label);letter-spacing:.06em;color:#fff;font-weight:400;
  border:none;padding:0;font-family:inherit;cursor:pointer;
}
/* The label is solid white (#409). A square's colour is decoration and its
   label is the information (John, 2026-08-25), so the label is the only
   channel a square's rule reaches the player through; at the 55% white it
   used to wear it measured 2.9-4.4:1 on 16 of the 19 fills. Solid, it clears
   AA on every fill, which contrast_focus.spec.ts computes off this sheet. */
.cell.dl{--fill:var(--dl)}
.cell.tl{--fill:var(--tl)}
.cell.dw{--fill:var(--dw)}
.cell.tw{--fill:var(--tw)}
.cell.vb{--fill:var(--vb)}
.cell.cr{--fill:#2a6f6a}
.cell.ks{--fill:#3d4a86}
.cell.rc{--fill:#7a5a1e}
.cell.hm{--fill:#3f5568}
.cell.es{--fill:#7a3080}
.cell.co{--fill:#17607d}
.cell.to{--fill:#6b2230}
.cell.vd{--fill:#0b1017}
.cell.cl{--fill:#1f6b4a}
.cell.br{--fill:#5a4a8c}
.cell.ga{--fill:#8a5a22}
.cell.ec{--fill:#2f5f7a}
.cell.mr{--fill:#7a4a5f}
.keychip.dl{--fill:var(--dl)}.keychip.tl{--fill:var(--tl)}.keychip.dw{--fill:var(--dw)}
.keychip.tw{--fill:var(--tw)}.keychip.vb{--fill:var(--vb)}.keychip.cr{--fill:#2a6f6a}
.keychip.ks{--fill:#3d4a86}.keychip.rc{--fill:#7a5a1e}.keychip.hm{--fill:#3f5568}
.keychip.es{--fill:#7a3080}.keychip.co{--fill:#17607d}.keychip.to{--fill:#6b2230}
.keychip.vd{--fill:#0b1017}
.keychip.cl{--fill:#1f6b4a}.keychip.br{--fill:#5a4a8c}.keychip.ga{--fill:#8a5a22}
.keychip.ec{--fill:#2f5f7a}.keychip.mr{--fill:#7a4a5f}

/* A square's colour is decoration and its label is the information (John,
   2026-09-12), so the board carries no per-type shape or ring channel. Two
   marks remain, both as taste. Every premium square wears .bonus: a larger
   corner radius than a plain square, and its colour brought in two pixels
   behind a frame in the plain square's own colour, so the coloured area sits
   inside a round-cornered plain cell (John, same day: he liked the larger
   radius once it was gone, and asked for the inset). The three squares that
   can cost a player points — Void, Toll, Cold — also wear .pf-hazard, a
   45-degree hatch behind the label, because a hatch is the oldest hazard
   idiom there is and this sheet already uses it (see .btile.hidden). It sits
   at 6% white — half the 12% #614 shipped — so the stripes read as texture
   rather than as a second colour. Both classes come from
   src/ts/ui/premium_marks.ts, on the board cell and its key-sheet chip alike.
   Void keeps its faint ring, moved just inside the frame, since its fill is
   the darkest on the board. */
/* Three layers, as John reads a cell: the board's gap around it, the cell's
   own neutral background, and the coloured patch inside that. Every cell
   keeps the same outline and 4px radius, plain or premium; what a premium
   square adds is the patch, a ::before inset two pixels with its own 9px
   radius, painted from the --fill token the per-type rules above set. The
   patch sits behind the label and under any tile: with the cell isolated,
   z-index:-1 puts it above the cell's background and below its content. */
.cell.bonus,.keychip.bonus{isolation:isolate;position:relative}
.cell.bonus::before,.keychip.bonus::before{
  content:'';position:absolute;inset:0.125rem;z-index:-1;border-radius:var(--patch-radius,0.5625rem);background:var(--fill);
}
/* Void's faint ring, on its patch: its fill is the darkest on the board. */
.cell.vd::before,.keychip.vd::before{box-shadow:inset 0 0 0 1px #33414f}
.cell.pf-hazard::before,.keychip.pf-hazard::before{
  background-image:repeating-linear-gradient(45deg,#ffffff10,#ffffff10 0.125rem,transparent 0.125rem,transparent 0.375rem);
}
/* eslint-disable-next-line css/no-invalid-properties -- --label-small is declared on #board from container-query math the rule cannot resolve; the value is valid CSS. */
.cell.to,.cell.vd{font-size:var(--label-small)}
.cell.target{outline:0.125rem solid var(--struct);outline-offset:-0.125rem}
.tile{
  position:absolute;inset:0;border-radius:0.25rem;
  background:linear-gradient(#f4ecdd,#e2d6bf);
  color:var(--graphite);
  display:flex;align-items:center;justify-content:center;
  /* eslint-disable-next-line css/no-invalid-properties -- --letter is declared on #board from container-query math the rule cannot resolve; the value is valid CSS. */
  font-weight:700;font-size:var(--letter);
  box-shadow:inset 0 -0.125rem 0 #b9ab90, 0 1px 0.125rem #0006;
}
.tile .v{
  position:absolute;right:0.125rem;bottom:1px;
  /* eslint-disable-next-line css/no-invalid-properties -- --label is declared on #board from container-query math the rule cannot resolve; the value is valid CSS. */
  font-family:'Space Mono',monospace;font-size:var(--label);font-weight:400;opacity:.6;
}
.tile.pending{
  background:linear-gradient(#fffaf0,#efe2c9);
  box-shadow:inset 0 -0.125rem 0 #b9ab90, 0 0 0 0.125rem var(--struct);
  /* Not yet played, so not yet solid (John, 2026-09-12); and it can be
     dragged to another square, so it says so. */
  opacity:.85;cursor:grab;
}
.tile.seedtile{background:linear-gradient(#cfc4ae,#bcb098);color:#2a3542;box-shadow:inset 0 -0.125rem 0 #9b9078}
.tile.solved{background:linear-gradient(#3f6e52,#33593f);color:#e9f5ec;box-shadow:inset 0 -0.125rem 0 #2a4a35}

/* ---- rack ---- */
/* align-items:center rather than stretch, so a rack tile's height comes from
   its own aspect-ratio below and not from the row's: a tile that flex has
   narrowed stays square instead of keeping the row's full height. */
.rack{display:flex;gap:0.3125rem;padding:0 var(--gutter) 0.625rem;justify-content:center;align-items:center;min-height:3.25rem}
/* Same trap as .actions: .rack's display:flex outranks the browser's own
   [hidden]{display:none}, so without this the rack review.ts hides for the
   replay stayed on screen under it. */
.rack[hidden]{display:none}
/* 2.625rem is the tile's width wherever the rack has room, and the width
   flex shrinks from where it has not (#636): the tiles share whatever a
   narrow rack has, on a 320px phone or in the landscape layout's side
   column, rather than running off its edge. aspect-ratio keeps the height
   equal to the width at any size, where a fixed 2.625rem height left a
   squeezed tile taller than wide (36.9x42 at 320px). */
.rtile{
  width:2.625rem;height:auto;aspect-ratio:1;border-radius:0.3125rem;border:none;padding:0;cursor:pointer;
  background:linear-gradient(#f4ecdd,#e2d6bf);color:var(--graphite);
  font-family:inherit;font-weight:700;font-size:1.1875rem;position:relative;
  box-shadow:inset 0 -0.1875rem 0 #b9ab90,0 0.125rem 0.1875rem #0007;
  transition:transform .12s ease, box-shadow .12s ease;
}
.rtile .v{position:absolute;right:0.1875rem;bottom:0.125rem;font-family:'Space Mono',monospace;font-size:0.5625rem;font-weight:400;opacity:.6}
/* A selected tile is lifted out of the row, and the lift is the whole
   signal (#509). It used to wear a --struct ring as well, which read as
   the focus ring (#777) now that the rack is walked by keyboard (#442): a
   ring means focus, and only focus. aria-pressed says it to a screen reader. */
.rtile.sel{transform:translateY(-0.4375rem);box-shadow:inset 0 -0.1875rem 0 #b9ab90,0 0.375rem 0.625rem #0008}
/* Both are also disabled in the markup (#481), which is what keeps the
   keyboard off them. pointer-events:none stays for a different job: a drag
   finds its drop target with elementFromPoint, which a disabled button does
   not escape, so without it a tile dropped on a spent or empty slot would
   reorder into it rather than fall through to the rack. */
.rtile.spent{opacity:.16;pointer-events:none}
.rtile.empty{background:none;box-shadow:inset 0 0 0 1px var(--line);pointer-events:none}
/* A drag starts on a rack tile or a board square, so neither may pan the page
   under a single finger: src/ts/ui/drag.ts would be fighting the scroll. But
   the page may still zoom (#388). pinch-zoom withholds one-finger panning and
   leaves the two-finger pinch, and the drag tracks exactly one pointer, so a
   pinch is never a drag. These are the smallest type on the page, and the
   place a player who cannot read them will pinch. */
.rtile,.cell{touch-action:pinch-zoom}
/* The browser's grey tap flash fires on the touchstart that begins a drag,
   and looks wrong under one, so it is withheld only where a drag can start:
   the rack, the board (the pending tiles inherit it) and the bag strip. Every
   other control keeps the browser's flash as well as its own press state
   below (#513). */
.rtile,.cell,.track{-webkit-tap-highlight-color:transparent}
.rtile.dragging,.tile.dragging{opacity:.25}
.rtile.slidepeer{transition:transform .13s ease}
.cell.droptarget{outline:0.125rem dashed var(--struct);outline-offset:-0.125rem;background:var(--panel-2)}
#ghost{
  position:fixed;z-index:60;pointer-events:none;width:2.625rem;height:2.625rem;border-radius:0.3125rem;
  background:linear-gradient(#fffaf0,#efe2c9);color:var(--graphite);
  font-weight:700;font-size:1.1875rem;display:flex;align-items:center;justify-content:center;
  box-shadow:0 0.5rem 1.125rem #000a,0 0 0 0.125rem var(--struct);transform:translate(-50%,-115%);
}
/* #ghost's own unconditional display:flex above outranks the UA stylesheet's
   [hidden]{display:none} (an ID selector beats an attribute selector), so
   without this the `hidden` property drag.ts sets never actually hides the
   ghost — only its accompanying left:-9999px offscreen park does, which
   fools the eye but not a real visibility check. Matches .sheet[hidden]
   below, for the same reason. */
#ghost[hidden]{display:none}
#ghost .v{position:absolute;right:0.1875rem;bottom:0.125rem;font-family:'Space Mono',monospace;font-size:0.5625rem;font-weight:400;opacity:.6}
#ghost.blank{background:linear-gradient(#e8e2f4,#d3c9e6)}

/* ---- bag strip ---- */
.strip{position:relative;padding:0.25rem var(--gutter) 0.75rem}
/* Drawn tiles leave the strip rather than being greyed (2026-09-12), so it
   is exactly what will be drawn next. Its letters are not selectable, and a
   mouse drag across it scrolls it sideways, as do the arrow, page, Home and
   End keys once it has focus (#445) — see src/ts/ui/strip.ts. Its focus ring
   is the global :focus-visible one, which fits inside .strip's top padding.
   The track ships empty and strip.ts fills it, so min-height reserves one
   row of .btile before the first tile exists (#418), the way .rack's
   min-height reserves the rack: the tile's 1.375rem, the 0.375rem padding
   above and below, and the 1px border hairline top and bottom, which stays
   1px at every root size. Change them together. */
.track{
  display:flex;gap:0.1875rem;overflow-x:auto;padding:0.375rem 0.5rem;cursor:grab;
  min-height:calc(1.375rem + 0.75rem + 1px + 1px);
  background:var(--ink);border:1px solid var(--line-soft);border-radius:0.4375rem;
  /* eslint-disable-next-line css/use-baseline -- deliberate: a drag across the strip scrolls it, so its letters must not select; every engine that runs this game supports user-select. */
  user-select:none;
  /* eslint-disable-next-line css/use-baseline -- deliberate: hides the bag strip's native scrollbar; the next rule's -webkit- prefix covers older engines. */
  scrollbar-width:none;
  /* eslint-disable-next-line css/use-baseline -- deliberate: over-swiping the strip's end must not chain to the browser's back/forward swipe (#456); contain keeps the strip's own bounce. Older engines ignore it and behave as before. */
  overscroll-behavior-x:contain;
}
.track::-webkit-scrollbar{display:none}
/* The hidden scrollbar's replacement (#577): whichever edge has tiles
   scrolled out past it dissolves into the track, so the tile cut off there
   reads as the start of more rather than the end of the bag. strip.ts sets
   more-before / more-after on #strip from the track's scroll measurements.
   The fades are drawn by the frame, not the track, because anything inside a
   scroll container scrolls away with it; they are inset by the track's 1px
   border and match its inner corner radius. A mask-image on the track would
   need no positioning, but it would fade the track's own border and panel
   too, eroding its rounded ends. No transition: they change as the player
   scrolls, and should track the scroll, not trail it. The positions below
   are .strip's padding and .track's border; change them together. */
.strip::before,.strip::after{
  content:"";position:absolute;top:calc(0.25rem + 1px);bottom:calc(0.75rem + 1px);width:1.75rem;
  pointer-events:none;opacity:0;
}
.strip::before{
  left:calc(var(--gutter) + 1px);border-radius:calc(0.4375rem - 1px) 0 0 calc(0.4375rem - 1px);
  background:linear-gradient(to right,var(--ink),transparent);
}
.strip::after{
  right:calc(var(--gutter) + 1px);border-radius:0 calc(0.4375rem - 1px) calc(0.4375rem - 1px) 0;
  background:linear-gradient(to left,var(--ink),transparent);
}
.strip.more-before::before,.strip.more-after::after{opacity:1}
.btile{
  flex:none;width:1.375rem;height:1.375rem;border-radius:0.1875rem;
  display:flex;align-items:center;justify-content:center;
  font-size:0.75rem;font-weight:700;
  background:var(--ivory-2);color:var(--graphite);
}
/* Centred when the bag fits, start-aligned when it overflows. Auto margins
   on the end tiles absorb free space when there is any and compute to zero
   when there is none; justify-content:center would instead push the first
   tiles past the left edge where no scroll can reach them. */
.btile:first-child{margin-left:auto}
.btile:last-child{margin-right:auto}
.btile.hidden{background:repeating-linear-gradient(45deg,#22303f,#22303f 0.1875rem,#1a2530 0.1875rem,#1a2530 0.375rem);color:transparent}

/* ---- actions ---- */
.actions{display:flex;gap:0.5rem;padding:0 var(--gutter) 0.625rem}
/* Same trap as #ghost and .sheet: an author rule's display:flex outranks the
   browser's own [hidden]{display:none} whatever the specificity, so without
   this the replay's Back / Next / Results row (#reviewActions, hidden in the
   markup and toggled by review.ts) drew during play as three dead buttons.
   Visible in the repo's own start-screen screenshot since August; reported by
   John on 2026-09-12. */
.actions[hidden]{display:none}
button.act{
  flex:1;padding:0.875rem 0.625rem;border-radius:0.5rem;border:1px solid var(--line);
  background:var(--panel);color:var(--text);font-family:inherit;
  font-size:0.8125rem;font-weight:600;letter-spacing:.04em;cursor:pointer;
}
button.act:disabled{opacity:.35;cursor:default}

/* Play word has three looks, and the difference between the last two is the
   whole point: `primary` alone means pressable but not yet a legal play,
   `primary.ready` means this exact placement would be accepted. The button
   stays *enabled* while unlit, so pressing it still reports why — the light
   is a signal, never a lock. */
button.act.primary{background:var(--panel-2);border-color:var(--line);color:var(--text)}
button.act.primary.ready{background:var(--struct);border-color:var(--struct);color:#08131f}
button.act.primary:disabled{background:var(--panel);border-color:var(--line);color:var(--text)}
/* The lit state's check mark (#429), added and removed by view.ts's
   renderPlayCue: the channel that is not colour. */
button.act .cue{margin-left:0.375rem}
@media (prefers-reduced-motion:no-preference){
  button.act.primary{transition:background .14s ease,border-color .14s ease,color .14s ease}
}
.msg{
  padding:0 var(--gutter) 0.625rem;font-size:0.75rem;min-height:2rem;color:var(--dim);line-height:1.4;
}
.msg.bad{color:var(--cold)}
.msg.good{color:var(--hot)}
footer{
  padding:0.5rem 0 0.875rem;margin:0 var(--gutter);border-top:1px solid var(--line-soft);
  display:flex;justify-content:space-between;align-items:center;
  font-size:0.6563rem;letter-spacing:.1em;text-transform:uppercase;color:var(--dim);
}
.chip{display:flex;align-items:center;gap:0.375rem}
/* The solver chip left the footer on 2026-09-12; the elements stay for the
   solver driver to write and the tests to read, just unseen. */
.chip[hidden]{display:none}
.led{width:0.375rem;height:0.375rem;border-radius:50%;background:var(--warm);animation:pulse 1.4s infinite}
.led.ready{background:var(--hot);animation:none}
@keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
.linkish{background:none;border:none;color:var(--dim);font-family:inherit;font-size:0.6563rem;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;padding:0;text-decoration:none}
/* The footer's centre: "Rackled <version>", stamped by the build, at half
   strength so it reads as a colophon, linking to the version history (John,
   2026-09-12). */
.ver{color:var(--dimmer);opacity:.5;text-decoration:none}
/* Help links on the left, End round alone on the right (John, 2026-09-12),
   so the round-ending control sits apart from the instructions. None of the
   three is underlined, and End round's penalty figure carries a faint pink:
   --pen, which is --dim with a little of --cold mixed in, so the cost reads
   as a cost while the words stay in the footer's own voice. */
#btnGiveUp .pen{color:var(--pen)}
/* Every control in the footer — Config, Rules, the version link and End
   round, plus Reload when installed — is small type on purpose, and the
   padding is what a finger hits (#436, #515): 0.875rem above and below lifts
   a 0.6563rem line to about 2.6rem, past the 24px of WCAG 2.5.8, and the
   equal negative margin gives it all back to the layout, so the footer's
   height does not move. The version link needs it as much as the buttons:
   on one line it measured 13px tall. Vertical padding only: Config and Rules
   sit a middot apart, and side padding would lay each one's hit area over
   the other's. Width comes from the words, all far wider than 24px; the
   min-width is a floor for a short translation. inline-block so the <a>s
   take the same box a button does. */
footer .linkish,footer .ver{display:inline-block;min-width:1.5rem;padding:0.875rem 0;margin:-0.875rem 0}

/* ---- narrow screens (#558) ---- */
/* At 360px and below (22.5rem: a media query's rem is the browser's 16px),
   the board and rack are small drag targets. On a 320px phone seven 2.625rem
   rack tiles and their six gaps no longer fit inside 1rem gutters, which left
   30px Medium squares and 37px rack tiles at 320x568. 360px, among the
   commonest Android widths (360x640, 360x800), is included too: its Brutal
   squares measured 32-33px and its Medium squares just under 44px with 1rem
   gutters, and the narrow gutters lift Medium past 44px at 360x640. 375px
   and wider keep the 1rem gutters. The page cannot scroll (see the
   fit rule above), so the room comes out of the margins instead. The side
   gutter narrows to 0.375rem for the whole column at once, so the header and
   footer rules still end on the board's edges (John, 2026-09-12), and the
   board's area gives up its top and bottom padding to the same 0.375rem,
   because a 320x568 phone is height-bound and only height makes its squares
   larger. Placed before the landscape rule so that, in the rare window that
   matches both, the landscape layout's own board padding wins. */
@media (max-width:22.5rem){
  :root{--gutter:0.375rem}
  #boardArea{padding-block:0.375rem}
}

/* ---- landscape: two columns (#636, #514) ---- */
/* On a phone held sideways the column's fixed chrome, about 330px of it,
   leaves the board some 30px of a 360px-tall screen, and the fit rule above
   can only shrink the board, since it is the one thing that gives. So below
   500px of height (31.25rem: a media query's rem is the browser's 16px, not
   the root's) and wider than tall, the page turns into two columns: the
   board on the left at the full height, and everything else stacked on the
   right, in the order the portrait column stacks it. John ruled on
   2026-09-24 for this over locking to portrait. Portrait is untouched: the
   condition needs landscape, so a short, narrow window (an old 320x480
   phone) keeps the column, where two columns would leave no room for either.

   #app becomes a grid, and main a subgrid of it, spanning every row and
   both columns, so that main's children (#boardArea, the rack, the bag, the
   replay, the message line and both action rows) sit in the same tracks as
   #app's own header and footer, which are not main's. No element moves in
   the markup, so the landmarks and the reading order are the portrait
   ones. The rows are the header, a flexible gap, the six rows of controls,
   another flexible gap and the footer: the header keeps the top, the footer
   the bottom, and the controls sit centred between them, while #boardArea
   spans them all. A hidden row (the replay's during play; the rack's, the
   bag's, the message's and the main actions' during the replay) is
   display:none, so its auto track is empty and takes no height.

   The board's column is as wide as #app is tall, so #boardArea is square and
   the board fills it less its padding; it gives up width only to leave the
   right-hand column its 17.5rem floor (280px at the 16px root, which every
   window under 500px tall has), the width seven rack tiles can shrink into.
   The right-hand column is at most the portrait column's 28.75rem, and the
   pair is centred when the window is wider than both. #app's height pays the
   top and bottom safe-area insets that body pads it by, which the
   100dvh of the portrait rule does not; in landscape the bottom inset is
   the home indicator's, and the footer sat under it.

   Behind @supports, though subgrid is Baseline, because an engine without
   it (Safari before 16) would give #boardArea, which has size containment
   and so no width of its own, an auto column of nothing: the board would
   vanish. There the portrait column stands, cramped but whole. */
@media (orientation:landscape) and (max-height:31.25rem){
  @supports (grid-template-rows:subgrid){
    #app{
      max-width:none;display:grid;justify-content:center;
      height:calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
      grid-template-columns:min(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom),100% - 17.5rem) minmax(0,28.75rem);
      grid-template-rows:auto 1fr repeat(6,auto) 1fr auto;
    }
    /* Positioned so they paint, and take pointers, above main's box, which
       spans their cells too. */
    header{grid-column:2;grid-row:1;position:relative}
    footer{grid-column:2;grid-row:10;position:relative}
    main{
      grid-column:1 / -1;grid-row:1 / -1;display:grid;
      grid-template-columns:subgrid;grid-template-rows:subgrid;
    }
    #boardArea{grid-column:1;grid-row:1 / -1;padding:0.5rem}
    #rack{grid-column:2;grid-row:3}
    #strip{grid-column:2;grid-row:4}
    #review{grid-column:2;grid-row:5}
    #msg{grid-column:2;grid-row:6}
    #reviewActions{grid-column:2;grid-row:7}
    #mainActions{grid-column:2;grid-row:8}
  }
}

/* ---- installed (#478) ---- */
/* Everything here is keyed off :root[data-installed], which
   src/ts/ui/standalone.ts sets from matchMedia's display-mode *and* iOS's
   navigator.standalone. A bare @media (display-mode: standalone) block would
   miss the second, and CSS cannot read it, so the script is the one place
   that decides. */
/* The wordmark: the setup sheet's title in the header's own small size and
   grey, so it names the app without competing with the score under it. In
   sentence case, the way that title and the footer colophon spell it. */
.appname{font-size:0.8125rem;font-weight:700;letter-spacing:-.01em;line-height:1;color:var(--dim);margin-bottom:0.625rem}
/* The recovered height. Where height binds (a small phone), #boardArea is a
   size container and the board already grows into it with no rule at all.
   Where width binds (most phones, portrait), the board cannot get wider, and
   #boardArea's justify-content:center would split the slack into dead space
   above and below it. Installed, the slack goes above instead: the board
   sits on the rack, so a drag is shorter and the board is nearer the thumb,
   and the one gap left opens under the header, far from anything a thumb
   aims at. */
:root[data-installed] #boardArea{justify-content:flex-end}
/* iOS lays its status bar over the page (black-translucent, #568) and draws
   its clock in white, whatever the theme, and it reads the style only at
   launch, so the theme switch cannot retarget it. Over the dark ground that
   is right; over the light theme's paper it is white on cream. So under the
   light theme an installed page paints the dark ground behind the status
   bar, exactly as tall as the bar: env(safe-area-inset-top), which is zero
   everywhere the bar is not overlaid (Android, landscape iPhone, a tab), so
   the band exists only where the white clock does. Above the sheets (20),
   whose light backdrop would otherwise take the band away, and below the
   toasts (40) and the drag ghost (60), which never reach the top edge. */
:root[data-installed][data-theme="light"] body::before{
  content:'';position:fixed;inset:0 0 auto;z-index:30;pointer-events:none;
  block-size:env(safe-area-inset-top,0px);background:#0e151d;
}

/* ---- overlays ---- */
/* A sheet covers the whole viewport, cutout and all, so its padding is the
   larger of its own 1.25rem and each side's safe-area inset (#461): the same
   breathing room where there is no cutout, and clear of it where there is
   (a landscape notch is 44pt, far more than 1.25rem). */
.sheet{
  position:fixed;inset:0;background:var(--backdrop,#080d13ee);z-index:20;
  display:flex;align-items:center;justify-content:center;
  padding:max(1.25rem,env(safe-area-inset-top)) max(1.25rem,env(safe-area-inset-right)) max(1.25rem,env(safe-area-inset-bottom)) max(1.25rem,env(safe-area-inset-left));
}
/* The blur behind a sheet is served only where it is cheap and wanted
   (#565): never to a player who asked for reduced transparency, and never
   on a narrow screen, where a full-viewport blur is a mobile GPU's most
   expensive frame and sits behind a 93%-opaque backdrop anyway. The setup
   sheet is up on every cold load, so this is the boot window's cost too.
   An engine that does not know the preference reads the whole condition
   as false and gets no blur: failing closed is the point, since the blur
   is barely visible and the preference is the accessibility guard. */
/* eslint-disable-next-line css/use-baseline -- deliberate: an engine without prefers-reduced-transparency skips the blur, which is the safe side (#565). */
@media (prefers-reduced-transparency:no-preference) and (min-width:43.75rem){
  /* eslint-disable-next-line css/use-baseline -- deliberate: blurs the page behind a modal sheet (setup/result/blank-tile/key overlays). */
  .sheet{backdrop-filter:blur(0.1875rem)}
}
.sheet[hidden]{display:none}
/* A sheet takes focus itself only as a fallback, when it opens with no control
   showing, or is uncovered with nowhere better to go (src/ts/ui/modal.ts). It
   is a container, not a control, and the global :focus-visible ring would
   otherwise trace the whole viewport. */
.sheet:focus-visible{outline:none;box-shadow:none}
.card{
  width:100%;max-width:26.25rem;background:var(--panel);border:1px solid var(--line);
  border-radius:0.875rem;padding:1.25rem;max-height:90dvh;overflow-y:auto;
  /* eslint-disable-next-line css/use-baseline -- deliberate: a sheet's scroll must stop at its own ends rather than chain to the page behind it, which on Android is pull-to-refresh from inside a modal (#458); contain keeps the card's own bounce. */
  overscroll-behavior:contain;
}
/* A sheet's title is its h2 and its sections are h3 (#526): the page's one
   h1 heads the game, and each sheet is a panel over it. The sizes are the
   ones the h1 and h2 had before they were demoted. */
.card h2{font-size:1.5rem;margin:0 0 0.25rem;letter-spacing:-.01em}
.card h3{font-size:0.9375rem;margin:1.125rem 0 0.5rem;letter-spacing:.02em}
.card p{font-size:0.8125rem;line-height:1.55;color:var(--dim);margin:0 0 0.875rem}
/* The setup sheet's status line: it says the word list is still arriving, or
   that it never did. Sits directly above the Start button so the reason a tap
   did nothing is next to the thing that was tapped. */
.card p.note{font-size:0.75rem;margin:0 0 0.625rem}
.card p.note[hidden]{display:none}
.card p.note.bad{color:var(--cold)}
/* The sheets' one-off sizes, moved here from style attributes in index.html
   so the page needs no inline styles and the Content-Security-Policy can say
   style-src 'self' without 'unsafe-inline' (#426, #410). The attributes were
   in px; here they are in rem at the 16px root, as every length in this sheet
   is (rem_sizing.spec.ts), so each is the attribute's size at the root's
   16px floor and scales with the window from there like everything around
   it. Each selector outranks the general rule it overrides (.card h2,
   .card p, .actions) and comes after it, which csp_inline.spec.ts checks
   through getComputedStyle. #rBar and #rPctBlock are with the meter's rules
   further down, for the same reason. */
.card p.fine{font-size:0.75rem}
#blankTitle,#keyTitle,#configTitle,#howTitle{font-size:1.25rem}
#blankHelp{margin-bottom:0.75rem}
#rHold p{margin:0.875rem 0 0.75rem}
/* The solver-unavailable note result.ts puts in place of the best line. */
#rLine p{margin:0}
#rOpen > .actions{padding:0 0 0.25rem}
#result .card > .actions{padding:1rem 0 0}
#howSheet .card > .actions{padding:0.875rem 0 0}
#btnStart,#btnReveal,#blankCancel,#btnKeyClose,#btnConfigClose{width:100%}
#blankCancel{margin-top:0.75rem}
#btnKeyClose,#btnConfigClose{margin-top:0.875rem}
/* End round's confirmation (#515): the blank chooser's heading size, and its
   two buttons flush with the card like the result sheet's. */
#endTitle{font-size:1.25rem}
#endSheet .card > .actions{padding:0.25rem 0 0}
/* The share image, revealed only as Save image's last resort, for a
   long-press (#498). Its width/height attributes are 800x660, and with no
   rule it drew at that size, bursting out of the 26.25rem card; this scales
   it to the card's width. The display rule outranks the browser's own
   [hidden]{display:none}, so the [hidden] override keeps it hidden until then. */
#shareCanvas{display:block;max-width:100%;height:auto;margin:0.375rem 0 0.625rem;border-radius:0.5rem}
#shareCanvas[hidden]{display:none}
/* The standing instruction and the open-the-image link, revealed with the
   canvas as Save image's last resort (#490). The explicit [hidden] rules keep
   both out of sight until then, whatever display a later rule gives them. */
#shareHint{margin:0.375rem 0 0}
#shareHint[hidden],#shareLink[hidden]{display:none}
.opts{display:flex;gap:0.375rem;margin-bottom:0.875rem}
.opt{
  flex:1;padding:0.6875rem 0.375rem;border-radius:0.5rem;border:1px solid var(--line);
  background:var(--ink);color:var(--dim);font-family:inherit;font-size:0.75rem;font-weight:600;cursor:pointer;
  position:relative;
}
.opt.on{border-color:var(--struct);color:var(--text);background:var(--panel-2);font-weight:700}
/* The chosen option's non-colour cue (#431): a bar along its foot, a shape
   that is there or is not, so the choice reads without telling one fill or
   border colour from another (the fills differ by 1.29:1). It sits in the
   bottom padding, clear of the <small> line, and is absolutely positioned,
   so the button's box does not change. Empty content, so it adds nothing to
   the accessible name; aria-pressed carries the state for a screen reader. */
.opt.on::after{
  content:'';position:absolute;left:30%;right:30%;bottom:0.25rem;height:0.125rem;
  border-radius:0.0625rem;background:var(--struct);
}
.opt small{display:block;font-weight:400;font-size:0.625rem;opacity:.7;margin-top:0.125rem}
.grade{display:flex;align-items:baseline;gap:0.625rem;margin:0.375rem 0 0.125rem}
.grade .big{font-family:'Space Mono',monospace;font-size:2.875rem;font-weight:700;line-height:1}
.grade .of{font-size:0.8125rem;color:var(--dim)}
.bar{height:0.4375rem;border-radius:0.25rem;background:var(--ink);overflow:hidden;margin:0.75rem 0 0.25rem}
.bar i{display:block;height:100%;border-radius:0.25rem;background:var(--hot)}
/* The meter starts empty; result.ts sets its width through CSSOM, which a
   CSP's style-src does not govern (#426). */
#rBar{width:0}
/* The percentage block's extra space above, moved from a style attribute
   (#426). After .grade, not with the other moved values above it: the id
   already outranks .grade's margin shorthand, but jsdom, which the tests
   compute styles in, lets a later shorthand override an earlier longhand
   whatever the specificity, and source order satisfies both. */
#rPctBlock{margin-top:1rem}
/* The best line is an <ol> for its semantics (#549), not its looks: the rows
   keep the .line look they always had. role="list" in the script restores
   the list semantics WebKit drops when list-style is none. */
#rLine ol{list-style:none;margin:0;padding:0}
/* Visually hidden, still read: the announcer and the result sheet's labels
   (#396, #549). Never display:none or [hidden], which remove an element from
   the accessibility tree along with the screen. Absolutely positioned, so a
   .vh inside a flex row such as .grade or .line takes no part in its layout. */
.vh{
  position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;
  overflow:hidden;clip-path:inset(50%);white-space:nowrap;
}
.sharetext{
  background:var(--ink);border:1px solid var(--line-soft);border-radius:0.5rem;
  padding:0.6875rem 0.75rem;font-size:0.75rem;line-height:1.6;white-space:pre-wrap;
  margin:0 0 0.625rem;color:var(--dim);
}
.grade .of b{color:var(--text);font-size:0.9375rem;font-weight:600;letter-spacing:.01em}
.share{
  font-size:1.1875rem;letter-spacing:0.125rem;padding:0.6875rem;background:var(--ink);
  border-radius:0.5rem;text-align:center;margin:0.375rem 0 0.75rem;
}
.line{display:flex;justify-content:space-between;font-size:0.7813rem;padding:0.375rem 0;border-bottom:1px solid var(--line-soft)}
.line span:last-child{font-family:'Space Mono',monospace;color:var(--dim)}
.legend{display:flex;flex-wrap:wrap;gap:0.25rem 0.75rem;font-size:0.6563rem;color:var(--dim);margin-top:0.5rem}
.review{padding:0 var(--gutter) 0.625rem}
.rtabs{display:flex;gap:0.375rem;margin-bottom:0.5625rem}
.rtabs .opt{padding:0.5625rem 0.375rem}
.rcap{
  background:var(--ink);border:1px solid var(--line-soft);border-radius:0.4375rem;
  padding:0.6875rem 0.75rem;font-size:0.8125rem;line-height:1.45;min-height:2.75rem;
}
.rcap b{font-weight:600}
.rcap .sc{font-family:'Space Mono',monospace;color:var(--hot);float:right}
.rcap .none{color:var(--dim)}
/* The replayed line's total (#542), under the caption and quieter than it:
   the caption is the step, this is the whole line's sum. */
.rtotal{font-size:0.7188rem;color:var(--dim);text-align:right;margin-top:0.375rem}
.keyrow{display:flex;gap:0.6875rem;align-items:flex-start;padding:0.5625rem 0;border-bottom:1px solid var(--line-soft)}
/* A square not on this board, shown only when the key is widened to every
   square (John, 2026-09-12): present but quieter. */
.keyrow.absent{opacity:.55}
.keychip{
  flex:none;width:2.375rem;height:2.375rem;border-radius:0.3125rem;display:flex;align-items:center;justify-content:center;
  font-size:0.5625rem;font-weight:600;color:#fff;letter-spacing:.04em;background:var(--panel-2);
}
.keytext b{display:block;font-size:0.8125rem;margin-bottom:0.125rem}
.keytext span{font-size:0.7188rem;color:var(--dim);line-height:1.4}
.alpha{display:grid;grid-template-columns:repeat(6,1fr);gap:0.375rem}
.alpha button{
  padding:0.75rem 0;border-radius:0.375rem;border:1px solid var(--line);background:var(--ink);
  color:var(--text);font-family:inherit;font-size:1rem;font-weight:700;cursor:pointer;
}
/* A pointer resting on a letter outlines it in the press colour, so a player
   sees which of 26 near-identical squares they are about to hit before the
   press fills it (#552). Only where hover is real: on touch a hover state
   sticks to the last square tapped. */
@media (hover:hover){.alpha button:hover{border-color:var(--struct)}}
.alpha button:active{background:var(--struct);color:#08131f}
/* Press states (#513): a finger that lands on a control sees it answer under
   the finger, not only in the small message line at the foot of the screen.
   filter:brightness() lifts whatever a square is painted with, so one rule
   covers every premium fill. None of these properties is
   transitioned, so a press looks the same with reduced motion on or off. */
button.act:active:not(:disabled){transform:translateY(1px);filter:brightness(1.15)}
.opt:active{filter:brightness(1.2)}
.linkish:active{color:var(--text)}
.cell:active{filter:brightness(1.35)}
.rtile:active{filter:brightness(1.1)}
.rtile.blank,.btile.blank{background:linear-gradient(#e8e2f4,#d3c9e6)}
/* An Either tile (#583) keeps the plain ivory: it is two letters, not a
   blank. Its letters sit side by side, first red and second blue, smaller
   than a single letter so the pair fits the tile; the corner shows the two
   half values in the same inks, lifted from the corner's usual .6 opacity so
   the colours still read. */
.ea{color:var(--either-a)}
.eb{color:var(--either-b)}
.rtile.either .ef,#ghost.either .ef{font-size:0.9375rem;letter-spacing:-.02em}
.rtile.either .v,#ghost.either .v{opacity:.9}
.v .eb{margin-left:0.1875rem}
.btile.either .ef{font-size:0.625rem;letter-spacing:-.03em}
/* Once placed, an Either shows its chosen letter like any tile, and a small
   red-and-blue bar in the top-right corner keeps saying it was an Either.
   Top-right, clear of the value corner and of the replay's top-left notch.
   Drawn with two border halves rather than a gradient, so Forced Colors,
   which repaints borders in a system colour, keeps the bar as a shape. */
.tile.either::after{
  content:'';position:absolute;top:0.125rem;right:0.125rem;width:0;height:0.1875rem;
  border-left:0.21875rem solid var(--either-a);border-right:0.21875rem solid var(--either-b);
}
/* eslint-disable-next-line css/no-invalid-properties -- --digraph is declared on #board from container-query math the rule cannot resolve; the value is valid CSS. */
.tile.digraph{font-size:var(--digraph);letter-spacing:-.02em}
.rtile.digraph{font-size:0.8125rem;letter-spacing:-.02em}
.btile.digraph{font-size:0.5313rem;letter-spacing:-.03em}
.tile.recent{box-shadow:inset 0 -0.125rem 0 #2a4a35,0 0 0 0.125rem var(--warm)}
/* The shown play's tiles in the replay also wear a small corner notch (#541):
   the warm ring alone was a colour-only cue, 3.32:1 against the green under
   it. The notch is a shape, so it reads without telling warm from green. It
   is a pseudo-element in the top-left corner, clear of the value in the
   bottom right, and absolutely positioned, so the tile's box is unchanged. */
.tile.recent::before{
  content:'';position:absolute;top:0;left:0;width:0.4375rem;height:0.4375rem;
  border-top-left-radius:0.25rem;background:var(--warm);clip-path:polygon(0 0,100% 0,0 100%);
}
/* Forced Colors discards box-shadow, so the ring vanishes there, and it
   repaints backgrounds, which can wash out the notch. An inset outline in a
   system colour survives both, and is a shape rather than a hue. It does not
   change the tile's box. (forced-color-adjust, which would keep the notch,
   is not yet baseline, and the lint config refuses it.) */
@media (forced-colors:active){
  .tile.recent{outline:0.125rem solid Highlight;outline-offset:-0.125rem}
  /* The same trade for every other state the sheet draws as a box-shadow
     ring, which Forced Colors discards with nothing left to say it (#449):
     the pending tile, the selected rack tile, and the empty rack slot's
     outline. None has a text equivalent, so each becomes an outline in a
     system colour, which Forced Colors keeps. */
  .tile.pending,.rtile.sel{outline:0.125rem solid Highlight;outline-offset:-0.125rem}
  /* A tile both selected and focused has one outline to give. It goes
     outside, where focus draws everywhere else, so focus still shows; the
     lift, which Forced Colors keeps, still says selected (#442, #509). */
  .rtile.sel:focus-visible{outline-offset:0.0625rem}
  .rtile.empty{outline:1px solid GrayText;outline-offset:-1px}
  .cell.droptarget{outline:0.125rem dashed Highlight}
  /* States told only by fill and border colour, both of which Forced Colors
     repaints to one system pair: Play word lit (the placement would be
     accepted) against unlit, and which difficulty and mode are chosen. */
  button.act.primary.ready,.opt.on{outline:0.125rem solid Highlight;outline-offset:-0.1875rem}
  /* A gradient is a background-image, which some engines leave standing
     over the forced surface, where it can end up under CanvasText. */
  .tile,.tile.pending,.tile.seedtile,.tile.solved,.rtile,.rtile.blank,.btile,.btile.blank,#ghost,#ghost.blank{background-image:none}
}
/* eslint-disable-next-line css/no-important -- deliberate: a reduced-motion override has to beat every component's own animation/transition rule regardless of specificity. */
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
/* The focus ring (#507): white, with a dark halo on both sides of it. The
   halo is a box-shadow spread past the ring, so from the control's edge out
   it reads dark, white, dark, and the ring stands off whatever it is drawn
   on or beside — a premium fill, the lit Play word button (the old --struct
   ring against its --struct fill was 1.00:1), a cream tile, or either
   theme's ground. Under Forced Colors the box-shadow is dropped and the
   outline is repainted in the system's own focus colour, which is the
   remedy that mode exists for. The offset is one pixel, not two, so the
   ring sits closer to its control. */
:focus-visible{outline:0.125rem solid var(--focus-ring);outline-offset:0.0625rem;box-shadow:0 0 0 0.25rem var(--focus-halo)}
/* A board cell's ring is inset, over the cell's own two-pixel frame, with
   its halo outside in the 3px gap between cells, so a focused cell never
   reaches into its neighbours (#507: the old ring ran 1px into each). The
   ring is drawn above any tile on the cell, so the halo outside is what it
   stands off where the tile's cream is next to it. */
.cell:focus-visible{outline-offset:-0.125rem;box-shadow:0 0 0 0.125rem var(--focus-halo)}
/* The rack tiles carry box-shadows of their own, the bevel and the drop
   shadow, which the halo would otherwise replace. A selected tile's is the
   deeper drop of its lift; it has no ring of its own (#509), so focus on it
   reads exactly as focus on any tile, and the lift alone says selected. */
.rtile:focus-visible{box-shadow:inset 0 -0.1875rem 0 #b9ab90,0 0 0 0.25rem var(--focus-halo),0 0.125rem 0.1875rem #0007}
.rtile.sel:focus-visible{box-shadow:inset 0 -0.1875rem 0 #b9ab90,0 0 0 0.25rem var(--focus-halo),0 0.375rem 0.625rem #0008}

/* ---- settings ---- */
/* The Config sheet's slider row. */
.cfg{display:flex;align-items:center;gap:0.75rem;font-size:0.8rem;color:var(--dim);margin-bottom:0.875rem}
.cfg input[type=range]{flex:1}
/* Light theme (John, 2026-09-12): a first pass, paper rather than ink. The
   settings module resolves "system" through prefers-color-scheme and sets
   data-theme on the root, so only this one selector has to exist. Tiles,
   premium fills and the status colours are shared: they are saturated marks
   that read on either ground. */
:root[data-theme="light"]{
  --ink:#ebe6dc;
  --ink-2:#f4f1ea;
  --panel:#e2dccf;
  --panel-2:#d5cdbd;
  --line:#c6bcaa;
  --line-soft:#d8d0c1;
  --text:#1b2430;
  /* #5b6775 until #451: 4.2:1 on the card, below AA for the copy and the
     share legend that sit there. */
  --dim:#4f5a67;
  --dimmer:#8a929c;
  /* --pen for paper: the light --dim with the same touch of --cold. */
  --pen:#695962;
  --backdrop:#f4f1eaee;
  color-scheme:light;
}
:root[data-theme="dark"]{color-scheme:dark}
/* A theme change eases its colours over a third of a second (John,
   2026-09-12): the settings module puts .theme-fade on the root for the
   duration, so every other colour change stays instant. */
.theme-fade,.theme-fade *,.theme-fade *::before,.theme-fade *::after{
  transition:background-color .333s ease,color .333s ease,border-color .333s ease,box-shadow .333s ease
}
/* ---- stats (Config sheet) ---- */
/* The lifetime tally table: one row per difficulty-and-mode the player has
   started, numbers right-aligned in the mono face. */
table.stats{width:100%;border-collapse:collapse;font-size:0.7813rem;margin-top:0.625rem}
table.stats th,table.stats td{padding:0.3125rem 0.25rem;border-bottom:1px solid var(--line-soft);text-align:right}
table.stats th:first-child,table.stats td:first-child{text-align:left;font-weight:400;color:var(--dim)}
table.stats thead th,table.stats tr:first-child th{color:var(--dim);font-weight:400;font-size:0.6875rem;letter-spacing:.02em}
table.stats td{font-family:'Space Mono',monospace}
/* ---- result sheet eyebrow ---- */
/* "Round complete", a dot, then the mode, difficulty and seed in the copy
   block's grey (John, 2026-09-12: the three ran together with no space). */
.eyebrow{display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;margin-bottom:0.375rem}
/* "Round complete" is the result sheet's h2 (#526), its dialog's title, but
   it keeps the small bold look it had as a <b>: the eyebrow's own size, no
   margin, no tracking. The id outranks .card h2 and comes after it. */
#resultTitle{font-size:inherit;margin:0;letter-spacing:normal}
.eyebrow .dot{width:0.25rem;height:0.25rem;border-radius:50%;background:var(--dimmer);flex:none}
/* The capitals are this rule's alone (#560): result.ts writes the mode and
   difficulty in lower case, so assistive tech gets words, not shouted tokens. */
.eyebrow #rMode{color:var(--dim);font-family:'Space Mono',monospace;font-size:0.6875rem;letter-spacing:.02em;text-transform:uppercase}
/* A heading with its action on the right, for "The best line" and its
   replay button (John, 2026-09-12); the small button variant fits a heading. */
.card h3.with-action{display:flex;align-items:center;justify-content:space-between;gap:0.5rem}
button.act.small{flex:none;padding:0.375rem 0.625rem;font-size:0.6875rem;letter-spacing:.03em}

/* ---- achievement toasts ---- */
/* The stack anchors to the *viewport* corner, not the content column's
   (John, 2026-09-21: "on a screen like a pc with a bunch of empty space,
   the toast should come up in the empty space, not in the constrained
   column area" — "it just means use the screen as the fixed corner, rather
   than the center content region"). One rule covers both form factors with
   no media query and no breakpoint, because the root font size scales with
   the window: when width binds, 28.75rem *is* 100vw and the column is the
   whole screen, so the viewport corner is the column corner; when height
   binds the column is 53.49dvh wide and the viewport corner is out in the
   gutter — roughly 410px of it per side at 1280x800, roughly 671px at
   1920x1080, against a stack that is at most 21rem (336px at the 16px
   floor, 422px at 1920x1080) wide. The gutter is ample or nonexistent,
   never awkward, which is exactly why position:fixed needs no conditional.
   Nothing in this app scrolls, so a fixed element never drifts. The design
   is src/superpowers/spec/2026-09-21-achievement-toasts-design.md; this
   block is its rulings 1 and 3.

   Logical insets rather than bottom/right, so an RTL language flips the
   corner for free (#669, #476, and this repo's i18n direction generally):
   free to do now against a find-and-replace later. env() has no logical
   spelling, so the inline side pays the larger of the two side insets,
   which is correct whichever way the document runs and costs nothing —
   only one side of a device ever has a notch to declare. body carries the
   top and bottom safe areas as padding, but a fixed element sits outside
   that, so the block end pays for its own.

   pointer-events:none on the container and back on for the rows: on a phone
   the column *is* the screen, so the stack's box sits over the rack and the
   controls (open ruling 1 — the overlap is flagged for a real device, not
   decided). Nothing but a row should ever eat a tap meant for a tile.

   z-index 40 puts the stack above the sheets (20) and below the drag ghost
   (60). A toast should never coexist with the ghost — ruling 2 defers a
   firing until the drag ends — but the ordering is stated rather than
   assumed. */
#toasts{
  position:fixed;z-index:40;
  /* 1rem is the column's own gutter, so on a phone the stack lines up with
     the board's edge instead of floating at a private margin of its own. */
  inset-block-end:calc(1rem + env(safe-area-inset-bottom,0px));
  inset-inline-end:calc(1rem + max(env(safe-area-inset-left,0px),env(safe-area-inset-right,0px)));
  inline-size:min(21rem,calc(100dvw - 2rem));
  display:flex;flex-direction:column;
  pointer-events:none;
  /* Records the cap ruling 3 takes. The rule further down has to spell the
     3 out, because :nth-last-child() cannot read a custom property; this is
     here so the toast module can read the number instead of keeping a
     second copy of it. Three is the working number and is not a measured
     one (open ruling 2). */
  --toast-cap:3;
  --toast-gap:0.5rem;
  /* 4rem is the 48px icon plus 0.5rem of padding above and below. 3rem is
     exactly 48px at the root's 16px floor and only grows from there, so
     #602's 48px minimum for the house icons — they are scenes, not glyphs,
     which is why a toast cannot be a thin strip of text — holds at every
     window size with no px literal involved. A capped stack is therefore
     at most three 4rem rows, a 1.75rem summary row and four 0.5rem gaps:
     15.75rem, or 252px at the floor — 29% of an 860px-tall phone and 38% of
     a 667px one. Uncapped it could cover the board, which is the whole
     reason for a cap. */
  --toast-row:4rem;
}
/* Same trap as #ghost, .sheet, .actions and .chip: an author rule's display
   outranks the browser's own [hidden]{display:none} whatever the
   specificity, so without this an empty stack would still be laid out and
   still be announced. hidden_rows.spec.ts computes that requirement off
   these two files rather than transcribing it, and will say so. */
#toasts[hidden]{display:none}

/* A column growing *away* from the anchored corner, which is the whole of
   ruling 3's answer to expiry. DOM order is oldest first, so an appended
   toast lands at the bottom, nearest the anchor, and the anchored edge
   never moves. Removing a member shrinks the box upward, so only the
   members further from the corner travel: the newest — the one most likely
   being read — never jumps. The batch's own order is evaluateAll's, which
   reads top to bottom.

   The gap lives on each row as margin-block-start rather than on the
   container as `gap`, so that a leaving row can collapse its own gap with
   it; a container gap would outlive the collapse and leave a hole where
   the row was.

   block-size is explicit, and that is deliberate: `auto` is not
   interpolable, so a row whose height is auto cannot animate closed, and
   ruling 3 asks for the gap to animate rather than snap. The row is tall
   enough for what goes in it — a two-line title at 1.02rem a line plus a
   0.89rem reputation line is 2.92rem against the 3rem the icon already
   sets — and clamps rather than overflows if a translation runs longer.

   position:relative is the containing block .toast__close pins itself to,
   and padding-inline-end is the room reserved for it: 0.25rem of corner
   inset, the button's 1.75rem, and 0.25rem clear of the text. The body
   stops at that padding, so a long title clamps short of the glyph instead
   of running under it (John, 2026-09-24). */
.toast{
  position:relative;
  display:flex;align-items:center;gap:0.625rem;
  margin-block-start:var(--toast-gap);
  block-size:var(--toast-row);
  padding:0.5rem;
  padding-inline-end:calc(0.25rem + 1.75rem + 0.25rem);
  background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
  box-shadow:0 0.375rem 1rem #0008;
  pointer-events:auto;overflow:hidden;
  animation:toast-in .18s ease;
  transition:block-size .18s ease,margin-block-start .18s ease,padding-block .18s ease,border-block-width .18s ease,opacity .14s ease;
}
/* The exit, applied by the toast module for one transition before it takes
   the element out. Every property here goes from a length to zero, so the
   row's whole contribution to the stack's height — its box, its padding,
   its hairline and its gap — closes smoothly, and the rows above it slide
   down with it rather than after it.

   The 0.18s above is a contract with src/ts/ui/toast.ts: it applies this
   class and removes the element LEAVE_MS (240ms) later, so the transition
   has to finish inside that window or the row vanishes mid-collapse.
   Lengthen one and the other goes with it.

   Under prefers-reduced-motion the blanket rule near .tile.recent kills
   both the transition and the entry animation, the collapse is instant, and
   that is correct: the design says so explicitly, and a per-component
   reduced-motion rule here would be redundant. The module waits out a plain
   timer rather than a transitionend, which is what makes that safe — a
   transitionend never fires when there is no transition. */
.toast--leaving{
  block-size:0;margin-block-start:0;padding-block:0;border-block-width:0;opacity:0;
}
/* Arrival, mirrored: no `to` frame, so the 100% values are the row's own. */
@keyframes toast-in{
  from{block-size:0;margin-block-start:0;padding-block:0;border-block-width:0;opacity:0}
}
/* The icon well. 3rem square, per #602, with the panel's next step up
   behind it so an achievement that has no icon yet — six of the eight live
   starters, until #387's pipeline catches up — reads as a deliberate
   placeholder rather than as a hole, and the layout is the same width
   either way. */
.toast__icon{
  flex:none;inline-size:3rem;block-size:3rem;border-radius:0.375rem;
  background:var(--panel-2);overflow:hidden;
  display:flex;align-items:center;justify-content:center;
}
.toast__icon img,.toast__icon svg{display:block;inline-size:100%;block-size:100%}
/* min-inline-size:0 for the same reason main has min-height:0 — without it
   a flex child refuses to shrink below its content, and a long achievement
   name would push the icon out of the row instead of clamping. flex:1
   so the body takes the row's whole remaining width, up to the room
   .toast reserves for the dismiss control. */
.toast__body{flex:1 1 auto;min-inline-size:0;display:flex;flex-direction:column;justify-content:center;gap:0.125rem}
.toast__title{
  font-size:0.8125rem;font-weight:600;line-height:1.25;color:var(--text);
  /* Two lines and then an ellipsis, which is what makes a fixed row height
     safe for a language that runs longer than English — and this repo is
     headed for several. The visual clamp costs a screen reader nothing: the
     announcement carries the achievement's name and meaning in full (see the
     design's accessibility section, and #471 for what a carelessly assembled
     accessible name reads like). The -webkit- prefixed trio rather than the
     unprefixed `line-clamp`, which is still too new to rely on; the lint's
     baseline data accepts the prefixed form, so this needs no disable
     comment. An engine that ignores it clips at the row's own
     overflow:hidden instead — a worse ellipsis, not a broken layout. */
  display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;
}
/* The reputation delta, in the mono face and the gain colour, like the
   replay caption's score. It is Reputation and not the round score: an
   achievement bonus feeds the player ranking and never the round's number,
   so this figure and the header's score are deliberately different types
   of thing. */
.toast__rep{
  font-family:'Space Mono',monospace;font-size:0.6875rem;line-height:1.3;color:var(--hot);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
/* The dismiss control (John, 2026-09-22: "a small x in the corner to close
   it"). The design's accessibility section had already asked for a dismiss
   control reachable by keyboard; this is it, and the module builds it as a
   real <button type="button"> rather than a styled div, so Enter, Space, the
   tab order and the disabled state all come from the element instead of
   being reimplemented.

   Pinned to the toast's top-right corner (John, 2026-09-24: "the x for
   closing a toast is oddly placed. i'd like it to be in the upper right
   hand corner please"). It used to be a third flex child in normal flow,
   which put it wherever the body's text happened to end, mid-row, rather
   than in the corner. Now it is position:absolute against .toast's
   position:relative, and .toast's padding-inline-end reserves its column,
   so the two-line clamp on .toast__title still ends before the glyph
   instead of running under it.

   The 0.25rem corner inset is the focus ring's room, not decoration:
   .toast is overflow:hidden, and the global :focus-visible ring (#777)
   reaches 0.25rem past the button with its halo, so a button flush with
   the corner would have its ring clipped off on two sides. Logical
   properties throughout, so the corner mirrors with the stack's own (see
   #toasts above).

   No pointer-events of its own, and that is deliberate rather than an
   oversight: #toasts is pointer-events:none so the stack's box cannot eat a
   tap meant for a tile, .toast turns it back to auto, and a child inherits
   the auto. The button is clickable because its row is.

   1.75rem square is 28px at the root's 16px floor and grows with the root
   from there — about 44px at 1920x1080 — against a 4rem row, so it reads as
   small without being a pinpoint on the form factor where the stack sits
   over the rack. Quiet at rest in --dim, the copy's grey (it was --dimmer
   until #451 found that below AA), so it never competes with the title
   beside it or the delta under it; it comes up to --text on hover, and the
   global :focus-visible ring gives it the same two-tone ring every other
   control in this interface gets.

   The glyph is U+00D7, the same character index.html spells "6x6" with, and
   it is inside the self-hosted Space Grotesk subset's unicode-range where
   U+2715 would not be — a prettier cross would silently fall back to a
   system face at a different weight. The font resets are here for the same
   reason .toast__more carries them: the UA's own button face and colour are
   nothing like this interface's. */
.toast__close{
  position:absolute;inset-block-start:0.25rem;inset-inline-end:0.25rem;
  inline-size:1.75rem;block-size:1.75rem;padding:0;
  display:flex;align-items:center;justify-content:center;
  background:none;border:none;border-radius:0.3125rem;
  color:var(--dim);font-family:inherit;font-size:0.9375rem;line-height:1;
  cursor:pointer;
  transition:color .12s ease,background-color .12s ease;
}
.toast__close:hover{color:var(--text);background:var(--panel-2)}
/* The cap, held in CSS as well as in the module, because an uncapped stack
   could in principle cover the board and the board is the game. Counting
   only .toast siblings means the summary row can sit anywhere in the DOM
   without moving the window of three. */
/* n+4 is the cap of three read backwards: it matches every row that has
   three or more rows after it, so the three nearest the anchor survive and
   everything older goes. `of` is the load-bearing half — without it the
   selector would count *children*, and the summary row would silently shift
   the window by one depending on where in the markup it sat.

   A leaving row is excluded on both sides of that, and both exclusions
   matter. Excluding it from the count keeps an eviction from moving the
   window while the outgoing row is still on its way out — a full stack plus
   one arrival is four rows for the length of one transition. Excluding it
   from the subject keeps this rule from display:none-ing the very row that
   is mid-collapse, which would replace the animation with exactly the snap
   ruling 3 rejected.

   An engine that cannot parse `of` drops the rule and shows an uncapped
   stack, which is the pre-cap behaviour rather than a broken one; the module
   caps the stack itself, so this is a floor under that and not the only
   copy of the rule. */
#toasts > .toast:not(.toast--leaving):nth-last-child(n+4 of .toast:not(.toast--leaving)){display:none}
/* The overflow summary — "+2 more", opening the achievement list — rather
   than a queue behind the stack: serial dwells multiply, and three of them
   straight after someone's first round is the worst possible moment to hold
   the screen (ruling 3). order:-1 keeps it at the far end of the stack from
   the anchor whatever its DOM position, so it can never come between the
   corner and the newest toast, and its arrival or departure moves only rows
   further from the corner. The font and colour resets are here so the
   module can use a <button> without inheriting the UA's own. */
.toast__more{
  order:-1;
  margin-block-start:var(--toast-gap);
  block-size:1.75rem;padding:0 0.625rem;
  display:flex;align-items:center;
  background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius);
  color:var(--dim);font-family:inherit;font-size:0.6875rem;font-weight:600;letter-spacing:.04em;
  pointer-events:auto;cursor:pointer;
  animation:toast-in .18s ease;
}
.toast__more:hover{color:var(--text);border-color:var(--line)}

/* ---- content pages (#530) ---- */
/* The static pages under /rules, /cells, /tiles and the rest link this same
   sheet, so they share its fonts, palette and focus ring, and carry
   <html class="doc"> to opt out of the game's layout. The game is one
   viewport tall and never scrolls, and styles header, main and footer as its
   own chrome; a content page is a reading column that scrolls like any
   document. Every rule here is scoped under .doc, which the shell never
   carries, so nothing below can reach the game. The pages have no script,
   so they take the unthemed palette, the dark one (#520). */
.doc,.doc body{
  overflow:auto;
  /* eslint-disable-next-line css/use-baseline -- deliberate: gives a reading page back the pull-to-refresh the game's html,body rule turns off; engines without it never had it off. */
  overscroll-behavior:auto;
}
.doc{font-size:100%}
.doc body{display:block;min-height:0;line-height:1.6}
.doc header,.doc main,.doc footer{
  display:block;position:static;max-width:42rem;margin:0 auto;padding:1rem;border:none;
  font-size:1rem;letter-spacing:normal;text-transform:none;color:var(--text);
}
.doc header,.doc footer{font-size:0.875rem;color:var(--dim)}
.doc header{border-bottom:1px solid var(--line-soft)}
.doc footer{border-top:1px solid var(--line-soft)}
.doc footer nav{display:flex;flex-wrap:wrap;gap:0.25rem 1rem}
.doc a{color:var(--text);text-underline-offset:0.1875em}
.doc a:hover{text-decoration-thickness:0.125rem}
.doc header a,.doc footer a{color:var(--dim)}
.doc h1{font-size:2rem;line-height:1.2;margin:0.5rem 0 1rem;letter-spacing:-.01em}
.doc h2{font-size:1.25rem;margin:2rem 0 0.5rem}
.doc ul,.doc ol{padding-left:1.25rem}
.doc li{margin:0.25rem 0}
.doc .facts{display:grid;grid-template-columns:max-content 1fr;gap:0.375rem 1rem;margin:1rem 0}
.doc .facts dt{color:var(--dim)}
.doc .facts dd{margin:0}
.doc .glossary dt{font-weight:700;margin-top:1rem}
.doc .glossary dd{margin:0.25rem 0 0}
.doc table{border-collapse:collapse;width:100%;margin:1rem 0;font-size:0.875rem}
.doc caption{text-align:left;color:var(--dim);padding-bottom:0.5rem}
.doc th,.doc td{padding:0.375rem 0.5rem;border-bottom:1px solid var(--line-soft);text-align:left}
.doc thead th{color:var(--dim);font-weight:400}
.doc .swatch .keychip{width:3rem;height:3rem;font-size:0.875rem}
.doc li .keychip{display:inline-flex;vertical-align:middle;width:1.75rem;height:1.75rem;font-size:0.5625rem;margin-right:0.25rem}
