/* ==========================================================================
   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.

   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, 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 or a control's label. */
@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;
  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-accent-2: #ff4fd8;
  --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;

  --color-accent-2-100: #ffe8fb;
  --color-accent-2-800: #5c1a4e;

  /* 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;
  --color-warn: #e8c33f;

  /* 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;

  --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, a countdown and a
     score — things that are two words 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;

  --space-1: 2.8px;
  --space-2: 5.6px;
  --space-3: 8.4px;
  --space-4: 11.2px;
  --space-6: 16.8px;
  --space-8: 22.4px;

  /* 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. */
  --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: 15px;
  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-2);
}
h1 { font-size: 42px; }
h2 { font-size: 32px; }
h3 { font-size: 25px; }
h4 { font-size: 20px; }
h5 { font-size: 16px; }
h6 { font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; }

p { margin: 0 0 var(--space-3); }
/* `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: 11px; 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; }

/* Rules fade to transparent at both ends instead of stopping cleanly — a
   Nocturne signature. The ramp is 48px an end, painted from the divider token.
   Freestanding rules fade; box outlines and in-control separators stay solid. */
.hr {
  height: 1px; border: 0; margin: var(--space-4) 0;
  background: linear-gradient(to right,
    transparent, var(--color-divider) 48px,
    var(--color-divider) calc(100% - 48px), transparent);
}

/* ---- 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: 14px; line-height: 1.2; color: var(--color-text);
  background: transparent; border: 1px solid transparent;
  min-height: 36px;
  padding: var(--space-2) calc(var(--space-3) * 1.2);
  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 the console's four team actions ran together as one line
   of prose, and the dialog's ✕ was a 19px-wide target on a modal.
   Neutral until pointed at, rather than accent-coloured: the console's team
   strip is four of these in a row, and in the accent they read as four equally
   urgent things next to a filled primary that is the actually urgent one. */
.btn-ghost { color: var(--color-neutral-300); padding-inline: var(--space-3); }
.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: #3a1512; }
.btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--color-bad) 14%, transparent); }
.btn-icon { width: 36px; height: 36px; padding: 0; }
.btn-block { width: 100%; margin-top: var(--space-2); }

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

.field > label {
  display: block; font-size: 12px; 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: 14px; 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-2);
  padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
}
.card-kicker { font-size: 10px; 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: 17px; line-height: 1.2;
}
.card-body { margin: 0; font-size: 13px; 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: 11px;
  letter-spacing: 0.02em; padding: 3px 10px;
  border-radius: calc(var(--radius-md) * 0.75);
}
.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); }

.dialog-backdrop {
  position: fixed; inset: 0; display: grid; place-items: center;
  padding: var(--space-4); 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-3);
  padding: var(--space-4); border-radius: var(--radius-lg);
  background: var(--color-surface); box-shadow: var(--shadow-lg);
  max-height: 90vh; overflow-y: auto;
}
.dialog-title {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 20px;
}
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }

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

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

.shell { max-width: 1180px; margin: 0 auto; padding: 24px 24px 64px; }
.shell.narrow { max-width: 620px; }



/* ---- 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-1);
  max-width: 1180px; margin: 0 auto; height: 56px; padding: 0 24px;
}

.topbar-item {
  display: inline-flex; align-items: center; gap: 7px;
  height: 32px; padding: 0 11px; border-radius: var(--radius-md);
  font-family: var(--font-body); font-size: 13px; 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: 8px;
  height: 32px; margin-right: var(--space-3);
  font-family: var(--font-display); font-weight: 400;
  font-size: 10px; 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; }

.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: 12px; color: var(--color-neutral-500); gap: 6px; padding: 0 9px; }
.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); }

/* `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-1); margin-left: var(--space-2); }
.topbar-me { padding-left: 5px; padding-right: 9px; 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: 13px; color: var(--color-neutral-300);
  max-width: 16ch; overflow: hidden; text-overflow: ellipsis;
}
.topbar-me .avatar { width: 22px; height: 22px; font-size: 11px; }

/* ---- 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. */
.topbar-menu {
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 60;
  display: none; flex-direction: column; gap: 2px;
  min-width: 180px; max-width: min(280px, calc(100vw - 20px));
  padding: 6px; border-radius: var(--radius-md);
  background: var(--color-surface); box-shadow: var(--shadow-md);
}
.topbar-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. Paired with the
   720px query at the foot of this section — the two numbers are one decision. */
@media (min-width: 721px) {
  .topbar-nav {
    position: static; display: flex; flex-direction: row; align-items: center;
    gap: var(--space-1); 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. */
.topbar-menu-head {
  padding: 6px 10px 8px; margin-bottom: 2px;
  border-bottom: 1px solid var(--color-neutral-800);
  font-size: 12px; color: var(--color-neutral-500);
  overflow: hidden; text-overflow: ellipsis;
}
.topbar-menu-item {
  display: flex; align-items: center; gap: 8px;
  min-height: 36px; padding: 0 10px; border: 0; border-radius: var(--radius-sm);
  font-family: var(--font-body); font-size: 13px; text-align: left;
  color: var(--color-neutral-300); background: transparent; cursor: pointer;
  text-decoration: none; white-space: nowrap;
}
.topbar-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. */
@media (max-width: 720px) {
  .topbar-inner { height: 52px; padding: 0 10px; gap: var(--space-1); }
  .topbar-brand { margin-right: 0; font-size: 13px; }
  .topbar-name { display: none; }
  .topbar-item { padding: 0 9px; font-size: 12px; }
  .topbar-burger { display: inline-flex; padding: 0 8px; }

  /* The panel itself is `.topbar-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 10px; font-size: 14px; 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 (max-width: 520px) {
  .topbar-conn { padding: 0 6px; }
  .topbar-conn span:not(.topbar-ico) { 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: 24px;
  display: flex; flex-direction: column; gap: 22px;
}
.surface + .surface { margin-top: 20px; }

.kicker {
  font-size: 10px; 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: 12px; 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: 11px; }
/* 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 { display: none; }

.stack { display: flex; flex-direction: column; gap: 14px; }
.row { display: flex; gap: 8px; 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. */
.section-head { display: flex; align-items: center; gap: 12px; }
.rule-fade {
  flex: 1; height: 1px;
  background: linear-gradient(90deg,
    transparent, var(--color-neutral-800) 48px,
    var(--color-neutral-800) calc(100% - 48px), transparent);
}

/* ---- connection dot ------------------------------------------------------ */

.blip {
  width: 7px; height: 7px; border-radius: 50%; flex: none;
  background: var(--color-neutral-600);
}
.blip.is-live { background: var(--color-accent); animation: blip 2.6s ease-in-out infinite; }
.blip.is-down { background: var(--color-bad); }
@keyframes blip { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
@media (prefers-reduced-motion: reduce) {
  .blip.is-live { animation: none; }
}


/* ---- 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);
}

/* ---- console header ------------------------------------------------------ */

.console-head {
  display: flex; align-items: center; gap: 24px; flex-wrap: wrap;
  padding-bottom: 18px; border-bottom: 1px solid var(--color-neutral-800);
}
/* The trailing action belongs at the far edge. It used to get there for free on
   the console, which had a wide summary column between the title and it; that
   column said what the strip's own live dot says and went with `showing`. */
.console-head > :last-child { margin-left: auto; }
.head-id { display: flex; flex-direction: column; gap: 3px; min-width: 170px; }
.head-name {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 21px; letter-spacing: -0.01em;
}
.head-status {
  display: flex; align-items: center; gap: 7px;
  font-size: 12px; 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: 14px 18px; font-family: var(--font-display); font-size: 9px;
  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);         /* the panel's top edge,
                                                       carried on under the
                                                       tabs that are not open */
  --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: 14px; }
/* 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: 4px; 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: 2px;
  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: 10px 14px 12px; margin-bottom: var(--tab-seam);
  cursor: pointer; text-align: left; white-space: nowrap;
}
.tab-label { font-size: 14px; font-weight: var(--font-weight-medium); color: var(--color-neutral-500); }
.tab:hover .tab-label { color: var(--color-neutral-300); }
.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: 14px; flex-wrap: wrap;
  justify-content: flex-end; padding-bottom: 12px; 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-2); }
.tabs-scores .standing .name {
  flex: none; font-size: 11px; 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: 18px;
}

/* 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: 16px;
  padding-top: 14px;
  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-3);            /* a gap, where the merge had overlap */
  --tab-baseline: transparent;           /* no shared edge left to carry */
  --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-baseline: transparent;
  --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: 12px 14px;
}
/* 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: 14px;
  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: 10px; }
.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-2);
  width: 5px; height: 5px; border-radius: 50%; background: var(--color-accent);
  vertical-align: middle;
}

/* A participant whose picker the admin has overruled. Dimmed rather than
   disabled: their choice is still theirs and comes back when the admin clears
   the room, so the controls stay usable. */
/* Draw is exempt: contributing art to the pool is still worth doing while the
   admin has the room, and the pad's preview is a live event that outranks a
   soft global anyway. Dimming it would say otherwise. */
.views.is-preempted .tabpanel { opacity: 0.55; }
.vp-preempt { margin: 0 0 var(--space-3); color: var(--color-accent-300); }

/* 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-4); }
.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-4); }
.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 (max-width: 560px) { .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; max-height carries it so the same number of
   rows still shows. */
.sprite-grid {
  display: grid; gap: 7px; align-content: start;
  grid-template-columns: repeat(auto-fill, minmax(46px, 1fr));
  padding: 3px; max-height: 178px; overflow-y: auto;
}
.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 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: 8px; min-height: 36px;
  padding: 0 4px; cursor: pointer;
  font-size: 13px; 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: 10px 12px; font-size: 13px; 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: 7px; }
.chip-row { display: flex; gap: 6px; }
.chip-row .chip { flex: 1; padding: 9px 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: 34px; font-weight: 400;
  line-height: 1.1; letter-spacing: 0;
}

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

.team-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.team-card {
  flex: 1; min-width: 190px; display: flex; align-items: center; gap: 10px;
  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: 11px 12px;
}
.team-card .who { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.team-card .name { font-size: 14px; font-weight: var(--font-weight-medium); text-transform: capitalize; }
.team-card .members { font-size: 11px; color: var(--color-neutral-500); }
/* 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: 22px; font-weight: var(--font-weight-strong);
  font-variant-numeric: tabular-nums;
}
.team-card .nudge { display: flex; flex-direction: column; gap: 4px; }
.nudge button {
  width: 26px; height: 22px; background: none; cursor: pointer;
  border: 1px solid var(--color-neutral-800); color: var(--color-neutral-300);
  border-radius: var(--radius-sm); font-size: 13px; line-height: 1;
}
.nudge button:hover { border-color: var(--color-accent); color: var(--color-text); }

.winner-bar {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  background: var(--color-surface); border: 1px solid var(--color-neutral-800);
  border-radius: var(--radius-md); padding: 10px 12px;
}
.winner-bar .lead { display: flex; flex-direction: column; gap: 1px; width: 160px; }
.winner-bar .lead-name { font-size: 13px; font-weight: var(--font-weight-medium); }
.cap { text-transform: capitalize; }
.winner-sprite { width: 38px; height: 38px; flex-shrink: 0; }

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

.tile-grid {
  display: grid; gap: 10px;
  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: 8px; text-align: left;
  background: none; border: 0;
  border-top: 3px solid var(--color-neutral-800);
  border-radius: var(--radius-md); padding: 10px 9px 8px; 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: 6px; }
.tile-card .name {
  flex: 1; min-width: 0; font-size: 13px; 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: 10px; 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: 16px; font-weight: var(--font-weight-medium); }

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

/* A grid, not a flex row — three columns declared once:
     1  the avatar gutter, fixed to the avatar's own width
     2  the text column: the name, and the offline badge when there is one
     3  the actions rail, hugging the right edge
   As a flex row every element chose its own vertical alignment and the two on
   the right carried a `margin-top: 3px` to land on the name's line, which is
   how a header ends up with nothing sharing a rail. Here the avatar, the name
   and both actions share one centre line because they are cells in one row, and
   the left and right rails are the header's own edges. */
.player-head {
  display: grid;
  grid-template-columns: 38px minmax(0, 1fr) auto;
  column-gap: 11px; align-items: center;
  padding-bottom: 16px; border-bottom: 1px solid var(--color-neutral-800);
}
.player-head .avatar-wrap { grid-column: 1; }
.player-head .grow { grid-column: 2; }
.player-head .head-acts { grid-column: 3; display: flex; align-items: center; gap: 10px; }
/* A badge in a stack, not in a row of them: it stops short of the block's width
   rather than filling it. */
.player-head #tilePill { align-self: flex-start; margin: 4px 0 1px; }
.player-name { font-family: var(--font-heading); font-size: 18px; font-weight: var(--font-weight-medium); }
/* The identity block keeps a floor rather than the bare .grow (flex:1;
   min-width:0), which lets it collapse to a one-word-per-line sliver with the
   badges sitting on top of it. A basis, not a fixed width, so on a phone the
   badges wrap onto their own line instead. */
.player-head .grow { flex: 1 1 150px; min-width: 150px; }
.team-pill {
  display: flex; align-items: center; gap: 7px;
  border: 1px solid var(--color-neutral-800); border-radius: 999px; padding: 5px 12px;
}
.team-pill .swatch { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--color-neutral-600); }
.team-pill .name { font-size: 12px; 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: 12px; 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); }

.player-body { display: flex; gap: 22px; align-items: flex-start; flex-wrap: wrap; }
/* 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. */
.player-side { flex: 0 1 300px; min-width: 260px; display: flex; flex-direction: column; gap: 18px; }
.player-main { flex: 2 1 320px; min-width: 0; display: flex; flex-direction: column; gap: 20px; }

/* 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 border stays declared but
   transparent: it costs nothing at rest, keeps the box from resizing, and is
   what .stage.is-offline turns dashed and red to say "this is standing in for
   the tile". Padding stays for the same reason — the offline edge needs room. */
.stage {
  background: none; border: 1px solid transparent;
  border-radius: var(--radius-lg); padding: 16px;
  display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.stage-matrix { width: 200px; max-width: 100%; }
.stage-status { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-neutral-500); }


/* Whether the TILE is reachable, as opposed to the topbar dot, which is this
   page's own link to the server. Both can be green at once and mean different
   things, so this one is coloured and the other stays neutral. */
.status-pill .name { color: var(--color-neutral-400); }
.status-pill.is-online { border-color: color-mix(in srgb, var(--color-good) 45%, transparent); }
.status-pill.is-online .name { color: var(--color-good); }
.status-pill.is-online .blip { background: var(--color-good); }
.status-pill.is-offline { border-color: color-mix(in srgb, var(--color-bad) 55%, transparent); }
.status-pill.is-offline .name { color: var(--color-bad); }
.status-pill.is-offline .blip { background: var(--color-bad); }

/* An unreachable tile keeps its preview at FULL strength: this screen is the
   only tile its owner has left, so it stands in for the tile rather than
   greying out the one picture they can still see. The stage says it is a
   stand-in with its edge — dashed, in the bad colour — which reads as "not the
   real thing" without hiding what the real thing would be doing. */
.stage.is-offline {
  border-style: dashed;
  border-color: color-mix(in srgb, var(--color-bad) 45%, var(--color-neutral-800));
}
.stage.is-offline .stage-status { color: var(--color-bad); }

/* No tile of your own yet: the page still runs as a tile — the stage shows
   what every other tile is showing, and the sprite pad still contributes to
   the shared pool. What goes is anything that describes a piece of hardware
   nobody has: brightness, orientation, and the sprite that would be sent to it. */
.is-virtual [data-needs-tile] { display: none; }
.offline-note {
  margin: 0; max-width: 30ch; text-align: center;
  font-size: 11px; line-height: 1.45; color: var(--color-neutral-400);
}

.slider-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.slider-value { font-size: 12px; font-variant-numeric: tabular-nums; }

.orient-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; }
.orient {
  display: flex; flex-direction: column; align-items: center; gap: 7px;
  padding: 10px 0 8px;
}
/* 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: 11px; font-variant-numeric: tabular-nums; }

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

.pad-row { display: flex; gap: 14px; 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: 8px; }
/* Where each page hangs its own name/save controls on the shared pad. */
.pad-extra { display: flex; flex-direction: column; gap: 8px; }
.pad-extra .btn-block { margin-top: 0; }
.swatch-row { display: flex; gap: 5px; flex-wrap: wrap; }
.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: 4px; }
/* 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: 6px 0; }
.c4-board {
  display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px;
  padding: 6px; 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; }

/* ---- 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: 6px; flex: none; height: 30px;
  font-size: 13px; color: var(--color-neutral-300); font-variant-numeric: tabular-nums;
}

/* The team scores on /leaderboard, in the same shape as the Connect 4 board
   above them — one row treatment for both tables on a page that is two tables.
   `.standing` is already swatch · name · value, which is that shape. */
.board .standing { padding: 9px 10px; 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: 15px; }
/* 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); }

/* The reachability dot, on the avatar. Ringed in the surface colour so it reads
   as sitting on the disc 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);
}
/* Green, not the accent `.blip.is-live` uses everywhere else — the avatar disc
   IS the accent, so the default rule painted an amber dot on an amber circle and
   only the ring showed it was there at all. And no pulse: on a coloured disc a
   dot fading in and out reads as a rendering fault rather than as liveness,
   which the topbar's blip does not have to contend with. */
.avatar-wrap .blip.is-live { background: var(--color-good); animation: none; }
.avatar-wrap .blip.is-down { background: var(--color-bad); }

/* 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. */
.player-head .btn-icon {
  width: 30px; height: 30px; min-height: 0; border-radius: 999px;
  font-size: 13px; 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;
}
.standing { display: flex; align-items: center; gap: 10px; }
.standing .swatch { width: 8px; height: 8px; border-radius: 2px; flex: none; }
.standing .name { flex: 1; font-size: 13px; color: var(--color-neutral-300); text-transform: capitalize; }
/* 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: 14px; 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: 12px; }

/* 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: 8px; text-align: center; cursor: pointer;
  background: none; border: 0;
  border-radius: var(--radius-md); padding: 10px;
}
.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: 11px; padding: 2px; align-self: center; min-height: 0;
  font-weight: var(--font-weight-body); color: var(--color-neutral-500);
}

.steps { display: flex; align-items: center; gap: 8px; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--color-neutral-500); }
.step.is-on { color: var(--color-accent); }

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

.login { max-width: 400px; margin: 14vh auto; padding: 0 24px; 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: 14px;
  font-family: var(--font-heading); font-size: 30px; font-weight: var(--font-weight-medium);
  letter-spacing: 0.06em; color: var(--color-accent);
  /* The gap the tag line used to hold open, now that the logo carries it. */
  margin-bottom: 36px;
}
.login .logo .brandmark { width: 38px; height: 38px; }
.login .actions { display: flex; flex-direction: column; gap: 10px; }
.login .actions .btn { width: 100%; padding: 13px 16px; font-size: 15px; }
.btn-discord {
  background: #5865f2; color: #fff; border-color: #5865f2;
}
.btn-discord:hover:not(:disabled) { background: #6b78f5; border-color: #6b78f5; }
.login .op { margin-top: 30px; }
.login .op a { color: var(--color-neutral-500); font-size: 13px; }
.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: 10px 16px; border-radius: var(--radius-md);
  font-size: 14px; opacity: 0; transition: opacity 0.2s;
  pointer-events: none; z-index: 100;
}
.toast.show { opacity: 1; }
.toast.err { border-color: var(--color-bad); color: #ffb3bd; }

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

/* One row per tile, with a swatch per team: assigning is a click, so sorting
   sixteen of them doesn't mean sixteen trips through the tile dialog. */
.roster { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 5px 18px; }
.roster-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.roster-row.is-off { opacity: 0.5; }
.roster-name {
  flex: 1; min-width: 96px; font-size: 13px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.roster-name .mono { margin-left: 6px; }
.roster-pick { display: flex; gap: 2px; flex: none; }
.roster-pick button {
  width: 18px; height: 18px; padding: 0; cursor: pointer;
  border-radius: var(--radius-sm); border: 1px solid var(--color-neutral-700);
  background: transparent;
}
/* The current team reads as filled; the rest are outlines of what they'd become. */
.roster-pick button.is-on { box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 3px currentColor; }
.roster-pick button.none { color: var(--color-neutral-500); font-size: 11px; line-height: 1; }

/* ---- 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: 16px; margin: 8px 0 20px; }
.flash-choice {
  flex: 1 1 220px; min-height: 200px; cursor: pointer;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
  padding: 24px; 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: 20px; 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;
}

.flash-detail { margin-bottom: 14px; }
.flash-detail > summary {
  cursor: pointer; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--color-neutral-500);
}
.flash-detail > summary:hover { color: var(--color-accent); }
.flash-built { font-size: 12px; color: var(--color-neutral-500); }

/* ---- 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: 6px 16px;
  margin: 10px 0 14px;
}
.baked dt { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-neutral-500); }
.baked dd { margin: 0; font-size: 12px; color: var(--color-neutral-400); }
.flash-steps { margin: 4px 0 16px; padding-left: 20px; font-size: 13px; color: var(--color-neutral-400); }
.flash-steps li { margin-bottom: 5px; }

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

@media (max-width: 720px) {
  .shell { padding: 20px 14px 48px; }
  .surface { padding: 16px; gap: 18px; }
  /* 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: 40px; }
  /* Once the body has wrapped, the side column is a ROW, not a column beside
     something — so it takes the whole width. `width: auto` used to say this and
     could not: `flex: 0 1 300px` sets a 300px basis with no grow, and a width
     does not override a basis. The tile centres inside this box, so a box 30px
     narrower than the page was a tile 15px left of centre — which is exactly
     how it looked. */
  .player-side { flex: 1 1 100%; min-width: 0; }
}

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

@media (max-width: 560px) {
  /* 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: 12px; }
  .topbar-inner { padding: 0 16px; }
}

/* ---- leaderboard ---------------------------------------------------------- */
.board { display: flex; flex-direction: column; gap: 2px; }
.board-row { display: flex; align-items: center; gap: 12px; padding: 9px 10px; 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: 12px; color: var(--color-neutral-500);
  font-variant-numeric: tabular-nums; flex: none;
}
.board-row .who { flex: 1; min-width: 0; font-size: 14px; overflow: hidden; text-overflow: ellipsis; }
.board-row .value { font-size: 15px; font-weight: var(--font-weight-strong); font-variant-numeric: tabular-nums; }
.board-row.is-me .who { color: var(--color-accent); }
.result { display: flex; align-items: baseline; gap: 8px; font-size: 13px; padding: 5px 0; }
.result .beat { color: var(--color-neutral-500); font-size: 12px; }
.result .when { margin-left: auto; font-size: 11px; color: var(--color-neutral-500); }

/* The people list. One row per person who has signed in: who they are, what
   they have, and the two decisions an admin can make about them. Actions sit at
   the end of the row rather than behind a menu — there are three of them, and a
   party is no place to go hunting. */
.people { display: flex; flex-direction: column; gap: 2px; }
.person-row {
  display: flex; align-items: center; gap: 10px;
  padding: 7px 8px; border-radius: var(--radius-sm);
}
.person-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: 13px; }
.person-who { flex: 1; min-width: 0; }
.person-name {
  display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
  font-size: 14px; font-weight: var(--font-weight-medium);
}
.person-acts { display: flex; gap: 6px; flex: none; }
/* A row per person, three actions each — these stay under field height so the
   list reads as a list rather than as a stack of forms. */
.person-acts .btn { min-height: 30px; padding: 6px 10px; font-size: 12px; }
/* 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 { padding: 1px 8px; }

/* Narrow: the actions drop under the name rather than squeezing it to nothing. */
@media (max-width: 640px) {
  .person-row { flex-wrap: wrap; }
  .person-acts { width: 100%; }
  .person-acts .btn { flex: 1; }
}
