/* Copyright (c) 2024-2026 Kilotronic LLC. All rights reserved. */

/**
 * Shared styles for host and player UIs.
 *
 * Loaded by both host.html and play.html via <link href="/shared/shared.css">.
 * Contains CSS custom properties, reset, and component styles used by the
 * shared rendering functions in game-core.js.
 */

/* Self-hosted brand fonts (woff2 vendored in /shared/fonts). Bricolage = display
   (headings/wordmark via --font-display); Inter = body/UI (via --font-body). */
@font-face {
  font-family: "Bricolage Grotesque";
  src: url("/shared/fonts/bricolage-grotesque-800.woff2") format("woff2");
  font-weight: 800;
  font-style: normal;
  font-display: swap;
}
/* One variable face covering every weight the UI asks for (400 body, 500/600
   emphasis). It was previously declared as three single-weight faces at three
   URLs — but Inter ships as ONE variable font (fvar axis wght 100-900), so those
   three files were byte-identical and the browser downloaded the same 48 KB more
   than once. The weight RANGE is what lets it instance any weight in between. */
@font-face {
  font-family: "Inter";
  src: url("/shared/fonts/inter-variable.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
/* A real italic cut, not a synthesized one. Inter shipped normal-only, so every
   `font-style: italic` rule (the prompt-composer placeholder, the undone-play
   feedback line) made the engine skew the roman itself — and a synthetic
   oblique's ink leans outside the text run's advance box, which Safari then
   clips (placeholder rendered as "Typ"). Drawn glyphs stay inside their box. */
@font-face {
  font-family: "Inter";
  src: url("/shared/fonts/inter-italic-400.woff2") format("woff2");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
/* Card/prompt face: used ONLY for the content the room reads (deck cards + the
   hero prompt), so that surface reads as its own object against the all-sans
   chrome. Scoped behind --font-card so the face is a one-line swap. */
@font-face {
  font-family: "Averia Serif Libre";
  src: url("/shared/fonts/averia-serif-libre-700.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* Material Symbols Rounded subsets (Apache-2.0), built by
   scripts/subset_material_symbols.py:
   - Filled  (FILL=1/wght=600/opsz=20): 7 controller-chip + 4 feedback + 3 host
   - Outline (FILL=0/wght=500/opsz=20): 4 feedback glyphs (ghost state)
   Shared (menu-strategy 2026-07-30) so both apps can render icon glyphs
   without duplicating the font. */
@font-face {
  font-family: "Material Symbols Rounded";
  font-style: normal;
  font-weight: 600;
  font-display: block;
  src: url("/shared/fonts/MaterialSymbolsRounded-Filled.woff2") format("woff2");
}
@font-face {
  font-family: "Material Symbols Rounded Outline";
  font-style: normal;
  font-weight: 500;
  font-display: block;
  src: url("/shared/fonts/MaterialSymbolsRounded-Outline.woff2") format("woff2");
}

/* Inline Material Symbol glyph for scattered chrome (host audio-unlock button,
   finale eyebrow, time's-up confirmation; player hamburger menu). Inherits
   the surrounding font-size and color so it scales with its context; the raw
   codepoint is emitted with ligatures off. */
.ms-icon {
  font-family: "Material Symbols Rounded";
  font-weight: normal;
  font-feature-settings: "liga" 0;
  -webkit-font-smoothing: antialiased;
  line-height: 1;
  vertical-align: -0.15em;
}

/* Reset */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}
/* Form controls don't inherit font-family from the UA stylesheet — without this
   they fall back to the browser default (Arial), so buttons/inputs would break
   away from the brand type. */
button,
input,
textarea,
select {
  font-family: inherit;
}
/* Placeholder text, every field, one rule. Dimmed and italic so a hint is never
   read as a value already entered (playtest: "not clear the placeholder is a
   placeholder").
   This lived in play.css as a lone `.active-input` rule, which styled one of the
   app's six placeholder fields — the join code/name inputs, the host feedback
   textarea and the auth phone/code inputs kept the UA default: upright, and a
   different grey in every browser. Styling the pseudo-element rather than a class
   means a newly added field inherits the treatment instead of needing its own rule.
   Typeface needs no declaration here: ::placeholder inherits from its field, and
   the reset above already pulls form controls onto the brand face.
   opacity: 1 because Firefox dims placeholders on top of the author colour. */
/* [hidden] must beat any component's display value, on BOTH surfaces. The UA
   stylesheet sets `[hidden] { display: none }`, but ANY author rule with a
   display wins on specificity — so the moment a control family declares
   `display: inline-flex`, every `[hidden]` member of it silently un-hides.
   That is not hypothetical: it shipped the glyph-button family and turned the
   host's hidden card-flag button visible, caught by e2e, not by any unit test
   (2026-08-01). This lived in play.css only, which is why the host had no
   cover; it belongs where both apps load it. */
[hidden] {
  display: none !important;
}
input::placeholder,
textarea::placeholder {
  color: var(--text-dim);
  font-style: italic;
  opacity: 1;
}

/* Color palette and design tokens */
:root {
  /* The one full-width CTA cap (ui-style.md Width: full-width is `width:100%`
     capped, centered). It was previously the literal 320px repeated in three
     places and scoped to #auth-buttons / #phone-auth / #collecting-panel, which
     is how the config CTA (480px) and the shared tertiary ghost (432px) escaped
     it. One token, applied on the buttons themselves.
     Lives HERE rather than in host.css because its consumer `.btn--stack` is a
     shared component, and the player app loads shared.css but never host.css —
     so a player-app `.btn--stack` resolved `var(--cta-max)` to nothing, which
     makes the whole max-width declaration invalid and falls back to `none`. An
     unresolved token fails silently and looks like a layout bug, which is
     exactly how it reached review (2026-08-16). */
  --cta-max: 320px;
  /* Phase-5 in-app tokens, bridged to the Spraoi brand (see ui/shared/tokens.css).
     In-app theme is Neutral Charcoal: near-hue-neutral warm-dark surfaces, cream
     text, exact coral accent, Bricolage display + Inter body. (Was dark plum — the
     brand ink #2b1a2e promoted to every surface read "too purple" against the warm
     brand; charcoal lets the coral/amber accents carry the color.) Token names are
     unchanged so all consumers re-skin automatically. */
  --bg-1: #211d1a; /* raised charcoal panel */
  --bg-2: #141110; /* darkest charcoal — app background */
  --surface: #262220; /* charcoal card */
  /* Tells the UA every native control (scrollbars, form-field chrome,
     autofill) should render in its dark variant — this file previously
     declared none, so those rendered light against the charcoal app (audit
     §6, 2026-08-08). Every page that loads this file inherits dark
     (host/play/recap/admin/info); landing.css overrides this back to
     `light` at :root (via `:root:has(body.landing)` — color-scheme is read
     off the root element, so a body-scoped override alone would not take
     effect) for landing, the only page that is light-themed. */
  color-scheme: dark;
  --text: var(--color-surface); /* cream #faf4ea */
  --text-dim: #cdc3b8; /* warm muted cream — 9.0:1+ on the charcoal surfaces */
  --accent: var(--color-accent); /* coral #ff5e3a */
  --glow-blur: 34px;
  /* Radius scale — the corner values hardcoded ~35x across host.css/play.css
     (4px / 6px / 8px), formalized so a future rescale is a one-line edit
     instead of a find-and-replace. --radius-btn/-card predate this scale;
     kept as-is (they already have a single source of truth each). */
  --radius-xs: 4px; /* compact rows/chips (menu item, avatar dropdown rows) */
  /* Small boxes that are NOT buttons: text inputs, message/error panels, the
     intro way-badge, and the .hw-btn keycap depiction. Was --radius-btn-sm,
     when radius was a function of button size; that rule died with the
     `--dialog` tier (2026-08-01) but the value kept seven users, so it is
     renamed to what it actually is rather than deleted. */
  --radius-sm: 6px;
  --radius-md: 8px; /* standalone inputs/cards/buttons (mostly play.css) */
  --radius-btn: 14px;
  --radius-card: 16px;
  /* FLOATING OVERLAY — a control that floats OVER content rather than sitting
     in the layout: the enable-sound chip and the collapsed party-code chip.
     Formalized as a family radius by the button survey's role layer (§5,
     owner-confirmed 2026-08-20) rather than left as one control's one-off,
     which is what it had been. 999px, not the box's exact half-height:
     border-radius clamps to half the box on each axis, so it reads as "fully
     round" at any height instead of silently reverting to a less-rounded
     rectangle when the content grows. */
  --radius-pill: 999px;
  --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.4); /* floating chip/toast/cue */
  --shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.5); /* larger popover (account sheet) */
  --font: var(--font-body); /* Inter */
  /* Content face for deck cards + hero prompts (the words players read). Swap
     this single token to retheme card/prompt content. */
  --font-card: "Averia Serif Libre", Georgia, serif;
  --font-card-weight: 700;

  /* Button action colors — three-tier language matching the physical smiley
     controller caps (see ui/shared/controller-profiles.js). Tier 1 game verbs are
     colored (softened ~15% sat off the old neon, AA-verified on charcoal); Tier 2
     equipment (timer + dice) share amber as a matched pair, told apart by icon;
     Tier 3 navigation (back + menu) share warm gray. All hold a dark glyph
     (--clr-on-accent) at >=6.4:1. Dice reads --c-timer in host.css so the amber
     pair stays in sync. */
  --c-proceed: #5c9df2; /* verb: go */
  --c-positive: #66d685; /* verb: yes */
  --c-negative: #f36b82; /* verb: no */
  --c-timer: #eaa24a; /* equipment: timer + dice (amber pair) */
  --c-back: #a59d94; /* nav: back + menu (warm gray pair) */
  --c-menu: #a59d94;
  /* Dark glyph on coral/amber accent fills (composer +, hw-btn labels).
     Aliases --bg-2 — identical contrast on coral (6.19:1) to the old private
     near-black. Keeps a semantic name so call sites read as "ink on fill,"
     not "page background," without minting a near-black twin of --bg-2. Not
     marketing --color-on-accent (white on light brand). */
  --clr-on-accent: var(--bg-2);

  /* Legacy aliases — kept for shared components not yet migrated */
  --bg-card: var(--surface);
  --text-muted: #b4a99e; /* warm muted cream — AA (>=4.5:1) on --bg-2 and --surface */
  --accent-active: #e0492a; /* darker coral press state */
  --border: #38322e; /* charcoal border */
  --clr-scored: var(--c-proceed);
  --clr-passed: var(--c-timer);
  --clr-expired: var(--c-negative);
  /* Frozen header timer bar (engine pause: menu pause, round transition) —
     inert warm gray so a paused countdown never reads as live (game-core.js
     timerBarColor's paused branch references this token). */
  --clr-timer-paused: var(--c-back);
  /* SCRIM DEPTH LADDER — every "dim the background" fill in the app, named so
     the four depths read as a deliberate scale instead of unlabeled magic
     numbers scattered through host.css. Values are unchanged from what they
     replace; this only names them. */
  --scrim-subtle: rgba(0, 0, 0, 0.3); /* #engine-warming badge backdrop */
  --scrim-roll: rgba(0, 0, 0, 0.55); /* dice-roll overlay dim */
  --scrim: rgba(0, 0, 0, 0.6); /* overlay-backdrop dim, shared host + player */
  --scrim-cover: rgba(0, 0, 0, 0.95); /* menu turn-cover: opaque word-cover */
  /* The "chosen" fill on a selectable card/choice control — .game-card.selected
     and .choice-card:has(input:checked) declared this identically. */
  --selected-fill: color-mix(in srgb, var(--accent) 10%, var(--surface));

  /* Solid-fill destructive button (delete-account confirm, `.btn--danger`).
     Distinct from --c-negative, which is tuned for text-on-dark contrast, not
     a filled button with white label text — --c-negative there reads too
     pale. */
  --c-danger-fill: #b91c1c;
  --c-danger-fill-hover: #dc2626;
  /* Connection-banner / offline warning fill — amber, not the timer equipment
     amber (--c-timer), which is tuned for icons on charcoal. */
  --clr-warning-amber: #b45309;

  /* Join-QR ink and paper. This is the one element in a dark console that stays
     LIGHT: phone cameras assume dark modules on a light field, so an inverted QR
     fails outright on some scanners. The QR is therefore brand-TINTED, not
     brand-inverted — the brand's own light pair, plum ink on cream (14.9:1)
     instead of black-on-white (21:1), a difference no scanner can notice.
     Coral and marigold are 2.8:1 and 1.5:1 on cream, below even the 3:1 floor,
     so they never touch a module: brand accent rides the card ring only, outside
     the quiet zone. Holds up across every brand theme (14.2:1 worst case). */
  --qr-dark: var(--color-ink);
  --qr-light: var(--color-surface);
}

body {
  font-family: var(--font);
  background: var(--bg-2);
  color: var(--text);
}

/* ── Beta badge ─────────────────────────────────────────────────────────
   Quiet "BETA" pill beside the Spraoi wordmark on entry screens (host
   sign-in + header, player join). Spraoi is in public beta; the pill is
   persistent and decorative. Deliberately NOT rendered during active
   gameplay — the in-game session header stays clean. Sized in `em` so it
   tracks whatever heading it sits on.

   The pill HANGS off the wordmark's right instead of sharing its inline
   run (owner, 2026-08-02 playtest: the pill pushed "Spraoi" ~34px left of
   true center on the sign-in wall). Mechanism: the .beta-badge-slot wrapper
   is a zero-width inline-block, so the heading centers as if the pill were
   absent, while the pill overflows visibly to the right. A wrapper rather
   than position:absolute because the player join badge is a flex item — an
   abs child's static position in a centered flex row is mid-row, not
   after-the-title. */
.beta-badge-slot {
  display: inline-block;
  width: 0;
  white-space: nowrap;
  overflow: visible;
}
.beta-badge {
  display: inline-block;
  margin-left: 0.4em;
  padding: 0.15em 0.45em;
  border-radius: var(--radius-xs);
  background: color-mix(in srgb, var(--accent) 18%, transparent);
  color: var(--accent);
  font-family: var(--font);
  font-size: 0.42em;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  line-height: 1;
  vertical-align: super;
}
/* Player join heading: the h1 carries data-i18n (its textContent is rewritten
   on translate, so it can't hold a child span). The badge is a sibling in a
   centered baseline row instead. */
.screen-title {
  display: flex;
  justify-content: center;
  align-items: baseline;
  /* No gap: the badge slot is zero-width, so any gap between h1 and slot
     would shift the title off center by gap/2. The pill's spacing comes
     from its own margin-left, which overflows the slot and costs nothing. */
  gap: 0;
  margin-bottom: 1.5rem;
}
.screen-title h1 {
  margin-bottom: 0;
}
.screen-title .beta-badge {
  font-size: 0.55rem;
  vertical-align: baseline;
}

/* ── Button system ──────────────────────────────────────────────────────
   Reusable base + role variants for host's setup/recap CTAs and overlay-
   dialog buttons (see ui/web/host/host.html for consumers). `.btn` owns the
   SKIN every button shares — a CENTERED label (so a full-width button can
   never left-align its text against an empty half, the party-code-modal
   Continue bug class), one radius token, focus ring, cursor, font,
   transition; `.btn--<role>` owns only the fill/border/color (`--primary` /
   `--secondary` / `--neutral` / `--surface` / `--danger`). Every size tier
   is retired now (`--lg` and `--dialog` — #1740, 2026-08-01; `--sm` —
   2026-08-02, CSS dedup train): a button is the one standard 44px/r14 shape,
   dialog or not, compact or not. Each
   button's genuine per-instance SIZE/layout (extra padding, width, margin)
   stays a thin modifier in the consuming surface's own CSS — sizes are
   deliberately different (hero Start vs quiet Back), so the system unifies
   the drift-prone skin without flattening the hierarchy. Specialized
   input-affordance families (gameplay .cta-chip, hardware .hw-btn, gamepad
   .gp-btn, keycap .hint-btn) are intentionally NOT part of this system. The
   `.overlay-card button` element-selector safety net (host.css) was retired
   once every overlay button carried an explicit role class (app-unification
   Phase 2A Task 11, 2026-07-30). */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  border: none;
  border-radius: var(--radius-btn);
  /* Default size = the setup primary (intro Continue / collecting Start). */
  min-height: 44px;
  /* rem, so a raised OS text size grows the label and the padding around it;
     the 44px floor deliberately stays px — see the unit note in ui-style.md. */
  padding: 0.625rem 1.125rem;
  font-family: var(--font);
  font-size: 1.125rem;
  cursor: pointer;
  transition:
    background 0.1s,
    border-color 0.1s,
    filter 0.1s;
}
.btn:disabled {
  opacity: 0.35;
  cursor: default;
}
/* Controller/keyboard selection ring (toggled by OverlayFactory). */
.btn.focused {
  outline: 2px solid var(--text);
  outline-offset: 2px;
}
/* Primary — solid coral CTA. Dark text on coral is 7.17:1 (AA). */
.btn--primary {
  background: var(--accent);
  color: var(--bg-2);
  font-weight: 700;
}
.btn--primary:hover:not(:disabled),
.btn--primary:active {
  filter: brightness(1.1);
}
/* The one exception to "Disabled — one model" (ui-style.md), and the reason
   it exists: `.btn:disabled`'s opacity dim fades the fill and the label
   together, which is harmless for a light label on a dark ground but fatal
   here — the primary's label is dark INK chosen for bright coral, so the
   pair converges on the page colour at 1.74:1 (measured; guarded by
   disabled-contrast.test.js). The host reading "Waiting for words" on the
   collecting screen could not read it (button survey 2026-08-19, §5 finding
   c). So dim the FILL instead of the element: still unmistakably the coral
   primary, still obviously inert, and the label comes back at 8.4:1. */
/* Literal first, mix second — and the literal is not decoration. Moving the
   dim off `opacity` (supported everywhere) onto `color-mix()` means a browser
   without it discards the fill as invalid at computed-value time and keeps
   the rest of the block, `opacity: 1` included: the button would fall back to
   its ENABLED fill at full strength and look live while being inert. Every
   other color-mix() in this file degrades to "no tint"; this is the one whose
   absence inverts the signal. disabled-contrast.test.js pins the pair to the
   same colour so they cannot drift. */
.btn--primary:disabled {
  opacity: 1;
  color: var(--text);
  background: #723729;
  background: color-mix(in srgb, var(--accent) 35%, var(--surface));
}
/* Same defect, same cure, swept the same day: #fff on --c-danger-fill dimmed
   to 0.35 composites to 2.48:1 — and `#delete-confirm-submit` ships disabled
   until the operator types their identifier, so that unreadable state is the
   whole time the account-deletion dialog is on screen. */
.btn--danger:disabled {
  opacity: 1;
  color: var(--text);
  background: #59201f;
  background: color-mix(in srgb, var(--c-danger-fill) 35%, var(--surface));
}
/* Secondary — the ONE cancel/back/dismiss-button treatment: a filled neutral
   surface with a light label, sharing its proceed's box.

   This is the button survey's keystone (2026-08-19 §5). Three overlapping
   neutral classes used to split this role — `--secondary` (1.5px accent
   outline), `--surface` (this fill) and `--neutral` (transparent `--text-dim`
   outline) — so no one owned "the cancel look" and the same word wore three
   of them: a bright full-white outline (Keep Timer), a near-invisible one
   (Cancel, the Rules Done) and a dark chip (Back to Games). Outline is what
   produced both the too-loud and the too-quiet case, because an outline's
   weight is set by its stroke colour rather than by the role; a filled peer
   cannot out-shout its primary when it shares the box and only the fill
   differs. `--surface` and `--neutral` therefore retire into this one class
   (btn-role-guard.test.js pins both the treatment and their absence).

   The fill is a RAISED neutral, not `--surface` itself. `--surface` is the
   card/dialog ground (`--bg-card`), so a `--surface` button sitting on a card
   measured 1.000:1 against it with a `--bg-1` hairline at 1.061:1 — a "fill"
   that is not a fill, which is how `#intro-back-btn` and the profile actions
   read as captions rather than controls (measured in the running app,
   2026-08-20). Lifting it 15% toward `--text` gives 1.58:1 on a card and
   1.89:1 on the page ground, so the same one treatment reads on both. Literal
   first, mix second: a browser without `color-mix()` drops the invalid
   declaration and keeps the literal rather than falling through to no fill.

   No border: the primary it pairs with has none, and "same box" is the whole
   point — the tone does the delineating.

   `:active` as well as `:hover`, and both LIGHTEN. Touch has no hover, and the
   retired `--surface` darkened toward `--bg-1`, which on the page ground moved
   the button toward its background — hover made it recede. */
.btn--secondary {
  background: #46423e;
  background: color-mix(in srgb, var(--text) 15%, var(--surface));
  color: var(--text);
}
.btn--secondary:hover:not(:disabled),
.btn--secondary:active {
  background: #55504c;
  background: color-mix(in srgb, var(--text) 22%, var(--surface));
}
/* Same defect as `--primary`/`--danger` above, pre-empted: now that this role
   is filled, `.btn:disabled`'s opacity dim fades fill and label together and
   composites the pair to 2.7:1. Dim the FILL instead. No control ships
   disabled in this role today — the guard exists because the failure is
   silent, which is exactly how it reached production on the primary. */
.btn--secondary:disabled {
  opacity: 1;
  color: var(--text);
  background: #332f2c;
  background: color-mix(in srgb, var(--text) 6%, var(--surface));
}
/* Danger — the destructive confirm (delete-account submit). */
.btn--danger {
  background: var(--c-danger-fill);
  color: #fff;
}
.btn--danger:hover:not(:disabled) {
  background: var(--c-danger-fill-hover);
}
/* Stack — a button in a STACKED column takes full width, capped (ui-style.md
   Width: "auto width in a row, full-width in a stacked column"). Declared
   three times over host.css (the collecting panel's actions, the config
   launch CTA, the intro panel's actions) with an 8px/10px margin-top drift
   between them — unified on 10px (owner call, 2026-08-02). Depends on
   `--cta-max`, defined in this file's own `:root` — it moved here from
   host.css when the player app became a user of this class, because an
   unresolved custom property drops the cap silently. */
.btn--stack {
  display: flex;
  width: 100%;
  max-width: var(--cta-max);
  margin: 10px auto 0;
}

/* Detail rows (region-renderers.js _renderDetailLeaf, etc.) */
.detail-row {
  margin: 1px 0;
}
.detail-scores {
  margin: 4px 0;
}

/* Feedback toast animation. Was two mounts (host.js's region-payload toast
   and play.js's own game-core.js mount, each with its own wrapper class);
   docs/todo.md "One feedback dedup key" retired play.js's legacy mount, so
   `.region-toast`/`.region-toast--linger` (moved here from host.css — they
   are no longer host-only) are now the one wrapper both apps mount via
   region-renderers.js's shared mountRegionFeedback. */
@keyframes feedback-fade {
  0% {
    opacity: 0;
  }
  15% {
    opacity: 1;
  }
  70% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}
.region-toast {
  animation: feedback-fade 1.5s ease-out forwards;
}
/* Turn-over beats (lingering/concluded) hold the toast up longer so the next
   team never sees the previous team's confirmation before their turn starts
   (owner decision 2026-08-01) — host.js-only today (play.js's mount omits
   `linger`; see its call site comment for why). */
/* Turn-over beats (lingering/concluded) hold the toast up longer so the next
   team never sees the previous team's confirmation before their turn starts
   (owner decision 2026-08-01). The toast names the concluding word and then
   gets out of the way; the SCREEN outlives it by design (20s linger, owner
   2026-08-03), so this is deliberately much shorter than the hold rather than
   matched to it. Keep in sync with _TOAST_LINGER_DURATION_MS in
   region-renderers.js, which stands in for animationend under reduced motion. */
.region-toast--linger {
  animation-duration: 5s;
}
/* Each semantic feedback color is declared ONCE, on renderPlayConfirmation's
   own inner <span class="play-confirmation detail-feedback-*"> — the wrapper
   (.region-toast, or a bare inline row) never re-applies it. */
.play-confirmation.detail-feedback-scored {
  color: var(--clr-scored);
}
.play-confirmation.detail-feedback-passed {
  color: var(--clr-passed);
}
.play-confirmation.detail-feedback-expired {
  color: var(--clr-expired);
}
.play-confirmation.detail-feedback-undone,
.play-confirmation.detail-feedback-recycled {
  color: var(--text-muted);
  font-style: italic;
}

/* Score rows (renderPlayerScoreboardHtml) */
.score-row {
  display: flex;
  justify-content: space-between;
  padding: 0.7rem 1rem;
  margin-bottom: 0.3rem;
  border-radius: var(--radius-xs);
  background: var(--bg-card);
}
.score-row.active-team {
  border-left: 3px solid var(--accent);
  background: color-mix(in srgb, var(--accent) 20%, var(--surface));
}
.team-name {
  font-weight: 500;
}
.team-score {
  font-weight: 700;
  font-size: 1.2rem;
}

/* Game-over display (renderGameOverHtml) */
.game-over-winner {
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--accent);
  margin-bottom: 0.5rem;
}
.game-over-label {
  font-size: 0.9rem;
  color: var(--text-muted);
  margin-bottom: 0.5rem;
}

/* Timer (renderPlayerTimerHtml) */
.timer-text {
  font-size: 1.8rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.timer-text.ok {
  color: var(--clr-scored);
}
.timer-text.warn {
  color: var(--clr-passed);
}
.timer-text.expired {
  color: var(--clr-expired);
}
.timer-bar {
  height: 4px;
  border-radius: 2px;
  margin-top: 4px;
  transition: width 0.2s linear;
}
#connection-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  padding: 6px;
  text-align: center;
  font-size: 0.85rem;
  font-weight: 600;
  background: var(--clr-warning-amber);
  color: white; /* functionally white on amber warning bg: ~5:1 AA; --text drops to ~4.42:1 */
  z-index: 100;
  display: none;
}
#connection-banner.visible {
  display: block;
}

/* Reduced-motion accessibility */
@media (prefers-reduced-motion: reduce) {
  :root {
    --glow-blur: 0;
  }
  /* Default-safe suppression: EVERY animated element finishes instantly
     unless a more specific rule opts it into a bespoke treatment (a class
     selector beats `*` at equal !important; `animation: none` shorthand
     zeroes animation-name, which this duration-only rule can't resurrect).
     Instant-finish rather than `animation: none` so `fill-mode: forwards`
     end-states still apply — a fade-out completes (element ends hidden)
     instead of freezing at its from-state. Before this rule, suppression
     was an enumerated per-feature list (host.css reduced-motion blocks), so
     a NEW animation silently kept running for reduced-motion users; now it
     is suppressed by default.
     Caveats: (1) `animation-delay` is untouched — a delayed animation
     waits, then finishes instantly. (2) This author-!important rule BEATS
     inline styles: JS-driven motion that must survive reduced motion
     (functional time cues like #linger-bar's drain) must set its inline
     style with priority "important" and say why. */
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  /* feedback-fade's 100% keyframe fades back to opacity:0 — an instant
     single iteration would leave the turn's score/pass/undo confirmation
     invisible right when it should read. Force it to hold at full opacity
     instead. This selector used to also carry play.js's own legacy toast
     class (retired by docs/todo.md "One feedback dedup key" — both apps
     mount through the one shared wrapper now). */
  .region-toast,
  .region-toast--linger {
    animation: none !important;
    opacity: 1 !important;
  }
}

/* Higher-contrast palette when the user requests more contrast (OS "Increase
   contrast" / Safari "prefers-contrast"). Pulls dim and muted text up toward the
   primary text color and brightens borders so card and zone boundaries stay
   legible. Shared tokens, so both host and player inherit it. */
@media (prefers-contrast: more) {
  :root {
    /* Phase-5 tokens: text-dim already 10.8:1 vs --bg-2; raise further for
       non-default backgrounds (--surface, --bg-1) where contrast is tighter */
    --text-dim: #d8cfc4;
    /* Coral already passes AA vs --bg-2; boost for surface bg */
    --accent: #ff9478;
    /* An operator who asked the OS for more contrast gets the maximum-
       scannability QR and the brand tint deferred: 21:1 instead of 14.9:1. */
    --qr-dark: #000000;
    --qr-light: #ffffff;
    /* Legacy aliases */
    --text-muted: #cbc4ba;
    --border: #7d756c;
  }
  /* Strengthen the focus indicator for keyboard operators. */
  :focus-visible {
    outline: 3px solid var(--text);
    outline-offset: 2px;
  }
}

/* Card slot panel (renderCardSlotHtml) — primary content for freeform
   card-browse games (Categories, Fill in the Blanks). Shared by host + player. */
.card-panel {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 1rem 1.25rem;
  border-radius: var(--radius-card);
  background: var(--surface);
  text-align: left;
  /* Lets the panel shrink below its content's natural height when the
     .headline-fit flex ancestor genuinely doesn't have room for it (a tall
     .card-list on a short/landscape viewport) instead of overflowing past
     #phone-headline's overflow:hidden clip. A flex item's default min-height
     is its content size, which is exactly the floor that needs to give here;
     .card-panel-fill's own min-height below still wins where it applies. */
  min-height: 0;
}
.card-label {
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-dim);
}
.card-content-text {
  font-family: var(--font-card);
  font-size: clamp(1.5rem, 6vw, 3rem);
  font-weight: var(--font-card-weight);
  text-align: center;
}
/* Fill-in-the-blank slot: the renderer replaces runs of underscores with this,
   so the blank reads as a deliberate coral fill-in rule rather than serif
   underscores. Width scales with the (FitText-driven) font size via em. */
.card-blank {
  display: inline-block;
  width: 1.6em;
  height: 0.08em;
  /* In fill mode this span is a FLEX ITEM of .card-panel-fill
     .card-content-text, so the default flex-shrink: 1 let it absorb the line's
     overflow and the rule collapsed to 0-2px at phone widths — the blank IS
     the prompt, so the card then read as "butter" instead of "_____ butter".
     Desktop hid it: at 1280px the row has slack and nothing shrinks. Pin the
     width so the phrase (which FitText is already sizing to the box) gives way
     instead of the rule. Assumes ONE blank per phrase, which is what the 615
     shipped cards have: two pinned blanks could together outrun the row, and
     #phone-headline's overflow:hidden would clip rather than degrade. */
  flex-shrink: 0;
  margin: 0 0.12em;
  /* Sit the fill-in rule near the text baseline — a line you'd write the answer
     on — rather than floating mid-x-height. A rounded bar (not a border) so the
     ends are gently rounded. */
  vertical-align: baseline;
  /* Pencil-line grey, not the coral accent (owner, 2026-08-16: "The orange +
     white on black looks too much like the Blank Slate commercial game"). This
     is a trade-dress concern, not a taste one, so the replacement has to leave
     the orange family entirely — Marigold would not clear it. --text-dim reads
     as the ruled line you would actually write an answer on, which is what the
     mark is imitating anyway, and the accent stays reserved for things the
     player can act on. */
  background: var(--text-dim);
  border-radius: 0.06em;
  transform: translateY(0.04em);
}
/* Inline wrapper for a fill-mode phrase. Exists purely to give `.card-blank` an
   inline formatting context to sit in: its parent `.card-content-text` is a
   flex container in fill mode, and a flex item ignores `vertical-align`
   outright. Carries no styling of its own — the phrase's type comes from
   `.card-content-text`, and FitText still measures and scales that element. */
.card-phrase {
  display: inline;
}
/* Fill mode (Fill in the Blanks phrase, Categories list): the card IS the
   gameplay surface on phone. The panel grows to fill its parent; FitB's host
   post-render hook scales .card-content-text via FitText. The clamp size above
   is the floor while measuring; FitText overrides it on the host for string
   content. Player surfaces also benefit from the centered layout. */
.card-panel-fill {
  flex: 1;
  align-items: center;
  justify-content: center;
  text-align: center;
  /* min-height keeps the panel reasonably tall even when its parent is a
     loose column — the FITB phone view sets the parent to grow, but this is
     a defensive floor so the renderer doesn't collapse on misc layouts. */
  min-height: 240px;
}
.card-panel-fill .card-content-text {
  width: 100%;
  /* Long unbroken pseudo-words ("supercalifragilistic_____") need the same
     emergency wrap FitText's headline path uses. */
  overflow-wrap: break-word;
  /* Take exactly the column space the footer leaves, and centre the phrase in
     it. The footer is a REAL in-flow row now, so fitting the phrase to the
     whole panel would oversize it by the footer's height and the text would
     bleed over the number; the host fits the phrase to THIS box instead. */
  flex: 1 1 0;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* Single column, tight rows (2026-08-04): every card-list consumer is a
   phone in someone's hand, host and player alike — there is no larger
   display to spend width on, so the prior 2-column layout only bought
   overflow on a narrow viewport (a two-digit marker like "10." could step
   past its column edge, and a long item like "Things in a Classroom" forced
   the row wide). One column keeps every item flush left. Item text no longer
   sets or inherits a bold weight either — bolding all 12 rows left nothing
   for the eye to land on. */
.card-list {
  margin: 0;
  /* The marker sits INSIDE the item box (not the padding gutter) so a
     two-digit marker never steps past the list's left edge. */
  padding-left: 0;
  list-style-position: inside;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  font-size: clamp(0.8rem, 3.5vw, 0.95rem);
  font-weight: 400;
  line-height: 1.25;
  /* A 12-item list is content to scan, not a headline — opt out of the
     host's inherited --font-display (Bricolage Grotesque, for short chrome
     titles) the same way .card-content-text opts into its own face above.
     The grotesque display face still reads heavy at 400, which is what made
     "unbolded" not look unbolded on the host. */
  font-family: var(--font-body);
  /* Safety net, not the common case: #phone-headline clips (overflow:hidden)
     and nothing rescues this list if it doesn't fit — the host's
     shrink-to-fit sets px directly on the ancestor .headline-fit, and this
     rule's own font-size doesn't inherit that (same as .card-label and
     .card-label beside it, which has the same gap). flex:1 + min-height:0
     make the list (not a vh guess) absorb whatever space .card-panel's flex
     column actually has left after .card-label/.card-foot, at any viewport;
     overflow-y:auto keeps every row reachable by scroll on a short/landscape
     viewport (measured failing case: 390x480) instead of silently losing
     rows 7-12 to the ancestor's clip — the same tradeoff #collecting-zone
     already makes for its action buttons. 12 tight rows fit every normal
     portrait phone (measured down to 375x667) with room to spare, so the
     scroll never engages there. */
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}
/* Categories list under fill: parent .card-panel-fill centres FitB's phrase
   (align-items + text-align). Without this, the list shrinks to content
   width (~half the panel on phone) and centres each row. Same width:100%
   override .card-content-text already has; left align for scanable rows.
   Declared AFTER .card-list so extractRuleBlock(".card-list {") still
   hits the base rule (indexOf would otherwise match this selector first). */
.card-panel-fill .card-list {
  width: 100%;
  text-align: left;
  align-self: stretch;
}
/* The desktop-host font-size ceiling for this list lives in host.css, not
   here — .card-list is truly shared (region-renderers.js ships in both the
   host and player manifests), so a wide-viewport override in THIS file would
   also apply to the player app, which is never opened at a 700px+ viewport
   in practice but would make the "host-only" framing structurally false.
   Keeping it host.css-scoped makes that claim actually true, not just
   measured-true-so-far. See host.css's own comment at the override for the
   full reasoning (the clamp below is phone-primary; the vw term never
   reaches its cap on a desktop host). */
.card-list li::marker {
  /* De-emphasized relative to the item text: the number is a locator, not
     content — --text-dim is the same muted token .card-label uses for
     secondary text. */
  color: var(--text-dim);
  font-size: 0.9em;
}
/* The card's footer row — the host's rating thumbs, mounted into
   .card-foot-fb. In flow at the panel's bottom edge: the rating is about this
   card, so it belongs to the card and moves with it. `margin-top: auto` pins
   it to the bottom of a taller panel without any measurement. The padding is
   the thumbs' own breathing room and is reserved before host.js moves the row
   in, so the panel does not reflow as the thumbs arrive. */
.card-foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  margin-top: auto;
  padding-top: 0.5rem;
}
/* Mount point for the host's rating thumbs (#card-feedback-row is moved in
   here every commit). Empty — and so zero-width — on every other surface. */
.card-foot-fb {
  display: flex;
  align-items: center;
}
/* .card-foot-num is gone (owner, 2026-08-16). The row survives as the thumbs
   mount. NOT because the status region's "N of M" is the same fact — it is
   not: that is the DRAW count and this was the browse position, and they part
   company the moment you press Previous. See game-render.js's footer comment
   for the measurement and docs/todo.md for the gap it leaves. */

/* Screen-reader only content */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  /* Both spellings on purpose: clip-path is the modern one, `clip` the
     deprecated-but-universally-supported fallback older engines still honor.
     This class is what the Orca screen-reader tests read, so it keeps belt and
     braces rather than trading reach for tidiness. */
  /* stylelint-disable-next-line property-no-deprecated */
  clip: rect(0, 0, 0, 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ── Site footer ──────────────────────────────────────────────────────
   The single canonical footer (<site-footer>, ui/shared/site-footer.js):
   one line — Spraoi by Kilotronic · Terms · Privacy. The brand and links
   spans flow inline (~210px total, comfortable at a 320px viewport) and wrap
   between words if a container is narrower. Base style is a centered, muted
   block; each surface positions it via its own container rule. */
site-footer {
  display: block;
  text-align: center;
  font-size: 0.75rem;
  line-height: 1.6;
  opacity: 0.5;
}
site-footer a {
  color: inherit;
}
/* Matches kilotronic.com's own wordmark face (ui-monospace stack, roman —
   the site never sets it italic) rather than borrowing the app's brand font. */
.site-footer__brand-link {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-style: normal;
}

/* ── Overlay backdrop + dialog card ──────────────────────────────────────
   Shared chrome for every modal in both apps: host's menu/undo/timer/resume/
   scoreboard/etc AND the player's in-game menu, leave-confirm, and profile
   dialog all ride the same `.overlay-backdrop`/`.overlay-dialog` classes.
   Each surface keeps its own per-dialog size deltas (min/max-width) as a thin
   override alongside these rules.

   Every modal is a native <dialog> opened with showModal(). The element itself
   IS the full-screen scrim, not a wrapper around one, so a click landing on it
   (rather than bubbling up from the card) is unambiguously a backdrop click —
   which is what OverlayDismiss's `target === currentTarget` guard keys on.
   Resetting the UA's dialog box is what lets it be that scrim.

   `[open]` replaces the old `.visible` class: it is state the browser
   maintains, so it cannot drift from whether the dialog is actually showing. */
dialog.overlay-backdrop {
  position: fixed;
  inset: 0;
  justify-content: center;
  align-items: center;
  background: var(--scrim);
  display: none;
  border: 0;
  /* The backdrop, not each card, owns the viewport inset — every card keeps
     the same margin of air on a small screen without a per-dialog padding
     delta (the player app used to carry one per dialog id). */
  padding: 1.5rem;
  box-sizing: border-box;
  margin: 0;
  max-width: none;
  max-height: none;
  width: 100%;
  height: 100%;
  color: inherit;
  overflow: visible;
}
dialog.overlay-backdrop[open] {
  display: flex;
}
/* The dialog element paints the scrim itself; without this the UA's own
   ::backdrop layer would dim the page a second time. */
dialog.overlay-backdrop::backdrop {
  background: transparent;
}
/* One card recipe (owner, 2026-08-18 standardization): the raised --bg-card
   surface with a real border and shadow — the account-sheet / player
   "prominent" look promoted to the whole family. The retired --bg-2 fill was
   the page-background color, so a card read as a flat cutout separated from
   the page only by the scrim. Width lives HERE, on the bare class, so a
   two-class per-dialog rule (.overlay-dialog.hold-reveal-inner) or an id
   (content viewers: recap/scoreboard/rules) still outranks it. */
.overlay-dialog {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  padding: 24px;
  box-sizing: border-box;
  box-shadow: var(--shadow-lg);
  width: 100%;
  max-width: 22rem;
}
/* A block's trailing margin separates it from what follows; as the card's LAST
   child there is nothing to separate from, and the margin simply adds to the
   card's own padding. Measured on the party-code dialog the day its "Done"
   button was retired: `.intro-next`'s 18px bottom (sized to collapse with the
   `.btn--stack` that used to follow it) left 43px under the Next Step block
   against 24px above the title — a visibly bottom-heavy card. Keyed on
   last-child rather than on that one widget so the next block promoted to the
   end of a dialog does not repeat it. */
.overlay-dialog > :last-child {
  margin-bottom: 0;
}
/* The card is a focus PARK, not a control: OverlayFactory gives it
   tabindex="-1" and focuses it on open so the operator's first intent isn't
   claimed by the ✕ (see overlay-factory.js). Nothing can Tab to it, and it
   activates nothing, so the browser's own focus ring around it indicates
   nothing operable — it just drew a UA-blue rectangle around every dialog in
   an app whose entire palette is coral (button survey 2026-08-19, §5 finding
   a: "reads like a leaked :focus-visible, not an intended border"). Measured
   on the live party-code dialog: once any key has been pressed — i.e. for
   every operator driving with a keyboard or a controller — the parked card
   matches :focus-visible and computes `outline-style: auto`, the browser's
   own ring, around the whole card. The rings that mean something (the ✕, the
   buttons, the roving row cursor) are untouched; test_dialog_focus_park.py
   pins both halves. */
.overlay-dialog:focus,
.overlay-dialog:focus-visible {
  outline: none;
}

/* Menu panel skin — the avatar-sheet presentation every menu now wears
   (owner, 2026-08-18: "standardize on the chooser menu's appearance").
   Carried by the modal menu cards (alongside .overlay-dialog — declared
   after it, so the menu radius/padding win) and standalone by the anchored
   #account-sheet, which is not a dialog. 8px radius per the menu-strategy
   spec 2026-07-30; the panel owns only vertical padding so rows run
   full-bleed and the hover/focus fill reaches the panel edge — rows and the
   title carry their own 16px inline inset. */
.menu-panel {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  padding: 8px 0;
  box-sizing: border-box;
}
.overlay-dialog h3,
.player-menu-title-row {
  font-size: 14px;
  color: var(--accent);
  margin: 0 0 12px;
}
.menu-panel h3,
.menu-panel .player-menu-title-row {
  padding: 0 16px;
}

/* ── Confirm-dialog body/actions template ────────────────────────────
   Every confirm/prompt overlay (undo, action, collecting-quit,
   session-delete, timer, resume, adopt, feedback) is the same shape: the
   shared .overlay-dialog card, an <h3> title, an optional body line, and a
   stacked button column. `.dialog-body` and `.dialog-actions` give the
   body its spacing and the stack its flex layout, so a NEW dialog is laid
   out correctly just by using these classes. This replaced a set of
   per-dialog id lists that silently stranded any dialog whose id was
   forgotten — the cause of the collecting-quit/session-delete
   text-over-buttons overlap. */
.dialog-body {
  color: var(--text);
  font-size: 13px;
  line-height: 1.5;
  margin-bottom: 16px;
}
/* Stacked full-width buttons — the ONE dialog action layout (owner,
   2026-08-18: "two wide buttons on top of each other … I prefer wide
   buttons", generalizing the 2026-08-16 opt-in --stack variant to every
   dialog and retiring the side-by-side row). The affirmative sits first in
   the DOM, so it renders on top; the dismiss/cancel sits below it. */
.dialog-actions {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* The stack owns the gap above itself: in block flow this collapses with
     .dialog-body's own 16px bottom margin (one 16px gap, not 32), and it
     supplies the gap for cards whose body copy is a bare <p> with reset
     margins (player leave-confirm — review finding 2026-08-18). */
  margin-top: 16px;
  gap: 8px;
}
.dialog-actions > .btn {
  width: 100%;
  max-width: var(--cta-max);
}

/* ── Overlay close (X) ─────────────────────────────────────────────────
   The title-bar close affordance shared by the host and player overlays. */
/* A real <button> (focusable and activatable for free), styled back down to the
   bare glyph it has always looked like — without these resets it renders as a
   boxed UA button, which the <span> version never did. */
/* The GLYPH is the glyph family's (1.375rem); the TARGET is 44px. This rule
   used to set 18px of its own, which never applied — the family rule below
   names `.overlay-close` at equal specificity and later in the file, so it
   always won. Measured across all eight overlays
   (2026-08-01 sweep), this was an 18x26 hit area — the smallest control in the
   app by a factor of two, while every other tappable thing (.btn, .menu-item,
   .header-action-btn, .sb-step) honours a 44px minimum. Nothing named a rule
   for the exception, and it is the most-repeated control we ship. Centring the
   glyph in a 44px box keeps the bare-glyph look identical and only grows what
   a finger has to land on. */
.overlay-close {
  appearance: none;
  background: none;
  border: 0;
  font: inherit;
  /* Legacy fallback only: floats are ignored on flex items, so on any
     browser with :has() the flex title row below makes this inert. A
     browser WITHOUT :has() drops that rule (and its guarded margin) and
     falls back to this — the pre-2026-08-19 float layout, ✕ at the right
     edge on the title's line box — instead of an inline ✕ beside the
     title text. */
  float: right;
  cursor: pointer;
  color: var(--text-muted);
  line-height: 1;
}
/* The heading that carries the ✕ is a flex row: title left, ✕ hugging the
   right edge, glyph level with the title. The float above placed the 44px
   target against the title's LINE BOX, so the centred 18px glyph painted
   ~13px BELOW the title text (owner, 2026-08-17: "'x' dismiss in menu is
   not in the upper right either, it's below 'Menu' text").
   Membership-keyed via :has() so every dialog heading gets the row for
   free — the same shape .completed-recap-header builds by hand. */
h2:has(> .overlay-close),
h3:has(> .overlay-close) {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
/* The 44px target overhangs the title line symmetrically instead of growing
   the heading: without this every title row would gain ~27px of height
   (44px target against a ~17px line) and push each dialog's content down.
   Only the invisible hit box extends past the text. Nested under :has() so
   a browser that cannot build the flex row above never applies the margin
   to the float fallback. */
h2:has(> .overlay-close) > .overlay-close,
h3:has(> .overlay-close) > .overlay-close {
  margin-block: -14px;
}
.overlay-close:hover {
  color: var(--accent);
}
/* The menu ✕ is a roving-focus stop, not just a pointer target (menu-strategy
   spec 2026-07-30), so it wears the same accent cursor a .menu-item does. The
   ring surrounds the GLYPH — inset from the 44px box (which is bigger than
   the visible glyph and, in a tight menu panel, overhangs the panel's top
   edge; a box-hugging ring painted outside the panel there) but still fully
   outside the 18px glyph itself, unlike a row's fill-inset ring. Non-menu
   overlays never set .focused on their ✕, so their glyphs are unaffected. */
.overlay-close.focused {
  outline: 2px solid var(--accent);
  outline-offset: -8px;
  color: var(--accent);
}
/* The key-hint chip the retired Dismiss row used to teach with; it rides the ✕
   only while focused, exactly as it does on a row (.menu-btn-hint's base
   display:none is below, in the menu-row block). */
.overlay-close.focused .menu-btn-hint {
  display: inline-flex;
}

/* ── Hold-to-reveal dialog (ui/shared/hold-reveal.js) ─────────────────────
   Impostor's reveal dialog — a NAMED EXCEPTION to the hold-to-confirm
   retirement above (docs/ui-style.md, "Exception (named, 2026-08-04):
   Impostor's hold-to-reveal"): this hold gates disclosure DURATION, not
   action confirmation, so it is not the gesture that was banned.

   Anatomy: the dialog body (#hold-reveal-area) is the press-hold surface
   (name + prompt/secret + instructions). Instructions and the secret are
   only written while held. "I'm done" sits outside the hold target.
   Rides the shared .overlay-dialog card; only the parts unique to this
   dialog are declared here. */
/* .overlay-dialog.hold-reveal-inner (not the bare class): the base
   `.overlay-dialog` width (100% capped at 22rem) sits at the SAME
   specificity as a bare `.hold-reveal-inner` rule, so a one-class rule
   would win or lose on file/source order alone. Compounding the selector
   with .overlay-dialog (two classes) is what makes this dialog's own fixed
   width win regardless of link order.

   A FIXED width, not a min/max range, is the actual fix for the "jumps on
   hold" symptom: a range lets the dialog auto-size to whatever's on screen
   (short "Hold to reveal" prompt pre-hold, the full secret post-hold), which
   is the jump itself — measured 240px -> 390px at 390px viewport before this
   rule was made to win. One width fits both states without moving; 320px
   comfortably fits the longest secrets seen ("Cooking in the kitchen" at
   ~231px content width) with room to spare, and the viewport-relative cap
   keeps it from overflowing a narrow phone. */
.overlay-dialog.hold-reveal-inner {
  /* 100%, not a 100vw calc: the backdrop's own 1.5rem padding owns the
     viewport inset now, so the card just fills up to it on narrow screens. */
  width: min(320px, 100%);
  display: flex;
  flex-direction: column;
  gap: 12px;
}
/* The flex gap above already spaces "I'm done" — the stack's block-flow
   margin-top would ADD to it (no margin collapsing inside flex). */
.hold-reveal-inner .dialog-actions {
  margin-top: 0;
}
.hold-reveal-name {
  text-align: center;
  font-size: 20px;
  color: var(--text);
  margin: 0 0 8px;
}
/* The press-hold target — the dialog body minus I'm Done. No inset "box":
   the whole surface is the gesture. */
.hold-reveal-area {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* Doubled from 160px (owner, 2026-08-17: "View secret should take up more
     vertical space. increase by 2x").

     The viewport cap is the second half of the fix. Flat 320px was tried and
     rejected on measurement: fine in portrait, but at 667x375 it puts the
     card at -25..401 and takes "I'm done" off-screen entirely — and this
     backdrop is `position: fixed` with no scroll, so clipped means
     unreachable, not scrolled-to. A 45vh cap was rejected the other way, at
     300px on a 375x667 phone: quietly less than the 2x that was asked for.

     `dvh`, NOT `vh` — the unit is the whole point on a phone. `vh` resolves
     against the LARGE viewport (mobile toolbar hidden) while this fixed
     backdrop sizes to the visible one, so a `vh` floor stays put exactly
     when the container shrinks and reintroduces the clip above. Same reason
     host.css:24 and play.css:10 use `100dvh`; a desktop browser cannot show
     the difference, which is how the first draft of this rule passed its own
     measurements with the wrong unit.

     55, not 60: the card is the area plus ~106px of name/gaps/button, inside
     a backdrop with a 1.5rem inset, so it fits while 0.55H + 154 <= H, i.e.
     H >= 342 — clear of a 375px landscape. 60dvh breaks even at 385 and
     leaves nothing in hand. Measured at 375x667 (full 320px), 375x470
     (258px) and 667x375 (206px). */
  min-height: min(320px, 55dvh);
  margin: 0;
  border-radius: var(--radius-md);
  background: transparent;
  cursor: pointer;
  text-align: center;
  padding: 8px 4px 12px;
  box-sizing: border-box;
  transition: background 0.15s;
  flex: 1 1 auto;
  width: 100%;
  /* Same trio `.roster-row` carries below, for the same reason and on the same
     surface: play.css has no body-level user-select opt-out (host.css does), so
     a long press over this text opens the iOS text-select callout. Here the
     cost is worse than a stray selection — the callout fires `pointercancel`,
     which hold-reveal.js maps straight to hideSecretNow, so the secret vanishes
     mid-hold.

     Exactly the trio, and deliberately NOT `touch-action: none`. That was here
     briefly to stop a drifting finger from being read as a scroll, but this
     element is the whole dialog body (`width: 100%; flex: 1 1 auto`), and
     `touch-action: none` suppresses pinch-zoom along with panning — so a
     low-vision player could not magnify the one word they are holding the
     phone to read. Review caught it (2026-08-16). Drift-cancel robustness, if
     a real device shows it is needed, belongs in hold-reveal.js as pointer
     capture or a movement threshold; neither costs zoom. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
.hold-reveal-area:hover,
.hold-reveal-area:focus-visible {
  background: color-mix(in srgb, var(--accent) 12%, var(--surface));
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* An animation cue while revealed (spec: "shows the secret with an
   animation cue") — a brief scale/opacity settle, not a persistent pulse
   that would draw a neighbor's eye to a screen mid-hold. */
.hold-reveal-area.is-revealed {
  background: color-mix(in srgb, var(--accent) 18%, var(--surface));
  animation: hold-reveal-in 0.15s ease-out;
}
@keyframes hold-reveal-in {
  from {
    opacity: 0.4;
    transform: scale(0.97);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}
/* The release fade (owner, 2026-08-17). hold-reveal.js adds .is-releasing on
   a deliberate release and keeps .is-revealed on for LINGER_MS (2000ms)
   before wiping the text, so both classes are live through the fade — this
   block must therefore come AFTER .is-revealed above, which it ties with on
   specificity (two classes each) and beats on source order alone.

   1.8s, deliberately INSIDE the 2000ms JS window: the wipe then lands on
   text that is already invisible instead of cutting the fade short. The
   transitions live only on this class, so a re-hold that removes it snaps
   the secret straight back to full opacity rather than fading it in. */
.hold-reveal-area.is-releasing {
  background: transparent;
  transition: background 1.8s ease-in;
}
.hold-reveal-area.is-releasing .hold-reveal-secret,
.hold-reveal-area.is-releasing .hold-reveal-instructions {
  opacity: 0;
  transition: opacity 1.8s ease-in;
}
/* Opt out of the global reduced-motion suppression above, using the escape
   hatch that block's own comment defines for functional time cues (its cited
   precedent is #linger-bar's drain). Without this, `transition-duration:
   0.01ms !important` collapses the fade and a reduced-motion player gets the
   old instant hide — while hold-reveal.js still holds the text for the full
   LINGER_MS. That pairing is strictly the worst one: blank screen, secret
   still in the accessibility tree for two more seconds, which is the opposite
   of what ui-style.md promises. An opacity ramp is not vestibular motion, so
   honouring it costs nothing the preference exists to protect. */
@media (prefers-reduced-motion: reduce) {
  .hold-reveal-area.is-releasing,
  .hold-reveal-area.is-releasing .hold-reveal-secret,
  .hold-reveal-area.is-releasing .hold-reveal-instructions {
    transition-duration: 1.8s !important;
  }
}
.hold-reveal-prompt {
  color: var(--text-muted);
  font-size: 14px;
}
.hold-reveal-area.is-revealed .hold-reveal-prompt {
  display: none;
}
.hold-reveal-secret {
  display: none;
  font-size: 22px;
  font-weight: 600;
  color: var(--text);
  overflow-wrap: break-word;
}
.hold-reveal-area.is-revealed .hold-reveal-secret {
  display: block;
}
/* Instructions only appear while held (JS also clears the text off-hold so
   the role copy cannot leak via the accessibility tree). Scrollable so a
   long grid-mode block never grows the dialog off-screen. */
.hold-reveal-instructions {
  display: none;
  max-height: 140px;
  overflow-y: auto;
  padding: 8px 0 0;
  color: var(--text);
  font-size: 13px;
  line-height: 1.5;
  width: 100%;
}
.hold-reveal-area.is-revealed .hold-reveal-instructions {
  display: block;
}

/* ── Impostor roster rows (ui/shared/impostor-roster.js) ──────────────────
   Rendered identically by the host console and the player app (PR 9 Task
   9.3 mount) — modeled on the player app's prompt-list row (play.css's
   .prompt-row/.icon-btn), which is why this rides the same shared list/row/
   icon-btn shapes rather than a per-surface duplicate. Screen-specific
   layout (the zone/panel around the list) stays in host.css/play.css. */
.impostor-roster-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
/* Lobby lists scroll with the page/zone (QR runway padding keeps the
   overlay clear). Nested max-height + sibling fade were deleted — they
   wasted viewport under short parties and fought page scroll.

   The GAMEPLAY roster is different: host.css bounds it to the on-screen
   stage (`[data-region-roster] { overflow-y: auto }` — the display must
   read at a glance, not require page-scrolling like the lobby), so an
   overflowing gameplay roster needs its OWN affordance. Repro'd 2026-08-09:
   13 players clipped the list to 3 of 13 rows visible with no scrollbar and
   no d-pad/remote scroll path. `.roster-scroll-fade` mirrors the deleted
   lobby fade's own mechanism (and play.css's still-live
   `.prompt-list-scroll-fade`) — `position: sticky; bottom: 0` pinned inside
   the SAME scrolling element, `margin-top` cancelling its own `height` so it
   never inflates `scrollHeight` (no false "more below" once actually
   scrolled to the end). renderList (impostor-roster.js) appends it as the
   list's own last child and toggles `.has-scroll-more` on the list itself
   from a real `scrollHeight > clientHeight` read, so it only ever shows when
   there truly is more to scroll to — never on a short roster (data-driven,
   not a fixed always-on band, which is exactly what the lobby's OWN deleted
   fade got wrong under short parties). Scoped to `[data-region-roster]` only
   — the lobby list is unbounded (scrollHeight never exceeds clientHeight
   there), so `.has-scroll-more` never engages on it and this rule is inert
   there by construction, not by a second selector fork. */
.impostor-roster-list[data-region-roster] .roster-scroll-fade {
  display: none;
  /* Every `.roster-row` is flex-shrink:0 now (see that rule's own comment),
     which left this element the ONLY flex-shrink:1 (the default) item in
     the list once content overflows — the flex algorithm shrank it to 0
     height to absorb the whole deficit by itself (caught live: Playwright's
     to_be_visible() reported it "hidden" with a real overflowing roster).
     It must not participate in the squeeze either. */
  flex-shrink: 0;
  position: sticky;
  bottom: 0;
  height: 1.5rem;
  margin-top: -1.5rem;
  background: linear-gradient(to bottom, transparent, var(--bg-2));
  pointer-events: none;
}
.impostor-roster-list[data-region-roster].has-scroll-more .roster-scroll-fade {
  display: block;
}
/* Composer-top Add row above the lobby roster (host + player). Host keeps
   .composer-add-btn* coral affordance in host.css; player uses a text Add. */
.impostor-lobby-add-row {
  display: flex;
  gap: 8px;
  align-items: center;
  margin: 0 0 8px;
}
.impostor-lobby-add-row .text-input {
  flex: 1;
  min-width: 0;
}
/* The one-line how-to above the roster (owner feedback 2026-08-05) — shared
   because both surfaces render the identical line above the identical list. */
.impostor-roster-instructions {
  color: var(--text-dim);
  font-size: 0.9rem;
  margin: 0 0 10px;
}
.roster-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 0.7rem;
  min-height: 44px;
  /* Repro'd 2026-08-09 (todo.md's roster-spill entry): the GAMEPLAY roster's
     bounded ancestor (host.css's `[data-region-roster] { overflow-y: auto }`)
     is a flex column, and a flex item's default flex-shrink:1 lets the flex
     algorithm squeeze every row down to its 44px min-height floor once total
     row content exceeds the container — collapsed rows tolerate that, but an
     EXPANDED row's second `.roster-row-actions` strip still wants its own
     44px, so the squeeze leaves the actions strip rendering 35px+ past the
     row's own (shrunk) box, into the next row's hit-test area
     (`elementFromPoint` on an action button returns the SIBLING `<li>`, and a
     real click 404s with "roster-row intercepts pointer events" — measured
     at 6 players, 1280x720). flex-shrink:0 keeps every row at its true
     content height, so `overflow-y:auto` on the list does the actual
     overflow handling (scroll) instead of the flex algorithm silently
     stealing space from rows that can't afford to give it up. */
  flex-shrink: 0;
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius-xs);
  /* D1 (2026-08-07/08) moved the tap target from a <button> (never
     selectable text) to this <li>/its plain <span> name — host.css has a
     body-level user-select:none opt-out, but play.css (touch-first, needs
     text selection elsewhere) has none, so a long-press on a player's
     roster row opened the iOS text-select callout instead of expanding it.
     Scoped here rather than body-wide on either surface. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
.roster-row.is-accused {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
}
/* Owner feedback 2026-08-07/08: the whole row is the tap target for
   gameplay rows (impostor-roster.js), not just the centered name text — the
   row itself carries role="button"/tabIndex now, so this only needs the
   pointer affordance (focus-visible ring already comes from the app-wide
   :focus-visible rule). */
.roster-row--expandable {
  cursor: pointer;
}
/* Name + optional role + eye. GAMEPLAY rows' (expandable) actions ride
   .roster-row-actions below so expand chips never shrink the name into a
   letter stack; LOBBY rows' edit/delete instead append directly into this
   flex row so they stay on the name's own line (owner bug, filed
   2026-08-08 — see impostor-roster.js renderList's gameplay/!gameplay
   branch). */
.roster-row-main {
  display: flex;
  flex: 1 1 auto;
  min-width: 0;
  align-items: center;
  gap: 0.4rem;
}
.roster-row-actions {
  flex: 1 0 100%;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
}
.roster-row .row-text {
  flex: 1;
  min-width: 0;
  font-weight: 600;
  overflow-wrap: break-word;
}
.roster-row .viewed-flag {
  flex: none;
  font-size: 1rem;
  line-height: 1;
  letter-spacing: 0;
  text-transform: none;
}
.roster-row .viewed-flag.is-viewed {
  color: var(--text);
}
.roster-row .viewed-flag.is-unviewed {
  color: var(--text-dim);
  opacity: 0.55;
}
.roster-row .revealed-role {
  font-size: 0.8rem;
  font-weight: 700;
  padding: 0.1rem 0.5rem;
  border-radius: var(--radius-xs);
}
.roster-row .revealed-role.is-impostor {
  /* --c-negative ("verb: no") is tuned for text-on-dark contrast — the right
     token for colored TEXT on a tinted background, unlike --c-danger-fill
     (a solid destructive-button fill, too dark to read here). */
  color: var(--c-negative);
  background: color-mix(in srgb, var(--c-negative) 15%, transparent);
}
.roster-row .revealed-role.is-civilian {
  color: var(--clr-scored);
  background: color-mix(in srgb, var(--clr-scored) 15%, transparent);
}
/* The action buttons render visible TEXT ("View Secret"/"Accuse"/"Reveal
   role"), not a glyph — .icon-btn's shared 44px/22px glyph geometry
   (shared.css, the FIVE CONTROL FAMILIES section) is still the right
   touch-target floor, but its fixed square sizing is wrong for a text
   label. Widen and let the label size its own font here, on
   `.roster-action` (NOT `.icon-btn` itself — host-css.test.js's "no
   family member re-declares the geometry its family owns" guard pins
   every glyph-family member's OWN rule, and this selector is a different
   one).

   DESCENDANT selector, deliberately (2026-08-05 fix): a bare `.roster-action`
   rule here LOSES to the `.icon-btn` family rule further down this same file
   — equal single-class specificity, later source order — so every
   declaration below was inert on the host console (measured live: bare text,
   no border, transparent). The player app only looked right because play.css
   loads after shared.css and re-styles `.icon-btn` for its prompt rows.
   Scoping under the row that owns these buttons out-specifies the family
   rule without reordering the file or touching the family rule itself; any
   edit here must keep it a descendant (host-css.test.js pins this). */
.roster-row .roster-action {
  width: auto;
  height: auto;
  padding: 0.35rem 0.7rem;
  font-size: 0.8rem;
  font-weight: 600;
  border: 1px solid var(--border);
  border-radius: var(--radius-xs);
  color: var(--text);
  background: var(--bg-2);
}
.roster-row .roster-action.roster-accuse {
  color: var(--accent);
  border-color: var(--accent);
}
/* Present-but-disabled Accuse (spec §Active gameplay screen: "disabled/
   grayed until that player has completed a reveal"; owner decision
   2026-08-05 — the control renders on every row, unusable rather than
   absent, so its location is learned consistently). Same "one disabled
   model" the app already uses everywhere else (`.btn:disabled`/
   `.hw-btn:disabled`: opacity-dim, not a color/background swap) — kept a
   descendant selector for the same specificity reason as `.roster-action`
   above: a bare `.roster-action:disabled` rule here would still lose to the
   `.icon-btn`/`button:disabled` family rules further down/in play.css. */
.roster-row .roster-action:disabled {
  opacity: 0.35;
  cursor: default;
}
/* Glyph-labelled actions (edit ✎ / remove ✕) keep the family's square
   geometry — the same look as the prompt list's row icons they were adopted
   from (play.js renderPromptList) — so only the TEXT actions widen into
   bordered chips.

   `border-radius` is reset explicitly (2026-08-19 button survey §4 item 2):
   the text-chip rule above sets the ROW corner (--radius-xs, 4px), and this
   rule used to reset only border/padding/background — so a glyph that
   belongs to the 14px glyph family rendered at 4px. Invisible while the
   glyph is transparent at rest; visible the moment either gains a fill. */
.roster-row .roster-action.roster-edit,
.roster-row .roster-action.roster-delete {
  width: auto;
  height: auto;
  min-width: 44px;
  padding: 0;
  font-size: 1.1rem;
  border: none;
  border-radius: var(--radius-btn);
  background: transparent;
  color: var(--text-dim);
}

/* Thin space between party-code halves — excluded from copy-selection so
   copying "BCD FGH" puts "BCDFGH" on the clipboard. (The per-surface
   .code-part letter-spacing stays in host.css / play.css.) */
.code-part-tail::before {
  content: "\2009";
}

/* Wave 5: QR overlay skin lives here (not host.css) so the player lobby
   mount shares the same bottom-anchored rules. */
/* ── Bottom-anchored party-code pill (ui/shared/qr-overlay.js) ──────
   The one lobby/chooser party-code affordance: a pill showing the scaled QR
   and the code text; tapping anywhere on it opens the canonical party-code
   DIALOG (owner, 2026-08-18: one party-code presentation — the expanded
   cream card and its expand/minimize state machine are retired). Scoped to
   the .qr-overlay CLASS every mount carries — never a mount id (2026-08-05
   fix; host-css.test.js pins the class scoping). No ancestor between the
   mounts and the viewport sets a transform/filter/contain, so `fixed`
   resolves against the viewport despite the DOM nesting. Centered up to the
   lobby card's own 480px cap so it never stretches edge-to-edge. */
.qr-overlay {
  position: fixed;
  left: 50%;
  bottom: 0;
  transform: translateX(-50%);
  width: 100%;
  max-width: 480px;
  padding: 0 16px;
  /* iPad/iPhone home-indicator gap — the inset is 0 on devices without a
     home bar, so this is a no-op there. */
  padding-bottom: max(16px, env(safe-area-inset-bottom));
  z-index: 5; /* above screen-zone content; real dialogs stack in the UA top layer regardless */
  /* The wrapper spans the full width so its child can center within it, but
     only the pill inside should catch clicks — nothing between them may
     swallow a tap meant for the panel's own content. */
  pointer-events: none;
}
/* FLOATING OVERLAY — the family for a control that floats OVER content rather
   than sitting in the layout: the enable-sound chip pinned to the top edge and
   the collapsed party-code chip pinned to the bottom. The family owns the
   GEOMETRY (floor, corner, ground, hairline) the way every other family in
   docs/ui-style.md does; each member keeps only what its own job needs —
   position, padding, type, the direction its shadow falls, and in the
   enable-sound chip's case a louder border (see host.css). `.audio-unlock-btn`
   is declared in host.css but named here, exactly as the text-link family
   names `.rules-view-online`: the family rule is the point, not the file. */
.audio-unlock-btn,
.qr-overlay .qr-overlay-card {
  min-height: 44px;
  border-radius: var(--radius-pill);
  background: var(--bg-card);
  border: 1px solid var(--border);
}
/* The pill wears the app's card chrome (--bg-card/--border/shadow, the same
   family as .menu-panel and .overlay-dialog — 2026-08-18 standardization),
   not the old cream paper: only the QR itself needs a light field, and it
   gets one on its own tile below. */
.qr-overlay .qr-overlay-card {
  pointer-events: auto;
  position: relative;
  display: flex;
  flex-direction: row;
  align-items: center;
  width: fit-content;
  max-width: 100%;
  margin: 0 0 0 auto;
  /* Shadow points UP: this chip is anchored to the bottom edge, where the
     enable-sound chip is anchored to the top and casts downward. Opposite
     edges, same family — the geometry above is what they share. */
  box-shadow: 0 -2px 10px var(--scrim-subtle);
  /* Right padding reserves the chevron knob's lane so the code text never
     sits under it: 44px box + its 10px inset from the card edge + 4px air.
     The LEFT inset is set by the pill's curve, not by taste: on a 66px-tall
     pill the radius is 33px, so at the QR tile's top and bottom edges the
     card's own boundary has already travelled ~17px inward — a 4px inset put
     the tile's corners outside it and `overflow: hidden` sliced them off
     (measured 2026-08-20, when this chip joined the floating-overlay role).
     16px clears the curve at the tile's corners. */
  padding: 4px 58px 4px 16px;
  overflow: hidden;
}
/* The QR keeps a light tile behind its modules — phone cameras expect dark
   modules on a light field (same reasoning as .partycode-qr in the dialog:
   brand-tinted, never brand-inverted) — the one place the light paper
   survives on the pill. */
.qr-overlay .qr-overlay-qr {
  line-height: 0;
}
.qr-overlay .qr-overlay-qr .qr-code {
  background: var(--qr-light);
  border-radius: var(--radius-xs);
}
.qr-overlay .qr-overlay-qr .qr-code svg {
  width: 56px;
  height: 56px;
  display: block;
}
.qr-overlay .qr-overlay-qr .qr-code svg rect {
  fill: var(--qr-light);
}
.qr-overlay .qr-overlay-qr .qr-code svg path {
  fill: var(--qr-dark);
}
/* Code text: readable across a table, clearly subordinate to the dialog's
   own code box. A <span>, not a control — the pill has ONE action (open the
   dialog) and the open button below owns every tap. */
.qr-overlay .qr-overlay-code {
  padding: 0 0 0 10px;
  color: var(--text);
  font-family: var(--font);
  font-size: 22px;
  font-weight: bold;
}
/* PartyCode.populate appends a share glyph inside the code slot, but the
   pill's one action is opening the dialog — showing the share affordance
   here would lie. Share stays on the dialog's code box. */
.qr-overlay .qr-overlay-code .partycode-share-glyph {
  display: none;
}
/* The open BUTTON is the whole pill (inset: 0) — a tap anywhere opens the
   party-code dialog. The ▴ knob is an indicator painted inside that larger
   hit area, drawn as a clear rectangular button like the card's other
   controls (owner 2026-08-09 + 2026-08-16 knob decisions, carried over from
   the retired expand chevron). */
.qr-overlay .qr-overlay-open {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  padding-right: 10px;
  border: 0;
  border-radius: inherit;
  background: none;
  cursor: pointer;
  color: var(--text-dim);
}
/* Plain Unicode glyph, not the Material Symbols icon font: that font is
   subsetted per-glyph (scripts/subset_material_symbols.py) and this chevron
   is not in it — an un-subsetted codepoint renders as tofu. */
.qr-overlay .qr-overlay-open::before {
  content: "\25b4"; /* ▴ black up-pointing small triangle */
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 44px;
  height: 44px;
  border: 1.5px solid var(--border);
  border-radius: var(--radius-btn);
  font-size: 28px;
  line-height: 1;
}
.qr-overlay .qr-overlay-open:hover::before,
.qr-overlay .qr-overlay-open:focus-visible::before {
  background: var(--surface);
  color: var(--text);
}
/* Real outline (WCAG 2.4.7) on top of the knob's hover/focus fill. */
.qr-overlay .qr-overlay-open:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
/* ── Party-code dialog content (host #partycode-overlay + player copy) ──
   The ONE expanded party-code presentation (owner, 2026-08-18) — both apps
   mount a #partycode-overlay dialog whose content rides these classes, so
   the styles live here rather than per-app (moved from host.css when the
   player app gained the dialog). */
#partycode-inner {
  max-width: 380px;
  text-align: center;
}
/* Title row splits the label and the close X across the width. */
#partycode-inner h3 {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
/* partycode-value is a <button>: tapping SHARES the join link (the appended
   .partycode-share-glyph signals it). Reset button baseline first, then
   restore the original display appearance. */
.partycode-value {
  display: block;
  position: relative;
  width: 100%;
  background: var(--surface);
  border: 1.5px solid var(--border);
  border-radius: var(--radius-btn);
  cursor: pointer;
  font-family: var(--font);
  /* 32px → 26px (owner, 2026-08-02 playtest: "the join code can be a bit
     smaller"). Still the loudest text in the block, just not shouting. */
  font-size: 26px;
  font-weight: bold;
  color: var(--text);
  /* Symmetric inline reserve for the hung share glyph (rule below), so the
     code's center stays the box's center instead of being pushed left by an
     inline icon — the 2026-08-02 push-vs-hang fix. */
  padding: 8px 44px;
  margin-bottom: 4px;
  text-align: center;
  transition: border-color 0.15s;
}
.partycode-value:hover,
.partycode-value:focus-visible {
  border-color: var(--accent);
  outline: none;
}
.partycode-value .code-part {
  letter-spacing: 0.15em;
}
/* The share glyph rides the code box, visually quieter than the code itself
   so it reads as "this box does something", not part of the code. Hung at
   the right edge and vertically centered rather than sharing the code's
   inline run — inline it sat on the code's baseline (optically low against
   the tall bold digits) and pushed the code off the box's center. */
.partycode-share-glyph {
  position: absolute;
  right: 14px;
  top: 50%;
  transform: translateY(-50%);
  font-size: 20px;
  color: var(--accent);
}
.partycode-hint {
  font-size: 13px;
  color: var(--text-dim);
  text-align: center;
  margin: 0 0 10px;
  /* Honors the authored "\n" in chrome_partycode_hint so the two ways in sit on
     their own lines (still wrapping normally beyond that). `pretty` is the
     backstop for narrow viewports where the second line wraps again: it avoids
     leaving a one-word last line, which is how "code:" got orphaned. */
  white-space: pre-line;
  text-wrap: pretty;
}
.partycode-copy-hint {
  font-size: 11px;
  color: var(--accent);
  min-height: 1.4em;
  margin-bottom: 6px;
  text-align: center;
  /* Visibility toggled by JS; aria-live announces to screen readers. */
}
/* Sits directly under the QR it spells out (owner, 2026-07-29). */
.partycode-url-link {
  display: block;
  font-size: 12px;
  color: var(--text-muted);
  margin: 8px 0 12px;
  word-break: break-all;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.partycode-url-link:hover,
.partycode-url-link:focus-visible {
  color: var(--accent);
  outline: none;
}
.partycode-qr {
  display: flex;
  justify-content: center;
}
/* Join QR — brand-TINTED, not brand-inverted (see --qr-dark/--qr-light in
   shared.css): plum ink on cream, which stays dark-on-light the way phone
   cameras expect. Brand accent must never touch a module — at 2.8:1 on cream it
   would not decode — so the brand reads here through the cream paper itself.
   No accent ring: at 2px on a charcoal card it read as an error/focus state
   (owner, 2026-07-30), and the cream against --surface is already a 14.4:1
   boundary, so the outline was decoration doing no work. */
.partycode-qr .qr-code {
  background: var(--qr-light);
  border-radius: var(--radius-card);
  padding: 6px; /* card breathing room; the quiet zone is inside the svg */
  line-height: 0; /* drop the inline-svg baseline gap */
}
/* The generator hardcodes fill="white"/fill="black" as PRESENTATION attributes,
   which any CSS rule outranks — so the recolor needs no change to qr-code.js. */
.partycode-qr svg rect {
  fill: var(--qr-light);
}
.partycode-qr svg path {
  fill: var(--qr-dark);
}
/* Spans modules + the 4-module quiet zone, so it is wider than the old
   modules-only 180px at the same ~6px module pitch. Physical size, not color, is
   the binding scannability constraint for a phone across a room. */
.partycode-qr svg {
  width: 224px;
  height: 224px;
  display: block;
}

/* ── Menu row (.menu-item) ───────────────────────────────────────────────
   The one menu-row implementation, shared by host (collecting-menu config
   rows + in-game menu + account sheet) and player (in-game menu). Metrics
   are rem, not px (2026-08-18 standardization — formerly a player-only
   delta), so rows respect OS font scaling on every surface; the 1rem inline
   inset pairs with .menu-panel's full-bleed `padding: 8px 0`. Host keeps
   only its stepper-row rules in host.css. */
.menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: none;
  border: none;
  border-radius: var(--radius-xs);
  padding: 0.6rem 1rem;
  text-align: left;
  /* >=44px tap target, matching the no-controller hint-pill convention. */
  min-height: 44px;
  box-sizing: border-box;
  color: var(--text);
  cursor: pointer;
  font-size: 0.9rem;
  transition: background 0.1s;
  position: relative;
  overflow: hidden;
}
/* Quiet row (menu-strategy spec 2026-07-30): no per-row box. Hover/roving-
   .focused is an accent-tint fill, plus a visible outline (not border, so
   the ring doesn't shift row geometry) so the projector cursor stays
   legible. */
.menu-item:hover,
.menu-item.focused {
  background: color-mix(in srgb, var(--accent) 15%, var(--surface));
}
.menu-item.focused {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
.menu-item:active {
  background: var(--accent);
  color: var(--bg-2); /* dark text on coral flash */
}
.menu-btn-hint {
  display: none;
  margin-left: 8px;
  flex-shrink: 0;
}
.menu-item.focused .menu-btn-hint {
  display: inline-flex;
}

/* ── Menu trigger (one hamburger treatment, spec 2026-07-30) ─────────────
   Quiet dim glyph; hover/focus grows a contained box. Shared by host
   (collecting panel, gameplay header menu+pause pair) and player (all three
   screens). Surfaces keep positional deltas (absolute placement, margin
   cancellation) in their own sheets. */
/* GLYPH BUTTON — a control whose entire label is one symbol: the hamburgers,
   the overlay ✕, the feedback thumbs, the account avatar, the profile pencil,
   the composer's ⬆, the player rows' ✎/✕, the resume card's dismiss. One
   target (44x44) and one corner for all of them; only the glyph's own size and
   the member's skin (fill, colour, position) vary. This spec is not new — it is
   what `.menu-trigger` already applied to three hamburgers while eight other
   glyph buttons drifted across five heights and four radii (2026-08-01 metrics
   sweep). Members must not re-declare geometry; guarded in
   ui/web/host/host-css.test.js. */
.menu-trigger,
.overlay-close,
.fb-btn,
.avatar-btn,
.header-action-btn,
.collecting-menu-btn,
.resume-card-dismiss,
.icon-btn,
.composer-add-btn {
  /* `max(44px, …)`, not a bare 44px: with `line-height: 1` the line box is
     exactly 1em, so a plain `min-height: 44px` stops growing at the moment the
     glyph reaches 44px and the floor silently becomes a CEILING — the ink
     (~1.18em for these characters) then paints outside the box, and anything
     calibrated inside it goes with it (`.overlay-close.focused`'s inset ring
     crossed the ✕; `.menu-trigger`'s hover fill no longer covered its glyph).
     Measured at root 32px: 44×44 box with 44×52 ink before, 66×66 with 44×52
     after. Provably inert at the default root — 1.5em is 33px at the family's
     1.375rem, well under the floor — so this changes nothing until OS text
     scaling is actually raised. The 44px FLOOR is unchanged and still px: it
     is a fingertip. */
  min-width: max(44px, 1.5em);
  min-height: max(44px, 1.5em);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: transparent;
  border: none;
  border-radius: var(--radius-btn);
  color: var(--text-dim);
  font-size: 1.375rem;
  line-height: 1;
  cursor: pointer;
  touch-action: manipulation;
}
.menu-trigger:hover,
.menu-trigger:focus-visible {
  background: var(--bg-2);
  color: var(--text);
}
.menu-trigger:active {
  color: var(--text);
}

/* STEPPER — the round knob that adjusts a number: the scoreboard's and the
   timer overlay's − / +, and the pre-play menu's ◀ / ▶. One knob, one size
   (2026-08-01 owner decision); the glyph inside it varies. Skin (fill, border,
   focus colour) stays with the member.

   The third member was `.config-row .config-arrow` until 2026-08-19 — a
   selector nothing rendered (the config screen's rows are native fields), so
   the family claimed a member it did not own while the real arrows ran a
   second 36 × 36 square implementation in host.css. */
.sb-step,
#timer-overlay-stepper button,
.collecting-menu-stepper .config-arrow {
  flex: none;
  width: 44px;
  height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  /* px, deliberately, against the rem rule the other families follow. Unlike
     the glyph family above, this box is FIXED (`width`/`height`, one knob one
     size — 2026-08-01 owner decision), so it cannot absorb a larger glyph, and
     two of the three members paint a FILL (`background: var(--surface)` on
     `.sb-step` and the timer stepper) that the glyph would then hang outside
     of. Measured on the rendered timer stepper: at root 32px the rem form put
     26×49 of ink in a 44×44 filled circle, at 48px 39×72 — the − / + spilling
     onto the number between them, which is the value the host is adjusting.
     Named in both guards' px-by-design lists. */
  font-size: 20px;
  cursor: pointer;
}

/* TEXT LINK — reads as prose, not as chrome: "Full rules ▸", the player's
   sign-out, the share note's inline action, the rules overlay's "View
   online ▸". Deliberately outside the button rules (no target floor, no
   corner, no fill) — but one treatment between them, so inline actions can't
   wear different sizes. .rules-view-online was 13px before joining this
   family (now 15px, owner call 2026-08-02) — its own hover/focus-visible
   stays separate (underline, not the family's color shift) since it's the
   one member that shows an underline affordance. */
.text-link,
.intro-full-rules-btn,
.transition-rules-btn,
.inline-btn,
.signout-btn,
.rules-view-online {
  background: none;
  border: none;
  padding: 0;
  font-family: var(--font);
  font-size: 0.9375rem;
  font-weight: 600;
  line-height: 1.2;
  color: var(--accent);
  cursor: pointer;
}
.text-link:hover:not(:disabled),
.intro-full-rules-btn:hover:not(:disabled),
.transition-rules-btn:hover:not(:disabled),
.inline-btn:hover:not(:disabled),
.signout-btn:hover:not(:disabled) {
  color: var(--text);
}
/* The family had no disabled treatment because no member was ever disabled —
   until `#profile-export-btn` joined it (2026-08-20) and `runDataExport` holds
   it disabled for the whole round trip. Without this the link kept its full
   accent colour, its pointer cursor and its hover shift while every click was
   swallowed: a control that looks live and is not. Same model as `.btn`'s
   (`opacity` + `cursor`), which is right here for the same reason it is right
   there — a light label on a transparent ground fades WITH its page and keeps
   its separation, so only the filled roles need the fill-dim exception. */
.text-link:disabled,
.intro-full-rules-btn:disabled,
.transition-rules-btn:disabled,
.inline-btn:disabled,
.signout-btn:disabled,
.rules-view-online:disabled {
  opacity: 0.35;
  cursor: default;
}

/* EMPTY REGIONS COLLAPSE — a region with nothing rendered into it takes no
   space, rather than reserving an empty band. One rule for every collapsible
   region across host.css and play.css (an ID/class-level :empty selector
   always outranks a plain same-selector rule regardless of load order, so
   consolidating here is safe even though the per-page rules load after this
   file). */
#header-timer-value:empty,
#phone-subheader:empty,
#phone-cta:empty,
#phone-status:empty,
#phone-hints:empty,
#gamepad-panel:empty,
.menu-session-id:empty,
#gameplay-feedback:empty,
#gameplay-content:empty,
#gameplay-timer:empty,
#gameplay-counter:empty,
#gameplay-turninfo:empty,
#gameplay-dice:empty,
.char-counter:empty {
  display: none;
}

/* SCREEN ZONE — the host's setup-flow full-bleed panels (chooser, collecting,
   intro, config) share one hide/show + scroll recipe: flex:1 so the zone
   fills the display column, min-height:0 so it can shrink to scroll instead
   of forcing the column taller, hidden until `.visible` is toggled (host.js
   drives this off one SCREENS table), momentum-scrolling on iOS. Each screen
   still owns its own layout details (padding, flex-direction, gap) in
   host.css — this covers only the shape all four share verbatim.
   #display-zone (gameplay stage) and #controls-zone (bottom action bar) are
   deliberately NOT part of this: the former clips instead of scrolling
   (`overflow: hidden`, never `auto`) and the latter never grows or scrolls
   at all, so folding either in would need override declarations that add
   more code than the shared class removes — they keep their own standalone
   rules in host.css. */
.screen-zone {
  flex: 1;
  min-height: 0;
  display: none;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
.screen-zone.visible {
  display: flex;
}
