/* ===========================================================================
   Theme — TE FIELD QUIET. Flat, minimal, teenage-engineering-inspired LIGHT
   theme: warm-gray ground, off-white rounded-SQUARE tiles, orange accent for
   everything selected/active. Text-only top UI (no button chrome): the timer row
   is words, and what icons exist are drawn by ui.js on one shared 24×24 grid —
   the verdict badges, the locked tab's padlock, and the rotate control at the
   right end of the verdict line. No shadows, and exactly ONE gradient — the ground's
   (see body below); every surface above it is still flat. All colors flow
   through the design tokens below; the legacy semantic names (--surface-2,
   --text, …) are aliased onto them so the modals/debug panel restyle for free.
   --------------------------------------------------------------------------- */
:root {
  /* design tokens (see design_handoff_te_field_quiet/README.md) */
  --bg:#E6E4DD;           /* page background */
  --surface:#F7F6F1;      /* letter tiles at rest */
  --surface-dim:#D8D6CE;  /* progress-bar track */
  --line:#C9C7BE;         /* spent-letter ghost glyphs */
  --edge:#7C7A71;         /* board connection lines — 3.4:1 on --bg */
  --rule:#D0CEC5;         /* 1px separators */
  --ink:#1A1A1A;          /* primary text */
  --muted:#8A877E;        /* secondary text */
  --accent:#FF4D00;       /* selection, active tab, links, progress fill */
  --on-accent:#FFFFFF;    /* letters on selected tiles */

  /* legacy aliases (modals, debug panel, feedback) */
  --surface-2:#ECEAE3; --surface-hover:#EFEDE6;
  --text:var(--ink); --text-dim:var(--muted); --border:var(--rule);
  --node-ink:var(--ink); --flow:var(--accent);
  --ok:#2f7d4f; --warn:#a86a00; --bad:#c23a1d;
  --shadow:rgba(0,0,0,.1); --overlay:rgba(230,228,221,.88);

  --font-display:"Space Grotesk", system-ui, sans-serif;
  --font-mono:"IBM Plex Mono", ui-monospace, monospace;
}

* { box-sizing: border-box; }
/* Single centered column, 390px reference width, 18px vertical rhythm.
   Fixed-position children (modals, toast, banner overlay) are out of flow,
   so the flex gap never touches them. */
body {
  margin: 0 auto; max-width: 390px; min-height: 100vh;
  padding: 20px 18px 28px;
  display: flex; flex-direction: column; gap: 18px;
  font-family: var(--font-mono); color: var(--ink);
  /* THE GROUND — "la Levée" (docs/protos/2026-08-09-fond-degrade.html, where it
     was picked against a halo, a vignette, a temperature tilt and a grained
     variant). Light arrives from above and the ground settles under it: white
     down to nothing by mid-page, then a shadow floor of 5%. One axis, no shape,
     nothing to align — which is why it survives a page whose height changes
     (twist banner, 5×5 grid, a long bonus list) without ever looking *placed*.
     Every stop is mixed FROM --bg rather than baked as hex, so --bg stays the one
     place the ground's colour is written and theme.js's future light variant
     inherits the wash for free. The mixes restate the wash exactly as it was first
     written (60% white, 18% white, none, 5% black over the token) — they are the
     same four colours, said in a form that can also be handed to background-color.
     Which is the point, and it was a BUG on iOS: background-color must equal the
     gradient's FIRST STOP. Safari tints the strip behind the status bar from the
     page's own ground, and it samples the background *colour* — a gradient image
     it will not read. Leave --bg there and the strip paints the mid-page grey
     against a page starting 15 steps lighter, which is a hard horizontal cut right
     under the clock (reported on iOS 26, iPhone Safari). theme-color says #F5F4F1
     in index.html and the manifest for the same reason. All three now name one
     colour; whichever of them any OS decides to sample, it lands on the page's
     actual first pixel. Retune the top stop and all three move together, or the
     cut comes back.
     no-repeat, for the region OUTSIDE the root box: the image is sized to that box,
     so the default `repeat` starts the ramp over in the rubber-band gutter — pull
     the page down and the wash tiles. With no-repeat the gutter falls back to
     background-color, which is exactly right at the top (same colour as the first
     pixel) and is the accepted cost at the bottom: bounce past the last line and a
     light band shows under a page that ends 26 steps darker. A seam you have to go
     looking for, traded against one that was always on screen.
     It goes on BODY, and body's background must stay the one that propagates to
     the canvas — #starfield sits at z-index -1, i.e. above the root background
     and below everything else, so a gradient given to html instead would paint
     OVER the stars. Same reason there is no fixed background layer of its own.
     No `background-attachment: fixed`, deliberately. Fixed would anchor the ramp
     to the viewport and hold it there at any page length, which is the tidier
     idea and the worse bet: iOS has a long history of ignoring it on the root,
     and this is a PWA that lives on phones. Left alone, the positioning area is
     the root box — at least 100vh thanks to min-height above, so on the one
     screen the game actually occupies the two are identical, and a page that
     does grow past the fold simply stretches the ramp gentler. A benign failure
     beats an unpredictable one.
     Measured, on an 812px viewport: #F4F3F0 at the top, #ECEAE5 where the board
     starts, #E5E3DC where it ends, #DAD8D2 at the fold. Two consequences worth
     knowing before anyone retunes these stops.
     One: the tiles keep their separation, but not evenly. --surface (#F7F6F1)
     stood 17 steps above a flat ground; it now stands 11 at the top of the board
     and 18 at the bottom, so the FIRST ROW is the thinnest the grid ever gets.
     11/255 still reads at a glance — it was checked on the proto's --surface card,
     which exists for exactly this — but it is the number that fails first if the
     wash is ever pushed harder, and it fails at the top, not at the fold.
     Two: 26 steps spread over 812px is a fresh edge every ~31px, which is what
     BANDING is made of on an 8-bit display. The proto's fifth variation answers
     it with a 3% noise tile (dithering) that layers straight onto this one; it
     was left off because the choice made was depth, and noise is texture. Reach
     for it if the banding turns out to be real on a device we care about. */
}
/* ON HTML, NOT BODY, since 2026-09-12 — and that one word is the whole fix for a
   hard horizontal cut across the ground, reported the day the word-rules
   disclosure got long enough to push the page past the fold.
   The mechanism: body carries `zoom: var(--ui-scale)` (see below), and a body
   background PROPAGATES to the canvas. Chrome sizes that propagated image in
   body's own zoomed coordinate space and then paints it on the canvas in device
   pixels, without scaling it back up — so the wash covered 1/1.12 of the page and
   the last 10.7% fell back to `background-color`. Which is the FIRST stop, the
   lightest colour in the ramp, landing directly under its darkest: a light band
   across the bottom of any page long enough to scroll. Verified by driving
   --ui-scale to 1, where the seam disappears exactly.
   html is not zoomed, so its box IS the canvas box and the ramp spans the real
   page at any content length. Everything the wash depends on survives the move:
   the root background still paints below a z-index:-1 #starfield (the reason this
   was on body at all), and background-color is still what an OS samples for the
   status-bar strip, which is the iOS bug recorded above. body keeps no background
   of its own — one that painted in its 390px column would draw a band over this.
   Do NOT move it back to body without re-reading this. */
html {
  /* The stops are in vh, not %, and that is the second half of the same fix.
     A percentage stop is a share of the BOX, so the ramp restretched every time
     the page changed length — opening the word rules re-timed the whole wash and
     every pixel on screen shifted colour at once. Owner's report, 2026-09-12:
     "the gradient is stretched […] the end colour should continue down the page".
     In vh the ramp is pinned to the SCREEN: it always lands its last colour at
     the fold, whatever the page below is doing, and a gradient holds its final
     colour past its final stop — so the page simply continues in the floor colour
     as it grows. Toggling a disclosure now changes nothing above the fold.
     vh needs no `/ var(--ui-scale)` here, unlike every other viewport unit in this
     file: that division exists for lengths inside the zoomed body, and html is not
     zoomed. These are true viewport pixels, which is what the design was measured
     in (#F4F3F0 at the top … #DAD8D2 at the fold, on an 812px screen).
     The percentages they replace were 0/30/52/100 of a box that was ~one viewport
     tall, so a page that fits the screen looks exactly as it did. */
  background: color-mix(in srgb, var(--bg) 40%, #FFF) linear-gradient(180deg,
    color-mix(in srgb, var(--bg) 40%, #FFF)    0,
    color-mix(in srgb, var(--bg) 82%, #FFF)  30vh,
    var(--bg)                                52vh,
    color-mix(in srgb, var(--bg) 95%, #000) 100vh) no-repeat;
}
/* --- UI scale ------------------------------------------------------------
   The whole page is drawn at --ui-scale, through CSS `zoom` on the game's body.
   Why zoom and not a bigger type ramp: this stylesheet is a px design whose
   numbers lean on each other — the verdict line is a 52px box holding a 26px
   token behind a 24px badge, the skeleton pills are cut to the tabs' text, the
   copy budgets in tools/copy-manifest.js are px measured against px boxes.
   Scaling the type alone would break every one of those couplings; zoom scales
   the boxes with the type, so every coupling and every budget holds unchanged
   (a 12px label in a 295-unit card is still 12px in 295, only rendered larger).
   It is also literally what Safari's own "Zoom" (the AA menu) does, which is
   where the number came from: 125% read well on a standard iPhone but a touch
   big, so 118% was tried first and read too big as well, then 110%; 112% is
   where the page settled. One knob for the page — retune it here, nowhere else
   — and one for the grid, --board-scale, just below, which is why the two are
   not the same number: the grid was judged separately and stayed at 110%.
   Standardised zoom (Chrome 128, Firefox 126, WebKit since always) keeps
   getBoundingClientRect, elementFromPoint and pointer clientX/Y in ONE
   coordinate space, so board.js's hit-testing, welcome.js's rect ratios and the
   starfield's canvas sizing need no compensation. Viewport-relative lengths DO
   need it: zoom multiplies every used length, `100vh` included, so a body that
   was exactly one screen tall becomes 1.18 screens of empty ground — hence the
   division below. The modal cards' widths were `min(92vw, …)` for the same
   reason and are now `min(92%, …)` against their full-viewport overlay, which
   says the same thing and is true at any zoom.
   Scoped to body.game (index.html) rather than body: copy.html, preview.html
   and the legal pages share this stylesheet and lay their own workbench or
   column around it — the atelier judges strings at 1:1 on a desktop, and a
   zoomed board there would say nothing a zoomed budget did not already say.
   The COLUMN does not zoom, only what is in it. Its max-width and its side
   padding are divided back down so they stay 390 and 18 DEVICE px — the same
   column a phone drew yesterday, on desktop too.
   The GRID has its own knob, --board-scale, so the 4×4 and the type around it
   can be judged apart — 1.18 across the board was ruled too big the day it was
   tried, and "the grid" and "the UI" are two different complaints. Zoom cannot
   say that, it scales everything under it by one number, so the stage's width
   is stated in DEVICE px and divided back through the page zoom: 295 ×
   --board-scale device px on a 393 phone, the column binding below that.
   It sits at 1.00 — the grid ends up EXACTLY the size it has always been, and
   only the type around it grew. It went 1.18 → 1.10 → 1.00 in one day, and the
   thing that settled it is not the grid's own look: the board is what stands
   between the player and the found-words list, so every unit it gains pushes
   the word you just traced further under the fold. Measured on a 393×852 phone
   at a 734px visible viewport (Safari's toolbars taken off), with 14 words
   found: 9 of them on screen at 1.10, 12 at 1.00. That is the number to weigh
   before growing this knob again — not how the tiles look on their own. */
/* NOTE for the same reason: the list below is what pays for the board, so if a
   bigger grid is ever wanted again, take the space from the gap above it (the
   rotate row, main's 18px rhythm) rather than from the sidebar. La Grande Grille takes neither number: its stage is column-bound on
   every phone (354 on a 393 one) and capped at 372 device px above that, so
   `body.grid5` divides its cap back down and nothing about the 5×5 moves on any
   device — measured, 354 → 354, letter height 31 → 31. It was hand-tuned to its
   column and stays there; only the type around it grows. */
:root { --ui-scale: 1.12; --board-scale: 1.00; }
body.game {
  zoom: var(--ui-scale);
  max-width: calc(390px / var(--ui-scale));
  min-height: calc(100vh / var(--ui-scale));
  padding-left: calc(18px / var(--ui-scale)); padding-right: calc(18px / var(--ui-scale));
}
body.game #board-stage { width: min(calc(295px * var(--board-scale) / var(--ui-scale)), 100%); }
body.game.grid5 #board-stage { width: min(calc(372px / var(--ui-scale)), 100%); }
body.game .stats-list { max-height: min(calc(55vh / var(--ui-scale)), 360px); }
/* THE TYPE SCALE (2026-09-13). Three sizes of mono on the game screen and no
   others: 14 for the found list, 12 for everything you READ — the footer's prose
   and the controls alike — and 10 for LABELS. That last rung is a REGISTER, not a
   leftovers bin: short, uppercase, tracked, muted, and nothing else — the date,
   the verdict note, the progress label, the BETA pill. Which rung a thing sits on
   is decided by what it IS, never by how important it feels.
   It was FIVE — 9/10/11/12/13 — which is a 1px step, below what anyone perceives
   as a level, and the sizes had stopped matching the roles: 10px carried the date,
   the verdict note, the progress label AND the footer credit, while the footer's
   prose sat at 12, a step ABOVE the controls, making the least important text on
   the screen the largest mono on it.
   Proto: docs/protos/2026-09-13-echelle-typographique.html (three directions
   side by side, this is A).

   Tracking is in em from here down, not px: px tracking does not follow a size
   change, so the same "tracked-out label" look silently drifts apart the moment a
   size moves — which is how .16em and 1.5px ended up being the same intention
   spelled two ways. .13em at 12px is 1.56px, i.e. what the row looked like before.

   .pause-title is NOT in this list any more. It was, and then overrode the size to
   14px twelve hundred lines further down — so the one rule that claimed to be the
   system had its exception baked into it. It states its own four properties now
   (see #pause-overlay), which is honest about being a title rather than a label. */
.label, #controls button, #gamebar button, .auth-login, #resume-btn,
.score-actions button {
  font-family: var(--font-mono); font-weight: 500; font-size: 12px;
  letter-spacing: .13em; text-transform: uppercase;
}

/* --- header: wordmark + date sub-line left, Connexion (plain text) right --- */
header { display: flex; align-items: baseline; justify-content: space-between; }
#brand { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
h1 {
  margin: 0; font-family: var(--font-display); font-weight: 700;
  font-size: 22px; letter-spacing: -0.5px; text-transform: uppercase; color: var(--ink);
}
#puzzle-date {
  font-size: 10px; font-weight: 500; letter-spacing: .13em; text-transform: uppercase; color: var(--muted);
  /* Reserves the date's line while the span is still empty (JS paints it): its
     13px arriving used to push everything below the header down. Block, because
     min-height does nothing on an inline span. */
  display: block; min-height: 13px;
}
.random-link {
  font: inherit; letter-spacing: inherit; text-transform: inherit;
  color: var(--accent); background: none; border: none; padding: 0; cursor: pointer;
}

/* --- tab row: text-only tabs, active = ink + 2px accent underline on the rule --- */
#controls { display: flex; gap: 22px; border-bottom: 1px solid var(--rule); padding-bottom: 10px; }
#controls button {
  background: none; border: none; padding: 0; cursor: pointer; color: var(--muted);
}
/* NO underline: the current tab is said by its INK alone — muted for the others,
   full ink for this one (owner, 2026-09-06). There was an accent bar here until
   that call, and before it a `box-shadow: 0 12px 0 -10px` that never worked — a
   shadow that size is the button's box inset 10px on every side, and the tab is
   19.99px tall, so its rectangle was NEGATIVE and what anyone saw was the
   antialiasing of a zero-height box floating 8px above the rule it claimed to sit
   on. Both are gone; don't reintroduce one without being asked. */
#controls button.current { color: var(--ink); }
/* A tab carrying a marker — the locked Anomaly's padlock, or the tick on a day
   that has been cleared (ui.js's markerSlot). Only the layout is shared: Random
   and Stats never carry one and stay plain text buttons. */
#controls button.locked, #controls button.done { display: inline-flex; align-items: center; gap: 3px; }
/* Locked: dimmed, still clickable — entering it is what explains the lock. But NOT
   while it is the tab you are on: .current means the screen in front of you is this
   one, and the row's whole job is to say which that is. Dimming the current tab
   made the Anomaly the one mode you could not tell you were in. */
#controls button.locked { opacity: .6; }
#controls button.locked.current { opacity: 1; }
/* Stats is hidden once you are logged in (ui.js setUser), and the two rules above set
   a `display` of their own — so any tab that grew a marker class while hidden would
   walk straight back onto the row. Stated here like every other [hidden] in this file.
   It is NOT what keeps the Anomaly and Random off the row: those are not appended at
   all, precisely so their absence never depends on this stylesheet having landed. */
#controls button[hidden] { display: none; }
/* The padlock (ui.js LOCK_ICON) and the tick (DONE_ICON, which is the verdict
   line's own ✓ at tab size). Sized to the label's 11px and inked in currentColor,
   so they take the tab's own colour — muted idle, ink when current — instead of an
   emoji font's idea of grey. `display: block` on the svg drops the inline baseline
   gap that would push it off-centre against the text. */
/* The Anomaly's label, which glitches on its own every 10–20 s (ui.js's
   installTabGlitch, curve and alphabet in src/glitch.js). The corruption is the
   TEXT — letters swapped for look-alikes and back — and that is ALL of it: CSS
   has no job here beyond the inline-block. Nothing animates, nothing is layered,
   nothing changes colour, and the row cannot move, because the tab is monospace
   at a fixed letter-spacing and a substitution is always one character for one
   character. A damaged frame used to also go var(--accent), which read as a
   highlight — the tab looked *selected* every fifteen seconds, on the one row
   where colour already means "you are here". Colourless, the glitch keeps the
   tab's own state colour (muted idle, ink when current) and the only thing that
   changes is the letters. The word is its own span so the padlock in front of it
   is never rewritten. */
.tab-glitch { display: inline-block; }
.tab-lock-slot { display: flex; align-items: center; }
.tab-lock { display: block; width: 11px; height: 11px; overflow: visible; }
.tab-lock-shackle { fill: none; stroke: currentColor; stroke-width: 2.2; }
.tab-lock-body { fill: currentColor; stroke: none; }
/* The tick is all stroke, so it needs more nominal width than the padlock to land
   at the same weight beside it — the same optical correction .tr-icon-check makes at
   24px, and for the same reason. Round joins: at 11px a mitred corner on a 3px
   stroke is a spike. */
.tab-done { display: block; width: 11px; height: 11px; overflow: visible; }
.tab-done path {
  fill: none; stroke: currentColor; stroke-width: 3;
  stroke-linecap: round; stroke-linejoin: round;
}

/* --- timer/actions row: timer left, text actions right. All words, no icons:
   the rotate control used to sit here as a ↻ glyph and now lives on the verdict
   line (see #rotate-btn), because it acts on the board, not on the clock. --- */
/* position:relative so the end-of-game title can be laid over the row rather
   than in it — see #end-title at the bottom of this block. */
/* wrap + row-gap exist for La Traque's crew bar, which takes a full line of its
   own (see #crew below); with the bar absent nothing overflows and the row is the
   single line it always was. */
#gamebar { display: flex; align-items: center; gap: 22px; row-gap: 6px; position: relative; flex-wrap: wrap; }
#timer {
  font-family: var(--font-mono); font-weight: 600; font-size: 12px;
  font-variant-numeric: tabular-nums; color: var(--ink);
}
/* Marée: the countdown IS the rule, so the clock stops being furniture — an
   accent chip with white digits, a clear step up from the daily's 12px readout.
   The class is put on by ui.setTwist for countdown twists only, and comes off
   with setTwist(null) on every daily/random mount. Negative margins keep the
   chip's padding from growing the gamebar row. */
#timer.countdown {
  font-size: 15px; color: var(--on-accent); background: var(--accent);
  padding: 3px 8px; margin: -3px 0; border-radius: 5px;
}
/* La Traque : l'équipage en signes vitaux (spec 2026-08-19, révisée ; proto
   docs/protos/2026-08-19-traque-vitaux-se.html, variante B1). Sept moniteurs,
   un tracé cardiaque chacun : une bande de papier qui défile à vitesse
   constante où chaque battement est un ÉVÉNEMENT — l'urgence resserre les
   nouveaux battements de la proie, jamais l'historique déjà tracé. Le dessin
   vit dans ui.js (canvas) ; ici, la géométrie des chips. L'ordre d'affichage
   est FIXE — « vous » au bout gauche, puis l'équipage — et les morts tombent
   en positions dispersées : la bête cueille dans le rang, pas dans une file.
   Pause/Terminer restent dans la rangée au-dessus — la barre est sa propre
   ligne, jamais une carte autour des contrôles. */
#crew { flex: 1 1 100%; order: 9; display: flex; gap: 4px; position: relative; min-width: 0; }
/* display:flex above would defeat the hidden attribute — restate it. */
#crew[hidden] { display: none; }
.crew-chip {
  flex: 1; min-width: 0; border: 1px solid var(--rule); border-radius: 3px;
  padding: 3px 2px 2px; background: var(--surface);
}
.crew-chip canvas { display: block; width: 100%; height: 13px; }
.crew-chip .crew-name {
  display: block; font-family: var(--font-mono); font-size: 7.5px;
  letter-spacing: .02em; text-transform: uppercase; text-align: center;
  color: var(--muted); overflow: hidden; white-space: nowrap;
}
.crew-chip.vous .crew-name { color: var(--accent); }
/* The hunted name steps up to ink — the one reading that survives with the
   animation off (prefers-reduced-motion gets static traces). */
.crew-chip.hunted .crew-name { color: var(--ink); font-weight: 600; }
.crew-chip.dead { opacity: .45; }
.crew-chip.dead .crew-name { text-decoration: line-through; }
/* La ligne de mort remplace les moniteurs le temps qu'elle se joue. */
#crew.announcing .crew-chip { visibility: hidden; }
.crew-line {
  position: absolute; inset: 0; display: none; align-items: center;
  justify-content: center; font-family: var(--font-mono); font-size: 11px;
  color: var(--accent); white-space: nowrap; overflow: hidden;
}
#crew.announcing .crew-line { display: flex; }
#gamebar button {
  background: none; border: none; cursor: pointer; color: var(--ink);
  /* keep ≥44px effective hit targets even though the type is 11px */
  padding: 15px 0; margin: -15px 0;
}
#gamebar button#pause { margin-left: auto; }
/* Transport mark in front of the pause action and the overlay's resume button:
   two bars while the clock runs, a triangle once it's paused. Drawn from
   currentColor — ink in the gamebar, accent on the board — and sized in em at
   the font's cap height, so it sits inside the text's own band and tracks any
   type change. No markup, no icon font. */
#pause::before, #resume-btn::before {
  content: ""; display: inline-block; vertical-align: baseline;
  width: .68em; height: .72em; margin-right: .55em; background: currentColor;
}
/* Running: two bars. */
#pause::before {
  background: linear-gradient(to right,
    currentColor 0 34%, transparent 34% 66%, currentColor 66% 100%);
}
/* Paused: "play", in the gamebar (now reading REPRENDRE) and on the overlay. */
body.paused #pause::before, #resume-btn::before {
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
#gamebar button:disabled { opacity: .4; cursor: default; }
#gamebar button#finish.confirm { color: var(--accent); font-weight: 600; }

/* The twist banner: the rule of the day, ABOVE the timer row since 2026-08-19
   (it sat between the clock and the board before). A callout — a bordered,
   left-set card in the page's own rule colour — so the day's law reads as
   posted orders before the instruments: first what is wrong today, then the
   controls that live with it. */
#twist {
  display: flex; flex-direction: column; gap: 4px;
  /* The bottom margin is negative on purpose: the body's 18px column gap alone
     was already more air than the rule needs before the controls it governs —
     18 − 3 = 15px, half of what the old 12px margin + gap produced. */
  margin: 10px 0 -3px; padding: 10px 12px;
  border: 1px solid var(--rule); border-radius: 6px;
  text-align: left;
}
/* An author `display` beats the UA sheet's [hidden] rule, so setTwist(null) needs
   this to actually take the banner off the daily and Random. */
#twist[hidden] { display: none; }
.twist-head { display: flex; align-items: center; gap: 8px; }
#twist .twist-name {
  font-family: var(--font-mono); font-size: 11px;
  letter-spacing: .18em; text-transform: uppercase; color: var(--accent);
}
/* A filled grey pill with light text — the inverse of what it was (outlined, grey
   ink). Solid, it reads as a stamp on the callout instead of a second bordered box
   inside one. --muted is the palette's grey and clears 4.5:1 against white. */
/* The brand's own beta tag, beside the wordmark: the twist pill's shape, filled
   with the wordmark's own --ink (owner, 2026-09-15) — it was --muted, which made
   the tag a second grey beside a black title and read as a caption on the logo
   rather than a stamp in it. One colour for the two halves of the brand; the
   pill still stays subordinate by being small, tracked and caps, not by fading.
   Ships hidden, painted by ui.js.

   The wordmark is a flex row so the tag can be CENTRED on it (owner's call,
   2026-09-05). It was `vertical-align: 6px` — a nudge measured by eye against one
   font size, which is the thing that goes wrong later: the offset is fixed while
   the type around it is not, so any change to the h1's size or line-height slides
   the tag off centre with nothing to say it had. `align-items: center` states the
   intent instead of a number that happens to look like it.
   Scoped to #brand: `h1` is the game's wordmark HERE, and a plain page title
   elsewhere (confidentialite.html, sources.html, the atelier). */
#brand h1 { display: flex; align-items: center; }
/* The two paddings are ASYMMETRIC by measurement, not by eye, and each side
   answers a different distortion. Both were off, and in a pill this small a
   pixel is a fifth of the ink.
   HORIZONTAL: letter-spacing puts a gap after EVERY letter, the last one
   included, so a centred line box has .16em of empty on the right that the left
   does not have — the word reads left-of-centre by half of it. The left padding
   carries one full letter-spacing to put it back.
   VERTICAL: the type is all-caps, so its ink runs from the baseline up to the
   cap height and stops — nothing below. Against a line box pinned to 1 (below),
   whose half-leading is negative and lands the baseline 7.14px down at this
   size, the ink's middle sits ABOVE the box's middle, by half a pixel per side.
   The TOP padding carries the correction, .11em more than the bottom, and the
   direction is worth stating because the intuition ("caps sit high, push them
   down") gives the sign and not the amount: it was measured off the real
   baseline (a zero-height inline-block strut in the pill), not derived from the
   font's declared metrics, which disagree with what the browser actually does
   here. Both corrections are in em, so they follow the font-size rather than
   being a number that was true once.
   line-height comes from the `font` shorthand's `/1`: `normal` is the font's
   own leading, which is a third distortion with no reason to be here, and a
   `line-height` declared before the shorthand would be reset by it. */
.beta-tag { display: inline-block; margin-left: 8px;
  padding: calc(2px + .11em) 6px 2px calc(6px + .16em);
  border-radius: 999px; background: var(--ink); color: var(--bg);
  font: 700 10px/1 var(--font-mono); letter-spacing: .16em; text-transform: uppercase; }
.beta-tag[hidden] { display: none; }
/* Same two corrections as .beta-tag above, for the same reasons, at this chip's
   own letter-spacing (.14em) — the defect is the type's, not the pill's. */
#twist .twist-beta {
  font-family: var(--font-mono); font-size: 9px; line-height: 1; letter-spacing: .14em;
  color: var(--on-accent); background: var(--muted); border-radius: 3px;
  padding: calc(2px + .11em) 5px 2px calc(5px + .14em);
}
#twist .twist-rule { font-size: 13px; color: var(--ink); opacity: .75; }

/* --- current word row: traced word / submit feedback ---
   Line 1 (.tr-line1): badge slot + word; line 2 (.tr-note): a smaller
   qualifier ("déjà trouvé"). Fixed height so nothing reflows the board when
   text appears (34px word row per spec + the note row). */
/* Styled by class, not by #trace-result, because the verdict line stopped being a
   single instance: preview.html's gallery shows all seven verdicts side by side,
   and an id can only dress one of them. index.html's element keeps the id — that
   is what main.js hands createUI — and wears the class for the styling. */
.trace-result {
  --tr-line-size: 26px; --tr-shift: calc(var(--tr-line-size) * 1.2);
  position: relative; height: 52px; margin-bottom: 4px;
  display: flex; flex-direction: column; justify-content: flex-start;
  color: var(--ink); user-select: none;
  /* The reel's clipping window (see tr-roll-out/tr-roll-in below): without this a
     rolling word would fly past the block's own 52px and over the timer row. */
  overflow: hidden;
}
.tr-line1 { display: flex; align-items: center; min-height: 34px; }
/* The badge slot OPENS from zero width on a verdict (.tr-shift), pushing the
   word right to make room and fading the badge/note in. The word itself is
   never translated: its left edge is pinned to the column, so the first letter
   can't be clipped off the page edge if the badge slot ever fails to size
   (e.g. a browser that drops `flex: 0 0 calc(...)`) — it just doesn't slide. */
.tr-badge {
  flex: 0 0 auto; width: 0; overflow: hidden;
  display: inline-flex; align-items: center; opacity: 0;
  color: var(--accent); transition: color .3s ease;
}
/* The badges are drawn, not typed (see BADGE_ICONS in ui.js): as characters only
   the plus existed in IBM Plex Mono, so the rest fell through to whatever each OS
   substituted — four different fonts, four different sizes, and an emoji on iOS
   for the back-arrow. One 24×24 box and one stroke width make them a set. `flex:
   none` keeps the slot's `width: 0` from squashing the icon: it has to overflow
   and be clipped, since that clipping IS the reveal. Stroke and fill both take
   currentColor, so the badge still follows .tr-badge's colour transitions. */
.tr-icon {
  flex: none; display: block; width: 24px; height: 24px;
  fill: none; stroke: currentColor; stroke-width: 2.1;
  stroke-linecap: round; stroke-linejoin: round;
}
/* Optical correction, not a design flourish: equal nominal widths do NOT render
   as equal weights at 24px. The check and the cross are drawn entirely on the
   diagonal, where a stroke can never align to the pixel grid — rasterized and
   measured, only 37% and 23% of their pixels land fully opaque, against 40% for
   the back-arrow and 87% for the plus, whose strokes are orthogonal and snap
   crisply. Same stroke-width, visibly lighter glyph. Widening just these two
   brings their rendered density back in line (44% and 51% solid at 2.5). If you
   redraw either icon, re-measure — the number belongs to these paths, not to a
   rule of thumb. */
.tr-icon-check, .tr-icon-cross { stroke-width: 2.5; }
/* The word renders at 80% of --tr-line-size. The token itself stays at the full
   26px because it also sizes the badge slot and the line-2 indent (--tr-shift):
   scaling it down would pull the slot in under the 24px badge icon, which is
   clipped by the slot's overflow:hidden. */
.tr-word {
  display: inline-block; white-space: nowrap;
  font-family: var(--font-display); font-weight: 700;
  font-size: calc(var(--tr-line-size) * .8); letter-spacing: .29em;
  transition: color .3s ease;
}
/* "Pivoter la grille", holding the right end of the verdict slot — the badge opens
   line 1, this closes the block. It was a ↻ character up in the timer row; it is a
   drawn icon here (ui.js's ROTATE_ICON), 30px against the badges' 24px: those are
   passive marks on a word, this is a control you press, and it was measurably
   crowded by "Terminer" directly above it.
   Absolutely positioned, NOT a flex item, and that is the whole trick. Centred on
   line 1 it sat 10px under Terminer's text with both flush right, and their 44px
   hit areas OVERLAPPED BY 16px — the timer row's buttons carry `padding: 15px 0`,
   so that row reaches 15px below its own text. Taking the button out of flow lets
   it hang lower against the two-line slot (top: 15px ≈ centred on the 52px block,
   biased down) without growing line 1, which is fixed at 34px and would push the
   board down for everyone. Right edge still aligns with Terminer's; it is the gap
   that changed, 10px to 19px.
   A long word cannot reach it: the widest the slot ever holds is La Consigne's
   spaced mask at ~270px of a 354px column, and the icon's left edge is at 324. */
#rotate-btn {
  position: absolute; right: 0; top: 15px;
  /* No double-tap zoom. iOS Safari has ignored `user-scalable=no` in the viewport
     meta since iOS 10 (deliberately, for accessibility), so the page-level meta
     does NOT stop a double tap here — and this button is one a player taps twice
     in a row on purpose, to turn the board through two quarter-turns. Without
     this, the second tap zoomed the page into the grid instead of turning it.
     `manipulation` is the narrow form: it drops the double-tap gesture and keeps
     panning and pinch-zoom, so nothing about the page's own zoom is taken away. */
  touch-action: manipulation;
  /* Above .tr-note, which is a later sibling and therefore paints over the
     invisible hit pad below — measured: it was eating 9px of the target. */
  z-index: 1;
  width: 30px; height: 30px; padding: 0;
  /* Ink, not accent — the same colour PAUSE and TERMINER wear. It was orange
     because it inherited the ↻ glyph's styling, and orange in this palette is
     reserved for what the game is SAYING: a live trace, a word that landed, the
     rank. This says nothing; it is a control the player reaches for, like the two
     above it, and a filled plate in accent shouted over the verdict beside it. */
  background: none; border: none; cursor: pointer; color: var(--ink);
  display: inline-flex; align-items: center; justify-content: center;
  transition: opacity .4s ease;
}
/* The hit pad is deliberately LOPSIDED, and it is boxed in on BOTH sides. Above,
   the timer row's buttons reach 15px below their own text (padding: 15px 0), so a
   pad that grew upward would overlap Terminer's target — it did, by 16px. Below,
   the board's .node hit circles start 6px ABOVE #board's own top edge, and a pad
   that grew downward covered a 15×11 corner of the top-right tile — a dead spot on
   a tile you have to be able to start a trace on. That leaves exactly 39px of
   clear vertical space, and this takes 37 of it.
   So the target is short and WIDE rather than square: the room that does exist is
   sideways, where the verdict line is empty (the word is left-set and the note's
   text is short). 52×37. Below the 44×44 guideline in one axis, and deliberately
   — the alternatives were re-crowding Terminer or eating into the grid.
   If these numbers are ever retuned, re-measure both neighbours; they are what
   constrains this, not taste. */
#rotate-btn::after { content: ""; position: absolute; inset: -2px -9px -4px -14px; }
#rotate-btn:disabled { opacity: .4; cursor: default; }
#rotate-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 2px; }
/* Same split as the ★ badge: the plate is filled, the arrow is stroked, and both
   read currentColor, so the button's ink and its disabled fade reach the whole
   icon from one declaration.
   The viewBox stays 24 while the box is 30, so the 2.1 stroke lands at 2.6px —
   a shade heavier than the ✕ badge's 2.5px, which is what a control should be
   next to a mark. Scale the box and the weight follows; don't compensate. */
.rotate-icon {
  display: block; width: 30px; height: 30px;
  fill: none; stroke: currentColor; stroke-width: 2.1;
  stroke-linecap: round; stroke-linejoin: round;
}
.rotate-icon-grid { fill: currentColor; stroke: none; }
/* The celebration blanks the timer row's controls (see body.celebrating below);
   this button no longer lives there, so it is blanked here on the same beat and
   with the same transition. setEnded() has already disabled it — this is about
   not leaving a live-looking control on a board that is replaying itself. */
body.celebrating #rotate-btn, body.scored #rotate-btn { opacity: 0; pointer-events: none; }
/* Line 2 aligns under the word (indented past the badge slot). */
.tr-note {
  margin-left: var(--tr-shift); height: 14px; line-height: 14px;
  font-size: 10px; font-weight: 500; letter-spacing: .13em; text-transform: uppercase;
  color: var(--muted); opacity: 0;
}
.trace-result.tr-shift .tr-badge {
  width: var(--tr-shift); opacity: 1;
  transition: width 200ms ease-in-out, opacity 200ms ease-in-out, color .3s ease;
}
.trace-result.tr-shift .tr-note { opacity: 1; transition: opacity 200ms ease-in-out; }
/* The win replay shows verdicts settled long ago: badge and note are already in
   place when the word appears, so the slide above is suppressed (ui.js sets
   .tr-instant alongside .tr-shift). Same specificity as the two rules above —
   it has to stay after them to win. */
.trace-result.tr-instant .tr-badge,
.trace-result.tr-instant .tr-note { transition: none; }
/* The puzzle's single longest word is the one verdict that colours its whole
   line: the word joins the badge in accent, and the badge is the same ★ the word
   already carries in the found list (#found-words li.longest). It is the only
   `result-*` class the stylesheet reads — the others are set by fillSlot and go
   unused, which is why every other verdict reads identically. That's deliberate:
   the emphasis here is the point, a palette per verdict would dilute it. */
.trace-result.result-longest .tr-word { color: var(--accent); }
/* Every verdict (found or bad, any reason) keeps the word ink first; DIM_DELAY
   after it is revealed (badge slide), .tr-dim fades word + badge to gray. The
   longest word dims with the rest — the accent marks the moment, not a state. */
/* The retiring verdict fades — the WORD, its badge and its note, and nothing else.
   Scoped to those three exactly like the reel's roll below, and for the same
   reason, learned twice: #rotate-btn lives inside .tr-line1, so a fade on the
   container takes the board's turn control with it. That one is not part of the
   verdict — it is a persistent control that happens to share the row — and it has
   opacity states of its own (:disabled), so riding the fade made it wash out and
   then snap back to full the moment fillSlot reset the class. Put on the three
   text elements, the control never moves.
   Kept below the .tr-shift rules, which set opacity on the same badge and note at
   equal specificity: source order is what lets the retire win. */
.trace-result.tr-fade :is(.tr-badge, .tr-word, .tr-note) {
  opacity: 0; transition: opacity 400ms ease-out;
}
/* --- the reel: a multi-credit trace shows its words one at a time ----------
   Same slot-machine grammar as the end title (#end-title above): the current
   verdict rolls up and out, the next rolls in from below and settles. Applied to
   the line's three text elements, NOT to .tr-line1 — the rotate button lives in
   that flex row and must not ride the reel. Distances are px, not % like the end
   title's: the badge (26px), word (26px) and note (14px) boxes differ, and a
   %-of-own-height roll would land them raggedly. 44/48px clear the 52px window.
   ui.js's REEL_EXIT_MS / REEL_ENTER_MS are these two durations — one number in
   two places, like END_TITLE_EXIT_MS. */
@keyframes tr-roll-out {
  from { transform: translateY(0); opacity: 1; }
  to   { transform: translateY(-44px); opacity: 0; }
}
@keyframes tr-roll-in {
  0%   { transform: translateY(48px); }
  50%  { transform: translateY(-6px); }
  75%  { transform: translateY(2px); }
  100% { transform: translateY(0); }
}
.trace-result.tr-roll-out :is(.tr-badge, .tr-word, .tr-note) {
  animation: tr-roll-out .32s ease-in forwards;
}
.trace-result.tr-roll-in :is(.tr-badge, .tr-word, .tr-note) {
  animation: tr-roll-in .5s cubic-bezier(.2,1.15,.35,1);
}
/* --- board: 295×295, rounded-SQUARE tiles over flat connection lines --- */
/* The traced-word row reads as part of the board, not as its own section, so it
   sits the same 4px off the timer row above as it leaves below itself (see
   #trace-result's margin-bottom — keep the two in sync). body's flex `gap` can't
   be overridden for a single pair of items, so the 18px rhythm is trimmed back. */
main { display: flex; flex-direction: column; gap: 18px; margin-top: calc(4px - 18px); }
#board-col { position: relative; width: 100%; }
/* Matches board.js's viewBox (PAD*2 + 3*CELL = 295) so one SVG unit renders as
   one CSS px: the 58-unit tiles stay 58px and the tightened 77 pitch is what you
   actually see. Change this only alongside CELL. (This is the 1:1 base; on the
   game page body.game's rules above restate the width at --board-scale, through
   the page's --ui-scale zoom, so on screen a unit is --board-scale device px.
   The mapping still holds where it matters, which is that the stylesheet's
   numbers and the viewBox's agree.) */
/* touch-action here as well as on the svg below, and this copy is the load-bearing
   one on iOS: WebKit ignores touch-action set on SVG elements and walks up to the
   nearest HTML ancestor instead. Without it the whole board scrolled the page under
   the finger — most visibly when a press landed in the gutter between two discs,
   where nothing lit up to say the game had taken the touch. */
/* The WIDTH lives on the stage now, not on #board — same number, same rendered
   grid, one level up — because the pause veil is the stage's other child and has
   to be able to size itself against the board's box (see #pause-overlay). #board
   simply fills it. */
/* The box the end-of-game card is anchored to: the verdict line, the grid and the
   card. It exists so the card can start at the TOP of this area (y = the verdict
   line) rather than at the top of the grid — that band is empty by the time a card
   is drawn, and reclaiming it is most of what brings the recap up under the title. */
/* NONE of this box is text to select — not the verdict line, not the grid, not the
   recap drawn over it. Stated HERE, once, and inherited down, for two reasons.
   Exactly like touch-action above, this is the load-bearing copy: WebKit ignores
   user-select on SVG elements and walks up to the nearest HTML ancestor, so the two
   declarations further down (#board svg, .node text) do nothing at all on Safari and
   iOS — they are left in place for other engines, but they are not what holds. And a
   long press does not select a glyph, it selects a RUN: on iOS it started at the
   verdict line and ran down through the grid, so covering the tiles alone left the
   band above them as a handle onto the same selection. Prefixed for that same WebKit,
   plus -webkit-touch-callout, because the long-press menu (Copy / Look Up) is a
   separate iOS behaviour from selection and has to be refused separately. */
#board-area {
  position: relative;
  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
}
#board-stage { position: relative; width: min(295px, 100%); margin: 0 auto; }
#board { width: 100%; touch-action: none; }
/* La Grande Grille's viewBox is 372 (PAD*2 + 4*CELL). Conditional on the mode
   rather than a second CELL: keeping ONE pitch in the game means the SVG scales
   itself down below 372px of column, which is the accepted cost (see the spec —
   19.8px letters on an iPhone SE, arbitrated on the mockup). */
body.grid5 #board-stage { width: min(372px, 100%); }
/* overflow:visible — edge tiles sit 3px from the viewBox border, so the
   trace/bump inflation (×1.04 / ×1.15) pokes past it; don't clip the discs. */
#board svg { width: 100%; height: auto; display: block; overflow: visible; touch-action: none; user-select: none; transform-origin: 50% 50%; transition: transform .45s cubic-bezier(.4,0,.2,1); }
/* --- loading skeletons (index.html ships them; JS removes them) ------------
   Static markup, because the wait they cover IS the JavaScript: no module has
   run yet while the import graph downloads. The board skeleton shares #board
   svg's sizing above, so it reserves the exact box the real grid will claim and
   nothing below the board moves on mount.
   ONE material for every skeleton, --surface-dim: --surface is the tiles' own
   token, and discs wearing it read as a real (broken) board — worse, the intro
   then plays its wave onto discs that were apparently already there. The dim
   gray never claims to be the game; it claims to be its shadow. One animation
   too — everything breathes together. */
/* fill-opacity on top of the shared pulse: at full skeleton material the discs
   read as sixteen buttons waiting for a tap. Washed toward the ground they read
   as where a board will be, not as a board — a watermark, nothing clickable. */
.board-skeleton circle { fill: var(--surface-dim); fill-opacity: .25; }
.board-skeleton, .skel-pill, .skel-tab { animation: skeleton-pulse 1.4s ease-in-out infinite alternate; }
.skel-pill, .skel-tab { display: block; border-radius: 7px; background: var(--surface-dim); }
/* Sized like the resting Connexion label (13px text ≈ this bar). */
.skel-pill { width: 72px; height: 13px; }
/* The tab row's four labels: 15px is the buttons' rendered line box, so the row
   is 26px (content + its 10px padding + 1px rule) before and after the tabs
   paint. Widths only echo QUOTIDIEN / L'ANOMALIE / RANDOM / STATS. */
.skel-tab { height: 15px; }
.skel-tab:nth-child(1) { width: 73px; }
.skel-tab:nth-child(2) { width: 95px; }
.skel-tab:nth-child(3) { width: 49px; }
.skel-tab:nth-child(4) { width: 41px; }
@keyframes skeleton-pulse { from { opacity: .9; } to { opacity: .35; } }
@media (prefers-reduced-motion: reduce) {
  .board-skeleton, .skel-pill, .skel-tab { animation: none; opacity: .6; }
}
/* --- win-replay starfield (see src/replay.js + main.js) --- */
/* Fixed behind the page: a negative z-index child paints above the root's
   background but under all content. Hidden at rest; body.celebrating fades it
   in/out so createStarfield/destroy never pop a frame. Requires html to keep
   no background of its own — body's must stay the one that propagates to
   the root. */
#starfield {
  position: fixed; inset: 0; width: 100%; height: 100%;
  z-index: -1; opacity: 0; transition: opacity .4s ease; pointer-events: none;
}
/* Visible for the celebration AND for the score that follows it: the end screen
   stays in the sky the replay flew through, by then back at cruise. */
body.celebrating #starfield, body.scored #starfield { opacity: 1; }

.edge { stroke: var(--edge); stroke-width: 2.5; transition: opacity .175s; }
/* A dead link RETRACTS into its own middle rather than fading out. The fade was
   175ms of opacity on a 2.5px line and players simply missed it — the board lost
   a link and nothing said so. The retraction gives the loss a direction: the
   segment is pulled in from both ends until there is nothing left.
   Mechanics: the line carries pathLength="1" (set in board.js), so the dash
   pattern below is expressed in fractions of the segment whatever its real length
   — the diagonals retract at the same rate as the straights. `1 1` at offset 0 is
   the whole line drawn; shrinking the dash to 0 while sliding the offset to -.5
   keeps the surviving dash centred at every instant (offset = -(1-dash)/2 falls
   out of the two properties sharing one easing), which is what makes it read as
   "pulled in from both ends" rather than "erased from the left".
   The keyframes deliberately touch NOTHING but the dash. Opacity has to stay at 1
   for the whole retraction — .edge's own .175s fade fires at the same moment and
   would otherwise empty the line before it is a third of the way in — but holding
   it there with a keyframe is the wrong tool: a property under a CSS animation
   cannot start a transition, and that suppression outlives the animation, so the
   way BACK would snap. Links do come back (game.unfind returns a word to play in
   the Anomaly), and they should return as gently as they always did. Hence the
   delay below instead: opacity is left to the transition, simply held until the
   retraction is over, by which point the dash is empty and the flip is invisible.
   No `fill: forwards`, and the resting state stays on the class: the animation may
   expire — or be cancelled outright, which celebration.reset() does to every edge —
   leaving nothing behind on a board-owned element. That is the invariant board.js's
   celebration layer documents at length.
   Which is why the EMPTY DASH is declared on the class too, and not only reached by
   the last keyframe. Without it the link blinked: at t=400ms the animation stops
   contributing and `stroke-dasharray` falls back to `none` — a solid, full-length
   line — while the opacity transition is only just starting (delay .4s, so progress
   0, so opacity still 1). One frame of the whole link at full strength before it
   vanished, 8ms on a ProMotion iPhone and 16ms at 60Hz. Not a race: guaranteed.
   It never showed on the proto, which used `fill: forwards` and so had a destination
   to hold. Declaring the destination on the class gives the animation nothing to
   snap back to, keeps `fill: forwards` off a board-owned element, and makes a
   cancelled animation land on the right state instead of the wrong one.
   The way back is unaffected: dropping .faded returns the dash to `none` — a whole
   line again — at the same instant opacity starts its .175s climb, so an unfound
   link fades back in fully drawn, exactly as it always did. */
@keyframes edge-retract {
  from { stroke-dasharray: 1 1; stroke-dashoffset: 0; }
  to   { stroke-dasharray: 0 1; stroke-dashoffset: -.5; }
}
.edge.faded {
  opacity: 0;
  stroke-dasharray: 0 1;
  stroke-dashoffset: -.5;
  animation: edge-retract .4s cubic-bezier(.4,0,.2,1);
  transition: opacity 1ms linear .4s;
}
/* The selected path draws above default edges, below the tiles. */
.trace { stroke: var(--accent); stroke-width: 6; fill: none; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
/* Tiles are flat discs. No border, no shadow. The tile itself never changes
   colour or size any more: the accent arrives as a FILL LAYER over it (the trame,
   below) and the size lives on the .disc group that holds tile, fill and letter. */
.node .tile { fill: var(--surface); cursor: pointer; }
/* The disc — tile + fill layer + glyph — is what grows, as one, so the fill never
   shows a ring of tile at the rim. Three sizes, one decel curve, never a spring
   (spec docs/superpowers/specs/2026-09-19-trame-versee-board-animation-design.md):
     .selected   ×1.05 in 290 ms, HELD for as long as the letter is traced;
     .confirmed  ×1.05 × 1.06 = ×1.113 in 210 ms, held — the word was accepted;
     .releasing  back to rest in 170 ms, from wherever it was — the drain, as the
                 verdict reads the letter out. Untracing comes back on the 290 ms.
   The rises are board.js's CONFIRM_LEAD_MS and main.js's chain in disguise: change
   one here and check what waits on it there. */
.node .disc { transform-box: fill-box; transform-origin: center; will-change: transform;
  transition: transform .29s cubic-bezier(.2,0,0,1); }
.node.selected .disc { transform: scale(1.05); }
.node.confirmed .disc { transform: scale(1.113); transition-duration: .21s; }
.node.releasing .disc { transition-duration: .17s; }
/* The trame: a square grid of accent dots at board.js's TRAME_PITCH (5 units),
   clipped to the disc by the layer's clip-path. Each dot grows from 0 to
   0.78 × pitch — 3.9 units, so neighbours OVERLAP and the grid closes into a flat
   accent disc as the wave passes (at less than half the pitch it would stay a
   halftone, which is what shipped for an hour on 2026-09-19). Stated HERE and not
   templated in board.js, which
   test/board-rotation.test.js forbids from writing any `${…}px` — over the
   duration below, after the inline transition-delay board.js gives it from its
   distance to the wave's origin. Three ways for the wave to run, three classes on
   the fill <g>:
     .in    growing, 70 ms per dot, from the rim point the tube arrives at;
     .undo  shrinking on the same 70 ms, delays inverted — the fill backwards, when
            the player drags back off the letter;
     .out   shrinking on 80 ms, delays re-measured from the centre — the drain of an
            accepted word, which is NOT the fill in reverse (owner, 2026-09-19).
   The two durations are board.js's TRAME_DOT_MS and DRAIN_DOT_MS, which it uses to
   time the letter's colour and the spent fill's removal; keep them in step. */
.fill-layer { pointer-events: none; }
.fill circle { fill: var(--accent); transition: r 70ms cubic-bezier(.2,0,0,1); }
.fill.in circle { r: 3.9px; }
.fill.undo circle { r: 0px; }
.fill.out circle { r: 0px; transition-duration: 80ms; }
/* An inert board (the locked Anomaly's veil — createBoard's `inert`) has no gesture
   listeners, so nothing here may offer a trace. The cursor is the whole rule: the
   tiles and their hit areas each ask for `pointer` above, and both have to stop. */
svg.inert .node .tile, svg.inert .node .hit { cursor: default; }
/* The opening wave is running (board.intro): the grid is still arriving, so it takes
   no trace. Deliberately here rather than as a guard inside the pointerdown handler
   — the gesture path stays exactly what it is, the same way `inert` above is an
   absence of listeners rather than a flag to test. A tap is not swallowed: main.js
   listens on the document and uses it to cut the wave short. */
#board svg.intro { pointer-events: none; }
/* The invisible hit area, wider than the disc (see HIT in board.js) so a press in
   the gutter still picks the nearest letter. Every rule above and below targets
   .tile rather than `circle` for this one reason: a bare `circle` selector would
   paint this one accent the moment its node went .selected.
   `transparent`, never `none` — under the default pointer-events: visiblePainted an
   unfilled circle is not hit-tested at all, which is the entire point of it. Same
   reason .node.rocket paints transparent below. */
.node .hit { fill: transparent; cursor: pointer; }
/* 700/26px, not the handoff's 600/24px: at DPR 2 the lighter, smaller cut
   rasterises Space Grotesk's squared left back as a hard fully-inked column
   (no antialiasing ramp), so round letters read as sliced. This matches the
   traced word above the board, which never had the problem. */
.node text {
  font: 700 26px var(--font-display); text-anchor: middle; dominant-baseline: central;
  pointer-events: none; user-select: none; fill: var(--ink);
  transition: transform .45s cubic-bezier(.4,0,.2,1);
  /* The counter-rotation is a bare rotate() about THIS origin — the glyph sits at
     (0,0) of a <g> its build step translated to the tile's centre by attribute
     (board.js, the node build, for the iOS bug behind that arrangement). Both are
     the initial values for an SVG element; stated so nobody "helps" by switching
     to fill-box, which would pivot on the glyph's ink box and put the letter a
     half-cap-height off its disc on every turn. */
  transform-box: view-box; transform-origin: 0 0;
}
/* The letter's traced colour is its own class, .lit, set by a board.js timer at
   mid-fill and cleared at mid-drain so the letter follows the front rather than
   snapping ahead of it. A class and a timer rather than a CSS delay because the
   opening wave's probe (board.js surfaceWave) reads this colour off one synchronous
   style recalc, which a delayed transition would hand back stale. The flip itself
   is instant — no fill transition on a traced letter, whichever way it goes. */
.node.lit text { fill: var(--on-accent); }
/* Releasing a FOUND word is the exception: board.js marks each disc .releasing as
   the celebration spells the word out letter by letter, and the glyph fades back to
   ink over the drain's own beat. Set by release(), cleared when the tile is next
   traced (see setSelected) — never applied to a rejected word, which drops
   instantly. */
.node.releasing text { transition: transform .45s cubic-bezier(.4,0,.2,1), fill .16s; }
/* A spent letter (no words left through it): the tile vanishes, leaving the
   letter as a pale ghost — flat, icon-free. */
.node.rocket .tile { fill: transparent; cursor: default; transition: fill .35s; }
.node.rocket text { fill: var(--line); }
/* Brume, and the veiled board of a locked Anomaly: the tile is there, the letter
   is not. A full disc in the resting tile colour — a tile with its letter veiled —
   not the dashed ring it used to be: an outline-only tile left the links around it
   floating into nothing, and read as a different object from its neighbours. Being
   filled it is also hit-tested like any tile, so the visiblePainted trap that
   .rocket's comment above explains never arises here. */
.node.fogged .tile { fill: var(--surface); }
/* The creature: the tile is held, not spent — and she is drawn by her own marker
   (board.js passengerMarker), never by the tile's fill. The tile keeps only the
   class, so all this rule owes is the cursor: there is nothing to trace here.
   Two earlier attempts, both wrong, both worth not repeating. First an accent
   disc with the letter in --surface — the same orange .selected paints, told
   apart from a selection by 4% on one glyph, which is to say not at all. Then an
   ink fill here plus a sliding marker, which split her in two on every move: the
   disc jumped with the class while the eyes crawled after it. */
.node.occupied .tile { cursor: default; }
/* Her, whole: an ink body with two accent eyes. Ink inverts every other tile on
   the board (elsewhere ink is the letter and paper is the disc), so she can never
   read as a selection and she is the heaviest object on the grid — which is what
   "you cannot go here" should look like. The accent is doing its one job here
   that it does nowhere else: marking something that is not yours.
   She is a single element moved between tiles, so this transition IS her walk —
   it slides her tile-to-tile and holds her upright through a quarter-turn, and
   sharing the grid's own .45s means the two can never pull apart mid-turn.
   Two elements since 2026-09-06, one transition each: the marker's position is an
   SVG `transform` ATTRIBUTE (user units — a CSS px translate is scaled by the
   page zoom on iOS, see board.js's node build), which is a presentation
   attribute and so drives the same property this transition watches; the
   quarter-turn is a CSS rotate() on .passenger-turn inside her. The
   letter under her needs no rule of its own: an opaque body covers it, and gives
   it back as she leaves, in step with her. */
.passenger, .passenger-turn { pointer-events: none; transition: transform .45s cubic-bezier(.4,0,.2,1); }
.passenger .body { fill: var(--ink); }
.passenger ellipse { fill: var(--accent); }
/* The blink: a lid dropping, i.e. the eye squashing to a sliver — never opacity,
   which reads as the eye switching off. The scale is on the bare ellipse inside
   its socket <g> (board.js passengerMarker says why the tilt lives on the g: a CSS
   transform would replace the attribute one). The close is a touch faster than
   the open, which is what real lids do; board.js owns the cadence. */
.passenger ellipse {
  transform-box: fill-box; transform-origin: center;
  transition: transform .09s ease-out;
}
.passenger.blink ellipse { transform: scaleY(.12); transition-duration: .06s; }
/* --- end-of-game scorecard drawn on the board (board.js scorecard) --- */
/* The spent letters stay visible as paper behind the recap, but faint: the
   mock's 0.45, taken down twice by 20%: .45 → .36 → .29. They are the game you
   just played — texture behind the numbers, not text to read. */
/* `fill` is in the transition, not just opacity: when the recap goes up the board
   also settles into its solved state (board.settleAsSolved), so letters that were
   still ink have to travel to the spent grey. Without it they snap while the discs
   under them fade over .35s — the one movement broke into two. Transitions read
   the AFTER-change style, so this covers the flip even though .scoring lands in the
   same tick as the class change that recolours the glyph. */
#board svg.scoring .node text { opacity: .29; transition: opacity .5s ease, fill .5s ease; }
#board svg.scoring { pointer-events: none; }   /* the board is done; no stray tracing under the recap */
/* And the PAGE gets its scroll back. `touch-action: none` on #board (above) is what
   lets a trace start anywhere in the grid without iOS panning the page — but once the
   day is scored the grid is a spent backdrop under the card, and the card's own
   pointer-events are off, so a finger landing on the recap reaches #board and is
   still refused as a pan. On a phone that is most of the screen: the one place you
   could not scroll was the score you were reading (reported 2026-09-02). Keyed on
   body.scored, the state main.js sets when the card goes up, on the DIV as well as
   the svg for the same WebKit reason the original rule is stated twice. */
body.scored #board, body.scored #board svg { touch-action: auto; }
/* The rank leads: one big display-face character (the letter locks first, the
   modifier a beat later — see board.js's scorecard choreography), the word
   that qualifies it underneath, in the same slot the "computing" readout
   used a moment before. transform-box/origin center the terminal's lock-in
   scale kick on the glyph rather than the SVG viewport's corner. */
.sc-grade { font: 700 76px var(--font-display); fill: var(--accent); }
/* No transform-box / transform-origin here any more: the rank's pops are SVG
   attribute transforms on .sc-shift (board.js), because CSS transforms on SVG text
   are what Safari failed to repaint — the rank came back cut flat along one edge. */
.sc-word  { font: 500 11px var(--font-mono); letter-spacing: .3em; fill: var(--ink); }
.sc-read  { font: 500 11px var(--font-mono); letter-spacing: .16em; fill: var(--accent); }
/* The Anomaly's seal, where the scored card puts its rank. */
.sc-seal circle { fill: none; stroke: var(--rule); stroke-width: 2; }
.sc-seal.won circle { stroke: var(--accent); }
.sc-seal path { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.sc-scan  { fill: var(--accent); pointer-events: none; }
/* The card sits over the grid and no longer inside it, so it does not turn with the
   board and needs no counter-rotation to read upright — board.js used to undo the
   grid's angle on it by hand at every quarter-turn, and the transition that lived
   here was there to keep that undoing in step with the sweep. Both are gone. */
/* The breakdown reads as a list you can actually scan: the label in ink (it
   names the thing), its detail a step down in grey, and the points in accent —
   the same orange the tiles use for a word that landed. */
.sc-lbl { font: 500 11px var(--font-mono); letter-spacing: .1em; fill: var(--ink); }
.sc-lbl-small { font: 500 10px var(--font-mono); letter-spacing: .2em; fill: var(--muted); }
.sc-detail { font: 400 12.5px var(--font-mono); fill: var(--muted); }
.sc-val { font: 700 19px var(--font-display); fill: var(--accent); }
/* The total is a sum, not another breakdown line: same accent digit face,
   a step up in size. */
.sc-total .sc-val { font-size: 22px; }
/* There is no `.sc-dense` any more, and its absence is the point. The card used to
   be fitted into the grid's 295-unit box, so past four lines the type had to step
   down a size to avoid running into the total. The card sizes itself to its content
   now (see board.js drawScorecard), so every line is set at one size whatever the
   count — the card gets taller instead of the words getting smaller. */

/* The card's own element: anchored to the top of #board-area so it starts at the
   verdict line, floating over the dimmed grid rather than being drawn inside it.
   `width` is the card's own 295 design units, independent of the grid — La Grande
   Grille is 372 wide and its recap has no reason to be. pointer-events are off so
   the spent board underneath is never blocked; PARTAGER turns them back on for
   itself (.sc-share below). */
#scorecard {
  position: absolute; top: 0; left: 50%; transform: translateX(-50%);
  width: min(295px, 100%); z-index: 3; pointer-events: none;
}
/* By CLASS, not `#scorecard svg`: the atelier hosts the same card in its own stage
   (see copy.css), and an id can only dress the one in the page. */
.sc-svg { width: 100%; height: auto; display: block; overflow: visible; }
.sc-share {
  font: 500 10.5px var(--font-mono); letter-spacing: .12em; fill: var(--accent);
  pointer-events: auto; cursor: pointer; text-anchor: start; /* the total owns the right side now */
}
.sc-share:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The celebration owns the screen, and the end state keeps it: from the rewind
   onward the timer row's CONTROLS stay blank — nothing ticks, nothing to pause.
   Faded rather than removed so the board never jumps when the score lands. What
   takes their place in the same band is the end-of-game title below: the controls
   go and it arrives, one crossfade, no reflow. The rotate control is blanked on
   the same beat but by its own rule, since it left this row for the verdict line
   — grep body.celebrating #rotate-btn. */
body.celebrating #gamebar > :not(#end-title),
body.scored #gamebar > :not(#end-title) { opacity: 0; pointer-events: none; }
#gamebar > * { transition: opacity .4s ease; }
/* The bar under the board goes with them, on the same beat: "16 / 16 — 100 %" is
   a progress reading, and there is no progress left to report — the replay and
   then the score are the whole statement. Faded, not removed, so the found-word
   list below it never jumps; that list stays, it is the game you just played. */
body.celebrating #progress, body.scored #progress { opacity: 0; pointer-events: none; }
#progress { transition: opacity .4s ease; }
/* "Quotidien complété" / "Quotidien terminé" (main.js's endTitleFor), in the mono
   caps register of the tab row — a status line, not a banner: the retracing board
   is what the eye should be on. But it has to be *read*, so it sits a weight and a
   step above the tab row, in ink rather than grey.
   The outer span is the reel's WINDOW: it covers the row (inset:0), centres the
   text on the column, and clips — which is what makes the entrance below read as
   a slot machine rather than as something flying in. It is absolutely positioned,
   so nothing it does can change the row's height. `transition: none` overrides the
   row-wide opacity fade inherited from `#gamebar > *`: a crossfade under a rolling
   reel just muddies it — the clip is the reveal. */
#end-title {
  position: absolute; inset: 0; overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  font-family: var(--font-mono); font-weight: 600; font-size: 12px;
  letter-spacing: .2em; text-transform: uppercase; color: var(--ink);
  white-space: nowrap; opacity: 0; pointer-events: none; transition: none;
}
body.celebrating #end-title, body.scored #end-title { opacity: 1; }
/* The entrance: the text rolls up into the window from below, overshoots its line
   and settles back — one reel stopping. Purely vertical and measured in % of the
   text's own box, so it steps by its own height like a reel row; no scale, which
   is what made the old version read as growing out of the left corner. Starts at
   140% so the ascenders clear the window's bottom edge before the roll begins.
   Driven by .rising, which ui.setEndTitle adds with the text (and only when the
   text actually changes, so the win path's second, identical call can't re-roll it
   mid-replay); the body classes hold opacity up afterwards, so no fill is needed. */
@keyframes end-title-roll {
  0%   { transform: translateY(140%); }
  50%  { transform: translateY(-18%); }
  72%  { transform: translateY(7%); }
  88%  { transform: translateY(-3%); }
  100% { transform: translateY(0); }
}
#end-title.rising > span { animation: end-title-roll .5s cubic-bezier(.2,1.15,.35,1); }
/* The exit, two seconds later (ui.js's END_TITLE_HOLD_MS): the line is a toast, and
   it leaves the way a reel row leaves — CONTINUING upward, out of the same window
   it rolled into, rather than dropping back down the way it came (which would read
   as the entrance rewound, i.e. as a mistake). Fades on the way so the ascenders
   don't clip hard against the window's top edge. Kept below .rising so it wins on
   source order for the one frame both classes could overlap; ui.js drops .rising as
   it adds this, and drops this with the text when the .32s is up — the duration and
   END_TITLE_EXIT_MS are one number in two places. */
@keyframes end-title-retire {
  0%   { transform: translateY(0); opacity: 1; }
  100% { transform: translateY(-115%); opacity: 0; }
}
#end-title.leaving > span { animation: end-title-retire .32s ease-in forwards; }
/* --- reward feedback (see feedback.js) — flat: pop scale only, no glow --- */
.node { transform-box: fill-box; transform-origin: center; }
@keyframes fb-rocket-pop { 0% { transform: scale(.55); } 55% { transform: scale(1.28); } 78% { transform: scale(.94); } 100% { transform: scale(1); } }
.node.fb-rocket { animation: fb-rocket-pop .65s cubic-bezier(.3,1.5,.5,1); }
@media (prefers-reduced-motion: reduce) {
  .node.fb-rocket { animation: none; }
  /* The link's retraction is motion for motion's sake here. Dropping it restores
     exactly the quiet fade the board had before — the hold delay has to go with
     it, or the link would sit there for .4s and then blink out. The dash has to be
     handed back as well: the retracted state now rests on .edge.faded itself (see
     the block above), and left in place it would empty the line instantly and leave
     the .175s fade nothing to fade. */
  .edge.faded {
    animation: none; transition: opacity .175s;
    stroke-dasharray: none; stroke-dashoffset: 0;
  }
  #board svg, .node text, .node.releasing text, .node .disc, .fill circle, .sc, .passenger { transition: none; }
  /* Same for the end-screen settle: the board arrives as paper, it doesn't fade
     to it. Needs its own selector — the .scoring rule out-specifies .node text. */
  #board svg.scoring .node text { transition: none; }
  /* The title still appears — it just arrives, with none of the roll. And it still
     goes, on the same schedule: the toast's two seconds are how long the line is
     readable, not an effect. It simply stops being there when ui.js drops the text
     instead of rolling out of the window. */
  #end-title.rising > span, #end-title.leaving > span { animation: none; }
  /* The reel's words still take turns on the same schedule — the hold is how
     long each word is readable, not an effect — they just swap instead of
     rolling. The end title's own PRM stance, restated. */
  .trace-result.tr-roll-out :is(.tr-badge, .tr-word, .tr-note),
  .trace-result.tr-roll-in :is(.tr-badge, .tr-word, .tr-note) { animation: none; }
}

/* pause overlay (#board-stage is its positioning context)
   A pane of frosted glass laid over the grid, not a grey slab dropped on the page.
   It used to be the latter and it showed: a 72%-opaque fill with SQUARE corners,
   over a board blurred so hard (24px) that nothing survived behind it. Two
   mismatched rectangles — the fill's hard edge, and the soft-edged square the
   board's own blur bled into — stacked on each other, and the grid simply gone.
   Now there is ONE shape. The blur moved off the board and onto this element as a
   backdrop-filter, so it is the panel that frosts, exactly to its own rounded
   outline, and the fill lightened to 58% so the grid reads THROUGH it as shape
   without ever resolving into letters. Radius 20 answers the tiles' 12: the same
   family, further round, because this is the container and they are the contents.
   The NEGATIVE INSETS are the fix for the edge glitches, and they are why this had
   to move onto #board-stage. A backdrop-filter has no data past its own boundary,
   so it clamps — and any high-contrast thing sitting under that boundary gets
   smeared along it. Spanning the whole column, the panel's top edge ran under the
   rotate icon and its bottom edge under the progress label, and both streaked
   exactly there. Sized to the grid and grown outward instead, every edge lands on
   bare background: 14px into the column's side margins (22px are free), and into
   the gaps above and below the board — asymmetrically, because those gaps are not
   the same size. Above, the verdict line's note row ends 11px over the first tile
   and the edge sits in the middle of that; below there is more room before the
   progress bar. Grow these and re-measure both neighbours, or the streaks come
   back. */
#pause-overlay {
  position: absolute; inset: -5px -14px -12px; z-index: 5;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;
  border-radius: 20px;
  background: color-mix(in srgb, var(--bg) 58%, transparent);
}
/* La Grande Grille's grid fills the column outright, so there is no side margin
   left to grow into: at -14 the panel came within 4px of the screen edge and read
   as a mistake. Half as much, and nothing is lost — the only thing near the edge
   on that board is a tile, off-white on a light ground, which cannot streak the
   way the dark rotate icon did. */
body.grid5 #pause-overlay { left: -8px; right: -8px; }
#pause-overlay[hidden] { display: none; }
/* Stands on its own since 2026-09-13 — it used to ride the shared label rule and
   then override its size, which made that rule a lie. It is a title on an overlay,
   not a control in a row, so it keeps its own 14px. */
.pause-title {
  font-family: var(--font-mono); font-weight: 500; font-size: 14px;
  letter-spacing: .13em; text-transform: uppercase; color: var(--ink);
}
#resume-btn { padding: 12px 0; background: none; border: none; color: var(--accent); cursor: pointer; }
/* Blur the board itself, so the letters can't be studied for free. This is the
   FALLBACK — heavy on purpose, because on its own it has to do the hiding. */
body.paused #board { filter: blur(24px); pointer-events: none; }
/* Where the backdrop can be filtered, the panel does the work instead and the
   board is left alone: one blurred shape with a clean rounded edge, rather than a
   blurred square bleeding out under a hard-edged fill. Must stay AFTER the rule
   above — same specificity, later wins. -webkit- included and not optional: this
   is a PWA that lives on iOS, where the prefix is still what answers. */
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  #pause-overlay { -webkit-backdrop-filter: blur(13px); backdrop-filter: blur(13px); }
  body.paused #board { filter: none; }
}

/* Why a locked Anomaly's board is a ghost — and, for a guest, the way out. It sat
   UNDER the veiled board until 2026-08-10, which on a phone put it below the fold:
   the only explanation of the screen was the one thing you had to scroll to find,
   so the mode read as broken rather than locked. It now follows the rule of the day
   directly, above the board (see index.html).
   Centred on the board rather than the column, and capped at the 4x4 viewBox so the
   sentence breaks over the grid it is about instead of running the page's full
   width. 11px, the tab row's size: it is a caption, not a headline — but in --ink,
   because at that size and centred, --muted was a disclaimer. It also has to hold
   the longer of the two sentences (the guest's, which names both locks) in the same
   two lines; that measurement is LOCKED_NOTE_MAX in tools/copy-manifest.js. */
#locked-note {
  margin: 0 auto 6px; max-width: 295px; text-align: center;
  font-size: 11px; line-height: 1.5; color: var(--ink);
}
/* The login link under it (guest only — ui.js's renderLockedNote). A text button in
   the accent, like .random-link: the one actionable thing on a screen that is
   otherwise entirely inert, so it is the only coloured thing on it. */
#locked-login {
  display: block; margin: 0 auto 4px;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
  color: var(--accent); background: none; border: none; padding: 0; cursor: pointer;
  text-decoration: underline; text-underline-offset: 3px;
}
/* The `display: block` above beats the UA sheet's [hidden] rule, exactly as #twist's
   does — so the link needs this or it survives its own hiding, and a finished daily
   ends up inviting you to sign in underneath its scorecard. */
#locked-login[hidden] { display: none; }

/* --- progress: 6px flat track under the board --- */
#progress { display: flex; flex-direction: column; gap: 6px; margin-top: 18px; }
#progress-track { height: 6px; background: var(--surface-dim); border-radius: 3px; overflow: hidden; }
#progress-fill { height: 100%; width: 0; background: var(--accent); border-radius: 3px; transition: width .35s ease; }
/* min-height: the label is empty until JS paints "0 / 16 — 0 %", and its line
   arriving used to shove the sidebar and footer down 13px mid-load. */
#progress-label { font-size: 10px; font-weight: 500; letter-spacing: .13em; text-transform: uppercase; color: var(--muted); font-variant-numeric: tabular-nums; min-height: 13px; }
/* HIDDEN, 2026-09-06, for the height it costs: on a phone the board already
   pushes the found-words list toward the fold (see --board-scale above), and the
   count is the one line here that says nothing the filling bar above it and the
   list below it do not. KEPT, not deleted — ui.js still writes it on every
   credited word, `progress.label` is still in the registry and the manifest, and
   the reserved line is still reserved. Delete this ONE rule to bring it back. */
#progress-label { display: none; }

/* --- found words / status --- */
#sidebar { min-width: 0; }
/* Two columns: the list runs to 15+ words and the sidebar is full width under
   the board. A row-flow grid rather than CSS multi-column, so the 1px rules line
   up across every column instead of the columns balancing to whatever heights
   they like. Bonus words are rows of this same list since 2026-09-06 (li.bonus
   below); the separate #bonus block under its own title is gone.

   THREE columns since 2026-09-05, and row-flow is what makes that safe: the grid
   fills left-to-right, so with renderList handing it newest-first the newest word
   is always top-left and reading order is the order they were found. The count is
   the only thing that changed — a fourth would not fit the sidebar's measure at
   13px, and multi-column would have staggered the rules the moment the columns
   balanced unevenly.

   `minmax(0, 1fr)`, NOT a bare `1fr`, and the difference is the whole reason the
   rules line up: `1fr` is `minmax(auto, 1fr)`, so its floor is the widest word in
   the column and one long answer widens its own track at the others' expense —
   measured at 109/148/101px on a 354px sidebar, three different rule lengths on
   every row. With a 0 floor the three tracks are equal whatever they hold, and a
   word too wide for its 110px wraps instead (overflow-wrap below) rather than
   pushing the grid out of true. Targets run to ten letters on a 4×4 and the wrap
   should never fire; it is there because the grid is a parameter (La Grande
   Grille is 5×5) and a silently ragged column is not the way to find that out. */
#found-words {
  list-style: none; padding: 0; margin: 0; font-size: 14px;
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 12px;
}
#found-words li { padding: 6px 0; border-bottom: 1px solid var(--rule); text-transform: lowercase;
  overflow-wrap: anywhere; position: relative; }
#found-words li a { color: inherit; text-decoration: none; }
#found-words li a:hover { text-decoration: underline; }
/* THE TWO MARKS HANG IN THE GUTTER, they do not sit in the track (2026-09-13).
   They were inline, and the row's word started one glyph further in — "the indent
   is the emphasis". That worked at 13px by ONE PIXEL: a track is (316 − 24)/3 =
   97.3px on the widest column the sidebar ever gets, a ten-letter answer is 10 ×
   0.6em, and at 13px the star plus its 5px gap left 1.3px of room. At the scale's
   14px it needs 103px of a 97px track, so `overflow-wrap: anywhere` fired and the
   day's ★ answer broke across two lines — the safety net above became the common
   case, exactly what its own comment says should never happen on a 4×4.
   Absolutely positioned, the mark costs the track nothing: every row's word starts
   on the same x, and the mark sits in the 12px column-gap (or, for column one, in
   the page's own padding). Mono makes that exact rather than hopeful — every glyph
   is 0.6em, so the mark is 8.4px in a 12px gutter at any size on this scale.
   The emphasis is unchanged to the eye: the mark is still there, still to the left,
   still in the row's colour. What it stops doing is pushing the word. */
#found-words li.longest { font-weight: 600; color: var(--accent); }
#found-words li.longest::before,
#found-words li.bonus::before { position: absolute; left: -.72em; }
#found-words li.longest::before { content: '★'; }
/* A bonus word, in the same list (ui.js's renderList, 2026-09-06): muted, with a
   `+` in front — the verdict line's own register for a bonus — carried by ::before
   exactly as the ★ is. A bonus word is never the longest, so the two marks never
   compete for one row. The colour is the one thing kept from the old separate
   block: a bonus is a real word the grid did not ask for, and it reads as an aside
   among the targets. */
#found-words li.bonus { color: var(--muted); }
#found-words li.bonus::before { content: '+'; }
/* THE FRESH MARK IS GONE (owner, 2026-09-12). The last trace's credits wore a
   2px hairline of the type's own ink in the margin, held until the next credit
   replaced the set — itself the survivor of two rounds of tuning (an accent bar
   with a 5s wash, then monochrome on 2026-08-31, because orange in this list
   already means .longest).
   What retired it was the list being MERGED and ordered newest-first on
   2026-09-06: the mark answered "what did that trace just pay?", and a single
   list whose top rows are the answer says that by itself. Marking the top of a
   list that is already sorted by recency is saying it twice.
   The freshSet stays in ui.js — it still decides which rows FALL IN (settleList),
   which is motion rather than a mark, and plays once rather than being held. */

/* --- instructions --- */
/* The footer reads at 12, the middle rung, NOT at 10. It went to 10 for a few
   hours on 2026-09-13 and came back too small to read (owner) — and the answer is
   not a size of its own, which is the exception the scale exists to refuse. It is
   that 10 is the LABEL rung: short, uppercase, tracked. This is a paragraph of
   sentence-case French prose, so it belongs with reading.
   It shares the rung with the tab row and the buttons and is in no danger of
   competing with them. Caps carry no x-height, so at THE SAME SIZE a line of
   capitals stands ~34% taller than a line of lowercase — 8.5px against 6.3px at
   12px, measured off the live page, cap height .71em against x-height .53em in IBM
   Plex Mono. (The often-quoted ~19% is the figure for caps one step BELOW the
   lowercase, which is what this row looked like before the scale.) Sharing a rung
   is therefore not sharing a voice. Muted, lowercase and untracked against ink,
   uppercase and tracked, the footer recedes on three axes at once — which is what
   a fourth size would have been buying. */
#howto { margin: 0; font-size: 12px; line-height: 1.6; color: var(--muted); }
/* The rules paragraph that opened this footer (page.howto) is gone since 2026-09-04:
   the welcome modal teaches the same two facts on a live board, and a sentence
   nobody re-reads under the grid was the one place left saying them twice. The
   credit is the footer's first line now. */

/* …except for this one, since 2026-09-12: the footer's first line is again a
   sentence saying what the game is (seo.intro, written here by tools/sync-seo.mjs).
   It is NOT the paragraph that was removed above, and the distinction is the whole
   reason it is allowed back: that one taught the player how to play, which the
   welcome modal now does on a live board, while this one exists to be READ BY A
   CRAWLER — it is the only prose in the markup, everything else on the page being
   painted by ui.js. It gets no styling of its own beyond killing the <p> margin:
   same 12px, same muted colour, same register as the credit and the two
   disclosures under it. Nothing here may hide it — a sentence served to Google and
   not to the reader is the pattern search engines discount, and rightly. */
#intro-line { margin: 0; }

/* The word rules (#wordrules), collapsed by default. They stay in the footer's own
   register — 12px, muted — because this is a lookup hung off the intro line, not a
   new section of the page. No `display` is declared anywhere in this block on
   purpose: index.html ships the <details> hidden until ui.js paints it, and an
   author `display` would beat the UA's [hidden] rule.
   NO hairline above it since 2026-09-12 (owner's call). It carried the same short
   rule the credit and the bonus heading still use; with the second disclosure gone
   this was one separator too many, sitting between two things that read as one —
   the sentence saying what the game is, and the disclosure answering the first
   question about it. `position: relative` and the extra `padding-top` went with it:
   both existed only to place that rule, and nothing inside the block is positioned
   against it. One margin now does the spacing, the same 14px the credit and the
   links row below use. */
.howto-rules { margin-top: 14px; }
/* The summary is a real control and has to read as one, but the weight is no
   longer what says so (owner's call, 2026-09-12 — it is 400 now, the same as the
   paragraphs under it). What carries the affordance is the UA's disclosure
   triangle, which says "this opens" better than any weight could, plus hover and
   focus answering the pointer, plus ink once it is open. The focus ring is the
   page's one focus vocabulary — see #rotate-btn.
   `.howto-sources-title` is gone from these selectors: Sources stopped being a
   disclosure on 2026-09-05 and became a link, so the class had no element left to
   match. The bonus heading is not here either — it stopped being a <summary> on
   2026-09-12 and has its own rules below. */
/* The triangle is DRAWN here rather than left to the UA (2026-09-12). The browser's
   own ::marker sat visibly high against the mono line and there is no way to move
   it: ::marker takes colour, font and content, never a position. So the marker is
   switched off on all three engines — `list-style: none` for Firefox,
   `::-webkit-details-marker` for WebKit, and `display: flex` which drops it in
   Blink by itself — and a border triangle takes its place, centred by
   `align-items: center` on the same line box as the text.
   It is still the same shape the UA drew, because that shape is what says "this
   opens"; only the alignment changed. currentColor, so it follows the summary
   from muted to ink when the block opens. No transition on the flip: the UA marker
   never animated either, and an animated one would need a reduced-motion path for
   a 90° turn nobody asked to watch. */
.howto-rules-title {
  cursor: pointer; font-weight: 400; padding: 2px 0;
  display: flex; align-items: center; gap: 7px; list-style: none;
}
.howto-rules-title::-webkit-details-marker { display: none; }
/* The border-triangle is the FALLBACK, not the shape we want: borders cannot round
   a corner, and the UA marker this replaces is a rounded, almost-square wedge, not
   a narrow spike. Anything that cannot mask gets this — sharp, but never a bare
   square, which is what a masked box degrades to when the mask is dropped. */
.howto-rules-title::before {
  content: ''; flex: 0 0 auto;
  border-left: 6px solid currentColor;
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
}
/* The real one: an SVG wedge used as a MASK over currentColor, so it keeps
   following the summary from muted to ink while getting the rounded joins a border
   cannot give. Drawn as a small triangle STROKED with `stroke-linejoin: round` —
   the stroke both fattens it and rounds all three corners at once, which is how
   the UA draws its own. Width and height are near enough equal on purpose: the
   spike it replaced was 6 wide by 8 tall, and that ratio is what read as pointy. */
@supports (mask-image: none) or (-webkit-mask-image: none) {
  .howto-rules-title::before {
    border: 0;
    width: 10px; height: 10px;
    background-color: currentColor;
    --tri: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2010%2010'%3E%3Cpath%20d='M3.3%203%20L8%205%20L3.3%207%20Z'%20fill='%23000'%20stroke='%23000'%20stroke-width='2.2'%20stroke-linejoin='round'/%3E%3C/svg%3E");
    -webkit-mask: var(--tri) center / contain no-repeat;
    mask: var(--tri) center / contain no-repeat;
  }
}
.howto-rules[open] > .howto-rules-title::before { transform: rotate(90deg); }
.howto-rules-title:hover { color: var(--ink); }
.howto-rules-title:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
/* Once open, the title is a heading over its own list — ink, so the eye finds the
   way back out of the block it just opened. With the weight gone this is the only
   thing marking it as the head of what you opened, which is why the bonus heading
   inside stays muted: two ink lines in one block would compete for that job. */
.howto-rules[open] > .howto-rules-title { color: var(--ink); }
.howto-rules-intro { margin: 8px 0 0; }
/* padding-left is the bullets' whole indent: the list sits one step in from the
   intro, and the disc is drawn inside it (list-style-position stays outside), so a
   wrapped rule line aligns under the first word rather than under the disc. */
.howto-rules-list { margin: 4px 0 0; padding-left: 1.4em; }
.howto-rules-list li { margin: 3px 0; }
.howto-rules-closing { margin: 10px 0 0; }
/* The last line is about the BOARD, not the dictionary, so it stands off the list of
   exclusions — but on whitespace alone. It was given the credit's short hairline
   first, and that put two identical rules within forty pixels of each other at the
   foot of the page: the device stops reading as "a footnote begins here" the moment
   it is used twice in a row. One hairline opens the block, one opens the credit. */
.howto-rules-note { margin: 14px 0 0; }

/* The bonus-words section, INSIDE the word rules since 2026-09-12 (it had its own
   <details> until then). It is a heading and a paragraph, not a disclosure, so it
   has no cursor, no focus ring and no marker — nothing here may suggest something
   is left to click.
   It also has no colour and no weight of its own (owner's calls, same day): muted
   and 400, inherited, exactly like the paragraphs around it. Both were tried
   heavier and both were wrong for the same reason — ink is what the summary above
   turns when it OPENS, and weight was what the summary used to have, so either one
   here would have this sub-section outranking the block it sits inside.
   That leaves the SPACE and the hairline to do all the work, which is the whole
   point: the section is announced the way the footer announces every section (see
   .howto-credit::before), and by nothing else. If it ever stops reading as a
   heading, add room above it before adding ink or weight back. */
.howto-bonus-title {
  position: relative; margin: 20px 0 0; padding-top: 12px;
}
.howto-bonus-title::before {
  content: ''; position: absolute; top: 0; left: 0; width: 32px;
  border-top: 1px solid var(--rule);
}
.howto-bonus-text { margin: 6px 0 0; }

/* The Puzzmo credit sits apart from the rules of play, behind a short hairline
   rather than the full-width rule used higher up the page — it's a footnote, not
   a new section. Drawn as a pseudo-element so the span itself stays full width. */
/* min-height: two lines (the credit sentence + the Puzzmo link always wrap on
   the 390px column) plus this rule's own padding-top — the lead-in text is
   JS-painted, and the credit used to gain its second line mid-load. */
/* The credit and the two page links below it are ONE closing block since
   2026-09-12 (owner's call): same 10px, same line-height, same muted colour, so
   the bottom of the page reads as one quiet register rather than three sizes
   stacked. The only thing that separates them is the underline, which stays
   exclusive to links — here that means « Puzzmo » alone (via #howto a) and never
   the sentence around it.
   It was 11px ITALIC for part of the same day. The italic went with the merge:
   neither family loads an `ital` axis, so it was the browser's synthesised
   oblique, and it was the page's only italic — a slant that no longer had a job
   once the credit stopped needing to look different from the row beneath it.
   The hairline stays, and is now the whole block's opening rather than the
   credit's own: it is what holds these four small lines apart from the rules.
   No `min-height` any more either. It carried `calc(3.2em + 12px)` to reserve two
   lines while the block was hidden awaiting its painted text — with the sentence
   static in the markup there is nothing to reserve, and an em-based reserve would
   be measuring the wrong font size now anyway. */
/* No font-size of its own since 2026-09-13: it inherits the footer's 12px. It used
   to be "a step smaller" than the prose above it (12 → 10), and that step has
   nowhere to go on a three-rung scale — 10 is the label rung and this is prose, so
   putting it there would be the same mistake the whole footer just came back from.
   The italic and the rule above it were always doing most of that work; they now
   do all of it. */
.howto-credit {
  display: block; position: relative; margin-top: 14px; padding-top: 12px;
  line-height: 1.5;
}
.howto-credit::before { content: ''; position: absolute; top: 0; left: 0; width: 32px; border-top: 1px solid var(--rule); }
/* The credit takes the accent (the token's stated job) to be findable without an
   underline breaking the line. It was once the ONLY link down here, which is what
   this rule was written for; the sources disclosure below now adds three more and
   opts out of the accent rather than joining it — see #sources a. */
#howto a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
#howto a:hover { color: var(--ink); }
/* One register for every link down here, and a QUIET one (owner, 2026-09-03): the
   credit's Puzzmo used to be the footer's one accent word, back when it was the only
   link; with the sources' three and the privacy page's one beside it, an orange word
   in a twelve-pixel muted footer out-shouted the game above it. They all take the
   running colour and an underline for their affordance instead. */
.howto-sources-line { margin: 8px 0 0; }

/* --- end / score screen (flat card: 1px rule border, no shadow) ---
   The on-demand twin of the board's scorecard (stats history, "Montrer score"):
   same rank headline, same three-part lines, same total-as-a-sum. Keep the
   registers here in step with the .sc-* block above — and with the share page
   and its OG card, which say the same thing again (api/lib/render-share.js,
   api/lib/share-card.js). */
#endscreen { position: fixed; inset: 0; background: var(--overlay); display: flex; align-items: center; justify-content: center; z-index: 10; }
#endscreen[hidden] { display: none; }
#endcard { position: relative; background: var(--surface); color: var(--ink); border: 1px solid var(--rule); border-radius: 14px; padding: 24px 28px; width: min(92%, 360px); text-align: center; }
#endcard h2 { margin: 0 0 8px; font-family: var(--font-display); text-transform: uppercase; letter-spacing: 1px; font-size: 18px; }
/* The rank, as the board locks it in: one big display character in accent, its
   qualifying word beneath in the mono caps register. */
.score-hero { margin: 18px 0 22px; }
#endcard h2.score-hero { margin: 4px 0 22px; text-transform: none; letter-spacing: 0; }
.score-grade { font-family: var(--font-display); font-weight: 700; font-size: 76px; line-height: .9; letter-spacing: -2px; color: var(--accent); }
.score-word { margin-top: 10px; font-size: 11px; font-weight: 500; letter-spacing: .3em; }
.score-rows { text-align: left; margin-bottom: 18px; }
.score-rows div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--rule); }
.score-rows span { font-size: 11px; font-weight: 500; letter-spacing: .1em; text-transform: uppercase; }
/* The detail sits under its label, a step down in grey — the board's sc-detail. */
.score-rows i { display: block; margin-top: 3px; font-style: normal; font-size: 12.5px; letter-spacing: 0; text-transform: none; color: var(--muted); font-variant-numeric: tabular-nums; }
.score-rows b { font-family: var(--font-display); font-weight: 700; font-size: 19px; color: var(--accent); font-variant-numeric: tabular-nums; }
/* The total is a sum, not another breakdown line: same accent face, a step up. */
.score-sum { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 4px; }
.score-sum span { font-size: 10px; font-weight: 500; letter-spacing: .2em; text-transform: uppercase; color: var(--muted); }
.score-sum b { font-family: var(--font-display); font-weight: 700; font-size: 22px; text-transform: uppercase; color: var(--accent); font-variant-numeric: tabular-nums; }
/* Text-only action, like the gamebar and the board's PARTAGER: no chrome,
   ≥44px hit target via padding. */
.score-actions { display: flex; gap: 22px; justify-content: flex-start; }
.score-actions button { background: none; border: none; padding: 15px 0; margin: -15px 0; color: var(--accent); cursor: pointer; }
.score-actions button:disabled { opacity: .4; cursor: default; }

/* --- stats modal --- */
#statsscreen { position: fixed; inset: 0; background: var(--overlay); display: flex; align-items: center; justify-content: center; z-index: 10; }
#statsscreen[hidden] { display: none; }
#statscard { position: relative; background: var(--surface); color: var(--ink); border: 1px solid var(--rule); border-radius: 14px; padding: 24px 28px; width: min(92%, 420px); }
#statscard h2 { margin: 0 0 16px; text-align: center; font-family: var(--font-display); text-transform: uppercase; letter-spacing: 1px; font-size: 16px; }
.stats-list { list-style: none; padding: 0; margin: 0 0 16px; max-height: min(55vh,360px); overflow-y: auto; }
.stats-list li { display: flex; align-items: center; gap: 10px; padding: 9px 8px; border-bottom: 1px solid var(--rule); font-size: 13px; cursor: pointer; border-radius: 8px; }
.stats-list li:hover { background: var(--surface-hover); }
.stats-date { flex: 1; }
.stats-score { font-weight: 600; font-variant-numeric: tabular-nums; color: var(--accent); white-space: nowrap; }
.stats-time { color: var(--muted); font-size: 12px; white-space: nowrap; }
.stats-arrow { color: var(--muted); padding: 4px 8px; font-size: 16px; line-height: 1; flex-shrink: 0; }
.stats-list li:hover .stats-arrow { color: var(--ink); }
.stats-empty { text-align: center; color: var(--muted); padding: 24px 0; margin: 0; font-size: 13px; }

/* --- debug panel (dev-only, ?debug=true) --- */
#debug { border: 1px dashed var(--rule); border-radius: 8px; padding: 10px 12px; background: var(--surface); }
.debug-header { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 13px; font-weight: 600; color: var(--muted); }
.debug-header button { font-size: 11px; padding: 2px 8px; border: 1px solid var(--rule); border-radius: 6px; background: var(--surface-2); color: var(--ink); cursor: pointer; }
.debug-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.debug-reset { color: var(--bad); border-color: var(--bad) !important; }
.debug-reset:hover { background: var(--surface-hover) !important; }
.debug-solve { color: var(--ok); border-color: var(--ok) !important; }
.debug-solve:hover { background: var(--surface-hover) !important; }
.debug-list { list-style: none; padding: 0; margin: 8px 0 0; columns: 2; font-family: var(--font-mono); font-size: 13px; }
.debug-list li { padding: 2px 0; break-inside: avoid; }
.debug-list li.done { color: var(--accent); text-decoration: line-through; }
.debug-freq { font-size: 11px; font-weight: 600; opacity: .85; }
/* The bonus list's heading: a sibling section under the puzzle words, ruled off the
   way the colour workshop below it is. */
.debug-bonus-title { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--rule); font-size: 13px; font-weight: 600; color: var(--muted); }
.debug-freq.known-h { color: var(--ok); }
.debug-freq.known-m { color: var(--muted); }
.debug-freq.known-l { color: var(--warn); }
/* Debug-only colour workshop (see buildColorTool in ui.js). */
.debug-colors { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--rule); }
.debug-colors-title { font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: .04em; margin-bottom: 8px; }
.debug-collapse-title { cursor: pointer; user-select: none; }
.debug-collapse-title.collapsed { margin-bottom: 0; }
.debug-caret { display: inline-block; width: 1em; font-size: 10px; color: var(--muted); }
.debug-color-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: var(--ink); cursor: pointer; }
.debug-color-row input[type="color"] { width: 30px; height: 22px; padding: 0; border: 1px solid var(--rule); border-radius: 5px; background: none; cursor: pointer; flex: none; }
.debug-color-name { flex: 1; }
.debug-color-hex { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.debug-colors-css { width: 100%; margin-top: 8px; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.45; color: var(--ink); background: var(--surface-2); border: 1px solid var(--rule); border-radius: 6px; padding: 6px 7px; resize: vertical; }
.debug-colors-actions { display: flex; gap: 6px; margin-top: 8px; }
.debug-color-btn { font-size: 11px; padding: 3px 9px; border: 1px solid var(--rule); border-radius: 6px; background: var(--surface-2); color: var(--ink); cursor: pointer; }
.debug-color-btn:hover { background: var(--surface-hover); }

/* --- auth: plain-text Connexion link / inked avatar, right side of the header --- */
.auth-pill { display: flex; align-items: center; margin-left: 12px; flex-shrink: 0; z-index: 200; }
.auth-login { background: none; border: none; padding: 0; color: var(--accent); cursor: pointer; }
.auth-avatar-wrap { position: relative; }
/* THE AVATAR, INVERTED — ink square, letter in --surface. It used to be the other
   way round, and that was fine while the ground was flat: --surface stood 17 8-bit
   steps over --bg. La Levée then put its LIGHTEST pixel (#F5F4F1, the gradient's
   first stop) exactly under the header, which left the square at 1.02:1 against
   its own background — white on white, and the reason this changed.
   Inverting rather than nudging the fill: 15.8:1 puts it beyond any retune of the
   wash, where a muted fill (--surface-dim, 1.32:1) would sit in the same band as
   the tiles and go thin again if the ramp is ever pushed. Weighed on
   docs/protos/2026-08-09-pastille-initiale.html against that muted fill, an accent
   square, and dropping the chip for a bare initial.
   Two costs, both taken knowingly. The header now carries a SECOND weight facing
   the wordmark, where before it was ink-on-nothing all the way across. And the
   square is the one piece of button chrome in a top UI the file header calls
   text-only ("no button chrome, no icons except the ↻ glyph") — it already was
   that before the inversion, but it used to be quiet enough not to argue.
   Geometry is untouched: 34px is the tap target and .auth-menu's anchor, so the
   menu's `top: 42px` still clears the row. */
.auth-avatar {
  width: 34px; height: 34px; border-radius: 10px;
  background: var(--ink); color: var(--surface); border: none;
  cursor: pointer; font-family: var(--font-display); font-weight: 600; font-size: 14px;
  display: flex; align-items: center; justify-content: center;
}
/* Hover lifts the ink instead of the old darken-a-light-surface, so the gesture
   reads the same way round as it did. color-mix is already a dependency here
   (#pause-overlay, and every stop of the ground above). */
.auth-avatar:hover { background: color-mix(in srgb, var(--ink) 86%, #FFF); }
.auth-menu {
  position: absolute; top: 42px; right: 0;
  background: var(--surface); border: 1px solid var(--rule);
  border-radius: 8px; min-width: 170px; overflow: hidden;
}
.auth-menu-item {
  display: flex; align-items: center; gap: 8px;
  width: 100%; text-align: left;
  padding: 10px 14px; background: none; border: none;
  cursor: pointer; font-family: var(--font-mono); font-size: 11px; letter-spacing: 1.5px;
  text-transform: uppercase; color: var(--ink);
}
.auth-menu-item:hover { background: var(--surface-hover); }

/* Transient confirmation toast (e.g. "Lien copié"). */
.toast {
  position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
  background: var(--surface); color: var(--ink); border: 1px solid var(--rule);
  border-radius: 10px; padding: 10px 18px; font-size: 12px; z-index: 300;
  opacity: 0; transition: opacity .18s ease;
}
.toast.show { opacity: 1; }

/* Team modal reuses the stats modal shell (#teamscreen mirrors #statsscreen). */
#teamscreen { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
  background: var(--overlay); z-index: 210; }
#teamscreen[hidden] { display: none; }
#teamcard { position: relative; background: var(--surface); color: var(--ink); border: 1px solid var(--rule); border-radius: 14px; padding: 24px 28px;
  width: min(92%, 420px); }
#teamcard h2 { margin: 0 0 16px; text-align: center; font-family: var(--font-display); text-transform: uppercase; letter-spacing: 1px; font-size: 16px; }
.team-form { display: flex; gap: 8px; margin: 10px 0; }
.team-form input { flex: 1; padding: 8px 10px; border: 1px solid var(--rule); border-radius: 8px;
  background: var(--surface-2); color: var(--ink); font-family: var(--font-mono); }
.team-form button, .team-copy, .team-leave { padding: 8px 14px; border: 1px solid var(--rule);
  border-radius: 8px; background: var(--surface-2); color: var(--ink); cursor: pointer; font-family: var(--font-mono); font-size: 12px; }
.team-msg { color: var(--bad); font-size: 13px; min-height: 18px; }
.team-sub { font-size: 13px; }
.team-sub code { background: var(--surface-2); padding: 1px 6px; border-radius: 6px; }
.team-list { list-style: decimal; padding-left: 22px; margin: 14px 0; font-size: 13px; }
.team-list li { display: flex; justify-content: space-between; gap: 12px; padding: 4px 0; }
.team-leave { margin-top: 6px; color: var(--bad); }
.team-notif { margin: 10px 0; }
.team-notif button { width: 100%; padding: 9px 14px; border: 1px solid var(--rule);
  border-radius: 8px; background: var(--surface-2); color: var(--ink); cursor: pointer; font-family: var(--font-mono); font-size: 12px; }
.team-notif button:disabled { opacity: .6; cursor: default; }
.team-notif-hint { color: var(--muted); font-size: 12px; line-height: 1.5; }

/* Login modal reuses the stats/team modal shell. */
#loginscreen { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
  background: var(--overlay); z-index: 220; }
#loginscreen[hidden] { display: none; }
#logincard { position: relative; background: var(--surface); color: var(--ink); border: 1px solid var(--rule); border-radius: 14px; padding: 24px 28px;
  width: min(92%, 380px); }
#logincard h2 { margin: 0 0 14px; font-family: var(--font-display); font-size: 15px; text-transform: uppercase; letter-spacing: 1px; text-align: center; }
/* ---- the share modal ------------------------------------------------------
   Spec: docs/superpowers/specs/2026-09-02-share-modal-design.md.
   z-index above #loginscreen's 220: this one layers OVER the recap it is opened
   from, because closing it must hand that score back rather than leave the player
   on a bare board. */
#sharescreen { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
  background: var(--overlay); z-index: 240; }
#sharescreen[hidden] { display: none; }
#sharecard { position: relative; background: var(--surface); color: var(--ink); border: 1px solid var(--rule);
  border-radius: 14px; padding: 24px 28px; width: min(92%, 380px); }
#sharecard h2 { margin: 0 0 14px; font-family: var(--font-display); font-size: 15px;
  text-transform: uppercase; letter-spacing: 1px; text-align: center; }
/* The preview keeps the card's 1200:630 even before the bytes land, so the buttons
   under it never jump down the screen mid-tap. */
.share-preview { aspect-ratio: 1200 / 630; margin-bottom: 18px; border: 1px solid var(--rule);
  border-radius: 8px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
/* `contain`, not `cover`. The frame above is already the card's ratio, so the two
   are identical today — but if the server card is ever re-proportioned, `cover`
   would silently CROP the preview, which is the one thing a preview may not do. */
.share-card-img { display: block; width: 100%; height: 100%; object-fit: contain; }
/* A preview that failed leaves the frame empty rather than broken — the actions
   below it still work, which is the whole point of dropping the image. */
.share-preview:empty { display: none; }
.share-pending, .share-failed, .team-pending { margin: 0; font-size: 13px; color: var(--muted); text-align: center; }
.share-failed { padding: 24px 0 4px; }
.share-actions { display: flex; gap: 10px; }
.share-actions button { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
  padding: 12px 10px; border: 1px solid var(--rule); border-radius: 10px;
  background: none; color: var(--ink); font: inherit; font-size: 13px; cursor: pointer; }
.share-actions button:hover { background: var(--surface-hover); }
.share-actions button:disabled { cursor: default; color: var(--accent); border-color: var(--accent); }
/* The sheet button is the affirmative one — it is what most phones will use. */
.share-actions .share-sheet { border-color: var(--accent); color: var(--accent); }
.share-glyph { width: 18px; height: 18px; flex-shrink: 0; fill: none; stroke: currentColor;
  stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }

/* ---- the welcome modal ------------------------------------------------------
   Spec: docs/superpowers/specs/2026-09-02-welcome-modal-design.md. Same shell as
   #loginscreen; z-index between it and the share modal, since nothing opens over
   this one. */
#welcomescreen { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
  background: var(--overlay); z-index: 230; }
#welcomescreen[hidden] { display: none; }
#welcomecard { position: relative; background: var(--surface); color: var(--ink); border: 1px solid var(--rule);
  border-radius: 14px; padding: 24px 28px 20px; width: min(92%, 380px); }
#welcomecard h2 { margin: 0 0 14px; font-family: var(--font-display); font-size: 15px;
  text-transform: uppercase; letter-spacing: 1px; text-align: center; }
/* One pane at a time. `hidden` does the switching (the UA rule is enough here: a
   pane is a plain div), and the viewport keeps a fixed height across the three so
   the button below never moves under a finger mid-tutorial. */
.welcome-panes { position: relative; overflow: hidden; touch-action: pan-y; }
/* The track: every pane side by side, one viewport wide each, slid by transform.
   .dragging drops the easing so the track sticks to the finger; the release
   re-enables it for the snap. Under reduced motion the slide is a cut. */
.welcome-track { display: flex; align-items: flex-start; will-change: transform;
  transition: transform .38s cubic-bezier(.2, .8, .2, 1); }
.welcome-track.dragging { transition: none; }
/* 2px of side padding, box-sizing border-box: a pane's stage then never touches the
   pane's edge, so the subpixel rounding of translateX(-N00%) cannot show a hairline
   of the neighbours' stages at either side (it did, on the prose pane). The prose
   pane is also centred vertically in the track's height — the track is as tall as
   the tallest pane, and a top-aligned list over a void read as unfinished. */
.welcome-pane { flex: 0 0 100%; min-width: 0; box-sizing: border-box; padding: 0 2px; }
.welcome-pane.is-text { align-self: stretch; display: flex; flex-direction: column; justify-content: center; }
@media (prefers-reduced-motion: reduce) { .welcome-track { transition: none; } }
/* The rules pane: one row per rule, the statement left and its example words right,
   each word wearing the game's badge — a list of verdicts, not a paragraph. Hairlines
   between rows, like the footer's own disclosure. */
.welcome-rules { list-style: none; margin: 4px 0 12px; padding: 0; border-top: 1px solid var(--rule); }
.welcome-rules li { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
  padding: 9px 2px; border-bottom: 1px solid var(--rule); font-size: 12.5px; line-height: 1.35; }
.welcome-rule-label { color: var(--ink); }
/* The examples stack, one word per line, each with its badge (owner, 2026-09-06):
   "mangé / mangeons / mangeant" side by side read as one phrase, and the refused
   ones no longer wear a strike — dimmed with a ✗ is enough, and the strike made
   "poisson-chat" unreadable. The label's first line and the first example share a
   baseline through the row's padding, so a single-example row looks as before. */
.welcome-rule-ex { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; flex-shrink: 0;
  font: 500 11px var(--font-mono); letter-spacing: .04em; padding-top: 1px; }
.welcome-rule-ok, .welcome-rule-ko { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
.welcome-rule-ok { color: var(--ink); }
.welcome-rule-ko { color: var(--muted); }
.welcome-rule-ex .tr-icon { width: 13px; height: 13px; flex-shrink: 0; }
/* The prose pane's last, smaller line. */
.welcome-note { font-size: 11.5px; color: var(--muted); margin: 0 0 14px; text-align: center; }
.welcome-pane h3 { margin: 0 0 6px; font-family: var(--font-display); font-size: 17px; letter-spacing: -0.2px; text-align: center; }
.welcome-pane p { margin: 0 0 14px; font-size: 13px; line-height: 1.5; text-align: center; color: var(--ink); }
/* The stage: a real board, small. 218 units wide on a 3×3 (PAD*2 + 2*CELL). It fills
   the modal card's 324px of content with 22px of ground on each side, because at
   240px with 10px of padding the third pane's letters and score lines ran into the
   stage's edge (owner, 2026-09-03). The card host sits INSIDE that padding, so the
   score card is scaled to the same width as the board under it. The mini verdict
   line sits under the board. */
/* The stage stands on the PAGE's ground, not the card's surface: the tiles are
   --surface, and on a --surface card they vanished — nine letters floating with no
   discs under them, which is not the board the tutorial is about. */
.welcome-stage { position: relative; width: min(324px, 100%); margin: 0 auto; padding: 18px 22px;
  background: var(--bg); border-radius: 12px; }
/* A pane with no recap to draw narrows its panel to the half-size grid rather
   than framing it in empty ground — 324 was the width the FULL-size grid needed.
   Keyed on the card host's absence because that is the real distinction (see
   welcome.js: only the 'day' pane builds one), and the card pane must keep the
   full width or the recap would be drawn 122px wide and be unreadable. */
.welcome-stage:not(:has(.welcome-cardhost)) { width: min(227px, 100%); }
.welcome-stage:not(:has(.welcome-cardhost)) .welcome-board { width: 100%; }
/* The card pane is the one place the panel is sized by the CARD instead of by the
   grid, and it needs BOTH numbers because the card is absolutely positioned and so
   contributes no height of its own — halving the board alone left the recap
   hanging out of the bottom of its panel.
   `width` sets the card's width (the host is inset 22px each side) and therefore
   its height too, since it is one svg with a fixed viewBox. The min-height is that
   height, written as the arithmetic rather than as its answer: panel width less
   the 44px of inset, times the card's own aspect (its viewBox is 295 × 271 — five
   detail lines and a total), plus the panel's 18+18 of padding. 216.1px as it
   stands. The ONE number to revisit is 271: a recap that gains or loses a line
   changes its viewBox and nothing here would know. */
.welcome-stage:has(.welcome-cardhost) {
  width: min(240px, 100%);
  min-height: calc((240px - 44px) * 271 / 295 + 36px);
}
/* THREE QUARTERS of full size, and only here (owner, 2026-09-06: halved first,
   then back up by half — 0.5 × 1.5). The tutorial's board is an ILLUSTRATION of
   the mechanic, not a board to play, so it needs to be legible rather than
   tappable — and at full size the four panes stacked to a 624px modal that ran
   off the bottom of a phone. Shrinking the grid is the one cut that takes height
   off every pane at once without touching a word of the copy.
   On the host, not on the svg: the svg is `width: 100%` of it (below), the
   finger and the trace both read the svg's own rect against its viewBox, and the
   sky scales by the stage's area — so every one of them follows from this and
   nothing else needed a second number. The CARD is deliberately NOT halved: it
   lives in `.welcome-cardhost`, absolutely positioned across the stage, and it
   is the whole subject of the pane it appears on — a recap at half size is a
   recap nobody can read. */
.welcome-board { position: relative; z-index: 1; width: 75%; margin: 0 auto; }
/* Under the score card the spent letters dim exactly as they do under the real one:
   this is #board's own `.scoring` rule (styles.css, the recap section), restated for
   a board that is not #board — without it the tutorial's card sat over letters at
   full strength, and read heavier than the real ending (owner, 2026-09-03). */
.welcome-board svg.scoring .node text { opacity: .29; transition: opacity .5s ease, fill .5s ease; }
.welcome-board svg { width: 100%; height: auto; display: block; overflow: visible; }
/* The finger. A translucent disc that rides the trace on the two teaching panes, so
   the eye sees a touch and not a line drawing itself (owner, 2026-09-03; a fingertip's
   width, 68px, since the owner found 34 too small to read as one). Positioned
   by transform from the stage's top-left; hidden between words. Never under reduced
   motion — there is no trace to ride. */
.welcome-finger { position: absolute; left: 0; top: 0; width: 68px; height: 68px; margin: -34px 0 0 -34px;
  border-radius: 50%; background: rgba(255, 255, 255, .55); z-index: 4; pointer-events: none;
  box-shadow: 0 2px 10px rgba(0, 0, 0, .18), inset 0 0 0 1px rgba(255, 255, 255, .75);
  opacity: 0; transition: opacity .25s ease; }
.welcome-finger.on { opacity: 1; }
/* The sky. Pane 3's starfield is its OWN canvas under the board, the stage's size and
   radius, not the page's #starfield (owner, 2026-09-03: the burst belongs to the
   preview, not to the page behind the modal). */
.welcome-sky { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 12px; pointer-events: none; }
.welcome-cardhost { position: absolute; top: 18px; left: 22px; right: 22px; z-index: 3; pointer-events: none; }
.welcome-verdict { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 4px 8px;
  min-height: 24px; margin: 6px 0 12px; font: 500 12px var(--font-mono); letter-spacing: .12em; text-transform: uppercase; }
.welcome-verdict .tr-icon { width: 18px; height: 18px; }
/* The note takes its own centred line under the word, as on the real verdict line;
   inline it pushed « horizon » off centre against the noteless « rond » above it. */
.welcome-verdict .tr-note { flex-basis: 100%; text-align: center; font-size: 10px; color: var(--muted); letter-spacing: .08em; text-transform: none; }
.welcome-verdict.result-longest .tr-word { color: var(--accent); }
.welcome-verdict:empty { visibility: hidden; }
/* Footer: three dots, one button. The dots are buttons too (a tap jumps to a pane). */
.welcome-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 4px; }
.welcome-dots { display: flex; gap: 8px; padding: 0; margin: 0; list-style: none; }
.welcome-dots button { width: 8px; height: 8px; padding: 0; border: none; border-radius: 50%;
  background: var(--rule); cursor: pointer; }
.welcome-dots button.current { background: var(--accent); }
/* touch-action: the same iOS double-tap zoom as #rotate-btn, and the same reason
   it bites HERE of all buttons — this is the one the player taps repeatedly in
   one place to walk through the panes, so the taps fall inside the double-tap
   window by design. */
.welcome-next { touch-action: manipulation;
  padding: 10px 18px; border: 1px solid var(--accent); border-radius: 10px; background: none;
  color: var(--accent); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
.welcome-next:hover { background: var(--surface-hover); }
/* While the opening wave waits for the welcome modal to close, the daily board
   is built but unseen: the wave must start from nothing, not from a grid the
   player already glimpsed through the overlay. main.js removes the class the
   instant it calls runIntro. */
body.intro-held #board svg { opacity: 0; }

/* ---- the feedback form (src/contact.js) -------------------------------------
   Same shell as #loginscreen. z-index above the welcome modal's 230: the tutorial
   is a thing you read, this is a thing you do, and a tester who wants to report
   that the tutorial is broken must be able to open this over it. */
#contactscreen { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
  background: var(--overlay); z-index: 250; }
#contactscreen[hidden] { display: none; }
#contactcard { position: relative; background: var(--surface); color: var(--ink); border: 1px solid var(--rule);
  border-radius: 14px; padding: 24px 28px 20px; width: min(92%, 420px); }
.contact-title { margin: 0 0 10px; font-family: var(--font-display); font-size: 15px;
  text-transform: uppercase; letter-spacing: 1px; text-align: center; }
.contact-intro { margin: 0 0 14px; font-size: 13px; line-height: 1.5; color: var(--ink); text-align: center; }
.contact-message {
  display: block; width: 100%; box-sizing: border-box; resize: vertical;
  padding: 10px 12px; border: 1px solid var(--rule); border-radius: 10px;
  background: var(--bg); color: var(--ink); font: inherit; font-size: 13px; line-height: 1.5;
}
.contact-message:focus-visible, .contact-email:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.contact-email-label { display: block; margin: 12px 0 0; font-size: 11px; color: var(--muted); }
.contact-email {
  display: block; width: 100%; box-sizing: border-box; margin-top: 5px;
  padding: 9px 12px; border: 1px solid var(--rule); border-radius: 10px;
  background: var(--bg); color: var(--ink); font: inherit; font-size: 13px;
}
/* One line for refusal, failure and thanks alike, and it keeps its height empty so
   nothing below it moves when it fills. */
.contact-status { min-height: 18px; margin: 10px 0 0; font-size: 12px; line-height: 1.4; }
.contact-status.contact-ok { color: var(--ok); }
.contact-status.contact-warn { color: var(--bad); }
.contact-actions { display: flex; justify-content: flex-end; margin-top: 8px; }
.contact-send {
  padding: 10px 20px; border: 1px solid var(--accent); border-radius: 10px; background: none;
  color: var(--accent); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
}
.contact-send:hover { background: var(--surface-hover); }
.contact-send:disabled { cursor: default; color: var(--muted); border-color: var(--rule); }
/* The two tools at the right end of the tab row — the feedback bubble, then the
   tutorial's « ? » — drawn icons the size of the tabs' type, no labels, no chrome
   (owner, 2026-09-03: in the header they sat at the wrong height and looked bolted
   on). They fade with everything else while the win REPLAYS (body.celebrating) and
   come back with the recap: until 2026-09-06 they also went on body.scored, with the
   gamebar's controls — but pause, finish and rotate are controls on a game, and a
   finished game has none to offer, whereas the rules and the feedback form are
   about the PAGE, and the recap is exactly where someone wants to say something
   about it (owner: "when the grid is completed, there's no help or feedback icon.
   there should be"). */
.tab-tools { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.tab-tools button { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
  border-radius: 5px; color: var(--muted); transition: opacity .4s ease, color .15s; }
.tab-tools button:hover { color: var(--ink); }
.tab-tools button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
body.celebrating .tab-tools { opacity: 0; pointer-events: none; }
/* Filled, like the rotate icon (ui.js ROTATE_ICON) — the row's third tool, and the
   one that set the register: a solid shape carries at this size where a stroke
   reads as a doodle. The « ? » is cut out of its disc in the page's ground colour. */
.tool-glyph { width: 17px; height: 17px; fill: currentColor; stroke: none; }
.tool-glyph .tool-cut { fill: var(--bg); }
/* The footer's last line: the privacy page, quiet like every link around it. */
/* The two page links, on one row and last on the page (2026-09-12). 10px is the
   smallest anything here is set in, and it is a FLOOR, not a starting point: the
   sources link is how CC BY-SA 4.0 attribution is given for the word lists, and an
   attribution nobody can read is not given "in a reasonable manner". The underline
   stays for the same reason — at this size it is the only thing that says these
   are links.
   No `display` on .howto-link, deliberately: both spans ship `hidden` and the UA's
   [hidden] rule must win, exactly as in the word-rules block above. */
#footer-links { margin: 8px 0 0; font-size: 10px; line-height: 1.5; }
/* The separator is generated, and only between two SHOWING links — each span is
   revealed by a different module (ui.js, src/contact.js), so a row that is half
   painted for a moment must never render a leading or dangling « · ». */
.howto-link:not([hidden]) + .howto-link:not([hidden])::before {
  content: '·'; margin: 0 6px; color: var(--muted); text-decoration: none;
}

/* Top-right "×" close button, shared by all modals. Their cards are position:relative. */
.modal-close {
  position: absolute; top: 10px; right: 12px;
  width: 30px; height: 30px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  border: none; border-radius: 8px; background: none;
  color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer;
}
.modal-close:hover { background: var(--surface-hover); color: var(--ink); }
.login-benefits { list-style: none; padding: 0; margin: 0 0 4px; display: flex; flex-direction: column; gap: 10px; }
.login-benefits li { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.45; }
.login-benefits li::before { content: "—"; color: var(--accent); flex-shrink: 0; }
.login-sep { height: 1px; background: var(--rule); margin: 18px 0; }
.sso-btn { display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%;
  padding: 11px 14px; margin: 8px 0; border-radius: 10px; font-weight: 600; font-size: 14px;
  font-family: var(--font-mono); text-decoration: none; cursor: pointer; border: 1px solid transparent; }
.sso-apple { background: #000; color: #fff; }
.sso-apple:hover { background: #1a1a1a; }
.sso-google { background: var(--surface-2); color: var(--ink); border-color: var(--rule); }
.sso-google:hover { background: var(--surface-hover); }
