/* ==========================================================================
   Nocturne — the design system, then the app.
   --------------------------------------------------------------------------
   Everything above the "APP" banner is the design system as designed: tokens,
   base type, and the component classes (.btn, .input, .card, .tag, .dialog,
   .table…). Everything below it is this app's own furniture built from those
   tokens — never from raw hex. If you need a new colour or spacing step, add
   it to :root rather than inlining it.

   THE UNITS ARE THE RULE, TOO. Anything made of text or spaced around it is in
   rem, so it grows with the reader's own font size instead of ignoring it — a
   column that stays 1180px while the text in it goes from 16 to 20 is a worse
   line for exactly the person who asked for a bigger one. Breakpoints are in em
   for the same reason. What stays in px is what is device geometry and not
   type: a hairline border, and the LED sizes, where the pixel grid is what
   makes `image-rendering: pixelated` read as a tile rather than as a blur.

   Both faces are SELF-HOSTED, from public/fonts (npm run vendor:fonts).

   Inter used to be named and never fetched, on the grounds that these pages
   "run on a LAN that may have no route to the internet". That `may` was doing
   too much work: the network model in CLAUDE.md has the broker deliberately
   internet-reachable over DDNS, and browsers reaching the service "on the LAN
   or anywhere else" — the house has internet, and a remote participant is a
   first-class user. What the comment was right about is that a font hanging on
   a third party is a visible stall on the console. Serving both faces from this
   origin keeps that guarantee and drops the guesswork: Inter renders the same
   for everyone now, rather than only for whoever happened to have it installed.
   ========================================================================== */

/* Inter carries everything readable. Press Start 2P is the arcade face and is
   rationed hard — a brand, a blackout switch, Setup's keys, a countdown, a
   score. It has one weight, no lowercase worth the name, and a character width
   that makes any sentence set in it unreadable, which is exactly why it reads
   as a machine rather than as a font. Never put it on body copy, and on a
   control only where the control is the cabinet's own switch. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;          /* one variable file, every weight below */
  font-display: swap;
  src: url("fonts/inter-var.woff2") format("woff2");
}
@font-face {
  font-family: "Press Start 2P";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  /* The face is drawn on an 8-unit grid with the baseline at the foot of the em.
     Capitals fill the top 7/8, the descender row the last 1/8, and the metrics
     declare a one-em ascent and no descent, so a box centred on them sets the
     capitals 1/16 em high. A 9/8 ascent puts the box on the capitals instead. */
  ascent-override: 112.5%;
  src: url("fonts/press-start-2p.woff2") format("woff2");
}

/* CABINET — the arcade direction. The ground moved down to meet the tiles: the
   old #161826 was a blue-grey dashboard, and the LED substrate the hardware is
   actually printed on is #101120, so the chrome and the content are now the
   same material rather than two greys that never agreed. The accent is the
   amber the tiles emit. Everything loud is spent on controls and numbers;
   panels stay quiet, because a hundred sprites have to shout over them. */
:root {
  --color-bg: #0b0c14;
  --color-surface: #16182b;
  --color-text: #f2f4ff;
  --color-accent: #ffc21e;
  --color-divider: color-mix(in srgb, #f2f4ff 16%, transparent);

  /* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
     same step of any role matches the others in visual value.

     500 is the quietest step that still reads as TEXT. Below it the ramp is for
     things you look at rather than read — an idle icon, a blip, a swatch. The
     quiet labels (.kicker, .steps, .baked dt) sat on 600 and were 3.5:1 against
     their own surface at 10px uppercase, which is a laptop in a lit room asking
     you to guess. Reach for 600 and darker on shapes, not on words. */
  --color-neutral-100: #f3f5fe;
  --color-neutral-200: #e4e7f5;
  --color-neutral-300: #cfd3e5;
  --color-neutral-400: #b2b6ca;
  --color-neutral-500: #9397ab;
  --color-neutral-600: #75798c;
  --color-neutral-700: #595d6c;
  --color-neutral-800: #3f424d;
  --color-neutral-900: #292b31;

  /* Amber, generated at the SAME nine OKLCH lightness steps the purple ramp
     used, so a step of accent still matches the same step of neutral in visual
     value. Only the hue and the chroma changed (84.7deg, and amber sustains
     about half again the chroma purple did before it clips out of sRGB), which
     is why 400 and below go fully saturated where the purple stayed pastel. */
  --color-accent-100: #fdf5e5;
  --color-accent-200: #f9e6c1;
  --color-accent-300: #f3cf86;
  --color-accent-400: #eaac00;
  --color-accent-500: #c28e00;
  --color-accent-600: #9d7200;
  --color-accent-700: #7a5700;
  --color-accent-800: #573e00;
  --color-accent-900: #3a2800;

  /* Status. Not in the design's palette — the design never had to show a tile
     that went offline — so they're mixed to sit at the ramps' own
     lightness rather than borrowed from some other system. */
  --color-good: #57c98a;
  --color-bad: #e4564f;
  /* Idle, and deeper than `--color-accent` because it is worn as a dot ON an
     amber disc: the accent there is the disc's own colour and only the ring
     showed the dot was present. */
  --color-idle: #faa61a;

  /* PLAYER_COLORS, src/game/connect4.js — a match's own two colours, used
     where something needs to say "a board is running" rather than "somebody
     is here", which is what the accent and the presence blips already mean. */
  --disc-red: #ff0000;
  --disc-yellow: #ffcc00;

  /* LED tile. The substrate a tile is printed on and the colour an unlit
     pixel actually shows — a dark tile is never pure black in a lit room. */
  --led-substrate: #101120;
  --led-unlit: #1e2030;

  /* fx_boot's own three colours (firmware/esphome, effects_1d.cpp), so the
     connect-stage legend on /flash matches the matrix it explains. */
  --led-boot-blue: #4f8fe4;
  --led-boot-cyan: #30d8e0;
  --led-boot-green: #3be25b;

  /* Discord's, and the only colour here that is not ours. A sign-in button
     wears the brand it signs you in to, so it sits outside the ramps on
     purpose — named, so it reads as foreign rather than as forgotten. */
  --brand-discord: #5865f2;
  --brand-discord-hover: #6b78f5;

  /* The same face twice, on purpose. Nothing here is set in a second family —
     what separates a heading from body text is the weight below and the
     negative tracking — and the two names are the seam a display face would
     arrive on. Eleven rules name the heading one; they change no glyph today. */
  --font-heading: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
  --font-body: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
  /* The arcade face. Rationed to a brand, a blackout switch, Setup's keys,
     a countdown and a score — a word or two, or a number. See the @font-face note. */
  --font-display: "Press Start 2P", ui-monospace, monospace;

  /* Two weights, and only two: body text, and the one step up that a heading, a
     tab label, a score or a name is set in. The app half had been writing that
     step as a bare `500` in twelve places while the design half spelled it
     `var(--font-heading-weight)` — one intent, two spellings, so retuning the
     type meant finding all twelve. `<b>` was a third spelling: the UA resolves
     `bolder` against the 500 it inherits and lands on 700, a weight this design
     does not otherwise contain, on every player name in a Connect 4 line.
     Heading weight is now an alias, so a heading and an emphasised word move
     together unless somebody deliberately splits them again. */
  --font-weight-body: 400;
  --font-weight-medium: 500;
  /* Cabinet's third step. Headings were 500 — the same weight as an emphasised
     word — which is why nothing on the page had a top. Now that Inter is a
     self-hosted variable file, 700 costs no extra request. */
  --font-weight-strong: 700;
  --font-heading-weight: var(--font-weight-strong);

  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  /* Type, and the whole of it below the base h1-h6. It was 19 sizes in 82
     declarations, four of them — 17, 18, 21, 22 — inside a six-pixel band all
     doing one job: a heading inside a panel. That job is --text-title.

     Press Start 2P is absent on purpose. It is drawn on an 8px em and sets
     about twice Inter's width per character, so a size of it is not a step of
     this scale; the arcade sizes stay written where they are used. */
  --text-10: 0.625rem;
  --text-11: 0.6875rem;
  --text-12: 0.75rem;
  --text-13: 0.8125rem;
  --text-14: 0.875rem;
  --text-15: 0.9375rem;
  --text-16: 1rem;
  --text-title: 1.25rem;

  /* Named for what they are, and this is the whole set: a rule wanting 9px has
     to choose 8 or 10, which is the only thing a scale does. 2px apart at the
     bottom where the dense rows are, then 4, then the page's own room.

     They were 2.8 / 5.6 / 8.4 / 11.2 / 16.8 / 22.4 — a 2.8px base nothing in
     the app wanted a multiple of, with holes at the four values it uses most.
     So it went unused: 153 raw pixel values across 18 steps below the banner,
     under a header saying never to write one.

     Every gap, padding and margin below that banner is on this ramp now. What
     is left raw is a hairline and the sprite grid's ring room, which is tied to
     the `inset` its box-shadow is drawn into — geometry, not a step. */
  --space-2: 0.125rem;
  --space-4: 0.25rem;
  --space-6: 0.375rem;
  --space-8: 0.5rem;
  --space-10: 0.625rem;
  --space-12: 0.75rem;
  --space-14: 0.875rem;
  --space-16: 1rem;
  --space-20: 1.25rem;
  --space-24: 1.5rem;
  --space-32: 2rem;
  --space-48: 3rem;
  --space-64: 4rem;

  /* Tighter. 14px read as a soft app card; an arcade key is nearly square, and
     3px is one LED at the pitch a preview is drawn on. */
  --radius-sm: 3px;
  --radius-md: 6px;
  --radius-lg: 8px;

  /* Elevation, as a HARD offset rather than ambient darkness. A blurred shadow
     says "floating"; an unblurred one displaced down-right says "a physical
     thing sitting on the panel", which is what makes a control look pressable —
     and it is the same trick a cabinet's silkscreen uses. --lift is the
     displacement, so .btn:active can cancel it and the key travels. */
  /* The page column, and the gutter it stands in. Both are set twice — the
     shell and the top bar's inner row — and have to agree, or the wordmark
     stops lining up with the text under it. */
  /* The page column, and the gutter it stands in. Both are set twice — the
     shell and the top bar's inner row — and have to agree, or the wordmark
     stops lining up with the text under it.

     In rem rather than px so the column tracks the reader's own font size: a
     1180px column under 20px text is a worse line than it is under 16px, and
     the person who set 20px is the one who needed it not to be. */
  --page-max: 73.75rem;                    /* 1180px at a 16px root */
  --page-gutter: var(--space-24);

  /* A reading width is a count of CHARACTERS, which is the thing that makes a
     line easy or hard to follow — so it is stated in ch and lands wherever the
     face and size put it. Used by the list pages inside the column above. */
  --measure: 68ch;

  --lift: 3px;
  --shadow-sm: 0 0 0 1px var(--color-neutral-800);
  --shadow-md: 0 0 0 1px var(--color-neutral-700), var(--lift) var(--lift) 0 0 var(--color-neutral-900);
  --shadow-lg: 0 0 0 1px var(--color-neutral-600), 6px 6px 0 0 rgba(0, 0, 0, 0.55);
}

/* ---- base ---------------------------------------------------------------- */

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

/* Every tile below sets `display` from a class, which outranks the UA's
   `[hidden] { display: none }` on specificity alone — so a hidden dialog would
   still lay out and eat clicks. Restate it once, decisively, and `hidden` can
   be trusted everywhere. */
[hidden] { display: none !important; }

body {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--text-15);
  line-height: 1.55;
  font-weight: var(--font-weight-body);
  background: var(--color-bg);
  color: var(--color-text);
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-heading);
  font-weight: var(--font-heading-weight);
  line-height: 1.12;
  letter-spacing: -0.015em;
  margin: 0 0 var(--space-6);
}
h1 { font-size: 2.625rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.5625rem; }
h4 { font-size: var(--text-title); }
h5 { font-size: var(--text-16); }
h6 { font-size: var(--text-13); letter-spacing: 0.08em; text-transform: uppercase; }

p { margin: 0 0 var(--space-8); }
/* `bolder` is relative, and relative to the 500 these pages are set in it
   resolves to 700. Pin it to the scale's own emphasis step instead — this is
   the whole of what `<b>` means here: a name inside a sentence. */
b, strong { font-weight: var(--font-weight-medium); }
a { color: var(--color-accent); text-underline-offset: 3px; text-decoration: none; }
a:hover { color: var(--color-accent-300); }
img { display: block; max-width: 100%; }

.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
.mono { font-family: var(--font-mono); font-size: var(--text-11); color: var(--color-neutral-500); }

:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
::placeholder { color: var(--color-neutral-500); }

input, select, button, textarea { font-family: inherit; }

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

/* One control height. A button was 30px tall next to a 36px `.input`, which is
   most visible on the tile dialog's score stepper: the − and + bracketing the
   number sat 3px inset at the top and 3px at the bottom of the field they
   belong to. Padding alone can't fix it, because the two boxes are built from
   different type sizes — so state the height both are aiming at and let each
   pad up to it. Anything that wants to be squatter than a field (a dense list
   row, the Connect 4 drop strip) says so where it says the rest of its size. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  cursor: pointer; text-decoration: none;
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: var(--text-14); line-height: 1.2; color: var(--color-text);
  background: transparent; border: 1px solid transparent;
  min-height: 36px;
  padding: var(--space-6) var(--space-10);
  border-radius: var(--radius-md);
}
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }

/* The key travels. Every raised button carries the hard offset and gives it
   back on :active, moving by exactly the displacement it loses — so the face
   lands where its shadow was and the thing reads as depressed rather than as
   redrawn. Applied on the base class so a primary, a secondary and a danger
   all press identically; a ghost has no edge to lift and stays flat. */
.btn:not(.btn-ghost) { box-shadow: var(--lift) var(--lift) 0 0 var(--btn-lift-ink, transparent); }
.btn:not(.btn-ghost):active:not(:disabled) {
  transform: translate(var(--lift), var(--lift));
  box-shadow: 0 0 0 0 transparent;
}
@media (prefers-reduced-motion: reduce) {
  .btn:not(.btn-ghost):active:not(:disabled) { transform: none; }
}

/* Filled, not outlined. The primary action was an outline in the accent, which
   at a glance was the same shape as the secondary beside it — this is the
   Chakra reading: one obvious target, and everything else quieter than it. */
/* Filled at 400, not at the full accent. The raw amber is near the top of the
   luminance range, and the app repeats its primary — five Challenge buttons in
   a list, a Save that spans a column — so at full strength a page turns into a
   wall of it. One step down is still unmistakably the loudest thing on screen
   and stops competing with the sprite art, which is what the colour is for. */
.btn-primary {
  color: var(--color-bg); background: var(--color-accent-400); border-color: var(--color-accent-400);
  --btn-lift-ink: var(--color-accent-700);
}
.btn-primary:hover:not(:disabled) { background: var(--color-accent); border-color: var(--color-accent); }
.btn-secondary { border-color: var(--color-neutral-700); --btn-lift-ink: var(--color-neutral-900); }
.btn-secondary:hover:not(:disabled) { background: color-mix(in srgb, var(--color-text) 7%, transparent); border-color: var(--color-neutral-600); }
/* A ghost has no edge, so its padding is the only thing separating it from the
   next one. At 2.8px a dialog's ✕ was a 19px-wide target on a modal.
   Neutral until pointed at, rather than accent-coloured: an Animation panel
   carries two of these side by side, and in the accent they read as urgent as
   the filled Apply beside them. */
.btn-ghost { color: var(--color-neutral-300); padding-inline: var(--space-8); }
.btn-ghost:hover:not(:disabled) { color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); }
.btn-ghost:active:not(:disabled) { color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 20%, transparent); }
.btn-danger { color: var(--color-bad); border-color: color-mix(in srgb, var(--color-bad) 55%, transparent); --btn-lift-ink: color-mix(in srgb, var(--color-bad) 30%, var(--color-neutral-900)); }
.btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--color-bad) 14%, transparent); }
/* A button that IS the case, rather than one you can press. One thing uses it —
   the console's team-colours toggle, which swaps the room between the score and
   the colours — and it has been setting `is-on` since it was written with no
   rule to land on, so the state was carried entirely by the label changing. */
.btn.is-on {
  color: var(--color-accent); border-color: var(--color-accent);
  background: color-mix(in srgb, var(--color-accent) 14%, transparent);
}
.btn-icon { width: 36px; height: 36px; padding: 0; }
/* An inline icon carries its own size in the markup; this is what colours
   it, since a button's label colour is the one it should wear. */
.btn-icon svg { fill: currentColor; margin: 0 auto; }
.btn-block { width: 100%; margin-top: var(--space-6); }
/* The arcade face on Setup's keys besides the blackout switch, and the same
   reason: Setup is the page somebody works through with hardware in their hands,
   and these are the presses that carry it. A word or two, because the face sets
   an em per character. */
.btn-arcade {
  font-family: var(--font-display); font-weight: 400;
  font-size: var(--text-12); letter-spacing: 0; text-transform: uppercase;
  line-height: 1.5; padding: var(--space-16) var(--space-24);
  border-radius: var(--radius-lg);
  /* Deeper than the 3px every other button carries, and read from the same
     variable the base class presses by, so the face still lands exactly where
     its shadow was. */
  --lift: 4px;
  /* A key, not a bar: the column runs the width of the page, and a button that
     spans it stops reading as something to press. One word sets 60px at this
     size, so 160 holds it with room either side at every width. It starts where
     every line above it starts. The step above is 14px away; a start takes 20. */
  align-self: start; min-width: min(100%, 160px); margin-top: var(--space-6);
}

/* ---- forms --------------------------------------------------------------- */

.field > label {
  display: block; font-size: var(--text-12); margin-bottom: 5px;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
.input {
  width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
  font-size: var(--text-14); color: var(--color-text); caret-color: var(--color-accent);
  background: var(--color-surface);
  border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
select.input { cursor: pointer; }

input[type=range] {
  -webkit-appearance: none; appearance: none;
  height: 4px; border-radius: var(--radius-sm);
  background: var(--color-neutral-800); outline: none;
}
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; width: 16px; height: 16px;
  border-radius: 50%; background: var(--color-accent); cursor: pointer;
}
input[type=range]::-moz-range-thumb {
  width: 16px; height: 16px; border: none;
  border-radius: 50%; background: var(--color-accent); cursor: pointer;
}
input[type=color] {
  -webkit-appearance: none; appearance: none;
  border: none; background: none; padding: 0; cursor: pointer;
}
input[type=color]::-webkit-color-swatch-wrapper { padding: 0; }
input[type=color]::-webkit-color-swatch {
  border: 1px solid var(--color-neutral-800); border-radius: var(--radius-md);
}
input[type=color]::-moz-color-swatch {
  border: 1px solid var(--color-neutral-800); border-radius: var(--radius-md);
}

/* ---- cards, tags, dialog ------------------------------------------------- */

.card {
  display: flex; flex-direction: column; gap: var(--space-6);
  padding: var(--space-8); border-radius: var(--radius-md); background: var(--color-surface);
}
.card-kicker { font-size: var(--text-10); letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
.card-title {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: var(--text-title); line-height: 1.2;
}
.card-body { margin: 0; font-size: var(--text-13); opacity: 0.8; flex: 1; }
.elev-sm { box-shadow: var(--shadow-sm); }
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }

.tag {
  display: inline-flex; align-items: center; font-size: var(--text-11);
  letter-spacing: 0.02em; padding: 3px 10px; border-radius: calc(var(--radius-md) * 0.75);
  /* Stated rather than inherited: a <button> ignores body's line-height and
     falls back to the UA's own, a few px shorter — which is invisible until
     a `.tag` sits on a `<button>` (`.tag-action`) next to one on a <span>. */
  line-height: 1.55;
}
.tag-accent { background: var(--color-accent-800); color: var(--color-accent-100); }
.tag-neutral { background: var(--color-neutral-800); color: var(--color-neutral-100); }
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
/* The system had no way to say "this is wrong" in a tag — the design never had
   to show a person who had been removed. Mixed at the ramps' own lightness, like
   the status tokens themselves. */
.tag-bad { background: color-mix(in srgb, var(--color-bad) 26%, var(--color-neutral-900)); color: var(--color-neutral-100); }

/* A tag that IS the control, not a label sitting beside one — the Tiles
   header's beta tag, which promotes what it names rather than waiting for a
   separate button to say so. No border, no shadow: it still has to read as
   the tag it was, only pressable. */
.tag-action { border: 0; cursor: pointer; gap: var(--space-4); }
.tag-action:hover { background: var(--color-accent-700); }
.tag-action svg { width: 10px; height: 10px; }

.dialog-backdrop {
  position: fixed; inset: 0; display: grid; place-items: center;
  padding: var(--space-12); z-index: 60;
  background: color-mix(in srgb, var(--color-neutral-900) 70%, transparent);
}
.dialog {
  width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-8);
  padding: var(--space-12); border-radius: var(--radius-lg);
  background: var(--color-surface); box-shadow: var(--shadow-lg);
  max-height: 90vh; overflow-y: auto;
}
/* A dialog holding two sprite pools rather than a column of fields. Same shell,
   twice the width, so the two pools stand side by side instead of one above the
   other with a scroll between them. */
.dialog-wide { width: min(720px, 100%); }
.dialog-title {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: var(--text-title);
}
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-6); margin-top: var(--space-6); }

/* ==========================================================================
   APP — built from the tokens above.
   ========================================================================== */

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

/* Also a container-query boundary — harmless until something inside asks for
   one (see the match-history grid on /connect4), and one name here beats a
   fresh one on every page that eventually wants its own breakpoint. */
.shell {
  max-width: var(--page-max); margin: 0 auto; padding: var(--page-gutter) var(--page-gutter) var(--space-64);
  container-type: inline-size; container-name: shell;
}

/* A page whose content is a list of names caps the LIST, not the column. It
   used to cap the column — `.shell.narrow`, 620px — which left the top bar
   spanning 1180 above a card spanning 620, so on four of the seven pages the
   wordmark sat nowhere near the card it belongs to. Now every page is one
   column wide and the reading pages hold their content in the middle of it. */
.shell.reading > .surface > * { width: 100%; max-width: var(--measure); margin-inline: auto; }

/* Appended to every page by shared.js, after whatever the page ends with. */
.pagefoot {
  padding: 0 var(--page-gutter) var(--space-24);
  color: var(--color-neutral-500); font-size: var(--text-12); text-align: center;
}



/* ---- top bar -------------------------------------------------------------
   A band, not a row of text floating above the page. Three things make it read
   as chrome rather than content: it spans the full viewport while its contents
   stop at the page's own column, it has a surface of its own with an edge under
   it, and it stays put when you scroll.

   Inside, the pieces are different kinds of thing — a link, a live status, an
   identity, a button — so they are all built from one `.topbar-item` box: same
   height, same padding, same type size. Without that the row is as tall as its
   largest child, which is how a 34px button once set the height of a bar full
   of 12px text. */
.topbar {
  position: sticky; top: 0; z-index: 50;
  background: color-mix(in srgb, var(--color-bg) 82%, transparent);
  backdrop-filter: saturate(150%) blur(12px);
  border-bottom: 1px solid var(--color-neutral-900);
}
/* Without backdrop-filter the translucency would let content show through as
   it scrolls under, so fall back to the page colour outright. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .topbar { background: var(--color-bg); }
}

/* `relative`, so the nav's dropdown hangs off the page's own column rather than
   off the full-bleed band — it lines up with the content underneath it. */
.topbar-inner {
  position: relative;
  display: flex; align-items: center; gap: var(--space-2);
  max-width: var(--page-max); margin: 0 auto; height: 56px; padding: 0 var(--page-gutter);
}

.topbar-item {
  display: inline-flex; align-items: center; gap: var(--space-8);
  height: 32px; padding: 0 var(--space-12); border-radius: var(--radius-md);
  font-family: var(--font-body); font-size: var(--text-13); line-height: 1;
  color: var(--color-neutral-400); white-space: nowrap;
  background: transparent; border: 0; text-decoration: none;
}

/* 10px, because Press Start 2P is drawn on an 8px em and sets about twice as
   wide as Inter at the same size — the letter-spacing the old brand needed is
   built into the face. */
.topbar-brand {
  display: inline-flex; align-items: center; gap: var(--space-8);
  height: 32px; margin-right: var(--space-8);
  font-family: var(--font-display); font-weight: 400;
  font-size: var(--text-10); letter-spacing: 0; text-transform: uppercase;
  color: var(--color-text); text-decoration: none;
}
/* The mark is a box, not a glyph, which is the whole reason it lines up: the
   flex row centres it against the wordmark's own box instead of against a
   hexagon's baseline in whatever font happened to supply one. It is sized per
   place it appears rather than in em, because it is an 8x8 with the real
   1-unit gaps between LEDs, and those gaps cost legibility to buy the tile:
   at 20 the H is a cluster of dots, and 24 is where it resolves. That is
   larger than the 10px wordmark beside it, which is fine — it is a tile, not
   a capital, and it still clears the 32px `.topbar-item` box. */
.brandmark { flex: none; }
.topbar-brand .brandmark { width: 24px; height: 24px; }
/* Chromium rounds a text box's ascent to whole pixels, which at 10px leaves the
   capitals a pixel above the mark's centre with the box itself centred. Half a
   pixel is what the rounding took, and puts them back on it. The login page's
   lockup is 24px and lands within half a pixel either way, so it has none. */
.topbar-brand .wordmark { position: relative; top: 0.5px; }

.topbar-gap { flex: 1; }

/* Quiet until wanted: the page you're on is the one that isn't dimmed, so the
   bar reads as "you are here" instead of a row of competing buttons. */
.navlink { cursor: pointer; }
.navlink:hover { color: var(--color-text); background: color-mix(in srgb, var(--color-text) 8%, transparent); }
.navlink.is-here { color: var(--color-text); background: color-mix(in srgb, var(--color-text) 10%, transparent); }

/* Two connections, two icons. They are separate questions — this browser's link
   to the server, and whether anything is lit on the tiles — and a pair of
   identical dots invited people to read them as one. Colour lives on the icon;
   the label stays quiet so the bar doesn't shout while everything is fine. */
.topbar-conn { font-size: var(--text-12); color: var(--color-neutral-500); gap: var(--space-6); padding: 0 var(--space-10); }
.topbar-ico { width: 14px; height: 14px; flex: none; fill: currentColor; color: var(--color-neutral-600); }
.topbar-conn.is-live .topbar-ico { color: var(--color-good); }
.topbar-conn.is-down .topbar-ico { color: var(--color-bad); }
.topbar-conn.is-down { color: var(--color-neutral-400); }

/* The one item in this row that is a CONTROL rather than a reading: it says
   whether this browser makes a noise, and struck through is what off looks
   like. `is-off` dims the bell so the two states differ in weight as well as
   in shape, for the row read at arm's length across a dark room. */
#soundToggle { cursor: pointer; background: none; border: 0; }
#soundToggle:hover .topbar-ico { color: var(--color-neutral-400); }
#soundToggle.is-off .topbar-ico { color: var(--color-neutral-700); }

/* The server link doubles as the match indicator while one of the viewer's
   is running — see renderMatchStatus() below. Below the hamburger breakpoint
   the "Connect 4" nav link carrying the same dot is inside the collapsed
   menu, unseen until it opens; this copy is always in the row. */
.topbar-conn .nav-live-dot, .topbar-conn .match-text { display: none; }
.topbar-conn.is-playing { cursor: pointer; color: var(--disc-yellow); }
.topbar-conn.is-playing .topbar-ico { display: none; }
.topbar-conn.is-playing .nav-live-dot { display: inline-block; }
.topbar-conn.is-playing #connText { display: none; }
.topbar-conn.is-playing .match-text { display: inline; }

/* An impersonated session draws every page as that person's own, so the one
   thing that says otherwise is loud: filled like a primary button rather than
   quiet like the items beside it, and in the bar rather than behind the avatar.
   One step down from full accent, for the reason .btn-primary is. */
.topbar-as {
  cursor: pointer; font-weight: var(--font-weight-medium);
  color: var(--color-bg); background: var(--color-accent-400);
}
.topbar-as:hover { background: var(--color-accent); }

/* `relative`, so the account menu hangs off the avatar and not off the band. */
.topbar-user { position: relative; display: flex; align-items: center; gap: var(--space-2); margin-left: var(--space-6); }
.topbar-me { padding-left: var(--space-6); padding-right: var(--space-10); cursor: pointer; }
.topbar-me:hover { background: color-mix(in srgb, var(--color-text) 8%, transparent); }
.topbar-me[aria-expanded="true"] { background: color-mix(in srgb, var(--color-text) 10%, transparent); }
.topbar-name {
  font-size: var(--text-13); color: var(--color-neutral-300);
  max-width: 16ch; overflow: hidden; text-overflow: ellipsis;
}
.topbar-me .avatar { width: 22px; height: 22px; font-size: var(--text-11); }

/* ---- the bar's dropdowns -------------------------------------------------
   Two things drop out of the bar — the account menu at any width, and the nav
   itself below the breakpoint — so they are one panel with one open/close
   contract (see menu() in shared.js) and, here, one description of what a
   dropped panel looks like. The nav carries this class at every width and the
   wide bar *unsets* it back to a row below, rather than the panel's fill,
   radius and shadow being written out a second time under the media query
   where the two copies would be free to drift.

   `data-open` rather than `hidden`, because `[hidden]` is
   `display: none !important` here and the wide nav has to be able to win. */
.menu {
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 60;
  display: none; flex-direction: column; gap: var(--space-2);
  min-width: 180px; max-width: min(280px, calc(100vw - 20px));
  padding: var(--space-6); border-radius: var(--radius-md);
  background: var(--color-surface); box-shadow: var(--shadow-md);
}
.menu[data-open="1"] { display: flex; }

/* Above the breakpoint the nav is not a dropdown at all: it is the row of links
   it always was, and there is no hamburger to open or close it.
   45em is asked three times: here, at the foot of this section, and again under
   "narrow screens" for the page. One decision, and a media query cannot read a
   custom property, so tuning narrow layout means editing all three. */
@media (width >= 45em) {
  .topbar-nav {
    position: static; display: flex; flex-direction: row; align-items: center;
    gap: var(--space-2); flex-wrap: wrap;
    min-width: 0; max-width: none; padding: 0;
    background: none; box-shadow: none; border-radius: 0;
  }
}
/* The username, which the bar itself drops below 720px. */
.menu-head {
  padding: var(--space-6) var(--space-10) var(--space-8); margin-bottom: var(--space-2);
  border-bottom: 1px solid var(--color-neutral-800);
  font-size: var(--text-12); color: var(--color-neutral-500);
  overflow: hidden; text-overflow: ellipsis;
}
.menu-item {
  display: flex; align-items: center; gap: var(--space-8);
  min-height: 36px; padding: 0 var(--space-10); border: 0; border-radius: var(--radius-sm);
  font-family: var(--font-body); font-size: var(--text-13); text-align: left;
  color: var(--color-neutral-300); background: transparent; cursor: pointer;
  text-decoration: none; white-space: nowrap;
}
.menu-item:hover { color: var(--color-text); background: color-mix(in srgb, var(--color-text) 8%, transparent); }

/* Only ever a control below the breakpoint — above it the nav is always laid
   out as a row, so a button that could hide it would be a trap. */
.topbar-burger { display: none; cursor: pointer; }
.topbar-burger:hover { color: var(--color-text); background: color-mix(in srgb, var(--color-text) 8%, transparent); }
.topbar-burger .topbar-ico { color: currentColor; }

/* Narrow. The band stays ONE line — a bar that grows a second row shoves the
   page down and stops being chrome — and the links go behind a hamburger to
   keep it that way.

   They used to scroll sideways instead. That kept the one line honestly enough,
   but a horizontal scroller with no scrollbar (the scrollbar has to be hidden,
   or the band grows by its height) gives no sign that there is anything past
   the right edge, so on a phone the console and People were reachable only by
   someone who already knew to swipe a row that looks complete. A hamburger is
   the shape every other phone site uses for exactly this, and it costs one tap
   to say what is behind it.
   With the links gone from the row, the brand fits again — the bar keeps its
   name on a phone rather than opening with an anonymous icon.
   Second of the three 45em queries; see the one above the dropdown rules. */
@media (width < 45em) {
  .topbar-inner { height: 52px; padding: 0 var(--space-10); gap: var(--space-2); }
  .topbar-brand { margin-right: 0; font-size: var(--text-13); }
  .topbar-name { display: none; }
  .topbar-item { padding: 0 var(--space-10); font-size: var(--text-12); }
  .topbar-burger { display: inline-flex; padding: 0 var(--space-8); }

  /* The panel itself is `.menu`, above. All this says is that the nav's
     hangs off the LEFT, under the hamburger that opens it, and that its links
     are thumb-sized rows rather than a column of bar-height targets. */
  .topbar-nav { left: 0; right: auto; align-items: stretch; min-width: 200px; }
  .topbar-nav .navlink { height: 38px; padding: 0 var(--space-10); font-size: var(--text-14); border-radius: var(--radius-sm); }
  .topbar-gap { flex: 1; }
  .topbar-user, .topbar-conn { flex: none; }
}

/* Tighter still: the two indicators keep their icons and lose their words. The
   colour is the message; the label was only ever naming which is which, and the
   icons already do that. */
@media (width < 32.5em) {
  .topbar-conn { padding: 0 var(--space-6); }
  /* The live dot is exempt: it is the one thing this loses its word for on
     purpose everywhere else, so it cannot also be the thing dropped here. */
  .topbar-conn span:not(.topbar-ico):not(.nav-live-dot) { display: none; }
}

/* The one surface every screen is built on. */
.surface {
  background: var(--color-surface); box-shadow: var(--shadow-sm);
  border-radius: var(--radius-lg); padding: var(--space-24);
  display: flex; flex-direction: column; gap: var(--space-24);
}
.surface + .surface { margin-top: var(--space-20); }

.kicker {
  font-size: var(--text-10); letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--color-neutral-500);
}
/* A note is nearly always a flex child of a `gap` container, where a trailing
   paragraph margin is dead space stacked on top of the gap — which is why nine
   of them carried an inline `margin:0` to cancel it. Say it once here. */
.note { margin: 0; font-size: var(--text-12); color: var(--color-neutral-500); }
/* The one-step-quieter note, for captions sitting under something they explain.
   Added for the Connect 4 legend, which was reaching for an inline font-size to
   get here; tile.html's three were the same thing and now come through here
   too, so 11px is a step of the scale rather than a number typed into markup. */
.note-sm { font-size: var(--text-11); }
/* A `:name:` in a scroll, previewed as the sprite it names riding the words —
   see paintScrollPreview() in shared.js. `pixelated` because the source is
   8x8 art, not a photo shrunk to fit. */
.scroll-preview { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-height: 18px; }
.scroll-preview-sprite {
  width: 18px; height: 18px; vertical-align: -4px;
  image-rendering: pixelated; border-radius: 3px;
}
/* A container the JS fills with rows, sitting beside the note that shows when
   there are none. Empty, it is still a flex child, so its parent's gap landed
   on both sides of it and the note floated at twice its heading's distance —
   an empty leaderboard read as a rendering fault rather than as "no games
   yet". Collapse the box and the note sits where a first row would have. */
.tile-grid:empty, .team-grid:empty, .board:empty,
.results:empty, .people:empty, .roster:empty, .tile-roster:empty { display: none; }

.stack { display: flex; flex-direction: column; gap: var(--space-14); }
/* A game surface — a board, a picker — holds a hand's width and centers in
   its card, as the tile page's stage and the playback already do. */
.game-col { width: 100%; max-width: 384px; margin-inline: auto; display: flex; flex-direction: column; gap: var(--space-14); }
.row { display: flex; gap: var(--space-8); align-items: center; flex-wrap: wrap; }
.grow { flex: 1; min-width: 0; }

/* A section head: label, a rule that fades at both ends, then a status note.
   The fade is a Nocturne signature and this is the only rule that carries it —
   48px a side. Box outlines and in-control separators stay solid. */
.section-head { display: flex; align-items: center; gap: var(--space-12); }
.rule-fade {
  flex: 1; height: 1px;
  background: linear-gradient(90deg,
    transparent, var(--color-neutral-800) 48px,
    var(--color-neutral-800) calc(100% - 48px), transparent);
}

/* ---- the liveness dot ----------------------------------------------------- */

/* Four colours for two questions, and one of each is shared: green is live —
   a person here, a tile answering — grey is neither, and amber is a PERSON's
   idle alone. Live was the accent everywhere but on an avatar, which put
   #ffc21e for live a shade from #faa61a for idle and left the two states
   telling apart by hue. */
.blip {
  width: 7px; height: 7px; border-radius: 50%; flex: none;
  background: var(--color-neutral-600);
}
.blip.is-live { background: var(--color-good); }
.blip.is-idle { background: var(--color-idle); }


/* ---- LED art ------------------------------------------------------------- */
/* Frames are painted onto a 26x26 canvas — 8 pixels of 2px on a 3px pitch,
   inset 2px — so a preview reads as a physical tile rather than a swatch.
   `pixelated` keeps the pixel edges hard at any display size. */
.matrix {
  display: block; width: 100%; aspect-ratio: 1 / 1;
  image-rendering: pixelated;
  border-radius: var(--radius-sm);
  background: var(--led-substrate);
}

/* ---- page header --------------------------------------------------------- */
/* ONE header for every page that is about something: the console, a
   participant's own tile, /flash. Three cells on one centre line — an optional
   mark, who the page is about, and the actions rail — rather than the two
   different headers those pages used to carry.

   A grid, not a flex row, because as a row every element chose its own vertical
   alignment and the two on the right carried a `margin-top: 3px` to land on the
   name. Here they share a centre line because they are cells of one row, and the
   rails are the header's own edges.

   Column 1 is `auto` and the gap it would have carried rides on the mark itself,
   so a page with no mark — the console, /flash — starts at the name instead of
   being indented by an empty track. */
.page-head {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  padding-bottom: var(--space-16); border-bottom: 1px solid var(--color-neutral-800);
}
.page-head .avatar-wrap { grid-column: 1; margin-right: var(--space-12); }
.page-head /* The identity block keeps a floor rather than tracking its column all the way
   down, which let it collapse to a one-word-per-line sliver with the badges
   sitting on top of it. */
.head-id { display: flex; flex-direction: column; gap: var(--space-4); min-width: 150px; }
.page-head .head-acts {
  grid-column: 3; margin-left: var(--space-16);
  display: flex; align-items: center; gap: var(--space-10);
}
.head-id { display: flex; flex-direction: column; gap: var(--space-4); min-width: 170px; }
.head-name { font-family: var(--font-heading); font-size: var(--text-title); font-weight: var(--font-weight-medium); }
.head-status {
  display: flex; align-items: center; gap: var(--space-8);
  font-size: var(--text-12); color: var(--color-neutral-500); font-variant-numeric: tabular-nums;
}
/* The one switch in the room that turns every tile off. It gets the arcade
   face and the hard lift, because it is the closest thing here to a physical
   button on a cabinet — and it should not be mistakable for the ghost buttons
   beside it. */
.blackout-btn {
  background: transparent; color: var(--color-neutral-300);
  border: 1px solid var(--color-neutral-700); border-radius: var(--radius-md);
  padding: var(--space-14) var(--space-20); font-family: var(--font-display); font-size: 0.5625rem;
  letter-spacing: 0; text-transform: uppercase; cursor: pointer; line-height: 1.5;
  box-shadow: var(--lift) var(--lift) 0 0 var(--color-neutral-900);
}
.blackout-btn:active {
  transform: translate(var(--lift), var(--lift)); box-shadow: 0 0 0 0 transparent;
}
@media (prefers-reduced-motion: reduce) { .blackout-btn:active { transform: none; } }
.blackout-btn:hover { border-color: var(--color-accent); color: var(--color-text); }
.blackout-btn.is-on {
  background: color-mix(in srgb, var(--color-accent) 22%, transparent);
  color: var(--color-accent-100); border-color: var(--color-accent);
}

/* ---- mode tabs ----------------------------------------------------------- */
/* The active tab merges into the tile below it by painting its own bottom
   border in the tile's colour and overlapping it by a pixel.
   That trick only reads right while the panel below is genuinely open at the
   top — a single strip, sharing an edge with the panel. Where the panel is a
   closed rounded card instead (a tile page, any narrow screen), the tab's
   square bottom corners sit over the panel's rounded top ones and the seam
   shows as two roundings nested inside each other. So the five things the
   merge is made of are variables, and the chip treatment further down flips
   all five together rather than some of them. */

.views {
  --tab-bottom-radius: 0;
  --tab-seam: -1px;                                 /* the pixel of overlap */
  --tab-baseline: var(--color-neutral-800);         /* a closed tab's bottom
                                                       edge, which where the
                                                       strip merges is also the
                                                       panel's top line */
  --tab-on-bottom-color: var(--color-neutral-900);  /* the panel's own fill */
  --tab-off-border: transparent;                    /* cut into the strip, an
                                                       unopened tab has no edge
                                                       of its own to show */
  --panel-top-left-radius: 0;
}
.tabs-row { display: flex; align-items: flex-end; gap: var(--space-14); }
/* Shrinkable, so a strip too wide for its container wraps onto a second row of
   chips. Held at `flex-shrink: 0` it grew past the box instead and was clipped
   by it — which in the console's tile dialog, 440px wide with six views in it,
   meant Countdown and Off could not be reached at all. */
.tabs { display: flex; gap: var(--space-4); min-width: 0; flex-wrap: wrap; }
.tab {
  /* One line: the label, and nothing under it. It was a column stacking a hint
     beneath — see appLabel in public/apps/index.js for why those went. */
  display: flex; align-items: baseline; gap: var(--space-2);
  background: transparent; border: 1px solid var(--tab-off-border);
  border-bottom-color: var(--tab-baseline);
  border-radius: var(--radius-md) var(--radius-md)
                 var(--tab-bottom-radius) var(--tab-bottom-radius);
  padding: var(--space-10) var(--space-14) var(--space-12); margin-bottom: var(--tab-seam);
  cursor: pointer; text-align: left; white-space: nowrap;
}
.tab-label { font-size: var(--text-14); font-weight: var(--font-weight-medium); color: var(--color-neutral-500); }
.tab:hover .tab-label { color: var(--color-neutral-300); }
/* An app this deployment has not set up. Dimmed rather than disabled: the panel
   under it is where it gets set up. */
.tab.is-unset .tab-label { color: var(--color-neutral-600); font-style: italic; }
.tab.is-on {
  background: var(--color-neutral-900);
  border-color: var(--color-neutral-800);
  border-bottom-color: var(--tab-on-bottom-color);
}
.tab.is-on .tab-label { color: var(--color-text); }

.tabs-scores {
  flex: 1; min-width: 0; display: flex; gap: var(--space-14); flex-wrap: wrap;
  justify-content: flex-end; padding-bottom: var(--space-12); overflow: hidden;
}
/* Only the console's global strip fills this (renderStandings, in admin.js). On
   a tile page and in the tile dialog it stays empty, where `flex: 1` was still
   claiming half the row from the tabs beside it. */
.tabs-scores:empty { display: none; }
/* The score strip beside the tabs and the standings on a tile page are one
   component (renderStandings) — the strip only wants its rows to sit side by
   side rather than filling a column. */
.tabs-scores .standing { gap: var(--space-6); }
.tabs-scores .standing .name {
  flex: none; font-size: var(--text-11); letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--color-neutral-400);
}

.tabpanel {
  background: var(--color-neutral-900);
  border: 1px solid var(--color-neutral-800);
  border-radius: var(--panel-top-left-radius) var(--radius-md)
                 var(--radius-md) var(--radius-md);
  padding: var(--space-20);
}

/* Apply, on the console's global strip only: the one place a click reaches the
   whole party, so the choice is held until this is pressed (the `stage` option
   in public/apps.js). Ruled off from the controls above it because it is not one
   of them — they compose a choice and this one is the moment the room changes. */
.vp-apply-row {
  margin-top: var(--space-16);
  padding-top: var(--space-14);
  border-top: 1px solid var(--color-neutral-800);
}
/* Armed: a choice is staged and no tile is showing it yet. A ring rather than a
   pulse — this button is already the primary one, and fading a primary button in
   and out reads as disabled, which is the opposite of what it means here. */
.vp-apply.is-armed:not(:disabled) {
  outline: 2px solid color-mix(in srgb, var(--color-accent) 55%, transparent);
  outline-offset: 2px;
}

/* ---- the view picker ------------------------------------------------------
   ONE control, at two scopes: the console sets it for every tile, a participant
   for their own (see public/apps.js). Same markup either way — what differs is
   which views are offered and where they are published to. */

/* A tile page is narrower and reached on a phone, so its strip wraps into rows
   of chips instead of running off the side. Once it wraps there is no single
   edge to merge into — the panel is a card of its own, and the tabs are chips
   sitting above it rather than tabs cut into it. */
/* Chips above a card — for a strip that ACTUALLY WRAPPED, and only then.
   `is-wrapped` is set by layout() in apps.js, which compares the first tab's
   offsetTop to the last one's.

   This used to key off `[data-scope="tile"]`, which was the wrong question and
   looked it: a tile page at desktop width fits all eight tabs on one line, and
   got the gap anyway — a single unwrapped row floating over a card it plainly
   belonged to, while the console's identical single row merged into its panel.
   The reason for the treatment was never the scope; the comment on the merge
   says so itself — "once it wraps there is no single edge to merge into". So
   ask that. It also covers the case the old rule missed entirely: the console
   wrapping on a mid-width window, where the merge kept its square corners and
   its -1px and produced the nested roundings that comment warns about. */
.views.is-wrapped {
  --tab-bottom-radius: var(--radius-md);
  --tab-seam: var(--space-8);            /* a gap, where the merge had overlap */
  --tab-on-bottom-color: var(--color-neutral-800);
  /* A chip is a thing with an edge. Without this the unopened ones were
     invisible boxes, so a strip over three rows read as loose text ragged
     across the page with one bordered box sitting in it. */
  --tab-off-border: var(--color-neutral-800);
  --panel-top-left-radius: var(--radius-md);
}
.views .tabs { flex-wrap: wrap; }

/* ---- the same picker, as an accordion ------------------------------------
   Below 560px of its OWN width — a phone, or the console's tile dialog — the
   strip stops being a strip. Six chips wrapped over three rows put the panel so
   far from the chip that opened it that the two stopped reading as one thing,
   and there was no way to tell which chip the panel belonged to without
   counting. As an accordion the answer is where the question is.

   Same buttons, same panels, re-parented by layout() in apps.js — so this is
   only ever a change of arrangement, never of what is on screen. */
.views.is-accordion {
  --tab-seam: 0;
  --tab-bottom-radius: var(--radius-md);
  --tab-off-border: var(--color-neutral-800);
  --tab-on-bottom-color: var(--color-neutral-800);
  --panel-top-left-radius: var(--radius-md);
}
/* ONE stack, not seven cards in a column. The panels are IN this list — views.js
   does `tab.after(panel)`, so the answer sits under its own question — and any
   gap here lands in two places at once: between a header and the panel it owns
   (the seam that must not exist) and between one section and the next. Six
   floating rounded boxes was the second of those read as the design.
   So: no gap, and -1px so each row's top border lands ON the one above rather
   than beside it. Only the outer corners of the whole stack are round. */
.views.is-accordion .tabs { flex-direction: column; flex-wrap: nowrap; gap: 0; }
.views.is-accordion .tabs > * + * { margin-top: -1px; }
.views.is-accordion .tab,
.views.is-accordion .tabpanel { border-radius: 0; }
.views.is-accordion .tabs > :first-child {
  border-top-left-radius: var(--radius-md); border-top-right-radius: var(--radius-md);
}
/* The LAST ROW, which is not `:last-child`: eight closed panels sit in this list
   carrying `hidden` (views.js sets the attribute), so the DOM's last child is
   almost always one of them and the stack's bottom corners stayed square.
   Ask the real question — nothing visible after me. */
.views.is-accordion .tabs > *:not(:has(~ *:not([hidden]))) {
  border-bottom-left-radius: var(--radius-md); border-bottom-right-radius: var(--radius-md);
}
/* Where two borders overlap, the open section's is the one to see: a header and
   its panel are a lit block and have to read as continuous through the join. */
.views.is-accordion .tab.is-on,
.views.is-accordion .tabpanel { position: relative; z-index: 1; }
/* A header, not a chip: full width, and the whole row is the target rather than
   the words in it. */
.views.is-accordion .tab {
  width: 100%; padding: var(--space-12) var(--space-14);
}
/* The chevron says the row opens — and only these rows do, so it is the one
   mark distinguishing a header from every other bordered thing on the page. */
.views.is-accordion .tab::after {
  content: ""; flex: none; align-self: center;
  width: 7px; height: 7px; margin-left: auto;
  border-right: 1.5px solid var(--color-neutral-500);
  border-bottom: 1.5px solid var(--color-neutral-500);
  transform: translateY(-2px) rotate(45deg);
  transition: transform 0.15s ease;
}
.views.is-accordion .tab.is-on::after {
  transform: translateY(1px) rotate(225deg);
  border-color: var(--color-accent-300);
}
@media (prefers-reduced-motion: reduce) {
  .views.is-accordion .tab::after { transition: none; }
}
/* The open panel is the bottom half of the header above it: square where they
   meet, and pulled up by the one pixel their borders would otherwise double. */
.views.is-accordion .tab.is-on {
  border-bottom-left-radius: 0; border-bottom-right-radius: 0;
}
.views.is-accordion .tabpanel {
  margin-top: -1px; padding: var(--space-14);
  border-top-left-radius: 0; border-top-right-radius: 0;
}
/* The row that held the strip beside the scores becomes a column — the
   accordion is full-width, so there is nothing left to sit next to. Standings
   belong above the thing you are choosing with, not after it. */
.views.is-accordion .tabs-row { flex-direction: column; align-items: stretch; gap: var(--space-10); }
.views.is-accordion .tabs-scores { order: -1; justify-content: flex-start; padding-bottom: 0; }

/* The tab you have OPEN and the view actually running are different questions,
   and conflating them was why the console's strip used to jump under the
   admin's hand. `is-on` is the open one; this dot is the live one. */
.tab.is-live .tab-label::after {
  content: ""; display: inline-block; margin-left: var(--space-6);
  width: 5px; height: 5px; border-radius: 50%; background: var(--color-accent);
  vertical-align: middle;
}

/* A participant whose picker an admin has overruled. A stored view sits at the
   tile rung and loses to the global app, so no ordinary tab can take — the
   global app's own id included, since `clock` at tile scope is a stored view
   like any other. Every tab in the strip is a stored view now that Connect 4
   carries none: apps.js sets `is-dead` with no exception left to carve out.
   `opacity`, the same idiom .btn:disabled uses, rather than a darker step of
   the ramp: dropping the label one step read as another shade of label, and
   dropping it far enough to read put unreadable words on the page. This takes
   the label, the border and the tab's own background together. */
.tabpanel.is-dead { opacity: 0.55; pointer-events: none; }
.tab.is-dead { opacity: 0.45; cursor: not-allowed; }
/* The open tab gives up its bright label first. `.tab.is-on` sets it to full
   text colour, which at 0.45 composites to the same weight as the one tab that
   still works. Its background stays: which panel is open is a shape. */
.tab.is-dead .tab-label { color: var(--color-neutral-500); }

/* Who the strip answers to, above it and sharing an edge with the first tab, so
   it is read before a hand reaches for one. Not in `.tabs-scores`: that slot is
   sized for the console's short score chips, and two words beside a full strip
   push it to wrap. */
.vp-control-row { display: flex; margin-bottom: var(--space-8); }
.vp-control {
  display: inline-flex; align-items: center; gap: var(--space-6);
  font-size: var(--text-13); font-weight: var(--font-weight-medium);
  color: var(--color-neutral-500); white-space: nowrap;
}
.vp-control.is-admin { color: var(--color-accent); }
.vp-lock { display: block; width: 13px; height: 13px; flex: none; }

/* One colour field, instead of the three differently-sized inline-styled
   <input type="color"> the effect panel, the marquee and the drawing pad each
   used to carry. */
.color-input {
  width: 38px; height: 34px; flex: none; padding: 0;
  border: 1px solid var(--color-neutral-800); border-radius: var(--radius-md);
  background: none; cursor: pointer;
}

/* A labelled slider with its readout — speed, scale, marquee speed. */
.dial-row { flex-wrap: nowrap; gap: var(--space-12); }
.dial-label { width: 52px; flex: none; }
.dial-value { width: 32px; flex: none; text-align: right; }
.row.nowrap { flex-wrap: nowrap; }

/* The sprite picker's header row: label, count, search. */
.sp-head { gap: var(--space-12); }
.sp-search { width: 150px; }
/* Not a narrower fixed width — 110px cut its own placeholder off mid-word, so
   the control that tells you what it is for could not say it. It takes what is
   left of the row instead, and wraps to its own line when that is not enough. */
@media (width < 35em) { .sp-search { width: auto; flex: 1 1 140px; } }

/* ---- sprite pickers ------------------------------------------------------ */

/* The padding is the selection ring's room. `.sprite-opt.is-on` is a 2px
   box-shadow, which is OUTSIDE the element's box and therefore outside this
   scroll container's content box — so on the top row, the bottom row and both
   end columns the ring was cut in half by the scroller. Padding is what a
   box-shadow can be drawn into, and the floor below counts it in.

   Three rows is that FLOOR, not the height: the grid scrolls, so where the page
   leaves it room it takes it. `--sp-room` is that room, measured and published
   by fitSpriteGrid() in shared.js. Nothing caps it — a grid stops at the
   sprites in it, and showing the pool whole is the point. */
.sprite-grid {
  --sp-floor: 178px;
  display: grid; gap: 7px; align-content: start;
  grid-template-columns: repeat(auto-fill, minmax(46px, 1fr));
  padding: 3px; max-height: max(var(--sp-floor), var(--sp-room, 0px)); overflow-y: auto;
}
/* Who added the sprite in the pad, drawn the way a person is drawn everywhere
   else: the disc first, the sentence beside it. */
.sprite-about { display: flex; align-items: center; gap: var(--space-8); }

/* The pool on a page of its own, where the cap above is wrong: 178px is the
   right height for a picker under a tab and half a screen of nothing beside the
   pad on /sprites. It still scrolls — a hundred 8x8s is a wall either way —
   but at the height of the screen rather than of a panel. */
.sprite-wall .sp-grid { max-height: min(62vh, 560px); }

.sprite-grid.dense {
  grid-template-columns: repeat(auto-fill, minmax(34px, 1fr));
  gap: 6px; max-height: 84px;
}
/* No frame at rest. A `.matrix` already IS a tile — it carries the substrate
   colour and its own corner — so a bordered box around one is a second frame
   around an object that did not need the first, and a picker draws a hundred of
   them at once. The edge only ever meant "you can click this" and "this is the
   one", so it arrives on hover and on selection and is a ring rather than a
   border: a box-shadow takes no space, so a hundred tiles do not reflow by a
   pixel each when one of them lights up. */
.sprite-opt {
  aspect-ratio: 1; background: none; padding: 3px; cursor: pointer;
  border: 0; border-radius: var(--radius-md);
  position: relative;
}

/* A sprite in the gallery is an <img> of an 8x8 PNG the browser caches, not a
   canvas paintFrame() drew — so the LED gaps have to come from here. One
   substrate-coloured line per pixel in each axis, on the same 3-unit pitch with
   a 2-unit LED that paintFrame uses, so a sprite reads as a tile in the picker
   and as a tile on the stage. */
.sprite-opt::after {
  content: ''; position: absolute; inset: 3px; pointer-events: none;
  border-radius: var(--radius-sm);
  background:
    linear-gradient(to right, transparent 0 66.7%, var(--led-substrate) 66.7%) 0 0 / 12.5% 100% repeat-x,
    linear-gradient(to bottom, transparent 0 66.7%, var(--led-substrate) 66.7%) 0 0 / 100% 12.5% repeat-y;
}
.sprite-grid.dense .sprite-opt::after { inset: 2px; }
.sprite-opt:hover { box-shadow: 0 0 0 2px var(--color-accent); }
.sprite-opt.is-on { box-shadow: 0 0 0 2px var(--color-accent); }
.sprite-grid.dense .sprite-opt { padding: 2px; border-radius: var(--radius-sm); }

/* ---- a fold -------------------------------------------------------------- */
/* A disclosure whose summary is a kicker you can press. It was `.flash-detail`,
   used once, until the team panel needed the same thing: everything in there
   that is done ONCE — sorting the room into sides — folds away, so what is left
   open is what an admin touches all evening (the scores, and how the room shows
   them).
   `<details>` rather than a button and a hidden div: the open/shut state is the
   element's own, so nothing in admin.js has to remember it. */
/* A `.kicker` you can press, and set as one: two micro-labels a pixel and a
   hundredth of an em apart is two spellings of one thing, which on the console's
   team panel sit six lines from each other — the fold that opens the roster, and
   the winner's own label. The marker is what says this one is a control. */
.fold > summary {
  cursor: pointer; font-size: var(--text-10); letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--color-neutral-500);
}
.fold > summary:hover { color: var(--color-accent); }
.fold[open] > summary { margin-bottom: var(--space-12); }
/* The body sits in from its summary, against a rule. Without it an open fold
   reads as the page simply continuing, and whatever comes after the fold looks
   like the last thing inside it — which on the team panel meant the winner row
   appeared to be part of sorting the sides out. */
.fold > :not(summary) {
  padding-left: var(--space-14); border-left: 1px solid var(--color-neutral-800);
}
/* What the fold is about, said in the summary so closing it doesn't hide the
   answer — only the machinery for changing it. Not uppercase, and not the
   summary's colour: it is a value, beside a label. */
.fold-aside {
  margin-left: var(--space-10); text-transform: none; letter-spacing: 0;
  font-size: var(--text-12); font-weight: var(--font-weight-medium); color: var(--color-neutral-400);
}

/* A checkbox and its label as one target. The console had no checkbox at all
   until the score cycle needed one, so this is the first: same height as the
   ghost buttons it sits beside, so the row reads as one line of controls. */
.check {
  display: inline-flex; align-items: center; gap: var(--space-8); min-height: 36px;
  padding: 0 var(--space-4); cursor: pointer;
  font-size: var(--text-13); color: var(--color-neutral-300);
}
.check input { width: 15px; height: 15px; accent-color: var(--color-accent); cursor: pointer; }
.check:hover { color: var(--color-text); }

/* ---- chips (animations, orientations, presets) --------------------------- */

.chip {
  background: none; border: 1px solid var(--color-neutral-800);
  color: var(--color-neutral-300); border-radius: var(--radius-md);
  padding: var(--space-10) var(--space-12); font-size: var(--text-13); cursor: pointer;
  font-variant-numeric: tabular-nums;
}
.chip:hover { border-color: var(--color-accent); color: var(--color-text); }
.chip.is-on {
  background: color-mix(in srgb, var(--color-accent) 16%, transparent);
  border-color: var(--color-accent); color: var(--color-text);
}
.chip-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-8); }
.chip-row { display: flex; gap: var(--space-6); }
.chip-row .chip { flex: 1; padding: var(--space-10) 0; }

/* ---- countdown ----------------------------------------------------------- */

/* A countdown on a wall of LEDs, set in the face those LEDs would draw it in.
   34px rather than 52: the arcade face is nearly twice Inter's width per
   character, so "10:00" occupies the same room it always did. It is already
   monospaced by construction, so tabular-nums has nothing left to correct. */
.clock-face {
  font-family: var(--font-display); font-size: 2.125rem; font-weight: 400;
  line-height: 1.1; letter-spacing: 0;
}

/* ---- teams --------------------------------------------------------------- */

/* The console's team panel, mounted under the Team score tab (admin.js). Three
   steps in the order an evening runs them — deal the sides, score the rounds,
   declare a winner and clear the room — ruled off from each other so a scroll
   down the panel has landmarks rather than one flat stack of controls at one
   weight. Written on the second of a pair, so the panel opens on a step rather
   than on a rule. */
.team-step + .team-step { padding-top: var(--space-12); border-top: 1px solid var(--color-neutral-800); }

/* Thirty-two characters, in a field sized for them. Filling the panel's width
   is what an `.input` does by default, and it left the one control at the top
   of this column spanning a metre while every control under it is as wide as
   what it holds. */
.team-round .input { max-width: 24rem; }

/* The deal — how many sides, and the button that deals them. The select is
   sized to its options rather than to the row. */
.team-acts { display: flex; gap: var(--space-8); align-items: center; flex-wrap: wrap; }
.team-acts > .input { width: auto; flex: none; }
/* Clearing up, on its own line under the winner it follows. */
.team-undo { display: flex; gap: var(--space-8); }

/* A gap saying "a different thing starts here" where the row's own gap says
   "these belong together" — the winner's button after the line it acts on. It
   was a `flex: 1`, which spends the whole panel on that distinction and leaves
   two controls at opposite edges of a metre of nothing. */
.set-off { margin-left: var(--space-20); }

.team-grid { display: flex; gap: var(--space-8); flex-wrap: wrap; }
/* Capped, because the row divides itself between however many sides are in
   play: two teams on a wide panel is two cards half a metre each, holding a
   name, a number and a stepper adrift in the middle of them. */
.team-card {
  flex: 1 1 190px; max-width: 22rem; display: flex; align-items: center; gap: var(--space-10);
  background: var(--color-surface); border: 1px solid var(--color-neutral-800);
  border-left: 3px solid var(--color-neutral-700);
  border-radius: var(--radius-md); padding: var(--space-12);
}
.team-card .who { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.team-card .members { font-size: var(--text-11); color: var(--color-neutral-500); }
/* What the side is called, typed where it is read. An unnamed side shows its
   colour as the placeholder, which is what it is called until somebody says
   otherwise — so the card reads the same whether or not anybody has. */
.team-card .team-rename {
  font: inherit; font-size: var(--text-14); font-weight: var(--font-weight-medium);
  color: var(--color-text); caret-color: var(--color-accent);
  width: 100%; min-width: 0; background: none;
  border: 1px solid transparent; border-radius: var(--radius-sm);
  padding: 1px var(--space-6); margin-left: calc(var(--space-6) * -1);
}
/* The colour standing in for a name it has not been given yet — capitalised
   here alone, because what an admin types is left as typed. */
.team-card .team-rename::placeholder { text-transform: capitalize; }
.team-card .team-rename:hover { border-color: var(--color-divider); }
.team-card .team-rename:focus-visible { border-color: var(--color-accent); outline: none; }
/* Inter. The arcade face was tried here at 20px and lost — not because the
   glyphs are wrong (at 34px on the countdown they are the best thing on the
   page) but because a lone "0" at card size has no presence beside a 14px
   label, and every team starts a party on nought. The face is rationed to the
   three places it is display-scale: the brand, Blackout, and the countdown. */
.team-card .value {
  font-family: var(--font-heading); font-size: var(--text-title); font-weight: var(--font-weight-strong);
  font-variant-numeric: tabular-nums;
}
/* One stepper, read − + across, at every width. It was a stacked pair that
   turned into a row on a phone, so the control changed shape halfway down the
   page's own breakpoint and the two halves swapped sides doing it. Only the
   size varies now. */
.team-card .nudge { display: flex; gap: var(--space-4); }
.nudge button {
  width: 28px; height: 28px; background: none; cursor: pointer;
  border: 1px solid var(--color-neutral-800); color: var(--color-neutral-300);
  border-radius: var(--radius-sm); font-size: var(--text-13); line-height: 1;
}
.nudge button:hover { border-color: var(--color-accent); color: var(--color-text); }

/* Declaring a winner: two pools side by side in a dialog, because it is two
   choices out of a hundred sprites each and it happens once a night. It was a
   bar on the team panel with one pool in it, open all evening. */
.winner-pick { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-20); }
/* The option that is an absence: the losing pool's first cell, meaning "carry
   on". A glyph rather than a word because it stands in a grid of 34px tiles. */
.sprite-none {
  display: flex; align-items: center; justify-content: center;
  color: var(--color-neutral-500); font-size: var(--text-13); line-height: 1;
  border: 1px dashed var(--color-neutral-700);
}
.cap { text-transform: capitalize; }
.winner-sprite { width: 38px; height: 38px; flex-shrink: 0; }

/* ---- the tile grid ------------------------------------------------------- */

.tile-grid {
  display: grid; gap: var(--space-10);
  grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
}
/* A card, not a frame — this one earns its box, because it carries a name and
   an avatar the tile itself cannot show. What goes is the FILL and the outline:
   the team stripe along the top is the only edge that says anything, so it is
   the only one left, and pointing at the card rings it rather than repainting
   a border that was already there. */
.tile-card {
  display: flex; flex-direction: column; gap: var(--space-8); text-align: left;
  background: none; border: 0;
  border-top: 3px solid var(--color-neutral-800);
  border-radius: var(--radius-md); padding: var(--space-10) var(--space-10) var(--space-8); cursor: pointer;
}
.tile-card:hover { box-shadow: 0 0 0 2px var(--color-accent); }
/* Dimmed, but still readable: this is the card you squint at to find out WHICH
   tile dropped, so the name has to survive the dimming. 0.45 took it to 2:1. */
.tile-card.is-offline { opacity: 0.6; }
.tile-card .who { display: flex; align-items: center; gap: var(--space-6); }
.tile-card .name {
  flex: 1; min-width: 0; font-size: var(--text-13); color: var(--color-neutral-200);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.avatar {
  width: 19px; height: 19px; border-radius: 50%; flex-shrink: 0;
  background: var(--color-accent); color: var(--color-bg);
  /* The one place a weight above the medium step is right: a 10px initial
     reversed out of a coloured disc needs the extra stroke to hold together.
     `.lg` is 16px on the same disc and does not — at that size 600 just read as
     heavier than the name sitting beside it, which is the opposite of the
     order those two want. */
  font-size: var(--text-10); font-weight: 600;
  display: flex; align-items: center; justify-content: center;
  /* An avatar image, when there is one, is painted over the initial's disc —
     see avatarHtml() in shared.js for why it's a background and not an <img>. */
  background-size: cover; background-position: center;
}
.avatar.lg { width: 38px; height: 38px; font-size: var(--text-16); font-weight: var(--font-weight-medium); }

/* ---- player view --------------------------------------------------------- */

/* The header is `.page-head`, up with the other header rules, because the
   console and /flash wear it too. What is here is the rest of a tile page: the
   badges that ride under the name, and the tile beside the controls for it. */
.team-pill {
  display: flex; align-items: center; gap: var(--space-8);
  border: 1px solid var(--color-neutral-800); border-radius: 999px; padding: var(--space-6) var(--space-12);
}
/* A small coloured mark beside a name, spelled four ways: here, `.standing
   .swatch` (8px, 2px radius), `.board .standing .swatch` (8px, round) and
   `.blip`. Within a pixel of each other, so merging them changes nothing and
   risks something. A fifth should merge rather than add a spelling. */
.team-pill .swatch { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--color-neutral-600); }
.team-pill .name { font-size: var(--text-12); color: var(--color-neutral-200); text-transform: capitalize; }
/* A pill whose mark is a glyph rather than a coloured dot — it stands in the
   `.swatch` slot, so it is sized to the label beside it and not to the 15px
   body text it would otherwise inherit. */
.team-pill .swatch-glyph { font-size: var(--text-12); line-height: 1; flex: none; }
.team-pill .value { font-weight: var(--font-weight-strong); font-variant-numeric: tabular-nums; }
/* The pill doubles as the team picker — it looks like a badge and behaves like
   a select, so the player's team isn't a separate form row. */
.team-pill:hover { border-color: var(--color-accent); }

/* The tile goes beside the picker in a window wider than it is tall, and on top
   of it in one taller than wide. The window's SHAPE is the question: an 900px
   column has width enough for two columns and no height to spend on them, and
   the old rule — stack under 720px of width — put the tile beside the picker
   there and stacked it on a wide short laptop that had the room. */
.page-body { display: flex; gap: var(--space-24); align-items: flex-start; }
/* The side column holds the tile itself, with a gear under it opening its two
   physical settings — a fixed 300px so the sprite pool beside it gets every
   spare pixel. */
.page-side { flex: 0 1 300px; min-width: 260px; display: flex; flex-direction: column; gap: var(--space-20); }
.page-main { flex: 2 1 320px; min-width: 0; display: flex; flex-direction: column; gap: var(--space-20); }

/* Stacked, and the tile centres in the full width rather than in a 300px box
   30px narrower than the page — which is exactly how the wrapped version used
   to look, 15px left of centre. The width clause is the floor under the shape
   question: below it there is no room for two columns whatever the shape. */
@media (orientation: portrait), (width < 40em) {
  .page-body { flex-direction: column; align-items: stretch; }
  .page-side { flex: none; min-width: 0; }
}

/* The tile page's own tile, at 200px, and for a long time inside a filled
   rounded panel — the most redundant frame on the site, because this is the one
   place the picture IS the subject of the page. The stage says nothing about
   the tile's state: the word under the name does, once. */
.stage {
  background: none;
  border-radius: var(--radius-lg); padding: var(--space-16);
  display: flex; flex-direction: column; align-items: center; gap: var(--space-14);
}
.stage-matrix { width: 200px; max-width: 100%; }


/* The tile's state, in one word under the name, or none when the matrix is
   answering. It replaced five things that each said the same boolean: a dot on
   the avatar, a pill beside the name, a dashed red edge on the stage, a
   paragraph under it, and this element sitting empty.

   Empty, it takes no room: a matrix that is answering leaves the name on the
   avatar's own centre line, which is the header's whole geometry. Reserving the
   line to keep the height fixed cost that, and the height was never what
   mattered — the badge sits under the name so the ROW keeps a fixed set of
   things in it, and the gear never wraps to a second line.

   An unreachable tile keeps its preview at FULL strength: this screen is the
   only tile its owner has left. */
.tile-state {
  display: flex; align-items: center; gap: var(--space-6);
  font-size: var(--text-13); font-weight: var(--font-weight-medium);
  color: var(--color-neutral-500);
}
.tile-state[data-state="offline"] { color: var(--color-bad); }
/* A link, because for a virtual tile "what is this" and "what do I do" have the
   same answer. */
.tile-state[data-state="virtual"] a {
  color: var(--color-accent); text-decoration: none;
  border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
}
.tile-state[data-state="virtual"] a:hover { border-bottom-color: var(--color-accent); }

/* Brightness and orientation describe hardware, which a virtual tile has none
   of. The stage keeps running: what the tile WOULD show is what somebody
   waiting on hardware is watching. */
.slider-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-10); }
.slider-value { font-size: var(--text-12); font-variant-numeric: tabular-nums; }

.orient-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-8); }
.orient {
  display: flex; flex-direction: column; align-items: center; gap: var(--space-8);
  padding: var(--space-10) 0 var(--space-8);
}
/* A little tile with a thick top edge — turn it and you can see which way up
   the tile will be. */
.orient .glyph {
  width: 22px; height: 22px; border: 1px solid currentColor;
  border-top-width: 5px; border-radius: 3px;
}
.orient .label { font-size: var(--text-11); font-variant-numeric: tabular-nums; }

/* ---- the 8x8 drawing pad -------------------------------------------------- */

.pad-row { display: flex; gap: var(--space-14); flex-wrap: wrap; }
/* The pad IS a preview — the same `.matrix` canvas every other 8x8 on the site is,
   painted by the same paintFrame() — so what you are drawing already looks like
   the tile it is going to. It used to be a grid of 64 divs with its own gap,
   its own corner radius and its own copy of the two LED colours, which is two
   pictures of one object sitting side by side and drifting apart.

   Qualified with `.matrix` rather than relying on this rule coming later in the
   file: the width override is the whole point of the selector, and it should
   not depend on source order to win. */
.matrix.pad {
  width: 190px; flex: none;
  /* `.matrix` is square by aspect-ratio rather than by a stated height, and a flex
     item's default align-self is `stretch` — which wins, and drew the pad 190
     wide by however tall the controls beside it happened to be. */
  align-self: flex-start;
  border: 1px solid var(--color-neutral-800); border-radius: var(--radius-md);
  /* Painting is a drag, and on a phone a drag scrolls unless it is claimed. */
  touch-action: none; cursor: crosshair;
}
.pad-side { flex: 1; min-width: 170px; display: flex; flex-direction: column; gap: var(--space-8); }
/* Where each page hangs its own name/save controls on the shared pad. */
.pad-extra { display: flex; flex-direction: column; gap: var(--space-8); }
/* ---- the ask strip --------------------------------------------------------
   What is asking this person, under the top bar on every page. It carries the
   `.live-card` treatment — a gradient edge meaning HAPPENING NOW — because
   that is what it is; the colour says which kind of ask, since a match and the
   room are different claims. Two can be open at once and no more (a person is
   in one match and the room asks one question), oldest on top. */
.asks {
  display: flex; flex-direction: column; gap: var(--space-8);
  max-width: var(--page-max); margin: 0 auto;
  padding: var(--space-16) var(--page-gutter) 0;
}
.ask { border-radius: var(--radius-lg); padding: 2px; }
.ask-poll { background: linear-gradient(135deg, var(--color-accent-400), var(--color-accent)); }
.ask-match { background: linear-gradient(135deg, var(--disc-red), var(--disc-yellow)); }
/* A poke asks for nothing back, so its row carries no controls: an edge, who
   sent it, and what they typed. The edge is the hand's own two colours, which
   is what the matrix is showing while this stands. */
.ask-poke { background: linear-gradient(135deg, #ffc94a, #ff3d7f); }
.ask-poke .ask-title:empty { display: none; }
.ask-body {
  display: flex; align-items: center; gap: var(--space-20); flex-wrap: wrap;
  background: var(--color-surface); border-radius: calc(var(--radius-lg) - 2px);
  padding: var(--space-14) var(--space-24);
}
.ask-head { display: flex; flex-direction: column; gap: var(--space-4); flex: none; }
/* Not `.chip-row`: that stretches its chips to equal widths for a row of
   presets, which here squeezes an answer's label onto two lines and slides the
   art under it. An answer is as wide as what it is called. */
.ask-choices { display: flex; flex-wrap: wrap; gap: var(--space-8); }
.ask-choices .chip {
  display: inline-flex; align-items: center; gap: var(--space-6);
  white-space: nowrap; min-height: 44px;
}
.ask-title {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: var(--text-title); line-height: 1.2;
}
.ask-body .note { margin-left: auto; flex: none; }
/* On a phone the row becomes a stack, and the clock stops being a right-hand
   column it no longer has. */
@media (max-width: 35em) {
  .asks { padding: var(--space-12) var(--space-16) 0; }
  .ask-body { flex-direction: column; align-items: stretch; gap: var(--space-12); }
  .ask-choices { flex-direction: column; }
  .ask-body .note { margin-left: 0; }
}

/* A poll's answer, wherever it is written down: the sprite it will put on a
   tile, or the colour it will turn one. Unsmoothed, because an 8x8 shown at
   18px is pixels and reads as art only while they stay square. */
.vp-po-art { width: 18px; height: 18px; flex: none; border-radius: 2px; image-rendering: pixelated; }
.vp-po-pip { width: 9px; height: 9px; flex: none; border-radius: 50%; }

.swatch-row { display: flex; gap: var(--space-6); flex-wrap: wrap; }
/* The colours this browser last painted with, under the fixed palette. Its own
   column rather than a `.stack`, whose gap is for whole controls. */
.pad-recent { display: flex; flex-direction: column; gap: var(--space-6); }
.swatch-btn {
  width: 22px; height: 22px; padding: 0; cursor: pointer;
  border: 1px solid var(--color-neutral-800); border-radius: var(--radius-sm);
}
.swatch-btn.is-on { border-color: var(--color-text); }
.swatch-btn.erase {
  background: repeating-linear-gradient(45deg,
    var(--color-neutral-800), var(--color-neutral-800) 4px,
    var(--color-neutral-900) 4px, var(--color-neutral-900) 8px);
}

/* ---- Connect 4 board ------------------------------------------------------ */
/* Made of the same material as a tile — a dark substrate with unlit holes in
   it — so it takes the LED tokens rather than the second pair of dark greys it
   used to carry inline. The disc colours are the exception and stay data: they
   come from the server (meta.gameColors) precisely so the board in a player's
   hand matches the one rendering on the tiles, and arrive per-cell as --disc. */

.c4-drops { display: grid; grid-template-columns: repeat(7, 1fr); gap: var(--space-4); }
/* Seven of these sit directly on top of the board and read as part of it, so
   they stay squatter than a field-height button. */
.c4-drops .btn { min-height: 28px; padding: var(--space-6) 0; }
.c4-board {
  display: grid; grid-template-columns: repeat(7, 1fr); gap: var(--space-4);
  padding: var(--space-6); border-radius: var(--radius-lg);
  background: var(--led-substrate);
}
.c4-cell { aspect-ratio: 1; border-radius: 50%; background: var(--led-unlit); }
.c4-cell.is-disc { background: var(--disc); }
.c4-cell.is-win { box-shadow: 0 0 0 2px var(--color-text) inset; }

/* A live match is the one thing on /connect4, or the pointer to it on /tile,
   that is happening RIGHT NOW — so it carries the one weight nothing else on
   either page has: its own two colours on the edge, not the chrome's amber,
   which already means a dozen other things. */
.live-card {
  display: block; border-radius: var(--radius-lg); padding: 2px;
  background: linear-gradient(135deg, var(--disc-red), var(--disc-yellow));
}
.live-card > .surface { margin: 0; }

/* The pointer on /tile: a live-card whose whole surface is the link, since
   the one thing to do with it is go play. Row, not `.surface`'s own column —
   a sentence and a dot, not a stack of sections. */
.match-pointer {
  flex-direction: row; align-items: center; justify-content: space-between;
  text-decoration: none; color: var(--color-text);
}

/* The same claim, wherever it is made small: a dot in the match's own
   colours, not the presence blip's green — "a board is running" is a
   narrower claim than "somebody is here" and reads wrong in presence's
   colour. Used on the Play tab and, merged into the connection indicator,
   in the topbar below 720px (see mountTopbar in shared.js). */
.nav-live-dot {
  width: 7px; height: 7px; border-radius: 50%; flex: none;
  background: linear-gradient(135deg, var(--disc-red), var(--disc-yellow));
}
@media (prefers-reduced-motion: no-preference) {
  .live-pulse { animation: live-pulse 2s ease-in-out infinite; }
}
@keyframes live-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }

/* ---- standings ----------------------------------------------------------- */

/* The party's score in the page header, beside the pills — and shaped like one,
   because a row of outlined capsules with two rows of bare swatch-and-text
   floating among them was two visual languages in one line. A standing IS a
   pill here; `.tabs-scores` on the console leaves it bare, which is right there
   because the console's row is all bare text. It takes the slack so the pills
   stay right-aligned, and drops onto its own line before squeezing them. */
/* The two on the actions rail are the SAME 30px box. That is the whole trick:
   a glyph-and-number beside a round button only look like a pair if they are
   one height, and then the grid's `align-items: center` puts them on the name's
   line with nothing to nudge. */
.head-wins {
  display: inline-flex; align-items: center; gap: var(--space-6); flex: none; height: 30px;
  font-size: var(--text-13); color: var(--color-neutral-300); font-variant-numeric: tabular-nums;
}

/* The team scores on /rounds, in the same shape a round's own line-up below
   them takes — one row treatment for every table this page is. `.standing`
   is already swatch · name · value, which is that shape. */
.board .standing { padding: var(--space-10); border-radius: var(--radius-md); }
.board .standing:nth-child(odd) { background: color-mix(in srgb, var(--color-text) 3%, transparent); }
.board .standing .swatch { width: 8px; height: 8px; border-radius: 50%; }
.board .standing .value { font-size: var(--text-15); }
/* Your own team, in its colour — the same job `.board-row.is-me` does for your
   own name in the Connect 4 table. */
.board .standing.is-mine .name { color: var(--team-ink); }

/* The reachability dot, on the mark it belongs to — a person's avatar or a
   tile's. Ringed in the surface colour so it reads as sitting on the mark rather
   than as a pixel of it. */
.avatar-wrap { position: relative; display: inline-flex; flex: none; }
.avatar-wrap .blip {
  position: absolute; right: 0; bottom: 1px;
  width: 11px; height: 11px; box-shadow: 0 0 0 2.5px var(--color-surface);
}

/* The gear stands in a row of pills, so it is shaped like one. It is still a
   `.btn` and still 36px of target on its own elsewhere; what goes is being the
   single square, taller thing in a line of round ones — which is most of what
   made it look dropped in rather than placed. */
.page-head .btn-icon {
  width: 30px; height: 30px; min-height: 0; border-radius: 999px;
  font-size: var(--text-13); border-color: var(--color-neutral-800);
  /* And no lift. `.btn` carries a hard offset shadow, which beside three flat
     pills made the gear look like it was sitting on top of the row rather than
     in it. Hover still answers, through the background `.btn-secondary` gives
     it. */
  box-shadow: none;
}
/* `--team` is the LED colour, which is what a matrix shows and what a swatch, a
   bar or a card edge is painted in. As small TEXT on this ground two of the five
   are barely legible — blue is #0000FF — so type takes `--team-ink`, the same
   hue lifted toward the page's own. */
.standing, .round-won { --team-ink: color-mix(in srgb, var(--team) 55%, var(--color-text)); }
.standing { display: flex; align-items: center; gap: var(--space-10); }
.standing .swatch { width: 8px; height: 8px; border-radius: 2px; flex: none; }
.standing .name { flex: 1; font-size: var(--text-13); color: var(--color-neutral-300); text-transform: capitalize; }
/* A colour is a word this build owns, so CSS capitalises it. A side's name is
   somebody's, and is printed as it was typed. */
.standing.is-named .name { text-transform: none; }
/* Inter, not the arcade face. A standings row is dense and small, and Press
   Start 2P at 11px stops being type and becomes texture — its zero carries a
   centre dot that reads as dirt at that size. The face is for display sizes;
   this is a list. */
.standing .value { font-size: var(--text-14); font-weight: var(--font-weight-strong); font-variant-numeric: tabular-nums; }

/* ---- claim flow ---------------------------------------------------------- */

.pick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: var(--space-12); }

/* One tile, centred. An auto-fill track list puts a lone item in the first
   column, which on a wide surface strands it against the left edge with the
   rest of the row empty beside it — it reads as the first of several that
   failed to load rather than as the only one there is. Both of these genuinely
   go down to one: a party that has flashed a single tile, and a claim page with
   one free tile left. */
.tile-grid:has(> :only-child),
.pick-grid:has(> :only-child) { grid-template-columns: minmax(104px, 168px); justify-content: center; }
/* Same again: the tile is the thing you are choosing, so it is not put in a
   card to be choosable. Quiet at rest, a ring when you point at it. */
.pick {
  display: flex; flex-direction: column; gap: var(--space-8); text-align: center; cursor: pointer;
  background: none; border: 0;
  border-radius: var(--radius-md); padding: var(--space-10);
}
.pick:hover { box-shadow: 0 0 0 2px var(--color-accent); }
.pick.is-on {
  box-shadow: 0 0 0 2px var(--color-accent);
  background: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
/* A hint, not an action — the sprite above it is what actually identifies the
   tile, so this stays quiet enough not to compete with it. */
/* Explicitly quieter than the button it is built from: .btn now carries the
   heading weight, which on an 11px hint under a tile reads as a second label
   competing with the tile's own name. */
.pick-hint {
  font-size: var(--text-11); padding: var(--space-2); align-self: center; min-height: 0;
  font-weight: var(--font-weight-body); color: var(--color-neutral-500);
}

/* ---- login page ---------------------------------------------------------- */

.login { max-width: 400px; margin: 14vh auto; padding: 0 var(--space-24); text-align: center; }
.login .logo {
  /* Centred as a row, so the mark sits on the wordmark's middle rather than on
     a text baseline — the same reason the top bar's does. */
  display: flex; align-items: center; justify-content: center; gap: var(--space-14);
  /* The arcade face, as in the bar. It is the same brand, and this is the one
     page a participant sees before anything of ours is on screen. 24px keeps
     the row inside a 320px phone, at 8 characters of an em each. */
  font-family: var(--font-display); font-size: 1.5rem; font-weight: 400;
  letter-spacing: 0; color: var(--color-accent);
  /* The gap the tag line used to hold open, now that the logo carries it. */
  margin-bottom: var(--space-32);
}
.login .logo .brandmark { width: 38px; height: 38px; }
.login .actions { display: flex; flex-direction: column; gap: var(--space-10); }
.login .actions .btn { width: 100%; padding: var(--space-14) var(--space-16); font-size: var(--text-15); }
.btn-discord {
  background: var(--brand-discord); color: var(--color-neutral-100);
  border-color: var(--brand-discord);
}
.btn-discord:hover:not(:disabled) {
  background: var(--brand-discord-hover); border-color: var(--brand-discord-hover);
}
.login .op { margin-top: var(--space-32); }
.login .op a { color: var(--color-neutral-500); font-size: var(--text-13); }
.login .op a:hover { color: var(--color-text); }

/* ---- toast --------------------------------------------------------------- */

.toast {
  position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
  background: var(--color-surface); color: var(--color-text);
  border: 1px solid var(--color-neutral-700); box-shadow: var(--shadow-md);
  padding: var(--space-10) var(--space-16); border-radius: var(--radius-md);
  font-size: var(--text-14); opacity: 0; transition: opacity 0.2s;
  pointer-events: none; z-index: 100;
}
.toast.show { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .toast, .flash-choice { transition: none; }
}
.toast.err {
  border-color: var(--color-bad);
  color: color-mix(in srgb, var(--color-bad) 55%, var(--color-neutral-100));
}

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

/* One row per person: assigning is one control, so sorting sixteen of them
   doesn't mean sixteen trips through the tile dialog. */
/* A column is sized to what a row holds — a name and a select, about 200px of
   it. At 330 a wide panel fitted two of them and spent the leftover on the gap
   between each name and its own control, which is the one gap in the row that
   means nothing. */
.roster { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: var(--space-6) var(--space-20); }
.roster-row { display: flex; align-items: center; gap: var(--space-8); min-width: 0; }
.roster-row.is-off { opacity: 0.5; }
.roster-name {
  flex: 1; min-width: 96px; font-size: var(--text-13);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.roster-name .mono { margin-left: var(--space-6); }
/* One select, wearing the side's colour, where there used to be a swatch per
   team and an ✕ — six targets per row, in a list as long as the party. Moving
   somebody between sides happens a handful of times an evening, so it is worth
   one click and not six slots. Sized down from `.input`: this is a cell in a
   dense list, not a form field. */
.roster-team {
  width: auto; flex: none; min-height: 26px; padding: var(--space-2) var(--space-6);
  font-size: var(--text-12); text-transform: capitalize;
}

/* ---- flash page: two doors ---------------------------------------------- */

/* The page is a choice between two things, so it looks like one: two big
   targets, an icon each, and nothing else competing for the eye. Everything
   that used to be on the page is still here, behind the disclosure below. */
.flash-picker { display: flex; flex-wrap: wrap; gap: var(--space-16); margin: var(--space-8) 0 var(--space-20); }
.flash-choice {
  flex: 1 1 220px; min-height: 200px; cursor: pointer;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-10);
  padding: var(--space-24); color: var(--color-text);
  background: var(--color-surface);
  border: 1px solid var(--color-divider); border-radius: var(--radius-lg);
  transition: border-color 0.15s, background 0.15s;
}
.flash-choice:hover { border-color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface)); }
.flash-choice:active { background: color-mix(in srgb, var(--color-accent) 16%, var(--color-surface)); }
.flash-choice svg { width: 64px; height: 64px; stroke: var(--color-accent); fill: none; stroke-width: 1.5; }
.flash-choice-label {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: var(--text-title); letter-spacing: -0.01em;
}
.flash-choice .note {
  text-align: center; max-width: 26ch;
  /* Two lines' worth whether or not it needs them, so a caption that wraps on
     one door doesn't shove that door's label out of line with the other's. */
  min-height: 2.6em;
}
/* The version line under a door's caption is one line, not a second caption. */
.flash-choice .flash-choice-prov { min-height: 0; }

/* ---- flash page: the ladder ----------------------------------------------
   One page, three steps, read as one ladder: a mark per step down the left
   rail, a segment linking each to the next. A step is green only when the
   thing it is for has happened — a write finished, a tile reached the broker,
   a claim answered — so the marks are a checklist rather than a progress bar. */
.flash-steps { display: flex; flex-direction: column; gap: var(--space-16); }
.step {
  gap: 0; padding: var(--space-12) var(--space-16);
  border: 1px solid var(--color-neutral-800);
  background: color-mix(in srgb, var(--color-bg) 45%, var(--color-surface));
  transition: border-color 0.25s;
}
.step[data-state="doing"] { border-color: color-mix(in srgb, var(--color-accent) 40%, var(--color-neutral-800)); }
.step[data-state="error"] { border-color: color-mix(in srgb, var(--color-bad) 45%, var(--color-neutral-800)); }

/* The segment between one step and the next, on the marks' own centre line.
   It wears the colour of the step above it, so a green link is the ladder
   saying that rung delivered. */
.step + .step { position: relative; }
.step + .step::before {
  content: ""; position: absolute; width: 2px;
  left: calc(var(--space-16) + 7px);
  top: calc(-1px - var(--space-16)); height: calc(var(--space-16) + 1px);
  background: var(--color-neutral-800);
}
.step[data-state="done"] + .step::before {
  background: color-mix(in srgb, var(--color-good) 55%, var(--color-neutral-800));
}

/* One of three buttons from scripts/build-icons.mjs, drawn at the 16px they
   are painted at so no cell is resampled. The frame, the corners and the
   shading are in the art, which is why the box carries no border, no radius
   and no colour of its own. */
.step-mark {
  flex: none; width: 16px; height: 16px; image-rendering: pixelated;
  background: url("icons/blank-neutral.png") center / 100% 100% no-repeat;
}
.step[data-state="doing"] .step-mark { background-image: url("icons/blank-amber.png"); }
.step[data-state="done"] .step-mark { background-image: url("icons/check.png"); }
.step[data-state="error"] .step-mark { background-image: url("icons/cross.png"); }
/* The current rung breathes. The mark is the one thing every state repaints,
   so it is the one thing that moves. */
.step[data-state="doing"] .step-mark { animation: mark-beat 1.8s ease-in-out infinite; }
@keyframes mark-beat { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) {
  .step[data-state="doing"] .step-mark { animation: none; }
}

/* A row, so the mark is centred against the title's box rather than set on its
   baseline. The number is the arcade face — Setup's keys already wear it — and
   takes the step's state colour, so the rung reads off the rail alone. A step
   never collapses, so its head is a plain div, not a summary — nothing here
   to press. */
.step > .step-head { display: flex; align-items: center; gap: var(--space-10); }
.step-no { flex: none; font-family: var(--font-display); font-size: var(--text-10); line-height: 1; color: var(--color-neutral-500); }
.step[data-state="doing"] .step-no { color: var(--color-accent); }
.step[data-state="done"] .step-no { color: var(--color-good); }
.step[data-state="error"] .step-no { color: var(--color-bad); }
.step-title { font-size: var(--text-15); font-weight: var(--font-weight-medium); }
.step-summary {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: var(--text-12); color: var(--color-neutral-500);
}
.step-summary:not(:empty)::before { content: "— "; }
.step[data-state="error"] .step-summary { color: var(--color-bad); }

/* The contents hang off the rail, under the title rather than under the mark,
   so the marks stay one clear line down the page. */
.step > .card-body {
  display: flex; flex-direction: column; gap: var(--space-12);
  margin: var(--space-10) 0 var(--space-4) calc(16px + var(--space-10));
  opacity: 1; font-size: var(--text-13);
}
.step .flash-picker { margin: 0; }
@media (width < 35em) { .step > .card-body { margin-left: 0; } }

/* An error is a note that has stopped being background: same slot, the one
   colour that means something is wrong, a step up in size. */
.note.is-bad { color: var(--color-bad); font-size: var(--text-13); }

/* ---- the connect-stage legend --------------------------------------------
   The new firmware's three stages, drawn as fx_boot draws them on the matrix:
   a blue pinwheel sweep while it looks for WiFi, a faster one with a cyan
   counter-ray once it has it, a green pulse on the broker. Each chip is a
   little matrix — the substrate, the LED lattice, and the light under it. */
.mx-legend { display: flex; flex-wrap: wrap; gap: var(--space-8) var(--space-16); }
.mx-stage {
  display: inline-flex; align-items: center; gap: var(--space-8);
  font-size: var(--text-12); color: var(--color-neutral-400);
}
.mx-chip {
  position: relative; overflow: hidden; flex: none;
  width: 24px; height: 24px; border-radius: var(--radius-sm);
  background: var(--led-substrate);
}
/* ::before is the light; oversized so a rotating ray covers the corners. */
.mx-chip::before { content: ""; position: absolute; inset: -30%; }
/* ::after is the lattice, on the pitch every sprite cell draws. */
.mx-chip::after {
  content: ""; position: absolute; inset: 0;
  background:
    linear-gradient(to right, transparent 0 66.7%, var(--led-substrate) 66.7%) 0 0 / 12.5% 100% repeat-x,
    linear-gradient(to bottom, transparent 0 66.7%, var(--led-substrate) 66.7%) 0 0 / 100% 12.5% repeat-y;
}
.mx-seek::before {
  background: conic-gradient(transparent 0deg, var(--led-boot-blue) 70deg, transparent 70deg);
  animation: mx-spin 2.8s linear infinite;
}
.mx-seek.is-found::before {
  background: conic-gradient(transparent 0deg, var(--led-boot-blue) 70deg, transparent 70deg 180deg,
    var(--led-boot-cyan) 250deg, transparent 250deg);
  animation-duration: 1.4s;
}
.mx-pulse::before { background: var(--led-boot-green); animation: mx-beat 1.6s ease-in-out infinite; }
@keyframes mx-spin { to { transform: rotate(1turn); } }
@keyframes mx-beat { 0%, 100% { opacity: 0.12; } 50% { opacity: 0.95; } }
@media (prefers-reduced-motion: reduce) {
  .mx-seek::before, .mx-pulse::before { animation: none; }
  .mx-pulse::before { opacity: 0.7; }
}

/* ---- flash page: the port help -------------------------------------------
   Two questions the steps cannot answer, and a first tile is where both are
   asked: which of the computer's serial ports is the tile, and what to do when
   the picker offers none. Structure over prose — the dialog is drawn with the
   tile's own entry marked, and what to try is a list to work down. */
.port-help {
  gap: var(--space-20); padding: var(--space-16);
  border: 1px solid var(--color-neutral-800);
}
.port-help-part { display: flex; flex-direction: column; gap: var(--space-8); }
.port-help-part + .port-help-part { padding-top: var(--space-20); border-top: 1px solid var(--color-neutral-900); }

/* The browser's own device dialog, redrawn. It cannot be styled, screenshotted
   or pointed at from a page, so the page draws what it will say. */
.picker-mock {
  display: flex; flex-direction: column;
  border: 1px solid var(--color-neutral-800); border-radius: var(--radius-md);
  background: var(--color-bg); overflow: hidden;
}
.picker-row {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: var(--space-6) var(--space-10); padding: var(--space-8) var(--space-10);
  border-top: 1px solid var(--color-neutral-900);
}
.picker-row:first-child { border-top: 0; }
.picker-row.is-tile { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.picker-row.is-tile .mono { color: var(--color-text); }
.picker-tag { font-size: var(--text-11); color: var(--color-accent); white-space: nowrap; }

.port-help-do { margin: 0; padding-left: 1.4em; font-size: var(--text-12); color: var(--color-neutral-400); }
.port-help-do li + li { margin-top: var(--space-6); }

/* The networks a tile listed, under the box they are typed into. Ours rather
   than a <datalist>, which narrows itself to what is in the field: the list has
   to stay the tile's list while somebody types over it. The arrow sits inside
   the box, and the input reserves its width so a long name never runs under it. */
.combo { position: relative; }
.combo .input { padding-right: 34px; }
.combo-open {
  position: absolute; inset: 0 0 0 auto; width: 34px;
  display: flex; align-items: center; justify-content: center;
  font-size: var(--text-12); color: var(--color-neutral-300);
  background: none; border: 0; cursor: pointer;
}
.combo-open:hover { color: var(--color-accent); }
.combo-list {
  position: absolute; z-index: 5; top: calc(100% + 4px); left: 0; right: 0;
  max-height: 220px; overflow: auto; margin: 0; padding: var(--space-4) 0;
  list-style: none; font-size: var(--text-14);
  background: var(--color-surface); border-radius: var(--radius-md);
  box-shadow: var(--shadow-md);
}
.combo-list li {
  padding: var(--space-6) var(--space-10); cursor: pointer;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.combo-list li:hover, .combo-list li:focus {
  outline: 0; color: var(--color-accent);
  background: color-mix(in srgb, var(--color-accent) 12%, transparent);
}

/* The cable's own words, for a handshake that reported only that it got none.
   Scrolls rather than growing: a tile that is talking does not stop. Nothing
   listened to yet is nothing to show: an empty box is a black slab under the
   buttons rather than a transcript waiting to be filled. */
#probeOut:empty { display: none; }
#probeOut {
  max-height: 240px; overflow: auto; margin: var(--space-8) 0 0;
  padding: var(--space-8); border-radius: var(--radius-md);
  background: var(--color-bg); color: var(--color-neutral-400);
  white-space: pre-wrap; word-break: break-all; line-height: 1.5;
}

/* The claim landed. The mark is a sprite off the pool drawn as a tile draws it,
   so the thing that says "yours" is in the same 8x8 as everything on the
   matrix. It pops once, which is the whole celebration. */
.claim-done { display: flex; flex-direction: column; align-items: center; gap: var(--space-8);
  padding: var(--space-16) 0; text-align: center; }
.claim-done .claim-mark { width: 96px; animation: claim-pop 320ms cubic-bezier(0.2, 1.4, 0.4, 1) both; }
.claim-done-line {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: var(--text-title); color: var(--color-good);
}
@keyframes claim-pop { from { transform: scale(0.3); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .claim-done .claim-mark { animation: none; } }

/* The taps so far. A `.matrix` is width:100%, so a slot in a flex row needs a
   width — without one each tap was as wide as the page. Sized to the grid the
   sprites were tapped from. */
/* The number being entered, drawn as a code row: a picture where one has been
   tapped, a rule where one is still to come, and the accent on the one that
   takes the next tap. Nothing here says right or wrong — an answer is neither
   until the last slot is filled. */
.pick-slot { width: 46px; flex: 0 0 auto; }
.pick-slot.is-empty {
  height: 46px;
  border-bottom: 3px solid var(--color-neutral-700);
  border-radius: var(--radius-sm);
}
.pick-slot.is-empty.is-next { border-bottom-color: var(--color-accent); }

/* The write, reported where the buttons are. Height and radius match .btn so
   the bar reads as part of the same control group. */
.flash-bar { height: 8px; border-radius: var(--radius-sm); background: var(--color-neutral-900); overflow: hidden; margin: var(--space-8) 0 var(--space-14); }
.flash-fill { height: 100%; width: 0; background: var(--color-accent); transition: width 120ms linear; }


/* ---- flash page ---------------------------------------------------------- */

/* What's about to be written to a tile, read as a checklist against the same
   fields in the tile's own settings pages. */
.baked {
  display: grid; grid-template-columns: max-content 1fr; gap: var(--space-6) var(--space-16);
  margin: var(--space-10) 0 var(--space-14);
}
.baked dt { font-size: var(--text-11); letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-neutral-500); }
.baked dd { margin: 0; font-size: var(--text-12); color: var(--color-neutral-400); }

/* ---- narrow screens ------------------------------------------------------ */

/* Third of the three 45em queries; the other two are in the top bar. */
@media (width < 45em) {
  .shell { padding: var(--space-20) var(--space-14) var(--space-48); }
  .surface { padding: var(--space-16); gap: var(--space-20); }
  /* No chip variables here any more. A strip this narrow either wraps or turns
     into an accordion, and both of those now say so for themselves — see
     `.views.is-wrapped`. Restating them at a viewport width was a third answer
     to a question two rules were already answering. */
  .tabs-row { flex-direction: column; align-items: stretch; }
  .tabs-scores { justify-content: flex-start; padding-bottom: 0; }
  .clock-face { font-size: 2.5rem; }

  /* The console's stage, at a width that fits two of them. A phone laid eleven
     tiles out in six rows and put every control on the page — the strip, the
     blackout switch, the whole team panel — four screens below the fold. The
     wall is meant to be taken in at a glance, which is what it stops being once
     it is longer than the screen, so the cards shrink until the room fits on
     one. An 8x8 still reads at 80px; a name under it is what needs the smaller
     step. */
  .tile-grid { grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr)); gap: var(--space-8); }
  .tile-card { padding: var(--space-6) var(--space-6) var(--space-4); gap: var(--space-6); }
  .tile-card .who { gap: var(--space-4); }
  .tile-card .name { font-size: var(--text-11); }
  .tile-card .avatar { width: 15px; height: 15px; }

  /* Scoring is what an admin does all evening, and 28px is a mouse target on a
     page reached with a thumb. The card has the width to spend: one side is a
     name and the other a two-digit number. */
  .nudge button { width: 40px; height: 40px; font-size: var(--text-16); }

  /* What the fold is about, under its own summary rather than trailing off the
     end of it — the counts run to five terms, which wrapped the summary into
     three lines of uppercase and left nothing that read as a row to press. */
  .fold > summary { padding: var(--space-6) 0; }
  .fold > summary .fold-aside { display: block; margin-left: 0; margin-top: var(--space-2); }

  /* Nothing is set off by a gap at this width: a control that wraps onto a line
     of its own is already set off. */
  .set-off { margin-left: 0; }
  .team-undo .btn { flex: 1; }

  /* A cell in a dense list is still a target here. */
  .roster-team { min-height: 34px; font-size: var(--text-13); }
}

/* ---- phones -------------------------------------------------------------- */

@media (width < 35em) {
  /* Full-bleed. A rounded card floating in a 14px gutter spends 28px of a 390px
     screen saying "this is a card", and on a phone there is nothing beside it
     for the card to be distinct FROM — the surface is the page. The padding
     stays so nothing touches the glass; what goes is the gutter, the corner and
     the shadow. The topbar keeps its own padding (it is not in `.shell`), so it
     is set to match and the wordmark still lines up with the text below it. */
  .shell { padding-left: 0; padding-right: 0; }
  .surface { border-radius: 0; box-shadow: none; }
  .surface + .surface { margin-top: var(--space-12); }
  .topbar-inner { padding: 0 var(--space-16); }
}

/* ---- a ranked standings row ------------------------------------------------
   Shared by the team board on /rounds and the Connect 4 standings on
   /connect4 — one row treatment for "who is ahead", wherever it is asked. */
.board { display: flex; flex-direction: column; gap: var(--space-2); }
.board-row { display: flex; align-items: center; gap: var(--space-12); padding: var(--space-10); border-radius: var(--radius-md); }
.board-row:nth-child(odd) { background: color-mix(in srgb, var(--color-text) 3%, transparent); }
.board-row .rank {
  width: 22px; font-size: var(--text-12); color: var(--color-neutral-500);
  font-variant-numeric: tabular-nums; flex: none;
}
.board-row .who {
  flex: 1; min-width: 0; font-size: var(--text-14);
  display: flex; align-items: center; gap: var(--space-8);
  overflow: hidden; text-overflow: ellipsis;
}
.board-row .value { font-size: var(--text-15); font-weight: var(--font-weight-strong); font-variant-numeric: tabular-nums; }
.board-row.is-me .who { color: var(--color-accent); }

/* One grid, fixed columns, every row the same shape — WON is a real column
   now rather than an inline word, because most finished games ARE wins and a
   tag that only sometimes appears is not a column at all. Named areas rather
   than column order, so the phone layout below can re-place the same six
   children into two lines without touching the markup. */
.result-head, .result {
  display: grid;
  grid-template-columns: 3.4em 1fr 1.4em 1fr 6.5em 3.4em;
  grid-template-areas: "badge p1 sep p2 reason when";
  align-items: center; gap: var(--space-10);
}
.result-head {
  padding: 0 var(--space-4) var(--space-6);
  font-size: var(--text-10); letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--color-neutral-600);
}
.result-head span:last-child { text-align: right; }
.result {
  font-size: var(--text-13);
  padding: var(--space-8) var(--space-4); border-bottom: 1px solid var(--color-neutral-900);
}
.result:last-child { border-bottom: none; }
.result .badge {
  grid-area: badge;
  font-size: var(--text-10); font-weight: var(--font-weight-medium);
  letter-spacing: 0.04em; text-transform: uppercase; padding: 3px 0; border-radius: 4px;
  text-align: center;
}
.result .badge.win { color: var(--color-good); background: color-mix(in srgb, var(--color-good) 16%, transparent); }
.result .badge.draw { color: var(--color-neutral-300); background: color-mix(in srgb, var(--color-text) 10%, transparent); }
/* The winner's name is the one thing in the row that gets colour — nothing
   else needed a legend once this reads at a glance. */
.result .p1 { grid-area: p1; } .result .p2 { grid-area: p2; }
.result .p1, .result .p2 { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result .p1.is-winner { color: var(--color-good); font-weight: var(--font-weight-medium); }
.result .sep { grid-area: sep; color: var(--color-neutral-600); font-size: var(--text-11); text-align: center; }
.result .reason { grid-area: reason; color: var(--color-neutral-500); font-size: var(--text-12); }
.result .when { grid-area: when; color: var(--color-neutral-500); font-size: var(--text-11); font-variant-numeric: tabular-nums; text-align: right; }

/* Six columns crush on a phone, so a container query — bound to `.shell`,
   which every page already is — reflows the same six children into two
   lines instead: player names on top, reason and time trailing under them. */
@container shell (max-width: 30em) {
  .result-head { display: none; }
  .result {
    grid-template-columns: auto 1fr auto 1fr;
    grid-template-areas: "badge p1 sep p2" "reason reason reason when";
    row-gap: 2px;
  }
  .result .reason, .result .when { font-size: var(--text-11); }
}

/* ---- the rounds log ------------------------------------------------------- */
/* A finished round is a small table: what it was called and how it ended, then
   a row per side. `.result` above is the shape a GAME takes — two names and an
   outcome — and four teams carrying a score and a line-up each do not fit on
   one line. */
.rounds { display: flex; flex-direction: column; gap: var(--space-16); }
.round + .round { border-top: 1px solid var(--color-divider); padding-top: var(--space-16); }
.round-head {
  display: flex; align-items: baseline; gap: var(--space-10);
  margin-bottom: var(--space-8);
}
.round-name {
  font-size: var(--text-15); font-weight: var(--font-weight-medium);
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* A pill in the team's colour. The outcome is a verdict about the table below
   it, so it is shaped unlike the rows: coloured text alone reads as a side. */
.round-won {
  flex: none; font-size: var(--text-11); color: var(--team-ink);
  padding: 1px var(--space-8); border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--team-ink) 40%, transparent);
  background: color-mix(in srgb, var(--team) 12%, transparent);
}
.round-won.is-tie {
  color: var(--color-neutral-500); border-color: var(--color-divider); background: none;
}
.round-head .when {
  margin-left: auto; flex: none;
  font-size: var(--text-11); color: var(--color-neutral-500);
}
/* An admin types the name in place, so the field is the title until it is
   wanted: a boxed input per round turns a log into a form. */
.round-rename {
  font: inherit; font-size: var(--text-15); font-weight: var(--font-weight-medium);
  color: var(--color-text); caret-color: var(--color-accent);
  background: none; border: 1px solid transparent; border-radius: var(--radius-sm);
  width: 22ch; max-width: 100%; min-width: 0;
  padding: 1px var(--space-6); margin-left: calc(var(--space-6) * -1);
}
.round-rename:hover { border-color: var(--color-divider); }
.round-rename:focus-visible { border-color: var(--color-accent); outline: none; }
/* Quiet until it is pointed at, because a log of ten rounds is a row of ten of
   these and every one of them is destructive. */
.round-strike {
  flex: none; min-height: 26px; padding: 0 var(--space-8);
  font-size: var(--text-11); color: var(--color-neutral-500);
}
.round-strike:hover:not(:disabled) {
  color: var(--color-bad); background: color-mix(in srgb, var(--color-bad) 14%, transparent);
}
/* Named cells, because the line-up drops onto a second row on a phone. */
.rounds .standing {
  display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto;
  grid-template-areas: "swatch name players value";
  padding: var(--space-8) var(--space-10); border-radius: var(--radius-md);
}
.rounds .standing:nth-child(odd) { background: color-mix(in srgb, var(--color-text) 3%, transparent); }
.rounds .standing .swatch { grid-area: swatch; border-radius: 50%; }
/* Wide enough for the longest team name, so the line-ups down the block start
   on one rail instead of each row sizing its own column. */
.rounds .standing .name { grid-area: name; min-width: 4.25rem; }
.rounds .standing .value { grid-area: value; }
.rounds .standing .players {
  grid-area: players; font-size: var(--text-12); color: var(--color-neutral-500);
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.rounds .standing.is-won .name { color: var(--team-ink); }
@media (width < 32.5em) {
  .rounds .standing {
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas: "swatch name value" ". players players";
    row-gap: 2px;
  }
}

/* The round in progress, named in the head of the board it is the score of. */
.head-title { font-size: var(--text-13); color: var(--color-text); }

/* The people list and the tile roster. Both are one row per thing — a mark, its
   name, and the admin's actions — so the rules below are written for the pair.
   The mark is the difference: a round avatar for a person, a square one for a
   tile, each carrying the same dot in the same corner.
   Both are pulled out by the row's own side padding, so a name starts on the
   same rail as the section head above and the hover band still has room. */
.people, .tile-roster {
  display: flex; flex-direction: column;
  margin-inline: calc(var(--space-12) * -1);
}
/* Both pages read at `--measure`, which caps this list too — so the cap grows by
   what the pull-out spends, or the rows end short of the head above them. */
.shell.reading > .surface > .people,
.shell.reading > .surface > .tile-roster { max-width: calc(var(--measure) + var(--space-12) * 2); }
/* A grid, for the same reason the tile page's header is one: the mark, the name
   and the actions are cells of one row, so they share a centre line whatever is
   in them. One rule for both lists, so a row is the same row on either page. */
.person-row, .tile-row {
  display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center; gap: var(--space-12);
  padding: var(--space-10) var(--space-12); border-radius: var(--radius-sm);
}
/* A hairline between rows, at the weight the top bar is ruled off at. Written
   on the second row of a pair, so a list ends where its rows stop. */
.person-row + .person-row, .tile-row + .tile-row {
  border-top: 1px solid var(--color-neutral-900);
}
.person-row:hover, .tile-row:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
/* Still listed, still readable — they just aren't coming back in. */
.person-row.is-blocked { opacity: 0.55; }
.person-row .avatar { width: 30px; height: 30px; font-size: var(--text-13); }
/* A tile's mark, on the avatar's rail and at its size, so a name starts at the
   same place on either page. Square where a person's is round. */
.tile-mark {
  width: 30px; height: 30px;
  display: flex; align-items: center; justify-content: center;
  border-radius: var(--radius-sm); background: var(--led-substrate);
  color: var(--color-accent);
}
.tile-mark svg { width: 20px; height: 20px; fill: currentColor; }
/* Smaller than the 38px avatar's dot on the tile page, in proportion. */
.person-row .avatar-wrap .blip, .tile-row .avatar-wrap .blip {
  width: 9px; height: 9px; box-shadow: 0 0 0 2px var(--color-surface);
}
.person-who, .tile-who { min-width: 0; }
.person-name, .tile-id {
  display: flex; align-items: center; gap: var(--space-6); flex-wrap: wrap;
  font-size: var(--text-14); font-weight: var(--font-weight-medium);
}
.tile-id { font-family: var(--font-mono); }

/* Ranked causes, above the roster. A bar rather than a number alone: the
   question this answers is which cause is the big one, and a column of
   integers makes that a comparison the reader has to do. */
/* The grid scrolls inside itself rather than widening the page: a fleet is as
   many columns as it has tiles, and this is read on a phone. The cause column
   stays put, or a scrolled cell names nothing. */
.inc-causes { overflow-x: auto; }
/* Out of the reading measure `.shell.reading` gives a surface's children. That
   measure is right for the prose under the grid and wrong for the grid, which
   is data and wants the width the panel has — capped, it scrolls inside a
   narrow box with the panel empty either side. The heading comes with it, or
   the two start at different places. */
#incPanel > .section-head, #incPanel > .inc-causes, #incPanel > .inc-filter { max-width: none; }
.inc-filter {
  display: flex; align-items: center; gap: var(--space-8);
  margin: var(--space-12) 0; flex-wrap: wrap;
}
.inc-filter .input { width: auto; min-height: 30px; font-size: var(--text-12); }
.inc-grid { border-collapse: separate; border-spacing: 2px; width: max-content; }
.inc-grid th, .inc-grid td { font-family: var(--font-mono); font-size: var(--text-12); }
.inc-grid thead th {
  font-weight: var(--font-weight-body); color: var(--color-neutral-600);
  text-align: center; padding: 0 var(--space-4) var(--space-4);
}
.inc-grid tbody th {
  display: grid; grid-template-columns: 1fr auto; column-gap: var(--space-16);
  text-align: left; font-weight: var(--font-weight-body); white-space: nowrap;
  padding-right: var(--space-8);
  /* Sticky against the panel's own surface, so a column scrolling under it is
     covered rather than showing through. */
  position: sticky; left: 0; background: var(--color-surface);
}
.inc-name { font-family: var(--font-mono); font-size: var(--text-13); }
.inc-when { font-size: var(--text-11); color: var(--color-neutral-600); }
/* The one line on a row worth colour: a count says how often, and this says
   whether it was once, to several tiles at the same moment. */
.inc-burst { font-size: var(--text-11); color: var(--color-accent-200); }
.inc-total {
  /* Every row in the column, however many the name stacks above it: a row with
     a burst has three lines and one without has two. */
  grid-column: 2; grid-row: 1 / -1; align-self: center; text-align: right;
  font-variant-numeric: tabular-nums; font-weight: var(--font-weight-medium);
  font-size: var(--text-14);
}
/* Four steps of --color-accent over the surface, capped at 48%: past that the
   count printed on the cell drops under 4.5:1 and the number stops being
   readable, which is the encoding the colour is only assisting. */
.inc-cell {
  min-width: 2.75rem; text-align: center; padding: var(--space-4) 0;
  border-radius: var(--radius-4, 4px); font-variant-numeric: tabular-nums;
  background: color-mix(in srgb, var(--color-text) 4%, transparent);
  color: var(--color-neutral-600);
}
.inc-cell[data-heat="1"] { background: color-mix(in srgb, var(--color-accent) 14%, var(--color-surface)); color: var(--color-text); }
.inc-cell[data-heat="2"] { background: color-mix(in srgb, var(--color-accent) 26%, var(--color-surface)); color: var(--color-text); }
.inc-cell[data-heat="3"] { background: color-mix(in srgb, var(--color-accent) 36%, var(--color-surface)); color: var(--color-text); }
.inc-cell[data-heat="4"] { background: color-mix(in srgb, var(--color-accent) 48%, var(--color-surface)); color: var(--color-text); }
#incVersions { display: flex; flex-direction: column; gap: var(--space-16); }
.ver-row {
  display: grid; grid-template-columns: 5rem 1fr 5rem 8rem; gap: var(--space-12);
  align-items: center; padding: var(--space-6) 0;
  font-family: var(--font-mono); font-size: var(--text-12);
}
.ver-track {
  display: block; height: 6px; border-radius: 3px;
  background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
.ver-track > span { display: block; height: 6px; border-radius: 3px; background: var(--color-accent); }
.ver-n { text-align: right; font-variant-numeric: tabular-nums; color: var(--color-text); }
.ver-meta { text-align: right; font-size: var(--text-11); color: var(--color-neutral-600); }
@media (max-width: 560px) {
  .ver-row { grid-template-columns: 4.5rem 1fr auto; row-gap: var(--space-4); }
  .ver-meta { grid-column: 2 / -1; text-align: left; }
}
/* Ours, not a tile's — the one row that is not about hardware. */
.inc-grid tbody tr.is-ours th { color: var(--color-neutral-400); }
/* The column's own summary, under a rule: read down a column rather than
   across a row, so it wears no cell fill and does not join the heat. */
.inc-grid tfoot th, .inc-grid tfoot td { padding-top: var(--space-8); }
.inc-grid tfoot tr:first-child th, .inc-grid tfoot tr:first-child td {
  border-top: 1px solid var(--color-divider);
}
.inc-foot {
  text-align: center; font-variant-numeric: tabular-nums;
  font-size: var(--text-11); color: var(--color-neutral-500);
}
.inc-grid tfoot tr:first-child .inc-foot {
  font-size: var(--text-13); color: var(--color-text);
  font-weight: var(--font-weight-medium);
}
/* Data (what the device reports) gets a line per layer. Who holds the tile is
   prose and short, so it rides the id line rather than taking a sixth. */
.tile-facts { display: flex; flex-direction: column; margin-top: 2px; }
.tile-fact-who {
  font-family: var(--font-body); font-weight: var(--font-weight-body);
  font-size: var(--text-12); color: var(--color-neutral-500);
}
.tile-fact-dev {
  font-family: var(--font-mono); font-size: var(--text-11);
  color: var(--color-neutral-600); font-variant-numeric: tabular-nums;
  /* Body's 1.55 across five lines is most of a row's height. */
  line-height: 1.45;
  /* Flex, so a line too long for the row wraps inside its own column instead
     of back under the label. */
  display: flex; gap: var(--space-8); min-width: 0;
}
/* Which layer the line is about. They read alike otherwise, and which one is
   carrying something is the diagnosis. Wide enough for `content`. */
.fact-src {
  flex: none; min-width: 4rem;
  color: var(--color-neutral-800);
}
/* The one device fact worth a second look: everything else on this line is
   routine telemetry, so only a firmware version behind staging gets colour. */
.tile-fact-dev .fw-drift { color: var(--color-accent-200); }
.person-acts, .tile-acts { display: flex; gap: var(--space-6); }
/* `relative`, so a row's menu hangs off its own actions and not off the list. */
.person-acts, .tile-acts { position: relative; }
/* These stay under field height so the list reads as a list rather than as a
   stack of forms. */
.person-acts .btn, .tile-acts .btn {
  min-height: 30px; padding: var(--space-6) var(--space-10); font-size: var(--text-12);
}
/* Badges are the design system's .tag, not a shape of their own. An env admin
   gets the OUTLINE rather than a louder fill: it reads as fixed rather than as
   more senior, which is why the buttons beside it are dead. */
.person-name .tag, .tile-id .tag { padding: 1px var(--space-8); }

/* Two icons in the rail: the sign-in link, which a click copies, and the menu
   holding everything an admin can decide. Both square, so the row spends its
   width on the name rather than on four labelled buttons — which is what wrapped
   it into three lines at a reading measure. Tiles carries the same shape: a
   select plus what used to be three more buttons, now behind `.tile-more`. */
.person-link, .person-more, .tile-more, .tile-check { width: 30px; padding: 0; flex: none; }
.person-link svg, .person-more svg, .tile-more svg, .tile-check svg { width: 16px; height: 16px; fill: currentColor; }
/* Two words an item, against the bar's "Tile settings" — so a narrower panel
   than the 180px `.menu` asks for. */
.person-row .person-menu, .tile-row .tile-menu { min-width: 150px; }
/* Block and Remove, in the colour they wear as buttons elsewhere. */
.menu-item.is-bad { color: var(--color-bad); }
.menu-item.is-bad:hover { color: var(--color-bad); background: color-mix(in srgb, var(--color-bad) 14%, transparent); }
.menu-item:disabled { opacity: 0.45; cursor: not-allowed; background: transparent; }

/* The row that adds one: a name and a button. */
.test-add { display: flex; gap: var(--space-8); align-items: center; }
.test-add .input { max-width: 220px; }

/* ---- Emoji Brawl ------------------------------------------------------------ */
/* The roster and slots are made of tile material like the Connect 4 board:
   sprite art on a dark substrate. The canvas is a tile in the hand — 8x8
   pixels scaled up whole, never smoothed. */

.br-thumb { width: 32px; height: 32px; image-rendering: pixelated; display: block; }

/* The art IS the button — no caption rides beside it — and the pick screen
   is always the 3x3 the game is: nine throws, three columns, at every width. */
.br-roster { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); }
.br-throw {
  display: grid; place-items: center; position: relative;
  padding: var(--space-8); border-radius: var(--radius-md);
  background: var(--led-substrate); border: 1px solid transparent;
  color: var(--color-text); font: inherit; cursor: pointer;
  min-width: 0;
}
.br-roster .br-thumb { width: 40px; height: 40px; }
.br-throw.is-mined { border-color: var(--color-bad); }
.br-mine-mark { position: absolute; top: 2px; right: 4px; font-size: 12px; }

.br-slots { display: flex; gap: var(--space-6); }
.br-slot {
  position: relative; width: 56px; height: 56px; display: grid; place-items: center;
  border-radius: var(--radius-md); background: var(--led-substrate);
  border: 1px dashed var(--color-neutral-700); color: var(--color-neutral-400);
  font: inherit; cursor: pointer;
}
.br-slot.is-backed { border-color: var(--color-text); border-style: solid; }
.br-slot-backup { border-style: dotted; }
.br-bkp-mark { position: absolute; bottom: 2px; right: 4px; font-size: 10px; color: var(--color-neutral-400); }
.br-slot .br-thumb { width: 40px; height: 40px; }

.br-powerups { display: flex; flex-wrap: wrap; gap: var(--space-6); }

/* A card wears its own hue: the glyph is drawn in it, and arming borrows it. */
.br-card { display: inline-flex; align-items: center; gap: var(--space-6); }
.br-card canvas { image-rendering: pixelated; display: block; }
.br-card:hover { border-color: var(--card, var(--color-accent)); }
.br-card.is-on {
  background: color-mix(in srgb, var(--card, var(--color-accent)) 14%, transparent);
  border-color: var(--card, var(--color-accent));
  color: var(--color-text);
}

/* The playback: the same frame a tile hangs in. */
.br-stage { display: flex; flex-direction: column; align-items: center; gap: var(--space-8); }
.br-tileframe {
  padding: var(--space-8); border-radius: var(--radius-lg); background: var(--led-substrate);
}
.br-tileframe canvas { width: 192px; height: 192px; image-rendering: pixelated; display: block; }
.br-caption { min-height: 2.4em; text-align: center; }

/* The history filter names a game per chip, and "Connect 4" is two words. */
.filter-drop { position: relative; }
.filter-btn { display: inline-flex; align-items: center; gap: var(--space-6); }
.filter-btn svg { width: 16px; height: 16px; }
#historyFilterMenu { min-width: 140px; }
