/* ==========================================================================
   Graphwar - printed mathematical plate
   A two-ink risograph print of a textbook figure.
   Blue and flo are TEAM inks only; they never decorate.
   ========================================================================== */

/* ============================================================== tokens === */

/* The one :root. Everything below reads from here and nothing below
   redefines any of it -- a value that needs to change in one place changes
   here, in that place, and the sheet follows. */

:root {
  --paper:      #E2E5DE;
  --paper-deep: #D5DAD1;
  --paper-lift: #EDEFE9;
  --rule:       #A9B1A7;
  --ink:        #141A17;
  --ink-soft:   #535D57;   /* 4.5:1 on paper-deep; the #5F6A63 before it measured 3.96:1 */
  --blue:       #1B3FD8;
  --flo:        #FF4A18;

  /* Three to six players get a side each so everyone can find their own
     soldiers. The first two inks are blue and flo; the rest are picked to
     stay apart from each other under the common kinds of colour blindness
     and in plain greyscale. Text uses darker variants than the canvas
     strokes, because #FF4A18 measures 2.64:1 on paper and fails AA
     outright; every -text variant holds >= 4.5:1 on paper and paper-deep. */
  --team-0: #1B3FD8;
  --team-1: #FF4A18;
  --team-2: #1E7A46;
  --team-3: #B4278A;
  --team-4: #9A6B00;
  --team-5: #0E7C86;

  --team-0-text: #1B3FD8;
  --team-1-text: #A83100;
  --team-2-text: #146035;
  --team-3-text: #91206F;
  --team-4-text: #6F4D00;
  --team-5-text: #0A5C64;

  /* Two families. Bricolage sets every word that is not a figure, from the
     wordmark down to the smallest label; Plex Mono is the game's own voice
     and sets anything typed, measured or counted. The fallbacks are
     grotesques, because a serif standing in for Bricolage changes the
     sheet's whole character while the webfont loads. */
  --font-display: 'Bricolage Grotesque', system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-mono:    'IBM Plex Mono', ui-monospace, monospace;

  /* Type. A minor third (1.2) from a 16px body, rounded to whole pixels:
     11 / 13 / 16 / 23, then the wordmark, which is fluid. Five sizes for the
     whole sheet. Hierarchy here is size, weight and space, and with only
     five rungs each one is visibly a rung. --text-mono is the working size,
     named for what mostly sits on it; a button label or a caption set at 13px
     is still on that rung. */
  --text-display: clamp(3rem, 12vw, 6rem);
  --text-h:       1.4375rem;   /* 23px  headings, the banner */
  --text-body:    1rem;        /* 16px  copy, the plate mark, every input */
  --text-mono:    0.8125rem;   /* 13px  figures, captions, buttons, the rail */
  --text-label:   0.6875rem;   /* 11px  small-caps labels, counters */

  --weight-regular:  400;
  --weight-medium:   500;
  --weight-semibold: 600;
  --weight-bold:     700;
  --weight-black:    800;

  /* Space: 4 8 12 16 24 32 48 64. Every padding, gap and margin on the sheet
     sits on one of these, or on the two fluid screen measures below. The
     only bare pixel values left are 1px hairlines, the registration ticks,
     and control heights, which are dimensions rather than spacing. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
  --space-8: 64px;

  --pad-screen: clamp(16px, 3.2vw, 40px);
  --step: clamp(14px, 2vw, 22px);

  --hair: 1px solid var(--rule);
  --tick: 13px;
  --radius: 2px;

  /* Ink does not wind up. It lands and settles, so every curve in this file is
     an ease-out and none of them overshoot -- ease-in is physically wrong for
     the metaphor and ease-in-out is the curve every SaaS dashboard already
     uses. Three of them, because the plate only really animates three things:
     a fill that flips, a hairline on a field the player is typing into, and a
     quiet label that discloses a band. */
  --ease-ink:   cubic-bezier(0.2, 0, 0, 1);
  --ease-hair:  cubic-bezier(0.25, 0, 0.3, 1);
  --ease-quiet: cubic-bezier(0.33, 0, 0.15, 1);

  /* Each ease has one duration, and they are always used as a pair. The
     press has no ease of its own: it only ever shortens a transition that is
     already running on one of the three, so the paper gives way at once and
     lifts back on that transition's own curve. */
  --dur-quick: 120ms;   /* --ease-hair:  a hairline on a field being typed into */
  --dur:       160ms;   /* --ease-ink:   a fill that flips, a press that lifts */
  --dur-slow:  240ms;   /* --ease-quiet: a label that discloses, a title that wakes */
  --dur-press:  40ms;
}

/* --------------------------------------------------------------- reset -- */

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

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

body {
  margin: 0;
  min-height: 100vh;
  min-height: 100dvh;
  overflow-x: hidden;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-display);
  /* auto is the initial value; it is written down so the optical-size axis
     the font link fetches is seen to be in use. The browser sets opsz from
     the rendered size, so 13px labels get the text cut and 23px headings the
     display cut without any rule per size. */
  font-optical-sizing: auto;
  font-size: var(--text-body);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

h1, h2, p, dl, dd, ol, ul {
  margin: 0;
  padding: 0;
}

ol, ul { list-style: none; }

canvas, svg, img { max-width: 100%; }

button, input {
  font: inherit;
  color: inherit;
  margin: 0;
}

code {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
}

[hidden] { display: none !important; }

::selection {
  background: var(--ink);
  color: var(--paper);
}

/* --------------------------------------------------- focus, everywhere -- */

/* Every control on the sheet is a button, an input or the one summary, so
   this is the one focus ring; nothing restates it. */

a:focus-visible,
button:focus-visible,
input:focus-visible,
summary:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

/* A text field answers focus with its own edge -- the border goes to ink,
   and on the menu a rule presses in along the bottom (see "the field being
   filled in") -- so the ring would be a second frame around the same box.
   And unlike a button, a text input matches :focus-visible on a mouse
   click, so that second frame would be there for every player, not only
   the keyboard ones. Buttons keep the ring. */

.field__input:focus-visible,
.fire__input:focus-visible,
.chat__input:focus-visible { outline: none; }

/* ============================================================ surfaces == */

/* A panel is a change in the paper, not a box: the ground is the whole edge.
   No panel on this sheet carries a border. The hairlines that remain
   separate rows in a list, underline a section label, or frame the plate
   itself -- each one is there because something on either side of it
   differs. A control (a button, a field) is not a panel; it keeps its
   hairline box, because that is how the eye tells it is pressable. */

/* ================================================================ menu == */

.screen { position: relative; }

/* The menu is a title screen: one viewport, no scroll, with the hero match
   playing full-bleed under the type. Two states, carried on data-state so
   the stylesheet, menu.js and the browser check all read the same word.
   "title" is the poster -- wordmark top-left, PLAY dead centre, the caption
   bottom-left. "modes" lays a sheet of paper over it with the six ways to
   play and screens the print underneath.

   Exactly the viewport tall, not min-height: everything inside is out of
   flow, and the section's own height is what keeps the page from ever
   scrolling on the title. dvh, so a phone's collapsing address bar resizes
   the stage rather than leaving a strip of paper under it; the vh line
   before it is for browsers without the unit. */

.screen--menu {
  --px: 0;
  --py: 0;
  /* How far the crop overshoots the viewport, so that a finisher's kick has
     something to slide. effects.js caps the kick at SHAKE_MAX_PX -- 7px
     across, 0.55 of that down -- and eight was that ceiling with a pixel to
     spare, which was the right idea and the wrong amount of spare.

     A pixel of spare is not a pixel by the time it reaches the screen. The
     plate is cover-scaled off a viewport height, so it lands on fractional
     widths (1394.77 at 1366x768), menu.js's pan writes its position in half
     pixels, and the compositor snaps what is left to device pixels -- and on
     the 1.25 ratio a Windows laptop runs at, one CSS pixel of margin is not
     even one of those. The strip came back at 1366x768 and 390x844, on the
     right edge, under a kick to the left, and only at 1.25: it was clean at
     1, 1.5 and 2, which is how it survived the first repair.

     Two things were wrong and both are fixed. menu.js was clamping the pan
     against the plate's rounded-up clientWidth and handing out slack the
     plate had not got (see measure and pan there); that is the actual defect.
     Ten here is the belt to that pair of braces: three pixels of margin
     rather than one, so the next rounding on the way to the screen has
     somewhere to go. Verified over twenty viewport-and-ratio pairs, and at
     eight the same test failed the moment the pan was half a pixel off its
     mark. See .hero__stage. */
  --kick-slack: 10px;
  position: relative;
  width: 100%;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  background: var(--paper);
}

/* the stage -------------------------------------------------------------- */

/* The figure is the whole viewport. The plate inside it keeps the game's
   16:9 -- the ruling on it is square and a soldier is a circle, and a plate
   stretched to a phone's proportion would keep neither -- so it is cover
   scaled: as wide as the stage, or as wide as the stage's height asks for,
   whichever is more, centred, and the stage crops what does not fit. On a
   portrait phone that is most of the plate's width, which is why menu.js
   slides the plate under the crop to keep the shot in the window (the pan
   there) and zooms a little closer than it would on a desk.

   No tilt: a full-bleed sheet turned three degrees would show its edges.
   The ticks stay, brought inside the viewport by a step; they are the
   match plate's ticks, declared with it (see "the one flourish"), and the
   z-index puts them over the plate and under the type. No hairline round
   the figure either -- a rule at the edge of the viewport is the edge of
   the viewport. */

.hero {
  position: absolute;
  inset: 0;
  margin: 0;
}

.screen--menu .hero::before {
  inset: var(--space-4);
  z-index: 1;
}

/* The stage is the element client.js kicks when a finisher lands on the
   hero (it writes an inline transform on the canvas's parent), so nothing
   here puts a transform of its own on it. It is also the crop: a kick moves
   the window over the plate by a few pixels, which is the shake.

   Those two jobs fought. A crop that is exactly the viewport, slid 7px
   sideways, is 7px of the section's own paper down one edge -- flat paper
   against a grained plate, so it reads -- and it is not only the 16:9 desk
   where the plate has no slack of its own to give: the crop moves whatever
   is inside it, so every size did it. Measured at the worst frame of the
   kick: a 7px strip at the left and 3.85 at the top at 1920x1080, and the
   same at 1440x900, 844x390 and 390x844.

   So the crop overshoots the viewport by --kick-slack on all four sides and
   the section, which is overflow:hidden, clips it back. The kick still moves
   the window over the plate and now never moves it off the paper; what
   slides into view at the edge is more plate, because the plate is sized to
   cover the overshot stage and not the viewport (see below). Moving the kick
   to a layer inside the crop would have done as well and is the tidier
   drawing, but the element it lands on is canvas.parentElement in client.js,
   and neither that file nor the markup is this one's to change. */
.hero__stage {
  position: absolute;
  inset: calc(var(--kick-slack) * -1);
  overflow: hidden;
}

/* Sized by width and proportion, never by both edges, so the box is
   always 16:9 and client.js's fitCanvas (which reads clientWidth and
   clientHeight) cuts a store to match. The reset caps every canvas at 100%
   of its container and that cap comes off here, since covering the stage
   is the point. Centred by the translate property and not by transform:
   menu.js writes pixel values to the same property to pan the plate under
   the crop, and the two do not fight over one shorthand.

   The height it is made to cover is the stage's and not the viewport's --
   the stage overshoots by --kick-slack on each side (above), so the plate
   has to reach that much further or the overshoot would be paper. At 16:9,
   where the two boxes are otherwise the same box, a plate cut to 100dvh
   came out exactly 2 x --kick-slack short. 100% is already the stage's
   width, so only the height wants saying.

   The trailing pixel is a rounding hair and nothing else. The engine keeps
   lengths in 64ths and derives the height from the quantised width, so a
   plate cut to exactly the crop's height measured 0.02px short of it at
   both 16:9 and 390x844 -- far too little to see, and still a number that
   ought to be zero. A pixel of width is 0.5625 of height, which is thirty
   times the loss and a thirty-fifth of the slack it is spent out of. */
.hero__plate {
  position: absolute;
  top: 50%;
  left: 50%;
  display: block;
  width: max(100%, calc((100vh + var(--kick-slack) * 2) * 16 / 9 + 1px));
  width: max(100%, calc((100dvh + var(--kick-slack) * 2) * 16 / 9 + 1px));
  max-width: none;
  height: auto;
  aspect-ratio: 16 / 9;
  translate: -50% -50%;
}

/* The screen: paper dots on a four-pixel pitch over the whole stage, the
   veil the finishers lay over the plate, at rest. Two densities on one
   element. The low one is always there, a little tooth that keeps the
   print from reading as a photograph; the heavy one arrives with the modes
   and is what dims the match behind them -- dots of paper three quarters
   of the pitch wide, which is a screen and not a wash, and the plate under
   it is painted at full ink the whole time. The dots are radial-gradient
   stops with no falloff, the same way the ticks are linear-gradient stops:
   solid colour drawn by a gradient function is paint, not a gradient. The
   heavy screen's opacity is what transitions, on the same beat as the
   sheet; the canvas is never faded. */
.hero__screen {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background-image: radial-gradient(circle, var(--paper) 0.6px, transparent 0.6px);
  background-size: 4px 4px;
}

.hero__screen::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image: radial-gradient(circle, var(--paper) 1.5px, transparent 1.5px);
  background-size: 4px 4px;
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease-quiet);
}

.screen--menu[data-state='modes'] .hero__screen::after { opacity: 1; }

/* the type layer --------------------------------------------------------- */

/* Three labels stuck on the print: the masthead top-left, PLAY dead
   centre, the caption bottom-left. Each sits on a band of paper so the
   type keeps its edge whatever ink the match puts under it -- a curve
   through a wordmark takes the counters with it -- and a band is a change
   in the paper, not a box: no border, no shadow. Two paper-lifts (the
   wordmark, PLAY) and one knockout in the plate's own paper (the caption),
   which is the whole of the hierarchy: the two things to read first are
   lifted, the one to read last is cut in. Nothing bottom-right: the site's
   address was tried there and cut across the soldier who stands in that
   corner between shots, and a label through a figure is not quiet.

   The layer takes two pixels of travel against the pointer off two numbers
   menu.js writes on the section (--px, --py), which is the parallax that
   makes the print a print and the type a sheet laid over it. Transforms
   only; no loop drives it, and menu.js writes nothing for a finger or for
   a reader who asked for less motion. */

.masthead,
.hero__overlay,
.hero__caption {
  transform: translate(calc(var(--px) * -2px), calc(var(--py) * -2px));
  transition: transform var(--dur-quick) var(--ease-hair);
}

.masthead {
  position: absolute;
  top: var(--pad-screen);
  left: var(--pad-screen);
  z-index: 2;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  max-width: calc(100% - var(--pad-screen) * 2);
  padding: var(--space-3) var(--space-4) var(--space-4);
  background: var(--paper-lift);
}

.masthead__eyebrow {
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.masthead__title {
  font-family: var(--font-display);
  font-weight: var(--weight-black);
  font-size: var(--text-display);
  /* Pinned to the top of the axis. Left to auto the wordmark would be cut
     at whatever size the clamp lands on -- 48 on a phone, 96 on a desk --
     and the two would not be the same drawing. */
  font-variation-settings: 'opsz' 96;
  line-height: 0.92;
  letter-spacing: -0.025em;
  color: var(--ink);
  transition: font-size var(--dur-slow) var(--ease-quiet);
}

/* With the modes open the wordmark yields the desk to the sheet: down to
   the heading size, in the same corner, under the same eyebrow. It stays
   where the eye left it, so the sheet reads as a layer laid over the title
   and not as a new page; it shrinks because at full size it is 460px wide,
   and a 640px sheet centred on a 1024px screen would cover half of it --
   a wordmark half under a sheet reads as a mistake, a small mark beside
   one reads as a running head. */
.screen--menu[data-state='modes'] .masthead__title { font-size: var(--text-h); }

/* The eyebrow goes with it: it is 320px of tracked capitals, and a band
   that wide is the one thing a 640px sheet on a 1024px screen still meets.
   The running head is the mark alone. */
.screen--menu[data-state='modes'] .masthead__eyebrow { display: none; }

/* And on a viewport the sheet cannot clear, there is no running head at all.
   Shrinking the mark buys the room a wide screen needs and no room at all on
   a short one, because what climbs into the corner there is the sheet: it is
   centred in the layer and it stops at the layer's top edge, so on anything
   short it starts at 16px whatever its content, which is above the mark.
   The rule's own comment two above calls a half-covered wordmark a mistake,
   and it was one at 844x390 -- 54 x 49px of the mark under the sheet -- and
   worse at 390x844, where the sheet is the width of the screen and the mark
   was under it entirely.

   The two clauses are the two bands the sheet has. Below 640 the rows stack
   their controls and the sheet is 846 to 1008px of content; measured at 390
   wide it wants 1150px of viewport height before its top edge passes the
   mark, which is no phone, so the mark goes on every one of them. From 640
   up the sheet is 652px of content and clears at 830px of height (measured
   at 844 wide: the sheet's top is 88.86 against the mark's bottom at 76.16,
   and 780px tall still overlapped). Nothing wider than 960 is named because
   at exactly 960 they part sideways -- the sheet's left edge is 160.00 and
   the mark's right edge 159.61 -- and every width above that clears by more.

   Hidden rather than moved. The opposite corner is the caption's, the one
   under the mark is nobody's by choice (see the type layer), and a mark that
   turns up in a third place has moved for a reason the reader cannot see.
   The sheet carries its own head -- "Six ways to play" -- so what is lost
   is the continuity, which a mark under a sheet had already lost. display,
   like the eyebrow above, and it costs the reading order nothing: the sheet
   is aria-modal, so everything outside it is already out. */
@media (max-width: 639.98px),
       (min-width: 640px) and (max-width: 959.98px) and (max-height: 829.98px) {
  .screen--menu[data-state='modes'] .masthead { display: none; }
}

/* Play, dead centre. The overlay is the full stage and ignores the pointer
   so the parallax has one element to move; the band inside takes the
   pointer back for the block. The hint hangs under the band, out of flow,
   so the block itself -- and not the block with its hint -- is what sits
   on the centre of the viewport. Gone while the modes are open: the sheet
   has replaced it, and a control under a modal must be out of the tab
   order, which visibility does and opacity would not. */
.hero__overlay {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  transition: transform var(--dur-quick) var(--ease-hair), visibility 0s;
}

.screen--menu[data-state='modes'] .hero__overlay { visibility: hidden; }

.hero__label {
  position: relative;
  padding: var(--space-3) var(--space-4);
  background: var(--paper-lift);
  pointer-events: auto;
}

/* The one solid block on the menu. A stamp of ink with a paper triangle and
   the word in label caps; under the cursor it grows four hundredths on the
   ink curve and settles back the same way. It is the only control on the
   sheet allowed to sit on a drawing, and the only one allowed a fill: the
   arrows at the ends of the rows and the sheet's Back are hairline boxes
   like everything else, so this is where the eye lands first. */
.hero__play {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  min-height: 44px;
  padding: 0 var(--space-5) 0 var(--space-4);
  font-family: var(--font-display);
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  line-height: 1;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--paper);
  background: var(--ink);
  border: 1px solid var(--ink);
  border-radius: var(--radius);
  cursor: pointer;
  transition: transform var(--dur) var(--ease-ink);
}

.hero__play:hover { transform: scale(1.04); }

.hero__tri {
  width: 10px;
  height: 12px;
  fill: currentColor;
}

/* What the block leads to, in the plate's own mono under it, on a knockout
   of paper so the ruling does not run through eleven-pixel type. */
.hero__hint {
  position: absolute;
  top: calc(100% + var(--space-2));
  left: 50%;
  margin: 0;
  padding: 0 var(--space-1);
  translate: -50% 0;
  font-family: var(--font-mono);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.02em;
  white-space: nowrap;
  color: var(--ink-soft);
  background: var(--paper);
}

/* The function on the plate, bottom-left. A single line, cut with an
   ellipsis rather than wrapped, because a caption that grew to two lines
   would climb the stage on a phone. Nothing on the plate yet, nothing to
   cut a knockout for. */
.hero__caption {
  position: absolute;
  left: var(--pad-screen);
  bottom: var(--pad-screen);
  z-index: 2;
  max-width: calc(100% - var(--pad-screen) * 2);
  margin: 0;
  padding: var(--space-1) var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.02em;
  color: var(--ink-soft);
  background: var(--paper);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.hero__caption:empty { visibility: hidden; }

/* A reader who asked for less motion gets the sheet flat and still: no
   parallax (menu.js writes no numbers for them either, and this makes the
   numbers inert if it did), and Play that does not grow. Transforms, so
   the duration blanket at the end of the file has no authority over them
   and they are held by hand here. The plate's own centring is a translate
   and is left alone; it is where the plate is, not motion. */
@media (prefers-reduced-motion: reduce) {
  .masthead,
  .hero__overlay,
  .hero__caption,
  .hero__play:hover { transform: none; }

  /* The sheet and PLAY's overlay swap visibility on every open and close,
     and menu.js moves focus in the same task as the swap. The blanket at
     the end of the file cuts every transition to 0.01ms rather than to
     nothing, and a visibility transition of any length is still at its
     first frame when focus() is called, with the target computed hidden:
     the focus is refused, and the keyboard lands on the body in both
     directions. The subtrees and not just the two elements, because the
     blanket also gives every element a transition on "all", and an element
     with one transitions the visibility it inherits on its own account --
     a row title under a sheet that is already visible is still hidden for
     that hundredth of a millisecond. So the two subtrees whose visibility
     gates focus have their transitions cut to nothing outright; there is
     no motion in them to lose under this preference, since the sheet is
     meant to be simply there. Two class selectors, so this outranks the
     blanket's universal one rather than tying with it. */
  .screen--menu .actions,
  .screen--menu .actions *,
  .screen--menu .hero__overlay,
  .screen--menu .hero__overlay * { transition-duration: 0s !important; }
}

/* the modes -------------------------------------------------------------- */

/* PLAY opens this: a sheet of paper laid over the screened print, holding
   the six ways to play, centred, and never wider than 640px -- a
   measure, not a page. The layer is the whole stage so the sheet can be
   centred in it; it takes the pointer only while open, so a click on the
   screened print outside the sheet puts the sheet away (menu.js), and in
   the title state it is not in anyone's way.

   The sheet arrives from one step below on the quiet curve and settles,
   and leaves the same way; visibility follows the opacity out after one
   duration so the leaving sheet is seen leaving, and in with no delay so
   the first frame in is hittable. Under reduced motion every one of these
   is 0.01ms (the blanket at the end of the file) and the sheet is simply
   there. Taller than the viewport -- six rows on a phone -- it scrolls
   inside itself; the page never does. */

.modes {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-4);
  pointer-events: none;
}

.screen--menu[data-state='modes'] .modes { pointer-events: auto; }

.actions {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  width: min(640px, 100%);
  max-height: 100%;
  min-height: 0;
  overflow-y: auto;
  /* Contained like the file's other two inner scrollers: without it, reaching
     row 05 on a phone hands the gesture to the document and the whole title
     rubber-bands behind the sheet. */
  overscroll-behavior: contain;
  padding: var(--space-5);
  background: var(--paper-lift);
  border-radius: var(--radius);
  visibility: hidden;
  opacity: 0;
  transform: translateY(var(--space-5));
  transition: transform var(--dur-slow) var(--ease-quiet),
              opacity var(--dur-slow) var(--ease-quiet),
              visibility 0s linear var(--dur-slow);
}

.screen--menu[data-state='modes'] .actions {
  visibility: visible;
  opacity: 1;
  transform: none;
  transition-delay: 0s;
}

/* The sheet's head: a mark in the eyebrow's voice, and Back. The menu
   message shares the mark's cell and covers it while it shows (client.js
   writes into it -- a bad code, a blank name, the server not answering),
   so a message moves no row: the row the player just pressed stays under
   their cursor. It is set on the sheet's own paper so the mark under it
   does not show through. */
.modes__head {
  display: grid;
  /* Three cells now: the mark (with the message stacked on it), the language,
     then Back. The language is beside the way out because that is where a
     reader who cannot read this sheet is already looking. */
  grid-template-columns: minmax(0, 1fr) auto auto;
  align-items: center;
  column-gap: var(--space-3);
  min-height: 32px;
}

.modes__mark {
  grid-column: 1;
  grid-row: 1;
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.menu-error {
  grid-column: 1;
  grid-row: 1;
  position: relative;
  z-index: 1;
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  font-weight: var(--weight-medium);
  line-height: 1.4;
  color: var(--ink);
  background: var(--paper-lift);
}

/* Back is a hairline box at the label size, a rank under the arrows: it
   is the way out, not a way in. */
.modes__close {
  grid-column: 3;
  grid-row: 1;
  min-height: 32px;
  padding: 0 var(--space-3);
  font-size: var(--text-label);
}

/* the language ----------------------------------------------------------- */

/* Two words in one hairline box, the one in force filled in.

   Findable and quiet, in the two places a reader who cannot read this
   interface is already looking for a way out of it: beside Back on the modes
   sheet, and beside Leave in the plate's head. It is deliberately not a row of
   its own and not on the title screen -- a language picker offered before PLAY
   would be the first decision the game asks for, and it is not the interesting
   one.

   Not uppercased, unlike every other label it sits beside. "TIẾNG VIỆT" in caps
   flattens the shape of its diacritics, and this is the one control on the page
   that has to be legible to somebody who cannot read anything else on it. */

.lang {
  flex: none;
  display: inline-flex;
  align-items: stretch;
  min-height: 32px;
  background: var(--paper);
  border: var(--hair);
  border-radius: var(--radius);
  overflow: hidden;
}

.modes__head .lang {
  grid-column: 2;
  grid-row: 1;
}

.lang__pick {
  display: inline-flex;
  align-items: center;
  padding: 0 var(--space-2);
  font-family: var(--font-display);
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.02em;
  white-space: nowrap;
  color: var(--ink-soft);
  background: transparent;
  border: 0;
  border-radius: 0;
  cursor: pointer;
  transition: color var(--dur) var(--ease-ink), background-color var(--dur) var(--ease-ink);
}

/* One hairline between the two, not a border each: it is one control. */
.lang__pick + .lang__pick { border-left: var(--hair); }

.lang__pick:hover { color: var(--ink); }

/* The one in force is the one filled. aria-pressed on the button says the
   same thing to a reader who is not looking at it. */
.lang__pick.is-on {
  color: var(--paper);
  background: var(--ink);
}

/* In the plate's head it rides with the leave controls, which is where the
   markup already puts it -- the head is a wrapping flex row and nothing here
   reorders it, so the language sits between the connection light and Leave on
   a desktop and wraps down with them on a phone. */

/* the list --------------------------------------------------------------- */

/* Six things to do, as six numbered rows. A card asks for a fill, a
   radius and its own padding, and six of them make a grid that then has
   to be balanced; a row asks for one hairline. The number is a figure, so
   it is set in the mono; the title is a name, so it is set in the display
   face. The counter comes from the list itself rather than being typed into
   the markup, so a row added or moved renumbers on its own.

   A row reads number, name, note, then what to type or press on a line of
   its own under the note, with its arrow at the right edge so the arrows
   of the rows make a column. From 640px up the sheet is wide enough for
   the controls to sit at the right end of the row instead (see "small
   viewports"). One step of padding, not two: six rows on a sheet that
   has to fit a 768px screen with room over and under it. */

.list {
  counter-reset: row;
  border-bottom: var(--hair);
}

.list__item {
  counter-increment: row;
  border-top: var(--hair);
}

.row {
  display: grid;
  grid-template-columns: var(--space-6) minmax(0, 1fr);
  column-gap: var(--space-4);
  row-gap: var(--space-1);
  padding: var(--space-4) 0;
}

/* Baseline-aligned with the title, not top-aligned: an 11px figure hung from
   the top of a 23px line floats a third of the way up it. */
.row::before {
  content: counter(row, decimal-leading-zero);
  grid-column: 1;
  grid-row: 1;
  align-self: baseline;
  font-family: var(--font-mono);
  font-size: var(--text-label);
  font-weight: var(--weight-medium);
  color: var(--ink-soft);
}

.row__title {
  grid-column: 2;
  grid-row: 1;
  align-self: baseline;
  font-family: var(--font-display);
  font-weight: var(--weight-bold);
  font-size: var(--text-h);
  line-height: 1.1;
  letter-spacing: -0.015em;
  text-wrap: balance;
}

/* The first title takes focus when the sheet opens (menu.js), and the one
   ring in the file would then frame a heading nobody pressed. */
.row__title:focus { outline: none; }

/* The title of a row that ends in one button is a label for that button, so
   the words are pressable too; the cursor says so. The titles of the two
   forms label their name field instead, and a label for a text field keeps
   the text cursor. */
.row__act { cursor: pointer; }

.row__note {
  grid-column: 2;
  grid-row: 2;
  font-size: var(--text-body);
  line-height: 1.45;
  color: var(--ink-soft);
  text-wrap: pretty;
}

.row__ctl {
  grid-column: 2;
  grid-row: 3;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-2);
}

/* On a phone the sheet is the width of the screen less a step, and every
   pixel inside it is spoken for: the sheet's own padding comes down a step,
   the number column gives up a step (a two-digit figure at 11px is 14px
   wide), and the join row puts the name on a line of its own over the code
   and the arrow. Measured at 390: with the desk's padding and column the
   name field had 78px, which showed "your n" of its own prompt; now it has
   the line, 254px. The create row keeps its name beside its arrow -- an
   arrow alone on a line is a line with nothing on it. */
@media (max-width: 639.98px) {
  .actions { padding: var(--space-4); }

  .row {
    grid-template-columns: var(--space-5) minmax(0, 1fr);
    column-gap: var(--space-3);
  }

  .row__ctl { flex-wrap: wrap; }

  .row__ctl:has(.field__input--code) > .field__input:not(.field__input--code) { flex-basis: 100%; }
}

/* fields ----------------------------------------------------------------- */

/* Every field is 40px tall and set at body size, on the same hairline box
   as the buttons beside it. Body size and not the working size on purpose:
   iOS Safari zooms the page into any field set smaller than 16px. */

.field__input,
.fire__input {
  width: 100%;
  min-width: 0;
  min-height: 40px;
  padding: 0 var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--text-body);
  line-height: 1.5;
  color: var(--ink);
  background: var(--paper);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  appearance: none;
  transition: border-color var(--dur-quick) var(--ease-hair),
              background-color var(--dur-quick) var(--ease-hair);
}

/* Soft ink at full strength: 5.37:1 on paper, where the same grey at 0.65
   blended to 2.68:1 and failed AA. Ink against soft ink is already how the
   sheet tells a title from its note, so it is enough to tell typed text
   from the prompt. The opacity is written out because Firefox's own sheet
   gives ::placeholder one of its own. */

.field__input::placeholder,
.fire__input::placeholder {
  color: var(--ink-soft);
  opacity: 1;
}

.field__input:hover,
.fire__input:hover { border-color: var(--ink-soft); }

.field__input:focus,
.fire__input:focus {
  border-color: var(--ink);
  background: var(--paper-lift);
}

/* On the sheet the name field takes whatever its line has left once the
   code field and the arrow have theirs, so the arrow lands on the right
   edge; the code field holds four tracked capitals and no more. In the
   band where the controls sit at the end of the row the name field takes
   a set width instead (see "small viewports"). */
.row .field__input { flex: 1 1 6rem; }

.row .field__input--code { flex: 0 0 8rem; }

.field__input--code {
  font-weight: var(--weight-medium);
  letter-spacing: 0.34em;
  text-transform: uppercase;
}

/* The tracking and the capitals are for a four-letter code. The placeholder
   is a ten-letter phrase, and at 0.34em it would run out of the box. */
.field__input--code::placeholder {
  letter-spacing: 0.02em;
  text-transform: none;
}

/* buttons ---------------------------------------------------------------- */

/* One family, two treatments. The primary is a solid block of ink, and in
   the match it is Fire alone. The menu has no primary in this family at
   all: its one solid block is Play, over the hero plate (.hero__play), so
   the arrows at the ends of all six rows are hairline boxes. Everything
   else that can be pressed is the same hairline box, which darkens its
   edge under the cursor -- the arrows, the chips, the skills and the chat's
   send are all drawn as it, so the eye learns it once and reads it
   everywhere. */

.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: 40px;
  padding: 0 var(--space-5);
  font-family: var(--font-display);
  font-size: var(--text-mono);
  font-weight: var(--weight-semibold);
  line-height: 1;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--ink);
  background: transparent;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  cursor: pointer;
  transition: background-color var(--dur) var(--ease-ink), color var(--dur) var(--ease-ink),
              border-color var(--dur) var(--ease-ink), transform var(--dur) var(--ease-ink);
}

.button:hover:not(:disabled) { border-color: var(--ink); }

.button--primary {
  color: var(--paper);
  background: var(--ink);
  border-color: var(--ink);
}

.button--primary:hover:not(:disabled) {
  color: var(--ink);
  background: var(--paper-lift);
}

/* The arrow at the end of a row: a square of the button's own height. The
   glyph inside is its own element so that it, and not the box, can move --
   6px towards the edge whenever the cursor is anywhere on the row or a field
   in it has focus, which is the row saying "this way" without a fill or a
   shadow. The row's :focus-within does the same for a keyboard, and for the
   two forms it means the arrow leans while the name is being typed. The
   auto margin holds it at the right edge of its line when nothing beside
   it grows to fill the line -- row 04's Sandbox link, row 05 on its own --
   so the arrows stay in one column. */

.row__go {
  flex: none;
  width: 40px;
  margin-left: auto;
  padding: 0;
}

.row__arrow {
  display: inline-block;
  font-size: var(--text-body);
  letter-spacing: 0;
  transition: transform var(--dur) var(--ease-ink);
}

.row:hover .row__arrow,
.row:focus-within .row__arrow { transform: translateX(6px); }

/* A text link is a button that has given up its box: the word in the
   button's voice, a hairline under it in rule grey, and the hairline goes to
   ink under the cursor. For the one thing on the sheet that is real but not
   the point -- the sandbox -- and the lessons offer under a match. */

.link {
  padding: 0;
  font-family: var(--font-display);
  font-size: var(--text-mono);
  font-weight: var(--weight-semibold);
  line-height: 1;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink);
  background: none;
  border: 0;
  cursor: pointer;
  text-decoration: underline;
  text-decoration-color: var(--rule);
  text-decoration-thickness: 1px;
  text-underline-offset: 0.3em;
  transition: text-decoration-color var(--dur) var(--ease-ink);
}

.link:hover { text-decoration-color: var(--ink); }

/* Disabled says one thing everywhere: the control is still there and still
   says what it does, at less than half the ink, and the cursor refuses. The
   hover rules above and below all carry :not(:disabled), so nothing has to
   be put back when the cursor lands on one. */

.button:disabled,
.fire__input:disabled,
.fire__sight:disabled,
.fire__mode:disabled,
.skills__btn:disabled {
  opacity: 0.42;
  cursor: not-allowed;
}

/* A press is a stamp: it lands almost at once and lifts back on the ink curve,
   a little slower than the fill it sits under, so the paper reads as giving
   way and then coming back rather than blinking. 1px is the whole travel --
   any further and the label starts to swim inside its own border. Every
   pressable control on the sheet is named here, and the reduced-motion block
   at the end of the file names the same nine, because a transform is the
   one thing its duration blanket cannot reach.

   .lesson__opt is on the list, and it is here rather than stated locally in the
   lesson panel because this list claims to be every pressable box on the
   sheet. A boxed control that dips nowhere but in its own section makes that
   claim false, and the next person to add a control reads the claim. */

.button:active,
.fire__dir:active,
.plate__mute:active,
.chips__key:active,
.chat__send:active,
.skills__btn:active,
.lesson__opt:active,
.plate__leave:active,
.fire__mode:active {
  transform: translateY(1px);
  transition-duration: var(--dur-press);
}

/* the four opponents ----------------------------------------------------- */

/* Words, not boxes. client.js builds each one as .button so it has the
   family's voice, press and focus ring; this takes the box back, keeping the
   hairline on the bottom edge only and transparent until the cursor lands,
   so a tier underlines the way the links do -- .button's own hover paints
   the border ink, and the one edge with a width shows it. The odds under
   each name are a measurement, so they are mono at the label size. */

.tiers {
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
}

.tiers__pick {
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-1);
  min-height: 0;
  padding: var(--space-1) 0;
  border-width: 0 0 1px;
  border-color: transparent;
  border-radius: 0;
}

.tiers__name { line-height: 1.1; }

.tiers__odds {
  font-family: var(--font-mono);
  font-size: var(--text-label);
  font-weight: var(--weight-regular);
  letter-spacing: 0.01em;
  text-transform: none;
  color: var(--ink-soft);
}

/* the menu arrives ------------------------------------------------------- */

/* The two lifted labels -- the masthead and PLAY -- settle rather than
   simply being there: one step of the space ladder and a fade, on the ink
   curve like everything else in this file. The plate under them does not:
   it is the ground the labels land on, and a full-bleed sheet sliding a
   step would show paper under its edge. The rows have no arrival of their
   own; they come with the sheet, which has its entrance above. Nothing
   here is pointer-events or visibility, so PLAY is live and hittable from
   the first frame even while it is still a little pale.

   The whole sequence is gated on :not(.is-settled), and menu.js adds that
   class once. Two reasons it is a class and not just an animation. Leaving a
   match un-hides this section, and [hidden] is display:none, so without the
   gate the arrival would replay every single time somebody backed out of a
   game. And if the script never runs at all the gate never closes, which
   fails the right way: the menu still animates in, it just does it again on
   the way back. */

@keyframes menu-settle {
  from { opacity: 0; transform: translateY(var(--space-2)); }
  to   { opacity: 1; transform: none; }
}

.screen--menu:not(.is-settled) .masthead,
.screen--menu:not(.is-settled) .hero__overlay {
  animation: menu-settle var(--dur-slow) var(--ease-ink) both;
}

/* the field being filled in ---------------------------------------------- */

/* One quiet answer to a cursor landing in a field: a rule presses in along
   the bottom of the input itself -- inset with no blur, which is a printed
   line and not a glow. It is there the whole time at a spread that holds it
   flat outside the box; focus lets it out. */

.field__input {
  box-shadow: inset 0 -2px 0 -2px var(--ink);
  transition: border-color var(--dur-quick) var(--ease-hair),
              background-color var(--dur-quick) var(--ease-hair),
              box-shadow var(--dur-quick) var(--ease-hair);
}

.field__input:focus {
  box-shadow: inset 0 -2px 0 0 var(--ink);
}

/* ================================================================ game == */

/* Three control heights on this screen, and each is a rank: 44px for the
   console, the one instrument the game is played on; 40px for every ordinary
   key under it and in the rail; 32px for the plate head, which is read more
   than pressed. */

.screen--game {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-auto-rows: auto;
  gap: var(--step);
  width: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  padding: var(--pad-screen);
}

/* plate ------------------------------------------------------------------ */

.plate {
  order: 1;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  min-width: 0;
  padding: var(--space-4);
  background: var(--paper-lift);
  border-radius: var(--radius);
}

/* Every child is a 32px box, so the row can centre them and nothing needs
   to opt out. Baseline alignment was the alternative, and it failed on the
   two children with no baseline worth having -- the pip and the mute glyph. */

.plate__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-4);
  padding-bottom: var(--space-3);
  border-bottom: var(--hair);
}

.plate__mark {
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  font-family: var(--font-display);
  font-weight: var(--weight-black);
  font-size: var(--text-body);
  letter-spacing: -0.01em;
}

/* The right padding is a step short of the left: 0.28em of tracking already
   trails the last letter, and matching pads would read as off-centre. */

.plate__code {
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  padding: 0 var(--space-2) 0 var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  font-weight: var(--weight-medium);
  letter-spacing: 0.28em;
  text-transform: uppercase;
  background: var(--paper-deep);
  border: var(--hair);
  border-radius: var(--radius);
}

.plate__code:empty { display: none; }

/* Whose turn it is, at body size and in full ink: it is the one line in the
   head that changes, and the one a player looks up for. */

.plate__turn {
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  margin-left: auto;
  font-size: var(--text-body);
  font-weight: var(--weight-medium);
  color: var(--ink);
}

/* the head's right-hand cluster ------------------------------------------ */

/* The head already pushes .plate__turn with margin-left:auto. The clock claims
   the far right, and the turn line gives up its auto margin whenever a clock is
   actually showing, so the two never split the free space between them. */

.plate__mute {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  font-family: var(--font-display);
  font-size: var(--text-mono);
  line-height: 1;
  color: var(--ink-soft);
  background: var(--paper);
  border: var(--hair);
  border-radius: var(--radius);
  cursor: pointer;
  transition: color var(--dur) var(--ease-ink), background-color var(--dur) var(--ease-ink),
              border-color var(--dur) var(--ease-ink), transform var(--dur) var(--ease-ink);
}

.plate__mute:hover {
  color: var(--ink);
  border-color: var(--ink);
}

.plate__mute[aria-pressed='true'] {
  color: var(--rule);
  background: var(--paper-deep);
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}

.plate__turn:has(~ .plate__clock:not([hidden])) { margin-left: 0; }

.plate__clock {
  flex: none;
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  font-weight: var(--weight-medium);
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  letter-spacing: 0.04em;
  color: var(--ink-soft);
}

.plate__clock.is-urgent {
  color: var(--flo);
  animation: clock-pulse 1s linear infinite;
}

@keyframes clock-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.34; }
}

/* It sits downstream of the margin-left:auto that .plate__turn (or the
   clock) already spends, so it joins the right-hand cluster without claiming
   any space of its own. */

.conn {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
  min-height: 32px;
}

/* The pip. Round on purpose: it is a signal lamp, not a panel, and the one
   curved mark on the sheet. */

.conn__dot {
  flex: none;
  width: 8px;
  height: 8px;
  background: currentColor;
  border-radius: 50%;
}

.conn__text {
  font-family: var(--font-mono);
  font-size: var(--text-label);
  font-weight: var(--weight-medium);
  letter-spacing: 0.08em;
  text-transform: lowercase;
}

/* live: present, quiet, no ink spent -- and the pip shrinks a step */

.conn[data-state='live'] { color: var(--ink-soft); }

.conn[data-state='live'] .conn__dot {
  width: 6px;
  height: 6px;
}

/* reconnecting: full ink, and the dot breathes -- opacity only, slow */

.conn[data-state='reconnecting'] { color: var(--ink); }

.conn[data-state='reconnecting'] .conn__dot {
  animation: conn-pulse 1.2s linear infinite;
}

@keyframes conn-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.25; }
}

/* lost: an alarm. The ONE licensed use of --flo outside team marking. */

.conn[data-state='lost'] { color: var(--flo); }

/* offline: hotseat, training, replay -- there is no connection to report.
   The script [hidden]s it; if it ever slips through, draw nothing. */

.conn[data-state='offline'] { display: none; }

/* #btn-leave and #btn-rejoin both carry .plate__leave and no .button, so
   nothing from the .button family reaches them -- only the element resets
   and the global button:focus-visible ring. Quiet by default: soft ink on
   raw paper behind a hairline. */

.plate__leave {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 32px;
  padding: 0 var(--space-3);
  font-family: var(--font-display);
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--ink-soft);
  background: var(--paper);
  border: var(--hair);
  border-radius: var(--radius);
  cursor: pointer;
  transition: color var(--dur) var(--ease-ink), background-color var(--dur) var(--ease-ink),
              border-color var(--dur) var(--ease-ink), transform var(--dur) var(--ease-ink);
}

.plate__leave:hover {
  color: var(--ink);
  border-color: var(--ink);
}

/* Rejoin, whenever it is visible, is the most important control on the
   screen: full ink. The id (1,0,0) beats .plate__leave (0,1,0) without
   !important, and the [hidden] reset still owns its display while the
   script keeps it stowed. */

#btn-rejoin {
  color: var(--paper);
  background: var(--ink);
  border-color: var(--ink);
}

#btn-rejoin:hover {
  color: var(--ink);
  background: var(--paper-lift);
  border-color: var(--ink);
}

/* the frame, with its registration ticks --------------------------------- */

.plate__frame {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  margin-inline: auto;
  background: var(--paper);
  border: var(--hair);
}

/* the one flourish: four L-shaped corner registration ticks -- on the match
   plate and on the menu's hero, which is the same plate */
.plate__frame::before,
.hero::before {
  content: '';
  position: absolute;
  inset: calc(var(--tick) * -0.62);
  pointer-events: none;
  background-image:
    linear-gradient(var(--rule), var(--rule)),
    linear-gradient(var(--rule), var(--rule)),
    linear-gradient(var(--rule), var(--rule)),
    linear-gradient(var(--rule), var(--rule)),
    linear-gradient(var(--rule), var(--rule)),
    linear-gradient(var(--rule), var(--rule)),
    linear-gradient(var(--rule), var(--rule)),
    linear-gradient(var(--rule), var(--rule));
  background-size:
    var(--tick) 1px, 1px var(--tick),
    var(--tick) 1px, 1px var(--tick),
    var(--tick) 1px, 1px var(--tick),
    var(--tick) 1px, 1px var(--tick);
  background-position:
    left top, left top,
    right top, right top,
    left bottom, left bottom,
    right bottom, right bottom;
  background-repeat: no-repeat;
}

/* Absolute, not in-flow: a canvas carries an intrinsic size from its width and
   height attributes, and in flow that size fights the frame's aspect-ratio —
   which collapsed the plate to a square on narrow viewports. Out of flow it can
   only ever fill the frame the aspect-ratio already decided. */
#field {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

/* The one bordered announcement. It sits over the drawing, so the ink edge is
   what separates it from whatever curve is under it. */

.plate__banner {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 2;
  transform: translate(-50%, -50%);
  max-width: calc(100% - var(--space-6));
  padding: var(--space-4) var(--space-5);
  font-family: var(--font-display);
  font-weight: var(--weight-bold);
  font-size: var(--text-h);
  line-height: 1.15;
  letter-spacing: -0.015em;
  text-align: center;
  text-wrap: balance;
  color: var(--ink);
  background: var(--paper-lift);
  border: 1px solid var(--ink);
  border-radius: var(--radius);
}

.plate__caption {
  font-size: var(--text-mono);
  line-height: 1.4;
  color: var(--ink-soft);
  text-wrap: pretty;
}

/* ================================================================ rail == */

/* Five sections under one label size, ranked by what is under the label:
   the roster's rows are set at body size because they are the live state
   of the match; the figures and the chat at the working size because they
   are a record; the notation is folded shut because it is a key, read once
   and then remembered. */

/* margin (the side rail) ------------------------------------------------- */

.margin {
  order: 3;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  min-width: 0;
  min-height: 0;
  padding: var(--space-4);
  background: var(--paper-deep);
  border-radius: var(--radius);
}

.margin__block {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  min-height: 0;
}

.margin__block--grow { flex: 1 1 auto; }

.margin__block--chat { flex: 0 0 auto; }

.margin__title {
  padding-bottom: var(--space-2);
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-soft);
  border-bottom: var(--hair);
}

/* folding the figures log ------------------------------------------------ */

/* The list is the one block in the margin that grows without limit, and a
   long match filled it: measured at 19 figures it stood 930px tall and drove
   the plate's grid row from 629px to 1260px, leaving 760px of empty plate
   between the map and the "y =" bar. It is capped and scrollable now, and
   this folds it away entirely.

   The heading becomes the control, so it costs no extra room; its chevron
   is the fold mark below. */

.margin__title--fold {
  padding-bottom: 0;
}

.margin__fold {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  width: 100%;
  padding: 0 0 var(--space-2);
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: inherit;
  background: none;
  border: 0;
  cursor: pointer;
  transition: color var(--dur-slow) var(--ease-quiet);
}

.margin__fold:hover { color: var(--ink); }

/* the fold mark ---------------------------------------------------------- */

/* One corner for every disclosure on the sheet -- the figures fold, the
   notation, the phone's tools toggle: two hairline edges of a 7px square,
   turned through 45deg. Down means "press to reveal", up means "press to
   stow". The translate nudges re-centre the rotated corner optically; no
   transition is set, so the flip is as instant as the rest of the print. */

.margin__fold::after,
.margin__summary .margin__title::after,
.tools__toggle::after {
  content: '';
  flex: none;
  width: 7px;
  height: 7px;
  border-right: 1px solid currentColor;
  border-bottom: 1px solid currentColor;
  transform: translateY(2px) rotate(-135deg);
}

.margin__fold[aria-expanded='false']::after,
.margin__notes:not([open]) .margin__title::after,
.tools__toggle[aria-expanded='false']::after {
  transform: translateY(-2px) rotate(45deg);
}

/* Folded: the list goes, and the block stops laying claim to spare height. */
.margin__block--grow.is-folded { flex: 0 0 auto; }
.margin__block--grow.is-folded .figures { display: none; }

/* roster ----------------------------------------------------------------- */

.roster {
  display: flex;
  flex-direction: column;
}

.roster__item {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  border-left: 2px solid transparent;
  color: var(--ink);
}

.roster__dot {
  flex: none;
  width: 10px;
  height: 10px;
  background: currentColor;
  border: 1px solid currentColor;
}

.roster__name {
  flex: 1 1 auto;
  min-width: 0;
  font-size: var(--text-body);
  font-weight: var(--weight-medium);
  color: inherit;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.roster__state {
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--text-label);
  color: var(--ink-soft);
}

/* Whose go it is: the row lifts and its left rule takes the team ink. That
   pair is the whole mark -- the name keeps its weight, so the eye is not
   asked to read two accents that mean the same thing. */

.roster__item.is-current {
  background: var(--paper-lift);
  border-left-color: currentColor;
}

.roster__item.is-dead { opacity: 0.45; }

.roster__item.is-dead .roster__name {
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}

.roster__item.is-dead .roster__dot { background: transparent; }

/* hearts ----------------------------------------------------------------- */

/* .roster__item is already a flex row and .roster__name already takes the free
   space, so the hearts only have to hold their place between name and state.
   10px, the same square as the team dot, so a row reads as one size of mark. */

.roster__hearts {
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--space-1);
  line-height: 1;
}

.roster__heart {
  display: block;
  width: 10px;
  height: 10px;
  background: currentColor;
  border: 1px solid currentColor;
}

/* A spent heart is an empty box on a grey rule; so is every heart of a dead
   soldier. Nothing later in the file may set a heart's ground again -- a rule
   that did, at (0,3,0), painted spent hearts solid in the team ink. */

.roster__heart--gone,
.roster__item.is-dead .roster__heart {
  background: transparent;
  border-color: var(--rule);
}

/* figures ---------------------------------------------------------------- */

.figures {
  max-height: 15rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--rule) transparent;
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  line-height: 1.4;
}

.figures__empty {
  font-family: var(--font-display);
  font-style: italic;
  color: var(--ink-soft);
}

.figures__item {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: var(--space-2);
  padding: var(--space-2);
  border-top: var(--hair);
  border-left: 2px solid transparent;
  color: var(--ink);
}

.figures__item:first-child { border-top: 0; }

.figures__n {
  grid-column: 1;
  font-weight: var(--weight-semibold);
  color: inherit;
}

.figures__fn {
  grid-column: 2;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.figures__result {
  grid-column: 2;
  margin-top: var(--space-1);
  font-size: var(--text-label);
  letter-spacing: 0.02em;
  color: var(--ink-soft);
  overflow-wrap: anywhere;
}

/* team inks -------------------------------------------------------------- */

.roster__item[data-team='0'], .figures__item[data-team='0'] { color: var(--team-0-text); }
.roster__item[data-team='1'], .figures__item[data-team='1'] { color: var(--team-1-text); }
.roster__item[data-team='2'], .figures__item[data-team='2'] { color: var(--team-2-text); }
.roster__item[data-team='3'], .figures__item[data-team='3'] { color: var(--team-3-text); }
.roster__item[data-team='4'], .figures__item[data-team='4'] { color: var(--team-4-text); }
.roster__item[data-team='5'], .figures__item[data-team='5'] { color: var(--team-5-text); }

/* The first two sides rule their figures in the canvas ink, the rest in the
   text ink. */

.figures__item[data-team='0'] { border-left-color: var(--team-0); }
.figures__item[data-team='1'] { border-left-color: var(--team-1); }
.figures__item[data-team='2'] { border-left-color: var(--team-2-text); }
.figures__item[data-team='3'] { border-left-color: var(--team-3-text); }
.figures__item[data-team='4'] { border-left-color: var(--team-4-text); }
.figures__item[data-team='5'] { border-left-color: var(--team-5-text); }

.roster__item[data-team='2'] { border-left-color: var(--team-2-text); }
.roster__item[data-team='3'] { border-left-color: var(--team-3-text); }
.roster__item[data-team='4'] { border-left-color: var(--team-4-text); }
.roster__item[data-team='5'] { border-left-color: var(--team-5-text); }

/* notation --------------------------------------------------------------- */

/* A native <details>, shut by default, with no script behind it. It stays in
   the rail rather than moving under the console because the console is for
   acting and the rail is where the record and the key live -- and on a
   phone the chips already put the notation under the player's thumb. The
   summary is the section label with the fold mark at its end (see "the
   fold mark"): on a desktop this is the only way to the function names,
   and a "?" that never turned read as a heading over an empty section.

   display is left at block: a <details> does not reliably take flex in every
   browser, so the gap under the label is the list's own padding rather than
   .margin__block's gap. */

.margin__notes { flex: 0 0 auto; }

.margin__summary {
  list-style: none;
  cursor: pointer;
}

.margin__summary::-webkit-details-marker { display: none; }

.margin__summary .margin__title {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  transition: color var(--dur-slow) var(--ease-quiet);
}

.margin__summary:hover .margin__title { color: var(--ink); }

.cheats {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding-top: var(--space-2);
}

.cheats__row {
  display: grid;
  grid-template-columns: 3.2rem minmax(0, 1fr);
  column-gap: var(--space-3);
  align-items: baseline;
  font-size: var(--text-mono);
}

.cheats__row dt {
  font-family: var(--font-mono);
  font-weight: var(--weight-semibold);
  color: var(--ink);
}

.cheats__row dd {
  color: var(--ink-soft);
  overflow-wrap: anywhere;
}

/* chat ------------------------------------------------------------------- */

.chat {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  max-height: 150px;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--rule) transparent;
  list-style: none;
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  line-height: 1.4;
}

.chat__empty {
  list-style: none;
  font-family: var(--font-display);
  font-style: italic;
  color: var(--ink-soft);
}

.chat__item {
  list-style: none;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.chat__who {
  margin-inline-end: var(--space-1);
  font-weight: var(--weight-semibold);
  color: var(--ink-soft);
}

.chat__text { color: var(--ink); }

.chat__item--system {
  font-family: var(--font-display);
  font-style: italic;
  color: var(--ink-soft);
}

.chat__form {
  display: flex;
  align-items: stretch;
  gap: var(--space-2);
  min-width: 0;
}

/* The console's field, one rank down: the same mono, paper and hairline,
   at 40px, and focus is the border going to ink and nothing else. */

.chat__input {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 40px;
  padding: 0 var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--text-body);
  line-height: 1.5;
  color: var(--ink);
  background: var(--paper);
  border: var(--hair);
  border-radius: var(--radius);
  appearance: none;
  transition: border-color var(--dur-quick) var(--ease-hair);
}

/* Full strength for the same reason as the menu fields' placeholder. */
.chat__input::placeholder {
  color: var(--ink-soft);
  opacity: 1;
}

.chat__input:hover { border-color: var(--ink-soft); }

.chat__input:focus { border-color: var(--ink); }

.chat__send {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  padding: 0;
  font-family: var(--font-mono);
  font-size: var(--text-body);
  line-height: 1;
  color: var(--ink);
  background: var(--paper);
  border: var(--hair);
  border-radius: var(--radius);
  cursor: pointer;
  transition: color var(--dur) var(--ease-ink), background-color var(--dur) var(--ease-ink),
              border-color var(--dur) var(--ease-ink), transform var(--dur) var(--ease-ink);
}

.chat__send:hover { border-color: var(--ink); }

/* lesson panel ----------------------------------------------------------- */

/* First in the rail and the primary thing on screen during training, so it
   takes the lifted ground -- the plate's own -- on the rail's deep paper. It
   ties with .margin__block at (0,1,0) and only adds ground and padding;
   nothing to fight. Shown/hidden with [hidden], whose display:none
   !important still wins.

   The block is a SEQUENCE and not a stack of equal paragraphs, which is what
   it was: under the old markup the brief, the task and the note were three
   <p>s of the same size, the same colour and the same 8px apart, and the one
   sentence telling the player what to do now had nothing holding it up.
   Everything below is that hierarchy -- where you are, what this is about,
   WHAT TO DO, then whatever the plate said back -- and the seams between the
   movements are ruled or opened rather than given to each part as its own
   box, because seven boxes in a 300px rail is not a sequence either. */

.margin__block--lesson {
  padding: var(--space-3);
  background: var(--paper-lift);
  border-radius: var(--radius);
}

/* 1. where you are. The counter and the rung ticks on one line, set as
   quietly as this sheet sets anything: it is a bearing, not news. */

.lesson__where {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding-bottom: var(--space-2);
  border-bottom: var(--hair);
}

.lesson__step {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-size: var(--text-label);
  font-weight: var(--weight-medium);
  letter-spacing: 0.04em;
  color: var(--ink-soft);
}

/* The tier, ahead of the counter: the same small-caps voice as the idea under
   the title, because it is the same kind of thing -- a name for where you are,
   not news. Ink rather than soft ink so it and the counter read as heading and
   figure rather than as one grey phrase. */

.lesson__tier {
  flex: 0 0 auto;
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink);
}

.lesson__tier:empty { display: none; }

/* One tick per rung, so the ladder has a length you can see. Too many to
   number and too few to abbreviate, and aria-hidden in the markup because
   #lesson-step already says "4/14" in words a screen reader can use. */

.lesson__rungs {
  display: flex;
  flex: 1 1 auto;
  gap: 3px;
  align-items: flex-end;
  min-width: 0;
  height: 9px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.lesson__rung {
  flex: 1 1 0;
  min-width: 0;
  height: 3px;
  background: var(--rule);
  border-radius: 1px;
}

/* Three states and a position, in one ink. A rung merely reached is the soft
   ink -- it was visited, and that is all the ladder knows. A rung SOLVED is
   full ink at the same height, which is the one distinction the old strip
   could not make: it marked everything behind you as done, and a player who
   had skipped four rungs was shown four rungs they understood. The one being
   worked is full ink and three times as tall, so the eye finds it without a
   second colour -- this sheet has two of those and both are teams. */

.lesson__rung--seen { background: var(--ink-soft); }
.lesson__rung--done { background: var(--ink); }

.lesson__rung--here {
  height: 9px;
  background: var(--ink);
}

/* A breath between tiers, on the first tick of each. The strip is the ladder's
   length; this is its shape. */

.lesson__rung--first { margin-left: 4px; }
.lesson__rung--first:first-child { margin-left: 0; }

/* 2. the name of the rung. The h2 no longer wears .margin__title -- that rule
   is an 11px small-caps label, which is the right voice for "ON THE PLATE" and
   the wrong one for the title of the thing being taught. */

.lesson__title {
  font-family: var(--font-display);
  font-size: var(--text-body);
  font-weight: var(--weight-semibold);
  line-height: 1.2;
  color: var(--ink);
  text-wrap: balance;
}

/* 3. the idea being taught -- a small-caps label, set like .skills__label and
   pulled up under the title so the two read as one heading. */

.lesson__teaches {
  margin-top: calc(2px - var(--space-2));
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* 4. the explanation. Deliberately the quietest full sentence in the block:
   it is background, and a player who already knows it should be able to slide
   off it and land on the task. */

.lesson__brief {
  font-size: var(--text-mono);
  line-height: 1.55;
  color: var(--ink-soft);
  text-wrap: pretty;
}

/* 5. THE TASK -- the loudest thing in the panel and the only part of it set at
   the body size. Full ink, on the deep ground, behind a 3px rule of solid ink.
   Everything above it is smaller; everything below it is either quieter or
   only there some of the time. */

.lesson__task {
  padding: var(--space-3);
  font-size: var(--text-body);
  font-weight: var(--weight-medium);
  line-height: 1.4;
  color: var(--ink);
  background: var(--paper-deep);
  border-left: 3px solid var(--ink);
  border-radius: var(--radius);
  text-wrap: pretty;
}

/* The miss line under a match plate: the measurement in the game's mono
   voice, then the offer as a link. Borrows the caption's ground. */

.plate__miss {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  color: var(--ink);
}

/* 5b. the skill the rung has armed, and the note to a reader in another
   language on a rung not yet translated. Both are conditions of the exercise,
   so both take the withheld line's dashed rule below rather than a voice of
   their own. */

.lesson__armed,
.lesson__english {
  padding-left: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-label);
  line-height: 1.5;
  color: var(--ink-soft);
  border-left: 1px dashed var(--rule);
  text-wrap: pretty;
}

/* 6. the rung's own terms, when it withholds something. A dashed rule rather
   than a solid one: this is a condition of the exercise, not a thing to do. */

.lesson__withheld {
  padding-left: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-label);
  line-height: 1.5;
  color: var(--ink-soft);
  border-left: 1px dashed var(--rule);
  text-wrap: pretty;
}

/* 7. what the last shot measured, said as the edit to make. A measurement, so
   it is set in the game's own mono voice on raw paper -- the same treatment
   the plate gives the number it strikes beside the miss tick. Quiet on
   purpose: it arrives on every miss, and something loud arriving on every miss
   is the game raising its voice at somebody who is already trying. */

.lesson__miss {
  padding: var(--space-2) var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  line-height: 1.5;
  color: var(--ink);
  background: var(--paper);
  border-left: 2px solid var(--rule);
  border-radius: var(--radius);
  text-wrap: pretty;
}

/* 7b. the hint ladder. The hints themselves take the miss line's voice -- mono
   on raw paper -- because they are the same kind of thing said the other way
   round: the miss says what the plate measured, a hint says what to do about
   it. A dashed rule instead of a solid one, since nothing here was measured.
   The button is the skip's cousin and never a .button: asking for help must
   not look like one of the things the rung asks you to do. */

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

.lesson__hints {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.lesson__hint {
  padding: var(--space-2) var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  line-height: 1.5;
  color: var(--ink);
  background: var(--paper);
  border-left: 2px dashed var(--rule);
  border-radius: var(--radius);
  text-wrap: pretty;
}

/* The answer, when it is finally asked for, gets the solid rule back: it is
   the one hint that is a fact rather than a direction. */

.lesson__hint--answer { border-left-style: solid; border-left-color: var(--ink); }

.lesson__more {
  align-self: flex-start;
  padding: 0;
  font-family: var(--font-display);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-soft);
  background: none;
  border: 0;
  border-bottom: 1px solid var(--rule);
  cursor: pointer;
}

.lesson__more:hover { color: var(--ink); border-bottom-color: var(--ink); }

/* 8. the question asked once a rung is solved. Raw paper, hairlined all the
   way round, because unlike anything else in the panel it is a thing that
   waits for an answer. */

.lesson__ask {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-3);
  background: var(--paper);
  border: var(--hair);
  border-radius: var(--radius);
}

.lesson__question {
  font-size: var(--text-mono);
  font-weight: var(--weight-semibold);
  color: var(--ink);
}

.lesson__opts {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

/* Full sentences, so these are set left and allowed to wrap -- .button's
   centred, uppercase, nowrap treatment is for a verb, and every one of these
   is a claim about mathematics. */

.lesson__opt {
  padding: var(--space-2) var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  line-height: 1.45;
  text-align: left;
  color: var(--ink);
  background: var(--paper-lift);
  border: var(--hair);
  border-radius: var(--radius);
  cursor: pointer;
  text-wrap: pretty;
  transition: border-color var(--dur) var(--ease-ink),
              background-color var(--dur) var(--ease-ink);
}

.lesson__opt:hover:not(:disabled) { border-color: var(--ink); }
.lesson__opt:disabled { cursor: default; }

/* The marked answers, after a choice. Ink weight and a rule do the work and
   not colour: the right one is left standing in full ink whether or not it was
   the one picked, and a wrong pick is struck through beside it, so the two can
   be read against each other. That comparison is the whole value of asking. */

.lesson__opt--right {
  font-weight: var(--weight-semibold);
  border-color: var(--ink);
  border-left-width: 3px;
}

.lesson__opt--picked.lesson__opt--wrong {
  color: var(--ink-soft);
  background: var(--paper-deep);
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}

.lesson__opt--spent:not(.lesson__opt--right):not(.lesson__opt--picked) {
  color: var(--ink-soft);
  opacity: 0.7;
}

/* Never a button among the buttons: skipping is always available and must
   never look like a fourth answer. */

.lesson__skip {
  align-self: flex-start;
  padding: 0;
  font-family: var(--font-display);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-soft);
  background: none;
  border: 0;
  border-bottom: 1px solid var(--rule);
  cursor: pointer;
}

.lesson__skip:hover { color: var(--ink); border-bottom-color: var(--ink); }

.lesson__verdict {
  font-size: var(--text-mono);
  line-height: 1.5;
  color: var(--ink-soft);
  text-wrap: pretty;
}

/* 9. the reward: raw paper on the lifted card, ruled like a worked answer in
   the margin. A <p>, so no display is set here and the [hidden] toggle keeps
   working. */

.lesson__reveal {
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-mono);
  line-height: 1.5;
  color: var(--ink-soft);
  background: var(--paper);
  border-left: 3px solid var(--ink);
  border-radius: var(--radius);
  text-wrap: pretty;
}

/* 10. the nav, held off the sequence above it by a rule -- everything over the
   line is the lesson, everything under it moves you about. */

.lesson__nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding-top: var(--space-3);
  border-top: var(--hair);
}

/* Ties with .button at (0,1,0) and wins on source order: a shorter box and
   smaller type so Reset / Back / Next share one line in the rail, and wrap
   below it when they cannot. */

.lesson__btn {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 32px;
  padding-inline: var(--space-3);
  font-size: var(--text-label);
  letter-spacing: 0.06em;
}

/* The way back to the first rung, shown only when there is somewhere to come
   back from. A footnote rather than a fourth button: this ladder is too long
   to redo by accident, so a returning player resumes where they were -- and
   this is the line that makes that a choice instead of a trap. */

.lesson__restart {
  align-self: flex-start;
  padding: 0;
  font-family: var(--font-mono);
  font-size: var(--text-label);
  color: var(--ink-soft);
  background: none;
  border: 0;
  border-bottom: 1px solid var(--rule);
  cursor: pointer;
}

.lesson__restart:hover { color: var(--ink); border-bottom-color: var(--ink); }

/* =============================================================== bench == */

/* Everything under the plate that the player works with: the console, the
   toggle that discloses the tools on a phone, and the tools. One column with
   one gap, so the seam between the console and whatever follows it is a
   token and not the screen's fluid step. */

.bench {
  order: 2;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  min-width: 0;
}

/* ============================================================= console == */

/* One instrument. No hairline around the strip: the plate above it is the
   same lifted paper with no edge of its own, and a box here would be the
   one bordered panel on the screen, sitting right under the plate frame's
   hairline. What makes the strip read as one thing is that every control
   in it is 44px, drawn with the same hairline, a --space-2 apart, and
   centred on one line. FIRE is the only solid ink on the game screen. */

.fire {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
  padding: var(--space-3);
  background: var(--paper-lift);
  border-radius: var(--radius);
}

/* direction: a segmented pair -------------------------------------------- */

/* Two squares sharing one hairline: the second overlaps the first by the
   width of the border, and whichever is hovered or on is raised so its own
   edge shows through. The on state is an ink rule pressed in along the
   bottom, the mark the menu's field already makes under focus, and not a
   fill: a filled square here sat at the far end of the same row as FIRE at
   the same weight, and unlike an armed skill a direction is always on. */

.fire__aim {
  flex: none;
  display: flex;
}

.fire__dir {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  padding: 0;
  font-family: var(--font-mono);
  font-size: var(--text-body);
  line-height: 1;
  color: var(--ink-soft);
  background: var(--paper);
  border: var(--hair);
  border-radius: var(--radius) 0 0 var(--radius);
  cursor: pointer;
  transition: color var(--dur) var(--ease-ink), box-shadow var(--dur) var(--ease-ink),
              border-color var(--dur) var(--ease-ink), transform var(--dur) var(--ease-ink);
}

.fire__dir + .fire__dir {
  margin-left: -1px;
  border-radius: 0 var(--radius) var(--radius) 0;
}

.fire__dir:hover {
  position: relative;
  z-index: 1;
  color: var(--ink);
  border-color: var(--ink);
}

.fire__dir.is-on {
  position: relative;
  z-index: 1;
  color: var(--ink);
  box-shadow: inset 0 -2px 0 0 var(--ink);
}

/* the equation: a segmented three ---------------------------------------- */

/* "y =" was a label for the field. It is three of them now, one true at a
   time, because the left-hand side IS the mode: you are typing the curve, or
   its slope, or its curvature. Drawn on the direction pair's geometry rather
   than merely next to it -- the same 44px rank, the same hairline, the same
   shared edge, the same rule pressed in along the bottom of whichever is on
   -- so the strip still reads as one instrument carrying two segmented
   controls, and not as a panel of settings.

   Sized to its glyphs and not to a square. "y'' =" is five mono characters:
   a 44px box would clip it, and widening all three to fit it would leave "y ="
   swimming in a box twice the size of anything it is beside. The 44px height
   is what makes the touch target, and it is the rank the whole strip is drawn
   to, so nothing is lost by letting the width follow the label.

   Soft ink for the two that are off and full ink for the one that is on: the
   prompt is still the prompt, and what is typed after it is still the answer. */

.fire__eq {
  flex: none;
  display: flex;
  min-width: 0;
}

.fire__mode {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 44px;
  padding-inline: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-body);
  line-height: 1;
  white-space: nowrap;
  color: var(--ink-soft);
  background: var(--paper);
  border: var(--hair);
  border-radius: 0;
  cursor: pointer;
  transition: color var(--dur) var(--ease-ink), box-shadow var(--dur) var(--ease-ink),
              border-color var(--dur) var(--ease-ink), transform var(--dur) var(--ease-ink);
}

.fire__mode:first-child { border-radius: var(--radius) 0 0 var(--radius); }
.fire__mode:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
.fire__mode + .fire__mode { margin-left: -1px; }

.fire__mode:hover:not(:disabled) {
  position: relative;
  z-index: 1;
  color: var(--ink);
  border-color: var(--ink);
}

.fire__mode.is-on {
  position: relative;
  z-index: 1;
  color: var(--ink);
  box-shadow: inset 0 -2px 0 0 var(--ink);
}

.fire__input {
  flex: 1 1 12ch;
  min-height: 44px;
}

/* Focus is the border going to ink, and only that: the shared field rule
   also lifts the ground, and two answers to one cursor was the menu's
   defect. The paper is written out so the shared rule cannot put it back. */

.fire__input:focus {
  background: var(--paper);
  border-color: var(--ink);
}

/* aria-invalid is set by the script; this is where it shows. */
.fire__input[aria-invalid='true'] { border-color: var(--team-1-text); }

/* fire, and the two buttons that stand in its slot ----------------------- */

/* #fire-go, #btn-start, #btn-share, #btn-card and #btn-daily-copy all carry
   .fire__go, so they share one box: 44px, and the label size in caps tracked
   like the sheet's section labels -- the block carries the weight, the letters
   only name it. #fire-go alone adds .button--primary.

   Fire and Play again are shown one at a time, but the end of a match shows
   Play again beside as many as three others; .fire already wraps, so the extra
   labels take a second line on a narrow console rather than squeezing the
   function box, which is the one control that must stay legible. */

.fire__go,
.fire__sight {
  flex: none;
  min-height: 44px;
  padding-inline: var(--space-5);
  font-size: var(--text-label);
  letter-spacing: 0.16em;
}

/* sighting button -------------------------------------------------------- */

/* #btn-sight also carries .button; the count is a figure, so it is mono. */

.fire__sight { gap: var(--space-2); }

.fire__sight #sight-left {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  font-weight: var(--weight-semibold);
  letter-spacing: 0;
}

/* the mode's note --------------------------------------------------------- */

/* What the three keys mean, kept on screen for as long as the key is on.

   The status line under this one cannot do this job: say() is written by
   every input event, so a warning put there is erased by the first character
   of the equation it was warning about. This block is only ever written by a
   press on the picker, so it survives typing.

   All three lines live in the markup and all three are stacked in the one
   grid cell, so the block stands at the height of the tallest of them
   whatever the language and whatever the width. That is the whole reason for
   the grid: at 1000px and up the match screen's first row is `1fr` and the
   bench's is `auto`, so a console that grows by a line takes that line off
   the bottom of the plate. A note that appeared, or that rewrapped from one
   line to two as the mode changed, would move the board under the player's
   cursor every time they pressed y''. Reserved, nothing moves.

   Soft ink, because the loud voice on this bar belongs to the status line's
   parse errors; this is the caption on the control above it. */

.fire__note {
  flex: 1 1 100%;
  display: grid;
}

/* visibility and not `hidden`: display:none would collapse the reservation
   the grid exists for, and visibility also takes the two lines that are off
   out of the accessibility tree, so a reader is told about one mode rather
   than all three at once. */

.fire__line {
  grid-area: 1 / 1;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  line-height: 1.4;
  color: var(--ink-soft);
  overflow-wrap: anywhere;
  visibility: hidden;
}

.fire__line.is-on { visibility: visible; }

/* status ----------------------------------------------------------------- */

/* The console's last row, full width under the controls. Ink on paper, no
   ground and no rule: a parse error or a note reads as a line written under
   the field it is about, and the field never moves -- only what is under
   the console steps down by the note's height. It hung out of flow into
   the bench's seam before this, and the seam held one line; the skill
   blurbs and the server's notices run to two lines at 1024 and four at 390,
   and everything past the first printed over the toggle and the skills.

   Empty, it leaves the flow and not the page: display:none would take the
   live region out of the accessibility tree, and a region that appears
   together with its text is not reliably read. Out of flow it costs the
   console nothing. */

.status {
  flex: 1 1 100%;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  line-height: 1.4;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.status:empty { position: absolute; }

/* ====================================================== tools disclosure == */

/* A phone's control, hidden from 1000px up (see "wide viewports"). It sits
   at the console's right edge, the size of a plate-head control, and its
   chevron is the rail's fold mark (see "the fold mark"). */

.tools__toggle {
  align-self: flex-end;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 32px;
  padding: 0 var(--space-3);
  font-family: var(--font-display);
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-soft);
  background: transparent;
  border: var(--hair);
  border-radius: var(--radius);
  cursor: pointer;
  transition: color var(--dur-slow) var(--ease-quiet), border-color var(--dur-slow) var(--ease-quiet);
}

.tools__toggle:hover {
  color: var(--ink);
  border-color: var(--ink);
}

/* =============================================================== tools == */

/* The skills and the keys on one ground. What the phone's toggle discloses
   is one thing, and it was drawn as two -- the skills on deep paper, the
   keys on none, so the keys floated loose between the band and the rail.
   From 1000px up the keys are gone and the skills are all that is left
   here. Ground is paper-deep so the paper-lift keys read against it. */

.tools {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  min-width: 0;
  padding: var(--space-3);
  background: var(--paper-deep);
  border-radius: var(--radius);
}

/* Nothing on the ground, no ground. The script stows the skills where a
   match has none (training, replay) and the keys outside play, both with
   [hidden]; with both gone the wrapper would stand as an empty band of deep
   paper under the console. From 1000px up the keys are gone regardless,
   so the skills alone decide (see "wide viewports"). */

.tools:has(> .skills[hidden]):has(> .chips[hidden]) { display: none; }

/* =========================================================== skill bar == */

/* The buttons the script inserts carry .skills__btn WITHOUT .button, so
   nothing from the .button family leaks in; only the element resets
   (font/color inherit), the global focus ring, the shared press and the
   shared disabled rule reach them. */

.skills {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  min-width: 0;
  max-width: 100%;
}

/* caption -- the rail's section label, on the same tracking */

.skills__label {
  flex: none;
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.skills__row {
  flex: 1 1 auto;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  min-width: 0;
}

/* the three states: available / armed / spent ---------------------------- */

/* 40px, the chips' height: a skill is a key like any other under the
   console. position: relative is for the spent mark below. */

.skills__btn {
  position: relative;
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 40px;
  min-width: 0;
  padding: 0 var(--space-4);
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  line-height: 1;
  color: var(--ink);
  background: var(--paper-lift);
  border: var(--hair);
  border-radius: var(--radius);
  cursor: pointer;
  transition: color var(--dur) var(--ease-ink), background-color var(--dur) var(--ease-ink),
              border-color var(--dur) var(--ease-ink), transform var(--dur) var(--ease-ink);
}

.skills__btn:hover:not(:disabled) { border-color: var(--ink); }

/* armed: full ink, paper text -- will apply to the next shot. A fill is
   allowed here because it is a state and it is transient. */

.skills__btn.is-armed,
.skills__btn.is-armed:hover {
  color: var(--paper);
  background: var(--ink);
  border-color: var(--ink);
}

/* Not available: the shared fade, and a dot in the left padding. The script
   sets [disabled] both for a skill that is spent and for every skill while
   it is not your turn, so the mark says "not now" rather than "gone". It
   is out of flow inside the padding so a turn changing hands, which flips
   the whole row, moves no button. */

.skills__btn:disabled::before {
  content: '·';
  position: absolute;
  inset: 0 auto 0 var(--space-1);
  display: flex;
  align-items: center;
}

/* blurb: full width below the controls, in the status line's voice -- the
   script writes the same sentence to both when a skill is armed -- and
   nothing at all while no skill is armed. It reserved an empty line before
   this, which left the band 12px above its row and 38px below. */

.skills__blurb {
  flex: 1 1 100%;
  font-size: var(--text-mono);
  line-height: 1.4;
  color: var(--ink-soft);
  overflow-wrap: anywhere;
}

.skills__blurb:empty { display: none; }

/* =============================================================== chips == */

/* A phone rail, hidden from 1000px up. A grid of equal cells: twelve keys
   as two rows of six, and one row of twelve once a twelfth of the width
   holds "sin(x)" in the mono at the working size -- 47px, which a 600px
   screen's 39px cell does not (see "small viewports"). Every key is the
   same key, clear included. */

.chips {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: var(--space-2);
  min-width: 0;
  max-width: 100%;
}

.chips__key {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  min-height: 40px;
  padding: 0 var(--space-1);
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  line-height: 1;
  color: var(--ink);
  background: var(--paper-lift);
  border: var(--hair);
  border-radius: var(--radius);
  cursor: pointer;
  transition: color var(--dur) var(--ease-ink), background-color var(--dur) var(--ease-ink),
              border-color var(--dur) var(--ease-ink), transform var(--dur) var(--ease-ink);
}

.chips__key:hover { border-color: var(--ink); }

/* ============================================================== impact == */

/* The plate's kick used to live here as @keyframes plate-shake, seven hand-
   placed translate3d stops on a 280ms linear run. It is a JS spring now,
   written straight onto .plate__frame's transform, because a fixed keyframe
   track cannot scale its amplitude with the damage that caused it -- a graze
   and a direct hit shook the paper by exactly the same 4px. The two could not
   have been left to coexist either: a CSS animation in its active period
   outranks the inline style attribute, so the keyframes would simply have
   swallowed every transform the script wrote. Nothing to declare here any
   more, and nothing for the reduced-motion block below to cancel -- that
   guard lives in the script now, next to the transform it has to stop. */

/* ====================================================== wide viewports == */

/* The chips go to one row of twelve here; the number is where a cell first
   holds the widest key (see "chips"). */

@media (min-width: 720px) {
  .chips { grid-template-columns: repeat(12, minmax(0, 1fr)); }
}

/* The menu is the viewport at every width (see "menu"), so nothing about
   it changes here; the rules in this band are the match screen's. */

@media (min-width: 1000px) {
  /* Two columns: the plate over the bench on the left, the rail down the
     whole right, so the rail's bottom edge is the bench's bottom edge. */

  .screen--game {
    grid-template-columns: minmax(0, 1fr) 300px;
    grid-template-rows: 1fr auto;
    align-content: start;
  }

  .plate {
    grid-column: 1;
    grid-row: 1;
  }

  .margin {
    grid-column: 2;
    grid-row: 1 / 3;
    overflow: hidden;
    /* Without this the sidebar's content height is free to push the grid's
       first row taller, and the plate — which fills that row — grows with it.
       The map inside keeps its fixed size, so every extra pixel became dead
       space between the map and the fire bar. */
    min-height: 0;
  }

  .bench {
    grid-column: 1;
    grid-row: 2;
  }

  /* keep the 16:9 plate inside the fold instead of stretching the page */
  .plate__frame {
    max-width: max(420px, calc((100dvh - 400px) * 16 / 9));
  }

  .figures {
    flex: 1 1 auto;
    min-height: 6rem;
    /* Measured: with 19 figures logged and no cap here, this list stood 930px
       tall, drove the grid's first row from 629px to 1260px, and left 760px of
       empty plate between the map and the "y =" bar. It scrolls instead. */
    max-height: min(22rem, calc(100dvh - 26rem));
  }

  .chat { max-height: 130px; }

  /* The chips are a phone rail, and from here up the tools are always on the
     table, so there is nothing to disclose. */
  .chips { display: none; }

  .tools__toggle { display: none; }

  .tools:has(> .skills[hidden]) { display: none; }
}

/* A 1920px screen is not a third empty paper: the rail takes one more
   step. (The menu is full-bleed at every width and has nothing to take.) */

@media (min-width: 1440px) {
  .screen--game { grid-template-columns: minmax(0, 1fr) 340px; }
}

/* ===================================================== small viewports == */

/* From 640px up the modes sheet is wide enough -- 592px inside at its full
   640 -- for a row's controls to sit at its right end, beside the note, so
   the eye runs number, name, then what to type or press. The sheet is never
   wider than that, so this band has no upper edge. The opponents are the
   exception: inline they are the widest cluster on the sheet and they
   squeezed "Against the machine" onto three lines, so they keep the line
   under the note. The name field takes a set width here rather than its
   line: 9rem shows fourteen letters of a sixteen-letter name in the mono,
   and leaves the join row's title 192px, which "Join with a code" needs to
   stay on one line. */

@media (min-width: 640px) {
  .row:not(.row--tiers) { grid-template-columns: var(--space-6) minmax(0, 1fr) auto; }

  .row:not(.row--tiers) .row__ctl {
    grid-column: 3;
    grid-row: 1 / 3;
    align-self: center;
    margin-top: 0;
  }

  .row .field__input {
    flex: none;
    width: 9rem;
  }

  .row .field__input--code { width: 8rem; }
}

/* Below 640 the console and the skill bar take a phone's proportions: the
   field keeps the direction pair's line, and sight and fire share the line
   under it evenly. The two bases decide the wrap without a second breakpoint:
   half the strip for the field keeps it on the first line down to 390px
   (87 + 8 + 167 = 262 of 334), and 40% for each button is more than what that
   line has left at 639px (428 used of 574), so the pair always drops together.
   The heights hold -- 44 and 40 are the console's and the keys' ranks, not
   desk sizes.

   The equation is what changed, and it cost the console a row. It used to be a
   30px label sharing the field's line, and three touch targets do not fit in
   30px: at 390 the line has 334 - 87 - 8 - 8 - 167 = 64 left once the
   direction pair and the field have taken theirs, and three keys measure about
   150 together. Something had to give, so the equation takes the rest of the
   direction pair's line (87 + 8 + 239 = 334, the keys splitting the 239 in
   thirds) and the field drops below it at full width.

   The field going full width rather than half is not a consolation. Half was
   only ever enough because a 30px label was sharing the line, and the field is
   the one control on the strip that must stay legible -- so given a line of
   its own it takes all of it, and reads better on a phone than it did before.
   Sight and Fire keep their own line under it, still 40% each, still dropping
   as a pair: three rows where there were two, and the row bought a mode. */

@media (max-width: 639.98px) {
  .fire__eq { flex: 1 1 auto; }

  .fire__mode { flex: 1 1 0; min-width: 0; }

  .fire__input { flex-basis: 100%; }

  .fire__go,
  .fire__sight { flex: 1 1 40%; }

  .skills__row { flex-basis: 100%; }

  .skills__btn { flex: 1 1 auto; }
}

/* Below 460 the four opponents go two by two: on a phone's width the flex
   row broke them three and one, and the fourth name alone on a line read as
   a different kind of choice from the other three. The plate head gives at
   the same point, into two rows and not three: the wordmark, the mute, the
   code and Leave keep the first, and the turn line takes the second with
   the clock and the pip at its end. It asks for half the head, more than
   the first row has left once those four have taken theirs, so it begins
   the row, and the two behind it in the markup follow it there. Visual
   order only; the tab order is the markup's. The registration ticks shrink
   with the frame they mark. */

@media (max-width: 460px) {
  .row--tiers .tiers {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .plate__frame,
  .hero { --tick: 10px; }

  .plate__code { letter-spacing: 0.2em; }

  .plate__turn {
    order: 10;
    flex: 1 0 50%;
    margin-left: 0;
  }

  .plate__clock,
  .conn { order: 10; }

  /* Leave takes the first row's right edge; Rejoin, when it is showing,
     sits beside it rather than splitting the free space with it. */
  .plate__leave { margin-left: auto; }

  .plate__leave:not([hidden]) + .plate__leave { margin-left: 0; }
}

/* Below 1000px only: the class the script puts on #screen-game stows the
   tools -- the ground and both bands on it. The bands are named as well as
   the ground because a band under a hidden ancestor still computes its own
   display, and the ui check reads the chips' own. No !important -- the
   [hidden] attribute each band already answers to carries its own
   !important and stays in charge either way, and removing the class hands
   display straight back to the base rules. At 1000px and up the three are
   ignored entirely, so wide screens always show the skills (the chips are
   already display:none up there). */

@media (max-width: 999.98px) {
  #screen-game.is-tools-hidden .tools,
  #screen-game.is-tools-hidden .skills,
  #screen-game.is-tools-hidden .chips { display: none; }
}

/* ====================================================== reduced motion == */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
    animation-duration: 0.01ms !important;
    animation-delay: 0ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
  }

  /* The press is the one piece of motion here that is a transform and not a
     duration, so the blanket above has no authority over it. Every control
     that dips under a finger is named by hand -- the same nine the press
     rule names.

     The !important is doing real work. A media query adds nothing to
     specificity, so each of these ties exactly with the :active rule it is
     cancelling; the !important means this holds wherever the block sits in
     the file, not only while it is last. */

  .button:active,
  .fire__dir:active,
  .plate__mute:active,
  .chips__key:active,
  .chat__send:active,
  .skills__btn:active,
  .lesson__opt:active,
  .plate__leave:active,
  .fire__mode:active { transform: none !important; }

  /* The arrow at the end of a menu row leans 6px on hover; the same blanket
     cannot stop a transform, so it is held still by hand too. */
  .row__arrow { transform: none !important; }

  /* The two pulses are infinite, so the blanket's single 0.01ms iteration
     would leave them parked on whatever frame it cut them at. */

  .plate__clock.is-urgent,
  .conn[data-state='reconnecting'] .conn__dot {
    animation: none;
    opacity: 1;
  }
}

/* what's new ------------------------------------------------------------- */

/* A sheet of the same family as the modes sheet, and deliberately not a toast
   or a banner: what changed is a thing you read, not a thing that slides past
   you. Built entirely by public/whatsnew.js, so these classes have no markup in
   index.html to match them -- the only thing that wires it in is a script tag.

   No shadow, no gradient, no blur, per the rule the rest of this file keeps:
   the sheet sits on the paper and is separated from it by a hairline and a
   lift, the same way every other panel here is. */
.news {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-4);
  background: color-mix(in srgb, var(--ink) 30%, transparent);
}

.news__sheet {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  width: min(560px, 100%);
  max-height: 100%;
  min-height: 0;
  padding: var(--space-5);
  background: var(--paper-lift);
  border: var(--hair);
}

.news__head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: start;
  column-gap: var(--space-3);
}

.news__mark {
  grid-column: 1;
  margin: 0;
  font-size: var(--text-label);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.news__sub {
  grid-column: 1;
  margin: var(--space-1) 0 0;
  font-size: var(--text-mono);
  color: var(--ink-soft);
}

.news__close {
  grid-column: 2;
  grid-row: 1 / span 2;
  min-height: 32px;
  padding: 0 var(--space-3);
  font-size: var(--text-label);
}

/* The list scrolls, not the sheet: the heading and the way out stay put while
   a reader who has been away a while works down the entries. */
.news__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}

.news__item {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding-top: var(--space-4);
  border-top: var(--hair);
}
.news__item:first-child { padding-top: 0; border-top: 0; }

.news__when {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--text-label);
  color: var(--ink-soft);
}

.news__title {
  margin: 0;
  font-size: var(--text-body);
  font-weight: var(--weight-bold);
}

.news__line {
  margin: 0;
  font-size: var(--text-mono);
  line-height: 1.55;
  color: var(--ink-soft);
}

/* The way in, under "six ways to play". Quiet on purpose: it must never
   compete with PLAY, which is the only thing on that screen that matters. */
.hero__news {
  margin-top: var(--space-2);
  font-size: var(--text-label);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-soft);
}
