/* 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 {
  /* 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 */
  --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;
  --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 (#14110f)
     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;

  /* Legacy aliases — kept for shared components not yet migrated */
  --bg-primary: var(--bg-1);
  --bg-card: var(--surface);
  --bg-input: var(--surface);
  --text-primary: var(--text);
  --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 */
  --border-accent: var(--surface);
  --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;

  /* 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` / `--ghost` / `--surface` / `--danger`) and
   `--sm` sets the one remaining compact size tier (`--lg` and `--dialog`
   were both retired — #1740, 2026-08-01 — a dialog button is a standard
   44px/r14 button now, same as every other non-gameplay control). 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;
  padding: 10px 18px;
  font-family: var(--font);
  font-size: 18px;
  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);
}
/* Secondary — accent-outlined (Share). */
.btn--secondary {
  background: var(--surface);
  border: 1.5px solid var(--accent);
  color: var(--accent);
  font-weight: 600;
}
.btn--secondary:active {
  background: color-mix(in srgb, var(--accent) 15%, var(--surface));
}
/* Neutral — muted transparent outline (config Back, Add my entries). */
.btn--neutral {
  background: transparent;
  border: 1px solid var(--text-dim);
  color: var(--text-dim);
}
.btn--neutral:active {
  background: color-mix(in srgb, var(--text-dim) 15%, var(--surface));
}
/* Surface — the filled neutral dismiss/secondary inside overlay dialogs (as
   opposed to --neutral's transparent outline used on the setup screens). */
.btn--surface {
  background: var(--surface);
  border: 1px solid var(--bg-1);
  color: var(--text);
}
.btn--surface:hover {
  background: var(--bg-1);
}
/* 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`, currently defined only in host.css's `:root` block; a future
   player-app user of this class needs that token in scope too. */
.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 {
  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: #b45309; /* amber warning; deliberate semantic one-off — no amber token */
  color: white; /* functionally white on amber warning bg (#b45309): ~5:1 AA; --text (#f3ecff) 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;
    --border-accent: #4a78b0;
  }
  /* 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;
  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;
  background: var(--accent);
  border-radius: 0.06em;
  transform: translateY(0.04em);
}
/* Fill mode (Fill in the Blanks): the phrase IS the gameplay surface — readable
   across the room. The panel grows to fill its parent, and the host's
   post-render hook scales .card-content-text via FitText to the panel's content
   area. The clamp size above is the floor while measuring; FitText overrides
   it on the host. 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-foot-num beside it, which have 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;
}
.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/.card-foot-num
     use for secondary text. */
  color: var(--text-dim);
  font-size: 0.9em;
}
/* The card's footer row — draw-order number, plus the host's rating thumbs
   mounted into .card-foot-fb. In flow at the panel's bottom edge: it is card
   metadata, 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. */
.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 {
  font-size: 0.9rem;
  font-weight: 700;
  color: var(--text-dim);
}

/* 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;
  padding: 0;
  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;
}
.overlay-dialog {
  background: var(--bg-2);
  border: 1px solid var(--surface);
  border-radius: var(--radius-card);
  padding: 16px;
}
/* Menu panels take the 8px radius (menu-strategy spec 2026-07-30); every
   other overlay-dialog (confirms, profile, scoreboard, …) keeps the 16px
   --radius-card above — the radius change is scoped to menus only. */
.overlay-dialog--menu {
  border-radius: var(--radius-md);
}
.overlay-dialog h3,
.player-menu-title-row {
  font-size: 14px;
  color: var(--accent);
  margin: 0 0 12px;
}

/* ── 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
   right-aligned button row. `.dialog-body` and `.dialog-actions` give the
   body its spacing and the row 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;
}
.dialog-actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
}

/* ── 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 18px; the TARGET is 44px. 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;
  float: right;
  cursor: pointer;
  color: var(--text-muted);
  font-size: 18px;
  line-height: 1;
}
.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 sits OUTSIDE the glyph (positive offset) — unlike a row's inset ring —
   because the bare glyph has no fill to inset against. Non-menu overlays never
   set .focused on their ✕, so their glyphs are unaffected. */
.overlay-close.focused {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  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 (docs/specs/2026-07-07-impostor-game-design.md,
   "Reveal gesture") — a NAMED EXCEPTION to the hold-to-confirm retirement
   above (docs/ui-style.md): this hold gates disclosure DURATION, not action
   confirmation, so it is not the gesture that was banned.

   Anatomy: name heading, a press-hold region that swaps its "hold to
   reveal" prompt for the secret while held, a scrollable mode-specific
   instructions block, and "I'm done" — matching the spec's dialog shape.
   Rides the shared .overlay-dialog card; only the parts unique to this
   dialog are declared here. */
.hold-reveal-inner {
  /* Roomier than the confirm-dialog default: this dialog is read/held for
     several seconds, not glanced at and dismissed. */
  min-width: 260px;
  max-width: 360px;
}
.hold-reveal-name {
  text-align: center;
  font-size: 20px;
  color: var(--text);
}
/* The press-hold target. >=44px per the app-wide tap-target minimum, but
   deliberately much taller — this is the dialog's whole reason to exist, not
   an incidental control. */
.hold-reveal-area {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 120px;
  margin: 12px 0;
  border-radius: var(--radius-md);
  background: var(--surface);
  cursor: pointer;
  text-align: center;
  padding: 12px;
  box-sizing: border-box;
  transition: background 0.15s;
}
.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);
  }
}
.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;
}
/* Scrollable so a long mode-specific instructions block (the grid mode's 16
   entries) never grows the dialog off-screen. */
.hold-reveal-instructions {
  max-height: 140px;
  overflow-y: auto;
  padding: 8px 0;
  color: var(--text);
  font-size: 13px;
  line-height: 1.5;
}

/* ── 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-only bounded scroll — never apply to bare .impostor-roster-list
   (gameplay [data-region-roster] must stay unconstrained). */
.impostor-roster-list.is-lobby-scroll {
  max-height: min(40dvh, 280px);
  overflow-y: auto;
}
/* Bottom-edge fade for lobby roster scroll. Surfaces override
   --impostor-roster-fade-to when the list sits on a different ground
   (host panel = --surface; player lobby defaults to --bg-2). */
.impostor-roster-scroll-fade {
  position: sticky;
  bottom: 0;
  height: 1.5rem;
  margin-top: -1.5rem;
  background: linear-gradient(
    to bottom,
    transparent,
    var(--impostor-roster-fade-to, var(--bg-2))
  );
  pointer-events: none;
}
/* 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;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem;
  padding: 0.5rem 0.7rem;
  min-height: 44px;
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius-xs);
}
.roster-row.is-accused {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
}
.roster-row .row-text {
  flex: 1;
  min-width: 0;
  font-weight: 600;
  overflow-wrap: break-word;
}
button.row-text--expandable {
  appearance: none;
  background: transparent;
  border: 0;
  color: inherit;
  font: inherit;
  font-weight: 600;
  text-align: left;
  padding: 0;
  cursor: pointer;
}
.impostor-count-reveal {
  align-self: flex-start;
  margin: 0 0 8px;
}
.roster-row .viewed-flag {
  font-size: 0.75rem;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.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"/"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. */
.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;
  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 QR overlay (Task 2.2, ui/shared/qr-overlay.js) ──────
   Replaces the old inline join card: the QrOverlay mount floats fixed to
   the viewport bottom instead of sitting in the lobby panel's scrolling
   flow, so it never pushes the Start CTA down behind information the host
   has already used. Scoped to the .qr-overlay CLASS every mount carries —
   never a mount id: the skin must reach #collecting-partycode and
   #impostor-lobby-partycode alike (2026-08-05 fix — the id-scoped version
   left the Impostor lobby's overlay completely unstyled; host-css.test.js
   pins the class scoping). No ancestor between either mount and the
   viewport sets a transform/filter/contain (verified against
   #app-container, .screen-zone, #collecting-panel), so `fixed` resolves
   against the viewport despite the DOM nesting. Centered up to the lobby
   card's own 480px cap (#collecting-panel) so it never stretches
   edge-to-edge on a wide viewport. */
.qr-overlay {
  position: fixed;
  left: 50%;
  bottom: 0;
  transform: translateX(-50%);
  width: 100%;
  max-width: 480px;
  padding: 0 16px;
  /* iPad/iPhone home-indicator gap — same convention as #phone-cta above:
     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 card/expand-glyph inside should catch clicks — nothing between
     them may swallow a tap meant for #collecting-panel's own content. */
  pointer-events: none;
}
.qr-overlay .qr-overlay-card {
  pointer-events: auto;
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: fit-content;
  max-width: 100%;
  margin: 0 auto;
  background: var(--qr-light);
  border-radius: var(--radius-card);
  padding: 28px 16px 14px;
  box-shadow: 0 -4px 16px var(--scrim-subtle);
}
/* Sized down from the dialog's 224px so the floating card stays well clear of
   a short phone viewport; still spans modules + the 4-module quiet zone at
   the qr-code.js module pitch, so it isn't the modules themselves shrinking. */
.qr-overlay .qr-overlay-qr .qr-code svg {
  width: 148px;
  height: 148px;
  display: block;
}
/* Brand-tinted, not brand-inverted — same reasoning as .partycode-qr in the
   dialog (see shared.css --qr-dark/--qr-light): phone cameras expect dark
   modules on a light field, and accent-on-cream fails module contrast. */
.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);
}
/* The code button — cream ink, not a bordered control, so it reads as part of
   the paper rather than a second card floating on the first (mirrors
   .partycode-value in the dialog). */
.qr-overlay .qr-overlay-code {
  width: auto;
  background: none;
  border: 0;
  color: var(--qr-dark);
  font-family: var(--font);
  font-size: 26px;
  font-weight: bold;
  cursor: pointer;
  padding: 6px 12px 0;
  margin: 0;
}
.qr-overlay .qr-overlay-code:hover,
.qr-overlay .qr-overlay-code:focus-visible {
  text-decoration: underline;
  text-underline-offset: 3px;
}
/* A real outline, not just the underline above (WCAG 2.4.7): the button has
   no border to color-swap the way .partycode-value does in the dialog (this
   box is cream ink, not a bordered control — see the comment above), so the
   visible indicator has to be the outline itself. --qr-dark, not --accent:
   accent-on-cream is 2.8:1, below the 3:1 non-text contrast floor this same
   file already avoids for the QR modules and the share glyph. */
.qr-overlay .qr-overlay-code:focus-visible {
  outline: 2px solid var(--qr-dark);
  outline-offset: 2px;
}
/* The instructional sentence above the QR (chrome_partycode_hint — "Scan
   the QR code / or visit {host} / to enter the party code:"), restored
   2026-08-05 after the overlay rework dropped it. Mirrors the dialog's
   .partycode-hint treatment: pre-line honors the authored "\n" so the two
   ways in sit on their own lines; `pretty` keeps a narrow-viewport re-wrap
   from orphaning "code:". Dark-on-cream like everything else on the card. */
.qr-overlay .qr-overlay-hint {
  font-size: 13px;
  color: var(--qr-dark);
  text-align: center;
  margin: 0 0 8px;
  white-space: pre-line;
  text-wrap: pretty;
}
/* aria-live="polite" feedback slot for PartyCode.populate's copyToClipboard
   ("Copied!") — the Web Share API isn't available on most desktops, so this
   is the only observable signal a tap on the code did anything there.
   min-height reserves the line so it doesn't shift the URL below when text
   appears/clears. Mirrors .partycode-copy-hint's role in the dialog. */
.qr-overlay .qr-overlay-share-hint {
  font-size: 11px;
  color: var(--qr-dark);
  min-height: 1.4em;
  margin: 2px 0 0;
  text-align: center;
}
.qr-overlay .qr-overlay-url {
  display: block;
  font-size: 12px;
  color: var(--qr-dark);
  opacity: 0.7;
  margin: 6px 0 0;
  word-break: break-all;
  text-decoration: underline;
  text-underline-offset: 2px;
  text-align: center;
}
/* Down-chevron: collapses the expanded card. Clears the 44px tap floor
   (2026-08-07 playtest — the prior 28px glyph was too small to find). */
.qr-overlay .qr-overlay-collapse {
  position: absolute;
  top: 2px;
  right: 2px;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--qr-dark);
  opacity: 0.7;
  cursor: pointer;
}
.qr-overlay .qr-overlay-collapse:hover,
.qr-overlay .qr-overlay-collapse:focus-visible {
  opacity: 1;
}
/* Real outline (WCAG 2.4.7), not just the opacity bump above — the button is
   a bare glyph with no fill/border to contrast against, mirroring
   .avatar-btn's round-icon-button treatment elsewhere in this file. */
.qr-overlay .qr-overlay-collapse:focus-visible {
  outline: 2px solid var(--qr-dark);
  outline-offset: 2px;
}
/* Plain Unicode glyph, not the Material Symbols icon font: that font is
   subsetted per-glyph (scripts/subset_material_symbols.py's HOST_ICONS list)
   and neither this chevron nor the QR glyph below is in it — an
   un-subsetted codepoint renders as tofu, so these stay in the page's own
   font instead of adding a font-subset/rebuild step to a CSS-only task. */
.qr-overlay .qr-overlay-collapse::before {
  content: "\25be"; /* ▾ black down-pointing small triangle */
  font-size: 28px;
  line-height: 1;
}
/* Minimized: keep the REAL QR visible (scaled) so the host still sees the
   scannable mark (2026-08-07 playtest). Hide every other card child; the
   expand control is an absolute overlay over the QR so the tap target stays
   named for AT without a ▦ placeholder glyph. */
.qr-overlay.qr-overlay-min .qr-overlay-card {
  position: relative;
  width: 64px;
  height: 64px;
  padding: 4px;
  margin: 0 0 0 auto;
  border-radius: var(--radius-btn);
  box-shadow: 0 -2px 10px var(--scrim-subtle);
  overflow: hidden;
}
.qr-overlay.qr-overlay-min .qr-overlay-card > :not(.qr-overlay-qr):not(.qr-overlay-expand) {
  display: none;
}
.qr-overlay.qr-overlay-min .qr-overlay-qr {
  display: block;
  line-height: 0;
}
.qr-overlay.qr-overlay-min .qr-overlay-qr .qr-code svg {
  width: 56px;
  height: 56px;
}
.qr-overlay .qr-overlay-expand {
  display: none;
}
.qr-overlay.qr-overlay-min .qr-overlay-expand {
  display: block;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  border-radius: inherit;
  background: none;
  cursor: pointer;
  /* Transparent hit target over the QR — no placeholder glyph. */
  color: transparent;
  font-size: 0;
}
.qr-overlay.qr-overlay-min .qr-overlay-expand:hover {
  opacity: 0.85;
}
/* Real outline (WCAG 2.4.7) — matches .qr-overlay-collapse's treatment. */
.qr-overlay.qr-overlay-min .qr-overlay-expand:focus-visible {
  outline: 2px solid var(--qr-dark);
  outline-offset: 2px;
}
/* ── Menu row (.menu-item) ───────────────────────────────────────────────
   The one menu-row implementation, shared by host (collecting-menu config
   rows + in-game menu) and player (in-game menu). Each surface layers its
   own metric deltas (host keeps its stepper-row rules in host.css; player's
   rem-sized padding/font-size/focus-state deltas live in play.css) on top
   of this base — see docs/ui-style.md's "Known deviations backlog" #2. */
.menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: none;
  border: none;
  border-radius: var(--radius-xs);
  padding: 8px 12px;
  /* >=44px tap target, matching the no-controller hint-pill convention. */
  min-height: 44px;
  box-sizing: border-box;
  color: var(--text);
  cursor: pointer;
  font-size: 13px;
  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. */
.glyph-btn,
.menu-trigger,
.overlay-close,
.fb-btn,
.avatar-btn,
.header-action-btn,
.collecting-menu-btn,
.resume-card-dismiss,
.icon-btn,
.composer-add-btn {
  min-width: 44px;
  min-height: 44px;
  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: 22px;
  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 config screen's ◀ / ▶. One knob, one size
   (2026-08-01 owner decision); the glyph inside it varies. Skin (fill, border,
   focus colour) stays with the member. */
.stepper-btn,
.sb-step,
#timer-overlay-stepper button,
.config-row .config-arrow {
  flex: none;
  width: 44px;
  height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  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: 15px;
  font-weight: 600;
  line-height: 1.2;
  color: var(--accent);
  cursor: pointer;
}
.text-link:hover,
.intro-full-rules-btn:hover,
.transition-rules-btn:hover,
.inline-btn:hover,
.signout-btn:hover {
  color: var(--text);
}

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