/*
 * Shared components: the enamel, and the objects pressed out of it.
 *
 * Games build from these, so a second game inherits the identity rather than
 * reinventing it. Everything here is either a region of material or an object
 * with a rim on it — see `.enamelled` and `.field` in base.css, which are the
 * two primitives the rest of this file is assembled from.
 */

/* ---------------------------------------------------------------- layout */

/*
 * The stage.
 *
 * Every screen is composed to the viewport rather than to its own content. This
 * is the fix for the fault the whole redesign exists for: each screen used to be
 * a flex-column that stopped where its content stopped, leaving 30–60% of an
 * enclosing frame empty — and because the frame *encloses* it, the emptiness
 * was pointed at rather than ignored.
 *
 * ## `min-height`, not `height`, and that is the whole of the change
 *
 * REVISED 2026-08-11. This was `height: 100dvh` — the shell was exactly one
 * viewport, the table inside it a fixed three-row grid, and `main` a scroll
 * container so the rail and the prompt could stay put while the body moved
 * under them. The cabinet. It composed beautifully right up to the moment
 * something inside it did not fit, and then it put a scrollbar on a div.
 *
 * The owner, looking at "Play something else" open inside a room: *"'Play
 * something else' view shouldn't have scrollbars within the page. No page
 * should have div scrollbars."* Measured on that exact screen at 1280×800: the
 * shelf overlay was a 560px box over 1060px of content, and at 960×700 it had a
 * horizontal bar as well — 510px of box over 523px, which is the vertical bar's
 * own width taken out of the row and handed back as sideways overflow. Two bars
 * on an inner box, and the window not moving at all.
 *
 * A floor keeps everything the cabinet was for and gives up only the clamp: a
 * screen with less in it than the glass is still composed to the glass (the
 * table stretches, the foot holds the bottom edge, nothing floats in a tall
 * frame), and a screen with more in it than the glass becomes a page and
 * scrolls the way a page does. Which is not a new model here — it is exactly
 * what the phone block below has done since it was written, and the reason it
 * was written was the same one: a composition that hands the leftover height to
 * one child cannot survive there being none.
 *
 * What it costs is named where it is paid for: `.table-foot` below is sticky
 * now, because "what do I do right now" is the one line that must not be
 * scrollable off a tall page.
 */
.shell {
  max-width: var(--shell-max);
  min-height: 100dvh;
  margin: 0 auto;
  /*
   * The screen's own margins are added to the gutter rather than replacing it.
   * `viewport-fit=cover` (index.html) hands the page the glass under the notch
   * and the home indicator; on a phone held sideways that is a 44px bite out of
   * one edge, and the table's rim is precisely the thing that must not go under
   * it. Zero on everything without a notch, which is every test.
   */
  padding: calc(var(--s4) + var(--safe-t)) calc(var(--gutter) + var(--safe-r))
    calc(var(--s4) + var(--safe-b)) calc(var(--gutter) + var(--safe-l));
  display: flex;
}

.stack {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
}
.stack-sm {
  gap: var(--s2);
}
.stack-lg {
  gap: var(--s6);
}

.row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s3);
  align-items: center;
}

/* ------------------------------------------------------------- the table */

/*
 * The largest enamelled object there is: the whole interface, rimmed.
 *
 * It used to be a hairline box, which read as a drawn edge. A rim reads as a
 * pressed one, and it is what makes the screen a piece of the material rather
 * than a rectangle on a page.
 */
.table {
  flex: 1;
  min-width: 0;
  /*
   * A flex column — rail, body, foot — and not the three-row grid it was.
   *
   * `grid-template-rows: auto minmax(0, 1fr) auto` is a statement that the
   * middle row takes what is left, which is the right shape for a box of fixed
   * height and the wrong one for a box that grows. `flex: 1 0 auto` on the body
   * below says the other half out loud: take what is left, and never less than
   * your own content. The phone block reached the same two lines first.
   */
  display: flex;
  flex-direction: column;
  min-height: 0;
  /*
   * The table keeps `--table`. What changed is the page underneath it, which is
   * now `--table-deep` (base.css) — so the table is the brighter of the two and
   * reads as a lit surface rather than as a shape cut out of the same sheet.
   *
   * The old note here said the table takes the same enamel as the ground and
   * that separation is the rim's job, "because darkening the table instead put
   * it at exactly the value of the card back, and a card is meant to read as an
   * object on a surface rather than a hole in one". That finding stands and is
   * the reason this did NOT move the table down: it moved the page down and
   * left the table where a card expects to find it.
   *
   * **The speckle stays off it, deliberately.** When the page darkened, the
   * grain was added here too, on the reasoning that rule 1 wants the surface
   * somebody looks at to be a material. Erik's call, and it is the better one:
   * the dots belong to the page and the table is a clean sheet laid on it. The
   * texture then reads as what is *behind* the product rather than as noise
   * across everything printed on it — and the objects that do want a material
   * (a lid, the beats band, a field) still ask for the grain themselves.
   *
   * An opaque `background-color` with no image is what does that: `body` paints
   * the grain, and this covers it wherever the table lies.
   */
  background-color: var(--table);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-field);
  box-shadow: var(--glaze-line), var(--lift-sm);
  /*
   * The rim is a radius, so anything reaching a corner has to be clipped by
   * it — otherwise a full-bleed field squares off the corner it sits in.
   *
   * `clip` and not `hidden`, for the reason the phone block already gives: an
   * `overflow: hidden` box IS a scroll container, and a `position: sticky`
   * child then pins itself to a scrollport that never moves — which is a foot
   * that does not stick, silently. `clip` never creates one.
   */
  overflow: clip;
}

/*
 * ------------------------------------------- WHAT STANDS ON THE TABLE
 *
 * EVERY RAISED SURFACE CARRIES ITS OWN CUT OF THE QUIET INK.
 *
 * `--dim` is a contrast against `--table`, and a `--panel` box is not `--table`
 * — see `--dim-lift` in tokens.css, which exists for exactly this and says so.
 * By daylight a panel is lighter than the ground, so dark type on it only
 * improves and `--dim-lift` is the same value. In the evening the panel is the
 * lighter of the two and the same ink loses a third of its contrast.
 *
 * ## Proven red, in the lobby, in ten palettes out of thirteen
 *
 * `.game-card` on the front door carried this line already and nothing else
 * did, so the fault it was written for was still live one screen further in —
 * and `portal-legible.spec.ts` could not see it, because not one of the
 * elements below is on the front door.
 *
 * Photographed in a lobby at 1440×900, both lights, all thirteen palettes.
 * These are the worst of each line, and every one of them is in the evening;
 * by daylight a panel is the lighter surface and the same ink only gains:
 *
 *     "Chairs still to fill before…"     3.84:1  (the lobby field)
 *     the join link under the code       3.85:1  (the invitation slab)
 *     "Read this out on the call"        3.87:1  (the invitation slab)
 *     a box's blurb in the room's picker 3.90:1
 *     a palette's note in the picker     3.90:1  (the palette menu)
 *
 * against a floor of 4.5, and each failed in ten of the thirteen palettes —
 * every one except marin, natt and bläck, whose grounds are dark enough that
 * 64% of pale clears it anyway. A hundred failing cells in all. With this rule
 * the worst pairing is 4.97:1, by arithmetic in every palette.
 *
 * ONE SELECTOR LIST RATHER THAN A LINE PER COMPONENT, because a line per
 * component is what produced the fault: the token's own note in tokens.css
 * promises that "anything printed inside a box is correct without every future
 * component having to remember which ground it is standing on", and that is
 * only true if there is one place to add a box to. A surface whose background
 * is `--panel` or `--panel-soft` belongs in this list; nothing else does.
 *
 * The shell's surfaces are here. A game's own fields are its own file's — see
 * the same rule at the head of `games/spyfall/spyfall.css` — because a game
 * that owns its markup owns the ground under it.
 */
.game-card,
.shelf-item,
.share--open,
.picker-menu {
  --dim: var(--dim-lift);
}

/* The rail across the top: wordmark, which room this is, and how long left. */
.rail {
  display: flex;
  align-items: center;
  gap: var(--s5);
  flex-wrap: wrap;
  padding: var(--s3) var(--s5);
  border-bottom: var(--hair);
}

/*
 * THE DOORS: THE WHOLE OF THIS PRODUCT'S PERSISTENT NAVIGATION, AND THERE ARE
 * TWO OF THEM.
 *
 * The competition runs a four-tab bar along the bottom edge of the phone. Two
 * of those four tabs are a ladder and an account, neither of which exists here,
 * and a bar that is two-thirds empty is not navigation — it is a list of what
 * is missing, printed on every screen. What this product has outside a room is
 * a shelf and a puzzle, so this carries a shelf and a puzzle.
 *
 * Along the top because the bottom edge is already spoken for: the prompt sits
 * there, `chrome.js` publishes its height as `--shell-foot` so a game can pin
 * its own controls above it, and an immersive game takes the whole glass. The
 * rail is the one strip that is the shell's alone on every screen — and it is
 * the strip the wordmark has been the first door in since the beginning.
 *
 * `.wordmark` keeps every rule it had; all that changed is that it is inside a
 * `<nav>` with the second door, so "where am I" is one mechanism rather than a
 * link that happens to go home. The argument is in `docs/interaction-parity.md`
 * and `shell/doors.js`.
 */
.doors {
  display: flex;
  align-items: baseline;
  gap: var(--s4);
  min-width: 0;
}

.door {
  text-decoration: none;
  border: 0;
}

.wordmark {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1rem;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  color: var(--dim);
  text-decoration: none;
  border: 0;
}

@media (hover: hover) {
  .wordmark:hover {
    color: var(--on-table-strong);
  }
}

/* A door set as data, beside a door set as the wordmark. Mono caps rather than
   display type, because it is a destination and not an identity — the
   difference in typeface is what stops the rail reading as two products. */
.door--word {
  display: inline-flex;
  align-items: center;
  gap: var(--s1);
  color: var(--dim);
  transition: color var(--fast) var(--ease);
}

@media (hover: hover) {
  .door--word:hover {
    color: var(--on-table-strong);
  }
}

/*
 * Where you are. Said by weight and ink rather than by a rule under the word:
 * the rail already has a hairline along its own bottom edge, and a second one
 * four pixels above it reads as a mistake.
 */
.door[data-here="true"],
.wordmark[data-here="true"] {
  color: var(--on-table-strong);
  font-weight: 600;
}

/*
 * The mark that says today's puzzle has not been looked at.
 *
 * `--flourish` and deliberately neither accent ink. `--stamp` means a person
 * being singled out and `--agree` means the table agreeing; the connection pill
 * is already the one exception to that, taken knowingly, and a second one for a
 * six-pixel disc would be the point at which the rule stopped meaning anything.
 * The flourish is cut from the palette's own accent — it is the front door's
 * full stop — so this is a different colour in all twelve rooms and there is no
 * hex for anybody to keep in step.
 *
 * It is `aria-hidden` and a sentence beside it carries the same fact, because
 * nothing in this interface may be a colour alone.
 */
.door-dot {
  width: 0.4rem;
  height: 0.4rem;
  border-radius: var(--r-pill);
  background-color: var(--flourish);
  /* Off the baseline the mono caps sit on, so it reads as a mark against the
     word rather than as a full stop after it. */
  align-self: center;
}

.rail-code {
  display: flex;
  align-items: baseline;
  gap: var(--s2);
}

/*
 * In the rail the code is a reference, not the thing you are being asked to
 * read out — so it is set as plain data. The slab in the invitation is the one
 * printed on stock, and the two never show at once.
 */
.rail-code .code {
  background: none;
  /* No rim either: an object in the rail would read as something to press,
     and this one is a reference you glance at. */
  border: 0;
  box-shadow: none;
  padding: 0;
  /*
   * AND THE MARGIN THAT WAS COMPENSATING FOR THAT PADDING.
   *
   * `.code` carries `margin-inline-start: calc(var(--s3) * -1)`, and it is
   * correct where it was written: the invitation slab has
   * `padding: var(--s2) var(--s3) var(--s3)`, and the negative margin cancels
   * the left half so six characters of display type sit flush with the column
   * edge rather than indented by their own padding.
   *
   * The line above removes that padding. The compensation was left behind, and
   * a compensation for something that is gone is just an error: -12px against
   * this container's `gap: var(--s2)` of 8px nets -4px, so the code's box
   * starts four pixels INSIDE its own "Room" label. Measured at 1280, 390 and
   * 360 — the same -4 at every width, which is what an arithmetic fault looks
   * like next to a layout one.
   *
   * Ten games reported it, in the shape it reads on screen: "ROOMNYQG4E".
   *
   * NOTE, and it is a real one: at 390 this correctly restores 8px and the rail
   * then WRAPS — the code goes to a second line. The rail is over-full at that
   * width and the negative margin had been hiding twelve pixels of that. This
   * commit does not fix the crowding; it stops the header lying about it.
   */
  margin-inline-start: 0;
  font-size: 1rem;
  letter-spacing: 0.08em;
  color: var(--on-table);
}

/*
 * AND THE POOL OF LIGHT GOES OUT IN THE RAIL TOO — 2026-09-08.
 *
 * `.code::before` is the lamp-lit pool behind the invitation's read-aloud slab.
 * The lobby sheds it when the code steps back to a reference chip
 * (`.share:not(.share--open) .code::before`) and so does the immersive pill
 * (`.roompill-head .code::before`), for the reason written there: behind a
 * fact you merely glance at, a glowing serif is the room shouting. The rail is
 * that same reference — it says so above, "a reference you glance at" — and it
 * reset the slab's background, rim, shadow and padding but was never told to
 * put the light out. So the header carried a warm smudge offset to one side of
 * the room code at every width. Same decision as the other two, said a third
 * time because the pool is on the base `.code` and every plain-data context
 * has to name itself out of it.
 */
.rail-code .code::before {
  display: none;
}

.rail-end {
  margin-left: auto;
  display: flex;
  align-items: baseline;
  gap: var(--s4);
}

/*
 * Connection state. Absent entirely off-table.
 *
 * The dot carries the state and the word repeats it, rather than the word
 * carrying it alone — a status light is the thing people actually glance at,
 * and colour on its own would fail anybody who cannot separate the hues.
 *
 * This is the one place the two accent inks do a third job. They are the
 * game's colours everywhere else — `--stamp` marks a person being singled out,
 * `--agree` marks the table agreeing — and the pill is platform chrome sitting
 * outside that language, in the rail, next to the clock. It is a deliberate
 * exception rather than a leak: the alternative was a third hue for a six-pixel
 * square that is invisible when everything is fine.
 */
/*
 * `--dim` and not `--dimmer`, and the word is why.
 *
 * `--dimmer` is the tone for a line and a placeholder, not for type — see the
 * note beside it in tokens.css. This word is eleven-pixel mono tracked to
 * 0.2em, which is small text and owes 4.5:1, and on `--dimmer` it never once
 * reached it: photographed in a lobby in all thirteen palettes and both
 * lights, the worst was **2.74:1** (citron, daylight — rgb(140 142 82) on the
 * rgb(236 234 146) actually painted under it) and the best **3.63:1** (natt,
 * daylight). Twenty-six cells, twenty-six failures, and the same twenty-six in
 * each of the three states a lobby has. On `--dim` the worst is 5.16:1.
 *
 * The dot keeps its own colour, so the state is still carried by the light
 * somebody actually glances at rather than by how loud the word is.
 */
.conn {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  font-family: var(--font-mono);
  font-size: var(--t-data-sm);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--dim);
}

.conn::before {
  content: "";
  width: 6px;
  height: 6px;
  background: var(--conn-dot, currentColor);
}

/* Connected, over the transport we wanted. */
.conn[data-state="ws"] {
  --conn-dot: var(--agree);
}

/*
 * Connected, over the fallback. Not a fault — the round plays normally — so it
 * is neither green nor red. Stock says "working"; the word beside it says how.
 */
.conn[data-state="sse"] {
  --conn-dot: var(--on-table);
}

.conn[data-state="lost"] {
  --conn-dot: var(--stamp);
  color: var(--stamp);
}

/* ------------------------------------------------------------- the lamp */

/*
 * The light in the room. Last in the rail, past everything that changes during
 * a round, because it is a setting rather than a game state.
 *
 * Not a `.btn`: a slab with a shadow next to the clock would compete with the
 * controls that actually do something to the round. It is a quiet glyph that
 * warms on hover — the smallest thing that can still be found.
 */
.lamp {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  padding: 0;
  background: none;
  border: 0;
  border-radius: var(--r-control);
  color: var(--dim);
  cursor: pointer;
  /*
   * The pressable region is `--tap-min` and the DRAWN object stays 2rem, which
   * is the whole point of doing it this way round.
   *
   * Measured at 390×844 and at 360×640: both of these were 32×32 — the two
   * controls in the rail that are on every page of the product, twelve pixels
   * under the floor `.btn` has held since block controls got one. On a phone
   * that is a thumb landing between the light and the swatch and getting
   * neither.
   *
   * It is NOT fixed by growing the box, and the reason is the rail. These sit
   * in `.rail-end` on `align-items: baseline` beside the clock and the
   * connection pill; a 44px lamp makes the rail 12px taller on every screen in
   * the product, in a composition where a game may be doing arithmetic against
   * the chrome's height. `47615c6` is what that costs when it is guessed at:
   * one shared control's floor took four assertions red in The Belt because in
   * an immersive composition a control's height IS the camera's arithmetic.
   *
   * So the hit area is an overlay rather than a size. `::after` is an absolutely
   * positioned 44×44 box centred on a 32×32 button: it contributes nothing to
   * layout, nothing to the rail's height, nothing to any measurement anybody
   * has ever taken of this interface — and a finger anywhere inside it hits the
   * control, because a pseudo-element's hits belong to its originating element.
   *
   * The glyph keeps `position: relative` so the overlay is measured against the
   * lamp and not against whatever ancestor happens to be positioned.
   */
  position: relative;
  transition: color var(--fast) var(--ease), background-color var(--fast) var(--ease),
    transform var(--press) var(--ease);
}

.lamp::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--tap-min);
  height: var(--tap-min);
  transform: translate(-50%, -50%);
}

@media (hover: hover) {
  .lamp:hover {
    color: var(--on-table-strong);
    background: var(--panel);
  }
}

.lamp:active {
  transform: translateY(1px);
}

/* -------------------------------------------------------- the palette picker */

/*
 * What the room is made of, beside the switch for the light in it.
 *
 * Two controls because they are two questions. The lamp is a glyph; this is a
 * swatch, because the thing it changes is a colour and a word for a colour is
 * no use until you have seen it.
 */
.picker {
  position: relative;
  display: inline-flex;
}

.lamp--swatch {
  width: auto;
  padding: 0 var(--s1);
}

.swatch {
  display: inline-flex;
  width: 1.5rem;
  height: 1.5rem;
  border-radius: var(--r-chip);
  border: var(--rim-hair) solid var(--rim);
  overflow: hidden;
  flex: none;
}

/*
 * Unequal on purpose: a ground is most of what you see in a room, so the two
 * grounds are most of the swatch. Equal stripes made the palettes look like a
 * row of flags and told you nothing about which one you would be sitting in.
 */
.swatch-deep {
  flex: 4;
}

.swatch-day {
  flex: 3;
  background: var(--light-ground);
}

.swatch-pale {
  flex: 2;
}

.swatch-accent {
  flex: 2;
}

/* In the menu they are for comparing, not for identifying, so they get room. */
.picker-item .swatch {
  width: 3.25rem;
  height: 2rem;
}

/*
 * The three colours that actually distinguish one palette from another: the
 * ground, the material on it, and the accent. They resolve against the `pal-*`
 * class on the swatch itself, so this shows a palette the app is not wearing.
 */
.swatch-deep {
  background: var(--deep);
}

.swatch-pale {
  background: var(--pale);
}

.swatch-accent {
  background: var(--accent);
}

.picker-menu {
  position: absolute;
  top: calc(100% + var(--s2));
  right: 0;
  z-index: 40;
  min-width: 18.5rem;
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  padding: var(--s3);
  background: var(--panel);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-panel);
  box-shadow: var(--glaze-line), var(--lift);
  /* Twelve of them will not fit a short laptop, and a menu taller than the
     window is a menu with options you cannot reach. */
  max-height: min(72vh, 36rem);
  overflow-y: auto;
}

/*
 * `--dim`, for the reason `.conn` gives: `--dimmer` carries no words.
 *
 * The picker is the one screen in the product whose whole job is to be looked
 * at in every palette, and it is drawn in the palette you are *leaving* rather
 * than the one you are choosing — so its own labels failing is the failure
 * that makes every other palette harder to reach. They were the worst type in
 * the lobby: "What the room is made of" measured **2.24:1** (tegel, evening)
 * at the bottom and 3.81:1 (natt, daylight) at the top, and failed in all
 * twenty-six cells. The menu is a `--panel` box and takes `--dim-lift` with
 * the rest of the raised surfaces, so this resolves to the lifted cut: worst
 * 4.97:1.
 */
.picker-head {
  color: var(--dim);
  padding: 0 var(--s2) var(--s2);
}

/* Grouped by character rather than hue, because that is what actually differs.
   The heading earns its line by saying what the group *is*. */
.picker-group {
  display: flex;
  align-items: baseline;
  gap: var(--s2);
  padding: var(--s3) var(--s2) var(--s1);
  border-top: var(--hair);
  margin-top: var(--s2);
}

.picker-group:first-of-type {
  border-top: 0;
  margin-top: 0;
  padding-top: 0;
}

.picker-group-name {
  color: var(--on-table-strong);
}

.picker-group-note {
  font-size: var(--t-data-sm);
  /* Same measurement as `.picker-head` above, to two decimals: 2.24:1 at its
     worst on `--dimmer` and 3.79:1 at its best, failing in all twenty-six
     cells. This is the line that says what a group of palettes IS. */
  color: var(--dim);
}

.picker-item {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s2);
  background: none;
  border: 0;
  border-radius: var(--r-chip);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background-color var(--fast) var(--ease);
}

@media (hover: hover) {
  .picker-item:hover {
    background: var(--panel-soft);
  }
}

/*
 * WHAT A CONTROL OWES A FINGER, NOW THAT ITS HOVER IS BEHIND A GUARD.
 *
 * This is the second half of the `hover: hover` sweep and the half that is a
 * change rather than a correction. Wrapping a hover rule takes a wrong answer
 * away from a thumb — the fill that arrived on tap and then sat there — and on
 * a control whose ONLY pointer feedback was that fill it leaves no answer at
 * all, which is a worse screen than the one we started with.
 *
 * `.btn` never had the problem: it has had `:active` since it was written, and
 * most of the shelf composes against it. Eighteen bespoke controls did, and
 * they are listed in `docs/catalogue-gap.md`. Each one takes the same shape,
 * and the shape is the house rule `.btn:active` already states — **a press is
 * the hover carried one step further, and then the object goes down into its
 * own dent.** A control that changes fill on hover presses to a deeper fill; a
 * control that changes ink presses to the ink it was heading for. The
 * `translateY(1px)` is the part that is common to all of them, because that is
 * the movement this design uses to mean *pushed*, and it is the only half a
 * finger can see under itself.
 */
.picker-item:active {
  background: var(--panel-soft);
  transform: translateY(1px);
}

/* The one you are wearing. A tick would need a column of its own; the swatch
   growing a ring says it in the place you are already looking. */
.picker-item[aria-pressed="true"] .swatch {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.picker-text {
  display: flex;
  flex-direction: column;
  line-height: 1.25;
}

.picker-name {
  color: var(--on-table-strong);
  font-weight: 600;
}

.picker-note {
  font-size: var(--t-small);
  color: var(--dim);
}

/* ----------------------------------------------------------------- clock */

/*
 * The countdown is data, so it is set as data: mono, tracked, tabular. The
 * label carries the framing — "Questions end in" is a horizon, where a bare
 * 07:42 is a fuse — and the whole thing is chrome-sized, not hero-sized.
 */
.clock {
  display: flex;
  align-items: baseline;
  gap: var(--s3);
  white-space: nowrap;
  /*
   * For the bar, which is laid across the foot of the whole clock — label and
   * digits together — rather than under the digits alone. The digits are two
   * to five characters wide depending on the phase, so a bar the width of the
   * number would change length every time the minutes rolled over.
   */
  position: relative;
  padding-block-end: 6px;
}

/*
 * THE COUNTDOWN, DRAWN.
 *
 * A rule under the clock that shortens as the phase runs out. It says nothing
 * the digits above it do not; what it adds is that you can read it without
 * reading — the position this product is played in is a phone on a desk at
 * arm's length, which is the same argument that put the buzzer in
 * `shell/chrome.js`.
 *
 * `--clock-left` is written by `paintBar()` twice a second and is the fraction
 * of the phase remaining. Only ever a scale on a full-width element: animating
 * `width` would lay the strip out four times a second, and this sits inside
 * the one strip on the screen that four sweeps measure.
 *
 * Absolute, so it costs the row no width at 390px — the 6px above is the only
 * space it takes, and `docs/design.md`'s note about the clock being
 * chrome-sized rather than hero-sized is why it is 2px of rule and not a
 * progress bar with a trough.
 */
.clock-bar {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  height: 2px;
  border-radius: 1px;
  /*
   * THE TRACK IS DRAWN, SO THE FILL READS AS A GAUGE AND NOT AN UNDERLINE.
   *
   * It was `--rule`, the faintest divider ink, and on the baize that is very
   * nearly nothing: the only thing you saw was the `--stamp` fill, which
   * covers the LABEL end first and drains from the digits back — so for most
   * of a phase a bright line sat under the words "Baton passes in" and stopped,
   * and read as the label being underlined rather than as a clock draining. A
   * link's underline, or a spelling mark, on a line that is neither.
   *
   * With the whole track visible the same fill reads correctly: a rule under
   * the clock, part of it spent. `--rule-strong` is the divider ink one step
   * up, present enough to trace the empty end without competing with the
   * digits above it. The fill stays `--stamp` — the documented "on the clock"
   * ink below — because now it is plainly the filled part of a gauge and not a
   * mark under a word. Flagged on Anagram Relay's foot, 2026-09-08.
   */
  background: var(--rule-strong);
  overflow: hidden;
}

.clock-bar::after {
  content: "";
  display: block;
  height: 100%;
  /*
   * `--stamp` is the ink for a person being singled out — accused, or on the
   * clock — and `chrome.js` records the rule beside its own use of it. This is
   * the second of those two and wants no new token.
   */
  background: var(--stamp);
  transform: scaleX(var(--clock-left, 1));
  transform-origin: left center;
  /*
   * Exactly the tick, so four readings a second read as one movement. The
   * value is correct at every tick with or without this, which is why
   * `tokens.css` collapsing it under `prefers-reduced-motion` costs nothing —
   * see `paintBar()` on why the drain is NOT a CSS animation.
   */
  transition: transform 500ms linear;
}

/*
 * The first paint of a new phase goes from almost-nothing to everything, and
 * animating that draws a bar filling up. Set for one paint by `setDeadline`.
 */
.clock[data-fresh="true"] .clock-bar::after {
  transition: none;
}

.clock-label {
  font-size: var(--t-small);
  /* Balanced when it must wrap: "Drawing · 1 of / 5" became "Drawing · / 1 of 5"
     on the phone walk of 2026-09-07. Engines without `text-wrap: balance`
     keep the old break, which is a wrap either way. */
  text-wrap: balance;
  color: var(--dim);
}

.clock-time {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: var(--t-clock);
  letter-spacing: 0.08em;
  line-height: 1;
  color: var(--on-table-strong);
  font-variant-numeric: tabular-nums;
}

/* Nothing is timed: the phase name stays, the digits go. */
.clock[data-idle="true"] .clock-time {
  display: none;
}

/*
 * AND IN A LOBBY THE WHOLE IDLE CLOCK GOES — 2026-09-08.
 *
 * A lobby clock has no deadline to draw: it shows only a phase name, 'Sitting
 * down' on Anagram Relay, and sits to the left of the prompt on the empty
 * green of a table nobody has started. The phone layout already dropped it in
 * a lobby (see the max-width block below); the desk kept it, one more orphan
 * in the dead space this screen was flagged for. Dropped at every width now,
 * so the lobby's strip is the one thing worth saying — the prompt — and not a
 * clock reading nothing beside it. Once a game is dealt the clock is no longer
 * idle and this never applies; a finished game is not a lobby, so 'Done' still
 * shows where it should.
 */
:root[data-lobby="true"] .clock[data-idle="true"] {
  display: none;
}

/*
 * AND THE EMPTY WRAPPER GOES WITH IT, SO THE PROMPT STARTS AT THE EDGE.
 *
 * Hiding the clock above left `.stage-status-clock` behind — a flex item that
 * still carried `padding-right` reserving room for the reaction key (which is
 * itself hidden in a lobby). So the strip opened with a ~52px dead column and
 * the prompt began indented from the panel's own left edge, a gap with nothing
 * in it. Collapsing the wrapper in a lobby lets the prompt sit flush left where
 * the rest of the game's copy does. Scoped to the strip's own clock
 * (`.stage-status > .stage-status-clock`), so the immersive pill — which adopts
 * its clock out of this strip — is untouched; and a lobby strip's clock is
 * always the idle one hidden just above, so there is nothing here to lose.
 */
:root[data-lobby="true"] .stage-status > .stage-status-clock {
  display: none;
}

/* --------------------------------------------------------------- prompt */

/*
 * Rendered by the shell for every game, so "what do I do right now" is always
 * in the same place at the same size.
 *
 * ...AND THAT SIZE IS NOW A RANK OF ITS OWN.
 *
 * It was `--t-lead`, which is 20px, which is also `--t-name` — so the sentence
 * telling you what to do was set at the same size as a colleague's name in the
 * column beside it, and only 1.2x the paragraph explaining the rules. Every
 * game screen in the shelf therefore had a flat middle: three or four ranks of
 * information all within half a step of each other, with nothing claiming to
 * be read first. Table Party sets the same sentence at 34px above the controls
 * it refers to and the difference on screen is not subtle.
 *
 * `--t-say` is the step that was missing. The display face rather than the
 * body one for the same reason a heading is: this is the shell speaking, not
 * prose. Weight stays at 500 — it is already the largest thing in the foot and
 * bold on top of that reads as shouting rather than as rank.
 */
.prompt {
  font-family: var(--font-display);
  font-size: var(--t-say);
  font-weight: 500;
  letter-spacing: var(--track-display);
  line-height: 1.25;
  display: flex;
  align-items: baseline;
  gap: var(--s3);
  color: var(--on-table-strong);
  min-height: 1.9em;
}

/*
 * The one place `--stamp` appears outside an accusation: it marks the seat
 * being singled out, and "it is your turn" is that, pointed at you.
 *
 * Scaled with the type rather than pinned at 9px: it is a bullet against a
 * sentence, and a fixed square beside a clamped line looks like a dot at one
 * end of the range and a block at the other.
 */
.prompt[data-tone="act"]::before {
  content: "";
  flex: none;
  width: 0.42em;
  height: 0.42em;
  background: var(--stamp);
  transform: translateY(-0.15em);
}

.prompt[data-tone="wait"] {
  color: var(--dim);
}

/* --------------------------------------------------------- printed matter */

/* A glazed panel: the enamel dipped white instead of blue. */
.sheet {
  background-color: var(--stock);
  background-image: var(--ruling);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-panel);
  box-shadow: var(--glaze-line), var(--lift-sm);
  padding: var(--s5);
  color: var(--ink);
}

.sheet h1,
.sheet h2,
.sheet h3 {
  color: var(--ink);
}

/* A raised region of enamel, pressed out of the ground rather than laid on it. */
.panel {
  background: var(--panel);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-panel);
  box-shadow: var(--glaze-line);
  padding: var(--s5);
}

/* A mono fact in a small pressed tab: "15 LOCATIONS", "8 MINUTES". */
.chip {
  font-family: var(--font-mono);
  font-size: var(--t-data-sm);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--on-table);
  background: var(--panel);
  border: var(--rim-hair) solid var(--rim);
  border-radius: var(--r-chip);
  box-shadow: var(--glaze-line-dark);
  padding: var(--s2) var(--s3);
  white-space: nowrap;
}

/*
 * A chip in a wrapping row wraps its own words before it leaves the panel.
 * "Anything two of you wrote is worth nothing" is 360px in this face and the
 * lobby's row is 300px at 360 — it ran off the right edge as "…WORTH NOT"
 * (2026-09-07). Short chips are unaffected: a chip only breaks when it must.
 */
.row > .chip {
  white-space: normal;
  max-width: 100%;
  text-align: left;
}

/* ------------------------------------------------------------- initials */

/*
 * People are initials in a mono block, not photographs and not coloured
 * circles. It is an enamelled name tag, and it stays legible at 26px.
 *
 * Rimmed like everything else, and the same object in both lights — a name tag
 * belongs to the person, not to the room.
 */
.avatar {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  background: var(--cardback);
  color: var(--cardback-ink);
  border: var(--rim-hair) solid var(--rim);
  border-radius: var(--r-chip);
  box-shadow: var(--glaze-line-dark);
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: var(--t-data);
  letter-spacing: 0.04em;
}

.avatar--lg {
  width: 68px;
  height: 68px;
  border-radius: var(--r-control);
  font-size: 1.375rem;
}

.avatar--sm {
  width: 32px;
  height: 32px;
  font-size: 0.75rem;
}

/* On stock, the block inverts: dark ink on the paper. */
.avatar--print {
  background: var(--ink);
  color: var(--stock);
}

/* Singled out. The spy at the reveal, the seat you accused. `--on-stamp`
   rather than `--stock`, because the stamp is a pale tint at night and pale
   initials on it would be unreadable. */
.avatar--stamp {
  background: var(--stamp);
  color: var(--on-stamp);
}

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

/*
 * Three controls. The primary one is a piece of stock with ink on it, which is
 * the strongest thing on a dark table without borrowing a colour that means
 * something else. The default is a raised keyline block; the quiet one is a
 * rule under text.
 *
 * **A button has to be a thing you can push.** The first version of these was a
 * transparent box with a 1px `--rule-strong` keyline on a near-black ground —
 * barely visible at rest, left-aligned like a panel, and with no `:active` at
 * all, so pushing one did nothing whatsoever until the screen changed. It read
 * as a label with a hover.
 *
 * So: every control sits *on* the table with a contact shadow, and every
 * control depresses — down a pixel, shadow collapsed. That is the same physical
 * story the cards and the slips already tell, and it is the whole difference
 * between an affordance and a rectangle.
 */
/*
 * A block button is a pressed-enamel object: a fill inside a rim, with a line
 * of glaze along the top edge where the light catches it.
 *
 * The rim replaces the borderless slab the previous identity used. There, an
 * edge came from the contact shadow alone, which meant a button on a field of
 * its own tone had no edge at all.
 *
 * ## SET IN THE BODY FACE, IN SENTENCE CASE, AND IT WAS MONO CAPS
 *
 * The argument that was here: *"Set in mono, because that is already this
 * interface's voice for a machine fact and a button label is an instruction to
 * the machine — every control therefore reads as a control before it is read as
 * words."*
 *
 * The premise is the part that fails. A button label is not an instruction to
 * the machine; it is the machine telling a person what will happen if they
 * press it, which is the most human sentence on the screen. "Copy link", "See
 * it played", "Show this room on a big screen", "Deal Word Tiles" — every one
 * of those is written to be read by somebody, and the house set all of them in
 * a face that removes word-shape.
 *
 * `docs/design-verdict.md` §1 measured the cost — eleven mono-caps labels on
 * one lobby, five of them controls — and §4 measured what it did to the one
 * that matters: *"Ours is a full-width black bar with mono-caps text… That is
 * the shape of a page footer, and it is the least inviting rectangle on the
 * screen. Theirs is a pill with an orchid gradient, generous padding, sentence
 * case, and a name in it."*
 *
 * The half of the old argument that survives is that a control should read as a
 * control before it is read as words, and it still does — the rim, the fill,
 * the glaze line and the depress all say "object" before a letter is read, and
 * those were always doing that work. Typeface was never what made a button look
 * like a button; it was only what made it sound like a terminal.
 *
 * `--t-small` and not `--t-data`: 15px proportional is the same apparent size
 * as 13px tracked caps, so nothing in any layout moved. Measured on the shelf,
 * not assumed — sentence case is the narrower of the two at equal apparent
 * size, so every control that fitted still fits.
 *
 * `.btn--quiet` below is unchanged and always was in the body face, which is
 * the tell that this file already knew: *"it is a sentence you can click…
 * setting it in tracked mono caps would make an aside shout."* That was true of
 * the aside and it was true of everything beside it.
 */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s2);
  padding: var(--s3) var(--s5);
  /* A finger is about 9mm. 13px mono in 12px of padding came out at 41px,
     which is under every published floor and under the height of the chips
     and slips it sits beside. The 44px itself is `--tap-min` in `tokens.css`,
     which argues it once for everything that wants it — this value did not
     change when it was given a name. */
  min-height: var(--tap-min);
  background: var(--control);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-control);
  color: var(--on-control);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: var(--t-small);
  letter-spacing: normal;
  text-transform: none;
  text-align: center;
  cursor: pointer;
  box-shadow: var(--glaze-line), var(--lift-xs);
  transition: color var(--fast) var(--ease), background-color var(--fast) var(--ease),
    transform var(--press) var(--ease), box-shadow var(--press) var(--ease);
}

@media (hover: hover) {
  .btn:hover {
    background: var(--control-hover);
  }
}

/* Pushed: it moves, the light under it goes, and the glaze inverts to the
   shadow an object sitting *in* its own dent would cast. */
.btn:active {
  background: var(--control-press);
  transform: translateY(1px);
  box-shadow: inset 0 2px 4px 0 rgb(8 22 29 / 0.3);
}

.btn[disabled] {
  cursor: not-allowed;
  opacity: 0.5;
  box-shadow: none;
  transform: none;
}

/*
 * The strongest thing on the table, whichever way up the room is.
 *
 * "Strongest" is furthest from the ground, so it flips: a piece of stock with
 * ink on it in the evening, a slab of ink by daylight. Pinning it to stock in
 * both left the light theme with a primary that receded into the table while
 * every ordinary button advanced past it — the hierarchy exactly backwards.
 */
/*
 * ...AND IT IS ALLOWED TO BE BIGGER THAN THE THINGS AROUND IT.
 *
 * This variant used to change four properties — fill, ink, weight, shadow —
 * and not one of them was size. So the primary was exactly as large as the
 * "Add a bot" beside it and exactly as large as the numbered chips it was
 * asking you to confirm, and it depended entirely on the fill to say which
 * one mattered. Measured against Table Party's equivalent screen, whose
 * primary is twice the WIDTH of its secondary and a full type step above it:
 * a difference in fill alone is a difference you have to look for, and a
 * difference in size is one you cannot miss.
 *
 * Three signals now, and they compound: darker, heavier, larger.
 */
/*
 * ...AND IT IS A PILL IN THE ROOM'S OWN LIGHT, NOT A BAR ACROSS THE FOOT.
 *
 * `docs/design-verdict.md` §4, in full, because every clause of it is a
 * declaration below:
 *
 * > Ours is a full-width black bar with mono-caps text, at the bottom of a long
 * > scroll. **That is the shape of a page footer, and it is the least inviting
 * > rectangle on the screen.** Theirs is a pill with an orchid gradient,
 * > generous padding, sentence case, and a name in it.
 *
 * Four faults and four answers:
 *
 *   **Mono caps** is fixed in `.btn` above, for the whole product.
 *
 *   **The shape** is a `max-width`, and a corner rather than a pill since
 *   2026-09-23 — the owner's "I don't like the round shapes that much", said
 *   of a redesign that had made every control a pill; `--r-control` is the
 *   house's own corner, the one `.btn` has always had. The width is the fault that
 *   makes it read as furniture: a control the width of the page is a bar, and a
 *   bar at the foot of a page is a footer whatever is written in it. Capped at
 *   26rem it is an object sitting on the table at a desk, and on a phone —
 *   where the column is narrower than the cap — `flex: 1` still gives it the
 *   whole width, which is right, because there a full-width control is a thumb
 *   target rather than a footer.
 *
 *   **The type** takes `--t-action`, which is now a step ABOVE the body copy
 *   explaining it rather than two steps below. `primary-visible.spec.ts` holds
 *   that rank against an ordinary control.
 *
 *   **The light.** Rule 5 of the tone is *one primary, in the game's own
 *   light*, and `--lit` is what the game supplies. It arrives as the glaze line
 *   along the top edge and as the halo underneath — the light FALLING on the
 *   object — and deliberately NOT as the fill.
 *
 * ## Why the light is on the primary and not in it
 *
 * The obvious reading of "in the game's own light" is to tint the fill, and it
 * was measured before it was rejected. `--primary`/`--on-primary` is a pair
 * this house guarantees: it is the flip at the heart of rule 4 in `tokens.css`
 * and `primary-visible.spec.ts` asserts 4.5:1 on it. Mixing an arbitrary game
 * ink into that fill is thirty-seven inks × fourteen palettes × two lights =
 * 1036 pairs, of which the failing ones would be a dark ink under a dark label
 * in one palette nobody opens — exactly the shape of fault that ships.
 *
 * A halo cannot fail that way. It is a shadow, so it touches no contrast at
 * all, and it is the more literal reading of the rule besides: a lamp does not
 * repaint the thing it shines on, it lights it. Word Tiles' primary sits in
 * firelight, Minesweeper's in the glow of a screen, and the label reads at
 * exactly the ratio it was measured at in both.
 *
 * `--glow` is themed: a bloom in the evening, a warm contact shadow by day,
 * because a bright object on a bright ground does not glow. Same token, one
 * declaration.
 *
 * NO GRADIENT, and that is a constraint rather than a preference. The
 * competitor's primary is an orchid gradient and ours cannot be one:
 * `primary-visible.spec.ts` asserts `background-image: none`, because the fault
 * it was written for was a gradient painted over this exact control until the
 * label was cream on cream. The test is right and the gradient is the price.
 */
.btn--primary {
  padding: var(--s4) var(--s6);
  min-height: 3.25rem;
  /*
   * 26rem, measured at the longest label on the shelf: "End this round and pick
   * another game" is 30 characters and sets to ~24rem at `--t-action`, so the
   * cap is the widest primary plus a thumb's worth of padding rather than a
   * round number. Wider than that and the control is a bar again.
   */
  max-width: 26rem;
  background: var(--primary);
  color: var(--on-primary);
  border-radius: var(--r-control);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--t-action);
  letter-spacing: var(--track-display);
  box-shadow: var(--glaze-line), var(--lift-sm), var(--glow);
}

@media (hover: hover) {
  .btn--primary:hover {
    background: var(--primary);
    color: var(--on-primary);
    filter: brightness(1.1);
  }
}

.btn--primary:active {
  filter: brightness(0.92);
}

/*
 * The quiet one is not an object — it is a sentence you can click, so it stays
 * in the body face and in sentence case. "I would rather not accuse anyone" is
 * prose; setting it in tracked mono caps would make an aside shout.
 *
 * Nothing to depress, so the rule under it thickens under the finger instead.
 * Still a change at the moment of the press, which is the part that matters.
 */
.btn--quiet {
  padding: var(--s2) 0;
  border: 0;
  border-bottom: 1px solid var(--dimmer);
  border-radius: 0;
  background: none;
  box-shadow: none;
  color: var(--dim);
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 400;
  letter-spacing: normal;
  text-transform: none;
}

@media (hover: hover) {
  .btn--quiet:hover {
    color: var(--on-table-strong);
    background: none;
    border-bottom-color: var(--on-table);
  }
}

.btn--quiet:active {
  transform: none;
  background: none;
  color: var(--on-table-strong);
  border-bottom-color: var(--on-table-strong);
  border-bottom-width: 2px;
  padding-bottom: calc(var(--s2) - 1px);
}

/* A primary that is the only thing to do takes the width it is offered. */
.btn--block {
  display: flex;
  width: 100%;
}

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

/*
 * THE FADE BELONGS TO A CONTAINER, NEVER TO WHATEVER HAPPENS TO BE LAST.
 *
 * Two games had solved the same real problem the same wrong way. A host wrote
 * in to say they could not find the button that starts the game, because it
 * sat below the visible area of an inner scroller with no scrollbar and no
 * cue; the answer was to stick the control to the bottom of its scroller and
 * fade the content out underneath it, which is right. What was written was:
 *
 *     .gang-lobby > :last-child {
 *       position: sticky; bottom: 0;
 *       background: linear-gradient(to top, var(--table) 70%, transparent);
 *     }
 *
 * The last child of that lobby is the deal button. So the rule painted the
 * TABLE COLOUR over `.btn--primary`'s `--primary` fill while the label kept
 * `--on-primary`, and by daylight, where the table is the pale enamel and
 * `--on-primary` is the pale glaze, cream printed on cream: the only control
 * that continues the game rendered as an empty outlined box. Photographed at
 * 1280x800 and at 390x844 in `docs/screens/`. Three sites carried it — the
 * Safe Hands lobby, its showdown, and Sixth Takes' verdict in immersive mode,
 * where "Another deal" is likewise the last child.
 *
 * A selector that names a POSITION cannot know what it has hit, and the whole
 * fault is in that sentence: the rule wanted "the foot of this stage" and the
 * only vocabulary it had was "the last one". So the foot is a real element
 * now. The gradient is the container's, the control inside it keeps its own
 * fill, and adding a sentence after the button no longer moves the scrim onto
 * something that was never meant to carry it.
 *
 * `z-index` because sticky only lifts above content that comes before it in
 * paint order, and a stage may put a starfield or a felt behind everything.
 *
 * ## `bottom` is the shell's foot and not zero
 *
 * This element was written to keep a control on screen and then pinned it to
 * the one line of the screen that is already spoken for. `.table-foot` is
 * `position: sticky; z-index: 30` across the bottom of every room, so a thing
 * stuck at `bottom: 0` sits UNDERNEATH it — painted over, and not clickable.
 * The paragraph under the next rule has said so since that rule was written:
 * *"An earlier attempt used `bottom: 0`, moved the button from 1032px to 767px,
 * and left it just as unhittable as before."* The lesson was applied to the
 * sibling and never carried back here.
 *
 * Measured on the tables rather than reasoned about. At 1280x800 Paper Novel's
 * *Write it* was at 734..792 with the prompt bar's top edge at 716 — the whole
 * button beneath it, for every player, in the phase that is the entire game —
 * and Word Tiles' *Lay it down* was at 682..740, hittable at its centre by five
 * pixels with its lower third under the bar. On a 390x844 phone the same two
 * are worse. It has been like that since each shipped.
 *
 * One panel had already found it and fixed it in its own stylesheet:
 * `deal.css` carried `.dl-foot.stage-foot { bottom: var(--shell-foot) }` with
 * the whole diagnosis written above it. A panel patching a house component
 * privately is the tell that the fault is the house's.
 *
 * The safe-area inset is subtracted rather than paid twice: whatever holds the
 * bottom edge of the screen is what owes the notch, and while the shell's foot
 * is up that is the shell's foot. When it stands down — a game holding the
 * whole viewport publishes `--shell-foot: 0px` — this element is on the edge
 * itself and the inset comes back. `--s2` is the floor in both cases.
 *
 * ## Found twice, an hour apart, and this is both accounts
 *
 * A second sweep — `tests/e2e/lobby-reachable.spec.ts`, which hit-tests the
 * start control of every lobby on the shelf — reached the same declaration from
 * the other end: Night Train's lobby is long enough for this foot to actually
 * stick, and `elementFromPoint` at the middle of "Deal the tickets" answered
 * `p[data-t="prompt"]`. Not below the fold, not disabled, not invisible —
 * covered, exactly as the measurements above describe for Paper Novel and Word
 * Tiles.
 *
 * The two agreed on the fault and on the fix. The version kept is the one above,
 * because it also caught what the other missed: the safe-area inset must be
 * subtracted rather than paid twice. Three games, one declaration, and the only
 * reason it stayed hidden this long is that a foot has to be tall enough to
 * stick before anybody can see it pinned into the dark.
 *
 * ## The fade is a DISTANCE, and it was a proportion
 *
 * `70%` is right for the foot this was written for — one button, about 80px,
 * so 24px of fade above it. It is wrong for a tall one, and the tall ones
 * arrived the day three write bars took this class: Most of Us' box is 239px,
 * which made the fade 72px, and the picture at 390×844 showed exactly what a
 * 72px band of half-table does — the label *YOUR ANSWER* printed across two
 * other players' names, both legible, neither readable. A scrim that is meant
 * to say *"this passes behind"* was saying *"these two things are on top of
 * each other"*.
 *
 * `max()` rather than a swap, because the short case was never broken: a 2rem
 * fade is what a foot gets unless 70% of it is more, which keeps every foot
 * shorter than about 7rem exactly as it was drawn.
 */
/*
 * MID-ROUND THE STRIP STICKS, BECAUSE IT IS CARRYING THE CLOCK.
 *
 * The clock used to be a lamp in the rail, which is always on screen. The
 * boundary move put it in `.stage-status` at the foot of the game's own screen
 * — correct, because a phase clock is a fact only the game knows — and made
 * that strip static, which put it below the fold the moment a board is taller
 * than the window.
 *
 * The re-measurement found what that costs, and it is not cosmetic:
 *
 *   Night Train — "a 90-second turn clock runs with nothing on screen showing
 *   it, and it takes your turn". The wire carried a live `phaseDeadline` while
 *   `[data-t="timer"]` was empty on screen.
 *
 *   Word Tiles — the round clock measured at top 872 in an 800px viewport, at
 *   scroll 0, the instant the round opened.
 *
 * A clock you cannot see is worse than no clock: it runs your turn out while
 * the screen says nothing. So the strip sticks exactly when it is carrying
 * something time-critical, and is static when it is not — the mirror of the
 * rule below it, keyed off the same `data-lobby` the shell already publishes.
 *
 * Below `.stage-foot`'s z-index of 2, deliberately: if a game pins its own
 * controls mid-round, those are what a hand is reaching for, and the clock is
 * a thing to read rather than a thing to press.
 *
 * `:not([data-lobby="true"])` and NOT `[data-lobby="false"]`: `setLobby()` in
 * `chrome.js` DELETES the attribute when it is false rather than writing the
 * word, so the equality selector would have matched nothing, silently, for
 * ever — which is the same shape as the set-built-one-way-probed-the-other bug
 * caught in Four Leaves this morning. A selector that never fires does not
 * throw; it just quietly stops being a rule.
 */
/*
 * A NARROWER SELECTOR WAS TRIED HERE AND DROPPED — 2026-08-23, later.
 *
 * `[data-timed="true"]`, so the strip pinned only while a deadline was running
 * — which is what the paragraph above says the rule is, against a selector that
 * pins on every mid-round frame. It was taken because the shell's
 * `--stage-foot` publisher was answering zero and a session had costed the fix
 * at 240–290px of an 844px phone.
 *
 * That costing was wrong and `room.js` now carries the measurement that says so
 * — `landscape.spec.ts` at 750×342, 39 presses on the shelf that could not be
 * aimed at, all of them the scroll padding and the foot's `bottom` reading zero
 * rather than a screen-space trade. With the cost gone the narrowing bought
 * nothing and changed the composition of every clockless frame on the shelf, so
 * the wide selector stands.
 */
:root:not([data-lobby="true"]) .stage-status {
  position: sticky;
  bottom: 0;
  z-index: 1;
  background-color: var(--table);
}

/*
 * A LOBBY'S FOOT DOES NOT STICK, AND THIS IS THE SAME FAULT TWICE.
 *
 * `.stage-foot` is sticky so a game's primary stays on screen while the board
 * scrolls under it. Mid-round that is exactly right. In a LOBBY it is the fault
 * that cost this shelf sixty-three blockers a few hours ago, wearing a
 * different class: an opaque sticky box at `z-index: 2` pinned to the bottom of
 * the VIEWPORT, floating over the lobby's own controls.
 *
 * The re-measurement caught it in Paper Novel with the coordinates:
 * `[data-t="add-bot-scribbler"]` at y=708 with the primary on top of it, so
 * **pressing Scribbler dealt the game** — the identical symptom, in a lobby, on
 * the deploy that was supposed to have fixed exactly this.
 *
 * The earlier fix made `.stage-status` static and stopped there. That was one
 * instance of a general fault: fifteen panels use `.stage-foot`, and every one
 * of their lobbies has been doing this. Padding cannot answer it — padding
 * protects the end of a box and a sticky element floats at the bottom of the
 * window, which is the sentence this session has now written twice.
 *
 * So the foot stops sticking exactly when its stickiness has nothing to do:
 * a lobby is not scrolling a board under a control, it is a list of things to
 * press. `data-lobby` is already on the root, published by `chrome.js`.
 */
/*
 * AND IT SHEDS ITS SCRIM WITH ITS STICKINESS — 2026-09-08.
 *
 * The `--table` gradient on the foot below exists for one reason: a sticky foot
 * floating over a scrolling board needs an opaque edge so the board does not
 * read through the control. A lobby's foot is static and floats over nothing,
 * so the gradient is a band of page-green (--table) laid across whatever panel
 * the lobby draws its primary on — and most panels are not page-green. On
 * Anagram Relay's navy setup panel it printed a lighter rectangle beside the
 * capped 'Run' button (which is meant to cap at 26rem — see .btn--primary), so
 * the button read as sitting in a half-empty framed box. No scrim where there
 * is nothing to scrim.
 *
 * BOTH ACTION-CONTAINER SHAPES, AND THE SECOND IS WHY THE FIRST CUT MISSED. A
 * foot is scrimmed by TWO rules at the same (0,3,0) weight: `.stage-foot`
 * itself and `.stage > :has(> .btn--primary[data-t="action"])` further down —
 * the container form Bazaar and friends use. `.stage-foot` matches both, and
 * the `:has` rule is later, so a lobby override written only against
 * `.stage-foot` tied and lost the scrim back. Naming the `:has` container too,
 * under the same lobby guard, carries the extra `.stage` and outweighs it — and
 * makes the lobby-static intent hold for the container-only panels as well,
 * which the original static rule (just above) had been losing the same way.
 */
:root[data-lobby="true"] .stage-foot,
:root[data-lobby="true"] .stage > :has(> .btn--primary[data-t="action"]) {
  position: static;
  background: none;
}

.stage-foot {
  position: sticky;
  bottom: var(--stage-foot, 0px);
  z-index: 2;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s3);
  width: 100%;
  padding-top: var(--s4);
  padding-bottom: max(var(--safe-b) - var(--stage-foot, 0px), var(--s2));
  /*
   * Opaque from the padding edge down, fading only through the padding.
   *
   * It used to fade through its top 2rem, and the first thing in a foot sits
   * exactly there: Hostile Takeover's "Your hand · 3 of 3 plays left" was read
   * over "5 cards in hand 0 banked" sliding beneath it (phone walk,
   * 2026-09-07). Everything a foot carries is now on solid table; the 16px of
   * padding above it is the soft edge, which is all the softness was for.
   */
  background: linear-gradient(to top, var(--table) calc(100% - var(--s4)), transparent);
}

/*
 * AND THE PART OF IT YOU CAN SEE THROUGH DOES NOT TAKE PRESSES.
 *
 * Found 2026-08-22, the first run in which `action-sweep.ts` looked at anything
 * other than `[data-t="action"]`. Measured on Bazaar at 390×844, on every seat:
 * `.bz-controls` is stuck at **594..724** and the hand is at **630..694**, so
 * SIX OF TWELVE goods cards answered `elementFromPoint` with `DIV.bz-controls`
 * — a box whose own background at that height is `transparent`, painted from
 * the note above: *"30% of a 130px box is 39px of half-table over the hand it
 * is sitting on"*.
 *
 * That note treated the overhang as a PAINT problem and capped the fade at a
 * distance so the cards stayed readable. They did. They also stopped being
 * pressable, because a transparent box is transparent to light and opaque to a
 * thumb, and nothing in this repo was looking at a card.
 *
 * So the scrim keeps its whole box and gives up the presses in it. The controls
 * themselves take them back on the next line — a foot is a row of buttons and a
 * label or two, and the buttons are what a finger is for. `.screenprompt` in
 * this file is already written this way — a full-glass layer that takes no
 * presses, with *"the only thing on this layer a finger can land on"* switching
 * one child back on — and this is the same shape one scale down.
 *
 * It cannot mask a real overlap: where the CONTROL and not its scrim is lying
 * on something — Twelve Crowns' *Push a card forward* across its own fan, which
 * the same run found and which is recorded in `action-reachable.spec.ts` — this
 * changes nothing and the sweep goes on saying so.
 */
.stage-foot,
.stage > :has(> .btn--primary[data-t="action"]) {
  pointer-events: none;
}

.stage-foot > *,
.stage > :has(> .btn--primary[data-t="action"]) > * {
  pointer-events: auto;
}

/*
 * A foot with one control in it is a foot with one job, so the control takes
 * the width and there is nothing to aim at but the thing to do.
 */
.stage-foot > :only-child {
  flex: 1;
}

/*
 * Every lobby's primary action stays on screen, above the shell's own foot.
 *
 * Twenty-three lobbies were opened at 1280x800 with a full table and asked the
 * only question that matters — if somebody aimed at the middle of the control
 * that starts the game, would they hit it. Thirteen would not. The button was
 * not missing and not disabled; it was simply further down a page that scrolls,
 * with nothing to say so, which is precisely the report that produced
 * `.stage-foot` for Safe Hands and then stopped there while twenty-seven other
 * panels kept the problem.
 *
 * Two details, both learned by measuring rather than reasoning:
 *
 * `bottom` is `--shell-foot` and not zero. The shell's prompt bar is itself
 * `position: sticky; z-index: 30` across the bottom 84px, so a control stuck to
 * the viewport's edge lands UNDERNEATH it — visible in a screenshot, sliced in
 * half, and not clickable. An earlier attempt at this rule used `bottom: 0`,
 * moved the button from 1032px to 767px, and left it just as unhittable as
 * before, which is a good demonstration that "now it is in the viewport" and
 * "now a person can press it" are different claims. `chrome.js` already
 * measures that bar with a ResizeObserver and publishes its height, and already
 * publishes zero while a game holds the whole viewport.
 *
 * `:has(> .btn--primary…)` targets the CONTAINER of the button and never the
 * button. Painting a scrim onto `.btn--primary` is exactly how Safe Hands'
 * deal button became an invisible box of cream text on a cream ground.
 *
 * The fade is capped at a distance for the reason written against `.stage-foot`
 * above: Bazaar's controls wrap to two rows on a 390px phone, and 30% of a
 * 130px box is 39px of half-table over the hand it is sitting on.
 */
.stage > :has(> .btn--primary[data-t="action"]) {
  position: sticky;
  bottom: var(--stage-foot, 0px);
  z-index: 2;
  padding-top: var(--s4);
  background: linear-gradient(to top, var(--table) max(70%, 100% - 2rem), transparent);
}

/*
 * The other shape: the control is a child of the stage itself, with prose
 * printed after it — Roses does this. Sticky, and deliberately without a scrim,
 * because there is no foot here to fade behind and the button's own fill is
 * opaque. A step above the shell's foot rather than flush against it.
 */
.stage > .btn--primary[data-t="action"] {
  position: sticky;
  bottom: calc(var(--stage-foot, 0px) + var(--s2));
  z-index: 2;
}

/*
 * AND THESE TWO ARE THE THIRD INSTANCE OF THE FAULT ABOVE, STILL STICKING.
 *
 * `.stage-status` was made static in a lobby, then `.stage-foot`, under the
 * heading forty lines up — *"A LOBBY'S FOOT DOES NOT STICK, AND THIS IS THE SAME
 * FAULT TWICE"*. The two rules directly above are the same shape and were left
 * pinned, which is why `docs/reckoning-tail.md` still ranks this the top open
 * blocker on the shelf.
 *
 * It is now measured from both sides rather than argued, and the two numbers are
 * why nothing is changed here yet. `setup-pressable.spec.ts` — the gate that was
 * missing — counts **19 presses across eleven lobbies** that land on the deal
 * button instead of the setup control aimed at, at 1280×800 and 390×844 with a
 * full table. Making these two static closes all nineteen and costs
 * `lobby-reachable.spec.ts` **19 of 37 lobbies**, whose start control then sits
 * between 4px and 619px below the fold. Both runs are recorded in
 * `docs/interface-fit.md` with the per-lobby deficits and the composition lever
 * they point at.
 *
 * So the pin stands until a lobby that fits is what replaces it, and the eleven
 * games are named in `setup-pressable.spec.ts` rather than left for the next
 * replay to rediscover. Do not unpin these without running that file's
 * counterpart in the same session: each is half of one requirement.
 */

/*
 * A CONTROL THAT LIES OVER THE PAGE IS DIMMED IN INK, NEVER IN OPACITY.
 *
 * `.btn[disabled]` is `opacity: 0.5`, which is right for a button in the flow —
 * what shows through it is the table, and a faded slab on a plain ground reads
 * exactly as intended. Every rule above this one makes the game's primary
 * STICK, so it is no longer on the table: it is lying on the panel's own
 * content. Photographed in the Roses lobby at 1280×800 with two people seated,
 * the dead primary was a half-transparent slab across two bot cards with
 * *"anybody is ready."* legible straight through the middle of its own label.
 * Two things unreadable where there had been one.
 *
 * So the dimming stops being a transparency and becomes what it always meant:
 * this is not the strongest thing on the table at the moment. It takes an
 * ordinary control's fill and a softened ink — a solid object that has stood
 * down, rather than a ghost of the object it will be. Nothing behind it shows
 * through, which is the whole point.
 *
 * It applies to every `data-t="action"` primary and not only the stuck ones, so
 * that one control does not have two disabled appearances depending on where in
 * a panel it happens to sit.
 *
 * The label is untouched, and it carries the reason: `lib/shortof.js` puts
 * *"1 more to play"* on the button itself rather than in a sentence beside it,
 * and `primary-says-why.spec.ts` holds that. A dead control here says what it
 * wants; it just no longer says it through two other things.
 */
.btn--primary[data-t="action"][disabled] {
  opacity: 1;
  background: var(--control);
  color: color-mix(in srgb, var(--on-control) 78%, var(--control));
  box-shadow: none;
}

/* ------------------------------------------------------------ the people */

/*
 * The table of colleagues. Rows rather than a rail of tokens: at eight players
 * a wrapping rail reflows every time somebody's tag changes, and a column of
 * name badges is what a game component would actually print.
 *
 * The 3px stamp edge marks the one seat being singled out — whose turn it is,
 * or who stands accused. Exactly one row can carry it.
 */
.people {
  border-left: var(--hair);
  padding: var(--s4) 0;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.people-head {
  padding: 0 var(--s4) var(--s3);
}

/*
 * The way out, at the foot of the people column.
 *
 * `margin-top: auto` pins it to the bottom of the rail rather than letting it
 * follow the last seat up the page: leaving is a property of the room, not of
 * the roster, and a control that moves every time somebody joins is a control
 * people mis-click. Deliberately quiet — it should be findable, not inviting.
 *
 * ## Why this is a column with a gap, which reads like a formality and is not
 *
 * It held two controls in a plain block: "What just happened" and "Leave the
 * room", appended as siblings by `room.js` with no text node between them.
 * `.btn--quiet` is `inline-flex` with `padding: var(--s2) 0` — no horizontal
 * padding at all, because it is a text link with an underline rather than a
 * pressable slab — so the two boxes met at exactly 0px and the underline ran
 * straight through the join. On screen, bottom right of every room, in every
 * game: **"What just happenedLeave the room"**. One sentence, two buttons, and
 * no way to tell where to press for which.
 *
 * A column is also what `room.js` already describes when it puts the recorder
 * "one row above the way out". `align-items: start` keeps each link the width
 * of its own words, so the underline still marks the text and not the column.
 */
/*
 * TWO CONTROLS, AND IT HAD TO BE SAID OUT LOUD.
 *
 * Reported as `What just happenedLeave the room` — one run-on line at the
 * bottom right of every room in the product, reading as a single broken link.
 * Two children laid out by nothing: the default `display: block` left two
 * inline-flex buttons on one line, and a quiet button is deliberately not an
 * object — no fill, no rim, `padding: var(--s2) 0` — so there was no
 * horizontal padding to hold them apart. `fill()` appends nodes rather than
 * parsing markup, so there is not even a whitespace text node between them,
 * and the two labels met exactly.
 *
 * A ROW WITH A GAP RATHER THAN A STACK, and the stack was tried first because
 * `room.js` says "one row above the way out". It costs 49px of the people
 * column, and this column is pinned to the bottom of a track whose height
 * feeds the whole table grid: at 1600×380 those 49px slid "Play something
 * else" underneath the foot, and `panel.spec` caught it on three games at
 * once. The two are short enough to sit side by side in a 340px rail at every
 * width the product has, they wrap to a stack when they cannot, and the row
 * costs the column nothing it did not already spend.
 *
 * The gap is `--s5` and not `--s2`. One of these two ends the round for
 * everybody else in the room; the other copies a log. They should not be a
 * thumb's width apart.
 */
.room-exit {
  margin-top: auto;
  padding: var(--s4) var(--s4) 0;
  display: flex;
  /*
   * A STACK, and the row that was here first is worth recording because its
   * reasoning was sound and its premise expired.
   *
   * The row existed because a stack cost 49px of a column pinned to the bottom
   * of a fixed-height table grid, and at 1600×380 those 49px slid "Play
   * something else" underneath the foot — `panel.spec` caught it on three games
   * at once. That was true of the CABINET. The room is a page now: the column
   * is no longer pinned inside a clamped grid, the 49px come out of a document
   * that simply grows, and the constraint the row was answering is gone.
   *
   * Two links stacked read as two links. Side by side under one continuous
   * underline they read as one, which is how "What just happenedLeave the room"
   * looked in the first place.
   */
  flex-direction: column;
  align-items: start;
  gap: var(--s2);
  /*
   * AND IT CLEARS THE PINNED PROMPT.
   *
   * The foot is sticky at every size now, not just on a phone, because the room
   * became a page and something has to keep "what do I do right now" on screen.
   * A sticky foot is painted over the bottom of the document, and this column
   * sits at the bottom of the people rail — so the prompt covered whichever of
   * these two links was lowest. Both layouts hit it: as a row the black box
   * went under, as a stack it was Leave the room, which is how it announced
   * itself as a stacking problem rather than a flex-direction one.
   *
   * `--shell-foot` was the foot's own measured height, published on the root by
   * `chrome.js` for exactly this.
   *
   * THE FOOT NO LONGER STANDS HERE. The prompt is the game's now and lives in
   * `.stage-status` inside `.panel-root`, so the only thing it can paint over
   * is the game's own board — and `.panel-body` reserves for that. This column
   * is the people rail, outside the panel entirely, and nothing is pinned above
   * it any more. What is left is the `--s3` that was always the gap, rather
   * than clearance for a bar that has gone.
   */
  padding-bottom: var(--s3);
}

.room-exit:empty {
  display: none;
}

/*
 * IN A LOBBY THE REACTIONS DROP TO THE FOOT WITH THE ROOM CONTROLS — 2026-09-08.
 *
 * The reaction keys sit right under the roster, which is where they belong once
 * a game is on and there is a runner to react to. In a LOBBY there is nothing
 * to react to yet, and the roster is short, so the keys were stranded high in
 * the rail with a tall void beneath them down to `.room-exit` (pinned to the
 * bottom by its own `margin-top: auto`). `margin-top: auto` here absorbs that
 * gap instead: the reaction slot — and the exit column after it — settle into
 * one cluster of aside-controls at the foot of the rail, where a thumb rests
 * and where "What just happened / Leave the room" already live. Lobby only, so
 * the moment the game deals the keys spring back up beside the people they are
 * about. The phone hides the slot in a lobby outright (below), so this is the
 * desk's answer to the same premature-chrome question.
 */
:root[data-lobby="true"] .people > [data-t="react-slot"] {
  margin-top: auto;
}

/*
 * ...AND THE EXIT GIVES UP ITS OWN AUTO SO THEY GROUP, NOT SPLIT.
 *
 * `.room-exit` is pinned to the foot by `margin-top: auto`. With the reaction
 * slot ALSO claiming `auto` above, flexbox shares the free space evenly between
 * the two — which left the keys stranded halfway down instead of dropping to
 * the exit. So in a lobby with a reaction slot present, the exit yields its
 * auto: the slot alone absorbs the gap and the two settle as one cluster at the
 * foot, the keys directly above the way out. Guarded by `:has(> react-slot)` so
 * a lobby without one keeps the exit pinned to the bottom as before.
 */
:root[data-lobby="true"] .people:has(> [data-t="react-slot"]) > .room-exit {
  margin-top: 0;
}

/*
 * ONE COLUMN OR TWO, DECIDED BY THE COLUMN AND NOT BY THE WINDOW.
 *
 * A single file of 60px rows is the right shape for eight people and the wrong
 * shape for four: measured on the Safe Hands table at 1280x800, the roster was
 * four names over 240px followed by 370px of empty ground, and that emptiness
 * is most of what made the screen read as unfinished rather than as calm. The
 * ground being a material is what lets a gap read as surface (tokens.css, rule
 * 1) — but a gap the size of the content above it reads as something missing
 * no matter what it is made of.
 *
 * `auto-fill` rather than a breakpoint, so the roster packs itself: two abreast
 * where there is room for two, one where there is not, without either the
 * shell or a game having to know which. `min-width: 0` on the seat keeps a
 * long name ellipsing instead of forcing the track wide.
 *
 * No media query and no container query: `auto-fill` already asks the only
 * question worth asking, which is how wide this column is, and it asks it of
 * the column. On a laptop with the roster beside the stage the viewport lies
 * about that — the reason the six-nimmt sheet gives for its own container
 * query — and a track size does not have to be told.
 */
/*
 * THE FLOOR IS 16REM BECAUSE THAT IS WHAT A SEAT MEASURES, NOT BECAUSE IT IS A
 * ROUND NUMBER.
 *
 * The argument above is right and the number under it was wrong. `auto-fill`
 * does ask the only question worth asking — how wide is this column — but it
 * answers it against a floor that declared 11rem enough room for a seat, and a
 * seat does not fit in 11rem.
 *
 * Measured in Boggle at 390x844 with two seated people, the roster packed two
 * abreast: `grid-template-columns` computed to `177px 177px` in a 354px rail —
 * two 176px tracks fitting by two pixels. A 177px track holds a 36px avatar, a
 * 12px gap, 32px of padding and a tag, and what is left for the name is 27px.
 * "Emmanuelle" wants 112 and got 27. "Bartholomew" wants 121 and got 94. That
 * is the "E…" thirteen games reported, and it is this line.
 *
 * The floor is now what the content measures: avatar 36 + gap 12 + the longest
 * name a person actually has, call it 121 + gap 12 + tag 30 + padding 32 =
 * 243px. 16rem is 256, the next size up that does not cut anybody off.
 *
 * WHAT THIS DOES NOT COST, and it is worth saying because the paragraph above
 * defends the packing: the roster was never two-abreast where the packing was
 * argued for. Measured in the same game at 1280x800, the rail is **339px** —
 * one column already, and no name clipped. The only place it packed two was
 * the phone, which is the one place there is no room to. Two abreast now
 * happens when the rail is genuinely wide enough for two readable seats, which
 * is what `auto-fill` was asked for in the first place.
 *
 * Measured after, at both sizes: one column, `clipped=false` on both names,
 * and the row height unchanged at 60px — the fix costs no vertical space per
 * row, unlike every alternative tried (a tag on its own line took rows from 60
 * to 137; letting the name wrap took one to 204).
 */
.seat-rail {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
  align-content: start;
}

.seat {
  display: flex;
  width: 100%;
  min-width: 0;
  align-items: center;
  gap: var(--s3);
  padding: var(--s3) var(--s4);
  background: transparent;
  border: 0;
  border-left: 3px solid transparent;
  text-align: left;
  min-width: 0;
}

.seat-name {
  font-weight: 700;
  font-size: var(--t-name);
  color: var(--on-table);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/*
 * `--on-table` AND NOT `--dim` — 2026-08-27.
 *
 * The word beside a name that says what that person is: host, away, stand-in.
 * `--dim` is cut against the room and a chair is a chip with its own ground:
 * measured on the join screen across all fourteen palettes, **3.91:1 at worst
 * and under the floor in ten of them**, every one dark. `--on-table` clears all
 * twenty-eight at 5.38:1.
 *
 * Found by sweeping the screens that are NOT a game room — the front door, the
 * shelf page, the daily, the record, the television, pass-and-play and this one.
 * The deep census drives rooms, so seven surfaces had never been read across
 * fourteen palettes. Six of the seven were clean; this was the seventh.
 */
.seat-tag {
  margin-left: auto;
  font-size: var(--t-small);
  font-weight: 600;
  color: var(--on-table);
  white-space: nowrap;
}

/* The marked seat: named in the turn line, and named here, once each. */
.seat[data-marked="true"] {
  background: var(--panel);
  border-left-color: var(--stamp);
}

.seat[data-marked="true"] .seat-name {
  color: var(--on-table-strong);
}

.seat[data-marked="true"] .seat-tag {
  color: var(--stamp);
}

/* You. Your own badge is the one printed the other way round. */
.seat[data-you="true"] .avatar {
  background: var(--stock);
  color: var(--ink);
}

.seat[data-presence="limbo"] {
  opacity: 0.6;
}

.seat[data-presence="departed"] {
  opacity: 0.4;
}

.seat[data-presence="departed"] .seat-name {
  text-decoration: line-through;
}

/* A CHAIR SOMEBODY FILLED WITHOUT SITTING IN IT.

   Three states of a chair, and this is the middle one: a person, a stand-in, a
   gap. So it is drawn between them — an OUTLINED badge where a person's is
   printed solid, and a solid outline where a gap's is dashed.

   At full weight, and that is the decision worth writing down. The reflex is to
   dim it, because everything else that is not-quite-a-player here is dimmed:
   away is 0.6, left is 0.4. But those two are people who have gone, and a
   stand-in has not gone anywhere — it is playing, it is holding a chair the
   table can see, and in a moment it is going to beat somebody. A greyed row for
   the thing that wins the game is the interface disagreeing with the
   scoreboard.

   Ink, never colour. A hue for "machine" would be a fourth meaning in a palette
   that spends `--stamp` on a person being singled out, and it would say in
   colour exactly what the tag says in words two inches to the right. */
.seat[data-presence="standin"] .avatar {
  background: transparent;
  color: var(--dim);
  border-color: var(--rule-strong);
  box-shadow: none;
}

/* A chair nobody is in yet. Dashed, so it reads as a gap rather than a person.
   `--dim` and not `--dimmer`, because this row says the words "Empty seat" and
   `--dimmer` carries no words — see the note beside it in tokens.css. It is
   still far quieter than a name: 13px mono against 20px display at 700. */
.seat--empty {
  color: var(--dim);
}

/*
 * An empty chair is the one object with no rim: nothing has been pressed here
 * yet, so it is an outline of where something goes.
 *
 * THE DASH SAYS "GAP". THE VALUE DOES NOT HAVE TO SAY IT TWICE — and on
 * `--rule-strong` it said it so quietly there was nothing there. A lobby with
 * one person in it is exactly where these are on screen, and photographed in
 * that lobby the outline measured between **2.13:1** (skiffer, daylight) and
 * **2.87:1** (bläck, evening) against what is behind it, failing the 3:1 an
 * object owes its ground in twenty-five of twenty-six cells. `--rule-strong`
 * is a hairline *between* things and is built to be nearly invisible; a chair
 * is a thing. On `--dim` the worst is 3.90:1 and 4.97:1 inside a field.
 *
 * Still the quietest object in the room by a distance: a seated avatar is
 * `--cardback` — near-black enamel, over 10:1 — and this is a dashed line.
 */
.avatar--empty {
  background: transparent;
  border: 1px dashed var(--dim);
  box-shadow: none;
}

/* Selectable: ask someone, accuse someone, nominate someone. Generic, so the
   next game gets it for nothing. */
.seat--target {
  cursor: pointer;
  border-left-color: var(--rule-strong);
  transition: background-color var(--fast) var(--ease), border-left-color var(--fast) var(--ease),
    transform var(--press) var(--ease);
}

@media (hover: hover) {
  .seat--target:hover {
    background: var(--panel);
    border-left-color: var(--on-table);
  }
}

/* Nudged towards the table, the way you push a token across one. */
.seat--target:active {
  background: var(--rule);
  transform: translateX(2px);
}

.seat--target .seat-name {
  color: var(--on-table-strong);
}

/* ------------------------------------------------------------- the host

   A ROLE, MARKED WHERE A ROLE BELONGS: BESIDE THE NAME.

   The word "host" used to be the whole of it, sitting in `.seat-tag` in the
   same grey and the same size as "away" and "left". Those two are states of a
   seat; this is an authority somebody holds, and it is the only one of the
   three anybody goes looking for. It also lost that slot to any game that
   annotated the row, so the fact went missing for whole rounds at a time.

   Ink, never `--stamp`. Red here means a person singled out by the game — the
   accusation, the turn — and the host is not singled out, they are holding a
   job. Small enough to be a mark rather than an object: a star at name size
   would be the loudest thing in the column. */
.seat-star {
  margin-right: var(--s1);
  font-size: 0.8em;
  line-height: 1;
  color: var(--on-table-strong);
  vertical-align: 0.08em;
}

.seat[data-presence="departed"] .seat-star {
  color: var(--dimmer);
}

/* ----------------------------------------------------- showing somebody out

   Under the row rather than inside it, and that is structural before it is
   visual: a selectable seat IS a `<button>`, and a button inside a button is
   markup a browser resolves by throwing one of them away. The same arrangement
   the shelf uses for its explainer, for the same reason — and it is the honest
   one, since pressing the row means "pick this person" and pressing this means
   something else entirely.

   Indented past the avatar so it hangs under the name it is about, in a column
   where eight rows look alike. */
.seat-out {
  padding: 0 var(--s4) var(--s2) calc(var(--s4) + 36px + var(--s3));
}

/* Quiet to the point of nearly not being there, until it is wanted. A control
   that ejects a colleague should not compete for attention with the names in
   the column it sits in — it is looked FOR, never noticed. */
.seat-out-ask {
  font-size: var(--t-small);
  color: var(--dim);
}

@media (hover: hover) {
  .seat-out-ask:hover {
    color: var(--stamp);
  }
}

/* The second press, and the only moment this reads as red.

   `--stamp` is exactly right here where it is wrong on the star: this is one
   person being singled out, which is the whole of what that ink means. The
   field is a panel, so it is cut square — paper is guillotined; only things a
   finger operates are rounded. */
.seat-out--asking {
  margin: 0 var(--s4) var(--s3);
  padding: var(--s3) var(--s4);
  background: var(--panel);
  border-left: 3px solid var(--stamp);
}

.seat-out--asking .measure {
  margin-bottom: var(--s3);
  color: var(--on-table-strong);
}

/* The sentence a guest gets where the host gets controls. Sits under the whole
   column, not under a row: it is about the column. */
.seat-note {
  padding: var(--s3) var(--s4) 0;
  font-size: var(--t-small);
}

/* ==================== THE PEOPLE, WHILE THE ROOM IS FILLING ===============
 *
 * Everything from here to the end of this block applies only under
 * `[data-lobby="true"]`, which `shell/chrome.js` writes on the band between
 * rounds. Mid-round the roster goes back to being the column of rows above,
 * because that is the right shape beside a board — a game annotating eight
 * seats needs a list, not a row of 72px discs.
 *
 * ## The measurement this answers
 *
 * `docs/design-verdict.md` §6, added by the judging panel and, in their words,
 * *"not in the original five and… may be the most important item on the page"*:
 *
 * > The roster is dead. The lobby's entire job is "look who's coming", and the
 * > people are rendered in the same cool grey-green as the card they sit next
 * > to. **A room with two colleagues in it feels emptier than Table Party's
 * > empty seat.**
 * >
 * > …**the arriving of people is the emotional content of a lobby** — it is the
 * > only thing on the screen that changes while you wait, and the only thing
 * > you actually care about — and both we and our first redesign attempt
 * > treated it as metadata.
 *
 * And the brief it set: *"The people in the room are the second-loudest thing
 * on the screen, after the code. An empty chair says somebody could sit in it."*
 *
 * ## What changes, in numbers
 *
 * A row becomes a counter: 36px → 72px, which is four times the area; the name
 * goes from `--t-name` in the body face to `--t-lead` in the display face; and
 * the disc stops being `--cardback` — the same near-black as a card back, for a
 * person — and becomes one of six colours declared in `tokens.css`. The rail
 * stops being a grid of 11rem tracks and becomes a wrapping row of chairs.
 */
/*
 * THREE CHAIRS ACROSS, AND THE THIRD ONE IS THE POINT.
 *
 * The gap and the padding are `--s3` rather than `--s4` for one measured
 * reason: the people column is `21.25rem` (340px) at a desk, and the judges'
 * note about the empty chair turns on where it sits — *"sitting third in the
 * row where a person would be rather than beside it"*. Two people and an
 * invitation is the composition §6 of the verdict is entirely about, so three
 * chairs have to fit on one line or the idea is a button under a list again.
 *
 * The arithmetic, at 340px: three 6rem chairs is 288, two 12px gaps is 24, and
 * 12px of padding each side is 24 — 336, four pixels inside the column. At
 * `--s4` it was 344 and the invitation wrapped, which is what the first
 * photograph showed.
 */
.people[data-lobby="true"] .seat-rail {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s5) var(--s3);
  padding: var(--s2) var(--s3) 0;
}

/*
 * THE CHAIRS SHARE THE ROW; THEY DO NOT TAKE WHAT THEY WANT.
 *
 * The flex children here are the `<li>`s, and content-sizing them is what kept
 * the invitation on a line of its own: "Sanna Lindqvist" wants the full 9rem,
 * so two people and a free seat measured 360px in a 336px row and the third
 * chair wrapped. Photographed twice — once at `--s4` gaps, once at `--s3` —
 * before the cause turned out to be the basis rather than the gap.
 *
 * `flex: 1 1 6rem` makes them equal instead: three across at about 104px each,
 * or two at their full 9rem when there are only two, and rows of three after
 * that. The cap stays, because a table of one should not draw a chair the width
 * of the column.
 */
.people[data-lobby="true"] .seat-rail > li {
  flex: 1 1 6rem;
  min-width: 0;
  max-width: 9rem;
}

.people[data-lobby="true"] .seat-rail > li > .seat {
  width: 100%;
  min-width: 0;
  max-width: none;
}

/*
 * A chair, not a row: the disc over the name, both centred, in a column the
 * width of a counter plus the longest name a Swedish office produces.
 *
 * `6rem` and `overflow-wrap: normal`, and that pair is a bug fix rather than
 * a taste. The verdict's punch list, item 4: *"At 390px, `.seat{width:86px}`
 * plus `overflow-wrap:anywhere` wraps 'Bookworm' as **'Bookwor / m'**, pushing
 * that seat's ready-line 17px below its neighbours'. Kristina, Sebastian and
 * Margareta all do this in production. This is a bug, not a taste note."* A
 * name breaks between words or not at all, and the chair is wide enough for the
 * ones that cannot.
 */
.people[data-lobby="true"] .seat {
  flex-direction: column;
  align-items: center;
  width: auto;
  min-width: 6rem;
  max-width: 9rem;
  gap: var(--s2);
  padding: 0;
  border-left: 0;
  text-align: center;
}

/*
 * THE COUNTER. Rule 2 — materials, not fills — applied to a person.
 *
 * A glossy plastic disc: the palette's rim around a saturated fill, a highlight
 * where the light hits it and a shade on the side away. Both come from
 * `--gloss` in `tokens.css`, which is placed from `--light-x`/`--light-y`, so
 * a room lit from the lower left and a room lit from the upper right hand out
 * different discs from one declaration and rule 1 holds without anybody
 * remembering it.
 *
 * The initials are `--on-person`, a near-black constant, and they clear 4.5:1
 * on all six counters — measured, and the six numbers are written beside the
 * colours where somebody changing one will read them.
 */
/* `:not(.avatar--empty)` because a gap is not a person: the dashed ring the
   invitation and a game's unfilled chair both wear is an `.avatar--empty`, and
   handing it a counter's fill would make the one object on this screen that
   means "nobody" the brightest thing on it. */
/* `--cast` and not `--lift-xs`, and `tokens.css` carries the arithmetic: the
   panel shadow's negative spread is wider than a counter's whole radius, so
   the discs were drawn with a depth token and came out as flat circles. */
.people[data-lobby="true"] .avatar:not(.avatar--empty) {
  width: 72px;
  height: 72px;
  border-radius: 50%;
  background-color: var(--person, var(--person-1));
  background-image: var(--gloss);
  color: var(--on-person);
  border: var(--rim-hair) solid var(--rim);
  box-shadow: var(--cast), inset 0 -6px 14px rgb(0 0 0 / 0.16);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.5rem;
  letter-spacing: 0.02em;
}

/*
 * YOUR OWN COUNTER IS THE ONE WITH THE LIGHT ON IT.
 *
 * The column above prints your badge inverted — dark on stock — which was the
 * way to be different when every badge was the same near-black. Now that every
 * counter has a colour, taking yours away to mark it would single you out by
 * being the only person in the room with no colour, which is backwards. So the
 * mark moves to the light: a ring of `--lit`, which is the same ring the host
 * gets in a different ink, and the counter keeps its own colour.
 */
.people[data-lobby="true"] .seat[data-you="true"] .avatar {
  background-color: var(--person, var(--person-1));
  color: var(--on-person);
  box-shadow: var(--cast), 0 0 0 3px color-mix(in srgb, var(--lit) 72%, transparent), var(--glow);
}

/* The host holds a job, and a job is a mark rather than a colour — the reason
   is written against `.seat-star`. A ring in the room's own light, one step
   quieter than yours, because "who deals" is a smaller question than "which of
   these is me". */
.people[data-lobby="true"] .seat[data-host="true"] .avatar {
  box-shadow: var(--cast), 0 0 0 2px color-mix(in srgb, var(--lit) 46%, transparent);
}

.people[data-lobby="true"] .seat[data-you="true"][data-host="true"] .avatar {
  box-shadow: var(--cast), 0 0 0 3px color-mix(in srgb, var(--lit) 72%, transparent), var(--glow);
}

/*
 * A stand-in and a departed seat give the colour back.
 *
 * The colour belongs to a person who is here. A machine holding a chair is
 * drawn the way the column above draws it — outlined, at full weight, because
 * *"a greyed row for the thing that wins the game is the interface disagreeing
 * with the scoreboard"* — and somebody who has left keeps their dimming and
 * loses their disc, because the counter is the mark of being in the room.
 */
.people[data-lobby="true"] .seat[data-presence="standin"] .avatar,
.people[data-lobby="true"] .seat[data-presence="departed"] .avatar {
  background-color: transparent;
  background-image: none;
  color: var(--dim);
  border-color: var(--rule-strong);
  box-shadow: none;
}

.people[data-lobby="true"] .seat-name {
  font-family: var(--font-display);
  font-size: var(--t-lead);
  font-weight: 600;
  letter-spacing: var(--track-display);
  color: var(--on-table-strong);
  /* A name wraps between words or not at all. See the note on `.seat` above. */
  white-space: normal;
  overflow: visible;
  overflow-wrap: normal;
  hyphens: none;
  line-height: 1.15;
}

/*
 * The ellipsis comes off in a chair, and it has to be taken off HERE.
 *
 * `.seat-name` is a flex box and cannot truncate, so the `nowrap`/`ellipsis`
 * pair lives on the text inside it — which means the rule above, which lets a
 * name wrap, does nothing on its own. This is the half that was missing, and it
 * is the exact shape of the fault the verdict's punch list records: a name that
 * cannot wrap does not get shorter, it gets cut, and "Margareta" ellipsed to
 * "Margar…" under a 72px counter is worse than two lines.
 */
.people[data-lobby="true"] .seat-name-text {
  overflow: visible;
  text-overflow: clip;
  white-space: normal;
}

.people[data-lobby="true"] .seat-tag {
  margin: 0;
  font-weight: 500;
  white-space: normal;
}

/* The star sits before the name in a row and reads as a bullet in a column, so
   in a chair the ring carries the host and the word carries it for anybody who
   cannot see a ring. */
.people[data-lobby="true"] .seat-star {
  display: none;
}

/* Showing somebody out is a host's tidying, not part of the picture of who is
   here. It stays reachable and stops being a second line under every face. */
.people[data-lobby="true"] .seat-out {
  padding: 0;
}

.people[data-lobby="true"] .seat-out .btn--quiet {
  font-size: var(--t-data-sm);
}


/*
 * THE TIDYING, IN THE ROW OF FACES RATHER THAN UNDER IT.
 *
 * `shell/chrome.js` carries the measurement and the arithmetic. The short of
 * it: a wrapping row that is not full has a free slot, and a control that takes
 * that slot costs the column nothing above the row it lands in — the rail goes
 * 229.8px → 145.0px, against the 188.3px the same control buys as a block of
 * its own.
 *
 * `flex: 0 1 auto` and NOT the `1 1 6rem` its siblings get. A chair shares the
 * row equally with the other chairs because chairs are interchangeable; this is
 * not a chair, and stretching it to a third of the row would make the quietest
 * thing in the column the widest object in it. It takes the width of its own
 * two words and gives the rest back to the faces.
 *
 * `min-height: 44px` is the product's thumb floor, and it is the reason this
 * costs 17px on its own row rather than nothing at all: a chip here is 27px,
 * because the counter is off the glass at this width and a chip is a name. The
 * floor is not negotiable — `lobby-phone.spec.ts` rule 6 measures every target
 * on the screen — so the 17px is the price of the control being pressable, paid
 * once instead of eight times.
 *
 * IT IS SPELLED HERE EVEN THOUGH `:root[data-lobby="true"] .btn--quiet` FURTHER
 * DOWN ALREADY SETS IT, and the duplication is on purpose. That rule keys off
 * the ROOT's `data-lobby` and this one off the BAND's, and the two are written
 * by different callers — `setRoomLobby()` and `renderRoster({ lobby })`. They
 * have disagreed before: the room with nothing on the table had the root saying
 * lobby and the roster saying otherwise for long enough to put 680px of rail on
 * an 844px screen, which is what rule 7 of `lobby-phone.spec.ts` now exists to
 * catch. A control whose pressability depended on the OTHER attribute would go
 * under the floor in exactly that disagreement, silently, and the audit would
 * report it as a tap target rather than as the mismatch it is.
 *
 * `min-width` has no such sibling and is load-bearing on its own. MEASURED at
 * 360×640, the toggle's box with both floors off and with them on:
 *
 *   shut, "Tidy up"    52.5×33.5   →   52.5×44.0
 *   open, "Done"       41.8×33.5   →   44.0×44.0
 *
 * Four letters at `--t-data-sm` inside `--s2` of padding is **41.8px**, which
 * is under the floor on the axis nothing else was going to raise. The shut
 * state clears it on its own words and the open state does not, so a floor set
 * from how the label reads today would be a floor that fails when the label
 * changes.
 */
.people[data-lobby="true"] .seat-rail > li.seat-tidy {
  flex: 0 1 auto;
  max-width: 9rem;
  display: flex;
  align-items: center;
}

.people[data-lobby="true"] .seat-tidy-toggle {
  min-height: 44px;
  min-width: 44px;
  padding-inline: var(--s2);
  font-size: var(--t-data-sm);
  /* The words wrap between them or not at all. "Tidy up" on two lines in a
     narrow slot is the same fault `.seat-name` is written against, and this
     box has a floor rather than a fixed height, so it would grow the row. */
  white-space: nowrap;
}

/*
 * ------------------------------------------- THE CHAIR THAT ASKS SOMEBODY IN
 *
 * `shell/stand-in-picker.js` argues what this is and where the shape came from
 * — including why it survived the removal of the control it was invented for.
 * This is only how it is drawn, and the two decisions in the drawing:
 *
 * **It is the same object as a chair.** Same width, same disc size, same two
 * lines of caption, sitting in the row where a person would be. A dashed ring
 * in a toolbar above the roster would be a button with a circle on it; a dashed
 * ring at the end of the row of faces is a gap, and the gap is what makes it
 * legible without a word.
 *
 * **It breathes.** The one idle animation on this screen, on a 3.6s loop — slow
 * enough to be peripheral, which is the point: something on this page should be
 * alive while you wait, and the verdict measured that the base had *"zero
 * `@keyframes` and zero `animation:` declarations — literally a still image"*.
 * `prefers-reduced-motion` collapses it through the global rule in
 * `tokens.css`, so it cannot be forgotten here.
 */
.seat--recruit {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s2);
  width: auto;
  min-width: 6rem;
  max-width: 9rem;
  padding: 0;
  background: none;
  border: 0;
  color: inherit;
  font: inherit;
  text-align: center;
  cursor: pointer;
}

.avatar--recruit {
  width: 72px;
  height: 72px;
  border-radius: 50%;
  border: 2px dashed color-mix(in srgb, var(--lit) 62%, var(--rule-strong));
  background: none;
  box-shadow: none;
  color: var(--dim);
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 1.75rem;
  line-height: 1;
  animation: waiting 3.6s var(--ease) infinite;
}

/*
 * THE TROUGH CAME UP, BECAUSE HALF THE BREATH WAS SPENT NOT BEING LIT.
 *
 * 34% of `--lit` in `--rule-strong` is very nearly the rule colour, so for a
 * good part of every 3.6s cycle the one object on the screen that means
 * "somebody could sit here" was a grey outline — and a still photograph of the
 * lobby is as likely to catch that frame as any other.
 *
 * Which is how it was found. A photograph of the real lobby went to
 * `tools/design/imgedit.py` under three briefs; two of the three came back with
 * this ring in unmistakable lamplight and the third — the one asked for a
 * calmer hierarchy — drew it in plain grey, which is the argument for the floor
 * from the other side: at 34% we were already shipping the quiet version of a
 * chair whose whole job is to be noticed.
 *
 * 56% is the floor at which the dashes still read as the room's light at 1x
 * against a 2px stroke. The peak is untouched at 90%, so the chair still
 * breathes — it now breathes between lit and brighter rather than between grey
 * and lit, which is what "an empty chair says somebody could sit in it" was
 * always asking for.
 */
@keyframes waiting {
  0%,
  100% {
    border-color: color-mix(in srgb, var(--lit) 56%, var(--rule-strong));
    box-shadow: 0 0 0 0 transparent;
  }
  50% {
    border-color: color-mix(in srgb, var(--lit) 90%, var(--rule-strong));
    box-shadow: var(--glow-lg);
  }
}

.seat-name--recruit {
  font-family: var(--font-display);
  font-size: var(--t-lead);
  font-weight: 600;
  letter-spacing: var(--track-display);
  color: var(--on-table);
  line-height: 1.15;
}

.seat-tag--recruit {
  margin: 0;
  font-size: var(--t-small);
  font-weight: 500;
  white-space: normal;
  color: var(--dim);
}

@media (hover: hover) {
  .seat--recruit:hover .avatar--recruit {
    border-color: var(--lit);
    color: var(--on-table-strong);
  }
}

.seat--recruit:active .avatar--recruit {
  transform: translateY(1px);
}

/*
 * A CHAIR THAT WILL NOT TAKE ANYBODY STOPS BREATHING.
 *
 * The ring means "somebody could sit here", so a full table's copy of it must
 * not go on saying that. It keeps its place in the row — the sentence to a
 * reader is still that this is where a chair would be — and the reason it
 * refuses is on the control itself, put there by `setBlocked` as `title` and
 * `aria-description` (`lib/dom.js`), because unlike the exceptions recorded
 * beside `showOut` this one names something the reader can go and do.
 */
.seat--recruit[disabled] {
  cursor: default;
  opacity: 0.55;
}

.seat--recruit[disabled] .avatar--recruit {
  animation: none;
  border-color: var(--rule-strong);
}

/* A chair a game is still waiting on, in the row rather than in the column: the
   same dashed gap, without the press, because a game asking for a fourth player
   and a room offering to go and get one are two different sentences. */
.people[data-lobby="true"] .seat--empty .avatar--empty {
  width: 72px;
  height: 72px;
  border-radius: 50%;
}

.seat-name--empty {
  font-family: var(--font-display);
  font-size: var(--t-lead);
  font-weight: 600;
  letter-spacing: var(--track-display);
  color: var(--dim);
}

/* ------------------------------------------------------------ vote slips */

/*
 * A slip is a small glazed tile with one person's name on it. Generic —
 * The ballot in Out of Place uses it, and a nomination step would too.
 */
.slip {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  padding: var(--s5) var(--s4) var(--s4);
  background-color: var(--stock);
  background-image: var(--ruling);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-panel);
  color: var(--ink);
  text-align: left;
  cursor: pointer;
  box-shadow: var(--glaze-line), var(--lift-xs);
  transition: background-color var(--fast) var(--ease), transform var(--press) var(--ease),
    box-shadow var(--press) var(--ease);
}

@media (hover: hover) {
  .slip:hover {
    background-color: var(--stock-white);
  }
}

/* Pressed down onto the table before it is picked up and lifted. */
.slip:not([data-chosen="true"]):active {
  transform: translateY(1px);
  box-shadow: inset 0 2px 4px 0 rgb(8 22 29 / 0.22);
}

.slip-label {
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: var(--t-data-sm);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: rgb(8 22 29 / 0.6);
  border-bottom: var(--hair-print);
  padding-bottom: var(--s3);
}

.slip-name {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.875rem;
  line-height: 1;
  letter-spacing: var(--track-display);
  text-transform: uppercase;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.slip-action {
  margin-top: auto;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: var(--t-data-sm);
  letter-spacing: var(--track-data);
  text-transform: uppercase;
  /*
   * 65% and not 50%, for the same reason and by the same measurement as
   * `.card-let-go` below — the slip's stock is the card's stock. At 50% this
   * read **3.34:1 at worst, under the floor in all twenty-eight**, and it is
   * the word that says what pressing the slip will do. The chip's own hairline
   * stays where it is: it is a decoration inside a control that already has a
   * border, and the word is what carries the affordance.
   */
  color: rgb(8 22 29 / 0.65);
  border: 1px solid rgb(8 22 29 / 0.3);
  border-radius: var(--r-chip);
  padding: var(--s3);
  text-align: center;
}

/*
 * The one you have picked up. Lifted, stamped, and the only red on the screen.
 *
 * `--stamp-print`, not `--stamp`: a slip is glaze in both lights, so the mark
 * on it has to be constant too. The themed stamp is a pale tint at night, and
 * a pale mark on pale stock is no mark at all.
 */
.slip[data-chosen="true"] {
  border-color: var(--stamp-print);
  border-width: 3px;
  padding: calc(var(--s5) - 1px) calc(var(--s4) - 1px) calc(var(--s4) - 1px);
  transform: translateY(-10px);
  box-shadow: var(--glaze-line), var(--lift);
}

.slip[data-chosen="true"] .slip-label {
  color: var(--stamp-print);
  border-bottom-color: color-mix(in srgb, var(--stamp-print) 35%, transparent);
}

.slip[data-chosen="true"] .slip-action {
  background: var(--stamp-print);
  border-color: var(--stamp-print);
  color: var(--on-stamp-print);
}

/* ----------------------------------------------------------- the counter */

/* "3 of 6 have voted", as a length rather than a number you have to read. */
.counter {
  display: flex;
  align-items: center;
  gap: var(--s5);
  flex-wrap: wrap;
}

.counter-track {
  display: flex;
  gap: 7px;
}

.counter-seg {
  width: 34px;
  height: 8px;
  background: var(--rule);
}

.counter-seg[data-on="true"] {
  background: var(--stock-dim);
}

/* ---------------------------------------------------------------- the card */

/*
 * The identity's core object. Everything here is generic: a game supplies the
 * face and nothing else, which is what lets the portal show a specimen card
 * built from exactly the same component the room deals.
 *
 * Fixed height whichever face is up. A card that grew a line for a long
 * location would be a card you could measure from across the room, and the
 * whole point is that they are indistinguishable.
 */

.card {
  --card-w: min(24.5rem, 100%);
  --card-h: 31rem;
  position: relative;
  width: var(--card-w);
  height: var(--card-h);
  border-radius: var(--r-card);
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  touch-action: none;
  /* Each face clips itself, so a raised thumbnail is not cut off by its own
     footprint — see `.card--small[data-facing="up"]` below. */
  overflow: visible;
  isolation: isolate;
}

@media (max-height: 780px) {
  .card {
    --card-h: 26rem;
  }
}

/*
 * Your hand during the round: a thumbnail resting at the table edge.
 *
 * Scoped through the same custom properties the full card uses, so the
 * viewport-height rule above cannot reach in and resize a thumbnail — which is
 * exactly what it did on a 720px-tall laptop, turning a 182px card into a
 * 416px one that fell off the bottom of the screen.
 */
.card--small {
  --card-w: 8.125rem;
  --card-h: 11.375rem;
  transform: rotate(-2deg);
  filter: var(--drop);
}

/*
 * Held: it lifts to full size from where it lies, rather than rendering a
 * whole card face into a 130px box. "Hold to read" has to mean readable.
 */
.card--small[data-facing="up"] {
  z-index: 5;
}

.card--small[data-facing="up"] .card-face {
  inset: auto auto 0 0;
  width: min(24.5rem, 78vw);
  height: min(31rem, 74vh);
}

/* ---- the back ---- */

.card-back {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s5);
  background-color: var(--cardback);
  background-image: var(--grain-card);
  background-size: var(--grain-card-size);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-card);
  box-shadow: var(--glaze-line-dark);
  padding: var(--s6);
  overflow: hidden;
}

/* The rim stays; the glaze brightens, the way enamel does when you tilt it. */
@media (hover: hover) {
  .card:hover .card-back {
    box-shadow: var(--glaze-line);
  }
}

/* The printed keyline a real card back has, inset from the trim. */
.card-keyline {
  position: absolute;
  inset: 12px;
  border: 1px solid rgb(242 237 225 / 0.09);
  border-radius: 6px;
}

/* A coffee ring. The only ornament in the whole interface, and it earns it. */
.card-ring {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 8rem;
  height: 8rem;
  border-radius: 50%;
  border: 2px solid rgb(242 237 225 / 0.28);
}

.card--small .card-ring {
  width: 3.5rem;
  height: 3.5rem;
  border-width: 1px;
}

.card-ring-inner {
  width: 5.75rem;
  height: 5.75rem;
  border-radius: 50%;
  border: 10px solid color-mix(in srgb, var(--alert) 62%, transparent);
}

.card--small .card-ring-inner {
  width: 2.25rem;
  height: 2.25rem;
  border-width: 8px;
}

.card-back-copy {
  text-align: center;
  z-index: 1;
}

.card-back-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.375rem;
  letter-spacing: -0.01em;
  text-transform: uppercase;
  color: var(--cardback-ink);
}

.card-back-note {
  margin-top: var(--s2);
  font-size: var(--t-small);
  color: var(--cardback-dim);
}

.card-back-foot {
  color: var(--cardback-dim);
  position: absolute;
  left: 0;
  right: 0;
  bottom: var(--s5);
  text-align: center;
}

/* ---- the face ---- */

@keyframes card-in {
  0% {
    opacity: 0;
    transform: perspective(1400px) rotateY(-82deg) scale(0.97);
  }
  60% {
    opacity: 1;
  }
  100% {
    opacity: 1;
    transform: perspective(1400px) rotateY(0deg) scale(1);
  }
}

.card-face {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  padding: var(--s5) var(--s5) var(--s4);
  background-color: var(--stock);
  background-image: var(--ruling);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-card);
  box-shadow: var(--glaze-line), var(--lift);
  color: var(--ink);
  text-align: left;
  overflow: hidden;
  animation: card-in 240ms var(--ease) both;
}

/* ---- the face's grammar: a header rule, labelled fields, a footer ---- */

.card-head {
  display: flex;
  justify-content: space-between;
  gap: var(--s3);
  border-bottom: var(--hair-print);
  padding-bottom: var(--s3);
}

.card-field {
  margin-top: var(--s5);
}

.card-field--ruled {
  border-top: var(--hair-print);
  padding-top: var(--s4);
}

.card-label {
  font-weight: 600;
}

/*
 * `--fit` steps the size down for a long name. The card cannot grow, so the
 * type gives — a clipped location is not a styling problem, it is a card the
 * player cannot read, and it has shipped broken before.
 */
.card-headline,
.card-role {
  margin-top: var(--s2);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: calc(var(--t-card) * var(--fit, 1));
  line-height: 0.94;
  letter-spacing: -0.03em;
  text-transform: uppercase;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.card-role {
  font-size: calc(var(--t-card) * 0.78 * var(--fit, 1));
}

.card-foot {
  margin-top: auto;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
}

.card-note {
  font-size: var(--t-small);
  line-height: 1.45;
  color: rgb(8 22 29 / 0.8);
  max-width: 17rem;
}

/*
 * HOW TO PUT THE CARD DOWN, ON THE MOST PRIVATE SCREEN ON THE SHELF.
 *
 * A dealt secret turns over for exactly as long as a hand is on it, so this is
 * the one line on a card face that nobody can ask a friend to read out. It was
 * **3.87:1 at worst and under the floor in all twenty-eight palette-lights** at
 * 55% — measured on the card's own stock, which is glaze in both lights.
 *
 * 60% clears at 4.54:1, which is four hundredths of margin and not a decision;
 * 65% clears at 5.36:1 and is still the quietest thing on the card, a step under
 * `.card-note` at 80%.
 *
 * Found on 2026-08-27 by teaching `ink-floor.mjs` to HOLD the card. No sweep in
 * this repo had ever read a card face, in any game, because reading one means
 * doing what a player does. Four games print this line: Small Hours, Out of
 * Place, Odd Word Out and One Word Only.
 */
.card-let-go {
  color: rgb(8 22 29 / 0.65);
}

/* ---------------------------------------------------------------- shelf */

/*
 * Choosing what to play. Printed matter again — a shelf of boxes, each with
 * what it is and what it needs — rather than a grid of tiles.
 *
 * Everything unplayable stays on the shelf rather than being hidden. A picker
 * that quietly omitted the unfinished game would leave somebody wondering
 * whether it exists.
 */
/*
 * The shelf must fit on the shelf.
 *
 * At `max-width: 46rem` this was two columns however wide the room was, so
 * five games made three rows in a stage about 734px tall on a 900px screen.
 * The overflow was reachable — an outer container scrolled — but there was no
 * scrollbar and the cut landed exactly at a card's bottom edge, so the page
 * looked finished. Two of the five games were invisible, and the person who
 * asked for one of them concluded it could not be started at all.
 *
 * Wider, and denser: three columns in a normal room puts every game on one
 * screen. The stage below scrolls as well, so a short window loses nothing.
 */
/*
 * The line above the shelf, which both callers already had and neither filled.
 *
 * A search box on a row of its own is paid for out of the shelf underneath it:
 * MEASURED in an empty lobby at 1440×900, the first row of cards ran 515→900,
 * its bottom exactly on the fold, and 61px of box moved it to 588→974 — four
 * whole cards to none. So it shares the sentence's line, and the fold does not
 * move.
 *
 * It wraps rather than shrinking on a phone, because a search box narrow enough
 * to sit beside "Play something else" at 390px is a search box you cannot read
 * what you typed in.
 */
/*
 * The way to the other language, which until now only a crawler could find.
 *
 * A quiet line under the page's own sentence rather than a control in the rail:
 * it is true of the four document pages that have a translated pair and of
 * nothing else, so it belongs where the pair is, not in furniture that every
 * screen carries.
 */
.shelfpage-lang {
  margin-top: var(--s3);
}

.shelfpage-lang a {
  color: var(--dim);
}

.shelf-ask {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s2) var(--s3);
}

.shelf-ask > .lead {
  margin: 0;
}

/*
 * AND IT SITS INSIDE THE LINE RATHER THAN SETTING IT.
 *
 * `.control`'s own padding makes the box 61px, one pixel taller than the
 * sentence beside it, and that pixel is not free: the lobby's first row of
 * cards ends exactly on the fold at 1440×900, so a row grown by one moved the
 * cards to 902 and `sweep.spec.ts` went from four whole cards to none. Two
 * pixels, and it is a real reading — the row was always that tight.
 *
 * 2.75rem is 44px, which is the smallest thing a finger is asked to hit
 * anywhere in this product, so the box gives back height it did not need
 * without becoming something that has to be aimed at.
 */
.shelf-search {
  flex: 0 1 18rem;
  min-width: 0;
  padding-block: 0;
  block-size: 2.75rem;
}

/* The answer when there is none, set as the screen's own sentence rather than
   as an error: an empty shelf is a question that found nothing, not a fault. */
.shelf-nothing {
  margin-bottom: var(--s3);
}

.shelf {
  display: grid;
  gap: var(--s4);
  grid-template-columns: minmax(0, 1fr);
  width: 100%;
  max-width: 72rem;
}

/*
 * Narrower tracks than the 16rem this used to ask for, because the boxes now
 * carry their pictures: at 16rem the lobby got three columns of 317px, so a
 * lid was 211px tall and only one and a bit rows of a seven-game shelf were
 * on screen. At 14rem it is four columns, the lid is 157px, and two rows fit —
 * which for seven games is nearly the whole shelf without scrolling.
 */
/*
 * AND THE ARITHMETIC ABOVE WAS DONE FOR SEVEN GAMES. There are forty.
 *
 * `auto-fit` was gated behind `min-width: 820px`, so every window narrower than
 * that got ONE column however wide it was — and a column is as wide as the
 * shelf. MEASURED in a room with the picker open: at 750×342, a landscape
 * phone, the shelf was a single 653px-wide box per game, 264px tall, and the
 * whole list ran to **12,769px — thirty-seven screens to choose from forty
 * games**. At 390×844 it was 277px a game against the front door's 158px row
 * for the same six things.
 *
 * The gate is not replaced by a lower number, because `auto-fit` with a
 * minimum already IS the rule the gate was approximating: at 390px the column
 * is 330px and 14rem does not fit twice, so a phone gets the one column it
 * should; at 653px it gets two; at 824px the three the note above wanted. The
 * breakpoint only ever existed to stop a 14rem minimum splitting a phone, and
 * `minmax(14rem, 1fr)` refuses to do that on its own.
 */
.shelf {
  grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
}

/*
 * The room's shelf reuses the front door's `.catalogue` card and grid (so it is
 * one shelf, see `shelf.js` `picker`) — but it shares its row with the roster,
 * so it is narrower than the front door's full-width shelf. The front door's
 * grid, dropped into that narrower space, packed columns tight enough that the
 * longest names ("Minesweeper", "Forbidden Words") broke mid-word at a desk
 * (shelf-names, 2026-09-11). A wider track floor keeps every card wide enough to
 * print its name whole; below 640 it still collapses to the one-column phone
 * strip the `.catalogue` rules draw.
 */
.shelf.catalogue {
  grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
}

/*
 * THE SHELF IS AS TALL AS THE SHELF. This is the screen the owner was looking
 * at when he said no page should have div scrollbars, so it is worth being
 * exact about what used to be here and why it is not a loss.
 *
 * Both places the picker appears were scroll containers — `overflow: auto`,
 * plus `max-height: 70svh` on the mid-room one because `flex: 1` did not bind
 * against a 720px window. It was an honest answer to a real fault (five games,
 * two of them below a fold with no scrollbar drawn, and somebody concluding the
 * game they wanted did not exist), and inside a cabinet it was the only answer.
 * Measured on the reported screen at 1280×800: a 560px box over 1060px of
 * content. At 960×700 it had a horizontal bar as well, 510px over 523px — the
 * vertical bar's own width taken out of the row and handed straight back as
 * sideways overflow, which is the pair of bars in the screenshot.
 *
 * The room is a page now, so the shelf is simply eight cards tall and the
 * window scrolls. Nothing is below a fold that cannot be reached, which was the
 * only thing the scroller was ever protecting.
 *
 * `flex: 1; min-height: 0` stays: it is what makes the lobby's shelf fill a
 * tall window instead of huddling at the top of one.
 */
.stage--lobby,
.change-game[data-open="true"] {
  flex: 1;
  min-height: 0;
}

/*
 * Three lines of pitch, not five. The blurb is what made a card 390px tall,
 * and card height is what decided how many games a person could see. Whoever
 * is choosing wants to know what a game is; they can find out the rest by
 * dealing it.
 */
.shelf-item .measure {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  overflow: hidden;
}

/*
 * `.shelf-item` is now the front door's `.game-card` with this hook class added,
 * so `shelf-names` and `sweep` can still count and address a picker card
 * (`.shelf-item`, `[data-t="shelf-item"]`). The card takes its whole box model
 * from `.game-card` — see `shelf.js` `picker` and the note by `.game-card--pick`
 * — so this class only needs to not fight it. It carries the button resets for
 * the non-`--pick` guest card, which is a <div> and needs none, so it is bare.
 */
.shelf-item {
  color: inherit;
  text-align: left;
  font: inherit;
}

.shelf-item--pick {
  cursor: pointer;
  box-shadow: var(--lift-xs);
  transition: border-color var(--fast) var(--ease), background-color var(--fast) var(--ease),
    transform var(--press) var(--ease), box-shadow var(--press) var(--ease);
}

@media (hover: hover) {
  .shelf-item--pick:hover {
    border-color: var(--on-table);
    background: var(--panel-soft);
  }
}

.shelf-item--pick:active {
  transform: translateY(1px);
  box-shadow: none;
}

/*
 * Smaller than a page heading, and breakable.
 *
 * At `--t-display` in a 234px picker column "MINESWEEPER" was 250px of
 * unbreakable type and painted straight across the card beside it. A name in a
 * list is a label, not a headline — the box above it is what the eye lands on
 * now — so it takes a fixed size that fits the narrowest track, and breaks
 * rather than escaping if a longer name ever arrives.
 *
 * `break-word` RATHER THAN `anywhere`, AND THE DIFFERENCE IS THE ONLY REASON
 * THE ROW BELOW CAN WRAP. The two behave identically once a line is being laid
 * out — both break inside a word that will not fit — and differ in exactly one
 * place: `anywhere` contributes ONE CHARACTER to its box's min-content width,
 * `break-word` contributes the longest word. So under `anywhere` the heading's
 * `min-width: min-content` resolved to about 12px, the flex line never had a
 * reason to break, and the name was chopped in a 93px column while a full-width
 * line sat empty underneath it. Measured with `anywhere` still in place and the
 * wrap rule already written: eight names broken at 820, five at 1440, and the
 * heading exactly as wide as before.
 *
 * Nothing about the protection is given up. A name longer than a whole card is
 * still broken rather than allowed to escape — which is what the paragraph
 * above is about, and it is now the second thing tried rather than the first.
 */
.shelf-title {
  font-size: 1.3125rem;
  text-transform: uppercase;
  overflow-wrap: break-word;
  min-width: 0;
}

.shelf-head {
  justify-content: space-between;
  width: 100%;
  align-items: baseline;
}

/*
 * The box in the picker: the lid's smallest size, beside the name.
 *
 * The room's shelf listed seven titles and asked people to remember which of
 * them was the one with the asteroids in it. A `--r-chip` corner rather than a
 * `--r-panel` one because it is an object *inside* another object, and two
 * equal radii nested read as a printing error.
 *
 * Its width is fixed rather than fluid, and that is the point of putting it
 * here: it fits inside the height the title and the facts line already take,
 * so seven games cost exactly what six titles used to.
 */
/*
 * AND THE NAME DROPS BELOW THE PICTURE RATHER THAN BEING BROKEN IN HALF.
 *
 * `min-width: 0` on the heading said "this may be squeezed to nothing", and the
 * rule above it says the picture may not be squeezed at all, so on a narrow
 * track the name got whatever 72px of lid and two paddings left over.
 * `.shelf-title` has `overflow-wrap: anywhere` to stop a long name painting
 * across the box beside it, and `anywhere` does exactly what it is told once
 * the column is narrower than one word. Measured in a room, on this shelf, at
 * eight desk widths:
 *
 *     1600  4 cols  name column 106px  MINESWEE/PER, CATALOGU/E, FORBIDDE/N
 *     1440  4 cols               98px  five names broken
 *      820  3 cols               93px  EIGHT names broken
 *
 * "Minesweeper" is 138px of unbreakable type at this size and the next longest
 * word on the shelf is 108px, so a 93px column cannot hold either of them
 * whatever the type does. Shrinking the face is not the way out: it would take
 * about 13px to fit eleven capitals in 93, which is under the facts line
 * printed beneath it, and a game's name set smaller than its own player count
 * is not a name any more.
 *
 * So the row wraps: the name sits beside the picture while there is room for it
 * whole, and drops underneath it, full width, the moment there is not.
 * `min-width` is what decides, because a flex line breaks on an item's
 * hypothetical size and `flex: 1 1 0` makes that the floor.
 *
 * AND THE FLOOR IS ONE NUMBER FOR THE WHOLE SHELF RATHER THAN EACH CARD'S OWN
 * LONGEST WORD. `min-content` was written here first and it is the tempting
 * answer: every card measures itself, nothing is typed in, a new game needs no
 * edit. What it draws is a row in which Bazaar's name is beside its box and
 * Back Catalogue's is under it — the same object composed two ways, side by
 * side, and the eye reads that as a mistake before it reads either name. A grid
 * of thirty-seven identical cards is the one place raggedness is not free.
 *
 * `9rem` is 144px against the widest word on the shelf at this size, measured:
 * "Minesweeper" 138, "Catalogue" 108, "Forbidden" 107, and the facts line
 * beneath needs 98. So every card wraps together or not at all, and what it
 * costs is the page getting between 0 and 8% taller at the widths where they
 * all drop — measured at twelve widths, worst case 7,585px to 8,229px at 1024.
 *
 * It is a typed number and it will go stale, which is exactly why
 * `shelf-names.spec.ts` asserts that no name on the shelf is broken at any of
 * twelve widths rather than asserting this value. A longer name than
 * "Minesweeper" makes that spec red on the day it lands, naming itself; the
 * repair is this one number.
 *
 * `min(9rem, 100%)` rather than `9rem`, so the floor can never be wider than
 * the line it sits on — a card narrower than 144px would otherwise be told its
 * own child may not fit inside it.
 *
 * Nothing here is a viewport number, and that is deliberate: the arithmetic
 * that went stale was `.game-title`'s "175px at four columns", which was true
 * of `/games` and was never true in a room, where the people column takes
 * 21.25rem off the same grid.
 *
 * The picture keeps `flex: none`. It is the thing being recognised — the whole
 * argument for putting it here — and a lid that shrank would buy 24px, which
 * is not enough to matter and would cost the one size the eye can learn.
 */
.shelf-top {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s3);
  width: 100%;
  min-width: 0;
}

.shelf-item .lid {
  width: 4.5rem;
  flex: none;
}

.shelf-heading {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  flex: 1 1 0;
  min-width: min(9rem, 100%);
}

.shelf-cta {
  margin-top: auto;
  pointer-events: none; /* the whole card is the control */
}

/*
 * A box on the shelf, and the one thing beside it that is not part of the box.
 *
 * The item itself is one enormous `<button>` — pressing anywhere on it deals
 * the game — so a second control cannot live inside it, and it should not want
 * to: it does something else. It is a grid row so the boxes in a multi-column
 * shelf still line up along their tops however tall each blurb runs, which is
 * what a bare `<li>` with two children stopped doing.
 */
/*
 * A COLUMN, AND NOT A GRID, BECAUSE OF WHAT A GRID DOES TO A BUTTON'S WIDTH.
 *
 * This was `display: grid` first and it put every box 1.6px into the one beside
 * it. A grid item's automatic minimum width is its MIN-CONTENT width, and a box
 * on this shelf is one enormous `<button>` whose call to action does not wrap —
 * so at the narrowest track the product draws, "Deal Bowl of Nouns" set the
 * floor and the box overflowed its own column. Nothing looked wrong; the boxes
 * simply touched. `shell.spec.ts` found it in a room at 1600×380 with the shelf
 * open, which is where the tracks are narrowest, and it found it by measuring
 * the gap between two controls rather than by looking.
 *
 * The cross axis of a column flex container is `stretch` and carries no such
 * floor, which is exactly how the box behaved before it had a sibling. `flex:1`
 * keeps the boxes level along their tops however tall each blurb runs.
 */
.shelf-row {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  min-width: 0;
}

.shelf-row > .shelf-item,
.shelf-row > .game-card {
  flex: 1;
  min-width: 0;
}

/* The one child that hugs its own words: it is a sentence, not an object. */
.shelf-row > .howto-open--compact {
  align-self: start;
}

/*
 * Under a box on the front door: one loud control, and one quiet one beneath it.
 *
 * A COLUMN, DELIBERATELY, AND IT WAS A `.row` FIRST. The two of them side by
 * side is what a row of controls usually wants to be, and here it wrapped on
 * all thirty cards — "Deal Bowl of Nouns" is a long label by design and the
 * column is 338px, so the pair never fitted on one line for any game on the
 * shelf. A flex row that wraps every single time is a stack that has not
 * admitted it, and the arrangement it produced by accident is the right one
 * anyway: `.btn--quiet` is a clickable sentence, and a sentence belongs under
 * the slab rather than beside it.
 *
 * It costs the card about forty pixels. `screens.spec.ts` records what that
 * broke and why the fix was in the test.
 */
.game-card-actions {
  display: flex;
  flex-direction: column;
  align-items: start;
  gap: var(--s2);
}

/*
 * The mark the explainer wears on a phone strip, and nowhere else.
 *
 * `screens.js` hands it to `howToPlayControl` for the front door's cards only,
 * and even there it is drawn over the picture rather than beside the words —
 * so at every width above the strip it is off. Declared here rather than only
 * in the media query because a control that appears when a stylesheet half
 * loads should appear as the sentence it has always been.
 */
.game-card-mark {
  display: none;
}

/*
 * ON THE STRIP THE TWO CONTROLS SHARE A LINE.
 *
 * MEASURED at 390x844 against the shelf people compare us with: they carry 106
 * pixels per game and we carried **283**, a full sweep of 10,759px against
 * their 3,831. Finding a game is the job this page exists for and we were 2.7x
 * slower at it — the comment above `@media (max-width: 639px)` says the strip
 * was introduced when we were four times slower, so this is the same argument
 * carried the rest of the way.
 *
 * The single largest box in a 248px card is not the picture and not the words:
 * it is **104px of controls**, a 52px deal button stacked on a 44px explainer.
 * Stacked because "Deal Night Train" is 165px wide in a 208px column. Drop the
 * name — which the title two lines above has already said — and it is about 70,
 * so both controls fit on one line and the card gives back 52px.
 *
 * NEITHER CONTROL IS REMOVED. The deal is still the loudest thing on the card
 * and the explainer is still under it, for the reasons `screens.js` records
 * beside each of them.
 */
@media (max-width: 639px) {
  .catalogue .game-card.game-card .game-card-actions {
    flex-direction: row;
    align-items: center;
    flex-wrap: wrap;
    /*
     * `--s1` and not `--s2`, and the difference was ONE PIXEL. With the name
     * dropped the deal is 71 and the explainer 98; at an 8px gap that is 177 in
     * a 176px column, so it wrapped and the row cost exactly what the column
     * had. Measured, not guessed — the two buttons were sitting at y=1562 and
     * y=1622 with `flex-direction: row` correctly applied above them.
     */
    gap: var(--s1);
  }

  /*
   * VISUALLY HIDDEN, AND IT WAS `display: none` — 2026-08-27.
   *
   * `screens.js` splits the verb from the name so the strip can drop the name,
   * and says beside it that whatever asks the button for its name "is told
   * exactly what it was told before". `display: none` does not do that: it
   * removes the span from the accessibility tree with the layout.
   *
   * Measured on a phone at 390: the front door's **forty deal buttons all
   * answer to "Deal"**. A screen reader's list of controls is forty identical
   * entries, on the surface this product is built for first.
   *
   * Found while building the compact shelf, which needed the same span dropped
   * for the same reason and reached for `visually-hidden` because that is what
   * the comment promised. Then it was worth checking what the promise had
   * actually compiled to.
   */
  .catalogue .game-card.game-card .game-card-deal-name {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

.change-game {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  align-items: start;
}

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

/*
 * `--s2` here left the label sitting under the focus ring: the outline is 3px
 * offset 2px, so a focused input reaches 5px above its own box and struck
 * through the label naming it.
 */
.field {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
}

.field label {
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: var(--t-data-sm);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--dim);
}

.field input {
  padding: var(--s4);
  background: var(--panel);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-control);
  box-shadow: inset 0 2px 4px 0 rgb(8 22 29 / 0.14);
  color: var(--on-table-strong);
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 1.125rem;
  letter-spacing: 0.08em;
}

.field input::placeholder {
  color: var(--dimmer);
  letter-spacing: 0.04em;
}

/*
 * A CONTROL THAT IS NOT INSIDE A `.field`, AND STILL LOOKS LIKE ONE.
 *
 * `.field` above is a wrapper and `.field input` styles what it wraps. That
 * works for the join screen and the room code, where a label sits over its box.
 * The finder has no visible labels — its search is described by `aria-label`
 * and its three selects by the `.finder-pick-label` beside them — so its
 * controls are not inside a `.field` and never picked up any of this.
 *
 * They wore the wrapper's class instead, which gave them `display: flex` and
 * nothing else: four browser-default widgets on the front door, `padding: 1px
 * 2px`, no rim, no panel, no radius. This is the chrome, addressable directly.
 *
 * `font: inherit` rather than the mono-and-tracking `.field input` uses: that
 * treatment is for a room CODE, six characters a person reads out loud. This
 * takes a game's name.
 */
.control {
  padding: var(--s4);
  background: var(--panel);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-control);
  box-shadow: inset 0 2px 4px 0 rgb(8 22 29 / 0.14);
  color: var(--on-table-strong);
  font: inherit;
  min-width: 0;
}

.control::placeholder {
  color: var(--dimmer);
}

/* ---------------------------------------------------------- the code slab */

/*
 * The thing you read out loud, or paste in the chat. Glazed and set in mono,
 * because it is a machine fact that a human has to transcribe.
 */
.share {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  align-items: start;
}

.share-row {
  display: flex;
  align-items: stretch;
  gap: var(--s3);
  max-width: 100%;
}

/*
 * THE CODE IS THE LIT OBJECT — rule 3 of the tone, and the biggest single
 * change to this screen.
 *
 * ## What it was, and why that was a form field
 *
 * A slab: mono, on `--stock`, inside a 2px rim, with a glaze line. Every one of
 * those declarations says *inset panel*, and an inset panel with characters in
 * it is an input. `docs/design-verdict.md` §3 has the consequence — our lobby
 * *"renders, all at once and all expanded"* eleven things, so nothing is first
 * — and §5 has the diagnosis: *"hairline rectangles… no elevation, gradient, or
 * radius above 8px anywhere. It reads as a well-set document, not as a thing
 * you play with."*
 *
 * ## What it is now
 *
 * Type on the table with a pool of light on it. Three declarations do the work
 * and each is one of the seven rules:
 *
 *   **Serif, large.** `--font-lit` is the only serif in the product and it is
 *   spent here, on six characters, precisely so that it agrees with nothing
 *   else on the screen: two grotesks and a mono are the voice of the interface,
 *   and this is the object on the table rather than part of the interface.
 *
 *   **Lit.** A radial pool of `--lit` centred on the code, positioned from
 *   `--light-x`/`--light-y` like everything else in the room, and `--glow-lg`
 *   under it. That is one mechanism working in both lights, which is the whole
 *   reason it is a pool and not a text-shadow: on a deep ground it reads as a
 *   bloom, on cream it reads as lamplight falling on paper. A glow alone would
 *   have been invisible by daylight and the code would have been the lit object
 *   in one theme out of two.
 *
 *   **No rim.** The one object in this interface without one, and the house
 *   rule says why it can be: *"separation comes from the rim rather than from
 *   luminance"* — the rim is what lets an object survive a ground brighter than
 *   itself. This object is the brightest thing in the room by construction, so
 *   it is separated by the light, which is the only honest reason to drop the
 *   signature.
 *
 * ## Mono was load-bearing and is not
 *
 * The argument for mono on a code is transcription: `0`/`O` and `1`/`I` are the
 * same shape in a lot of proportional faces, and somebody is typing this into a
 * join box. That argument was already spent elsewhere and better —
 * `packages/platform/src/codes.ts` builds codes from a 29-character alphabet
 * that *"omits 0/O, 1/I/L and U/V — a code gets read aloud on a Teams call"*.
 * There is no ambiguous pair left for a typeface to disambiguate, so mono here
 * was buying nothing and costing the one thing this screen needed, which is for
 * six characters to look like an object somebody hands you.
 *
 * `tabular-nums` stays for the same reason it always applied: the six glyphs
 * should sit on a rhythm, not kern into words.
 */
.code {
  position: relative;
  /*
   * `isolation: isolate`, AND WITHOUT IT THE POOL IS PAINTED AND INVISIBLE.
   *
   * The pool below is a `z-index: -1` pseudo-element, which puts it behind its
   * own parent's background — and, with no stacking context in between, behind
   * every ancestor's background too. `.share--open` is an opaque panel, so the
   * light was being drawn underneath the plate it was supposed to be lying on.
   * Photographed twice before it was found, because "a very subtle glow" and
   * "a glow behind an opaque rectangle" look identical.
   *
   * Isolating confines the negative index to this element: the pool sits above
   * the band and below the type, which is where light on a surface goes.
   */
  isolation: isolate;
  font-family: var(--font-lit);
  font-weight: 700;
  letter-spacing: 0.045em;
  color: var(--on-table-strong);
  background: none;
  border: 0;
  box-shadow: none;
  padding: var(--s2) var(--s3) var(--s3);
  margin-inline-start: calc(var(--s3) * -1);
  line-height: 1;
  /*
   * MEASURED AGAINST THE COLUMN IT LIVES IN, not chosen as a nice big number.
   *
   * Six characters of Georgia at 68px set to about 300px, and the code column
   * of the boxed invitation has a 23rem (368px) floor with `--s4` of padding
   * each side — so 4.25rem is the largest size that cannot collide with the
   * game's print beside it at any width down to a phone. The first cut was
   * 5.25rem and the picture showed exactly what that costs: `HBQW89` ran under
   * the Word Tiles plate and lost its last character, in the one place on the
   * screen where every character has to be read out loud.
   */
  font-size: clamp(2.75rem, 6.6vw, 4.25rem);
  font-variant-numeric: tabular-nums;
}

/*
 * THE POOL OF LIGHT, AND IT IS A PSEUDO-ELEMENT BECAUSE A BACKGROUND HAS AN
 * EDGE.
 *
 * The first cut painted the gradient on `.code` itself and the photograph shows
 * exactly why that is wrong: the element's padding box clips the gradient, so
 * the dense end of it stops dead at a rounded rectangle and the code was
 * sitting on a slab again — the same slab this rule exists to remove, in a
 * lighter colour. Light does not have a border-radius.
 *
 * So the pool bleeds well past the type on a layer of its own, and
 * `closest-side` makes it reach transparent before it reaches any edge there is
 * to see. `z-index: -1` and not `opacity` on a wrapper, so the type stays at
 * full strength and the measured contrast is the contrast.
 *
 * `inset` is negative and asymmetric — wider than it is tall, and offset the
 * way the light is — because a lamp low and to one side pools wider than it
 * pools deep. It reads as a bloom in the evening and as a warm patch on paper
 * by daylight, which is one mechanism doing both jobs; a text-shadow glow would
 * have been the lit object in one theme and invisible in the other.
 */
/*
 * THE POOL IS ON THE CODE AND LEANS TOWARDS THE LAMP — halfway.
 *
 * Putting the gradient's centre AT `--light-x`/`--light-y` is the obvious
 * reading and it is wrong twice over. It puts the bright part of the pool in a
 * corner of this box, which is somewhere the code is not, so the lit object
 * would be unlit; and with `closest-side` at 6% of the width the radius
 * resolves to twenty-eight pixels, which is a coin of light nobody can see.
 * Photographed at both, which is how the second one was found.
 *
 * A lit object is lit ON THE SIDE FACING THE LAMP. So the pool is centred on
 * the code and displaced half the distance towards the light: at Word Tiles'
 * 6%/88% that is low and to the left of the type, exactly where the bright side
 * of a serif standing under a table lamp would be. Move the lamp and it
 * follows, which is rule 1 holding by arithmetic.
 *
 * ## THE LEAN MOVES THE BOX, AND IT USED TO MOVE THE GRADIENT
 *
 * It was written as `farthest-side at calc(50% + (--light-x - 50%) * 0.5) …`,
 * and that pair cannot fade out. `farthest-side` measures the radius to the
 * side FURTHEST from the centre, so displacing the centre towards the lamp
 * leaves the two NEAR sides well inside the `transparent 82%` stop: at Word
 * Tiles' 6%/88% the bottom edge sits at 46% of the vertical radius and the left
 * edge at 39% of the horizontal one, where the gradient is still around a
 * seventh of `--lit`. So it stopped dead in a straight line — a horizontal edge
 * across the panel level with the copy button, and a vertical one down the left
 * — on the two sides facing the lamp, which are the two brightest.
 *
 * Visible at 3× in a photograph of the real band and absent from all three of
 * the renderings that photograph was handed to, which is what sent anybody
 * looking: a hard edge inside a 34%-alpha wash reads as banding in the grain
 * until something draws the same panel without it.
 *
 * Two ways out and only one of them keeps the argument above. Pulling the last
 * stop in to 39% would fade the pool inside the box but shrink it to the coin
 * this rule already rejected once. So the DISPLACEMENT MOVES THE BOX INSTEAD
 * and the gradient sits back at its centre, where `farthest-side` and
 * `closest-side` are the same number and `transparent 82%` genuinely lands
 * inside every edge. Same lamp, same lean — 0.42 of the distance across a box
 * that is correspondingly bigger lands the pool's centre within a couple of
 * percent of where the gradient's centre used to sit — and no edge, because
 * light has none.
 *
 * The inset grows with it: a box displaced towards one corner needs the room
 * back on the other side. −80%/−42% brings the radius to within a tenth of what
 * the old `farthest-side` measured, and the whole of it is now spent fading,
 * where before the near half of it was cut off.
 *
 * `translate` rather than `transform`, so a future transform on this
 * pseudo-element cannot silently drop the lean; percentages on both resolve
 * against this box, which is the one the displacement is a fraction of.
 */
.code::before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: -80% -42%;
  translate: calc((var(--light-x) - 50%) * 0.42) calc((var(--light-y) - 50%) * 0.42);
  pointer-events: none;
  background: radial-gradient(
    farthest-side at 50% 50%,
    color-mix(in srgb, var(--lit) var(--lit-pool), transparent) 0%,
    color-mix(in srgb, var(--lit) calc(var(--lit-pool) / 2.6), transparent) 44%,
    transparent 82%
  );
}

/* Once play starts the invitation steps back to a line beside everything else. */
.share:not(.share--open) {
  flex-direction: row;
  align-items: center;
  gap: var(--s3);
}

/*
 * ...AND THE LIT OBJECT PUTS ITSELF OUT.
 *
 * Behind the room pill mid-round this is a reference, not the thing being read
 * aloud, and a glowing serif at reading-out-loud size in a hand's-width sheet
 * would be the room shouting a fact nobody asked for. So it goes back to being
 * a small chip of stock — which is what a machine fact printed for reference
 * has always looked like in this interface — and the light stays in the lobby
 * where the job is.
 *
 * `.roompill-head .code` joins it, and that is a fault this change surfaced
 * rather than caused. The immersive pill's sheet prints the room code beside
 * the word "Room" in a 23rem panel, and it has always taken the FULL `.code`
 * rule — 62px of mono in a header two lines tall. Nothing had ever scoped it,
 * because at 62px mono it was merely too big and at 68px of serif with a pool
 * of light behind it, it would have been the whole sheet.
 */
.share:not(.share--open) .code,
.roompill-head .code {
  font-family: var(--font-mono);
  font-weight: 600;
  letter-spacing: 0.1em;
  font-size: var(--t-body);
  color: var(--ink);
  background: var(--stock);
  padding: var(--s1) var(--s3);
  margin-inline-start: 0;
  border: var(--rim-hair) solid var(--rim);
  border-radius: var(--r-chip);
  box-shadow: var(--glaze-line);
}

.share:not(.share--open) .code::before,
.roompill-head .code::before {
  display: none;
}

/*
 * The address, and it is the one machine fact on this card that keeps its mono.
 *
 * THE UPPERCASE IS GONE AND IT WAS A BUG RATHER THAN A STYLE. A URL is
 * case-sensitive in its path, and this one carries the room code — so the
 * screen was printing `HTTP://FRIDAYFIKA.SE/JOIN/W3TFKZ` for a link that
 * resolves as lower case, in the one place somebody might retype rather than
 * copy. `normaliseJoinCode` upper-cases the code on the way in and would have
 * rescued that particular string, which is exactly why nobody noticed: the
 * interface was displaying something untrue and getting away with it.
 */
.share-link {
  font-family: var(--font-mono);
  font-size: var(--t-data-sm);
  letter-spacing: 0.06em;
  color: var(--dim);
  word-break: break-all;
}

/*
 * WHAT THE CODE IS STILL FOR, ON A TABLE NOBODY ELSE CAN JOIN.
 *
 * Set as prose and not as data — this is the one line on the plate that is a
 * sentence rather than a fact to be read out, and the mono uppercase around it
 * is the register of the code and the link. Held to a `measure` so it breaks
 * where a sentence should rather than at the width of whatever column it
 * landed in, which in the unboxed card behind the room pill is a hand's width.
 *
 * `room.js` draws it only when the room is full, so there is no open-room case
 * for this rule to be wrong in.
 */
.share-note {
  max-width: 34ch;
  font-size: var(--t-small);
  line-height: 1.45;
}

/* ----------------------------------------------------------- room layout */

.room {
  display: flex;
  flex-direction: column;
  gap: var(--s5);
  padding: var(--s5);
  min-height: 0;
}

/*
 * BETWEEN ROUNDS THE GAME COMES FIRST.
 *
 * `shell/room.js` sets `data-interlude` when the room is in lobby dress but
 * this panel has already had a live phase: between rounds, or at the end.
 * The panel is then holding what people came back to read — the last duel,
 * the ledger, "Read it back, then deal the next round" — and the invitation
 * slab was standing on it, 450px of it at 360 (2026-09-07). Before the first
 * round the order stays: the invitation is the point of that screen.
 */
:root[data-interlude="true"] .room > .panel-root {
  order: -1;
}

/*
 * ============================== THE ROOM HAS ONE LIGHT IN IT ==============
 *
 * Rules 1 and 2 of the tone, as three rules of CSS. `shell/room.js` publishes
 * `--lit`, `--unlit` and the two coordinates on the root from the game's own
 * entry in `games/registry.js`; everything below reads them and nothing below
 * decides anything for itself. That is the mechanism: there is one lamp and one
 * pair of coordinates on the page, so a highlight that disagreed with the light
 * would have to be written to disagree on purpose.
 *
 * ## Why this is on `.table-body` and not on `.room`
 *
 * The room screen is not one element — `.room` holds the invitation, the panel
 * and the shelf, and the people are an `<aside>` beside `<main>`. Lighting
 * `.room` would light the game and leave the roster in the dark, which is
 * exactly the fault §6 of the verdict is about. `.table-body` is the smallest
 * box that contains both.
 *
 * `:has()` keys it to a room being live rather than to a route: the aside is
 * `hidden` outside a room, so the front door and the shelf pages are untouched
 * and no rule here has to know what page it is on.
 *
 * ## Why it is a wash and not a ground
 *
 * The game supplies a colour and the palette supplies the room, and it is the
 * palette that wins. Fourteen palettes × two lights is twenty-eight grounds
 * this would otherwise have to be legible on; mixed as a percentage against
 * whatever `--table` currently is, it is safe on all of them by construction —
 * which is the same argument the invitation band's own six-percent wash records
 * a few hundred lines down, and the reason both are percentages rather than
 * colours. The percentages themselves were measured: `--lit-wash` came down
 * three times before `--dim` cleared 4.5:1 on the washed ground in all
 * twenty-eight, and `tokens.css` records the failing palettes and the numbers.
 *
 * READ THAT SENTENCE WITH THE NOTE ON THE RULE BELOW BESIDE IT. Every one of
 * those measurements is an EVENING, the third gradient is in none of them, and
 * eleven of the fourteen daylights were failing on the roster when somebody
 * finally put a camera on one. The rule below carries the numbers and the two
 * changes that closed it.
 *
 * A game with no room in the catalogue publishes nothing, the `var()` fallbacks
 * resolve to the palette's own accent and the default corner, and the screen is
 * the palette's room instead of the game's. Plainer, never broken — the same
 * ladder the hero print walks down.
 */
.table-body:has(> .people:not([hidden])) {
  /*
   * ========================= THE DAYLIGHT HALF WAS NEVER MEASURED ==========
   *
   * The paragraph above says `--lit-wash` *"came down three times before
   * `--dim` cleared 4.5:1 on the washed ground in all twenty-eight"*, and
   * tokens.css tells that story in full: 20% → 13% → 10% → **9%**, *"clears
   * everywhere at 4.57:1 worst"*.
   *
   * **Every number in it is an evening.** 9% is the dark theme's value;
   * daylight's is 12% and no line anywhere says it was photographed. It was
   * not, and it did not clear.
   *
   * Photographed on `.seat-tag` — the "host" label under a name in the roster,
   * plain `--dim`, a declaration nothing in this sweep touched — at 1440×900,
   * fourteen palettes × two lights:
   *
   *     ELEVEN OF THE FOURTEEN FAIL IN DAYLIGHT.  citron 3.50:1, mynta 3.52,
   *     skiffer 3.61, fika 3.65, persika 3.65, lingon 3.76, tegel 3.88,
   *     bibliotek 3.89, plommon 4.20, marin 4.33, björk 4.47 — floor 4.5.
   *     All fourteen EVENINGS pass, every one.
   *
   * That split is the diagnosis. `--lit-wash` is not the term that bites here:
   * in daylight the lamp's two gradients LIGHTEN, and what darkens is the third
   * one — the corner the light does not reach, mixed from the game's own
   * `--unlit`. The roster is the column that lives in that corner, on the 24
   * of 37 games whose lamp hangs bottom-left. So two changes, and it takes
   * both:
   *
   *   1. `--dim: var(--dim-lift)` — `--dim-lift`'s own argument (*"`--dim` is
   *      a contrast against `--table`, and a `--panel` box is not `--table`"*)
   *      applied to the other surface that is not `--table`: the table with a
   *      lamp on it. On the same selector as the wash, so the fact that
   *      darkens the ground and the fact that re-cuts the ink cannot drift
   *      apart. **Alone it closes five of the eleven** and leaves six at
   *      4.08–4.41.
   *   2. The unlit corner, **34% → 22%**. Walked down the way tokens.css walked
   *      the evening's wash down, and for the same reason: 28% still failed
   *      citron at 4.39 and mynta at 4.41, 25% cleared everywhere with no room
   *      in it, and 22% clears all twenty-eight at **4.71:1 worst** (citron,
   *      light) — a margin, which is what the 4.57 above was chosen to have.
   *
   * MEASURED IN ONE ROOM, AND THAT ROOM IS THE WORST CASE. `--unlit` is the
   * game's, so this reading is Safe Hands' `#34120f`. Of the 24 games lit from
   * bottom-left the grounds run 0.0119 to 0.0130 in relative luminance — a
   * spread under a tenth — and Safe Hands is the second darkest of the 24. The
   * other 13 hang the lamp top-right, which puts the roster in the LIT half and
   * cannot be worse than this. `tests/e2e/quiet-is-not-invisible.spec.ts` holds
   * all 28 cells, and is proven able to fail by putting either number back.
   */
  --dim: var(--dim-lift);
  background-image:
    /* The lamp. */
    radial-gradient(
      70% 60% at var(--light-x, 8%) var(--light-y, 86%),
      color-mix(in srgb, var(--lit) var(--lit-wash), transparent) 0%,
      transparent 70%
    ),
    /* Its spill along the surface it stands on. */
    radial-gradient(
      110% 50% at var(--light-x, 8%) 100%,
      color-mix(in srgb, var(--lit) calc(var(--lit-wash) / 2), transparent) 0%,
      transparent 64%
    ),
    /* And the corner it does not reach, which is the game's own dark. */
    radial-gradient(
      80% 70% at calc(100% - var(--light-x, 8%)) calc(100% - var(--light-y, 86%)),
      color-mix(in srgb, var(--unlit, var(--table-deep)) 22%, transparent) 0%,
      transparent 72%
    );
  background-attachment: fixed;
}

/* ============================== THE ROOM'S VOICE =========================
 *
 * §1 of the verdict counted eleven mono-caps labels in this one lobby and only
 * two of them were the shell's — the other nine are a game's own `.data` field
 * labels inside its panel ("HOW LONG IS A ROUND", "HOW MANY ROUNDS", "SOMEBODY
 * SHORT? ASK ONE OF THESE"). Those live in thirty-seven files this stylesheet
 * is not allowed to know the names of.
 *
 * So the fix is here, once, and it is the smaller of the two changes it looks
 * like: **`.data` keeps its mono and loses its shouting, inside a room.**
 *
 *   - Mono is right and stays. A count, a duration, a score — these are machine
 *     facts and the house voice for a machine fact is a monospace. The rule the
 *     verdict actually wrote is *"mono is for machine facts"*, and it is a rule
 *     about WHERE mono goes, not about mono being wrong.
 *   - Uppercase is what removes word-shape, and word-shape is what fast readers
 *     read. It buys nothing on `45s` and costs a whole sentence on "somebody
 *     short? ask one of these".
 *
 * Scoped to the room rather than applied globally, and that is deliberate
 * caution rather than principle: the front door, the game sheets and the
 * television are twenty-odd screens this pass did not measure, and a house rule
 * is worth applying everywhere only once somebody has looked at everywhere. The
 * room is what was measured. The rest is a follow-up with a photograph
 * attached, not a sed.
 */
.room .data,
.people .data {
  text-transform: none;
  letter-spacing: 0.04em;
}

/*
 * Between rounds the invitation is the page's job, so it is a field rather than
 * a stack of text: a glazed panel with the code on it, at the top of the room.
 *
 * It disappears the moment a clock starts — see `idle` in room.js — which is
 * why giving it this much weight costs nothing during play.
 */
.share--open {
  align-items: start;
  padding: var(--s5);
  background-color: var(--panel);
  background-image: var(--grain);
  background-size: var(--grain-size);
  background-position: var(--grain-pos);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-field);
  box-shadow: var(--glaze-line);
}

/*
 * ...AND IN A LOBBY IT IS THE OPEN BOX.
 *
 * The band is the same object with the game's picture bled into the right of
 * it: code on stock at one end, the print running to three edges at the other.
 * `room.js`'s `heroPlate` argues why the picture goes in here rather than in a
 * band of its own — briefly, because this plate is already this tall for the
 * code's sake, so the picture is free, and a game's own lobby copy is what
 * would otherwise have been pushed under the fold to make room for it.
 *
 * The picture is set to BLEED. It runs to the plate's inner edge on three
 * sides — negative margins against the band's own padding — and takes the
 * plate's radius on the two corners it meets. An inset picture would be a
 * mount inside a mount, which is the same reason `.lid-art` has no margin of
 * its own; a bled one reads as the lid of the box the code is lying on.
 *
 * `1.15fr` against `minmax(19rem, 1fr)`: the code side has a floor, because
 * six characters at 62px is a width below which the slab itself would have to
 * shrink and reading it out is the whole job; above that floor the picture
 * takes slightly MORE than half, because a hero that is the smaller half of a
 * plate is a thumbnail. At 1280 that lands the print at very nearly the 3:2 it
 * was drawn as, so the crop takes almost nothing off it.
 *
 * AND THE COPY BUTTON IS ALLOWED TO STEP UNDER THE CODE. That row is `nowrap`
 * everywhere else, and holding it to one line here squeezed the button until
 * "Copy link" broke across two lines inside its own slab and ran under the
 * picture. Wrapping is the honest response to a column that has genuinely
 * become narrow: the button drops below the slab at desk width and sits back
 * beside it on a wide screen, with no breakpoint deciding which.
 */
/* 23rem and it was 19rem. The floor exists because *"six characters at 62px is
   a width below which the slab itself would have to shrink and reading it out
   is the whole job"* — the argument is unchanged and the arithmetic moved: the
   code is a serif now and Georgia sets six capitals wider than Plex Mono does
   at the same size. Measured rather than guessed; `.code` above carries the
   picture of what the old floor did. */
.share--boxed {
  display: grid;
  grid-template-columns: minmax(23rem, 1fr) minmax(0, 1.15fr);
  align-items: stretch;
  gap: var(--s5);
}

.share--boxed .share-row {
  flex-wrap: wrap;
}

/*
 * A PHONE HELD SIDEWAYS HAS NO ROOM FOR THE PICTURE — 2026-08-28.
 *
 * `action-sweep` on the shelf, at 750x342: avalon's *"1 more to play"* is
 * pinned and still off the bottom of the glass, and the sweep names the repair
 * itself — *"its containing block `section.stage` starts at 395 in a 342px
 * window, and a sticky box cannot be lifted above the top of its own block, so
 * the pin can never reach the glass. Whatever is stacked above the stage is the
 * repair, not the pin."*
 *
 * Measured at scroll zero in that window: the rail is 60px and **the invitation
 * card is 236** — of a 342px screen. The card is `.share--boxed`, two columns
 * at that width, and its height is the game's own picture in the right-hand
 * one. On a tall phone that plate is the best thing on the screen. Turned
 * sideways it is the whole screen, and the control that starts the game is
 * below it.
 *
 * So the plate goes and the code stays. `room.js` already treats a missing
 * picture as a general case — *"a game with no picture at all takes the plate
 * off the DOM and leaves the band exactly as it was before any of this"* — so
 * a band with no plate is a state this card is already built for, and the one
 * column it falls back to is the one carrying the code, the copy and the way
 * in. The picture is decorative and `aria-hidden`; nothing is announced twice
 * or lost.
 *
 * `max-height` rather than `orientation: landscape`, because the constraint is
 * vertical room and not which way up the phone is: a short window on a desk has
 * the same problem and an iPad in landscape has 768px and does not.
 */
@media (max-height: 480px) {
  .share--boxed {
    grid-template-columns: minmax(0, 1fr);
  }

  .share--boxed .lobby-hero {
    display: none;
  }
}

/*
 * The band takes the ink of the game on the table.
 *
 * Set through `--game-ink`, which `shell/room.js` publishes from the plate's
 * own dominant colour — derived from the picture rather than chosen beside it,
 * so a redrawn lid changes the room with it instead of leaving a hand-picked
 * accent behind disagreeing.
 *
 * Six percent, and mixed against `--table` rather than stated as a colour.
 * That is what makes one rule safe across twelve palettes and two lights: the
 * wash is always a short step from whatever ground the room is already on, so
 * it can tint a cream table and a deep one without knowing which it got. The
 * hairline takes more of it, because an edge can carry colour that a field
 * underneath running text cannot.
 *
 * A game with no plate has no `--game-ink` and the fallbacks make this rule a
 * no-op, which is the same degradation the hero uses one line above.
 */
.share {
  background: color-mix(in srgb, var(--game-ink, transparent) 6%, var(--table));
  border-color: color-mix(in srgb, var(--game-ink, transparent) 28%, var(--edge-hair, var(--dim)));
}

/*
 * The code side of the plate, TOP-ALIGNED, and that is the fallback's doing.
 *
 * It was centred first, which is invisible at desk width — the code column is
 * taller than the picture's floor there — and wrong in two places at once. On
 * a wide screen, where the copy button sits back beside the slab and the column
 * is shorter than the floor, centring floated the code in the middle of a plate
 * whose picture bleeds to the top edge, so the two halves lined up with
 * nothing. And when the picture failed and the plate came off the DOM, the band
 * shrank to the code's own height and the code moved four pixels UP — which
 * `portal.spec.ts` caught, because "the band is exactly what it was before any
 * of this" is a promise a fallback either keeps or does not.
 */
.share-code {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  align-items: start;
  justify-content: flex-start;
  min-width: 0;
}

/*
 * The plate the picture is printed on — the lid's own ground, so a hero that
 * has not arrived yet is a blank plate of the right shape rather than a hole
 * that collapses the band and moves the code as it loads.
 */
.lobby-hero {
  position: relative;
  overflow: hidden;
  min-height: 13rem;
  margin: calc(var(--s5) * -1) calc(var(--s5) * -1) calc(var(--s5) * -1) 0;
  border-left: var(--rim-hair) solid var(--rim);
  border-radius: 0 var(--r-field) var(--r-field) 0;
  background-color: var(--stock);
  background-image: var(--grain);
  background-size: var(--grain-size);
  background-position: var(--grain-pos);
}

/*
 * THE LABEL ON THE LID.
 *
 * The name of the game, the two facts about it, and the control that plays it
 * back — laid along the foot of the print, on a bar of stock, the way a label
 * is pasted on a box.
 *
 * **On stock and not on a scrim.** A gradient over the picture would be
 * legible on some of the thirty-seven prints and not on others, and the set is
 * deliberately not tuned to a common value: `docs/design.md` argues that a
 * boxed game keeps its own inks whatever the tablecloth is. An opaque bar is
 * the only ground that reads the same on all of them, and it is also the house
 * material — cream enamel with a hairline — rather than a web scrim.
 *
 * **It takes the bottom sixth**, which is the strip `docs/design.md` already
 * reserves for nothing important because a phone crops a print to a letterbox.
 * So it costs the band no height at all: the plate is stretched to whatever the
 * code column beside it is, and the picture gives up the sixth it was told to
 * keep clear.
 *
 * `absolute` rather than a row in a flex column, because the picture is
 * `object-fit: cover` at 100% height and must go on filling the plate — the bar
 * lies ON the print and does not take a share of it. The phone rule below is
 * the one exception and says why.
 */
.lobby-plate {
  display: flex;
  gap: var(--s4);
  align-items: center;
  justify-content: space-between;
  padding: var(--s3) var(--s4);
  background-color: var(--stock);
  background-image: var(--grain);
  background-size: var(--grain-size);
  background-position: var(--grain-pos);
  /* `--ink` is the one colour that does not flip with the light, because stock
     does not either — the same reason a card back carries its own. */
  color: var(--ink);
}

/* Laid on the print. Lifted off it — see the fallback in `heroPlate` — the bar
   keeps its material and stops being positioned. */
.lobby-plate--onprint {
  position: absolute;
  z-index: 1;
  inset-inline: 0;
  bottom: 0;
  border-top: var(--rim-hair) solid var(--rim);
}

/*
 * The third rung of the picture's ladder, and the label lands on it.
 *
 * With no plate at all there is nothing to lie along, so the bar becomes an
 * object in its own right at the foot of the code column — rimmed and rounded,
 * because a patch of stock with no edge on it reads as a hole in the band
 * rather than as a label. It goes last, after the address and the television
 * link, so the code slab does not move: `portal.spec.ts` measures exactly that.
 */
.share-code > .lobby-plate {
  align-self: stretch;
  border: var(--rim-hair) solid var(--rim);
  border-radius: var(--r-chip);
}

.lobby-plate-said {
  min-width: 0;
}

/*
 * A step under the prompt and well under the code slab, on purpose.
 *
 * This is a label on a box, not the heading of the screen. A host in a lobby is
 * here to get people into the room, so the six characters stay the loudest
 * thing on the band; the name only has to be *somewhere*, which for
 * twenty-eight of the thirty-seven games it was not.
 */
.lobby-plate-name {
  font-family: var(--font-display);
  font-size: var(--t-name);
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: var(--track-display);
  color: var(--ink);
  overflow-wrap: anywhere;
}

/* `.data--print` carries the colour: a machine fact printed on glaze rather
   than on the table, which is the distinction that token exists for. */
.lobby-plate-facts {
  margin-top: 0.15rem;
}

/* Neither fact ever breaks inside itself — `3–6` and `15 min` are each one
   thing — so the only place the line can give is between them. */
.lobby-plate-fact {
  white-space: nowrap;
}

/*
 * ON A PHONE THE TWO FACTS ARE TWO LINES, SO THE SEPARATOR GOES.
 *
 * The strip wants about 186px of tracked mono and a 390px screen leaves the
 * label around 170 once the explainer has its slab, so it wraps — and a wrapped
 * `A · B` ends its first line on a dot that is no longer between anything. The
 * two facts stacked, with no dot, is what the line was going to be anyway; this
 * only stops it announcing the wrap.
 */
@media (max-width: 479px) {
  .lobby-plate-dot {
    display: none;
  }

  .lobby-plate-fact {
    display: block;
  }
}

.lobby-hero-art {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Every print in the set is composed on its own subject, so the crop is
     taken from the middle and never from a per-game focus point — the shell
     is not allowed to hold a fact about one game, and a picture that needs one
     is a picture that was drawn wrong. */
  object-position: center;
}

/*
 * ON A PHONE THE BOX IS SHUT AGAIN, AND THE CODE STILL LEADS.
 *
 * Two columns at 390px is two things too narrow to be either. Stacked, the
 * order is the one the band already argues for — the code is what a host is
 * here to do something about, so it stays first and the picture follows it as
 * a letterbox strip. 3:1 rather than 3:2 because a portrait screen has one
 * scarce axis and it is this one; the prints are composed centrally, so the
 * band takes the middle of the same picture.
 */
@media (max-width: 959px) {
  .share--boxed {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s4);
  }

  .lobby-hero {
    min-height: 0;
    aspect-ratio: auto;
    margin: 0 calc(var(--s5) * -1) calc(var(--s5) * -1);
    border-left: 0;
    border-top: var(--rim-hair) solid var(--rim);
    border-radius: 0 0 var(--r-field) var(--r-field);
  }

  /*
   * AND THE LABEL STOPS LYING ON THE PICTURE, BECAUSE HERE THERE IS NO SIXTH
   * TO SPARE.
   *
   * On a desk the plate is stretched to the code column and is around 340px
   * tall, so a 56px bar is the bottom sixth the design already reserves and
   * the picture loses nothing it was using. Stacked on a phone the strip is a
   * 3:1 letterbox about 118px tall, and the same bar is half of it — a label
   * with a sliver of picture above it rather than a picture with a label on
   * it.
   *
   * So the plate becomes a column: the print keeps its own 3:1 and the label
   * sits under it, on the same stock, still inside the same object. The band
   * grows by the height of the bar, which is the one screen where that is
   * worth paying — somebody who has just tapped a link on a phone is the
   * person least likely to know what they have joined.
   */
  .lobby-hero-art {
    aspect-ratio: 3 / 1;
    height: auto;
  }

  .lobby-plate--onprint {
    position: static;
    border-top: var(--rim-hair) solid var(--rim);
  }
}

/*
 * A SHORT WINDOW GETS WHATEVER HEIGHT THE CODE WAS GOING TO COST, AND NOT A
 * PIXEL MORE.
 *
 * The floor above is a minimum for a picture, not a claim on the layout: it
 * only ever binds on a wide screen, where the copy button sits back beside the
 * slab and the code column would otherwise be 150px tall with a letterbox
 * strip beside it. Dropping it on a short viewport makes the picture exactly
 * as tall as the code is, which is the one composition where the hero is
 * genuinely free — 1600×380 is a browser window somebody has squashed, the
 * room lays out with a people column there, and `panel.spec.ts` gates three
 * games at that size precisely because moving height around in this band
 * rearranges what sits under what.
 */
@media (max-height: 640px) {
  .lobby-hero {
    min-height: 0;
  }
}

/*
 * And on a phone held sideways THE PICTURE goes, for the same reason the
 * address line above it does: there the band IS the screen, and the note on
 * `.share--open` measures what that cost. A picture of the game, on top of the
 * game, is the first thing that should give.
 *
 * THE LABEL STAYS, and the note below is the reason. It says what was wrong
 * with this screen when it was photographed at 750×342: "No name of the game,
 * nobody at the table, no way to begin." The print was never what was going to
 * answer the first of those — for twenty-eight of the thirty-seven games
 * nothing on this screen did. A 50px bar carrying the name and the two facts is
 * the cheapest thing on the band and the only one that answers it, so what
 * gives is the 118px of picture above it and not the line of type on it.
 */
@media (max-height: 480px) and (max-width: 900px) {
  .lobby-hero {
    display: block;
    min-height: 0;
    aspect-ratio: auto;
    border-top: 0;
  }

  .lobby-hero-art {
    display: none;
  }
}

/*
 * AND ON A SMALL PHONE HELD UPRIGHT, FOR THE SAME REASON, WHICH WAS NEVER
 * ABOUT WHICH WAY THE PHONE WAS TURNED.
 *
 * The rule above is written as a landscape rule and its argument is not one: it
 * says the picture gives when the band is being asked for more of the screen
 * than the screen has. That is also true at 360×640, and it is exactly what
 * `lobby-phone.spec.ts` caught the day the pictures came in.
 *
 * Measured, 360×640, host's view, four at the table: the print is a 3:1
 * letterbox 108px tall and the plate around it 191px, and the control that
 * starts the game lands at y=711 — in **all eleven** of that size's lobbies,
 * below a 640px fold. (The buttons were called "Deal the cards", "Start
 * sweeping" and "Rack them up" when this was measured; they name their game
 * now, and are the same height.) The plate was composed onto this screen before any game had a
 * picture; the note two blocks up calls the extra height "the one screen where
 * that is worth paying", and at 640px it is not the screen paying for it, it is
 * the button.
 *
 * THE LABEL STAYS, whole — the name, the two facts and "See it played" — for
 * the reason the landscape note gives at length: it is the only thing on this
 * screen that says what somebody has just joined, and somebody on a phone got
 * here by tapping a forwarded link.
 *
 * `max-height: 700px` and not the width alone, because 390×844 keeps the
 * picture and should: the band fits there with the primary above the fold, the
 * print is the best thing on the screen, and the two phone sizes the owner
 * named differ by 204px — which is most of a plate. This is the smaller of the
 * two, and the boundary is where the height stops covering the band.
 */
@media (max-width: 599px) and (max-height: 700px) {
  .lobby-hero {
    display: block;
    min-height: 0;
    aspect-ratio: auto;
    border-top: 0;
  }

  .lobby-hero-art {
    display: none;
  }

  /*
   * AND "AT THE TABLE · 4" IS CLIPPED, FOR THE REASON "READ THIS OUT ON THE
   * CALL" IS.
   *
   * The same move, thirty lines further down the same screen: kept in the
   * accessible tree for anybody having the page read to them, and taken off the
   * glass, because what it labels is directly underneath it and countable. Four
   * name chips in a row say *at the table · 4* better than the words do, and
   * they say who.
   *
   * Only on the small phone. At 390×844 the count is 27px the screen can afford
   * and a label over a list is worth having; at 360×640 those 27px are the
   * difference between "Claim the belt" being on the screen and being under it.
   */
  :root[data-lobby="true"] .people-head {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }
}

/*
 * AND IT STANDS DOWN ON A SHORT SCREEN, BECAUSE THERE IT IS THE WHOLE SCREEN.
 *
 * Photographed at 750×342 — a real iPhone 13 in landscape — in a room with a
 * game already on the table: the first screenful was the band and nothing
 * else. No name of the game, nobody at the table, no way to begin, and no
 * indication that any of it existed below. The weight is right on a laptop and
 * on a phone held upright, where the band leads and the lobby follows it in
 * the same view; it is only wrong when the viewport is shorter than the band.
 *
 * The code itself is not what gives, because reading it out is the whole job:
 * it drops from 62px to 40px, which is still by some way the largest thing on
 * the page. What goes is the air around it and the address line underneath —
 * a URL that is also in the address bar, on the one device where the address
 * bar is right there. Measured at 750×342: 216px to 118px, which is the
 * difference between a game you have to scroll to find and one you can see.
 *
 * AND ONLY WHEN IT IS ALSO NARROW, which is not belt and braces. Short AND
 * narrow is a phone held sideways, and it is the same corner the room's own
 * phone layout is written for (`max-width: 959px` above). Short and WIDE is a
 * browser window somebody has squashed on a desk, where the room lays out as a
 * grid with a people column, the foot is a row rather than a sticky band, and
 * moving 110px of height around rearranges what sits under what — `panel.spec`
 * gates three games at 1600×380 and caught exactly that. A phone rule belongs
 * behind a phone's width.
 */
@media (max-height: 480px) and (max-width: 900px) {
  .share--open {
    gap: var(--s2);
    padding: var(--s3) var(--s4);
  }

  .share--open .code {
    font-size: clamp(1.75rem, 4vw, 2.5rem);
    padding: var(--s2) var(--s3);
  }

  .share--open .share-link {
    display: none;
  }
}

/* The one root a game panel owns. Everything inside it belongs to the game. */
/*
 * THE PANEL IS SIZED FROM ITS CONTENT. That one sentence is the fourth attempt
 * at this and the only one that is not an `overflow` rule, and the history is
 * kept below because three plausible one-liners are on it.
 *
 * `.panel-root` and the `.stage` inside it are both `flex: 1; min-height: 0`,
 * so in a room of FIXED HEIGHT a panel that does not fit was squeezed and —
 * with the default `overflow: visible` — went on painting *outside* its box,
 * across whatever was under it. Two real bugs: a Minesweeper board drawn
 * through the game picker, and "Deal the cards" rendered underneath "Play
 * something else".
 *
 *   1. `overflow: auto` alone made it worse. The panel becomes a scroll
 *      container and the scrollbar appearing and disappearing as an
 *      aspect-ratio board reflows leaves every cell permanently "unstable":
 *      Minesweeper became unclickable, in two tests, for a reason no screenshot
 *      would show.
 *   2. `overflow: clip` avoids that and silently swallows content, which is how
 *      both original bugs would have gone unnoticed instead of being reported.
 *   3. `min-height: 18rem` + `overflow: auto` + `scrollbar-gutter: stable` —
 *      shipped, and it did work: the floor gave the scrollport something to be,
 *      and the reserved gutter meant no scrollbar could change a box's width,
 *      which was the actual mechanism behind (1). What it could not do is stop
 *      being a scrollbar. The owner: *"No page should have div scrollbars."*
 *
 * The note that shipped with (3) had already written down where this goes:
 * *"If a general fix is wanted it needs to reserve gutter space or size the
 * stage from its content, not a one-line overflow."* This is the second of
 * those. **There is no squeeze any more** — `.shell` has a floor rather than a
 * clamp, the table is a flex column, `main` no longer scrolls, and the room's
 * column grows with what is in it — so a panel is as tall as its game, the two
 * original bugs have no mechanism left, and there is no bar because there is no
 * overflow. The document takes what the panel could not.
 *
 * The floor stays. 18rem is the stage a 390px phone already hands every game,
 * and it is what keeps a panel from being nothing at all in some composition
 * nobody has thought of yet; on a page it costs only a game that wanted to be
 * shorter than a phone screen, which is not a game.
 *
 * `tests/e2e/panel.spec.ts` is still the gate on the first two failures —
 * nothing paints outside the panel, nothing is swallowed by a clip it cannot be
 * scrolled to, every control hit-tests to itself and holds still — and
 * `tests/e2e/shell.spec.ts` is the gate on the third: no box inside the room
 * draws a scrollbar of its own, at seven sizes, across five games.
 */
.panel-root {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/*
 * `hidden` has to be said out loud here.
 *
 * The UA stylesheet's `[hidden] { display: none }` is a single attribute
 * selector, so the class rule above outranks it and the element stays a flex
 * container — visibly, while claiming to be hidden. Any element given both a
 * `display` and an occasional `hidden` needs this pairing; it is the quietest
 * way to lose an hour in CSS.
 */
.panel-root[hidden] {
  display: none;
}

/*
 * The panel's own box, and it must pass the height through.
 *
 * `.panel-root` is a column flex and every panel's `.stage` is `flex: 1;
 * min-height: 0` — that chain is what lets a board fill the room and scroll
 * inside itself instead of growing the page. Putting `.panel-body` between the
 * two breaks the chain unless it repeats both halves: without `flex: 1` the
 * body is content-sized and the stage has nothing to fill, and without
 * `min-height: 0` a flex item refuses to shrink below its content, which is
 * how a board ends up taller than the window with no scrollbar to reach it.
 *
 * This is the same fault the room's own comment records about the chooser
 * squeezing the panel to zero height — the other direction of one rule.
 */
.panel-body {
  flex: 1;
  min-height: 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  /*
   * ROOM FOR THE STRIP TO STAND IN.
   *
   * `.stage-status` is sticky and opaque, so when it is pinned it covers the
   * bottom of this box. `scroll-padding` handles the case where the answer is
   * to scroll; it cannot help a lobby SHORT ENOUGH THAT NOTHING SCROLLS, and
   * that is where this was caught: Word Chain's "Lay it" at y=763 in an 800px
   * window, with `elementFromPoint` at its centre returning the prompt. There
   * was nowhere to scroll to — the strip was simply standing on it.
   *
   * So the game's own box gives up the height the strip can occupy, the way a
   * page gives up a margin. `--stage-foot` is the strip's measured height,
   * published by `shell/room.js`, and zero when there is no strip.
   */
  padding-bottom: var(--stage-foot, 0px);
}

/*
 * Immersive has no strip at the foot to reserve for, so nothing is given up.
 *
 * THE REASON IN THIS COMMENT WAS WRONG — 2026-08-23. It read *"immersive puts
 * the strip at the TOP (`order: -1`, static)"*, and there is no such rule
 * anywhere in this sheet: the only thing this mode says about `.stage-status`
 * is `display: none`, six hundred lines down, under a heading arguing at
 * length why the strip may not paint in a field. The declaration below is
 * right for that reason and was being explained by another one.
 *
 * Worth correcting rather than leaving, because the sentence described a
 * strip that is on the screen somewhere — and it is exactly that reading that
 * left four games running a clock nobody could see for as long as it stood.
 * `shell/immersive.js` adopts the clock into the room banner now, which is a
 * different node in a different row and does not change this line.
 */
:root[data-immersive="true"] .panel-body {
  padding-bottom: 0;
}

/*
 * An empty body claims nothing.
 *
 * Between rounds the shell empties this and opens the shelf beneath it. With
 * `flex: 1` still applying, the empty box took the whole room and pushed the
 * shelf off the bottom of the window — measured as zero cards on screen where
 * the test wants at least three. `panelRoot` was content-sized before there
 * was a body inside it, and this is how it stays content-sized now.
 */
.panel-body:empty,
.panel-body[hidden] {
  flex: none;
}

/* Said out loud for the same reason `.panel-root[hidden]` is: the class rule
   above sets `display: flex`, which outranks the UA's `[hidden]` selector. */
.panel-body[hidden] {
  display: none;
}

/* The people sit beside the game, not above it: a fixed column, so the panel
   never reflows when somebody's tag changes. */
.table-body {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  min-height: 0;
  /*
   * Grow into a tall window, never shrink below what is in it.
   *
   * `flex-grow: 1` is what keeps a nearly-empty room composed to the glass;
   * `flex-shrink: 0` with a `flex-basis: auto` is what stops the old fault, in
   * which this row was handed "whatever is left" and one short viewport later
   * that was **zero** — see the note above `.panel-root`, which is a long
   * account of what a game does when it is given no height at all.
   */
  flex: 1 0 auto;
}

/*
 * The body grows the page rather than scrolling inside it.
 *
 * This was `overflow: auto` — the cabinet's scrollport, and the largest of the
 * div scrollbars the owner is pointing at: measured at 1600×380 with Minesweeper
 * on the table, a 214px box over 621px of content. There is nothing left for it
 * to do. The document scrolls now, so what does not fit is reached by the
 * window's own bar, in one gesture, with the prompt pinned to the bottom edge
 * throughout.
 *
 * `min-height: 0` stays because immersive mode still needs it: there the table
 * IS a fixed two-row grid again and this is the row that must be allowed to be
 * exactly the viewport.
 */
.table-body > main {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  overflow: visible;
}

.room,
.panel-root,
.play,
.stage {
  flex: 1;
  min-height: 0;
}

/*
 * ...and the panel alone gets a floor, because it is the only one of the four
 * that holds content the shell cannot see.
 *
 * 18rem is the stage a 390px phone already hands every game (see the room's
 * padding note below: 334px), so it is the size the product has already decided
 * a game can be played in, not a number invented here. It is a MINIMUM and
 * nothing else: a game taller than that makes the room taller, which makes the
 * page taller, which is the whole model.
 *
 * `min-content` was tried here first and is wrong, which is worth writing down
 * because it reads like the obvious answer: in the block axis it is the
 * content's PREFERRED height, not the height below which it breaks. Out of
 * Place's questioning screen lays out happily in 415px and reports 622px, so a
 * content floor grew the room by 207px it did not need and put the spy's own
 * card half below the fold on a 1280×720 laptop. A stage that can fit itself
 * must be allowed to.
 *
 * There is no `overflow` here and no `scrollbar-gutter` any more; both went
 * with the cabinet, and the note above says why.
 */
.panel-root {
  min-height: 18rem;
}

/*
 * A floor under nothing is 18rem of nothing.
 *
 * With no game on the table the shell leaves the panel empty and draws the
 * shelf below it, and a reserved 288px pushed the shelf clean off a 1440×900
 * screen — measured, four cards on screen before, zero after. An empty box has
 * no content to contain, so it takes no room.
 *
 * THIS USED TO READ `.panel-root:empty`, and the status strip is why it cannot
 * any more: the root now always holds two children — the body the game mounts
 * into, and the strip that says what the game is waiting for — so it is never
 * empty and the floor never lifted. The identical 288px, the identical zero
 * cards on a 1440×900 screen. Finding this note already here, describing the
 * measurement being taken again, is what named it.
 *
 * So the question moves down one level to the box that actually holds the
 * game. The body is empty between rounds and hidden while the shelf is open,
 * and either way there is no game to give a floor to.
 */
.panel-root:has(> .panel-body:empty),
.panel-root:has(> .panel-body[hidden]) {
  flex: none;
  min-height: 0;
}

/*
 * Except when the game IS the screen.
 *
 * Immersive mode promises one viewport, nothing scrolling, the field edge to
 * edge — `main` is `overflow: hidden` there on purpose and says in as many
 * words that a scroll container is how the board became a window onto a board.
 * A game that has claimed the glass owns its own containment by contract, and a
 * floor written for a document would push the field past the fold.
 */
:root[data-immersive="true"] .panel-root {
  min-height: 0;
}

/*
 * THE PROMPT IS PINNED, AT EVERY SIZE. This is the price of the page model and
 * the place it is paid.
 *
 * The cabinet kept "what do I do right now" on screen by never letting the page
 * be taller than the screen. A page can be, so the one line that must never
 * leave has to hold the bottom edge itself — opaque, over its own hairline, so
 * what scrolls under it is covered rather than collided with. The phone block
 * has pinned it this way since it was written; there was never a reason it was
 * right at 390px and wrong at 1280 other than that the desktop did not need it.
 *
 * Its measured height is published as `--shell-foot` (see chrome.js) so a game
 * that pins its own controls can stack above it instead of underneath it.
 *
 * DECLARED BEFORE THE PHONE BLOCK BELOW, and that is not cosmetic ordering: two
 * `.table-foot` rules have the same specificity, so whichever is written last
 * wins. This rule used to sit *after* the phone block and quietly took the
 * phone's own padding and gap back off it — a foot with 48px of desk padding on
 * a 342px-tall phone, which matters much more now that it is pinned there.
 */
/*
 * THE GAME'S OWN STATUS STRIP.
 *
 * What `.table-foot` used to be, moved inside `.panel-root` where the game it
 * speaks for can lay it out. It is the last row of the game's screen rather
 * than the last row of the SITE, so it does not need `position: sticky`, does
 * not need a z-index above the room, and — the part that mattered — nothing
 * pins itself over a game any more.
 *
 * `.panel-root` is a column flex whose body is `flex: 1`, so this sits at the
 * foot of the board without being told to. The hairline is what separates the
 * game's account of itself from the game.
 */
.stage-status {
  flex: none;
  display: flex;
  align-items: baseline;
  gap: var(--s4);
  flex-wrap: wrap;
  padding: var(--s3) 0 var(--s2);
  margin-top: var(--s3);
  border-top: var(--hair);
  /*
   * STICKY TO THE GAME, NOT TO THE SITE.
   *
   * The old `.table-foot` was `position: sticky; bottom: 0` on the SHELL, and
   * `shell.spec.ts` holds it to a promise worth keeping: the line telling you
   * what to do never leaves the screen. Measured at 390×844 with this static,
   * the belt's prompt sat at y=1687 in an 844px window — the instruction for
   * the game you are looking at, two screens below the game you are looking at.
   *
   * The promise survives; what changed is what it is stuck to. This pins to the
   * bottom of `.panel-root`, so it holds the bottom edge only while the game it
   * belongs to is on screen, and scrolls away with that game rather than
   * sitting over whatever replaced it. A sticky item needs an opaque background
   * or the board scrolls through the words — `--table` is the page's own paper,
   * so it reads as the foot of the board rather than as a bar laid over it.
   *
   * `z-index` is deliberately low. The old foot was 30, over everything,
   * because it belonged to the site; this belongs to one game and must lose to
   * anything that game raises over itself — a card sheet, a lab overlay.
   */
  /*
   * IN FLOW, NOT PINNED — AND THIS COST FIFTEEN BLOCKERS TO LEARN.
   *
   * This was `position: sticky; bottom: 0` with an opaque background, to keep
   * the old `.table-foot` promise that the line telling you what to do never
   * leaves the screen. A play-test of all 37 games on the live deploy found the
   * same fault in eight of them, and it is the sort that reads as the game
   * being broken rather than as a bar being in the way:
   *
   *   - The Deal: "all three named stand-in buttons completely covered".
   *   - Bazaar: "pressing the Hoarder trader deals the game instead".
   *   - The Steal: "How many races is unpressable — pressing 1 starts the game".
   *   - Our Words: "Give them the key is dead at its centre".
   *
   * The reservation on `.panel-body` was supposed to prevent exactly this and
   * could not, for a reason worth stating plainly: **padding protects the end
   * of a box, and a sticky element floats at the bottom of the VIEWPORT.** On
   * any panel taller than the window the strip pins somewhere in the middle of
   * the game's own content, and no amount of padding at the far end reaches it.
   * That is not a tuning problem, it is the wrong mechanism.
   *
   * So the strip is the last row of the game's screen and scrolls with it. The
   * promise it was keeping was already half given up when the strip moved
   * inside the panel — a bar pinned to the window is a property of site chrome,
   * and site chrome is the thing that was removed. Keeping the other half at
   * the price of a button that deals the game when you meant to choose a
   * stand-in was never the right trade.
   */
  position: static;
  background-color: var(--table);
}

.stage-status[hidden] {
  display: none;
}

/* The prompt takes the room that is left, so a long sentence wraps under
   itself rather than pushing the replay control off the line. */
.stage-status .prompt {
  flex: 1 1 12rem;
  margin: 0;
}

.stage-status-clock {
  flex: none;
}

.stage-status-act {
  flex: none;
  display: flex;
  gap: var(--s2);
}

.stage-status-act:empty {
  display: none;
}

/*
 * THE GAME'S REFUSAL — its own words, on its own strip.
 *
 * `flex-basis: 100%` puts it on a line of its own beneath the prompt rather
 * than beside it. Two sentences sharing a row would be read as one sentence,
 * and the whole point of this line is that it is an ANSWER to something the
 * person just did, distinct from the standing instruction next to it.
 *
 * It costs nothing when silent. `:empty` collapses the box entirely, so the
 * strip is exactly the height it has always been until the moment a game says
 * no — and because `.stage-status` sits in flow (see the long note above), the
 * line it adds extends the foot of the board rather than covering a control.
 *
 * `--stamp` is the ink this palette already spends on a person being singled
 * out, which is what a refusal is: the table carried on, and this is addressed
 * to one seat. Not `--danger`: nothing is broken and nothing is lost. Somebody
 * asked for a move that was not available, which happens several times a round
 * in a game worth playing, and painting it as an error would make an ordinary
 * part of play look like a fault in the site.
 */
.stage-status .refused {
  flex: 1 0 100%;
  margin: 0;
  color: var(--stamp);
  /*
   * A rank below the prompt, and in the body face rather than the display one.
   * The prompt is the standing instruction and has to be the loudest thing on
   * the strip; this is a remark about one press. Two lines at the same size in
   * the same face would compete, and the one that would lose is the one that
   * says what to do next.
   */
  font-size: var(--t-small);
  line-height: 1.35;
}

.stage-status .refused:empty {
  display: none;
}

/*
 * IMMERSIVE: THE STRIP DOES NOT PAINT AT ALL.
 *
 * Immersive mode's whole contract is one viewport with the field edge to edge,
 * and `belt-screen.spec.ts` holds it to the pixel. As a flex row the strip took
 * forty of those pixels and the test said exactly what it should have — "the
 * field is not the screen: {top: 40, bottom: 342}" at 750x342.
 *
 * Floating it over the field was tried and abandoned. With `position: absolute`
 * the belt did not fail that assertion, it never reached it: the renderer died,
 * three runs running, reporting "Target page, context or browser has been
 * closed". Removing the positioned ancestor did not help, so the mechanism is
 * not the one that was guessed at — and it was not the machine either, which
 * was the other suspicion and the wrong one: with this rule the same test on
 * the same machine passes in TEN SECONDS, against minutes of dying under the
 * absolute version. A renderer that survives instantly one way and dies three
 * times the other is being killed by the stylesheet, not by the load on the
 * box. An unexplained renderer death is not a thing to ship over a status bar.
 *
 * So: the arrangement that needs no arbitration. A game that has claimed the
 * whole glass owns what is said on it, and the belt draws its own prompt in its
 * own bar, which is most of why it asked for the glass. The strip returns the
 * moment the game gives the viewport back.
 */
:root[data-immersive="true"] .stage-status {
  display: none;
}

@media (min-width: 960px) {
  .table-body {
    grid-template-columns: minmax(0, 1fr) 21.25rem;
  }

  /* No people column: the page gets the whole table rather than being laid
     out inside a track that is 340px narrower than the frame around it. */
  .table-body--solo {
    grid-template-columns: minmax(0, 1fr);
  }
}

@media (max-width: 959px) {
  .people {
    border-left: 0;
    border-top: var(--hair);
  }
}

/*
 * Phones: the room drops the second column too.
 *
 * THIS BLOCK USED TO BE WHERE THE PAGE MODEL LIVED, and most of what it said is
 * now said once, at the top of this file, because the page model is the model
 * everywhere. What it argued is worth keeping, since it is the evidence the
 * general change rests on. Measured on production, with the cabinet: the
 * roster's row is content-sized and the panel's is not, so the panel got what
 * was left — 36px for a Minesweeper board whose cells then painted 129px up
 * across their own header and the rail, and 0px for the Belt's lobby, whose
 * "Add a bot" ended up under the roster while the prompt was telling people to
 * press it. At 750×342, a real iPhone held sideways, `main` was **0px tall**:
 * not squeezed, absent.
 *
 * That is a composition failing when there is no remainder to hand out, and a
 * short wide desktop window has no remainder either — which is why the fix is
 * no longer keyed to a phone. The shell's floor, the flex column, the foot's
 * pin and `main` not scrolling are all in the base rules now.
 *
 * What is left here is genuinely about a narrow screen:
 *
 *   - `display: block` on the table's body, so the roster stops being a column
 *     beside the game and becomes a section under it. This is the one line that
 *     must NOT go general: past 960px the table is a two-column grid with the
 *     roster beside the game, and collapsing it on a wide short window would
 *     break a composition that was never at fault. Every phone in landscape is
 *     inside 959px (the largest, a Pro Max, is 932×430); every tablet in
 *     landscape is outside it (1024×768 upward). The two numbers are doing
 *     different jobs and are not a range.
 *   - the foot's padding, and the rail's and the invitation's wrapping: a
 *     phone's chrome does not fit on one line.
 *   - the room stops spending the screen on its own margins (measured: the
 *     padding, two 24px gaps and the change-game control took 139px).
 *
 * The rail is deliberately NOT pinned, here or anywhere: wrapped at 390px it is
 * two lines tall, and a tenth of the screen is too much to spend on the
 * wordmark and the lamp. The game lives at the top of the page where the rail
 * already is; scrolling away from it is scrolling to the roster, which does not
 * need a clock.
 */
@media (max-width: 599px), (max-height: 500px) and (max-width: 959px) {
  /* Block flow: main, then the people, at their own heights. */
  .table-body {
    display: block;
  }

  /* The rail's end is one packed line of chrome — connection and the two lamps
     — and it used to be wider than a phone: at 360px, during a timed phase, the
     CLOCK's last digit and both lamps were clipped off the table's edge.
     Nothing here may be cut, so the cluster wraps instead, ranged right.

     The clock left this cluster on 2026-08-22 — it is the game's, and it went
     into the game's own status strip — which is what made the line fit. The
     rule stays anyway: it was written against a floor rather than against one
     component, three items at 360px is not much margin, and a wrap that never
     triggers costs nothing. Removing it would be trading a safety net for a
     line of CSS on the argument that the thing it caught last time has moved
     out of reach, which is the argument that ends with somebody re-finding it
     at 360px. */
  .rail-end {
    flex-wrap: wrap;
    justify-content: flex-end;
    row-gap: var(--s1);
  }

  /* Same law for the invitation: the code slab is sized to be read out loud
     and does not give, so at 360px it pushed "Copy link" half off the card.
     The button drops under the slab rather than losing its own right edge. */
  .share-row {
    flex-wrap: wrap;
  }

  .room {
    gap: var(--s3);
    padding: var(--s3);
  }
}

/* A playing room keeps all navigation in two compact rows on a phone.
   The visible rail code identifies this state; lobbies use the invitation slab.
   A connection problem can take an extra row instead of squeezing its message. */
@media (max-width: 599px) {
  /* During play the status strip already has its own row in .panel-root.
     Reserving its height again inside the body added 110px of empty space on
     a 360px phone. The strip's flow row leaves room to scroll past controls. */
  :root:not([data-lobby="true"]) .panel-body {
    padding-bottom: 0;
  }

  :root:not([data-immersive="true"]) .rail:has(.rail-code:not([hidden])) {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    gap: 4px 8px;
    padding: 8px 12px;
  }

  .rail:has(.rail-code:not([hidden])) .doors {
    grid-column: 1;
    grid-row: 1;
    gap: 8px;
    align-items: center;
  }

  .rail:has(.rail-code:not([hidden])) .wordmark,
  .rail:has(.rail-code:not([hidden])) .door--word {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }

  .rail:has(.rail-code:not([hidden])) .rail-code {
    grid-column: 1 / -1;
    grid-row: 2;
  }

  .rail:has(.rail-code:not([hidden])) .rail-end {
    grid-column: 2;
    grid-row: 1;
    margin-left: 0;
    gap: 4px;
    align-items: center;
  }

  .rail:has(.rail-code:not([hidden])) .lamp {
    width: 44px;
    height: 44px;
    min-width: 44px;
  }

  .rail:has(.rail-code:not([hidden])) .conn[data-state="ws"] {
    font-size: 0;
    gap: 0;
  }

  .rail:has(.rail-code:not([hidden])):has(.conn:not([data-state="ws"]):not([hidden])) .rail-end {
    grid-column: 1 / -1;
    grid-row: 3;
    justify-content: flex-end;
  }
}

/* ----------------------------------------------------- the lobby on a phone */

/*
 * THE ROOM WHILE IT IS STILL FILLING UP, ON THE GLASS IT IS ACTUALLY FILLED ON.
 *
 * A lobby asks three things of a screen: say the code so it can be read out,
 * show who has arrived, and offer the one button that starts the game. On a
 * phone the room could do exactly one of them without scrolling.
 *
 * Measured on the real product — Playwright, WebKit and Chromium, 390×844 and
 * 360×640, every game on the shelf, four-person and full tables, 48 lobbies:
 *
 *   - the roster was off screen in 48 of 48. Not partly: `0` of 4 seats
 *     visible, every time. The first seat sat at y=897 in Out of Place, y=1383
 *     in Minesweeper, y=2767 in the shelf lobby — because `.table-body` is
 *     `display: block` on a phone, so the people column becomes a section
 *     UNDER a panel that is between 400px and 2,000px tall.
 *   - the primary was off screen in 22 of 48 — "Start sweeping" at y=1045 on
 *     an 844px screen, "Deal the cards" at y=680 on a 640px one.
 *
 * Both faults have the same cause: the phone lobby spends its first screen on
 * furniture and puts everything a person came for underneath it. So in a lobby,
 * and only in a lobby (`data-lobby`, published by `shell/chrome.js`):
 *
 *   1. the roster comes up out of the basement and leads the page, as a wrapped
 *      row of name chips rather than a column of 60px rows — measured at 360px,
 *      four people cost 90px instead of 240, and eight cost 132 instead of 480;
 *   2. the invitation gives up the inches it does not need: the address line
 *      goes (it is in the copy button and in the address bar, on the one device
 *      where the address bar is right there) and "Copy link" comes back up
 *      beside the slab instead of wrapping under it. The code itself does NOT
 *      give — reading it out is the whole job — it is the air around it that does.
 *
 * Everything here is scoped to `:root[data-lobby="true"]`, which is off the
 * moment a clock starts. A round mid-play keeps the composition it has: the
 * roster back under the board, the invitation gone entirely.
 *
 * `display: contents` on `.people` rather than `order` on it, because the
 * column carries three things and only two of them belong at the top. The way
 * out — "What just happened", "Leave the room" — stays at the foot of the page
 * where it has always been; hoisting the roster must not hoist the exit into
 * the middle of the screen, an arm's length from the button that deals.
 */
@media (max-width: 599px) {
  :root[data-lobby="true"] .table-body {
    display: flex;
    flex-direction: column;
  }

  /* The band's three children join the page's own column, and take their
     places in it individually. */
  :root[data-lobby="true"] .people {
    display: contents;
  }

  :root[data-lobby="true"] .people-head {
    order: -2;
    padding: var(--s1) var(--s3) 0;
    font-size: var(--t-data-sm);
    letter-spacing: var(--track-label);
  }

  :root[data-lobby="true"] [data-t="seats"] {
    order: -1;
    padding-top: var(--s2);
    padding-bottom: var(--s2);
    border-bottom: var(--hair);
  }

  /* And the exit stays where it was: last thing on the page, under the game. */
  :root[data-lobby="true"] .room-exit {
    order: 1;
    margin-top: var(--s5);
  }

  /* The room's own air, once there are four things stacked in it rather than
     two. `--s3` between the code, the game and the shelf control is a gap; at
     `--s2` it is still a gap, and it is 8px nearer the button. */
  :root[data-lobby="true"] .room {
    gap: var(--s2);
    padding-top: var(--s2);
  }

  /*
   * A seat becomes a chip.
   *
   * The name keeps `white-space: nowrap` from `.seat-name`, which is what
   * stops a chip narrower than a name breaking KRISTOFFER across two lines —
   * the fault this composition would otherwise have invented. A chip is as
   * wide as the name on it and the row wraps between chips instead.
   */
  :root[data-lobby="true"] .seat-rail {
    flex-direction: row;
    flex-wrap: wrap;
    gap: var(--s2);
    padding: 0 var(--s3);
  }

  /*
   * AND THE CHIP IS THE WIDTH OF THE NAME, WHICH IS WHAT THE RULE ABOVE SAYS
   * AND WHAT THIS LINE WAS MISSING.
   *
   * `.people[data-lobby="true"] .seat-rail > li` sets `flex: 1 1 6rem` for the
   * desk, where the people column is a fixed 340px and three equal chairs are
   * the composition §6 of the verdict is about. This block overrode that rule's
   * `max-width` and left its `flex` alone, so on a phone every chip still had a
   * 6rem basis AND still grew to fill the line — which is the one thing a chip
   * is not. Two chips took a 324px row at 146px each, whatever was written on
   * them, and a row that could have held three or four held two.
   *
   * Measured at 360×844 with four people and the roster's (+): five items at a
   * 6rem basis is three rows of chips, 216px of a 640px screen, and it put Say
   * It Once's start control 19px under the fold and The Cable's 3px under. The
   * chips there read "Ada", "Bo", "Cy", "Di" — none of them 96px wide.
   *
   * `0 1 auto` is the chip the paragraph above describes: content-width, free
   * to shrink if a Swedish surname needs the whole line, never padded out to a
   * basis it does not need. The names still cannot break — `.seat-name` keeps
   * `white-space: nowrap`, which is the fault that paragraph was written about.
   */
  :root[data-lobby="true"] .seat-rail > li {
    flex: 0 1 auto;
    max-width: 100%;
  }

  :root[data-lobby="true"] .seat {
    width: auto;
    max-width: 100%;
    /*
     * A ROW, and it was a column by inheritance rather than by decision.
     * `.people[data-lobby="true"] .seat` stands the chair up — disc over name,
     * both centred — which is the desk's chair, and on a phone the disc was
     * hidden so the column had one thing in it and nothing showed. Putting the
     * bead back put it ON TOP of the name: MEASURED, the chip went 27px to 45
     * and the rail 251 to 342, which took the primary off the screen again.
     *
     * Sideways it costs width instead, which this row has and that column did
     * not. The host's tag comes up beside the name for the same reason.
     *
     */
    gap: var(--s2);
    padding: var(--s1) var(--s3);
    border-left: 0;
    border-radius: var(--r-chip);
    background: var(--panel);
    border: var(--rim-hair) solid var(--rim);
  }

  :root[data-lobby="true"] .seat-name {
    font-size: var(--t-small);
  }

  :root[data-lobby="true"] .seat-tag {
    margin-left: 0;
  }

  /*
   * A BEAD RATHER THAN A BADGE — 2026-08-26.
   *
   * This was `display: none`, and the reason given was sound: *"the badge is
   * what identifies a seat on a board. In a list of names it is the same two
   * letters as the word beside it, at 36px a chip."* Two letters beside the
   * word they are taken from is nothing, and 36px of nothing on a phone is
   * worse than nothing.
   *
   * It is not the same two letters any more. Since the counters went to twelve
   * the badge carries a COLOUR, and a colour beside a name is not information
   * the name already gives — it is the thing that lets somebody find their own
   * marker on a scoreboard without reading. Hiding the badge on a phone hid the
   * only place a phone could learn it, on the device this product is for.
   *
   * So the letters go and the colour stays: a 10px bead, the counter at the
   * size a chip can spare. It costs 16px of chip width and no height — measured
   * at 360x640 with a full table, the chips still sit two to a row and the
   * primary keeps its place. An empty chair keeps `display: none`, because
   * there is nobody there to have a colour.
   */
  :root[data-lobby="true"] .seat .avatar {
    display: block;
    inline-size: 0.625rem;
    block-size: 0.625rem;
    min-inline-size: 0.625rem;
    padding: 0;
    border-radius: 50%;
    /* The two letters go without going: `font-size: 0` leaves them in the
       accessibility tree, where they were never the point anyway — the badge
       is `aria-hidden` and the name is printed beside it. */
    font-size: 0;
    background-color: var(--person, var(--person-1));
    background-image: var(--gloss);
    border: var(--rim-hair) solid var(--rim);
    box-shadow: none;
  }

  :root[data-lobby="true"] .seat .avatar--empty {
    display: none;
  }

  /* You, without the inverted badge that used to say so. */
  :root[data-lobby="true"] .seat[data-you="true"] {
    background: var(--stock);
    border-color: var(--rim);
  }

  :root[data-lobby="true"] .seat[data-you="true"] .seat-name {
    color: var(--ink);
  }

  :root[data-lobby="true"] .seat[data-you="true"] .seat-tag {
    color: rgb(8 22 29 / 0.6);
  }

  /*
   * AND ONLY A CHAIR WITH SOMEBODY IN IT LIES DOWN.
   *
   * The direction is separated from the chip styling above because it is the
   * one declaration that must not reach a chair with nobody in it.
   *
   * `.seat--recruit` is the (+) at the end of the row, and it is THREE things
   * stacked: a plus, "Free seat", and "Ask a stand-in" — what the object is
   * over what pressing it does. Standing those side by side takes the pair to
   * **186x33**, under the 44px floor, which `lobby-phone.spec.ts` reported 56
   * times across both engines and both sizes. The same measurement is in this
   * repo's notes from an earlier afternoon, when somebody laid this chip down
   * for a different reason and reverted it over exactly that number.
   *
   * Two selectors and not one, because the two are different objects: an empty
   * chair is a dashed chip that says "Empty seat" and the recruit is a control.
   * Neither has a person, and a person is the only thing that brought a bead
   * to put beside a name.
   */
  :root[data-lobby="true"] .seat:not(.seat--empty):not(.seat--recruit) {
    flex-direction: row;
    align-items: center;
  }

  /*
   * AND YOUR OWN BEAD IS A FINGER WIDE WITHOUT BEING A FINGER TALL.
   *
   * The bead is 10px, which is right for a mark beside a name and is not a
   * control. It became one — pressing it is how you choose your colour — and
   * `lobby-phone.spec.ts` said so at once, in four cases: *"a tap target is
   * under 44px: KR is 43,127 10x10."*
   *
   * The two obvious repairs both cost the row. A 44px disc takes the chip from
   * 27px to 44 and, at twelve seats two to a row, the roster from 261px to
   * about 360 — which is the primary back off the bottom of a 640px screen,
   * the exact fault that file exists for. A `::after` hit area is invisible to
   * `getBoundingClientRect`, so the spec would go green over a control it
   * cannot measure, which is worse than the fault.
   *
   * So the BOX is `--tap-min` and its layout contribution is cancelled: a
   * negative margin of half the difference on every side leaves the chip laid
   * out around a 10px mark while the button really is 44px square. The target
   * is honest — it is what a finger lands on and what a camera measures — and
   * the roster is the height it was.
   */
  /*
   * `[data-you="true"]` IS IN THIS SELECTOR AND IT IS THE WHOLE REASON IT
   * WORKS.
   *
   * Two rules paint this element and the first attempt lost to the second
   * without either being wrong. The bead rule a few lines up sets a
   * `background-color` on `.avatar`; so does *"your own counter is the one with
   * the light on it"* near the top of this file, at
   * `.people[data-lobby] .seat[data-you="true"] .avatar` — five classes to the
   * four this had, and the seat this decorates is by definition the one wearing
   * `data-you`. Measured, twice: a 44px disc in the person's colour where a
   * 10px bead belonged.
   *
   * So the selector names the attribute that was beating it, and the shorthand
   * is unpacked into longhands. A rule that has to win should say why it wins
   * rather than rely on a count nobody re-does.
   */
  :root[data-lobby="true"] .seat[data-you="true"] button.avatar--pick {
    display: grid;
    place-items: center;
    inline-size: var(--tap-min);
    block-size: var(--tap-min);
    min-inline-size: var(--tap-min);
    margin: calc((0.625rem - var(--tap-min)) / 2);
    background-color: transparent;
    background-image: none;
    box-shadow: none;
    border: 0;
    border-radius: 50%;
  }

  :root[data-lobby="true"] .seat[data-you="true"] button.avatar--pick > span {
    display: block;
    inline-size: 0.625rem;
    block-size: 0.625rem;
    border-radius: 50%;
    font-size: 0;
    background-color: var(--person, var(--person-1));
    background-image: var(--gloss);
    border: var(--rim-hair) solid var(--rim);
    /* The light that says which one is you, on the bead rather than on the
       invisible box around it — the ring is a mark, and a mark 17px away from
       the thing it marks is a mark on nothing. */
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--lit) 72%, transparent);
  }

  /* An empty chair is a chip with nobody on it. */
  :root[data-lobby="true"] .seat--empty {
    border-style: dashed;
    background: none;
  }

  /* 2. The invitation, at the size a lobby can afford. */
  :root[data-lobby="true"] .share--open {
    gap: var(--s2);
    padding: var(--s2) var(--s3);
  }

  /*
   * "Read this out on the call" is clipped rather than deleted — it stays in
   * the accessible tree, said to anybody who is having the page read to them,
   * and stops taking 25px of a 640px screen from everybody else. What is left
   * is a six-character slab with COPY LINK beside it, which is not a thing
   * anybody has to be told the purpose of.
   *
   * WRITTEN `> .data` AND IT HAD STOPPED MATCHING ANYTHING.
   *
   * Two changes went past it. The line is a sentence addressed to a colleague,
   * so the mono-caps reform in `base.css` moved it from `.data` to `.said`; and
   * the hero plate put a `.share-code` column between it and `.share--open`, so
   * it is no longer a direct child either. Both are right and neither knew this
   * rule was here. A selector that matches nothing fails silently and in the
   * flattering direction — the screen simply quietly got 23px taller, and the
   * note above went on claiming it had given them back.
   *
   * `lobby-legible.spec.ts` had the same stale `.data` in its own list and
   * spent 240 seconds waiting for it before the suite timed out, which is the
   * one version of this that DOES say something out loud.
   */
  :root[data-lobby="true"] .share--open .said {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }

  /*
   * A FULL TABLE SHOWS THE FACT AND DROPS THE GLOSS.
   *
   * The rule above clips the headline on a phone, and it is right to: "Read
   * this out on the call" over a six-character slab with COPY LINK beside it
   * is telling somebody something the slab already says.
   *
   * It matches `.share--open`, which `room.js` puts on the band in **both**
   * states — `share--full` is added beside it rather than instead of it — so
   * the headline is clipped when the table is full as well. And a full card
   * carries a second line the open one does not: *"The code still brings back
   * anyone who drops out."*, which exists precisely to explain the headline.
   *
   * **So on a phone that note was explaining a sentence 1px tall.** MEASURED at
   * 360px with eight seated: `.share-code` is 105px open and **164px full** —
   * 59px taller, of which the note is 44 — and the two lobbies that cannot fit
   * a game on this screen are both full tables. The room is at its tallest
   * exactly when it has the most people in it and the least left to ask of
   * anybody.
   *
   * The fact is shorter than the gloss and carries more: "The table is full" is
   * one line where the note is two, and it is the thing somebody needs to know.
   * The gloss stays on wider screens, where it costs nothing and sits under a
   * headline that is actually visible, and it stays in the accessible tree here
   * — this hides the note the same way the headline was hidden, not with
   * `display: none`.
   *
   * AT THE END OF ITS OWN BLOCK, NOT BESIDE THE RULE IT OVERRIDES ELSEWHERE:
   * both selectors are four classes deep, so this only wins by coming later.
   */
  :root[data-lobby="true"] .share--full .said {
    position: static;
    width: auto;
    height: auto;
    margin: 0;
    overflow: visible;
    clip: auto;
    white-space: normal;
  }

  :root[data-lobby="true"] .share--full .share-note {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }

  /*
   * The slab keeps its job — six characters somebody reads down a call — and
   * gives up the air around them: 32px of type in a box padded `--s4` is 68px
   * tall, and the same six characters at 28px in a box padded `--s1` is 46px.
   * The floor is 1.75rem, which is still the largest thing on the screen.
   */
  :root[data-lobby="true"] .share--open .code {
    font-size: clamp(1.75rem, 8vw, 2.25rem);
    padding: var(--s1) var(--s3);
  }

  :root[data-lobby="true"] .share-row {
    flex-wrap: nowrap;
    align-items: center;
    gap: var(--s3);
  }

  /* Beside the slab rather than under it: the words are what give, not the
     hit area, which stays a full 44px tall. */
  :root[data-lobby="true"] .share-row .btn {
    padding: var(--s3);
    min-height: 2.75rem;
    font-size: var(--t-data-sm);
    letter-spacing: var(--track-label);
  }

  /* The address line is the copy button's job and the address bar's. */
  :root[data-lobby="true"] .share--open .share-link {
    display: none;
  }

  /*
   * The facts a lobby prints about its game — "14 LOCATIONS", "1 SPY", "8
   * MINUTES OF QUESTIONS" — wrap to three lines at 360px, and at `--s2 --s3`
   * each line is 44px of tab for 11px of type. The type does not change; the
   * pressed tab around it does, and three lines give back 42px.
   */
  :root[data-lobby="true"] .chip {
    padding: var(--s1) var(--s2);
  }

  /*
   * A CARD ON THE SHELF, AT THE SIZE A PHONE CAN SHOW ONE.
   *
   * The room with nothing on the table is a lobby too, and its primary is a
   * card rather than a button — the whole card is the control. Measured at
   * 360×640: the first card ran y=384..648 on a screen whose foot starts at
   * 585, so the one game a person could reach without scrolling was not
   * reachable either. 72px of lid, `--s5` all round and three lines of pitch is
   * a desk's card.
   *
   * The lid stays — it is how anybody has ever picked a game off a shelf — at
   * the size a phone can spare, and the pitch keeps two of its three lines.
   * Together that is 83px a card, 264px down to 181, which is the difference
   * between a game a person can press without scrolling and none.
   *
   * Not behind `data-lobby`: this is the same card the mid-round picker deals
   * out under "Play something else", and a card is a card.
   */
  .shelf-item {
    padding: var(--s3) var(--s4);
    gap: var(--s2);
  }

  .shelf-item .lid {
    width: 3rem;
  }

  /*
   * THE PILL INSIDE THE BUTTON STOPS PRETENDING TO BE ONE.
   *
   * `.shelf-cta` carries `pointer-events: none` and the rule above it says why:
   * *"the whole card is the control"*. So on a phone it is a LABEL naming what
   * pressing the card does, and it was wearing a full-width primary button's
   * padding to say it — 56px of the item's 174px, on every one of forty.
   *
   * Set to the type it actually is and left to hug its own words, it says the
   * same thing in a third of the height. MEASURED at 390×844: 174px an item
   * becomes 140px, which is 1,360px off the length of the shelf — and it is
   * what pays for the search box above it, which the lobby at 360×640 had no
   * room to give.
   *
   * It keeps its ink and its shape. What goes is the claim to be pressed
   * separately, which was never true.
   */
  .shelf-cta {
    align-self: start;
    padding: var(--s1) var(--s3);
    font-size: var(--t-data-sm);
    /*
     * AND IT GIVES BACK THE FINGER'S 44px, BECAUSE NOTHING AIMS AT IT.
     *
     * `--tap-min` comes from `.btn` and is right for every button in this
     * product — 13px mono in 12px of padding is 41px, under every published
     * floor. This is not a button. It carries `pointer-events: none` and the
     * rule that put it there says why: *"the whole card is the control"*. The
     * finger is aimed at 360×163 pixels of card, and reserving a second target
     * inside it reserved 52px of the 163.
     *
     * Measured with the padding cut but this left standing, the item went 174px
     * to 163 — eleven, where the label had looked like thirty. It is the floor
     * that was holding it, not the padding.
     */
    min-height: 0;
  }

  /*
   * AND THE ROW ABOVE THE SHELF STAYS ONE ROW.
   *
   * Wrapping is right at a desk, where the sentence is long and the box has
   * 18rem to sit in. Here it is what costs the shelf a row: MEASURED at 360×640
   * with three at the table, the wrapped row was 97px and the first card ran to
   * 693px of a 640px glass. Told not to wrap, with the sentence cut to its
   * instruction — see `shelf.js` — the row is the box's own 44px and the card
   * is whole.
   */
  .shelf-ask {
    flex-wrap: nowrap;
  }

  .shelf-ask > .lead {
    flex: 0 1 auto;
    min-width: 0;
  }

  .shelf-search {
    flex: 1 1 7rem;
  }

  /* The reassurance, which is not the instruction. See `shelf.js`. */
  .lobby-lead-more {
    display: none;
  }

  /*
   * ONE LINE, which is the choice the front door already made for the same
   * sentence.
   *
   * Two is what this said while the shelf held seven games and a phone could
   * see most of them. At forty, MEASURED in a room with the picker open at
   * 390×844: 277px a game, against the front door's 158px row carrying the
   * same six things. Whoever is scrolling a list of forty is deciding WHICH,
   * and the sentence that answers which is the first one — the rest is what
   * the sheet is for, and "See it played" is on this box too.
   *
   * It is written here rather than beside the `line-clamp: 3` it overrides,
   * because this block is later in the file and a media query adds no
   * specificity: a rule up there loses to this one at every phone width. That
   * is the trap this stylesheet has been caught by four times.
   */
  .shelf-item .measure {
    -webkit-line-clamp: 1;
    line-clamp: 1;
    font-size: var(--t-small);
  }

  /* And the sentence over the shelf is set to the phone rather than to the
     page: two lines of `--t-body` above eight cards is 60px spent saying what
     the cards say. It keeps both its lines — the non-host's copy carries the
     only thing they are told — at the size a caption takes. */
  :root[data-lobby="true"] .stage .lead {
    font-size: var(--t-small);
  }

  /*
   * 3. AND THE RAIL STOPS BEING THREE LINES OF FURNITURE.
   *
   * Measured at 360×640 in the lobby, after the roster came up: the rail was
   * **185px tall** — 29% of the screen — because five things at `--s5` gaps do
   * not fit across 276px and every one of them wrapped. Above the code, above
   * the roster, above the game: a wordmark, the word CONNECTED, the word
   * Waiting, and two lamps.
   *
   * Three cuts, none of which removes a fact from the screen:
   *
   *   - the gaps come in. `--s5` is a desk's rail; on a phone the same cluster
   *     reads perfectly well at `--s3`.
   *   - the connection pill keeps its dot and drops its word WHILE IT IS
   *     CONNECTED — and only then. The word is there because colour alone
   *     cannot carry a state (see `.conn` above), which is an argument about
   *     the states that MATTER: `sse` still says "connected · fallback" and
   *     `lost` still says "reconnecting", in the stamp ink, next to the banner
   *     that says it again. What goes is 110px of tracked mono announcing that
   *     nothing is wrong. It is still in the DOM, so it is still announced.
   *   - the clock goes while nothing is timed AND the room is a lobby. It is
   *     showing a phase name with no digits — "Waiting", "Choosing a board" —
   *     and the foot is already saying what the table is waiting for, in a
   *     whole sentence, at reading size.
   */
  :root[data-lobby="true"] .rail {
    gap: var(--s3);
    padding: var(--s2) var(--s3);
  }

  :root[data-lobby="true"] .rail-end {
    gap: var(--s2);
  }

  :root[data-lobby="true"] .conn[data-state="ws"] {
    font-size: 0;
    gap: 0;
  }

  :root[data-lobby="true"] .clock[data-idle="true"] {
    display: none;
  }

  /*
   * THE ROOM'S PICKER BECOMES THE SAME STRIP THE FRONT DOOR'S SHELF IS —
   * 2026-08-26.
   *
   * `lobby-phone.spec.ts` went red the moment its own "full table" started
   * meaning twelve people instead of eight, and it named the number: the first
   * card of the picker runs **y=531..671 on a 640px screen**, so the one game a
   * person could reach without scrolling is thirty-one pixels short of being
   * reachable. Four more chairs are 71px of roster, and the card underneath had
   * no slack left to absorb them — the note further up this file records it
   * being taken from 264px to 181 and then to 140 already.
   *
   * The front door's shelf met the same wall this morning and the way through
   * was to stop stacking: the words in one column, the deal as a full-height
   * stub in another, and the row as tall as the taller of the two rather than
   * their sum. The same move works here and costs nothing, because the pieces
   * are the same pieces. 140px becomes about 96.
   *
   * `.shelf-cta` keeps `pointer-events: none` — the whole card is still the
   * control, and this is still a label naming what pressing it does. What it
   * gains is a shape: an end stub, which is what the front door's deal is, so
   * the two shelves read as the same object in two places.
   */
  .shelf-item {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    /*
     * Declared, and the reason is the one the front door's card records: with
     * no explicit rows the explicit grid has a single line, so `1 / -1` on the
     * stub resolves to the first row and it stands in the title's row instead
     * of the card's full height.
     */
    grid-template-rows: auto auto;
    align-items: center;
    column-gap: var(--s2);
    row-gap: var(--s1);
    padding: var(--s2) var(--s3);
  }

  .shelf-item .shelf-top,
  .shelf-item .measure {
    grid-column: 1;
  }

  /* One line, for the reason the front door's blurb keeps one: on a strip a
     sentence is an invitation to press the box, and the whole of it is on the
     page the box opens. */
  .shelf-item .measure {
    -webkit-line-clamp: 1;
    line-clamp: 1;
  }

  .shelf-cta {
    grid-column: 2;
    grid-row: 1 / -1;
    align-self: stretch;
    display: grid;
    place-items: center;
    min-height: var(--tap-min);
    inline-size: 3.25rem;
    padding-inline: var(--s1);
    /*
     * Out through the card's padding to its own edge, square, and clipped by
     * the card's corners — the same stub the front door's shelf grew this
     * morning, for the same reason written there: inside the padding with a
     * radius of its own it reads as a button dropped onto a row, and against
     * the edge the row reads as one object with a printed face at one end and
     * a torn-off stub at the other. `overflow: hidden` on the item is what
     * rounds the two corners that meet the outside.
     */
    margin: calc(-1 * var(--s2)) calc(-1 * var(--s3)) calc(-1 * var(--s2)) 0;
    border-radius: 0;
  }

  .shelf-item {
    overflow: hidden;
  }

  /* The name goes, and only here. The title is printed 40px above it. */
  .shelf-cta-name {
    display: none;
  }

  /*
   * AND EIGHT PIXELS OFF THE PICTURE'S SIDE, WHICH IS TWENTY-TWO OFF THE CARD.
   *
   * MEASURED once the strip above was in: the card came to 110.5px and the
   * spec wanted 106. The 4px was not spread across the layout — it was one
   * wrapped line. `.shelf-heading` had **162.7px** and the fact line
   * "1–8 players · 12 min" wants a shade over 163, so it broke in two and cost
   * 22px on every card on the shelf. The front door's strip met the identical
   * wall at 360px and closed it the identical way; the arithmetic is written
   * out beside that one.
   *
   * Half a rem off the picture and one step off the gap beside it is 8px, and
   * 8px is the difference between a fact line on one row and on two.
   */
  .shelf-item .lid {
    width: 2.75rem;
  }

  .shelf-top {
    gap: var(--s2);
  }
}

/* ---------------------------------------------------- forty-four by forty-four */

/*
 * WHAT A THUMB CAN ACTUALLY HIT.
 *
 * Measured across the same 48 lobbies: the theme lamp and the palette swatch
 * were 32×32, the wordmark 84.8×24, and "Play something else" 149.5×42.5 —
 * every one of them under the 44px both platform guidelines have asked for
 * since 2013, and three of the four are in the rail, which is the strip a
 * thumb reaches over while holding the phone one-handed.
 *
 * Behind a phone's width OR a coarse pointer, so a mouse on a desk keeps the
 * quiet 32px glyph the rail was composed with: a lamp is a setting, and at a
 * desk it should not weigh the same as a control that does something to the
 * round. On glass there is no such thing as a small control, only a missed one.
 *
 * The wordmark and the quiet buttons grow their BOX and not their type — the
 * words stay the size they were, the box around them reaches 44px. For
 * `.btn--quiet` that moves its underline about a pixel further from the text,
 * which is the whole visible cost.
 *
 * IN A LOBBY, AND THE BOUNDARY IS DELIBERATE.
 *
 * These four controls are the same size in a live round, and they should not
 * be: 32px is 32px whatever the room is doing. But the rail sits ABOVE the game
 * in every document-shaped game and INSIDE the room pill in every immersive
 * one, so twelve pixels of lamp is twelve pixels off the composition below it —
 * and those compositions are measured to the pixel by the games' own suites.
 * Both said so within a minute of the rule going in: `viewport-device.spec` had
 * the belt 7.8px off centre between its ceiling and its tray at 360×640, and
 * `regicide.spec` had the first card of the fan no longer hit-testing to itself
 * at the same size. Neither is a lobby.
 *
 * So this is the lobby's rule, which is the screen that was measured and the
 * screen the brief is about. Growing the same four in a live round means
 * re-measuring eight games' phone compositions, which is a job of its own and
 * is named here rather than half-done.
 */
@media (max-width: 599px), (pointer: coarse) {
  :root[data-lobby="true"] .lamp {
    width: 2.75rem;
    height: 2.75rem;
  }

  :root[data-lobby="true"] .lamp--swatch {
    width: auto;
    min-width: 2.75rem;
    padding: 0 var(--s2);
  }

  :root[data-lobby="true"] .wordmark {
    display: inline-flex;
    align-items: center;
    min-height: 2.75rem;
  }

  :root[data-lobby="true"] .btn--quiet {
    min-height: 2.75rem;
  }

  /*
   * AND THE FIFTH, WHICH ARRIVED AFTER THE FOUR WERE MEASURED.
   *
   * "Today" is the rail's second door and it was not in the sweep above,
   * because the sweep was run before the door existed. It is set as data —
   * mono, small caps — so its box is the height of one line of 11px type:
   * **54×17**, the smallest target in the product, sitting four pixels from
   * the wordmark that was already fixed for being 84.8×24.
   *
   * It costs the rail nothing to fix. `.doors` is a flex row whose height is
   * already 44px in a lobby — the wordmark's rule above set it — so the door
   * is a 17px word floating in a 44px row, and giving it the row's own height
   * grows the hit area into space the rail has already paid for. The type does
   * not move: `.door--word` is `inline-flex` with `align-items: center`
   * already, so the word stays centred on the same line it was centred on.
   *
   * `.doors` aligns on the BASELINE, which is right for two words printed on
   * one line and wrong the moment both of them are boxes: display type and mono
   * type centred in the same 44px height do not share a baseline, so aligning
   * on it staggered the two words by two pixels and made the row 46px. In a
   * lobby the doors are two full-height targets rather than two words, so they
   * align the way targets do. Measured: the rail is 64px before and after.
   */
  :root[data-lobby="true"] .doors {
    align-items: center;
  }

  :root[data-lobby="true"] .door--word {
    min-height: 2.75rem;
  }
}

/* ------------------------------------------- a game that wants the viewport */

/*
 * A field is not a page, so nothing on one is text.
 *
 * The prose exemption in `base.css` is written for documents — the landing
 * page, the shelf, how a game is played. In immersive mode there is no document
 * left: the banner across the bottom and every label floating on the field are
 * furniture, and a long press on furniture that starts a text selection cancels
 * the pointer stream the game was reading a gesture from.
 *
 * The room code keeps its exemption below, and deliberately — folded into the
 * pill, it is still the six characters somebody has to paste into a chat.
 */
:root[data-immersive="true"] p,
:root[data-immersive="true"] h1,
:root[data-immersive="true"] h2,
:root[data-immersive="true"] h3,
:root[data-immersive="true"] li {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

:root[data-immersive="true"] .code {
  user-select: text;
  -webkit-user-select: text;
  -webkit-touch-callout: default;
}

/*
 * THE FIELD, AND THE ROOM FLOATING ON IT.
 *
 * A game may declare on its registry entry that its one object has to be as
 * large as the glass (`wantsViewport`). While a sitting of such a game is live
 * the shell publishes `data-immersive` on the root and stops being a document
 * with a game in it: the rail goes, `main` loses its padding, its card and its
 * internal scroll, and everything the shell owns that is not "what do I do
 * right now" folds into the room pill below.
 *
 * Every number in this block was paid for. Measured on the live mid-game
 * sitting the viewport gate drives, before it existed:
 *
 *   - 390×844 — `change-game-toggle` centred at y=1388 and `leave-room` at
 *     y=1573, both ~700px below an 844px fold. Stacking the room's own
 *     paperwork under the game put the two controls that LEAVE a room behind
 *     a scroll past the whole of it.
 *   - 844×390 — the stage's visible window was **13.5px tall**: the board
 *     entirely off screen, spilling 248px, with six controls hit-testing to
 *     the prompt, the roster's head or a seat. Rotating a phone reached a
 *     state in which the game did not exist.
 *   - 768×1024 — the board spilled 42.1px past `main`'s clip, because a
 *     portrait tablet is too wide for the phone model and too short for the
 *     cabinet.
 *
 * The mode is deliberately not a breakpoint. Whether the board is the screen
 * is a property of the game and not of the glass: a game whose object is one
 * card, or a handful of chips, is a document at every size and stays one.
 */

:root[data-immersive="true"] .shell {
  /* Exactly one viewport at every width, phones included: the phone block
     above hands the DOCUMENT its scroll, and here nothing may scroll at all. */
  height: 100dvh;
  min-height: 0;
  max-width: none;
  padding: 0;
}

:root[data-immersive="true"] .table {
  /*
   * Two rows: the banner, then everything.
   *
   * `grid-row` is stated on both children, and it is no longer what puts them
   * in that order: `shell/immersive.js` moves the banner to the head of the
   * table while the mode is on, because a row assignment paints a box early
   * and leaves it late in the document — which is how the room came to be
   * drawn at the top of a phone and reached last by the keyboard. The
   * declarations stay as the same statement in the other language, so a
   * composition this whole mode rests on does not depend on a script having
   * run; they now agree with the DOM instead of contradicting it.
   */
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  /* The containing block for the short-viewport block below, where the banner
     stops being a row and becomes an object floating in the corner. */
  position: relative;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  overflow: clip;
}

/* No app name, no room code, no connection state, no clock. None of it is a
   thing to do, and the reference composition has none of it on screen. */
:root[data-immersive="true"] .rail {
  display: none;
}

/* The prompt becomes Catan's one-line banner across the top: the pill on the
   left, the sentence filling the rest. Both are the shell's, so putting them
   in one row is also what guarantees they can never cover each other — which
   is precisely what the prompt did to three bench controls at 844×390. */
:root[data-immersive="true"] .room-pill-dock {
  grid-row: 1;
  /*
   * A ROW, WHICH THIS BLOCK HAS BEEN DESCRIBING AND NOT DECLARING.
   *
   * The heading above says *"the pill on the left, the sentence filling the
   * rest"*; `gap`, `flex-wrap: nowrap` and `align-items: center` below say it
   * again; `.roompill` carries `flex: none`, which means nothing outside a
   * flex container. And no rule in this sheet ever gave the dock a `display`,
   * so all five have been inert and the dock has been a plain block with one
   * child in it — which looked identical, because one child is one child.
   *
   * It stopped looking identical the moment the clock was adopted in beside
   * the pill (`shell/immersive.js`): a second block child went onto a second
   * line, and in the phone and landscape block below — where this dock is an
   * absolutely positioned overlay over the field — that second line landed on
   * top of the game's own header. Photographed in Sixth Takes at 844×390 with
   * "Choosing" printed across "ROUND 1 · CARD 1 OF 10".
   */
  display: flex;
  /* `static`, undoing the phone block's sticky pin: the banner is a grid row
     now, and a sticky item with a z-index would open a stacking context that
     capped the room pill's own sheet below its backdrop. */
  position: static;
  /*
   * A grid item's automatic minimum size is its MIN-CONTENT, and a min-content
   * wider than the track overflows it rather than shrinking. Measured at
   * 360×640 with this line missing: the whole table laid out 450px wide inside
   * a 360px column and the right-hand 90px — a seat chip, a resource card and
   * "End turn" — was cut off by `.table`'s own clip. No document overflow, no
   * scrollbar, nothing to see but the loss.
   */
  min-width: 0;
  border-top: 0;
  border-bottom: var(--hair);
  /* The shell's padding is zero in this mode, so the screen's own margins are
     honoured by whatever holds each edge. This row holds the top, and in
     portrait the top is where the notch is. */
  padding: calc(var(--s1) + var(--safe-t)) calc(var(--s2) + var(--safe-r)) var(--s1)
    calc(var(--s2) + var(--safe-l));
  gap: var(--s2);
  flex-wrap: nowrap;
  align-items: center;
  background-color: var(--table);
}

/*
 * THE CLOCK, IN THE ONE ROW THAT SURVIVES THIS MODE.
 *
 * `shell/immersive.js` adopts `[data-t="timer"]` into this banner because the
 * strip it normally lives in is `display: none` here, and its own note carries
 * the measurement: Night Train's ninety-second turn clock reading "01:12" into
 * a box that is not drawn. This is what it is drawn as once it arrives.
 *
 * `margin-left: auto` puts it at the right-hand end, opposite the pill, with
 * the prompt taking the room between them. A clock is a glance, not a read,
 * and the far end of a banner is where a glance goes without disturbing the
 * sentence in the middle.
 *
 * SIZED SO THE ROW DOES NOT GROW, and that is load-bearing rather than
 * tasteful. This row is grid row 1 and the field is grid row 2, so every pixel
 * this takes is a pixel off the glass — which is the whole objection the strip
 * was removed from this mode over, and `belt-screen.spec.ts` holds the field
 * to the pixel. `--t-data-sm` against the pill's 34px leaves the row exactly
 * the height the pill already made it. `--t-clock` is the size for a strip
 * under a board with room to spend; it is the wrong size here.
 *
 * `baseline` on the base `.clock` aligns the label to the digits; in a row
 * whose other item is a 34px control, centre is what lines it up with the
 * pill, so this is one of the few places the base rule is overridden rather
 * than composed with.
 */
:root[data-immersive="true"] .room-pill-dock > .clock {
  flex: none;
  margin-left: auto;
  align-items: center;
  gap: var(--s2);
  min-height: 34px;
}

:root[data-immersive="true"] .room-pill-dock > .clock .clock-time {
  font-size: var(--t-data-sm);
}

/*
 * One line, clipped with an ellipsis rather than wrapped.
 *
 * `display: block` (not the base flex) so `text-overflow` has a block
 * container to act on; the act marker then has to say it is inline-block, or
 * a pseudo-element that was a flex item loses its own width and height.
 */
:root[data-immersive="true"] .prompt {
  display: block;
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  font-size: var(--t-small);
  line-height: 1.5;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

:root[data-immersive="true"] .prompt[data-tone="act"]::before {
  display: inline-block;
  vertical-align: middle;
  margin-right: var(--s2);
  transform: none;
}

:root[data-immersive="true"] .table-body {
  grid-row: 2;
  /* Block, so neither the desktop people column nor the phone flex column
     survives: the roster now lives in the pill and the panel gets the row. */
  display: block;
  min-height: 0;
  /* See the foot: min-content, not the track, is what a grid item falls back
     to, and a panel's min-content is whatever its widest fixed row happens to
     be. Both children say 0 so the column is the only width there is. */
  min-width: 0;
  height: 100%;
}

/*
 * The panel gets the viewport: no padding, no card, and no scroll of its own.
 * `hidden` rather than `auto` on purpose — a scroll container here is how the
 * board came to be a window onto a board rather than a board.
 */
:root[data-immersive="true"] .table-body > main {
  display: flex;
  height: 100%;
  min-height: 0;
  min-width: 0;
  overflow: hidden;
}

:root[data-immersive="true"] .room {
  padding: 0;
  gap: 0;
}

/*
 * ------------------------------------------ THE FOUR-STATE LOCK, AS CSS
 *
 * Declared here as well as scripted, so a missed listener cannot let the page
 * move. `data-screen` on the root is `lib/fullscreen.js` publishing which state
 * the document is in, and — REVISED 2026-08-06 — it is present from the first
 * frame of the mode rather than from a press. The owner pointed at a casino game
 * that never hides Safari's bar and simply refuses to let the page move under
 * the finger: **the game is locked in the view at all times, and folding the
 * browser's furniture away is a bonus on top, not the mechanism.**
 *
 * Two of the four states hide overflow, and two must not:
 *
 *   - `plain` (boot) and `windowed` — no runway exists, so hiding overflow costs
 *     nothing and guarantees the document cannot move at all. `body { position:
 *     fixed }` is the classic iOS lock and it is the right one here.
 *   - `solicit` — must scroll natively, or the bar will never fold. The whole
 *     point of the state is that the swipe reaches Safari as a root scroll.
 *   - `locked` — must STAY scrolled. **Hiding overflow here would clamp the
 *     offset to zero, and iOS reads a zeroed offset as a scroll to the top and
 *     restores its bar.** That is the exact bug that shipped once already:
 *     measured on this project's fixture at 390×664 with 72px of furniture, the
 *     lock took the range from 144 to 0 and the offset from 72 to 0 in the frame
 *     the fold was caught, and then said *"The bar came back."* `position: fixed`
 *     is never used for this state, for the same reason.
 *
 * ## The field, which is the same in all four
 *
 * `position: fixed` at `100dvh`: the DYNAMIC viewport, so it is always exactly
 * what can be seen, whichever state the toolbar is in — and fixed, so a document
 * scrolled by 56px looks identical to one that is not. Nothing on the field is
 * ever below the fold, in any state. That is the whole reason this is not simply
 * `height: 100lvh` on the shell, which is what the "size it to the large
 * viewport" recipe usually means: that recipe puts the bottom of the tray under
 * the fold until the swipe happens, and the tray is where End turn lives.
 *
 * ## The runway, which exists in `solicit` and in `locked`
 *
 * A spacer with nothing in it, whose height is arithmetic rather than a number
 * somebody liked. Write G for the browser's furniture:
 *
 *   - `calc(200lvh - 100svh)` = `lvh + G`. Toolbar out, the visible height is
 *     `svh`, so the range is **2G**; toolbar in, it is `lvh`, so the range is
 *     **G** — still positive, and that is the point. A page that becomes exactly
 *     one viewport the moment the toolbar retracts has nowhere left to be
 *     scrolled to, and Safari puts the toolbar straight back.
 *   - `100lvh + 2·var(--screen-fold) + 2px`, where `--screen-fold` is the same G
 *     measured live by `lib/fullscreen.js` from `innerHeight` against
 *     `visualViewport.height`. A toolbar that OVERLAYS the layout viewport moves
 *     neither CSS unit, so the first term computes to exactly one viewport and
 *     the page has nowhere to be scrolled to at all — the portrait half of the
 *     owner's report. The `2·G` and not `G` is the fourth fault as arithmetic:
 *     in the overlaying shape the visible height is `lvh`, not `svh`, so a term
 *     of `lvh + G` leaves a range of G + 2px where the units shape leaves 2G, and
 *     `askFold` spends exactly G of it on the press. Two pixels were then all the
 *     runway a real swipe had left. Both terms now leave 2G.
 *
 * **AND ALL OF THE 2G IS THE FINGER'S**, since 2026-08-06. It was once split —
 * "one G for the opening ask, one for the finger" — because the press scrolled
 * the page by G on its way into this state. It no longer does: `lab/touch.html`,
 * which is the model and the thing the owner says works, enters its soliciting
 * state and scrolls nothing at all, and side by side at 390×664 with 72px of
 * furniture the game left 74px under the finger out of a range of 146 where the
 * lab left 72 out of 72. Same swipe, twice the runway, no more room. The whole
 * range is the gesture's now, and the page starts at the top — which is where
 * Safari's own collapse heuristic reads a downward scroll from. See
 * `lib/fullscreen.js`, `askFold`.
 *
 * **BYTE FOR BYTE THE SAME DECLARATION IN BOTH STATES.** The document must not
 * change height when the lock lands, or the transition itself clamps the scroll
 * offset — and a clamped offset is a scroll to the top, which is one of the two
 * things that brings the bar back.
 *
 * Unverifiable here, and said plainly rather than dressed up: neither the author
 * nor the owner can run iOS Safari, Playwright's WebKit has no retractable
 * furniture, and so the toolbar retracting has been reasoned from the viewport
 * specification and Safari's documented behaviour, not measured. What IS
 * measured, at real device profiles under WebKit, is that the composition is
 * correct at both heights, that each state's declarations are what they claim,
 * and that nothing leaves the field while the document is scrolled.
 */

/* `:root` IS the html element, so it is named here and not descended into:
   base.css pins both to `height: 100%`, and a document that is exactly its own
   viewport tall cannot hold a spacer taller than one. */
:root[data-immersive="true"][data-screen="solicit"],
:root[data-immersive="true"][data-screen="solicit"] body,
:root[data-immersive="true"][data-screen="locked"],
:root[data-immersive="true"][data-screen="locked"] body {
  height: auto;
  min-height: 100%;
}

:root[data-immersive="true"][data-screen] .shell {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Still exactly one viewport — the DYNAMIC one, so it grows into the space the
     toolbar frees the instant it frees it. */
  height: 100dvh;
}

:root[data-immersive="true"][data-screen="solicit"] body::after,
:root[data-immersive="true"][data-screen="locked"] body::after {
  content: "";
  display: block;
  height: max(calc(200lvh - 100svh), calc(100lvh + 2 * var(--screen-fold, 0px) + 2px));
}

/*
 * THE LOCK. `body { position: fixed; inset: 0 }` takes the root scroller away
 * entirely, so no finger, in any direction, on any surface, can produce a scroll.
 * That is the right lock for `plain` — nothing has been asked to fold yet — and
 * for `windowed`, where the bar is accepted and an unscrollable page is exactly
 * what is wanted.
 *
 * It is the WRONG lock for `locked`, which is excluded here and handled by the
 * runway above plus a pin in `lib/fullscreen.js`: stay scrolled, rather than
 * become unscrollable.
 *
 * `body` and not `:root`. `position: fixed` on the root element is legal and
 * behaves oddly — the root establishes the initial containing block that fixed
 * positioning is defined against — and the classic iOS lock has always been on
 * the body for that reason.
 *
 * The JavaScript half (a non-passive `touchmove` that calls `preventDefault`
 * outside inner scrollers, and a prevented `gesturestart`) lives in
 * `lib/fullscreen.js` and is installed and removed with these states. Neither
 * half is sufficient: CSS alone leaves Safari free to interpret the gesture, and
 * JS alone leaves a frame of async scroll before the listener runs.
 */
:root[data-immersive="true"][data-screen="plain"],
:root[data-immersive="true"][data-screen="windowed"] {
  height: 100%;
  overflow: hidden;
  overscroll-behavior: none;
}

:root[data-immersive="true"][data-screen="plain"] body,
:root[data-immersive="true"][data-screen="windowed"] body {
  position: fixed;
  inset: 0;
  overflow: hidden;
  overscroll-behavior: none;
}

/*
 * The held lock's own rubber-band suppression. It keeps its scroller — that is
 * the point — so `overflow` is left alone and only the chaining is stopped: an
 * overscroll at either end must not bounce, because a bounce IS a scroll to iOS
 * and the bar is watching for one.
 */
:root[data-immersive="true"][data-screen="locked"],
:root[data-immersive="true"][data-screen="locked"] body {
  overscroll-behavior: none;
}

/*
 * `clip`, not `hidden`, while the machine is running.
 *
 * An `overflow: hidden` box IS a scroll container — it simply has no scrollbar —
 * and a touch that lands inside one can be claimed by it rather than chained out
 * to the document. There are two of them between the board and the page (this one
 * and the game's own stage), and in `solicit` the gesture that has to reach the
 * document is the entire point of the state. `overflow: clip` is defined as never
 * creating a scroll container, which says the same thing about the layout and
 * leaves no room for the question.
 */
:root[data-immersive="true"][data-screen] .table-body > main {
  overflow: clip;
}

/*
 * ------------------------------------------------------------ THE SOLICITATION
 *
 * A direction, drawn on the glass while the page waits for a swipe.
 *
 * **`pointer-events: none` on everything but the button is the single most
 * important declaration in this block.** The gesture being asked for has to
 * reach the document underneath as a native scroll; an overlay that swallows the
 * touch is the commonest reason the bar never folds, and it fails silently —
 * the prompt sits there telling you to do the thing it is preventing. So the
 * card, the arrow and both lines are transparent to a finger and only
 * `.screenprompt-keep` takes a press.
 *
 * Placement: centred, then lifted clear of the bottom `48px + safe-area`. Two
 * different things live down there and neither can be intercepted — Safari's
 * minimised pill (tapping it re-expands the bar, and no API prevents that) and
 * the home indicator. A button a thumb reaches for in the dark must not share
 * that band.
 *
 * It is the shell's chrome and it names no game, because it is about the
 * browser's furniture and not about what is being played on it. The one word it
 * uses for the thing behind it is "the game".
 */
.screenprompt {
  position: fixed;
  inset: 0;
  z-index: 70;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: var(--s4);
  padding-bottom: calc(48px + var(--safe-b) + var(--s4));
  pointer-events: none;
}

.screenprompt-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s2);
  padding: var(--s4) var(--s5);
  max-width: min(22rem, 100%);
  text-align: center;
  background-color: var(--panel);
  color: var(--on-table);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-panel);
  box-shadow: var(--glaze-line), var(--lift-sm);
}

.screenprompt-arrow {
  width: 30px;
  height: 39px;
  display: block;
}

/*
 * Three chevrons climbing, one behind the other, so the mark reads as "up"
 * before the line under it is read at all.
 *
 * The stagger is decoration and nothing depends on it: under
 * `prefers-reduced-motion` the animation is dropped and the three arms simply
 * sit at their resting opacities, still pointing.
 */
.screenprompt-arm {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* 0.5 and not the 0.35 this started at: photographed on the field at 390×664,
     the faintest chevron of three simply did not read and the mark looked like
     two. The gradient is there to say "up", and a step nobody sees is one step. */
  opacity: 0.5;
  animation: screenprompt-rise 1.6s ease-in-out infinite;
}

.screenprompt-arm[data-i="1"] {
  opacity: 0.72;
  animation-delay: 0.12s;
}

.screenprompt-arm[data-i="2"] {
  opacity: 1;
  animation-delay: 0.24s;
}

@keyframes screenprompt-rise {
  0%,
  100% {
    transform: translateY(2px);
  }

  50% {
    transform: translateY(-2px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .screenprompt-arm {
    animation: none;
  }
}

.screenprompt-say {
  margin: 0;
  font-weight: 600;
}

.screenprompt-sub {
  margin: 0;
  font-size: var(--t-small);
  line-height: 1.45;
  color: var(--dim);
}

/* The only thing on this layer a finger can land on. */
.screenprompt-keep {
  pointer-events: auto;
  margin-top: var(--s2);
}

/*
 * A PHONE-SIZED FIELD: THE BANNER STOPS BEING A ROW AND BECOMES A CORNER OBJECT.
 *
 * A grid row costs its height out of the one thing the mode exists to make
 * large. On a tall phone 43px out of 844 is 5% and the row looked honest;
 * rotate the same phone and it is 43px out of **342** — 12.6% of the screen
 * spent on a room code and one sentence, in the orientation a board game is
 * actually played in.
 *
 * Measured on a live Belt sitting under WebKit at `iPhone 13 landscape`
 * (Playwright's descriptor: **750×342**, not the bare 844×390 the gate used —
 * Safari's own chrome and the notch insets are the difference, and 844×390 is
 * not a viewport any iPhone presents):
 *
 *   - the banner row took y=0..43 and handed the game y=43..342;
 *   - the board's painted extent was **139px of 342 — 40.6%** against the
 *     reference composition's 73%, and 24.3% once Safari's URL bar is up.
 *
 * So where the field is short the banner is pinned over it in the top-left
 * corner, transparent, with the pill and the prompt as their own rimmed enamel
 * objects on the sea — which is what every other control in this mode already
 * is, and what the reference does with its own top banner.
 *
 * ## And then the same 43px in portrait, 2026-08-05
 *
 * "5% and the row is honest" was measured against a screen the browser was not
 * actually giving us. The height a phone in portrait hands a page is not 844
 * but **664** with Safari's toolbar out (and 720 once it folds — the state
 * `lib/fullscreen.js` now goes after), so the row is 6.5%, not 5%. That is
 * still small. What is not small is what it does to the board's BOX, measured
 * on a live sitting at 390×664:
 *
 *   | banner        | box the field gives the board | of the screen |
 *   | ------------- | ----------------------------- | ------------- |
 *   | a row         | 374×387                       | **58.3%**     |
 *   | a corner object | 374×430                     | **64.8%**     |
 *
 * 62% is what the gate asks for, against the reference composition's 73%. The
 * row is the whole of the difference: one 43px band is what stands between a
 * portrait phone passing and failing, and it is holding a room code that a
 * person looks at once when they join.
 *
 * So the query gains a width arm. 47rem = 752px is this project's phone
 * breakpoint everywhere else (a real iPhone 13 reports 750 and the bench
 * stacks two pixels inside it), and a screen that narrow has the same problem
 * in either orientation. A tablet and a desk keep the row: they have the
 * height to spend and a 15rem corner panel to put beside the board.
 *
 * 56% wide and no wider: the rest of the top edge belongs to whatever the
 * game floats in ITS top-right corner, and a cap is the only way two
 * absolutely-positioned strips owned by different modules can be *proved*
 * never to cover each other. `pointer-events` is off on the strip and back on
 * for its two children, so a drag that starts on the empty sea beside the
 * prompt still reaches the game under it.
 *
 * 520px and 47rem, not an orientation query: a laptop window dragged short has
 * exactly the same problem, and a tablet held wide does not have it at 1024.
 *
 * ## AND 56% OF THE SCREEN IS NOT 56% OF THE SEA, 2026-08-06
 *
 * The cap above proves this strip clear of the game's own top-right cluster and
 * says nothing whatever about the game's OBJECT, which is the thing the mode
 * exists for. Measured on a live Belt sitting: the belt is fitted to the height
 * a phone on its side leaves and comes out 266px wide on a 750px screen, so it
 * begins at x=242 — and 56% of 750 is 420. The banner ran 178px into the board.
 * Photographed, that is the top row of hexes and a port with a beige plaque
 * lying across them, and it is what the owner reported twice as the board being
 * "clipped at the top": **3,997px² of drawn belt covered at 750×342 and 4,191px²
 * at 568×320.**
 *
 * A cap against the screen is a GUESS AT THE SEA, and this project has now paid
 * for the same guess three times — once as the Belt's `padding-right` reserve
 * for its standings, once as the `27vw` the standings sized themselves by, and
 * here. Both of those are gone; the game's camera measures the sea its own fit
 * leaves and publishes it on the root as `--field-flank-*` (belt/camera.js),
 * exactly as the shell publishes `--shell-foot` for a game to spend. So the
 * banner is bounded by a fact instead of a fraction.
 *
 * The rule reads no game and requires none: the fallback is `100%`, under which
 * `min()` leaves the 56% cap standing and every non-immersive page is untouched.
 * The floor is the pill — a room code is the one thing on this strip that
 * cannot stand down, and a screen with no sea beside its board (a phone held
 * upright, where the flank is 8px) is a screen whose game reserves the top edge
 * as padding instead. Both mechanisms, one composition: whoever has the room
 * gives it.
 */
@media (max-height: 520px), (max-width: 47rem) {
  :root[data-immersive="true"] .table {
    grid-template-rows: minmax(0, 1fr);
  }

  /*
   * WHAT THE OVERLAY OCCUPIES, SAID OUT LOUD — 2026-08-28.
   *
   * Below this width the dock stops being a row and becomes an absolutely
   * positioned overlay in the top-left corner. Over a map that is free: the
   * games that ask for the viewport are boards you look INTO, and a pill in the
   * corner costs them nothing.
   *
   * Over a game that draws a line of its own at the top it is not free, and
   * this sheet has photographed that twice — *"in Sixth Takes at 844x390 with
   * 'Choosing' printed across ROUND 1 · CARD 1 OF 10"* in the note above, and
   * The Shake's *"Drag across the letters. Or type."* rendering as *"…s the
   * letters. Or type."* underneath the pill and the clock.
   *
   * A game cannot reserve room it is not told about. `--immersive-dock` is that
   * number, and the dock takes it as its own `min-height` so the two cannot
   * drift: whatever the overlay occupies is what a panel is told to clear.
   *
   * 50px is measured rather than picked — a 34px pill with `--s2` above and
   * below it — and it is the figure four of the five profiles in the note below
   * already report.
   */
  :root[data-immersive="true"] {
    --immersive-dock: 50px;
  }

  :root[data-immersive="true"] .room-pill-dock {
    position: absolute;
    z-index: 40;
    top: 0;
    left: 0;
    right: auto;
    min-height: var(--immersive-dock);
    /*
     * `grid-row: auto`, undoing the row assignment above: an absolutely
     * positioned child of a grid container that names a definite grid
     * position takes that grid AREA as its containing block, and WebKit then
     * stretches it. Measured at iPhone SE landscape (568×320): the banner box
     * was 250×**320** — the full height — and `align-items: center` put the
     * room code and the prompt in the middle of the sea. The other four
     * profiles reported 50. Naming no row makes the padding box of `.table`
     * the containing block and the height content-sized again.
     */
    grid-row: auto;
    height: fit-content;
    /* 56%: the widest the banner can be and still be provably clear of the
       game's own top-right cluster (a gear and two dice, ~164px inset from
       the right edge) at the narrowest screen in the table below — 568 × 0.56
       = 318 against a cluster that starts at 404. It survives the width arm
       unchanged: at 390 the cluster measures 108px and starts at 282, and
       390 × 0.56 = 218.
       …and the sea beside the game's own object, whichever is less — see the
       preamble. `--field-flank-l` is the game's measurement of it; 100% is the
       fallback, under which this is the 56% cap alone. The `max()` is the room
       pill's floor: it is the one thing here that cannot stand down, and where
       the flank is 8px (a phone held upright) the game reserves the top edge
       as padding instead, so the pill costs the board nothing. */
    max-width: min(56%, max(7rem, calc(var(--field-flank-l, 100%) - var(--s2))));
    padding: var(--s2);
    gap: var(--s2);
    border-bottom: 0;
    background-color: transparent;
    pointer-events: none;
  }

  :root[data-immersive="true"] .table-body {
    grid-row: 1;
  }

  /*
   * THE DIGITS ONLY, BECAUSE THIS BANNER IS 56% OF A PHONE.
   *
   * The cap above is a measurement — the widest this overlay can be and stay
   * provably clear of a game's own top-right cluster — which at 390px is
   * 218px, and the pill has already spent about ninety of them. "Turn ends in
   * 01:12" does not fit beside it; "01:12" does, with room left.
   *
   * The label is what goes, not the number, and the two are not equal here: a
   * phase name is a thing you read once and the count is a thing you glance at
   * repeatedly, and every immersive panel already draws its own line saying
   * what the phase is — `.sn-say` in Sixth Takes states exactly that reason,
   * *"on a phone in this mode the shell's is gone and this is the only one
   * there is."* So the sentence is not lost; the duplicate of it is.
   *
   * It also keeps the rule honest at the one size that matters most: a
   * ninety-second turn clock is worth the most to somebody holding the
   * screen in one hand.
   */
  :root[data-immersive="true"] .room-pill-dock > .clock .clock-label {
    display: none;
  }

  /*
   * ...AND WITH THE LABEL GONE, AN IDLE CLOCK HAS NOTHING LEFT TO SAY.
   *
   * `chrome.js` keeps the phase name and drops the digits when nothing is
   * timed — the right answer in a strip, where the name is the only thing on
   * the row. Here the name is already hidden by the rule above, so an
   * unhurried table would float an empty rimmed box over its own board.
   * Photographed exactly that way in Sixth Takes at a table of one before this
   * line existed. `[data-idle="true"]` is the shell's own word for it, set on
   * the same call that paints the digits, so there is nothing new to keep in
   * step.
   */
  :root[data-immersive="true"] .room-pill-dock > .clock[data-idle="true"] {
    display: none;
  }

  :root[data-immersive="true"] .roompill,
  :root[data-immersive="true"] .prompt {
    pointer-events: auto;
  }

  /*
   * An object on the sea, for the reason the prompt's rule below gives: the
   * field is a starfield in all twelve rooms and in both themes, so unrimmed
   * digits over it are unreadable in half of them.
   *
   * NOT `pointer-events: auto`, unlike the two above it. Those are controls; a
   * clock is read and never pressed, and this dock's `none` exists so a finger
   * that lands on the board over which it floats reaches the board. Painting a
   * box opaque and leaving it transparent to presses is exactly the pairing
   * this mode wants.
   */
  :root[data-immersive="true"] .room-pill-dock > .clock {
    padding: 2px var(--s2);
    background-color: var(--table);
    color: var(--on-table);
    border: var(--rim-hair) solid var(--rim);
    border-radius: var(--r-control);
  }

  /*
   * AND THE BAR INSIDE THE PILL IS A RULE UNDER THE DIGITS, NOT A SECOND RIM.
   *
   * The base rule lays the bar across the clock's padding box, which
   * everywhere else IS the clock. Here the clock is a boxed object on the
   * field: `padding: 2px var(--s2)` overrides the 6px the base reserves, and
   * an absolute `inset-inline: 0` runs corner to corner — so the bar lands on
   * the bottom border, inside the corner radius, and reads as part of the rim
   * rather than as a reading. Photographed at 390 before this rule: 53×2 at
   * the foot of a 53px pill, and invisible.
   *
   * So the pill pays the 6px back and the bar is inset by the pill's own
   * inline padding, which puts its ends under the digits' ends.
   */
  :root[data-immersive="true"] .room-pill-dock > .clock {
    padding-block-end: 6px;
  }

  :root[data-immersive="true"] .room-pill-dock > .clock .clock-bar {
    inset-inline: var(--s2);
    bottom: 2px;
  }

  /* An object on the sea, not a line of ink on it: the field is a starfield
     in all twelve rooms and in both themes (belt.css keeps it dark on
     purpose), so unrimmed body text over it is unreadable in half of them. */
  :root[data-immersive="true"] .prompt {
    flex: 0 1 auto;
    padding: 2px var(--s2);
    background-color: var(--table);
    color: var(--on-table);
    border: var(--rim-hair) solid var(--rim);
    border-radius: var(--r-control);
  }
}

/*
 * AND BELOW 30rem THE SENTENCE STANDS DOWN AND THE PILL HOLDS THE CORNER ALONE.
 *
 * The banner is capped at 56% of the edge (above), so what the prompt actually
 * gets is `0.56·W − 95px` once the pill and the gap beside it are paid for.
 * Arithmetic at the three sizes this mode is composed for:
 *
 *   | screen | banner | left for the sentence |
 *   | ------ | ------ | --------------------- |
 *   | 750    | 420    | 325px — a clause      |
 *   | 568    | 318    | 223px — a few words   |
 *   | 390    | 218    | **123px** — a stub    |
 *
 * 123px at `--t-lead` is about fifteen characters: a rimmed object saying
 * "Build, tr…", which reads as a fault rather than as a fact. A truncated
 * sentence is worse than no sentence, and this is the one line in the mode
 * that is pure restatement — the game owns the field and states its own
 * condition on it, twice (the dock's hint line and the state of its own
 * buttons), which is exactly why the reference composition has no banner.
 *
 * 30rem = 480px: every phone in portrait is inside it and every phone on its
 * side is outside it, so nothing that reads today stops reading. The rule
 * names no game — it is about how many pixels a sentence needs.
 *
 * ## AND A PHONE ON ITS SIDE JOINS THEM, 2026-08-06
 *
 * The table above measured the sentence against the SCREEN, and the screen was
 * never what it had. The banner is now bounded by the sea the game's own object
 * leaves beside it (see the preamble to the block above), and re-measured
 * against that the second column of the table collapses:
 *
 *   | screen  | sea to the left | banner | left for the sentence |
 *   | ------- | --------------- | ------ | --------------------- |
 *   | 750×342 | 242             | 234    | **131px**             |
 *   | 734×343 | 234             | 226    | **123px**             |
 *   | 568×320 | 163             | 155    | **52px**              |
 *
 * 131px is the same stub the 390px row was hidden for, and 52px is three
 * characters. So the arm is added rather than the number argued with: where a
 * screen is short, the game's object is fitted to the height and the only sea
 * is at the sides, and the sea a board leaves beside itself will not seat a
 * sentence at any phone size. The pill holds the corner alone, exactly as it
 * already does in portrait — which is to say a landscape phone now shows what a
 * portrait phone has shown since this mode was written, and the reasoning
 * above is unchanged: the game states its own condition on the field, twice.
 *
 * 520px, matching the block above, so the two halves of one composition are
 * decided by one number.
 */
@media (max-width: 30rem), (max-height: 520px) {
  :root[data-immersive="true"] .prompt {
    display: none;
  }
}

/* -------------------------------------------------------- the room pill */

/*
 * Everything the room is, in a control the size of a thumb.
 *
 * Code and a connection dot at rest — the two facts worth a glance mid-round —
 * and on press the invitation, the shelf, the people, the way out and the two
 * appearance switches. It sits inside the banner rather than floating over the
 * board so that the shell's own two pieces of chrome cannot collide.
 */
.roompill {
  position: relative;
  flex: none;
}

.roompill[hidden] {
  display: none;
}

.roompill-tab {
  /* Above its own backdrop, below its own sheet: the three sit in one
     stacking context precisely so the order is these numbers and nothing
     further up the tree. */
  position: relative;
  z-index: 55;
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  /* 34px tall: a target a thumb finds without looking, and the same height as
     the game furniture that sits beside it in the row below. */
  min-height: 34px;
  padding: 0 var(--s3);
  background: var(--panel);
  border: var(--rim-hair) solid var(--rim);
  border-radius: var(--r-control);
  box-shadow: var(--glaze-line);
  color: var(--on-table);
  font-family: var(--font-mono);
  font-size: var(--t-data-sm);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  cursor: pointer;
}

@media (hover: hover) {
  .roompill-tab:hover {
    color: var(--on-table-strong);
  }
}

/* The press the hover was standing in for — see `.picker-item:active`. It takes
   the open state's ground as well as its ink, because this tab's whole job is
   to open, and arriving at the opened look under the thumb is the truest
   preview of what the press is about to do. */
.roompill-tab:active {
  background: var(--table);
  color: var(--on-table-strong);
  transform: translateY(1px);
}

.roompill-tab[aria-expanded="true"] {
  background: var(--table);
  color: var(--on-table-strong);
}

/*
 * The connection, as the dot alone. The word is in the sheet's own chrome
 * nowhere — mid-round "CONNECTED" is furniture — but the dot is the thing
 * people actually glance at, and it reads the state off the root so there is
 * one call (`setTransport`) and no second copy to forget.
 */
.roompill-dot {
  width: 7px;
  height: 7px;
  flex: none;
  background: var(--dimmer);
}

:root[data-conn="ws"] .roompill-dot {
  background: var(--agree);
}

:root[data-conn="sse"] .roompill-dot {
  background: var(--on-table);
}

:root[data-conn="lost"] .roompill-dot {
  background: var(--stamp);
}

.roompill-sheet {
  position: absolute;
  z-index: 60;
  top: calc(100% + var(--s2));
  left: 0;
  width: min(23rem, calc(100vw - var(--s3) * 2));
  /*
   * Below the banner and above the bottom edge: the shelf inside it is a
   * screen in its own right and will not fit a phone. What scrolls is the BODY
   * and not the sheet — see `.roompill-body` below for the measurement that
   * moved it there.
   *
   * AND THE CEILING IS MEASURED RATHER THAN ASSUMED. `calc(100dvh - 4.5rem)` is
   * the right shape for a sheet that starts at the top of the screen and this
   * one does not: it opens under a tab whose place the banner decides. Measured
   * at 390×844 the tab sat at y=8, the sheet at y=50, and 772px of ceiling put
   * its bottom at 822 — 22px of clearance where 72 was intended, with nothing
   * anywhere to stop a taller banner hanging the sheet off the bottom of the
   * glass. There is no CSS expression for "how far down the page am I", so that
   * one fact is published from JS (`shell/immersive.js`), the same trade
   * `publishShellFoot` makes for `--shell-foot`. The constant stays as the
   * fallback for the frame before the first measurement lands.
   */
  max-height: var(--roompill-ceiling, calc(100dvh - 4.5rem));
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding: var(--s3);
  background-color: var(--table);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-field);
  box-shadow: var(--glaze-line), var(--lift-sm);
}

.roompill-sheet[hidden] {
  display: none;
}

.roompill-head {
  display: flex;
  align-items: center;
  gap: var(--s3);
}

.roompill-lamps {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--s2);
}

/*
 * THE SCROLLER IS HERE, BETWEEN A FIXED HEAD AND A FIXED FOOT.
 *
 * It was on `.roompill-sheet`, which made the whole menu one scrolling column
 * with the way out as its last row. The list of people is the one thing in
 * that column whose height is somebody else's decision, and it sits directly
 * above the exit: measured at 390×844 in a Belt room of one player and three
 * stand-ins, `leave-room` centred at y=955 in an 844px window — 111px below
 * the fold, in the menu that exists to hold it. A room of eight is worse.
 *
 * `min-height: 0` is what lets a flex item shrink below its content and is the
 * whole mechanism; without it the body takes its natural height, the sheet
 * grows past its own `max-height`, and nothing scrolls anywhere.
 */
.roompill-body {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}

/*
 * The foot the exit was moved into, and the hairline is doing work: it is the
 * seam that says the list above it has been cut short rather than ended.
 */
.roompill-foot {
  flex: none;
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding-top: var(--s3);
  border-top: var(--hair);
}

/* Adopted out of the people column, so it arrives wearing that column's own
   spacing. In a foot it is two buttons and nothing else. */
.roompill-foot > .room-exit {
  margin-top: 0;
  padding: 0;
  border: 0;
}

/* And a foot with nothing in it is a hairline under a menu for no reason.
   `.room-exit:empty` already hides itself; the seam above it has to go too. */
.roompill-foot:not(:has(> .room-exit > *)) {
  display: none;
}

/* Adopted, not rebuilt — so the people column arrives wearing the border it
   wore beside the table, which reads as a seam in a menu. */
.roompill-body > .people {
  border: 0;
  padding: 0;
}

.roompill-scrim {
  position: fixed;
  inset: 0;
  z-index: 50;
}

.roompill-scrim[hidden] {
  display: none;
}

/*
 * ------------------------------------------------------------ THE BLACK BOX
 *
 * The page's own account of the last few minutes, raised over everything.
 *
 * It lives on `document.body` (see `lib/blackbox.js`) for the same reason the
 * solicitation above does: it is read at the moment the thing underneath it has
 * stopped answering, so it cannot be drawn inside that thing. Which makes the
 * stack order the whole of this block's job — 80, above the room pill's 60 and
 * the solicitation's 70, because a sheet somebody opened deliberately outranks a
 * prompt that raised itself.
 *
 * Enamelware on petrol, like every other object here: a rimmed panel over a
 * dimmed table, glazed at the top edge. The tape inside it is the one place in
 * the product that is deliberately NOT typeset — it is a machine's log, columns
 * matter, and a proportional face would hide the gaps that are the evidence.
 */
.blackbox {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: calc(var(--s3) + var(--safe-t)) var(--s3) calc(var(--s3) + var(--safe-b));
  /* A wash rather than a curtain: the board stays legible behind it, because
     "what was on screen when it stopped" is part of what is being reported. */
  background-color: color-mix(in srgb, var(--table) 78%, transparent);
}

.blackbox-card {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  width: min(46rem, 100%);
  max-height: 100%;
  min-height: 0;
  padding: var(--s3);
  background-color: var(--panel);
  color: var(--on-table);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-panel);
  box-shadow: var(--glaze-line), var(--lift-sm);
}

.blackbox-head {
  display: flex;
  align-items: center;
  gap: var(--s3);
}

.blackbox-close {
  margin-left: auto;
}

/*
 * The tape. Scrolls INSIDE itself, and that is not only a layout choice: while a
 * game holds the viewport `lib/fullscreen.js` cancels every `touchmove` that is
 * not inside a genuine scroll container, so a `<pre>` that merely overflowed
 * would be a wall of text a finger could not move. `overscroll-behavior: contain`
 * is the other half — it keeps the scroll from chaining out to the document,
 * which is the gesture the solicitation may be waiting for.
 */
.blackbox-tape {
  flex: 1;
  min-height: 6rem;
  margin: 0;
  padding: var(--s2);
  overflow: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* Never wrapped. A wrapped column is a column that no longer lines up, and
     the columns are how a silence is spotted at a glance. */
  white-space: pre;
  font-family: var(--font-mono);
  /* 12px, measured rather than picked: a line is `time +gap tag detail`, about
     88 characters at its widest, and 12px of this face is ~7.2px per character
     — so a real iPhone in landscape (750px) reads a whole line without moving
     anything. Portrait scrolls sideways, which is the honest answer for a
     fixed-column log and better than wrapping columns that then line up with
     nothing. It sits on `--on-table` over `--table`, the product's own ink
     pairing, so it is as readable as the prompt. */
  font-size: 12px;
  line-height: 1.5;
  color: var(--on-table);
  background-color: var(--table);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-control);
  /* Selectable, and said out loud: rung three of the copy path is a long press
     on this text, and a `user-select: none` inherited from the field would take
     the fallback away on exactly the device that needs it. */
  user-select: text;
  -webkit-user-select: text;
}

.blackbox-row {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
}

.blackbox-said {
  margin: 0;
  font-size: var(--t-small);
}

/* ------------------------------------------------------------- customize */

/*
 * ONE ROW ABOVE THE GAME, SAYING WHAT THE GAME IS SET TO.
 *
 * `shell/customize.js` argues what this is and why the closed row carries the
 * values rather than the word "Options". This is how it is drawn, and there are
 * three decisions in the drawing.
 *
 * **It is quiet, and the fact inside it is not.** The row is a rule and a
 * press, with no fill and no rim — it must not compete with the game's own
 * heading, which is directly beneath it. What it does spend is on
 * `.customize-now`: the values are set in the reading face at the size a chair's
 * name is, because they are the only thing on this row a person is actually
 * reading. "Customize" is the mono caption above the fact, which is the house
 * arrangement for anything `.data` (see the note on `--t-data` in `tokens.css`).
 *
 * **It reserves nothing when it is empty.** Thirty-four lobbies have not adopted
 * this and `lobby-reachable.spec.ts` measures every one of them against the
 * fold, so a container that claimed a line for a game with no settings would
 * push every unconverted start control down by it. `:empty` collapses the slot
 * outright — no margin, no border, no gap contribution.
 *
 * **It opens in flow.** Nothing here is absolute or pinned: opening the row
 * grows the page and the board moves down, which on a lobby is exactly right —
 * a lobby is a document, the panel below it scrolls, and an overlay would put
 * eleven controls on top of the chairs they are about to change the number of.
 */
.customize-slot {
  min-width: 0;
}

.customize-slot:empty,
.customize-slot[hidden] {
  display: none;
}

.customize {
  display: flex;
  flex-direction: column;
  border-bottom: var(--hair);
  margin-bottom: var(--s3);
}

.customize-head {
  display: flex;
  align-items: baseline;
  gap: var(--s3);
  width: 100%;
  /* The tap floor, and it is the reason for the padding rather than a height:
     a row whose text is one line and whose box is 44px is a row with the press
     where the eye is, at every size. */
  min-height: var(--tap-min);
  padding: var(--s2) 0;
  background: none;
  border: 0;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.customize-says {
  flex: none;
  color: var(--dim);
}

/*
 * The fact. It takes the room, wraps if it must, and never ellipses: "Three
 * minutes · 3 races · 8 colours" on a 360px phone is two lines and both of them
 * are worth having, where "Three minutes · 3 rac…" is a setting somebody now
 * has to open the row to read — which is the whole failure this replaces.
 */
.customize-now {
  flex: 1;
  min-width: 0;
  font-family: var(--font-display);
  font-size: var(--t-lead);
  font-weight: 600;
  letter-spacing: var(--track-display);
  line-height: 1.2;
  color: var(--on-table-strong);
}

.customize-chevron {
  flex: none;
  color: var(--dim);
  font-size: var(--t-lead);
  line-height: 1;
  transition: transform 140ms var(--ease);
}

.customize[data-open="true"] .customize-chevron {
  transform: rotate(90deg);
}

@media (hover: hover) {
  .customize-head:hover .customize-now {
    color: var(--lit);
  }
}

.customize-open {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  padding: var(--s2) 0 var(--s4);
}

/* Said out loud, because the rule above sets `display: flex` and outranks the
   UA's single-attribute `[hidden]` selector. The same pairing `.panel-body`
   needs, and the same hour it costs when it is missing. */
.customize-open[hidden] {
  display: none;
}

.customize-setting {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  min-width: 0;
}

.customize-ask {
  margin: 0;
  color: var(--dim);
}

.customize-note {
  margin: 0;
  font-size: var(--t-small);
}

.customize-values {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
}

/*
 * A value, and it is drawn as a chip rather than as a button.
 *
 * The chosen one is filled and the rest are outlined, which is the only pair of
 * states this needs: these are one decision with several answers, exactly one
 * true, and `aria-pressed` on each says so to anything that cannot see a fill.
 * That attribute is not decoration — `mastermind/panel.js` records that `.is-on`
 * alone left the chosen row reading identically to the unchosen ones.
 */
.customize-value {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-min);
  padding: var(--s2) var(--s3);
  font-family: var(--font-body);
  font-size: var(--t-small);
  font-weight: 500;
  line-height: 1.2;
  color: var(--on-table);
  background: none;
  border: var(--hair-strong);
  border-radius: var(--r-control);
  cursor: pointer;
}

/* A value nobody may change is a `<span>` — see `customize.js`. It reads the
   same and does not offer a press it cannot honour. */
.customize-value:not(button) {
  cursor: default;
}

.customize-value.is-on {
  color: var(--on-lit);
  background-color: var(--lit);
  border-color: var(--lit);
}

/*
 * THE CHOSEN ONE STILL ANSWERS THE POINTER, AND THAT IS A RULE WITH A HISTORY.
 *
 * `affordance.spec.ts` found the same bug in eight lobbies wearing eight class
 * names: the chosen-state rule sets the very property the shared `:hover`
 * moves, from a selector that is longer or later, so it wins — and the one
 * control on the screen that stopped reacting was the one you had picked.
 * Pressing an option again is how a person finds out it is already on, so it
 * has to go on behaving like a control.
 *
 * Written as two rules rather than one with `:not(.is-on)`, deliberately: the
 * chosen chip is a fill and the rest are outlines, so what moves under the
 * cursor has to be a different property for each. One rule covering both would
 * be the fault this comment is about.
 */
@media (hover: hover) {
  .customize-value:is(button):not(.is-on):hover {
    border-color: var(--lit);
    color: var(--on-table-strong);
  }

  .customize-value:is(button).is-on:hover {
    background-color: color-mix(in srgb, var(--lit) 84%, var(--on-lit));
    border-color: color-mix(in srgb, var(--lit) 84%, var(--on-lit));
  }
}

.customize-value:is(button):active {
  transform: translateY(1px);
}

/* ----------------------------------------------------- the stand-in picker */

/*
 * WHICH COUNTER IS YOURS — the sheet, 2026-08-26.
 *
 * The same object as the stand-in sheet a few rules down and deliberately not
 * the same CLASSES: that file's own note records what it cost to take a name
 * another component already owned. Same curtain, same card, same rim, because
 * two sheets in one product that look like two different products is worse than
 * a little repetition in a stylesheet.
 *
 * What is its own is the grid. Twelve discs is the whole content, so they are
 * the largest thing on the card and are laid out to be aimed at rather than
 * read: `--tap-min` square with the disc drawn inside, which keeps the target a
 * finger's width while the mark stays the size the roster draws it.
 */
.ink-sheet {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: calc(var(--s3) + var(--safe-t)) var(--s3) calc(var(--s3) + var(--safe-b));
  background-color: color-mix(in srgb, var(--table) 88%, transparent);
}

.ink-card {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  width: min(26rem, 100%);
  max-height: 100%;
  min-height: 0;
  padding: var(--s4);
  background-color: var(--panel);
  color: var(--on-table);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-panel);
  box-shadow: var(--glaze-line), var(--lift-sm);
}

.ink-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
}

.ink-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--t-title);
  letter-spacing: -0.01em;
}

.ink-grid {
  display: grid;
  /* Six across at a desk and four on the narrowest phone, both by arithmetic
     rather than by breakpoint: `auto-fit` over a `--tap-min` floor. */
  grid-template-columns: repeat(auto-fit, minmax(var(--tap-min), 1fr));
  gap: var(--s2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.ink-disc {
  display: grid;
  place-items: center;
  inline-size: 100%;
  block-size: var(--tap-min);
  padding: 0;
  background: none;
  border: 0;
  border-radius: var(--r-chip);
  cursor: pointer;
}

.ink-disc-mark {
  inline-size: 1.75rem;
  block-size: 1.75rem;
  border-radius: 50%;
  background-color: var(--person, var(--person-1));
  background-image: var(--gloss);
  /* The same ring the bead on a phone takes, and for the same reason: four of
     the twelve are pale and a pale disc on a pale card is a mark you have to
     already know is there. */
  box-shadow: 0 0 0 var(--rim-w) var(--rim), var(--lift-xs);
  transition: transform var(--press) var(--ease);
}

.ink-disc:active .ink-disc-mark {
  transform: scale(0.94);
}

/* The one you are wearing, marked the way your own counter is marked on the
   roster — a ring of `--lit` rather than the absence of something. */
.ink-disc[aria-pressed="true"] .ink-disc-mark {
  box-shadow: 0 0 0 var(--rim-w) var(--rim), 0 0 0 5px color-mix(in srgb, var(--lit) 72%, transparent);
}

/*
 * A COUNTER SOMEBODY ELSE IS WEARING IS DRAWN, NOT HIDDEN.
 *
 * A grid with gaps in it that move as people come and go is a grid nobody can
 * learn, and "why can I not have blue" has an answer worth giving — the button
 * carries whose it is in its `title` and its accessible name. Dimmed in INK
 * rather than made see-through, which is the rule `primary-visible.spec.ts`
 * holds every disabled control in this product to.
 */
.ink-disc[disabled] {
  cursor: not-allowed;
}

.ink-disc[disabled] .ink-disc-mark {
  background-image: none;
  background-color: color-mix(in srgb, var(--person, var(--person-1)) 32%, var(--panel));
  box-shadow: 0 0 0 var(--rim-w) color-mix(in srgb, var(--rim) 45%, transparent);
}

/*
 * AND YOUR OWN DISC LOOKS LIKE SOMETHING TO PRESS.
 *
 * It is a `<button>` only for you and only in a lobby — everybody else's is a
 * picture, and a picture that reacted to a pointer would be promising something
 * it cannot do. `.avatar` already carries the disc's whole appearance, so this
 * adds the two things a control owes: a cursor, and an answer to being pressed.
 */
.avatar--pick {
  padding: 0;
  border: 0;
  font: inherit;
  cursor: pointer;
  transition: transform var(--press) var(--ease), box-shadow var(--press) var(--ease);
}

.avatar--pick:active {
  transform: translateY(1px) scale(0.97);
}

.ink-said:empty {
  display: none;
}

/*
 * THE CAST, AT THE SIZE IT WAS ALWAYS WRITTEN FOR.
 *
 * `shell/stand-in-picker.js` argues why these cards left the lobby. What this
 * block is for is the half of that argument that only shows up in pixels: on
 * the lobby the four cards shared 210px of a 390px phone with a heading, so
 * each blurb was a 40-word paragraph in a 60px box. Here they have the screen.
 * This is the first time these lines have been legible, which is worth saying
 * because "we hid the bots" is the obvious reading and it is the wrong one.
 *
 * Stacked at 80, above the room pill's 60 and the solicitation's 70, for the
 * reason `.blackbox` gives: a sheet somebody opened deliberately outranks a
 * prompt that raised itself.
 *
 * ## `standin-` AND NOT `picker-`, WHICH IT WAS FOR ONE COMMIT
 *
 * This block shipped as `.picker`, `.picker-card`, `.picker-head`,
 * `.picker-lead` and `.picker-list`. Two of those names were already taken,
 * eleven hundred lines up, by the PALETTE picker in the rail — `index.html`
 * carries `<span class="picker">` on every page the site has, and
 * `shell/palette.js` writes `picker-head` into its menu.
 *
 * Same specificity, later in the file, so this block won. Every page in the
 * product — front door, room, all thirty-seven lobbies — drew that little
 * wrapper round the swatch button as `position: fixed; inset: 0; z-index: 80`
 * with a curtain on it: an invisible full-screen sheet over the whole site that
 * swallowed the first press on anything. Caught driving a lobby, where it ate
 * the press on "Take a seat".
 *
 * The lesson is the one `.standin` two blocks down already followed and this
 * half did not: a component in a shared stylesheet is named after the thing it
 * IS, not after the shape it happens to be. "Picker" is a shape, and something
 * else was already called it.
 */
.standin-sheet {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: calc(var(--s3) + var(--safe-t)) var(--s3) calc(var(--s3) + var(--safe-b));
  /* A curtain rather than a wash. Unlike the black box, what is underneath is
     not part of what is being read — it is a lobby whose one open question is
     the one this sheet is asking. */
  background-color: color-mix(in srgb, var(--table) 88%, transparent);
}

.standin-card {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  width: min(34rem, 100%);
  max-height: 100%;
  min-height: 0;
  padding: var(--s4);
  background-color: var(--panel);
  color: var(--on-table);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-panel);
  box-shadow: var(--glaze-line), var(--lift-sm);
}

.standin-head {
  display: flex;
  align-items: center;
  gap: var(--s3);
}

.standin-close {
  margin-left: auto;
}

.standin-lead {
  margin: 0;
  font-size: var(--t-small);
}

/*
 * The list scrolls inside itself rather than growing the sheet.
 *
 * `overscroll-behavior: contain` is the other half and it is not cosmetic:
 * while a game holds the viewport `lib/fullscreen.js` cancels every `touchmove`
 * that is not inside a genuine scroll container, so a list that merely
 * overflowed would be a cast a finger could not reach. Ten seats' worth of
 * characteristics is a real case — Quest of Shadows offers four and seats ten.
 */
.standin-list {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  margin: 0;
  padding: 0;
  list-style: none;
  overflow: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  min-height: 0;
}

/*
 * ONE CARD: A NAME, AND THE LINE THAT MAKES IT A CHOICE.
 *
 * The name is set in the display face at reading size and the line under it in
 * the body face, because that is the hierarchy of the sentence — you are
 * choosing a character, and the line is why this one. Left-aligned and full
 * width: a paragraph centred in a card is a paragraph nobody finishes.
 */
.standin {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  width: 100%;
  min-height: var(--tap-min);
  padding: var(--s3);
  text-align: left;
  color: var(--on-table);
  background-color: var(--table);
  border: var(--hair-strong);
  border-radius: var(--r-control);
  cursor: pointer;
}

.standin-name {
  font-family: var(--font-display);
  font-size: var(--t-lead);
  font-weight: 600;
  letter-spacing: var(--track-display);
  line-height: 1.15;
  color: var(--on-table-strong);
}

.standin-blurb {
  font-size: var(--t-small);
  line-height: 1.4;
  color: var(--dim);
}

@media (hover: hover) {
  .standin:hover {
    border-color: var(--lit);
  }

  .standin:hover .standin-name {
    color: var(--lit);
  }
}

.standin:active {
  transform: translateY(1px);
}

/* ---------------------------------------------------------------- portal */

/*
 * The front door. Two bands: a hero you can act on, then the shelf.
 *
 * The page it replaced was a pitch with a link to the real thing — a headline,
 * three chips restating facts the games already state, and a deck of cards that
 * did nothing. Everything here is either an action or an object the product
 * genuinely has.
 *
 * THE PORTAL USED TO SCROLL IN ITS OWN REGION. `.portal-scroll` was
 * `overflow-y: auto`, so that the one thing below it — the "you are still in a
 * room" strip — could hold a fixed row at the table's foot without floating
 * over anything. Measured at 1280×720, that region was a 2098px page inside a
 * 604px box: a div scrollbar on the front door, which is the thing the owner
 * has ruled out. The whole page scrolls now, exactly as the room does, and the
 * strip holds its edge by being sticky rather than by being outside the
 * scroller. `.portal-scroll` keeps its name and its flex — it is still the
 * band the strip is docked below — and simply no longer contains a scroll.
 */
.portal {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

.portal-scroll {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

/*
 * "YOU ARE OUT OF THAT ROOM" — THE ONE PIECE OF NEWS THE FRONT DOOR CARRIES.
 *
 * A seat can end without the person deciding it, and when it does they arrive
 * here. Without a line to read, the whole event is a page that changed, which
 * from the outside is the same thing as the site falling over.
 *
 * `flex: none` matters: `.portal-scroll` is a flex column whose bands take the
 * height, and a notice allowed to be flexible would either be crushed to
 * nothing or steal the fold from the hero it sits above.
 *
 * `--stamp` on the rule and nowhere else. Something has been done TO the person
 * reading this — the one meaning that ink carries — and it is one hairline
 * rather than a red panel, because a notice that shouts about a chair reads as
 * an error the reader caused.
 */
.departed {
  flex: none;
  padding: var(--s4) var(--s5);
  border-left: 3px solid var(--stamp);
  border-bottom: var(--hair);
  background: var(--panel);
}

.departed .data {
  margin-bottom: var(--s2);
  color: var(--stamp);
}

.departed .measure {
  color: var(--on-table-strong);
}

/*
 * The pitch band: one screen's worth of saying what this is, and no more.
 *
 * It used to be the whole first screen — measured on a 1440×900 laptop, 810px
 * of hero in an 805px scroll window, which put every box on the shelf below a
 * fold with no scrollbar to hint at it. A visitor saw four lines of slogan and
 * a card they did not know to press. The band is now capped so the first row of
 * lids is inside the first screenful on a laptop, which is the entire argument
 * the page has.
 *
 * The code field sits opposite the headline rather than under it: somebody
 * arriving from a Slack thread already has a code and is looking for somewhere
 * to put it, and everybody else is looking at the shelf below.
 */
.portal-hero {
  display: grid;
  gap: var(--s4) var(--s6);
  grid-template-columns: minmax(0, 1fr);
  align-items: start;
  /*
   * NO BOTTOM PADDING, because the promise is the next band and brings its own.
   *
   * With the paragraph inside the hero this padding was the space under it.
   * With the paragraph lifted out, the two stacked: the hero's 16 and the
   * lead's 16, and the desk's first card went from 499 to 533 — a regression on
   * a row where this shelf is already behind, which is the wrong direction to
   * be moving by accident.
   */
  padding: var(--s5) var(--s5) 0;
  /* The hairline moved to , which is what is last before the
     shelf on a desk; the phone block at the foot of this file gives it back
     here, because there the promise has gone below the shelf. */
}

/*
 * Two lines, not three.
 *
 * The default `--measure` is set for body copy in a column; here the lead runs
 * under a headline that is already 17ch wide, and at the reading measure it
 * broke to three lines and cost the shelf's first row of buttons the fold. Every
 * line in this band is paid for out of the boxes below it.
 */
.portal-pitch .lead {
  max-width: 44rem;
}

/* 720 rather than 900, because a phone held sideways is 750 wide and 342 tall:
   the one screen that can least afford the code field on a row of its own is
   the one that was getting it. */
/*
 * 60rem, not 720px — 2026-09-07. Beside the code field the pitch column is
 * about 340px at 768, and the headline took FIVE lines there ("58 SHORT /
 * GAMES FOR / THE FRIDAY / COFFEE / BREAK."), which is taller than the pitch
 * on its own row followed by the code field. From 960 the column is 530px
 * and the headline is two or three lines, which is what the side-by-side
 * layout was for.
 */
@media (min-width: 60rem) {
  .portal-hero {
    grid-template-columns: minmax(0, 1fr) auto;
  }
}

.portal-pitch {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  align-items: start;
}

/*
 * The other door, drawn as one.
 *
 * Two audiences arrive here: somebody choosing a game, who wants the shelf,
 * and somebody who was sent six characters in a chat thread, who wants
 * somewhere to put them. The shelf is the page; this is the second door, so it
 * is a small printed field ranged opposite the headline rather than a control
 * competing with seven Deal buttons for the same attention.
 */
.portal-join {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
}

/* Right-aligned only once it sits beside the pitch (same 60rem as `.portal-hero`). */
@media (min-width: 60rem) {
  .portal-join {
    justify-content: flex-end;
    padding-top: var(--s2);
  }
}

.portal-code {
  width: 8.5rem;
  padding: var(--s3) var(--s4);
  background: var(--panel);
  border: var(--hair-strong);
  border-radius: var(--r-control);
  color: var(--on-table-strong);
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 1rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.portal-code::placeholder {
  color: var(--dimmer);
  letter-spacing: 0.14em;
}

.portal-shelf {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  padding: var(--s5) var(--s5) var(--s7);
}

.portal-shelf-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s4);
  flex-wrap: wrap;
}

/*
 * A ROW THAT WAS FOUR PIXELS FROM FITTING, AND SPENT THIRTY-FIVE ON IT.
 *
 * "On the shelf" and "How a round goes" are 310px of content at 390px wide, and
 * `--s4` of gap makes 326 — six over the 322 available, so the row wraps and
 * the head costs 61px instead of 26. Measured, and the same shape as the
 * finder above it: a row laid out for a desk and merely made narrower.
 *
 * `--s2` here rather than a smaller type size, because the two ends are an
 * eyebrow and a link and both should stay the size they are elsewhere. At 360
 * the row still wraps and that is correct — there genuinely is not room.
 */
@media (max-width: 40rem) {
  .portal-shelf-head {
    gap: var(--s2);
  }
}

/* Both ends of the head are a mark beside words, so both are rows rather than
   paragraphs — otherwise the glyph sits on the text baseline and hangs. */
.portal-shelf-eyebrow,
.quiet-link {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
}

/*
 * A link that is a link.
 *
 * It was `.muted` with an arrow typed into the string, which made the one
 * explanatory link on the front door the same weight as a caption. The rule
 * underneath is what says it is pressable; the chevron says where it goes; and
 * both are drawn rather than punctuation, so the gap between the words and the
 * mark is a gap this stylesheet controls.
 */
.quiet-link {
  color: var(--dim);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  padding-bottom: 2px;
  font-size: var(--t-small);
  transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease);
}

/*
 * Split rather than written as one list, and it is the only one in the product
 * that had to be: the hover half is now behind `(hover: hover)` and the focus
 * half must not be. A phone with a keyboard paired to it resolves `hover:
 * none` and still tabs, so a combined list inside the guard would take the
 * focus ring off exactly the person who needs it most.
 */
@media (hover: hover) {
  .quiet-link:hover {
    color: var(--on-table-strong);
    border-bottom-color: var(--on-table-strong);
  }
}

.quiet-link:focus-visible {
  color: var(--on-table-strong);
  border-bottom-color: var(--on-table-strong);
}

/*
 * The mark beside a word, wherever one appears.
 *
 * Stroked in `currentColor` and nothing else, which is the whole of the rule:
 * a glyph is the same ink as the words it stands beside, in twelve palettes
 * and two lights, and there is no icon colour anywhere to keep in step. It
 * never shrinks below its declared size — a flex row is allowed to squeeze
 * text and is not allowed to squeeze this.
 */
.glyph {
  flex: none;
  display: block;
}

/* The front door's only ornament: see `--flourish` in tokens.css. */
.flourish {
  color: var(--flourish);
}

/*
 * The strip the shelf ends on: the objection answered, and the other door.
 *
 * A full-width object rather than a quiet button and a caption, because it is
 * the last thing on the page and the last thing on a page is either a
 * conclusion or a stray control. It is `--panel` on `--table` inside a rim, the
 * same construction as a box, so the page ends on the material it is made of.
 */
.portal-foot {
  /*
   * No `--dim-lift` here, unlike `.game-card`, and the difference is the whole
   * point of that token: the strip is a shade INTO the ground now rather than
   * raised off it, so the quiet line in it is ink on a ground DARKER than
   * `--table` in the evening — which is the direction that gains contrast, not
   * the one that loses it. The mark stays in the contrast suite either way.
   */
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--s4) var(--s5);
  margin-top: var(--s3);
  padding: var(--s4) var(--s5);
  /* A shade INTO the ground rather than off it — measured #f5eada against a
     #fdf6eb table, so the strip is a recess and not another box on the shelf.
     `--table-deep` is that relation in every palette, and in the evening it is
     the same relation the other way up. */
  background: var(--table-deep);
  border: 1px solid var(--rule);
  border-radius: var(--r-field);
}

.portal-foot-say {
  display: flex;
  align-items: center;
  gap: var(--s4);
  min-width: 0;
}

/* The cup, in a drawn ring rather than a pressed well: the strip is already a
   recess, and a filled disc inside it was an object inside an object. */
.portal-foot-mark {
  display: grid;
  place-items: center;
  flex: none;
  width: 2.75rem;
  height: 2.75rem;
  border-radius: var(--r-pill);
  border: 1px solid var(--rule-strong);
  color: var(--on-table);
}

.portal-foot-lines {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  min-width: 0;
}

.portal-foot-line {
  font-weight: 600;
  color: var(--on-table-strong);
  text-wrap: balance;
}

.portal-foot-acts {
  gap: var(--s3);
  flex-wrap: wrap;
}

.hero-title {
  font-size: var(--t-hero);
  line-height: 0.95;
  letter-spacing: -0.04em;
  text-transform: uppercase;
  max-width: 14ch;
  text-wrap: balance;
}

/*
 * The front door's headline is a sentence and has to break like one.
 *
 * It used to be four hand-set lines with `<br>`s in the markup, which is fine
 * at 1440 and wrong at 390: the first line was the single word "SIX" with the
 * rest ragged under it. Balanced and measured in characters, it sets itself at
 * every width, and it is a size smaller than the old slogan because the shelf
 * underneath is what the page is arguing with.
 */
.portal-pitch .hero-title {
  font-size: clamp(2.25rem, 4.2vw, 3.25rem);
  max-width: 17ch;
}

/* A sentence rather than a slogan: 14ch broke it into three ragged lines. */
.hero-title--wide {
  max-width: 22ch;
}


.lead {
  font-size: var(--t-lead);
  color: var(--on-table);
}

.narrow {
  max-width: 30rem;
  padding: var(--s6) var(--s5);
}

/*
 * A phone's first screen is about 770px of content under the rail, and the
 * pitch band was spending 490 of it — which put the first box's picture at the
 * fold and its name below it, so a phone visitor got the same "a sentence and
 * nothing else" first impression the laptop used to get. Every value here is
 * paid for out of the first lid.
 *
 * The height clause is the same phone turned sideways, which is not a narrow
 * screen at all: an iPhone 13 in landscape reports 750×342, so every width
 * query missed it and the band — sized for a desk — filled the whole 342px of
 * glass with a headline and a lead. No box, no shelf, nothing to press. A pitch
 * band is measured against the height it is eating, so that is what asks.
 *
 * BOTH BLOCKS SIT AFTER `.portal-pitch .hero-title` ON PURPOSE. A media query
 * adds no specificity, and the first cut of this was written above that rule:
 * it matched, it lost the cascade, and the landscape phone went on printing a
 * 36px headline. Found in a screenshot, not in a stylesheet.
 */
@media (max-width: 599px), (max-height: 560px) {
  .portal-hero {
    /* Block padding in one more step than inline: the inline is the page's
       margin and is doing a job; the block was a desk's breathing room. */
    padding: var(--s3) var(--s4);
    gap: var(--s3);
  }

  /*
   * THE COUNT AND THE SURPRISE SHARE THE SEARCH'S LINE.
   *
   * `min-width: 12rem` on the search is a desk's floor: at 390px it leaves
   * 358 - 192 - 24 = 142 for a count and a button that need about 150, so they
   * wrapped to a row of their own and the finder was three rows deep before a
   * single game was shown. Dropping the floor lets all three sit together and
   * the search still takes every pixel the other two do not — about 184 of
   * them, which is a search box.
   *
   * Nothing is removed and nothing moves on a desk: this is the phone's
   * arithmetic only.
   */

  .portal-pitch {
    gap: var(--s2);
  }

  /*
   * TWO LINES RATHER THAN THREE — 2026-08-26, measured against their door.
   *
   * The headline is a sentence: "40 short games for the Friday coffee break".
   * At 31px in a 322px column it is 42 uppercase characters over **three
   * lines, 88px**, which is a wall rather than a headline — and it is the top
   * of the one screen where every pixel above the shelf is rent.
   *
   * 26px puts it on two: **49px**, and the first card moves from y=678 to 639,
   * which is the difference between one whole card above the fold and two. It
   * is a step down of five pixels of type in exchange for a whole game, and the
   * two-line setting reads BETTER — `text-wrap: balance` has something to
   * balance and the eye gets a shape instead of a paragraph in display caps.
   *
   * Their hero is 65px, an h1 reading "Pick a game" over one line, and four
   * cards clear their fold. Ours argues, and the argument — nobody installs
   * anything, nobody makes an account — is not what is being trimmed here.
   * Not one word moves. The type does.
   */
  .portal-pitch .hero-title {
    font-size: 1.625rem;
    max-width: none;
  }

  .portal-pitch .lead {
    font-size: var(--t-body);
    line-height: 1.4;
  }

  .portal-shelf {
    padding: var(--s4) var(--s4) var(--s6);
  }

  .catalogue {
    gap: var(--s5);
  }
}

/*
 * Sideways on a phone: 342px of glass top to bottom, and the rail takes 64 of
 * them. Everything above the first box is rent, so the headline steps down
 * again and the lead becomes the size the rest of the small print is.
 */
@media (max-height: 430px) {
  .portal-hero {
    padding: var(--s3) var(--s4);
  }

  .portal-pitch .hero-title {
    font-size: 1.5rem;
  }

  .portal-pitch .lead {
    font-size: var(--t-small);
  }

  .portal-shelf {
    padding-top: var(--s3);
  }
}

/* ------------------------------------------------------------------ join */

/*
 * Two fields, side by side, filling the stage: what you have to do on the left,
 * what you are walking into on the right.
 *
 * This screen used to be a 30rem column of form in the top-left corner of a
 * 1440×900 frame — two-thirds empty, and silent about the room. The form did
 * not need to grow; the screen needed to say something, and there was something
 * true to say.
 */
.join {
  display: grid;
  gap: var(--s5);
  grid-template-columns: minmax(0, 1fr);
  align-content: start;
  padding: var(--s6) var(--s5);
  min-height: 100%;
}

@media (min-width: 860px) {
  .join {
    grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
    align-content: stretch;
  }
}

.join-form-side {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--s6);
  max-width: 27rem;
}

.join-form {
  align-items: stretch;
}

.join-form .btn {
  justify-content: center;
}

/* The code as a read-out. It arrived correct; it is not a question. */
.join-code-known {
  display: flex;
  flex-direction: column;
  align-items: start;
  gap: var(--s3);
}

/*
 * "You are still in WZE2Z4" on the front door.
 *
 * A rimmed object rather than a tinted strip, because it is a thing on the
 * table — the room you left lying there — and every object in this interface
 * carries a rim.
 *
 * Docked, not stacked. It used to be a card in the top-left corner, which put
 * an interruption in competition with the hero and shoved the whole page down
 * (and, on "Leave it", back up). It then became a fixed row below the portal's
 * own scroll region — on screen at any scroll depth, covering nothing.
 *
 * IT IS STICKY NOW, and the earlier note against that is worth answering
 * rather than deleting, because it was right about a different thing. What was
 * tried and rejected was `position: fixed`: an overlay with no place of its own
 * in the page, which on a 720px laptop sat across the hero's buttons *and could
 * not be scrolled clear of them*, because there was nowhere further to scroll.
 * A sticky element is still in flow — this is the last child of `.portal`, and
 * the page is as tall as its own bottom margin — so at the foot of the page it
 * is simply a row at the foot of the page, and anything it floats over on the
 * way down can be brought out from under it by scrolling on. That is the
 * difference, and it is the same one the room's own prompt relies on.
 *
 * It went sticky because the region it used to sit below is gone: the front
 * door scrolls as a document now (see `.portal-scroll` above), so a row at the
 * end of the page would be a notice 1400px below the fold on the exact screen
 * whose whole job is to be seen by somebody who has lost their room.
 *
 * Dismissing it still moves nothing the eye is on: it is removed from the end
 * of the page, and the page above it does not shift.
 */
/*
 * The one object on the front door that is not made of the room.
 *
 * It sat on `--panel` like everything else and read as furniture — which is
 * wrong for what it is: a notice that you have a game open somewhere else, on
 * a page whose whole job is starting a new one. So it takes the palette's
 * accent as its ground and the dark `--edge` as its ink, the same pairing the
 * seat swatches use for their pips, which holds across all twelve palettes
 * and both themes without a bespoke colour anywhere.
 *
 * Colour is not the only signal: it keeps the rim, the lift and the words.
 */
.still-seated {
  flex: none;
  position: sticky;
  bottom: var(--s3);
  z-index: 30;
  margin: var(--s3) var(--s5) var(--s5);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s3) var(--s4);
  padding: var(--s3) var(--s4);
  background: var(--accent);
  color: var(--edge);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-panel);
  box-shadow: var(--glaze-line), var(--lift-sm);
}

/* The sentence rides the accent, so it cannot keep the muted ink it had. */
.still-seated .data {
  color: var(--edge);
  opacity: 0.75;
}

/*
 * THE CODE IS INK ON THE ACCENT, NOT A SLAB LAID ON IT — 2026-09-08.
 *
 * It used to keep the invitation's pale-stock slab here — the reasoning being
 * that the code should read as the same printed object everywhere. But the
 * invitation slab sits on a dark table, where a stock rectangle is paper; this
 * bar is a field of accent, and a square-cut cream box on it read as a stray
 * highlight — the one boxed thing on a bar where the label, Rejoin and Leave it
 * all sit straight on the colour. So the code sheds the slab and stays the code
 * by TYPE instead: the bar's own dark ink (--edge, at full strength where the
 * sentence beside it is dimmed), in the same serif at a larger size. It is the
 * loudest reading on the bar without being a patch stuck to it.
 */
.still-seated .code--read {
  background: none;
  color: var(--edge);
}

/*
 * And the primary action inverts. On an accent ground an accent button would
 * vanish into it, so Rejoin becomes the dark object on the bright field — the
 * strongest thing in the bar, which is what it should be.
 */
.still-seated .btn--primary {
  background: var(--edge);
  color: var(--stock);
}

@media (hover: hover) {
  .still-seated .btn--primary:hover {
    background: var(--edge);
    color: var(--stock);
    filter: brightness(1.35);
  }
}

/* "Leave it" stays quiet, but quiet against accent, not against panel. */
.still-seated .btn--quiet {
  color: var(--edge);
  opacity: 0.8;
}

@media (hover: hover) {
  .still-seated .btn--quiet:hover {
    opacity: 1;
    background: color-mix(in srgb, var(--edge) 12%, transparent);
  }
}

/* The sentence and the code it is about are one unit: they never wrap apart. */
.still-seated-room {
  display: flex;
  align-items: center;
  gap: var(--s3);
}

.still-seated .code--read {
  font-size: 1.375rem;
  /* No slab any more, so no chip padding — the gap on `.still-seated-room`
     already sets it off from the sentence. A hair of inline lead keeps the
     serifs off the words without redrawing the box. */
  padding: 0;
  padding-inline-start: var(--s1);
}

.still-seated-actions {
  margin-left: auto;
}

/*
 * On a phone the notice goes FIRST, because "docked at the foot" has no
 * meaning there.
 *
 * The desktop model is a scroll region with this row under it, so it is on
 * screen at every scroll depth. Below 600px the document scrolls instead and
 * the region has no bottom — measured on a 390px phone, the strip sat at
 * y=3,760 of a 3,890px page. Somebody who has lost their room and gone back to
 * the front door to open another one is exactly the person it exists for, and
 * they would have had to scroll past all seven boxes to find it.
 *
 * `order`, rather than a second element or a sticky overlay: one strip, in the
 * DOM once, in the place each layout can actually show it. Above the hero it
 * covers nothing, which is the rule the desktop version was built around too.
 *
 * AND A ROTATED PHONE IS A PHONE — 2026-08-12.
 *
 * This rule asked how WIDE the screen was, and everything it is about is how
 * TALL it is. A rotated iPhone reports 750×342, so it took the desktop
 * arrangement: the strip stayed docked, ~64px of a 342px window, at every
 * scroll depth. What it landed on was the shelf's own head — "How a round goes
 * →", the one link on the front door that explains the product to somebody who
 * has never played, hit-tested and found behind an accent bar.
 *
 * This is the same fault the room layout had and the same fix. `landscape.spec`
 * is named after it and now holds this case too.
 */
@media (max-width: 599px), (max-height: 500px) and (max-width: 959px) {
  .still-seated {
    order: -1;
    margin: var(--s3) var(--s3) 0;
  }

  .still-seated-actions {
    margin-left: 0;
  }
}

.code--read {
  font-size: clamp(1.75rem, 4vw, 2.5rem);
  padding: var(--s3) var(--s4);
}

/* ---- the room you are about to enter ---- */

.arriving {
  display: flex;
  min-height: 18rem;
}

.arriving-in {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding: var(--s6) var(--s5);
  background-color: var(--panel);
  background-image: var(--grain);
  background-size: var(--grain-size);
  background-position: var(--grain-pos);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-field);
  box-shadow: var(--glaze-line);
}

/*
 * Nothing to describe: no code typed yet, or a code that names no room.
 *
 * This used to be the same panel at 35% opacity with nothing inside it — a
 * large rimmed box that had visibly given up, on the theory that the form
 * would say so on submit. It says so here too, in the same words the form
 * would use, and it offers the way out somebody in either state actually
 * wants. An empty screen is an invitation to act.
 */
.arriving-in--empty {
  gap: var(--s4);
}

/* Directly under the sentence it answers. Pinned to the foot of the field it
   was four hundred pixels from the thing it was the answer to. */
.arriving-in--empty .btn {
  align-self: start;
  margin-top: var(--s2);
}

.arriving-game {
  font-size: var(--t-display);
  text-transform: uppercase;
}

/* The box you are about to sit down to, at the width of the field it is in. */
.arriving .lid {
  max-width: 26rem;
  margin-bottom: var(--s2);
}

.arriving-facts {
  margin-bottom: var(--s4);
}

.arriving-head {
  padding-top: var(--s4);
  border-top: var(--hair);
}

.arriving-seats {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
}

/* Pushed to the foot of the field, so the panel is anchored top and bottom
   rather than trailing off into three hundred pixels of its own material. */
.arriving-foot {
  margin-top: auto;
  padding-top: var(--s5);
}

.arriving-seats .seat {
  padding: var(--s2) 0;
}

/* Your chair. Dashed like any empty seat, but named, so the list ends on you
   rather than on a gap. */
.seat--yours .data {
  color: var(--on-table-strong);
}

/*
 * A phone held sideways, which this screen had never been looked at on.
 *
 * 750×342, and the form was stacked above the room panel in one column at desk
 * spacing: the heading, the code slab and the name field alone came to 386px,
 * so focusing the name field scrolled the heading off the top and "Take a seat"
 * — the only button on the screen — sat below the fold of a page that did not
 * look as though it had one. Side by side, top-aligned, at the spacing the
 * height allows, the form is 320px and fits.
 *
 * Last in the section deliberately: every declaration here overrides a base
 * rule of equal specificity — `.join-form-side`'s centring, `.stack-lg`'s
 * 32px, `.arriving`'s 18rem floor — and a media query buys no specificity at
 * all. Written above them, it matched and lost, silently.
 */
@media (max-height: 430px) and (min-width: 560px) {
  .join {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-content: start;
    align-items: start;
    padding: var(--s3) var(--s4);
    gap: var(--s4);
  }

  /* Top of the column, not the middle of it. Centred inside a row as tall as
     the room panel, the form's own button left the screen it otherwise fits. */
  .join-form-side {
    gap: var(--s3);
    justify-content: flex-start;
  }

  /* The greeting keeps its words and stops being a headline. At 342px the four
     things below it are what somebody is here for, and two lines of display
     type is the difference between the button being on the glass and not. */
  .join-form-side .hero-title {
    font-size: 1.0625rem;
    letter-spacing: 0;
    line-height: 1.2;
  }

  .join-form {
    gap: var(--s2);
  }

  .join-code-known {
    gap: var(--s2);
  }

  .join .code--read {
    font-size: 1.25rem;
    padding: var(--s1) var(--s3);
  }

  .join .field {
    gap: var(--s2);
  }

  .arriving {
    min-height: 0;
  }

  /* The box is worth showing and is not worth half the glass. */
  .arriving .lid {
    max-width: 13rem;
  }
}

/* ------------------------------------------------------------------ lids */

/*
 * THE LID: the printed plate on the front of a box, and the only picture of a
 * game anywhere in this interface.
 *
 * One shape, 3:2, at three sizes — the shelf on `/`, the entries on `/games`,
 * and the thumbnail in the room's own picker and on the join screen. A game is
 * recognised by its box before its name is read, which is how anybody has ever
 * picked a game off a shelf, and it is what the front door had no way of doing
 * while its one picture was a card lying face down.
 *
 * **Placed, not masked.** The previous box art was an alpha-only halftone the
 * shell filled with `--ink`, so it printed in whichever of twelve palettes the
 * room was wearing. The artwork that replaced it is a real two-colour
 * screenprint — petrol and cream with one amber plate — and it keeps its own
 * inks deliberately. A boxed game does not repaint itself when somebody changes
 * the tablecloth: the lid is a printed label applied to an object, so it sits
 * on the palette's own `--stock`, inside the palette's own rim, with the glaze
 * line across it, and the room changes around it exactly as a room does.
 *
 * `content-visibility` is deliberately not used here: the lids are the page.
 */
.lid {
  position: relative;
  container-type: inline-size;
  width: 100%;
  aspect-ratio: 3 / 2;
  overflow: hidden;
  background-color: var(--stock);
  background-image: var(--grain);
  background-size: var(--grain-size);
  background-position: var(--grain-pos);
  border: var(--rim-w) solid var(--rim);
  border-radius: var(--r-panel);
  box-shadow: var(--glaze-line), var(--lift-xs);
  flex: none;
}

.lid-art {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Edge to edge. Every picture in the set carries its own printed paper
     margin, so a second inset here would be a mount inside a mount. */
}

/*
 * A lid with no picture yet.
 *
 * Not a hole and not a shorter card: the plate is the same size and shape and
 * prints the game's monogram in outline, the way an unlabelled box in a
 * cupboard still has a shape. Nothing in `design/` or `shell/` knows which
 * games have art — the mark is what is under the picture, and it is simply
 * still visible when there is no picture on top of it.
 */
.lid-mark {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 34cqi;
  letter-spacing: -0.05em;
  color: transparent;
  -webkit-text-stroke: 2px color-mix(in srgb, var(--ink) 34%, transparent);
  user-select: none;
}

.lid[data-art="printed"] .lid-mark {
  display: none;
}

.lid--wide {
  aspect-ratio: 3 / 2;
}

/* In the room's picker, where the lid is a thumbnail beside a decision rather
   than the argument for making one. */
.lid--small {
  border-radius: var(--r-chip);
  box-shadow: var(--glaze-line);
}

/* ------------------------------------------------------- the shelf on `/` */

/*
 * Boxes on a table, not cards in a grid.
 *
 * Each box is one object — see `.game-card` below for what that costs and what
 * it buys, and for why the words are inside the rim again rather than on the
 * table under the lid.
 *
 * The gap is even in both directions now. It was 32px between rows and 24px
 * between columns, which is the right asymmetry when the words are loose on the
 * table and each box needs air under it to be told from the one below; once a
 * box has an edge of its own, an uneven gap just makes the grid read as rows of
 * four rather than as a shelf.
 */
.catalogue {
  display: grid;
  gap: var(--s5);
  grid-template-columns: minmax(0, 1fr);
  /* Stretched, so the Deal buttons across a row land on one line: the blurbs
     are three clamped lines and the featured box's pitch is not, which was
     enough to leave four buttons at four different heights. */
  align-items: stretch;
}

/*
 * Column counts chosen against the fold, not against a breakpoint chart. At
 * 1440×900 three columns put a 420px lid on every box and pushed all seven
 * Deal buttons below the fold — a shelf you can see and cannot press. Four
 * brings the lid to ~300px and the first row's buttons back onto the first
 * screen, which is the only thing this page is trying to achieve.
 *
 * THREE WAS TRIED AGAIN WHEN THE FACTS MOVED UP BESIDE THE NAME, AND MEASURED
 * BACK OUT. A name and its two facts need 336px of content on one line for the
 * longest of them ("Twelve Crowns", 2–10 players, 25 min); four columns give
 * 277 and three give 389, so three does fit them. What it costs is the page:
 * the same eight boxes went from 1,465px tall to 2,047, the lids from 300px to
 * 410, and three boxes rather than seven were on the first screen. Density is
 * the argument this page makes, so the facts gave up the line instead — see
 * `.game-card-facts`, where they stack in the corner rather than shrinking to
 * the ~8px that would have fitted them beside the name at four across.
 */
@media (min-width: 640px) {
  .catalogue {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/*
 * AND BELOW THAT, WHERE THE CATALOGUE IS ONE COLUMN, THE BOX LIES DOWN.
 *
 * One column of tall boxes is a long walk. MEASURED on a Pixel 7 with 839px of
 * viewport: a card is 473-498px, so TWO games are on screen at once and the
 * thirty-eight take about nineteen screens of scrolling. Finding a game is the
 * job this page exists for, and the catalogue people compare us with fits four
 * on a screen — we were four times slower at it on the device it is most often
 * opened on.
 *
 * So the lid becomes a square thumbnail on the left — `object-fit: cover` was
 * already doing the work, this only changes the frame it crops to — and the
 * name, the facts, the blurb and the controls stack to its right.
 *
 * NOTHING IS REMOVED, and that is deliberate. The picture is still the control
 * that turns the box over, `Deal <game>` is still the loudest thing on the
 * card, and the quiet link is still under it. `screens.js` records a reason
 * beside each of those three, and not one of the reasons is about height.
 *
 * Today's box keeps its full size: it is the one card meant to be looked at
 * rather than scanned past, and the only one carrying the game's own sentence.
 */
@media (max-width: 639px) {
  .catalogue .game-card.game-card {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: stretch;
  }

  /*
   * THE CONTROLS SIT UNDER THE WORDS, AND A GRID WAS TRIED TWICE.
   *
   * Putting `.game-card-actions` in a second column of the body makes the card
   * as tall as the taller of words and controls rather than their sum — the
   * best numbers this shelf has measured, 98px with the explainer hidden and
   * 150 with it back. Both were photographed and both read worse than the row
   * they replace:
   *
   *   - 208px of body split three ways leaves the words about 102, and every
   *     title breaks over two lines
   *   - widening the words until the titles hold pushes the deal over the facts
   *     — "20 MI" with the rest of it behind a button
   *   - bounding the control column instead sets "See it played" over two lines
   *     beside a fact line that wraps, and the shelf reads ragged
   *
   * The constraint under all three: 322px of phone holds either a row carrying
   * box art, a name, two facts, a sentence, a deal AND an explainer — or a row
   * as short as the shelf we are compared with. Not both. What is here is the
   * first, made as short as it goes without taking anything out.
   */
  .catalogue .game-card.game-card .game-card-body {
    gap: var(--s2);
    padding: var(--s3);
  }

  /* The other half of the eighteen. "Deal" is four letters; 16px of padding
     each side is a label's proportion, not a word's. */
  .catalogue .game-card.game-card .game-card-deal {
    padding-inline: var(--s3);
  }

  /*
   * THE EXPLAINER STAYS, AND HIDING IT WAS WRONG — 2026-08-26.
   *
   * It was `display: none` here on the argument that the picture is already a
   * control and this is the second route to the same sheet. That is true, and
   * `sheet-has-a-way-out.spec.ts` is about the two routes rather than the one
   * destination: it drives `open-box` AND `how-to-play` for every game at
   * 390x844 and 360x640, and a hidden button is still in the DOM, so it found
   * the trigger, waited thirty seconds for something unclickable, and failed
   * three cases.
   *
   * The repo's own reason is older than my argument: `howto.js` made the box a
   * control BECAUSE the quiet sentence was easy to miss — "a visitor who wanted
   * to find out had one quiet sentence to notice, and a visitor who did not
   * notice it had a shelf of thirty pictures that did nothing when pressed".
   * Deleting the sentence on a phone re-creates that fault from the other side.
   *
   * It costs the strip its 98px — see what the card measures now beside the
   * numbers in `docs/design-vs-tableparty.md`. A row that matches a competitor's
   * height by removing one of two ways into a game is not a row worth having.
   */

  /*
   * AND THE PICTURE GIVES UP MORE ON A NARROW PHONE.
   *
   * `shelf-names.spec.ts` caught this at 360px: "Minesweeper" chopped in a
   * 123px column. It is one word, so it cannot wrap out of trouble the way
   * "Bowl of Nouns" can — it has to fit, and at this type size it needs about
   * 118. Half a rem off the picture is the cheapest 8px on the card, and it
   * keeps the name at the size the shelf sets everywhere else.
   */
  /*
   * AND THE SHELF CLOSES UP BETWEEN THE ROWS.
   *
   * `--s5` is 24px, and the comment on `.catalogue` explains why it is even in
   * both directions: "once a box has an edge of its own, an uneven gap just
   * makes the grid read as rows of four rather than as a shelf". That is a
   * reading of a GRID of boxes, and on a phone this is not one — it is a list
   * of strips, each with its own rim.
   *
   * MEASURED, and it is the largest single number left: a card is 126px and we
   * were spending **160 per game**, so 34 of every 160 was air. The shelf we
   * are compared with spends 8 between rows. Thirty-eight gaps at 24px is 912px
   * of a 6,089px walk — fifteen per cent of the scrolling, and none of it
   * anything to read.
   */
  .catalogue {
    gap: var(--s2);
  }


  /*
   * AN IMAGE2IMAGE PASS OVER THE SHIPPED SHELF, AND WHAT IT DID NOT BUY —
   * 2026-08-26. Reference kept at `docs/screens/shelf-row-reference.png`.
   *
   * It drew each row as a printed ticket: box art bled to the edge, the rim
   * doubled by a hairline set in from it, corners cut. Two of the three were
   * already true — the art measures 55x156 in a 158px card, which is as
   * full-bleed as it gets — and cut corners are a shape `docs/design.md` has
   * ruled on.
   *
   * The doubled rim was tried and taken out again. `inset 0 0 0 1px` on the
   * card draws its ring over EVERY edge, including the one the art is bleeding
   * to, so the row gained a printed edge by putting a cream line across its own
   * picture. The reference runs that rule around the words only, which on this
   * markup means putting it on the body — a frame around a text block, which is
   * a different device and not obviously the better one.
   *
   * Written down rather than left as an absence: the next person to look at
   * this reference will see the same idea and should know it was tried.
   */

  /*
   * AND THE FACTS WRAP RATHER THAN CLIP.
   *
   * `white-space: nowrap` is right where the facts have a column of their own;
   * beside a deal button they had about 143px for a line that wants 150, and
   * "4–12 PLAYERS · 20 MIN" came out as "4–12 PLAYERS · 20 MI" with the rest
   * behind the button. A fact cut in half is worse than a fact on two lines,
   * and the row only grows for the games whose facts are actually too long.
   */
  .catalogue .game-card.game-card .game-card-head .game-card-facts {
    white-space: normal;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: baseline;
    /*
     * MEASURED, because the first two attempts at this missed by a hair and I
     * kept guessing at the cause. The two facts are 106px and 61px, they had a
     * 12px gap (this selector was losing to a later one, hence the extra
     * `.game-card-head` above), and the column is 153. 179 into 153 wraps, and
     * a wrapped fact line costs 28px on EVERY card in the shelf.
     *
     * Four here and eleven off the deal's side padding below is the eighteen
     * that closes it.
     */
    gap: var(--s1);
    /*
     * TRACKING, NOT SIZE. The fact line wants 171px and has 165, and the two
     * ways to close six pixels are not equal: setting these below 11px makes
     * the one thing on the row people SCAN harder to scan, while 0.08em of
     * letter-spacing down to 0.04 costs nothing anybody reads. Mono caps carry
     * their own separation; the tracking here was for a line standing alone in
     * a column, not one sharing a row with a button.
     *
     * It is worth six pixels because a wrapped fact line is 20px on the card,
     * and the difference between a shelf whose rows are all 98 and one whose
     * rows are 98 or 118 depending on how wide a player range happens to be.
     */
    letter-spacing: 0.04em;
  }

  /*
   * THE CONTROLS BESIDE THE WORDS WAS TRIED AND PHOTOGRAPHED OUT — 2026-08-25.
   *
   * Putting `.game-card-actions` in a second column of the body took the card
   * from 166px to 126 and the shelf from 198px per game to 159, which is the
   * best number this shelf has measured. It is not shipped, and the screenshot
   * is why: 208px of body split three ways leaves the words about 102, every
   * title broke over two lines, and rebalancing to fix that pushed the deal
   * button over the facts — "20 MI" with the rest behind a button.
   *
   * Widening the words until the titles held put the card back to 150 with the
   * explainer set over two lines beside a clipped fact line. Both versions are
   * faster to scroll and worse to read, and this page's job is finding a game.
   *
   * The honest constraint: 322px of phone holds either a row that carries box
   * art, a name, two facts, a sentence, a deal and an explainer — or a row as
   * short as the shelf we are compared with. Not both. What follows is the
   * first, made as short as it goes without taking anything out.
   */

  /*
   * A full-height strip rather than a square, which is what asking for both
   * actually produced: `height: 100%` wins over `aspect-ratio`, and the strip
   * turned out to be the better object anyway — it fills the card's edge like
   * the spine of a box on a shelf, and shows more of a picture that was drawn
   * to be looked at. `object-fit: cover` was already doing the cropping; this
   * only changes the frame it crops to.
   */
  .catalogue .game-card.game-card .lid {
    height: 100%;
  }

  /*
   * THE NAME GETS THE WHOLE WIDTH, AND THE FACTS GO UNDER IT.
   *
   * `.game-card-head` puts the facts in the corner beside the name, which is
   * right at four columns across a desk and wrong in a 240px column: the two
   * together broke "WORD CHAIN", "FOUR LEAVES" and "OUR WORDS" over two lines
   * apiece. `shelf-names.spec.ts` passes either way — its own note says a name
   * breaking BETWEEN its words is still a name — but a shelf where half the
   * titles are two lines reads as a list of long names rather than of games.
   */
  .catalogue .game-card.game-card .game-card-head {
    flex-direction: column;
    align-items: start;
    gap: var(--s1);
  }

  /* Side by side once they are on their own line: two short facts, one row. */
  .catalogue .game-card.game-card .game-card-facts {
    flex-direction: row;
    align-items: center;
    gap: var(--s3);
    padding-top: 0;
  }

  /* The hairline that separated lid from body turns with the layout. */
  .catalogue .game-card.game-card .game-card-lid {
    border-bottom: 0;
    border-right: 1px solid var(--rule);
  }

  /*
   * Two lines rather than three. The column is narrower now, so three clamped
   * lines is the wall the blurb's own note warns about — and this sentence is
   * an invitation to press the box, not a description of the game, which is on
   * the page the box opens.
   */
  /*
   * ONE LINE ON THE STRIP, not two.
   *
   * MEASURED: two lines are 45px of a 196px card, on a shelf where we carry 227
   * pixels per game against the 106 of the catalogue people compare us with.
   * One line is what a scanning list gives a description — theirs are written
   * to that length ("Shake. Hunt. Don't match.") — and it sits under a name and
   * a player count that are already saying what the game is.
   *
   * The whole blurb is still on the card's other side, which the picture turns
   * over; nothing here is the only place a sentence lives.
   */
  .catalogue .game-card.game-card .game-card-blurb {
    -webkit-line-clamp: 1;
    line-clamp: 1;
  }

  /* And the body's own rhythm comes in one step, three gaps deep. */
  .catalogue .game-card.game-card .game-card-body {
    gap: var(--s2);
  }

  /*
   * THE ROW SPENDS THE PICTURE INSTEAD OF ITS OWN HEIGHT — 2026-08-26.
   *
   * Two notes above this one record the same idea failing twice: put the
   * controls in a second column of the body and the card drops to 126px, then
   * to 98 with the explainer hidden, and both were photographed out because the
   * words that were left could not hold a title on one line. The constraint was
   * written down as settled — *"322px of phone holds either a row carrying box
   * art, a name, two facts, a sentence, a deal AND an explainer, or a row as
   * short as the shelf we are compared with. Not both."*
   *
   * It was settled about the wrong axis. Both attempts spent HEIGHT on the
   * second route into the sheet — 44px of "See it played" stacked under the
   * deal, or 98px of it beside the deal, eating the width the titles needed.
   * There is a third surface on this row and it was already a control: the
   * picture. `boxControl` has opened the same sheet from the same card since
   * the day it was written, and `howto-control.js` says exactly why the
   * sentence existed anyway — *"a visitor who did not notice it had a shelf of
   * thirty pictures that did nothing when pressed"*. That is an argument for
   * MARKING the picture, not for a second control in the flow.
   *
   * So the explainer stays, keeps its name, keeps `data-t="how-to-play"` and
   * stays pressable — `sheet-has-a-way-out.spec.ts` drives it for all forty
   * games at 390x844 and 360x640, which is what caught the version that hid it
   * — and it moves onto the picture's bottom corner as a play mark. It costs
   * the row nothing, because it is over something that was there anyway.
   *
   * MEASURED at 390x844, first card, against the shelf we are compared with:
   *
   *   row     158px -> 88px   (theirs: 98)
   *   pitch   166px -> 96px   (theirs: 106)
   *   sweep 6,642px -> 3,834  for forty games (theirs: 3,831 for thirty-six)
   *
   * and the row still carries box art, a name, two facts, a sentence, a deal
   * and a way to the sheet. Theirs carries art, a name and one line.
   *
   * The words get 185px where the failed attempts left them 102 and 153. The
   * widest title on the shelf is "Quest of Shadows" at 173px and the fact line
   * is 163, so both clear on one line with room to spare, which is the thing
   * neither earlier attempt could say.
   */
  .catalogue .game-card.game-card .game-card-body {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    /*
     * The rows are declared even though `auto auto` is what they would be
     * anyway, and that is not tidiness: `grid-row: 1 / -1` on the stub below
     * counts to the end of the EXPLICIT grid, and with no rows declared the
     * explicit grid has one line, so -1 resolved to line 1 and the stub sat in
     * the title's row at 47px instead of standing the height of the card.
     */
    grid-template-rows: auto auto;
    align-items: center;
    column-gap: var(--s2);
    row-gap: var(--s1);
    padding: var(--s2) var(--s2) var(--s2) var(--s3);
  }

  /*
   * THE ROOM'S PICKER CARD HAS NO DEAL BUTTON IN ITS BODY — 2026-09-11.
   *
   * The front door's card is `[words | Deal]`, and the two-column body above is
   * that split. The room's card IS the button (a press deals it), so its body
   * carries only words and the second column is empty — into which the strip
   * grid was scattering the name and squeezing it to a 94px column, chopping
   * "Forbidden Words" and "Daydream" at 360 (shelf-names). With no deal to place
   * beside them, the words take a plain single column. Same (0,4,0) specificity
   * as the rule above and placed after it, so it wins for this card only.
   */
  .catalogue .game-card.shelf-item .game-card-body {
    display: flex;
    flex-direction: column;
    align-items: start;
    gap: var(--s1);
  }

  .catalogue .game-card.game-card .game-card-head,
  .catalogue .game-card.game-card .game-card-blurb {
    grid-column: 1;
  }

  /*
   * The deal becomes an end stub the height of the row — the picture's opposite
   * number, and a target the size of the thing rather than of its label. It is
   * 51px wide because "Deal" is 35 and `--s2` either side is a word's
   * proportion; the note beside `padding-inline` further up made the same
   * argument about `--s3` when the button was sitting under the words.
   */
  .catalogue .game-card.game-card .game-card-actions {
    grid-column: 2;
    grid-row: 1 / -1;
    align-self: stretch;
    display: flex;
    align-items: stretch;
    gap: 0;
    /*
     * Out through the body's padding to the card's own edge, so the stub bleeds
     * the way the picture bleeds on the other side. Floating inside the padding
     * it read as a button dropped onto a row; against the edge the row reads as
     * one object with a printed face at one end and a torn-off stub at the
     * other, which is what it is. The 8px it gives back goes to the words.
     */
    margin: calc(-1 * var(--s2)) calc(-1 * var(--s2)) calc(-1 * var(--s2)) 0;
  }

  .catalogue .game-card.game-card .game-card-deal {
    /* `align-items: stretch` on the flex parent is not enough on its own —
       `.btn` sets its own box — so the stub is told its height outright. */
    height: 100%;
    min-height: var(--tap-min);
    inline-size: 3.5rem;
    padding-inline: var(--s1);
    /*
     * Square, and the card's `overflow: hidden` rounds the two corners that
     * meet the outside. A stub with a radius of its own sat inside the row as
     * an egg; a stub with none is the end of the row.
     */
    border-radius: 0;
  }

  /*
   * AND THE SECOND DOOR IS WORN BY THE PICTURE.
   *
   * A disc rather than a bare triangle, for the reason `howto-control.js` gives
   * about the lobby's copy of this control: the print underneath is eight
   * different inks depending on which game it is, so anything laid over it has
   * to bring its own ground or it is legible on some boxes and not others.
   * `--panel` is that ground and it flips with the light, so the mark is a
   * cream disc in the morning and a raised dark one in the evening, both with
   * the same `--ink` triangle cut into them.
   *
   * AND IT IS PINNED AT THE SEAM RATHER THAN IN THE MIDDLE OF THE PICTURE,
   * WHICH IS ARITHMETIC AND NOT TASTE.
   *
   * The first version put the tap area over the picture's own bottom half, and
   * `sheet-has-a-way-out.spec.ts` went red on six cases with the reason spelled
   * out: *"how-to-play ... intercepts pointer events"* on a click aimed at
   * `open-box`. It is right, and it is a fault a person would meet too. The
   * picture is 48-56px wide and 84 tall, so its centre is 42px down; a mark
   * that is `--tap-min` tall and sits on the bottom edge starts at 42 and takes
   * that centre with it. The two doors do not land in the same place — the
   * picture opens the sheet at the top and this opens it at the steps — so
   * swallowing the picture's middle silently changes what pressing a box does.
   *
   * The column has no room for two 44px targets stacked, and there is no free
   * 44x44 anywhere else on an 86px row. So the mark straddles the seam: its box
   * starts 20px before the picture ends, which clears the picture's centre by
   * eight pixels at 390 and four at 360, and the disc is drawn at the box's
   * leading edge so two thirds of it still sits ON the picture. It reads as
   * pinned to the box rather than dropped beside it, which is the affordance
   * this was for, and it lands in the body's own padding rather than over any
   * word.
   */
  .catalogue .game-card.game-card .game-card-actions .btn--quiet {
    position: absolute;
    inset: auto auto 0 2.25rem;
    z-index: 2;
    display: grid;
    place-items: center;
    /*
     * `.btn` sets `justify-content: center` for its inline-flex life, and in a
     * grid that centres the TRACK — so `justify-items: start` was overruled and
     * the disc sat 8px in, far enough right to cover the "E" of the blurb. It
     * is the track that has to start, not the item in it.
     */
    justify-content: start;
    inline-size: var(--tap-min);
    block-size: var(--tap-min);
    min-height: 0;
    padding: 0;
    /*
     * And the underline goes. A quiet button is a clickable SENTENCE — the rule
     * beside `.btn--quiet` says so — and it is underlined because that is what
     * tells a sentence it is one. With the words hidden, the underline is a 44px
     * dark bar lying across the bottom of the card with nothing above it, which
     * is what the first screenshot of this row showed and what the disc is for.
     */
    border-bottom: 0;
  }

  .catalogue .game-card.game-card .game-card-actions .btn--quiet .howto-said {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }

  .catalogue .game-card.game-card .game-card-mark {
    display: grid;
    place-items: center;
    inline-size: 1.75rem;
    block-size: 1.75rem;
    border-radius: 50%;
    background: var(--panel);
    /*
     * `--on-table-strong` AND NOT `--ink`, WHICH IS THE WHOLE OF THE EVENING.
     *
     * The paragraph above says the disc is "a cream disc in the morning and a
     * raised dark one in the evening, both with the same `--ink` triangle cut
     * into them". The first half is right. The second is how the mark went
     * missing after dark: `--panel` flips with the light and `--ink` does not,
     * so the evening drew a near-black triangle on a dark green disc.
     *
     * MEASURED on the shipped build, all fourteen palettes, both lights:
     * daylight ran 14.69:1 to 17.21:1 and every single evening was UNDER the
     * 3:1 floor a graphical object has to clear — 1.30:1 in Natt, 1.39 in
     * Lampan, 2.27 at the very best in Citron. Fourteen of twenty-eight.
     *
     * `--on-table-strong` is what the house already puts on a `--panel` ground
     * — `.portal-code` does exactly this — and it flips, so it holds both:
     * 7.61:1 at its worst after dark and 11.84:1 at its worst in daylight.
     * One token, no media query, and the second door is a door in both lights.
     */
    color: var(--on-table-strong);
    /*
     * A ring as well as a shadow, and the ring is the part that matters. Four
     * of the forty prints are pale — Word Chain's is cream card stock — and a
     * cream disc on cream stock is a mark you have to already know is there.
     * `--rim` is the same edge every other object on this page carries, so the
     * disc is an object on the picture rather than a hole in it.
     */
    box-shadow: 0 0 0 var(--rim-w) var(--rim), 0 1px 3px rgb(0 0 0 / 0.3);
  }

  /*
   * AND THE TRIANGLE IS FILLED, WHICH IS WHAT A PLAY MARK IS.
   *
   * `.glyph` is the house's line icon — `fill="none"`, a 1.9px stroke — and it
   * is the right shape for every other mark on the product. Here it drew a
   * hollow outline 12px across inside a 28px disc with a heavy ring: 43% of
   * the circle, in a hairline, competing with its own border.
   *
   * An `image2image` pass over a photograph of this row came back with the
   * same note before the ratio above was measured — a solid triangle, larger
   * in its circle — and it is the one suggestion of three worth taking. The
   * second, that the disc should sit clear of the card's bottom rule, was
   * measuring the 44px TAP BOX rather than the 28px disc, which already sits
   * 9px clear. The third, two lines of blurb, is 17px on a row this document
   * has spent weeks keeping under the 98px the shelf is compared with.
   *
   * `fill` and the size are set here rather than on the icon, because every
   * other `.glyph` on the product is a line and should stay one.
   */
  .catalogue .game-card.game-card .game-card-mark .glyph {
    inline-size: 0.875rem;
    block-size: 0.875rem;
    fill: currentColor;
  }

  /* The disc takes the press, because the tap area around it is invisible and
     a control that shows nothing when pressed reads as one that did nothing. */
  .catalogue .game-card.game-card .game-card-actions .btn--quiet:active .game-card-mark {
    transform: scale(0.94);
  }

  /*
   * AND THE NARROWEST PHONE DOES THE SAME SUM WITH SMALLER NUMBERS.
   *
   * Last in the block on purpose, and that is the fifth time this stylesheet
   * has recorded the reason: a media query adds no specificity, so a nested one
   * sitting ABOVE these rules loses every declaration they both name. This
   * block was above them for one build — the picture narrowed because nothing
   * else set that property, and the stub, the padding and the mark's own width
   * all silently kept their 390px values. The mark hung 8px past the picture it
   * is supposed to be worn by, which is how it was noticed.
   */
  @media (max-width: 24rem) {
    .catalogue .game-card.game-card {
      grid-template-columns: 3rem minmax(0, 1fr);
    }

    /*
     * THE ARITHMETIC IS DIFFERENT AT 360 AND HAS TO BE DONE AGAIN.
     *
     * MEASURED at 360x640 before this block existed: the words had 158px, the
     * fact line wants 163, and "4-12 PLAYERS - 20 MIN" came apart over two
     * lines. That is 20px on EVERY card whose player range happens to be wide —
     * the row went 86px to 106 and the pitch 94 to 114, which is the shelf we
     * are compared with caught up with on the narrowest phone people carry.
     *
     * Half a rem off the picture, a quarter off the stub and a step off the
     * words' left margin is 22px, and it puts the fact line back on one line
     * with 19 to spare. Nothing is removed and nothing is shrunk: the stub
     * still holds "Deal" with room either side, and the picture at 48px is
     * still a picture. The two constants this is arithmetic ABOUT are written
     * down where the wider phone does the same sum.
     */
    .catalogue .game-card.game-card .game-card-body {
      padding-left: var(--s2);
    }

    .catalogue .game-card.game-card .game-card-deal {
      inline-size: 2.75rem;
    }

    /*
     * The seam is half a rem further left, so the mark's box follows it — but
     * not all the way. At 1.75rem with a 28px disc, the disc's right edge sat
     * on the first letter of the blurb at 360 (2026-09-07). Moving the box to
     * 1.5rem cleared the words by 4px and put the box's LEFT edge on the
     * centre line of the 48px picture, which is the other door: a press on the
     * middle of the picture opened the explainer instead of the box
     * (`sheet-has-a-way-out.spec.ts`, both engines, 2026-09-08). The 44px box
     * has to fit between the picture's centre (24) and the words (56), which
     * is 32px, so the box cannot fit — but the DISC can. A 24px disc at
     * 1.75rem ends 4px short of the words, and the box behind it starts 4px
     * right of the picture's centre. Both doors keep their middles.
     */
    .catalogue .game-card.game-card .game-card-actions .btn--quiet {
      left: 1.75rem;
    }

    .catalogue .game-card.game-card .game-card-mark {
      inline-size: 1.5rem;
      block-size: 1.5rem;
    }
  }
}

@media (min-width: 40rem) {
  .portal-hero {
    order: 1;
  }

  .portal-modes {
    /*
   * ORDER 4 — BELOW THE SHELF ON A DESK TOO, FROM 2026-08-27.
   *
   * This was `order: 2`, between the hero and the shelf, and the note above
   * gives the reason: "a desk has no fold to defend".
   *
   * That premise is false and was measured false today. At 1440x900 the shelf
   * showed **nought whole boxes** — a box is 434 tall and there were 399 above
   * the fold — and it took thirty-five pixels out of the box and its run-up to
   * fit the first row of five. A desk whose first row of boxes does not fit has
   * a fold, and this line is 32px of it with its margins.
   *
   * Nothing is hidden and nothing is deleted: `portal.spec.ts` presses both
   * links and goes on doing so. They are the two exceptional ways to play — one
   * phone between you, and a television — and after the games is where an
   * exception reads best on any size of screen.
   */
  order: 4;
    /* It sat under the pitch inside the hero and keeps that gutter, so the
       links stay on the column the headline is set in rather than jumping to
       the page edge. */
    padding-inline: calc(var(--s5) + var(--s5));
    margin-top: 0;
    margin-bottom: var(--s4);
  }

  .portal-shelf {
    order: 3;
  }
}

@media (min-width: 1020px) {
  .catalogue {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

@media (min-width: 1340px) {
  .catalogue {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}

/*
 * THE BOX IS THE OBJECT, AND THE LID IS ITS PRINTED FACE.
 *
 * This reverses the arrangement described above and the reversal is worth the
 * paragraph, because the old argument was sound and was answering a different
 * question. The lid used to be the only rimmed thing on the shelf, with the
 * words on the bare table under it, so that a box was not a rim inside a rim.
 * What it cost was containment: at four columns the gap between a lid and its
 * own words is smaller than the gap to the next lid only by grid arithmetic,
 * and the row read as a strip of pictures above a paragraph of names.
 *
 * There is still exactly one rim per object. It moved outwards — the card
 * carries it and the lid gives its own up, clipped by the card's corners, so a
 * box is one pressed-enamel thing with a label across the top rather than two
 * things stacked. The material is `--panel` and not `--stock`, which matters
 * more than it looks: `--stock` is glaze and does not flip with the light, so
 * a card made of it would be pale in both themes and every themed ink on it —
 * `--dim` at 64% of pale in the evening — would be a pale mark on a pale
 * ground. `--panel` is the ground raised, and the text tokens are cut against
 * the ground.
 */
.game-card {
  /* The strip hangs the explainer's mark on the picture's own corner, which is
     an absolute position, which needs a box to be absolute inside. It is the
     card and not the picture: the mark has to be able to sit ACROSS the seam
     between them, and a lid that clipped it would decide that for it. */
  position: relative;
  /*
   * Raised, so the faintest ink in it is cut against a panel and not against
   * the ground. `--dim: var(--dim-lift)` used to be declared here and here
   * alone, which is how the lobby's three raised surfaces shipped without it —
   * it now lives with the rest of them, under "what stands on the table" near
   * the top of this file. The finding that put it there stands: the version
   * without it measured 4.10:1, in the evening, in the palette this ships with.
   */
  display: flex;
  flex-direction: column;
  min-width: 0;
  overflow: hidden;
  background: var(--panel);
  /*
   * A HAIRLINE AND A SHADOW, NOT THE RIM — AND THIS IS THE ONE PLACE THAT IS
   * TRUE.
   *
   * Rule 2 at the top of tokens.css says every object has a rim: a heavy dark
   * edge, because separation from the rim rather than from luminance is what
   * lets an object keep its identity when the room's light changes. That rule
   * is about the TABLE — about a card, a tile and a slip having to stay
   * distinct objects to eight people in two themes and twelve palettes.
   *
   * The front door is not a table. Measured off the supplied design: its boxes
   * have no border at all, and the darkest pixel anywhere near a box's left
   * edge is #ece4d9 against a #fdf6eb ground — a soft shadow bottoming out
   * about 1.1:1, which is a lift and not an edge. A 2px `--edge` here read as
   * eight framed pictures where the design has eight things lying on paper.
   *
   * So the shelf gets the hairline and keeps the shadow, and nothing that is
   * dealt to anybody is touched.
   */
  border: 1px solid var(--rule);
  border-radius: var(--r-card);
  box-shadow: var(--lift-xs);
  transition: box-shadow var(--fast) var(--ease), transform var(--fast) var(--ease);
}

/* Lifted under the pointer — the box being taken off the shelf. A device with
   no pointer never resolves this, which is what `hover: hover` is for. */
@media (hover: hover) {
  .game-card:hover {
    transform: translateY(-2px);
    box-shadow: var(--glaze-line), var(--lift-sm);
  }
}

/*
 * THE ROOM'S PICKER BUILDS THIS SAME CARD — 2026-09-11.
 *
 * On the front door the card is an <li> with a stretched `.game-card-hit`
 * button inside it; in a room the whole card IS the button (a press deals the
 * game onto the table). That variant needs the resets a <button> brings that
 * the <li> did not, and it earns the same "off the shelf" lift above and the
 * "pressed in" feedback below. Placed after `:hover` so a press on a desk wins
 * the tie and the card goes down rather than staying lifted.
 */
.game-card--pick {
  padding: 0;
  width: 100%;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.game-card--pick:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: -3px;
}

.game-card--pick:active {
  transform: translateY(1px);
  box-shadow: var(--lift-xs);
}

@media (prefers-reduced-motion: reduce) {
  .game-card--pick:active {
    transform: none;
  }
}

/*
 * THE BODY IS THE DEAL CONTROL — 2026-09-08.
 *
 * A transparent `<button>` stretched over the card's BODY opens a room from a
 * press on the title, the facts or the blurb, so the loud "Deal <name>" button
 * and the "See it played" link could come off (one card, one press). It is a
 * real control with its own name and focus ring, laid over content that stays a
 * heading and two facts — the accessible "card as one action" shape, not a
 * clickable `<li>`.
 *
 * It covers the body and NOT the picture, so the picture (boxControl) keeps its
 * own press — turning the box over to the explainer — and the two never fight
 * for a click or leave the deal control with its centre under the lid. The card
 * already lifts on hover ("off the shelf"); now it depresses on press, the
 * physical story the rest of the design tells.
 */
.game-card-hit {
  position: absolute;
  inset: 0;
  z-index: 1;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  border-radius: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.game-card-hit:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: -3px;
}

.game-card:has(.game-card-hit:active) {
  transform: translateY(1px);
  box-shadow: var(--lift-xs);
}

@media (prefers-reduced-motion: reduce) {
  .game-card:has(.game-card-hit:active) {
    transform: none;
  }
}

/* The lid's own frame is the card's now. Its `overflow: hidden` stays — that is
   what holds a `cover`-fitted print inside the 3:2 — and the corners it is
   clipped to are the card's, one layer up. */
.game-card .lid {
  border: 0;
  border-radius: 0;
  box-shadow: none;
}

/* The lid, plus whatever is stuck to it. */
.game-card-lid {
  position: relative;
  /* The same hairline the card wears, for the same reason. */
  border-bottom: 1px solid var(--rule);
}

/*
 * THE BOX AS A CONTROL: PRESSING THE PICTURE OPENS THE SHEET.
 *
 * It is a `<button>` carrying a picture, so every default a button brings — a
 * fill, a rim, a font, a padding, centred text — has to be undone before it can
 * be a lid again. It is the same reset the room's picker does one layer up, and
 * it earns its place here rather than there because on this page the deal
 * button lives *inside* the card and a button inside a button is markup a
 * browser fixes by discarding one of them.
 *
 * **It does not depress, and that is the one place this design's rule bends.**
 * Everything you can push goes down a pixel with its shadow collapsed; a
 * translate here would move a `cover`-fitted print inside a card whose
 * `overflow: hidden` is what holds the picture to its 3:2, and open a hairline
 * of panel along the top edge for 70ms. So the feedback is the rule under the
 * lid instead — thickened in the flourish, which is exactly what `.btn--quiet`
 * does under a finger and exactly the colour the front door already spends on
 * nothing else. An inset shadow rather than a wider border, because a border
 * that grows relays out the picture above it.
 */
.game-card-open {
  display: block;
  width: 100%;
  padding: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: inherit;
  border-top: 0;
  border-right: 0;
  border-left: 0;
  border-radius: 0;
  cursor: pointer;
  transition: box-shadow var(--fast) var(--ease);
}

@media (hover: hover) {
  .game-card-open:hover {
    box-shadow: inset 0 -3px 0 0 var(--flourish);
  }
}

.game-card-open:active,
.game-card-open:focus-visible {
  box-shadow: inset 0 -3px 0 0 var(--flourish);
}

/*
 * The sticker: whether this box can be taken down, or is today's.
 *
 * Over the print rather than in the title row, which the name and the two facts
 * have between them. It needs a ground of its own for the same reason a sticker
 * does — the artwork underneath is not the palette's and cannot be relied on
 * for contrast — which `[data-status]` is given
 * below.
 */
.game-card-sticker {
  position: absolute;
  top: var(--s2);
  left: var(--s2);
  z-index: 1;
  box-shadow: var(--lift-xs);
}

.game-card-body {
  /* The deal control (`.game-card-hit`) is stretched inside the body, so the
     body is its positioning context — the picture stays a separate box above it
     with its own press (the explainer). */
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: start;
  gap: var(--s3);
  min-width: 0;
  width: 100%;
  flex: 1;
  padding: var(--s4);
}

/*
 * The two facts that decide whether a game is possible this Friday.
 *
 * In the corner beside the name rather than on a line under it: a shelf is
 * scanned for "what fits five people in twenty minutes", so the answer belongs
 * where the question is asked.
 *
 * STACKED, AND THAT IS THE MEASUREMENT TALKING. Side by side they are 175px
 * wide and the longest name is 149; a box at four columns has 277, so one line
 * needs 336 and is 60px short. Closing that gap means ~15px names and ~8px
 * facts — which is the size these are drawn at in the picture this layout comes
 * from, and is under any floor this product's own contrast suite would pass.
 * Stacked they are 90px, every name fits beside them on one line with room
 * left, and nothing shrank.
 *
 * `align-items: end` so both facts range right against the card's edge, and
 * `white-space: nowrap` because "2–10 players" breaking after the dash would be
 * a fact split across two lines.
 */
.game-card-facts {
  display: flex;
  flex-direction: column;
  align-items: end;
  gap: var(--s1);
  flex: none;
  color: var(--dim);
  font-size: var(--t-data-sm);
  letter-spacing: 0.08em;
  white-space: nowrap;
  /* Optically level with the name's cap line: the head aligns on first
     baselines, and the facts are 11px where the name is 19. */
  padding-top: 2px;
}

.fact {
  display: inline-flex;
  align-items: center;
  gap: var(--s1);
}

/*
 * Three lines, and the rest is on `/games`.
 *
 * The same rule the room's picker already lives by, for the same reason: blurb
 * length is what decides card height, card height is what decides how many
 * boxes fit on a screen, and how many boxes fit on a screen is the whole
 * argument this page is making. Whoever is choosing wants to know what a game
 * is; the entry that tells them the rest is one link away.
 */
.game-card-blurb {
  color: var(--on-table);
  /* One step down from body copy, because it is a caption inside an object and
     not a paragraph on a page — and because three clamped lines of 17px in a
     300px box is a wall where three of 15px is a description. */
  font-size: var(--t-small);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  overflow: hidden;
}

/* Pushed to the foot of the box, so a row of Deal buttons is a row. */
.game-card .btn {
  margin-top: auto;
}

/*
 * Every box's button is the primary one.
 *
 * It used to be the plain control everywhere except today's box. On a shelf
 * where every entry is an equal offer that made seven of eight games look like
 * the alternatives to one of them — and the stamp already says which is
 * today's, in words, without spending the page's only strong colour on it.
 */
.game-card-deal {
  padding: var(--s2) var(--s4);
  /* The verb and the name are two flex items, and the button's icon gap
     between them read as a double space — "Deal  Bowl of Nouns" on every
     card at 1280 (2026-09-07). A word space is about a third of an em. */
  gap: 0.3em;
}

/*
 * Name on the left, the two facts in the corner on the right.
 *
 * It does not wrap: the facts are a fixed 90px column (`.game-card-facts`) and
 * the name takes what is left, which at four columns is 175px against a longest
 * name of 149. If a game arrives whose name is longer than that, the name wraps
 * to a second line inside its own half and the facts stay in the corner — which
 * is the failure mode to want, because the alternative is the facts dropping
 * below the name on some boxes and not others and the row of eight going
 * ragged.
 */
.game-card-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  width: 100%;
  gap: var(--s3);
}

/* Breakable for the same reason the picker's is: the shelf is four columns
   wide at a desk, and a name is not allowed to leave its own box. */
.game-title {
  font-size: var(--t-display);
  text-transform: uppercase;
  overflow-wrap: anywhere;
  min-width: 0;
}

/*
 * On the shelf the name shares its line, so it is set to the line rather than
 * to the page. `--t-display` is 34px at a desk, which is a heading for a screen
 * and not for a 300px box with a fact line beside it — at that size "Twelve
 * Crowns" alone was two lines and the facts were a third.
 */
.game-card .game-title {
  font-size: 1.1875rem;
  line-height: 1.1;
  letter-spacing: -0.01em;
}

.badge {
  font-family: var(--font-mono);
  font-size: var(--t-data-sm);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  padding: var(--s1) var(--s2);
  border: var(--rim-hair) solid var(--rim);
  border-radius: var(--r-chip);
  color: var(--dim);
  white-space: nowrap;
}

.badge[data-status="live"] {
  background: var(--stock);
  border-color: var(--stock);
  color: var(--ink);
}

/*
 * "In the workshop", which on the shelf is stuck to the artwork.
 *
 * A badge is a hairline and tracked type with no ground, which reads on the
 * table and vanishes over a two-colour screenprint the palette does not own. So
 * the one badge that can land on a picture is given a ground — glaze, which
 * does not flip with the light, and the ink that is printed on glaze.
 */
.badge[data-status="workshop"] {
  background: var(--stock-dim);
  border-color: var(--rim);
  color: var(--ink);
}

/* ------------------------------------------------------------ the shelf */

/*
 * `/games` is the page you read before you choose; `/` is the one you act on.
 * Two shelves would have been the same page twice, so what lives here is the
 * depth: the beats a round is made of, and a full entry per game.
 *
 * Bands separated by hairlines rather than cards floating on the table — this
 * is a document, and the objects inside it are the specimens.
 */
.shelfpage {
  display: flex;
  flex-direction: column;
}

.shelfpage-head {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding: var(--s6) var(--s5) var(--s5);
}

/* Two lines under a 22ch headline, for the reason the front door's lead is
   two: a band that fills the first screen is a band nobody scrolls past. */
.shelfpage-head .lead {
  max-width: 44rem;
}

.shelfpage-foot {
  display: flex;
  flex-direction: column;
  align-items: start;
  gap: var(--s3);
  padding: var(--s6) var(--s5) var(--s8);
}

/* ---- the five beats ---- */

.arc {
  display: flex;
  flex-direction: column;
  gap: var(--s6);
  padding: var(--s6) var(--s5) var(--s7);
  border-block: var(--hair);
  background-image: var(--grain);
  background-size: var(--grain-size);
}

.arc-head {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
}

.beats {
  display: flex;
  flex-direction: column;
  gap: var(--s6);
  list-style: none;
}

/*
 * Text left, the object it describes right. The object column is fixed so the
 * five specimens line up in a stack you can read down — a card, a clock and a
 * score sheet at ragged left edges would read as five unrelated illustrations
 * rather than one sequence.
 */
.beat {
  display: grid;
  gap: var(--s5);
  grid-template-columns: minmax(0, 1fr);
  align-items: center;
  padding-top: var(--s6);
  border-top: var(--hair);
  /* Capped, because the prose inside is capped: a full-width row put the
     specimen at the far edge of a 1440px table with a void in between. */
  max-width: 64rem;
}

.beat:first-child {
  padding-top: 0;
  border-top: 0;
}

@media (min-width: 880px) {
  .beat {
    grid-template-columns: minmax(0, 1fr) 22rem;
    gap: var(--s7);
  }
}

.beat-text {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}

/* The number is a position in a sequence, so it is set as data, not as a
   display digit competing with the beat's name. */
.beat-n {
  color: var(--dim);
}

.beat-title {
  font-size: var(--t-display);
  text-transform: uppercase;
  letter-spacing: var(--track-display);
}

.beat-objects {
  display: flex;
  align-items: center;
  gap: var(--s4);
  flex-wrap: wrap;
}

.arc-sheet {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  min-width: 14rem;
}

.arc-sheet-row {
  display: flex;
  justify-content: space-between;
  gap: var(--s4);
  padding-top: var(--s2);
  border-top: var(--hair-print);
}

.arc-sheet-points {
  font-family: var(--font-mono);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/*
 * A fixed-geometry component shown small.
 *
 * Scaled, not resized: the card is deliberately the same shape whatever is
 * printed on it, and re-laying it out at 60% would make this page's specimen a
 * different object from the one the table deals.
 */
.plate {
  --plate: 0.56;
  width: calc(24.5rem * var(--plate));
  height: calc(31rem * var(--plate));
  flex: none;
}

.plate-in {
  width: 24.5rem;
  height: 31rem;
  transform: scale(var(--plate));
  transform-origin: top left;
}

/* Nothing on this page is held, so nothing here is a button. */
.plate .card {
  --card-w: 24.5rem;
  --card-h: 31rem;
  cursor: default;
}

/* ---- one game, at length ---- */

.dossier {
  display: flex;
  flex-direction: column;
  gap: var(--s5);
  align-items: start;
  padding: var(--s6) var(--s5) var(--s7);
  border-bottom: var(--hair);
}

/*
 * The lid heads the entry, beside the name rather than above it, so an entry
 * here is recognisably the box somebody pressed on the front door. On a phone
 * it stacks; the picture is worth its height there too, because this page is
 * long and the boxes are what somebody is scrolling between.
 */
.dossier-top {
  display: grid;
  gap: var(--s4) var(--s5);
  grid-template-columns: minmax(0, 1fr);
  align-items: center;
  width: 100%;
  max-width: 64rem;
}

@media (min-width: 640px) {
  .dossier-top {
    grid-template-columns: 20rem minmax(0, 1fr);
  }
}

.dossier-title {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  min-width: 0;
}

/* The badge belongs beside the name, not at the far edge of the column: with
   `space-between` in a 900px track "READY" ended up 500px away from the title
   it was describing, reading as an unrelated label. */
.dossier-head {
  display: flex;
  align-items: baseline;
  gap: var(--s4);
  width: 100%;
  flex-wrap: wrap;
}

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

.dossier-body {
  display: grid;
  gap: var(--s6);
  grid-template-columns: minmax(0, 1fr);
  width: 100%;
  max-width: 64rem;
}

@media (min-width: 880px) {
  .dossier-body {
    grid-template-columns: minmax(0, 1fr) 22rem;
  }
}

.dossier-prose {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
}

/* A labelled row: the mono label states what the strip is, the strip shows it. */
.dossier-strip {
  display: grid;
  gap: var(--s2) var(--s5);
  grid-template-columns: minmax(0, 1fr);
  width: 100%;
}

@media (min-width: 640px) {
  .dossier-strip {
    grid-template-columns: 8rem minmax(0, 1fr);
    align-items: start;
  }
}

.dossier-strip-label {
  color: var(--dim);
  padding-top: var(--s1);
}

.shape {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
  align-items: center;
}

.dossier-sheet {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  max-width: 34rem;
}

.dossier-sheet-row {
  display: flex;
  gap: var(--s4);
  align-items: baseline;
  padding-top: var(--s2);
  border-top: var(--hair-print);
}

.dossier-sheet-row:first-of-type {
  padding-top: 0;
  border-top: 0;
}

.dossier-sheet-points {
  font-family: var(--font-mono);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  min-width: 2.5rem;
}

/*
 * `/games` on a phone.
 *
 * Same rent, same landlord: the head band was 3 lines of hero type over a
 * 5-line lead and filled the whole first screen, so the page you are on to
 * read about the games opened on nobody's game. It steps down to the size the
 * front door's band already uses, and the bands underneath stop spending a
 * desk's padding on a 390px column.
 *
 * Last in the section for the reason the join block is: these all override
 * base rules of equal specificity, and a media query buys none.
 */
@media (max-width: 599px), (max-height: 560px) {
  .shelfpage-head {
    padding: var(--s4);
    gap: var(--s2);
  }

  .shelfpage-head .hero-title {
    font-size: 1.9375rem;
    max-width: none;
  }

  .shelfpage-head .lead {
    font-size: var(--t-body);
    line-height: 1.4;
  }

  .arc {
    padding: var(--s5) var(--s4) var(--s6);
    gap: var(--s5);
  }

  .beats {
    gap: var(--s5);
  }

  .beat {
    padding-top: var(--s5);
  }

  .dossier {
    padding: var(--s5) var(--s4) var(--s6);
    gap: var(--s4);
  }

  .shelfpage-foot {
    padding: var(--s5) var(--s4) var(--s6);
  }
}

.form-error:empty {
  display: none;
}

.form-error {
  border-left: 3px solid var(--stamp);
  padding-left: var(--s3);
  color: var(--on-table-strong);
}

/* ---------------------------------------------------- saying "reconnecting"

   The room telling a player what is wrong, rather than looking idle.

   Everything else that reports the connection is furniture in the rail — a
   six-pixel dot beside the clock, and the same dot on the room pill. Nobody
   looks at either while they are thinking about their turn, and that is most
   of why this fault took five rounds to place: a table that has silently lost
   its connection is indistinguishable, from inside, from a table waiting for
   somebody else to move. Both look like nothing happening. Nothing on screen
   ever said the word.

   This is the one piece of chrome allowed to sit on top of a game, pinned to
   the bottom edge above the safe area so a game holding the whole viewport
   cannot bury it. Rimmed on the table's own ground, like every other object.

   Delayed by two seconds through an animation rather than a timer in script,
   so an ordinary quarter-second retry — now the common case — never flashes a
   warning at anybody, and the delay lives beside the thing it delays.
   `visibility` steps rather than fades: transparent-but-present would still be
   announced two seconds early. */
.reconnecting {
  position: fixed;
  z-index: 200;
  left: 50%;
  bottom: calc(var(--s4) + var(--safe-b, 0px));
  transform: translateX(-50%);
  display: none;
  align-items: center;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
  background: var(--table);
  border: var(--rim-hair) solid var(--rim);
  box-shadow: var(--lift-sm);
  font-family: var(--font-mono);
  font-size: var(--t-data-sm);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--stamp);
  white-space: nowrap;
}

:root[data-conn="lost"] .reconnecting {
  display: inline-flex;
  visibility: hidden;
  animation: reconnecting-appear 0s linear 2s forwards;
}

@keyframes reconnecting-appear {
  to {
    visibility: visible;
  }
}

/* A player who has switched motion off still needs the words; they simply
   arrive without the wait, which is the safe direction to fail in. */
@media (prefers-reduced-motion: reduce) {
  :root[data-conn="lost"] .reconnecting {
    animation: none;
    visibility: visible;
  }
}

.reconnecting-dot {
  width: 6px;
  height: 6px;
  flex: none;
  background: var(--stamp);
}

/* ------------------------------------------------------------------ the daily

   `/daily`, the async layer's front door. One column, three objects: a band
   that names today, a printed sheet carrying either the invitation or the
   result, and a fortnight of dots.

   No new colours. The grid is drawn in emoji rather than in styled squares —
   see the note in `shell/daily.js` — so all this has to do is set it in mono at
   a size a thumb can read and lay the rest out on the spacing scale everything
   else already uses. */

.daily {
  display: flex;
  flex-direction: column;
  gap: var(--s5);
  padding: var(--s6) var(--s5) var(--s8);
  max-width: 40rem;
  width: 100%;
  margin: 0 auto;
}

.daily-head {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}

.daily-sheet {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s4);
}

/* The button is the point of the sheet in two of its three states, so it is
   allowed to be as wide as the paper it is printed on. */
.daily-sheet > .btn {
  align-self: stretch;
}

/* The artifact, and the same characters that reach the clipboard.

   `pre` because the rows are newline-separated and a browser must not reflow
   them: a grid that wraps is a different picture. */
.daily-grid {
  font-family: var(--font-mono);
  font-size: var(--t-lead);
  line-height: 1.25;
  letter-spacing: 0.02em;
  margin: 0;
  white-space: pre;
  overflow-x: auto;
  color: var(--ink);
  user-select: text;
}

.daily-run {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}

.daily-run-dots {
  display: flex;
  gap: 4px;
  flex-wrap: wrap;
}

/* Paper, guillotined square — the radius rule in `docs/design.md`: rounded is
   for a thing your finger operates, and a day is not one. */
.daily-dot {
  width: 12px;
  height: 12px;
  flex: none;
  background: var(--facedown);
  border: var(--hair) solid var(--rule);
}

.daily-dot[data-on="true"] {
  background: var(--stock);
  border-color: var(--rim);
}

/* A day that was played and did not come out is still a day you showed up for,
   so it is paper too — dimmer, and never marked in the accusation ink. */
.daily-dot[data-outcome="missed"] {
  background: var(--stock-dim);
}

.daily-foot {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s3);
  padding-top: var(--s3);
  border-top: var(--hair) solid var(--rule);
}

/* ------------------------------------------------------------- the finder */

/*
 * Finding a game among twenty-nine, without turning the shelf into a database.
 *
 * The row reads as one object rather than three controls in a line: a search
 * field that takes the width it needs, two questions in the house's own words,
 * and the count at the end so an answer changes something you can see. The
 * count is the feedback — a filter that silently removes cards and says nothing
 * looks like a page that has lost its content.
 */
.finder {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  margin-bottom: var(--s5);
}

.finder-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: var(--s3);
}

.finder-search {
  flex: 1 1 16rem;
  min-width: 12rem;
}

.finder-pick {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  /*
   * FOUR QUESTIONS FIT ON THE DESK'S ONE LINE, which three did at 11rem and
   * four did not: the fourth pushed the surprise onto a second line by 34px.
   * A shared basis rather than a floor is what makes them fit — at
   * `flex: 0 1 auto` each pick was as wide as its longest option, so "A quarter
   * of an hour" made the minutes question 211px while its neighbours sat at
   * 176. They ask equally important questions and now take equal room, and the
   * 30px left over is shared out rather than won by whichever list happens to
   * contain the longest phrase.
   */
  flex: 1 1 10rem;
  min-width: 10rem;
}

/*
 * The question is the label, so it is set as one — tracked mono caps, the same
 * register as every other machine fact on this page. Set above the control and
 * not beside it, because two questions this long laid out inline read as one
 * run-on sentence: "HOW MANY OF YOU HOW LONG HAVE YOU GOT".
 */
.finder-pick-label {
  font-family: var(--mono);
  font-size: var(--t-tiny, 0.6875rem);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--dim);
  white-space: nowrap;
}

/*
 * ON A PHONE THE QUESTION IS ASKED BY THE ANSWER, so it is not asked twice.
 *
 * MEASURED against Table Party on the front door at 390x844: their first game
 * card is at y=448 with four of them above the fold, and ours was at **y=846 on
 * an 844px screen** — one pixel below it, so a person landing on the shelf saw
 * a headline, a paragraph and a search box, and no evidence that this product
 * has any games in it.
 *
 * The budget was 78px of header, 445 of hero and 324 of shelf chrome. This is
 * the part of that chrome which says nothing the control under it does not
 * already say: the select's resting option reads "Any number" and "Any length",
 * which is the question and the answer in one. The label stays in the accessible
 * name — `visually-hidden` and not `display: none` — so a screen reader is told
 * exactly what it was told before.
 */
@media (max-width: 40rem) {
  .finder-pick-label {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }

}

/*
 * A select drawn as the house draws a field, because the browser's own is the
 * one control on this page that belongs to somebody else's design system —
 * grey, system-font, and sitting on cream paper like a sticker.
 *
 * `appearance: none` takes the native arrow away, so one is drawn back as a
 * background chevron: without it there is nothing to say the pill opens.
 */
/*
 * A RING WITH A FALLBACK TO A TOKEN THAT DOES NOT EXIST.
 *
 * This read `var(--focus-ring, 2px solid currentColor)`, and `--focus-ring` is
 * declared nowhere in the product — grepped across `design/`, `lib/`, `shell/`
 * and all thirty-seven game stylesheets. So the fallback was not a fallback, it
 * was the rule: the only two controls on the front door that restate their own
 * focus ring drew it in `currentColor` at 2px, where every other focusable
 * thing takes `--focus` at 3px from `base.css`. Two of the thirteen palettes
 * put the finder's ink close enough to its own field that the ring effectively
 * disappeared into the control it was marking.
 *
 * It is the token, at the system's width. The restatement stays rather than
 * being deleted, because a `<select>` is one of the few elements whose UA
 * stylesheet has its own opinion about focus and beating it explicitly is
 * cheaper than finding out on somebody's machine that it did not.
 */
/*
 * A PHONE ASKS THE SAME QUESTIONS IN LESS ROOM — 2026-08-25.
 *
 * ## What a column cost, measured on the front door at 390x844
 *
 * `.finder-row` holds five children — the search, the two questions, the count
 * and "Surprise us" — and a column gives each of them a full-width line:
 *
 *     hero        88px  @94        THE FINDER  334px  @615
 *     join       125px  @297       first card  459px  @989
 *     modes       59px  @446       the fold              844
 *
 * So a phone showed a headline, a paragraph, a code field, a button, two links
 * and four form controls, and NOTHING from the shelf. A desk showed three
 * cards. That is a games product whose phone front door has no games on it, and
 * `tableparty.io` measured in the same browser at the same viewport puts its
 * first tile at 383px with four rows above the fold.
 *
 * ## What is a grid's and what was never the column's fault
 *
 * The column was right about the search, which wants the width, and wrong about
 * everything under it: a dropdown reading "Any number" does not need 322px, and
 * the count is twenty pixels of text. Two columns, the search spanning both,
 * and the count on the line with the button it describes: **334px to 227px**,
 * measured, with 0.0px of overflow at 390 and at 360 and nothing clipped.
 *
 * A version that also HID the two questions and let the dropdowns speak for
 * themselves reaches 190px. It is not taken. "How many of you" and "How long
 * have you got" are the product's voice asking a person something, and a bare
 * pair of dropdowns is any filter bar anywhere; the trade was 37px, and it does
 * not put a card above the fold either — that is the hero's 445px, which is a
 * question about what a front door is for and not one a stylesheet answers.
 *
 * ## Two things this replaced, kept because they are why the shape is what it is
 *
 * `flex: 1 1 16rem` on the search is a basis for the ROW, where it means width.
 * In a column the main axis is vertical and the same declaration asked for a
 * search box SIXTEEN REM TALL — 256px of empty white, measured on a Pixel 7 and
 * an iPhone 13, a quarter of the way down the front door. A grid track cannot
 * make that mistake, which is one reason to prefer it here.
 *
 * And `flex-wrap: nowrap`, which was load-bearing: a WRAPPING column is a
 * multi-line flex container whose line takes its cross size from its widest
 * child rather than from the box — how Bowl of Nouns put *Put it in* 26px past
 * a clipping table (`tests/guards/a-column-does-not-wrap.test.ts`). There is no
 * column here now, so there is nothing for that guard to catch; the hazard is
 * recorded rather than deleted because the next person to reach for
 * `flex-direction: column` under a width query needs to know it.
 */
/*
 * THIS BRACE WAS MISSING, AND EVERYTHING BELOW IT WAS A PHONE RULE.
 *
 * A stylesheet with one unclosed block does not fail: the parser swallows the
 * rest of the file into it and every browser agrees. So the thirteen rules
 * under this line — the front door's hand-off pair, the room's television
 * link, the host's star, "Show out", and the notice a departure prints —
 * were declared once, indented at column 0 like the top-level rules they were
 * written as, and served to nobody wider than 40rem.
 *
 * What that looked like on a desk, measured before it was closed: the two
 * hand-off links on the front door in `rgb(125, 19, 46)` — the accent, because
 * `--dim` never reached them — at body size rather than small, jammed against
 * the shortcut above with no margin, and reading
 * "One phone between you?·Put it on the big screen" with no space either side
 * of the separator, because the `gap` that makes that space is a flex property
 * and the `display: flex` was in here too. At 960px the second label broke
 * across two lines mid-phrase for the same reason.
 *
 * `tests/guards/stylesheets-close.test.ts` counts the braces in every
 * stylesheet now. It is a fault that cannot be seen by reading the rule — the
 * rule is correct — only by counting, which is a machine's job.
 */

/*
 * The two rooms that are not one-person-one-device, on the front door.
 *
 * A quiet line under the join shortcut: prose you can click, in the body face,
 * because "One phone between you?" is a question rather than an instruction to
 * the machine, and tracked mono caps would make an aside shout.
 */
/*
 * THE TWO MODES: ONE ELEMENT, TWO PLACES.
 *
 * `screens.js` renders this as a sibling of the shelf rather than a child of
 * the hero, and the note there says why — on a phone it was 71px directly above
 * the shelf, which is a whole game card on the one screen where every pixel
 * above the games is rent.
 *
 * A phone gets the DOM order as written: hero, shelf, then these. A desk has no
 * fold to defend, so `order` puts them back between the hero and the shelf —
 * `.portal-scroll` is already a flex column, so this costs one property and no
 * second copy of the markup. The gutter comes with it: inside the hero it took
 * the hero's padding, and out here it has to bring its own.
 */
/* The room's own way to open a television: quiet, under the code, because the
/* ------------------------------------------------------------ the host */

/*
 * A STAR BESIDE THE NAME, AND THE WORD STILL AT THE END OF THE ROW.
 *
 * "host" in the tag slot was the same weight as "away" and "left" — three
 * states of a seat, except that one of them is not a state at all but a role,
 * and it is the only one anybody needs to find at a glance. So the mark goes
 * where a role goes, beside the name, and the word stays where it was: a glyph
 * on its own is a rebus, and this interface already spends its one exemption on
 * the connection pill.
 *
 * Ink rather than colour, on purpose. `--stamp` marks a person the *game* is
 * singling out — whose turn it is, who stands accused — and the host is a
 * property of the room. Borrowing the accusation colour for it would put a red
 * mark next to somebody's name for the crime of having opened the room.
 */
/* The ellipsis belongs to the text, not to the box the star shares with it: a
/*
 * The way to take a chair back.
 *
 * Under the row rather than beside it: the roster column is 340px at a desk and
 * a good deal less on a phone, the names in it are people's own and may be
 * long, and a control competing with a name for the same line is a control
 * that either wraps the name or gets clipped. Under it, the row is untouched
 * and the quiet variant's hairline reads as belonging to the seat above.
 */
/*
 * Asking is a different object from offering, and it should look like one.
 *
 * The same treatment the round-ending confirm takes: a raised field, the
 * consequence in prose at reading size, and the destructive answer as the
 * primary — because a confirm whose safe answer is the loud one is a confirm
 * people click through to get rid of.
 */
/*
 * What happened to the room you were in.
 *
 * At the very top of the front door and in the flow rather than docked: it is
 * not an offer, it is the answer to "why am I looking at this page", and the
 * question is asked in the first second. It is stock, because it is a printed
 * notice and every printed thing here is.
 */
/* ===========================================================================
 * THE FRONT DOOR, ON A PHONE — declared last, because order is the only thing
 * that decides these
 * ===========================================================================
 *
 * Every rule below matched from where it was first written and lost anyway. A
 * media query adds NO SPECIFICITY, so `@media … { .portal-modes { … } }` at line
 * 6950 loses to a plain `.portal-modes` at 8711 — and this file already has a
 * paragraph warning about exactly that, which I read and then walked into four
 * times in one afternoon. At the end of the stylesheet nothing can be later.
 *
 * WHY, MEASURED at 390x844 against Table Party's front door, whose first game
 * card is at y=448 with four above the fold. Ours was at y=797 with one, on a
 * budget of 78px of header, 437 of hero, 26 of shelf head and 190 of finder.
 */
/*
 * The seat ceiling on a card, which is a caveat and not a fact of the game.
 *
 * Set quieter than the two facts it follows: "4–12 players" and "20 min" are
 * what the game IS, and "8 in a free room" is what this product will do about
 * it today. A reader scanning forty cards should take the first two at a glance
 * and find the third when the number matters to them.
 */
/*
 * AND AT A DESK IT IS TWO LINES, BECAUSE THE THIRD ONE IS PAID FOR BY THE SHELF.
 *
 * 17ch is right on a phone and it is right at 900, and at 1440 it sets the
 * headline in a 501px column inside an 865px one — three lines of 49px with
 * 364px of nothing beside them. MEASURED at 1440x900: the headline is 148px of
 * a 900px screen, the first card starts at 576, and not one Deal button is on
 * the first screen. The comment above `.catalogue`'s column count says four
 * across was chosen so "the first row's buttons [come] back onto the first
 * screen"; that was true of a shelf of seven and has not been true for a while.
 *
 * At 27ch the same sentence balances onto two lines of the width it already
 * had to spare — 99px rather than 148 — and every band below it moves up by
 * the difference. MEASURED across eight widths: the first card starts at 499
 * rather than 576 at 1280 and above, which puts 401px of it on a 900px screen
 * against 324, and the phone is not touched at all.
 *
 * 1100 rather than 40rem because that is where the third line appears: below
 * it the headline is already two lines or fewer and widening the measure would
 * only make the lines uneven.
 *
 * BOTH BLOCKS SIT AFTER `.portal-pitch .hero-title` FOR THE REASON THE BLOCK
 * BELOW GIVES: a media query adds no specificity, so a rule that means to win
 * has to be further down the file. Sixth time.
 */
/*
 * THE BANDS BETWEEN THE HEADLINE AND THE FIRST BOX, AT A DESK.
 *
 * From the header to the first card is 498px at 1440, of which 339 is content
 * — headline, lead, the two mode links, the shelf's own head, five filters —
 * and 159 is air. This takes 28 of the air: the mode links keep `--s2` above
 * and below rather than `--s3`/`--s4`, the shelf's top padding matches the gap
 * it already puts between its own rows, and the finder sits `--s4` off the
 * boxes rather than `--s5`. Nothing is removed and nothing moves closer than
 * the spacing scale allows.
 *
 * **641 AND NOT 40rem.** `@media (max-width: 40rem)` and `@media (min-width:
 * 40rem)` BOTH match at exactly 640px, and the phone's block is the one that
 * wins there — MEASURED: at 640 the shelf's top padding is 8px and the
 * finder's margin is 12, both tighter than anything here. Writing this as
 * `min-width: 40rem` made a 640px window 12px WORSE, which is the seventh time
 * a media query's arithmetic has bitten this file and the first time it has
 * been an inclusive boundary rather than specificity.
 */
/*
 * FIVE ACROSS, ONCE THE FACTS STOP SHARING THE NAME'S LINE — 2026-08-26.
 *
 * Five was tried earlier today and MEASURED OUT: at 246px of card the name and
 * the facts share a row, the name gets 130px of it, and every one of the first
 * five broke onto two lines. That is exactly what the note above `.catalogue`'s
 * column count predicted — "a name and its two facts need 336px of content on
 * one line for the longest of them" — so it was recorded as tried and dropped.
 *
 * What that note assumes is that the two are on one line. They do not have to
 * be. An image2image pass over the shipped shelf came back with the facts moved
 * UNDER the name, which does not shorten the card — it was asked to and it did
 * not — but it does hand the name the whole width, and the whole width is what
 * five across could not give it any other way.
 *
 * The arithmetic, and it is why this is worth two rules: stacked in the corner
 * the facts are a 90px column, so a 246px card leaves the name 130. Under the
 * name they are a ROW — "4-12 PLAYERS · 20 MIN" is 167px of a 214px column, one
 * line — and the name has all 214. MEASURED at 1440, 1600 and 1920: not one of
 * the forty names wraps, where the shipped four-across wraps two of them.
 *
 * The card loses the 40px of lid that a narrower column costs it and gains
 * about 3 for the second row of the head, so:
 *
 *     four across   card 313x477   0 Deal buttons above the fold   shelf 5,672px
 *     five across   card 246x434   5 Deal buttons above the fold   shelf 4,368px
 *
 * Five whole games with their picture, their name, their facts, a sentence and
 * a button, on the first screen, where there were none — and 23% less shelf to
 * walk. The comment above `.catalogue` says four across was chosen so "the
 * first row's buttons [come] back onto the first screen"; this is the first
 * layout since the shelf passed a dozen games that actually does it.
 *
 * 1400 and not 1340, where four across begins: below 1400 the container has not
 * finished growing and a fifth column would take the card under 240px, which is
 * where the blurb starts breaking badly rather than the name.
 *
 * DECLARED LAST, for the reason the phone's section gives.
 */
/* ------------------------------------------------------------------------ *
 * WHAT THIS BROWSER PLAYS
 *
 * Two small marks for the one thing the site remembers about a person, and it
 * remembers it in their own browser — the front door's line naming what is
 * kept, with the switches to carry it to another phone or clear it.
 *
 * The picker has no rules here at all, which took three tries. Every version
 * that MARKED the reordered cards cost height on the one screen whose own note
 * records having none to spare; the front door says "so the shelf puts them
 * first" once, and that is the whole explanation the product needs.
 *
 * Declared at the END of the file, which is the habit this stylesheet has had
 * to learn seven times: a media query adds no specificity, so anything that
 * needs to win has to be last rather than merely more specific-looking.
 * ------------------------------------------------------------------------ */

/*
 * The two controls, on their own row rather than inside the sentence.
 *
 * THE FIRST VERSION PUT THEM INLINE, as `.linkish` — underlined accent words
 * mid-paragraph — and the screenshot settled it. Two things went wrong and only
 * one of them was obvious.
 *
 * The obvious one: `min-height: 44px` on an inline element does not make a
 * bigger target, it makes a taller LINE BOX. The sentence broke into three
 * ragged blocks with " or " and the closing full stop orphaned on their own.
 *
 * The other one is worse. Accent-coloured underlined words at body size were
 * the most saturated thing on the panel, so a footnote about a browser storage
 * key outshouted "Open a room" — the one action this page exists to offer. A
 * privacy note earns its place by being HONEST, not by being loud.
 *
 * So: `.btn--quiet` on a row underneath, which is the idiom this stylesheet
 * already has for exactly this — a real control, dim ink, no fill, and the
 * 44px floor arriving from `.btn` where it works.
 */
/*
 * Smaller than the two lines above it, which is the whole point.
 *
 * It sits under "No sign-ups. No downloads." and its job is to keep that
 * sentence honest, not to compete with it. At body size it was five lines and
 * the largest block in the foot.
 */
/*
 * STACKED, ALWAYS, AND MEASURED INTO RATHER THAN CHOSEN.
 *
 * These sat side by side first, and the widths say why they no longer try. The
 * foot's text column is 208px at 390, 182 at 360 and 142 at 320; the pair comes
 * to 157 + 8 + 43. So it fits on ONE width — 390, with nought pixels to spare —
 * and wraps on the two below it, where at 320 the longer label also breaks
 * across two lines and stands 62px tall on its own.
 *
 * A layout that is correct on one phone and ragged on the rest is not a layout.
 * Stacking always is predictable at every width, aligns the two underlines so
 * they read as a pair rather than as two stray links, and removes a two-pixel
 * cliff that the next word anybody edits would fall off.
 *
 * NOT a `.row`, which is how this is usually written here and would be wrong:
 * `.row` carries `flex-wrap: wrap`, and a container that is both a column and
 * wrapping is what `a-column-does-not-wrap.test.ts` exists to refuse — it
 * spills into a second column at a width nobody tested. The guard caught this
 * one the first time it was written.
 *
 * It costs one row of height, in the page FOOT — which is the one surface here
 * that is not fighting for a fold. The picker was; that is why the mark there
 * was deleted rather than shrunk.
 */
/*
 * Ranged left, like the sentence above them, and in the small face: they are
 * the footnote's controls, not the page's. `.btn` centres its label, which is
 * right for a block button and wrong for a row of quiet ones under prose.
 */
/*
 * The carry link, when a managed browser has refused the clipboard.
 *
 * Selectable and wrappable. A link is one long unbroken token, so without
 * `anywhere` it either overflows its line or forces the page sideways — and
 * this one is being read off one screen and typed into another device, which
 * is the one time the characters have to be legible rather than merely there.
 */
/* ------------------------------------------------------------------------ *
 * THE FRONT DOOR'S HEAD, ON A PHONE
 *
 * MEASURED at 390×844 against the shelf we are compared with: our first card
 * starts at **568px** and theirs at **383**, and three of ours are whole above
 * the fold against four of theirs. That is the one row on the scoreboard where
 * they are ahead and nothing about it is a decision — it is 185px of furniture.
 *
 * A card is 94px with its gap, so a hundred pixels is the difference between
 * three cards and four. That is what this block is for, and every pixel in it
 * comes off padding and nothing else: no text is cut, no control is hidden, and
 * the desk layout above 40rem is untouched.
 *
 * At the END of the file, which is the habit this sheet has been taught five
 * times — a media query adds no specificity.
 * ------------------------------------------------------------------------ */
@media (max-width: 40rem) {
  /*
   * The hero's own frame. `--s5` above the headline is a desk's margin; a phone
   * arriving at a shelf wants the shelf.
   */
  .portal-hero {
    gap: var(--s3);
    padding: var(--s3) var(--s4) var(--s3);
    border-bottom: var(--hair);
  }

  /*
   * "Take a seat" is 63px because `.btn`'s desk padding is generous. The 44px
   * floor is `--tap-min` on `.btn` itself and is not touched here — this only
   * removes the air above it, so the control cannot shrink past the floor
   * however hard this pushes.
   */
  .portal-join .btn {
    padding-block: var(--s2);
  }

  .portal-join {
    gap: var(--s2);
  }

  /*
   * "Got a code?" is a label above a box that already carries the shape of a
   * code as its placeholder, beside a button that says "Take a seat". It costs
   * 28px with its gap and tells a sighted person the third thing they already
   * know from the other two.
   *
   * `visually-hidden`'s own declarations rather than the class, because a class
   * cannot be added from a media query — and hidden rather than removed for the
   * reason `base.css` gives it: the input keeps its name, so the box is still
   * "Got a code?" to anybody listening rather than an unlabelled field.
   */
  .portal-join > .data {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }

}

/*
 * A select drawn as the house draws a field, because the browser's own is the
 * one control on this page that belongs to somebody else's design system —
 * grey, system-font, and sitting on cream paper like a sticker.
 *
 * `appearance: none` takes the native arrow away, so one is drawn back as a
 * background chevron: without it there is nothing to say the pill opens.
 */
.finder-pick select.control {
  appearance: none;
  -webkit-appearance: none;
  width: 100%;
  /* The search field's 16px on the left, so the two rows' text lines up; on
     the right the chevron's ten pixels and a gap. Border and radius are the
     control's own, the same weight as the search field in the row above. */
  padding: var(--s4) var(--s5) var(--s4) var(--s4);
  background-color: var(--panel);
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: right var(--s3) center, calc(100% - var(--s3) + 5px) center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

/*
 * A RING WITH A FALLBACK TO A TOKEN THAT DOES NOT EXIST.
 *
 * This read `var(--focus-ring, 2px solid currentColor)`, and `--focus-ring` is
 * declared nowhere in the product — grepped across `design/`, `lib/`, `shell/`
 * and all thirty-seven game stylesheets. So the fallback was not a fallback, it
 * was the rule: the only two controls on the front door that restate their own
 * focus ring drew it in `currentColor` at 2px, where every other focusable
 * thing takes `--focus` at 3px from `base.css`. Two of the thirteen palettes
 * put the finder's ink close enough to its own field that the ring effectively
 * disappeared into the control it was marking.
 *
 * It is the token, at the system's width. The restatement stays rather than
 * being deleted, because a `<select>` is one of the few elements whose UA
 * stylesheet has its own opinion about focus and beating it explicitly is
 * cheaper than finding out on somebody's machine that it did not.
 */
.finder-pick select.control:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
}

.finder-count {
  /* The gap that splits filters from view controls is at `.finder-pick--sort`
     now; the count rides in the right-hand group with the actions. */
  align-self: center;
  color: var(--dim);
  font-variant-numeric: tabular-nums;
}

.finder-empty {
  margin: 0;
}

/* ---------------------------------------------------------------------------
 * FINDER REWORK — 2026-09-08. Search reads as search, active filters are
 * visible, and one press clears them all.
 *
 * Three changes, all additive to the layout above so the measured geometry
 * (`finder.spec.ts`, `how-much-of-the-shelf.spec.ts`) is untouched: a magnifier
 * in the search field; an accent on any control holding a real answer, so the
 * four dropdowns resting on "Any …" no longer look the same as the two you set;
 * and a Clear that `screens.js` shows only when there is an answer to drop.
 * ------------------------------------------------------------------------- */

/*
 * THE LABELS COME OFF THE DESK, TOO.
 *
 * Five lines of tracked caps — WHAT KIND OF GAME, IN WHAT ORDER, HOW MANY OF
 * YOU, HOW LONG HAVE YOU GOT, HOW MUCH TO SHOW — stood over controls that
 * already say "Any kind", "Shelf order", "Any number". They were the row's
 * clutter and the reason it read as a form rather than a set of chips. The
 * dropdown's own resting text is the label now, which is what the phone has
 * always done; the word survives for a screen reader as the select's `<label>`.
 */
.finder-pick-label {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* One line of pills now, so centre them rather than sitting them on a baseline
   the vanished labels used to set. */
.finder-row {
  align-items: center;
}

/*
 * THE PILLS SIZE TO THEIR ANSWER, NOT TO AN EQUAL SHARE.
 *
 * With visible labels the four filters were held to one width so they read as a
 * form of equal questions (`flex: 1 1 10rem`). As chips they should be as wide
 * as the word inside them and no wider — five 179px blocks were overflowing the
 * desk row into four lines. Content-width, so the row is search + a few small
 * chips with the view controls to the right, on ONE line.
 */
.finder-pick {
  flex: 0 0 auto;
  min-width: 0;
}

/*
 * THE SEARCH AND THE FILTERS ARE THE HOUSE CONTROL, NOT A PILL.
 *
 * design.md: "rounded means a thing your finger operates that is not paper …
 * the controls you press at 6px". These are things you operate, so they are cut
 * at `--r-control`, the same corner as every `.btn` — a set of slate controls,
 * not a bank of rounded pills. Uniform height and the magnifier on the search;
 * its left padding stays well over the 8px the phone spec floors the field at.
 */
.finder-search,
.finder-pick select.control {
  border-radius: var(--r-control);
  min-height: 2.6rem;
}

.finder-search {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%237c8a84' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='10.5' cy='10.5' r='7'/%3E%3Cpath d='M21 21l-5.2-5.2'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: left 0.9rem center;
  background-size: 1.05rem;
  padding-left: 2.6rem;
}

/* A filter you have set is picked out from the four resting on "Any …": an
   accent rim, drawn as a ring so it costs no layout. */
.finder-search[data-active="true"],
.finder-pick select.control[data-active="true"] {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1.5px var(--accent);
}

/*
 * THE VIEW CONTROLS SIT TO THE RIGHT, OFF THE FILTERS.
 *
 * "What order" and "how much to show" are about the SHELF, not the games on it,
 * so they group with the count and the two actions rather than in the run of
 * filters. One auto-margin at the order pick opens the gap; the search keeps its
 * 16rem basis rather than stretching, because an auto-margin takes the row's
 * free space before flex-grow can.
 */
.finder-pick--sort {
  margin-left: auto;
}

/* Clear undoes the filters; it sits with the count and is quiet until there is
   something to clear (`screens.js` toggles `hidden`). */
.finder-clear {
  align-self: center;
  white-space: nowrap;
}

/*
 * A PHONE ASKS THE SAME QUESTIONS IN LESS ROOM — 2026-08-25.
 *
 * ## What a column cost, measured on the front door at 390x844
 *
 * `.finder-row` holds five children — the search, the two questions, the count
 * and "Surprise us" — and a column gives each of them a full-width line:
 *
 *     hero        88px  @94        THE FINDER  334px  @615
 *     join       125px  @297       first card  459px  @989
 *     modes       59px  @446       the fold              844
 *
 * So a phone showed a headline, a paragraph, a code field, a button, two links
 * and four form controls, and NOTHING from the shelf. A desk showed three
 * cards. That is a games product whose phone front door has no games on it, and
 * `tableparty.io` measured in the same browser at the same viewport puts its
 * first tile at 383px with four rows above the fold.
 *
 * ## What is a grid's and what was never the column's fault
 *
 * The column was right about the search, which wants the width, and wrong about
 * everything under it: a dropdown reading "Any number" does not need 322px, and
 * the count is twenty pixels of text. Two columns, the search spanning both,
 * and the count on the line with the button it describes: **334px to 227px**,
 * measured, with 0.0px of overflow at 390 and at 360 and nothing clipped.
 *
 * A version that also HID the two questions and let the dropdowns speak for
 * themselves reaches 190px. It is not taken. "How many of you" and "How long
 * have you got" are the product's voice asking a person something, and a bare
 * pair of dropdowns is any filter bar anywhere; the trade was 37px, and it does
 * not put a card above the fold either — that is the hero's 445px, which is a
 * question about what a front door is for and not one a stylesheet answers.
 *
 * ## Two things this replaced, kept because they are why the shape is what it is
 *
 * `flex: 1 1 16rem` on the search is a basis for the ROW, where it means width.
 * In a column the main axis is vertical and the same declaration asked for a
 * search box SIXTEEN REM TALL — 256px of empty white, measured on a Pixel 7 and
 * an iPhone 13, a quarter of the way down the front door. A grid track cannot
 * make that mistake, which is one reason to prefer it here.
 *
 * And `flex-wrap: nowrap`, which was load-bearing: a WRAPPING column is a
 * multi-line flex container whose line takes its cross size from its widest
 * child rather than from the box — how Bowl of Nouns put *Put it in* 26px past
 * a clipping table (`tests/guards/a-column-does-not-wrap.test.ts`). There is no
 * column here now, so there is nothing for that guard to catch; the hazard is
 * recorded rather than deleted because the next person to reach for
 * `flex-direction: column` under a width query needs to know it.
 */
@media (max-width: 40rem) {
  .finder-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    align-items: end;
  }

  .finder-search {
    grid-column: 1 / -1;
  }

  /* The 11rem floor is the ROW's, where two questions and a search share one
     line and a cramped dropdown would be the search's doing. Here each question
     has half the width by construction and the floor only forces an overflow. */
  .finder-pick {
    min-width: 0;
  }

  /* "How long have you got" does not fit 155px on one line and does not need
     to: the question may take two, and `align-items: end` keeps the two
     dropdowns on the same baseline whether it does or not. */
  .finder-pick-label {
    white-space: normal;
  }

  .finder-pick .control {
    min-width: 0;
    width: 100%;
    text-overflow: ellipsis;
  }

  /* 16px, the smallest a phone will not zoom for, and on the select itself
     because the rule above it says `font: inherit`: at 17px in a 138px pill
     "Shortest first" and "Up to 10 min" did not fit beside the chevron, and
     before the chevron rule reached this class at all, "Any number" and
     "Shelf order" read "Any num…" and "Shelf ord…" (2026-09-07). */
  .finder-pick select.control {
    font-size: 16px;
    /* The phone pill is 44px by rule; the desk's 16px above and below the
       line would leave 8px for the letters. The sides are in pixels like the
       letters: with the padding in rem, a reader at 125% text got a wider
       pad around a 16px label and "Shelf ord…" back (2026-09-07). */
    padding: 0 24px 0 10px;
  }

  .finder-count {
    margin-left: 0;
    align-self: center;
    justify-self: start;
  }

  .finder-surprise {
    justify-self: end;
  }
}
/*
 * THIS BRACE WAS MISSING, AND EVERYTHING BELOW IT WAS A PHONE RULE.
 *
 * A stylesheet with one unclosed block does not fail: the parser swallows the
 * rest of the file into it and every browser agrees. So the thirteen rules
 * under this line — the front door's hand-off pair, the room's television
 * link, the host's star, "Show out", and the notice a departure prints —
 * were declared once, indented at column 0 like the top-level rules they were
 * written as, and served to nobody wider than 40rem.
 *
 * What that looked like on a desk, measured before it was closed: the two
 * hand-off links on the front door in `rgb(125, 19, 46)` — the accent, because
 * `--dim` never reached them — at body size rather than small, jammed against
 * the shortcut above with no margin, and reading
 * "One phone between you?·Put it on the big screen" with no space either side
 * of the separator, because the `gap` that makes that space is a flex property
 * and the `display: flex` was in here too. At 960px the second label broke
 * across two lines mid-phrase for the same reason.
 *
 * `tests/guards/stylesheets-close.test.ts` counts the braces in every
 * stylesheet now. It is a fault that cannot be seen by reading the rule — the
 * rule is correct — only by counting, which is a machine's job.
 */

/*
 * The two rooms that are not one-person-one-device, on the front door.
 *
 * A quiet line under the join shortcut: prose you can click, in the body face,
 * because "One phone between you?" is a question rather than an instruction to
 * the machine, and tracked mono caps would make an aside shout.
 */
/*
 * THE TWO MODES: ONE ELEMENT, TWO PLACES.
 *
 * `screens.js` renders this as a sibling of the shelf rather than a child of
 * the hero, and the note there says why — on a phone it was 71px directly above
 * the shelf, which is a whole game card on the one screen where every pixel
 * above the games is rent.
 *
 * A phone gets the DOM order as written: hero, shelf, then these. A desk has no
 * fold to defend, so `order` puts them back between the hero and the shelf —
 * `.portal-scroll` is already a flex column, so this costs one property and no
 * second copy of the markup. The gutter comes with it: inside the hero it took
 * the hero's padding, and out here it has to bring its own.
 */
.portal-modes {
  padding-inline: var(--s5);
  margin-top: var(--s3);
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--s3);
  font-size: var(--t-small);
}

.portal-modes a {
  color: var(--dim);
  text-decoration-color: var(--rule-strong);
  text-underline-offset: 0.25em;
}

@media (hover: hover) {
  .portal-modes a:hover {
    color: var(--on-table-strong);
    text-decoration-color: currentColor;
  }
}

/* `.portal-modes-sep` was here: the front door's separator, which moved to
   `.fd-foot-sep` in `front.css` with the 2026-09 redesign. A rule for a class
   nothing carries is a colour nobody can check. */

/* The room's own way to open a television: quiet, under the code, because the
   code is the thing being read aloud and this is the aside beside it. */
.share-tv {
  align-self: flex-start;
}

/* ------------------------------------------------------------ the host */

/*
 * A STAR BESIDE THE NAME, AND THE WORD STILL AT THE END OF THE ROW.
 *
 * "host" in the tag slot was the same weight as "away" and "left" — three
 * states of a seat, except that one of them is not a state at all but a role,
 * and it is the only one anybody needs to find at a glance. So the mark goes
 * where a role goes, beside the name, and the word stays where it was: a glyph
 * on its own is a rebus, and this interface already spends its one exemption on
 * the connection pill.
 *
 * Ink rather than colour, on purpose. `--stamp` marks a person the *game* is
 * singling out — whose turn it is, who stands accused — and the host is a
 * property of the room. Borrowing the accusation colour for it would put a red
 * mark next to somebody's name for the crime of having opened the room.
 */
.seat-name {
  display: inline-flex;
  align-items: baseline;
  gap: var(--s2);
  min-width: 0;
}

/* The ellipsis belongs to the text, not to the box the star shares with it: a
   flex container cannot truncate, so the rule moved inwards when the star
   arrived. Names are people own and some of them are long. */
.seat-name-text {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.seat-star {
  flex: none;
  font-size: 0.8em;
  line-height: 1;
  color: var(--on-table-strong);
}

/*
 * The way to take a chair back.
 *
 * Under the row rather than beside it: the roster column is 340px at a desk and
 * a good deal less on a phone, the names in it are people's own and may be
 * long, and a control competing with a name for the same line is a control
 * that either wraps the name or gets clipped. Under it, the row is untouched
 * and the quiet variant's hairline reads as belonging to the seat above.
 */
.seat-out {
  padding: 0 var(--s4) var(--s2);
}

.seat-out .btn--quiet {
  font-size: var(--t-data-sm);
}

/*
 * Asking is a different object from offering, and it should look like one.
 *
 * The same treatment the round-ending confirm takes: a raised field, the
 * consequence in prose at reading size, and the destructive answer as the
 * primary — because a confirm whose safe answer is the loud one is a confirm
 * people click through to get rid of.
 */
.seat-out--asking {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  margin: 0 var(--s3) var(--s3);
  padding: var(--s4);
  background: var(--panel);
  border: var(--rim-hair) solid var(--rim);
  border-radius: var(--r-panel);
}

/*
 * What happened to the room you were in.
 *
 * At the very top of the front door and in the flow rather than docked: it is
 * not an offer, it is the answer to "why am I looking at this page", and the
 * question is asked in the first second. It is stock, because it is a printed
 * notice and every printed thing here is.
 */
.departed {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  margin: 0 0 var(--s5);
  padding: var(--s5);
  background: var(--stock);
  color: var(--ink);
  border-left: 3px solid var(--stamp);
}

.departed .data {
  color: var(--ink);
}

/* ===========================================================================
 * THE FRONT DOOR, ON A PHONE — declared last, because order is the only thing
 * that decides these
 * ===========================================================================
 *
 * Every rule below matched from where it was first written and lost anyway. A
 * media query adds NO SPECIFICITY, so `@media … { .portal-modes { … } }` at line
 * 6950 loses to a plain `.portal-modes` at 8711 — and this file already has a
 * paragraph warning about exactly that, which I read and then walked into four
 * times in one afternoon. At the end of the stylesheet nothing can be later.
 *
 * WHY, MEASURED at 390x844 against Table Party's front door, whose first game
 * card is at y=448 with four above the fold. Ours was at y=797 with one, on a
 * budget of 78px of header, 437 of hero, 26 of shelf head and 190 of finder.
 */
@media (max-width: 40rem) {
  /*
   * The lead steps down. A supporting paragraph at body size under a 31px
   * headline is a desk's proportion; here it was four lines and 95px. This is
   * the size the same file already steps the lead to when the phone is turned
   * sideways, so the precedent is its own.
   *
   * The HEADLINE is untouched. It is the sentence that says what this is, and
   * the case for shrinking it would be a metric rather than a reader.
   */
  .portal-pitch .lead {
    font-size: var(--t-small);
  }

  /* The modes are the third block in a stack of three, and the stack has a gap
     of its own already. */
  .portal-modes {
    margin-top: 0;
  }

  /*
   * THE FINDER IS A GRID ON A PHONE, AND THAT IS THE POINT.
   *
   * It was three flex lines — the search, then the two questions, then
   * "Surprise us" — 190px deep before a single game was shown, on a screen
   * whose first card sat below the fold.
   *
   * TEN ATTEMPTS TO CLOSE IT WITH FLEX FAILED, and every one of them applied:
   * `flex: 1 1 128px` on the search, `flex: 1 1 0` and `min-width: 0` on the
   * questions, `min-width: 0` on the `<select>` inside them, `order` to move
   * the surprise up — all confirmed in computed styles, and the row stayed
   * three lines every time. By the spec those base sizes sum to 249 of the
   * available 322 and belong on one line. They did not.
   *
   * So this stops arguing with the line-breaking and states the layout: two
   * rows, named. Grid does not infer where things go, which is exactly what was
   * wanted after the tenth try.
   *
   * The count is the one thing dropped, and only here: it says "39 games" under
   * a headline reading "39 short games for the Friday coffee break". With flex
   * it bought nothing because the surprise held that line anyway; with the
   * surprise moved up beside the search, it is the difference between two rows
   * and three.
   */
  .finder-row {
    display: grid;
    /*
     * THREE ROWS OF TWO, and the shape is arithmetic rather than taste: five
     * controls in two columns is three rows however they are arranged, and
     * three columns at 322px gives each question 99px, where every one of them
     * reads "Any …" — a filter whose value you cannot see is worse than none.
     *
     * THE SEARCH SHARES ITS ROW AGAIN, and this time there is a reason. With
     * three questions the row was free, so the search took a whole one rather
     * than truncate its placeholder to "Search the sh". With four there is no
     * free row: search alone makes it 1 + 2 + 1 = four rows, and the finder
     * sits ABOVE the shelf, so a fourth row is paid for in the one number this
     * front door is measurably behind on — how far down the first card starts.
     * A hint that reads short is a smaller loss than a card pushed off the
     * fold, so the placeholder is now short enough not to need the width.
     *
     * THE TWO COUNTS KEEP THEIR OWN LINE, which `finder.spec.ts` asserts to
     * within two pixels and was right to: "how many of you" and "how long have
     * you got" are the same question asked twice, about the ROOM, and reading
     * them as a pair is most of what makes them quick to answer. Pairing the
     * new question with them instead split them across lines and the spec
     * caught it — 601px and 673px. The kind is about the GAME, so it sits with
     * the surprise, which is the other control that is.
     *
     * (The third question is new — see `screens.js`. Before it, "party" matched
     * NOTHING on a shelf of thirty-nine party games.)
     */
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    /*
     * AND THE FOUR QUESTIONS STAY FOUR ROWS — TRIED 2026-08-29, REVERTED.
     *
     * The obvious saving, and an image2image reference for this screen proposed
     * it independently: fold `seats`/`minutes`/`kind`/`order` into one closed
     * disclosure carrying its own answers, the shape `customize.js` argues for
     * in the lobby. Built, and measured on a live front door at 390x844:
     *
     *   first card top   449px -> 393px
     *   whole cards above the fold   4 -> 4
     *
     * The second line is the one that matters and it did not move. A card is
     * about 187px here, so 56px buys most of a gap and none of a card — while
     * costing every person who filters on a phone a press to reach the control
     * they came for, and eight tests in `webkit-portrait`, which runs the whole
     * of `finder.spec.ts` at this size.
     *
     * So it is not that the fold is wrong. It is that on THIS shelf the row it
     * saves is not worth a press, and the number the front door is judged on is
     * whole cards. Worth re-testing only if a card gets materially shorter.
     */
    grid-template-areas:
      "search  surprise"
      "seats   minutes"
      "kind    order"
      "count   clear";
    align-items: end;
  }

  /*
   * THE COUNT TAKES ITS ROW ONLY WHEN IT HAS SOMETHING TO SAY — 2026-08-27.
   *
   * It was `display: none` here, full stop, so the one number that tells you a
   * filter did anything was visible on a desk and hidden on the phone this
   * product is built for. Their shelf shows it at 390 and ours did not.
   *
   * A fourth row costs 26px and the note above is right that the fold is what
   * pays. So it is not a fourth row at rest: `screens.js` sets `data-narrowed`
   * when the shelf has actually been cut down, and an unnarrowed shelf keeps
   * every pixel it had. `grid-template-areas` names a row that collapses to
   * nothing while its only child is out.
   */
  .finder-count {
    grid-area: count;
  }

  .finder-count[data-narrowed="false"] {
    display: none;
  }

  /* Clear shares the count's row (right half), so undoing a filter sits with the
     count that told you the filter did something. It collapses the row with the
     count when there is nothing to clear — `hidden` on the button, `display:none`
     on the count — so a resting finder keeps every pixel. */
  .finder-clear {
    grid-area: clear;
    justify-self: end;
    align-self: center;
  }

  .finder-search {
    grid-area: search;
    min-width: 0;
  }

  /*
   * BY NAME, NOT BY COUNT. These were `:nth-of-type(1|2|3)`, which counts
   * labels among siblings — so the fourth question renumbered the other three
   * and put "how many of you" where the kind had been. `screens.js` now writes
   * the key into the class.
   */
  .finder-pick--kind {
    grid-area: kind;
  }

  .finder-pick--sort {
    grid-area: order;
  }

  .finder-surprise {
    grid-area: surprise;
  }

  .finder-pick--seats {
    grid-area: seats;
  }

  .finder-pick--minutes {
    grid-area: minutes;
  }

  .finder-pick {
    min-width: 0;
  }

  .finder-pick > .control {
    min-width: 0;
    width: 100%;
  }


  /*
   * AND THE JOIN FORM STATES ITS TWO ROWS TOO.
   *
   * It was a wrapping row and wrapped into three lines' worth of height: the
   * question on one, the code box beside it, and "Take a seat" pushed onto its
   * own — 125px for three controls that are one action. Named rows put the
   * question over the pair that answers it, which is also how it reads aloud:
   * "Got a code? [______] [Take a seat]".
   */
  .portal-join {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas:
      "say  say"
      "code seat";
    align-items: center;
  }

  .portal-join > .data {
    grid-area: say;
  }

  .portal-code {
    grid-area: code;
  }

  .portal-join > .btn {
    grid-area: seat;
    justify-self: start;
  }

  /*
   * THE FINDER LOSES ITS LABELS AND ITS SPARE HEIGHT — 2026-08-26, measured
   * against theirs.
   *
   * Their front door was driven at 390x844 the same day, and the numbers are
   * the argument. Above their first card: a 65px hero, a 58px "join a friend's
   * game" row, and **119px of filtering** — a search field with an icon button
   * beside it, then one row of pills reading "Players · Type · Sort" that
   * expand inline when pressed. First card at y=383, four whole cards above the
   * fold.
   *
   * Ours: a 383px hero, and **206px of filtering**. The hero is the product's
   * whole case and is the owner's to spend. The 206 is not.
   *
   * MEASURED, and the first guess was wrong. I took the four labels off
   * expecting the height to fall, and it fell **eight pixels**: the search
   * field stood 62px and each dropdown 60, so three lines of controls were 198
   * of the 206. The labels were never the block. `--tap-min` is 44 and is the
   * floor for everything a finger aims at here; a 60px dropdown is not more
   * reachable than a 44px one, it is only taller. Theirs are 42.
   *
   * The labels stay off anyway, because each dropdown already says what it is —
   * "Any number", "Any length", "Any kind" and "Shelf order" all stand on their
   * own — and they are clipped rather than deleted, so the control keeps its
   * name for anybody having the page read to them.
   *
   * THE GRID STAYS, and that is the part I tried to throw away. A wrapping row
   * of pills is the obvious shape and it measured 8px shorter, and
   * `finder.spec.ts` refused it: it holds "how many of you" and "how long have
   * you got" to within two pixels of each other, and the note beside the grid
   * says why — they are the same question asked twice, about the ROOM, and
   * reading them as a pair is most of what makes them quick to answer. Wrapping
   * put the kind and the order between them. The spec was right, and eight
   * pixels were not worth the pair.
   */
  .finder-pick-label {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }

  .finder-search,
  .finder-pick .control {
    block-size: var(--tap-min);
    min-height: var(--tap-min);
    padding-block: 0;
  }

  /*
   * AND THE AIR BETWEEN THE HEAD, THE FINDER AND THE CARDS, WHICH WAS SIXTY
   * PIXELS.
   *
   * `.portal-shelf` stacks the three on a `--s4` gap over `--s5` of its own top
   * padding: 16px above the head, 16 below it and 28 above the first card, on
   * the one screen where the shelf starts below the fold. The hero above
   * already ends on its own padding, so the top helping is a second serving of
   * the same gap.
   *
   * It is the argument `.catalogue` already settled for the gap BETWEEN cards
   * on a phone — *"a card is 126px and we were spending 160 per game, so 34 of
   * every 160 was air"* — applied to the three blocks above them rather than
   * the forty below.
   *
   * Together with the shorter controls: the finder is 156px where it was 206,
   * and the first card was at y=751 this morning and is at **678** now — 73px
   * up, which is most of a card and not quite a whole one. A second whole card
   * needs y=664, and taking the last fourteen pixels out of a gap to cross a
   * threshold would be the arithmetic serving the scoreboard rather than the
   * page. Their four above the fold needs the hero, which is 383px
   * against their 65 and is the only thing left in this column that is not air.
   */
  .portal-shelf {
    gap: var(--s2);
    padding-top: var(--s2);
  }
}

/*
 * The seat ceiling on a card, which is a caveat and not a fact of the game.
 *
 * Set quieter than the two facts it follows: "4–12 players" and "20 min" are
 * what the game IS, and "8 in a free room" is what this product will do about
 * it today. A reader scanning forty cards should take the first two at a glance
 * and find the third when the number matters to them.
 */
.fact--limit {
  color: var(--dim);
  font-style: italic;
}

/*
 * AND AT A DESK IT IS TWO LINES, BECAUSE THE THIRD ONE IS PAID FOR BY THE SHELF.
 *
 * 17ch is right on a phone and it is right at 900, and at 1440 it sets the
 * headline in a 501px column inside an 865px one — three lines of 49px with
 * 364px of nothing beside them. MEASURED at 1440x900: the headline is 148px of
 * a 900px screen, the first card starts at 576, and not one Deal button is on
 * the first screen. The comment above `.catalogue`'s column count says four
 * across was chosen so "the first row's buttons [come] back onto the first
 * screen"; that was true of a shelf of seven and has not been true for a while.
 *
 * At 27ch the same sentence balances onto two lines of the width it already
 * had to spare — 99px rather than 148 — and every band below it moves up by
 * the difference. MEASURED across eight widths: the first card starts at 499
 * rather than 576 at 1280 and above, which puts 401px of it on a 900px screen
 * against 324, and the phone is not touched at all.
 *
 * 1100 rather than 40rem because that is where the third line appears: below
 * it the headline is already two lines or fewer and widening the measure would
 * only make the lines uneven.
 *
 * BOTH BLOCKS SIT AFTER `.portal-pitch .hero-title` FOR THE REASON THE BLOCK
 * BELOW GIVES: a media query adds no specificity, so a rule that means to win
 * has to be further down the file. Sixth time.
 */
@media (min-width: 1100px) {
  .portal-pitch .hero-title {
    max-width: 27ch;
  }
}

/*
 * THE BANDS BETWEEN THE HEADLINE AND THE FIRST BOX, AT A DESK.
 *
 * From the header to the first card is 498px at 1440, of which 339 is content
 * — headline, lead, the two mode links, the shelf's own head, five filters —
 * and 159 is air. This takes 28 of the air: the mode links keep `--s2` above
 * and below rather than `--s3`/`--s4`, the shelf's top padding matches the gap
 * it already puts between its own rows, and the finder sits `--s4` off the
 * boxes rather than `--s5`. Nothing is removed and nothing moves closer than
 * the spacing scale allows.
 *
 * **641 AND NOT 40rem.** `@media (max-width: 40rem)` and `@media (min-width:
 * 40rem)` BOTH match at exactly 640px, and the phone's block is the one that
 * wins there — MEASURED: at 640 the shelf's top padding is 8px and the
 * finder's margin is 12, both tighter than anything here. Writing this as
 * `min-width: 40rem` made a 640px window 12px WORSE, which is the seventh time
 * a media query's arithmetic has bitten this file and the first time it has
 * been an inclusive boundary rather than specificity.
 */
@media (min-width: 641px) {
  .portal-modes {
    margin-top: var(--s2);
    margin-bottom: var(--s2);
  }

  .portal-shelf {
    padding-top: var(--s4);
  }


  /*
   * AND A QUESTION THAT WRAPS DOES NOT THEN TAKE THE WHOLE ROW.
   *
   * `.finder-pick` is `flex: 1 1 10rem` so the four questions share the line
   * evenly, and flex grows per LINE: the moment one of them wraps it is the
   * only item on its line and grows to all of it. MEASURED at 1024x768 — a
   * common laptop, and the width where the search's 16rem plus four 10rem
   * questions plus the count and the surprise first stop fitting: "How long
   * have you got" dropped to a row of its own and drew a **720px dropdown**
   * under four 176px ones.
   *
   * A cap rather than a grid, because the row is right when it fits and the
   * fault is only what happens when it does not. 16rem is above the widest the
   * questions ever reach when they share a line (211px, the minutes list), so
   * nothing changes at 1280 and above; below that a wrapped question is the
   * size of a question instead of the size of the page.
   */
  .finder-pick {
    max-width: 16rem;
  }
}

/*
 * FIVE ACROSS, ONCE THE FACTS STOP SHARING THE NAME'S LINE — 2026-08-26.
 *
 * Five was tried earlier today and MEASURED OUT: at 246px of card the name and
 * the facts share a row, the name gets 130px of it, and every one of the first
 * five broke onto two lines. That is exactly what the note above `.catalogue`'s
 * column count predicted — "a name and its two facts need 336px of content on
 * one line for the longest of them" — so it was recorded as tried and dropped.
 *
 * What that note assumes is that the two are on one line. They do not have to
 * be. An image2image pass over the shipped shelf came back with the facts moved
 * UNDER the name, which does not shorten the card — it was asked to and it did
 * not — but it does hand the name the whole width, and the whole width is what
 * five across could not give it any other way.
 *
 * The arithmetic, and it is why this is worth two rules: stacked in the corner
 * the facts are a 90px column, so a 246px card leaves the name 130. Under the
 * name they are a ROW — "4-12 PLAYERS · 20 MIN" is 167px of a 214px column, one
 * line — and the name has all 214. MEASURED at 1440, 1600 and 1920: not one of
 * the forty names wraps, where the shipped four-across wraps two of them.
 *
 * The card loses the 40px of lid that a narrower column costs it and gains
 * about 3 for the second row of the head, so:
 *
 *     four across   card 313x477   0 Deal buttons above the fold   shelf 5,672px
 *     five across   card 246x434   5 Deal buttons above the fold   shelf 4,368px
 *
 * Five whole games with their picture, their name, their facts, a sentence and
 * a button, on the first screen, where there were none — and 23% less shelf to
 * walk. The comment above `.catalogue` says four across was chosen so "the
 * first row's buttons [come] back onto the first screen"; this is the first
 * layout since the shelf passed a dozen games that actually does it.
 *
 * 1400 and not 1340, where four across begins: below 1400 the container has not
 * finished growing and a fifth column would take the card under 240px, which is
 * where the blurb starts breaking badly rather than the name.
 *
 * DECLARED LAST, for the reason the phone's section gives.
 */
/*
 * THE TABLES THIS PHONE HAS SAT AT.
 *
 * `tables.js` and `screens.js` carry the argument. This is a list that most
 * visitors never see: it renders nothing at all until somebody has kept a
 * table, so its cost to the front door's fold is nought for a first visit and
 * one row for the people it is for.
 *
 * A row is a link and a way to let go, and the link is the whole row rather
 * than the code inside it — the target is the thing being aimed at, and a
 * six-character code is not a target.
 */
.portal-tables {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  margin-top: var(--s3);
}

.portal-tables-say {
  color: var(--on-table);
}

.portal-tables-list {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--s2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.portal-table {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  flex: 1 1 20rem;
  min-width: 0;
  max-width: 100%;
  align-items: stretch;
  gap: var(--s1);
}

.portal-table-go {
  min-width: 0;
  text-align: left;
  display: flex;
  flex-direction: column;
  align-items: start;
  justify-content: center;
  gap: 1px;
  /* The floor, and the reason this is a stacked block rather than one line:
     a code and its date side by side set to 28px of type on a phone. */
  min-height: 44px;
  padding: var(--s2) var(--s3);
  text-decoration: none;
}

.portal-table-code {
  overflow-wrap: anywhere;
  white-space: normal;
  font-family: var(--font-body);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: normal;
}

.portal-table-when {
  font-size: var(--t-data-sm);
  /* On the button's own fill, so it is cut against that and not against the
     page — the distinction this stylesheet has spent an evening on. */
  opacity: 0.78;
}

/*
 * Somebody is at that table. Full strength against the button's own fill,
 * because this is the one thing on the row that is true RIGHT NOW and it is
 * what the row is being read for.
 */
.portal-table-live {
  font-size: var(--t-data-sm);
}

.portal-table-live[data-there="true"] {
  font-weight: 600;
}

.portal-table-drop {
  min-height: 44px;
  padding: 0 var(--s2);
  font-size: var(--t-data-sm);
}

/*
 * HOW MUCH OF THE SHELF TO SHOW AT ONCE — 2026-08-27.
 *
 * `screens.js` carries the argument for why this exists and why it is desk
 * only. This is the drawing of it.
 *
 * OUT OF THE WAY BY DEFAULT. Below three columns the shelf is not dense enough
 * for density to be a question, and on the phone the finder is three rows of
 * two by named grid areas — an unplaced sixth child would auto-place itself
 * into that and break the layout, quite apart from costing the fold a row. So
 * it is `display: none` until the grid is wide enough to have the problem.
 */
.finder-pick--density {
  display: none;
}

@media (min-width: 1020px) {
  .finder-pick--density {
    display: flex;
  }
}

/*
 * Two buttons that share an edge, drawn as one object, because they are one
 * question with two answers. `aria-pressed` carries the state rather than a
 * class, so the answer is in the accessibility tree and not only in the paint.
 */
/*
 * A HAIRLINE OF DAYLIGHT, BECAUSE SHELL FURNITURE DOES NOT TOUCH — 2026-08-28.
 *
 * These two were drawn as a segmented control, sharing an edge the way a
 * segmented control does, and `shell.spec.ts` failed them: *"finder-density-roomy
 * and finder-density-compact sit 0px apart on the same line"*. Its rule is not
 * a style preference and it is argued for at length — *"out here there are no
 * fans: the rail, the roster, the invitation and the way out are furniture,
 * laid out in rows, and two pieces of furniture touching is always wrong"* —
 * the fan exception being for a game's own hand inside `.panel-root`, which
 * this is not.
 *
 * Two pixels was the first answer and it was still a collision: the check
 * clears at `worst >= 3`, so two is a gap the guard is right not to count. The
 * smallest spacing token is `--s1`, a quarter rem — four pixels at the default
 * root, comfortably over the line and a real number from the scale rather than
 * one picked to squeak past a threshold.
 *
 * They still read as one object: same height, same shape, one label above them.
 */
.finder-density {
  display: flex;
  gap: var(--s1);
}

.finder-density-opt {
  /* The floor, and it is the reason this is not a pair of chips. */
  min-height: 44px;
  padding: 0 var(--s3);
  border: 1px solid var(--rule-strong);
  background: var(--panel);
  color: var(--on-table);
  font: inherit;
  font-size: var(--t-small);
  cursor: pointer;
}

.finder-density-opt:first-child {
  border-radius: var(--r-control) 0 0 var(--r-control);
}

.finder-density-opt:last-child {
  border-radius: 0 var(--r-control) var(--r-control) 0;
}

/* The chosen half is filled in the page's own ink rather than the loud primary
   — density is a view preference, not the row's headline, so it reads as "this
   one" without shouting over the filters beside it. */
.finder-density-opt[aria-pressed="true"] {
  background: var(--ink);
  border-color: var(--ink);
  color: var(--panel);
}

/*
 * THE COMPACT SHELF.
 *
 * Nine and a half rem is the number the fold chose. At 1440 the row is eight
 * columns, the card lands near 215px, and TWO rows finish above 900 — sixteen
 * whole cards against the nine we are compared with, and unlike those nine
 * every one of them still carries the control that deals it.
 *
 * The blurb is the only thing that goes. It is three clamped lines of caption
 * and it is the one part of the card that cannot be scanned — the picture, the
 * name, the two facts and the button all can, and all four stay. Somebody who
 * wants the sentence has the roomy shelf one press away, and the box still
 * turns over to the full explainer either way.
 */
@media (min-width: 1020px) {
  .catalogue[data-density="compact"] {
    grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
    gap: var(--s3);
  }

  .catalogue[data-density="compact"] .game-card-blurb {
    display: none;
  }

  .catalogue[data-density="compact"] .game-card-body {
    padding: var(--s2);
    gap: var(--s2);
  }

  /*
   * WHAT THE FOLD ACTUALLY COSTS, ITEM BY ITEM.
   *
   * Hiding the blurb alone left the card at 317px and eight whole cards above
   * the fold — one SHORT of the nine we are compared with, which would have
   * been a worse outcome than not trying. Measured, the body was 212 of it:
   *
   *   the name wrapping to two lines at 137px wide          42
   *   "Deal <game>" wrapping to two lines                   74
   *   the second, quiet control under it                    44
   *
   * The name and the button are set one step down so each fits its line. The
   * quiet control goes, and it is the only thing here that does: it opens the
   * explainer, and the PICTURE opens the explainer too — `screens.js` says so
   * beside the lid, which is a button for exactly that reason. Nothing becomes
   * unreachable; one of two ways in is put away at the density where the room
   * for it is what is being spent.
   *
   * The lid takes 16:9 rather than 3:2. It is the cheapest 16px on the card and
   * it is a crop, not a shrink — `object-fit: cover` was already deciding what
   * to keep.
   */
  .catalogue[data-density="compact"] .lid {
    aspect-ratio: 16 / 9;
  }

  .catalogue[data-density="compact"] .game-title {
    font-size: var(--t-small);
  }

  .catalogue[data-density="compact"] .game-card-deal {
    font-size: var(--t-small);
  }

  .catalogue[data-density="compact"] .game-card-actions > .btn--quiet {
    display: none;
  }

  /*
   * THE FACTS STACK, BECAUSE THEY WERE HANGING OFF THE CARD.
   *
   * `.game-card-facts` is `white-space: nowrap; flex: none`, which is right on
   * a 246px card and overflows a 155px one: measured **175px of facts in a
   * 137px body**. The tracking that makes them read as a caption beside a name
   * is 8px of that, and it is not needed when they are on their own line.
   */
  .catalogue[data-density="compact"] .game-card-head {
    flex-direction: column;
    align-items: start;
    gap: 2px;
  }

  .catalogue[data-density="compact"] .game-card-facts {
    flex-direction: row;
    gap: var(--s2);
    letter-spacing: 0;
    padding-top: 0;
  }

  /*
   * AND THE UNIT WORDS STEP OUT OF THE WAY WITHOUT LEAVING.
   *
   * Stacking the facts fixed the overflow and cost 17px, which put the second
   * row back under the fold — the fix undoing the feature. Shrinking the type
   * to reach 137px means about 9.5px of caption, which is not a size to set
   * anything a person is meant to read.
   *
   * So the words go and the facts stay: each is an icon, a number and a unit,
   * and at this size the icon is already saying which unit it is. `players` and
   * `min` are `visually-hidden` and NOT `display: none` — the same distinction,
   * for the same reason, as `.finder-pick-label` on a phone further down this
   * file. A screen reader is told exactly what it was told before; only the eye
   * gives up the word it was not reading.
   *
   * 160px of facts in a 137px body becomes about 100.
   */
  /*
   * AND THEN THE CARD WAS COMPOSED RATHER THAN TRIMMED — 2026-08-28.
   *
   * Everything above this point is subtraction: the blurb goes, the type steps
   * down, two words stop being printed. It measured clean and it looked like
   * what it was — a roomy card with things taken out of it, a picture with a
   * left-aligned button under it and dead space beside the button.
   *
   * The screen was shot and handed to image-to-image with a brief about how a
   * row of eight should scan as a SHELF rather than as eight separate posters.
   * Three things came back that are about composition rather than colour, which
   * is the only kind of answer worth taking from a picture — `imgedit.py` says
   * so in its own header: a reference to implement in CSS, never a bitmap to
   * ship. Its palette and its invented artwork are ignored.
   *
   *   1. the picture, the name, the facts and the action are four BANDS with a
   *      hairline between them, which is what makes eight of them read as one
   *      shelf instead of eight objects;
   *   2. the two facts share a band and split it down the middle, so the width
   *      the card has is used by the thing that has least to say;
   *   3. the action spans the card. A pill with dead space beside it is a
   *      button that looks optional at this size.
   *
   * The padding moves from the body to each band, because a hairline has to
   * reach both edges of the card and a padded parent stops it short.
   */
  .catalogue[data-density="compact"] .game-card-body {
    padding: 0;
    gap: 0;
  }

  .catalogue[data-density="compact"] .game-card-head {
    padding: 6px var(--s2);
  }

  .catalogue[data-density="compact"] .game-card-facts {
    justify-content: center;
    gap: 0;
    width: 100%;
    padding: 4px var(--s2);
    border-top: 1px solid var(--rule);
  }

  /* Each fact takes half the band, and the rule between them is the second
     one's leading edge rather than an element nobody can see. */
  .catalogue[data-density="compact"] .game-card-facts > .fact {
    flex: 1 1 0;
    justify-content: center;
  }

  .catalogue[data-density="compact"] .game-card-facts > .fact + .fact {
    border-left: 1px solid var(--rule);
  }

  .catalogue[data-density="compact"] .game-card-actions {
    width: 100%;
    padding: 6px var(--s2);
    border-top: 1px solid var(--rule);
  }

  /*
   * THE BANDS COST FOURTEEN PIXELS AND THERE WERE ONLY FIFTEEN — 2026-08-28.
   *
   * Composing the card took it from 202px to 216, which still put sixteen above
   * a 900px fold **on the long-running server this was measured against** — by
   * 885 to 900. Fifteen pixels is not margin, and the guard proved it on the
   * first run against a freshly built one: the same shelf came out at EIGHT.
   *
   * A number that holds on one machine and not the next was never a
   * measurement, and the honest repair is height rather than a lowered
   * threshold. The band is 69px of which the button is 52 — `--btn`'s
   * `min-height: 3.25rem`, which is the right size for the primary action on a
   * page and two sizes too big for one of sixteen tiles.
   *
   * 44px, which is the floor rather than a guess: it is the smallest a control
   * is allowed to be and it is still a target a thumb takes. With six pixels of
   * band either side of it the card comes back under 200 and the second row
   * clears the fold with room to lose.
   */
  .catalogue[data-density="compact"] .game-card-deal {
    width: 100%;
    justify-content: center;
    min-height: 44px;
    padding: var(--s2) var(--s3);
  }

  .catalogue[data-density="compact"] .fact-word,
  .catalogue[data-density="compact"] .game-card-deal-name {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

@media (min-width: 1400px) {
  .catalogue {
    grid-template-columns: repeat(5, minmax(0, 1fr));
  }

  /* The name takes the line; the facts take the one under it. */
  .catalogue .game-card.game-card .game-card-head {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--s1);
  }

  /* And they are a row there, not the corner column they are at four across:
     `align-items: end` and the 2px optical nudge are both about ranging right
     against a name beside them, and there is no name beside them here. */
  .catalogue .game-card.game-card .game-card-facts {
    flex-direction: row;
    align-items: baseline;
    gap: var(--s3);
    padding-top: 0;
  }
}

/* ------------------------------------------------------------------------ *
 * WHAT THIS BROWSER PLAYS
 *
 * Two small marks for the one thing the site remembers about a person, and it
 * remembers it in their own browser — the front door's line naming what is
 * kept, with the switches to carry it to another phone or clear it.
 *
 * The picker has no rules here at all, which took three tries. Every version
 * that MARKED the reordered cards cost height on the one screen whose own note
 * records having none to spare; the front door says "so the shelf puts them
 * first" once, and that is the whole explanation the product needs.
 *
 * Declared at the END of the file, which is the habit this stylesheet has had
 * to learn seven times: a media query adds no specificity, so anything that
 * needs to win has to be last rather than merely more specific-looking.
 * ------------------------------------------------------------------------ */

/*
 * The two controls, on their own row rather than inside the sentence.
 *
 * THE FIRST VERSION PUT THEM INLINE, as `.linkish` — underlined accent words
 * mid-paragraph — and the screenshot settled it. Two things went wrong and only
 * one of them was obvious.
 *
 * The obvious one: `min-height: 44px` on an inline element does not make a
 * bigger target, it makes a taller LINE BOX. The sentence broke into three
 * ragged blocks with " or " and the closing full stop orphaned on their own.
 *
 * The other one is worse. Accent-coloured underlined words at body size were
 * the most saturated thing on the panel, so a footnote about a browser storage
 * key outshouted "Open a room" — the one action this page exists to offer. A
 * privacy note earns its place by being HONEST, not by being loud.
 *
 * So: `.btn--quiet` on a row underneath, which is the idiom this stylesheet
 * already has for exactly this — a real control, dim ink, no fill, and the
 * 44px floor arriving from `.btn` where it works.
 */
.portal-foot-kept {
  margin-top: var(--s2);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s1);
}

/*
 * Smaller than the two lines above it, which is the whole point.
 *
 * It sits under "No sign-ups. No downloads." and its job is to keep that
 * sentence honest, not to compete with it. At body size it was five lines and
 * the largest block in the foot.
 */
.portal-foot-kept-say {
  font-size: var(--t-small);
  text-wrap: pretty;
}

/*
 * STACKED, ALWAYS, AND MEASURED INTO RATHER THAN CHOSEN.
 *
 * These sat side by side first, and the widths say why they no longer try. The
 * foot's text column is 208px at 390, 182 at 360 and 142 at 320; the pair comes
 * to 157 + 8 + 43. So it fits on ONE width — 390, with nought pixels to spare —
 * and wraps on the two below it, where at 320 the longer label also breaks
 * across two lines and stands 62px tall on its own.
 *
 * A layout that is correct on one phone and ragged on the rest is not a layout.
 * Stacking always is predictable at every width, aligns the two underlines so
 * they read as a pair rather than as two stray links, and removes a two-pixel
 * cliff that the next word anybody edits would fall off.
 *
 * NOT a `.row`, which is how this is usually written here and would be wrong:
 * `.row` carries `flex-wrap: wrap`, and a container that is both a column and
 * wrapping is what `a-column-does-not-wrap.test.ts` exists to refuse — it
 * spills into a second column at a width nobody tested. The guard caught this
 * one the first time it was written.
 *
 * It costs one row of height, in the page FOOT — which is the one surface here
 * that is not fighting for a fold. The picker was; that is why the mark there
 * was deleted rather than shrunk.
 */
.portal-foot-kept-acts {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0;
}

/*
 * Ranged left, like the sentence above them, and in the small face: they are
 * the footnote's controls, not the page's. `.btn` centres its label, which is
 * right for a block button and wrong for a row of quiet ones under prose.
 */
.portal-foot-kept-acts .btn {
  justify-content: flex-start;
  font-size: var(--t-small);
}

/*
 * The carry link, when a managed browser has refused the clipboard.
 *
 * Selectable and wrappable. A link is one long unbroken token, so without
 * `anywhere` it either overflows its line or forces the page sideways — and
 * this one is being read off one screen and typed into another device, which
 * is the one time the characters have to be legible rather than merely there.
 */
.carry-link {
  display: inline-block;
  overflow-wrap: anywhere;
  user-select: all;
  color: var(--ink);
}

/* ------------------------------------------------------------------------ *
 * THE FRONT DOOR'S HEAD, ON A PHONE
 *
 * MEASURED at 390×844 against the shelf we are compared with: our first card
 * starts at **568px** and theirs at **383**, and three of ours are whole above
 * the fold against four of theirs. That is the one row on the scoreboard where
 * they are ahead and nothing about it is a decision — it is 185px of furniture.
 *
 * A card is 94px with its gap, so a hundred pixels is the difference between
 * three cards and four. That is what this block is for, and every pixel in it
 * comes off padding and nothing else: no text is cut, no control is hidden, and
 * the desk layout above 40rem is untouched.
 *
 * At the END of the file, which is the habit this sheet has been taught five
 * times — a media query adds no specificity.
 * ------------------------------------------------------------------------ */
@media (max-width: 40rem) {
  /*
   * The hero's own frame. `--s5` above the headline is a desk's margin; a phone
   * arriving at a shelf wants the shelf.
   */
  .portal-hero {
    gap: var(--s3);
    padding: var(--s3) var(--s4) var(--s3);
  }

  /*
   * "Take a seat" is 63px because `.btn`'s desk padding is generous. The 44px
   * floor is `--tap-min` on `.btn` itself and is not touched here — this only
   * removes the air above it, so the control cannot shrink past the floor
   * however hard this pushes.
   */
  .portal-join .btn {
    padding-block: var(--s2);
  }

  .portal-join {
    gap: var(--s2);
  }

  /*
   * "Got a code?" is a label above a box that already carries the shape of a
   * code as its placeholder, beside a button that says "Take a seat". It costs
   * 28px with its gap and tells a sighted person the third thing they already
   * know from the other two.
   *
   * `visually-hidden`'s own declarations rather than the class, because a class
   * cannot be added from a media query — and hidden rather than removed for the
   * reason `base.css` gives it: the input keeps its name, so the box is still
   * "Got a code?" to anybody listening rather than an unlabelled field.
   */
  .portal-join > .data {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }

  /*
   * The finder's bottom margin. `--s5` is a desk's separation between a control
   * block and what it filters; on a phone the shelf is what people came for.
   *
   * ITS COLUMNS ARE LEFT ALONE, and that is measured rather than lazy. The row
   * is already a grid, and re-laying it three across gives 99px selects and
   * exactly the same 156px height — because `.finder-search` takes a full row
   * of its own, so six items were never more than three rows. Narrower controls
   * for no pixels back is a worse row than the one that was there.
   */
  /*
   * ONE STEP, NOT `--s3` ON TOP OF THE SHELF'S OWN GAP.
   *
   * `.portal-shelf` is a flex column with a gap, so a margin here is a SECOND
   * separation — the same doubling the hero and the promise had, and the box
   * body and the modes line before that. Three times in one night is enough to
   * start looking for it on purpose.
   *
   * Not nought, though, and a screenshot is what said so. With the margin gone
   * the finder sat exactly as far from the first box as the boxes sit from each
   * other — MEASURED, 8px against 8px — and a control block spaced like a list
   * item reads as one. It wants more room than the gap separating it, and one
   * step is enough to say so.
   *
   * THIS IS THE ONLY ONE OF THESE THAT EVER APPLIED — 2026-08-27. The same rule
   * was declared three times under `max-width: 40rem` and twice on the desk, at
   * `--s3`, `--s3`, `--s4` and `--s2`, each with its own measured comment. The
   * last one wins and always did, so four of those measurements were describing
   * a margin no browser has ever drawn. The dead ones are gone.
   */
  .finder {
    margin-bottom: var(--s2);
  }

  /*
   * "On the shelf", over a shelf.
   *
   * The eyebrow labels forty game cards as games on a shelf, to somebody
   * looking at forty game cards on a shelf. On a desk it is quiet structure and
   * earns its 26px; on a phone that is a fifth of a card.
   *
   * `visually-hidden`'s declarations rather than `display: none`, because it is
   * still the heading of that region to anybody navigating by headings. This is
   * a decision about a phone's pixels, not about what the page is made of — and
   * "How a round goes" keeps its place in the row beside it.
   */
  .portal-shelf-eyebrow {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }
}

/*
 * The promise sits under the headline on a desk, exactly where it always has.
 * It is a band of its own now only so that a phone can move it; nothing about
 * the desk changes but the element it hangs from.
 */
.portal-lead {
  /*
   * `order: 1`, TO SIT WITH THE HERO RATHER THAN IN FRONT OF IT.
   *
   * `.portal-scroll`'s children are already ordered at a desk — the hero is 1,
   * the modes line is 2 and the shelf is 3, from the desk fold. A new child
   * defaults to `order: 0`, which is not "wherever the markup put it": it is
   * FIRST, ahead of everything with a number. This paragraph rendered above the
   * headline it belongs to and the measurement is what said so.
   *
   * Equal to the hero rather than between: with the same order, source order
   * decides, and in the markup this follows the hero.
   */
  order: 1;
  margin: 0 var(--s5) 0;
  /*
   * NO BOTTOM PADDING ON A DESK AT ALL.
   *
   * What follows this on a desk is the modes line, which brings its own top
   * margin. Anything here stacks with it — and the stack is what took the
   * desk's first card from 499 to 509 when the paragraph became a band, which
   * is a regression on a row this shelf was already behind on. A trade I had
   * written down as acceptable and then went and looked at again.
   */
  padding-bottom: 0;
  /*
   * THE HAIRLINE COMES WITH IT.
   *
   * The rule under the hero band used to be `.portal-hero`'s `border-bottom`,
   * and with the promise lifted out of the hero that line fell BETWEEN the
   * headline and its own second sentence — a rule drawn through the middle of
   * one thought. So the border belongs to whatever is last before the shelf,
   * which on a desk is this paragraph.
   *
   * At phone widths the paragraph has gone below the shelf, so the hero takes
   * its own line back — see the block at the foot of this file.
   */
  border-bottom: var(--hair);
}

@media (max-width: 40rem) {
  /*
   * ON A PHONE IT GOES UNDER THE SHELF.
   *
   * `.portal-scroll` is a flex column, so this is `order` and nothing else —
   * the paragraph is not hidden, not clamped and not rewritten, it is read
   * after the games instead of before them.
   *
   * MEASURED: the first card moves from 533 to 449 at 390, which is the
   * difference between three whole cards above the fold and FOUR — the one row
   * on the scoreboard where the shelf we are compared with was ahead on the
   * phone, and the 81px it needed.
   */
  .portal-lead {
    order: 1;
    margin: var(--s4) var(--s4) 0;
    padding-bottom: 0;
    border-bottom: 0;
  }

  .portal-shelf {
    order: 0;
  }

  .portal-modes {
    order: 2;
  }
}

/* ------------------------------------------------------------------------ *
 * THIRTY-FIVE PIXELS, AND A WHOLE ROW OF THE SHELF
 *
 * MEASURED at 1440×900: a box is 434 tall and there are 399 above the fold, so
 * the first row of five is **short by thirty-five pixels** and not one box is
 * whole. The shelf we are compared with shows nine. That is the widest gap left
 * on the board and the cheapest to close — a box that is 35px shorter turns
 * nought into five.
 *
 * WHERE IT IS NOT COMING FROM: the blurb. It is 68px of three lines and cutting
 * it to two is a truncation, which `the-picker-says-something-whole.test.ts`
 * exists because of — "a truncation is not a description; it is the shape of one
 * with the useful half removed". Nor the lid: the box art is the thing this
 * shelf has that theirs does not.
 *
 * So it is spacing, everywhere spacing is doubled or generous, and nothing else.
 * No text moves, no control shrinks past its floor, no picture is cropped.
 * ------------------------------------------------------------------------ */
.game-card-body {
  padding: var(--s3);
  gap: var(--s2);
}

/*
 * The run-up on a desk, where the same 35px is half-found again.
 *
 * `min-width: 40.0625rem` and not `40rem`: this sheet has been bitten by the
 * boundary before — at exactly 640px a `max-width: 40rem` block and a
 * `min-width: 40rem` block BOTH match, and the last one wins by accident rather
 * than by intent. 641 is the first width the phone block does not claim.
 */
@media (min-width: 40.0625rem) {
  /* The modes line sits between the promise and the shelf and is spaced from
     both; one step each side is enough at this size. */
  .portal-modes {
    margin-top: var(--s1);
    margin-bottom: var(--s1);
  }

  /*
   * One step, not two. MEASURED after the rest of this block landed: the first
   * row ended at exactly the fold — four boxes whole and the fifth's bottom
   * edge on 900.0 in a 900px window, out by a rounding error rather than by a
   * design. Four pixels is what a rounding error costs.
   */
  .portal-shelf {
    padding-top: var(--s1);
  }

  /*
   * ONE STEP, NOT `--s3` ON TOP OF THE SHELF'S OWN GAP.
   *
   * `.portal-shelf` is a flex column with a gap, so a margin here is a SECOND
   * separation — the same doubling the hero and the promise had, and the box
   * body and the modes line before that. Three times in one night is enough to
   * start looking for it on purpose.
   *
   * Not nought, though, and a screenshot is what said so. With the margin gone
   * the finder sat exactly as far from the first box as the boxes sit from each
   * other — MEASURED, 8px against 8px — and a control block spaced like a list
   * item reads as one. It wants more room than the gap separating it, and one
   * step is enough to say so.
   */
  .finder {
    margin-bottom: var(--s2);
  }
}

/* ------------------------------------------------------------------------ *
 * THE PROMPT GETS THE WHOLE WIDTH ON A PHONE, AND THE CLOCK STEPS ASIDE
 *
 * `.stage-status` is a flex row: the clock, then the prompt. At 390px that
 * leaves the prompt about 216px of the 322 available, and at 22px type its
 * sentence runs to THREE lines — 83px of a strip that is 104px tall in total.
 *
 * The strip is sticky and opaque and it stands on whatever a panel has put at
 * the bottom of the window, which is eight games' worth of controls a finger
 * cannot reach. Six attempts at moving the content have been tried and written
 * up; this moves the strip instead, and it is the first thing that does.
 *
 * A FLOAT, and not a stack. Stacking gives the prompt its full width and hands
 * back more than it saves — the clock's own row plus a gap. Floating gives the
 * prompt a short FIRST line and full-width ones after it, which is what a
 * newspaper does with a drop cap and what this sentence needs: two lines
 * instead of three, with the clock still on the same visual line it was on.
 *
 * Phone only. A desk has width for both and the flex row is the better
 * arrangement there.
 * ------------------------------------------------------------------------ */
@media (max-width: 46rem) {
  .stage-status {
    display: block;
    /*
     * And its own frame, which is 20px of the 76 that are left once the prompt
     * is two lines. `--s3` above the rule and `--s2` below it were a desk's
     * breathing room around a strip that had one line in it; on a phone this
     * band is standing on a panel that wants every pixel, and the rule itself
     * is what separates it from the game.
     */
    padding-top: var(--s2);
    margin-top: var(--s2);
  }

  /*
   * The game's own foot, for the games that have one. Same argument: `--s4` of
   * air above a row of buttons is a desk's spacing, and this row is pinned over
   * a panel that is already out of room.
   */
  .stage-foot {
    padding-top: var(--s2);
  }

  /*
   * `> ` MATTERS, AND IT COST A WEBKIT FAILURE TO FIND OUT.
   *
   * `immersive.js` ADOPTS this node — it moves `.stage-status-clock` into the
   * room banner rather than drawing a second clock, on the reasoning that "a
   * second clock in a banner would be a second thing to keep in step". So a
   * rule written on the class alone follows the node into the banner.
   *
   * Unscoped, this floated the clock there too, and `.clock-bar` inside it is
   * `position: absolute; inset-inline: 0` — its containing block shrank from
   * the banner's width to the words "Night Train", so the turn bar started
   * short instead of full. `clock-visible.spec.ts` caught it in webkit-portrait
   * and was right to: that bar is how somebody knows their turn is running out.
   *
   * Scoped to the strip, the adopted clock is untouched wherever it goes.
   */
  /*
   * THE FLOAT IS GONE, AND IT NEVER DID WHAT THE NOTE ABOVE SAYS — 2026-08-27.
   *
   * The drop cap is a good idea and it depends on the clock being narrow. It
   * never is. `chrome.js` hides the clock entirely when there is no phase name,
   * so ANY strip with a visible clock has a label in it, and a label makes the
   * clock **178–185px of a 330px strip**. Measured at 390 across five games:
   * every one of them, in play, wide.
   *
   * What the float actually produced, therefore always: "Side B is guessing."
   * on two lines squeezed into a hundred pixels, and "One word and a number.
   * Not a word on the board." on three with its left edge jumping from 224 to
   * 30 mid-sentence. A sentence that starts in two different places is the one
   * thing this strip exists to say.
   *
   * Stacked it costs twenty pixels and every sentence starts at 30. Measured
   * after: Secret Codes 92px one line, its clue phase 106px two lines, Word
   * Tiles 106px two, Minesweeper 101px two — all of them flush left.
   */
  .stage-status > .stage-status-clock {
    margin-bottom: var(--s1);
  }

  /*
   * AND THE PROMPT HAS TO BE A BLOCK, OR THE FLOAT BUYS NOTHING.
   *
   * `.prompt` is `display: flex` — for the mark that sits beside the sentence —
   * and a flex container establishes its own formatting context, so it does not
   * wrap around a float. It sat BESIDE the clock as a 238px rectangle and stayed
   * three lines tall, which is the float working and achieving nothing.
   *
   * As a block, the mark and the sentence are inline content: a short first line
   * beside the clock, full-width lines under it. The `gap` the flex provided
   * becomes a margin on the mark, which is the same eight pixels by another
   * route.
   */
  .stage-status .prompt {
    display: block;
  }

  .stage-status .prompt > * {
    margin-right: var(--s3);
  }

  /* `float` needs something to clear, or the act row and the refusal line ride
     up beside the clock instead of following the prompt. */
  .stage-status-act,
  .stage-status .refused {
    clear: both;
  }
}

/*
 * THE SEAT BUTTON WRAPPED ITS OWN LABEL ON A NARROW PHONE — 2026-08-27.
 *
 * `.portal-join` is a two-column grid at phone widths, `auto minmax(0, 1fr)`,
 * with `.portal-code` fixed at 8.5rem. On a 360px screen that leaves the seat
 * button 148px for a label needing 155, so "Take a seat" broke across two lines
 * and the button stood 74px tall instead of 52. MEASURED: the form was 82px at
 * 360 and 109px at 320 against 60 everywhere else, and the shelf below it began
 * 22px and 49px lower. That is the commonest narrow phone paying for a rule
 * that was written at 390.
 *
 * THE COLUMNS ARE THE FIX; THE `nowrap` ALONE IS NOT.
 *
 *   - `max-content` on the seat column and `minmax(0, auto)` on the code, so
 *     when the row is short of room the CODE FIELD yields and the button keeps
 *     its label. Tried the other way first — `nowrap` against the `1fr` seat
 *     column that was already there — and the button did not shrink the code,
 *     it overflowed its own track and stood 7px past the right margin every
 *     other block on the page holds to. A 1fr track's minimum is nought, so it
 *     let the button hang out rather than pushing back on its neighbour.
 *   - the code can only yield because `min-width: 0` releases it: a grid item's
 *     automatic minimum is its own content, and 8.5rem of letter-spaced mono is
 *     content. It never actually yields above 320 — 136px of field against a
 *     103px placeholder at every width measured — it is there so that the
 *     button never has to.
 */
@media (max-width: 40rem) {
  .portal-join {
    grid-template-columns: minmax(0, auto) max-content;
  }

  .portal-code {
    min-width: 0;
    /* And never wider than its track: an 8.5rem field in a track the button
       has squeezed was painting under the button (2026-09-07). */
    max-width: 100%;
  }

  .portal-join > .btn {
    white-space: nowrap;
  }
}

/*
 * ENLARGED TEXT PUTS THE BUTTON UNDER THE FIELD.
 *
 * At 125% text a 360px phone is 18rem wide — the 320 phone's arithmetic
 * without the 320 phone's fix — and the two columns left the code field 73px
 * for a six-character code. Written as rem against px so that only a larger
 * root font, never a narrower screen, turns it on: at 16px the rem bound is
 * 359px and the query is never true.
 */
@media (max-width: 22.4375rem) and (min-width: 360px) {
  .portal-join {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "say"
      "code"
      "seat";
  }
}

/*
 * AND 320 TAKES THE PHONE'S PADDING RATHER THAN THE DESK'S.
 *
 * 359px and down, which is the only way the smallest screen still sold holds
 * both controls on one line: 115px of button for 111px of label, against 131px
 * of room. 360 is deliberately OUTSIDE this — it has the space for the full
 * button and keeps it, so the narrowest phone pays for itself and nothing else
 * does. `--tap-min` on `.btn` still holds the 44px floor; measured height 52.
 */
@media (max-width: 22.4375rem) {
  .portal-join > .btn {
    padding-inline: var(--s3);
  }

  /* One question to a row: two across at 320 is a 116px pill with 82px for
     "Any number" (87) and "Any length" (91), which read "Any num…" — and 16px
     is the floor a phone will not zoom for, so the letters cannot give. The
     320 phone pays two rows of 44 here and nowhere else (2026-09-07). */
  .finder-row {
    grid-template-areas:
      "search  surprise"
      "seats   seats"
      "minutes minutes"
      "kind    kind"
      "order   order"
      "count   count";
  }
}

/*
 * AND THE SAME FAULT ON THE LOBBY PLATE — 2026-08-27.
 *
 * The plate is a `nowrap` flex of two things: what the game is called, and the
 * button that offers to show it played. Both were shrinkable, so a long GAME
 * NAME took room from the button until "See it played" broke in half — 73px of
 * button where every other lobby draws 51.
 *
 * It is the game's name that decides, which is why it looked like a Taboo bug:
 * "Forbidden Words" at 360 and "Twelve Crowns" at 320 squeeze it, "Odd One Out"
 * never does. A fault that only appears for some of the shelf's forty names,
 * on the narrow half of the phones, is one nobody was going to notice.
 *
 * `.lobby-plate-said` already carries `min-width: 0` so it CAN yield; what it
 * lacked was a reason to yield first. `flex-shrink: 0` on the button gives it
 * one — the name is prose and wraps to its next line quite happily, and the
 * label is a control's name and does not.
 */
.lobby-plate > .btn {
  flex-shrink: 0;
  white-space: nowrap;
}

/*
 * AND THE SUM AGAIN AT 320, WHICH IS WHERE THEIR CARD WAS BEATING OURS.
 *
 * The block at `max-width: 24rem` says this arithmetic "has to be done again"
 * per width and does it for 360. This is the same sum at 320, and it was found
 * by running our own card measurement against tableparty.io's at every width
 * instead of only the one where we already won:
 *
 *   | lines drawn whole | 320 | 360 | 390 |
 *   |---|---|---|---|
 *   | ours, before this | 4 of 40 | 40 of 40 | 40 of 40 |
 *   | theirs | 32 of 36 | 34 of 36 | 36 of 36 |
 *
 * Their card is a name, a line and a chevron; ours is a name, a facts row, a
 * line, a play badge AND a Deal stub. At 320 the card is 252px and ours spends
 * 48 of it on the picture and 44 on the stub, leaving 142 against their 170.
 *
 * FOUR PIXELS OFF THE PICTURE AND A SIZE OFF THE LINE. 146px of column against
 * a longest line of 136: forty of forty, whole, with ten to spare. The stub is
 * untouched at 2.75rem because it is already at the tap floor, and a picture at
 * 44px is still a picture.
 *
 * The row comes down with it — 127px to 102 — because the picture is sized off
 * its own width. That is a second win on the metric this shelf is compared on,
 * and it was not the reason for the change.
 *
 * THE FACTS ROW WAS THE OTHER HALF, and this note used to end by deferring it:
 * *"it wants a different answer — probably a shorter way to say 4–12 players —
 * and guessing at one inside a media query is how a row ends up with two bad
 * sizes instead of one."* That was right, and the answer is at the bottom of
 * this block: the word is split off in `screens.js` and hidden here, so the
 * line reads "4–12 · 20 MIN" beside a person glyph and a clock. 195px becomes
 * 139 in a 146px column, and the row comes down again — **102px to 81**.
 */
@media (max-width: 20rem) {
  .catalogue .game-card.game-card {
    grid-template-columns: 2.75rem minmax(0, 1fr);
  }

  /*
   * THE WORDS' LEFT MARGIN IS LEFT ALONE, and those four pixels are why.
   *
   * The first version of this block took it to `--s1` for four more pixels of
   * column. It cost the line its first letter: the disc ends at 53 and the text
   * moved to 49, so "Your links. Their rebuild." was drawn as "our links" on
   * every card at this width. The 24rem block warns about exactly this — the
   * mark is worn by the picture and the words start where it ends — and it was
   * still worth four pixels to go and find out.
   *
   * They are not needed. At 12px the longest line is 136 in a 146px column.
   * `every-card-says-something-whole.spec.ts` now measures the seam as well, so
   * the next person to want those four pixels is told rather than shown.
   */

  /* The seam moved a quarter-rem left, so the mark's box moves with it. */
  .catalogue .game-card.game-card .game-card-actions .btn--quiet {
    left: 1.5rem;
  }

  /*
   * 0.75rem AND NOT 0.8125, WHICH THE GUARD DECIDED RATHER THAN TASTE.
   *
   * 13px fits all forty at this width — and clears the column by three or four
   * pixels on the longest of them, which the guard refuses. It refuses it
   * because the FIRST version of this fix passed on one pixel at 360, and a
   * margin that thin is not a margin; it is a coincidence waiting for a font to
   * change.
   *
   * 12px is one step under the title and one over the facts row's mono, on the
   * narrowest screen still sold, and it is a whole sentence instead of two
   * thirds of one.
   */
  .catalogue .game-card.game-card .game-card-blurb {
    font-size: 0.75rem;
  }

  /*
   * AND THE FACTS ROW STOPS SAYING "PLAYERS", which the deferral note two
   * paragraphs up said it would need to.
   *
   * "4–12 PLAYERS · 20 MIN" wants 195px of a 146px column, so thirty-nine of
   * the forty drew it on two lines and stood 102px tall on a shelf of 86.
   * Without the word: 139px, and forty of forty on one line.
   *
   * Hidden and not removed. The person glyph beside it is `aria-hidden`, so a
   * screen reader that lost the word would be told "4–12" and left to guess —
   * these are the same declarations `.portal-join > .data` uses for the same
   * reason, written out rather than reached for as a class because a class
   * cannot be added from a media query.
   */
  .catalogue .game-card.game-card .game-card-facts .fact-word {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }
}

/*
 * `/record` — the page `record.js` draws.
 *
 * A document rather than a dashboard, and that is the whole of the design
 * decision: this is a list of things you did, read once in a while, on a page
 * with no live state on it. It borrows `.measure` for its column, `.data` for
 * the right-hand fact and `.btn--quiet` for its two switches, so there is
 * nothing here that another surface does not already answer for.
 *
 * The row is a name and a fact with the space between them, which is how a
 * results column reads in print and how `.lobby-plate` already sets a name
 * beside a number. Nothing is centred: a column of centred rows has no edge to
 * run your eye down, and running your eye down is the entire use of the page.
 */
.record {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  max-width: var(--measure);
  margin-inline: auto;
  padding-block: var(--s5);
  /* The gutter is this page's own: `main` does not carry one, and the first
     screenshot of this list ran "WON 2 OF 3" flush into the right edge of the
     glass with "PLAYED, NOT FINISHED YET" clipped off the end of it. */
  padding-inline: var(--s4);
}

.record-title {
  margin: 0;
}

.record-list {
  display: flex;
  flex-direction: column;
  margin: 0;
  padding: 0;
  list-style: none;
}

.record-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
  padding-block: var(--s2);
  border-bottom: var(--hair);
}

/* The name carries the press, so it takes the room and the fact keeps its
   own width — a fact that wraps under a name stops being a column. */
.record-row-name {
  min-width: 0;
}

.record-row-said {
  flex: none;
  color: var(--dim);
}

.record-acts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s3);
}

/*
 * THE INK ON A SHEET IS THE SHEET'S — 2026-08-27.
 *
 * `.sheet` is paper: `--stock` for a ground and `--ink` for its type, and both
 * of those are deliberately the same in both lights, because paper does not
 * change colour when you turn a lamp off. The rule sets `color: var(--ink)`
 * and its headings too, so everything that INHERITS is correct.
 *
 * `.data`, `.muted` and `.btn--quiet` do not inherit. They carry `--dim`, which
 * is cut against the ROOM and flips — dark by day, a pale grey after dark — so
 * on a sheet it is legible in one light and gone in the other.
 *
 * MEASURED on `/pass` across all fourteen palettes in both lights: **1.95:1 at
 * worst, and every one of the fourteen evenings under the 4.5 a line of type
 * has to clear.** "One phone, whole table", "Who is here", "Add another",
 * "Remove" — and the same on `/tv` for "The big screen" and "Room code".
 *
 * This is the third time tonight and the second file: `jaipur.css` carries the
 * same paragraph about the same fault on its cards, and the fix there is the
 * same one — the quiet ink on stock is a mix of the stock's own ink INTO the
 * stock, which `deal.css` and `decrypto.css` already use. 62% clears all
 * twenty-eight at 4.83:1 worst; the number is written down so a palette change
 * has something to check against rather than a feeling.
 *
 * AND THE STATES GO WITH IT, which is the half a `color:` declaration alone
 * misses — `jaipur.css` says so in as many words. A quiet button's underline is
 * `--dimmer` and its press is `--on-table-strong`, and both are cut against the
 * room exactly as `--dim` is.
 */
.sheet {
  --sheet-quiet: color-mix(in srgb, var(--ink) 62%, var(--stock));
}

.sheet .data,
.sheet .muted,
.sheet .btn--quiet {
  color: var(--sheet-quiet);
}

.sheet .btn--quiet {
  border-bottom-color: color-mix(in srgb, var(--ink) 45%, var(--stock));
}

/* Guarded, because an unguarded `:hover` latches after a tap and leaves a
   phone holding a state nobody asked for — `hover-is-guarded.test.ts`. */
@media (hover: hover) {
  .sheet .btn--quiet:hover {
    color: var(--ink);
  }
}

.sheet .btn--quiet:active {
  color: var(--ink);
}

/*
 * AND QUIET INK ON THE ARRIVAL PANEL — 2026-08-27.
 *
 * `--dim` is the quiet ink and it is cut against the TABLE. On the table it is
 * fine: 5.18:1 at its worst across all fourteen palettes and both lights. On a
 * `--panel` it is too close after dark — **3.91:1 at worst, and ten of the
 * twenty-eight combinations under the 4.5 a line of type has to clear.**
 *
 * `.arriving-in` is a panel, and the three lines on it are the ones somebody
 * reads while they are standing at the door waiting for a room: "No room yet",
 * "Nothing to install and no account", "Or open a room of your own".
 *
 * `--on-table` is 5.38:1 at its worst on a panel across the same twenty-eight
 * and is still the body voice rather than the heading's — the same swap
 * `nighttrain.css` makes for its chooser, which is the same pair on the same
 * kind of ground.
 *
 * SCOPED, and not fixed on `--dim` itself, which is the tempting move. A panel
 * often holds something printed on STOCK — a card, a plate, a tile — and stock
 * does not flip, so lifting the token everywhere inside a panel would take the
 * quiet ink on those from dark-on-cream to pale-on-cream and break them in the
 * light where they currently work. The pair is wrong in a place, not in
 * general, and `tools/design/light-flip.mjs` is what finds the next place.
 */
.arriving-in .data,
.arriving-in .muted,
.arriving-in .btn--quiet,
/*
 * AND THE STAND-IN PICKER, which is the third place that pair occurs and was
 * found the same way: by opening a sheet no sweep had ever opened.
 *
 * `.standin-card` is a `--panel` laid over the room, and "Who takes the chair",
 * "Never mind" and the line about what a stand-in knows were all `--dim` on it
 * — 4.16:1 after dark. It is the sheet somebody opens when they are one player
 * short on a Friday, which is not the moment to be squinting.
 */
.standin-card .data,
.standin-card .muted,
.standin-card .btn--quiet {
  color: var(--on-table);
}

/*
 * THE SPECTATOR'S LINE IS PRINTED ON THE CLOTH, SO IT TAKES THE CLOTH'S INK.
 *
 * `"Watching this one."` is what a game says to somebody who has no seat — and
 * on a television that is EVERYBODY, because the wall never holds a chair. It
 * is written by the panels as `.muted`, which is `--dim`: quiet ink cut against
 * the room's own ground.
 *
 * A game's stage is not the room's ground. It is the cloth the game supplies,
 * and on Bazaar's green that reads **1.18:1 in daylight** — measured, and
 * photographed, because a ratio that low is worth looking at rather than
 * believing. Dark green on mid-green, on a screen a whole room reads from the
 * far side of it.
 *
 * MEASURED against six of the shelf's clothes in both lights: `--on-table` fails
 * four of six, `--on-table-strong` three, `--ink` and `--dim` all six. `--pale`
 * and `--stock` clear all six at 5.65:1 worst, which is not a coincidence — it
 * is what the games' own cloth text already uses. Bazaar prints "Goods tokens ·
 * 0" in exactly that cream three inches above this line.
 *
 * So the rule is on the `data-t`, not on a game: the attribute means "this is
 * the sentence for somebody with no seat", and that sentence is on a cloth
 * wherever it appears.
 */
[data-t="watching"] {
  color: var(--pale);
}

/* ------------------------------------------------------ the noise a table makes */

/*
 * Five marks under the people, and a layer above everything for where they go.
 *
 * The bar is on the room's table, so it takes the table's inks. The keys are
 * 44 square because that is the floor and because five of them at 44 is 260px
 * of a 320px screen with the gaps -- which is the whole reason there are five
 * and not eight. Measured at 320, 360 and 390.
 */
.react-bar {
  display: flex;
  justify-content: center;
  gap: var(--s2);
  padding-top: var(--s3);
}

/*
 * THE ROW THE SHORTEST PHONE CANNOT AFFORD — 2026-08-28.
 *
 * The bar is mounted from `index.html`'s static slot, so it is in every room
 * including a lobby, where it costs a whole row above the fold. On a 640px-tall
 * screen that row is the difference between a game you can start and one you
 * cannot: **thirteen lobbies at 360x640 with the primary off the bottom** —
 * "Climb Word Chain" at 597..660 of a 640px screen. It had been true since the
 * bar shipped; it is not new breakage, it is breakage nobody had looked for,
 * which is what a suite that never finishes buys you.
 *
 * MAX-HEIGHT AND NOT THE LOBBY ALONE, because height is the constraint that
 * actually binds. At 390x844 every lobby clears its primary WITH the bar in
 * place, and `the-noise-a-table-makes.spec.ts` measures the five keys at 320,
 * 360 and 390 — all at 844 tall, all still true. Hiding the bar in every lobby
 * would have paid for a 640px screen out of an 844px one that was not in
 * trouble, and it would have taken the bar out of the only place it is above
 * the fold at all: the file above notes that in a dealt game it sits at y=1562.
 *
 * 700px is under every phone this product is tested at except the 640 that
 * fails, and above none of them.
 *
 * NOTHING IS LOST WHERE IT DOES APPLY. `room.js` also puts a single reaction
 * key in the stage strip, and that strip is in the lobby too — so on a short
 * screen the same five marks are still one press away, in 44x44 rather than in
 * a row of five.
 */
@media (max-height: 700px) {
  :root[data-lobby="true"] [data-t="react-slot"] {
    display: none;
  }
}


.react-key {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--tap-min);
  height: var(--tap-min);
  padding: 0;
  border: var(--hair-rim);
  border-radius: var(--r-control);
  background: transparent;
  color: var(--on-table);
  cursor: pointer;
  transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease),
    background-color var(--fast) var(--ease), transform var(--fast) var(--ease);
}

.react-key .react-glyph {
  width: 1.375rem;
  height: 1.375rem;
}

@media (hover: hover) {
  .react-key:hover {
    color: var(--on-table-strong);
    border-color: var(--rule-strong);
    background-color: var(--press);
  }
}

.react-key:active {
  transform: translateY(1px);
  color: var(--on-table-strong);
}

.react-key:focus-visible {
  outline: var(--focus);
  outline-offset: 2px;
}

/*
 * WHERE A MARK GOES, WHICH IS OVER EVERYTHING AND UNDER NOTHING'S CONTROL.
 *
 * One fixed layer rather than a node inside each chair: a chair is a small box
 * and in more than one palette it has `overflow` on it, and a thing that floats
 * out of a box must not be inside the box. Nothing here ever takes a pointer.
 */
.react-layer {
  position: fixed;
  inset: 0;
  z-index: 60;
  pointer-events: none;
}

/*
 * THE ONE KEY THAT RIDES THE STRIP, AND THE SHEET IT OPENS.
 *
 * The strip is the only thing on the glass while a game is on. The bar cannot
 * live there — fifty-two pixels of it cost four games a reachable control — but
 * one key can, pinned to the top right of the strip on the clock's own line.
 * MEASURED at 320 with the widest label on the shelf, "Chapter ends in": 64px
 * free beside it, and this needs 44. It adds NO height to the strip at all.
 *
 * The clock is padded out of its way rather than drawn under it, so the
 * progress bar across the clock's foot stops before the key instead of running
 * beneath it.
 */
/*
 * NO `position: relative` HERE, AND A GUARD IS WHY.
 *
 * The obvious way to anchor the key is to make the strip relative, and
 * `a-class-keeps-its-position.test.ts` refused it: the strip is `position:
 * sticky`, that is the whole reason it is the one thing on the glass, and a
 * later `relative` on the same class would have quietly unpinned it. The strip
 * is already a positioned ancestor — sticky is one — so the key needs nothing
 * added to hang off it.
 */
.react-open {
  position: absolute;
  top: 0;
  right: 0;
}

/*
 * AND IN A LOBBY IT IS NOT ABSOLUTE AT ALL — 2026-08-28.
 *
 * `position: absolute` needs a positioned ancestor. The key has one while a
 * game runs, because `.stage-status` is sticky there — and NONE in a lobby,
 * where the same rule deliberately leaves it static. With no containing block
 * it resolved against the initial one and left the panel: `panel.spec.ts`
 * caught it at 1600x380, a wide short window where the panel is centred, with
 * **the key painting at x 0..44 against a panel of 396.5..1156.2**. Four
 * hundred pixels adrift at the edge of the glass, on Minesweeper's setup
 * screen, which is a lobby.
 *
 * THE FIRST REPAIR WAS TO POSITION THE STRIP, and it was wrong within the
 * hour. `:root[data-lobby="true"] .stage-status { position: relative }` reads
 * as the exact complement of the sticky rule and cannot unpin anything — and
 * `clock-visible.spec.ts` failed it immediately, because it asserts a lobby's
 * strip is `static` and says why: *"a strip that is correctly static in a lobby
 * and a strip that is sticky but happens to have nothing under it look
 * identical from the outside, and the second one is a loaded gun that goes off
 * in the next lobby that gets one control taller."* Two guards, one wanting the
 * strip positioned and one wanting it in flow.
 *
 * The second repair put the key in flow instead — `position: static`, an
 * ordinary item in the strip's row. That satisfied both guards and broke a
 * third: an absolute key takes NO space, and a 44px flex item takes 44. At
 * 750x342 `landscape.spec.ts` measures avalon's lobby, where the stage already
 * begins within a few pixels of the glass, and the strip grew into the margin
 * that was keeping the primary reachable.
 *
 * SO THE KEY IS NOT IN A LOBBY AT ALL, which is what it was always for. It
 * exists because the BAR is below the fold once a game is dealt — `room.js`
 * says so where it appends it, measured at y=1562 in Boggle. A lobby has the
 * bar right there under the people. The key was solving a problem the lobby
 * does not have, in the one layout that could least afford it.
 *
 * Both guards that drive the key deal a game first, which is the same fact from
 * the other side.
 *
 * ONE CONSEQUENCE, STATED RATHER THAN DISCOVERED: on a lobby shorter than 700px
 * the bar is already hidden for the fold's sake, so a short lobby now offers no
 * reactions at all. That is the right trade — the control that starts the game
 * outranks a mark nobody has anything to react to yet — and it is written down
 * so the next person meets it as a decision rather than a gap.
 */
:root[data-lobby="true"] .react-open {
  display: none;
}

.stage-status > .stage-status-clock {
  padding-right: calc(var(--tap-min) + var(--s2));
}
/*
 * And when the prompt shares the clock's line, the LAST thing on that line
 * is what the key covers: the prompt, or the action slot when it has
 * something in it. `data-one-line` is published by `shell/room.js` beside
 * `--stage-foot`, because whether a flex row wrapped is not a fact a
 * stylesheet can read back. Measured on The Cable's desk: "And which of
 * theirs?" under the key before, clear of it after.
 */
.stage-status[data-one-line="true"]:has(> .stage-status-act:empty) > .prompt,
.stage-status[data-one-line="true"] > .stage-status-act:not(:empty) {
  padding-inline-end: calc(var(--tap-min) + var(--s2));
}

/*
 * AND WHEN THE HINT TOGGLE RIDES THE STRIP, IT CLEARS BOTH KEYS — 2026-09-08.
 *
 * The rule above reserves the width of ONE key on the clock's line, which is
 * right when the reaction key is the only thing pinned top-right. But the hint
 * toggle sits beside it (`.stage-hint-toggle { right: tap-min + s2 }`), so on
 * a game that prints a hint there are TWO keys out of flow on that edge. The
 * clock already reserves for both — see `:has(.stage-hint-toggle…) >
 * .stage-status-clock` above, `2 * tap-min + 2 * s2` — and the prompt/act slot
 * did not, so the toggle's "−" printed over the tail of the sentence ("in any
 * order" on Anagram Relay's desk, photographed 2026-09-08). Same reservation
 * as the clock, applied to whatever else is the last thing on the line.
 */
.stage-status[data-one-line="true"]:has(.stage-hint-toggle:not([hidden])):has(> .stage-status-act:empty) > .prompt,
.stage-status[data-one-line="true"]:has(.stage-hint-toggle:not([hidden])) > .stage-status-act:not(:empty) {
  padding-inline-end: calc(2 * var(--tap-min) + 2 * var(--s2));
}

.portal-table-id {
  font-family: var(--font-mono);
  font-size: var(--t-data-sm);
}

.portal-table-edit {
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
  padding: var(--s3);
  border: var(--hair);
  border-radius: var(--r-control);
}

.portal-table-name-label {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  flex: 1 1 100%;
  min-width: 0;
}

.portal-table-name-label input { width: 100%; min-width: 0; min-height: var(--tap-min); }
.portal-table-name, .portal-table .portal-table-drop {
  align-self: center;
  min-height: var(--tap-min);
  padding-inline: var(--s2);
  font-size: var(--t-small);
}
.portal-table-edit p { flex: 1 1 100%; margin: 0; }
.portal-table-edit p:empty { display: none; }
.portal-table-status { grid-column: 1 / -1; margin: 0; }

/* Keep the deadline visible while players make room to inspect their game. */
.stage-status:has(.stage-hint-toggle:not([hidden])) {
  min-height: var(--tap-min);
}

.stage-status:has(.stage-hint-toggle:not([hidden])) > .stage-status-clock {
  max-width: 100%;
  padding-right: calc(2 * var(--tap-min) + 2 * var(--s2));
}

.stage-status .clock-label {
  white-space: normal;
  min-width: 0;
}

.stage-status .clock-time {
  flex: none;
}

.stage-hint-toggle {
  position: absolute;
  top: 0;
  right: calc(var(--tap-min) + var(--s2));
  width: var(--tap-min);
  min-height: var(--tap-min);
  padding: 0;
  font-size: 1.25rem;
}

.stage-hint-toggle[hidden],
.stage-status[data-hint-collapsed="true"] .prompt {
  display: none;
}

/* Phone instructions occupy their own row before the game. The shell moves
   the actual node so keyboard and screen-reader order follow the layout. */
@media (max-width: 599px) {
  :root:not([data-lobby="true"]):not([data-immersive="true"]) .stage-status {
    top: 0;
    bottom: auto;
    margin-top: 0;
    margin-bottom: var(--s3);
    border-top: 0;
    border-bottom: var(--hair);
  }
}

/*
 * Over the game, and only while it is open. That is the whole difference from
 * drawing five keys on the board and leaving them there: this is a thing
 * somebody opened, it takes one tap to use and one to dismiss, and the letters
 * underneath are hidden for about a second.
 */
.react-sheet {
  position: fixed;
  display: flex;
  padding: var(--s2);
  background: var(--panel);
  border: var(--hair-rim);
  border-radius: var(--r-panel);
  box-shadow: var(--lift);
}

.react-sheet .react-bar {
  padding-top: 0;
}

.react-mark {
  position: absolute;
  display: block;
  transform: translate(-50%, -50%);
  color: var(--on-table-strong);
  animation: react-float 1.5s var(--ease) forwards;
  will-change: transform, opacity;
}

.react-mark .react-glyph {
  width: 1.75rem;
  height: 1.75rem;
  filter: drop-shadow(0 1px 2px rgb(8 22 29 / 0.35));
}

@keyframes react-float {
  0% {
    opacity: 0;
    transform: translate(-50%, -30%) scale(0.72);
  }
  18% {
    opacity: 1;
    transform: translate(-50%, -62%) scale(1.06);
  }
  32% {
    transform: translate(-50%, -70%) scale(1);
  }
  100% {
    opacity: 0;
    transform: translate(-50%, -190%) scale(0.94);
  }
}

/*
 * A mark that travels is a mark that moves in somebody's peripheral vision, and
 * that is exactly what this setting is asking us not to do. It still appears
 * and still goes, because the INFORMATION is not the motion -- it is that
 * somebody at the table made a noise.
 */
@media (prefers-reduced-motion: reduce) {
  .react-mark {
    animation: react-hold 1.5s steps(1, end) forwards;
  }

  .react-key {
    transition: none;
  }

  @keyframes react-hold {
    0%,
    82% {
      opacity: 1;
      transform: translate(-50%, -70%);
    }
    100% {
      opacity: 0;
      transform: translate(-50%, -70%);
    }
  }
}
