/* ============================================================================
   duel.css — SKIN ONLY. Every token lives in system.css (loaded first) and all
   zone geometry in layout.css (loaded last, so it wins). What's here is colour,
   type, fills and component skins. The die-cut sticker construction the info-box
   and p-stats are both printed with is a shared primitive — system.css › THE
   PRESS — and each component only sets its inputs.
   ============================================================================ */

/* ── reset (trimmed from Josh Comeau's): box-sizing, no UA margins, block
     media, form controls inherit the page font ── */
*,
*::before,
*::after {
  box-sizing: border-box;
}
* {
  margin: 0;
}
img,
picture,
svg {
  display: block;
  max-width: 100%;
}
input,
button,
textarea,
select {
  font: inherit;
}
body {
  height: 100svh;
  display: grid;
  place-items: center;
  overflow: hidden; /* board is a fixed frame — nothing ever scrolls the page */
  font: var(--fs-m)/1.4 sans-serif;
  color: #000;
  background: #fff;
}
/* §3 board layout: sidebar | play area | deck+discard rail.
     Capped at 1920×1080, centered, framed by a single black line. */
.board {
  width: min(100vw, 1920px);
  height: min(100svh, 1080px);
  border: 1px solid #000;
  display: grid;
  /* minmax(0,1fr): a plain 1fr is minmax(AUTO,1fr), so a nowrap card row's
       min-content width would grow the column and shove the deck/discard rail
       off-screen — and inflate the width pack() measures, so it never overlaps.
       The 0 floor pins the play column to its track, letting pack() do the work. */
  grid-template-columns: var(--side-w) minmax(0, 1fr) var(--side-w);
  overflow: hidden; /* clip hands spilling past the frame; tooltips still cross columns inside */
}

/* §3.1 / §3.5 game sidebar drawer: logs on top, stats below. Opaque so it reads
   over the board when slid open (geometry/slide in layout.css). */
.sidebar {
  background: #fff;
  border-right: 1px solid #000;
  padding: 12px 14px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 24px;
  text-align: center;
  min-height: 0;
}
/* hamburger: a dithered-fill circle, matching the board's B&W chrome (size in
   layout.css). */
.nav-toggle {
  border: 1px solid #000;
  border-radius: 50%;
  background: var(--fill-light);
  line-height: 1;
  cursor: pointer;
}
/* §3.1 lobby exit + fullscreen toggle — pinned above the log */
.sidebar-links {
  align-self: flex-end; /* clear of the fixed top-left hamburger */
  display: flex;
  gap: 12px;
  font-size: var(--fs-s);
}
/* pinned to the drawer's bottom-left corner, below whatever the log leaves */
.sidebar-foot {
  align-self: flex-start;
  margin-top: auto;
  font-size: var(--fs-s);
}
/* per-seat p-stats skin — a die-cut sticker cluster printed by the same press as
   the info-box (system.css › THE PRESS; geometry in layout.css). Four moves, at
   a quarter of the info-box's size, on a component that must stay glanceable at
   --die 28px:

   1. LOOSE STICKERS, NOT A PANEL. The score die is a kiss-cut sticker (white
      body, hand-cut ink outline, backed by the dithered ink edge); the three
      round pips are its satellites — tiny stickers with the same white kiss-cut
      margin + ink cut line, tilted ±3° like the info-box's mark pills.
   2. TEXTURE IS THE STATE. Hatch = an unclaimed round. Halftone dots = printed
      ink. Solid = nothing. So the score plate and the won pips are real dot
      screens, and winning a round is the dots growing until the ink closes
      (Anim.stamp).
   3. THE CORNER MARKS ARE TORN LABEL STRIPS. Each carries a word rather than a
      shape, so they need no teaching.
   4. THE NUMBER IS ITSELF DIE-CUT, so it stays legible sitting on its own
      halftone plate — the plate states the ink, the knockout states the number,
      and neither has to give ground.

   Everything that MEANS something prints in ink: the cut lines, the numeral, the
   plate, the hatch that says "round unclaimed" — p-stats carries no mood, so none
   of it may borrow a mood's colour (DESIGN.md › The Color-Means-Something Rule).
   The single exception is the ink edge, whose fill states depth and nothing else;
   see --edge-fill below. Text is never filtered — only the decoration warps. */
.pstats {
  /* the ink cut line, floored so it survives the smallest board */
  --hair: max(1px, calc(var(--die) / 58));
  --ink: var(--hair);
  --pp: max(2.4px, calc(var(--die) / 22)); /* pip screen pitch */
}
/* the satellites: the tile IS the sticker, so the white kiss-cut margin is its
   border and the hand-cut ink line its outline (the info-box's .sats recipe,
   verbatim). Nothing inside carries text, so the whole thing can take the
   displacement filter — pips are the one place the wobble is free. */
.pstats .pip {
  position: relative;
  background-color: #fff;
  background-image: var(--hatch);
  background-size: 5px 5px;
  background-clip: padding-box; /* the hatch stops at the kiss-cut margin */
  border: var(--cut-s) solid #fff;
  outline: var(--ink) solid #000;
  border-radius: var(--radius-s);
  filter: url(#deckle-s);
}
.pstats .pip:nth-child(odd) {
  rotate: -3deg;
}
.pstats .pip:nth-child(even) {
  rotate: 2.5deg;
}
/* a won round is PRINTED, not filled: a dot screen at full coverage, so the
   dots just overlap and leave the pinprick interstices a press leaves. --r is
   what Anim.stamp animates up from zero; this is also the resting value, so the
   static and animated states are one construction and a snap re-render can't
   disagree with the beat. */
.pstats .pip.won {
  --r: calc(var(--pp) * 0.58);
  background-color: #fff;
  background-image: radial-gradient(
    circle at 50% 50%,
    #000 var(--r),
    #0000 calc(var(--r) + 0.5px)
  );
  background-size: var(--pp) var(--pp);
  background-clip: padding-box;
}
/* the score sticker: the die keeps its exact geometry (layout.css); the body and
   the hand-cut outline move out to .cut, inflated past it by --cut, with the
   dithered ink edge under that. The hatch is gone from here — it now means
   "unclaimed round" and nothing else. */
.pstats .score {
  --ink: calc(var(--hair) * 1.7);
  --pitch: max(3px, calc(var(--die) / 15)); /* plate screen pitch */
  --plate-h: calc(var(--die) * 0.48);
  --dot: #000;
  --deckle: url(#deckle-m);
  /* the one accent of colour in the cluster — see --fill-wheel in system.css for
     why the depth edge is allowed to carry the whole wheel.
     Deliberately NOT applied to the info-box's edge: that sticker sits under a card
     whose colour already means that card's mood, and a second colour under it would
     compete with the one that has something to say. */
  --edge-fill: var(--fill-wheel);
  /* asymmetric corners so it reads cut-by-hand before the filter even lands;
     the filter only has to sell the wobble along the edges. */
  --cut-corners: calc(var(--die) * 0.17) calc(var(--die) * 0.24)
    calc(var(--die) * 0.19) calc(var(--die) * 0.27) / calc(var(--die) * 0.24)
    calc(var(--die) * 0.17) calc(var(--die) * 0.26) calc(var(--die) * 0.2);
  isolation: isolate; /* keeps the z-index:-1 shell out of the hand strip */
  font-weight: 800;
  letter-spacing: -0.015em;
}
/* the die-cut numeral: ink on paper, wearing the same white kiss-cut margin
   every sticker here wears, so it reads at full contrast over the densest band
   of its own plate. text-shadow is the floor; paint-order puts the stroke BEHIND
   the glyph where it's supported, which is what keeps the numeral's real weight
   instead of eroding it. */
.pstats .score .num {
  position: relative;
  color: #000;
  font-variant-numeric: tabular-nums;
  /* off --u, like the font-size it wraps: this margin belongs to the glyph, not
     to the paper the glyph is printed on. */
  text-shadow:
    0 0 calc(var(--u) / 27) #fff,
    0 0 calc(var(--u) / 27) #fff;
}
@supports (paint-order: stroke) {
  .pstats .score .num {
    -webkit-text-stroke: calc(var(--u) / 24) #fff;
    paint-order: stroke fill;
    text-shadow: none;
  }
}
/* three digits in a square die: the box can't grow (it would shove the hand
   strip on every render), so the numeral gives way instead. data-digits is set
   by pstatsEl and re-set from the TARGET value by the ratcheting beats, so a
   9 → 12 tick doesn't leave this step stale mid-animation. */
.pstats .score[data-digits="3"] {
  font-size: calc(var(--u) * 0.34);
}
/* the torn label strips. No displacement filter — they carry text; the scissor
   cut is the hand-made cue, and it crosses the sticker's outline where it
   passes. Tilted the other way from the info-box's nameplate (-1.4deg): same
   scissors, not the same hand. */
.pstats .firstmark {
  /* the whole wheel, for the same reason the die's edge takes it: this strip
     claims no mood, and being every hue at once is how it says so
     (DESIGN.md › The Color-Means-Something Rule). White text clears it. */
  background: var(--wubrg);
  border: var(--cut-s) solid #fff; /* the strip's own kiss-cut margin */
  border-radius: 0;
  rotate: 6deg;
  clip-path: var(--torn);
}
/* the quiet one of the pair: flat black against the wheel, and tilted back the
   other way so that when both are up they read as two strips slapped on at two
   angles rather than one label repeated. */
.pstats .firstmark.hurt {
  background: #000;
  rotate: -6deg;
}
.pstats .firstmark span {
  color: #fff;
  /* subordinate to the score by a clear step: it answers a yes/no, the numeral
     is what you actually read. Its size is --fm (layout.css), which also sets how
     far the strip hangs off the corner — the two can't be tuned apart, because the
     cap has a legibility floor and the strip has to clear the numeral once that
     floor kicks in. */
  font-weight: 800;
  line-height: 1;
  letter-spacing: 0.09em;
  text-transform: uppercase;
}

/* seat-name labels (geometry in layout.css). The firstmark's scissors and cap
   register again, on flat black: the wheel color means first player and nothing
   else, and a name claims no status either, so it takes the quieter of the two
   corner-strip fills (black). */
.pname {
  --cut-s: max(1px, calc(var(--u) / 60));
  background: #000;
  border: var(--cut-s) solid #fff; /* the sticker's own kiss-cut margin */
  clip-path: var(--torn);
  color: #fff;
  font-weight: 800;
  line-height: 1;
  letter-spacing: 0.09em;
  text-transform: uppercase;
}
/* The top seat folds its 180° counter-rotation into its own tilt: 176 = 180 −
   4, one value, so there is no second rule to keep in step. */
.seat-top .pname {
  rotate: 176deg;
}
.seat-left .pname {
  rotate: 6deg;
}
.seat-right .pname {
  rotate: -5deg;
}
/* spectating only — your own seat is blank when you're playing it. */
.seat-you .pname {
  rotate: 3deg;
}

/* the one solid action button — lobby and in-game menu actions share this.
     Chips/pills opt out via .chip; inert states are consolidated at the foot of
     this file. */
.lobby-btn,
.menu-acts button:not(.chip) {
  background: #000;
  color: #fff;
  /* transparent 1px border (not border:0) so the box height matches the
       outlined controls — chips, inputs, shareLink — at the same padding.
       Padding/radius/font-size (the pill form factor) live in layout.css. */
  border: 1px solid transparent;
  font-weight: 700;
  cursor: pointer;
}
/* take-turn's commit button fills with the selected card's color instead of
   the default black — :not(:disabled) so "Select a card" keeps the inert
   look untouched. */
.menu-acts button.play-active:not(:disabled) {
  background: var(--sel-color);
}
/* shared text-input box (name + room code); each sets only its own width */
.text-input {
  box-sizing: content-box;
  /* padding/radius/font-size come from the shared control rule in layout.css,
     so inputs stand the same height as chips + buttons. */
  font-weight: 800;
  text-align: center;
  border: 1px solid var(--gray);
}
/* player-name input, sized for 15 letters */
#nameInput {
  width: 11em;
}
#joinInput {
  width: 5em;
  letter-spacing: 0.15em;
}

/* ── lobby screens: welcome / tabs / pre-game ── */
#lobbyScreen,
#deckBrowser {
  position: fixed;
  inset: 0;
  z-index: 2100; /* over everything, including flying cards, while out of a game */
  background: #fff;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 48px 16px 64px;
  overflow-y: auto; /* body never scrolls — these screens scroll inside */
}
#lobbyScreen[hidden],
#deckBrowser[hidden] {
  display: none;
}
#deckBrowser {
  z-index: 2200; /* browsable from within the lobby screens */
}

/* the sorry page (404). Terminal: nothing in the app shows over it, so it takes
   the top tier — the only way out is the recovery link it prints. Centred rather
   than the top-aligned scrolling column the lobby screens are, because it holds
   one sticker sheet and never more than that; it clips for the same reason
   #rotateGate does. */
#sorryScreen {
  position: fixed;
  inset: 0;
  z-index: 2300;
  background: #fff;
  display: grid;
  place-items: center;
  align-content: center;
  padding: 24px;
  overflow: clip;
  text-align: center;
}
#sorryScreen[hidden] {
  display: none;
}
.lobby-title {
  font-size: var(--fs-l);
  font-weight: 800;
  letter-spacing: 0.06em;
  margin-bottom: 28px;
}
#lobbyNav {
  display: flex;
  gap: 24px;
  margin-bottom: 40px;
}
#lobbyNav[hidden] {
  display: none;
}
#lobbyNav button {
  background: none;
  border: 0;
  padding: 0;
  font-weight: 700;
  color: var(--gray);
  cursor: pointer;
}
#lobbyNav button.active {
  color: #000;
}
#lobbyContent {
  width: min(560px, 92vw);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 24px;
  text-align: center;
}
#lobbyContent section[hidden] {
  display: none;
}
#tabAbout {
  text-align: left;
}
/* the reset zeroed UA margins/list styles — restore rhythm here only */
#tabAbout p + p,
#tabAbout ul {
  margin-top: 1em;
}
#tabAbout h3 {
  margin-top: 1.5em;
  margin-bottom: 0.5em;
}
#tabAbout ul {
  padding-left: 1.2em;
  list-style: disc;
}
#tabAbout li + li {
  margin-top: 0.6em;
}
/* single-input-form: input + confirm, error line flags red */
.sif {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
}
/* a single-field error line + the red-border it flags — class-based (not
     scoped to .sif) so every text field (name, room code, deck name) shares it */
.err {
  color: var(--c-Red);
  font-size: var(--fs-s);
  min-height: 1.2em;
}
.err:empty {
  display: none;
}
.text-input.invalid {
  border-color: var(--c-Red);
}
/* bare text-link button (footer name, browse/close/share actions) */
button.link,
a.link {
  background: none;
  border: 0;
  padding: 0;
  color: #000;
  text-decoration: underline;
  cursor: pointer;
}
/* error/destructive red — on delete links, the slot-full notice, etc.
   .link.danger (0,2,0) beats button.link's own color above. */
.danger,
.link.danger {
  color: var(--c-Red);
}
/* danger variant of the boxed notice: red text on the same gray block. (0,2,0)
   beats plain .disclaimer below regardless of source order. */
.disclaimer.danger {
  color: var(--c-Red);
}
/* public-room-card / pre-game-room-card */
#roomList {
  display: flex;
  flex-direction: column;
  gap: 28px;
  align-items: flex-start;
}
.room-card {
  display: flex;
  flex-direction: column;
  gap: 4px;
  align-items: flex-start;
  text-align: left;
}
.room-card .hd {
  font-weight: 700;
}
/* create-room-form + decks-tab: the section lays out its fields + button(s) with
     the same rhythm the lobby uses between sections (was unstyled → fields
     collided). Each field is a label + a row of .chip toggles (shared with the
     in-game menu). Any content section that stacks fields/buttons wants this. */
#tabCreate,
#tabDecks {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 24px;
}
/* saved decks: one column per savable format, headed by the format label.
   The count comes from the children, not a hardcoded track list — quick draft
   isn't savable, so there are two. */
.decks-grid {
  width: 100%;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: 24px;
  align-items: start;
}
.decks-col {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 20px;
}
/* narrow phones: side-by-side leaves each column too thin to hold a deck name
   or the format label on one line. Stack them. */
@media (max-width: 480px) {
  .decks-grid {
    grid-auto-flow: row;
  }
}
/* section header inside the decks tab ("my decks" / "backup") — its own top
   rule doubles as the divider between sections, so no extra element. */
.decks-heading {
  font-weight: 700;
  font-size: var(--fs-m);
  width: 100%;
  border-top: 1px solid var(--gray-lighter);
  padding-top: 24px;
  text-align: center;
}
.deck-backup {
  display: flex;
  gap: 20px;
}
.form-field {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
}
.chips {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: center;
}
.field-lbl {
  font-size: var(--fs-s);
  color: var(--gray-dark);
}
/* boxed notice: a flat gray block, square-cornered so it never reads as a
   button or an input. */
.disclaimer {
  font-size: var(--fs-s);
  color: var(--gray-dark);
  background: var(--gray-lighter);
  /* block padding off the button token, not a literal: a disclaimer can stand
     in for a row of chips (create-room visibility with a bot), and same
     font-size + same block padding means near-enough the same box height, so
     nothing below jumps on the swap. */
  padding: min(calc(0.18 * var(--u)), var(--btn-pad-y)) 12px;
  max-width: 420px;
}
/* decklist-form: textarea + paste/validate, flags red with error lines */
.deck-form {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}
/* saved-deck-or-paste wrapper: source toggle above the picker/textarea body —
     same column rhythm the sections use so the two don't collide */
.deck-input {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 24px;
}
.deck-form textarea {
  width: min(420px, 88vw);
  height: 150px;
  font: var(--fs-s) / 1.5 monospace;
  border: 1px solid var(--gray);
  border-radius: var(--radius-s);
  padding: 10px;
  resize: vertical;
}
.deck-form textarea.invalid {
  border-color: var(--c-Red);
}
.deck-form textarea.valid {
  border-color: var(--c-Green);
}
/* error lines — decklist validation + create-form server rejections. .ok flips
   the same container green for the passing "decklist valid" message. */
.errs {
  color: var(--c-Red);
  font-size: var(--fs-s);
  text-align: left;
}
.errs.ok {
  color: var(--c-Green);
}
.errs:empty {
  display: none;
}
.deck-acts {
  display: flex;
  gap: 20px;
  align-items: center;
}
/* deck builder: search + chips over the whole pool, the deck so far, then the
   result grid beside its own info box. Column rhythm matches .deck-form so the
   two modes swap under the toggle without the page jumping. */
.deck-build {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  width: 100%;
}
.deck-build .text-input {
  width: min(320px, 80vw);
  text-align: left;
}
.deck-build .form-field {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}
/* the two filter fields stack on the same rhythm .deck-build uses between its
   own children, so "rarity" doesn't crowd the color chips above it. */
.build-filters {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}
/* value range: two native sliders stacked on one track — only their thumbs take
   the pointer, so each drag grabs its own bound and the wrapper paints the
   track. The fill is a gradient off --lo/--hi (set by lobby.js); at the ends it
   can sit up to half a thumb wide of the thumb's centre, which is a lot cheaper
   than reimplementing a range input to fix. */
.build-range {
  --step: calc(100% / 12); /* MAX_VALUE steps across the track */
  position: relative;
  width: min(320px, 80vw);
  height: 24px;
  background: linear-gradient(
      to right,
      var(--gray-light) 0 calc(var(--lo) * var(--step)),
      #000 calc(var(--lo) * var(--step)) calc(var(--hi) * var(--step)),
      var(--gray-light) calc(var(--hi) * var(--step)) 100%
    )
    center / 100% 2px no-repeat;
}
.build-range input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  appearance: none;
  -webkit-appearance: none;
  background: none;
  pointer-events: none; /* the track is the wrapper's; the thumbs opt back in */
}
.build-range input::-webkit-slider-thumb {
  -webkit-appearance: none;
  pointer-events: auto;
  width: 18px;
  height: 18px;
  border: 1px solid #fff;
  border-radius: 50%;
  background: #000;
}
.build-range input::-moz-range-thumb {
  pointer-events: auto;
  width: 18px;
  height: 18px;
  border: 1px solid #fff;
  border-radius: 50%;
  background: #000;
}
/* the deck so far: one row per distinct card, capped in height so a 45-card
   traditional list can't push the result grid off the screen. */
.build-picked {
  /* the column track lives here, not on the row: a grid per row would size its
     own columns and nothing would line up. Rows opt into these tracks with
     subgrid. */
  display: grid;
  grid-template-columns: auto auto auto 1fr;
  align-content: start;
  gap: 6px 8px;
  max-height: 210px;
  overflow-y: auto;
  width: min(320px, 80vw);
  /* a classic (non-overlay) bar: on macOS the overlay one fades out and the
     clipped row is the only clue the list goes on. Styling ::-webkit-scrollbar
     is what opts WebKit out of overlay; the gutter keeps rows from shifting. */
  scrollbar-width: thin;
  scrollbar-color: var(--gray) transparent;
  scrollbar-gutter: stable both-edges; /* rows stay on the centre line */
}
.build-picked::-webkit-scrollbar {
  width: 8px;
}
.build-picked::-webkit-scrollbar-thumb {
  background: var(--gray);
  border-radius: var(--radius-full);
}
/* the empty-state notice isn't a row — let it span the tracks and centre. */
.build-picked .disclaimer {
  grid-column: 1 / -1;
  justify-self: center;
}
/* name | colour initial | value(s) | actions: the three info columns align down
   the whole list, and the last track takes the slack so the actions sit hard
   right. */
.build-row {
  display: grid;
  grid-template-columns: subgrid;
  grid-column: 1 / -1;
  align-items: center;
  font-size: var(--fs-s);
}
.build-row .nm {
  text-align: left;
}
/* monospace so "3/7" and "4" line up on the same digit column */
.build-row .val {
  font-family: monospace;
}
.build-row .deck-acts {
  justify-self: end;
}
.build-row .n {
  min-width: 1.5em;
  text-align: center;
  font-weight: 800;
}
.build-row button.link:disabled {
  color: var(--gray);
  text-decoration: none;
  cursor: default;
}
.build-counts {
  font-size: var(--fs-s);
  color: var(--gray-dark);
  font-weight: 800;
}
/* the grid, centred, and nothing else in the row: the info box only ever
   floats (below) and the jump links cost no width, so the cards never reflow
   around them. */
.build-results {
  display: flex;
  justify-content: center;
  align-items: flex-start;
  width: 100%;
}
/* the read is a peek, not a panel: the box exists only while a finger is held
   on a card (touch) or the pointer is over one (desktop) — same centred pop,
   same lifetime, on both. Fixed + centred so nothing below it reflows, and
   inert to pointer events so the click/release still lands on the card
   underneath (and the hover it came from survives). */
.build-info {
  display: none;
  text-align: left;
}
.build-info.pop {
  display: block;
  position: fixed;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  width: min(260px, 78vw);
  z-index: 900;
  pointer-events: none;
}
/* a picked card reads as spent — in the deck already (it stays clickable for a
   second copy, where the format allows one), or kept out of a draft pack. */
.deck-build .card.picked,
.draft-grid .card.picked {
  opacity: 0.45;
}
/* the two search shortcuts: not a column — zero-width, so the row is still
   just the grid and the links hang off its right edge; sticky, so they ride
   the scroll instead of drifting up out of reach. Shown only once the search
   field has scrolled off (lobby.js toggles .show). */
.build-jump {
  position: sticky;
  top: 45vh;
  align-self: flex-start;
  width: 0;
  white-space: nowrap;
  z-index: 400;
  display: none;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
  /* the row has no gap and this item no width, so appearing costs the grid
     zero pixels — it can't shift. The step off the grid's edge is a translate
     (paint-only) for the same reason: padding or margin would be width. */
  translate: 12px 0;
}
.build-jump.show {
  display: flex;
}
/* a press on a card — or on a decklist row's name, which reads the same way —
   is a read, not a text selection: this kills iOS's callout menu, the
   selection handles a held word raises, and the drag-the-image gesture, any of
   which would eat the hold. -webkit-user-select is spelled out because iOS
   Safari still needs the prefix to suppress the handles. */
.deck-build .card,
.build-row .nm {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}
/* the name is readable, the controls beside it are not — say so on hover */
.build-row .nm {
  cursor: help;
}
/* narrow phones: the grid fills the row edge to edge, so there's no margin for
   the links to hang in. Matches .browser-grid's own 480px rule, which reshapes
   the grid underneath. */
@media (max-width: 480px) {
  /* fixed, not sticky: the grid ends at the viewport, so a right-edge overhang
     would sit off-screen. */
  .build-jump.show {
    position: fixed;
    right: 8px;
    top: 50%;
    translate: 0 -50%;
    align-items: flex-end;
  }
}
/* ── quick draft: the pack picker ───────────────────────────────────────
   Same column as the pre-game screen; the pack itself packs like the decklist
   browser's gallery so it reads at the game's own card size in portrait. */
#draft {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
}
.draft-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, var(--card-w));
  justify-content: center;
  max-width: calc(3 * var(--card-w) + 2 * 14px);
  gap: 14px;
}
@media (max-width: 480px) {
  .draft-grid {
    grid-template-columns: repeat(2, 1fr);
    max-width: none;
    width: 100%;
  }
  .draft-grid .card {
    width: 100%;
    height: auto;
    aspect-ratio: var(--card-ar);
  }
}

/* pre-game screen bits */
#pregame {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 24px;
}
#shareLink {
  border: 1px solid var(--gray);
  border-radius: var(--radius-s);
  padding: 8px 14px;
  background: #fff;
  font-family: monospace; /* it's a code — read it as one */
  /* font-weight: 700; */
  cursor: pointer;
}
/* focus affordance: drop the browser's blue glow; the dimmed resting border
     (--gray) snaps to full black instead. */
.text-input:focus,
#shareLink:focus,
.deck-form textarea:focus {
  outline: none;
  border-color: #000;
}
/* an invalid field stays red even while focused (extra class beats the :focus
     rule above on specificity) */
.text-input.invalid:focus,
.deck-form textarea.invalid:focus {
  border-color: var(--c-Red);
}
.deck-form textarea.valid:focus {
  border-color: var(--c-Green);
}
#lobbyFoot {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 20px;
  font-size: var(--fs-s);
  background: #fff;
}
#lobbyFoot[hidden] {
  display: none;
}
.lobby-foot-left {
  display: flex;
  gap: 24px;
}
/* decklist browser: room info + info-box sidebar, sticky beside a grid that
   packs as many in-game-size cards per row as fit (capped at 5) — one flex
   row at every viewport width, never stacked, so a narrow phone still reads
   as two columns rather than a single column of oversized cards. Both
   `.browser-wrap` and `.browser-grid` carry an explicit max-width so their
   sizing is definite at every step (shrink-to-fit + grid auto-repeat across
   nested indefinite contexts is exactly what broke this before). */
.browser-wrap {
  display: flex;
  gap: 40px;
  align-items: flex-start;
  width: 100%;
  max-width: calc(230px + 40px + 5 * var(--card-w) + 4 * 14px);
}
.browser-side {
  display: flex;
  flex-direction: column;
  gap: 16px;
  text-align: left;
  position: sticky;
  top: 0;
  /* pinned so it never competes for shrink space with the grid, and sized
     upfront (230px matches --info-w) so the layout doesn't jump the moment
     the info-box first populates. --info-w itself isn't reused directly —
     it's only responsive in the landscape-touch query, and this screen is
     usable in portrait too. */
  flex: none;
  width: clamp(120px, 32vw, 230px);
  /* clears a hovered/lifted board-style z-index (201) so a card's restack
     can never paint over the info-box's bled satellite stickers */
  z-index: 300;
}
.browser-grid {
  flex: 1 1 auto;
  min-width: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, var(--card-w));
  max-width: calc(5 * var(--card-w) + 4 * 14px);
  gap: 14px;
}
/* narrow phones: --card-w (shared with the live board) shrinks with the
   viewport, so auto-fill above packs in 3+ small cards with dead space at
   the row's end rather than 2 comfortably-sized ones — .card's own fixed
   width never stretches to fill a wider track. Force exactly 2 columns and
   let the card fill each one instead. */
@media (max-width: 480px) {
  .browser-grid {
    grid-template-columns: repeat(2, 1fr);
  }
  .browser-grid .card {
    width: 100%;
    height: auto;
    aspect-ratio: var(--card-ar);
  }
}
/* top-left, independent of the aside's content height (duel.html). Fixed, not
   absolute: #deckBrowser scrolls its own content (overflow-y:auto), and an
   absolutely-positioned descendant scrolls right along with it — only fixed
   (or sticky) actually stays put through that scroll. */
#browserClose {
  position: fixed;
  top: 16px;
  left: 16px;
  z-index: 400; /* clear of .browser-side's own 300 */
}
/* the browser's own info-box: filled by lobby.js on hover/tap, shaped like
   the in-game portal's face (layout.css › #inspector .info-face) minus the
   fixed-portal positioning — this one sits in normal flow in the aside.
   Deliberately NOT --info-max: that token is 6.5 × the in-game --u, which on
   this screen's own (much smaller, portrait-capable) --u would cap the box
   at barely more than its header, squeezing the effect text to nothing. */
#browserInfoBox:empty {
  display: none;
}
#browserInfoBox .info-face {
  max-height: min(60vh, 480px);
  overflow: visible;
  display: flex;
  flex-direction: column;
}
/* spectators get no interactive affordances — watch-only cursor everywhere */
body.spectator * {
  cursor: default !important;
}
#log {
  width: 100%;
  flex: 0 1 auto;
  max-height: 60svh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 3px;
  font-size: var(--fs-s);
  text-align: left;
}
#log div {
  padding: 3px 0;
  border-bottom: 1px solid var(--gray-lighter);
  opacity: 0.75;
}
#log div:last-child {
  opacity: 1;
  font-weight: 600;
}

/* §3 play area, hand tuck, and the centered #choices overlay moved to
   layout.css — the zone table (.mood-row / .hand-strip / .fan / .choices grid
   cell) supersedes the old .play/.zone/.row flex stack and the absolute-centered
   prompt. Only the menu row/message skin stays here. */
.menu-msg {
  min-height: 1.4em;
  font-weight: 600;
  text-align: center; /* center each wrapped line, not just the block */
}
.menu-acts {
  min-height: 40px;
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: center;
  /* min-height reserves the row; without this the default align-items:stretch
       would stretch buttons/chips to 40px, taller than the same button elsewhere */
  align-items: center;
}
/* the plain (non-chip) menu action button is styled by the shared solid-button
     rule near the top of this file. */

/* deck + discard are docked into the table's bottom corners now (layout.css);
   the old .rail column is gone. .slot still sizes each pile. */
.slot {
  position: relative;
  isolation: isolate; /* confine the pile-stack pseudo's z-index:-1 to the slot */
  /* pinned to card size so the slot never collapses when its last card leaves
     mid-animation — an empty in-flow slot would recenter the rail and jump the
     neighbouring deck. The empty placeholder holds this size at rest too. */
  width: var(--card-w);
  height: var(--card-h);
}
/* pile-stack hint: dithered sheets behind the top card, standing in for the
     cards beneath. Only when >1 card actually sits under the top (.stacked) —
     a single card shouldn't fake a stack.
       ::after — the dark edge, offset down to peek as a card edge at rest
         (any pile). ::before — an inverted face flush behind the top card,
         hidden at rest, exposed only when the discard pile lifts (.pile). */
.slot:has(> .card.stacked)::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -2;
  border-radius: var(--card-radius);
  background: var(--fill-dark);
  transform: translateY(6px);
}
.slot:has(> .card.pile)::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1; /* in front of the edge, flush behind the real card */
  border-radius: var(--card-radius);
  background: var(--fill-light);
}
/* opponent's deck (per-player-deck formats) sits inside .seat-top .module, so
     it already flips 180° for free — same counter-rotation exception as the
     mood badges and score digits, so the count itself reads upright. (The
     beats ratchet this count; Anim.ratchet clears its inline transform on
     completion so this rule survives — see .seat-top .badge.mood.) */
#oppDeck .badge {
  transform: rotate(180deg);
}

.slot .empty {
  /* background sizer: fills the pinned slot behind any card, so it's simply
     covered when a pile sits on top and revealed the instant it leaves — never
     added/removed mid-animation. */
  position: absolute;
  inset: 0;
  z-index: -3;
  border-radius: var(--card-radius);
  display: grid;
  place-items: center;
  text-align: center;
  font-size: var(--fs-s);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  /* diagonal-hatch dither at pattern-color opacity */
  background-image: var(--hatch);
  background-size: 5px 5px;
}
/* label: bayer 4×4 dark dither + doubled text-shadow so white text stays
     legible over the hatch. */
.slot .empty .lbl {
  display: inline-block;
  padding: 2px 6px;
  margin: 2px 0;
  border-radius: var(--radius-s);
  background: var(--fill-dark);
  color: #fff;
  text-shadow: var(--halo);
}

/* §4 discard browser. Placement lives in layout.css (.table > .discard-fan is
   its own table-level zone, spanning the discard corner to the deck's left
   edge); this file only skins it. packZone writes each card's margin-left and
   --z, so no overlap or stacking rules here. */
.slot .pile {
  cursor: pointer;
}
.discard-fan > .card {
  flex: 0 0 auto;
}
/* while inspecting a fanned card, lift the whole zone above the centred prompt
     so the inspected card's info-box reads over it. */
#discardFan:has(.card.focused) {
  z-index: 501;
}
/* a selected card stays lifted. */
.discard-fan .card.selected {
  transform: translateY(-12px);
  z-index: 1000;
}
@media (hover: hover) {
  #discardFan:has(.card:hover) {
    z-index: 501;
  }
  /* hover only restacks, never moves — packZone overlaps these, so the hovered
     card has to outrank its neighbours to show its full art. Same shape as the
     mood rows. Lifting is reserved for state (.selected / .focused). */
  .discard-fan .card:hover {
    z-index: 1000;
  }
}
/* while open, the discard slot's empty placeholder IS the close target — it
     already wears the hatch, so it only needs the × on top and its "discard"
     label stepping aside. */
#discard.open {
  cursor: pointer;
}
#discard.open .empty .lbl {
  display: none;
}
/* the × circle. Static: it used to lift 12px on hover off an inverted-bayer
     ::after sheet, aping the card lift — but hover stopped moving anything when
     plays became selections, and that sheet was invisible except underneath the
     lift, so both went together. #discard.open's pointer is the affordance now.
     Sized like a card badge (32px desktop, floored on mobile) so the close
     target scales with the card instead of dwarfing a shrunk one. */
#discard.open .empty::before {
  content: "×";
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--badge);
  height: var(--badge);
  border-radius: 50%;
  transform: translate(-50%, -50%);
  z-index: 1;
  display: grid;
  place-items: center;
  font-weight: 800;
  font-size: var(--fs-l);
  line-height: 1;
  background: var(--fill-dark);
  color: #fff;
  text-shadow: var(--halo);
}

.card {
  position: relative;
  width: var(--card-w);
  height: var(--card-h);
  border-radius: var(--card-radius);
  padding: 10px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  background: #fff;
  /* a card is a HAIRLINE in every state: the mood color frames the art, it
     doesn't compete with it. Nothing thickens it — .selectable changes the
     border's STYLE and only that. Still routed through --bd-w because the art's
     inner radius below has to subtract it, and one name beats the same literal
     written twice. */
  --bd-w: 1px;
  border: var(--bd-w) solid var(--bd, #000);
  cursor: default;
  /* packZone sets --z (not inline z-index) so hover/lift rules can still win —
     an inline style beats every stylesheet rule, which used to make every
     :hover restack in the hand, mood rows and the discard fan a no-op. */
  z-index: var(--z, auto);
}
.card:has(.art) {
  padding: 0;
}
/* the rotating face — everything but the badge. flips 180° when a mood shows
     its secondary value; the badge sits outside it so the flip never reaches it
     (the top seat's own badge counter-rotation lives in layout.css). */
.card .face {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  transform-origin: center;
}
.card .art {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* the art sits inside the card's border, so it rounds to the border's
     INNER radius (outer − border width), else its corners bow past the frame */
  border-radius: calc(var(--card-radius) - var(--bd-w));
}
/* mood-value badge: rounded square (to read distinct from the circular
     neutral count badge on piles), half-off the top-right corner */
.badge {
  position: absolute;
  top: calc(var(--badge) / -2 + 2px);
  right: calc(var(--badge) / -2 + 2px);
  width: var(--badge);
  height: var(--badge);
  border-radius: 25%;
  background: var(--bd, #000);
  border: 2px solid #fff;
  color: #fff;
  display: grid;
  place-items: center;
  font-weight: 800;
  font-size: var(--fs-m);
  line-height: 1;
  font-variant-numeric: tabular-nums;
  /* system fonts cap at 700; a slight same-color stroke thickens the glyph */
  -webkit-text-stroke: 0.4px currentColor;
  /* above the info box (z 999) so neither box nor its edge covers the value */
  z-index: 1000;
}
/* mood value badge takes the card's accent color; count badges go neutral + circular */
.card .badge.mood {
  background: var(--bd, #000);
}
.badge.count {
  background: #000;
  border-radius: 50%;
}
/* unpayable/inert cards: a white wash knocks the mood colour back and the
     shared diagonal hatch over it says "unavailable" as texture, not a flat
     gray — the same white + hatch an inert button wears (see the foot of this
     file). Both layers are painted FLAT on purpose. `filter` and
     `mix-blend-mode` (this was grayscale(1) + a multiply hatch) each push the
     card onto its own rendering surface, and Firefox drops that surface whole
     for a hand card: the tuck hangs most of the card past .board's clip, and
     what came back was an invisible card — right box, right hover, nothing
     painted. Neither property may come back here while the hand is tucked. */
.card.inert::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background-color: rgb(255 255 255 / 0.45);
  background-image: var(--hatch);
  background-size: 5px 5px;
  pointer-events: none;
}
/* The badge is the one part of the card the wash above can't reach: it's a
     separate layer (z 1000, so the info box never covers the value) and it sits
     half OUTSIDE the card's box, past `inset: 0`. Left alone, an unavailable
     mood wears a full-saturation value badge over a knocked-back body — the
     loudest thing on a card that means "you can't have this". Same white, its
     own layer. (`filter` is out here for the reason above.) */
.card.inert .badge::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background-color: rgb(255 255 255 / 0.45);
  pointer-events: none;
}
/* ── lift policy: a card rises for STATE, never for the pointer ─────────────
     Chosen (.selected) or looked-at (.focused) lifts it; nothing else does.
     Hover used to lift a playable card too — a preview of "clicking this plays
     it" — but plays are selections now, so that preview promised something the
     click no longer does. It's gone everywhere, including the discard pile:
     hover is purely the desktop look channel, and the info-box is what it buys.
     Hover still RESTACKS (below), which is a different job.
     (.lifted, the paying-costs card, only ever sits in a hand, and both hands
     override with their own tuck geometry — it needs no generic rule.)
     Generic values here are translateY-only, for the flat board rows; the hands
     and the fan compose their own below. */
/* z-lift: ANY hovered card (plus selected ones) rises above the prompt (150) so
     its info-box reads over the centered choices — the card's transform traps
     the info-box's z in the card's stacking context, so the card itself must
     outrank the prompt. Flying cards (1000) still clear these. */
.card.selected {
  z-index: 200;
}
/* an inspected card must clear a lifted/selected sibling — they share a stacking
     context and equal z falls back to DOM order, so a lifted card to the right
     would otherwise cover the inspected one. */
.card.focused {
  z-index: 201;
}
/* translate-lift: only these actually rise off the surface. */
.card.selected,
.card.focused {
  transform: translateY(-12px);
}
/* ── hover is the DESKTOP look channel, and only there ──────────────────────
     .focused is its touch counterpart (board.js sets it on tap), so these are
     two halves of one policy and each must be inert on the other's platform.
     .focused rules already are — desktop never sets the class. Hover rules are
     NOT inert on touch by default: a tap leaves the element in :hover until you
     tap something else, which is why anything hover-driven that OUTLIVES the
     pointer belongs in here. That's also why hover no longer moves a card at
     all: a stuck :hover left a deselected card stranded in the air. What's left
     is the restack, which is invisible once the pointer goes.
     Everything keyed on a class (.selected/.focused) stays outside — that's
     real state, and it belongs on both platforms. */
@media (hover: hover) {
  .card:hover {
    z-index: 201;
  }
}
/* §3.10 click-to-select: eligible targets get a dashed accent ring, selection
     commits it back to solid (the .selected lift is the other cue). The card's
     own border is the ring — going dashed follows the radius and scales with
     --card-w/-h for free. */
.card.selectable,
.card.selected {
  cursor: pointer;
}
.card.selectable {
  /* style only, never weight: the frame is a hairline in EVERY state. A
     thickened ring shouted at the art and made the card's edge two different
     objects depending on whose turn it was; the dash alone carries "you can
     pick this". */
  border-style: dashed;
}
/* (flip lives on face.style.transform, set by cardEl — no .flipped class,
     which would double-rotate against the inline transform) */
/* the one chip: an outlined toggle, inverted when picked — distinct from the
     solid action buttons. One class for both the create-room form fields and the
     in-game color/value/player picks (was two near-identical definitions). Only a
     color/value chip carries the swatch/die icon; a plain (player) chip is text. */
.chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-width: 48px; /* half the old 96px — chips pack tighter than buttons */
  background: #fff;
  color: #000;
  /* identical shape to the solid buttons (padding/radius/font in layout.css);
     the white resting fill is the tell — a chip pins a selection, it doesn't act. */
  border: 1px solid #000;
  font-weight: 700;
  cursor: pointer;
}
/* --pick-color lets a chip pin its own meaning (the builder's color filters
   fill with the mood they name) — same move as .play-active's commit button,
   white caps on a mid-tone fill. Black when unset: an ordinary chip. */
.chip.picked {
  background: var(--pick-color, #000);
  color: #fff;
}
/* chip icon: a color swatch or a value-pick die face. Both are small tiles
     that keep their own fill regardless of the chip's picked (inverted) state,
     so the pick reads off the label like the color chips. */
.chip .sw,
.chip .die-ico {
  /* sized to the label (1em), not a fixed px — so the icon tracks the shrunk
     control font instead of standing chunkier than the text beside it. */
  width: 1em;
  height: 1em;
  border-radius: var(--radius-s);
  border: 1px solid #000;
}
.chip .sw {
  background: var(--sw);
}
.chip .die-ico {
  background: #fff;
  display: grid;
  place-items: center;
}
.chip .die-ico .pips {
  width: 82%;
  height: 82%;
  fill: #000;
}
/* info-box skin — a die-cut sticker (system.css › THE PRESS). layout.css
   positions the #inspector portal and clamps its height; here we skin the face.
   --bd is set inline on .info-face (board.js) so the mood color arrives as ink.
   Three ideas, no more:

   1. DIE-CUT, NOT BORDERED. The silhouette is the frame: a kiss-cut margin
      around the face, wobbled so the outline reads hand-cut, backed by the
      dithered ink edge (DESIGN.md forbids shadows).
   2. REAL HALFTONE, NOT NOISE. The mood color arrives as a printed screen that
      bleeds past the face into the cut margin, like ink on a sticker. It is the
      only place the mood color is stated.
   3. THE NAME IS A TORN LABEL STRIP crossing the cut line and bleeding out both
      sides. Marks and SUPPRESSED become satellite stickers that overhang the
      bottom cut.

   Everything that carries text stays unfiltered and upright — only the
   decoration warps and only the satellites tilt. */
.info-face {
  --cut: var(--info-cut); /* kiss-cut margin (layout.css clears it too) */
  --ink: 2px;
  --pitch: 6px; /* halftone screen pitch */
  --plate-h: 112px; /* how far the ink plate reaches down the sticker */
  --dot: var(--bd, #000);
  --deckle: url(#deckle);
  --edge-x: 3px;
  --edge-y: 5px;
  /* asymmetric corners so it reads cut-by-hand before the filter even lands */
  --cut-corners: 20px 26px 22px 30px / 26px 20px 28px 22px;
  position: relative;
  isolation: isolate;
  padding: 12px 13px 10px;
  /* the sticker body and its outline are drawn by .cut underneath */
  background: none;
  border: 0;
  border-radius: 0;
}
.info-face .hdr {
  margin-bottom: 9px;
  text-align: left;
}
/* the name's torn strip: hard-cut black, white caps, bleeding past the face into
   the cut margin on both sides and crossing the ink outline; its own white
   kiss-cut margin breaks that outline where it passes. (Named .nameplate, not
   .strip — --strip and .hand-strip already mean "the hand tuck" here.) */
.nameplate {
  margin: 0 calc(-1 * var(--cut) - 3px) 6px;
  padding: 5px 12px 6px;
  background: #000;
  border: 3px solid #fff; /* the sticker's kiss-cut margin */
  rotate: -1.4deg;
  clip-path: var(--torn);
}
.info-face .hdr-name {
  display: block;
  color: #fff;
  font-size: var(--fs-l);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: 0.055em;
  text-transform: uppercase;
  text-wrap: balance;
  /* a single-word name (most of them) has no natural break point, and the
     browser's own narrower aside can be tighter than any card name at
     --fs-l — without this it overflows the nameplate instead of wrapping. */
  overflow-wrap: break-word;
}
/* color + printed value, ink-black on the plate: the hue is already stated by
   the dot screen behind it, so mood-colored type here would only fight its own
   ground (and fail contrast on the light moods). */
.info-face .hdr-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  color: #000;
  font-size: var(--fs-s);
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
/* the marker scribble underlines the color WORD — it is emphasis on a word, not
   a rule across the box. */
.hdr-meta .clr {
  position: relative;
  padding-bottom: 5px;
}
.hdr-meta .scribble {
  position: absolute;
  left: -3px;
  right: -5px;
  bottom: 0;
  height: 7px;
  color: var(--bd);
}
.hdr-meta .vals {
  display: flex;
  align-items: center;
  flex: none;
}
/* the printed value, always as an integer: pips belong to the physical cards,
   this box is the reading surface. The box is sized in em against the INHERITED
   size and the numeral in em against the box, so the two never compound as they
   would if both lived on one element. Height hugs the cap height — a box taller
   than the caps has to either dip below the baseline or tower over it, and inside
   a sentence both read as broken. min-width, not width, so a two-digit sum
   ([6][6] is twelve) still fits. */
.val-die {
  min-width: 1.16em;
  height: 1.16em;
  padding: 0 0.14em;
  flex: none;
  display: inline-grid;
  place-items: center;
  background: #fff;
  border: 1.5px solid #000;
  border-radius: var(--radius-s); /* same corner as the board's dice */
}
.val-die span {
  font-size: 0.82em;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}
/* in the header it is the most-consulted number on the box, so it reads
   die-sized rather than glyph-sized. */
.hdr-meta .val-die {
  min-width: 2.2em;
  height: 2.2em;
  border-width: 2px;
}
.hdr-meta .val-die span {
  font-size: 1.35em;
}
/* base / secondary are two different printed values, so the slash between them
   has to be as legible as the dice it separates. */
.val-sep {
  margin: 0 5px;
  color: var(--gray-dark);
  font-weight: 700;
}
/* the only scroller: a flex child with min-height 0 inside the face's height
   clamp (layout.css), so it takes whatever the pinned header and the satellites
   leave and scrolls the rest. No magic header allowance to keep in sync. */
.info-face .fx {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  font-size: var(--fs-s);
  line-height: 1.42;
}
/* a clipped rule is worse than a short one: when the text overflows its clamp,
   the last line dissolves instead of being guillotined, which is both the scroll
   affordance and the only honest way to end a print run. Set by openInspector
   (CSS can't yet ask whether a box scrolls). */
.info-face .fx.clipped {
  mask-image: linear-gradient(#000 calc(100% - 18px), #0000);
}
/* clause openers ("To play this card") become their own ink labels rather than
   inline bold, so the rule structure is scannable at hover speed (typeset). */
.fx .clause {
  display: block;
  margin: 7px 0 1px;
  font-weight: 800;
  font-size: var(--fs-s);
  letter-spacing: 0.07em;
  text-transform: uppercase;
}
.fx .clause:first-child {
  margin-top: 0;
}
.fx em {
  color: var(--gray-dark);
}
/* a color word set in its own mood color (board.js's colorizeWords). */
.fx .cw {
  font-weight: 800;
}
/* a hair below the baseline, not a fifth of an em: enough that the numeral
   inside optically centers on the lowercase, not enough to read as falling off
   the line. */
.fx .val-die {
  vertical-align: -0.11em;
}
/* satellite stickers: marks + SUPPRESSED tilt and overhang the bottom cut, so
   the silhouette is broken by its own contents. Each gets the sticker treatment:
   fill, white kiss-cut margin (border), ink cut-line (outline). Every row starts
   ON the left cut line and the last one straddles the bottom one, so a wrapped
   cluster still reads as stickers resting on the silhouette instead of floating
   inside it. The side bleed also buys back the ~46px that made three marks wrap
   on desktop but not on mobile. */
.sats {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  flex: none;
  gap: 7px;
  margin: 10px calc(-1 * var(--cut) - 12px) calc(-1 * var(--cut) - 18px);
}
.sats:empty {
  display: none;
}
.sats .pill-solid {
  padding: 4px 9px 5px;
  border: 2.5px solid #fff;
  outline: 1.5px solid #000;
  font-size: var(--fs-s);
}
/* --pc is the marked color, set per-pill by board.js */
.sats .mark-pill {
  background: var(--pc);
  color: #fff;
  /* white caps on the mood fill, holding the .pill-solid halo rather than killing
     it: the mood fills are mid-tone, so white alone runs to 2.5:1 on Blue — under
     any threshold at 13px. The halo is the ink edge that carries it, and it's the
     same one the neighbouring "suppressed" pill already wears over its dither. */
}
.sats .pill-solid:nth-child(odd) {
  rotate: -3deg;
}
.sats .pill-solid:nth-child(even) {
  rotate: 2.5deg;
}
.sats .sup {
  background: var(--fill-dark);
  color: #fff;
}
/* the one authored moment: the sticker slaps on, then the ink prints.
   @starting-style animates from the unmounted state, so the portal's existing
   open path (fill innerHTML) is all that's needed — no JS animation, and hover
   speed stays honest at ~150ms. */
@media (prefers-reduced-motion: no-preference) {
  .info-face {
    transition:
      transform 160ms cubic-bezier(0.2, 0.9, 0.2, 1),
      opacity 90ms linear;
  }
  .nameplate {
    transition: rotate 220ms cubic-bezier(0.2, 0.9, 0.2, 1);
  }
  .info-face .plate i {
    transition: opacity 120ms linear;
  }
  .info-face .plate i:nth-child(2) {
    transition-delay: 40ms;
  }
  .info-face .plate i:nth-child(3) {
    transition-delay: 80ms;
  }
  .info-face .plate i:nth-child(4) {
    transition-delay: 120ms;
  }
  @starting-style {
    .info-face {
      transform: scale(1.06) translateY(-4px);
      opacity: 0;
    }
    .nameplate {
      rotate: -5deg;
    }
    .info-face .plate i {
      opacity: 0;
    }
  }
}
.card .name {
  font-weight: 700;
  font-size: var(--fs-m);
}
.card .val {
  align-self: flex-end;
  font-size: var(--fs-m);
  font-weight: 800;
}

/* ── the page graphic ───────────────────────────────────────────────────────
   One sticker sheet, whatever the page needs to say — the rotate gate and the
   404. dice-friend is kiss-cut to his own contour (not parked in a rounded box)
   and holds a torn speech strip printed with the message. The markup, the
   silhouette and the three mask URIs come from page-graphic.js; the measurements
   are in layout.css. This is the skin.

   Four constructions, in the order they print:
     1. THE DIE-CUT IS THE DRAWING'S OWN OUTLINE. One silhouette, stroked at two
        weights to make paper and ink line, and used as a CSS mask so the
        dithered depth edge is cut to the same shape. Four layers, one
        silhouette — they can't drift apart.
     2. THE COLOUR PLATE IS MIS-REGISTERED. The body prints as a halftone screen
        of --wubrg, offset a few px under the black key art and trimmed back by
        the die-cut. The one colour moment on the page (see --fill-wheel in
        system.css for why the wheel is allowed here at all).
     3. THE BUBBLE IS TORN, NOT ROUNDED. THE PRESS as-is, with a coarser,
        longer-wavelength deckle. No tail: two stickers laid down overlapping
        already read as one saying the other, and a tail crossing the die-cut
        would put two ink lines through each other right where the eye lands.
     4. THE MARKS ARE THE SAME STICKER, SMALL. Each satellite is one path stroked
        three times (ink / paper / mark) — the kiss-cut recipe at mark scale.

   NOTHING MOVES once it has landed. These are stickers; the entrance is the
   press stamping them down, and after that the page is a printed object. That
   entrance is page-graphic.js's stampIn(), which gates itself on
   prefers-reduced-motion — so there is no @media (prefers-reduced-motion) block
   here, because nothing in this CSS animates. */
.page-graphic {
  --pg-pitch: max(3px, calc(var(--pg) / 32)); /* colour-plate screen */
  /* 0.24 of the pitch ≈ 18% ink coverage. Deliberately light: this plate is a
     ghost under the key art, not a paint job. Past ~30% it stops reading as a
     screen and starts reading as a rainbow cube. */
  --pg-r: calc(var(--pg-pitch) * 0.24);
  --pg-slip: calc(var(--pg) * 0.04); /* how far off register */
  position: relative;
  display: grid;
  justify-items: center;
}

/* ── 1. the kiss-cut character ─────────────────────────────────────────────
   four layers stacked in one box, every one cut from the same silhouette.
   --pg-cut / --pg-paper / --pg-solid are the three mask URIs, written onto
   .page-graphic by page-graphic.js from its single path constant. */
.pg-char {
  position: relative;
  isolation: isolate;
  width: var(--pg);
  aspect-ratio: 11017 / 11314; /* the padded viewBox */
  rotate: 1.4deg; /* stickers are applied by hand and never land square */
  margin-top: calc(var(--pg) * 0.12);
}
.pg-char > * {
  position: absolute;
  inset: 0;
}
/* every masked layer reads its contour the same way; only the URI differs */
.pg-edge,
.pg-ink,
.pg-paper,
.pg-plate {
  mask-size: 100% 100%;
  mask-repeat: no-repeat;
}
/* the dithered ink edge, cut to the die-cut's own outline. The system's only
   depth cue (DESIGN.md › The No-Shadow Rule). */
.pg-edge {
  z-index: -2;
  mask-image: var(--pg-cut);
  translate: calc(var(--pg) * 0.022) calc(var(--pg) * 0.034);
  filter: url(#pg-torn);
}
/* the ink cut line and the paper it surrounds: the same contour at two stroke
   weights, so the black ring is whatever the two weights differ by and stays
   proportional at every --pg. */
.pg-ink,
.pg-paper {
  z-index: -1;
  filter: url(#pg-torn);
}
.pg-ink {
  mask-image: var(--pg-cut);
}
.pg-paper {
  background: #fff;
  mask-image: var(--pg-paper);
}
/* the two layers that are only ever SEEN through their mask paint nothing until
   masking is known to work. Without the guard, a browser that drops mask-image
   paints .pg-ink and .pg-edge as what they actually are — two full-bleed
   rectangles, one solid black — right over the drawing. Degrades to bare line
   art on paper, which is still the component. */
@supports (mask-image: linear-gradient(#000, #000)) {
  .pg-ink {
    background: #000;
  }
  .pg-edge {
    background: var(--fill-wheel);
  }
}

/* the mis-registered colour plate: the wheel, screened into halftone dots,
   clipped twice — once by the offset body (that's the mis-register) and once by
   the un-offset die-cut (that's the knife trimming the overprint back to the
   sticker). */
.pg-plate {
  mask-image: var(--pg-solid);
  display: none; /* enabled only where the double mask really works */
}
@supports (mask-composite: intersect) {
  .pg-plate {
    display: block;
  }
}
.pg-plate > .pg-screen {
  position: absolute;
  inset: 0;
  overflow: hidden;
  translate: var(--pg-slip) calc(var(--pg-slip) * 1.35);
  mask-image:
    radial-gradient(
      circle at 50% 50%,
      #000 var(--pg-r),
      #0000 calc(var(--pg-r) + 0.5px)
    ),
    var(--pg-solid);
  mask-size:
    var(--pg-pitch) var(--pg-pitch),
    100% 100%;
  mask-repeat: repeat, no-repeat;
  mask-position:
    0 0,
    center;
  mask-composite: intersect;
}
/* the wheel itself, oversized and centred so the conic's centre sits under the
   middle of the sticker rather than the corner of the screened band */
.pg-plate .pg-wheel {
  position: absolute;
  inset: -35%;
  background: var(--wubrg);
}

/* the key plate: the black line art, on top of everything. It rides a gentler
   deckle than the die-cut — the drawing should look printed, not mangled. */
.pg-art {
  color: #000;
  filter: url(#pg-art);
}

/* ── 2. the torn speech strip ──────────────────────────────────────────────
   THE PRESS as-is; what's tuned here is the wavelength. --deckle points at
   #pg-torn (long, slow undulations) instead of the fine #deckle-m fuzz, and the
   corner radii are pushed far enough apart that the shape reads hand-torn before
   the filter even lands. */
.pg-bubble {
  position: relative;
  isolation: isolate;
  z-index: 1; /* prints over the character */
  max-width: min(70vw, 15.5em);
  padding: calc(var(--pg) * 0.085) calc(var(--pg) * 0.115)
    calc(var(--pg) * 0.11);
  --hair: 1.5px;
  --ink: calc(var(--hair) * 1.7);
  --cut: 11px;
  --deckle: url(#pg-torn);
  /* no two corners alike and no corner circular — the silhouette has to read
     hand-torn BEFORE the displacement filter lands, or the filter is doing all
     the work and the shape underneath is still a rounded box */
  --cut-corners: 46px 9px 34px 16px / 13px 40px 20px 44px;
  --edge-x: 4px;
  --edge-y: 7px;
  --edge-fill: var(--fill-wheel);
  rotate: -1.8deg;
  /* off the shared centre line, against dice-friend's own +1.4° lean — two
     stickers applied by the same hand, neither of them squared up */
  margin-right: calc(var(--pg) * 0.14);
}
/* the message. Styled as a heading because it is one; the ELEMENT is chosen by
   page-graphic.js (h1 when the graphic is the page, p when it's an overlay over
   something that already has a title), so both cases land here. */
.pg-msg {
  position: relative;
  margin: 0;
  text-align: center;
  text-wrap: balance;
  font-size: var(--fs-l);
  font-weight: 800;
  line-height: 1.28;
  letter-spacing: -0.012em;
}
/* the recovery line, when the page has one to offer */
.pg-act {
  position: relative;
  display: block;
  margin: 0.5em auto 0;
  padding: 0;
  border: 0;
  background: none;
  font: 700 var(--fs-s) sans-serif;
  color: #000;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.pg-act:hover {
  text-decoration-thickness: 2px;
}

/* ── 3. the leftovers on the sheet ─────────────────────────────────────────
   kiss-cut marks, tilted, sized off --pg so they travel with the sticker they
   orbit. Ink and paper only: a mark that means something can't be printed in a
   mood colour it doesn't own. Placement is in layout.css. */
.pg-sat {
  position: absolute;
  z-index: 3; /* loose on the sheet — they sit over both stickers */
  aspect-ratio: 1;
  overflow: visible;
  filter: url(#pg-mark);
  pointer-events: none;
}

/* ── single inert visualization ──────────────────────────────────────────
     Every not-actionable button reads the same: a gray outlined control with
     the diagonal-hatch texture, matching .card.inert so "unavailable" reads the
     same on buttons and cards. The `.inert` class is the opt-in for non-button
     elements; real `disabled` buttons (the endgame "propose new game" while
     waiting, lobby Confirm before a valid decklist, locked chips) keep the
     attribute for function and map to the same look. Last in the file so it
     wins specificity ties. */
.inert,
.lobby-btn:disabled,
.menu-acts button:disabled,
.chip:disabled {
  background-color: #fff;
  background-image: var(--hatch);
  background-size: 5px 5px;
  color: var(--gray);
  border: 1px solid var(--gray-light);
  cursor: default;
}

/* ── mobile: portrait gate + landscape retune ──────────────────────────────
   The board is built for landscape. Everything below is scoped to touch
   devices; desktop (fine pointer / hover) never sees it. */

/* the fullscreen link + rotate gate are mobile-only affordances */
#fsToggle {
  display: none;
}
#rotateGate {
  display: none;
}

/* portrait: send the player to landscape. z 1500 sits above the live board
   (flying cards 1000) but below the lobby (2100), so the lobby — a scrolling
   column that works fine in portrait — is never gated; only the board is.
   This query is the ONLY thing that decides whether the gate is up. board.js
   mounts the page graphic into it lazily and asks this rule for the answer
   (getComputedStyle(gate).display) rather than restating the query in JS, so the
   two can't drift. The overlay box stays here with the z-tier comment it belongs
   to; only the graphic's own measurements are in layout.css. */
@media (hover: none) and (pointer: coarse) and (orientation: portrait) {
  #rotateGate {
    /* the flag board.js reads back instead of restating this query. Set here and
       nowhere else, so "is the gate up" has exactly one answer and it is this
       rule's. Deliberately not `display`, which a div reports as "block" when no
       stylesheet has loaded at all (happy-dom under test) — this reads empty
       there, and empty is correctly "not up". */
    --gate-up: 1;
    display: grid;
    place-items: center;
    align-content: center;
    position: fixed;
    inset: 0;
    z-index: 1500;
    padding: 24px;
    /* a satellite hung past the sticker's edge must not be able to raise a
       scrollbar on an overlay that is exactly the viewport */
    overflow: clip;
    background: #fff;
    text-align: center;
  }
}
/* the no-JS line inside the gate (duel.html): the CSS above shows this overlay
   whether or not the bundle ran, so it has to say something on its own. Replaced
   wholesale by the page graphic the moment board.js mounts. */
.pg-fallback {
  margin: 0;
  font-size: var(--fs-l);
  font-weight: 800;
}

/* landscape touch: the token retune itself lives in system.css (with the tokens
   it retunes); what's left here are the rules that ride along with it. */
@media (hover: none) and (pointer: coarse) and (orientation: landscape) {
  #fsToggle {
    display: inline;
  }
  /* the in-game action button tightens automatically on the small frame — its
     padding is u-scaled in layout.css, so only the reserved row height is
     dropped here. */
  .menu-acts {
    min-height: 0;
  }
}
