/* Element defaults and the rules that must exist exactly once: the tap-target
 * floor, the focus ring, the visually-hidden helper, the skip link, the shell
 * grid and the archetype frames. Reads tokens.css and adds no values of its own —
 * test/ui.base.test.mjs fails on any hex or literal font-size in this file. */

/* Hanken Grotesk, variable 400-900, self-hosted. Two subsets: latin for the app,
 * latin-ext because the pseudo-locale generates accented forms in U+0100-024B and
 * U+1E00-1E9F and they must render in the real face or Task 14's long-string
 * layout check would be measuring a fallback. */
@font-face {
  font-family: 'Hanken Grotesk';
  font-style: normal;
  font-weight: 400 900;
  font-display: swap;
  src: url('/vendor/fonts/HankenGrotesk-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Hanken Grotesk';
  font-style: normal;
  font-weight: 400 900;
  font-display: swap;
  src: url('/vendor/fonts/HankenGrotesk-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* --- element defaults ---------------------------------------------------- */
/* Every rule below exists to take a colour AWAY from the user agent. That is a
 * different job from the component rules further down, and it is the one that is
 * invisible in source: a user-agent colour is not written anywhere, so nothing can
 * grep for it — the bug is a rule that is ABSENT. Four were measured in the Browser
 * pane and are fixed here (the anchor colour, the SVG fill, a form control's own
 * pair, and the selection highlight); accent-color is the fifth and is declared
 * before anything renders a check box. test/ui.base.test.mjs enumerates the elements
 * the client actually renders and refuses to let a new one join the tree
 * unclassified, so the guard is on the class of bug rather than on the five. */
*, *::before, *::after { box-sizing: border-box; }

html {
  background: var(--canvas);
  color: var(--ink);
  /* No font-size override. The browser's 16 px default IS the §9.2 floor, and
   * overriding it in a percentage is how a user's own text-size setting gets
   * quietly discarded. */
  -webkit-text-size-adjust: 100%;
  /* The tint a check box, a radio, a range slider and a <progress> paint
   * themselves. Its initial value is `auto`, which means the USER AGENT's accent —
   * a blue in Chrome, the OS accent elsewhere — and measured on the demo screen it
   * computed `auto` on <html> in BOTH themes, so data-theme could never reach it.
   * Nothing in 0c renders a check box, and that is exactly why it is declared now:
   * phase 1's questionnaire does, and by then the leak would already be shipping on
   * every item. Declared here rather than on the form-control selector below because
   * accent-color is inherited, so one declaration also covers <progress>, which that
   * selector does not name. */
  accent-color: var(--role);
}

body {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--type-body);
  font-weight: var(--weight-body);
  line-height: var(--leading-body);
  /* A NAME A PERSON TYPED CAN BE ONE UNBROKEN RUN (loop 18, browser pass): a team
   * called by an 80-character word — team.create's own limit — widened host.live
   * from 1280 to 1709 px. break-word here breaks only a word that would overflow
   * its line; measured, the layout of every host screen with ordinary names is
   * unchanged at 1280 and at 375. */
  overflow-wrap: break-word;
}
/* And in a row's identity cell — archetype E's <th scope="row">, where the team,
 * game or person name sits — where break-word does not lower the column's
 * minimum, so the cell grew to the whole word instead: anywhere does. ONLY there
 * (loop 19, measured): on every cell it let the table squeeze the other columns
 * below their own words, and host.live's Open broke into "Op" and "en". */
th[scope="row"] { overflow-wrap: anywhere; }

h1, h2, p, ul, ol, figure { margin: 0; }
ul, ol { padding: 0; list-style: none; }
img, svg { max-width: 100%; }
/* `fill` is an INHERITED property whose initial value is the keyword black, not
 * currentColor — so an SVG shape with no fill of its own paints pure black, which is
 * all but invisible on the dark canvas and is not a palette value in either theme. Measured
 * on the demo screen: every element in the tree computed `fill: rgb(0,0,0)`. Nothing
 * broke, because components.js writes fill="none" on both of the gate ring's circles
 * — the leak is latent, and §12 puts four SVG charts in later phases. currentColor
 * makes the default --ink and makes it swap. */
svg { fill: currentColor; }
/* A form control's colour AND its fill. `color: buttontext` and
 * `background-color: buttonface` are user-agent values that no token can reach and
 * no data-theme can swap: under index.html's `color-scheme: dark light` the browser
 * picks its own dark-scheme pair, and under the light theme it keeps picking, so the
 * control would be the one box on the page that does not follow the palette. Every
 * button the client renders today carries .btn, .card or .pill and so is painted by
 * its own rule; transparent here is the floor under the one that gets forgotten. */
button, input, select, textarea {
  font: inherit;
  color: inherit;
  background-color: transparent;
}
/* The selection highlight. With no rule the pair is entirely the browser's — and it
 * is worse than the link colour was, because getComputedStyle does not even expose
 * it: measured on the demo screen, ::selection reported background-color
 * rgba(0, 0, 0, 0) in both themes while the browser painted its own highlight, so
 * neither a token nor a test nor a theme swap could see or reach it. --canvas on a
 * role fill is the same pair .btn-primary uses, which ui.base.test.mjs already
 * recomputes at >= 4.5:1 for all three roles in both themes. */
::selection {
  background: var(--role);
  color: var(--canvas);
}
/* Anchors too, and this one is not tidiness. With no rule here the user agent
 * painted the shell's Settings and Help and the skip link rgb(158,158,255) — its
 * own dark-scheme link colour, picked because of index.html's `color-scheme: dark
 * light` — and measured in the Browser pane it stayed that value in BOTH themes:
 * 8.17:1 on the dark canvas, but 2.2:1 on the light one, against SC 1.4.3's 4.5:1,
 * on all sixty screens. A user-agent colour is not a token, so data-theme="light"
 * cannot reach it, and §9.1's "the light theme is a token swap" was quietly untrue
 * for every link in the app. --ink is 16.48:1 dark and 16.51:1 light.
 *
 * The underline is declared rather than inherited from the UA for the same reason,
 * and it is what says "link" — the hue never was. */
a { color: inherit; text-decoration: underline; }
/* <fieldset> joined with player.questionnaire's scale choices (finding 283(a)).
 * The UA sheet draws it a 2px groove border in ThreeDFace — a system colour no
 * token reaches. Taken away here; .choices (ChoiceField) then drops the border. */
fieldset { border-color: var(--line); }

/* --- type roles ------------------------------------------------------------ */
.t-display {
  font-size: var(--type-display);
  font-weight: var(--weight-display);
  letter-spacing: var(--tracking-display);
  line-height: var(--leading-display);
}
.t-h1 { font-size: var(--type-h1); font-weight: var(--weight-h1); }
.t-h2 { font-size: var(--type-h2); font-weight: var(--weight-h2); }
.t-body { font-size: var(--type-body); font-weight: var(--weight-body); }
/* Chrome only, and only at >= 4.5:1 — --mute clears that on both surfaces. */
.t-meta { font-size: var(--type-meta); font-weight: var(--weight-meta); color: var(--mute); }
.t-micro {
  font-size: var(--type-micro);
  font-weight: var(--weight-micro);
  letter-spacing: var(--tracking-micro);
  text-transform: uppercase;
  color: var(--mute);
}

/* --- the 44 px floor ------------------------------------------------------- */
/* One rule, one token. §7.3 asks for a shared mixin rather than per-component
 * judgement, and the selector list is the "rather than judgement" part: an
 * interactive element gets the floor whether or not its author remembered the
 * class — which is also why the class name must not appear in this comment, or
 * the test's block extraction would start here instead of at the selector. */
.tap,
button, [role="button"], a[href], summary,
input:not([type="hidden"]), select, textarea {
  min-width: var(--tap);
  min-height: var(--tap);
}
/* A control inside a line of prose must not be 44 px wide, so opt out
 * explicitly and only where the target is genuinely a word in a sentence.
 *
 * The doubled class is not a typo and not a style: the floor above matches
 * `a[href]`, whose specificity (0,1,1) BEATS a single class, so `.tap-inline`
 * alone loses the cascade on exactly the elements it exists for — the shell's
 * Settings and Help links, measured in the Browser pane at the full floor width
 * before this selector was doubled. Keeping the class twice makes it (0,2,0) for
 * every element rather than special-casing the anchor. */
.tap.tap-inline { min-width: 0; min-height: var(--tap); }

/* --- no double-tap zoom on a control -------------------------------------- */
/* Loop 10: two quick taps on a card or a button zoom the page on a phone, in the
 * middle of a round. `manipulation` drops only the double-tap gesture on these
 * elements; pinch zoom is the reader's and stays (SC 1.4.4). */
button, [role="button"], a[href], summary, label, select, input:not([type="hidden"]) {
  touch-action: manipulation;
}

/* --- focus ---------------------------------------------------------------- */
/* Keyboard only. A mouse user clicking a card should not get a ring; a keyboard
 * user tabbing to it must. §9.1 asks for 2 px in the role colour at >= 3:1
 * against its neighbour, which tokens.css's test verifies for all three roles.
 *
 * NO GLOW HERE, on purpose. §7.7 rules that focus stays a solid outline and
 * nothing else: a halo on every focusable element would be paint work on all 21
 * activity cards for no information, and a soft ring beside a hard one is exactly
 * how a user learns to read the soft one as focus when it means selection. The
 * outline is painted after box-shadow, so where a selected card is also focused
 * the solid ring lands on top of the halo and stays crisp. */
:focus-visible {
  outline: var(--focus-width) solid var(--role);
  outline-offset: var(--hairline);
}
/* The ONLY permitted `outline: none`. Stripping the default ring in a rule that
 * does not immediately hand the job to :focus-visible is how a codebase loses
 * keyboard operability, so base.css's test rejects any other form. */
:focus:not(:focus-visible) { outline: none; }

/* --- helpers -------------------------------------------------------------- */
/* Available to a screen reader, not to the eye. Used for chart data tables, for
 * the word that accompanies a state glyph when the glyph is decorative, and for
 * the live-region text §9.1 requires on the timer and notifications. */
.vh {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.skip-link {
  position: absolute;
  left: var(--space-1);
  top: calc(var(--space-1) * -8);
  z-index: 10;
  padding: var(--space-1) var(--space-2);
  background: var(--surface);
  border: var(--focus-width) solid var(--role);
  border-radius: var(--radius);
  transition: top var(--motion-fast) var(--easing);
}
.skip-link:focus { top: var(--space-1); }

/* --- the shell ------------------------------------------------------------ */
/* One implementation, three themes. Header, content, footer; the footer is the
 * action slot and sticks, because the primary action must be reachable without
 * scrolling past a list of 21 activities. */
/* The page wash is the depth layer's only gradient: a radial lift at the top of
 * the page in the role colour, from the approved mockup. It is on the shell and
 * not on html, so it scales with the document rather than needing
 * background-attachment: fixed — a fixed attachment forces a full-page repaint on
 * every scroll frame, which is the single worst thing to hand a low-end Android
 * phone. Cards and fields are opaque, so the wash reaches only text drawn
 * directly on the page; §7.7 records what that costs in contrast. */
/* FOUR ROWS, EACH CHILD PLACED (loop 7, browser pass at 375 px). The template was
 * `auto 1fr auto` from before .shell-notice sat between the head and <main>: with
 * a notice on screen the NOTICE took the 1fr, and on a short screen one toast
 * grew to 372 px of empty box above the content. Placed by row, so a hidden
 * (empty) notice leaves its auto row at 0 and <main> always takes the rest. */
.shell {
  display: grid;
  grid-template-rows: auto auto 1fr auto;
  min-height: 100vh;
  min-height: 100dvh;
  background-image: var(--wash-page);
  background-repeat: no-repeat;
}
/* The header WRAPS, and that is Task 14's ruling rather than a default. Measured
 * in the Browser pane on /player/round/activities — the longest player title in
 * en.json — with the pseudo-locale's 40 % inflation:
 *
 *   nowrap, 360 px:  scrollWidth 395 against a 360 px viewport. 35 px of
 *                    horizontal overflow, which is an SC 1.4.10 (Reflow) failure,
 *                    and the h1 crushed to 79 x 114 px: four lines of eight
 *                    characters between the chip and the Settings link.
 *   nowrap, 360 px, ENGLISH: no overflow, but the same title is 78 x 86 px —
 *                    three narrow lines. The cramped title is not a long-string
 *                    problem; the overflow is only where it stops being survivable.
 *   wrap, 360 px:    no overflow, h1 328 x 29 px. One readable line. The header
 *                    grows from 118 to 166 px, and that is the whole cost.
 *   wrap, 600 px, ENGLISH: 76.67 px, byte-identical to nowrap — wrap costs
 *                    nothing where the row already fits, which is every English
 *                    screen above the phone breakpoint.
 *
 * A header cannot shrink past its own tap targets, so at some string length the
 * only alternatives are to overflow sideways or to grow downward, and growing
 * downward is the one that is not a conformance failure. The flex-basis below is
 * the second half: flex decides where to wrap from an item's UNSHRUNK width, so
 * without it a title one pixel too wide takes a whole row anyway and the wrap
 * point would be luck. Giving the h1 the full line under 600 makes the phone
 * layout deliberate — chip, then title, then the chrome row — and handing it back
 * to `0 1 auto` from 600 restores the single row wherever there is room for one. */
.shell-head {
  grid-row: 1;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2);
  border-bottom: 1px solid var(--line);
}
.shell-head > h1 { flex: 1 1 100%; }
.shell-head-spacer { flex: 1; }
/* The header's chrome controls never shrink. They are flex items, so the tap
 * floor's min-width was the only thing holding them open and a label wider than
 * the floor spilled out of its own box — measured at the reference width, where
 * the settings link shrank to the floor while its word stayed 13 px wider, so the
 * visible target and the visible word disagreed. Every string in a long-string
 * language does the same. The h1 is what gives instead, because a heading may wrap
 * and a target may not. */
.shell-head > .tap { flex: none; }
/* A stack of Toasts, ruling 7's mechanism: unseen persist until seen, so more
 * than one can be showing at once. Grid, not flex — a stack reads top to
 * bottom regardless of how many are showing. Empty by default, like the
 * timer slot in the header beside it — :empty is what keeps an unused stack
 * from reserving space no screen without a notice needs. */
.shell-notice { grid-row: 2; display: grid; gap: var(--space-1); }
.shell-notice:empty { display: none; }
/* Finding 110: a grid item's automatic min-width is its content's min-content
 * size, so O1's 900px row list forced this whole track wide and the page
 * scrolled instead of .arch-o1's own overflow-x container. min-width: 0 lets
 * this track shrink to the viewport; the overflow is caught below it. */
.shell-main { grid-row: 3; padding: var(--space-2); min-width: 0; }
/* Outlier 2 (Task 17) — ruling 6: not an archetype, its own width
 * assumption, host green. No .tap anywhere in this section: nothing here is
 * tappable, the one screen CLAUDE.md's >= 44 px floor exempts by name.
 *
 * THE ROW HEIGHT IS A MEASURED BUDGET, and the plan's own arithmetic was out
 * by 600 px. Ten rows of --type-jumbo at the BODY leading (1.5) make a
 * 132 px row and a 1,464 px list; measured in Chrome at 1920x1080 the
 * document scrolled to 1,680 px inside a 1,080 px frame, and nobody scrolls a
 * projector. The rows therefore carry .t-display in markup — the display
 * leading is 0.86, which is the right treatment for a number this size anyway
 * — and the list gap is --space-1. The frame then budgets:
 *
 *   1,080  the projected frame
 *     -61  .shell-head AT THE WALL'S OWN WIDTH. It was 77 until the spacing
 *          pass hid the header's two chrome links from 1920 up — see the
 *          1920 block below. They are 44 px tap targets and were therefore
 *          the tallest things in a header whose next tallest item is a
 *          28.5 px <h1>, so removing them gives the list 16 px back.
 *     -48  .shell-main's own --space-3 padding, top and bottom
 *     -63  this block's --space-3 gap (24) plus the foot AT ITS TALLEST (39)
 *   =  908 for the list, against 10 x 76 + 9 x 8 = 832. 76 px spare, and 74 for
 *          the fifteen-row band. Measured after the change: the board's bottom
 *          edge sits at 972.34 in the 1,080 frame with the foot showing "Live".
 *
 * THE FOOT IS BUDGETED AT ITS TALLEST, and the first draft of this comment was
 * not: measured, it is 29 px showing "Live" and 39 px once the connection
 * badge appears, because the badge adds its own padding and border to the
 * 19 px line. Budgeting the 29 claims 10 px of spare that is not there. The
 * conclusion survives either way, but a comment that presents an estimate as a
 * measurement is the thing this project keeps having to go back and fix.
 *
 * The board itself adds NO padding of its own: .shell-main already pads it,
 * and a second --space-3 inside the first was 48 px of the budget above spent
 * twice.
 *
 * ONE THING SITS OUTSIDE THIS BUDGET ENTIRELY: .shell-notice, between the head
 * and .shell-main. A toast is unbounded in height and nothing here reserves
 * room for it, so a notice addressed to the host while the wall is up would
 * push the last row off the bottom PERMANENTLY — nobody is standing at a
 * projector to dismiss one. Safe today only because all three producers of a
 * notification target teams (pb_hooks/lib/sync.js), which is a property of
 * today's intents and not of this screen. A host-directed notification is
 * where someone has to come back to this.
 *
 * THIS BUDGET IS SINGLE-LINE ROWS ONLY, and only holds from 900px up —
 * apps/host/leaderboard.js's own projection boundary, the same one
 * .leaderboard-exit uses. The 900px minimum-width breakpoint block, a few
 * thousand lines below this one, reverts Ruling 24's wrap to that single
 * line at every width that could be the wall for exactly this reason
 * (findings 169/N1/N4 — a wrapped row is taller than 76px and this budget
 * has no spare for one). Below 900 the row may wrap and the budget does not
 * apply; nothing below that width claims the fifteen-row band fits in one
 * frame. */
.leaderboard { display: grid; gap: var(--space-3); }
.leaderboard-list { display: grid; gap: var(--space-1); }
/* THE LAYOUT IS A FUNCTION OF TEAM COUNT — three bands, declared in
 * apps/host/leaderboard.js and selected here by attribute. The first draft of
 * this screen rotated ten-row pages at every size, which meant that at the
 * product's own stated scale of thirty teams a team was off the wall for 16 of
 * every 24 seconds — and "is my team above yours right now" is the entire job
 * of a display wall. It also left 1,872 px of frame carrying one column of
 * names. Two columns of fifteen hold all thirty permanently. Rotation stays,
 * as the honest answer for the case it was actually needed for: past thirty.
 *
 * Filled DOWN the left column and then down the right, which is how a ranked
 * list is read. grid-auto-flow: column with an explicit row count is what
 * produces that; the row count is not optional, because without it the browser
 * makes one row per item and the columns come out as pairs. */
.leaderboard-list[data-columns="2"] {
  grid-auto-flow: column;
  grid-template-columns: 1fr 1fr;
  column-gap: var(--space-5);
}
.leaderboard-list[data-rows="10"] { grid-template-rows: repeat(10, auto); }
.leaderboard-list[data-rows="15"] { grid-template-rows: repeat(15, auto); }
/* THE TYPE ROLE IS ON THE ROW, not on the three spans inside it, and that is
 * load-bearing rather than tidy: an em unit resolves against the font-size of
 * the element it is written on, so the name's own 4em flex-basis below (and,
 * before Ruling 24, the rank column's `2ch`) scale with the row's own size —
 * at the 16 px body size a rank of 88 px type ran 4 px into the team's own
 * name, measured at 1920 with a two-digit rank, before this rule moved. */
/* Ruling 24: FLEX, not grid, and flex-wrap is the mechanism — a grid track
 * either fits on the row or forces the row wider; a flex line drops an item
 * that does not fit onto a new line inside the SAME element, with no media
 * query naming a width. Rank and name stay on one line (name shrinks first,
 * down to its own min-width: 0). The score — plain number, or a corrected
 * team's badge beside it — moves to its own line only when it does not fit
 * beside them, via margin-inline-start: auto so it still reads flush right.
 * min-width: 0 on the row itself is still needed: it is a grid item of the
 * two-column list above, and the same min-width:auto propagation as 110 and
 * 168 would force that outer column wide rather than wrapping in here. */
.leaderboard-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 var(--space-3);
  min-width: 0;
}
.leaderboard-rank { flex: 0 0 2ch; }
/* min-width: 0 lets the flex item shrink at all; overflow-wrap: anywhere is
 * what makes that a WRAP rather than the old truncate-to-nothing — a name
 * this narrows still reads, across two or three lines, instead of losing
 * itself to an ellipsis at 0 px. flex-basis 4em is Ruling 24's floor — a name
 * does not surrender the row to the score before it has to. */
.leaderboard-name { flex: 1 1 4em; min-width: 0; overflow-wrap: anywhere; white-space: normal; }
/* flex: 0 1 auto (not 0 0 auto): the score's own hypothetical width can still
 * shrink on the line it shares with rank and name, so a short plain score
 * stays put; a badge wide enough that shrinking would crush it below its own
 * min-content is what pushes it to wrap onto its own line instead — the flex
 * algorithm's ordinary behaviour, not a rule naming a breakpoint. */
.leaderboard-score {
  flex: 0 1 auto;
  margin-inline-start: auto;
  text-align: end;
  overflow-wrap: anywhere;
}
/* The correction mark rides beside the number it applies to. Sized up from
 * chrome, because a 13 px badge next to an 88 px number is not a mark anybody
 * in the room can read, and given its tracking back because the display type
 * it sits inside carries -4.2 %. It borrows no colour (.badge-corrected keeps
 * --ink), so this introduces no pair the token audit has not already checked. */
.leaderboard-score .badge { font-size: var(--type-h1); letter-spacing: normal; }
/* Small, in the corner, never competing with the jumbo numbers — but sized so
 * the host standing at the laptop can read it, which is the whole reason it
 * exists. --mute is 5.40:1 on the canvas, so growing it costs no contrast. */
/* THREE CHILDREN SINCE FINDING 16, AND THEY WRAP BETWEEN THEMSELVES RATHER THAN
 * INSIDE THEMSELVES. Measured in a real browser at 375 px with the longest
 * content this row can hold — "Page 12 of 12", "Last update 120 min ago", the
 * warn badge and "Backup connection", which is the venue case and not a corner
 * one: without `flex-wrap` the row cannot break, so flex shrank all three and
 * each one wrapped internally to 114 / 86 / 67 px of ragged text — a 114 px foot
 * and a 20 px HORIZONTAL PAGE SCROLL (scrollWidth 395 against clientWidth 375).
 * With it: two rows, every child on one line, 83 px, scrollWidth 375. A column
 * at this width was measured too and is 128 px for the same content.
 *
 * It is in the base rule and not in a `max-width` query because it is a no-op
 * wherever the row fits: at 768 px and at 1920 px the three children measure
 * 607 px of a 768+ px row, so nothing wraps and the wall is byte-identical to
 * what ruling 6 approved. 375 px is in scope for this screen by the same
 * reasoning .leaderboard-exit below is: host.live opens this screen one tap away
 * on whatever the facilitator is holding, which §7.5 says may be a phone. */
.leaderboard-foot {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--type-h2);
  color: var(--mute);
}
.leaderboard-foot .badge { font-size: var(--type-h2); }
.leaderboard-mode { display: flex; align-items: center; gap: var(--space-1); }
/* Freshness, beside the mode and deliberately looking nothing like it: label
 * then value, no badge and no state colour, because finding 16's measurement
 * supports a FACT and not a verdict (core/transport.js's health() answers
 * `stale: null`). Same flex row shape as .leaderboard-mode so the three feet
 * items sit on one baseline. */
.leaderboard-fresh { display: flex; align-items: baseline; gap: var(--space-1); }
/* Loading, empty and error. §11's four states on the one screen with nobody
 * standing at it, so they are set at display size like everything else here. */
.leaderboard-state { display: grid; gap: var(--space-2); }
/* THE ONLY INTERACTIVE THING ON THIS SCREEN, and it exists only where the
 * screen is not a wall. Ruling 6's "no interaction, exempt from the tap floor"
 * rests on nobody standing at a projector; Task 19b put this screen one tap from
 * host.live on a phone, where Settings and Help are viewless and the browser's
 * own control was the only way back. Hidden from 900 px in the responsive block
 * below, so at the wall's width it generates no box and cannot take focus —
 * measured, .focus() on it leaves activeElement on <main>. It stays in the DOM;
 * "not rendered" would overstate it. Not focusable is the property the ruling
 * rests on, and it holds where the ruling applies.
 * It keeps the tap floor, because below 900 px it is a phone control like any
 * other and the exemption does not reach it. */
.leaderboard-exit { display: flex; margin-top: var(--space-3); }
/* THE EIGHTH FLEX ROW THAT HOLDS BUTTONS, AND THE ONLY ONE THAT WAS NOT PINNED.
 * Ledger finding 4 is the same defect closed three times — once for
 * .arch-a/b/c-actions, once for .arch-d/e/f-actions, once for .hostlive-hub —
 * and this row inherited flex's `stretch` right through all three, because
 * nothing rendered it: app.js passed no `actions`, so no screen could show the
 * failure. Latent is not fixed — and it stopped being latent in phase 5 Task 8,
 * which filled this slot with the host's during-event hub (ledger 37: "the
 * principled home is ui/shell.js's own .shell-foot slot"). Had this line not
 * already been here, that one change would have reproduced finding 4 for a
 * fourth time on all sixty screens at once.
 *
 * MEASURED with this line absent, one plain primary Button beside one held
 * Button — the mixed-height pair a .btn-reason span produces, which is exactly
 * host.games's first-run state:
 *
 *   .shell-foot        plain button 44 -> 67.5 px   at 375, 599, 700, 899, 900,
 *                                                   1280 and 1920 — every width
 *   .arch-a-actions    plain button 44 px           the same two children
 *   .arch-d-actions    plain button 44 px
 *   .arch-e-actions    plain button 44 px
 *
 * Not a band: two flex items in a sticky bar do not care how wide the bar is,
 * which is the same argument .arch-b > .btn-wrap records. */
.shell-foot {
  grid-row: 4;
  position: sticky;
  bottom: 0;
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  padding: var(--space-2);
  background: var(--canvas);
  border-top: 1px solid var(--line);
}

/* --- shared.mediaGallery (Task 19) ----------------------------------------- */
/* The one place this client renders an <img>. The element default at the top of
 * this file already caps max-width, so what is load-bearing here is `display:
 * block`, which removes the inline baseline gap under every thumbnail, and
 * `height: auto`, which keeps the ratio once the width is capped. No shadow and
 * no glow: this is an item a list repeats on a low-end Android phone, which
 * §7.7 rules out by name.
 *
 * THE BOX IS RESERVED BY THE width AND height ATTRIBUTES, not by an
 * aspect-ratio here — every thumbnail is exactly 640x480 because the server
 * crops it (see pb_migrations/1785802800), so the markup can state the real
 * size and the UA derives `aspect-ratio: auto 640 / 480` from it. Measured in a
 * 328 px column: 328x16 reserved without them, 328x246 with. Without that the
 * page collapses to nothing until the images decode, the scroll jumps as each
 * one lands, and — because a collapsed page puts every image inside the
 * lazy-load threshold — loading="lazy" stops loading on demand and becomes a
 * cascade, which is the low-end-Android case CLAUDE.md names by hand.
 *
 * object-fit is the belt on the one silent failure this design can still meet:
 * if the declared thumb size ever falls out of the field's `thumbs` list,
 * PocketBase answers the FULL-SIZE ORIGINAL with a 200, and the two attributes
 * above would squash it to 4:3. Cropping is the better wrong answer than
 * distorting. */
.mediagallery-item { display: grid; gap: var(--space-1); margin: 0; }
.mediagallery-thumb {
  display: block;
  max-width: 100%;
  height: auto;
  object-fit: cover;
  border-radius: var(--radius);
}
/* Label-plus-value on one line, the same shape as .arch-d-fact and never a
 * sentence. It wraps rather than truncating, because it is the item's only
 * accessible name. overflow-wrap: a host-typed caption can hold an unbroken run
 * (a URL); measured, 298 such characters widened the page to 2,859 px. */
.mediagallery-caption { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1); overflow-wrap: anywhere; }
/* A grid and not a flex row. The file input, two checkboxes and the button are
 * four controls of different heights, and .arch-e-actions' inherited
 * `align-items: stretch` was on record as stretching a button to 72 px beside a
 * field — fixed for that row in phase 2.5 Task 6, and this block stays a grid
 * regardless: one column, one gap, nothing to stretch.
 *
 * No margin-top: as of Task 19b this block is a flex item of .arch-a-actions,
 * which carries that margin for the whole row. Both applied and the gap above
 * the upload block measured 48 px against every other action row's 24. */
.mediagallery-upload { display: grid; gap: var(--space-2); }
/* The host's per-photo caption and sharing control (phase 5 Task 10): the upload
 * block's grid, set off from the figure above it. */
.mediagallery-edit { display: grid; gap: var(--space-2); margin-top: var(--space-2); }
/* host.gameReview's add-an-expert control. It sits in .arch-d-actions, which is
 * a flex row, so the field and its button are ONE flex item — and two block
 * children with no margin stack flush at 0 px, which is a 44 px field sharing an
 * edge with a 44 px button and is the exact defect test/client.classes.test.mjs
 * exists to sweep for. A min width so it does not collapse to the button's own
 * width when it shares a line with Print, Back and Publish. */
.gamereview-invite { display: grid; gap: var(--space-2); min-inline-size: 14rem; }
/* Finding 138: the named researchers, then the email field and its button, in the same strip. */
.gamereview-researchers { display: grid; gap: var(--space-2); min-inline-size: 14rem; }
.gamereview-researchers ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
/* host.invitations' add-an-invitation control, and it is .gamereview-invite's
 * rule for .gamereview-invite's reason one archetype over: it sits inside
 * .arch-o1-actions, which is a flex row, so the name field, the kind picker and
 * the button are ONE flex item — and three block children with no margin stack
 * flush at 0 px, which is two 44 px controls sharing an edge.
 *
 * NOT MERGED WITH .gamereview-invite into one shared class, deliberately. Their
 * numbers agree today by coincidence rather than by a shared decision: one is a
 * two-control row inside archetype D's action strip, this is a three-control
 * row inside outlier 1's, and the first screen to measure either at the 360 px
 * reference width should be free to move one without moving the other. A
 * duplicated one-line rule is cheaper than a shared name that hides that.
 *
 * PHASE 4 ADDS TWO CSS CLASSES, WHICH THE PLAN'S GLOBAL CONSTRAINTS SAID IT
 * WOULD ADD NONE OF. That claim was already known false when this task was
 * dispatched; this rule and .invitations-disclosure below are the two, and
 * each has its own measured-shape reason above it. */
.invitations-compose { display: grid; gap: var(--space-2); min-inline-size: 14rem; }
/* host.checkIn's two whole-roster controls — the draw and the auto-form — and it
 * is .invitations-compose's rule for .invitations-compose's reason, on the same
 * archetype: both sit inside .arch-o1-actions, which is a flex row, so each
 * group's field(s) and its button are ONE flex item, and block children with no
 * margin stack flush at 0 px, which is two 44 px controls sharing an edge. TWO
 * elements carry it on this screen rather than one, which is what keeps "how
 * many to admit / Draw" from running into "prefix / size / Form teams" when the
 * row wraps.
 *
 * NOT MERGED WITH .invitations-compose, for the reason that rule already gives
 * against merging with .gamereview-invite: the numbers agree today by
 * coincidence rather than by a shared decision, and the first screen to measure
 * one at the 360 px reference width should be free to move it without moving
 * the others. */
.checkin-compose { display: grid; gap: var(--space-2); min-inline-size: 14rem; }
/* host.checkIn's team-and-code cell, drawn through outlier 1's `render` seat.
 * .invitations-link's rule for .invitations-link's reason, which ui/print.css
 * records: .arch-b-field is hidden on paper — right for an input, wrong for a
 * credential — and an auto-formed team's code is the one thing on this screen
 * paper is for, because team.autoForm mints teams no student was handed a code
 * for. */
.checkin-team { display: grid; gap: var(--space-1); }
/* host.checkIn's personal-link cell (finding 181): .checkin-team's rule for its
 * reason — not .arch-b-field, which paper hides. */
.checkin-link { display: grid; gap: var(--space-1); }
/* Finding 282(g): a row's hint is for the printed card (print.css shows it);
 * on screen .checkin-legend says the two sentences once above the roster. */
.checkin-card-hint { display: none; }
.checkin-legend { display: grid; gap: var(--space-1); }
/* The standing no-delete / no-revocation disclosure, above the rows in every
 * state that has them. Flex with a gap and NOT two bare block siblings, for
 * .arch-o1-status's own recorded reason: htm strips the whitespace-only text
 * between two tags, so a badge and a sentence sitting inline would fuse into
 * one string that reads as neither. Wrapping because a long-string language
 * plus a two-sentence disclosure does not fit on one line, and sideways
 * overflow is an SC 1.4.10 failure.
 *
 * NO box-shadow and no rim. This is not a repeated row, so the venue argument
 * does not apply — but it is also not a WARNING about a state the screen is in:
 * it is a standing fact about what the product cannot do, and giving it
 * .arch-o1-narrow's dashed --warn rim would make the ordinary case look broken
 * every single time the screen loads. The StateBadge beside it carries the
 * glyph and the word, so nothing here rests on colour. */
/* host.invitations' personal-link cell, drawn through outlier 1's `render` seat.
 * It is .arch-b-field's rule and deliberately NOT .arch-b-field: ui/print.css
 * hides every field wrapper in the client — right for an input, which prints as
 * an empty box with a label, and wrong for a credential, which is the reason
 * the page is being printed at all. Same grid, same gap, so a link cell and the
 * four fields beside it line up in .arch-o1-row-fields' own column grid. */
.invitations-link { display: grid; gap: var(--space-1); }
.invitations-disclosure {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin-bottom: var(--space-3);
}
/* The checkbox and its word travel together, and the whole label is the target
 * — which is what gets a 16 px box to the >= 44 px floor without growing the
 * box itself. `align-items: center` and not baseline: a checkbox has none. */
.mediagallery-check { display: flex; align-items: center; gap: var(--space-2); min-height: var(--tap); }

/* --- archetype frames ----------------------------------------------------- */
/* A · status -> list -> action. The stats header, then a scrollable list of
 * selectable cards, then one primary action in the shell footer. */
/* 140px is the largest minimum that still fits TWO tiles on a 320 px phone —
 * see .arch-e-kpis below, which carries the arithmetic and the sweep. The two
 * rules are the same tile grid and deliberately share the number. */
.arch-a-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: var(--space-1);
  margin-bottom: var(--space-3);
}
.arch-a-list { display: grid; gap: var(--space-1); }
/* A's action row and C's — the two missing members of a set of five.
 * .arch-d-actions, .arch-e-actions and .arch-f-actions are three identical
 * rules. A never had one because until Task 19b every archetype A screen passed
 * a single Button; C has never had one at all, even though
 * apps/expert/briefing.js has emitted the class since Task 9. Two .btn-wrap
 * children are two block grids with no margin and there is no owl rule in this
 * file, so a pair stacks flush at 0 px — measured, briefing's Skip and Next
 * buttons at the 360 px reference width, two 44 px tap targets sharing an edge,
 * which is a mis-tap. The same defect was measured in apps/host/live.js, where
 * an input's bottom edge and a button's top edge both sat at 506 px.
 *
 * align-items: flex-start AND NOT THE FLEX DEFAULT. `stretch` grows a short item
 * to the height of the tallest on its line, and .arch-e-actions was on record
 * for it — .mediagallery-upload's own comment below cites the 72 px button it
 * produced beside a field. Phase 2.5 Task 6 gave the declaration to
 * .arch-d/e/f-actions too, so all six action rows now carry it and ledger
 * finding 4 is closed for every archetype rather than for the three that had
 * been reached first.
 *
 * MEASURED ON shared.mediaGallery, the one screen putting mixed heights in this
 * slot (an upload grid, a notice, a Back link). With this line deleted:
 *
 *   viewport 700     Back    44 -> 77.3 px   notice and Back share a line
 *   viewport 800     Back    44 -> 77.3 px
 *   viewport 880     notice 114 -> 263.5 px  upload and notice share a line;
 *                                            Back is alone and unaffected
 *   viewport >= 900  nothing: .arch-a becomes a 300 px grid column, so all three
 *                    wrap and no two items ever share a line
 *   viewport <= 600  nothing: each item is already alone on its line
 *
 * AN EARLIER VERSION OF THIS COMMENT SAID THE DEFECT WAS NOT REPRODUCIBLE, on
 * three samples — 375, 465 and 1400 — that all miss the band. It is bounded by a
 * grid column above and by wrapping below, so neither edge is a width anybody
 * would think to sample, and three points are not a sweep. test/ui.base.test.mjs
 * pins this declaration with those numbers, because deleting it failed nothing.
 *
 * .arch-b-actions is the sixth member. Archetype B has no action slot on
 * purpose — a form's way FORWARD is its submit — but a form has no way BACK
 * either. It sits outside <form> for that reason: whatever it holds is a
 * navigation control, or a second act of its own, and must never be mistaken
 * for a second submit.
 *
 * THE "BELONGS TO ONE SCREEN, player.round.funding" THIS REPLACES WAS STALE and
 * had been for two phases: counted at Task 14, the class has SEVEN consumers —
 * player.round.funding, player.join, player.register, expert.deal.compose,
 * expert.feedback, expert.vote and now host.builder.publish, whose Export and
 * builder nav sit here because Publish is the submit. A comment naming a count
 * is a comment somebody has to recount; this one names the rule instead. */
.arch-a-actions, .arch-b-actions, .arch-c-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--space-2);
  margin-top: var(--space-3);
}

/* Finding 115: who a personal-slot screen answers as, and "Not you?" beside it. */
.personal-who {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin-bottom: var(--space-3);
}

/* B · form. One column at the reference width; labels above fields, never
 * beside them, because a placeholder-as-label disappears the moment it is
 * typed into and a beside-label wraps badly in a long-string language. */
.arch-b-form { display: grid; gap: var(--space-3); max-width: 560px; }
.arch-b-field { display: grid; gap: var(--space-1); }
/* THE SUBMIT, WHICH SAT FLUSH AGAINST THE LAST FIELD ON ALL FOURTEEN ARCHETYPE B
 * SCREENS FROM 0c UNTIL PHASE 2.5. ui/archetypes/b.js renders the submit OUTSIDE
 * the <form> — deliberately, so a form's own gap is the field rhythm and nothing
 * else — and .arch-b is a bare <section> with no layout, so the two are block
 * siblings with no margin between them. Identical in kind to
 * .arch-b-summary-stats below, whose comment already says it: the form's gap
 * never reaches a sibling.
 *
 * MEASURED, five screens at 375 px, before this rule existed. On player.join,
 * player.register, host.signIn and shared.settings the form's bottom edge and
 * the button's top edge were BOTH at 349.00; on expert.invite both at 253.50.
 * The number that matters is the one below it: the last INPUT's bottom edge and
 * the submit's top edge were the same pixel on all five, so two 44 px tap
 * targets shared an edge — and on player.join, the door every student at the
 * event walks through on a venue phone, aiming low at the code field submits it.
 *
 * NOT A BAND, AND THAT WAS CHECKED RATHER THAN ASSUMED. Ledger finding 4's
 * lesson is that a layout defect can hide between the widths anybody samples, so
 * player.join was swept at 320, 360, 375, 414, 599, 600, 700, 768, 899, 900,
 * 1024, 1279, 1280, 1440 and 1920 — every breakpoint and both sides of each. The
 * gap was 0 at all fifteen, because two block siblings do not care how wide they
 * are. That is the argument for one declaration rather than a media query.
 *
 * --space-3 and not --space-2: it is the form's OWN field rhythm, so the submit
 * sits one field-gap below the last field rather than inventing a spacing. */
/* FINDING 46 — AND THE max-width IS THE SECOND HALF OF THE SAME STRUCTURAL
 * CAUSE. `.arch-b-form` caps itself at 560 px and the submit is rendered OUTSIDE
 * the <form>, so nothing the form declares reaches it: the button tracked the
 * whole content column, 2.2x the width of the fields it submits. Measured on
 * player.join at 1280 px, before and after:
 *
 *                       before    after
 *   .shell-main          1280      1280
 *   .arch-b-form          560       560
 *   its <input>           560       560
 *   .arch-b > .btn-wrap  1232       560
 *   the submit <button>  1232       560
 *
 * NOT A BAND either, and for the reason the margin below records: the cap and
 * the column are two block boxes and the smaller wins at every width. Below
 * 900 px both are already narrower than 560, so nothing on a phone moves.
 *
 * .arch-b-actions IS DELIBERATELY NOT CAPPED, and that was measured rather than
 * assumed. The finding names it as "a third sibling of the same parent, with the
 * same uncapped width" and asks for a measurement first: on player.join at
 * 1280 px the row is 1232 px wide and the only control in it is 262.77 px. A
 * flex row is a container, not a control — its children sit at their content
 * width against the row's start edge — so capping it would change nothing
 * anybody can see. The submit is a GRID item filling .btn-wrap, which is why
 * that one was visible and this one is not. */
.arch-b > .btn-wrap { margin-top: var(--space-3); max-width: 560px; }
/* The error summary above the form. A <p> is display: block, so the risk badge and
 * the count sat flush against each other — measured at exactly 0 px, both edges at
 * 81.38 — and htm strips the whitespace-only text between two tags, so there was not
 * even a space to separate them. The paragraph read "■Problem1 of 1": the state word
 * and the count fused into a string that says neither, which is the one place on an
 * archetype B screen where a glyph and a word have to be legible as a pair.
 *
 * Flex rather than a word-space, because the badge is inline-flex and its own
 * internal separation is already --hairline (.glyph carries it): a text space would
 * not survive the type scale. It wraps for the same reason .arch-f-actions does — a
 * long-string language's "Problem" plus its count does not fit beside a badge on one
 * 360 px row, and sideways overflow is an SC 1.4.10 failure. */
.arch-b-summary {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin-bottom: var(--space-3);
}

/* F · overlay. A bottom sheet at the reference width, a centred modal from 600.
 * Focus-trapped and escape-dismissible — see ui/archetypes/f.js. */
.arch-f-scrim {
  position: fixed;
  inset: 0;
  display: grid;
  align-items: end;
  /* Opaque first (loop 12): color-mix() is Chrome 111+ and Safari 16.2+, and on an
   * older phone the overlay was see-through over the screen beneath it. */
  background: var(--canvas);
  background: color-mix(in srgb, var(--canvas) 82%, transparent);
  /* THE ONLY OTHER z-index IN THIS FILE IS THE SKIP LINK'S 10, AND THIS HAS TO
   * BEAT IT AND THE SHELL FOOTER BOTH. The rule below says "everything under the
   * scrim is inert and focus-trapped out", and that was true for as long as
   * nothing rendered .shell-foot. Phase 5 Task 8 filled it: the footer is
   * position: sticky, so it is POSITIONED with an auto z-index, it is written
   * after <main> in ui/shell.js, and two positioned siblings at auto paint in
   * DOM order — measured on host.lifecycle at 375 px, elementFromPoint over the
   * footer answered `.shell-hub`, so the hub sat ON TOP of the modal and a
   * pointer could navigate straight out of a dialog the focus trap was holding
   * shut. A keyboard user was never affected, which is exactly why this needed
   * a measurement rather than a reading. 20 and not 11, so the skip link and the
   * footer can both move within a decade without meeting it. */
  z-index: 20;
}
/* The one place --elev-2 is used: a sheet or modal is the only thing in the
 * product that genuinely floats. The shadow falls on the scrim, so it cannot take
 * a live control's boundary below 3:1 — everything under the scrim is inert and
 * focus-trapped out. The rim stays --line-strong; the shadow is never the
 * boundary. */
.arch-f-panel {
  background: var(--surface);
  border-top: 1px solid var(--line-strong);
  border-radius: var(--radius) var(--radius) 0 0;
  padding: var(--space-3);
  max-height: 88vh;
  max-height: 88dvh;
  overflow-y: auto;
  box-shadow: var(--elev-2);
}
/* The panel's own header and action row. These two classes are emitted by
 * archetypes/f-parts.js and had NO rule at all, so both fell back to `display:
 * block` and the Close control rendered as a full-width block UNDER the title —
 * measured at 376.77 px wide at the reference width. A dialog whose dismiss
 * control is a banner across the middle of it does not read as a dismiss control.
 *
 * The title is what gives when the pair does not fit, for the same reason it gives
 * in the shell: a heading may wrap and a tap target may not. .btn-wrap is the
 * Button component's own grid wrapper, so it is what has to be pinned, not the
 * button inside it. */
.arch-f-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-bottom: var(--space-2);
}
.arch-f-head > h2 { flex: 1; }
.arch-f-head > .btn-wrap { flex: none; }
/* Wraps, because two confirm actions at the tap floor plus their labels do not fit
 * on one 360 px row in a long-string language, and a row that cannot fit must
 * become two rows rather than scroll sideways inside a dialog. */
/* align-items: flex-start here for the reason the .arch-a/b/c rule above
 * carries it, and this is ledger finding 4 closed for the last three archetypes
 * rather than for the one that happened to meet it. Flex defaults to `stretch`,
 * which grows every item to the tallest on its line: a Button renders a
 * .btn-wrap that gains a second .btn-reason span whenever `disabled` and
 * `disabledReason` are both passed, so one held control beside one plain
 * control makes the plain one 72 px. host.games hits it in its FIRST-RUN state
 * — Clone is held with a reason when there is nothing to clone — and
 * host.gameReview will put a Publish button, a Print button and two links in
 * one .arch-d-actions row. Leaving two of the three would be leaving the same
 * defect with a different name. */
.arch-f-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--space-2);
  margin-top: var(--space-3);
}

/* C · stepper. An ordered list of named steps, aria-current="step" on the
 * team's own position — the ARIA pattern built for exactly this, and why
 * ui/archetypes/c.js does not reuse ProgressBar (see its own header comment).
 * The current item's boundary is --line-strong, the same token .field and
 * .card use for the same reason: SC 1.4.11 wants 3:1 for anything identifying
 * a control, and this is the one boundary telling a sighted user which step
 * they are on. */
.arch-c-steps { display: grid; gap: var(--space-1); margin-bottom: var(--space-3); }
.arch-c-step {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-1);
  padding: var(--space-1);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.arch-c-step-current { border-color: var(--line-strong); }
.arch-c-body { margin-bottom: var(--space-3); }
/* player.teamSetup's step region — a named, focusable group so an IN-PLACE step
 * change is announced (see that module's own comment, and archetype C's header,
 * which this screen is the first to break the precondition of).
 *
 * A grid because it also has to be one: .arch-c-body sets a margin and no gap,
 * so step 1's two .arch-b-field children were block siblings with nothing
 * between them — two 44 px inputs sharing an edge at the 360 px reference
 * width, which is the defect test/client.classes.test.mjs sweeps for one level
 * up. The gap matches .arch-b-form's, because these are the same fields. */
.teamsetup-step { display: grid; gap: var(--space-3); }
/* host.gameSetup's step body, for the same reason (loop 5, browser pass at 375 px):
 * its fields sat in .arch-c-body with no gap, so "Region" met the language select
 * and "Admission" met the line of hint above it. */
.gamesetup-step { display: grid; gap: var(--space-3); }
/* player.questionnaire's step region — the same named, focusable group as
 * .teamsetup-step one line up, and for the identical reason: this screen also
 * advances props.current IN PLACE, so archetype C's "each step is its own
 * navigation" precondition is false here too and the announcement is the
 * screen's own debt. One rule and not a shared class, because the two screens
 * hold different content and the value of the name is that a rule can move
 * without touching the other.
 *
 * A GRID FOR THE SAME MEASURED REASON. .arch-c-body sets a margin and no gap,
 * and this region holds three block children — the low bound, the field and
 * the high bound — which would otherwise share edges. The gap is --space-2
 * rather than --space-3: the two bounds belong TO the field between them, and
 * the same 24 px that separates two unrelated inputs on .teamsetup-step would
 * read as three unrelated things here. */
.questionnaire-step { display: grid; gap: var(--space-2); }
/* player.onboarding's step region: the named, focusable group its in-place step
 * change moves focus to, as .teamsetup-step above. */
.onboarding-step { display: grid; gap: var(--space-3); }
/* §9.2 NAMES QUESTIONNAIRE ITEMS AS BODY TEXT, AND THE FIELD'S LABEL IS THE
 * ITEM. MEASURED IN A BROWSER at 1280x720 before this rule existed: the prompt
 * "I can spot an opportunity others miss." rendered at 13px, because
 * ui/components.js's NumberField renders its <label> as .t-meta — correct for
 * "Team name" or "Founders", where the label is chrome above the thing that
 * matters, and wrong here, where the label IS the question and the input is a
 * single digit.
 *
 * THE LABEL AND NOT A SEPARATE PARAGRAPH, deliberately. Rendering the prompt as
 * a sibling <p> and giving the input a short app label would leave a screen
 * reader announcing "Your answer, edit text" with the question in a paragraph it
 * has to go looking for. The accessible name has to be the question, so the
 * question has to be the label, so the label has to be body text on this screen.
 *
 * Scoped to .questionnaire-step so no other .t-meta moves. The colour moves with
 * the size for the same reason: .t-meta is mute, and the question would then
 * have been the quietest thing on a screen whose only content is the question.
 * The bounds stay mute with an ink VALUE, which is the label-plus-value
 * treatment .arch-d-fact and StatTile already use. */
.questionnaire-step .arch-b-field > label {
  font-size: var(--type-body);
  color: var(--ink);
}
/* Label-plus-value, the same flex row .arch-d-fact is, including the baseline
 * declaration that row's own comment measured: the definition's word for this
 * end of the scale sits left, its number right. No shadow and no border — the
 * item list is a high-volume repeated row on a low-end Android phone, and
 * CLAUDE.md's rule is one layer per shadow and none on anything a list
 * repeats. */
.questionnaire-bound {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-2);
  color: var(--mute);
}
.questionnaire-bound-value { color: var(--ink); }
/* player.teamSetup's lead (finding 283(b)): the sentence and the join door above
 * the fields, a gap between them and below, so neither shares an edge. */
.teamsetup-lead { display: grid; gap: var(--space-2); margin-bottom: var(--space-3); }
/* player.lobby's code block: label, the code in groups, the hint — one column at
 * the start edge like the rest of the lobby (.qr-value centres under a symbol,
 * and there is no symbol here; measured in the pane at 375 px). */
.lobby-code { display: grid; gap: var(--space-1); justify-items: start; }
/* player.round.funding's instruction: body text (it is the screen's one
 * instruction, not chrome), with room before the first field's label, which it
 * met at 0 px (loop 3, browser pass at 375 px). */
.funding-intro { margin-bottom: var(--space-3); }
/* A static warning or refusal sentence with its badge — the `.post` deadline,
 * the no-participant note and a refused submit all use it. NOT a .toast: a
 * toast is a live region and announces itself, and two of these three are
 * standing advice that is true every time the screen renders, so announcing
 * them on every draw is the "unbearable" half of ui/components.js's own note
 * on urgency. The third (a refusal) carries role="status" on the element
 * itself, where it is a real event.
 *
 * The gap is what stops the badge and the sentence running together, which is
 * the measurement .arch-b-summary's own rule records happening at exactly
 * 0 px. */
.questionnaire-note {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-2);
}

/* D · summary panel. Grouped facts as a real <dl> — label-plus-value, never a
 * sentence, and the shape ui/print.css depends on: a <dl> prints with no CSS
 * at all, which is why ui/archetypes/d.js does not build this out of StatTile.
 * The rule below is presentation only, laying the div-wrapped dt/dd pair out
 * as a row; the semantics come from the elements themselves. */
.arch-d-group { margin-bottom: var(--space-3); }
.arch-d-facts { display: grid; gap: var(--space-1); margin-top: var(--space-1); }
/* align-items: baseline, WHICH BOTH OF THIS ROW'S DOCUMENTED SIBLINGS ALREADY
 * DECLARE and this one did not. .mediagallery-caption's own comment calls itself
 * "the same shape as .arch-d-fact" and says baseline; .arch-c-step says it too.
 * This row is the original of that shape and was the only one on flex's default.
 *
 * `justify-content: space-between` does NOT make it academic — that is the main
 * axis and this is the cross axis. MEASURED with the declaration absent, a
 * `Cash / 30,000` pair at every width from 320 to 1920: the <dt> is .t-meta
 * (13 px, a 19.5 px line box) and the <dd> is .t-body (16 px, 24 px), `stretch`
 * grew both boxes to 24 px and started both at the same y, so the label's
 * baseline sat 3.33 px ABOVE the value's on every fact in every archetype D
 * panel. Both children shared a line at all fifteen widths, so there is no width
 * at which it does not apply. */
.arch-d-fact {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding: var(--space-1) 0;
  border-bottom: 1px solid var(--line);
}
.arch-d-fact > dt, .arch-d-fact > dd { margin: 0; }
.arch-d-fact > dd { text-align: end; }
/* TWO DECLARATIONS FOR TWO DIFFERENT OVERFLOWS, BOTH MEASURED IN A BROWSER.
 * `.arch-d-fact` is a grid item of `.arch-d-facts` and its <dd> is a flex item
 * of itself; both default to `min-width: auto`, so neither may shrink below its
 * content and a fact whose value is WIDER than the panel pushes the whole page
 * sideways instead of overflowing inside itself.
 *
 * (1) min-width: 0 — for a value that CANNOT be broken up, which is what an
 * <svg> box is. Latent until host.builder.graph put a 3,224 px diagram in a
 * <dd>: document.scrollWidth was 3,248 px against a clientWidth of 1,009, and
 * 3,240 against 375 at the phone reference width. BOTH SELECTORS ARE NEEDED
 * AND NEITHER ALONE DOES ANYTHING — measured: `.arch-d-fact` alone leaves
 * 3,248 and `> dd` alone leaves 3,248; the pair gives 1,009, with the graph's
 * own container then at 961 client against 3,224 scroll, i.e. scrolling inside
 * itself. `.arch-d-facts` was measured too and adds nothing, so it is absent.
 *
 * (2) overflow-wrap: anywhere — for a value that CAN be broken up and was not,
 * and this one is a defect that predates the graph. host.gameReview's revealed
 * expert credential is a 40-character token in a <code> that does not wrap, so
 * at 375 px that screen ALREADY scrolled the page: 419 px against 375, before
 * anything on this branch was written. min-width: 0 alone improved it to 389
 * and made it worse in one respect — the <dd> is `text-align: end`, so a
 * 403 px unbreakable child inside a 343 px box overflows to the LEFT, and the
 * token's first 14 px went to x = -14, where no amount of scrolling reaches it.
 * With the pair: 375 against 375, the token wraps onto a second line (the row
 * grows 304 -> 328 px) and nothing leaves the viewport in either direction.
 * `anywhere` and not `break-word`, because only `anywhere` lowers the MIN-
 * CONTENT size, which is the number the flex item is measured against.
 * Measured on the real screen through its own mount(), not a replica.
 *
 * AND NEITHER SUBSTITUTES FOR THE OTHER, measured both ways round: with wrap
 * alone the graph is back to 3,240, because an <svg> has no break opportunity;
 * with min-width alone the credential is the -14 px case above.
 *
 * NOTHING ELSE MOVES. A shrink floor and a break opportunity only take effect
 * once min-content already exceeds the available width, which no other
 * archetype D fact reaches: measured before and after at 1,009 px and at
 * 375 px over a right-aligned label-plus-value pair, shared.help's prose
 * (.arch-d-prose), a 22-character unbroken token and a 25-character team name
 * — all four rows identical to the pixel, position included, at both widths. */
.arch-d-fact, .arch-d-fact > dd { min-width: 0; }
.arch-d-fact > dd { overflow-wrap: anywhere; }
/* A SENTENCE IN A <dd>, WHICH THE RULE ABOVE IS WRONG FOR. `text-align: end` is
 * right for the value half of a label-plus-value pair — `Cash · €9,700` reads
 * as a pair only when the number is flush against the opposite margin — and
 * every archetype D screen until shared.help put a number or a short phrase
 * there. Help puts prose there, and prose is what that rule cannot carry.
 *
 * MEASURED at the 375 px reference width before this rule existed: all four of
 * Help's bodies wrapped to the full 343 px column and rendered as 3, 3, 3 and 2
 * line RIGHT-ALIGNED paragraphs, so every line started at an unpredictable
 * horizontal position — the readability failure SC 1.4.8 is written about.
 *
 * A class on the SPAN and not on the <dd>, because ui/archetypes/d.js owns the
 * <dd> and ten other screens depend on its alignment. `display: block` is
 * load-bearing: text-align applies to a block container, so an inline <span>
 * carrying it would change nothing at all. */
.arch-d-prose { display: block; text-align: start; }
.arch-d-actions {
  display: flex; flex-wrap: wrap; align-items: flex-start;
  gap: var(--space-2); margin-top: var(--space-3);
}

/* E · dashboard table. KPI row, filters, a table that is one card per row
 * below 900 px and a real table from it — see ui/archetypes/e.js's own header
 * for why the collapse loses no accessible information. No shadow anywhere
 * in this block: CLAUDE.md's venue constraint forbids one on anything a list
 * repeats, and every row here is exactly that. */
/* 140px IS THE TILE-GRID THRESHOLD AND IT IS EXACT, not a round number — the
 * same value .arch-a-stats above uses, for the same reason and measured the
 * same way. A 320 px viewport (the narrowest this client is designed for) leaves
 * .shell-main 288 px of content, and auto-fit fits two columns while
 * 2 x min + --space-1 <= 288, which is min <= 140. At 141 px a 320 px phone
 * drops to ONE tile per row and the HUD becomes a column. Measured across the
 * sweep: 2 tiles per row at 320, 360 and 375; 3 from 599; 1 at 900 and above,
 * where the responsive block re-declares the track as 1fr inside the rail.
 * A tile's own content does not set it — max-content for `HOURS LEFT / 2,232`
 * measures 89.52 px, so the floor is the layout's, not the label's. */
.arch-e-kpis {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: var(--space-1);
  margin-bottom: var(--space-3);
}
/* ONE STEP AND NOT TWO, WHICH IS PROXIMITY DOING WORK RATHER THAN AN OVERSIGHT.
 * Two chrome rows stack in this header and they are separated differently on
 * purpose: --space-3 below the KPIs, --space-1 x2 below the filters. A filter
 * acts on the TABLE, so it must read as belonging to the table under it and not
 * as the second half of the KPI block above it — the same one-step-not-two
 * argument .round-refused already carries for a block belonging to its toast.
 * Measured, invariant at every width from 320 to 1920: 24 px KPIs-to-filters,
 * 16 px filters-to-table. Written down because until this pass it was two
 * numbers with no reason, which reads identically to an accident. */
.arch-e-filters { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: var(--space-2); }
.arch-e-body { display: grid; gap: var(--space-3); align-items: start; }
/* --space-1 AND NOT --space-2, WHICH IS THE CARD RHYTHM AND NOT THIS TABLE'S OWN
 * CHOICE. Below 900 px .arch-e-row is not a table row at all: it is the same
 * visual object .card is, and that was measured rather than asserted — padding
 * --space-2, background --surface, border 1px --line-strong, radius --radius and
 * an inner gap of --hairline, identical on both, at every width in the sweep.
 * Archetype A stacks that object at --space-1 (.arch-a-list) and so do the four
 * A-family rules below (.arch-activities-group, .arch-attributes-group,
 * .arch-pivot-list) and .arch-c-steps, .arch-d-facts, .arch-snapshot-facts and
 * .leaderboard-list. This rule stacked it at --space-2, so a host moving between
 * host.live and shared.mediaGallery — one tap apart through the hub — saw
 * the same card rhythm double. Both values are on the grid, so no test caught it;
 * test/ui.base.test.mjs now pins the whole set to one token.
 *
 * ONLY BELOW 900 px, and that is measured too: from 900 this element is
 * `display: table` and gap is inert on a table box — the row-to-row distance is
 * 0 at 900, 1024, 1279, 1280, 1440 and 1920 with the declaration reading 16 px
 * the whole time. So this changes the phone and tablet card view, which is the
 * only place the two archetypes ever show the same object. */
.arch-e-table { display: grid; gap: var(--space-1); width: 100%; }
.arch-e-head-row { display: none; }
.arch-e-row {
  display: grid;
  gap: var(--hairline);
  padding: var(--space-2);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
}
.arch-e-row > th, .arch-e-row > td { display: block; text-align: start; }
/* The decoration Task 7's header comment is about: painted in only below
 * 900 px, and turned off above it in the media query, where the real header
 * row has come back and the label would repeat itself. */
.arch-e-row > td[data-label]::before { content: attr(data-label) ": "; font-weight: var(--weight-h2); }
.arch-e-sort { background: none; border: none; padding: 0; cursor: pointer; }
/* THE ONE PLACE IN THE CLIENT WHERE A GLYPH FOLLOWS ITS WORD instead of leading
 * it, so .glyph's own margin-inline-end lands on the wrong side and the sort
 * arrow rendered flush against the column label — measured at 0 px, `Score▲`, at
 * every width in the sweep. The logical shorthand flips it: --hairline before,
 * nothing after, so the button's focus ring stays tight to its content. */
.arch-e-sort > .glyph { margin-inline: var(--hairline) 0; }
.arch-e-actions {
  display: flex; flex-wrap: wrap; align-items: flex-start;
  gap: var(--space-2); margin-top: var(--space-3);
}
/* THE PERSISTENT HUB, a <nav> landmark inside .shell-foot. It was
 * `.hostlive-hub`, a flex item of .arch-e-actions on ONE screen, and phase 5
 * Task 8 moved it into the shell for finding 37: the rule is unchanged and only
 * its name and its parent are, because a hub is a wrapped row of links wherever
 * it hangs. It still needs its own row rule or its four links would stack flush,
 * which is the defect this stylesheet has now fixed sixteen times.
 *
 * NO SHADOW AND NO GLOW. .shell-foot is on every one of the sixty screens and
 * the venue is a low-end Android phone; the footer's own 1px --line top border
 * is the whole of its separation from the content above it. */
/* ONE ROW ON A PHONE, SCROLLING SIDEWAYS (loop 3, browser pass at 375 px): the
 * four links wrapped to three rows and the sticky footer held about 200 px of
 * an 812 px screen over host.live's team cards. Nowrap with its own overflow
 * keeps it to one 44 px row; each link keeps its width. From 600 px it wraps,
 * as before, where the row fits. */
.shell-hub { display: flex; flex-wrap: nowrap; overflow-x: auto; align-items: flex-start; gap: var(--space-2); }
.shell-hub > * { flex: 0 0 auto; }
/* host.live's Status cell, which now always carries the state WORD and gains
 * the Approve control beside it on the rows that have one — see that module for
 * why the column stopped meaning two things. A row rule and not two block
 * children: below 900 px a <td> is display:block by the collapse above and
 * .btn-wrap is a grid with no margin, so a word and a 44 px target inside one
 * cell would stack flush at 0 px. Packed from the start and wrapping, exactly as
 * .arch-meeting-row is and for the same measured reason — pushed apart, the
 * button ends up alone on the second line of a 360 px card and reads as a
 * different control. align-items: center, because the pair is a word and a
 * pill and neither is prose. */
.hostlive-status {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}

/* O1 · row editor (outlier 1, not one of the six). §5.2 declares a 900 px
 * minimum for this archetype; it is DECLARED here, on the row list, and the
 * notice below is what says so on a narrower screen. The rows scroll inside
 * this container rather than the document, which is the rule for every wide
 * thing in this codebase.
 *
 * .arch-o1 GETS A REAL RULE rather than joining client.classes.test.mjs's
 * STYLED_ELSEWHERE list, where its five archetype siblings sit. It is the one
 * archetype root with something to say: the scroll container and the minimum
 * are the archetype, not decoration on it. */
.arch-o1 { display: block; overflow-x: auto; }
.arch-o1 > .arch-o1-rows { min-width: 900px; }

/* ALWAYS IN THE DOM, hidden above the minimum by the media query in the
 * responsive block. Conditional rendering would need a measurement a component
 * cannot make, and it would make the notice untestable without a browser.
 * Dashed and --warn, which is §9.1's warn treatment and the same pair
 * .toast-warn and .badge-warn carry; the ▲ glyph and the word both come from
 * the StateBadge in the markup, because a rim is colour and colour never
 * carries meaning alone. */
.arch-o1-narrow {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
  padding: var(--space-2);
  margin-bottom: var(--space-3);
  border: 1px solid var(--warn);
  border-style: dashed;
  border-radius: var(--radius);
}

/* The archetype's own live region. NO `:empty { display: none }`, for the
 * reason ui/shell.js records for .shell-timer and client.classes.test.mjs
 * repeats: a display:none region is not exposed to assistive technology at all,
 * so it would be created and filled in the same paint and announce nothing. It
 * is empty until a row moves or one is removed, and a flex container with no
 * children generates no line box, so it costs no height until it has something
 * to say.
 *
 * FLEX WITH A GAP, WHICH IS .arch-b-summary'S DEFECT ANSWERED BEFORE IT SHIPPED
 * RATHER THAN AFTER. That rule's own comment records the measurement: a <p> is
 * display: block, htm strips the whitespace-only text between two tags, and two
 * inline children therefore sit at EXACTLY 0 px — "■Problem1 of 1", a state word
 * and a count fused into a string that says neither. A removal announcement is
 * the same shape one archetype over: the app sentence and the removed row's own
 * name are two spans, deliberately (§6.6 — a host-authored noun is rendered
 * BESIDE an app string and never inside it), so without this they would read
 * "Row removedCash". Wrapping for the reason every other row in this file wraps:
 * a long-string language plus a host-authored name does not fit on one line, and
 * sideways overflow is an SC 1.4.10 failure. */
.arch-o1-status {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin: 0;
}

/* A real <ul>, so the count is announced. --space-1 is the card rhythm this
 * stylesheet now pins to one token — see .arch-e-table's own note for the
 * measurement behind it — and a row here is the same visual object .card and
 * .arch-e-row are. No list-style, margin or padding reset: the element defaults
 * at the top of this file already strip all three from every ul, and .arch-a-list
 * is the same rule for the same reason. */
.arch-o1-rows { display: grid; gap: var(--space-1); }

/* NO box-shadow: this is a repeated row and the venue is low-end Android.
 * The rim is --line-strong for SC 1.4.11's 3:1 on a boundary that identifies a
 * group of controls, which is what every card-shaped thing in this file uses. */
.arch-o1-row {
  display: grid;
  gap: var(--space-2);
  padding: var(--space-2);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
}
/* AND IT DOES NOT SPLIT ACROSS A PAGE. The same hint .arch-d-fact and
 * .builder-brief both take, for the same shape and a sharper reason: as of
 * phase 4 host.invitations is an O1 screen whose rows ARE the printed sheet,
 * and that sheet is cut up — a host prints the personal links and physically
 * separates them to hand out. A row whose name lands on one page and whose
 * 40-character link lands on the next is a slip that cannot be cut. `avoid` is
 * a hint the printed-page box model offers rather than a guarantee, and it is
 * the whole of what that model has to give here. Inert on screen. */
.arch-o1-row {
  break-inside: avoid;
  page-break-inside: avoid;
}
/* The armed row — Remove tapped once, Confirm remove showing. Three signals and
 * not one: this rim, the glyph beside the control and the control's own changed
 * word. A solid colour and not a tint, so nothing composited has to be
 * re-measured against --line-strong or --mute. */
.arch-o1-row[data-removing="true"] { border-color: var(--warn); border-style: dashed; }
.arch-o1-row-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); }
/* The fields wrap into as many columns as fit. 200px is a CHOSEN floor and not
 * a measured one — say so rather than imply otherwise, which is this file's own
 * house rule: it is wider than the 140px tile grid because a row's fields carry
 * a label above a full-width control rather than a label above a number, and no
 * builder screen exists yet to measure against. The first screen built on this
 * archetype should check it and write the number down here. Below 900 px
 * nothing reflows anyway — the container scrolls, which is what the minimum
 * above is for. */
.arch-o1-row-fields {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: var(--space-2);
  align-items: start;
}
/* align-items: flex-start for the reason every other action row in this file
 * carries it: flex defaults to `stretch`, and a Button gains a .btn-reason span
 * when it is held with a reason, so the first and last rows' plain controls
 * would grow to the held one's height beside them. That is not hypothetical
 * here — EVERY row has one held control except the ones in the middle. */
.arch-o1-row-actions { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--space-2); }
.arch-o1-actions {
  display: flex; flex-wrap: wrap; align-items: flex-start;
  gap: var(--space-2); margin-top: var(--space-3);
}

/* --- screen compositions inside those frames ------------------------------ */
/* THE ELEVEN RULES THIS FILE WAS MISSING, and they were found by a test rather
 * than by reading. Task 19b promoted the gallery's "every class this module
 * emits is defined in a stylesheet" assertion to sweep all of pb_public/apps/
 * (test/apps.join.test.mjs) after the phase-2 ledger recorded a FIFTH instance
 * of the same defect. The sweep found fifteen, not five, and every one was then
 * measured at the 360 px reference width before a rule was written for it:
 *
 *   .arch-activities-card    card -> its lock-reason line          0 px
 *   .arch-activities-group   card -> card                          0 px
 *   .arch-attributes-group   heading -> card, card -> card         0 px
 *   .arch-meeting-row        name -> Request (44 px target)        0 px
 *   .arch-downtime-row       View snapshot -> Summon (both 44 px)  0 px
 *   .arch-pivot-list         card -> card                          0 px
 *   .arch-snapshot-facts     fact -> fact                          0 px
 *                            and every <dd> indented 40 px by the user agent
 *   .arch-b-summary-stats    submit button -> the cash tile        0 px
 *   .arch-summary-change     one changed value -> the next         0 px
 *   .arch-summary-warning    risk badge -> its own word            0 px
 *
 * The last is the identical failure .arch-b-summary above documents and is the
 * worst of them: htm strips the whitespace-only text between two tags, so the
 * player's over-budget alert read as one fused string where the state glyph and
 * the sentence it qualifies had no space between them at all.
 *
 * Four more classes came out of the same sweep and correctly have no rule:
 * .leaderboard-rank and .leaderboard-page are a grid item and a flex item fully
 * positioned by .leaderboard-row and .leaderboard-foot, and .lock-fact and
 * .arch-pivot-card are single-child wrappers laid out by the two rules directly
 * above. They are named in the test rather than given an empty rule here, so the
 * exception is a list somebody has to edit on purpose.
 *
 * FINDING 58, AND IT WAS SMALLER THAN THE ENTRY SAYS. That entry records this
 * block naming `.arch-activities-fact` twice and `.arch-attributes-fact` once,
 * all three collapsed into one `.lock-fact` by the wrong-word pass. Measured
 * when Task 8 came to fix it: ONE stale name was left in this file, the line
 * above. The other two had already gone with the rules they sat beside. */

/* One activity — or, as of W2, one attribute option — and, when it is locked,
 * the resolved noun beside its reason. The attributes screen renders the same
 * card with the same lock treatment, so it takes the same rule rather than a
 * second one that would drift from it. */
.arch-activities-card, .arch-attributes-card { display: grid; gap: var(--space-1); }
/* A SIBLING MARGIN AND NOT A GRID GAP, which is the one place this file breaks
 * its own no-owl-rule habit, for a measured reason. apps/player/activities.js
 * emits this class on a <div> when a group carries no label and on a <details>
 * when it does — and a <details> wraps everything after its <summary> in an
 * anonymous content box, so `display: grid` gives that element exactly TWO grid
 * items and the gap lands between the summary and the whole block of cards
 * while the cards inside it stay flush. Measured: 8 px summary-to-first-card
 * and 0 px card-to-card. The `+` combinator matches the DOM and not the box
 * tree, so one rule spaces both shapes identically. */
.arch-activities-group > * + * { margin-top: var(--space-1); }
/* One attribute — its heading and its options. A plain <section>, so a grid. */
.arch-attributes-group { display: grid; gap: var(--space-1); }
/* A name and the controls that act on it. NOT space-between: at 360 px the
 * downtime row's three children do not fit, and pushed apart they leave one
 * button alone on the second line reading as a different control. Packed from
 * the start and wrapping, both rows keep the same 16 px separation between two
 * 44 px targets. */
.arch-meeting-row, .arch-downtime-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}
/* A flex item never shrinks below its longest word, so body's break-word could
 * not reach a team name here (loop 18): an 80-character one in an expert's
 * downtime row made a 320 px page 1241 px wide. Measured with this rule: 320, the
 * name wraps, and the Call beside it keeps its 44 px. */
.arch-meeting-row > *, .arch-downtime-row > * { min-width: 0; }
.arch-pivot-list { display: grid; gap: var(--space-1); }
/* player.snapshot's own <dl>, which is not archetype D's and so inherits none
 * of .arch-d-facts. The margin reset is the load-bearing half: `dd` is not in
 * this file's element reset (h1, h2, p, ul, ol, figure are), so the user
 * agent's own 40 px margin-inline-start indented every value away from its
 * label on a 360 px phone. .arch-d-fact zeroes it for the same reason. */
.arch-snapshot-facts { display: grid; gap: var(--space-1); }
.arch-snapshot-facts dt, .arch-snapshot-facts dd { margin: 0; }
/* player.achievement's earned list: one .card per achievement, no bullets. */
.arch-achievements { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
/* The cash tile below the funding form. A sibling of .arch-b-form and not a
 * child of it, so the form's own gap never reaches it. */
.arch-b-summary-stats { margin-top: var(--space-3); }
/* Consecutive changed values. Two <p>s at the same size with no separation read
 * as one wrapped paragraph rather than as two facts. */
.arch-summary-change { margin-top: var(--space-1); }
/* player.register's consent block, in archetype B's beforeSubmit slot between
 * the fields and the "I agree" button. Two <p>s, and the element reset above
 * zeroes p's margin — so with no rule the marker line and the consent body
 * stack flush and read as one wrapped paragraph, which is the
 * .arch-summary-change defect on the one block in the product a person is being
 * asked to agree to. The top margin clears .arch-b-form for the reason
 * .arch-b-summary-stats already records: a form's own gap does not reach a
 * sibling. It is --space-3, the form's own field rhythm, so the block sits one
 * field-gap below the last field and one above the submit. */
.consent-block { display: grid; gap: var(--space-2); margin-top: var(--space-3); }
/* A game's own text keeps a host's single line breaks (finding 163, review minor 7). */
.consent-block p { white-space: pre-line; }
/* Flex and not a word-space, exactly as .arch-b-summary and
 * .arch-summary-warning: the badge is inline-flex and separates its own glyph by
 * --hairline, so a text space would be a font metric and would not survive the type
 * scale. It wraps because "Placeholder wording, awaiting confirmation" does not
 * fit beside a badge on one 360 px row in any language, and sideways overflow
 * is an SC 1.4.10 failure. */
.consent-marker {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
}
/* apps/round.js's RoundNotice, above the archetype on all ten round screens.
 * It can hold TWO toasts at once — the standing "waiting for the host" state
 * and a transient refusal — and .toast is a block, so without a gap the two
 * live regions stack flush and read as one message. Space below it as well,
 * because the archetype it sits above declares no top margin of its own. */
.round-notice {
  display: grid;
  gap: var(--space-2);
  margin-bottom: var(--space-3);
}

/* WHAT THIS ROUND DEALT THE TEAM — the deck primitive's one player-facing
 * surface. A grid for the reason .round-notice above is one: consecutive blocks
 * at the same size with no separation read as one wrapped paragraph rather than
 * as separate cards.
 *
 * NEUTRAL, AND THAT IS THE FIX ROUND'S CORRECTION. It carried a dashed --warn rim
 * and a "▲ Warning" badge, on EVERY card including a positive one: the suite's
 * own fixture reads "A small grant · Cash +500". A state treatment claims a
 * meaning, and nothing here can know one — the deck schema declares no
 * `severity` (gameEvents does; a card does not), and inferring polarity from an
 * effect's sign would guess, wrongly for `set`, for `clampMin`, for an empty
 * effects list and for a card moving two resources opposite ways. So this is the
 * ordinary card treatment, the same --line-strong rim .card carries, which claims
 * nothing at all. A declared severity is deferred finding 80's second half.
 *
 * NO SHADOW AND NO GLOW, and that is the venue rather than taste: this renders
 * inside a scrolling column on a low-end Android phone, and CLAUDE.md's depth
 * layer forbids a shadow on anything a list repeats. */
.card-drawn { display: grid; gap: var(--space-2); margin-bottom: var(--space-3); }
.card-drawn-item {
  display: grid;
  justify-items: start;
  gap: var(--hairline);
  padding: var(--space-2);
  overflow-wrap: break-word;
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
}
/* W3's refused-decision block: one warn toast and, under it, the decisions that
 * were not applied as label-plus-value. A grid for exactly the reason
 * .arch-summary-change is: consecutive <p>s at the same size with no separation
 * read as one wrapped paragraph rather than as a list of facts. The gap is one
 * step, not two, so the block reads as belonging to the toast above it and not
 * as a third message. */
.round-refused { display: grid; gap: var(--space-1); }
/* Flex and not a word-space, exactly as .arch-b-summary above: the badge is
 * inline-flex and separates its own glyph by --hairline, so a text space would be
 * a font metric and would not survive the type scale. It wraps because a
 * long-string language's over-budget sentence does not fit beside a badge on
 * one 360 px row, and sideways overflow is an SC 1.4.10 failure. */
.arch-summary-warning {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin-bottom: var(--space-3);
}
/* Finding 251: one .arch-summary-warning row per overspent resource. */
.arch-summary-overspent { display: grid; gap: var(--space-1); margin-bottom: var(--space-3); }
.arch-summary-overspent .arch-summary-warning { margin-bottom: 0; }
/* shared.settings's two lines under the form: the note naming the four
 * languages that have no translator yet, and the live region that says whether
 * a choice was actually remembered. Both are <p>, and the element defaults
 * above reset p to margin: 0 — so with no rule here they stack flush at 0 px
 * and read as one wrapped paragraph, which is the .arch-summary-change defect
 * three rules up and the class test/client.classes.test.mjs sweeps for. The
 * top margin is the same one .arch-b-summary-stats uses to clear .arch-b-form,
 * for the same reason: the form's own gap does not reach a sibling.
 *
 * NO `:empty { display: none }` ON THE STATUS LINE, deliberately, and for the
 * reason ui/shell.js records for .shell-timer: a display:none region is not
 * exposed to assistive technology at all, so it would be created and filled in
 * the same paint and the announcement most likely to be dropped would be the
 * first one. The cost is one grid gap under an empty paragraph. */
.settings-notes { display: grid; gap: var(--space-2); margin-top: var(--space-3); }

/* host.builder's mode list (phase 3 Task 7). NOT .arch-o1-rows/.arch-o1-row:
 * that shape is an EDITABLE field set with a Move/Remove/Add contract this
 * list does not have — there is no delete intent (ruling 13) and nothing here
 * is reordered — so it gets its own two rules rather than borrowing one built
 * for a different job. A real <ul> so the count is announced, the same reason
 * .arch-o1-rows and .arch-a-list both give for it, and --space-1 is this
 * stylesheet's one card rhythm. */
.builder-modes { display: grid; gap: var(--space-1); }
/* No box-shadow, the same rule .arch-o1-row states for itself: this repeats
 * once per mode a host owns and the venue is low-end Android. --line-strong is
 * the rim every card-shaped thing in this file uses for SC 1.4.11's 3:1 on a
 * boundary around a group of controls. Flex and wrap rather than a fixed grid:
 * a bundled label is not always present, so the row's children are a variable
 * count and the gap is what keeps a 44 px Open beside a 44 px Fork from
 * sharing an edge on a narrow phone. */
.builder-mode {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2);
  padding: var(--space-2);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
}
/* The import field plus whatever gate 3 renders under it once a file fails
 * checkDefinition. A grid and not a flex row, for .mediagallery-upload's own
 * reason: the field and the issue list that can follow it are different
 * heights, and stacking removes any temptation to stretch one to the other. */
.builder-import { display: grid; gap: var(--space-2); margin-top: var(--space-3); }

/* host.builder's section editor (Task 8). Three layout-only wrappers — every
 * visible control inside them (PillButton, SelectField, the ScreenLink
 * Buttons) already carries its own rule elsewhere in this file. */
.builder-editor-header { display: grid; gap: var(--space-2); margin-bottom: var(--space-3); }
/* Pills-as-tabs over SECTION_FIELDS's twenty-one keys — archetype E's own
 * .arch-e-filters shape reused rather than a second definition of "a row of
 * pills", so the wrap behaviour on a narrow laptop window is identical. */
.builder-sections { display: flex; flex-wrap: wrap; gap: var(--space-1); }
/* The three links to this mode's other screens. A ScreenLink is already a
 * Button, so three of them need only the gap every other button row here
 * uses. */
.builder-nav { display: flex; flex-wrap: wrap; gap: var(--space-2); }

/* THE DRILL-IN'S BREADCRUMB (Task 19) — the only way back out of a nested level,
 * because the shell's Back leaves the screen entirely. It reuses .builder-nav's
 * flex/wrap/gap shape rather than inventing a second "row of controls" pattern,
 * exactly as Task 8 reused .arch-e-filters for the section pills.
 *
 * NO SHADOW AND NO GLOW on either rule: this is a repeated row of small things
 * and the venue floor is a low-end Android device — .arch-o1-row's own reason,
 * one level up.
 *
 * The separator is a ::before on every crumb but the first and is DECORATION
 * ONLY: generated content, so it is aria-hidden by construction, and the crumb's
 * own text is what carries the meaning. Colour never carries meaning here either
 * — the last crumb is told apart by aria-current and by not being a button. */
.builder-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
  list-style: none; margin: 0; padding: 0; }
.builder-crumb { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); }
.builder-crumb + .builder-crumb::before { content: "/"; color: var(--mute); }

/* THE GUIDED ROWS (Task 9) — §3.4's "no host ever types an expression". A
 * composite field is a row inside a row, so it needs the one thing
 * .arch-o1-row-fields cannot give it: its own grouping. That grid flows every
 * field of a row into auto-fit columns, which for a three-conjunct condition
 * would interleave conjunct 1's value with conjunct 2's left-hand side across a
 * wrap and leave nothing saying which belongs to which.
 *
 * NO box-shadow and no rim, for .arch-o1-row's own reason one level up: this
 * repeats per conjunct inside a row that repeats per activity, and the venue is
 * low-end Android. The grouping is carried by the gap, the line break and the
 * row's own numbered heading.
 *
 * THAT HEADING IS NOT DECORATION, and the first version of this comment was
 * wrong about why it was not needed: it said the gap and the line break were
 * enough "because each control keeps its own visible <label>", which is exactly
 * the reasoning that fails once a row REPEATS. Three conjuncts carry three
 * controls labelled "What to check", three labelled "Compare" and three
 * labelled "Value", so the label says what a control is and nothing says which
 * conjunct it belongs to. .builder-row-index is that missing fact, and it is the
 * accessible name of the row's role="group" as well as the visible one. */
.builder-rows { display: grid; gap: var(--space-2); }
/* Flex and wrap rather than a fixed column count: a conjunct is three controls,
 * an effect is four and a cost is three, and the trailing "add" row is one. The
 * basis is the same 200px floor .arch-o1-row-fields chose — a control with a
 * label above it — and min-width: 0 is what stops a long <select> option from
 * refusing to shrink and pushing the row sideways, which inside .arch-o1's own
 * overflow-x container would be invisible until a host scrolled. */
.builder-row { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--space-2); }
.builder-row > .arch-b-field { flex: 1 1 200px; min-width: 0; }
/* The row's number, on a line of its own above its controls: a flex basis of
 * 100% is what forces the break, and it is why this is a class rather than a
 * bare .t-meta child. --mute is .t-meta's own colour and carries no meaning the
 * text does not already say. */
.builder-row-index { flex: 1 0 100%; }
/* The read-only fallback for an expression the rows cannot express. The rule is
 * a boundary and NOT a state colour: --line-strong is the same neutral rim every
 * card-shaped thing in this file uses, and what says this field cannot be edited
 * here is the field's own hint sentence plus the input's `readonly` — colour
 * carries none of it. --warn would have been wrong twice over: a host's
 * arithmetic is not a fault, and a dashed --warn rim is already
 * .arch-o1-row[data-removing="true"]'s armed-for-removal signal. */
.builder-row-raw {
  display: block;
  border-left: 2px solid var(--line-strong);
  padding-left: var(--space-2);
}

/* host.builder.graph (Task 11) — the picture, the table and the cycle report.
 *
 * ONE SCROLL CONTAINER CLASS, USED TWICE. The diagram is 3,224 px wide on the
 * shipped mode (fifteen 200 px boxes on rank 0, per ui/dag-layout.js's own
 * grid), and a three-column table of IDs is wider than a narrow window too, so
 * both artefacts scroll INSIDE themselves and the page body never does. The
 * element also carries tabindex="0" in graph.js so the scroll is reachable
 * from a keyboard; no conformance is claimed for that and the module says so. */
.builder-graph { overflow-x: auto; }
/* The diagram. Every colour below is declared HERE rather than as a
 * presentation attribute in the markup, so the light-theme swap is a token
 * swap and never a second template.
 *
 * `max-width: none` UNDOES THE ELEMENT DEFAULT `img, svg { max-width: 100% }`,
 * and it is the one declaration here that is a measurement rather than a
 * choice. Left at 100 % the diagram is scaled to its container instead of
 * scrolling: 3,224 px into about 900 px of laptop is 28 %, which takes the
 * node labels from --type-meta to about four pixels — the same illegibility
 * ui/print.css hides this element on A4 to avoid, reintroduced on screen.
 * `display: block` because an inline <svg> sits on the text baseline and
 * leaves a descender gap under it inside the <dd>. */
.builder-graph-canvas { display: block; max-width: none; }
/* A node box. --surface on --line-strong is the same rim every card-shaped
 * thing in this file uses for SC 1.4.11's 3:1 around a group, and it is what
 * keeps a box visible against the canvas in both themes. */
.builder-graph-canvas rect { fill: var(--surface); stroke: var(--line-strong); }
/* --type-body AND NOT --type-meta. A node label is CONTENT — it is the id a
 * host types inside completed("…") — and tokens.css's own comment says
 * --type-meta is chrome only, with body text >= 16 px a hard constraint on this
 * project. It fits, and that was measured rather than hoped: with
 * getComputedTextLength() over all 21 labels of the shipped mode, the widest
 * (`marketAnalysisOutsourced`, 24 characters) is 193 px at 16 px against the
 * 200 px box ui/dag-layout.js lays out, and the next widest is 165 px. So the
 * fit threshold is about 24 characters; a host-authored id longer than that
 * paints outside its box rather than being clipped, and the table beside it
 * carries the same id at the same size with no bound at all. */
.builder-graph-canvas text { fill: var(--ink); font-size: var(--type-body); }
/* An edge. fill:none matters on a <polyline>, which otherwise closes itself
 * and paints a filled triangle between its two points. */
.builder-graph-canvas polyline { fill: none; stroke: var(--line-strong); }
/* A BACK EDGE — a dependency that closes a loop. The dash and the colour are
 * both REDUNDANT here, not load-bearing: what says this mode has a loop is the
 * .builder-cycle block below, which carries the risk glyph, the word "Problem"
 * and the validator's own sentence naming the path. Colour never carries
 * meaning alone, and neither does a stroke pattern. */
.builder-graph-canvas polyline[data-back="true"] {
  stroke: var(--risk);
  stroke-dasharray: 6 4;
}
/* The authoritative rendering (ruling 9). border-collapse so 21 rows do not
 * draw 21 doubled rules, and `text-align: start` on every cell because the
 * <dd> this table sits in carries `text-align: end` for the value half of a
 * label-plus-value pair — right for `Cash · 9,700` and wrong for a column of
 * identifiers, which is the same mismatch shared.help's .arch-d-prose exists
 * for. `start` and not `left`, so the one decision survives an RTL locale. */
.builder-graph-table { border-collapse: collapse; width: 100%; }
.builder-graph-table th,
.builder-graph-table td {
  text-align: start;
  vertical-align: top;
  padding: var(--space-1);
  border-bottom: 1px solid var(--line);
}
/* A cell holds a variable number of IDs and no separator character: the gap is
 * the separator, so nothing has to invent a punctuation mark that would then be
 * read out between every pair. */
.builder-graph-ids { display: flex; flex-wrap: wrap; gap: var(--space-1); }
/* The cycle report. A rim and NOT a fill: the --risk fill belongs to
 * .badge-risk, which is already inside this block through DefinitionIssueList,
 * and two risk fills nested one inside the other is the one place a state
 * treatment reads as decoration. No box-shadow — a cycle report repeats once
 * per loop and the venue is low-end Android. */
.builder-cycle {
  border: 1px solid var(--risk);
  border-radius: var(--radius);
  padding: var(--space-2);
}

/* host.builder.translations (Task 13) — §6.4's completeness counts, the
 * translator brief and the import's unmatched-key report.
 *
 * THE COMPLETENESS STRIP. A real <ul>, so the count of languages is announced,
 * the same reason .arch-o1-rows, .builder-modes and .arch-a-list all give. Flex
 * and wrap rather than a grid: a mode declares between one and five languages
 * and the strip has to read as one line at five and still wrap at one column on
 * a narrow laptop window. --space-2 between entries and --space-1 inside one, so
 * the pairs group without a separator character having to be invented. */
.builder-langs { display: flex; flex-wrap: wrap; gap: var(--space-2); }
/* One language, LABEL-PLUS-VALUE: the code and then "{done} of {total}". Baseline
 * alignment because the label is --type-meta and the value is body text, which is
 * .builder-mode's own pairing and the one .arch-o1-status exists to get right. No
 * rim, no fill and no current-selection highlight: nothing here is a control, and
 * the SelectField below the strip is what says which language is being edited —
 * so no colour carries the selection either. No box-shadow; this repeats. */
.builder-lang { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1); }

/* THE TRANSLATOR BRIEF — ruling 10's printable, and the one block on this screen
 * that is meant to leave the building. A rim so it reads as a sheet rather than
 * as more of the page, in --line-strong (SC 1.4.11's 3:1 on a boundary around a
 * group) and never a state colour: a brief is not a warning. No box-shadow — it
 * appears once, but ui/print.css drops depth anyway and the venue is low-end
 * Android.
 *
 * The <dl> is label-plus-value in two columns: `auto 1fr` so the labels size to
 * the longest of them and the values share one left edge, which is what makes a
 * printed sheet scannable. `dd` loses the user-agent margin-inline-start, which
 * is the one element default the reset at the top of this file leaves in place
 * for definition lists. */
.builder-brief {
  display: grid;
  gap: var(--space-2);
  padding: var(--space-2);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
}
.builder-brief dl {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-1) var(--space-2);
  margin: 0;
}
.builder-brief dd { margin: 0; }

/* The keys an import matched nothing against. NEUTRAL, deliberately: ruling 6
 * part 5 says an unmatched key is reported and "not treated as an error", so
 * this takes .builder-row-raw's left rule rather than .builder-cycle's --risk
 * rim — a boundary that groups, carrying no state at all. The keys inside reuse
 * .builder-graph-ids, which is the same object one screen over: a wrapped list
 * of ids whose separator is the gap. */
.builder-unmatched {
  display: grid;
  gap: var(--space-1);
  border-left: 2px solid var(--line-strong);
  padding-left: var(--space-2);
}

/* host.builder.publish (Task 14) — the validator's report, the freeze and the
 * export. Three rules and no fourth, because everything else on this screen is
 * archetype B's or ui/components.js's already.
 *
 * .builder-report IS THE WHOLE beforeSubmit BLOCK, and it exists for one
 * reason: the blocks inside it are <div>s and <p>s, every one of which this
 * file's own reset zeroes the margin of, so with no rule the error group, the
 * warning group and the version list stacked flush at 0 px. That is the
 * most-repeated defect class in this codebase and the one .issue-list's own
 * comment was written about eight rules further down. --space-3 and not
 * --space-2: these are unrelated blocks rather than rows of one list, and it
 * matches .arch-b-form's own field rhythm so the report reads at the same
 * spacing as the form it sits under.
 *
 * .builder-issues IS ONE SEVERITY'S GROUP: a heading that says what the group
 * MEANS, and the shared .issue-list under it. --space-1, because a heading and
 * the list it introduces are one object.
 *
 * A LEFT RULE AND NEVER A FILL, which is .builder-cycle's ruling one screen
 * over and its reasoning applies here exactly: the --risk FILL belongs to
 * .badge-risk, which is already inside this block once per row through
 * DefinitionIssueList, and two risk fills nested one inside the other is the
 * one place a state treatment reads as decoration. The solid/dashed split is
 * kept — errors solid, warnings dashed — so the two groups differ by TREATMENT
 * and not only by hue, which is the same pairing .badge-risk and .badge-warn
 * are already asserted to hold. Colour still carries nothing on its own: the
 * heading is a sentence and every row inside carries a glyph and a word.
 *
 * `clean` and `versions` take --line-strong, a boundary that groups and carries
 * no state at all — .builder-unmatched's own reason two rules up. Neither is a
 * fault and neither may borrow a fault's colour.
 *
 * No box-shadow anywhere below: the venue is low-end Android and a version list
 * repeats.
 *
 * .builder-issue is a BADGE AND A SENTENCE ON ONE ROW — the clean line, which
 * is a <p> and therefore display:block, so its badge and its sentence sat flush
 * with no space between them. The identical measured defect .arch-b-summary
 * records ("■Problem1 of 1"), answered the same way: flex, wrap and a gap, with
 * baseline alignment because the badge is --type-micro and the sentence is body
 * text. */
.builder-report { display: grid; gap: var(--space-3); }
.builder-issues {
  display: grid;
  gap: var(--space-1);
  border-left: 2px solid var(--line-strong);
  padding-left: var(--space-2);
}
.builder-issues[data-group="errors"] { border-color: var(--risk); }
.builder-issues[data-group="warnings"] {
  border-color: var(--warn);
  border-left-style: dashed;
}
.builder-issue {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-1);
}
/* §4.5's versions, label-plus-value per row. Same shape as .builder-lang one
 * screen over and for the same reason: a label at --type-micro beside a value
 * at body size needs a baseline, and a row of four small pieces has to wrap on
 * a narrow laptop window rather than push the page sideways. */
.builder-versions { display: grid; gap: var(--space-1); }
.builder-versions li {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-1);
}
/* AND THE NAV TAKES ITS OWN LINE INSIDE THE ACTION ROW, at every width. Export
 * is an ACT — it hands a host a file — and the three links beside it are
 * navigation. Measured in a browser: flex-wrap alone put all four on ONE line at
 * 1,280 px and on two lines at 790 px, so whether that distinction was visible
 * depended on how wide the host's window happened to be, and at the wider one
 * Export read as a fourth link. This is the same reason apps/form.js gives for
 * rendering a second control outside the <form> at all: a control that looks
 * like the ones beside it is read as one of them. Scoped to the pair, so no
 * other .arch-b-actions and no other .builder-nav is touched. */
.arch-b-actions > .builder-nav { flex: 1 0 100%; }

/* --- components ----------------------------------------------------------- */
/* §7.4's list, narrowed to the eleven ui/components.js actually emits. Every rule
 * below reads tokens.css and nothing else: the palette, the type scale, the radii
 * and every spacing step are named rather than written. That is what makes §9.1's
 * light theme a token swap instead of a second stylesheet.
 *
 * Colour is the LAST layer of every state and never the only one. components.js
 * already emits a glyph and a translated word for each; these rules add the hue
 * and — the part that matters more — the TREATMENT, because §7.1 gives warn a
 * dashed outline and risk a fill. Two different shapes, so the pair survives
 * greyscale, a red-green deficiency and a washed-out venue projector.
 *
 * Border widths are written as 1px, matching the four hairline borders the shell
 * and archetype rules above already use. It is the only length here that is not a
 * token, it is neither a palette nor a type-scale value, and ui.base.test.mjs
 * asserts that no other literal length appears in this section.
 *
 * Any boundary that identifies a control uses --line-strong, never --line: §7.1's
 * SC 1.4.11 note is why that token exists, and --line is 1.27:1.
 *
 * §7.7's depth layer appears three times below — a glow on the one primary action,
 * a halo on a selected or pressed control, and elevation under the toast. It is
 * always the LAST layer of a state and never the only one: the glyph, the word,
 * the border treatment and aria-pressed all still say it. Two rules follow from
 * that and are enforced by tests: a receded state (done, locked, disabled) never
 * glows, and the focus ring stays a solid outline with no glow of its own. */

/* A glyph never shrinks and never wraps away from its word. aria-hidden and the
 * accompanying word both come from components.js — do not add a ::before here, or
 * the meaning would live in CSS where a screen reader cannot reach it.
 *
 * THE SEPARATION IS ON THE GLYPH AND NOT ON ITS PARENT, and moving it here is a
 * fix rather than a tidy-up. "Colour never carries meaning alone" makes
 * glyph-plus-word the most repeated composite in the product, and it was
 * separated only where the PARENT happened to be a flex row with a gap — .chip
 * and .badge, which used to declare `gap: var(--hairline)` and now do not,
 * because this line replaces it at exactly the same 4 px.
 *
 * Everywhere else the pair rendered flush. MEASURED at the 375 px reference
 * width, glyph edge to word edge:
 *
 *   .chip (RoleChip)                     4 px   ●Host
 *   .badge (StateBadge)                  4 px   ✎Corrected by host
 *   apps/player/results.js  glyph+text   0 px   🎯13.5 / 30
 *   apps/expert/snapshot.js glyph+span   0 px   👍On Track
 *   .arch-e-sort            label+glyph  0 px   Score▲ (see its own rule)
 *
 * Those two apps modules each render a private copy of components.js's Glyph —
 * same class, no gapped parent — and htm strips the whitespace-only text between
 * two tags, so there was not even a word-space between them. A margin on the
 * glyph reaches every one of them from one declaration, which is what makes this
 * a component-kit fix instead of the same patch written per screen.
 *
 * margin-inline-end and not margin-right: the client is built for five
 * languages and phase 5 owns the rest of the string pass. */
.glyph { flex: none; margin-inline-end: var(--hairline); }

/* §6.3's visible flag for a definition string with no translation in the reader's
 * language. Dashed and warn-coloured, the same treatment as the warn state,
 * because that is what this is: a warning about the text itself. Finding 116:
 * selected by the attribute pair every definition emitter writes, not by a class
 * fourteen of the eighteen omitted. */
[data-i18n="definition"][data-untranslated="true"] {
  text-decoration: underline dashed var(--warn);
  text-underline-offset: var(--hairline);
}

/* Chip · the persistent role label §9.3 leans on. It is chrome, so --type-meta
 * applies, and it resolves its colour from data-role rather than from --role so a
 * shared surface (the join screen's three doors) can show all three at once. */
/* No `gap` here: .glyph above carries the same --hairline as its own
 * margin-inline-end, so the chip reads identically and every OTHER glyph in the
 * product gains the separation this rule used to keep to itself. A gap as well
 * would make it 8 px and read as two things rather than as one pair. */
.chip {
  display: inline-flex;
  align-items: center;
  padding: var(--hairline) var(--space-1);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-pill);
  font-size: var(--type-meta);
  font-weight: var(--weight-meta);
}
.chip-role { color: var(--role); border-color: var(--role); }
.chip-role[data-role="player"] { color: var(--role-player); border-color: var(--role-player); }
.chip-role[data-role="host"] { color: var(--role-host); border-color: var(--role-host); }
.chip-role[data-role="expert"] { color: var(--role-expert); border-color: var(--role-expert); }

/* Badge · the four states. Same box, four deliberately different treatments.
 * No `gap`, for the reason .chip above records: .glyph carries it now. */
.badge {
  display: inline-flex;
  align-items: center;
  padding: var(--hairline) var(--space-1);
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-pill);
  font-size: var(--type-meta);
  font-weight: var(--weight-meta);
}
/* warn — DASHED OUTLINE, no fill. */
.badge-warn { color: var(--warn); border-color: var(--warn); border-style: dashed; }
/* risk — FILL, and the label flips to the page colour so it reads on the fill.
 * --canvas is the right ink for a filled state in both themes: it is dark on a
 * bright fill and bright on a dark one, so the pair swaps with the palette. */
.badge-risk { color: var(--canvas); background: var(--risk); border-color: var(--risk); }
/* done — NOT a colour. It recedes to --done (which is --mute's value) with a ✓,
 * because green belongs to the host role and a finished thing should stop
 * competing for attention. No border, so it is the quietest badge on the screen. */
.badge-done { color: var(--done); }
/* locked — also receded, but it bounds itself, because a locked card stays
 * focusable and the badge is the thing that explains why. The 🔒 glyph and the
 * word are what separate it from done; the boundary is the reinforcement. */
.badge-locked { color: var(--mute); background: var(--surface); border-color: var(--line-strong); }
/* corrected — ALSO not a colour, and deliberately so. A host's score override is
 * a fact to be read, not a fault to be alarmed about, and every remaining state
 * colour already means something else. It keeps --ink, so it introduces no new
 * pair for ui.tokens.test.mjs to audit; the ✎ and the word are the whole
 * signal, which is what makes it legible on the projected leaderboard too.
 *
 * selected — THE SAME TREATMENT, and it is grouped with corrected rather than
 * given a colour of its own for the same two reasons. A selected activity card
 * ALREADY carries the role border and the halo (.card[aria-pressed="true"]
 * below), so a tinted badge inside it would be the third statement of one fact
 * and would put a coloured box on the one card whose cost line --mute is
 * already only 4.98:1 against. The ⊕ and the word are the whole signal. It used
 * to borrow .badge-done, which recedes to mute precisely because a finished
 * thing should stop competing for attention — the opposite of what a choice
 * still waiting to be submitted needs.
 *
 * Two rules and not one grouped selector, because test/ui.base.test.mjs matches
 * a declared class on its own brace — a grouped `.a, .b {` reads as undeclared
 * for the first of the pair, which is a check worth keeping over one saved line. */
.badge-corrected { color: var(--ink); border-color: var(--line-strong); }
.badge-selected { color: var(--ink); border-color: var(--line-strong); }

/* Credential · a long-lived code that this screen must be able to PRINT and must
 * not paint on a projector. host.gameReview is one tap from the games list on the
 * laptop driving the room, and it rendered every expert's 40-character invite
 * token, every team's 24-character code and a scannable QR for each, on load.
 *
 * The value is always in the DOM — ui/print.css reveals every one of these
 * unconditionally, so window.print() produces the same lanyard sheet it always
 * did with no state to get wrong — and the attribute is what hides it on screen.
 * display: none rather than opacity or a clip: a code that is merely invisible
 * is still selectable, still read out, and still photographable at 0.01 alpha.
 *
 * The mask keeps the row's height honest rather than collapsing the group each
 * time the toggle is pressed. */
.credential { display: block; }
.credential[data-revealed="false"] .credential-value { display: none; }
.credential[data-revealed="true"] .credential-mask { display: none; }
.credential-value { display: block; }
.credential-mask { color: var(--mute); }

/* Stat tile · label above value, two elements, never a sentence. The value stays
 * --ink: §7.2 gives the role colour to accents and the single primary action, and
 * a HUD where every number is orange has no accent left. */
.tile {
  display: grid;
  gap: var(--hairline);
  padding: var(--space-1);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.tile [data-role-part="value"] { color: var(--ink); }

/* Progress bar · the fill is the role colour and the track bounds itself at 3:1,
 * so the boundary between filled and unfilled is visible without relying on hue. */
.bar {
  block-size: var(--space-1);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-pill);
  overflow: hidden;
}
.bar-fill {
  block-size: 100%;
  background: var(--role);
  border-radius: inherit;
  transition: inline-size var(--motion-base) var(--easing);
}

/* Gate ring · the ARC is the information and the track is only its groove, so the
 * track is --line and NOT --line-strong. That is the opposite of the choice the
 * bar and the card make, and it is deliberate: --line-strong sits close enough in
 * luminance to all three role colours that an arc drawn on it reaches only 2.00:1
 * (1.60:1 in the light theme), which would hide the very thing being measured. On
 * --line the arc clears 3:1 in both themes, and so does the arc against the page.
 * The numbers it encodes are in the .vh table components.js emits, never in hue. */
.ring { display: inline-flex; }
.ring-track { stroke: var(--line); }
.ring-arc { stroke: var(--role); transition: stroke-dashoffset var(--motion-base) var(--easing); }

/* Activity card · a button, so it already has the 44 px floor and the focus ring.
 * justify-items keeps a badge from stretching the full width of the card. */
.card {
  display: grid;
  justify-items: start;
  gap: var(--hairline);
  inline-size: 100%;
  padding: var(--space-2);
  text-align: start;
  overflow-wrap: break-word;
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  transition: background-color var(--motion-fast) var(--easing),
    border-color var(--motion-fast) var(--easing);
}
/* ELEMENT-QUALIFIED, AND THAT IS THE POINT. `cursor: pointer` sat on .card
 * itself, which was true of every card until one of them stopped being a
 * button: ActivityCard's readOnly branch renders a <div>, because the hub's
 * cards are a record of a decision and not a picker. A hand cursor over
 * something nothing can do is the same lie the aria-pressed was. */
button.card { cursor: pointer; }
/* Selected. The role border and the halo are reinforcement; components.js also
 * emits aria-pressed, a done badge and the word for "selected".
 *
 * The 12 % role tint this rule used to carry is GONE, and removing it is a fix,
 * not a simplification: --mute is 4.98:1 on a card, so a 12 % role tint behind the
 * cost line dropped it to 4.28:1 — 3.98:1 under the host role — and the mockup's
 * own selection treatment, a 14 % linear sweep, is worse again at 3.80:1. The halo
 * carries the same signal OUTSIDE the card where no text sits, and its spread is
 * --hairline so it cannot reach the next card's boundary either. */
.card[aria-pressed="true"] {
  border-color: var(--role);
  box-shadow: var(--glow-halo);
}
/* Locked. The fill is REMOVED rather than dimmed — a flat card on the page reads
 * as unavailable without touching the legibility of its name or description, both
 * of which are body prose §9.2 holds to 16 px and 4.5:1. The boundary stays
 * --line-strong because the card is still focusable. */
.card[data-locked="true"] { background: var(--canvas); cursor: not-allowed; }
.card-name { font-weight: var(--weight-h2); }
.card-lock {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
}
/* Chosen, then locked: the lock and the tick together, and a tap takes it out
 * (ActivityCard's own note), so the pointer says it can be pressed. */
.card-stranded {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
}
.card[data-locked="true"][aria-pressed="true"] { cursor: pointer; }
/* THE RECURRING COMMITMENT, which a card had no line for at all. Measured on a
 * live game: "Senior Technical Partner · 32" — one number, hours — while
 * selecting it charged EUR 75,000 every subsequent round.
 *
 * A flex row with a gap for exactly the reason .card-lock is one: an app-string
 * label and its value sitting flush at 0 px is unreadable, and this line's whole
 * job is to be read before the tap. NO COLOUR — a standing charge is a fact, not
 * a fault, and a warn tint on every card that carries one is the cry-wolf failure
 * the display wall's connection badge already has a ruling about. The words
 * "Every round" are the distinction, which is the same thing the paper game's
 * facilitator said out loud. */
.card-recurring {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
}

/* Number field · --line-strong is mandatory here. §7.1's SC 1.4.11 note: --line
 * is 1.27:1 and --surface is 1.08:1, so neither can show where the field is. */
.field {
  inline-size: 100%;
  padding: var(--space-1);
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
}
.field[aria-invalid="true"] { border-color: var(--risk); }
/* The error message is body prose at --type-body, set by components.js's t-body.
 * The colour reinforces a sentence that already says what is wrong. */
.field-error { color: var(--risk); overflow-wrap: break-word; }
/* A standing explanation of what a value means, not a problem — so --mute and
 * never --risk, and no dashed outline either: a hint that looked like a warning
 * would make the ordinary case look broken. --mute is 4.98:1 on a card and
 * 7.16:1 on the canvas (ui/tokens.css's own audit), and .arch-b-field sits on
 * the canvas on every form screen in the product, so this clears 4.5:1 where it
 * is actually used. .t-meta is 13 px, which §9.2 permits for chrome beside a
 * control and not for body copy. */
.field-hint { color: var(--mute); overflow-wrap: break-word; }
/* A LEGAL CHOICE THIS BUILD DOES NOT YET HONOUR — not a problem with what the
 * author typed, so --risk would be a lie, and not a neutral explanation either,
 * so --mute would hide it. --warn is 8.79:1 on the canvas and 8.06:1 on a card
 * (ui/tokens.css's own audit), so it clears 4.5:1 wherever .arch-b-field sits.
 * The colour reinforces a glyph and a sentence that both already say it —
 * SelectField renders GLYPHS.state.warn beside the words — which is the whole of
 * "colour never carries meaning alone". No dashed rim and no glow: this is a
 * message under a live control, not a state the control is in. */
.field-warn { color: var(--warn); overflow-wrap: break-word; }

/* Button · pill, per §7.4 and the Founded reference. Exactly one primary action
 * per screen carries the role colour; everything else is outlined. */
.btn-wrap { display: grid; gap: var(--hairline); }
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  padding: var(--space-1) var(--space-3);
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-pill);
  font-weight: var(--weight-h2);
  cursor: pointer;
  transition: background-color var(--motion-fast) var(--easing),
    border-color var(--motion-fast) var(--easing);
}
/* The glow is the only depth on a control that is not a state change, and it is
 * here because this is the one element per screen §7.2 reserves the role colour
 * for. Its blur reaches half of 24 px, so a second button one --space-2 gap away
 * sits on untinted canvas and keeps its --line-strong boundary at 3:1. */
.btn-primary {
  color: var(--canvas);
  background: var(--role);
  border-color: var(--role);
  box-shadow: var(--glow-accent);
}
.btn-secondary { color: var(--ink); border-color: var(--line-strong); }
/* aria-disabled, never the disabled attribute — the control stays focusable so
 * the reason beside it can be reached. The receded look is not the whole message;
 * .btn-reason is.
 *
 * Clearing the shadow here is load-bearing. This rule beats .btn-primary on
 * specificity for colour but not for a property it does not mention, so without it
 * a disabled primary action would keep glowing — and .btn-reason, the one piece of
 * chrome text that ever sits within the glow's reach, only exists while the button
 * is disabled. §7.7 shows what it would cost: --mute in that band is 4.40:1. */
.btn[aria-disabled="true"] {
  color: var(--mute);
  background: transparent;
  border-color: var(--line-strong);
  box-shadow: none;
  cursor: not-allowed;
}
.btn-reason { overflow-wrap: break-word; }

/* Pill button · a toggle. Pressed inverts to a fill rather than only changing
 * hue, which is what keeps aria-pressed's visual counterpart legible in
 * greyscale. */
.pill {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-1) var(--space-2);
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-pill);
  cursor: pointer;
  transition: background-color var(--motion-fast) var(--easing),
    border-color var(--motion-fast) var(--easing);
}
/* ChoiceField (findings 283(a), loop 4): a short scale is one radio per point.
 * The fieldset drops the user agent's border and padding so the group sits where
 * a field would; the points wrap on a 320 px phone. The radio is the real control
 * and stays in the tab order and the accessibility tree, laid transparent over
 * its whole pill, so the pill is the 44 px target and a tap anywhere on it
 * chooses (a visible 44 px native radio made each pill 110 px, measured at 375).
 * Focus moves to the pill in the role colour; a checked point inverts like a
 * pressed pill. No shadow: the points are a repeated row. */
.choices { border: none; padding: 0; margin: 0; display: grid; gap: var(--space-2); }
.choices > legend { padding: 0; color: var(--ink); }
.choices > legend.t-meta { color: var(--mute); }
.choices-row { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.choice { position: relative; }
.choice input {
  position: absolute; inset: 0; width: 100%; height: 100%;
  margin: 0; opacity: 0; cursor: pointer;
}
.choice:has(input:focus-visible) { outline: var(--focus-width) solid var(--role); outline-offset: var(--hairline); }
/* The keyboard ring where :has() is unknown (loop 15 review): Chrome 89-104 and
 * Firefox 108-120 run the app without it, and the input itself is transparent,
 * so its own ring cannot show. :focus-within there, which a tap may also light. */
@supports not selector(:has(a)) {
  .choice:focus-within { outline: var(--focus-width) solid var(--role); outline-offset: var(--hairline); }
}
/* The checked point is marked on the label as well (loop 12): `:has()` is
 * Safari 15.4+ and Chrome 105+, and on an older phone the chosen rating did not
 * show at all. The attribute is the render's own; `:has` stays for the moment a
 * tap lands before the redraw. */
/* TWO RULES, NOT ONE LIST: a browser that does not know :has() drops the WHOLE
 * selector list it appears in, fallback and all (loop 12 review). */
.choice[data-checked="true"] { color: var(--canvas); background: var(--role); border-color: var(--role); }
.choice:has(input:checked) { color: var(--canvas); background: var(--role); border-color: var(--role); }
.pill[aria-pressed="true"] {
  color: var(--canvas);
  background: var(--role);
  border-color: var(--role);
  box-shadow: var(--glow-halo);
}

/* Toast · carries a StateBadge for every kind that IS one of §9.1's states, so the
 * badge is where the treatment lives. The panel echoes it: dashed for warn, a
 * TINTED fill for risk. A solid --risk panel is not available — ink on it is under
 * 2:1 — and a tint is still a fill, so the two states stay two shapes.
 *
 * THREE SHAPES NOW, AND THE THIRD IS THE QUIET ONE. .toast-done had no rule at
 * all: the approved-round notice came out identical to a neutral toast, and its
 * badge was missing too, so neither colour NOR glyph carried the state — on the
 * one toast in the product that is good news. It was invisible to
 * test/client.classes.test.mjs because the class is BUILT (`'toast toast-' +
 * kind`), and ui.base.test.mjs's own VARIANTS table declared the toast suffixes
 * as warn and risk and called the rest a deliberate fallback. Both sweeps now
 * resolve the vocabulary from ui/components.js.
 *
 * THE BOUNDARY RECEDES RATHER THAN TAKING A HUE, which is the whole of CLAUDE.md's
 * position on done: green belongs to the host role, and a finished thing should
 * stop competing for attention. Warn reaches for --warn and risk for --risk;
 * done goes the other way, dropping from --line-strong to --line, so the panel is
 * quieter than a neutral one instead of louder. No new colour pair, and nothing
 * for ui.tokens.test.mjs to audit — the ✓ and the word are the whole signal, as
 * they are on .badge-done itself. */
.toast {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
  padding: var(--space-2);
  overflow-wrap: break-word;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  box-shadow: var(--elev-1);
}
.toast-warn { border-color: var(--warn); border-style: dashed; }
.toast-risk {
  border-color: var(--risk);
  background: var(--surface);
  background: color-mix(in srgb, var(--risk) 16%, var(--surface));
}
.toast-done { border-color: var(--line); }

/* The QR block. `color: var(--ink)` is what `fill="currentColor"` resolves against,
   so the symbol follows the theme without the SVG naming a colour. */
.qr { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); color: var(--ink); }
.qr-symbol { max-width: 100%; height: auto; }
.qr-value { text-align: center; }
/* A code is read aloud and typed in, so it is monospaced and spaced out. Two
   characters that look alike in a proportional face are a support call. Sizing is
   NOT set here: the "component may only shrink to --type-meta" rule in
   test/ui.base.test.mjs holds .chip and .badge as the only two component rules
   allowed to declare font-size, so qr.js applies .t-body as a utility class in
   markup instead — the same way .card-cost and .btn-reason reach .t-meta. */
.qr-code { display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  letter-spacing: var(--tracking-micro); margin-top: var(--space-1); }
/* FINDING 53 — A CODE HAS NO BREAK OPPORTUNITY IN IT, so it does not wrap and
 * the page scrolls sideways instead. An invite URL with a 40-character token,
 * a 24-character team code: no space, no hyphen, nothing a line breaker can
 * use. `anywhere` and not `break-word` because only `anywhere` lowers the
 * MIN-CONTENT size, which is the number a flex or grid item is measured
 * against — the same distinction `.arch-d-fact > dd` above already records.
 *
 * MEASURED at 375 px, rendering ui/qr.js's own component into the real page
 * with a real 78-character invite URL (a screen-level measurement needs a
 * seeded host session; this is the component and the stylesheet, not a replica
 * of either):
 *          document.scrollWidth   the <code>   its right edge   its height
 *   before        455 vs 375        423.22 px       439 px         48 px
 *   after         375 vs 375        343.33 px       359 px         96 px
 *
 * — the code wraps onto a second line and nothing leaves the viewport.
 *
 * BOTH SELECTORS, and the element one is what makes this a fix rather than a
 * patch: every <code> in the client carries .qr-code today, so the class alone
 * would answer for all four call sites — and the next credential rendered in a
 * bare <code> would reopen this at 375 px with nothing failing. SC 1.4.10. */
.qr-code, code { overflow-wrap: anywhere; }
/* A typed code in groups of four (finding 283(b), ui/components.js's CodeText):
 * a gap and no space character, so the code copies whole, and the groups wrap
 * on a 320 px phone rather than scrolling sideways. After .qr-code, whose
 * display: block it replaces at the same specificity. */
.code-groups { display: flex; flex-wrap: wrap; column-gap: var(--space-1); }
/* A group never splits across a line; the row breaks between groups. */
.code-group { white-space: nowrap; }

/* DefinitionIssueList's report (phase 3, fix round 1). The element defaults at
 * the top of this file zero every <ul>'s margin and padding, so with no rule
 * here every issue rendered flush against the next with no gap at all — found
 * reviewing the diff rather than by any sweep, because the component emitted
 * NO class rather than a wrong one. A grid for the list itself and a wrapped
 * flex row per issue, the same shape .toast and .arch-o1-row-actions both use
 * for "a badge, a sentence and whatever data rides beside it". */
.issue-list { display: grid; gap: var(--space-2); }
.issue-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1); }

/* --- the four states ------------------------------------------------------ */
/* Implemented once here and inherited by all three archetypes, which is what
 * makes §12's 40 layouts enough for 60 screens. */
/* The placeholder bars read --skeleton and NOT --surface. An archetype F panel is
 * itself --surface, so painting the bars --surface made them the same
 * rgb(18,24,20) as the panel and the loading state was an empty box — measured.
 * The state was still ANNOUNCED (aria-busy plus the visually-hidden "Loading"),
 * which is why source reading found nothing; only a sighted user lost it. See
 * tokens.css for why the tint is bounded on both sides. */
.state-skeleton > * {
  background: var(--skeleton);
  border-radius: var(--radius);
  color: transparent;
  animation: pulse var(--motion-base) var(--easing) infinite alternate;
}
@keyframes pulse { from { opacity: 0.55; } to { opacity: 1; } }
.state-empty, .state-error { display: grid; gap: var(--space-2); padding: var(--space-4) var(--space-2); }

/* --- responsive ----------------------------------------------------------- */
/* §7.5. 360 is the reference width and is the base above, not a query. Player
 * and expert scale up from it; the host scales down from 1280. 1920 is the
 * projected leaderboard only and arrives with it in phase 2. */
@media (min-width: 600px) {
  /* The title stops owning a whole row and goes back beside the chrome, shrinking
   * to fit as it did before. flex-wrap stays on above this width as the floor
   * under it: it costs nothing where the row fits — 76.67 px at 600 in English,
   * identical to nowrap — and it is what stops the header overflowing sideways
   * at some string length nobody has measured yet. */
  .shell-head > h1 { flex: 0 1 auto; }
  .shell-hub { flex-wrap: wrap; overflow-x: visible; }
  .arch-f-scrim { align-items: center; justify-items: center; padding: var(--space-3); }
  /* inline-size is load-bearing next to max-width. justify-items: center above
   * makes the panel a NON-stretched grid item, so it sizes to max-content and
   * max-width only caps a width it never reaches: measured at 90.49 px for a short
   * dialog in an 800 px viewport, a modal narrower than its own Close button's
   * label. 100 % fills the grid area first and 560 px then caps it. */
  .arch-f-panel {
    inline-size: 100%;
    border-radius: var(--radius);
    border: 1px solid var(--line-strong);
    max-width: 560px;
  }
}
@media (min-width: 900px) {
  /* The player's gate ring, stat tiles and submit move into a sticky left rail
   * and the activities scroll beside them — which is also what removes §9.2's
   * accepted cost of taller rows entirely above this width. */
  .shell-main { padding: var(--space-3); }
  /* 300px IS THE ONE RAIL WIDTH IN THE PRODUCT, and as of this pass it is shared
   * rather than guessed at twice. Two archetypes put a column beside their
   * content at this breakpoint and they declared 300 and 260 with no reason on
   * either. MEASURED at 900: .shell-main leaves 852 px, so this rail plus its
   * --space-4 gap gives the activities list 520 px — 1.6x the 328 px a 360 px
   * phone gives a card, so a card gets WIDER when the layout gains a rail rather
   * than narrower, which is the only direction that matters here. The rail's own
   * contents need far less than 300 (the gate ring is a fixed 120 px SVG and a
   * stat tile's max-content measures 89.52 px), so the number is set by what it
   * LEAVES and not by what it holds. .arch-e-body:has(.arch-e-rail) below takes
   * the same value. */
  .arch-a { display: grid; grid-template-columns: 300px 1fr; gap: var(--space-4); align-items: start; }
  .arch-a-stats { position: sticky; top: var(--space-3); grid-template-columns: 1fr; }

  /* Ruling 6, kept intact at every width that could be a projection. Above a
   * phone, the display wall has no control on it at all — display:none, so the
   * element generates no box and cannot take focus — it is still in the DOM,
   * which is why the tests assert focusability rather than presence. See .leaderboard-exit above for why it exists below this. */
  .leaderboard-exit { display: none; }

  /* N4: MOVED HERE FROM 1280px, fix round 3 — apps/host/leaderboard.js's own
   * header puts the projection boundary at 900, the same line .leaderboard-exit
   * just used two lines up, and 1024x768 (a common classroom projector) sits
   * BELOW 1280: Ruling 24's wrap still ran there, and a wrapped row broke the
   * row-height budget below .leaderboard exactly as it did at 1280 and 1920
   * before fix round 2 — measured, 1024x768 all corrected put 12 teams off the
   * frame; this block closes it (0 off, re-measured).
   *
   * THIS IS THE ORIGINAL, PRE-RULING-24 RULE SET, VERBATIM (`git show
   * 0c0eb5f:pb_public/ui/base.css`), not an approximation of it: grid, not
   * flex, and truncation, not a floor. A first draft kept `display: flex` with
   * `flex-shrink: 0` on the score, guessing that was equivalent — it was not
   * (`flex-shrink: 0` never shrinks; the original grid's "auto" track does),
   * and going back to the verbatim original is what this comment now records.
   *
   * VERBATIM IS NOT THE SAME AS FIXED. At exactly 900x700 with all 30 teams
   * corrected, THIS SAME RULE SET — measured against the unmodified
   * pre-batch-X tree — already put 2 of 30 teams off the frame (11.86px
   * names, `scrollHeight` 790/`innerHeight` 700). Ruling 24 was never the
   * cause: nobody had measured a corrected badge in a two-column band this
   * narrow before batch X existed. Filed rather than fixed here, finding 170
   * — closing it is a content decision (as 169 already was) or a redesign of
   * the 900-1279 band, neither of which this round's mandate covers. */
  .leaderboard-row {
    display: grid;
    grid-template-columns: 2ch 1fr auto;
    gap: var(--space-3);
    align-items: baseline;
  }
  .leaderboard-name {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .leaderboard-score { text-align: end; overflow-wrap: normal; }

  /* E's collapse, the other direction: the card layout above becomes a real
   * table, the header row returns, and the data-label decoration switches off
   * because the header it stood in for is visible again. A rail beside the
   * table only claims a column when one is actually rendered, so a screen
   * with no rail does not reserve dead space for it. */
  .arch-e-table { display: table; width: 100%; border-collapse: collapse; }
  .arch-e-head-row { display: table-row; }
  .arch-e-row { display: table-row; background: none; border: none; padding: 0; }
  .arch-e-row > th, .arch-e-row > td {
    display: table-cell;
    padding: var(--space-1) var(--space-2);
    border-bottom: 1px solid var(--line);
  }
  .arch-e-row > td[data-label]::before { content: none; }
  /* The same 300 px rail .arch-a declares, on the other side of the content —
   * see that rule for where the number comes from. It read 260 until this pass,
   * which made it the one length in this file that differed from its twin with
   * no reason on either. NOTHING PASSES `rail` TODAY: no module in
   * pb_public/apps/ does, so this is archetype E's declared capability for
   * §7.5's expert laptop ("queue and team snapshot sit side by side"), which is
   * .arch-a's shape with the columns swapped, and test/ui.archetypes.test.mjs is
   * its only exercise. One rail, one number, one decision. */
  .arch-e-body:has(.arch-e-rail) { grid-template-columns: 1fr 300px; }

  /* Outlier 1's minimum, met. The notice is in the DOM in all four states and
   * this is the only thing that ever hides it — a component cannot measure a
   * viewport, and refusing to render below 900 px would be the dead end §11
   * forbids on a tablet a host actually has. Below this width the rows keep
   * their 900 px and scroll inside .arch-o1 instead. */
  .arch-o1-narrow { display: none; }
}
@media (min-width: 1280px) {
  /* Content caps and centres. */
  .shell-head, .shell-main, .shell-foot { max-width: 1280px; margin-inline: auto; width: 100%; }
}
@media (min-width: 1920px) {
  /* THE 1280 CAP IS RELEASED, for this screen and no other. Measured at 1920:
   * with it in force the board rendered 1,232 px wide inside a 1,920 px frame
   * — 640 px of unusable projection, a third of the wall, with the jumbo type
   * squeezed into the middle two thirds. "Content caps and centres" is written
   * for something read at arm's length, and this is the one screen that is
   * not. Scoped by :has() on the board's own root class, the same tool
   * .arch-e-body:has(.arch-e-rail) above already uses, so no other screen at
   * this width could inherit it. */
  .shell:has(.leaderboard) .shell-head,
  .shell:has(.leaderboard) .shell-main { max-width: none; }
  /* THE ROOM'S CLOCK AT THE ROOM'S SIZE (loop 3, browser pass at 1920): the
   * countdown sat in the head at chrome size, the one number on the projection
   * a room watches between rounds. Display size, scoped as the rule above. */
  .shell:has(.leaderboard) .shell-timer { font-size: var(--type-display); }
  /* THE HALF OF RULING 6 THE MODULE CANNOT REACH. That ruling makes this the one
   * screen exempt from the >= 44 px tap floor BECAUSE nothing on it is tappable,
   * and apps/host/leaderboard.js honours it — .leaderboard-exit is display:none
   * from 900 px and measured unfocusable there. The SHELL did not. Measured at
   * 1920 before this rule: the header still rendered Settings (44 x 56.85) and
   * Help (44 x 44) at 16 px beside 56 px rows, and they were two of the three
   * focusable elements on the entire projection. Two controls nobody in the room
   * can reach, at a size nobody in the room can read, on a surface with no input
   * device pointed at it.
   *
   * SCOPED THE SAME WAY THE TWO RULES ABOVE ARE — :has() on the board's own root
   * class, inside the wall's own width — so nothing below 1920 changes. A laptop
   * previewing the screen keeps both links, and the phone that reaches it one tap
   * from host.live keeps them AND .leaderboard-exit, which phase 2.5 added for
   * exactly that phone and which lives below 900.
   *
   * WHAT STAYS, AND WHY. The role chip and the <h1> are not controls, and the h1
   * is the only thing that names what the room is looking at; an unlabelled wall
   * of names is worse than a small title. The skip link stays because it paints
   * nothing — measured at y = -64 until focused — and deleting a keyboard
   * affordance to improve a focusable COUNT is the wrong trade. The way back from
   * the wall was never Settings: it is host.live, the screen the wall is opened
   * from, and the browser's own control behind it.
   *
   * IT ALSO GIVES THE LIST BACK 16 px. The two links were the tallest things in
   * the header, so at 1920 the header falls from 77 to 61 — see the frame budget
   * in the .leaderboard comment above, which is written from the measurement. */
  .shell:has(.leaderboard) .shell-head > .tap { display: none; }
  /* The one place --type-jumbo is read: readable at ten metres, per Task 17's
   * own preamble. On the row and not on the spans, for the `ch` reason above.
   * Below this width (a laptop previewing the screen, which nobody should be
   * reading it from) the rows keep .t-display's own --type-display, declared
   * in the type roles further up. */
  .leaderboard-row { font-size: var(--type-jumbo); }
  /* The fifteen-row band is the one that cannot have 88 px: 15 x 76 + 14 x 8
   * is 1,252 px against the 892 the frame leaves. Its own token is sized from
   * that budget — see ui/tokens.css. */
  .leaderboard-list[data-rows="15"] .leaderboard-row { font-size: var(--type-jumbo-dense); }
  /* And so do the three states, because a wall reporting a failure that only
   * somebody standing at the laptop can read is only half an answer. §11's
   * empty and error are the two the room has to be able to act on. */
  .leaderboard-state-title { font-size: var(--type-jumbo); }
  .leaderboard-state-body { font-size: var(--type-display); }
}

/* host.prep. A check: its name, its badge and, while unmet, the link that fixes
 * it — wrapping, packed from the start, for .arch-meeting-row's 360 px reason.
 * An offline item is a plain line. A sheet is one printed page's content. */
.prep-check {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}
.prep-offline, .prep-step { display: grid; gap: var(--space-1); }
.prep-sheet { display: grid; gap: var(--space-2); }

/* §7.6 and §9.1: honoured, not optional. The achievement pop degrades to a
 * static card, and nothing else in the client animates by more than an opacity. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
  }
}

/* A section that exists only on paper — the certificate on
 * player.expertCredits. Never on screen; print.css reveals the one
 * core/print.js's printSection names. Not [data-print]: host.invitations
 * already gives that attribute an on-screen meaning. */
[data-print-only] { display: none; }
