@import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700;800&family=Nunito:ital,wght@0,400;0,600;0,700;0,800;1,700&display=swap');

/*
 * Eldoria — a platform for real-time party games on a shared TV.
 * Crayon Storybook theme: cream construction-paper page, crayon-box brights,
 * chunky cut-paper sticker shapes, squishy candy buttons, rounded display type.
 * TV shows the board (.board); phones show controls (.phone).
 */

:root {
  --paper: #fff6e3;
  --paper-2: #ffedca;
  --card: #fffdf7;
  --ink: #4a3526;
  --ink-soft: #98826c;
  --outline: #41301f;

  --red: #ff5d5d;
  --red-deep: #e23b48;
  --sun: #ffc233;
  --sun-deep: #f0a31e;
  --sky: #45c4e8;
  --sky-deep: #2a9fcc;
  --leaf: #6fcf73;
  --grape: #a472e8;
  --berry: #ff8fc0;

  --good: #2fb96f;
  --bad: #ef4d5b;

  /* cut-paper: hard offset "stack" + soft cast shadow */
  --pop: 0 7px 0 rgba(65, 48, 31, 0.16), 0 16px 26px rgba(150, 95, 35, 0.16);
  --pop-sm: 0 4px 0 rgba(65, 48, 31, 0.15), 0 9px 16px rgba(150, 95, 35, 0.16);
  --radius: 26px;
  --display: "Baloo 2", ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, sans-serif;
  --body: "Nunito", ui-rounded, "Segoe UI", system-ui, sans-serif;
}

* { box-sizing: border-box; }

/* `hidden` has to actually hide. The attribute's own UA rule is `display: none`
   at the very bottom of the cascade, so any class that sets `display` beats it —
   and several here do, on the exact elements a Stimulus controller toggles
   (Kalookie's per-card play groups and its lay-down builder, Turf's live delta
   on the phone). Every one of those controllers writes `el.hidden = true` and
   was quietly writing it to a visible element. */
[hidden] { display: none !important; }

html, body { margin: 0; min-height: 100%; }

body {
  font-family: var(--body);
  font-weight: 600;
  color: var(--ink);
  background-color: var(--paper);
  background-image:
    radial-gradient(closest-side at 12% 18%, rgba(69, 196, 232, 0.18), transparent),
    radial-gradient(closest-side at 88% 12%, rgba(255, 143, 192, 0.20), transparent),
    radial-gradient(closest-side at 80% 92%, rgba(111, 207, 115, 0.18), transparent),
    radial-gradient(closest-side at 18% 88%, rgba(255, 194, 51, 0.20), transparent),
    radial-gradient(rgba(65, 48, 31, 0.05) 1.6px, transparent 1.7px);
  background-size: 60% 60%, 55% 55%, 60% 60%, 55% 55%, 26px 26px;
  background-position: 0 0, 0 0, 0 0, 0 0, 0 0;
  background-attachment: fixed;
  -webkit-text-size-adjust: 100%;
}

h1, h2, h3 { font-family: var(--display); font-weight: 800; margin: 0; line-height: 1.05; }
a { color: var(--sky-deep); }

.crest {
  font-family: var(--display);
  font-weight: 800;
  letter-spacing: 0.04em;
  color: var(--red);
}
.crest::first-letter { color: inherit; }

/* ----------------------------------------------------------------- buttons */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5em;
  border: 3px solid var(--outline);
  background: var(--sun);
  color: var(--outline);
  font: 800 1.15rem/1 var(--display);
  letter-spacing: 0.01em;
  padding: 0.85em 1.5em;
  border-radius: 999px;
  cursor: pointer;
  box-shadow: 0 6px 0 var(--sun-deep), 0 12px 20px rgba(150, 95, 35, 0.22);
  text-decoration: none;
  transition: transform 0.08s ease, box-shadow 0.08s ease, filter 0.15s ease;
}
.btn:hover { filter: brightness(1.04) saturate(1.05); }
.btn:active {
  transform: translateY(5px);
  box-shadow: 0 1px 0 var(--sun-deep), 0 4px 10px rgba(150, 95, 35, 0.22);
}
.btn[disabled], .btn.is-disabled {
  background: #e9e0cf;
  color: #a99c87;
  border-color: #cfc3ad;
  box-shadow: 0 6px 0 #d4c8b2;
  cursor: not-allowed;
  transform: none;
}
.btn--ghost {
  background: var(--card);
  color: var(--ink);
  box-shadow: 0 6px 0 rgba(65, 48, 31, 0.14);
}
.btn--block { width: 100%; }

/* ------------------------------------------------------------------ panels */
.panel {
  background: var(--card);
  border: 3px solid var(--outline);
  border-radius: var(--radius);
  box-shadow: var(--pop);
}

/* ============================================================= TV / BOARD = */
/* A TV is never scrolled, so a board taller than the screen doesn't merely look
   cramped — everything past the fold is unreachable, and the bottom of a board
   is where the "what happens next" line lives. This wrapper is the screen: it
   never grows, and board_fit_controller.js scales the board down inside it when
   a big roster or a long recap outgrows it. */
.board-fit {
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
}
.board-fit > .board {
  transform: scale(var(--board-scale, 1));
  transform-origin: top center;
}
.board {
  position: relative;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(1rem, 2.2vh, 2rem);
  padding: clamp(1.2rem, 4vw, 3rem);
  text-align: center;
  overflow: hidden;
}
/* floating storybook doodles. Being positioned, they'd paint over every static
   sibling however early they sit in the box — which a full screen of content
   (eight standings rows, a four-name verdict) actually reaches. Held at layer 0
   with the content lifted to 1, so they stay behind whatever is on screen. */
.board::before,
.board::after {
  content: "";
  position: absolute;
  z-index: 0;
  font-size: clamp(3rem, 9vw, 7rem);
  opacity: 0.5;
  pointer-events: none;
  animation: float 7s ease-in-out infinite;
}
.board::before { content: "🌈"; top: 6%; left: 5%; }
.board::after { content: "⭐"; bottom: 7%; right: 6%; animation-delay: -3.5s; }
.board > * { position: relative; z-index: 1; }

.board__title { font-size: clamp(1.8rem, 5vw, 3.8rem); color: var(--ink); }
.board__sub { color: var(--ink-soft); font-size: clamp(1rem, 1.9vw, 1.45rem); font-weight: 700; }

.round-tag {
  font-family: var(--display);
  font-weight: 700;
  color: var(--grape);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  font-size: clamp(0.9rem, 1.5vw, 1.2rem);
}

/* lobby */
.lobby {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: clamp(1.5rem, 4vw, 3.5rem);
  align-items: center;
  padding: clamp(1.6rem, 3vw, 2.8rem);
  max-width: 1100px;
  width: 100%;
  animation: pop-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.join-card { text-align: center; }
.qr {
  background: #fff;
  padding: 16px;
  border: 3px solid var(--outline);
  border-radius: 22px;
  width: clamp(170px, 24vw, 280px);
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  margin: 0 auto;
  box-shadow: var(--pop-sm);
  transform: rotate(-2deg);
}
.qr svg { width: 100%; height: 100%; display: block; }
.join-url { margin-top: 1rem; color: var(--ink-soft); font-size: 0.95rem; word-break: break-all; }
.code-chip {
  display: inline-flex;
  gap: 0.1em;
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(2.4rem, 6.5vw, 4.4rem);
  letter-spacing: 0.16em;
  color: var(--outline);
  background: var(--sun);
  border: 3px dashed var(--outline);
  border-radius: 18px;
  padding: 0.2em 0.55em 0.2em 0.7em;
  margin-top: 0.7rem;
  box-shadow: var(--pop-sm);
  transform: rotate(1.5deg);
}

/* Facts, as chips rather than a paragraph: one fact per chip, so a screen
   reads at a glance instead of carrying a sentence that has to be read. Used
   for a lobby's setup, a catalogue card's player range, and any count a board
   wants to state without turning it into an instruction. */
.meta-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  justify-content: center;
  margin-top: 1.3rem;
}
.meta-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  background: var(--paper-2);
  border: 2px solid var(--outline);
  border-radius: 999px;
  padding: 0.3em 0.9em;
  font-family: var(--display);
  font-weight: 700;
  color: var(--ink);
  font-size: clamp(0.85rem, 1.4vw, 1.1rem);
  white-space: nowrap;
}

/* Who is holding the button, on the TV: a phone and a name. It replaces the
   "…can start from their phone" sentence every lobby and round-end carried,
   and it is the screen's one instruction once the room is full enough. */
.host-cue { margin-top: 1.2rem; }
.host-cue::before { content: "📱"; margin-right: 0.35em; }
.host-cue strong { color: var(--red); }

.roster {
  list-style: none;
  margin: 1.3rem 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.7rem;
  justify-content: center;
}
.roster li {
  background: #fff;
  border: 3px solid var(--outline);
  border-radius: 999px;
  padding: 0.45em 1.1em;
  font-family: var(--display);
  font-weight: 700;
  color: var(--ink);
  font-size: clamp(1rem, 1.7vw, 1.3rem);
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  box-shadow: var(--pop-sm);
}
.roster li:nth-child(5n+1) { background: #ffe1e1; }
.roster li:nth-child(5n+2) { background: #d9f3fb; }
.roster li:nth-child(5n+3) { background: #e3f8e4; }
.roster li:nth-child(5n+4) { background: #fff0cc; }
.roster li:nth-child(5n+5) { background: #efe3fc; }
.roster li:nth-child(odd) { transform: rotate(-2deg); }
.roster li:nth-child(even) { transform: rotate(2deg); }
.roster .host-badge { color: var(--sun-deep); font-size: 0.85em; font-weight: 800; }
/* An empty seat. How many more players a game needs is drawn as the gap it
   leaves in the roster rather than spelled out underneath it — the room counts
   dashes instead of reading a sentence. */
.roster li.is-empty {
  background: none;
  border-style: dashed;
  border-color: var(--ink-soft);
  color: var(--ink-soft);
  box-shadow: none;
}
.roster li.is-empty::before { content: "+"; font-size: 1.1em; line-height: 1; }

.roster.roster--status li.done { border-color: var(--good); background: #e3f8e4; color: var(--good); }
.roster.roster--status li.waiting { opacity: 0.55; }
.tick { color: var(--good); }
.hourglass { color: var(--ink-soft); }

/* guesser spotlight */
.spotlight { font-size: clamp(1.4rem, 2.8vw, 2.2rem); }
.spotlight strong { color: var(--red); }

/* clue cards on the board */
.clue-grid {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(0.9rem, 1.8vw, 1.4rem);
  justify-content: center;
  max-width: 1100px;
}
.clue-card {
  background: var(--card);
  color: var(--ink);
  border: 4px solid var(--sky);
  border-radius: 20px;
  padding: clamp(0.7rem, 1.8vw, 1.25rem) clamp(1.1rem, 2.6vw, 2rem);
  font-family: var(--display);
  font-size: clamp(1.4rem, 3.2vw, 2.5rem);
  font-weight: 700;
  box-shadow: var(--pop-sm);
  /* opacity-only entrance so the per-card rotate() tilts below are preserved */
  animation: appear 0.4s ease both;
}
.clue-grid .clue-card:nth-child(4n+1) { border-color: var(--red); transform: rotate(-2.5deg); }
.clue-grid .clue-card:nth-child(4n+2) { border-color: var(--sky); transform: rotate(1.5deg); }
.clue-grid .clue-card:nth-child(4n+3) { border-color: var(--leaf); transform: rotate(-1deg); }
.clue-grid .clue-card:nth-child(4n+4) { border-color: var(--grape); transform: rotate(2.5deg); }
.clue-grid .clue-card:nth-child(2) { animation-delay: 0.06s; }
.clue-grid .clue-card:nth-child(3) { animation-delay: 0.12s; }
.clue-grid .clue-card:nth-child(4) { animation-delay: 0.18s; }
.clue-grid .clue-card:nth-child(5) { animation-delay: 0.24s; }
.clue-grid .clue-card:nth-child(6) { animation-delay: 0.30s; }
.clue-card.struck {
  background: #fbeeee;
  color: var(--ink-soft);
  border-color: #f3c4c4 !important;
  text-decoration: line-through;
  text-decoration-color: var(--bad);
  text-decoration-thickness: 3px;
  font-size: clamp(1.05rem, 2.3vw, 1.7rem);
  box-shadow: none;
  transform: rotate(-3deg) !important;
}
.clue-card .why {
  display: block;
  font-family: var(--body);
  font-weight: 800;
  font-size: 0.42em;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  text-decoration: none;
  color: var(--bad);
  margin-top: 0.35em;
}
.clue-card .by {
  display: block;
  font-family: var(--body);
  font-weight: 800;
  font-size: 0.4em;
  letter-spacing: 0.02em;
  text-decoration: none;
  color: var(--sky-deep);
  margin-top: 0.45em;
}
.clue-card.struck .by { color: var(--ink-soft); }

/* result */
.reveal-word {
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(2.6rem, 8.5vw, 6rem);
  color: var(--grape);
  -webkit-text-stroke: 3px var(--outline);
  paint-order: stroke fill;
  animation: bounce-in 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.verdict { font-family: var(--display); font-size: clamp(1.6rem, 3.2vw, 2.4rem); font-weight: 800; }
.verdict.good { color: var(--good); }
.verdict.bad { color: var(--bad); }
/* A wipeout the guesser passed on: neither a win nor a miss. */
.verdict.void { color: var(--ink-soft); }
.guess-echo { color: var(--ink-soft); font-size: clamp(1rem, 2vw, 1.45rem); font-weight: 700; }
.scoreline { color: var(--ink); font-size: clamp(1rem, 2vw, 1.45rem); font-weight: 700; }

/* finale */
.grade {
  position: relative;
  font-family: var(--display);
  font-weight: 800;
  /* Sized off the height it has to fit in as well as the width: this glyph is
     the tallest thing on a finale, and a board only has one screen of room. */
  font-size: clamp(3.5rem, min(24vw, 18vh), 16rem);
  line-height: 0.9;
  color: var(--sun);
  -webkit-text-stroke: 5px var(--outline);
  paint-order: stroke fill;
  animation: bounce-in 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.grade::before {
  content: "";
  position: absolute;
  inset: -18% -18%;
  z-index: -1;
  background:
    conic-gradient(from 0deg, var(--sun) 0 18deg, transparent 18deg 36deg,
      var(--sun) 36deg 54deg, transparent 54deg 72deg, var(--sun) 72deg 90deg,
      transparent 90deg 108deg, var(--sun) 108deg 126deg, transparent 126deg 144deg,
      var(--sun) 144deg 162deg, transparent 162deg 180deg, var(--sun) 180deg 198deg,
      transparent 198deg 216deg, var(--sun) 216deg 234deg, transparent 234deg 252deg,
      var(--sun) 252deg 270deg, transparent 270deg 288deg, var(--sun) 288deg 306deg,
      transparent 306deg 324deg, var(--sun) 324deg 342deg, transparent 342deg 360deg);
  border-radius: 50%;
  opacity: 0.28;
  animation: spin 28s linear infinite;
}
/* An emoji glyph is taller than the tight line-box tuned for letter grades, so
   it would bleed into the neighbouring lines. Give the emoji finales room. */
.grade--emoji { line-height: 1.25; }
.recap {
  list-style: none; padding: 0; margin: 1.2rem 0 0;
  display: grid; gap: 0.5rem; width: min(560px, 92vw);
}
.recap li {
  display: flex; justify-content: space-between; gap: 1rem; align-items: center;
  padding: 0.55em 1em; border-radius: 14px;
  background: #fff; border: 3px solid var(--outline);
  font-weight: 700;
  box-shadow: var(--pop-sm);
}
.recap .word { font-family: var(--display); font-weight: 700; }
/* A long recap (a big roster, or twelve rounds) is the one thing that can grow
   without limit, and stacking it costs height the board doesn't have. Past a
   handful of rows it flows into columns — top to bottom, then across, so a
   ranked list still reads 1, 2, 3… down the first column. See recap_list. */
.recap--split {
  grid-auto-flow: column;
  grid-template-rows: repeat(var(--recap-rows, 6), auto);
  width: min(1120px, 95vw);
}
.recap--split li { padding: 0.42em 0.9em; font-size: 0.92em; }

/* The scores, held back between the last round and the finale
   (games/_scores_sealed). Dashed rather than solid, which is this theme's mark
   for a number that hasn't landed yet — see .tf-coin--live. */
.sealed {
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
  padding: 0.5em 1.2em;
  background: var(--card);
  border: 4px dashed var(--outline);
  border-radius: 999px;
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(1rem, 2vw, 1.5rem);
  color: var(--ink-soft);
}
.sealed__seal { font-size: 1.15em; line-height: 1; }

/* =============================================================== PHONE === */
.phone {
  max-width: 540px;
  margin: 0 auto;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  gap: 1.2rem;
  padding: clamp(1.1rem, 5vw, 1.8rem);
  /* The sound mute is fixed in the top-right corner of the viewport (.sfx-mute,
     0.6rem down and 2.4rem tall), so the column starts below it. Without this a
     screen tall enough to reach the top — a two-line verdict, a room full of
     hiding places — runs its first line under the button and loses the end of
     it. Costs nothing on a short screen, which is centred with room to spare. */
  padding-top: 3.5rem;
  justify-content: center;
}
.phone__head { text-align: center; }
.phone__head .crest { font-size: 1.05rem; }
.phone h2 { font-size: 1.9rem; margin-top: 0.25rem; color: var(--ink); }
.phone .muted { color: var(--ink-soft); }
.phone .panel { padding: 1.4rem; animation: pop-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
/* A paragraph sitting straight on a phone screen rather than inside a panel is
   spaced by .phone's flex gap; its own margins would only stack on top. Reaches
   exactly the screens that have dropped their panel, since anything still in
   one isn't a direct child. */
.phone > p { margin: 0; }

.field {
  width: 100%;
  font: 800 1.6rem var(--display);
  padding: 0.75em 0.7em;
  border-radius: 18px;
  border: 3px solid var(--outline);
  background: #fff;
  color: var(--ink);
  text-align: center;
  box-shadow: inset 0 2px 6px rgba(65, 48, 31, 0.08);
}
.field::placeholder { color: #c9bba6; font-weight: 700; }
.field:focus { outline: none; border-color: var(--sky); box-shadow: 0 0 0 4px rgba(69, 196, 232, 0.35); }

.field-hint { margin: -0.3rem 0 -0.2rem; font-size: 0.85rem; }

.form-stack { display: grid; gap: 1rem; }

/* The seat-reclaim list on the join screen (PlayersController#reclaim): a phone
   that lost its cookie taking its own name back off the roster. It sits under
   whatever the join panel is already offering — the join form in a lobby, the
   "already under way" line in a started room — so it's ruled off as a separate
   offer rather than reading as more of the same. The row gap is wider than the
   column gap because .btn casts a 6px stacked shadow that would otherwise land
   on the pill below it. */
.reclaim {
  margin-top: 1.4rem;
  padding-top: 1.2rem;
  border-top: 3px dashed var(--outline);
}
.reclaim__lede {
  margin: 0 0 1rem;
  text-align: center;
  font-family: var(--display);
  font-weight: 700;
  font-size: 0.98rem;
  color: var(--ink-soft);
}
.reclaim__seats {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 1rem 0.6rem;
  justify-content: center;
}
.reclaim__seats .button_to { margin: 0; }
.reclaim__seat { font-size: 1rem; padding: 0.6em 1.15em; }

.secret-word {
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(2.4rem, 13vw, 3.6rem);
  color: var(--grape);
  text-align: center;
  letter-spacing: 0.01em;
  -webkit-text-stroke: 2.5px var(--outline);
  paint-order: stroke fill;
  animation: bounce-in 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* Hide the secret word until the giver focuses the clue box, so the guesser
   can't read it from a glance. Blurred (not removed) to keep the layout
   stable and signal "tap to reveal". Scoped to .phone rather than the .panel
   it used to share with the boxes: that panel is gone, and the word and the
   form are siblings on the page now. The word only ever renders on the
   clue-giving screen, whose only .field is a clue box. */
.secret-word--masked { filter: blur(16px); transition: filter 0.18s ease; user-select: none; }
.phone:has(.field:focus) .secret-word--masked { filter: none; user-select: auto; }
.label-kicker {
  text-align: center;
  font-family: var(--display);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-size: 0.85rem;
  color: var(--sun-deep);
}
.flash {
  background: #ffe1e4;
  border: 3px solid var(--bad);
  color: var(--red-deep);
  border-radius: 16px;
  padding: 0.7em 1em;
  text-align: center;
  font-weight: 700;
  font-size: 0.98rem;
  box-shadow: var(--pop-sm);
}
.error-text { color: var(--bad); text-align: center; font-size: 0.95rem; font-weight: 800; }

.role-pill {
  align-self: center;
  background: var(--sky);
  border: 3px solid var(--outline);
  color: var(--outline);
  border-radius: 999px;
  padding: 0.4em 1.2em;
  font-family: var(--display);
  font-weight: 800;
  font-size: 0.95rem;
  box-shadow: var(--pop-sm);
  transform: rotate(-2deg);
}
.role-pill.guesser { background: var(--sun); }

.waiting-dots { text-align: center; color: var(--ink-soft); font-size: 1.05rem; }
.big-status { text-align: center; font-size: 1.4rem; line-height: 1.45; font-weight: 700; }
.big-status strong { color: var(--red); font-family: var(--display); }

/* landing */
.landing {
  position: relative;
  min-height: 100dvh;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: 1.6rem; padding: 2rem; text-align: center;
  overflow: hidden;
}
.landing::before { content: "✏️"; position: absolute; top: 10%; right: 12%; font-size: clamp(2.5rem, 8vw, 5rem); opacity: 0.55; animation: float 6s ease-in-out infinite; }
.landing::after { content: "📚"; position: absolute; bottom: 9%; left: 11%; font-size: clamp(2.5rem, 8vw, 5rem); opacity: 0.55; animation: float 6s ease-in-out infinite -3s; }
.landing h1 { font-size: clamp(2.8rem, 10vw, 5.2rem); color: var(--red); }
.landing p { color: var(--ink); max-width: 32rem; line-height: 1.5; font-weight: 700; }

/* --------------------------------------------------------------- catalog */
.catalog {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 20rem));
  gap: 1.4rem; width: 100%; max-width: 66rem; justify-content: center;
}
.catalog-card { text-align: center; display: grid; gap: 0.5rem; align-content: start; }
.catalog-card h2 { font-size: 1.7rem; color: var(--red); }
.catalog-card .btn { margin-top: 0.6rem; }
/* The player range as a chip, sitting on its own line in the card's grid. */
.catalog-card__players { justify-self: center; margin: 0.1rem 0; }


/* The way to the game's written rules (GuidesController). Deliberately a quiet
   text link under the loud button: the point of the card is to start playing. */
.how-to-link {
  font-family: var(--display); font-weight: 700; font-size: 0.9rem;
  color: var(--ink-soft); text-decoration: none;
  border-bottom: 2px dotted var(--ink-soft);
  justify-self: center; padding-bottom: 0.05em;
  /* Clears the offset "stack" shadow the button above it casts (--pop). */
  margin-top: 0.5rem;
}
.how-to-link:hover { color: var(--ink); border-bottom-color: currentColor; }

.center { text-align: center; }
/* A column of controls and short lines of text. It centres its *text* and
   stretches its *boxes* — which is the whole point: everything that goes in one
   is either a .btn--block, a full-width picker, or a line already carrying
   .center. It used to centre both (place-items: center), which shrink-wrapped
   every child: button_to wraps each control in a <form>, so .btn--block's
   width: 100% resolved against a box the size of its own label. Two stacked
   buttons came out at two different widths, and a length picker of "1 3 5" came
   out as three squeezed slivers. */
.stack { display: grid; gap: 0.9rem; justify-items: stretch; text-align: center; }
.stack > form { margin: 0; }

/* ------------------------------------------------------------------ motion */
@keyframes pop-in {
  from { opacity: 0; transform: scale(0.82) translateY(10px); }
  to   { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes bounce-in {
  0%   { opacity: 0; transform: scale(0.5) rotate(-6deg); }
  60%  { opacity: 1; transform: scale(1.08) rotate(2deg); }
  100% { opacity: 1; transform: scale(1) rotate(0); }
}
@keyframes float {
  0%, 100% { transform: translateY(0) rotate(-4deg); }
  50%      { transform: translateY(-14px) rotate(4deg); }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes appear { from { opacity: 0; } to { opacity: 1; } }

/* ----------------------------------------------------- emoji reactions */
/* TV: stacked toasts in the bottom-right corner, newest at the bottom. */
.reactions-layer {
  position: fixed;
  right: clamp(12px, 2vw, 28px);
  bottom: clamp(12px, 2vw, 28px);
  z-index: 50;
  display: flex;
  flex-direction: column-reverse;
  align-items: flex-end;
  gap: 0.6rem;
  pointer-events: none;
}
.reaction-toast {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  background: var(--card);
  border: 3px solid var(--outline);
  border-radius: 999px;
  padding: 0.35em 0.95em 0.35em 0.6em;
  box-shadow: var(--pop-sm);
  font-family: var(--display);
  font-weight: 700;
  color: var(--ink);
  animation: toast-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.reaction-toast__emoji { font-size: clamp(1.9rem, 3.8vw, 3.1rem); line-height: 1; }
.reaction-toast__name { font-size: clamp(0.9rem, 1.5vw, 1.3rem); padding-right: 0.2em; }
.reaction-toast.is-leaving { animation: toast-out 0.5s ease forwards; }

@keyframes toast-in {
  0%   { opacity: 0; transform: translateX(40px) scale(0.6); }
  60%  { opacity: 1; transform: translateX(-6px) scale(1.06); }
  100% { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes toast-out {
  from { opacity: 1; transform: translateX(0) scale(1); }
  to   { opacity: 0; transform: translateX(30px) scale(0.7); }
}

/* --------------------------------------------------- board voice controls */
/* Opt-in host voice, tucked into the TV's corner: a toggle, a picker for
   choosing the least-robotic of the OS's installed voices, and its speed. */
/* Sound controls (shared/_audio_controls) — one button in the corner of any TV
   screen, opening onto the host voice and the music. It's a <details>, so the
   panel is simply what follows the <summary>.

   Kept to a single pill because this floats over the board: five permanent
   controls across the top of a game is five too many, and nobody adjusts the
   speaking rate twice an evening. */
.audio-menu {
  position: fixed;
  top: 1rem;
  right: 1rem;
  z-index: 40;
}
.audio-menu__button {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  border: 3px solid var(--outline);
  background: var(--card);
  color: var(--ink);
  font: 800 0.95rem/1 var(--display);
  padding: 0.55em 0.95em;
  border-radius: 999px;
  box-shadow: var(--pop-sm);
  cursor: pointer;
  list-style: none; /* the default disclosure triangle, in every engine */
}
.audio-menu__button::-webkit-details-marker { display: none; }
.audio-menu__button:hover { filter: brightness(1.03) saturate(1.04); }
.audio-menu__icon { font-size: 1.2rem; line-height: 1; }

/* Anything switched on tints the closed button, so the room can tell at a
   glance whether the TV is meant to be making noise. */
.audio-menu:has(.is-on) > .audio-menu__button { background: var(--sun); }

.audio-menu__panel {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  min-width: 15rem;
  padding: 1rem;
  border: 3px solid var(--outline);
  border-radius: var(--radius);
  background: var(--card);
  box-shadow: var(--pop);
  animation: appear 0.18s ease-out both;
}
.audio-menu__group {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.audio-menu__heading {
  margin: 0;
  font-size: 0.78rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* The controls themselves. They stack in the panel now, so they fill its width
   rather than sizing to their own content. */
.voice-toggle,
.voice-picker {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  width: 100%;
  border: 3px solid var(--outline);
  background: var(--paper);
  color: var(--ink);
  font: 800 0.95rem/1 var(--display);
  padding: 0.55em 0.95em;
  border-radius: 999px;
  cursor: pointer;
}
.voice-toggle:hover { filter: brightness(1.03) saturate(1.04); }
.voice-toggle.is-on { background: var(--sun); }
.music-toggle.is-on { background: var(--sky); }
.sfx-toggle.is-on { background: var(--leaf); }
/* Asked for, but the browser is still holding out for a gesture (a refresh has
   none). Deliberately not the "on" colour — it isn't playing yet. */
.voice-toggle.is-waiting {
  background: var(--paper-2);
  border-style: dashed;
}
.voice-toggle__icon { font-size: 1.2rem; line-height: 1; }
.voice-picker:empty { display: none; } /* nothing to pick until voices load */

/* Phone: mute for the sound effects. The only control on a phone that isn't
   part of a game, so it's a bare icon, pinned clear of where a thumb lands. */
.sfx-mute {
  position: fixed;
  top: 0.6rem;
  right: 0.6rem;
  z-index: 30;
  width: 2.4rem;
  height: 2.4rem;
  display: grid;
  place-items: center;
  padding: 0;
  font-size: 1.1rem;
  line-height: 1;
  border: 3px solid var(--outline);
  border-radius: 999px;
  background: var(--card);
  box-shadow: var(--pop-sm);
  cursor: pointer;
  opacity: 0.75;
}
.sfx-mute.is-on { opacity: 1; background: var(--leaf); }

/* Phone: the host's way past a stalled player. Deliberately quiet and set apart
   from the game's own controls — it only appears after a long wait, and it takes
   somebody's turn away from them (see games/_host_rescue.html.erb). */
.host-rescue {
  margin-top: 1.2rem;
  padding-top: 1rem;
  border-top: 3px dashed rgba(65, 48, 31, 0.22);
  animation: appear 0.4s ease-out both;
}
.host-rescue__hint {
  margin: 0 0 0.6rem;
  text-align: center;
  font-size: 0.92rem;
  color: var(--ink-soft);
}
.host-rescue form { margin: 0; }
.host-rescue__btn {
  margin: 0;
  background: #fff;
  font-size: 1.05rem;
}

/* Phone: the tappable reaction palette shown while waiting. */
.reaction-bar { margin-top: 0.6rem; text-align: center; }
.reaction-bar__grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.6rem;
}
.reaction-form { margin: 0; display: inline-flex; }
.reaction-btn {
  width: 3.3rem;
  height: 3.3rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 1.75rem;
  line-height: 1;
  background: #fff;
  border: 3px solid var(--outline);
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 4px 0 rgba(65, 48, 31, 0.16);
  transition: transform 0.08s ease, box-shadow 0.08s ease;
}
.reaction-btn:active { transform: translateY(4px); box-shadow: 0 0 0 rgba(65, 48, 31, 0.16); }
.reaction-bar__grid .reaction-form:nth-child(5n+1) .reaction-btn { background: #ffe1e1; }
.reaction-bar__grid .reaction-form:nth-child(5n+2) .reaction-btn { background: #d9f3fb; }
.reaction-bar__grid .reaction-form:nth-child(5n+3) .reaction-btn { background: #e3f8e4; }
.reaction-bar__grid .reaction-form:nth-child(5n+4) .reaction-btn { background: #fff0cc; }
.reaction-bar__grid .reaction-form:nth-child(5n+5) .reaction-btn { background: #efe3fc; }

/* ------------------------------------------------------ wavelength spectrum */
/* The shared spectrum band: TV clueing/dialing/reveal + the psychic's phone. */
.spectrum { width: min(52rem, 92%); margin: 1.6rem auto 0; }
/* The target's pin overhangs the band top and bottom, so the phone's spectrum
   needs clearance under it or the stem draws through the hint text below —
   .phone's flex gap now provides more of it than the old margins did, and the
   two would only stack, so the phone's copy carries none of its own. */
.spectrum--phone { width: 100%; margin: 0; }
.spectrum__ends {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  font-family: var(--display);
  font-weight: 800;
  color: var(--ink);
  font-size: clamp(1rem, 2.2vw, 1.5rem);
  margin-bottom: 0.5rem;
}
.spectrum--phone .spectrum__ends, .dial .spectrum__ends { font-size: 1rem; }
/* The target flag and the team pill both live in the strip directly above the
   band — and near either end of the spectrum, that strip is exactly where an
   end label already is, so one covers the other. Give them their own lane
   wherever a spectrum draws them. The reveal's lane is the taller of the two:
   it carries the team pill, which is bigger than the target's flag. */
.spectrum--reveal .spectrum__ends { margin-bottom: 3.6rem; }
.spectrum--phone .spectrum__ends { margin-bottom: 2.5rem; }
.spectrum__band {
  position: relative;
  height: clamp(2.6rem, 6vw, 4rem);
  background: linear-gradient(90deg, #d9f3fb, var(--paper-2), #ffe1e1);
  border: 4px solid var(--outline);
  border-radius: 999px;
  box-shadow: var(--pop-sm);
  overflow: visible;
}
.spectrum--phone .spectrum__band { height: 2.4rem; }
.spectrum__zone { position: absolute; top: 0; bottom: 0; }
.spectrum__zone--1 { background: rgba(255, 194, 51, 0.45); }
.spectrum__zone--2 { background: rgba(240, 163, 30, 0.55); }
.spectrum__zone--3 { background: rgba(111, 207, 115, 0.65); }
.spectrum__zone--4 { background: var(--good); }
.spectrum__target {
  position: absolute;
  top: -0.55rem; bottom: -0.55rem;
  width: 6px;
  margin-left: -3px;
  background: var(--outline);
  border-radius: 3px;
  z-index: 3;
}
.spectrum__target-flag {
  position: absolute;
  top: -1.7rem; left: 50%;
  transform: translateX(calc(var(--label-anchor, 50) * -1%));
  font-family: var(--display);
  font-weight: 800;
  font-size: 0.95rem;
  background: var(--outline);
  color: var(--paper);
  border-radius: 999px;
  padding: 0.05em 0.55em;
  white-space: nowrap;
}
.spectrum__tick {
  position: absolute;
  top: 15%; bottom: 15%;
  width: 4px;
  margin-left: -2px;
  background: var(--sky-deep);
  border-radius: 2px;
  z-index: 2;
}
/* A name's anchor: the same geometry as a tick, but no visible bar. One dial's
   mark and one dial's label are no longer the same element, because names that
   landed together share a label while every dial keeps its own tick. */
.spectrum__tick-label {
  position: absolute;
  top: 15%; bottom: 15%;
  width: 0;
  z-index: 2;
}
.spectrum__tick-name {
  position: absolute;
  /* Which row this name sits in is packed server-side, so that names landing on
     the same patch of band step down instead of printing over each other — see
     WavelengthHelper#wavelength_dial_labels. The step has to clear a label's
     line box (~1.4rem), or "stepped down" still overlaps by a hair. */
  top: calc(100% + 0.9rem + var(--label-row, 0) * 1.65rem);
  left: 50%;
  /* Centred mid-band, but flush to its mark at either end: a label centred on a
     dial at 0 would hang off the spectrum entirely. */
  transform: translateX(calc(var(--label-anchor, 50) * -1%));
  font-size: clamp(0.75rem, 1.4vw, 1rem);
  font-weight: 800;
  color: var(--ink-soft);
  white-space: nowrap;
}
/* Only reserve height for the rows this round actually used. */
.spectrum--reveal { margin-bottom: calc(1.4rem + var(--label-rows, 1) * 1.65rem); }
.spectrum__team {
  position: absolute;
  top: -0.8rem; bottom: -0.8rem;
  width: 10px;
  margin-left: -5px;
  background: var(--red);
  border: 3px solid var(--outline);
  border-radius: 6px;
  z-index: 4;
}
.spectrum__team-label {
  position: absolute;
  bottom: calc(100% + 0.35rem); left: 50%;
  transform: translateX(calc(var(--label-anchor, 50) * -1%));
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(0.85rem, 1.6vw, 1.15rem);
  background: var(--red);
  color: #fff;
  border: 3px solid var(--outline);
  border-radius: 999px;
  padding: 0.05em 0.6em;
  white-space: nowrap;
}
/* Team dial landed on (or near) the target: raise the target's stem and flag
   above the team pill so both numbers stay readable. */
.spectrum--stacked-labels { margin-top: 3rem; }
.spectrum--stacked-labels .spectrum__target { top: -3.6rem; }
/* Stacking lifts the target's flag clear over the team pill, so the lane above
   the band has to grow with it or the flag lands back on the end labels. */
.spectrum--stacked-labels .spectrum__ends { margin-bottom: 6rem; }

/* The clue on the TV during dialing (and echoed on phones). */
.clue-banner {
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(1.6rem, 4vw, 2.8rem);
  color: var(--ink);
  background: var(--card);
  border: 4px solid var(--outline);
  border-radius: var(--radius);
  box-shadow: var(--pop-sm);
  padding: 0.5em 0.9em;
  display: inline-block;
  margin-top: 0.8rem;
  transform: rotate(-1deg);
}
/* Sits straight on the page now rather than inside a panel, so the spacing
   above and below it is .phone's flex gap rather than margins of its own. */
.clue-banner--phone { font-size: 1.25rem; display: block; text-align: center; transform: none; margin: 0; }

/* Phone dial: the range slider players drag. */
.dial { width: 100%; display: grid; gap: 0.55rem; }
.dial__slider {
  width: 100%;
  height: 2.2rem;
  margin: 0;
  appearance: none;
  -webkit-appearance: none;
  background: linear-gradient(90deg, #d9f3fb, var(--paper-2), #ffe1e1);
  border: 3px solid var(--outline);
  border-radius: 999px;
  box-shadow: var(--pop-sm);
}
.dial__slider::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  width: 2.6rem; height: 2.6rem;
  border-radius: 50%;
  background: var(--red);
  border: 3px solid var(--outline);
  box-shadow: 0 3px 0 rgba(65, 48, 31, 0.2);
  cursor: grab;
}
.dial__slider::-moz-range-thumb {
  width: 2.6rem; height: 2.6rem;
  border-radius: 50%;
  background: var(--red);
  border: 3px solid var(--outline);
  box-shadow: 0 3px 0 rgba(65, 48, 31, 0.2);
  cursor: grab;
}
.dial__readout {
  text-align: center;
  font-family: var(--display);
  font-weight: 800;
  color: var(--ink);
  font-size: 1.1rem;
}

/* -------------------------------------------------------------- hot streak */
/* TV: the grid of every player's face-up tableau. */
.hs-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 1.1rem;
  width: min(76rem, 96%);
  margin: 1.6rem auto 0;
}
.hs-player {
  background: var(--card);
  border: 4px solid var(--outline);
  border-radius: var(--radius);
  box-shadow: var(--pop-sm);
  padding: 0.9rem 1rem 1rem;
  text-align: left;
  display: grid;
  gap: 0.55rem;
  align-content: start;
}
.hs-player--turn {
  border-color: var(--sun-deep);
  animation: hs-glow 1.5s ease-in-out infinite;
}
@keyframes hs-glow {
  0%, 100% { box-shadow: var(--pop-sm), 0 0 0 0 rgba(255, 194, 51, 0.7); }
  50%      { box-shadow: var(--pop-sm), 0 0 0 12px rgba(255, 194, 51, 0); }
}
/* Hands that are out of the round sit dimmed behind their stamp. */
.hs-player--busted, .hs-player--stayed, .hs-player--frozen { opacity: 0.55; }
.hs-player--streak7 { border-color: var(--sun-deep); background: #fff4d6; }
.hs-player__head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 0.6rem;
}
.hs-player__name { font-family: var(--display); font-weight: 800; font-size: 1.25rem; }
.hs-player__bank { color: var(--ink-soft); font-weight: 800; white-space: nowrap; }
.hs-player__score {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 0.6rem;
  font-weight: 800;
}
.hs-player__score strong { font-family: var(--display); font-size: 1.3rem; }

/* Face-up cards. */
.hs-tableau {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem;
  min-height: 3rem;
}
.hs-card {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.2rem;
  height: 3rem;
  padding: 0 0.45rem;
  background: #fff;
  border: 3px solid var(--outline);
  border-radius: 10px;
  box-shadow: 0 3px 0 rgba(65, 48, 31, 0.14);
  font-family: var(--display);
  font-weight: 800;
  font-size: 1.2rem;
  color: var(--ink);
}
.hs-card--adder { background: #e3f8e4; color: var(--good); }
.hs-card--multiplier { background: #efe3fc; color: var(--grape); }
.hs-card--chance { background: #d9f3fb; }
/* On a phone the tableau is the middle of the page rather than a row inside a
   card, so it centres — including the "no cards yet" placeholder, which the
   left-aligned default left stranded against the screen edge. */
.hs-tableau--phone { justify-content: center; }
.hs-tableau--phone .hs-card { height: 2.4rem; min-width: 1.9rem; font-size: 1rem; }
.hs-empty { color: var(--ink-soft); font-size: 0.95rem; }

/* The freshly flipped card pops so a flip is obvious from the couch. */
.hs-card--fresh {
  animation: hs-flip-in 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes hs-flip-in {
  0%   { opacity: 0; transform: perspective(400px) rotateY(90deg) scale(1.6); }
  60%  { opacity: 1; transform: perspective(400px) rotateY(0) scale(1.3); }
  100% { opacity: 1; transform: none; }
}

/* The round result is held hidden during a staggered flip, then popped in once
   the last card lands (see hs_flip_controller) so the score can't run ahead. */
.hs-player__score--reveal {
  animation: hs-score-pop 0.32s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes hs-score-pop {
  0%   { opacity: 0; transform: scale(0.7); }
  100% { opacity: 1; transform: none; }
}

/* How a hand left the round. */
.hs-stamp {
  display: inline-block;
  font-family: var(--display);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 0.95rem;
  border: 3px solid currentColor;
  border-radius: 8px;
  padding: 0.05em 0.5em;
  transform: rotate(-5deg);
}
.hs-stamp--busted { color: var(--bad); }
.hs-stamp--frozen { color: var(--sky-deep); }
.hs-stamp--stayed { color: var(--good); }
.hs-stamp--active { color: var(--good); }
.hs-stamp--streak7 { color: var(--sun-deep); }

/* Phone: the big press-your-luck buttons. */
.hs-actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.8rem;
}
/* button_to wraps each action in a form, so without this the *forms* are the
   two grid halves and the buttons inside them shrink to their own labels —
   which put a narrow "Hit" next to a wide "Stay", each adrift in its half. Same
   fix as .pn-draw-actions, for the same reason. */
.hs-actions form { display: contents; margin: 0; }
.hs-actions .btn { font-size: 1.3rem; padding: 1.05em 0.5em; }
.hs-btn--hit { background: var(--red); color: #fff; box-shadow: 0 6px 0 var(--red-deep), 0 12px 20px rgba(150, 95, 35, 0.22); }
.hs-btn--hit:active { box-shadow: 0 1px 0 var(--red-deep), 0 4px 10px rgba(150, 95, 35, 0.22); }
.hs-btn--stay { background: var(--sky); color: var(--outline); box-shadow: 0 6px 0 var(--sky-deep), 0 12px 20px rgba(150, 95, 35, 0.22); }
.hs-btn--stay:active { box-shadow: 0 1px 0 var(--sky-deep), 0 4px 10px rgba(150, 95, 35, 0.22); }

/* --------------------------------------------------------------- play nine */
/* TV: every player's 2x4 grid, four columns of two stacked cards. */
.pn-tables {
  display: grid;
  /* A tableau (four cards + padding) is ~14.4rem wide; narrower tracks would
     let the cards overflow into the next card. Keep columns at least that wide
     so 8 players wrap onto a second row instead of colliding. */
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 1.1rem;
  width: min(82rem, 97%);
  margin: 1.2rem auto 0;
  justify-content: center;
}
.pn-player {
  background: var(--card);
  border: 4px solid var(--outline);
  border-radius: var(--radius);
  box-shadow: var(--pop-sm);
  padding: 0.8rem 0.9rem 0.9rem;
  display: grid;
  gap: 0.6rem;
  align-content: start;
  text-align: left;
}
.pn-player--turn { border-color: var(--sun-deep); animation: hs-glow 1.5s ease-in-out infinite; }
.pn-player--ready { border-color: var(--leaf); background: #eafbe9; }
.pn-player--out { background: #f3ecff; }
.pn-player__head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 0.2rem 0.5rem; }
.pn-player__name { font-family: var(--display); font-weight: 800; font-size: 1.15rem; min-width: 0; }
.pn-player__tag, .pn-player__bank { color: var(--ink-soft); font-weight: 800; white-space: nowrap; font-size: 0.9rem; }
.pn-player__score { display: flex; justify-content: space-between; align-items: center; font-weight: 800; color: var(--ink-soft); }
.pn-player__score strong { font-family: var(--display); font-size: 1.25rem; color: var(--ink); }

.pn-grid { display: flex; gap: 0.4rem; justify-content: center; }
.pn-col { display: flex; flex-direction: column; gap: 0.4rem; border-radius: 12px; padding: 2px; }
.pn-col--matched {
  background: repeating-linear-gradient(45deg, rgba(111, 207, 115, 0.28), rgba(111, 207, 115, 0.28) 6px, transparent 6px, transparent 12px);
  box-shadow: 0 0 0 3px var(--leaf);
}

.pn-card {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.6rem;
  height: 3.2rem;
  background: #fff;
  border: 3px solid var(--outline);
  border-radius: 10px;
  box-shadow: 0 3px 0 rgba(65, 48, 31, 0.14);
  font-family: var(--display);
  font-weight: 800;
  font-size: 1.3rem;
  color: var(--ink);
}
.pn-card--ace  { background: #efe0ff; color: var(--grape); }
.pn-card--zero { background: #fffaf0; color: var(--ink-soft); }
.pn-card--low  { background: #e3f8e4; color: var(--good); }
.pn-card--mid  { background: #fff0cf; color: var(--sun-deep); }
.pn-card--high { background: #ffe1e1; color: var(--red-deep); }
.pn-card--down {
  color: #fff;
  background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.35), transparent 40%), var(--grape);
}
/* The held card sliding into hand still uses the simple flip-in reveal. */
.pn-card--fresh { animation: hs-flip-in 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both; }

/* A single grid card mid-reveal: a real two-sided flip. Two faces (the ★ back
   and the number front) are stacked in one grid cell and share the inner's 3D
   rotation; backface-visibility hides whichever is turned away. The spin is
   opt-in (pn-flip--spin, added by the pn_flip controller for a new flip only),
   so a re-rendered card sits still on its face. Works for both the TV card
   (pn-card) and phone slot (pn-slot) sizes — the faces carry the sizing. */
.pn-flip { display: inline-flex; perspective: 600px; }
.pn-flip__inner { display: inline-grid; transform-style: preserve-3d; }
.pn-flip__face { grid-area: 1 / 1; backface-visibility: hidden; }
.pn-flip__back { transform: rotateY(180deg); }
.pn-flip--spin .pn-flip__inner { animation: pn-flip 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
@keyframes pn-flip {
  from { transform: rotateY(180deg); }
  to   { transform: rotateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .pn-flip--spin .pn-flip__inner { animation: none; }
}

/* A card named inside a sentence/button — a mini chip sized to the line so
   "Take", "In hand:", "swap in your …" clearly point at a card, not a number. */
.pn-card--inline {
  width: auto;
  min-width: 1.4em;
  height: 1.7em;
  padding: 0 0.35em;
  font-size: 1em;
  border-width: 2px;
  border-radius: 6px;
  box-shadow: 0 2px 0 rgba(65, 48, 31, 0.14);
  vertical-align: middle;
  position: relative;
  top: -0.06em;
  margin: 0 0.12em;
}

/* Draw + discard piles on the TV. */
.pn-piles { display: flex; gap: 1.6rem; justify-content: center; align-items: flex-start; margin-top: 0.4rem; }
.pn-pile { display: grid; gap: 0.35rem; justify-items: center; }
.pn-pile .pn-card { width: 3.2rem; height: 4rem; font-size: 1.7rem; }
.pn-pile__label { font-weight: 800; color: var(--ink-soft); font-size: 0.85rem; }
.pn-pile--held .pn-card { border-color: var(--sun-deep); }

/* Phone: the player's own tappable grid. button_to wraps each cell in a form;
   display:contents lets the button itself be the flex item. */
.pn-phone-grid { display: flex; gap: 0.4rem; justify-content: center; margin: 0.5rem 0; }
.pn-phone-grid .pn-col { display: flex; flex-direction: column; gap: 0.4rem; }
.pn-phone-grid form { display: contents; margin: 0; }
.pn-slot {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 3.4rem;
  height: 4rem;
  background: #fff;
  border: 3px solid var(--outline);
  border-radius: 12px;
  box-shadow: 0 3px 0 rgba(65, 48, 31, 0.14);
  font-family: var(--display);
  font-weight: 800;
  font-size: 1.5rem;
  color: var(--ink);
}
.pn-slot--down { color: #fff; background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.35), transparent 40%), var(--grape); }
.pn-slot--ace  { background: #efe0ff; color: var(--grape); }
.pn-slot--zero { background: #fffaf0; color: var(--ink-soft); }
.pn-slot--low  { background: #e3f8e4; color: var(--good); }
.pn-slot--mid  { background: #fff0cf; color: var(--sun-deep); }
.pn-slot--high { background: #ffe1e1; color: var(--red-deep); }
.pn-slot--btn { cursor: pointer; transition: transform 0.06s ease, box-shadow 0.06s ease; }
.pn-slot--btn:active { transform: translateY(3px); box-shadow: 0 1px 0 rgba(65, 48, 31, 0.14); }
/* Highlight the cells a tap can act on. */
.pn-phone-grid--place .pn-slot--btn:not(:disabled),
.pn-phone-grid--flip .pn-slot--btn:not(:disabled),
.pn-phone-grid--reveal .pn-slot--btn:not(:disabled) { outline: 3px dashed rgba(240, 163, 30, 0.7); outline-offset: 2px; }
/* A slot the current mode can't act on (e.g. a face-up card while flipping). */
.pn-slot--btn:disabled { cursor: default; opacity: 0.5; }
.pn-slot--btn:disabled:active { transform: none; box-shadow: 0 3px 0 rgba(65, 48, 31, 0.14); }

.pn-draw-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem; align-items: stretch; }
/* button_to wraps each action in a form; display:contents lets the buttons be
   the grid items so both stretch to equal height (the card chip in "Take"
   would otherwise make only that button taller). */
.pn-draw-actions form { display: contents; margin: 0; }
.pn-draw-actions .btn { font-size: 1.15rem; padding: 1em 0.5em; }
.pn-btn--draw { background: var(--grape); color: #fff; box-shadow: 0 6px 0 #7d4fc0, 0 12px 20px rgba(150, 95, 35, 0.22); }
.pn-btn--draw:active { box-shadow: 0 1px 0 #7d4fc0, 0 4px 10px rgba(150, 95, 35, 0.22); }
.pn-btn--discard { background: var(--sky); color: var(--outline); box-shadow: 0 6px 0 var(--sky-deep), 0 12px 20px rgba(150, 95, 35, 0.22); }
.pn-btn--discard:active { box-shadow: 0 1px 0 var(--sky-deep), 0 4px 10px rgba(150, 95, 35, 0.22); }
.pn-or { margin: 0.9rem 0 0.4rem; font-size: 0.9rem; }
/* The last-resort "keep my final card hidden" skip — deliberately understated
   so it reads as the fallback, not the main move. */
.pn-btn--skip { background: #fff; color: var(--outline); box-shadow: 0 5px 0 var(--outline); }
.pn-btn--skip:active { transform: translateY(3px); box-shadow: 0 2px 0 var(--outline); }

/* The draw decision: a segmented "radio" choosing what a slot tap does. The
   real radio input is hidden; its label is a chunky pill that lights up when
   checked, and the matching hint below is shown while the rest are hidden. */
.pn-mode { display: flex; gap: 0.6rem; margin: 0.2rem 0 0.6rem; }
.pn-mode__opt { flex: 1; }
.pn-mode__opt input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.pn-mode__opt span {
  display: block; text-align: center;
  padding: 0.6em 0.4em; font-weight: 800; line-height: 1.15;
  color: var(--outline); background: #fff;
  border: 3px solid var(--outline); border-radius: 14px;
  box-shadow: 0 5px 0 var(--outline); cursor: pointer;
  transition: transform 0.08s, box-shadow 0.08s, background 0.08s;
}
.pn-mode__opt:has(input:checked) span {
  background: var(--sun);
  transform: translateY(3px); box-shadow: 0 2px 0 var(--outline);
}
.pn-mode__opt:has(input:focus-visible) span {
  box-shadow: 0 5px 0 var(--outline), 0 0 0 4px rgba(69, 196, 232, 0.5);
}
.pn-decision__hint { margin: 0 0 0.2rem; }
.pn-decision__hint--flip { display: none; }
.pn-decision:has(input[value="flip"]:checked) .pn-decision__hint--place { display: none; }
.pn-decision:has(input[value="flip"]:checked) .pn-decision__hint--flip { display: block; }

/* Lobby: the host's segmented picker for how long the game runs — holes in Play
   Nine, turns each in Wordsmiths and Wavelength (see games/_length_picker).
   Each option is a button_to (its own form); the host's choice is persisted
   server-side, so the selected option is styled from that value (not fragile
   DOM state) and survives the re-renders that fire as players join. */
.length-picker { margin: 0 0 1rem; }
.length-picker__options { display: flex; gap: 0.6rem; }
.length-picker__options form { display: contents; margin: 0; }
.length-picker__option {
  /* min-width so a one-character option ("1" round, "3" holes) is still a
     comfortable target rather than a sliver, and inline padding so the digit
     never sits against its own border. */
  flex: 1; min-width: 3.2rem; text-align: center;
  padding: 0.7em 0.5em; font-size: 1.5rem; font-weight: 800; font-family: inherit;
  color: var(--outline); background: #fff;
  border: 3px solid var(--outline); border-radius: 14px;
  box-shadow: 0 5px 0 var(--outline); cursor: pointer;
  transition: transform 0.08s, box-shadow 0.08s, background 0.08s;
}
.length-picker__option.is-selected {
  background: var(--sky);
  transform: translateY(3px); box-shadow: 0 2px 0 var(--outline);
}
.length-picker__caption { margin: 0.5rem 0 0; }

/* ======================================================= party mode ======
   The room's front door: the home-screen hero, the ballot on the TV and on
   phones, and the tie-break wheel. See app/games/party/engine.rb. */

.small { font-size: 0.9rem; }

/* Home screen: party mode sits above the catalog as the way in, so it gets the
   sticker treatment and the individual games read as the alternative. */
.landing__or {
  font-size: 1.1rem; color: var(--ink-soft); font-family: var(--body);
  font-weight: 800; text-transform: lowercase; letter-spacing: 0.02em;
}
.party-hero {
  position: relative;
  width: min(34rem, 92vw);
  /* .panel carries no padding of its own; the extra on top leaves room for the
     badge straddling the edge. */
  padding: 2.1rem 1.4rem 1.4rem;
  text-align: center; display: grid; gap: 0.5rem; align-content: start;
  border-color: var(--grape);
  background: linear-gradient(180deg, #fffdf7 60%, rgba(164, 114, 232, 0.14));
}
.party-hero h2 { font-size: 2.1rem; color: var(--grape); }
.party-hero .btn { margin-top: 0.6rem; }
.party-hero__badge {
  position: absolute; top: -0.9rem; left: 50%; transform: translateX(-50%) rotate(-2deg);
  padding: 0.2em 0.9em;
  font-family: var(--display); font-weight: 800; font-size: 0.95rem;
  color: #fff; background: var(--grape);
  border: 3px solid var(--outline); border-radius: 999px;
  box-shadow: var(--pop-sm);
  white-space: nowrap;
}

/* TV: the ballot, one card per game, tallies filling in live as phones vote. */
.ballot {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
  gap: 1.1rem; width: min(72rem, 94vw); margin: 1.4rem auto 0;
}
.ballot-card {
  display: grid; gap: 0.45rem; align-content: start; text-align: left;
  padding: 0.9rem 1.1rem 1rem;
  animation: pop-in 0.3s ease-out both;
}
.ballot-card.is-leading { border-color: var(--leaf); background: #f6fff2; }
.ballot-card__head { display: flex; align-items: center; gap: 0.6rem; }
.ballot-card__head h2 { font-size: 1.5rem; color: var(--red); flex: 1; }
.ballot-card__tally {
  min-width: 2.4rem; padding: 0.05em 0.35em;
  font-family: var(--display); font-weight: 800; font-size: 1.7rem; text-align: center;
  color: var(--outline); background: var(--sun);
  border: 3px solid var(--outline); border-radius: 14px;
  box-shadow: 0 4px 0 var(--outline);
}
.ballot-card.is-leading .ballot-card__tally { background: var(--leaf); }
.ballot-card__voters { list-style: none; margin: 0.2rem 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
.ballot-card__voters li {
  padding: 0.15em 0.6em;
  font-weight: 800; font-size: 0.95rem;
  background: var(--paper-2); border: 2px solid var(--outline); border-radius: 999px;
  animation: pop-in 0.25s ease-out both;
}
.ballot-card__played {
  margin: 0.2rem 0 0;
  font-size: 0.85rem; font-weight: 800; text-transform: uppercase;
  letter-spacing: 0.05em; color: var(--ink-soft);
}
/* Phone: the ballot as a stack of buttons. button_to wraps each in its own
   form, so the forms have to carry the width. Tighter than .stack's default
   gap because these are one list rather than unrelated blocks — and a full
   six-game ballot plus the host's close-vote control has to clear the fold. */
.ballot-choices { width: 100%; gap: 0.6rem; }
.ballot-choices form { width: 100%; margin: 0; }
.ballot-choice { display: grid; gap: 0.1rem; line-height: 1.2; }
.ballot-choice__meta { font-family: var(--body); font-weight: 700; font-size: 0.85rem; opacity: 0.85; }
.ballot-choice.is-mine {
  background: var(--leaf);
  transform: translateY(3px); box-shadow: 0 2px 0 var(--outline);
}
.ballot-choice.is-mine .ballot-choice__name::after { content: " ✓"; }

/* TV: the tie-break wheel. The winner is already decided server-side — the
   light just runs the slots and stops on it (party_spin_controller.js). */
.wheel { display: grid; gap: 1.2rem; justify-items: center; margin-top: 1.4rem; }
.wheel__track {
  display: flex; flex-wrap: wrap; gap: 0.9rem; justify-content: center;
}
.wheel__slot {
  padding: 0.8rem 1.3rem;
  font-family: var(--display); font-weight: 800; font-size: 1.6rem;
  color: var(--ink); background: var(--card);
  border: 4px solid var(--outline); border-radius: 18px;
  box-shadow: var(--pop-sm);
  transition: transform 0.09s ease-out, background 0.09s;
}
.wheel__slot.is-lit {
  background: var(--sun);
  transform: translateY(-6px) scale(1.06) rotate(-1.5deg);
}
.wheel__slot.is-won {
  background: var(--leaf); color: var(--outline);
  transform: translateY(-6px) scale(1.12) rotate(0);
  animation: bounce-in 0.45s ease-out both;
}
.wheel__result { text-align: center; }
.wheel__result.is-in { animation: bounce-in 0.5s ease-out both; }
.wheel__result h2 { font-size: clamp(2rem, 5vw, 3.2rem); color: var(--red); }

/* Phone: the host's "what next" panel on any game's finished screen. */
.postgame { margin-top: 1rem; border-color: var(--grape); }
.postgame__label {
  margin: 0; font-family: var(--display); font-weight: 800; font-size: 1.3rem;
  color: var(--grape);
}
.postgame .btn { margin: 0; }
.postgame p { margin: 0; }
.postgame-hint { margin-top: 1.5rem; }

/* ------------------------------------------------------- phase countdown */
/* Shared by every game that runs a phase on a server-side clock (Letter Rip's
   writing, Turf's placing). Painted by countdown_controller.js — the server is
   what actually closes the round; this only lets the room feel it coming. */
.countdown {
  display: inline-flex;
  align-items: baseline;
  gap: 0.4rem;
  margin-top: clamp(0.6rem, 1.6vw, 1.2rem);
  padding: 0.3rem 1.2rem;
  border-radius: 999px;
  background: var(--card);
  border: 4px solid var(--outline);
  box-shadow: var(--pop-sm);
}
.countdown__clock {
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(1.8rem, 5vw, 3.4rem);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.countdown__unit {
  font-weight: 800;
  font-size: clamp(0.75rem, 1.4vw, 1rem);
  color: var(--ink-soft);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
@keyframes countdown-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.06); }
}
.countdown.is-expiring { border-color: var(--red); animation: countdown-pulse 0.9s ease-in-out infinite; }
.countdown.is-expiring .countdown__clock { color: var(--red-deep); }
.countdown.is-done { opacity: 0.5; }
/* No phone variant any more: both clocked games (Letter Rip, Turf) keep the
   clock on the TV and wear the deadline as the submit button reddening, which
   costs a phone no height at all. See .btn.is-expiring below. */

/* A button handed to countdown as an `urgent` target, for screens with no room
   for a clock: the deadline arrives as the button going red under the thumb
   rather than as a number to look up at. Animating `filter` rather than
   `transform` leaves the press squish (.btn:active) alone.
   Both exclusions are the same rule in two spellings — only shout at a button
   that has something to send. :not([disabled]) covers a move not yet aimed;
   :not(.btn--ghost) covers one already safely with the server, which in both
   clocked games is a state the player can leave and come back to all round. */
.btn.is-expiring:not([disabled]):not(.btn--ghost) {
  background: var(--red);
  box-shadow: 0 6px 0 var(--red-deep), 0 12px 20px rgba(226, 59, 72, 0.26);
  animation: btn-expiring 0.9s ease-in-out infinite;
}
.btn.is-expiring:not([disabled]):not(.btn--ghost):active {
  box-shadow: 0 1px 0 var(--red-deep), 0 4px 10px rgba(226, 59, 72, 0.26);
}
@keyframes btn-expiring {
  0%, 100% { filter: none; }
  50% { filter: brightness(1.1) saturate(1.08); }
}

/* ---- Letter Rip ---------------------------------------------------------- */

/* The drawn block, as physical tiles. This is the whole board during a writing
   phase, so it's sized to be read from the back of the room. */
.lr-letters {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(0.5rem, 1.4vw, 1.1rem);
  margin: clamp(0.8rem, 2vw, 1.6rem) 0;
}
.lr-tile {
  display: grid;
  place-items: center;
  min-width: clamp(3.2rem, 11vw, 8.5rem);
  padding: clamp(0.3rem, 1.2vw, 0.9rem) clamp(0.5rem, 1.4vw, 1rem);
  background: var(--sun);
  color: var(--outline);
  border: 5px solid var(--outline);
  border-radius: 22px;
  box-shadow: var(--pop);
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(2.4rem, 9vw, 7rem);
  line-height: 1.05;
  animation: bounce-in 0.45s ease-out both;
}
.lr-letters .lr-tile:nth-child(2) { animation-delay: 0.08s; transform: rotate(2deg); }
.lr-letters .lr-tile:nth-child(3) { animation-delay: 0.16s; transform: rotate(-2deg); }
.lr-letters .lr-tile:nth-child(4) { animation-delay: 0.24s; transform: rotate(1.5deg); }
.lr-letters--small .lr-tile {
  min-width: clamp(2rem, 5vw, 3.6rem);
  font-size: clamp(1.4rem, 4vw, 3rem);
  border-width: 4px;
  border-radius: 16px;
  box-shadow: var(--pop-sm);
}
/* The phone's copy of the block sits straight on the page rather than inside a
   panel, so .phone's flex gap is doing the spacing and .lr-letters' own margin
   would only be stacking on top of it. */
.lr-letters--phone { margin: 0; }
.lr-letters--phone .lr-tile {
  min-width: 3rem;
  font-size: 2.6rem;
  border-width: 4px;
  border-radius: 16px;
  box-shadow: var(--pop-sm);
}
/* What your word is worth, and who's still writing: one thought in two lines,
   so they're a single flex child spaced tighter than the gap between blocks. */
.lr-status { display: grid; gap: 0.35rem; }
/* Their own margins would stack on the grid gap and un-pair them again. */
.lr-status p { margin: 0; }

/* What the two tiers pay, side by side, because the choice between them is the
   game and the board is where it gets taught. */
.lr-payouts {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(0.6rem, 1.6vw, 1.2rem);
}
.lr-payout {
  display: grid;
  gap: 0.15em;
  background: var(--card);
  border: 3px solid var(--sky);
  border-radius: 18px;
  padding: 0.5rem 1.1rem;
  color: var(--ink-soft);
  font-weight: 800;
  font-size: clamp(0.8rem, 1.5vw, 1.1rem);
}
.lr-payout strong {
  font-family: var(--display);
  font-size: 1.6em;
  color: var(--sky-deep);
}
.lr-payout--bonus { border-color: var(--grape); }
.lr-payout--bonus strong { color: var(--grape); }

/* The shape of an answer, in place of a sentence about one: the block's letters
   run together for the in-order payout, spaced apart for the loose one. */
.lr-payout__shape {
  font-family: var(--display);
  letter-spacing: 0.06em;
  color: var(--ink);
  white-space: nowrap;
}

/* The in-order run, picked out inside a word: con(TRA)ct. A background tint
   rather than an underline, which detaches from the text at the phone's
   display sizes. */
.lr-run {
  color: var(--grape);
  background: rgba(164, 114, 232, 0.18);
  border-radius: 0.15em;
  padding: 0 0.06em;
}
.clue-card.struck .lr-run { color: inherit; background: none; }

.lr-card--bonus { background: #f6efff; border-color: var(--grape) !important; }
.lr-points {
  display: block;
  font-family: var(--body);
  font-weight: 800;
  font-size: 0.42em;
  color: var(--good);
  margin-top: 0.3em;
}
.lr-points--bonus { color: var(--grape); }
.lr-inline-bonus { color: var(--grape); }

/* Reveal flourishes: the sole longest word, and the first one in. They're worth
   no points (see Letterrip::Round#longest_submission), so they read as a quiet
   pill under the name rather than competing with the points chip or the grape
   in-order highlight. Sized in rem on the phone and in em inside a clue-card,
   whose own font-size is viewport-scaled. */
.lr-badges {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35em;
  justify-content: center;
  margin-top: 0.5em;
}
.lr-badges--phone { margin-top: 0.7rem; }
.lr-badge {
  font-family: var(--body);
  font-weight: 800;
  font-size: 0.78rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--ink-soft);
  background: rgba(152, 130, 108, 0.14);
  border-radius: 999px;
  padding: 0.28em 0.7em;
  white-space: nowrap;
}
.clue-card .lr-badge { font-size: 0.34em; }

/* Phone, host only: the appeal court on the reveal screen. */
.lr-rulings { display: grid; gap: 0.4rem; }
.lr-ruling {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  padding: 0.35rem 0.2rem;
  border-bottom: 2px dashed rgba(65, 48, 31, 0.14);
}
.lr-ruling:last-child { border-bottom: none; }
.lr-ruling__word { font-family: var(--display); font-weight: 700; font-size: 1.15rem; }
.lr-ruling__word .muted { font-family: var(--body); font-size: 0.8rem; }
.lr-ruling__actions { display: flex; gap: 0.35rem; flex: none; }
.lr-ruling__actions form { margin: 0; }
.lr-ruling__btn {
  width: 2.4rem;
  height: 2.4rem;
  border: 3px solid var(--outline);
  border-radius: 12px;
  background: var(--card);
  color: var(--ink);
  font-weight: 800;
  font-size: 1rem;
  cursor: pointer;
}
.lr-ruling__btn.is-on { background: var(--sun); box-shadow: var(--pop-sm); }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation: none !important; }
}

/* ---- Turf ---------------------------------------------------------------- */

/* The grid. One element, three jobs: the TV's read-only board, the reveal's
   annotated board, and the phone's tappable one — so the square a player aims
   at on their phone is drawn by exactly the code that draws it on the TV. */
.tf-board {
  display: grid;
  /* minmax(0, …) rather than a bare 1fr: a bare fr floors each track at the
     cell's min-content width (the initial plus its padding), which on the
     narrow --small board is wider than the track's share — so the grid grew
     past its own frame and the last columns were drawn outside the border. */
  grid-template-columns: repeat(var(--tf-size), minmax(0, 1fr));
  gap: clamp(2px, 0.5vw, 5px);
  width: min(52vh, 560px);
  max-width: 100%;
  padding: clamp(0.4rem, 1vw, 0.8rem);
  background: var(--card);
  border: 5px solid var(--outline);
  border-radius: 22px;
  box-shadow: var(--pop);
}
.tf-cell {
  aspect-ratio: 1;
  border-radius: 18%;
  background: #f0e2c6;
  display: grid;
  place-items: center;
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(0.5rem, calc(34vh / var(--tf-size)), 2rem);
  /* Ownership is carried by colour AND this initial — see TurfHelper. Ink at
     half strength reads on every one of the eight, which white doesn't. */
  color: rgba(65, 48, 31, 0.5);
  line-height: 1;
  /* The star pip on a claimed prize hangs off the corner. */
  position: relative;
  transition: background-color 0.25s ease;
}
.tf-cell.is-empty { color: transparent; }

/* The eight. Ordered so that neighbours in the roster are never neighbours on
   the colour wheel — the first four have to be told apart from across a room
   even in a two-player game. Held as variables as well as classes so a phone can
   point at its own player's colour by seat (see the placement preview). */
:root {
  --tf-seat-0: #ff5d5d;
  --tf-seat-1: #45c4e8;
  --tf-seat-2: #6fcf73;
  --tf-seat-3: #ffc233;
  --tf-seat-4: #a472e8;
  --tf-seat-5: #ff8fc0;
  --tf-seat-6: #3ba5a0;
  --tf-seat-7: #ff8a3d;
  /* The Grey (see Turf::Grey) — one past the eight, so it can never collide
     with a roster colour. Slate rather than the mud brown: mud is a square
     nobody has, the Grey is a square somebody has and it isn't you. */
  --tf-seat-8: #8b93a1;
}
.tf-seat-0 { background: var(--tf-seat-0); }
.tf-seat-1 { background: var(--tf-seat-1); }
.tf-seat-2 { background: var(--tf-seat-2); }
.tf-seat-3 { background: var(--tf-seat-3); }
.tf-seat-4 { background: var(--tf-seat-4); }
.tf-seat-5 { background: var(--tf-seat-5); }
.tf-seat-6 { background: var(--tf-seat-6); }
.tf-seat-7 { background: var(--tf-seat-7); }
.tf-seat-8 { background: var(--tf-seat-8); }

/* Paint that landed this round, and paint two people went for. The reveal is
   the only screen that marks either.
   The rim outlasts the splat on purpose: the animation is over in half a second
   and somebody is always mid-sentence when the board flips, so without a mark
   that stays, a square just taken off the leader is indistinguishable from one
   held since round one — and taking squares off people is the game. */
.tf-cell.is-new {
  animation: tf-splat 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
  /* The pale band alone vanishes on the light seats (green, yellow, teal), so
     it's backed by a faint ink line: the rim then has an edge on all eight
     colours while still reading as the light mark next to a steal's solid dark
     one. Both have to work at the 22px cells an eight-player reveal gets. */
  box-shadow:
    inset 0 0 0 3px rgba(255, 255, 255, 0.9),
    inset 0 0 0 4px rgba(65, 48, 31, 0.4);
}
/* Fresh ground gets the pale rim above; a square taken off somebody gets the
   dark one, so the theft reads differently from the land grab. */
.tf-cell.is-steal { box-shadow: inset 0 0 0 3px var(--outline); }
/* A square its owner painted again while somebody else was going for it: it
   stayed theirs and the attacker's cell was spent on nothing. Both rims, dark
   outside — a steal is the dark edge, fresh paint is the pale one, and a square
   that was held is the two of them stacked, which is legible at the 22px cells
   an eight-player reveal gets where a third colour of rim would not be.
   It bumps rather than splats: nothing arrived here, something bounced off. */
.tf-cell.is-held {
  box-shadow:
    inset 0 0 0 3px var(--outline),
    inset 0 0 0 6px rgba(255, 255, 255, 0.85);
  animation: tf-clang 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.tf-cell.is-mud {
  background:
    repeating-linear-gradient(45deg, rgba(65, 48, 31, 0.22) 0 3px, transparent 3px 7px),
    #b3a695;
  animation: tf-splat 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* A prize square: worth Engine::PRIZE_VALUE instead of one, and on an open
   board the only reason to paint here rather than there. A ring rather than a
   fill, so the square still reads as whoever's colour is on it — and the star
   moves to the corner once an initial has the middle. The colour override has to
   come after .is-empty (same specificity, later wins), or an unclaimed prize's
   star inherits the transparent text every empty cell gets. */
.tf-cell.is-prize {
  outline: 3px solid var(--sun);
  outline-offset: -3px;
  color: rgba(65, 48, 31, 0.8);
}
.tf-cell.is-prize:not(.is-empty)::after {
  content: "★";
  position: absolute;
  top: 0;
  right: 0.12em;
  font-size: 0.6em;
  line-height: 1;
  color: rgba(65, 48, 31, 0.8);
}

/* The defence: struck, and still there. Starts big rather than small — a splat
   arrives from nowhere, this was already on the board. */
@keyframes tf-clang {
  0% { transform: scale(1.2) rotate(4deg); }
  45% { transform: scale(0.93) rotate(-2deg); }
  100% { transform: scale(1) rotate(0); }
}

@keyframes tf-splat {
  0% { transform: scale(0.3) rotate(-12deg); opacity: 0.2; }
  60% { transform: scale(1.12) rotate(3deg); }
  100% { transform: scale(1) rotate(0); opacity: 1; }
}

/* Phone: the same grid, but every cell is a target — so it takes the screen's
   whole width rather than a share of it. It used to claw that width back from a
   .panel with negative margins; the panel is gone (the grid's own frame was
   doing the same job one border out), so plain full width is all it needs now.
   The cap only bites above phone widths, where the grid would otherwise keep
   growing to the 540px .phone limit. */
.tf-board--phone {
  width: 100%;
  max-width: 30rem;
  margin: 0 auto;
}
.tf-board--phone .tf-cell {
  border: none;
  padding: 0;
  font: inherit;
  font-family: var(--display);
  font-weight: 800;
  color: rgba(65, 48, 31, 0.5);
  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}
/* Where your paint would land, in your own colour — the phone's board is set
   to --tf-mine by the placing view. Tinting rather than merely outlining is the
   point: under a clock you're checking whose squares you're about to cover, and
   an outline says "selected" while a colour says "yours". */
.tf-cell.is-preview {
  background: var(--tf-mine, var(--sun));
  box-shadow: inset 0 0 0 3px var(--outline);
  transform: scale(1.06);
  z-index: 1;
}
/* The finale's board. Wide enough that a ten-by-ten still has legible initials
   from a sofa — with the grid now honouring its frame rather than bursting out
   of it, the frame is what has to give. */
.tf-board--small { width: min(38vh, 340px); }
/* Scaled off the small board's own width, or a 10x10 recap keeps the full
   board's initials and the glyphs spill out of their squares. */
.tf-board--small .tf-cell { font-size: clamp(0.35rem, calc(22vh / var(--tf-size)), 1.3rem); }

/* The three cards on offer, drawn as the squares they cover. Same offer for the
   whole table, which is why the TV shows them at all. */
.tf-cards {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(0.5rem, 1.4vw, 1rem);
}
.tf-card {
  position: relative;
  display: grid;
  place-items: center;
  min-width: 4.4rem;
  min-height: 4.4rem;
  padding: 0.7rem;
  background: var(--card);
  border: 4px solid var(--outline);
  border-radius: 18px;
  box-shadow: var(--pop-sm);
  cursor: pointer;
  touch-action: manipulation;
}
.tf-card.is-selected { background: var(--sun); transform: rotate(-2deg) scale(1.04); }
/* How many people have locked this card in, plus the Grey's dot when it's
   holding it — the two things that used to sit under the row in widgets of
   their own. Pinned to the corner and drawn only when there's something to say,
   so one arriving mid-round can't shift the row under a thumb mid-aim. */
.tf-card__heat {
  position: absolute;
  top: -0.6rem;
  right: -0.6rem;
  display: inline-flex;
  align-items: center;
  gap: 0.25em;
  min-width: 1.6rem;
  min-height: 1.6rem;
  padding: 0 0.4em;
  background: var(--sun);
  border: 3px solid var(--outline);
  border-radius: 999px;
  font-family: var(--display);
  font-weight: 800;
  font-size: 0.85rem;
  line-height: 1;
  color: var(--outline);
  justify-content: center;
}
/* The badge is yellow on a white card; the selected card is yellow, so there it
   flips. Either way it has to read as sitting on top of the card rather than
   melting into it. */
.tf-card.is-selected .tf-card__heat { background: var(--card); }
/* The row can't clip a badge that hangs off its cards. */
.tf-cards { padding: 0.6rem 0.6rem 0; }
.tf-thumb {
  display: grid;
  grid-template-columns: repeat(var(--tf-thumb-cols), 0.85rem);
  gap: 3px;
}
.tf-thumb__cell { width: 0.85rem; height: 0.85rem; border-radius: 4px; }
.tf-thumb__cell.is-on { background: var(--ink); }

/* Phone: the placing controls under the grid. */
.tf-place { display: grid; gap: 0.9rem; }
/* Turn and lock side by side rather than stacked — one row of height instead of
   two, and lock takes the slack because it's the one you're aiming for. */
.tf-actions { display: flex; align-items: stretch; gap: 0.6rem; }
.tf-actions .btn { flex: 1; }
.tf-rotate {
  justify-self: center;
  padding: 0.45em 1.4em;
  background: var(--sky);
  border: 4px solid var(--outline);
  border-radius: 999px;
  font-family: var(--display);
  font-weight: 800;
  font-size: 1.05rem;
  color: var(--outline);
  box-shadow: var(--pop-sm);
  cursor: pointer;
}
.tf-hint { text-align: center; color: var(--ink-soft); font-size: 0.9rem; font-weight: 700; margin: 0; }

/* Which cards have been locked in, and by how many people (see the heat
   partial). Dim until somebody takes one, because "nobody yet" is the state it
   spends most of a round in and it shouldn't shout. */
/* The TV's only. The chips are the cards, sized to be read from the sofa rather
   than as a footnote under a second row of the same three shapes. */
.tf-heat {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(0.5rem, 1.8vw, 1.1rem);
}
.tf-heat__item {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  padding: 0.35em 1em;
  font-size: clamp(1.1rem, 2vw, 1.6rem);
  background: var(--card);
  border: 4px solid var(--outline);
  border-radius: 999px;
  font-family: var(--display);
  font-weight: 800;
  opacity: 0.5;
}
.tf-heat__item.is-hot { opacity: 1; background: var(--sun); }
.tf-heat__item .tf-thumb { grid-template-columns: repeat(var(--tf-thumb-cols), 0.8rem); gap: 3px; }
.tf-heat__item .tf-thumb__cell { width: 0.8rem; height: 0.8rem; border-radius: 4px; }

/* The Grey, announced. Both screens name the card it's holding and none of them
   name the square, which is the whole of what a duel gets to read. */
.tf-grey-note {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.5em;
  margin: 0;
  color: var(--ink-soft);
  font-weight: 700;
}
/* A shape thumb sitting in a sentence rather than on a card: small, and inline
   so it doesn't push the line apart. Not *too* small — the note is down to
   "The Grey is playing <shape>", so the shape is the whole of the payload. */
.tf-grey-note .tf-thumb,
.muted .tf-thumb {
  display: inline-grid;
  grid-template-columns: repeat(var(--tf-thumb-cols), 0.5rem);
  gap: 2px;
  align-self: center;
  vertical-align: middle;
}
.tf-grey-note .tf-thumb__cell,
.muted .tf-thumb__cell { width: 0.5rem; height: 0.5rem; border-radius: 3px; }
/* On a phone the note is the width of the screen, so it wraps mid-sentence and
   the pieces have to stay on one line each. */
.tf-grey-note { font-size: 0.95rem; line-height: 1.5; }

/* A score, broken into the three things it adds up: squares, the patch counted
   again, and the stars. Small and grey — the total is the number being read
   across a room, this is the receipt underneath it. */
.tf-score__parts {
  margin-left: 0.5em;
  color: var(--ink-soft);
  font-size: 0.66em;
  font-weight: 700;
  white-space: nowrap;
}
/* A star square changing hands, said out loud. Gold, because it's the one line
   on the reveal about the squares worth three. */
.tf-prize-news { color: var(--sun-deep); margin-top: -0.4rem; }

/* A round's result per player: colour, name, squares won, squares lost to mud. */
.tf-tallies {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(0.4rem, 1.2vw, 0.9rem);
}
.tf-tally {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  background: var(--card);
  border: 3px solid var(--outline);
  border-radius: 999px;
  padding: 0.3em 0.9em;
  font-weight: 800;
  font-size: clamp(0.8rem, 1.5vw, 1.1rem);
}
/* Shape that didn't land: churned into mud, or turned away by the square's
   owner. Same weight on the row — both are a shape that came to nothing — but
   named apart, because only one of them left a mark on the board. */
.tf-tally__mud,
.tf-tally__blocked { color: var(--ink-soft); font-size: 0.85em; }
/* Squares lost elsewhere on the board this round. Sits beside the gain because
   the gain on its own is a half-truth — a clean +4 is often a net loss. */
.tf-tally__lost { color: var(--red-deep); }
/* Squares this painter held by painting them again while somebody came for
   them. Dark ink rather than the gain's green: nothing was won, something
   wasn't lost, and it reads next to the "held" rim on the grid above. */
.tf-tally__held { color: var(--outline); }
/* What one round did to one painter, tucked in after their name on the reveal's
   standings row. Small: the row's job is the running total, and this is the
   working that got it there. */
.tf-delta {
  display: inline-flex;
  align-items: baseline;
  gap: 0.5em;
  margin-left: 0.55em;
  font-size: 0.85em;
  font-weight: 800;
  white-space: nowrap;
}
.tf-delta__note { color: var(--ink-soft); font-weight: 700; font-style: italic; }
/* A player's colour, inline beside their name. Needs an explicit box because it
   sits in flowing text on the standings list as well as in flex rows. */
.tf-dot {
  display: inline-block;
  vertical-align: -0.12em;
  width: 1em;
  height: 1em;
  border-radius: 5px;
  border: 2px solid var(--outline);
  flex: none;
}

/* ---- Turf: the count track ----------------------------------------------- */

/* Every third round the board is counted and banked (Turf::Engine::CHECKPOINT_EVERY),
   which is a rule the room has to be able to *see* — a payout you have to
   remember is a payout nobody plays towards. So both screens draw the same
   thing: a pip per round, grouped into threes, with the count that closes each
   group sitting between the groups. Rendered by games/turf/_track. */
.tf-track {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: clamp(0.35rem, 1vw, 0.7rem);
}
/* Three rounds and their payout, boxed together so the grouping is the first
   thing read rather than something counted out. */
.tf-track__block {
  display: inline-flex;
  align-items: center;
  gap: clamp(0.2rem, 0.5vw, 0.35rem);
  padding: 0.28em 0.4em;
  background: var(--card);
  border: 3px solid var(--outline);
  border-radius: 999px;
  box-shadow: var(--pop-sm);
}
.tf-track__round,
.tf-track__count {
  display: grid;
  place-items: center;
  font-family: var(--display);
  font-weight: 800;
  line-height: 1;
  border: 2px solid var(--outline);
}
.tf-track__round {
  width: clamp(1.3rem, 2.4vw, 1.9rem);
  height: clamp(1.3rem, 2.4vw, 1.9rem);
  border-radius: 7px;
  font-size: clamp(0.6rem, 1.1vw, 0.9rem);
  background: var(--paper-2);
  color: var(--ink-soft);
}
/* Played and done with. */
.tf-track__round.is-played { background: var(--ink-soft); color: var(--card); }
/* The round on the table. Ringed rather than filled — it isn't finished, and
   filling it would say it was. */
.tf-track__round.is-now {
  background: var(--sun);
  color: var(--outline);
  box-shadow: 0 0 0 3px rgba(240, 163, 30, 0.45);
}
/* The payout marker: wider than a round pip, because it isn't one. */
.tf-track__count {
  width: clamp(1.6rem, 3vw, 2.4rem);
  height: clamp(1.3rem, 2.4vw, 1.9rem);
  border-radius: 8px;
  font-size: clamp(0.65rem, 1.2vw, 1rem);
  background: var(--paper);
  color: var(--ink-soft);
}
/* Banked. Green and ticked: this one is in the bag and can't be taken back,
   which is the entire point of the mechanic. */
.tf-track__count.is-paid { background: var(--good); color: #fff; border-color: var(--outline); }
/* The one being played towards. */
.tf-track__count.is-next {
  background: var(--sun);
  color: var(--outline);
  animation: tf-next-count 2s ease-in-out infinite;
}
@keyframes tf-next-count {
  0%, 100% { box-shadow: 0 0 0 0 rgba(240, 163, 30, 0.6); }
  50%      { box-shadow: 0 0 0 6px rgba(240, 163, 30, 0); }
}
/* On a phone the track shares a screen with the grid, so it loses the round
   numbers' breathing room rather than the numbers themselves. */
.tf-track--compact { gap: 0.3rem; }
.tf-track--compact .tf-track__block { padding: 0.2em 0.3em; gap: 0.15rem; }
.tf-track--compact .tf-track__round { width: 1.15rem; height: 1.15rem; font-size: 0.6rem; border-radius: 5px; }
.tf-track--compact .tf-track__count { width: 1.5rem; height: 1.15rem; font-size: 0.65rem; }

/* The count landing, on the TV. The loudest thing on a reveal, because it's the
   only screen where points stop being provisional. */
.tf-bank-banner {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6em;
  margin: 0.2rem auto 0.6rem;
  padding: 0.45em 1.1em;
  background: var(--sun);
  border: 4px solid var(--outline);
  border-radius: 999px;
  box-shadow: var(--pop);
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(1rem, 2.1vw, 1.7rem);
  color: var(--outline);
}
.tf-bank-banner__star { font-size: 1.2em; line-height: 1; }

/* The banked-vs-holding strip on the TV's placing screen. Reuses the round
   tally chips — same shape, different pair of numbers. */
.tf-bankline { margin-top: 0.2rem; }
.tf-bankline .tf-tally { font-size: clamp(0.8rem, 1.4vw, 1.1rem); }

/* ---- Turf: the two coins -------------------------------------------------- */

/* A Turf player carries two numbers and they mean completely different things:
   what they've *banked*, which is the score and is safe, and what the board is
   worth to them right now, which is not a score at all — it's what the next
   count will pay if the board still looks like this when it arrives.
   They used to be told apart by the words under them ("banked", "on the
   board"), which on a phone under a 45-second clock made the labels the
   noisiest thing on the screen. So the difference is carried by the shape
   instead: the bank is a solid coin with a tick, the board is a hollow one with
   a dashed edge and a star — and those are exactly the two marks the count
   track underneath draws for a payout that's been made and one still to come.
   One thing to learn, in three places. */
.tf-coin {
  display: inline-flex;
  align-items: center;
  gap: 0.3em;
  padding: 0.25em 0.7em;
  border-radius: 999px;
  font-family: var(--display);
  font-weight: 800;
  line-height: 1;
  white-space: nowrap;
}
.tf-coin__mark { font-size: 0.8em; opacity: 0.85; }
.tf-coin__num { font-variant-numeric: tabular-nums; }
/* Banked: filled, and ticked. It can't be taken back, and it shouldn't look
   like it could. Green is the ticked count's green on the track. */
.tf-coin--bank {
  background: var(--good);
  border: 3px solid var(--outline);
  color: #fff;
  box-shadow: var(--pop-sm);
}
/* On the board: hollow, dashed, starred. Provisional by every cue available —
   nothing is filled in, because nothing has been paid. */
.tf-coin--live {
  background: transparent;
  border: 3px dashed var(--sun-deep);
  color: var(--sun-deep);
}
/* What just landed on a coin: the count that paid on this round's reveal, or
   (on the phone, live) what the shape being lined up would add. */
.tf-coin__delta {
  padding: 0 0.35em;
  border-radius: 999px;
  background: var(--card);
  color: var(--good);
  font-size: 0.75em;
  animation: tf-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.tf-coin__delta.is-down { color: var(--red-deep); }
/* A move that gains nothing — which is what a defence is, and worth seeing
   before you commit to one rather than after. */
.tf-coin__delta.is-flat { color: var(--ink-soft); }
.tf-coin--live .tf-coin__delta { background: var(--sun); color: var(--outline); }
/* The live number while a shape is being lined up over it: this is a projection
   and not what the board says yet. */
.tf-coin__num.is-projected { text-decoration: underline dotted; text-underline-offset: 0.25em; }
/* A coin being paid — the phone's version of the TV's count-up, which it has no
   room for and no table to sequence against. */
.tf-coin.is-banking { animation: tf-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
@keyframes tf-pop {
  0% { transform: scale(0.4); opacity: 0; }
  70% { transform: scale(1.15); }
  100% { transform: scale(1); opacity: 1; }
}
/* The pair spelled out once, on the TV's placing screen, using the coins
   themselves — a legend made of the thing it explains needs no key. */
.tf-legend {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  font-size: clamp(0.65rem, 1.1vw, 0.85rem);
  opacity: 0.85;
}
.tf-legend .tf-coin { font-size: inherit; }

/* The phone's pair, under the grid — everything above the board is a row the
   thumb has to scroll past to reach the game. */
.tf-bank {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.5rem 0.8rem;
  padding: 0.6rem 0.9rem;
  background: var(--card);
  border: 3px solid var(--outline);
  border-radius: 18px;
  box-shadow: var(--pop-sm);
  font-size: 1.4rem;
}
/* The round this all pays out on. Worth shouting about on the phone, where the
   TV's banner isn't in view — but gold text on a gold panel is gold text
   nobody can read, so the hollow coin takes the ink outline here instead. */
.tf-bank.is-counting { background: var(--sun); }
.tf-bank.is-counting .tf-coin--live { border-color: var(--outline); color: var(--outline); }
.tf-bank.is-counting .tf-bank__rank { color: var(--outline); }
.tf-bank__rank { font-family: var(--display); font-weight: 800; font-size: 0.8rem; color: var(--ink-soft); }
.tf-bank__track { flex-basis: 100%; display: flex; justify-content: center; }

/* ---- Turf: the leaderboard ------------------------------------------------ */

/* A column per round of the whole game, played or not, so the table never
   reflows under the room; the count rounds tinted, because those are the only
   ones that paid; and Banked on the right, which is exactly the tinted cells
   added up. See games/turf/_standings. */
/* Framed like every other widget on the board, so a table of numbers doesn't
   read as the one thing on the screen that fell out of the design. */
.tf-table-wrap {
  width: fit-content;
  max-width: 100%;
  overflow-x: auto;
  margin: 0.8rem auto 0;
  padding: 0.5rem 1rem 0.7rem;
  background: var(--card);
  border: 4px solid var(--outline);
  border-radius: 20px;
  box-shadow: var(--pop);
}
.tf-table {
  margin: 0 auto;
  border-collapse: separate;
  border-spacing: 0;
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(0.75rem, 1.5vw, 1.2rem);
  font-variant-numeric: tabular-nums;
}
/* Twelve rounds is four more columns than six, on the same television. */
.tf-table--long { font-size: clamp(0.65rem, 1.2vw, 1rem); }
.tf-table th,
.tf-table td { padding: 0.42em 0.5em; text-align: center; }
.tf-table thead th {
  font-size: 0.72em;
  color: var(--ink-soft);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  border-bottom: 3px solid var(--outline);
  padding-bottom: 0.45em;
}
.tf-table tbody tr + tr th,
.tf-table tbody tr + tr td { border-top: 2px solid rgba(65, 48, 31, 0.12); }
.tf-col__n { display: block; }
/* The crown and the place sit on one line however narrow the column gets — a
   crown that wraps onto its own row reads as a rendering fault. */
.tf-col-rank { text-align: right; color: var(--ink-soft); white-space: nowrap; }
.tf-col-who { text-align: left; white-space: nowrap; padding-right: 0.8em; }
.tf-col-who .tf-dot { margin-right: 0.4em; }
.tf-col-round { min-width: 1.9em; color: var(--ink-soft); }
/* The rounds that pay. Gold while they're still to come, green once banked —
   the track's own two states, so the header row *is* the track and there is no
   separate one on these screens. */
.tf-col-round.is-count { background: rgba(240, 163, 30, 0.16); color: var(--ink); }
.tf-col-round.is-count.is-paid { background: rgba(74, 165, 96, 0.18); }
thead .tf-col-round.is-count { color: var(--sun-deep); }
thead .tf-col-round.is-count.is-paid { color: var(--good); }
.tf-col__mark { display: block; font-size: 0.9em; line-height: 1; }
/* The round that just landed: ringed, not filled — the number inside it is
   still moving. */
.tf-col-round.is-now { box-shadow: inset 0 0 0 3px var(--outline); border-radius: 8px; }
.tf-col-delta { text-align: left; padding-left: 0.9em; white-space: nowrap; }
.tf-col-delta .tf-delta { margin-left: 0; }
/* The same chips on the player's own phone, where they are the round's whole
   score rather than one column of a table. */
.tf-mydelta { margin: 0; font-size: 1.15rem; }
.tf-mydelta .tf-delta { margin-left: 0; }
/* Every round added up: the tie-break, on the one screen where a tie is final.
   Quiet, because it is not the score — it only decides who is above whom when
   the score can't. */
.tf-col-ground { color: var(--ink-soft); padding-left: 0.9em; }
.tf-col-bank { padding-left: 0.7em; }
.tf-cell-blank { color: var(--ink-soft); opacity: 0.5; }
/* A row whose number is mid-count, so it can be picked out from a sofa on a
   ten-row table. */
.tf-table tr.is-ticking td,
.tf-table tr.is-ticking th { background: rgba(240, 163, 30, 0.12); }
/* The Grey painted, but it scores for nobody — the row is there so a grey
   square has something on the table to blame, not to be read across. */
.tf-row-grey { opacity: 0.75; }
/* The tie-break, named on the finale — two rows showing the same total with a
   crown on only one of them reads as a bug otherwise. */
.tf-tiebreak { color: var(--sun-deep); margin-top: 0.3rem; }

/* ---- Turf: the awards ------------------------------------------------------

   Three trophies where the finale used to draw the final grid. The grid was a
   picture of one moment that didn't explain the total above it; these are the
   things the leaderboard has no column for, which is what gives the finale
   something to say to the four people who didn't win.

   Laid out as three fixed columns rather than a wrapping flex row: the count is
   always Awards::PICKS, and a row that reflows to 2 + 1 on a narrow TV reads as
   one prize being more important than the others. */
.tf-awards { margin: 1rem 0 0.6rem; }
.tf-awards__head {
  font-size: clamp(1rem, 1.8vw, 1.4rem);
  color: var(--ink-soft);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-align: center;
}
/* Bounded and centred so the three cards sit over the leaderboard below rather
   than spanning wider than it — they took the final grid's place, and the grid
   was a block in the middle of the screen, not a band across it. */
.tf-awards__list {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0.8rem;
  max-width: 56rem;
  margin: 0.6rem auto 0;
  padding: 0;
  list-style: none;
}
.tf-award {
  padding: 1rem 0.9rem 1.1rem;
  border: 3px solid var(--outline);
  border-radius: var(--radius);
  background: var(--card);
  box-shadow: var(--pop-sm);
  text-align: center;
}
.tf-award__icon { font-size: clamp(2rem, 3.6vw, 3rem); line-height: 1; }
.tf-award__title {
  margin: 0.35rem 0 0;
  font-family: var(--display);
  font-size: clamp(1.05rem, 2vw, 1.6rem);
}
.tf-award__who { margin: 0.45rem 0 0; }
/* The winner's name in their own paint, so a trophy can be matched to a row of
   the table underneath without reading either. The seat classes carry a
   background, so this needs the ink that goes with one. */
.tf-award__name {
  display: inline-block;
  padding: 0.1em 0.6em;
  border: 2px solid var(--outline);
  border-radius: 999px;
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(0.9rem, 1.6vw, 1.25rem);
  color: #fff;
  text-shadow: 0 1px 2px rgba(65, 48, 31, 0.45);
}
.tf-award__name + .tf-award__name { margin-left: 0.25rem; }
/* The evidence. An award nobody can check is an award nobody believes, so the
   number is the loudest thing on the card after the name. */
.tf-award__detail {
  margin: 0.45rem 0 0;
  font-family: var(--display);
  font-weight: 700;
  font-size: clamp(0.85rem, 1.5vw, 1.2rem);
  color: var(--sun-deep);
}
.tf-award__blurb {
  margin: 0.3rem 0 0;
  color: var(--ink-soft);
  font-size: clamp(0.75rem, 1.2vw, 0.98rem);
  line-height: 1.3;
}
/* The same card on a phone, where there's only ever this player's own and it
   gets the full width. */
.tf-award--mine { text-align: center; }
.tf-award--mine .tf-award__icon { font-size: 2.6rem; }
.tf-award--mine .tf-award__title { font-size: 1.3rem; }
.tf-award--mine .tf-award__detail { font-size: 1rem; }
.tf-award--mine .tf-award__blurb { font-size: 0.85rem; }
/* Column headings that are only there for a screen reader. */
.tf-hide {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ================================================ post-game rating ======
   Platform-wide, on every game's finished screen: five stars on the phone
   (games/_rating) and the room's running average on the TV
   (games/_ratings_tally). Every control is its own form — no JavaScript — so
   the styling has to make a row of one-button forms read as one widget. */
.rate { margin-top: 1rem; border-color: var(--sun-deep); }
.rate__label { margin: 0; font-family: var(--display); font-size: 1.2rem; text-align: center; }
.rate__stars { display: flex; justify-content: center; gap: 0.35rem; }
.rate__star-form { margin: 0; }
/* Big enough to hit without looking, since this is the screen where everybody
   is already halfway to the kitchen. */
.rate__star {
  border: 0;
  background: none;
  padding: 0.1rem 0.15rem;
  font-size: 2.6rem;
  line-height: 1;
  cursor: pointer;
  color: var(--ink-soft);
  opacity: 0.55;
  transition: transform 0.08s ease, opacity 0.12s ease;
}
.rate__star.is-on { color: var(--sun-deep); opacity: 1; }
.rate__star:active { transform: scale(0.88); }
.rate__tags {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.4rem;
  margin-top: 0.2rem;
}
.rate__tag-form { margin: 0; }
.rate__tag {
  border: 2px solid var(--outline);
  border-radius: 999px;
  background: var(--card);
  color: var(--ink);
  padding: 0.55rem 0.9rem;
  font-family: var(--display);
  font-size: 0.9rem;
  font-weight: 700;
  cursor: pointer;
}
.rate__tag.is-on { background: var(--sun); box-shadow: var(--pop-sm); }

/* The TV's half. Quiet: it sits under a finale that has just crowned somebody,
   and it is not the result. */
.rate-tally { margin-top: 1.4rem; text-align: center; }
.rate-tally__stars {
  font-size: clamp(1.6rem, 3.4vw, 2.6rem);
  line-height: 1;
  color: var(--sun-deep);
  letter-spacing: 0.1em;
}
.rate-tally__off { color: var(--ink-soft); opacity: 0.35; }
.rate-tally__tags { display: inline-flex; flex-wrap: wrap; gap: 0.4rem; margin-left: 0.5rem; }
.rate-tally__tag {
  border: 2px solid var(--outline);
  border-radius: 999px;
  background: var(--card);
  color: var(--ink);
  padding: 0.1rem 0.6rem;
  font-size: 0.75em;
}

/* ---- Hunt ------------------------------------------------------------------

   Team-vs-team hide and seek in a house (docs/hunt-design.md). Two rules
   govern everything below.

   1. Icons are styled with `color`, never `fill` or `stroke`. The furniture is
      two sources — Phosphor's paths carry fill="currentColor", ours carry
      stroke="currentColor" — and `color` is the one property both follow. A rule
      using `fill:` silently misses half the house.
   2. Colour never carries meaning alone. Every character card is stamped with
      its owner's initial, and each of the three states a character can be in
      (hidden / saved / burned) has a shape or a weight as well as a hue —
      burned in particular is struck through, because "gone" and "not found yet"
      must never be one glance apart.                                            */

:root {
  --hn-red: #e2564a;
  --hn-blue: #3f9fd0;
  --hn-paper: #fbf7f0;
  --hn-shut: color-mix(in srgb, var(--ink) 8%, transparent);
}

.hn-team-0, .hn-team-0.hn-roster { --hn-team: var(--hn-red); }
.hn-team-1, .hn-team-1.hn-roster { --hn-team: var(--hn-blue); }
.hn-team-0 { color: var(--hn-red); }
.hn-team-1 { color: var(--hn-blue); }

.hn-icon { display: inline-flex; width: 1em; height: 1em; }
.hn-icon svg { width: 100%; height: 100%; display: block; }
.hn-icon--rel { width: 1.1em; height: 1.1em; opacity: 0.75; flex: none; }

.hn-headline {
  font-size: clamp(1.6rem, 4vw, 2.6rem); font-weight: 800; text-align: center;
  margin: 0.4rem 0 0.2rem; letter-spacing: -0.01em;
}
.hn-headline--count { opacity: 0.85; }

/* ---- the cutaway house --------------------------------------------------- */

/* Rooms as labelled boxes, floors as rows. Deliberately schematic: the picture
   carries identity and mood, never information — nobody's position is drawn
   during play, so it only ever has to say "that's the bathroom" from a sofa. */
.hn-house {
  display: flex; flex-direction: column; gap: 6px; margin: 0.9rem 0;
  border: 3px solid var(--line-strong, currentColor); border-radius: 10px;
  padding: 8px; background: color-mix(in srgb, var(--ink) 3%, transparent);
}
.hn-floor { display: flex; align-items: stretch; gap: 8px; }
.hn-floor.is-shut { opacity: 0.28; }
.hn-floor__name {
  flex: 0 0 6.5rem; font-size: 0.72rem; text-transform: uppercase;
  letter-spacing: 0.09em; opacity: 0.65; align-self: center;
}
.hn-floor__rooms { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; flex: 1; }
.hn-room {
  position: relative; border: 2px solid color-mix(in srgb, var(--ink) 30%, transparent);
  border-radius: 7px; padding: 5px 6px 6px; min-height: 4.2rem;
  display: flex; flex-direction: column; gap: 4px;
}
.hn-room__name { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.02em; }
.hn-room__stuff {
  display: flex; flex-wrap: wrap; gap: 4px; font-size: 1.15rem; opacity: 0.5;
}
.hn-room__spots { position: absolute; inset: 0; pointer-events: none; }

/* A pip is the only bright thing on the screen, which is what makes a find land
   from across the room. */
/* Several characters can share a place, so the anchor holds a pile that fans
   out from it rather than stacking one pip invisibly on another. */
.hn-pile {
  position: absolute; display: flex; gap: 1px;
  transform: translate(-50%, -50%); pointer-events: none;
}
.hn-pip {
  width: 1.3rem; height: 1.3rem; flex: none;
  border-radius: 50%; display: grid; place-items: center;
  font-size: 0.66rem; font-weight: 800; color: #fff;
  background: var(--hn-team, var(--ink)); box-shadow: 0 0 0 2px var(--hn-paper);
}
.hn-pip.is-saved { animation: hn-flash 0.5s ease-out 3; }
/* Burned is struck through as well as ringed: gone must not read as unfound. */
.hn-pip.is-burned {
  animation: hn-flash 0.5s ease-out 3;
  box-shadow: 0 0 0 2px var(--hn-paper), 0 0 0 4px var(--ink);
  text-decoration: line-through;
}
.hn-pip.is-hidden { opacity: 0.5; }
@keyframes hn-flash { 50% { transform: scale(1.5); } }
@media (prefers-reduced-motion: reduce) { .hn-pip { animation: none !important; } }

/* ---- the roster strips --------------------------------------------------- */

.hn-rosters { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 0.8rem 0; }
.hn-roster {
  border: 3px solid var(--hn-team); border-radius: 10px; padding: 8px 10px;
  background: color-mix(in srgb, var(--hn-team) 7%, transparent);
}
.hn-roster__head { display: flex; justify-content: space-between; align-items: baseline; }
.hn-roster__name { font-weight: 800; color: var(--hn-team); letter-spacing: 0.03em; }
.hn-roster__score { font-size: 1.5rem; font-weight: 800; }
.hn-roster__of { font-size: 0.62rem; text-transform: uppercase; opacity: 0.6; }
.hn-roster__cards { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.hn-roster__left { font-size: 0.72rem; opacity: 0.7; margin-top: 5px; }

/* Dark = still hidden, lit = found by a teammate. The whole race, without
   revealing a single position. */
.hn-card {
  display: inline-flex; align-items: center; gap: 5px;
  border-radius: 999px; padding: 3px 10px 3px 3px;
  border: 2px solid var(--hn-team); font-size: 0.78rem; font-weight: 600;
}
.hn-card__initial {
  width: 1.5rem; height: 1.5rem; border-radius: 50%; display: grid; place-items: center;
  font-weight: 800; font-size: 0.78rem;
}
.hn-card.is-hidden { opacity: 0.45; }
.hn-card.is-hidden .hn-card__initial { background: color-mix(in srgb, var(--ink) 18%, transparent); }
.hn-card.is-saved { background: var(--hn-team); color: #fff; border-color: var(--hn-team); }
.hn-card.is-saved .hn-card__initial { background: rgba(255,255,255,0.28); }
/* Burned: still visible, plainly finished, and not mistakable for either of the
   other two — struck through, drained of colour, dashed rather than solid. */
.hn-card.is-burned {
  opacity: 0.7; border-style: dashed; text-decoration: line-through;
  border-color: color-mix(in srgb, var(--ink) 45%, transparent);
}
.hn-card.is-burned .hn-card__initial {
  background: color-mix(in srgb, var(--ink) 55%, transparent); color: var(--hn-paper);
}
.hn-card.is-dog { font-style: italic; }

/* ---- the clue cards ------------------------------------------------------
   The two clues are the only thing anybody is reading on the spotlight screen,
   so they get the whole of it. Sized to be read across a room; the cutaway
   house deliberately isn't on that screen to compete with them. */

.hn-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 0.9rem 0; }
.hn-clue-card {
  border: 3px solid var(--hn-team); border-radius: 12px; padding: 12px 14px;
  background: color-mix(in srgb, var(--hn-team) 8%, transparent);
  display: flex; flex-direction: column; gap: 8px; min-height: 7rem;
}
.hn-clue-card__who {
  display: flex; align-items: center; gap: 7px;
  font-weight: 800; color: var(--hn-team); font-size: 0.95rem;
}
.hn-clue-card__who .hn-card__initial {
  background: color-mix(in srgb, var(--hn-team) 25%, transparent);
}
.hn-clue-card__clue {
  font-size: 1.7rem; font-weight: 700; line-height: 1.2; flex: 1;
  display: flex; align-items: center;
}
/* Read on the first clue, and holding its secret through the second window:
   the card says it is done and nothing else — never by whom, never where. */
.hn-clue-card.is-settled { opacity: 0.55; border-style: dashed; }
/* Your own clue, on your own phone. A mark rather than a sentence — the screen
   already has its one instruction and it isn't this. */
.hn-clue-card.is-you { box-shadow: 0 0 0 3px color-mix(in srgb, var(--hn-team) 30%, transparent); }
.hn-clue-card__settled {
  flex: 1; display: grid; place-items: center;
  font-size: 1.2rem; font-weight: 800; letter-spacing: 0.05em;
}
.hn-clue-card__worth {
  font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.65;
}

.hn-guessing {
  display: flex; align-items: baseline; justify-content: center; gap: 0.5em;
  margin: 0.5rem 0;
}
.hn-guessing__count { font-size: 1.6rem; font-weight: 800; }
.hn-guessing__label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.6; }

/* ---- the reveal ----------------------------------------------------------
   Where the vocabulary is actually learned: the spot named, the clue that did
   or didn't work, and who read it. */

.hn-reveal { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 0.9rem 0; }
.hn-reveal-card {
  border: 3px solid var(--hn-team); border-radius: 12px; padding: 12px 14px;
  display: flex; flex-direction: column; gap: 7px;
}
.hn-reveal-card.is-saved { background: color-mix(in srgb, var(--hn-team) 14%, transparent); }
/* Burned reads as finished rather than as unfound: dashed and drained, the same
   grammar the roster cards use. */
.hn-reveal-card.is-burned {
  border-style: dashed;
  border-color: color-mix(in srgb, var(--ink) 45%, transparent);
}
.hn-reveal-card.is-missed { opacity: 0.7; }
.hn-reveal-card__who {
  display: flex; align-items: center; gap: 7px; font-weight: 800; color: var(--hn-team);
}
.hn-reveal-card__who .hn-card__initial {
  background: color-mix(in srgb, var(--hn-team) 25%, transparent);
}
.hn-reveal-card__spot {
  font-size: 1.35rem; font-weight: 800;
  display: flex; align-items: center; gap: 0.35em;
}
.hn-reveal-card__verdict { font-size: 1rem; }
.hn-reveal-card__verdict strong { font-size: 1.3rem; }
.hn-reveal-card__readers { display: flex; flex-wrap: wrap; gap: 5px; }
/* Right, and paid nothing for it: the exclusive save rule biting. Struck
   through rather than explained — the host names it once, the first time it
   happens in a game. */
.hn-chip.is-beaten { opacity: 0.5; text-decoration: line-through; border-style: dashed; }
.hn-reveal-card__clues { display: flex; flex-direction: column; gap: 3px; font-size: 0.8rem; opacity: 0.7; }
/* The clue that actually paid, marked so the room can see which rung landed. */
.hn-clue-line.is-scored { opacity: 1; font-weight: 700; }

/* ---- the phone ----------------------------------------------------------- */

.hn-you {
  border: 3px solid var(--line-strong, currentColor); border-radius: 12px;
  padding: 0.7rem 0.9rem; text-align: center;
}
.hn-you.is-saved { border-color: var(--hn-blue); background: color-mix(in srgb, var(--hn-blue) 10%, transparent); }
.hn-you.is-burned { border-style: dashed; opacity: 0.75; }
.hn-you.is-missed { opacity: 0.6; }
/* A guess is spent. What it was and where it went — never whether it landed,
   which nobody learns until the window closes. */
.hn-you.is-locked { border-style: dashed; opacity: 0.8; }
.hn-you__label { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.11em; opacity: 0.6; }
.hn-you__spot {
  font-size: 1.28rem; font-weight: 800; display: flex; align-items: center;
  justify-content: center; gap: 0.4em; margin: 0.15rem 0;
}
.hn-you__room { font-size: 0.8rem; opacity: 0.65; }

.hn-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin: 0.5rem 0; }
.hn-chip {
  border: 2px solid color-mix(in srgb, var(--ink) 35%, transparent);
  border-radius: 999px; padding: 3px 11px; font-size: 0.82rem; font-weight: 600;
}

/* The private result of a look or a listen. Not an error, despite arriving down
   the platform's error channel — that channel is simply "re-render this one
   phone with something to read", which is exactly what a secret guess needs. */
.hn-result {
  text-align: center; font-weight: 700; padding: 0.55rem 0.8rem; border-radius: 9px;
  background: color-mix(in srgb, var(--ink) 8%, transparent);
}

/* ---- writing the two clues ---------------------------------------------- */

.hn-clues { gap: 0.4rem; }
.hn-clue-box {
  width: 100%; font-family: inherit; font-size: 1rem; padding: 11px 12px;
  border: 2px solid color-mix(in srgb, var(--ink) 25%, transparent);
  border-radius: 8px; background: none; color: inherit;
}
.hn-clue-box:focus { border-color: var(--hn-blue); outline: none; }
/* The chips are a floor under a clue, never the clue itself — so they look
   like something you tap, and they append rather than replace. */
.hn-chip--tap { background: none; color: inherit; font-family: inherit; cursor: pointer; }

/* ---- one guess, two targets --------------------------------------------- */

.hn-aims { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 0.4rem 0 0.7rem; }
.hn-aim {
  border: 2px solid var(--hn-team); border-radius: 10px; background: none;
  color: inherit; font-family: inherit; font-weight: 700; font-size: 0.9rem;
  padding: 10px 8px; display: flex; flex-direction: column; gap: 3px; align-items: center;
}
.hn-aim.is-selected {
  background: var(--hn-team); color: #fff;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--hn-team) 30%, transparent);
}
.hn-aim__verb { font-size: 0.72rem; font-weight: 600; opacity: 0.8; }

.hn-picker { display: flex; flex-direction: column; gap: 4px; }
.hn-picker__floor {
  font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.11em;
  opacity: 0.55; margin-top: 0.5rem;
}
.hn-pick-room { border-bottom: 1px solid color-mix(in srgb, var(--ink) 15%, transparent); }
.hn-pick-room summary {
  display: flex; justify-content: space-between; padding: 9px 4px;
  font-weight: 700; cursor: pointer;
}
.hn-pick-room__n { opacity: 0.45; font-weight: 500; }
.hn-pick-spots { display: flex; flex-direction: column; gap: 4px; padding: 0 0 8px; }
.hn-pick-spots .button_to { margin: 0; }
.hn-spot {
  width: 100%; display: flex; align-items: center; gap: 0.5em; text-align: left;
  border: 2px solid color-mix(in srgb, var(--ink) 22%, transparent); background: none;
  /* 11px of padding on 0.92rem text is a ~44px row: this is the only control in
     Hunt, tapped under a clock, in a list of a dozen. */
  color: inherit; border-radius: 8px; padding: 11px 12px; font-size: 0.92rem;
  font-family: inherit;
}
.hn-spot.is-here { border-color: var(--hn-blue); border-width: 3px; font-weight: 700; }

.hn-swaps { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.hn-swaps .button_to { margin: 0; }
.hn-swap {
  border: 2px solid currentColor; border-radius: 999px; background: none;
  padding: 9px 14px; font-size: 0.85rem; font-weight: 700; font-family: inherit;
}
.hn-scoreline {
  display: flex; gap: 1.4rem; justify-content: center; font-size: 1.05rem;
  font-weight: 600;
}
.hn-scoreline strong { font-size: 1.5rem; }
.hn-scoreline--tight { gap: 1rem; font-size: 0.85rem; margin: 0.3rem 0; }
.hn-scoreline--tight strong { font-size: 1.05rem; }

.hn-table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
.hn-table th {
  font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.09em;
  opacity: 0.6; text-align: right; padding: 6px 10px;
}
.hn-table th:first-child { text-align: left; }
.hn-table td { padding: 9px 10px; border-top: 2px solid color-mix(in srgb, var(--ink) 15%, transparent); font-weight: 700; }
.hn-table .hn-num { text-align: right; font-variant-numeric: tabular-nums; }
.hn-table .hn-num--big { font-size: 1.4rem; }
.hn-table tr.is-winner td { background: color-mix(in srgb, var(--ink) 6%, transparent); }
.hn-table td .muted { font-weight: 400; font-size: 0.8rem; display: block; }

@media (max-width: 700px) {
  .hn-cards, .hn-reveal, .hn-aims { grid-template-columns: 1fr; }
  .hn-floor__name { flex-basis: 4rem; }
  .hn-rosters { grid-template-columns: 1fr; }
}

/* ---- Kalookie ---------------------------------------------------------------
   Contract rummy. Three things carry the look: a playing card that reads as a
   playing card at both distances (a TV across a room and a phone in a hand), a
   meld drawn as a joined-up strip rather than loose cards, and the bidding
   tokens as pips. Everything else borrows the platform's furniture.        */

.kl-card {
  display: inline-flex;
  align-items: baseline;
  justify-content: center;
  gap: 0.1em;
  min-width: 2.6rem;
  padding: 0.32rem 0.4rem;
  background: var(--card);
  border: 3px solid var(--outline);
  border-radius: 10px;
  box-shadow: var(--pop-sm);
  font-family: var(--display);
  font-weight: 800;
  font-size: 1.35rem;
  line-height: 1.1;
  white-space: nowrap;
}
.kl-card--red { color: var(--red-deep); }
.kl-card--black { color: var(--ink); }
.kl-card--joker { color: var(--grape); background: #f6efff; }
.kl-card--sm { min-width: 2rem; padding: 0.2rem 0.3rem; font-size: 1rem; border-width: 2px; border-radius: 8px; }
.kl-card--hero { min-width: 6rem; padding: 1rem 0.8rem; font-size: 3.4rem; border-width: 5px; border-radius: 18px; }
/* The joker wears what it's playing: the wild face over the card it became. */
.kl-card__face { font-size: 0.95em; }
.kl-card__as { font-size: 0.62em; opacity: 0.75; }

/* ---- the table ------------------------------------------------------------ */

.kl-table {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: 0.7rem;
  width: min(82rem, 97%);
  margin: 1rem auto 0;
}
.kl-table__player {
  background: var(--card);
  border: 3px solid var(--outline);
  border-radius: 18px;
  box-shadow: var(--pop-sm);
  padding: 0.5rem 0.6rem 0.6rem;
  text-align: left;
}
.kl-table__name { font-family: var(--display); font-weight: 800; font-size: 0.95rem; color: var(--ink-soft); }
.kl-table__melds { display: flex; flex-wrap: wrap; gap: 0.45rem; margin-top: 0.35rem; }

/* A meld is one thing, so its cards overlap into a strip rather than sitting
   apart as a row of separate cards. */
.kl-meld {
  display: inline-flex;
  padding: 0.25rem 0.3rem;
  border-radius: 12px;
  background: rgba(65, 48, 31, 0.06);
}
.kl-meld .kl-card { margin-right: -0.35rem; min-width: 2rem; padding: 0.2rem 0.3rem; font-size: 1rem; border-width: 2px; }
.kl-meld .kl-card:last-child { margin-right: 0; }
.kl-meld--run { background: rgba(69, 196, 232, 0.16); }
.kl-meld--set { background: rgba(255, 194, 51, 0.2); }

/* ---- the auction ----------------------------------------------------------- */

.kl-auction { display: grid; justify-items: center; gap: 0.7rem; margin: 0.8rem 0 0.4rem; }

.kl-verdict {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0.6rem auto 0;
  padding: 0.4rem 0.9rem;
  border: 3px solid var(--outline);
  border-radius: 999px;
  background: var(--card);
  box-shadow: var(--pop-sm);
  font-weight: 800;
}
.kl-verdict--won { background: #eafbe9; }
.kl-verdict--taken { background: #fff3d4; }

/* ---- the seats strip ------------------------------------------------------- */

.kl-seats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
  gap: 0.55rem;
  width: min(82rem, 97%);
  margin: 1rem auto 0;
}
.kl-seat {
  background: var(--card);
  border: 3px solid var(--outline);
  border-radius: 16px;
  box-shadow: var(--pop-sm);
  padding: 0.45rem 0.6rem 0.5rem;
  text-align: left;
  display: grid;
  gap: 0.2rem;
}
.kl-seat--turn { border-color: var(--sun-deep); animation: hs-glow 1.5s ease-in-out infinite; }
.kl-seat--down { background: #eafbe9; }
.kl-seat__head { display: flex; justify-content: space-between; align-items: baseline; gap: 0.4rem; }
.kl-seat__name { font-family: var(--display); font-weight: 800; font-size: 1rem; min-width: 0; }
.kl-seat__total { font-weight: 800; color: var(--ink-soft); }
.kl-seat__row { display: flex; justify-content: space-between; align-items: center; gap: 0.4rem; font-weight: 800; }
.kl-seat__mark { min-height: 1.1em; font-weight: 800; color: var(--ink-soft); }

.kl-tokens { letter-spacing: 0.05em; color: var(--grape); font-size: 0.9rem; }

.kl-contract-chips { letter-spacing: 0.08em; }
.kl-contract-chip { color: var(--sky-deep); }

/* ---- the round recap ------------------------------------------------------- */

.kl-caught { display: grid; gap: 0.4rem; width: min(64rem, 96%); margin: 1rem auto 0; }
.kl-caught__row {
  display: grid;
  grid-template-columns: 8rem 1fr 4rem;
  align-items: center;
  gap: 0.6rem;
  padding: 0.35rem 0.7rem;
  background: var(--card);
  border: 3px solid var(--outline);
  border-radius: 14px;
  text-align: left;
}
.kl-caught__row--out { background: #eafbe9; }
.kl-caught__name { font-family: var(--display); font-weight: 800; }
.kl-caught__cards { display: flex; flex-wrap: wrap; gap: 0.2rem; }
.kl-caught__points { font-family: var(--display); font-weight: 800; font-size: 1.2rem; text-align: right; }

.kl-final { width: min(70rem, 96%); margin: 1.2rem auto 0; overflow-x: auto; }
.kl-final__table { width: 100%; border-collapse: collapse; font-weight: 800; }
.kl-final__table th, .kl-final__table td { padding: 0.35rem 0.6rem; text-align: center; }
.kl-final__table thead th { color: var(--ink-soft); font-size: 0.9rem; }
.kl-final__table tbody tr { background: var(--card); }
.kl-final__table tbody tr.is-winner { background: #eafbe9; }
.kl-final__name { text-align: left; font-family: var(--display); }
.kl-final__total { font-family: var(--display); font-size: 1.2rem; }
.kl-final__table td.is-out { color: var(--good); }

/* ---- the phone ------------------------------------------------------------- */

.kl-hand { display: flex; flex-wrap: wrap; gap: 0.3rem; justify-content: center; }
.kl-hand--still { opacity: 0.9; }
.kl-hand button.kl-card { cursor: pointer; font: inherit; font-family: var(--display); font-weight: 800; font-size: 1.15rem; }
.kl-hand button.kl-card.is-selected { border-color: var(--sky-deep); transform: translateY(-4px); }
.kl-hand button.kl-card.is-spent { opacity: 0.3; }

.kl-turn { display: grid; gap: 0.7rem; }
.kl-turn__got { margin: 0; }

.kl-offer { display: grid; justify-items: center; }
.kl-choice { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.kl-choice form { margin: 0; }
.kl-choice .btn { width: 100%; }

.kl-bids { display: flex; flex-wrap: wrap; gap: 0.4rem; justify-content: center; }
.kl-bids form { margin: 0; }
.kl-bid {
  min-width: 3rem;
  padding: 0.6rem 0.7rem;
  background: var(--sun);
  border: 3px solid var(--outline);
  border-radius: 14px;
  box-shadow: var(--pop-sm);
  font-family: var(--display);
  font-weight: 800;
  font-size: 1.2rem;
  color: var(--ink);
  cursor: pointer;
}
.kl-bid--pass { background: var(--card); color: var(--ink-soft); }
/* The clock has no face on this screen — it reddens the row instead. */
.kl-bids.is-expiring .kl-bid { background: var(--red); color: #fff; }
.kl-bids.is-expiring .kl-bid--pass { background: var(--card); color: var(--ink-soft); }

.kl-mode { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; }
.kl-mode__opt {
  padding: 0.5rem;
  background: var(--card);
  border: 3px solid var(--outline);
  border-radius: 14px;
  font-family: var(--display);
  font-weight: 800;
  color: var(--ink-soft);
  cursor: pointer;
}
.kl-mode__opt.is-on { background: var(--sky); color: #fff; }

.kl-build { display: grid; gap: 0.55rem; }
.kl-slots { display: grid; gap: 0.4rem; }
.kl-slot {
  display: grid;
  grid-template-columns: 5.5rem 1fr 1.4rem;
  align-items: center;
  gap: 0.4rem;
  min-height: 3rem;
  padding: 0.35rem 0.5rem;
  background: rgba(65, 48, 31, 0.05);
  border: 3px dashed var(--ink-soft);
  border-radius: 14px;
}
.kl-slot.is-good { border-style: solid; border-color: var(--good); background: #eafbe9; }
.kl-slot.is-bad { border-style: solid; border-color: var(--bad); background: #ffeef0; }
.kl-slot__label { font-weight: 800; color: var(--ink-soft); font-size: 0.85rem; }
.kl-slot__cards { display: flex; flex-wrap: wrap; gap: 0.2rem; min-height: 1.6rem; }
.kl-slot__mark { font-weight: 800; text-align: center; }
.kl-build__aux { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; }

.kl-plays { display: grid; gap: 0.4rem; }
.kl-play-group { display: grid; gap: 0.35rem; }
.kl-plays form { margin: 0; }

.kl-brief {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 0.3rem 0.6rem;
  padding-top: 0.4rem;
  border-top: 2px dashed rgba(65, 48, 31, 0.2);
  font-weight: 800;
  color: var(--ink-soft);
  font-size: 0.9rem;
}
/* On a phone the table is reference, not the subject — it sits under the hand
   at a size you can scan rather than read. */
.phone .kl-table { grid-template-columns: 1fr; width: 100%; margin-top: 0.4rem; gap: 0.4rem; }
.phone .kl-meld .kl-card { min-width: 1.7rem; font-size: 0.85rem; }
