/* ===== Pixel Card Game Theme ===== */
* { image-rendering: pixelated; }

/* Design tokens (M3) — bounded first pass. Just the two heavily-repeated
 * values: the Balatro MOTION spring (was copy-pasted 16x, and one ctkErrPunch
 * instance had drifted to 1.65,0.4 instead of the canonical 1.56,0.64) and
 * the card-hover elevation base (duplicated across all 7 card types). Single
 * source of truth so neither can drift again. Colour / type-scale tokens are
 * intentionally NOT migrated here — that's a 500+ literal refactor with real
 * regression risk and no UX payoff, better as its own dedicated change. */
:root {
  --ctk-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  /* Arena FX scale — 1 on a phone, up to 2 on a full desktop board. Written
     by ctkApplyFxScale (client/src/seat-geometry.ts) from the rendered seat
     width; declared here so the calc()s below are valid before the first
     board lays out, and on every page that has no board at all. */
  --ctk-fx-scale: 1;
  --ctk-card-hover-base: inset 1px 1px 0 rgba(200,170,120,0.3), inset -1px -1px 0 rgba(0,0,0,0.3), 0 10px 22px rgba(0,0,0,0.55);
}

body {
  font-family: "VT323", monospace;
  max-width: 100%;
  overflow-x: hidden;
  padding-bottom: 2rem;
  /* Page ground = the inside of a war tent (sand-table package, 2026-09).
     The old "Balatro Phase 4" starfield — six tiny cream dots over the
     vignette — read as outer space, the one thing a Han campaign tent is
     not. Now: one warm lamp hung high (the vignette's hot spot sits at
     50% 30%, not dead centre, so it reads as light falling from above),
     and faint 56px vertical seams so the dark field reads as hung cloth
     panels rather than a void. Same brown palette, still static, still
     `background-attachment: fixed`. */
  background:
    repeating-linear-gradient(90deg, rgba(255,226,168,0.022) 0 1px, transparent 1px 56px),
    radial-gradient(ellipse at 50% 30%, #2e2115 0%, #1a120c 55%, #0a0604 100%);
  background-attachment: fixed;
  color: #e8dcc8;
  font-size: 16px;
  letter-spacing: 0.3px;
}

/* Thai mode: switch to Bai Jamjuree (condensed display) at heavier
 * weights with tracked letter-spacing so the language swap doesn't drop
 * the pixel-art tactility down to a banking-app sans-serif. Body stays
 * 500; titles and UI chips get 600/700 elsewhere for an explicit
 * "chunky" rhythm. Bai Jamjuree's condensed forms and slightly retro
 * character pair better with VT323's pixel rhythm than Kanit's clean
 * geometric sans. */
html[lang="th"] body {
  font-family: "Bai Jamjuree", "Kanit", "Sarabun", sans-serif;
  font-weight: 500;
  font-size: 15px;
  letter-spacing: 0.02em;
}
html[lang="th"] .font-pixel {
  font-family: "Bai Jamjuree", "Kanit", sans-serif !important;
  font-weight: 600;
  letter-spacing: 0.04em;
}
/* Display-class elements (title, hero name, card title, badges, big stat
 * labels) lean heavier so they read with the same weight presence as
 * VT323 in English. */
html[lang="th"] .game-title,
html[lang="th"] .card-title,
html[lang="th"] .hero-name,
html[lang="th"] .px-badge,
html[lang="th"] .seat-row,
html[lang="th"] .hp-text {
  font-family: "Bai Jamjuree", "Kanit", sans-serif;
  font-weight: 700;
  letter-spacing: 0.03em;
}
/* The game title runs at large size so dial weight back slightly to keep
 * the Thai glyphs from feeling cramped against the box-shadow border. */
html[lang="th"] .game-title { font-weight: 600; letter-spacing: 2px; }

pre {
  white-space: pre-wrap;
  word-break: break-word;
}

/* Pixel border mixin via box-shadow */
.pxborder {
  border: 2px solid #5a3e28;
  box-shadow:
    inset  2px  2px 0 #d4b896,
    inset -2px -2px 0 #3a2510,
    0 0 0 2px #0e0804;
}
.pxborder-light {
  border: 2px solid #8a6a3d;
  box-shadow: 0 0 0 2px #2a1a0e;
}

/* Game frame */
.game-frame {
  background:
    linear-gradient(180deg, #2a2018 0%, #221a12 42%, #1f1810 60%, #1a140e 100%);
  border: 3px solid #6b5030;
  box-shadow:
    inset 0 1px 0 rgba(214,184,150,0.18),
    inset 0 0 34px rgba(90, 60, 30, 0.16),
    0 0 0 3px #0e0804,
    0 0 22px rgba(90, 60, 30, 0.28);
}

/* Player panel — a hero standee sitting ON the felt.
 * The old comment here read "intentionally flat ambient chrome. No
 * drop-shadow ... without competing with the hand row for the player's
 * eye" — that rationale expired when the hand moved off the board into
 * #handSheet, and flatness was the reason the seats never separated from
 * the table (art audit 2026-08, problem #1: panel-vs-felt was 1.01:1).
 * The 12px radius + top-lit plate is the .ctk-endplay-card language
 * already shipping 700 lines above, finally applied to the board.
 * Elevation is a box-shadow term, NOT `filter: drop-shadow` — a filter
 * would make this a containing block for the chip tooltip bubble
 * (position:fixed, z-9999, authored INSIDE the panel) and trap it. The
 * cost is that the active-seat glow trough / the dying trough + its
 * reduced-motion static / the is-viewer ring all REPLACE box-shadow, so
 * the same cast term is repeated at each of those sites — change one,
 * change all five. (Since the 2026-09 paint fix the ctkTurnGlow /
 * ctkDyingPulse KEYFRAMES no longer carry it: they animate the opacity
 * of a peak-glow ::before overlay while the cast stays static beneath —
 * the overlays must NOT repeat the cast term or it doubles at peak.)
 * No `overflow: hidden` either: the
 * border-radius already clips this background, and a clip here would eat
 * the DYING / ELIMINATED stamps and any bubble that escapes the
 * micro-tier's display:none. */
.player-panel {
  position: relative;  /* anchor for corner badge + ELIMINATED stamp */
  background:
    radial-gradient(ellipse at 50% -10%, rgba(120,92,48,0.18) 0%, transparent 62%),
    linear-gradient(180deg, #3b2f23 0%, #1a140e 100%);
  /* ONE faction frame, from the panel's data-kingdom (Stage 2). Replaces
   * the two inline borders both renderers used to emit — a 3px seat-index
   * left edge off the Chakra ramp plus a 3px kingdom top edge, from two
   * unrelated palettes. The 4px top band is the faction banner; the rest of
   * the frame is that kingdom's own shadow tone, so an unseated / unknown
   * kingdom falls back to the neutral #5a4530 the board always used. */
  border: 2px solid var(--k-frame, #5a4530);
  border-top: 4px solid var(--k-lit, #6b5530);
  /* SQUARE, deliberately. The art audit told me to port .ctk-endplay-card's
   * M3 language here, radius and all, and I did — but the problem it had
   * actually diagnosed was FLATNESS (panel-vs-felt was 1.01:1), not the
   * corners. The rounding came along unexamined and it fights everything
   * else this product is: a 1-bit pixel icon set, VT323, `image-rendering:
   * pixelated` on this very element, and a Three Kingdoms table. A 12px
   * curve on a ~110px card is a lot of anti-aliased softness in a design
   * that is otherwise hard-edged. The depth work stays; the silhouette goes
   * back to sharp. */
  border-radius: 0;
  box-shadow:
    inset 1px 1px 0 rgba(200,170,120,0.12),
    inset -1px -1px 0 rgba(0,0,0,0.3),
    0 0 0 1px #0e0804,
    0 3px 8px rgba(0,0,0,0.6);   /* cast onto the felt — see note above.
                                    Deliberately shallower than the hand
                                    sheet's `0 6px 14px` .pixel-card: the
                                    cards sit on a modal layer ABOVE the
                                    board, and tests/elevation-hierarchy
                                    pins that ordering. */
  image-rendering: pixelated;
  transition: border-color 0.35s ease-out, border-width 0.35s ease-out;
}
.player-panel:hover {
  border-color: #7a6240;
}

/* ============================================================
 * EMPEROR (LORD) SEAT — the role-mode Lord (主公) gets the imperial
 * DOUBLE-GOLD frame AND is SCALED UP to read as the grand central
 * throne. Bound via :has(.role-chip[data-role="Lord"]) — the chip
 * carries data-role in BOTH renderers (legacy string + PlayerPanel.tsx)
 * and is display:none on the board, but :has() matches DOM presence.
 * The Lord's role is public in SanGuoSha, so every viewer's VM carries
 * it; in free-for-all (no roles) there is no Lord → no frame, correct.
 *
 * Conflict-routing (the panel is busy): the gold rides on `border` +
 * `outline` — NEITHER is animated, so the frame survives the amber
 * turn-glow / red dying pulses, which both own `box-shadow` (same
 * reason the cream is-viewer ring uses outline). The outline yields to
 * the TARGETING cues (.seat-targetable / .seat-nudge also ride on
 * outline). The scale-up is a `.ctk-lord-grand` class added by
 * ctkReapplyOval AFTER it reserves ring room (shrinks --seat-w so the
 * bigger Lord never overlaps neighbours — see LORD_SCALE there); it is
 * applied only in the oval, never the HUD layout. Pure CSS/JS — no
 * markup, no determinism regen. */
#players > .player-panel:has(.role-chip[data-role="Lord"]) {
  border: 3px solid #e9c87a !important;        /* bright-gold token edge */
}
/* Deep-gold outer ring (the 2nd line of the double-gold). It rides on
 * `outline`, but so do the TARGETING cues — .seat-targetable's white
 * dashed ring and .seat-nudge's red flash — so YIELD to them: when the
 * Lord is a selectable target (Attack/Duel/…) drop the gold outline so the
 * white dash shows. The bright-gold border above still marks the seat. */
#players > .player-panel:has(.role-chip[data-role="Lord"]):not(.seat-targetable):not(.seat-nudge) {
  outline: 2px solid #8a6a20 !important;        /* deep-gold outer ring → double-gold */
  outline-offset: 0 !important;
}
/* Grand throne: scaled up around its oval centre. The translate STAYS so
 * the seat keeps its --seat-x/y position (oval golden rule); scale is
 * appended. z-index above normal seats (2) so any rounding overlap reads
 * cleanly. ctkReapplyOval reserves the room and adds/removes the class. */
#players > .player-panel.ctk-lord-grand {
  transform: translate(-50%, -50%) scale(var(--lord-scale, 1.12)) !important;
  z-index: 4;
}

/* TURN SHIMMER — Balatro juice on whoever's turn it is. The active panel
 * carries aria-current="true" in BOTH renderers; the sheen sweeps once
 * then rests, layering over the amber turn-glow (the ctkTurnGlow overlay
 * defined next to its keyframes further down). Amber-cream (NOT gold) so
 * it reads as "active", not as the Lord's gold.
 * PAINT-CHEAP FORM (2026-09, the PR #725/#763 frame-drop fix): the sweep
 * used to animate background-position on the panel's ::before — not
 * compositable, so every frame repainted the seat AND the felt stack
 * beneath it. It is now transform:translateX on a gradient STRIP (the
 * ::before of .ctk-turn-shine) riding inside an overflow:hidden carrier.
 * The carrier is a REAL CHILD emitted by BOTH renderers (PlayerPanel.tsx
 * + the legacy template): a pseudo-element cannot both BE the moving
 * strip and clip it, and the panel itself must never clip (the stamps /
 * bubbles — see the .player-panel comment). The strip is 250% of the
 * panel wide carrying the same 38/50/62% gradient, so translateX(-105%)
 * →(45%) reproduces the old background-position 175%→-75% sweep exactly
 * ((100%−250%)·P/100 ≡ 250%·T/100). display gated on [aria-current] so
 * inactive seats pay nothing; GUARDED :not(.player-dead) so a dead seat
 * never shows a sheen (it never holds the turn anyway). */
.ctk-turn-shine {
  display: none;
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 3;
  border-radius: 0;
}
#players > .player-panel[aria-current="true"]:not(.player-dead) > .ctk-turn-shine {
  display: block;
}
.ctk-turn-shine::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 250%;
  background-image: linear-gradient(115deg,
    rgba(255, 240, 210, 0) 38%, rgba(255, 240, 210, 0.42) 50%, rgba(255, 240, 210, 0) 62%);
  transform: translateX(-105%);   /* rest = the 0% keyframe, parked off-panel */
  animation: ctkTurnShimmer 4s ease-in-out infinite;
  will-change: transform;
}
@keyframes ctkTurnShimmer {
  0%   { transform: translateX(-105%); }
  22%  { transform: translateX(45%); }
  100% { transform: translateX(45%); }
}
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-turn-shine::before {
    animation: none;
    background-image: none;
  }
}
.player-dead {
  opacity: 0.55;
  filter: grayscale(0.7);
  /* The seat used to switch off in one frame; it now fades and greys over the
   * death sting (fix 7). Preact reuses the keyed panel node, so the class flip
   * transitions in place. */
  transition: opacity 0.4s ease, filter 0.4s ease;
}
/* Stamp height. Measured across a live 8-seat board the hero-name band
 * spans 37-59% of the card, and these stamps sat at 38% — straight through
 * the name on every seat that wore one. 26% clears the whole band with
 * margin and puts the stamp over the hero's upper body, which is where a
 * stamp belongs. Re-measure this if the card's row stack changes again. */
.player-dead::before {
  content: 'ELIMINATED';
  position: absolute; top: 26%; left: 50%;
  transform: translate(-50%, -50%) rotate(-10deg);
  font-family: 'VT323', monospace; font-size: calc(15px * min(var(--ctk-fx-scale), 1.3)); letter-spacing: 1px;
  color: #f0c8c0; background: rgba(60,16,16,0.95);
  border: 3px solid #c83030; padding: calc(2px * min(var(--ctk-fx-scale), 1.3)) calc(7px * min(var(--ctk-fx-scale), 1.3)) calc(2px * min(var(--ctk-fx-scale), 1.3)) calc(24px * min(var(--ctk-fx-scale), 1.3));
  background-image: url('assets/icons/skull-light.svg');
  background-repeat: no-repeat; background-position: calc(5px * min(var(--ctk-fx-scale), 1.3)) center; background-size: calc(14px * min(var(--ctk-fx-scale), 1.3)) calc(14px * min(var(--ctk-fx-scale), 1.3));
  box-shadow: 0 calc(4px * min(var(--ctk-fx-scale), 1.3)) 0 rgba(20,0,0,0.6);
  pointer-events: none; z-index: 5;
}
/* Thai death stamp — the EN literal above is the only board glyph that
 * stayed hardcoded; mirror it so a TH player sees "พ่ายแพ้" (defeated). */
/* The stamp slams in (fix 7) — and while .ctk-just-died holds it (the death
 * beat, until the role placard has left) it stays hidden, so the sequence
 * reads fade → placard → stamp instead of everything at once. Removing the
 * class starts the slam. */
@keyframes ctkDeadStampIn {
  0%   { opacity: 0; transform: translate(-50%, -50%) rotate(-10deg) scale(1.5); }
  60%  { opacity: 1; transform: translate(-50%, -50%) rotate(-10deg) scale(0.96); }
  100% { opacity: 1; transform: translate(-50%, -50%) rotate(-10deg) scale(1); }
}
.player-dead::before { animation: ctkDeadStampIn 0.35s cubic-bezier(0.2,1.1,0.3,1) both; }
.player-dead.ctk-just-died::before { animation: none; opacity: 0; }
html[lang="th"] .player-dead::before { content: 'พ่ายแพ้'; font-family: 'Bai Jamjuree', 'Kanit', sans-serif; letter-spacing: 0; }

/* Dying / last-stand: a still-alive seat at 0 HP is awaiting a rescue
 * (Peach) — the tensest beat in a hand, previously indistinguishable from a
 * healthy seat. Pulsing red glow + a "DYING" stamp; reduced-motion falls
 * back to a static red glow + stamp. Box-shadow (not border-color) carries
 * the ring so the inline border-left/top accents don't make it asymmetric.
 * PAINT-CHEAP FORM (2026-09, the PR #725/#763 frame-drop fix): the pulse
 * used to interpolate the panel's whole box-shadow list (ctkDyingPulse) —
 * not compositable, repainting the seat + felt every frame. The panel now
 * holds the trough (old 0%) list STATICALLY and the peak rides an ::before
 * overlay whose OPACITY pulses. Overlay alphas are tuned so trough +
 * overlay@1 composites to ≈ the old 50% keyframe (which REPLACED the
 * list): inset 0.15→0.4 ⇒ 0.33, ring 0.3→0.7 ⇒ 0.6. z-index:-1 puts the
 * overlay's paint (a negative-z child of the panel's transform-induced
 * stacking context) exactly where native box-shadow paints: above the
 * panel background, below content — the inset wash never tints the
 * portrait or captions. Selector notes: (1,3,1) lets the static trough
 * beat .is-viewer's (1,2,1) cream shadow, a fight the animation used to
 * win for free; the overlay is EXCLUDED on .seat-targetable (ctkSeatPulse
 * out-cascaded the old panel animation, so the green picking pulse
 * replaced the red — it still overrides the static trough below, because
 * animations beat normal declarations) and on [aria-current] (the amber
 * turn glow always won the active seat's shadow; its own ::before overlay
 * takes the slot — that rule follows this one in file order). */
#players > .player-panel.seat-dying:not(.player-dead) {
  box-shadow:
    inset 0 0 8px rgba(200,40,40,0.15),
    0 0 2px 0 rgba(229,62,62,0.3),
    0 3px 8px rgba(0,0,0,0.6);   /* cast onto the felt — see .player-panel */
}
#players > .player-panel.seat-dying:not(.player-dead):not(.seat-targetable):not([aria-current="true"])::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  box-shadow:
    inset 0 0 16px rgba(229,62,62,0.33),
    0 0 12px 3px rgba(229,62,62,0.6);
  opacity: 0;
  animation: ctkDyingPulse 0.9s ease-in-out infinite;
  will-change: opacity;
}
@keyframes ctkDyingPulse {
  0%, 100% { opacity: 0; }
  50%      { opacity: 1; }
}
.seat-dying::after {
  content: 'DYING';
  position: absolute; top: 26%; left: 50%;
  transform: translate(-50%, -50%) rotate(-8deg);
  /* The stamp face (juice audit fix 3): the roulette card's weight — a 3px
   * border, a hard drop shadow, a solid ground — and it grows with the card
   * through --ctk-fx-scale like the float numbers do (capped at 1.3× so
   * ELIMINATED still fits a roomy 4-seat token); a 14px stamp on a 150px
   * desktop card had stopped registering. The transient .ctk-dying-label
   * copies the face this panel's ::after actually computes to — compact ring
   * tier included — so the fade into it is seamless (the hand-off contract,
   * pinned by brink-sequence.spec.ts). */
  font-family: 'VT323', monospace; font-size: calc(15px * min(var(--ctk-fx-scale), 1.3)); letter-spacing: 1px;
  color: #fff0f0; background: rgba(120,16,16,0.95);
  border: 3px solid #ff5a5a; padding: calc(2px * min(var(--ctk-fx-scale), 1.3)) calc(9px * min(var(--ctk-fx-scale), 1.3));
  box-shadow: 0 calc(4px * min(var(--ctk-fx-scale), 1.3)) 0 rgba(20,0,0,0.6), 0 0 12px rgba(239,68,68,0.55);
  pointer-events: none; z-index: 6;
  animation: ctkDyingStamp 0.9s ease-in-out infinite;
}
@keyframes ctkDyingStamp {
  0%, 100% { opacity: 0.85; transform: translate(-50%, -50%) rotate(-8deg) scale(1); }
  50%      { opacity: 1;    transform: translate(-50%, -50%) rotate(-8deg) scale(1.08); }
}
html[lang="th"] .seat-dying::after { content: 'ใกล้ตาย'; font-family: 'Bai Jamjuree', 'Kanit', sans-serif; letter-spacing: 0; }
@media (prefers-reduced-motion: reduce) {
  /* Static red glow instead of the pulse. .seat-dying is DOUBLED for
   * (1,4,1): the static must also beat the ACTIVE seat's amber trough
   * (1,3,1, later in the file) so a dying seat stays red on its own
   * reduced-motion turn — where the collapsed animations used to settle. */
  :where(html:not(.ctk-force-fx)) #players > .player-panel.seat-dying.seat-dying:not(.player-dead) { box-shadow: inset 0 0 16px rgba(229,62,62,0.4), 0 0 8px 2px rgba(229,62,62,0.5), 0 3px 8px rgba(0,0,0,0.6); }
  :where(html:not(.ctk-force-fx)) #players > .player-panel.seat-dying:not(.player-dead):not(.seat-targetable):not([aria-current="true"])::before { animation: none; opacity: 0; }
  :where(html:not(.ctk-force-fx)) .seat-dying::after { animation: none; opacity: 1; }
}
/* ===== Defiance (Zhou Tai, Buqu) — standing at <=0 HP. The same slot as
   the dying halo but the OPPOSITE state: no rescue is open, he keeps
   fighting. Ember red-gold, a slow breathing pulse (dying is a fast alarm)
   and a stamp that says so; the HP corner reads the true negative total.
   Same selector shape as .seat-dying so it wins/loses the same fights
   (targetable glow, active-seat amber). ===== */
#players > .player-panel.seat-defiant:not(.player-dead) {
  box-shadow:
    inset 0 0 10px rgba(180,40,30,0.22),
    0 0 3px 0 rgba(248,113,113,0.35),
    0 3px 8px rgba(0,0,0,0.6);
}
#players > .player-panel.seat-defiant:not(.player-dead):not(.seat-targetable):not([aria-current="true"])::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  box-shadow:
    inset 0 0 18px rgba(248,113,113,0.28),
    0 0 14px 3px rgba(220,80,60,0.55);
  opacity: 0.25;
  animation: ctkDefiantBreath 2.2s ease-in-out infinite;
  will-change: opacity;
}
@keyframes ctkDefiantBreath {
  0%, 100% { opacity: 0.25; }
  50%      { opacity: 1; }
}
.seat-defiant::after {
  content: 'DEFIANT';
  position: absolute; top: 26%; left: 50%;
  transform: translate(-50%, -50%) rotate(-8deg);
  font-family: 'VT323', monospace; font-size: calc(15px * min(var(--ctk-fx-scale), 1.3)); letter-spacing: 1px;
  color: #fff4e6; background: rgba(90,18,14,0.95);
  border: 3px solid #f87171; padding: calc(2px * min(var(--ctk-fx-scale), 1.3)) calc(9px * min(var(--ctk-fx-scale), 1.3));
  box-shadow: 0 calc(4px * min(var(--ctk-fx-scale), 1.3)) 0 rgba(20,0,0,0.6), 0 0 12px rgba(248,113,113,0.5);
  pointer-events: none; z-index: 6;
}
html[lang="th"] .seat-defiant::after { content: 'ทรหด'; font-family: 'Bai Jamjuree', 'Kanit', sans-serif; letter-spacing: 0; }
/* The negative HP readout glows ember; the empty pips take a dark-red cast
   so the row reads "wounded past empty" instead of "no HP, no colour". */
.player-hp-corner.hp-defiant .hp-text { color: #f87171; text-shadow: 0 0 6px rgba(248,113,113,0.6); }
.player-hp-corner.hp-defiant .hp-pip-empty { color: #8b2a2a; opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) #players > .player-panel.seat-defiant.seat-defiant:not(.player-dead) { box-shadow: inset 0 0 16px rgba(220,80,60,0.35), 0 0 8px 2px rgba(248,113,113,0.45), 0 3px 8px rgba(0,0,0,0.6); }
  :where(html:not(.ctk-force-fx)) #players > .player-panel.seat-defiant:not(.player-dead):not(.seat-targetable):not([aria-current="true"])::before { animation: none; opacity: 0; }
}

/* HP hearts */
.hp-heart { display: inline-block; width: 10px; height: 10px; margin: 0 1px; }
.hp-heart-full { color: #e53e3e; text-shadow: 0 0 3px rgba(229,62,62,0.5); }
.hp-heart-empty { color: #4a3a3a; }
.hp-row { display: inline-flex; align-items: center; gap: 1px; }
.hp-pip { display: inline-flex; line-height: 0; }
.hp-pip svg { width: 10px; height: 10px; image-rendering: pixelated; }
/* Lit hearts are uniformly red; empty (lost-HP) pips stay dim. */
.hp-pip-full  { color: #e53e3e; }
.hp-pip-empty { color: #6b5238; opacity: 0.8; }
.hp-text {
  font-family: 'VT323', monospace; font-size: 13px; color: #d4c4a0;
  letter-spacing: 1px; margin-left: 6px; vertical-align: 1px;
}

/* Defensive: inline pixelarticons SVGs anywhere inside a player-panel
 * fall back to chip-sized so a missing per-class rule won't blow up to
 * the 300x150 replaced-element default. Specific rules below win. */
.player-panel svg:not([width]) { width: 12px; height: 12px; }

/* HP plate — floats on the art's top-left corner instead of sitting on a
 * bare, left-aligned row of hearts ABOVE the hero image. The art audit
 * proposed floating it onto the portrait's corner on a plate; that was
 * rejected in review — nothing occludes the painting, and the row needs no
 * plate of its own because it sits on the panel, not on art. Left-flush and
 * undimmed, as it was. HP stays double-encoded (pips + "3/4") on purpose;
 * collapsing to one is a Stage 2 call, not a CSS one. */
.player-hp-corner {
  display: flex;
  align-items: center;
  gap: 1px;
  border-radius: 0;
  padding: 1px 4px;
  margin: 1px 0 0;
}
.player-hp-corner .hp-pip svg { width: 9px; height: 9px; }
.player-hp-corner .hp-text {
  font-family: 'VT323', monospace; font-size: 12px; color: #e8dcc8;
  letter-spacing: 1px; margin-left: 4px;
}

/* Bot/self/human badge — now lives inline in the seat-row caption, in front
 * of "Seat N". The title attr carries the AI/You/Human tooltip. */
.player-corner-badge {
  width: 16px; height: 16px; flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid rgba(107,85,48,0.6); background: rgba(20,14,8,0.85);
  border-radius: 2px; color: #d4c4a0;
}
/* THE KINGDOM LAW. One saturation and one lightness per role, hues spaced
 * around the wheel and well off the felt's ~32deg, so the four factions read
 * as one set instead of four unrelated swatches. `--k-lit` (S42 L58) is the
 * 4px top banner; `--k-frame` (S34 L26) is the surrounding frame in that
 * kingdom's own shadow tone. Applied to `.player-panel`, NOT `#players >`,
 * so the player-detail modal's clone — which carries the same data-kingdom —
 * gets the same frame.
 *
 * This replaces THREE disagreeing copies of the kingdom palette: the
 * `KINGDOM_TINT` local in the legacy renderer, `VM_KINGDOM_TINT` in
 * client/src/vm-builders.ts, and the `HERO_FALLBACK_TINT` pair in
 * client/src/hero-art.ts. Kingdom colour is defined HERE now; the two TS
 * copies that remain are documented as pointing back at this block.
 *
 * Note what is deliberately NOT here: seat-index hue. `SEAT_PALETTE`
 * (client/src/icons.ts) keeps its saturated ramp, because the LOG and the
 * all-hands spectator rows genuinely need to tell eight seats apart at a
 * glance. It simply no longer reaches the panel edge. */
.player-panel[data-kingdom="shu"] { --k-lit: #c16d67; --k-frame: #592f2c; }
.player-panel[data-kingdom="wei"] { --k-lit: #6794c1; --k-frame: #2c4259; }
.player-panel[data-kingdom="wu"]  { --k-lit: #67c194; --k-frame: #2c5942; }
.player-panel[data-kingdom="qun"] { --k-lit: #a067c1; --k-frame: #482c59; }

.player-corner-badge.is-self { border-color: #fbbf24; color: #fbbf24; }
/* The seat-row rule above strips the badge's box and dims it to caption
 * weight; your OWN marker stays at full strength. */
.seat-row .player-corner-badge.is-self { opacity: 1; }
.player-corner-badge svg { width: 11px; height: 11px; }

/* Chips are tap-to-inspect now (the ⓘ button was removed) — signal it. */
.ctk-tip-wrap { cursor: pointer; }

/* Hand-count stack */
.hand-stack {
  display: inline-flex; align-items: center; gap: 3px;
  color: #d4c4a0; margin-right: 4px; vertical-align: -2px;
}
.hand-stack svg { width: 14px; height: 14px; image-rendering: pixelated; }
.hand-count { font-family: 'VT323', monospace; font-size: 14px; }
/* Range stat: the ↔ icon + the attack-range number.
   A dead seat keeps its hand count but drops the (meaningless) range 0. */
.range-stat { display: inline-flex; align-items: center; gap: 3px; vertical-align: -2px; margin-left: 4px; color: #d4c4a0; }
.range-stat svg { width: 14px; height: 14px; image-rendering: pixelated; }
/* Defensive incoming-distance hint (-1 horse / Shadow): tinted so the "+N"
   reads as a separate, defensive stat rather than the seat's own range
   sitting right beside it. Was #9fc2e0 — a cool blue that, once the
   equipment chips went gold, was the last cold object left on a warm board
   (art audit 2026-08). It is now separated by CHROMA, not temperature. */
.range-stat.incoming-stat { color: #7fd4b4; }
#players .player-dead .range-stat { display: none; }

/* Seat-banner overlays. Two floating labels ride OUTSIDE the seat cards:
 * TURN (above the active seat) and YOU (below the viewer's own seat, ^
 * pointing up at it). Neither can be a panel child: every seat sets
 * `container-type: inline-size` (for the @container tiers), which applies
 * paint containment and clips any child that spills past the card;
 * #battleArena clips too. So each is a body-level position:fixed element that
 * JS (ctkPositionSeatBanners) parks over its seat — the coach-mark escape
 * hatch. Fixed means no ancestor overflow/containment can crop it, for any
 * seat or layout mode. pointer-events:none so they never eat a seat-targeting
 * tap. The seat-row caption also goes gold (.seat-active) on the active seat. */
.seat-active { color: #fbbf24; font-weight: bold; }
.ctk-seat-banner {
  position: fixed; top: 0; left: 0; z-index: 46;
  display: none; align-items: center; gap: 2px;
  padding: 1px 6px 0; white-space: nowrap; pointer-events: none;
  border-radius: 3px; box-shadow: 0 2px 6px rgba(0,0,0,0.6);
  font-family: 'VT323', monospace; font-size: 13px; font-weight: bold;
  letter-spacing: 0.5px; line-height: 1.2;
}
.ctk-seat-banner.is-shown { display: inline-flex; }
.ctk-seat-banner svg { width: 12px; height: 12px; }
/* TURN — amber, matches the turn glow / .seat-active gold. Now the
 * brightest note on the board, with a halo so it reads at a glance. */
#ctkTurnBanner {
  background: #fbbf24; color: #1a120a; border: 1px solid #8a6418; text-transform: capitalize;
  box-shadow: 0 2px 6px rgba(0,0,0,0.6), 0 0 14px rgba(251,191,36,0.45);
}
/* YOU — identity pill under the viewer's own seat. It was cream-on-dark at
 * L*88, out-valuing the TURN pill (L*81): the board's brightest object was
 * a permanent orientation label that never changes, while the one cue you
 * must find every few seconds sat below it (art audit 2026-08, problem #5).
 * Demoted to a dark plate; the accent budget belongs to whoever is acting. */
#ctkYouBanner { background: rgba(28,20,12,0.9); color: #d4c4a0; border: 1px solid #6b5530; text-transform: uppercase; }
/* While the engine is blocked on THIS viewer for a response, the bottom
 * prompt panel already announces "it's your move", and the fixed YOU pill
 * (z-index:46) would otherwise paint on top of the prompt text. Suppress it
 * for the duration of the prompt; render() toggles body.ctk-viewer-prompt. */
body.ctk-viewer-prompt #ctkYouBanner { display: none !important; }

/* "关系 / Reads" relationship modal — a directed graph of who publicly reads
 * whom as which HIDDEN role. Data-driven from the broadcast roleGuesses[]
 * (self-authored player OPINION): nodes = seats on a ring, edges = reads
 * coloured by the GUESSED role. Presentation-NEUTRAL — never coloured by a
 * seat's true role, never by whether a read is correct. Doubles as the input
 * surface (tap a seat -> set/clear your read). Replaces the old per-seat
 * board bubbles, which crowded the 8-seat oval. */
/* Sit ABOVE transient in-match overlays (coach-mark hints z=1050, hand sheet
 * 1080, card inspector 1100) so nothing floats over the graph and eats taps on
 * the seat nodes. The base .ctk-modal-backdrop is only z=1000. Still below the
 * match-end overlays (1200+), and #ctkReadsOnState closes this on match end. */
#readsModal { z-index: 1150; }
#readsModal .ctk-modal-card {
  background: #17110a; border: 2px solid #5a4530; border-radius: 8px;
  padding: 10px 12px 12px; width: min(560px, 92vw); max-width: 92vw;
  display: flex; flex-direction: column; gap: 8px;
}
.ctk-reads-head { display: flex; align-items: center; gap: 8px; padding-right: 26px; }
.ctk-reads-title { font-family: 'VT323', monospace; font-size: 20px; color: #f0d9a8; font-weight: bold; }
.ctk-reads-filter { margin-left: auto; display: inline-flex; border: 1px solid #5a4530; border-radius: 4px; overflow: hidden; }
.ctk-reads-filter button { background: transparent; color: #cbb892; border: 0; padding: 2px 10px; font-family: 'VT323', monospace; font-size: 13px; cursor: pointer; }
.ctk-reads-filter button.on { background: #5a4530; color: #ffe6b0; }
/* A centred SQUARE that never exceeds the vertical budget. Using width:100% +
 * max-height would break the 1:1 ratio on short/wide viewports (preserveAspectRatio
 * then shrinks the 400x400 viewBox into a narrow band with dead side-margins and
 * cramped, hard-to-hit circles). Capping the WIDTH by viewport height keeps it
 * square and fills the space, so the nodes stay large and clickable everywhere. */
.ctk-reads-svg { display: block; margin: 0 auto; width: min(100%, 58vh); aspect-ratio: 1 / 1; }
.ctk-reads-node-hit { cursor: pointer; }
.ctk-reads-node-hit:hover circle { filter: brightness(1.25); }
.ctk-reads-legend { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; font-family: 'VT323', monospace; font-size: 12px; }
.ctk-reads-legend span { display: inline-flex; align-items: center; gap: 3px; color: #cbb892; }
.ctk-reads-legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.ctk-reads-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 30px; }
.ctk-reads-foot .lbl { font-family: 'VT323', monospace; font-size: 13px; color: #cbb892; }
.ctk-reads-foot .hint { color: #8a7c60; }
.px-btn.ctk-reads-role.on { box-shadow: inset 0 0 0 2px currentColor; filter: brightness(1.15); }
.ctk-reads-close { position: absolute; top: 6px; right: 8px; padding: 2px 8px; }
/* Reads entry button — sits in the quick-chat strip alongside the emotes. */
.ctk-reads-open.ctk-emote-btn { color: #e0c48a; border-color: #7a5a30; }
.ctk-reads-open.ctk-emote-btn:hover { border-color: #c79a4e; color: #ffe6b0; }

/* Role chip */
.role-chip { margin-left: 4px; }
.role-chip[data-role] {
  font-weight: bold;
  transition: filter 0.12s ease-out, transform 0.12s ease-out;
}
.role-chip[data-role]:hover,
.role-chip[data-role]:focus-visible {
  filter: brightness(1.15) drop-shadow(0 0 4px currentColor);
  outline: none;
}
.role-chip[data-role]:active { transform: scale(0.96); }
/* Spacing + vertical centring come from the .px-badge flex base (gap:3px,
   align-items:center) now; the old vertical-align/margin-right would
   double-count the gap and mis-centre the icon. */
.role-chip svg { width: 12px; height: 12px; }

/* Status row layout */
.status-row {
  display: flex; flex-wrap: wrap; gap: 3px;
  justify-content: center; margin-top: 2px;
}

/* Critical strip: hidden in full / compact tiers, shown in micro via the
 * @container (max-width: 80px) rule below. Always emitted by the renderer
 * if any action-affecting flag is set, so tier-switching is pure CSS. */
.critical-strip { display: none; }
.critical-strip .crit { display: inline-flex; line-height: 0; }
.critical-strip .crit svg { width: 10px; height: 10px; image-rendering: pixelated; }
.critical-strip .crit-debuff { color: #ff8888; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.95)); }
.critical-strip .crit-buff   { color: #88e090; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.95)); }
/* Equipment = gold, completing the buff(green)/debuff(red)/equip(gold)
 * colour hierarchy so each category reads distinctly at a glance. */
.critical-strip .crit-equip  { color: #f0c050; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.95)); }

/* Hand card pixel style — Balatro-inspired tactile hover.
 * Resting state layers an M3-style soft drop-shadow on top of the
 * pixel-art bevel so hand-cards visibly *float above the table* at
 * a glance, distinct from the flat .player-panel chrome. */
.pixel-card {
  position: relative;
  background: linear-gradient(180deg, #efe6cf 0%, #e4d8bb 100%);
  border: 2px solid #2a2018;
  color: #2a2018;
  box-shadow:
    0 2px 0 #0e0804,
    0 6px 14px rgba(0,0,0,0.5);
  cursor: pointer;
  /* Spring easing — slight overshoot gives the card a "pop" on hover */
  transition: transform 0.22s var(--ctk-spring),
              box-shadow 0.18s ease-out,
              filter 0.18s ease-out;
  padding: 4px 8px;
  min-width: 60px;
  text-align: center;
  font-size: 14px;
  transform-origin: center bottom;
}
.pixel-card:hover:not(:disabled) {
  transform: translateY(-12px) scale(1.07) rotate(-2deg);
  box-shadow:
    inset 1px 1px 0 rgba(200,170,120,0.3),
    inset -1px -1px 0 rgba(0,0,0,0.3),
    0 10px 22px rgba(0,0,0,0.55),
    0 0 18px rgba(245,158,11,0.35);
  filter: brightness(1.08);
  z-index: 10;
}
.pixel-card:active:not(:disabled) {
  transform: translateY(-4px) scale(1.02) rotate(-1deg);
  transition: transform 0.08s ease-out, box-shadow 0.08s ease-out;
  box-shadow:
    inset 1px 1px 0 rgba(0,0,0,0.2),
    inset -1px -1px 0 rgba(200,170,120,0.1),
    0 4px 8px rgba(0,0,0,0.4);
}
.pixel-card:disabled { opacity: 0.5; cursor: not-allowed; filter: grayscale(0.5); }
/* Per-type hover glow tint — matches the accent bar colors */
.pixel-card.card-attack:hover:not(:disabled)  { box-shadow: var(--ctk-card-hover-base), 0 0 22px rgba(229,62,62,0.55); }
.pixel-card.card-defense:hover:not(:disabled) { box-shadow: var(--ctk-card-hover-base), 0 0 22px rgba(59,130,246,0.55); }
.pixel-card.card-peach:hover:not(:disabled)   { box-shadow: var(--ctk-card-hover-base), 0 0 22px rgba(236,72,153,0.55); }
.pixel-card.card-tactic:hover:not(:disabled)  { box-shadow: var(--ctk-card-hover-base), 0 0 22px rgba(139,92,246,0.55); }
.pixel-card.card-equip:hover:not(:disabled)   { box-shadow: var(--ctk-card-hover-base), 0 0 22px rgba(245,158,11,0.6); }
.pixel-card.card-delay:hover:not(:disabled)   { box-shadow: var(--ctk-card-hover-base), 0 0 22px rgba(107,114,128,0.55); }
.pixel-card.card-negate:hover:not(:disabled)  { box-shadow: var(--ctk-card-hover-base), 0 0 22px rgba(160,174,192,0.55); }
/* "Play" feedback — applied via JS when a card is selected.
   Use the class to chain an additional small bounce + glow flash. */
@keyframes ctk-card-play {
  0%   { transform: translateY(-12px) scale(1.07) rotate(-2deg); filter: brightness(1.6); }
  40%  { transform: translateY(-24px) scale(1.12) rotate(-3deg); filter: brightness(2.0); }
  100% { transform: translateY(-12px) scale(1.07) rotate(-2deg); filter: brightness(1.08); }
}
.pixel-card.is-playing { animation: ctk-card-play 0.32s ease-out; }

/* Card type accent bars */
.pixel-card::before {
  content: '';
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 2px;
}
.pixel-card.card-attack::before { background: #e53e3e; box-shadow: 0 0 4px #e53e3e; }
.pixel-card.card-attack { color: #7a1f1f; border-color: #c53030; }
.pixel-card.card-defense::before { background: #3b82f6; box-shadow: 0 0 4px #3b82f6; }
.pixel-card.card-defense { color: #1e3a6b; border-color: #2b6cb0; }
.pixel-card.card-peach::before { background: #ec4899; box-shadow: 0 0 4px #ec4899; }
.pixel-card.card-peach { color: #7a1f4a; border-color: #c53072; }
.pixel-card.card-tactic::before { background: #8b5cf6; box-shadow: 0 0 4px #8b5cf6; }
.pixel-card.card-tactic { color: #3a2470; border-color: #6b46c1; }
.pixel-card.card-equip::before { background: #f59e0b; box-shadow: 0 0 4px #f59e0b; }
.pixel-card.card-equip { color: #6b4a0a; border-color: #b7791f; }
.pixel-card.card-delay::before { background: #6b7280; box-shadow: 0 0 4px #6b7280; }
.pixel-card.card-delay { color: #33373d; border-color: #4a5568; }
.pixel-card.card-negate::before { background: #111827; }
.pixel-card.card-negate { color: #2a2018; border-color: #3d4758; background: linear-gradient(180deg,#d8cdb0,#cbbf9e); }

/* 主公技 Lord-only active skill card. Slots into the hand row as
 * a virtual play option when invocable. Gold border + crown badge
 * mark it as "borrowed" (the actual Attack comes from a Shu ally
 * via Jijiang). Underneath it reuses .card-attack styling so the
 * hover spring + suit-tint glow match a real Attack pick.
 *
 * Pulse keyframe lifted from the same Balatro hover idiom used by
 * .pixel-card:hover — runs continuously here since the card IS
 * an attention-getter (the player skipping their turn often
 * misses the opportunity otherwise). */
.pixel-card.card-skill-jijiang {
  border-color: #d4a020;
  box-shadow: 0 0 0 1px #fbbf24, 0 4px 12px rgba(212, 160, 32, 0.45);
  animation: ctkJijiangPulse 1.8s ease-in-out infinite;
}
.pixel-card.card-skill-jijiang::before { background: #fbbf24; box-shadow: 0 0 6px #fbbf24; }
.pixel-card.card-skill-jijiang .card-title {
  color: #fbbf24;
  font-weight: 700;
  letter-spacing: 0.5px;
}
.pixel-card.card-skill-jijiang .card-skill-badge {
  position: absolute;
  top: 3px;
  right: 3px;
  font-size: 8px;
  color: #1a1410;
  background: #fbbf24;
  padding: 1px 3px;
  border-radius: 1px;
  letter-spacing: 0.5px;
  pointer-events: none;
}
@keyframes ctkJijiangPulse {
  0%, 100% { box-shadow: 0 0 0 1px #fbbf24, 0 4px 12px rgba(212, 160, 32, 0.45); }
  50%      { box-shadow: 0 0 0 2px #fbbf24, 0 4px 18px rgba(212, 160, 32, 0.7); }
}

/* Card / hero artwork — Balatro Phase 2 redesign.
 * Fixed pixel dimensions so every hand card looks identical regardless
 * of the card's translated name length; the title row truncates with
 * ellipsis instead of growing the card. */
.pixel-card {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: 4px 6px 6px 6px;
  width: 84px;               /* fixed: card width is uniform across the hand */
  height: 140px;             /* fixed: title row, image, suit/rank header all fit */
  min-width: 84px;
  box-sizing: border-box;
  overflow: hidden;          /* hard cap; ellipsis on title handles overflow */
}
/* Compact picker form used inside #pendingActions for multi-card-pick
 * pending types (discard-pick, zhiheng-discard, serpent-attack-discard,
 * ganglie-choice discards). The full hand row already shows the same
 * cards in their dramatic 84x140 form — duplicating them in the pending
 * strip looked cluttered and hierarchy-flat. Pickers shrink to a single-
 * line chip-row: small badge, card label only, no big art and no per-
 * card ⓘ chip. The Balatro+M3 read: "controls live below the canvas,
 * not on it." */
.pixel-card.picker-compact {
  flex-direction: row;
  width: auto;
  min-width: 0;
  height: 28px;
  padding: 2px 8px;
  gap: 6px;
  font-size: 13px;
  line-height: 1;
  overflow: visible;
  box-shadow:
    inset 1px 1px 0 rgba(200,170,120,0.15),
    inset -1px -1px 0 rgba(0,0,0,0.3),
    0 2px 0 #0e0804;            /* drop the soft drop-shadow that hand
                                   cards use — controls stay flat */
}
/* M3/HIG tap-target floor: the picker chips are only 28px tall but are the
 * time-critical action controls (tapped under a turn timer). A transparent
 * ::after lifts the effective hit area to a 44px vertical floor WITHOUT
 * growing the visual chip. Vertical-only — the chips sit in a horizontal
 * row, so a horizontal expander would overlap neighbours and mis-route taps.
 * The pseudo is part of the button for hit-testing, so clicks in the grown
 * strip still fire the chip's own handler. (.pixel-card is position:relative
 * + overflow:visible, and ::before is the type-accent bar, so ::after is free.) */
.pixel-card.picker-compact::after {
  content: "";
  position: absolute;
  left: 0; right: 0; top: 50%;
  transform: translateY(-50%);
  height: 44px;
}
.pixel-card.picker-compact .card-img { display: none; }
.pixel-card.picker-compact .card-suit-rank { padding: 0 3px; font-size: 13px; }
.pixel-card.picker-compact .card-suit-rank .suit-glyph { font-size: 12px; }
.pixel-card.picker-compact .card-title {
  display: inline; width: auto; padding-left: 2px;
}
.pixel-card.picker-compact:hover:not(:disabled) {
  /* Less aggressive than the hand-card hover lift — pickers are
   * controls, not the protagonist. Just a small nudge + brightness. */
  transform: translateY(-1px) scale(1.02);
  filter: brightness(1.1);
}
/* When the picker row is rendered, the row container gets a thin
 * left accent so the user understands this strip is *the picker*,
 * not duplicate UI from the hand row. */
#pendingActions .picker-row {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  width: 100%;
  align-items: center;
  padding-left: 6px;
  border-left: 2px solid #6b5530;
  margin: 2px 0;
}
#pendingActions .picker-hint {
  width: 100%;
  color: #d4c4a0;
  font-size: 13px;
  line-height: 1.3;
  margin-bottom: 2px;
}

/* Fate's Hand (Guicai) picker: the hand is GROUPED by the outcome each card
 * produces (every judgement here is binary), so the player reads two plain-
 * language buckets — "these cards -> Strikes Seat 3", "these cards -> Passes,
 * safe" — instead of scanning a per-card badge on every chip. The bucket that
 * differs from the current flip sits on top (the change you can make); the one
 * Decline keeps is tagged "now" below. A green/red valence tint marks the
 * favorable bucket ONLY where allegiance is knowable (gated in the picker JS) —
 * never asserting a hidden role. */
#pendingActions .guicai-groups { display: flex; flex-direction: column; gap: 8px; width: 100%; }
#pendingActions .guicai-group {
  border: 1px solid #4a5a34; border-left-width: 3px; border-radius: 8px;
  padding: 8px 10px; background: rgba(0, 0, 0, .18);
}
#pendingActions .guicai-group.is-favorable { border-color: #22c55e; background: rgba(34, 197, 94, .08); }
#pendingActions .guicai-group.is-unfavorable { border-color: #ef4444; background: rgba(239, 68, 68, .07); }
#pendingActions .guicai-group-head { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
#pendingActions .guicai-group-dot { width: 8px; height: 8px; border-radius: 50%; background: #8b7a4f; flex: none; }
#pendingActions .guicai-group.is-favorable .guicai-group-dot { background: #22c55e; }
#pendingActions .guicai-group.is-unfavorable .guicai-group-dot { background: #ef4444; }
#pendingActions .guicai-group-result { font-weight: 700; font-size: 13px; color: #f1e6c8; line-height: 1.2; }
#pendingActions .guicai-group-now {
  margin-left: auto; font-size: 10px; text-transform: uppercase; letter-spacing: .03em;
  color: #c9b98f; border: 1px solid #6b5530; border-radius: 4px; padding: 1px 6px; white-space: nowrap; flex: none;
}
#pendingActions .guicai-group-cards { display: flex; flex-wrap: wrap; gap: 6px; }
#pendingActions .guicai-group-empty { font-size: 11px; color: #9c8f6f; font-style: italic; }
#pendingActions .picker-hint .guicai-rule { color: #c9b98f; font-weight: 400; font-size: 12px; margin-top: 3px; }

/* CONSIST-7: the shared "armed"/selected-chip highlight. It was referenced by
 * the Tuxi / Tianxiang / Liuli pickers (web/index.html) but never defined, so
 * their selection feedback was a silent no-op. A gold ring restores it. */
#pendingActions .picker-armed {
  outline: 2px solid #d9b45a; outline-offset: 1px;
  box-shadow: 0 0 0 3px rgba(217, 180, 90, .3), 0 2px 0 #0e0804;
}

/* ============================================================
 * Guan Xing reorder modal (Zhuge Liang's 觀星). A blocking, multi-step
 * task, so it's a focused M3 dialog (centered scrim + elevated surface)
 * rather than a cramped inline strip in the bottom action zone.
 * #pendingActions.guanxing-modal is the scrim; .guanxing-card is the panel.
 * The existing #pendingActions content + delegated click handlers are
 * reused verbatim — only presentation changes — so it works on mobile and
 * desktop.
 *
 * Design: a vertical list of elevated "slat" rows, each with a gilded
 * ordinal medallion, a card-type-tinted left rail, the tamed
 * .picker-compact card chip, and 44px ↑/↓ controls. The top slot (the next
 * card to be drawn) glows + softly pulses — an Arena-of-Valor HUD "next
 * draw" cue that teaches top-of-list = next-drawn without touching the
 * server payload. M3 mechanics, Sanguosha skin, a touch of Balatro pop.
 * ============================================================ */
#pendingActions.guanxing-modal {
  position: fixed; inset: 0; z-index: 1000;
  display: flex; align-items: center; justify-content: center;
  flex-wrap: nowrap;
  padding: 16px;
  margin: 0 !important;
  background: radial-gradient(120% 90% at 50% 0%, rgba(40,28,14,0.72) 0%, rgba(0,0,0,0.84) 70%);
  overflow-y: auto;
}
.guanxing-card {
  width: 100%; max-width: 380px;
  max-height: 88vh; max-height: 88dvh;
  display: flex; flex-direction: column;
  background: linear-gradient(180deg, #221a12 0%, #15110d 100%);
  border: 2px solid #6b5530; border-radius: 14px;
  padding: 16px 14px calc(14px + env(safe-area-inset-bottom, 0px));
  box-shadow:
    inset 0 1px 0 rgba(200,170,120,0.18),
    0 16px 40px rgba(0,0,0,0.65);
  animation: ctkGuanxingIn 180ms var(--ctk-spring);
}
@keyframes ctkGuanxingIn { from { transform: translateY(8px) scale(0.96); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { :where(html:not(.ctk-force-fx)) .guanxing-card { animation: none; } }
/* The Stargaze scrim (#pendingActions.guanxing-modal, z-index:1000 above)
 * covers the whole screen and BURIES the deadline countdown, which lives in
 * the #phaseStatus (turn-idle) / #pendingWatchdog (response) chips. While the
 * modal is open, lift those chips above the scrim so the player can still see
 * the auto-skip timer ticking down as they reorder. position:relative just
 * lets z-index take effect — the chip stays in its normal spot. */
/* In-modal deadline countdown. The turn-idle deadline normally lives in the
 * #phaseStatus chip, but that chip is trapped in a stacking context the
 * Stargaze scrim (z-index:1000) paints over regardless of z-index — so during
 * the modal it's invisible. Mirror the countdown into the modal itself, where
 * it's painted on top of the scrim naturally. renderPhaseStatusTimer keeps
 * #gxDeadline in sync each second. */
.guanxing-card .gx-deadline {
  display: none; align-items: center; justify-content: center; gap: 6px;
  margin: 2px auto 8px; font-family: "VT323", monospace; font-size: 16px;
  color: #ffcb6b; letter-spacing: 0.5px;
}
.guanxing-card .gx-deadline.gx-deadline-on { display: inline-flex; }
.guanxing-card .gx-deadline.tb-urgent { color: #ff5544; }

/* Title: gold VT323 with the guanxing target glyph + ❖ scroll flourishes
 * and a thin gold rule, so it reads as a dedicated dynasty overlay. */
.guanxing-card-title {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  font-family: 'VT323', monospace;
  color: #f0d890; font-size: 24px; line-height: 1;
  letter-spacing: 0.5px;
  padding-bottom: 8px; margin-bottom: 8px;
  border-bottom: 1px solid rgba(107,85,48,0.5);
  text-shadow: 0 1px 0 #0e0804, 0 0 10px rgba(240,216,144,0.25);
}
.guanxing-card-title .gx-title-icon { display: inline-flex; width: 18px; height: 18px; image-rendering: pixelated; opacity: 0.9; }
.guanxing-card-title .gx-flourish { color: #b7791f; font-size: 18px; line-height: 1; }
.guanxing-card .picker-hint { text-align: center; margin-bottom: 10px; }

/* List = vertical stack of elevated M3 slats. */
.guanxing-card .guanxing-list {
  display: flex; flex-direction: column; align-items: stretch;
  gap: 8px; overflow-y: auto; min-height: 0;
  padding: 2px;                 /* room for the focus ring on edge rows */
  scrollbar-width: thin;
  scrollbar-color: #6b5530 transparent;
}
.guanxing-card .guanxing-list::-webkit-scrollbar { width: 6px; }
.guanxing-card .guanxing-list::-webkit-scrollbar-thumb { background: #5a4530; border-radius: 3px; }

/* "Next draw" rail — an inert HUD cue pinned above row #1. */
.guanxing-card .gx-rail {
  display: flex; align-items: center; gap: 8px;
  margin: 0 2px 2px;
  font-family: 'VT323', monospace;
  font-size: 15px; line-height: 1;
  color: #f0d890; letter-spacing: 0.5px;
  pointer-events: none; user-select: none;
}
.guanxing-card .gx-rail::before,
.guanxing-card .gx-rail::after { content: ""; flex: 1 1 auto; height: 1px; }
.guanxing-card .gx-rail::before { background: linear-gradient(90deg, transparent, rgba(240,216,144,0.5)); }
.guanxing-card .gx-rail::after  { background: linear-gradient(90deg, rgba(240,216,144,0.5), transparent); }
.guanxing-card .gx-rail .gx-rail-icon { display: inline-flex; width: 14px; height: 14px; }

/* Row = M3 elevated surface slat. The class owns all layout (the template
 * carries no inline styles). */
.guanxing-card .guanxing-row {
  position: relative;
  display: flex; align-items: center; gap: 8px;
  margin: 0 !important;
  padding: 6px 8px 6px 12px;
  min-height: 52px;
  background: linear-gradient(180deg, rgba(58,48,37,0.85) 0%, rgba(40,32,24,0.85) 100%);
  border: 1px solid rgba(107,85,48,0.55);
  border-radius: 10px;
  box-shadow:
    inset 1px 1px 0 rgba(200,170,120,0.12),
    0 2px 6px rgba(0,0,0,0.4);
  overflow: hidden;             /* clip the tonal rail to the radius */
  transition: box-shadow 0.18s ease-out, transform 0.12s ease-out, border-color 0.18s ease-out;
}
/* Tonal left accent rail — gold by default, recolored per card type below. */
.guanxing-card .guanxing-row::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0;
  width: 4px; background: #b7791f; box-shadow: 0 0 8px rgba(183,121,31,0.6);
}
/* Row #1: the next card to be drawn — brighter surface, gold edge, glow +
 * a soft pulse (AoV "active slot" / Balatro scored-card pop). */
.guanxing-card .guanxing-list .guanxing-row[data-disp-idx="0"] {
  border-color: #d6a44a;
  background: linear-gradient(180deg, rgba(74,60,44,0.92) 0%, rgba(48,38,28,0.92) 100%);
  animation: ctkGuanxingNext 2.2s ease-in-out infinite;
}
@keyframes ctkGuanxingNext {
  0%, 100% { box-shadow: inset 1px 1px 0 rgba(200,170,120,0.2), 0 0 0 1px rgba(214,164,74,0.35), 0 4px 14px rgba(0,0,0,0.5), 0 0 14px rgba(214,164,74,0.16); }
  50%      { box-shadow: inset 1px 1px 0 rgba(200,170,120,0.25), 0 0 0 1px rgba(214,164,74,0.55), 0 4px 14px rgba(0,0,0,0.5), 0 0 26px rgba(214,164,74,0.34); }
}
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .guanxing-card .guanxing-list .guanxing-row[data-disp-idx="0"] {
    animation: none;
    box-shadow: inset 1px 1px 0 rgba(200,170,120,0.2), 0 0 0 1px rgba(214,164,74,0.4), 0 4px 14px rgba(0,0,0,0.5), 0 0 18px rgba(214,164,74,0.2);
  }
}
.guanxing-card .guanxing-row:hover { transform: translateY(-1px); border-color: rgba(214,164,74,0.7); }

/* Ordinal: a gilded coin medallion (radial gold seal w/ inset highlight). */
.guanxing-card .gx-ordinal {
  flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px;
  font-family: 'VT323', monospace; font-size: 18px; line-height: 1;
  color: #1a130a;
  background: radial-gradient(circle at 50% 35%, #f7e7a8 0%, #e0b657 45%, #a9802f 100%);
  border: 1px solid #f0d890; border-radius: 50%;
  box-shadow: inset 0 1px 1px rgba(255,255,255,0.5), inset 0 -2px 3px rgba(120,80,20,0.5), 0 1px 2px rgba(0,0,0,0.5);
  text-shadow: 0 1px 0 rgba(255,250,220,0.4);
}
/* Non-next ordinals read as quieter bronze seals so row #1's gold pops. */
.guanxing-card .guanxing-row:not([data-disp-idx="0"]) .gx-ordinal {
  color: #e8dcc8;
  background: radial-gradient(circle at 50% 35%, #5a4836 0%, #3a2e22 70%, #2a2018 100%);
  border-color: rgba(107,85,48,0.7);
  box-shadow: inset 0 1px 1px rgba(200,170,120,0.18), 0 1px 2px rgba(0,0,0,0.5);
  text-shadow: 0 1px 0 #0e0804;
}

/* Card chip fills the middle; the tamed .picker-compact carries the pip. */
.guanxing-card .guanxing-row .pixel-card { flex: 1 1 auto; min-width: 0; cursor: default; }
.guanxing-card .guanxing-row .pixel-card .card-title { text-align: left; }

/* Up/Down controls: 44px M3 touch targets reusing .px-btn (gold gradient +
 * ::after state-layer ripple + focus ring). Pixelarticons arrow SVG (the
 * down glyph is rotated 180deg for "up"). */
.guanxing-card .gx-moves { flex: 0 0 auto; display: flex; flex-direction: column; gap: 4px; }
.guanxing-card .gx-moves .px-btn {
  min-width: 44px !important; min-height: 44px;
  width: 44px; height: 44px; padding: 0; border-radius: 8px; font-size: 18px;
}
.guanxing-card .gx-moves .px-btn svg { width: 20px; height: 20px; image-rendering: pixelated; }
.guanxing-card .gx-moves .gx-move-up svg { transform: rotate(180deg); }
/* On short / landscape phones, lay the arrows side-by-side to save height
 * (still 44px each). */
@media (max-height: 640px) {
  .guanxing-card .guanxing-row { min-height: 48px; }
  .guanxing-card .gx-moves { flex-direction: row; }
}

/* Footer actions: Done (success) + Skip, both 44px, divided from the list. */
.guanxing-card .guanxing-actions {
  display: flex; justify-content: center; gap: 12px;
  margin-top: 14px; padding-top: 12px;
  border-top: 1px solid rgba(107,85,48,0.5);
}
.guanxing-card .guanxing-actions .px-btn {
  min-height: 44px; padding: 8px 20px; font-size: 18px;
  flex: 1 1 0; max-width: 160px;
}

/* Tonal accent rail per card type — reuse the exact .pixel-card palette so
 * the modal matches the hand / inspector color language. */
.guanxing-card .guanxing-row.gx-attack::before  { background: #e53e3e; box-shadow: 0 0 8px rgba(229,62,62,0.6); }
.guanxing-card .guanxing-row.gx-defense::before { background: #3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.6); }
.guanxing-card .guanxing-row.gx-peach::before   { background: #ec4899; box-shadow: 0 0 8px rgba(236,72,153,0.6); }
.guanxing-card .guanxing-row.gx-tactic::before  { background: #8b5cf6; box-shadow: 0 0 8px rgba(139,92,246,0.6); }
.guanxing-card .guanxing-row.gx-equip::before   { background: #f59e0b; box-shadow: 0 0 8px rgba(245,158,11,0.6); }
.guanxing-card .guanxing-row.gx-delay::before   { background: #6b7280; box-shadow: 0 0 8px rgba(107,114,128,0.6); }
.guanxing-card .guanxing-row.gx-negate::before  { background: #a0aec0; box-shadow: 0 0 8px rgba(160,174,192,0.5); }

/* ============================================================
 * Bamboo-Scroll Decree — End-Play-Phase confirm modal.
 * Mirrors the Stargaze/guanxing skin (scrim, #221a12->#15110d card, #6b5530
 * border, radius 14, --ctk-spring entrance) so it reads as the same dynasty
 * overlay. All classes are .ctk-endplay-* / .ep-* — nothing collides with
 * #pendingActions/.guanxing-*. Display-only; net-new + additive. */

.ctk-endplay-scrim {
  position: fixed; inset: 0; z-index: 1000;            /* ties Stargaze; they never co-open */
  display: flex; align-items: center; justify-content: center;
  padding: 16px; margin: 0;
  background: radial-gradient(120% 90% at 50% 0%, rgba(40,28,14,0.72) 0%, rgba(0,0,0,0.84) 70%);
  overflow-y: auto;
}
.ctk-endplay-scrim[hidden] { display: none !important; }   /* [hidden] beats display:flex */

.ctk-endplay-card {
  position: relative;
  width: 100%; max-width: 360px;
  display: flex; flex-direction: column;
  /* Parchment-warm tint over the guanxing base so it reads as aged scroll. */
  background:
    radial-gradient(140% 80% at 50% -10%, rgba(120,92,48,0.20) 0%, rgba(120,92,48,0) 60%),
    linear-gradient(180deg, #2a2014 0%, #15110d 100%);
  border: 2px solid #6b5530; border-radius: 14px;
  padding: 22px 16px calc(16px + env(safe-area-inset-bottom, 0px));
  box-shadow:
    inset 0 1px 0 rgba(200,170,120,0.18),
    0 16px 40px rgba(0,0,0,0.65),
    0 0 26px rgba(214,164,74,0.12);                    /* warm Balatro glow */
  animation: ctkGuanxingIn 180ms var(--ctk-spring);    /* REUSE existing keyframe + spring */
  outline: none;
}
@media (prefers-reduced-motion: reduce) { :where(html:not(.ctk-force-fx)) .ctk-endplay-card { animation: none; } }

/* Corner dismiss — echoes .ctk-act2-close (L2346/2354). */
.ctk-endplay-close {
  position: absolute; top: 2px; right: 2px;
  width: 44px; height: 44px;
  background: transparent; color: #c8b890;
  border: 1px solid transparent; cursor: pointer; border-radius: 6px;
  display: inline-flex; align-items: center; justify-content: center;
}
.ctk-endplay-close svg { width: 16px; height: 16px; image-rendering: pixelated; }
.ctk-endplay-close:hover { color: #fde68a; border-color: #6b5530; }
.ctk-endplay-close:focus-visible { outline: 2px solid #fbbf24; outline-offset: 2px; }

/* Wax-seal scroll medallion. Reuses @keyframes ctk-act1-seal-stamp (L2202): its
 * 0% is rotate(-12deg) scale(1.8) -> rests at rotate(-12deg) scale(1), runs
 * forwards. So the RESTING transform must be rotate(-12deg) (NOT scale(0)). */
.ctk-endplay-seal {
  align-self: center;
  width: 52px; height: 52px; margin-bottom: 8px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 50%;
  color: #2a1206;
  background: radial-gradient(circle at 50% 35%, #f7e7a8 0%, #e0b657 45%, #a9802f 100%);
  border: 2px solid #f0d890;
  box-shadow:
    inset 0 1px 1px rgba(255,255,255,0.5),
    inset 0 -2px 3px rgba(120,80,20,0.5),
    0 2px 6px rgba(0,0,0,0.55);
  transform: rotate(-12deg);                           /* resting state = keyframe end */
}
.ctk-endplay-seal-glyph svg { width: 26px; height: 26px; image-rendering: pixelated; }
/* Stamp on open (class added after unhide). */
.ctk-endplay-open .ctk-endplay-seal {
  animation: ctk-act1-seal-stamp 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.1s forwards;
}
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-endplay-open .ctk-endplay-seal { animation: none; }
}

/* Title — copied from .guanxing-card-title (L744-754): gold VT323 24px + ❖ + rule. */
.ctk-endplay-title {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  font-family: 'VT323', monospace;
  color: #f0d890; font-size: 24px; line-height: 1; letter-spacing: 0.5px;
  padding-bottom: 8px; margin-bottom: 10px;
  border-bottom: 1px solid rgba(107,85,48,0.5);
  text-shadow: 0 1px 0 #0e0804, 0 0 10px rgba(240,216,144,0.25);
}
.ctk-endplay-title .ep-title-icon { display: inline-flex; width: 18px; height: 18px; image-rendering: pixelated; opacity: 0.9; }
.ctk-endplay-title .ep-title-icon svg { width: 18px; height: 18px; }
.ctk-endplay-title .gx-flourish { color: #b7791f; font-size: 18px; line-height: 1; }
html[lang="th"] .ctk-endplay-title {
  font-family: 'Bai Jamjuree', 'Kanit', sans-serif; font-weight: 600; font-size: 19px; letter-spacing: 0;
}

/* Lead copy. */
.ctk-endplay-lead {
  margin: 0 0 10px; text-align: center; color: #f0d890;
  font-family: 'VT323', monospace; font-size: 19px; line-height: 1.2; letter-spacing: 0.3px;
}
html[lang="th"] .ctk-endplay-lead {
  font-family: 'Bai Jamjuree', 'Kanit', sans-serif; font-weight: 600; font-size: 16px; letter-spacing: 0;
}

/* Live CONTEXT row — grafted from aov-hud. Two pill chips. */
.ctk-endplay-ctx {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
  gap: 8px; margin-bottom: 10px;
}
.ctk-endplay-ctx .ep-ctx-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 10px; min-height: 30px;
  background: linear-gradient(180deg, rgba(58,48,37,0.85) 0%, rgba(40,32,24,0.85) 100%);
  border: 1px solid rgba(107,85,48,0.55); border-radius: 999px;
  box-shadow: inset 1px 1px 0 rgba(200,170,120,0.12), 0 2px 6px rgba(0,0,0,0.4);
  font-family: 'VT323', monospace; font-size: 16px; line-height: 1; color: #f0d890;
}
html[lang="th"] .ctk-endplay-ctx .ep-ctx-chip {
  font-family: 'Bai Jamjuree', 'Kanit', sans-serif; font-weight: 600; font-size: 12px;
}
.ctk-endplay-ctx .ep-ctx-icon svg,
.ctk-endplay-ctx .ep-ctx-cue-icon svg { width: 14px; height: 14px; image-rendering: pixelated; vertical-align: -2px; }
.ctk-endplay-ctx .ep-ctx-cue.ep-cue-has  { color: #ffcb6b; border-color: rgba(245,158,11,0.6); }
.ctk-endplay-ctx .ep-ctx-cue.ep-cue-none { color: #b8c2b0; }

/* Plain irreversible-consequence rail — alert glyph + warn-orange left border. */
.ctk-endplay-warn {
  display: flex; align-items: center; gap: 8px; justify-content: flex-start;
  margin: 0 auto; max-width: 300px;
  padding: 8px 10px; border-radius: 10px;
  background: linear-gradient(180deg, rgba(58,48,37,0.85) 0%, rgba(40,32,24,0.85) 100%);
  border: 1px solid rgba(107,85,48,0.55);
  border-left: 4px solid #f59e0b;
  color: #e8dcc8; font-size: 14px; line-height: 1.35; text-align: left;
}
.ctk-endplay-warn .ep-warn-icon { flex: 0 0 auto; display: inline-flex; color: #fbbf24; }
.ctk-endplay-warn .ep-warn-icon svg { width: 18px; height: 18px; image-rendering: pixelated; }
html[lang="th"] .ctk-endplay-warn { font-family: 'Bai Jamjuree', 'Kanit', sans-serif; font-size: 13px; }

/* Footer actions — divider rule + two 44px .px-btn (ghost Cancel + warn CTA). */
.ctk-endplay-actions {
  display: flex; justify-content: center; gap: 12px;
  margin-top: 16px; padding-top: 12px;
  border-top: 1px solid rgba(107,85,48,0.5);
}
.ctk-endplay-actions .px-btn {
  min-height: 44px; padding: 8px 16px; font-size: 18px;
  flex: 1 1 0; max-width: 170px; gap: 6px;
}
.ctk-endplay-actions .px-btn svg { width: 18px; height: 18px; image-rendering: pixelated; }
html[lang="th"] .ctk-endplay-actions .px-btn { font-size: 15px; }

/* Cancel = low-emphasis M3 "text/ghost" action so the warn-orange CTA dominates
 * (grafted from m3-alertdialog). Keeps .px-btn's 44px target, ::after state
 * layer, and #fbbf24 :focus-visible ring. */
.ctk-endplay-cancel {
  background: transparent; border-color: rgba(107,85,48,0.55);
  box-shadow: none; color: #e8dcc8;
}
.ctk-endplay-cancel:hover { background: rgba(107,85,48,0.18); border-color: rgba(107,85,48,0.8); }
.ctk-endplay-cancel:active { background: rgba(107,85,48,0.28); box-shadow: inset 1px 1px 0 rgba(0,0,0,0.3); }

/* Narrow / short phones: stack actions full-width, CTA on top (grafted from aov-hud). */
@media (max-width: 380px) {
  .ctk-endplay-actions { flex-direction: column-reverse; }
  .ctk-endplay-actions .px-btn { max-width: none; }
}
.card-suit-rank {
  display: inline-flex;
  align-items: center;
  align-self: flex-start;    /* anchor to top-left so it reads like a corner pip */
  gap: 2px;
  font-family: 'VT323', monospace;
  font-size: 16px;
  line-height: 1;
  padding: 1px 4px;
  background: rgba(20,14,8,0.85);
  border: 1px solid rgba(107,85,48,0.6);
  border-radius: 2px;
  color: #e8dcc8;            /* default — overridden by suit-red / suit-black below */
}
.card-suit-rank.suit-red   { color: #ff5050; }
.card-suit-rank.suit-black { color: #e8dcc8; }
.card-suit-rank .suit-glyph { font-size: 14px; line-height: 1; }
/* On the light card face (hand + pickers) the pip chip + suits go ink/red
 * so they read; scoped to .pixel-card so dark-context suit chips are untouched. */
.pixel-card .card-suit-rank { background: rgba(43,32,24,0.10); border-color: rgba(43,32,24,0.35); }
.pixel-card .card-suit-rank.suit-red   { color: #c0392b; }
.pixel-card .card-suit-rank.suit-black { color: #2a2018; }
.card-title {
  display: block;
  width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-family: 'VT323', monospace;
  font-size: 14px;
  line-height: 1.1;
  text-align: center;
}
.card-img {
  width: 64px;
  height: 88px;
  object-fit: cover;
  image-rendering: auto;     /* drop pixelated rendering for richer art */
  border: 2px solid rgba(0,0,0,0.55);
  border-radius: 4px;
  box-shadow:
    0 1px 2px rgba(0,0,0,0.4),
    inset 0 0 0 1px rgba(255,255,255,0.08);
  transition: filter 0.18s ease-out;
}
.pixel-card:hover:not(:disabled) .card-img {
  filter: brightness(1.12) saturate(1.15);
}
.hero-portrait {
  width: 80px;
  height: 80px;
  object-fit: cover;
  image-rendering: auto;
  border-radius: 6px;
  border: 2px solid #5a4530;
  box-shadow:
    0 1px 2px rgba(0,0,0,0.4),
    inset 0 0 0 1px rgba(255,255,255,0.1);
  display: block;
  margin: 2px auto;
  transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
}
.hero-portrait:hover {
  transform: scale(1.05);
  box-shadow:
    0 2px 6px rgba(0,0,0,0.5),
    0 0 12px rgba(245,158,11,0.4),
    inset 0 0 0 1px rgba(255,255,255,0.15);
}

/* Hero stage — the hero portrait fills the player panel and acts as the
 * primary canvas. Tags, name, HP, badges, chips all FLOAT over it via
 * absolutely-positioned overlays. Top/bottom dark gradients keep text
 * readable; the middle is left clear so the hero's face stays visible. */
.hero-stage {
  position: relative;
  width: 100%;
  aspect-ratio: 1 / 1;        /* reserves square space even if the img 404s */
  min-height: 120px;          /* fallback for ancient browsers without aspect-ratio */
  margin: 2px 0 0 0;
  overflow: hidden;
  border-radius: 0;      /* square, matching the panel — see the note there */
  /* The border + box-shadow that used to live here were a SECOND frame in
   * the same #5a4530 as .player-panel's — a brown box inside a brown box,
   * separating nothing and eating ~8px of the smallest dimension on the
   * board (art audit 2026-08, problem #3). One frame now: the panel's. */
  background:
    radial-gradient(circle at center 35%, rgba(90,60,30,0.6) 0%, rgba(28,20,16,0.95) 70%),
    #28201a;
}
/* PUNCH-IN. The source portraits are square full-scene compositions and
 * .hero-stage is forced square on the ring, so `object-fit: cover` crops
 * NOTHING and the hero landed at ~12% of the seat card. A 1.3x zoom about
 * the band where these paintings put their heads magnifies the subject
 * without displacing it — per-hero framing is a Stage 3 asset job, this is
 * the generic improvement that is safe for all 39. Every rule that also
 * writes `transform` on this element has to carry the scale or the crop
 * snaps out — see the :hover and .hero-flipped variants below. */
.hero-stage .hero-portrait {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover; object-position: center top;
  border: none; border-radius: 0; box-shadow: none;
  margin: 0; display: block;
  transition: transform 0.5s cubic-bezier(.2,.7,.2,1), filter 0.3s ease-out;
}
/* Scoped to #players ON PURPOSE. #playerDetailModal sets
 * `.hero-stage { overflow: visible }` (see the detail-modal block), so a
 * scaled portrait there spills out of its stage and swallows the clicks on
 * the role / skill chips underneath it. The modal also WANTS the whole
 * painting — it serves the 500px tier at full size, which is the one place
 * the original composition should be seen intact. The crop is a
 * seat-token treatment only. */
#players .hero-stage .hero-portrait {
  /* Per-hero framing. client/src/hero-art.ts emits --hero-zoom /
   * --hero-focus-x / --hero-focus-y on each portrait from measurements of
   * where that painting actually puts its subject; the fallbacks here are
   * the generic crop for any hero without an entry. */
  transform: scale(var(--hero-zoom, 1.3));
  transform-origin: var(--hero-focus-x, 50%) var(--hero-focus-y, 34%);
}
/* The lift is killed in the stage (transform fights overflow:hidden), so
 * signal tap-to-inspect with a clip-safe brightness/saturate cue instead.
 * Keeps the punch-in: a bare `transform: none` here would un-crop on hover. */
.hero-stage .hero-portrait:hover { box-shadow: none; filter: brightness(1.12) saturate(1.16); }
#players .hero-stage .hero-portrait:hover { transform: scale(var(--hero-zoom, 1.3)); }
/* Face-down (翻面 — Cao Pi's Fangzhu / Cao Ren's Jushou turn a general over):
   mirror the portrait with a juicy card-flip and desaturate it so the seat
   reads as "turned over / out this turn". scaleX has no translation, so it
   stays inside the stage's overflow:hidden. The :hover variant (higher
   specificity) keeps it flipped while hovering, beating the transform:none
   lift-reset above. Animation rides the transform transition on .hero-portrait. */
.hero-stage.hero-flipped .hero-portrait,
.hero-stage.hero-flipped .hero-portrait:hover {
  transform: scaleX(-1);
  filter: grayscale(0.55) brightness(0.82);
}
/* Board seats carry the punch-in through the flip. Needs the #players scope
 * to out-specify the seat-token crop above, which is ID-weighted. */
#players .hero-stage.hero-flipped .hero-portrait,
#players .hero-stage.hero-flipped .hero-portrait:hover {
  transform: scale(var(--hero-zoom, 1.3)) scaleX(-1);
}
/* STAGE LIGHT. Every seat used to render at identical brightness, so eight
 * portraits read as a contact sheet and the acting seat was lit exactly
 * like the five idle bots (art audit 2026-08, problem #5). One lamp now:
 * idle seats fall back, the acting seat steps forward. Both selectors are
 * (1,4,0) and would silently out-specify the :hover cue above, so the hover
 * is restored explicitly at matching weight for each state. */
#players > .player-panel:not([aria-current="true"]):not(.is-viewer) .hero-stage .hero-portrait {
  filter: saturate(0.8) brightness(0.84);
}
#players > .player-panel:not([aria-current="true"]):not(.is-viewer) .hero-stage .hero-portrait:hover {
  filter: saturate(1.0) brightness(1.02);
}
#players > .player-panel[aria-current="true"] .hero-stage .hero-portrait {
  filter: saturate(1.06) brightness(1.06);
}
#players > .player-panel[aria-current="true"] .hero-stage .hero-portrait:hover {
  filter: saturate(1.16) brightness(1.16);
}
/* Face-down keeps its own grade at every light level — a flipped general is
 * out of the scene, so it must not brighten when the turn reaches it. */
#players > .player-panel .hero-stage.hero-flipped .hero-portrait,
#players > .player-panel .hero-stage.hero-flipped .hero-portrait:hover {
  filter: grayscale(0.55) brightness(0.82);
}
/* Legibility scrim. Was 58% tall at 0.92 alpha — it buried the lower half of
 * every painting to make 12px text readable. The plate on .hero-name does
 * that job now, so this is back to a short, soft grounding wash. */
.hero-stage-fade-bottom {
  position: absolute; left: 0; right: 0; bottom: 0; height: 40%;
  background: linear-gradient(to top, rgba(6,3,1,0.90) 0%, rgba(6,3,1,0.55) 42%, rgba(6,3,1,0.14) 78%, rgba(6,3,1,0) 100%);
  pointer-events: none;
}

/* Bottom overlay block — hero name, hand+range, meta + equip/buff/debuff
 * chip rows. Sits on the bottom dark gradient. The chips have their own
 * solid backgrounds so they're individually legible.
 * Intentionally no z-index: a stacking context here would trap the chip
 * tooltip bubble (position:fixed z-9999) inside, letting the *next*
 * player's overlay paint over it via document order. Tree order
 * (portrait → fade → overlay) already gives the right paint layering. */
.player-bottom-overlay {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 4px 5px 5px 5px;
  display: flex; flex-direction: column; gap: 2px;
  text-shadow: 0 1px 2px rgba(0,0,0,0.95);
}
.player-bottom-overlay > div { font-size: 12px; line-height: 1.25; color: #f0e0c8; }
/* Hero name + kingdom sit DIRECTLY on the painting — no plate, no dimming
 * box behind them. The audit measured this text at 2.25:1 over a pale sky
 * and proposed a cartouche; that was rejected in review, so legibility is
 * carried entirely by INK: a brighter cream than the old #e8d4a8, a size
 * bump, and a tight dark halo (four 1px offsets plus a soft drop) that
 * traces the glyphs instead of boxing them. The .hero-stage-fade-bottom
 * wash still grounds this corner of the art. If a hero ever proves
 * unreadable here, deepen the halo or the wash — do NOT reintroduce a
 * background behind the text. */
.player-bottom-overlay .hero-name {
  color: #f7e8c4; font-size: 13px; font-weight: 700; letter-spacing: 0.3px;
  text-shadow:
    1px 1px 0 rgba(8,4,2,0.95), -1px 1px 0 rgba(8,4,2,0.95),
    1px -1px 0 rgba(8,4,2,0.95), -1px -1px 0 rgba(8,4,2,0.95),
    0 2px 4px rgba(0,0,0,0.95);
}
/* Thai stacks vowel and tone marks above AND below the base glyph, so the
 * 1.1 line-height the crowded-panel @container tier uses is Latin-only
 * spacing: measured on the live board, every Thai hero name overflows its
 * line box (scrollHeight 14 vs clientHeight 13) where English sits at
 * 13/13. Nothing visually clips — no ancestor boundary is crossed — but the
 * marks crowd the hand/range row underneath. The name is the only Thai
 * string on the seat card set this tight; the caption row already computes
 * 16.5px and the TURN/YOU pills 15.6px, both of which measure clean. */
html[lang="th"] .player-bottom-overlay .hero-name { line-height: 1.4; }
/* The kingdom qualifies the name; it should not be set at the same weight
 * as the name. Its own span (both renderers) so it can shrink — this is
 * what stops "Yan Liang Wen Chou · QUN" and the wider Thai names from
 * taking a second line. The banner across the panel's top edge carries the
 * same information in colour, so the text can afford to recede. */
.player-bottom-overlay .hero-kingdom {
  font-size: 0.8em; font-weight: 400; opacity: 0.78; letter-spacing: 0;
}
/* Hero name is tap-to-inspect. Subtle hover/focus glow without
 * adding visible chrome that crowds the 10-seat phone layout. */
.player-bottom-overlay .hero-name[data-hero] {
  cursor: pointer;
  transition: filter 0.12s ease-out;
}
.player-bottom-overlay .hero-name[data-hero]:hover,
.player-bottom-overlay .hero-name[data-hero]:focus-visible {
  filter: brightness(1.2) drop-shadow(0 0 4px rgba(232, 212, 168, 0.6));
  outline: none;
}
.player-bottom-overlay .hand-stack { color: #f0e0c8; }
.player-bottom-overlay .status-row { margin-top: 1px; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px; }
.player-bottom-overlay .px-badge { font-size: 12px; padding: 0px 3px; }
/* The hero-SKILL meta row is the only LABELLED chip row left on the board
   (equip/buff/debuff/judgement are icon-only). Let it WRAP rather than clip
   when a seat carries two skills (a Lord's base skill + 主公技) in a narrow
   panel — the row stays centred and the labels stay readable instead of
   being cut off by the hero-stage overflow:hidden. */
#players .player-bottom-overlay > .flex.gap-1.justify-center { flex-wrap: wrap; }

/* Delayed-trick judgement chips (lightning / famine / drought) now share the
 * single .status-row with every other status chip — no separate framed
 * JUDGEMENT zone. See the status-row assembly in the seat renderer +
 * PlayerPanel.tsx; the chips reuse the debuff tint. */

/* Human display handle on the seat-row caption — dim vs "Seat N", ellipsizes
 * when long so it can't break the single centered line. */
.seat-row .seat-row-name {
  flex: 0 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: #b8a888;
}
.seat-row.seat-active .seat-row-name { color: inherit; }
/* Table-level unrated state for the ranked HUD chip (#matchModeTier): the match
 * is ranked but will not be credited, because the table is not majority-human.
 * Greyed rather than gold — this is a heads-up, not an accolade. The element
 * carries inline styling from the markup, so these have to be !important to win
 * the cascade. Set by updateMatchModeTier(); see docs/ranked-8seat-audit-2026-08.md §3.3c. */
#matchModeTier.match-unrated {
  color: #b9b2a4 !important;
  background: linear-gradient(180deg,#2c2924,#1f1d1a) !important;
  border-color: #5a544a !important;
  font-weight: normal !important;
}
/* Role chip is board-hidden — revealed only in the tap-to-open detail modal
 * (the modal clones the panel OUTSIDE #players, so the clone still shows it). */
#players .seat-row .role-chip { display: none; }
/* EXCEPTION — the EMPEROR crown. The Lord (主公) is public in SanGuoSha, so
 * their golden crown rides the board on the Lord's seat (leaks nothing — every
 * viewer's VM already carries it; the same marker the imperial gold frame keys
 * off). Icon-only (font-size:0, no text label) so it reads as an imperial
 * seal, not a win-condition HUD; all other roles stay board-hidden. Pure CSS
 * → works in BOTH renderers (legacy string + PlayerPanel.tsx emit the same
 * .role-chip[data-role]). */
#players .seat-row .role-chip[data-role="Lord"] {
  display: inline-flex; font-size: 0; padding: 1px 3px; gap: 0;
  /* Lead the caption: sit at the left, right before "Seat N" (just after the
     identity badge, which is also pulled to the front) — the crown reads as an
     imperial marker rather than a trailing after-thought. Flex `order` reorders
     with no markup change, so it works in both renderers. */
  order: -1; margin-left: 0;
}
#players .seat-row .player-corner-badge { order: -1; }

/* Seat row sits below the hero stage, INSIDE the panel (the only thing
 * under the image, as requested). It used to be 14px #d4c4a0 — LARGER and
 * brighter than the hero name above it, inverting the card's type hierarchy
 * in favour of a bot glyph and a seat number (art audit 2026-08). Caption
 * ink now, and tight enough to stop eating ~26px of a ~148px token. */
.seat-row {
  margin-top: 2px;
  font-size: 11px; color: #9a8a70; letter-spacing: 0.5px;
  display: flex; align-items: center; justify-content: center;
  gap: 3px; flex-wrap: nowrap; min-width: 0;
}
.seat-row .player-corner-badge {
  width: 13px; height: 13px; border: 0; background: none; opacity: 0.6;
}
.seat-row .player-corner-badge svg { width: 11px; height: 11px; }
/* Identity slot showing the seat's chosen avatar (a DiceBear Pixel Art
 * face, inline SVG): a round, clipped box the same footprint as the glyph
 * it replaces, so the compact / micro container tiers need no new rule;
 * the kingdom banner tint frames it like the rest of the panel. */
.seat-row .player-corner-badge.seat-av {
  position: relative; width: 15px; height: 15px; opacity: 1;
  border-radius: 50%; overflow: hidden;
  border: 1px solid var(--k-lit, #6b5530); background: #2a2218;
  box-shadow: 0 0 0 1px #0e0804;
}
.seat-row .player-corner-badge.seat-av svg { width: 100%; height: 100%; display: block; }
.seat-row .player-corner-badge.seat-av img { width: 100%; height: 100%; object-fit: cover; display: block; }
.seat-row .seat-row-label { flex: 0 0 auto; }

/* Pixel buttons — M3 touch-target floor (44x44px) + Balatro state-layer juice. */
.px-btn {
  background: linear-gradient(180deg, #4a3828 0%, #3a2c1e 100%);
  border: 2px solid #6b5530;
  box-shadow:
    inset 1px 1px 0 rgba(200,170,120,0.15),
    0 2px 0 #0e0804;
  color: #e8dcc8;
  cursor: pointer;
  padding: 10px 16px;
  min-height: 44px;
  min-width: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  font-family: "VT323", monospace;
  font-size: 16px;
  line-height: 1;
  position: relative;
  overflow: hidden;
  transition: transform 0.08s, box-shadow 0.18s ease-out, background 0.18s ease-out;
}
/* M3 state layer: hover/press overlay that adds visible feedback without breaking the gradient. */
.px-btn::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at 50% 40%, rgba(255,240,200,0.18), rgba(255,240,200,0) 65%);
  opacity: 0;
  transition: opacity 0.18s ease-out;
  pointer-events: none;
}
html[lang="th"] .px-btn {
  font-family: "Bai Jamjuree", "Kanit", sans-serif;
  font-weight: 600;
  font-size: 14px;
  letter-spacing: 0.03em;
}
.px-btn:hover { background: linear-gradient(180deg, #5a4838 0%, #4a3c2e 100%); transform: translateY(-1px); }
.px-btn:hover::after { opacity: 1; }
.px-btn:active { transform: translateY(2px) scale(0.97); box-shadow: inset 1px 1px 0 rgba(0,0,0,0.35); }
.px-btn:active::after { opacity: 1; background: radial-gradient(circle at 50% 40%, rgba(255,240,200,0.45), rgba(255,240,200,0) 72%); }
.px-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.px-btn:disabled::after { opacity: 0; }
/* Chain target buttons: self-describing (seat + who + Chain/Untie verb),
 * stacked in a column. The inline seat-palette style on each button paints
 * the background/text, so these rules only handle layout + the chain glyph
 * size (an inline SVG with no box falls back to the 300x150 default). */
.ctk-chain-btn { flex-direction: column; gap: 1px; padding-top: 6px; padding-bottom: 6px; line-height: 1.15; }
.ctk-chain-btn .ctk-chain-seatline { font-size: 0.72em; opacity: 0.85; letter-spacing: 0.02em; }
.ctk-chain-btn .ctk-chain-action { display: inline-flex; align-items: center; gap: 3px; font-weight: 700; }
.ctk-chain-btn .ctk-chain-ico { display: inline-flex; }
.ctk-chain-btn .ctk-chain-ico svg { width: 0.95em; height: 0.95em; display: block; }
.ctk-chain-btn .ctk-chain-self { opacity: 0.75; font-weight: 400; }
/* "just chained / just untied" marker on the locked step-1 target. */
.ctk-chain-btn .ctk-chain-marker { font-size: 0.66em; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; opacity: 0.9; }
/* The step-1 pick, shown on step 2: readable-but-inert. Override the heavy
 * .px-btn:disabled dim (0.4 hides the marker) and suppress the hover lift so
 * it reads as "already done" rather than tappable. Palette bg stays (inline). */
.ctk-chain-locked { opacity: 0.7 !important; cursor: default; box-shadow: inset 2px 2px 0 rgba(0,0,0,0.4) !important; transform: none !important; }
/* Weimu-blocked seat: locked, but "never allowed" rather than "already done",
 * so it sits dimmer than .ctk-chain-locked and takes the not-allowed cursor.
 * The marker is exempted back to full alpha: this branch drops the verb and
 * the chain glyph, so that string is the button's only statement of WHY the
 * seat is unavailable, and 0.45 * the marker's own 0.9 would land it at 0.4 —
 * the exact value the .ctk-chain-locked comment above calls marker-hiding. */
.ctk-chain-blocked { opacity: 0.45 !important; cursor: not-allowed; }
.ctk-chain-blocked .ctk-chain-marker { opacity: 1; }
/* Two-step progress chip above the target grid. It sits alone on its own
 * flex line (the .muted prompt after it is width:100% and wraps below),
 * centered by #pendingActions' justify-content:center — consistent with the
 * centered prompt and target buttons below. */
.ctk-chain-step {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  margin-bottom: 4px;
  font-size: 0.72em;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: #ffe9a8;
  background: rgba(0,0,0,0.32);
  border: 1px solid #6b5530;
  border-radius: 2px;
}
/* Step-aware finish button ("Chain nobody" / "Done — keep 1 target"): its
 * own full-width row below the grid, so ENDING targeting reads distinctly
 * from picking a target. */
.ctk-chain-skip { flex-basis: 100%; margin-top: 4px; }
/* M3 focus ring — visible only for keyboard navigation; uses card-glow yellow so it reads on every variant. */
.px-btn:focus-visible,
.pixel-card:focus-visible,
input:focus-visible,
select:focus-visible {
  outline: 2px solid #fbbf24;
  outline-offset: 2px;
}

/* RETIRED, kept only so a stray class name cannot resurrect Material blue.
 * The rule this app already had — see .px-btn-gold below: "the primary action
 * is gold in every view" — was written but never finished; #ctkIdleOpenLobby,
 * the landing CTA, the guest-name CTA and the generic confirm dialog were all
 * still Material blue, and on the rebuilt board they were the only cool
 * primaries in the product. They now use .px-btn-gold / .px-btn-brass. This
 * alias keeps any missed call site on-palette instead of off it. */
.px-btn-primary { background: linear-gradient(180deg, #e8c878 0%, #b8924a 100%); border-color: #f0d890; color: #2a1d0a; font-weight: bold; }
.px-btn-primary:hover { background: linear-gradient(180deg, #f0d68e 0%, #c8a25a 100%); }
.px-btn-danger  { background: linear-gradient(180deg, #dc2626 0%, #b91c1c 100%); border-color: #ef4444; }
.px-btn-danger:hover { background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%); }
.px-btn-success { background: linear-gradient(180deg, #16a34a 0%, #15803d 100%); border-color: #22c55e; }
.px-btn-success:hover { background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%); }
.px-btn-warn    { background: linear-gradient(180deg, #d97706 0%, #b45309 100%); border-color: #f59e0b; }
.px-btn-warn:hover { background: linear-gradient(180deg, #f59e0b 0%, #d97706 100%); }
/* Palette-native pair for the Sanguosha skin. -primary/-success are Material
 * blue/green, which read as a different app when dropped into the gold-and-
 * brown modals — the lobby's own hero CTA (Quick Play) is gold, so "the
 * primary action is gold" should hold in every view. -jade is the muted
 * companion for a secondary confirm (Ready) that shouldn't outshout it. */
.px-btn-gold { background: linear-gradient(180deg, #e8c878 0%, #b8924a 100%); border-color: #f0d890; color: #2a1d0a; font-weight: bold; }
.px-btn-gold:hover { background: linear-gradient(180deg, #f0d68e 0%, #c8a25a 100%); }
.px-btn-jade { background: linear-gradient(180deg, #4a7a5a 0%, #35583f 100%); border-color: #6fae84; color: #eaf7ee; }
.px-btn-jade:hover { background: linear-gradient(180deg, #5a8f6b 0%, #40694b 100%); }
/* Brass = the room-list join / create-room look. Promoted from a gradient
 * that was inline-duplicated on #lobbyCreate AND every room-card Join, so the
 * two could silently drift apart. */
.px-btn-brass { background: linear-gradient(180deg, #5a4a2a 0%, #3f3318 100%); border-color: #a78a4a; color: #f0e0c0; font-weight: bold; }
.px-btn-brass:hover { background: linear-gradient(180deg, #6a5836 0%, #4a3d20 100%); }

/* Toolbar — icon-only buttons; intent reads from each button's title=
 * attribute (native browser tooltip + accessible name fallback). On desktop
 * (html WITHOUT .ctk-shell) buttons keep .px-btn's 44x44 touch target — M3 /
 * HIG / repo-44px floor — with 20px glyphs. The mobile single-row compaction
 * (28px buttons, horizontal-scroll) lives in the html.ctk-shell block (~L3540)
 * so it no longer leaks here. The @container tiers below tighten padding/icons
 * on narrow non-shell widths but never drop the target below 44px. */
.toolbar-bar, #bugReportDock { container-type: inline-size; }   /* dock: so its .px-btn tiers below track the same width bands as the bar's */
.btn-icon {
  vertical-align: -2px; image-rendering: pixelated;
  flex: 0 0 auto;
}
/* Language toggle reads EN/TH as crisp VT323 tiles — replaces the emoji
 * country flags, which render as bare "GB"/"TH" letters on Windows Chrome
 * and broke the all-Pixelarticons icon contract. */
.lang-flag { font-family: 'VT323', monospace; font-size: 15px; font-weight: bold; letter-spacing: 0.5px; line-height: 1; display: inline-flex; align-items: center; }
.lang-flag.is-flag { padding: 0; }
/* Colour flag SVG (pixel-framed to match the chunky aesthetic). */
.lang-flag svg { display: block; width: 22px; height: 15px; border: 1px solid #120d08; box-shadow: 0 0 0 1px rgba(107,80,48,0.7); }
/* Selected language: gold-glow the active toggle. setLanguage() sets aria-pressed
   on both the data-lang + data-set-lang groups, so this styles the flag buttons
   inside Settings/landing (the toolbar toggle having moved into Settings). */
button[data-lang][aria-pressed="true"], button[data-set-lang][aria-pressed="true"] {
  background: linear-gradient(180deg,#5a4670 0%,#4a3860 100%);
  border-color: #c28a3d; box-shadow: 0 0 6px rgba(139,92,246,0.4);
}
/* Visually hidden, still read by assistive tech — for live-region announces. */
.ctk-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* Hero-draft HP preview — red Sanguosha heart pips + a count, so a player
 * picking (and everyone reading the emperor reveal) can see how tanky each
 * hero / the throne will be. The Lord's own options fold in the +1/+2. */
.draft-hp { display: inline-flex; align-items: center; gap: 1px; flex: 0 0 auto; }
.draft-hp-pip { display: inline-flex; line-height: 0; color: #e53e3e; filter: drop-shadow(0 0 2px rgba(229,62,62,0.5)); }
.draft-hp-pip svg { width: 13px; height: 13px; image-rendering: pixelated; }
.draft-hp-num { font-family: 'VT323', monospace; font-size: 14px; color: #f0b0a0; margin-left: 3px; vertical-align: 1px; }
.draft-hp-lord { margin-left: 6px; vertical-align: -2px; }
/* Hero-draft win-rate badge — "You 60%" (personal) / "All 52%" (community, Phase 2).
 * flex:0 0 auto like .draft-hp so it never pushes or wraps the hero name; the
 * numeric part is VT323 (digits are language-neutral) while the label inherits the
 * UI font (VT323 has no Thai glyphs, so the TH label "คุณ" stays readable). */
.draft-wr { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; padding: 1px 7px; border-radius: 9px; border: 1px solid #6b5530; background: rgba(214,164,74,0.08); white-space: nowrap; }
.draft-wr-label { font-size: 10px; letter-spacing: 0.04em; text-transform: uppercase; color: #b8a888; }
.draft-wr-num { font-family: 'VT323', monospace; font-size: 14px; line-height: 1; color: #d6a44a; }
.draft-wr-all { background: rgba(143,191,127,0.08); }
.draft-wr-all .draft-wr-num { color: #8fbf7f; }
/* Draft-pick idle countdown chip ("Auto-picks your hero in 42s"). Centered,
 * muted; goes red-urgent under the .draft-pick-timer-urgent threshold (<=10s).
 * Digits come from the localized string, so no VT323 override (Thai needs Bai
 * Jamjuree). Own line above the candidates, so it never crowds the pick cards. */
/* #b8a888 (not the dimmer #9a8a70) so this 12px small text clears WCAG AA even
   against the worst-case composite of the 0.75 backdrop over a light region. */
.draft-pick-timer { text-align: center; margin: 2px 0 12px; font-size: 12px; color: #b8a888; letter-spacing: 0.02em; }
.draft-pick-timer-urgent { color: #ff6a52; font-weight: bold; }
.toolbar-bar .px-btn, #bugReportDock .px-btn {
  padding: 8px 10px; min-width: 44px; min-height: 44px; gap: 0;
}
.toolbar-bar .px-btn .btn-icon, #bugReportDock .px-btn .btn-icon { width: 20px; height: 20px; }
/* SFX toggle muted state: dim the speaker and stamp a red pixel-X badge
 * over its corner — visually distinct from the .px-btn:disabled dimming
 * (which reads "unavailable", not "sound off"). Toggled by sfx.ts. */
#sfxToggle, #ctkFxToggle { position: relative; }
#sfxToggle .sfx-mute-x, #ctkFxToggle .sfx-mute-x { display: none; position: absolute; right: 3px; bottom: 3px; image-rendering: pixelated; }
#sfxToggle.is-muted .btn-icon, #ctkFxToggle.is-muted .btn-icon { opacity: 0.4; }
#sfxToggle.is-muted .sfx-mute-x, #ctkFxToggle.is-muted .sfx-mute-x { display: block; }
/* The Motion-effects status line sits under its own label, not centred under
   the whole modal the way the .ctk-settings-soon footer does. */
#ctkFxStatus { margin-top: 2px; text-align: left; }
@container (max-width: 600px) {
  .toolbar-bar .px-btn, #bugReportDock .px-btn {
    padding: 6px 9px; min-height: 44px; min-width: 44px;
  }
  .toolbar-bar .px-btn .btn-icon, #bugReportDock .px-btn .btn-icon { width: 15px; height: 15px; }
  .toolbar-bar .google-chip { padding: 1px 4px; font-size: 12px; }
  .toolbar-bar .google-chip #googleAvatar { width: 18px !important; height: 18px !important; }
  .toolbar-bar #status { font-size: 12px; }
}
@container (max-width: 400px) {
  .toolbar-bar .px-btn, #bugReportDock .px-btn {
    padding: 4px 7px; min-height: 44px; min-width: 44px;
  }
  .toolbar-bar .px-btn .btn-icon, #bugReportDock .px-btn .btn-icon { width: 14px; height: 14px; }
  .toolbar-bar .toolbar-lang .lang-flag { font-size: 14px; }
  .toolbar-bar { gap: 4px !important; }
  .toolbar-bar #bots { width: 32px !important; min-height: 30px; padding: 2px 3px !important; }
  /* Keep the page's only aria-live region in the a11y tree on phones —
   * visually hidden, still announced (disconnect / session-expiry etc.).
   * Was display:none, which dropped it from the tree entirely. */
  .toolbar-bar #status {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }
}

/* Log scroll area */
.log-scroll {
  background: linear-gradient(180deg, #1a1410 0%, #16120e 100%);
  border: 2px solid #4a3828;
  box-shadow:
    inset 0 2px 8px rgba(0,0,0,0.5),
    0 0 0 1px #0a0604;
  scrollbar-width: thin;
  scrollbar-color: #5a4530 #1a1410;
}
.log-scroll::-webkit-scrollbar { width: 8px; }
.log-scroll::-webkit-scrollbar-track { background: #1a1410; }
.log-scroll::-webkit-scrollbar-thumb { background: #5a4530; border: 1px solid #4a3828; }

/* ============================================================
 * Live game-log bottom sheet (ALL viewports). Opened by the Logs button in
 * the #emoteBar tools strip (which forwards to #logBtn — the handler owner,
 * itself no longer surfaced — see its rule below); the old bottom-edge
 * #logEdgeHandle nudge tab was removed as redundant with it. Reparents the
 * existing #logFilterBar + #logScroll into #logSheetBody. Modal-on-demand
 * (M3 bottom sheet) keeps the board clean; the live log keeps rendering
 * because render() targets #log / #logFilterBar by id regardless of where
 * they live. Desktop uses the same sheet now — the inline log below the
 * board is hidden everywhere and surfaced only through the sheet.
 * ============================================================ */
#logBtn { display: none; }
.log-sheet {
  position: fixed; inset: 0; z-index: 60;
  /* Bottom edge = top of the on-screen keyboard. --vv-bottom is 0 with no
   * keyboard, so this is inset:0 in the normal case. Giving the flex
   * container a DEFINITE, keyboard-free height is what lets the panel's
   * max-height below resolve against the space that's actually visible —
   * justify-content:flex-end then pins the panel right above the keyboard
   * instead of below it. */
  bottom: var(--vv-bottom, 0px);
  display: flex; flex-direction: column; justify-content: flex-end;
}
/* The scrim must still cover the FULL viewport even though .log-sheet now
 * stops at the keyboard's top edge — otherwise the strip behind the
 * keyboard is undimmed, which shows as a flash while the keyboard slides
 * away and --vv-bottom is still catching up. Negative bottom re-extends it
 * by exactly the amount the parent was shortened; no-op at --vv-bottom 0. */
.log-sheet-backdrop { position: absolute; inset: 0; bottom: calc(-1 * var(--vv-bottom, 0px)); background: rgba(0,0,0,0.6); }
.log-sheet-panel {
  position: relative; z-index: 1;
  background: linear-gradient(180deg, #1f1812 0%, #16120e 100%);
  border-top: 2px solid #5a4530;
  border-radius: 12px 12px 0 0;
  /* Height budget. 72dvh reserves ~28% so the board stays readable behind
   * the sheet; the 100% term (of .log-sheet, which stops at the keyboard)
   * clamps that to the space actually visible once a keyboard is up — with
   * no keyboard 100% is the full viewport and 72dvh wins, i.e. unchanged.
   * BOTH terms are needed: dvh is spec'd to IGNORE the on-screen keyboard,
   * so 72dvh alone never shrinks; and % alone would drop the board-reveal
   * gap. vh first as the pre-dvh fallback. */
  max-height: 72vh;
  max-height: min(72dvh, calc(100% - 8px));
  display: flex; flex-direction: column;
  /* The keyboard lift lives on .log-sheet's `bottom`, NOT here. It used to
   * be added to this padding, but max-height caps the BORDER box (Tailwind
   * preflight sets box-sizing:border-box globally), so the keyboard height
   * was charged against the sheet's own height budget — the panel box never
   * moved and the message list absorbed the whole loss (440px -> 105px on a
   * 375x812 phone; down to its 24px padding floor on a short one).
   * safe-area is subtracted by --vv-bottom because the home indicator sits
   * UNDER the keyboard when one is up, so summing them over-pads. */
  padding: 6px 10px calc(10px + max(0px, env(safe-area-inset-bottom, 0px) - var(--vv-bottom, 0px)));
  box-shadow: 0 -8px 24px rgba(0,0,0,0.55);
  animation: ctkLogSheetUp 180ms ease-out;
}
@keyframes ctkLogSheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
.log-sheet-handle {
  width: 36px; height: 4px; border-radius: 2px;
  background: #5a4530; margin: 2px auto 8px;
}
.log-sheet-header {
  display: flex; align-items: center; justify-content: space-between;
  margin-bottom: 6px; flex: 0 0 auto;
}
.log-sheet-title {
  display: inline-flex; align-items: center; gap: 6px;
  color: #f0d890; font-size: 16px; font-weight: bold;
}
.log-sheet-title svg { width: 16px; height: 16px; image-rendering: pixelated; }
.log-sheet-actions { display: inline-flex; align-items: center; gap: 6px; }
.log-sheet-actions .px-btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 44px; min-height: 44px; padding: 4px 8px;
}
.log-sheet-actions svg { image-rendering: pixelated; }
/* Column layout: filter bar fixed, #logScroll fills the rest and is the
 * SOLE scroller (overflow hidden here, not auto) — so #logScroll stays the
 * scroll container in both the sheet and the inline desktop log, and the
 * open-to-bottom + jump-to-top/bottom controls target one element. */
.log-sheet-body {
  overflow: hidden; min-height: 0; flex: 1 1 auto;
  display: flex; flex-direction: column;
}
/* Drop the inline desktop height cap (Tailwind max-h-[140px]); bound the
 * height via flex instead so #logScroll itself scrolls. */
.log-sheet-body #logScroll {
  flex: 1 1 auto; min-height: 0;
  max-height: none !important; margin-top: 0 !important;
}
.log-sheet-body #logFilterBar { flex: 0 0 auto; margin-top: 0 !important; margin-bottom: 6px !important; }
@media (prefers-reduced-motion: reduce) { :where(html:not(.ctk-force-fx)) .log-sheet-panel { animation: none; } }


/* ===== Hamburger dropdown menu (#menuBtn -> #ctkMenuPanel) =====
 * The top toolbar clips overflow (overflow-y:hidden / overflow-x:auto) and
 * is only 34px tall, so the menu can't live inside it — it's a fixed
 * popover anchored under the hamburger. Items delegate to the existing
 * toolbar handlers (which fire even while those buttons are display:none
 * during a match), giving the mid-match-hidden Lobby/Support a home. */
.ctk-menu-panel {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 38px);
  left: calc(env(safe-area-inset-left, 0px) + 6px);
  z-index: 55;
  display: none;
  flex-direction: column;
  min-width: 184px;
  padding: 5px;
  background: linear-gradient(180deg, #221a12 0%, #15110d 100%);
  border: 2px solid #6b5530;
  border-radius: 10px;
  box-shadow: 0 12px 32px rgba(0,0,0,0.6), inset 0 1px 0 rgba(200,170,120,0.15);
  animation: ctkMenuIn 140ms var(--ctk-spring, ease-out);
}
.ctk-menu-panel.is-open { display: flex; }
@keyframes ctkMenuIn { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { :where(html:not(.ctk-force-fx)) .ctk-menu-panel { animation: none; } }
/* Items are <button>s, except the "Get the app" row (#ctkMenuApp), which is an
   <a> so its own navigation carries it to Play — hence text-decoration, which a
   button would never have needed. */
.ctk-menu-item {
  display: flex; align-items: center; gap: 10px;
  width: 100%; min-height: 44px;
  padding: 9px 12px; margin: 0;
  background: transparent; border: none; border-radius: 6px;
  color: #e8dcc8; font-family: inherit; font-size: 15px; text-align: left;
  text-decoration: none;
  cursor: pointer;
}
.ctk-menu-item:hover, .ctk-menu-item:focus-visible {
  background: rgba(107,85,48,0.30); color: #f0d890; outline: none;
}
.ctk-menu-item svg { width: 18px; height: 18px; flex: 0 0 auto; image-rendering: pixelated; }

/* Log line styling */
.log-line {
  display: block;
  padding: 1px 4px;
  border-bottom: 1px solid rgba(90,70,40,0.2);
  font-size: 15px;
  line-height: 1.4;
}

/* Tag badge pixel style.
 * inline-flex + align-items:center so the icon and label center on the
 * cross-axis without the old `vertical-align:-2px` baseline hack — and,
 * crucially, so the lone icon stays dead-centred in the icon-only modes
 * (equip/buff/debuff/judgement, and skills below the collapse width).
 * Inter-item spacing lives on `gap` now (not svg margin-right), so it
 * collapses cleanly to 0 when a label is hidden via font-size:0.
 * vertical-align:middle keeps the chip aligned to adjacent inline text
 * (e.g. the incoming-distance chip). */
.px-badge {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  vertical-align: middle;
  padding: 1px 4px;
  border: 1px solid rgba(107,85,48,0.5);
  background: rgba(42,34,24,0.8);
  font-size: 13px;
  color: #d4c4a0;
}
/* Chip palette. Equipment used to be cool slate-blue (#c8d4e8 on
 * rgba(40,52,70,.85)) — hue 218 against a board whose every other surface
 * sits around hue 27. On a warm table those chips became the single
 * highest-chroma object on screen, out-shouting the hero's face they sit
 * on (art audit 2026-08, problem #2). Equipment is now GOLD, which is what
 * this app's own .crit-equip strip already declared it to be; buff/debuff
 * follow into the same warm key, with debuff keeping its saturation since
 * it is the only one of the three that changes a decision. */
.px-badge-equip  { border-color: rgba(196,152,74,0.55); background: rgba(52,38,20,0.86); color: #f0c050; }
.px-badge-buff   { border-color: rgba(150,190,120,0.4); background: rgba(38,50,32,0.86); color: #b8dca0; }
.px-badge-debuff { border-color: rgba(214,110,90,0.5);  background: rgba(58,26,16,0.9);  color: #f2a888; }
/* On the board, the STATUS chips (equipment, buffs, debuffs, and the
   delayed-trick judgements — which reuse .px-badge-debuff) read as ICONS
   only: each has a distinct pixelarticon, and the name + effect stay in the
   chip tooltip and the tap-to-open detail modal (which clones the panel
   OUTSIDE #players, so the clone keeps the labels). Only the hero-SKILL
   chips (plain .px-badge, in the meta row) keep their label — the skill is
   the seat's identity, and its icons are generic/shared (all three Lord
   skills are a crown; sword/target/heart double as equip/judgement), so the
   word is the only thing that disambiguates them. This is what stops a
   fully-kitted, status-laden seat from burying the portrait.
   gap:0 so the hidden (font-size:0) label leaves no trailing flex gap. */
#players .player-panel .px-badge-equip,
#players .player-panel .px-badge-buff,
#players .player-panel .px-badge-debuff { font-size: 0; padding: 1px 3px; gap: 0; }
/* ...and on the BOARD they lose their boxes entirely. The chip background
 * is pure redundancy there — it sits on a 90%-black scrim already — while
 * the row of hard-edged rectangles was reading as an OS notification tray
 * bolted to the hero's chest. Glyph + drop-shadow keeps every one of them
 * legible and hands the focal point back to the portrait. The mobile
 * micro-tier has always rendered them this way; this is the board adopting
 * the better of two treatments it already shipped. The detail modal clones
 * the panel OUTSIDE #players, so its chips keep boxes AND labels. */
#players .player-panel .px-badge-equip,
#players .player-panel .px-badge-buff,
#players .player-panel .px-badge-debuff {
  background: none; border-color: transparent; padding: 0 1px;
  filter: drop-shadow(0 1px 1px rgba(0,0,0,0.95));
}
/* Board glyphs are one step smaller than the 12px default, and the row gap
 * is tight. The chips sit ON the portrait, so the row has to fit inside the
 * art: measured on a live board, 12px glyphs at a 5px gap fit ~5 per row,
 * and a maximum kit (weapon + armour + both horses + a buff + two debuffs +
 * all three delayed tricks = 10) then wrapped to a third row and pushed the
 * hero name clean off the top of the card — at 108px it broke at eight.
 * 11px at a 4px gap fits six per row, which keeps even the maximum kit to
 * two rows. Only the icon-only status chips shrink; the labelled hero-skill
 * chips in the meta row keep the 12px default. */
#players .player-bottom-overlay .status-row .px-badge svg { width: 11px; height: 11px; }
#players .player-bottom-overlay .status-row { gap: 4px; }
/* Deng Ai's 田 tally (bug report #65): the one status chip whose LABEL is
   a count, so it alone keeps its text in the icon-only board treatment —
   a field tally with the number hidden would just be a leaf. Higher
   specificity than the font-size:0 zeroing above, so order-independent. */
#players .player-panel .px-badge.px-badge-fields { font-size: 10px; gap: 1px; }
/* Zhou Tai's Defiance pile: the same count-bearing shape as the 田 tally
   (it rides .px-badge-fields for the keep-text exception) but in the
   skill's own ember red — it is a wound tally, not a buff. */
.px-badge-buff.px-badge-defiance { border-color: rgba(230,90,90,0.5); background: rgba(58,16,20,0.9); color: #f87171; }
.crit.crit-defiance { color: #f87171; }
.px-badge svg {
  flex: none;
  image-rendering: pixelated; width: 12px; height: 12px;
}
/* Hero-skill "used this turn" state — e.g. zhiheng once per turn. */
.px-badge.skill-used { opacity: 0.45; }

/* Seat badge in logs */
.seat-badge {
  display: inline-block;
  padding: 0 4px;
  font-weight: bold;
  font-size: 14px;
}

/* Tooltip pixel style */
.ctk-tip-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  font-size: 11px;
  line-height: 1;
  border-radius: 0;
  background: #3a2c1e;
  border: 1px solid #6b5530;
  color: #d4c4a0;
  cursor: pointer;
  margin-left: 2px;
  flex-shrink: 0;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  padding: 0;
  vertical-align: middle;
}
.ctk-tip-btn:hover {
  background: #5a4530;
}
.ctk-tip-bubble {
  display: none;
}
.ctk-tip-open > .ctk-tip-bubble {
  display: block;
}
.ctk-log-dimmed {
  opacity: 0.15;
}

/* Title banner */
.game-title {
  font-family: "VT323", monospace;
  font-size: 26px;
  color: #f0d890;
  text-shadow:
    1px 1px 0 #3a2510,
    0 0 6px rgba(240,216,144,0.28);
  letter-spacing: 2px;
}

/* ===== Battle Visualization ===== */
#battleArena {
  position: relative;
  /* Height of the centre pile stubs. Declared here, not on .ctk-deck-stub,
     because #reopenScoreboard is parked directly beneath them and offsets
     itself by this — one number, so the two cannot drift apart. */
  --ctk-pile-h: 92px;
}
/* Tent dressing around the table (sand-table package, 2026-09). A lantern
 * falloff from the top edge plus four hung kingdom pennants — two at each
 * top corner, swallow-tailed, in the kingdom law's own four hues at ~0.3
 * so they read as dressing on the tent wall, never as a HUD. Pennants are
 * decoration: seats are shuffled, so nothing here may look like it names a
 * player. A ::before at z-index:0 paints before the rail/felt siblings
 * (tree order at equal z), i.e. UNDER the table; it is static, so it costs
 * one raster on the arena's own layer and nothing per frame. Revealed with
 * the table so an idle arena stays bare. */
#battleArena::before {
  content: '';
  position: absolute; inset: 0;
  z-index: 0; pointer-events: none;
  opacity: 0; transition: opacity 0.3s ease;
  background:
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 56 90'><path d='M0 0h56' stroke='%23e0be7e' stroke-opacity='.45' stroke-width='2'/><path d='M4 1h20v58l-10-9-10 9z' fill='%23c16d67' fill-opacity='.32'/><path d='M32 1h20v70l-10-9-10 9z' fill='%23a067c1' fill-opacity='.28'/><path d='M4 1h20v6H4z M32 1h20v6H32z' fill='%23e0be7e' fill-opacity='.22'/></svg>"),
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 56 90'><path d='M0 0h56' stroke='%23e0be7e' stroke-opacity='.45' stroke-width='2'/><path d='M4 1h20v70l-10-9-10 9z' fill='%2367c194' fill-opacity='.28'/><path d='M32 1h20v58l-10-9-10 9z' fill='%236794c1' fill-opacity='.32'/><path d='M4 1h20v6H4z M32 1h20v6H32z' fill='%23e0be7e' fill-opacity='.22'/></svg>"),
    radial-gradient(ellipse at 50% 0%, rgba(255,204,128,0.08) 0%, rgba(255,204,128,0) 55%);
  background-repeat: no-repeat;
  background-position: left 2.5% top 0, right 2.5% top 0, center top;
  background-size: clamp(28px, 4.5%, 44px) auto, clamp(28px, 4.5%, 44px) auto, 100% 100%;
}
#battleArena.ctk-table-on::before { opacity: 1; }
/* Round-table felt. A bounded elliptical play-surface drawn UNDER the seat
 * ring so the oval reads as a physical table you sit around — not seat math
 * floating over the page vignette. Sized to the LIVE ring: ctkReapplyOval
 * writes --ctk-felt-w/h from the final oval radii (2·rx / 2·ry, as % of the
 * arena box), so the felt rim tracks the seat centers at any seat count /
 * viewport, and toggles .ctk-table-on so no bare table shows when idle.
 * A REAL element (#ctkFelt), NOT a ::before: pseudo-elements don't reliably
 * pick up JS-set custom props / a :has() reveal on every engine, but a real
 * child does. Warm amber/ember palette (NOT Balatro green) per the house
 * theme; domed vignette + wood rim + drop shadow give the "raised table"
 * read. Behind the panels (z-index:0 < panel z:2). */
#ctkFelt {
  position: absolute;
  left: var(--ctk-felt-cx, 50%);
  top: var(--ctk-felt-cy, 43%);
  width: var(--ctk-felt-w, 76%);
  height: var(--ctk-felt-h, 64%);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  /* VALUE LADDER (art audit 2026-08, problem #1): every stop here must sit
   * ABOVE .game-frame's own #1f1810 (L*~25). The old ramp ended at
   * rgba(16,10,6,.98) — DARKER than the page behind it — so the table had no
   * silhouette at all and the seat cards had nothing to sit on. Darkness now
   * lives in the OUTER shadow stack below, not on the cloth.
   * The two repeating layers are material, not decoration: the 1px-on-3px
   * scanline is the house CRT idiom already used by .ctk-landing /
   * .ctk-act1-backdrop, and the fractalNoise tile breaks the 8-bit banding a
   * bare 5-stop radial shows across a 590px span. (A 45/-45deg silk lozenge
   * was tried here in the 2026-09 sand-table pass and removed by decision.)
   * `background` is a SHORTHAND — the layers and the gradient must stay in
   * one declaration or the later one silently wins.
   *
   * SAND-TABLE MAP (2026-09). Between the scanline and the noise sits one SVG:
   * the Han territories c. 220 AD painted on the silk — the Yellow River and
   * the Yangtze as DARK INK lines (never gold, never dashed: a gold curve
   * across the cloth reads as an action arrow and a dashed one as the
   * marching pending arc — see viz-arrows.ts), the Han River joining them, three soft
   * kingdom washes in the kingdom law's own hues (Wei north, Shu west, Wu
   * south-east; Qun is the unwashed margin), capital marks at Luoyang /
   * Chengdu / Jianye, a small mark at Red Cliffs, Qinling and Ba mountain
   * ticks, and the Great Wall as a solid ink line along the north. Only the
   * capital marks stay gold, and they are dots, not lines. It is SCENERY: seats
   * are shuffled, so no region may ever be read as belonging to a player.
   * Sized 100%/100% so it stretches with the felt oval at every seat count.
   * Coordinates live in a 200x156 box (the felt's ~1:0.8) — edit the SVG in
   * that space. */
  background:
    repeating-linear-gradient(0deg,
      rgba(0,0,0,0.13) 0 1px, transparent 1px 3px),
    var(--ctk-felt-map),
    var(--ctk-felt-grain),
    radial-gradient(ellipse at 50% 38%,
      #5c452a 0%,
      #4a3722 38%,
      #3a2b1a 70%,
      #2c2013 88%,
      #241a10 100%);
  /* The map and grain layers are custom properties declared on :root in
   * the "Table effects" block below, so Settings can blank either one with
   * `none` — a `none` layer keeps its slot, so the sizes / repeats / blend
   * modes listed after this stay aligned. */
  background-size: auto, 100% 100%, 160px 160px, auto;
  background-repeat: repeat, no-repeat, repeat, no-repeat;
  background-blend-mode: normal, normal, soft-light, normal;
  box-shadow:
    inset 0 3px 0 rgba(255,226,168,0.10),        /* lamp catch on the near rim */
    inset 0 -70px 100px -50px rgba(0,0,0,0.55),  /* far side falls into shade */
    inset 0 0 60px 18px rgba(26,16,9,0.42),      /* edge occlusion → domed cloth */
    inset 0 0 0 3px rgba(10,6,3,0.85),           /* gasket under the wood rail */
    0 28px 64px -14px rgba(0,0,0,0.8),           /* table casts onto the page */
    0 0 110px 46px rgba(6,3,1,0.55);             /* vignette OUTSIDE the ellipse */
  /* Perf (2026-09, the WebView lag pass): width/height no longer
   * transition — the 0.25s size transition relaid-out and re-rastered
   * this layer for 15 frames per fit; a size change now snaps in one
   * frame. The two outer shadows above are the "shadows" table effect:
   * a compositor layer's bounds include its outer shadows, so on a 412px
   * phone they turn the 367x561 felt into a 790x970 layer (~20 MB at
   * DSF 2.6) and every re-raster pays for all of it. Settings can untick
   * them (html.ctk-fx-off-shadows, the phone default) — see the "Table
   * effects" block below. */
  transition: opacity 0.3s ease;
  /* Perf (2026-09, the PR #725/#763 frame-drop fix): pin the table art on
   * its own compositor layer. Felt + rail shared a paint layer with the
   * animated seat panels above, so every glow/shimmer/pulse frame
   * re-rasterised this whole stack (fractalNoise blend, 100-110px-blur
   * shadows, the conic rail) — measured at ~half the board's steady-state
   * paint cost. Both are leaf divs (aria-hidden, no descendants), so
   * will-change's fixed-containing-block side effect can trap nothing. */
  will-change: transform;
}
/* Table markings: two hairline inlay rings, concentric with the rim. The
 * cardinal-point glyphs that used to sit here (first ♠♥♣♦, then the Four
 * Symbols of the 2026-09 sand-table pass) are gone by decision — the map
 * on the cloth carries the period now, and the compass points stay clear
 * for arrows and the turn/YOU pills. Deliberately faint — this is inlay
 * catching lamplight, not a graphic. aspect-ratio matches the felt's own
 * ~1:0.8 so the rings stay concentric with the rim instead of drifting
 * into an off-centre circle. */
#ctkFelt::before {
  content: '';
  position: absolute; inset: 0;
  border-radius: 50%;
  pointer-events: none;
  background:
    radial-gradient(ellipse at 50% 50%,
      transparent 0 25.4%, rgba(224,190,126,0.30) 25.4% 25.9%, transparent 25.9%),
    radial-gradient(ellipse at 50% 50%,
      transparent 0 57.6%, rgba(224,190,126,0.24) 57.6% 58.1%, transparent 58.1%);
  background-size: 100% 100%, 100% 100%;
  background-position: center, center;
  background-repeat: no-repeat;
  opacity: 0.5;
  mix-blend-mode: overlay;
}
/* Aged-bronze rail (was lathed wood — felt-and-wood-rail is the poker
 * table recipe; a bronze rim with a key-fret band is the Han one). A
 * SIBLING, not #ctkFelt::after: #ctkFelt sets `z-index: 0`, so it forms a
 * stacking context and a negative-z child would paint over its own
 * background — blanketing the cloth in metal. Two static layers: a 12px
 * 回紋 (key-fret spiral) tile as the chased band, and the specular conic
 * that makes the highlight TRAVEL round the ring (one overhead lamp, one
 * highlight). Warm bronze only, no verdigris — green is Wu's.
 *
 * KEEP THIS QUIET. The first cut was bright cast bronze with a ring of
 * fine radial ridges; on a live board that read as a giant dashed gold arc
 * — i.e. exactly like a pending-target arrow (client/src/viz-arrows.ts:
 * amber/seat-hued strokes, marching dashes, dark halo) wrapped round the
 * whole table. So: NO dash-like ridge ring, the specular peaks at a dull
 * #a58a4a well under the arrows' #d69e2e-class golds, and the key-fret
 * is sunk to ~0.28 so it is texture at arm's length, not a pattern that
 * competes with a stroke. The rail must always be quieter than a seat
 * panel and much quieter than any arrow. Sized off the same --ctk-felt-*
 * vars ctkReapplyOval writes on #battleArena, plus a fixed 30px so the
 * rail reads as thickness at every seat count. */
#ctkRail {
  position: absolute;
  left: var(--ctk-felt-cx, 50%);
  top: var(--ctk-felt-cy, 43%);
  width: calc(var(--ctk-felt-w, 76%) + 30px);
  height: calc(var(--ctk-felt-h, 64%) + 30px);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  background: var(--ctk-rail-fret), var(--ctk-rail-metal);
  background-size: 12px 12px, auto;
  box-shadow:
    inset 0 2px 0 rgba(255,236,190,0.16),
    inset 0 -1px 0 rgba(0,0,0,0.5),
    0 20px 48px -10px rgba(0,0,0,0.7);    /* part of the "shadows" table effect */
  transition: opacity 0.3s ease;
  will-change: transform;   /* own compositor layer — see #ctkFelt */
}
/* Reveal the felt only once a board is up (ctkReapplyOval adds the class). */
#battleArena.ctk-table-on #ctkFelt,
#battleArena.ctk-table-on #ctkRail { opacity: 1; }

/* ── Rail sheen (Settings > Table effects) ─────────────────────────────
 * The rail's note above asks for "one overhead lamp, one highlight"
 * travelling round the ring — but the conic specular varies SPATIALLY and
 * is temporally static, so the highlight has sat at a fixed angle forever
 * and a big bronze ring reads as painted card rather than metal. This is
 * the motion that was described but never built.
 *
 * A small blob on an offset-path, NOT a rotating conic overlay. Rotating a
 * full-size gradient needs a square that covers the ellipse's diagonal —
 * about 1.6x the rail box, ~48 MB at phone DPR — which is exactly the
 * compositor-memory class of cost PR #792 spent a session removing. This
 * is one ~22%-of-box element following the rim, so its layer is small, and
 * offset-distance is transform-equivalent: never a paint property.
 *
 * #ctkFelt is the NEXT SIBLING at the same z-index, so it paints over this
 * — the glint is occluded across the cloth and shows only on the ~15px
 * bronze band, which is what a rim highlight should do. overflow:hidden
 * clips it to the rail's own ellipse; box-shadows are not clipped by
 * overflow, so the table shadow is untouched.
 *
 * No reduced-motion carve needed: the universal block collapses duration
 * to 0.001s and iteration to 1, which parks the glint at the path start as
 * a static highlight — the same idiom as the other ambient loops. With
 * `motion` off it runs one cycle and stops, per that key's contract.
 * Measured Pixel 5 @ 6x, counterbalanced n=6: no frame cost. */
#ctkRail { overflow: hidden; }
#ctkRail::after {
  content: "";
  position: absolute;
  left: 0; top: 0;
  /* Scale matters more than opacity here. The bronze band is only ~15px
   * (the rail is the felt + 30px, so 15px each side), and #ctkFelt covers
   * everything inside it — so a big soft blob puts only its faint outer
   * edge on the band and reads as nothing. Small and bright lands the
   * CORE on the band; the felt still crops its inner half, which is what
   * keeps it a rim highlight rather than a glow on the cloth. */
  width: 11%; height: 15%;
  margin: -7.5% 0 0 -5.5%;        /* centre the blob on its path point */
  border-radius: 50%;
  background: radial-gradient(circle,
    rgba(255, 240, 200, 0.50) 0%,
    rgba(255, 230, 175, 0.22) 40%,
    rgba(255, 226, 168, 0) 70%);
  offset-path: ellipse(49% 49% at 50% 50%);
  offset-distance: 0%;
  offset-rotate: 0deg;
  /* Slow on purpose. The rail must stay quieter than a seat panel and much
   * quieter than any arrow (see the rail note above); at 34s a player reads
   * it as the table catching the light, not as a cue demanding attention. */
  animation: ctkRailSheen 34s linear infinite;
  pointer-events: none;
  /* REQUIRED, not a micro-optimisation. offset-distance is NOT a composited
   * property, so without a layer of its own the moving blob re-dirties its
   * overflow-clipped parent — the whole ~1040x1265 device-px rail — every
   * frame. Measured Pixel 5 @6x: unpromoted -0.7 to -1.8 fps and drops
   * 22.7 -> 29.6; promoted +0.1 fps, i.e. free. This is a PERMANENT layer
   * on a 42x69px element (0.08 MB), the same idiom as #ctkFelt / #ctkRail /
   * .player-panel — not the transient toggling that caused the seat-pop
   * layer churn. Dropping overflow:hidden instead is much worse (-4.5 fps):
   * the blob then spills past the rim and dirties a far larger region. */
  will-change: transform;
}
@keyframes ctkRailSheen { to { offset-distance: 100%; } }
html.ctk-fx-off-sheen #ctkRail::after { content: none; }
/* ===== Table effects (Settings > Table effects) =====
 * Every dressing a player can untick. The <head> boot script turns the
 * persisted prefs into html.ctk-fx-off-<name> classes before first paint;
 * window.__ctkFx owns the list. Each effect maps to ONE cost:
 *   shadows  — the felt/rail outer shadows (compositor-layer bounds, GPU
 *              memory on phones; PR #792). Off = the tight stack.
 *   map      — the Han territories SVG on the cloth. (Cost re-measured
 *              2026-09-04: like `grain` below, this is a cached bitmap,
 *              not a per-raster decode. Kept as a toggle for taste and
 *              GPU memory, not for frame time.)
 *   grain    — the fractalNoise cloth grain. NOT "an SVG filter per
 *              raster", which is what this line used to claim and what
 *              two audits then tried to optimise away. Chromium decodes
 *              the SVG ONCE and caches the bitmap, exactly as it does a
 *              PNG. Falsification test (2026-09-04): raising the filter's
 *              work ~48x (numOctaves 3->9, tile 160->640px) changed
 *              per-raster cost by -0.30 ms, i.e. it got marginally
 *              FASTER. The residual SVG-vs-PNG gap is +0.87 ms/tile of
 *              RASTER-thread time and 0 ms on the main thread; at the
 *              board's measured 0.40 full felt re-rasters/s that is
 *              10.4 ms/s, and flipping this toggle moves dropped frames
 *              by nothing. A PNG bake would also cost 90 KB (or 623 KB
 *              to stay pixel-faithful at phone DPR) against 254 bytes
 *              today, and a 160px bake loses 36%% of the grain's
 *              amplitude — the banding suppression it exists for.
 *              DO NOT BAKE IT. See docs/ for the full numbers.
 *   rail     — key-fret tile + travelling conic specular (flat bronze off).
 *   pennants — the tent pennants + lantern falloff on #battleArena::before.
 *   motion   — the ambient LOOPS (turn shimmer/glow, breathing hints, the
 *              handle bob, the marching pending arc). One-shot beats keep
 *              playing; loops stop after their first cycle.
 *   sheen    — the lamp glint travelling round the bronze rail. One small
 *              promoted element on an offset-path. Measured (counterbalanced,
 *              n=8/arm): FREE on desktop @4x (59.0 fps both arms, delta
 *              -0.08) but -1.33 fps / +3.3 drops per 10s at Pixel 5 @6x,
 *              because offset-distance is not a composited property. So it
 *              defaults OFF on phone-class devices, like `shadows`, and a
 *              player can tick it back on.
 * The art layers are custom properties so an off-state blanks a layer
 * with `none` and the shorthand's sizes / blend modes stay aligned. */
    /* The art itself lives here on :root, NOT on #ctkFelt/#ctkRail: var()
 * substitution happens where the property is DECLARED, so a :root
 * `--ctk-felt-map: var(--ctk-felt-map-art)` can only see an -art value
 * that is also on :root (a felt-scoped one would leave it invalid and the
 * whole background shorthand with it). */
:root {
  --ctk-felt-map-art:
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 156'><g opacity='0.85'><defs><radialGradient id='w'><stop offset='0' stop-color='%236794c1' stop-opacity='0.26'/><stop offset='1' stop-color='%236794c1' stop-opacity='0'/></radialGradient><radialGradient id='s'><stop offset='0' stop-color='%23c16d67' stop-opacity='0.26'/><stop offset='1' stop-color='%23c16d67' stop-opacity='0'/></radialGradient><radialGradient id='u'><stop offset='0' stop-color='%2367c194' stop-opacity='0.26'/><stop offset='1' stop-color='%2367c194' stop-opacity='0'/></radialGradient></defs><ellipse cx='112' cy='48' rx='66' ry='32' fill='url(%23w)'/><ellipse cx='50' cy='100' rx='40' ry='34' fill='url(%23s)'/><ellipse cx='150' cy='110' rx='46' ry='30' fill='url(%23u)'/><g fill='none' stroke='%23140d06' stroke-linecap='round' stroke-linejoin='round'><path d='M18 58 C30 40 40 30 54 32 C70 34 74 26 84 40 C90 50 92 54 100 58 C118 56 136 52 152 44 C162 40 170 38 182 34' stroke-width='1.6' stroke-opacity='0.34'/><path d='M22 92 C34 90 44 96 56 94 C72 92 82 104 100 102 C112 100 118 96 130 96 C142 96 150 90 162 86 C172 84 180 88 188 90' stroke-width='1.6' stroke-opacity='0.34'/><path d='M98 62 C104 70 114 74 124 78 C134 82 140 86 146 88' stroke-width='0.9' stroke-opacity='0.26'/><path d='M60 70 l4 -5 4 5 M70 66 l4 -5 4 5 M80 68 l4 -5 4 5 M90 72 l4 -5 4 5' stroke-width='1' stroke-opacity='0.3'/><path d='M40 118 l4 -5 4 5 M50 122 l4 -5 4 5 M30 108 l4 -5 4 5' stroke-width='1' stroke-opacity='0.26'/><path d='M120 40 l4 -5 4 5 M132 36 l4 -5 4 5 M144 30 l4 -5 4 5' stroke-width='1' stroke-opacity='0.26'/><path d='M44 30 l6 -3 8 1 8 -3 8 2 8 -3 8 2 10 -2' stroke-width='1.2' stroke-opacity='0.28'/></g><g fill='%23e0be7e' fill-opacity='0.55'><rect x='95' y='55' width='5' height='5' transform='rotate(45 97.5 57.5)'/><rect x='47' y='91' width='5' height='5' transform='rotate(45 49.5 93.5)'/><rect x='160' y='83' width='5' height='5' transform='rotate(45 162.5 85.5)'/><rect x='118' y='96' width='3' height='3' transform='rotate(45 119.5 97.5)'/></g><path d='M116 92 l2 -5 2 3 2 -4 1 6z' fill='%23c16d67' fill-opacity='0.7'/></g></svg>");
  --ctk-felt-grain-art:
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/></filter><rect width='160' height='160' filter='url(%23n)' opacity='0.5'/></svg>");
  --ctk-rail-fret-art:
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12'><path d='M1.5 1.5h9v9h-7v-7h5v5h-3v-3h1' fill='none' stroke='%23120c05' stroke-opacity='.28' stroke-width='1'/></svg>");
  --ctk-rail-metal-art:
    conic-gradient(from 208deg,
      #4f3d1f 0deg, #7d6532 10%, #a58a4a 16%, #66502a 30%, #2f2412 50%,
      #45351a 66%, #776031 84%, #957c43 90%, #4f3d1f 100%);
  --ctk-felt-map: var(--ctk-felt-map-art);
  --ctk-felt-grain: var(--ctk-felt-grain-art);
  --ctk-rail-fret: var(--ctk-rail-fret-art);
  --ctk-rail-metal: var(--ctk-rail-metal-art);
}
html.ctk-fx-off-map      { --ctk-felt-map: none; }
html.ctk-fx-off-grain    { --ctk-felt-grain: none; }
html.ctk-fx-off-rail     { --ctk-rail-fret: none; --ctk-rail-metal: linear-gradient(180deg, #6b5630 0%, #4a3a1e 100%); }
html.ctk-fx-off-pennants #battleArena::before { display: none; }
html.ctk-fx-off-shadows #ctkFelt {
  box-shadow:
    inset 0 3px 0 rgba(255,226,168,0.10),
    inset 0 -70px 100px -50px rgba(0,0,0,0.55),
    inset 0 0 60px 18px rgba(26,16,9,0.42),
    inset 0 0 0 3px rgba(10,6,3,0.85),
    0 14px 28px -10px rgba(0,0,0,0.75);
}
html.ctk-fx-off-shadows #ctkRail {
  box-shadow:
    inset 0 2px 0 rgba(255,236,190,0.16),
    inset 0 -1px 0 rgba(0,0,0,0.5),
    0 10px 22px -8px rgba(0,0,0,0.7);
}
html.ctk-fx-off-motion *, html.ctk-fx-off-motion *::before, html.ctk-fx-off-motion *::after {
  animation-iteration-count: 1 !important;
}
/* Settings checklist for the above. */
.ctk-fx-prefs {
  display: grid; grid-template-columns: 1fr 1fr; gap: 4px 12px;
  padding: 0 0 8px; font-size: 14px; color: #d4c4a0;
}
.ctk-fx-prefs label { display: flex; align-items: center; gap: 6px; cursor: pointer; min-width: 0; }
.ctk-fx-prefs label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctk-fx-prefs input { margin: 0; flex: 0 0 auto; accent-color: #d69e2e; }
#battleSvg {
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
  pointer-events: none;
  overflow: visible;
  z-index: 10;
  /* Permanent compositor layer, like the seat panels (see the will-change
   * block near .hero-stage). The arrows animate stroke-dashoffset, a paint
   * property, so the SVG gets its own layer whenever one is on screen —
   * which, between strike arrows and pending arcs, is most of a game.
   * Pinning it avoids the create/destroy churn each arrow would otherwise
   * cause over the whole seat ring it overlaps. Measured over CDP
   * LayerTree (2026-09): the marching pending arc then costs exactly its
   * 10 SVG-layer repaints a second and touches no seat panel. */
  will-change: transform;
}
/* Center "LAST PLAYED" pile — a prominent fanned stack of card faces that
 * fades in on each card play, ages out after ~2.5s. Inner cards rotate;
 * the container keeps its own translate(-50%,-50%) (oval-safe). */
/* Both centre anchors read the SAME variable the felt does. They used to be
 * hard-coded at 42% and 43% — near enough while the ring centre was 43%, but
 * Stage 2 moved it to 46% to fix the board's top-heavy composition, which
 * left the deck floating above the middle of its own table. They also
 * disagreed with EACH OTHER by a percent, so the played card popped slightly
 * off the deck it replaced. One source of truth now; ctkReapplyOval writes
 * --ctk-felt-cy, and these follow the ring wherever it goes. */
#ctkCenterPile {
  position: absolute; left: var(--ctk-felt-cx, 50%); top: var(--ctk-felt-cy, 46%);
  transform: translate(-50%, -50%) scale(0.94);
  z-index: 9; pointer-events: none; opacity: 0;
  transition: opacity 0.2s ease-out, transform 0.2s ease-out;
}
#ctkCenterPile.is-showing { opacity: 1; transform: translate(-50%, -50%) scale(1); }
#ctkCenterPile.is-leaving {
  opacity: 0; transform: translate(-50%, -50%) scale(0.92);
  transition: opacity 0.4s ease-in, transform 0.4s ease-in;
}
/* Scaled in step with the table-center stubs below (~1.75x) so nothing
 * resizes when a played card pops over the resting deck. */
.ctk-pile-wrap { position: relative; display: flex; flex-direction: column; align-items: center; gap: 4px; font-family: 'VT323', monospace; }
/* Lifted OUT OF FLOW: as a flex row the caption made the wrap taller by a
 * variable amount, so the fan — the thing that should sit dead centre of
 * the table — drifted off the anchor by up to ~9px depending on whether a
 * label was present. */
.ctk-pile-label {
  position: absolute; left: 50%; bottom: calc(100% + 4px); transform: translateX(-50%); white-space: nowrap;
  font-size: 11px; letter-spacing: 2px; color: #e9c87a; text-transform: uppercase; text-shadow: 0 1px 2px rgba(0,0,0,0.8); padding: 1px 6px; border-radius: 3px; background: rgba(14,8,4,0.55); box-shadow: inset 0 0 0 1px rgba(107,80,48,0.5);
}
.ctk-pile-fan { position: relative; width: 81px; height: 105px; }
.ctk-pile-card {
  position: absolute; left: 0; top: 0; width: 60px; height: 84px; border-radius: 4px;
  border: 2px solid #2a2018; background: #efe6cf;
  box-shadow: 0 6px 16px rgba(0,0,0,0.65); image-rendering: pixelated;
}
.ctk-pile-ghost { background: #e2d6ba; }
.ctk-pile-g1 { transform: translate(3px, 7px) rotate(-9deg); filter: brightness(0.9); z-index: 1; }
.ctk-pile-g2 { transform: translate(18px, 4px) rotate(8deg); filter: brightness(0.95); z-index: 2; }
.ctk-pile-face { left: 10px; top: 0; z-index: 3; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; }
.ctk-pile-face.suit-red { color: #c0392b; }
.ctk-pile-face.suit-black { color: #2a2018; }
.ctk-pile-corner { position: absolute; top: 3px; left: 4px; font-size: 14px; line-height: 1; font-weight: 700; }
.ctk-pile-glyph { font-size: 31px; line-height: 1; }
.ctk-pile-name { font-size: 15px; line-height: 1; letter-spacing: 0.3px; max-width: 55px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #2a2018; }
.ctk-pile-cap { font-size: 12px; letter-spacing: 0.5px; color: #d4c4a0; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
.ctk-pile-arrow { color: #e9c87a; }

/* Persistent table-center anchor: a face-down draw deck + discard slot at the
 * oval center so the middle isn't empty at rest. Sits UNDER the transient
 * last-played pile (#ctkCenterPile z:9) and the action arrows (z:10); dims
 * to near-invisible while a last-played card is showing so it never fights
 * the live beat. Pure decoration (pointer-events:none). */
#ctkTableCenter {
  position: absolute; left: var(--ctk-felt-cx, 50%); top: var(--ctk-felt-cy, 46%);
  transform: translate(-50%, -50%);
  z-index: 1; pointer-events: none;
  display: flex; align-items: center; gap: 20px;
  /* Was 0.85 — the centre of the table has no reason to be the one thing on
   * it rendered at reduced strength. */
  opacity: 1; transition: opacity 0.3s ease;
}
#ctkCenterPile.is-showing ~ #ctkTableCenter { opacity: 0.16; }
/* The centre was an 86x52 pair of stubs inside a ~591x474 felt — about 2%
 * of the table, at the one spot the eye returns to on every action (art
 * audit 2026-08, problem #4). Scaled ~1.75x throughout. The stub is the
 * CONTAINER and stays proportionally larger than its card so the fanned
 * .ctk-deck-b1/b2 offsets below still have room. */
.ctk-deck-stub { position: relative; width: 70px; height: var(--ctk-pile-h, 92px); }
.ctk-deck-card {
  position: absolute; left: 0; top: 0; width: 60px; height: 84px;
  border-radius: 4px; border: 2px solid #2a1410; image-rendering: pixelated;
  /* Lacquer, not picnic plaid. This is the game's most-repeated image — it
   * flies across the table on every draw. Oxblood ground, gold rule; the
   * old check survives at ~0.06 so it reads as weave rather than tartan.
   * It carried a gold spade crest from the brand sheet for a while
   * (art audit 2026-08); removed 2026-09 by decision — the back is plain
   * lacquer now, and the gold rule alone marks it. */
  background:
    repeating-linear-gradient(45deg, rgba(240,216,144,0.06) 0 3px, transparent 3px 6px),
    repeating-linear-gradient(-45deg, rgba(240,216,144,0.05) 0 3px, transparent 3px 6px),
    linear-gradient(160deg, #5c1f18 0%, #2a0f0c 100%);
  box-shadow: 0 3px 8px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(214,164,74,0.35);
}
.ctk-deck-b2 { transform: translate(9px, 7px) rotate(3deg); filter: brightness(0.8); }
.ctk-deck-b1 { transform: translate(4px, 4px) rotate(-2deg); filter: brightness(0.9); }
.ctk-deck-top { box-shadow: 0 6px 16px rgba(0,0,0,0.6), inset 0 0 0 1px rgba(240,216,144,0.4); }
/* A ROUTED WELL, not a dashed box. `2px dashed` is the universal
 * "nothing has been placed here yet" idiom — it was still showing it while
 * reading a discard count of 33. A recess in the table top is correct
 * whether the pile is empty or not. */
.ctk-discard-stub {
  position: relative;
  width: 60px; height: 84px; border-radius: 4px;
  border: 0;
  background: linear-gradient(180deg, rgba(8,4,2,0.74) 0%, rgba(16,10,6,0.55) 100%);
  box-shadow:
    inset 0 3px 8px rgba(0,0,0,0.78),
    inset 0 -1px 0 rgba(214,178,112,0.16),
    0 0 0 1px rgba(90,66,38,0.5);
}
/* CARD-PLAY SLAM. A played card used to resolve as a 200ms opacity crossfade
 * at the table centre, throwing away the one thing that would have given it
 * weight: the seat it came from, which vizCenterPile already receives. The
 * card now flies in from that seat, overshoots, and settles. --slam-dx/dy
 * are written per play in client/src/viz-arrows.ts as the arena-relative
 * delta from the centre to the acting seat; with no known origin they stay 0
 * and this degrades to a straight scale-up. The translate(-50%,-50%) is
 * carried through EVERY stop — this element is centred by transform, so a
 * keyframe that drops it would fling the pile a half-width off-table. */
@keyframes ctkPileSlam {
  0%   { opacity: 0; transform: translate(-50%,-50%) translate(var(--slam-dx,0px), var(--slam-dy,0px)) scale(0.5) rotate(var(--slam-rot,-12deg)); }
  45%  { opacity: 1; }
  70%  { opacity: 1; transform: translate(-50%,-50%) scale(1.14) rotate(2deg); }
  85%  { transform: translate(-50%,-50%) scale(0.97) rotate(-1deg); }
  100% { opacity: 1; transform: translate(-50%,-50%) scale(1) rotate(0deg); }
}
#ctkCenterPile.is-slamming { animation: ctkPileSlam 420ms cubic-bezier(.18,.85,.3,1.05) both; }
/* Contact shadow under the fan, so the card lands ON something. */
.ctk-pile-fan::after {
  content: ''; position: absolute; left: 6%; right: 6%; bottom: -6px; height: 10px;
  border-radius: 50%; background: radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0.6) 0%, transparent 70%);
  pointer-events: none;
}

/* DECK TICK. The counts sat dead through every draw and discard — the only
 * numbers on the table that change constantly, and nothing marked the change.
 * translateX(-50%) is carried through every stop: .ctk-pile-count is centred
 * by transform, so a keyframe that omits it snaps the chip sideways. */
@keyframes ctkPileTick {
  0%   { transform: translateX(-50%) scale(1);    color: #f0d890; }
  35%  { transform: translateX(-50%) scale(1.28); color: #fff3c4; }
  100% { transform: translateX(-50%) scale(1);    color: #f0d890; }
}
.ctk-pile-count.is-ticking { animation: ctkPileTick 340ms ease-out; }

/* The last played card stays face-up in the discard well instead of the well
 * reading as an empty dropzone while the count says 33. Written by
 * client/src/viz-arrows.ts when the transient pile leaves; dimmed and
 * slightly turned so it reads as "already resolved", not as live play. */
.ctk-discard-face {
  position: absolute; inset: 4px;
  display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 2px;
  padding-top: 9px;
  border-radius: 4px; border: 1px solid rgba(24,17,10,0.9);
  /* Muted to the point of clearly reading as SPENT. Set as literal colours
   * rather than a filter on a light card: a spent card sunk in a recess is a
   * different object from the live deck beside it, and it must never compete
   * with the card that is actually in play. The count chip sits at
   * bottom:6px, so the content is top-aligned to stay clear of it. */
  background: linear-gradient(180deg, #8d8268 0%, #6f654e 100%);
  color: #241d14;
  box-shadow: inset 0 2px 6px rgba(0,0,0,0.45);
  font-family: 'VT323', monospace; line-height: 1;
  transform: rotate(-4deg);
  pointer-events: none;
}
.ctk-discard-face.suit-red { color: #6d221b; }
.ctk-discard-face .ctk-discard-glyph { font-size: 22px; }
.ctk-discard-face .ctk-discard-name {
  font-size: 12px; max-width: 92%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The count chip must stay ON TOP of the face it labels. */
.ctk-discard-stub .ctk-pile-count { z-index: 2; }

/* TURN BATON. ctkPositionSeatBanners re-writes left/top on every render,
 * resize AND scroll, so a bare transition on this element would smear the
 * pill around during a scroll. The transition is gated behind a class the
 * positioner sets ONLY when the banner actually changes seat. */
.ctk-seat-banner.is-travelling {
  transition: left 320ms cubic-bezier(.3,.8,.3,1), top 320ms cubic-bezier(.3,.8,.3,1);
}

@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) #ctkCenterPile.is-slamming { animation: none; }
  :where(html:not(.ctk-force-fx)) .ctk-pile-count.is-ticking { animation: none; }
  :where(html:not(.ctk-force-fx)) .ctk-seat-banner.is-travelling { transition: none; }
}

/* Phone tier for the centre. The ~1.75x scale-up above is sized for the
 * ~590px desktop felt; on a ~300px-wide phone felt the same stubs would
 * span half the table and collide with the side seats. Still comfortably
 * bigger than the 34x48 stubs this replaced. */
@media (max-width: 600px) {
  #battleArena { --ctk-pile-h: 68px; }
  #ctkTableCenter { gap: 14px; }
  .ctk-deck-stub { width: 52px; }
  .ctk-deck-card, .ctk-discard-stub { width: 44px; height: 62px; }
  .ctk-deck-b2 { transform: translate(7px, 5px) rotate(3deg); }
  .ctk-deck-b1 { transform: translate(3px, 3px) rotate(-2deg); }
  .ctk-pile-fan { width: 60px; height: 78px; }
  .ctk-pile-card { width: 44px; height: 62px; }
  .ctk-pile-g1 { transform: translate(2px, 5px) rotate(-9deg); }
  .ctk-pile-g2 { transform: translate(13px, 3px) rotate(8deg); }
  .ctk-pile-face { left: 7px; }
  .ctk-pile-glyph { font-size: 24px; }
  .ctk-pile-name { font-size: 12px; max-width: 40px; }
  .ctk-pile-count { bottom: 4px; }
}

/* Always-on draw/discard counts on the center table — a card-flow readout to
   help track the card-identity / phantom-discard family. The engine serializes
   deckCount/discardCount to every viewer, so this needs no dev gating. */
/* Seated ON the card rather than straddling its bottom edge — at the old
 * -7px the chip half-hung off a 48px stub; on an 84px one it just looks
 * detached. */
.ctk-pile-count {
  position: absolute; left: 50%; bottom: 6px; transform: translateX(-50%);
  min-width: 15px; padding: 0 4px; text-align: center;
  font-family: 'VT323', monospace; font-size: 15px; line-height: 16px; color: #f0d890;
  background: rgba(10,6,3,0.92); border: 1px solid #6b5530; border-radius: 6px;
  box-shadow: 0 1px 3px rgba(0,0,0,0.6); pointer-events: none;
}

/* All players ALWAYS in one row, even on phones — SanGuoSha needs at-a-glance
 * read of every opponent. The CSS overrides the Tailwind flex-wrap on the
 * #players markup and lets panels shrink to fill the available row. */
#players {
  flex-wrap: nowrap !important;
  gap: 4px !important;
  justify-content: center;  /* centers when panels hit max-width with row to spare */
  align-items: stretch;
  overflow: hidden;         /* clip any rounding overflow */
}
#players > .player-panel {
  flex: 1 1 0;
  min-width: 0;            /* allow shrink below content min */
  max-width: 180px;
  container-type: inline-size;  /* enables @container queries below */
}

/* Tier-2 compaction: when a panel's own width drops below 110px, shrink
 * fonts + tighten padding. The hero-SKILL chip LABELS used to collapse here
 * too; they now hold on until the lower tier below (status chips are already
 * icon-only at any width, so the meta row is the only labelled chip left and
 * can afford the extra room). */
@container (max-width: 110px) {
  /* Identity (bot/you/human) is carried by the anchored corner badge below
   * full width — hide the duplicate labeled chip so the meta row keeps room
   * for hero-skill chips and the icon-only corner marker reads as canonical. */
  .player-panel .px-badge.identity-chip { display: none; }
  .player-panel .player-bottom-overlay { padding: 2px 3px 3px 3px; }
  .player-panel .player-bottom-overlay > div { font-size: 10px; line-height: 1.1; }
  /* Hierarchy repair (art audit 2026-08): at the ~108px ring width this tier
   * is ALWAYS on, and it used to drop the hero name to 10px while lifting
   * the seat caption to 12px — the card's most important label rendered
   * smaller than its least. Both re-specified here at (0,3,0) so they beat
   * the `> div` collapse above rather than fighting it from the base rules. */
  .player-panel .player-bottom-overlay .hero-name { font-size: 12px; }
  .player-panel .hand-count { font-size: 11px; }
  .player-panel .hand-stack svg { width: 11px; height: 11px; }
  /* One more step down for the status glyphs on a crowded panel. At the
   * 96px floor (a short desktop window with 8 seats) 11px glyphs still let
   * a large kit wrap to a third row and push the hero name off the top of
   * the art; 10px at a 3px gap keeps even a maximum kit to two rows.
   * The `#players` prefix is REQUIRED, not decoration: the base rule this
   * overrides is `#players .player-bottom-overlay .status-row .px-badge svg`
   * (1,3,1), so a container-tier rule written without an id — however many
   * classes it carries — loses to it and silently never applies. */
  #players .player-panel .player-bottom-overlay .status-row .px-badge svg { width: 10px; height: 10px; }
  #players .player-panel .player-bottom-overlay .status-row { gap: 3px; }
  .player-panel .seat-row { font-size: 11px; margin-top: 2px; }
  /* Narrow panel: abbreviate the seat caption "Seat N" / "ที่นั่ง N" -> "#N"
     to free width for the ellipsizing handle. font-size:0 collapses the real
     text (kept in the DOM for screen readers + the seat-row-handle test) and
     the pseudo rebuilds "#N" from data-seat. Same idiom as the Lord crown +
     icon-only chips above. Fires on ANY crowded panel (10-seat desktop
     included), since crowding — not screen size — is what squeezes the row. */
  /* The DYING / ELIMINATED stamps are sized for a full-width panel; on a
   * ring token they ran past both card edges and were overlapped by the
   * neighbouring seat. Scaled to fit the token they belong to. */
  .player-panel.player-dead::before,
  .player-panel.seat-dying::after,
  .player-panel.seat-defiant::after {
    font-size: calc(11px * min(var(--ctk-fx-scale), 1.3)); letter-spacing: 0.5px; max-width: 96%;
    overflow: hidden; white-space: nowrap;
    border-width: 2px; box-shadow: 0 2px 0 rgba(20,0,0,0.6);
  }
  .player-panel.player-dead::before { padding: calc(1px * min(var(--ctk-fx-scale), 1.3)) calc(4px * min(var(--ctk-fx-scale), 1.3)) calc(1px * min(var(--ctk-fx-scale), 1.3)) calc(18px * min(var(--ctk-fx-scale), 1.3)); background-position: calc(4px * min(var(--ctk-fx-scale), 1.3)) center; background-size: calc(11px * min(var(--ctk-fx-scale), 1.3)) calc(11px * min(var(--ctk-fx-scale), 1.3)); }
  .player-panel.seat-dying::after,
  .player-panel.seat-defiant::after { padding: calc(1px * min(var(--ctk-fx-scale), 1.3)) calc(5px * min(var(--ctk-fx-scale), 1.3)); }
  .player-panel .seat-row .seat-row-label { font-size: 0; }
  .player-panel .seat-row .seat-row-label::before {
    content: "#" attr(data-seat); font-size: 12px;
  }
  /* Hide the ⓘ chip-tooltip buttons: too small to tap; native title= still
   * fires on desktop hover. Full-mode (>=110px) keeps tap-to-tooltip. */
  .player-panel .ctk-tip-btn,
  .player-panel .ctk-tip-bubble { display: none !important; }
}

/* Skill-label collapse: the hero-SKILL chips keep their label well past the
 * rest of tier-2 — they're the seat's identity. Only when the panel itself
 * narrows past 96px (the single-line `.flex` meta row is nowrap, so a wider
 * label would clip) do they fall back to icon-only like the status chips.
 * gap:0 + icon shrink mirror the status-chip icon-only treatment. */
@container (max-width: 96px) {
  .player-panel .px-badge { font-size: 0; padding: 0px 2px; gap: 0; }
  .player-panel .px-badge svg { width: 11px; height: 11px; }
}

/* Tier-3 micro: panel < 80px (e.g. iPhone SE landscape with 10 seats).
 * Hide name + hero-name + hand/range so the portrait + HP + corner badge
 * + chip-icon rows + seat label below remain. Reduce HP corner footprint.
 * (.ctk-turn-shine is exempt: the shimmer carrier is absolute inset:0 and
 * takes no row space — micro seats keep their sheen, as they always did
 * when it lived on the panel's ::before.) */
@container (max-width: 80px) {
  .player-panel > div:not(.hero-stage):not(.seat-row):not(.player-hp-corner):not(.player-corner-badge):not(.ctk-turn-shine) {
    display: none;
  }
  .player-panel .player-bottom-overlay .hero-name,
  .player-panel .player-bottom-overlay > div:nth-child(2) {
    display: none;
  }
  /* (The old `top/left` here were left over from a long-dead absolute
     placement and never applied — the HP band is in flow.) */
  .player-panel .player-hp-corner { padding: 0 2px; margin-bottom: 2px; }
  .player-panel .player-hp-corner .hp-pip svg { width: 7px; height: 7px; }
  .player-panel .player-hp-corner .hp-text { font-size: 10px; margin-left: 2px; }
  .player-panel .player-corner-badge { top: 2px; right: 2px; width: 14px; height: 14px; }
  .player-panel .player-corner-badge svg { width: 9px; height: 9px; }
  .player-panel .seat-row { font-size: 11px; margin-top: 1px; }
  .player-panel { padding: 2px !important; }
  /* In micro: kill the rich categorized chip rows entirely; the renderer's
   * pre-built critical strip takes over as the only at-a-glance status. */
  .player-panel .player-bottom-overlay .status-row { display: none; }
  .player-panel .critical-strip {
    display: flex; flex-wrap: wrap; justify-content: center;
    gap: 1px; padding: 1px 2px;
  }
}

/* Player panels are tap-to-expand (opens #playerDetailModal). The cursor
 * is a clarity affordance on desktop; tap is natural on touch. */
.player-panel { cursor: pointer; }

/* Tap-to-expand detail modal — minimal shared backdrop + card classes. */
.ctk-modal-backdrop {
  position: fixed; inset: 0; z-index: 1000;
  background: rgba(0,0,0,0.75);
  display: flex; align-items: center; justify-content: center;
  padding: 16px;
}
.ctk-modal-card {
  position: relative;
  /* background: #1a1208; border: 2px solid #5a4530; border-radius: 6px; */
  /* padding: 32px 16px 16px 16px; */
  max-width: 90vw;
  /* max-height: 90vh; */
  overflow: auto;
  box-shadow: 0 10px 30px rgba(0,0,0,0.6);
}
.ctk-modal-card .player-detail-body { display: flex; justify-content: center; }
/* Inside the player-detail popover the chip tooltips need to escape two
 * clipping ancestors:
 *   1. .hero-stage has overflow:hidden so the rounded image canvas clips
 *      its bottom gradient — but that also clips any .ctk-tip-bubble that
 *      translateY(-100%)s above a chip in .player-bottom-overlay.
 *   2. .ctk-modal-card has overflow:auto so long content can scroll —
 *      but a wide tooltip on a left-edge chip would just be clipped here.
 * Switch both to overflow:visible inside the popover and add explicit
 * border-radius to the image + bottom gradient so the visual rounding
 * isn't lost when the parent clip is removed. */
#playerDetailModal .ctk-modal-card { overflow: visible; }
#playerDetailModal .hero-stage { overflow: visible; }
#playerDetailModal .hero-stage .hero-portrait { border-radius: 6px; }
#playerDetailModal .hero-stage-fade-bottom { border-radius: 0 0 6px 6px; }
/* Player-detail close button. On desktop it floats in the gutter to the
   right of the compact 320px card; on a phone the card fills the width so
   that gutter lands off-screen — dock it inside the card's top-right
   corner instead. z-index lifts it above the cloned player-panel it
   overlays (the button precedes .player-detail-body in the DOM). */
#playerDetailModal [data-close-detail] { right: -55px; z-index: 20; }
@media (max-width: 640px) {
  #playerDetailModal [data-close-detail] { right: 4px; }
}

/* ===== Branded landing + how-to / settings modals ===== */
.ctk-landing {
  position: fixed; inset: 0; z-index: 1300;
  display: flex;
  flex-direction: column; align-items: center;
  padding: calc(env(safe-area-inset-top,0px) + 12px) 16px calc(env(safe-area-inset-bottom,0px) + 12px);
  overflow-y: auto;
  background:
    radial-gradient(ellipse at center, rgba(40,18,8,0.97) 0%, rgba(8,4,2,0.99) 72%),
    repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0px, rgba(0,0,0,0.18) 1px, transparent 1px, transparent 3px);
  color: #f0d890; font-family: 'VT323', monospace; image-rendering: pixelated;
}
html[lang="th"] .ctk-landing { font-family: 'Bai Jamjuree', sans-serif; }
.ctk-landing-top {
  width: 100%; max-width: 560px; flex: 0 0 auto;
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.ctk-landing-util { display: flex; gap: 6px; }
.ctk-landing-main {
  flex: 1 0 auto; display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: 14px; text-align: center;
  width: 100%; max-width: 460px; padding: 16px 0;
}
.ctk-landing-logo {
  width: min(58vw, 220px); height: auto; image-rendering: pixelated;
  filter: drop-shadow(0 4px 14px rgba(0,0,0,0.6));
}
.ctk-landing-tagline { font-size: 19px; line-height: 1.35; color: #e8dcc8; max-width: 24em; }
html[lang="th"] .ctk-landing-tagline { font-size: 16px; }
.ctk-landing-cta {
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  width: 100%; max-width: 320px; margin-top: 4px;
}
.ctk-landing-play { width: 100%; font-size: 20px; padding: 12px 18px; letter-spacing: 0.04em; }
.ctk-landing-or { color: #9a8a70; font-size: 14px; letter-spacing: 0.1em; }
.ctk-landing-signin-note { color: #9a8a70; font-size: 13px; line-height: 1.4; max-width: 28em; }
.ctk-landing-foot {
  width: 100%; max-width: 460px; flex: 0 0 auto;
  display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 8px;
}
.ctk-landing-community { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.ctk-landing-community .px-btn { text-decoration: none; }
/* "Get it on Google Play" (#ctkPlayBadge) — a store lockup, not another
   community chip. It used to be a third .px-btn in the row above and read as
   one; full width and first in .ctk-landing-foot is the whole point of the
   restyle. Two-line label (translated eyebrow over the untranslated wordmark)
   so it reads as a store button at a glance. */
.ctk-play-badge {
  width: 100%; box-sizing: border-box;
  display: flex; align-items: center; justify-content: center; gap: 10px;
  padding: 8px 18px;
  background: linear-gradient(180deg, #26201a 0%, #16110d 100%);
  border: 2px solid #6b5530; border-radius: 8px;
  color: #f0e0c0; text-decoration: none;
  box-shadow: inset 0 1px 0 rgba(200,170,120,0.16);
}
.ctk-play-badge:hover, .ctk-play-badge:focus-visible {
  border-color: #a78a4a; color: #ffeec8; outline: none;
}
.ctk-play-badge svg { flex: 0 0 auto; image-rendering: pixelated; }
.ctk-play-badge-txt { display: flex; flex-direction: column; line-height: 1.08; text-align: left; }
.ctk-play-badge-sup { font-size: 10px; letter-spacing: 0.16em; color: #b9a888; text-transform: uppercase; }
.ctk-play-badge-main { font-size: 19px; letter-spacing: 0.02em; }
.ctk-landing-trust { color: #c8a24a; font-size: 13px; letter-spacing: 0.03em; }

.ctk-sheet-title {
  font-size: 20px; font-weight: bold; color: #f0d890; text-align: center;
  margin-bottom: 12px; font-family: 'VT323', monospace; letter-spacing: 0.04em;
}
html[lang="th"] .ctk-sheet-title { font-family: 'Bai Jamjuree', sans-serif; }
/* Above the landing (z-index 1300) so they're visible when opened from it. */
#ctkHowTo, #ctkSettings { z-index: 1400; }
/* The bug report sits ABOVE every other overlay, deliberately. It inherits
 * .ctk-modal-backdrop's z-index:1000 and open() closes nothing, so with the
 * hand sheet (1080), card inspector (1100), reads (1150), match-end (1200 /
 * 1300) or how-to/settings (1400) up, it opened BEHIND them and its textarea
 * could not be typed into — i.e. it was unusable at exactly the moment you
 * want it, mid-match with something on screen that just went wrong. Raising
 * it (rather than closing the rival) keeps the player's place: dismiss the
 * report and the hand sheet is still open behind it. */
#bugReportModal { z-index: 1500; }
#ctkHowTo .ctk-modal-card, #ctkSettings .ctk-modal-card {
  background: linear-gradient(180deg, #221813 0%, #160f0a 100%);
  border: 2px solid #5a4530; border-radius: 6px; padding: 18px 16px; width: 100%;
  box-shadow: 0 10px 30px rgba(0,0,0,0.6), inset 1px 1px 0 rgba(200,170,120,0.12);
  max-height: 90vh;
}
/* ---- Profile modal (#ctkProfile) ---- a warlord's card. The card keeps the
 * How-to/Settings skin but drops its inner padding: the banner, tiles, hero
 * strip, actions and footer each own their gutters. Capped to the viewport
 * and scrollable so a landscape phone can always reach Sign out (the old
 * card had no max-height). Darker scrim than the shared .75 — the idle
 * board's "No game in progress" hint bled through behind the buttons. */
/* #ctkPlayerCard (another player's read-only card) wears the SAME skin: the two
   are the same object seen from either side, so they share every rule here
   rather than growing a near-copy that drifts. */
#ctkProfile.ctk-modal-backdrop, #ctkPlayerCard.ctk-modal-backdrop { background: rgba(0,0,0,0.86); }
#ctkProfile .ctk-modal-card, #ctkPlayerCard .ctk-modal-card {
  background: linear-gradient(180deg, #221813 0%, #160f0a 100%);
  border: 2px solid #5a4530; border-radius: 6px; padding: 0; width: 100%; max-width: 380px;
  max-height: 90vh; max-height: 90dvh; overflow: auto; outline: none; color: #d8c8a4;
  box-shadow: 0 10px 30px rgba(0,0,0,0.6), inset 1px 1px 0 rgba(200,170,120,0.12);
}
.ctk-pf-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px 0; }
.ctk-pf-title { font-size: 18px; color: #f0d890; font-weight: bold; }
.ctk-pf-banner {
  display: flex; gap: 12px; align-items: center; padding: 12px 16px 14px; border-bottom: 1px solid #4a3828;
  background: radial-gradient(120% 90% at 0% 0%, rgba(240,216,144,0.09), transparent 60%);
}
/* Avatar ring = PvP tier colour (--pf-tier, set by ctkProfileLoadRank), on a
   dark gap so it reads as a medal rim, not a border. */
.ctk-pf-avatar {
  position: relative; width: 64px; height: 64px; border-radius: 50%; flex: none; overflow: hidden;
  background: #2a2218; border: 2px solid var(--pf-tier, #6b5530); box-sizing: border-box;
  box-shadow: 0 0 0 3px #17110a, 0 0 0 4px #5a4530;
}
.ctk-pf-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.ctk-pf-initial { position: absolute; inset: 0; display: grid; place-items: center; font-family: "VT323", monospace; font-size: 30px; color: #f0d890; }
.ctk-pf-ident { min-width: 0; display: grid; gap: 3px; justify-items: start; }
.ctk-pf-nameline { display: flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; }
.ctk-pf-name { font-size: 17px; color: #e8dcc8; font-weight: bold; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctk-pf-email { font-size: 12px; color: #9a8a70; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.ctk-pf-chip { max-width: 100%; min-width: 0; }
.ctk-pf-chip:empty { display: none; }
/* Standing chip (rankChipHtml in client/src/rankings.ts). */
.rk-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px 2px 4px; border: 1px solid #6b5530; background: #241a10; color: #cfd3da; font-size: 13px; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rk-chip.is-note { color: #9a8a70; padding-left: 8px; }
.rk-chip-badge { width: 18px; height: 18px; image-rendering: pixelated; flex: none; }
.rk-chip .stars { display: inline-flex; gap: 1px; }
.ctk-pf-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; padding: 12px 16px 0; }
.ctk-pf-tile { background: #2a2014; border: 1px solid #4a3828; padding: 8px 4px 6px; text-align: center; min-width: 0; }
.ctk-pf-tile .n { font-family: "VT323", monospace; font-size: 28px; line-height: 1; color: #f0d890; font-variant-numeric: tabular-nums; white-space: nowrap; }
.ctk-pf-tile .n.is-good { color: #8fd6a8; }
.ctk-pf-tile .l { color: #9a8a70; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ctk-pf-heroes { display: flex; gap: 8px; padding: 12px 16px 0; align-items: flex-start; }
.ctk-pf-heroes:empty { display: none; }
/* ---- Another player's card (#ctkPlayerCard) --------------------------------
   Rides the .ctk-pf-* skin above; only the parts the read-only card adds live
   here. The avatar box is a plain div (no picker button), so its DiceBear SVG /
   photo <img> need the fill rules the own-profile got from .ctk-pf-avatar img. */
.ctk-pf-avatar > svg,
.ctk-pf-avatar > .ctk-av-photo { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* Fallback when a player has no chosen face: their initial, not a blank medal.
   A Google photo is never an option here — it never leaves its owner's profile. */
.ctk-pc-fallback { position: absolute; inset: 0; display: grid; place-items: center; font-family: "VT323", monospace; font-size: 30px; color: #f0d890; }
/* Loading / "no such player" line under the tiles. Empty = gone, so a loaded
   card has no reserved gap. */
/* The card ends at the hero strip (no actions row / footer like the own
   profile), so the bottom gutter has to come from the card itself. */
#ctkPlayerCard .ctk-modal-card { padding-bottom: 14px; }
/* Loading / not-found: no identity to show, so the banner goes rather than
   standing empty above the message. */
#ctkPlayerCard.ctk-pc-empty .ctk-pf-banner { display: none; }
.ctk-pc-note { padding: 12px 16px 0; font-size: 12px; color: #9a8a70; }
.ctk-pc-note:empty { display: none; }
/* A name that opens a card. Applied to room-chat senders and ladder rows; the
   underline only appears on hover/focus so a chat log is not a field of links. */
.ctk-pc-open { cursor: pointer; }
.ctk-pc-open:hover, .ctk-pc-open:focus-visible { text-decoration: underline; text-underline-offset: 2px; }
.ctk-pc-open:focus-visible { outline: 2px solid #f0d890; outline-offset: 2px; }
.ctk-pf-hero { flex: 1; min-width: 0; text-align: center; font: inherit; font-size: 12px; color: #b8a888; background: none; border: 0; padding: 0; cursor: default; }
.ctk-pf-hero.is-more { cursor: pointer; }
.ctk-pf-hero .md {
  position: relative; width: 44px; height: 44px; border-radius: 50%; margin: 0 auto 4px; overflow: hidden; box-sizing: border-box;
  background: #2a2218; border: 2px solid #6b5530; display: grid; place-items: center;
  font-family: "VT323", monospace; font-size: 18px; color: #f0d890;
}
.ctk-pf-hero.is-more .md { border-style: dashed; color: #9a8a70; font-size: 22px; }
.ctk-pf-hero .nm { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d8c8a4; }
.ctk-pf-hero .wl { color: #8fd6a8; font-variant-numeric: tabular-nums; }
/* Two equal gold actions; when one is gated off (display:none leaves no grid
   item) the other spans the row. */
.ctk-pf-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; padding: 14px 16px 0; }
.ctk-pf-actions:empty { display: none; }
.ctk-pf-btn { width: 100%; padding: 10px 8px; gap: 6px; background: linear-gradient(180deg, #5a4a2a 0%, #3f3318 100%); border-color: #a78a4a; color: #f0e0c0; font-weight: bold; }
.ctk-pf-btn svg { width: 17px; height: 17px; shape-rendering: crispEdges; flex: none; }
.ctk-pf-handle { padding: 12px 16px 0; font-size: 13px; color: #9a8a70; }
.ctk-pf-handle-line { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 32px; }
.ctk-pf-handle-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctk-pf-handle-line b { color: #e8dcc8; font-weight: normal; }
.ctk-pf-handle-line b.is-default { color: #9a8a70; font-style: italic; }
.ctk-pf-edit { padding: 2px 6px; min-width: 32px; min-height: 32px; flex: none; }
.ctk-pf-edit svg { width: 15px; height: 15px; }
.ctk-pf-label { display: block; font-size: 12px; color: #9a8a70; margin: 6px 0 4px; }
/* Footer: the destructive actions are the QUIETEST things on the card. */
.ctk-pf-foot { margin-top: 14px; padding: 12px 16px 14px; border-top: 1px solid #4a3828; display: grid; gap: 8px; }
.ctk-pf-signout { width: 100%; padding: 8px; background: none; box-shadow: none; border-color: #6a2626; color: #c98a8a; font-weight: bold; }
.ctk-pf-links { display: flex; justify-content: space-between; gap: 8px; }
.ctk-pf-links button { background: none; border: 0; padding: 2px 0; color: #9a8a70; font: inherit; font-size: 11px; text-decoration: underline; cursor: pointer; }
.ctk-pf-links button:focus-visible, .ctk-pf-hero.is-more:focus-visible { outline: 2px solid #f0d890; outline-offset: 2px; }
@media (max-width: 360px) {
  .ctk-pf-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* Avatar button (profile banner) + the picker (#avatarPicker): preview / Shuffle / Use this. */
.ctk-pf-avatar-btn { position: relative; background: none; border: 0; padding: 0; margin: 0; flex: none; cursor: pointer; border-radius: 50%; font: inherit; }
.ctk-pf-avatar-btn:focus-visible { outline: 2px solid #f0d890; outline-offset: 5px; }
.ctk-pf-avatar-dice { position: absolute; inset: 0; display: block; }
.ctk-pf-avatar-dice svg { width: 100%; height: 100%; display: block; }
.ctk-pf-avatar-edit { position: absolute; right: -4px; bottom: -4px; width: 22px; height: 22px; border-radius: 50%; background: #3f3318; border: 1px solid #a78a4a; color: #f0e0c0; display: grid; place-items: center; }
.ctk-pf-avatar-edit svg { width: 12px; height: 12px; }
.ctk-pf-avatar-btn[disabled] { cursor: default; }
.ctk-pf-avatar-btn[disabled] .ctk-pf-avatar-edit { display: none; }
#avatarPicker .ctk-modal-card {
  background: linear-gradient(180deg, #221813 0%, #160f0a 100%);
  border: 2px solid #5a4530; border-radius: 6px; padding: 0 0 12px; width: 100%; max-width: 340px;
  max-height: 90vh; max-height: 90dvh; overflow: auto; outline: none; color: #d8c8a4;
  box-shadow: 0 10px 30px rgba(0,0,0,0.6), inset 1px 1px 0 rgba(200,170,120,0.12);
}
.ctk-av-body { padding: 14px 16px 0; display: grid; justify-items: center; gap: 14px; }
/* The preview is the same disc as the banner, three times the size, so the
   face is judged the way it will actually be worn. */
.ctk-av-preview { display: block; width: 128px; height: 128px; border-radius: 50%; overflow: hidden; box-sizing: border-box; background: #2a2218; border: 3px solid #6b5530; box-shadow: 0 0 0 3px #17110a, 0 0 0 4px #5a4530; }
.ctk-av-preview svg { width: 100%; height: 100%; display: block; }
.ctk-av-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The one upload in the game: a full-width row under Shuffle / Use this. */
.ctk-av-upload { width: 100%; gap: 6px; }
.ctk-av-upload svg { width: 16px; height: 16px; shape-rendering: crispEdges; flex: none; }
.ctk-av-note { font-size: 11px; color: #9a8a70; text-align: center; line-height: 1.3; margin-top: -6px; }
.ctk-av-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; width: 100%; }
.ctk-av-actions .px-btn { width: 100%; gap: 6px; }
.ctk-av-actions svg { width: 16px; height: 16px; shape-rendering: crispEdges; flex: none; }
.ctk-av-link { background: none; border: 0; padding: 2px 0; color: #9a8a70; font: inherit; font-size: 12px; text-decoration: underline; cursor: pointer; }
.ctk-av-link:focus-visible { outline: 2px solid #f0d890; outline-offset: 2px; }
.ctk-av-status { padding: 8px 16px 0; font-size: 12px; color: #9a8a70; min-height: 16px; text-align: center; }
.ctk-av-status.is-ok { color: #8fd6a8; }
.ctk-av-status.is-err { color: #e89a8a; }
.ctk-av-status.is-err { color: #e89a8a; }
/* The draft card sizes to its content (header + role badge + "Lord chose X"
   banner + candidate cards with expandable skill text), which on a short or
   portrait viewport — or with the taller Thai fonts — can exceed the screen.
   The centered fixed .ctk-modal-backdrop can't scroll, so without a height cap
   the card's top (title, role badge, sometimes the pick buttons) drifts above
   y=0 and is unreachable. Cap it so the base rule's overflow:auto forms an
   internal scroll box, matching the How-to/Settings modals. */
#draftModal .ctk-modal-card, #draftMount .ctk-modal-card {
  max-height: 90vh; max-height: 90dvh;
}
/* Codex row at the top of the how-to — the full reference the sections below
 * summarise. The count chips inherit the button's ink so they read on the
 * brown .px-btn gradient (the standalone .ctk-codex-total is dimmer). */
.ctk-howto-codex { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 12px; padding: 0 0 10px; border-bottom: 1px solid #4a3828; }
.ctk-howto-codex-lead { font-size: 13px; color: #9a8a70; }
.ctk-howto-codex-btn { padding: 6px 12px; }
.ctk-howto-codex-btn > svg { width: 14px; height: 14px; flex: none; }
.px-btn .ctk-codex-total { color: inherit; border-color: currentColor; opacity: 0.8; margin-left: 2px; }
/* Pointer rows inside the six roster sections (basic / tactic / delayed /
 * equipment / heroes / lord skills): a lead line, then one button per codex
 * filter. The icon is injected by codex-modal.ts decorate(), never inlined in
 * the i18n string. */
.ctk-howto-ptr-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 8px 0 2px; }
.ctk-howto-ptr-lead { flex-basis: 100%; font-size: 13px; color: #9a8a70; }
.ctk-howto-ptr { padding: 6px 10px; min-height: 40px; font-size: 15px; }
.ctk-howto-ptr > svg { width: 14px; height: 14px; flex: none; }
.ctk-howto-body { color: #d4c4a0; font-size: 15px; line-height: 1.5; }
.ctk-howto-body h4 { color: #f0d890; font-weight: bold; margin: 10px 0 4px; font-size: 16px; }
.ctk-howto-body ul, .ctk-howto-body ol { margin: 4px 0 8px; padding-left: 18px; }
.ctk-howto-body li { margin: 3px 0; }
.ctk-howto-body p { margin: 6px 0; }
.ctk-howto-lead { font-size: 15px; margin: 0 0 8px; }
.ctk-howto-note { font-size: 13px; color: #9a8a70; }
/* Collapsible deep-reference sections (the "long version"). Native
 * <details>/<summary> so no JS wiring — the whole reference lives in the
 * DOM and expands on tap. Summary is styled to read like the h4 headers. */
.ctk-howto-body details {
  border: 1px solid #4a3828; border-radius: 5px; margin: 6px 0;
  background: rgba(255,255,255,0.02);
}
.ctk-howto-body details[open] { background: rgba(240,216,144,0.05); }
.ctk-howto-body summary {
  cursor: pointer; padding: 8px 10px; font-weight: bold; color: #f0d890;
  font-size: 15px; list-style: none; -webkit-user-select: none; user-select: none;
  display: flex; align-items: center; gap: 7px;
}
.ctk-howto-body summary::-webkit-details-marker { display: none; }
.ctk-howto-body summary::before {
  content: '\25B8'; color: #b08d57; font-size: 12px; line-height: 1;
  transition: transform 0.15s ease;
}
.ctk-howto-body details[open] > summary::before { transform: rotate(90deg); }
.ctk-howto-body summary:hover { color: #ffe9a8; }
.ctk-howto-sec { padding: 0 12px 10px; }
.ctk-howto-sec > *:first-child { margin-top: 2px; }
.ctk-howto-table { width: 100%; border-collapse: collapse; margin: 6px 0 10px; font-size: 13px; }
.ctk-howto-table th, .ctk-howto-table td { border: 1px solid #4a3828; padding: 2px 6px; text-align: center; }
.ctk-howto-table th { font-weight: bold; }
.ctk-howto-table td:first-child, .ctk-howto-table th:first-child { color: #c8b890; }
.ctk-settings-body { color: #d4c4a0; font-size: 15px; }
.ctk-settings-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px 0; }
.ctk-settings-soon { color: #9a8a70; font-size: 13px; margin-top: 10px; text-align: center; font-style: italic; }
#playerDetailModal .ctk-tip-bubble { z-index: 100; }
.ctk-modal-card .player-detail-body .player-panel {
  cursor: default;
}

/* M3 bottom-sheet card inspector — Balatro-style tap-to-inspect.
 * Long-press (touch) or right-click (desktop) on a hand card opens
 * this sheet with the card's full effect description. Keeps the
 * play-card click path untouched. Sized so it fits within the
 * iPhone-SE landscape (667px) constraint from [[project_sanguo_target]]
 * without obscuring the hand row beneath it. */
.ctk-card-inspector-backdrop {
  position: fixed; inset: 0; z-index: 1100;
  background: rgba(0,0,0,0.55);
  display: none;
  align-items: flex-end; justify-content: center;
  animation: ctk-sheet-fade 0.18s ease-out;
}
.ctk-card-inspector-backdrop.is-open { display: flex; }
@keyframes ctk-sheet-fade {
  from { background: rgba(0,0,0,0); }
  to   { background: rgba(0,0,0,0.55); }
}
@keyframes ctk-sheet-rise {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}
.ctk-card-inspector {
  position: relative;
  width: 100%;
  max-width: 420px;
  max-height: 75vh;
  overflow: auto;
  background: linear-gradient(180deg, #2a2218 0%, #1a1208 100%);
  border: 2px solid #6b5530;
  border-bottom: none;
  box-shadow: 0 -8px 24px rgba(0,0,0,0.6),
              inset 1px 1px 0 rgba(200,170,120,0.18);
  padding: 12px 14px 16px;
  animation: ctk-sheet-rise 0.22s cubic-bezier(0.2, 0.8, 0.2, 1.05);
  color: #e8dcc8;
  image-rendering: pixelated;
  font-family: 'VT323', monospace;
}
.ctk-card-inspector .sheet-drag-handle {
  width: 36px; height: 4px;
  background: #5a4530;
  margin: 0 auto 8px;
}
.ctk-card-inspector .sheet-close {
  position: absolute; top: 2px; right: 2px;
  width: 44px; height: 44px;
  display: inline-flex; align-items: center; justify-content: center;
  background: transparent; color: #c8b890;
  border: 1px solid transparent; cursor: pointer;
  font-family: 'VT323', monospace; font-size: 18px;
}
.ctk-card-inspector .sheet-close:hover { color: #fefcbf; border-color: #5a4530; }
.ctk-card-inspector .sheet-header {
  display: flex; align-items: center; gap: 10px;
  margin: 4px 46px 8px 0;
}
.ctk-card-inspector .sheet-card-art {
  width: 64px; height: 84px;
  display: inline-flex; align-items: center; justify-content: center;
  background: #1a1208;
  border: 2px solid #5a4530;
  box-shadow: inset 1px 1px 0 rgba(200,170,120,0.25),
              inset -1px -1px 0 rgba(0,0,0,0.5);
  flex-shrink: 0;
  position: relative;
  overflow: hidden;
}
.ctk-card-inspector .sheet-card-art img {
  width: 100%; height: 100%; object-fit: cover;
  image-rendering: pixelated;
}
.ctk-card-inspector .sheet-card-art .card-suit-rank {
  position: absolute; top: 2px; left: 3px;
  font-size: 16px; line-height: 1;
}
.ctk-card-inspector .sheet-title-block { flex: 1; min-width: 0; }
.ctk-card-inspector .sheet-title {
  font-family: 'Press Start 2P', monospace;
  font-size: 14px;
  letter-spacing: 0.5px;
  line-height: 1.3;
  color: #f0d890;
  margin: 0 0 4px 0;
  overflow-wrap: anywhere;
}
html[lang="th"] .ctk-card-inspector .sheet-title {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 700;
  letter-spacing: 0;
  font-size: 17px;
}
.ctk-card-inspector .sheet-kind-chip {
  display: inline-block;
  font-size: 13px; line-height: 1;
  padding: 3px 6px;
  border: 1px solid currentColor;
  color: #c8b890;
}
.ctk-card-inspector .sheet-kind-chip.card-attack  { color: #feb2b2; border-color: #c53030; }
.ctk-card-inspector .sheet-kind-chip.card-defense { color: #90cdf4; border-color: #2b6cb0; }
.ctk-card-inspector .sheet-kind-chip.card-peach   { color: #fed7e2; border-color: #c53072; }
.ctk-card-inspector .sheet-kind-chip.card-tactic  { color: #d6bcfa; border-color: #6b46c1; }
.ctk-card-inspector .sheet-kind-chip.card-equip   { color: #fefcbf; border-color: #b7791f; }
.ctk-card-inspector .sheet-kind-chip.card-delay   { color: #e2e8f0; border-color: #4a5568; }
.ctk-card-inspector .sheet-kind-chip.card-negate  { color: #a0aec0; border-color: #3d4758; }
/* Skill chip — tinted by kingdom so the Lord chip is recognizable
 * at a glance. Matches HERO_FALLBACK_TINT shades. */
.ctk-card-inspector .sheet-kind-chip.is-skill {
  display: inline-flex; align-items: center; gap: 4px;
}
.ctk-card-inspector .sheet-kind-chip.kingdom-shu { color: #feb2b2; border-color: #c83030; }
.ctk-card-inspector .sheet-kind-chip.kingdom-wei { color: #90cdf4; border-color: #4a82b5; }
.ctk-card-inspector .sheet-kind-chip.kingdom-wu  { color: #9ae6b4; border-color: #3aa66a; }
.ctk-card-inspector .sheet-kind-chip.kingdom-qun { color: #d6bcfa; border-color: #9858c8; }
.ctk-card-inspector .sheet-lord-crown {
  display: inline-flex; align-items: center;
  width: 14px; height: 14px;
}
.ctk-card-inspector .sheet-lord-crown svg {
  width: 14px; height: 14px;
  image-rendering: pixelated;
}
/* Hero inspector skill list — vertical stack inside .sheet-effect
 * when openHero() rewrites the effect block. One row per skill
 * with the skill name (bolded gold) and the description. Lord-
 * only rows get a leading crown SVG + small "Lord-only" pill.
 * Wraps naturally so long Thai/English descriptions reflow. */
.ctk-card-inspector .sheet-effect .sheet-hero-skill {
  padding: 6px 0;
}
.ctk-card-inspector .sheet-effect .sheet-hero-skill + .sheet-hero-skill {
  border-top: 1px dashed rgba(107, 85, 48, 0.5);
}
.ctk-card-inspector .sheet-effect .sheet-hero-skill-name {
  display: inline-flex; align-items: center; gap: 4px;
  font-family: 'Press Start 2P', monospace;
  font-size: 12px;
  color: #f0d890;
  margin-bottom: 4px;
  letter-spacing: 0.3px;
}
html[lang="th"] .ctk-card-inspector .sheet-effect .sheet-hero-skill-name {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 700;
  letter-spacing: 0;
  font-size: 15px;
}
.ctk-card-inspector .sheet-effect .sheet-hero-skill-lord-tag {
  display: inline-block;
  font-family: 'VT323', monospace;
  font-size: 11px;
  padding: 1px 4px;
  margin-left: 4px;
  border: 1px solid #fbbf24;
  color: #fbbf24;
  letter-spacing: 0;
}
html[lang="th"] .ctk-card-inspector .sheet-effect .sheet-hero-skill-lord-tag {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 600;
  font-size: 12px;
}
.ctk-card-inspector .sheet-effect .sheet-hero-skill-desc {
  color: #d4c4a0;
  font-size: 16px;
  line-height: 1.45;
}
html[lang="th"] .ctk-card-inspector .sheet-effect .sheet-hero-skill-desc {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 500;
  font-size: 14px;
  line-height: 1.55;
}
/* Granted-skill rows (card-inspector.ts grantRowsHtml) — an awakening that
 * hands out other heroes' skills (Ascension -> Gallantry + War Soul) lists
 * each one under its description: name, canonical owner, full tooltip.
 * Each row is a <button> that swaps the sheet to that skill. */
.ctk-card-inspector .sheet-effect .sheet-skill-grants {
  margin-top: 8px;
  padding-top: 6px;
  border-top: 1px dashed rgba(107, 85, 48, 0.5);
}
.ctk-card-inspector .sheet-effect .sheet-skill-grants-label {
  font-family: 'VT323', monospace;
  font-size: 13px;
  color: #a89878;
  letter-spacing: 0.3px;
  margin-bottom: 4px;
}
html[lang="th"] .ctk-card-inspector .sheet-effect .sheet-skill-grants-label {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 600;
  font-size: 12px;
  letter-spacing: 0;
}
.ctk-card-inspector .sheet-effect .sheet-skill-grant {
  display: block;
  width: 100%;
  text-align: left;
  background: rgba(0, 0, 0, 0.18);
  border: 1px solid rgba(107, 85, 48, 0.6);
  border-left: 3px solid #f0d890;
  padding: 6px 8px;
  margin-top: 4px;
  color: inherit;
  font: inherit;
  cursor: pointer;
  border-radius: 0;
}
.ctk-card-inspector .sheet-effect .sheet-skill-grant:active { background: rgba(240, 216, 144, 0.12); }
.ctk-card-inspector .sheet-effect .sheet-skill-grant-name {
  font-family: 'Press Start 2P', monospace;
  font-size: 11px;
  color: #f0d890;
  margin-right: 6px;
}
html[lang="th"] .ctk-card-inspector .sheet-effect .sheet-skill-grant-name {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 700;
  font-size: 14px;
}
.ctk-card-inspector .sheet-effect .sheet-skill-grant-owner {
  font-family: 'VT323', monospace;
  font-size: 13px;
  color: #a89878;
}
html[lang="th"] .ctk-card-inspector .sheet-effect .sheet-skill-grant-owner {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 500;
  font-size: 12px;
}
.ctk-card-inspector .sheet-effect .sheet-skill-grant-desc {
  display: block;
  margin-top: 3px;
  color: #d4c4a0;
  font-size: 14px;
  line-height: 1.45;
}
html[lang="th"] .ctk-card-inspector .sheet-effect .sheet-skill-grant-desc {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 500;
  font-size: 13px;
  line-height: 1.55;
}

/* Role inspector — the role icon (crown/shield/sword/eye) renders
 * large in the art slot, tinted to match the chip color so the
 * visual identity stays consistent with the player-panel chip. */
.ctk-card-inspector .sheet-role-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 100%; height: 100%;
}
.ctk-card-inspector .sheet-role-icon svg {
  width: 48px; height: 48px;
  image-rendering: pixelated;
}
.ctk-card-inspector .sheet-effect {
  font-size: 17px;
  line-height: 1.45;
  color: #d4c4a0;
  margin: 8px 0 0 0;
  padding: 8px 10px;
  background: rgba(0,0,0,0.35);
  border-left: 3px solid #6b5530;
}
html[lang="th"] .ctk-card-inspector .sheet-effect {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 500;
  font-size: 15px;
  line-height: 1.55;
}
.ctk-card-inspector .sheet-hint {
  font-size: 13px;
  color: #9a8a70;
  margin-top: 10px;
  text-align: center;
  font-family: 'VT323', monospace;
}
html[lang="th"] .ctk-card-inspector .sheet-hint {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 500;
}

/* Coach marks — M3 feature-discovery tooltips that appear the first
 * time the viewer encounters their role card, hand row, or a turn-
 * start judgment. Shown once per device (localStorage gated), small
 * + non-blocking (no backdrop), with an arrow that anchors them to
 * the target element. Pixel-art aesthetic per
 * [[feedback_pixel_aesthetic]]. */
@keyframes ctk-coach-rise {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}
@keyframes ctk-coach-pulse {
  0%, 100% { box-shadow: 0 4px 14px rgba(0,0,0,0.55), inset 1px 1px 0 rgba(200,170,120,0.25), 0 0 0 0 rgba(251,191,36,0); }
  50%      { box-shadow: 0 4px 14px rgba(0,0,0,0.55), inset 1px 1px 0 rgba(200,170,120,0.25), 0 0 0 4px rgba(251,191,36,0.18); }
}
.ctk-coach-mark {
  position: fixed;
  z-index: 1050;
  max-width: 280px;
  min-width: 200px;
  padding: 10px 12px 8px;
  background: linear-gradient(180deg, #2a2218 0%, #1a1208 100%);
  border: 2px solid #fbbf24;
  color: #e8dcc8;
  font-family: 'VT323', monospace;
  font-size: 15px;
  line-height: 1.4;
  animation: ctk-coach-rise 0.22s cubic-bezier(0.2, 0.8, 0.2, 1.05),
             ctk-coach-pulse 2.4s ease-in-out 0.5s infinite;
  image-rendering: pixelated;
  /* The mark's own surface does not swallow board clicks; only its CONTROLS
   * do (re-enabled below). This matters now that the hand trigger is centred
   * under the viewer's own seat: the "your hand lives here" mark is placed
   * ABOVE its anchor, which lands it squarely on that seat card, and while
   * it was up the seat's own equipment chips could not be clicked at all.
   * "Got it" / "Skip" / "Next" keep working, and outside-click-to-dismiss is
   * unaffected — the document listener still sees a click whose target is
   * outside the mark, which is exactly what it already treats as outside. */
  pointer-events: none;
}
.ctk-coach-mark-actions,
.ctk-coach-mark-actions-split,
.ctk-coach-mark-dismiss,
.ctk-coach-mark-next,
.ctk-coach-mark-skip { pointer-events: auto; }
html[lang="th"] .ctk-coach-mark {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 500;
  font-size: 14px;
}
.ctk-coach-mark::before {
  content: '';
  position: absolute;
  left: var(--arrow-x, 50%);
  transform: translateX(-50%);
  width: 0; height: 0;
  border-left: 8px solid transparent;
  border-right: 8px solid transparent;
}
.ctk-coach-mark.arrow-bottom::before {
  bottom: -10px;
  border-top: 10px solid #fbbf24;
}
.ctk-coach-mark.arrow-bottom::after {
  content: '';
  position: absolute;
  left: var(--arrow-x, 50%);
  transform: translateX(-50%);
  bottom: -7px;
  width: 0; height: 0;
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
  border-top: 8px solid #1a1208;
}
.ctk-coach-mark.arrow-top::before {
  top: -10px;
  border-bottom: 10px solid #fbbf24;
}
.ctk-coach-mark.arrow-top::after {
  content: '';
  position: absolute;
  left: var(--arrow-x, 50%);
  transform: translateX(-50%);
  top: -7px;
  width: 0; height: 0;
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
  border-bottom: 8px solid #2a2218;
}
.ctk-coach-mark-body { margin-bottom: 6px; }
.ctk-coach-mark-actions {
  display: flex; justify-content: flex-end; gap: 6px;
}
.ctk-coach-mark-dismiss {
  background: linear-gradient(180deg, #fbbf24 0%, #b7791f 100%);
  color: #1a1208;
  border: 1px solid #b7791f;
  padding: 2px 10px;
  font-family: 'VT323', monospace;
  font-size: 14px;
  cursor: pointer;
  letter-spacing: 0.5px;
}
html[lang="th"] .ctk-coach-mark-dismiss {
  font-family: 'Bai Jamjuree', sans-serif;
  font-weight: 600;
}
.ctk-coach-mark-dismiss:hover {
  background: linear-gradient(180deg, #fde047 0%, #d97706 100%);
}
/* Guided-tutorial extras: N-of-M progress pips + a Skip link + a Next
 * button, layered onto the same coach-mark shell. Language-neutral dots
 * dodge the "Step 3 of 8" translation-length trap. */
.ctk-coach-pips {
  display: flex; gap: 4px; justify-content: center; margin: 2px 0 8px;
}
.ctk-coach-pip {
  width: 6px; height: 6px; background: #5a4a2a;
  box-shadow: 0 0 0 1px #3a2e18; image-rendering: pixelated;
}
.ctk-coach-pip.on { background: #fbbf24; box-shadow: 0 0 0 1px #b7791f; }
.ctk-coach-mark-actions-split { justify-content: space-between; align-items: center; }
.ctk-coach-mark-next {
  background: linear-gradient(180deg, #fbbf24 0%, #b7791f 100%);
  color: #1a1208; border: 1px solid #b7791f; padding: 2px 12px;
  font-family: 'VT323', monospace; font-size: 14px; cursor: pointer; letter-spacing: 0.5px;
}
.ctk-coach-mark-next:hover { background: linear-gradient(180deg, #fde047 0%, #d97706 100%); }
.ctk-coach-mark-skip {
  background: none; border: none; color: #9a8560; padding: 2px 4px;
  font-family: 'VT323', monospace; font-size: 13px; cursor: pointer; text-decoration: underline;
}
.ctk-coach-mark-skip:hover { color: #e8dcc8; }
html[lang="th"] .ctk-coach-mark-next,
html[lang="th"] .ctk-coach-mark-skip {
  font-family: 'Bai Jamjuree', sans-serif; font-weight: 600;
}

/* Match-end Act 1 — Sanguosha-style role reveal + hero salute.
 * Sits above #cardInspector (z-index 1200). Triggered by the
 * rising edge of game.finished in render(). Skippable; on
 * Continue, hands off to Act 2 (M3 result sheet, TBD). */
/* ===== Act 0 — "The Mandate Breaks" (client/src/match-end-act0.ts) ==========
 * The 840ms beat in the gap render() already left between the last board frame
 * and Act 1. The Heirloom Seal at the centre of the felt cracks, splits and
 * falls. Verdict-BLIND by design: it is the Mandate passing, not a claim about
 * who lost, so it can never contradict Act 1 (see match-verdict.ts).
 *
 * PERF CONTRACT, written down because this repo has lost frames to exactly this
 * shape of work before (PR 725/763: opacity/transform overlays over a live felt,
 * 52->33fps):
 *   - Six own elements (host, stage, seal, two halves, crack; seven once the rim
 *     spawns) plus two static ::before boxes. Both halves ride ONE shared
 *     keyframe, direction supplied per node via --hx/--hr — the
 *     .ctk-act1-confetti contract.
 *   - THE DOMINANT COST IS NOT THE NODES: it is the 0.32s transform on
 *     #battleArena, which promotes and composites the whole board subtree (felt,
 *     rail, the ::before pennant layer, every seat panel) — the largest layer in
 *     the app, and the same shape as the regressions above. It is acceptable
 *     here ONLY because the beat runs after the match has ended, when the board
 *     has stopped updating and nothing else is competing for the compositor. Do
 *     not copy this pattern into live play.
 *   - NO will-change anywhere. An active transform/opacity animation
 *     self-promotes; will-change only buys the layer sooner, and leaving it off
 *     makes de-promotion exactly bounded by the animation.
 *   - No filter, no backdrop-filter, no box-shadow on an animated node, no
 *     clip-path interpolation. Only transform and opacity animate.
 *   - Every selector below is single-class and matches ZERO elements during a
 *     live match, so nothing here can invalidate a board subtree.
 * The host is body-level fixed, NOT a child of #battleArena: the arena is
 * position:relative and so its own stacking context, where z-index 1180 would
 * rank below #readsModal (1150) and bury the beat. 1180 sits under Act 1's 1200. */
.ctk-act0-host {
  /* --act0-k scales EVERY duration below in step with window.__ctkAct0Ms, which
   * scales the module's JS timers. Without it the two drift apart the moment the
   * override is anything but the 840ms default: the CSS would finish at 720ms
   * while the handoff waited, so a slowed beat for playtesting would show an
   * empty stage. The module sets it; 1 is the default beat. */
  position: fixed; inset: 0; z-index: 1180;
  pointer-events: none;            /* never eats a tap meant for the board */
  /* No `contain` here on purpose: layout containment does nothing for a fixed
   * box, the paint clip would be the whole viewport, z-index already makes the
   * stacking context, and there are no fixed descendants to re-parent. */
}
.ctk-act0-stage {
  position: absolute; inset: 0;
  transform-origin: 50% 50%;
}
/* The shake lives on the STAGE and on #battleArena — never on <body>. A
 * transform on body makes it the containing block for position:fixed children,
 * and Act 1's backdrop is fixed: on tap-to-skip the scrim would resolve inset:0
 * against the document box and pin to the document top on a scrolled phone.
 * #battleArena has no fixed descendants, so it is safe to transform. Reuses the
 * existing ctkScreenShake keyframes; cleared by the module's own timer AND by
 * destroy(), so a skip can never leak it. */
#battleArena.ctk-act0-shake,
.ctk-act0-stage.ctk-act0-shake { animation: ctkScreenShake calc(0.32s * var(--act0-k, 1)) ease-in-out; }

.ctk-act0-seal {
  position: absolute;
  left: var(--seal-x, 50%); top: var(--seal-y, 43%);
  width: 56px; height: 56px; margin: -28px 0 0 -28px;
  /* Scaled explicitly: the fx-scale registry only reaches #battleArena > .ctk-*,
   * and this host is body-level. The var lives on documentElement. */
  scale: var(--ctk-fx-scale, 1);
}
/* The two halves of the Heirloom Seal. ONE keyframe, direction per node.
 * Each half draws its own edge of the carved face via a static ::before (a plain
 * 1px border, no blur, no box-shadow) so the object reads as an imperial seal
 * rather than a gold slab — and so the split runs THROUGH the carving, which is
 * what makes the break legible at 56px. The pseudo-element never animates; it
 * rides the parent's transform, costing nothing extra. */
.ctk-act0-half {
  position: absolute; top: 0; left: 0;
  width: 28px; height: 56px;
  background: linear-gradient(150deg, #f7e6b4 0%, #d8a838 38%, #8a6a20 100%);
  border: 2px solid #57410f;
  box-sizing: border-box;
  image-rendering: pixelated;
  animation: ctk-act0-half-fall calc(0.54s * var(--act0-k, 1)) cubic-bezier(0.36, 0, 0.7, 1) calc(0.18s * var(--act0-k, 1)) both;
}
.ctk-act0-half::before {
  content: ''; position: absolute;
  top: 6px; bottom: 6px; left: 6px; right: 0;
  border: 1px solid rgba(87, 65, 15, 0.75);
  border-right: 0;
}
.ctk-act0-half:nth-child(2) { left: 28px; }
.ctk-act0-half:nth-child(2)::before { left: 0; right: 6px; border-right: 1px solid rgba(87,65,15,0.75); border-left: 0; }
@keyframes ctk-act0-half-fall {
  0%   { transform: translate(0, 0) rotate(0deg); opacity: 1; }
  30%  { transform: translate(calc(var(--hx) * 0.45), -5px) rotate(calc(var(--hr) * 0.5)); opacity: 1; }
  100% { transform: translate(var(--hx), 52px) rotate(var(--hr)); opacity: 0; }
}
/* The crack: a hairline that splits the seal top-down before the halves move. */
.ctk-act0-crack {
  position: absolute; left: 26px; top: -4px;
  width: 4px; height: 78px;
  background: linear-gradient(180deg, #fff6d8 0%, #f0d890 40%, transparent 100%);
  transform-origin: 50% 0;
  animation: ctk-act0-crack-split calc(0.24s * var(--act0-k, 1)) ease-out both;
}
@keyframes ctk-act0-crack-split {
  0%   { transform: scaleY(0.04); opacity: 0; }
  35%  { opacity: 0.95; }
  100% { transform: scaleY(1); opacity: 0.3; }
}
/* Expanding rim — opacity + transform only. Deliberately NOT vizShockRing,
 * whose .ctk-shock-ring animates border-width and repaints on the main thread
 * for its whole life. */
.ctk-act0-rim {
  position: absolute;
  left: var(--seal-x, 50%); top: var(--seal-y, 43%);
  width: 30px; height: 30px; margin: -15px 0 0 -15px;
  border: 2px solid #e6d2a0; border-radius: 50%;
  animation: ctk-act0-rim-out calc(0.42s * var(--act0-k, 1)) ease-out both;
}
@keyframes ctk-act0-rim-out {
  0%   { transform: scale(0.4); opacity: 0.85; }
  100% { transform: scale(4.6); opacity: 0; }
}
/* Reduced motion / FX off: the seal is rendered ALREADY BROKEN and simply held.
 * The module still fires the sting and still holds the beat, so the match end is
 * marked — a player who asked for less movement did not ask for no feedback. */
.ctk-act0-host.is-static .ctk-act0-half,
.ctk-act0-host.is-static .ctk-act0-crack { animation: none; }
.ctk-act0-host.is-static .ctk-act0-half { transform: translate(var(--hx), 10px) rotate(var(--hr)); opacity: 0.5; }
.ctk-act0-host.is-static .ctk-act0-crack { transform: scaleY(1); opacity: 0.3; }
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-act0-half,
  :where(html:not(.ctk-force-fx)) .ctk-act0-crack,
  :where(html:not(.ctk-force-fx)) .ctk-act0-rim,
  :where(html:not(.ctk-force-fx)) #battleArena.ctk-act0-shake,
  :where(html:not(.ctk-force-fx)) .ctk-act0-stage.ctk-act0-shake { animation: none; }
  /* The broken POSE is repeated here, not left to .is-static alone. .is-static is
   * applied from JS off prefersReducedMotion(), which returns false if matchMedia
   * throws and cannot react to the preference flipping mid-beat — and in either
   * divergence the rules above would kill the animations while JS never added the
   * class, leaving an intact gold rectangle sitting on the felt for the whole
   * beat. Belt and braces, cheap. */
  :where(html:not(.ctk-force-fx)) .ctk-act0-half {
    transform: translate(var(--hx), 10px) rotate(var(--hr)); opacity: 0.5;
  }
  :where(html:not(.ctk-force-fx)) .ctk-act0-crack { transform: scaleY(1); opacity: 0.3; }
}

/* ===== The farewell hold (body.ctk-act0-farewell) ==========================
 * The few seconds after the seal falls in which the LIVE board is held and
 * people say gg, before Act 1 takes the screen.
 *
 * (1) THE EMOTE BAR NEEDS A POSITIVE RULE. `.ctk-emote-bar` is display:none at
 *     rest and the ONLY thing that reverses it is `body.ctk-emote-ready`, which
 *     updateEmoteBar sets from `!game.finished` — false for the whole hold. So
 *     narrowing the `body.ctk-match-over` hide below achieves nothing on its own;
 *     it only removes a redundant second display:none. This rule is what actually
 *     puts the strip (Logs / Reads / Chat + unread badge) back on screen, and it
 *     sits after both so it wins on order at equal specificity.
 * (2) THE ROLE-CHIP SPOILER CARVE, correctly scoped. The server reveals every
 *     role on the wire the moment the match is finished. On the BOARD that leaks
 *     nothing — `#players .seat-row .role-chip { display: none }` already hides
 *     non-Lord chips unconditionally, finished or not — so the carve is NOT what
 *     an earlier draft of this comment claimed. What it actually protects is the
 *     one chip that lives OUTSIDE #players: the clone in the player-detail modal,
 *     which is reachable during the hold and would otherwise print a hidden role
 *     before Act 1's flip cascade. The Lord is public all match and stays.
 *     visibility (not display) so nothing reflows as the hold ends, and it takes
 *     the aria-label out of the a11y tree with it.
 *
 *     NOT covered, deliberately: the Scoreboard FAB stays on screen (it is the
 *     rematch ballot's announce surface in the next increment) and prints every
 *     role in Act 2 — but it says "Scoreboard", so a tap is a player asking for
 *     exactly that, and it ends the hold on the way.
 *
 * The two rules themselves live beside the ones they must beat — search
 * body.ctk-act0-farewell in the @media all block further down. */

/* The hold's drain: one element, one compositor-only transform transition,
 * re-seeded from the time actually remaining on every extension. No rAF, no
 * interval — two style writes per extension. Pinned to the bottom edge inside
 * the (pointer-events:none) Act 0 host so it is coupled to no layout. */
.ctk-act0-drain {
  position: absolute; left: 0; bottom: 0;
  width: 100%; height: 2px;
  background: linear-gradient(90deg, #c99a2e, #f0d890);
  transform-origin: 0 50%;
  transform: scaleX(1);
  /* Deliberately NOT dropped under prefers-reduced-motion: it is the only signal
   * of how much of the goodbye window is left, and a linear bar is not the kind
   * of motion that preference is about. */
}
.ctk-act1-backdrop {
  position: fixed; inset: 0; z-index: 1200;
  display: none;
  align-items: center; justify-content: center;
  background:
    radial-gradient(ellipse at center, rgba(40,18,8,0.92) 0%, rgba(8,4,2,0.97) 70%),
    repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0px, rgba(0,0,0,0.18) 1px, transparent 1px, transparent 3px);
  font-family: 'VT323', monospace;
  color: #f0d890;
  image-rendering: pixelated;
  overflow: hidden;
  animation: ctk-act1-fade 0.25s ease-out;
}
.ctk-act1-backdrop.is-open { display: flex; }
.ctk-act1-backdrop.tint-victory {
  background:
    radial-gradient(ellipse at center, rgba(70,42,12,0.92) 0%, rgba(20,8,2,0.97) 70%),
    repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0px, rgba(0,0,0,0.18) 1px, transparent 1px, transparent 3px);
}
.ctk-act1-backdrop.tint-defeat {
  background:
    radial-gradient(ellipse at center, rgba(70,18,18,0.92) 0%, rgba(20,4,4,0.97) 70%),
    repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0px, rgba(0,0,0,0.18) 1px, transparent 1px, transparent 3px);
}
@keyframes ctk-act1-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.ctk-act1-stage {
  position: relative;
  /* Own stacking context so the entrance bloom flash (a backdrop child at
   * z-index 2) layers cleanly OVER the stage content but under the skip
   * button (z-index 5). */
  z-index: 1;
  width: 100%; max-width: 960px;
  padding: 24px 16px;
  display: flex; flex-direction: column;
  align-items: center; gap: 12px;
}
.ctk-act1-banner {
  text-align: center;
  /* position + numeric z-index establish a stacking context so the
   * shockwave ring (a banner child at z-index -1) paints ABOVE the banner
   * gradient but BEHIND the verdict text, framing the word. */
  position: relative; z-index: 0;
  padding: 8px 18px;
  background: linear-gradient(180deg, rgba(120,82,18,0.6) 0%, rgba(64,40,8,0.6) 100%);
  border: 2px solid rgba(251,191,36,0.7);
  box-shadow:
    inset 1px 1px 0 rgba(252,211,77,0.25),
    inset -1px -1px 0 rgba(0,0,0,0.45),
    0 0 24px rgba(251,191,36,0.35);
  animation: ctk-act1-banner-drop 0.55s var(--ctk-spring) both;
}
.ctk-act1-verdict {
  font-size: 38px; line-height: 1; letter-spacing: 0.06em;
  font-weight: bold;
  color: #fde68a;
  text-shadow: 0 0 8px rgba(251,191,36,0.7), 3px 3px 0 #1a0a02, -1px -1px 0 #1a0a02;
}
html[lang="th"] .ctk-act1-verdict {
  font-family: 'Bai Jamjuree', sans-serif; font-weight: 700; font-size: 32px;
}
.ctk-act1-subtitle {
  margin-top: 4px; font-size: 18px; color: #f0d890;
  text-shadow: 1px 1px 0 #1a0a02;
}
html[lang="th"] .ctk-act1-subtitle { font-family: 'Bai Jamjuree', sans-serif; font-size: 15px; }
/* Result-screen unrated notice: this ranked match was not majority-human and so
 * was credited by nothing. Deliberately quiet — smaller and greyed under the
 * verdict, since it qualifies the result rather than competing with it. */
.ctk-act1-unrated {
  margin-top: 6px; font-size: 13px; line-height: 1.4;
  color: #cfc6b4; opacity: .92;
  text-shadow: 1px 1px 0 #1a0a02;
  max-width: 34ch; margin-left: auto; margin-right: auto;
}
html[lang="th"] .ctk-act1-unrated { font-family: 'Bai Jamjuree', sans-serif; font-size: 12px; }
@keyframes ctk-act1-banner-drop {
  0%   { transform: translateY(-60px) scale(0.85); opacity: 0; }
  60%  { transform: translateY(6px)   scale(1.04); opacity: 1; }
  100% { transform: translateY(0)     scale(1);    opacity: 1; }
}

/* Seat ring — wraps gracefully on narrow screens. */
.ctk-act1-seats {
  display: flex; flex-wrap: wrap; justify-content: center;
  gap: 10px;
  margin: 6px 0;
  perspective: 800px;
}
.ctk-act1-seat {
  position: relative;
  width: 96px;
  /* Portrait + role-card share one width token (--act1-tile) so the role
   * label card always tracks the portrait width at every breakpoint. The
   * mobile shell overrides it to 50px below. Pre-fix the card was a
   * hard-coded 80px while a leaked shell rule shrank the seat to 56px, so
   * the 80px card overflowed its 56px seat and adjacent role labels
   * overlapped. */
  --act1-tile: 80px;
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  /* Default state is VISIBLE — animation-fill-mode: backwards
   * applies the 0% keyframe (opacity: 0, translateY 20px) ONLY
   * during the animation-delay, then the animation runs and
   * naturally ends at opacity: 1. After the animation, we fall
   * back to the rule's declared opacity below (= 1).
   *
   * Pre-fix this rule had `opacity: 0` + `forwards` instead.
   * When JS added the .is-promoted class for the winner, the
   * `animation` property changed to ctk-act1-winner-pop, which
   * (per CSS spec) cancels the previous animation's `forwards`
   * effect — and winner-pop only animates `transform`, not
   * `opacity`, so the seat fell back to the declared
   * `opacity: 0` and the entire tile (portrait + label + role
   * card) became invisible. Chromium happens to retain the
   * outgoing forwards across the animation-name swap, but
   * stricter implementations (Safari, Firefox) reveal the bug —
   * the winning seat's image visibly disappears the moment the
   * promote class lands. With opacity: 1 as the base and a
   * `backwards` fill mode for the entrance, the seat survives
   * any later animation-property changes. */
  opacity: 1;
  animation: ctk-act1-seat-rise 0.4s cubic-bezier(0.22, 1, 0.36, 1) backwards;
  transform-origin: center bottom;
}
@keyframes ctk-act1-seat-rise {
  0%   { transform: translateY(20px) scale(0.92); opacity: 0; }
  100% { transform: translateY(0)    scale(1);    opacity: 1; }
}
.ctk-act1-seat-portrait {
  width: var(--act1-tile); height: var(--act1-tile);
  border: 2px solid #5a4530;
  background: #1a1208;
  display: inline-flex; align-items: center; justify-content: center;
  box-shadow: inset 1px 1px 0 rgba(200,170,120,0.25), inset -1px -1px 0 rgba(0,0,0,0.5);
  position: relative; overflow: visible;
  filter: grayscale(0.4) brightness(0.85);
  transition: filter 0.45s ease, box-shadow 0.45s ease;
}
.ctk-act1-seat-portrait > img,
.ctk-act1-seat-portrait > .hero-portrait {
  width: 100%; height: 100%; object-fit: cover; image-rendering: auto;
}
.ctk-act1-seat.is-revealed .ctk-act1-seat-portrait {
  filter: none;
  box-shadow:
    inset 1px 1px 0 rgba(200,170,120,0.25),
    inset -1px -1px 0 rgba(0,0,0,0.5),
    0 0 16px currentColor;
}
.ctk-act1-seat.is-dead .ctk-act1-seat-portrait { filter: grayscale(1) brightness(0.5); }
.ctk-act1-seat.is-dead.is-revealed .ctk-act1-seat-portrait {
  filter: grayscale(0.85) brightness(0.65);
}
.ctk-act1-seat-label {
  font-size: 13px; color: #c8b890; letter-spacing: 0.03em;
}
.ctk-act1-seat-label .seat-num { color: #f0d890; font-weight: bold; }

/* Role flip-card: 3D flip from back to face. */
.ctk-act1-card-wrap {
  width: var(--act1-tile); height: 28px;
  perspective: 600px;
}
.ctk-act1-card {
  width: 100%; height: 100%;
  position: relative;
  transform-style: preserve-3d;
  transition: transform 0.5s cubic-bezier(0.4, 0.6, 0.3, 1.2);
  transform: rotateY(0deg);
}
.ctk-act1-card.is-flipped { transform: rotateY(180deg); }
.ctk-act1-card .face {
  position: absolute; inset: 0;
  display: inline-flex; align-items: center; justify-content: center;
  border: 2px solid currentColor;
  font-size: 13px; letter-spacing: 0.05em;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  box-shadow: inset 1px 1px 0 rgba(255,255,255,0.12), inset -1px -1px 0 rgba(0,0,0,0.45);
}
.ctk-act1-card .face-back {
  color: #6b5530;
  background:
    repeating-linear-gradient(135deg, #2a1c0a 0px, #2a1c0a 4px, #1a1208 4px, #1a1208 8px);
  font-size: 16px; font-weight: bold;
}
.ctk-act1-card .face-front {
  transform: rotateY(180deg);
  background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.75) 100%);
  gap: 4px;
  padding: 0 4px;
}
.ctk-act1-card .face-front svg { width: 12px; height: 12px; image-rendering: pixelated; }
/* Role name never wraps or spills its card — ellipsis is the backstop if a
 * label is wider than its tile (e.g. a long name on the 50px shell tile). */
.ctk-act1-card .face-front span {
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
}

/* Winner promotion: scales up + adds calligraphy seal stamp. */
.ctk-act1-seat.is-winner.is-promoted {
  animation: ctk-act1-winner-pop 0.6s var(--ctk-spring) forwards;
  z-index: 2;
}
@keyframes ctk-act1-winner-pop {
  /* Belt-and-braces: explicit opacity: 1 throughout so even if
   * a browser cancels the seat-rise forwards effect when the
   * animation-name changes here, the winner tile cannot become
   * invisible. The base .ctk-act1-seat rule above already
   * declares opacity: 1, so this is a redundant guard against
   * any future regression where the base might change. */
  0%   { transform: scale(1);    opacity: 1; }
  60%  { transform: scale(1.32); opacity: 1; }
  100% { transform: scale(1.22); opacity: 1; }
}
.ctk-act1-seat.is-winner.is-promoted .ctk-act1-seat-portrait {
  box-shadow:
    inset 1px 1px 0 rgba(252,211,77,0.4),
    inset -1px -1px 0 rgba(0,0,0,0.6),
    0 0 28px rgba(251,191,36,0.75);
}
/* Team-winner highlight — every seat that shares the win but
 * isn't the single headline winner (Loyalists when Lord wins,
 * fellow Rebels when a Rebel wins).
 *
 * V2: a real (smaller) pop animation in addition to the gold
 * glow, after a user report that just the glow read as "still
 * losing, just less so." The Lord still gets the dramatic
 * scale(1.22) + 勝 seal + drifting motes; teammates get a
 * 1.12 scale + warm glow so a Loyalist looking at their own
 * tile clearly sees they're on the winning side.
 *
 * Animation runs in parallel with the per-seat reveal cascade
 * — added via JS at the same moment .is-promoted lands on the
 * headline winner, so the whole winning team pops together. */
.ctk-act1-seat.is-team-winner.is-promoted {
  animation: ctk-act1-team-winner-pop 0.55s var(--ctk-spring) forwards;
  z-index: 1; /* below the .is-winner.is-promoted (z-index: 2) */
}
@keyframes ctk-act1-team-winner-pop {
  0%   { transform: scale(1);    opacity: 1; }
  55%  { transform: scale(1.18); opacity: 1; }
  100% { transform: scale(1.12); opacity: 1; }
}
.ctk-act1-seat.is-team-winner .ctk-act1-seat-portrait {
  box-shadow:
    inset 1px 1px 0 rgba(252,211,77,0.32),
    inset -1px -1px 0 rgba(0,0,0,0.5),
    0 0 18px rgba(251,191,36,0.6);
}
.ctk-act1-seat.is-team-winner.is-revealed .ctk-act1-seat-portrait {
  /* Override the role-tinted glow from .is-revealed so the team
   * win signal isn't drowned out by the Loyalist-blue shadow. */
  box-shadow:
    inset 1px 1px 0 rgba(252,211,77,0.32),
    inset -1px -1px 0 rgba(0,0,0,0.5),
    0 0 18px rgba(251,191,36,0.6);
}
.ctk-act1-seal {
  position: absolute; right: -10px; top: -10px;
  width: 34px; height: 34px;
  background: #c83030;
  color: #fff7ed;
  border: 2px solid #fbbf24;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 18px; font-weight: bold; letter-spacing: 0;
  text-shadow: 1px 1px 0 #2a0a02;
  box-shadow: 0 0 14px rgba(200,48,48,0.75), inset 1px 1px 0 rgba(255,200,120,0.3);
  transform: rotate(-12deg) scale(0);
  opacity: 0;
  pointer-events: none;
}
.ctk-act1-seat.is-winner.is-promoted .ctk-act1-seal {
  animation: ctk-act1-seal-stamp 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.25s forwards;
}
@keyframes ctk-act1-seal-stamp {
  0%   { transform: rotate(-12deg) scale(1.8); opacity: 0; }
  70%  { transform: rotate(-12deg) scale(0.92); opacity: 1; }
  100% { transform: rotate(-12deg) scale(1);    opacity: 1; }
}

/* Gold motes drifting up from the winner. */
.ctk-act1-mote {
  position: absolute; width: 4px; height: 4px;
  /* Faction-tinted at spawn (--mote-color set per mote in spawnMotes):
   * gold for Lord/Loyalist/FFA, red for Rebels, purple for Spy. */
  background: var(--mote-color, #fbbf24);
  box-shadow: 0 0 6px var(--mote-color, #fbbf24);
  pointer-events: none;
  animation: ctk-act1-mote-rise 1.6s linear forwards;
}
@keyframes ctk-act1-mote-rise {
  0%   { transform: translate(0,0); opacity: 0; }
  20%  { opacity: 1; }
  100% { transform: translate(var(--mx,0px), -120px); opacity: 0; }
}

/* ============================================================
 * Match-end ENTRANCE impact (verdict slam + bloom flash + shockwave
 * ring). Three beats fired on the Act 1 open frame — see showAct1's
 * entrance-FX block. Everything is transform/opacity-only, one added
 * node max per effect, pointer-events:none, and collapses in the
 * reduced-motion block below. VICTORY = fast/bright/springy;
 * DEFEAT = slow/heavy/reluctant and never celebratory.
 * ============================================================ */

/* Verdict TEXT slam — layered on the banner box's existing drop for a
 * double punch (box drops, word crashes down oversized then settles).
 * transform-origin center so the big scale grows symmetrically without
 * shoving the subtitle. Classes added in showAct1. */
.ctk-act1-verdict { transform-origin: center; }
.ctk-act1-verdict.slam-victory {
  animation: ctk-act1-slam-victory 0.6s var(--ctk-spring) both;
}
.ctk-act1-verdict.slam-defeat {
  animation: ctk-act1-slam-defeat 0.9s cubic-bezier(0.7, 0, 0.84, 0) both;
}
@keyframes ctk-act1-slam-victory {
  0%   { transform: translateY(-40px) scale(2.4); opacity: 0; filter: blur(2px); }
  8%   { opacity: 1; }
  52%  { transform: translateY(0) scale(0.86); filter: blur(0); }  /* squash impact */
  72%  { transform: scale(1.08); }                                 /* spring overshoot */
  100% { transform: scale(1); }
}
@keyframes ctk-act1-slam-defeat {
  0%   { transform: translateY(-24px) scale(1.9); opacity: 0; filter: blur(3px); }
  10%  { opacity: 1; }
  62%  { transform: translateY(6px) scale(0.94); filter: blur(0); } /* undershoot below rest */
  100% { transform: translateY(0) scale(1); }                       /* sinks and stills */
}

/* Impact bloom flash — one full-bleed node appended to the backdrop,
 * over the stage content (z-index 2) but under the skip button (5),
 * pointer-events:none. Victory = bright warm gold pop; defeat = slow dim
 * red pall (no scale, no brightness pop). Self-removed at 750ms. */
.ctk-act1-flash {
  position: absolute; inset: 0; z-index: 2;
  pointer-events: none;
}
.ctk-act1-flash.flash-victory {
  background: radial-gradient(ellipse at center,
    rgba(253,230,138,0.85) 0%, rgba(251,191,36,0.35) 40%, transparent 70%);
  animation: ctk-act1-bloom-victory 0.45s ease-out both;
}
.ctk-act1-flash.flash-defeat {
  background: radial-gradient(ellipse at center,
    rgba(120,10,10,0.5) 0%, transparent 65%);
  animation: ctk-act1-bloom-defeat 0.7s ease-out both;
}
@keyframes ctk-act1-bloom-victory {
  0%   { opacity: 0; transform: scale(0.6); }
  18%  { opacity: 1; }
  100% { opacity: 0; transform: scale(1.15); }
}
@keyframes ctk-act1-bloom-defeat {
  0%   { opacity: 0; }
  22%  { opacity: 1; }
  100% { opacity: 0; }
}

/* Shockwave ring — one SQUARE (border-radius:0 keeps it pixel-art) ring,
 * child of the banner painted behind the verdict text (z-index -1). It
 * frames the word and expands outward past the banner. Victory
 * fast/wide/gold; defeat slow/small/reluctant/red. Self-removed at 900ms. */
.ctk-act1-shock {
  position: absolute; left: 50%; top: 50%;
  width: 40px; height: 40px;
  z-index: -1;
  pointer-events: none;
  border-radius: 0;
}
.ctk-act1-shock.shock-victory {
  border: 3px solid rgba(251,191,36,0.9);
  box-shadow: 0 0 12px rgba(251,191,36,0.5);
  animation: ctk-act1-shock-victory 0.55s cubic-bezier(0.15, 0.9, 0.35, 1) both;
}
.ctk-act1-shock.shock-defeat {
  border: 3px solid rgba(232,90,90,0.85);
  box-shadow: 0 0 12px rgba(232,90,90,0.45);
  animation: ctk-act1-shock-defeat 0.85s cubic-bezier(0.5, 0, 0.75, 0) both;
}
@keyframes ctk-act1-shock-victory {
  0%   { transform: translate(-50%,-50%) scale(0.3); opacity: 0.85; }
  100% { transform: translate(-50%,-50%) scale(9);   opacity: 0; }
}
@keyframes ctk-act1-shock-defeat {
  0%   { transform: translate(-50%,-50%) scale(0.3); opacity: 0.8; }
  100% { transform: translate(-50%,-50%) scale(6.5); opacity: 0; }
}

/* Victory confetti — faction-colored pixel squares bursting from the
 * winning seat at the winner-pop (celebration payoff; see spawnConfetti,
 * gated to a viewer WIN + motion-on). Per-piece apex/end/rotation vars
 * are set inline. Arc = burst up-and-out to an apex, then fall past
 * with spin. */
.ctk-act1-confetti {
  position: absolute;
  width: 6px; height: 6px;
  pointer-events: none;
  image-rendering: pixelated;
  will-change: transform, opacity;
  animation: ctk-act1-confetti-fall 1.4s cubic-bezier(0.2, 0.6, 0.4, 1) forwards;
}
@keyframes ctk-act1-confetti-fall {
  0%   { transform: translate(0,0) rotate(0deg); opacity: 0; }
  8%   { opacity: 1; }
  32%  { transform: translate(var(--ax,0), var(--ay,-60px)) rotate(var(--ar,180deg)); opacity: 1; }
  100% { transform: translate(var(--ex,0), var(--ey,130px)) rotate(var(--er,320deg)); opacity: 0; }
}

/* Skip + Continue buttons */
.ctk-act1-skip,
.ctk-act1-continue {
  font-family: 'VT323', monospace;
  font-size: 16px; letter-spacing: 0.05em;
  padding: 6px 14px;
  border: 2px solid #b7791f;
  background: linear-gradient(180deg, #8a6a20 0%, #5a4012 100%);
  color: #fde68a;
  cursor: pointer;
  text-shadow: 1px 1px 0 #1a0a02;
  image-rendering: pixelated;
  box-shadow: inset 1px 1px 0 rgba(252,211,77,0.25), inset -1px -1px 0 rgba(0,0,0,0.45), 0 2px 0 #0e0804;
}
.ctk-act1-skip:hover, .ctk-act1-continue:hover {
  background: linear-gradient(180deg, #b48830 0%, #8a6a20 100%);
}
html[lang="th"] .ctk-act1-skip,
html[lang="th"] .ctk-act1-continue {
  font-family: 'Bai Jamjuree', sans-serif; font-weight: 600;
}
.ctk-act1-skip {
  position: absolute; top: 12px; right: 12px;
  font-size: 14px; padding: 4px 10px;
  z-index: 5;
}
.ctk-act1-continue {
  margin-top: 14px;
  font-size: 18px; padding: 8px 22px;
  opacity: 0;
  animation: ctk-act1-continue-in 0.4s ease forwards;
}
@keyframes ctk-act1-continue-in {
  0%   { transform: translateY(8px); opacity: 0; }
  100% { transform: translateY(0); opacity: 1; }
}

/* Reduced motion: skip dramatic motion but keep the reveal. */
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-act1-backdrop,
  :where(html:not(.ctk-force-fx)) .ctk-act1-banner,
  :where(html:not(.ctk-force-fx)) .ctk-act1-seat,
  :where(html:not(.ctk-force-fx)) .ctk-act1-card,
  :where(html:not(.ctk-force-fx)) .ctk-act1-seat.is-winner.is-promoted,
  :where(html:not(.ctk-force-fx)) .ctk-act1-seal,
  :where(html:not(.ctk-force-fx)) .ctk-act1-continue,
  :where(html:not(.ctk-force-fx)) .ctk-act1-mote,
  /* Entrance-impact effects collapse to their inert end-state: the verdict
   * word snaps to legible rest scale(1); the flash/ring/confetti end at
   * opacity 0 and self-remove. (showAct1 also skips creating the flash/
   * ring/confetti nodes entirely when reduced-motion matches — this is the
   * belt-and-braces CSS half.) */
  :where(html:not(.ctk-force-fx)) .ctk-act1-verdict,
  :where(html:not(.ctk-force-fx)) .ctk-act1-flash,
  :where(html:not(.ctk-force-fx)) .ctk-act1-shock,
  :where(html:not(.ctk-force-fx)) .ctk-act1-confetti { animation-duration: 0.01s !important; transition: none !important; }
}

/* Match-end Act 2 — Material 3 result sheet with Balatro-style
 * count-up stat chips. Triggered by Act 1's Continue callback;
 * sits at z-index 1300 (one above Act 1's 1200). Container is a
 * tonal hero surface that takes over the same dark ink-wash
 * backdrop. Mobile: bottom sheet with drag handle. */
.ctk-act2-backdrop {
  position: fixed; inset: 0; z-index: 1300;
  display: none;
  /* Top-align so the sheet snaps to the top of the viewport (it is now a
   * full-height flex column, so this pins its top edge to y=0). */
  align-items: flex-start; justify-content: center;
  background:
    radial-gradient(ellipse at center, rgba(30,18,12,0.94) 0%, rgba(6,4,2,0.98) 70%),
    repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0px, rgba(0,0,0,0.18) 1px, transparent 1px, transparent 3px);
  font-family: 'VT323', monospace;
  color: #e8dcc8;
  image-rendering: pixelated;
  animation: ctk-act2-fade 0.25s ease-out;
  overflow-y: auto;
  padding: 0;
}
.ctk-act2-backdrop.is-open { display: flex; }
@keyframes ctk-act2-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.ctk-act2-sheet {
  position: relative;
  width: 100%; max-width: 720px;
  /* Flex column, top-aligned. On desktop the height is driven by content and
   * capped, so a short result sheet stays compact at the top while a tall
   * scoreboard scrolls internally; mobile (≤640px) overrides to full height.
   * (vh fallback first, dvh last so it wins on mobile.) */
  box-sizing: border-box;
  display: flex; flex-direction: column;
  height: auto;
  max-height: 92vh; max-height: 92dvh;
  /* visible so the upward-opening dropdown menus aren't clipped at the sheet
   * edges; the scoreboard's own wrapper handles internal scrolling. */
  overflow: visible;
  background: linear-gradient(180deg, #2a2218 0%, #1a1208 100%);
  border: 2px solid #6b5530;
  box-shadow:
    inset 1px 1px 0 rgba(200,170,120,0.18),
    0 12px 32px rgba(0,0,0,0.7),
    0 0 36px rgba(251,191,36,0.15);
  padding: 18px 20px 16px;
  animation: ctk-act2-sheet-in 0.45s cubic-bezier(0.2, 0.8, 0.2, 1.1) both;
}
@keyframes ctk-act2-sheet-in {
  0%   { transform: translateY(24px) scale(0.94); opacity: 0; }
  100% { transform: translateY(0)    scale(1);    opacity: 1; }
}
/* Flex-column children: fixed chrome + the scoreboard as the scroll region.
 * (Base, so the desktop sheet is a top-aligned column whose scoreboard
 * scrolls when content exceeds the height cap.) */
.ctk-act2-header,
.ctk-act2-stats,
.ctk-act2-actions { flex: 0 0 auto; }
.ctk-act2-scoreboard-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; width: 100%; }
/* Phones: full height so the sheet reaches the top of the viewport. Uses a
 * higher-specificity selector than the plain `.ctk-act2-sheet` rules (incl.
 * the @media all viewport-cap block) so it wins regardless of source order;
 * desktop keeps the base content-height/capped behaviour. */
@media (max-width: 640px) {
  .ctk-act2-backdrop .ctk-act2-sheet { height: 100vh; height: 100dvh; }
}
.ctk-act2-header {
  display: flex; align-items: center; gap: 12px;
  padding-bottom: 12px;
  border-bottom: 1px solid rgba(200,170,120,0.18);
}
.ctk-act2-portrait {
  width: 56px; height: 56px;
  border: 2px solid #6b5530;
  background: #1a1208;
  overflow: hidden;
  flex-shrink: 0;
  position: relative;
  box-shadow: 0 0 14px rgba(251,191,36,0.4);
}
.ctk-act2-portrait > img,
.ctk-act2-portrait > .hero-portrait {
  width: 100%; height: 100%; object-fit: cover; image-rendering: auto;
}
.ctk-act2-headline {
  flex: 1; min-width: 0;
}
.ctk-act2-title {
  font-size: 22px; line-height: 1.1;
  color: #fde68a;
  text-shadow: 0 0 6px rgba(251,191,36,0.6), 2px 2px 0 #1a0a02;
  margin: 0 0 4px 0;
  letter-spacing: 0.04em;
}
html[lang="th"] .ctk-act2-title {
  font-family: 'Bai Jamjuree', sans-serif; font-weight: 700; font-size: 18px;
}
.ctk-act2-subtitle {
  font-size: 14px; color: #c8b890;
}
html[lang="th"] .ctk-act2-subtitle { font-family: 'Bai Jamjuree', sans-serif; font-size: 13px; }
.ctk-act2-close {
  position: absolute; top: 2px; right: 2px;
  width: 44px; height: 44px;
  background: transparent; color: #c8b890;
  border: 1px solid transparent; cursor: pointer;
  font-family: 'VT323', monospace; font-size: 18px;
  display: inline-flex; align-items: center; justify-content: center;
}
.ctk-act2-close:hover { color: #fde68a; border-color: #6b5530; }

/* Stat chip grid — M3 tonal chips. Auto-fit to viewport width.
 * These are the "meta" stats (turns / cards / damage / seats), NOT the
 * bragging content (MVP + per-seat scoreboard below), so they're kept
 * deliberately compact — a lean stat strip, not a set of hero cards. */
.ctk-act2-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
  gap: 6px;
  margin: 10px 0 8px;
}
.ctk-act2-chip {
  display: flex; align-items: baseline; justify-content: center; gap: 6px;
  flex-wrap: wrap;
  background: linear-gradient(180deg, rgba(120,82,18,0.28) 0%, rgba(64,40,8,0.18) 100%);
  border: 1px solid rgba(200,170,120,0.35);
  padding: 4px 8px;
  box-shadow:
    inset 1px 1px 0 rgba(200,170,120,0.18),
    inset -1px -1px 0 rgba(0,0,0,0.35);
  opacity: 0;
  transform: translateY(8px);
  animation: ctk-act2-chip-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes ctk-act2-chip-in {
  0%   { transform: translateY(8px); opacity: 0; }
  100% { transform: translateY(0);   opacity: 1; }
}
.ctk-act2-chip-label {
  font-size: 10px; color: #9a8a70; letter-spacing: 0.04em;
  text-transform: uppercase; line-height: 1.1;
}
html[lang="th"] .ctk-act2-chip-label {
  font-family: 'Bai Jamjuree', sans-serif; font-weight: 500; text-transform: none;
  font-size: 11px; letter-spacing: 0.02em;
}
.ctk-act2-chip-value {
  font-size: 18px; line-height: 1; color: #fde68a; font-weight: bold;
  text-shadow: 0 0 6px rgba(251,191,36,0.45), 1px 1px 0 #1a0a02;
  font-variant-numeric: tabular-nums;
}
.ctk-act2-chip-sub {
  font-size: 12px; color: #c8b890; margin-top: 4px;
}
html[lang="th"] .ctk-act2-chip-sub { font-family: 'Bai Jamjuree', sans-serif; }

/* Per-seat scoreboard table. */
.ctk-act2-scoreboard {
  width: 100%; border-collapse: collapse;
  margin: 8px 0 12px;
  font-size: 14px;
}
.ctk-act2-scoreboard th {
  text-align: left; padding: 4px 8px;
  color: #9a8a70; font-weight: normal;
  font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase;
  border-bottom: 1px solid rgba(200,170,120,0.18);
}
html[lang="th"] .ctk-act2-scoreboard th {
  font-family: 'Bai Jamjuree', sans-serif; font-weight: 500;
  text-transform: none; letter-spacing: 0.02em;
}
.ctk-act2-scoreboard td {
  padding: 6px 8px;
  border-bottom: 1px solid rgba(200,170,120,0.08);
  color: #e8dcc8;
}
.ctk-act2-scoreboard tr.is-winner td {
  background: linear-gradient(90deg, rgba(251,191,36,0.12) 0%, rgba(251,191,36,0.0) 100%);
  color: #fde68a;
}
.ctk-act2-scoreboard tr.is-dead td { opacity: 0.55; }
.ctk-act2-scoreboard .seat-badge {
  display: inline-block; padding: 1px 6px; font-size: 12px;
  color: #fff;
}
.ctk-act2-scoreboard .role-pip {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: 12px;
}
.ctk-act2-scoreboard .role-pip svg {
  width: 12px; height: 12px; image-rendering: pixelated;
}
.ctk-act2-scoreboard td.num, .ctk-act2-scoreboard th.num { text-align: center; }
.ctk-act2-scoreboard tr.is-mvp td {
  background: rgba(253,230,138,0.12);
  animation: ctk-act2-mvp-row 0.9s ease-out 1s 1;
}
@keyframes ctk-act2-mvp-row {
  0%, 100% { background: rgba(253,230,138,0.12); }
  45%      { background: rgba(253,230,138,0.36); }
}
.ctk-act2-mvp-tag { color: #fde68a; }

/* MVP + category awards (#6) — sit between the stat chips and the per-seat
 * scoreboard on the Act 2 result sheet. The block is a REVEAL, not a static
 * label: it holds invisible ~0.85s (until the four stat chips have landed),
 * then rises in while the crown drops + overshoots and a gold shine sweeps
 * the pill — so the podium moment reads instead of just appearing. */
.ctk-act2-mvp {
  display: flex; align-items: center; justify-content: center;
  flex-wrap: wrap; gap: 8px; margin: 8px 0 2px;
  opacity: 0;
  animation: ctk-act2-mvp-in 0.45s ease-out 0.85s forwards;
}
@keyframes ctk-act2-mvp-in {
  0%   { opacity: 0; transform: translateY(10px); }
  100% { opacity: 1; transform: translateY(0); }
}
.ctk-act2-mvp-crown {
  line-height: 0; display: inline-flex; align-items: center;
  transform-origin: 50% 80%;
  animation: ctk-act2-crown-pop 0.5s cubic-bezier(0.22, 1, 0.36, 1) 0.9s both;
}
@keyframes ctk-act2-crown-pop {
  0%   { transform: translateY(-12px) scale(0.4) rotate(-14deg); opacity: 0; }
  60%  { transform: translateY(0) scale(1.18) rotate(6deg);      opacity: 1; }
  100% { transform: translateY(0) scale(1) rotate(0);            opacity: 1; }
}
/* Pixel-art award badges (Raven Fantasy Icons) — keep them crisp at small
 * sizes and aligned with the adjacent label text. */
.ctk-award-icon {
  height: 16px; width: 16px; image-rendering: pixelated;
  vertical-align: middle; display: inline-block;
}
.ctk-award-icon-lg { height: 22px; width: 22px; }
.ctk-act2-mvp-label {
  position: relative; overflow: hidden;
  font-weight: bold; color: #1a1208; background: #fde68a;
  border-radius: 999px; padding: 1px 8px; letter-spacing: 0.08em; font-size: 13px;
}
.ctk-act2-mvp-label::after {
  content: ''; position: absolute; top: 0; left: -60%; width: 45%; height: 100%;
  background: linear-gradient(100deg, transparent, rgba(255,255,255,0.85), transparent);
  transform: skewX(-18deg);
  animation: ctk-act2-mvp-shine 0.7s ease-out 1.2s 1;
}
@keyframes ctk-act2-mvp-shine {
  0%   { left: -60%; }
  100% { left: 170%; }
}
.ctk-act2-mvp-who { color: #f0d890; font-weight: bold; }
.ctk-act2-mvp-reason { color: #9a8a70; font-size: 13px; }
.ctk-act2-awards {
  display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin: 2px 0 4px;
}
.ctk-act2-award {
  font-size: 12px; color: #c8b890; background: rgba(90,69,48,0.4);
  border: 1px solid #5a4530; border-radius: 999px; padding: 2px 8px;
}

/* Rematch failure line, directly above the action bar. Collapsed by DEFAULT and
 * revealed with .is-shown rather than the `hidden` attribute: it is an aria-live
 * region, and `hidden` would pull it out of the accessibility tree so the first
 * message (set and un-hidden in one tick) would frequently never be announced.
 * Muted red — a refusal, not an alarm; the sheet stays open behind it and the
 * player can simply pick something else. */
.ctk-act2-note { display: none; }
.ctk-act2-note.is-shown {
  display: block;
  margin: 8px 0 0;
  padding: 7px 10px;
  border: 1px solid rgba(232,90,90,0.42);
  border-radius: 4px;
  background: rgba(232,90,90,0.10);
  color: #e8a8a0;
  font-family: 'VT323', monospace;
  font-size: 15px; line-height: 1.35;
  text-align: center;
}
html[lang="th"] .ctk-act2-note { font-family: 'Bai Jamjuree', sans-serif; font-size: 12.5px; }

/* Rematch roll call. Deliberately NOT styled like .ctk-act2-note above: that one
 * is the failure line (red), and "three of four are in" is not a failure. Warm
 * parchment, the same family the sheet's own chrome uses, so the table reads it
 * as part of the result rather than as an error. */
.ctk-act2-ballot { display: none; }
.ctk-act2-ballot.is-shown {
  display: block;
  margin: 10px 0 0;
  padding: 8px 10px;
  border: 1px solid rgba(203,166,102,0.38);
  border-radius: 4px;
  background: rgba(203,166,102,0.09);
  color: #e6d5b0;
  font-family: 'VT323', monospace;
  font-size: 15px; line-height: 1.35;
}
.ctk-act2-ballot-head {
  display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
}
.ctk-act2-ballot-title { font-weight: 700; letter-spacing: .02em; }
/* Tabular figures so a ticking countdown does not shuffle the header's width
 * once a second — the jitter is far more distracting than the number. */
.ctk-act2-ballot-clock { font-variant-numeric: tabular-nums; opacity: .78; }
.ctk-act2-ballot-rows {
  display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px;
}
.ctk-act2-ballot-chip {
  padding: 2px 7px;
  border: 1px solid rgba(203,166,102,0.34);
  border-radius: 999px;
  font-size: 13px;
  white-space: nowrap;
}
.ctk-act2-ballot-chip.is-in { border-color: rgba(126,196,126,0.55); color: #b6e0b6; }
/* A seat that lost its client is shown but visibly not blocking — the ballot
 * already excluded them, and a roll call that hid them would read "2 of 2" at a
 * table of three. */
.ctk-act2-ballot-chip.is-away { opacity: .55; font-style: italic; }
.ctk-act2-ballot-msg { text-align: center; }
.ctk-act2-ballot-acts {
  display: flex; gap: 8px; justify-content: center; margin-top: 8px;
}
html[lang="th"] .ctk-act2-ballot { font-family: 'Bai Jamjuree', sans-serif; font-size: 12.5px; }
html[lang="th"] .ctk-act2-ballot-chip { font-size: 11.5px; }

/* Action bar — M3 button hierarchy: filled primary, tonal,
 * text. Sticks to the bottom of the sheet on tall content. */
.ctk-act2-actions {
  display: flex; flex-wrap: wrap; gap: 8px;
  justify-content: flex-end;
  padding-top: 12px;
  border-top: 1px solid rgba(200,170,120,0.18);
}
/* Singleton (solo / tutorial / watch): the Rematch trigger ACTS instead of
 * opening a menu, because both menu items run the identical reset + open-lobby
 * path there. Drop the caret so it reads as the plain button it now is. */
.ctk-act2-dd-trigger.is-plain .ctk-act2-dd-caret { display: none; }
.ctk-act2-btn {
  font-family: 'VT323', monospace;
  font-size: 16px; letter-spacing: 0.04em;
  padding: 8px 16px;
  cursor: pointer;
  text-shadow: 1px 1px 0 #1a0a02;
  image-rendering: pixelated;
  border: 2px solid transparent;
  min-height: 36px;
  /* Leading pixel-art icon (scroll = log, reload = rematch) sits inline
   * with the label. */
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
.ctk-act2-btn svg { flex: 0 0 auto; }
html[lang="th"] .ctk-act2-btn { font-family: 'Bai Jamjuree', sans-serif; font-weight: 600; }
/* The sheet's primary action. Was a Tailwind amber-600 -> amber-800 gradient
 * that read ORANGE next to a gold board; now the same gold as .px-btn-gold,
 * which is this app's stated primary ("the primary action is gold in every
 * view"). Dark ink on gold, as everywhere else that pair is used. */
.ctk-act2-btn.is-filled {
  background: linear-gradient(180deg, #e8c878 0%, #b8924a 100%);
  border-color: #f0d890;
  color: #2a1d0a;
  box-shadow: inset 1px 1px 0 rgba(255,200,120,0.35), inset -1px -1px 0 rgba(0,0,0,0.4), 0 2px 0 #2a1908;
}
.ctk-act2-btn.is-filled:hover {
  background: linear-gradient(180deg, #f0d68e 0%, #c8a25a 100%);
}
.ctk-act2-btn.is-tonal {
  background: linear-gradient(180deg, rgba(120,82,18,0.45) 0%, rgba(64,40,8,0.35) 100%);
  border-color: #6b5530;
  color: #fde68a;
  box-shadow: inset 1px 1px 0 rgba(200,170,120,0.18), inset -1px -1px 0 rgba(0,0,0,0.3);
}
.ctk-act2-btn.is-tonal:hover {
  background: linear-gradient(180deg, rgba(155,115,40,0.55) 0%, rgba(90,60,16,0.4) 100%);
}
.ctk-act2-btn.is-text {
  background: transparent;
  color: #c8b890;
  border-color: transparent;
}
.ctk-act2-btn.is-text:hover { color: #fde68a; border-color: #6b5530; }


/* Action-bar dropdowns (Share ▾ / Rematch ▾). The trigger reuses the
 * .ctk-act2-btn look; the menu opens UPWARD (the action bar sits at the
 * bottom of the sheet) and mirrors the hamburger .ctk-menu-panel styling. */
.ctk-act2-dropdown { position: relative; display: inline-flex; }
.ctk-act2-dd-caret { display: inline-flex; align-items: center; margin-left: 1px; transition: transform 140ms ease; }
.ctk-act2-dropdown.is-open .ctk-act2-dd-caret { transform: rotate(180deg); }
.ctk-act2-dd-menu {
  position: absolute; bottom: calc(100% + 8px); left: 0; z-index: 30;
  display: none; flex-direction: column;
  min-width: max(100%, 190px); padding: 5px;
  background: linear-gradient(180deg, #221a12 0%, #15110d 100%);
  border: 2px solid #6b5530; border-radius: 10px;
  box-shadow: 0 12px 32px rgba(0,0,0,0.6), inset 0 1px 0 rgba(200,170,120,0.15);
  animation: ctkMenuIn 140ms var(--ctk-spring, ease-out);
}
.ctk-act2-dropdown[data-ctk-dd="replay"] .ctk-act2-dd-menu { left: auto; right: 0; }
.ctk-act2-dropdown.is-open .ctk-act2-dd-menu { display: flex; }
.ctk-act2-dd-item {
  display: flex; align-items: center; gap: 10px;
  width: 100%; min-height: 44px; padding: 9px 12px; margin: 0;
  background: transparent; border: none; border-radius: 6px;
  color: #e8dcc8; font-family: 'VT323', monospace; font-size: 16px;
  letter-spacing: 0.03em; text-align: left; white-space: nowrap; cursor: pointer;
}
html[lang="th"] .ctk-act2-dd-item { font-family: 'Bai Jamjuree', sans-serif; font-weight: 600; }
.ctk-act2-dd-item:hover, .ctk-act2-dd-item:focus-visible { background: rgba(107,85,48,0.30); color: #f0d890; outline: none; }
.ctk-act2-dd-item svg { width: 18px; height: 18px; flex: 0 0 auto; image-rendering: pixelated; }
/* Locked menu item ("Same lineup" once a second human is at the table; both
 * items in a ranked room or for an unseated viewer). Rendered locked rather than
 * removed — the .ctk-chain-blocked law (legacy.js): an item that silently
 * vanishes reads as a bug, a greyed one with a reason explains itself.
 *
 * The item is dimmed by COLOR, not opacity, so the marker can genuinely read
 * brighter than the label it explains — opacity on the parent creates a group
 * the child can never exceed (which is why .ctk-chain-blocked's `.marker
 * {opacity:1}` only restores the marker to parity, never above it).
 *
 * Deliberately still clickable: the item carries aria-disabled, not the
 * `disabled` property, because a disabled button fires no click and the tap is
 * the ONLY way to read the reason on touch (no hover, so no title). What
 * actually disarms it is dropping data-replay, which the menu delegate keys on. */
.ctk-act2-dd-item.is-locked {
  color: #8a7f6d; cursor: default;
  /* The reason goes on its OWN line under the label rather than sharing the row:
   * side by side, "Same lineup" wrapped to two lines and the reason crowded it. */
  flex-wrap: wrap; row-gap: 1px;
}
.ctk-act2-dd-item.is-locked:hover,
.ctk-act2-dd-item.is-locked:focus-visible { background: transparent; color: #8a7f6d; }
.ctk-act2-dd-item.is-locked svg { opacity: 0.5; }
.ctk-act2-dd-marker {
  /* Own line, below the icon + label row. PADDING, not margin, for the 28px
   * (18px icon + the 10px flex gap) that aligns it under the label: the basis is
   * the item's full content width and flex-shrink is 0, so a left MARGIN pushed
   * the box 28px past the item and the text painted outside the menu border. */
  flex: 1 0 100%;
  padding-left: 28px; box-sizing: border-box;
  font-size: 0.66em; letter-spacing: 0.06em; text-transform: uppercase;
  white-space: normal;            /* the base item rule is nowrap; the reason may fold */
  color: #e8a8a0;
}
.ctk-act2-dd-marker:empty { display: none; }
/* Room for the reason without squeezing the label. */
.ctk-act2-dd-menu:has(.ctk-act2-dd-item.is-locked) { min-width: max(100%, 240px); }
@media (prefers-reduced-motion: reduce) { :where(html:not(.ctk-force-fx)) .ctk-act2-dd-menu { animation: none; } :where(html:not(.ctk-force-fx)) .ctk-act2-dd-caret { transition: none; } }

/* Mobile (≤480px): bottom sheet with drag handle. */
@media (max-width: 480px) {
  .ctk-act2-backdrop {
    align-items: flex-end;
    padding: 0;
  }
  .ctk-act2-sheet {
    max-width: 100%;
    border-bottom: none;
    max-height: 100vh;
    max-height: 100dvh;
    overflow-y: auto;
    animation: ctk-act2-sheet-rise 0.35s cubic-bezier(0.2, 0.8, 0.2, 1.05) both;
  }
  @keyframes ctk-act2-sheet-rise {
    from { transform: translateY(100%); }
    to   { transform: translateY(0); }
  }
  .ctk-act2-sheet::before {
    content: '';
    display: block;
    width: 36px; height: 4px;
    background: #5a4530;
    margin: 0 auto 10px;
  }
  .ctk-act2-stats { grid-template-columns: 1fr 1fr; }
}

/* Action-bar layout: stretch the buttons across the whole small-screen
 * range (≤640px) — the same breakpoint where the sheet becomes a capped
 * flex-column — not just ≤480px. Otherwise the 481–640px band (small
 * tablet / narrow desktop window) falls back to the base flex-end row,
 * where the 4th button (Leave-to-lobby) wraps to a ragged second line. */
@media (max-width: 640px) {
  /* 2×2 grid: [View log][Leave] on top, [Share ▾][Rematch ▾] on the bottom
   * row. Predictable columns keep each dropdown's menu on-screen — the old
   * flex-wrap put Share at the right edge, so its menu overflowed off-screen. */
  .ctk-act2-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
  .ctk-act2-actions > * { min-width: 0; }
  .ctk-act2-btn { text-align: center; }
  .ctk-act2-dropdown { display: flex; }
  .ctk-act2-dd-trigger { width: 100%; justify-content: center; }
}

@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-act2-backdrop,
  :where(html:not(.ctk-force-fx)) .ctk-act2-sheet,
  :where(html:not(.ctk-force-fx)) .ctk-act2-chip { animation-duration: 0.01s !important; transition: none !important; }
  /* The MVP crown reveal degrades to a plain static podium: no drop-in,
   * no shine, no row pulse — and the block must NOT stay stuck at opacity 0. */
  :where(html:not(.ctk-force-fx)) .ctk-act2-mvp { animation: none !important; opacity: 1 !important; }
  :where(html:not(.ctk-force-fx)) .ctk-act2-mvp-crown { animation: none !important; }
  :where(html:not(.ctk-force-fx)) .ctk-act2-mvp-label::after { animation: none !important; display: none !important; }
  :where(html:not(.ctk-force-fx)) .ctk-act2-scoreboard tr.is-mvp td { animation: none !important; }
}

/* ctkDamageFlash / ctkHealFlash used to live here: outline + background-color
 * keyframes on the panel. Since R34 every vizFlash rim is a .ctk-rim-fx
 * overlay child animating opacity only — see the rim-flash block near
 * .ctk-anim-damage below. */
/* Active-seat cue. The old version was an amber bloom on amber-brown felt —
 * warm-on-warm, brightness-only, the worst figure/ground read on the board's
 * single most important signal (art audit 2026-08, problem #5). It now
 * separates by VALUE, not hue: a near-white contact ring against a dark
 * halo that pushes the surrounding cloth down.
 * PAINT-CHEAP FORM (2026-09, the PR #725/#763 frame-drop fix): this was an
 * inline `animation:ctkTurnGlow` on the panel interpolating the whole
 * 5-term box-shadow — not compositable, so the seat AND the felt stack
 * beneath repainted every vsync: the board's single biggest steady-state
 * paint. The panel now carries the trough (old 0%) list STATICALLY and
 * the pulse is an ::before overlay holding the peak terms, animating
 * OPACITY only. Overlay alphas are tuned so trough + overlay@1 composites
 * to ≈ the old 50% keyframe (which REPLACED the list): ring 0.75→1 ⇒ 1.0,
 * halo 0.8→0.92 ⇒ 0.6, amber 0.35→0.6 ⇒ 0.38; the bevel's 0.14→0.2 inset
 * hairline pulse is dropped as imperceptible. The bevel + cast ride the
 * static list — dropping either vanishes it on exactly the seat you most
 * want lifted (and the overlay must NOT repeat the cast, or it doubles at
 * peak — see the .player-panel five-site note). The overlay hugs the
 * BORDER box (negative inset = the 4/2/2/2 faction frame; 3px for the
 * Lord) so its contact ring lands on the same pixels as the static ring
 * term; z-index:-1 (a negative-z child of the panel's transform-induced
 * stacking context) paints it where native box-shadow paints: above the
 * panel background, below content. `animation: none` keeps the panel's
 * animation property occupied exactly as the old inline shorthand did —
 * .seat-targetable / .seat-nudge pulses stay suppressed on the active
 * seat, and the combat flashes no longer fight it at all — since R34 they
 * are .ctk-rim-fx overlay children, not panel animations (see the rim-flash
 * block near .ctk-anim-damage). The (1,3,1) selector beats .is-viewer's (1,2,1)
 * box-shadow — a fight the inline animation used to win for free — and
 * ties with the .seat-dying trough, which it follows in file order: an
 * active dying seat keeps the amber glow, as it always has. */
#players > .player-panel[aria-current="true"]:not(.player-dead) {
  box-shadow:
    0 0 0 2px rgba(255, 244, 205, 0.75),
    0 0 22px rgba(8, 5, 2, 0.8),
    0 0 14px rgba(251, 191, 36, 0.35),
    inset 1px 1px 0 rgba(200, 170, 120, 0.14),
    0 3px 8px rgba(0, 0, 0, 0.6);   /* cast onto the felt — see .player-panel */
  animation: none;
}
#players > .player-panel[aria-current="true"]:not(.player-dead)::before {
  content: '';
  position: absolute;
  inset: -4px -2px -2px -2px;   /* hug the border box: 4px banner / 2px frame */
  z-index: -1;
  pointer-events: none;
  box-shadow:
    0 0 0 2px rgba(255, 244, 205, 1),
    0 0 34px rgba(8, 5, 2, 0.6),
    0 0 24px rgba(251, 191, 36, 0.38);
  opacity: 0;
  animation: ctkTurnGlow 1.5s infinite;
  will-change: opacity;
}
/* The Lord's double-gold frame is 3px on ALL sides (`border: 3px
 * !important` in the :has() block near .player-panel) — track it here or
 * the peak contact ring drifts off the static one by 1px. */
#players > .player-panel[aria-current="true"]:not(.player-dead):has(.role-chip[data-role="Lord"])::before {
  inset: -3px;
}
@keyframes ctkTurnGlow {
  0%, 100% { opacity: 0; }
  50%      { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  /* The pulse collapses; the static trough ring above stays — same idiom
   * as the dying carve's static red. (The old collapsed inline animation
   * snapped box-shadow back to the bare panel list, losing the whose-turn
   * ring entirely and leaving the fixed banner as the only cue; keeping
   * the static ring is this refactor's deliberate accessibility upgrade.) */
  :where(html:not(.ctk-force-fx)) #players > .player-panel[aria-current="true"]:not(.player-dead)::before {
    animation: none;
    opacity: 0;
  }
}
@keyframes ctkFadeSlide {
  0%   { opacity: 1; transform: translateX(-50%) translateY(0); }
  100% { opacity: 0; transform: translateX(-50%) translateY(calc(-28px * var(--ctk-fx-scale))); }
}
/* Balatro-style floating damage number: bursts up + scales down + fades */
@keyframes ctkFloatDamage {
  0%   { opacity: 0; transform: translateX(-50%) translateY(calc(-4px  * var(--ctk-fx-scale))) scale(0.6); }
  18%  { opacity: 1; transform: translateX(-50%) translateY(calc(-14px * var(--ctk-fx-scale))) scale(1.45); }
  45%  { opacity: 1; transform: translateX(-50%) translateY(calc(-30px * var(--ctk-fx-scale))) scale(1.0); }
  100% { opacity: 0; transform: translateX(-50%) translateY(calc(-58px * var(--ctk-fx-scale))) scale(0.9); }
}
.ctk-float-damage {
  position: absolute;
  pointer-events: none;
  z-index: 30;
  font-family: 'VT323', monospace;
  font-size: calc(28px * var(--ctk-fx-scale));
  font-weight: bold;
  color: #ff3838;
  /* The 2px/-1px pair is a pixel-art OUTLINE, not a drop shadow — it has to
     keep its ratio to the glyph or a 56px number reads as unoutlined. */
  text-shadow:
    0 0 calc(6px * var(--ctk-fx-scale)) rgba(255, 56, 56, 0.9),
    calc(2px * var(--ctk-fx-scale)) calc(2px * var(--ctk-fx-scale)) 0 #1a0808,
    calc(-1px * var(--ctk-fx-scale)) calc(-1px * var(--ctk-fx-scale)) 0 #1a0808;
  animation: ctkFloatDamage 1.05s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.ctk-float-heal {
  color: #4ade80;
  text-shadow:
    0 0 calc(6px * var(--ctk-fx-scale)) rgba(74, 222, 128, 0.9),
    calc(2px * var(--ctk-fx-scale)) calc(2px * var(--ctk-fx-scale)) 0 #08120a,
    calc(-1px * var(--ctk-fx-scale)) calc(-1px * var(--ctk-fx-scale)) 0 #08120a;
}
/* Floating labelled event chip (vizFloatLabel) — self / judgement / global
 * events with no inter-seat arrow. Reuses the ctkFloatDamage rise+fade;
 * colour is set inline per-event. */
.ctk-float-label {
  position: absolute;
  pointer-events: none;
  z-index: 30;
  display: inline-flex;
  align-items: center;
  gap: calc(4px * var(--ctk-fx-scale));
  transform: translateX(-50%);
  font-family: 'VT323', monospace;
  font-size: calc(17px * var(--ctk-fx-scale));
  font-weight: bold;
  white-space: nowrap;
  color: #f0d890;
  text-shadow: 0 0 calc(6px * var(--ctk-fx-scale)) rgba(0,0,0,0.85),
               calc(2px * var(--ctk-fx-scale)) calc(2px * var(--ctk-fx-scale)) 0 #1a0808,
               calc(-1px * var(--ctk-fx-scale)) calc(-1px * var(--ctk-fx-scale)) 0 #1a0808;
  animation: ctkFloatDamage 1.3s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* Transient inter-seat action-arrow label chip (vizArrow): pixel-art icon + text. */
.ctk-arrow-label {
  position: absolute;
  pointer-events: none;
  z-index: 20;
  display: inline-flex;
  align-items: center;
  gap: calc(3px * var(--ctk-fx-scale));
  font-family: 'VT323', monospace;
  font-size: calc(13px * var(--ctk-fx-scale));
  font-weight: bold;
  white-space: nowrap;
  color: #fff;
  background: rgba(0,0,0,0.72);
  border-radius: calc(4px * var(--ctk-fx-scale));
  padding: calc(1px * var(--ctk-fx-scale)) calc(6px * var(--ctk-fx-scale));
  /* Base transform = the keyframe's 0% pose, so the reduced-motion pin
   * below (animation: none) leaves the chip centred on its anchor. */
  transform: translateX(-50%);
  animation: ctkFadeSlide 1.4s ease-out forwards;
}
/* ===== FX scale: the long tail =====================================
 * The signature-hero decorations (dying scan, strike bolt, shell dome,
 * judge/roulette card, shatter frags, ward ring, …) are ~25 families with
 * ~60 fixed-px constants between them. Rather than multiply each one, scale
 * the CONTAINER: `scale` is an INDEPENDENT transform property, so it
 * composes with each element's own animated `transform:` instead of
 * fighting it, and the composed order (translate → rotate → scale →
 * transform) means an element's own translate(-50%,-50%) centring is scaled
 * with it and stays centred on its seat anchor.
 *
 * Deliberately NOT in this list:
 *   .ctk-float-damage / .ctk-float-label / .ctk-arrow-label — scaled per
 *     property above instead. They are the chips a player reads on EVERY
 *     hit, and font-size:calc() re-rasterises the VT323 glyph crisply where
 *     a transform would soften the pixel edges.
 *   .ctk-shock-ring / .ctk-particle — scaled in JS at their two source
 *     primitives (_shockRing / _burst in client/src/viz-floats.ts), which
 *     covers all eleven call sites at once.
 * Everything listed here must be a DIRECT child of #battleArena; #players,
 * #ctkFelt, #ctkRail and #battleSvg are arena children too and must never
 * be caught by this. ================================================= */
#battleArena > .ctk-dodge-fx,
#battleArena > .ctk-draw-card,
#battleArena > .ctk-awaken-banner,
#battleArena > .ctk-awaken-fx,
#battleArena > .ctk-buqu-fx,
#battleArena > .ctk-dying-fx,
#battleArena > .ctk-equip-fx,
#battleArena > .ctk-fankui-card,
#battleArena > .ctk-fankui-whiff,
#battleArena > .ctk-gear-break-fx,
#battleArena > .ctk-hand-yank-fx,
#battleArena > .ctk-jianxiong-card,
#battleArena > .ctk-jianxiong-fx,
#battleArena > .ctk-judge-fx,
#battleArena > .ctk-huashen-card,
#battleArena > .ctk-huashen-fx,
#battleArena > .ctk-luanwu-banner,
#battleArena > .ctk-luanwu-vortex,
#battleArena > .ctk-mashu-fx,
#battleArena > .ctk-paoxiao-fx,
#battleArena > .ctk-pindian-fx,
#battleArena > .ctk-repel-fx,
#battleArena > .ctk-resp-ring,
#battleArena > .ctk-role-fx,
#battleArena > .ctk-saved-fx,
#battleArena > .ctk-shatter-fx,
#battleArena > .ctk-shell-fx,
#battleArena > .ctk-strike-bolt,
#battleArena > .ctk-strike-flash,
#battleArena > .ctk-ward-fx,
#battleArena > .ctk-wusheng-fx,
#battleArena > .ctk-wusheng-spark,
#battleArena > .ctk-xinsheng-card { scale: var(--ctk-fx-scale); }

/* Inline pixel-art icon inside an action label / float chip — ICONS SVG with
 * fill=currentColor, so it inherits the chip's text color. Replaces emoji. */
.ctk-viz-ico { display: inline-flex; align-items: center; }
.ctk-viz-ico svg { width: calc(13px * var(--ctk-fx-scale)); height: calc(13px * var(--ctk-fx-scale)); image-rendering: pixelated; }
.ctk-float-label .ctk-viz-ico svg { width: calc(15px * var(--ctk-fx-scale)); height: calc(15px * var(--ctk-fx-scale)); }
/* Emoji apex glyph fallback for any preview-arc type with no fitting ICONS SVG. */
.ctk-pending-arrow-glyph-emoji { font-size: calc(18px * var(--ctk-fx-scale)); line-height: 1; }
/* The secondary Borrowed Sword arc (holder → chosen victim, vizPendingArrow):
   same march, a step fainter than the responder's own arc. */
.ctk-pending-arrow-aux { opacity: 0.72; }
/* A queued Heavenly Halberd prong (vizVolleyPreview): the pending-arc look,
 * fainter and STATIC — no dash march, so a queue never adds a running
 * animation; "waiting on you" keeps the march to itself. */
.ctk-pending-arrow.ctk-volley-preview, .ctk-pending-arrow-glyph.ctk-volley-preview { animation: none; opacity: 0.6; }

/* ====================================================================
 * Dedicated DEFENSIVE-response animations — Defend (Dodge) & Negate
 * (Nullify) each get their OWN action effect instead of the shared
 * generic rising-shield float. Both are "shield" responses, told
 * apart by MOTION + COLOR so they read distinct at a glance:
 *   - Defend / Dodge  (Shan 闪):        a nimble LATERAL sidestep —
 *       blue rim flash + the shield glyph dashing aside on a speed streak.
 *   - Negate / Nullify (Wuxie 无懈可击): a RADIAL counter-magic ward —
 *       slate rim flash + expanding barrier rings sealing over the seat.
 * Design refs: Balatro juice · Arena-of-Valor skill impact · Material-3
 * motion · Pixelarticons glyphs. The seat rim flashes animate
 * outline/background ONLY (never transform) so they don't clobber the
 * panel's oval-positioning translate(-50%,-50%). Under prefers-reduced-
 * motion the global collapse rule mutes these; the always-on feed chip
 * carries the word, so information still survives. ==================== */
/* --- Defend / Dodge: lateral sidestep (blue) --- */
/* .ctk-anim-dodge is the panel MARKER; the blue rim itself is the
   .ctk-rim-fx[data-rim="dodge"] overlay (rim-flash block near .ctk-anim-damage). */
@keyframes ctkDodgeDash {
  0%   { opacity: 0; transform: translateX(-50%) translate(10px, 0)  scale(0.7); }
  22%  { opacity: 1; transform: translateX(-50%) translate(2px, 0)   scale(1.18); }
  55%  { opacity: 1; transform: translateX(-50%) translate(-16px, 0) scale(1.0); }
  100% { opacity: 0; transform: translateX(-50%) translate(-34px, 0) scale(0.9); }
}
.ctk-dodge-fx {
  position: absolute; pointer-events: none; z-index: 42;
  display: inline-flex; align-items: center; gap: 4px;
  transform: translateX(-50%);
  font-family: 'VT323', monospace; font-size: 17px; font-weight: bold;
  white-space: nowrap; color: #60a5fa;
  text-shadow: 0 0 6px rgba(0,0,0,0.85), 2px 2px 0 #07101f, -1px -1px 0 #07101f;
  animation: ctkDodgeDash 0.72s cubic-bezier(0.22,1,0.36,1) forwards;
}
/* Speed streak trailing the leftward dash (sits to the RIGHT — where it came from). */
.ctk-dodge-fx::after {
  content: ''; position: absolute; left: 100%; top: 50%; margin-left: 5px;
  width: 24px; height: 2px; transform: translateY(-50%);
  background: linear-gradient(90deg, #60a5fa, transparent);
  box-shadow: 0 -5px 0 -0.5px rgba(96,165,250,0.5), 0 5px 0 -0.5px rgba(96,165,250,0.5);
}
.ctk-dodge-fx .ctk-viz-ico svg { width: 18px; height: 18px; }
/* Response impact ring — a punchy pop so a Defend / Counter / Negate reads
 * even on the viewer's own (bottom) seat. Colour set inline per response. */
@keyframes ctkRespRing {
  0%   { transform: translate(-50%,-50%) scale(0.3); opacity: 0.9; }
  100% { transform: translate(-50%,-50%) scale(1.85); opacity: 0; }
}
.ctk-resp-ring {
  position: absolute; left: 0; top: 0; width: 54px; height: 54px; margin: -27px 0 0 -27px;
  border: 3px solid currentColor; border-radius: 50%;
  box-shadow: 0 0 12px currentColor; pointer-events: none; z-index: 42;
  animation: ctkRespRing 0.5s ease-out forwards;
}

/* --- Negate / Nullify: radial counter-magic ward (slate) --- */
/* .ctk-anim-negate is the panel MARKER; the slate rim is the
   .ctk-rim-fx[data-rim="negate"] overlay (rim-flash block near .ctk-anim-damage). */
.ctk-ward-fx {
  position: absolute; pointer-events: none; z-index: 42;
  transform: translate(-50%, -50%);
  display: inline-flex; align-items: center; justify-content: center;
}
@keyframes ctkWardRing {
  0%   { opacity: 0;    transform: translate(-50%,-50%) scale(0.25) rotate(0deg); }
  30%  { opacity: 0.95; }
  100% { opacity: 0;    transform: translate(-50%,-50%) scale(1.65) rotate(45deg); }
}
.ctk-ward-ring {
  position: absolute; left: 50%; top: 50%;
  width: 56px; height: 56px;
  border: 3px solid #cbd5e0; border-radius: 13px;
  box-shadow: 0 0 10px rgba(203,213,224,0.7), inset 0 0 8px rgba(203,213,224,0.45);
  transform: translate(-50%,-50%) scale(0.25);
  animation: ctkWardRing 0.78s ease-out forwards;
}
.ctk-ward-ring-2 { width: 40px; height: 40px; border-color: #94a3b8; animation-delay: 0.13s; }
/* Locked-skill repel (Qianxun / Weimu): the ward ring's motion and z-layer,
   but tinted per-skill. The ward ring hardcodes slate #cbd5e0, so a plain
   style.color on the wrapper cannot retint it — hence this currentColor
   twin rather than a reuse. Single ring, no second beat: this is a calm
   deflection (the card is never consumed), not a Negate's hard cancel. */
.ctk-repel-fx {
  position: absolute; pointer-events: none; z-index: 42;
  transform: translate(-50%, -50%);
  display: inline-flex; align-items: center; justify-content: center;
}
.ctk-repel-ring {
  position: absolute; left: 50%; top: 50%;
  width: 56px; height: 56px;
  border: 3px solid currentColor; border-radius: 13px;
  box-shadow: 0 0 10px currentColor, inset 0 0 8px currentColor;
  opacity: 0.85;
  transform: translate(-50%,-50%) scale(0.25);
  animation: ctkWardRing 0.78s ease-out forwards;
}
@keyframes ctkWardSeal {
  0%   { opacity: 0; transform: scale(0.4); }
  30%  { opacity: 1; transform: scale(1.3); }
  62%  { opacity: 1; transform: scale(1.0); }
  100% { opacity: 0; transform: scale(1.06); }
}
.ctk-ward-label {
  position: relative; z-index: 1;
  display: inline-flex; align-items: center; gap: 4px;
  font-family: 'VT323', monospace; font-size: 16px; font-weight: bold;
  white-space: nowrap; color: #e2e8f0;
  text-shadow: 0 0 6px rgba(0,0,0,0.9), 2px 2px 0 #14181f, -1px -1px 0 #14181f;
  filter: drop-shadow(0 0 6px rgba(203,213,224,0.8));
  animation: ctkWardSeal 0.8s ease-out forwards;
}
.ctk-ward-label .ctk-viz-ico svg { width: 18px; height: 18px; }

/* ====================================================================
 * Action kill-feed — a fixed top-right stacked column of compact
 * icon+text chips (newest on top, ~4 visible, each self-evicting) so a
 * human can follow each action as it drips in, without opening the log
 * sheet. Layout-only container (no background of its own); chips carry
 * the visuals. Lives in the free top-right gutter, outside the seat ring
 * and clear of the top-left phase chip; below the FABs/modals, above
 * floating numbers and the hand focus-pop, and never eats taps.
 * ==================================================================== */
#ctkActionFeed {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 34px + 6px);   /* clear the 34px toolbar + notch */
  right: calc(env(safe-area-inset-right, 0px) + 6px);
  width: clamp(150px, 42vw, 240px);   /* left half stays free for the phase chip */
  display: flex;
  flex-direction: column-reverse;      /* newest painted at top; DOM stays chronological for role=log */
  align-items: flex-end;               /* chips hug the right edge, shrink-to-fit */
  gap: 4px;
  z-index: 33;                         /* > hand focus-pop (30), < FABs (35) / modals (1000+) */
  pointer-events: none;                /* never intercept a tap meant for a seat/card/FAB */
}
.ctk-feed-chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  max-width: 100%;
  padding: 3px 8px;
  border-radius: 6px;
  background: rgba(8, 6, 4, 0.82);     /* dark-wood plate, matches the float chips */
  border-left: 3px solid currentColor; /* tinted to the event color (set inline) */
  font: bold 15px 'VT323', monospace;  /* project pixel font */
  color: #f0d890;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  text-shadow: 1px 1px 0 #000;
  /* Balatro overshoot entry — same easing vocabulary as the act-1 banner. */
  transform: translateX(28px) scale(0.92);
  opacity: 0;
  animation: ctkFeedIn 260ms var(--ctk-spring) forwards;
}
/* Verb text stays warm parchment (AA contrast on the dark plate) regardless of
 * the event color; the icon + border-left + seat pill carry the color. */
.ctk-feed-chip .ctk-feed-text { color: #f0d890; }
.ctk-feed-chip .ctk-viz-ico { color: currentColor; animation: ctkFeedIcoPop 260ms ease-out both; }
.ctk-feed-chip .ctk-viz-ico svg { width: 14px; height: 14px; }
.ctk-feed-chip .seat-pill {
  flex: 0 0 auto;
  padding: 0 4px;
  border-radius: 4px;
  font-size: 13px;
  color: #fff;                         /* bg set inline from seatBadgeColorFor */
}
.ctk-feed-chip .kf-mult { color: #f0d890; opacity: 0.85; flex: 0 0 auto; }
@keyframes ctkFeedIn {
  0%   { transform: translateX(28px) scale(0.92); opacity: 0; }
  60%  { transform: translateX(-3px) scale(1.04); opacity: 1; }   /* overshoot */
  100% { transform: translateX(0)    scale(1);    opacity: 1; }   /* settle */
}
@keyframes ctkFeedIcoPop {
  0% { transform: scale(1); } 55% { transform: scale(1.25); } 100% { transform: scale(1); }
}
/* Elimination chips: heavier red + a one-shot brightness flourish. */
.ctk-feed-chip.is-death {
  border-color: #ef4444;
  animation: ctkFeedIn 260ms var(--ctk-spring) forwards,
             ctkFeedDeath 320ms ease-out 60ms both;
}
@keyframes ctkFeedDeath { 0% { filter: brightness(1); } 40% { filter: brightness(1.7); } 100% { filter: brightness(1); } }
/* Two-stage age → leave (mirrors the arrow/float self-remove-via-setTimeout). */
.ctk-feed-chip.is-aging   { filter: saturate(0.5) brightness(0.8); opacity: 0.55; transition: filter 0.4s, opacity 0.4s; }
.ctk-feed-chip.is-leaving { opacity: 0; transform: translateX(12px); transition: opacity 0.22s, transform 0.22s; }
@media (max-height: 500px) {
  #ctkActionFeed { width: clamp(120px, 36vw, 200px); }
  .ctk-feed-chip { font-size: 14px; padding: 2px 6px; }
}
@media (max-width: 600px) { .ctk-feed-chip { font-size: 14px; } }
/* Narrow phones: keep a gutter from the top-left phase chip (max-width 58vw). */
@media (max-width: 400px) { #ctkActionFeed { width: clamp(110px, 40vw, 200px); } }
/* Reduced-motion: information MUST survive. The global rule collapses
 * animation-duration to 0.001s AND hides .ctk-float-damage with opacity:0 —
 * we explicitly do NOT inherit that trap. Chips snap in, stay fully visible,
 * and still age/leave via opacity only. */
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-feed-chip {
    animation: none !important;
    transform: none !important;
    opacity: 1 !important;
  }
  :where(html:not(.ctk-force-fx)) .ctk-feed-chip .ctk-viz-ico { animation: none !important; transform: none !important; }
  :where(html:not(.ctk-force-fx)) .ctk-feed-chip.is-aging   { opacity: 0.6 !important; transition: opacity 0.05s; }
  :where(html:not(.ctk-force-fx)) .ctk-feed-chip.is-leaving { opacity: 0   !important; transition: opacity 0.05s; }
}

/* Screen-shake on heavy damage — short, snappy. Amplitudes ride
 * --ctk-fx-scale: the shake is the one VIEWPORT-anchored effect here, and a
 * fixed 3px jolt is 0.77% of a 390px phone frame but only 0.16% of a 1920px
 * desktop one, so it stopped registering as a hit on a big screen. The scale
 * is board-derived (a bounded proxy for viewport width), which tops the peak
 * out at 6px — a thump, not a lurch. */
@keyframes ctkScreenShake {
  0%, 100% { transform: translate(0, 0); }
  10% { transform: translate(calc(-3px * var(--ctk-fx-scale)), calc(1px * var(--ctk-fx-scale))); }
  25% { transform: translate(calc(3px * var(--ctk-fx-scale)), calc(-2px * var(--ctk-fx-scale))); }
  45% { transform: translate(calc(-2px * var(--ctk-fx-scale)), calc(2px * var(--ctk-fx-scale))); }
  65% { transform: translate(calc(2px * var(--ctk-fx-scale)), calc(-1px * var(--ctk-fx-scale))); }
  85% { transform: translate(calc(-1px * var(--ctk-fx-scale)), calc(1px * var(--ctk-fx-scale))); }
}
body.ctk-shake { animation: ctkScreenShake 0.32s ease-in-out; }
/* Inter-seat strike arrow (vizArrow). One 1.3s timeline for shaft + head:
 * the shaft draws source→target over the first 23% (0.3s), the head pops in
 * as it lands (20–34%), both hold at full strength until 77% (1.0s), then
 * fade over the last 0.3s. The old single 0.7s ramp started fading at 70%
 * of a 0.7s draw, so a finished arrow was never on screen at full opacity.
 * Timing functions apply per segment, so ease-out shapes each phase. */
@keyframes ctkArrowDraw {
  0%   { stroke-dashoffset: var(--arr-len, 200); opacity: 0.95; }
  23%  { stroke-dashoffset: 0; }
  77%  { opacity: 0.95; }
  100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes ctkArrowHead {
  0%, 20% { transform: scale(0); opacity: 0; }
  27%  { transform: scale(1.3); opacity: 0.95; }
  34%  { transform: scale(1); }
  77%  { opacity: 0.95; }
  100% { transform: scale(1); opacity: 0; }
}
.ctk-arrow-shaft { animation: ctkArrowDraw 1.3s ease-out forwards; stroke-linecap: round; }
/* The dark under-stroke beneath each shaft (viz-arrows.ts HALO_STROKE). */
.ctk-arrow-halo, .ctk-pending-arrow-halo { stroke-linecap: round; }
/* transform-origin defaults to 0 0 in SVG user space = the head's tip after
 * the parent <g>'s translate/rotate, so the pop grows out of the card edge. */
.ctk-arrow-head  { animation: ctkArrowHead 1.3s ease-out forwards; transform-origin: 0 0; }

/* ===== Judgement "Fate Reveal" + Lightning strike — round-2 juice.
 * vizJudgeReveal flips a suit card at arena centre; vizLightningStrike
 * slams a bolt + arena flash + heavy shake. Both overlay #battleArena. ===== */
.ctk-judge-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; }
.ctk-judge-fx.suit-red   { color: #dc2626; }
.ctk-judge-fx.suit-black { color: #64748b; }
@keyframes ctkJudgeFlip {
  0%   { transform: translate(-50%,-50%) rotateY(90deg) scale(0.7);  opacity: 0; }
  45%  { transform: translate(-50%,-50%) rotateY(0deg)  scale(1.14); opacity: 1; }
  62%  { transform: translate(-50%,-50%) rotateY(0deg)  scale(0.95); }
  78%  { transform: translate(-50%,-50%) rotateY(0deg)  scale(1); }
  100% { transform: translate(-50%,-50%) rotateY(0deg)  scale(1);    opacity: 0; }
}
.ctk-judge-card {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  width: 44px; height: 60px; border-radius: 6px;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
  background: #f5efe0; border: 2px solid #2a2218; box-shadow: 0 0 14px rgba(0,0,0,0.55);
  font-family: 'VT323', monospace; font-weight: bold; line-height: 1;
  animation: ctkJudgeFlip 1.05s cubic-bezier(0.2,1.1,0.3,1) both;
}
.ctk-judge-glyph { font-size: 26px; }
.ctk-judge-rank  { font-size: 13px; }
@keyframes ctkJudgeRing {
  from { transform: translate(-50%,-50%) scale(0.4); opacity: 0.8; }
  to   { transform: translate(-50%,-50%) scale(2.6); opacity: 0; }
}
.ctk-judge-ring {
  position: absolute; left: 0; top: 0; width: 54px; height: 54px; margin: -27px 0 0 -27px;
  border: 2px solid currentColor; border-radius: 50%;
  animation: ctkJudgeRing 0.7s ease-out 0.25s both;
}
/* .ctk-anim-judge is the panel MARKER; the slate pulse is the
   .ctk-rim-fx[data-rim="judge"] overlay (rim-flash block near .ctk-anim-damage). */
/* ===== Outcome-aware fate card (round 14): the flip resolves the suspense —
 * green SAFE (the judgement spared you) vs red DOOM (it harmed you). Frame +
 * ring + verdict badge take the outcome colour; the pip keeps its suit colour. ===== */
.ctk-judge-fx.outcome-safe { color: #22c55e; }
.ctk-judge-fx.outcome-doom { color: #ef4444; }
.ctk-judge-card-outcome { border-color: currentColor !important; box-shadow: 0 0 18px currentColor !important; }
@keyframes ctkJudgeVerdict {
  0%   { opacity: 0; transform: translate(-50%,0) scale(0.6); }
  40%  { opacity: 1; transform: translate(-50%,0) scale(1.14); }
  65%  { transform: translate(-50%,0) scale(0.97); }
  80%  { transform: translate(-50%,0) scale(1); }
  100% { opacity: 0; transform: translate(-50%,0) scale(1); }
}
.ctk-judge-verdict {
  position: absolute; left: 50%; top: calc(50% + 42px); transform: translate(-50%,0);
  color: currentColor; font-family: 'VT323', monospace; font-weight: bold; font-size: 16px; letter-spacing: 0.1em;
  white-space: nowrap; text-shadow: 0 0 8px currentColor, 1px 1px 0 #000, -1px -1px 0 #000;
  animation: ctkJudgeVerdict 1.3s ease-out both;
}

/* ===== 拼点 Pindian face-off (round 4) — the two staked cards side by side.
 * A pindian is a head-to-head rank duel, and it used to resolve with NO
 * comparison on screen: each stake rode the generic single-card fate flip
 * (both reveals share the Fire Attack "reveals X[Sn]" shape), and the verdict
 * lines fired nothing at all. The whole mechanic is "whose rank is higher",
 * so both cards have to be visible AT ONCE, with the winner marked.
 * Reuses .ctk-judge-card for the faces; only the layout + verdict are new. ===== */
.ctk-pindian-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; }
.ctk-pindian-slot { position: absolute; top: 0; }
.ctk-pindian-slot.is-left  { left: -42px; }
.ctk-pindian-slot.is-right { left:  42px; }
/* The stakes must SETTLE and hold, not fade like a one-shot fate flip — the
 * verdict lands ~80ms later and has to have something to mark. */
@keyframes ctkPindianDeal {
  0%   { transform: translate(-50%,-50%) rotateY(90deg) scale(0.7); opacity: 0; }
  55%  { transform: translate(-50%,-50%) rotateY(0deg)  scale(1.12); opacity: 1; }
  75%  { transform: translate(-50%,-50%) rotateY(0deg)  scale(0.97); opacity: 1; }
  100% { transform: translate(-50%,-50%) rotateY(0deg)  scale(1);    opacity: 1; }
}
.ctk-pindian-fx .ctk-judge-card { animation: ctkPindianDeal 0.5s cubic-bezier(0.2,1.1,0.3,1) both; }
.ctk-pindian-fx .ctk-pindian-slot.is-right .ctk-judge-card { animation-delay: 0.16s; }
.ctk-pindian-vs {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  color: #e2e8f0; font-family: 'VT323', monospace; font-weight: bold; font-size: 18px;
  text-shadow: 0 0 8px #000, 1px 1px 0 #000; opacity: 0; animation: ctkPindianVs 0.4s ease-out 0.30s both;
}
@keyframes ctkPindianVs { from { opacity: 0; transform: translate(-50%,-50%) scale(0.5); } to { opacity: 1; transform: translate(-50%,-50%) scale(1); } }
/* Verdict marks, applied when the win/lose line lands. */
.ctk-pindian-slot.is-winner .ctk-judge-card {
  border-color: #fbbf24 !important; box-shadow: 0 0 20px #fbbf24 !important;
  animation: ctkPindianWin 0.5s cubic-bezier(0.2,1.1,0.3,1) both;
}
@keyframes ctkPindianWin {
  0% { transform: translate(-50%,-50%) scale(1); } 45% { transform: translate(-50%,-52%) scale(1.22); }
  100% { transform: translate(-50%,-50%) scale(1.08); }
}
.ctk-pindian-slot.is-loser .ctk-judge-card { filter: grayscale(0.8) brightness(0.62); transform: translate(-50%,-46%) scale(0.92); }
.ctk-pindian-verdict {
  position: absolute; left: 0; top: 46px; transform: translate(-50%,0);
  font-family: 'VT323', monospace; font-weight: bold; font-size: 16px; letter-spacing: 0.1em;
  white-space: nowrap; text-shadow: 0 0 8px currentColor, 1px 1px 0 #000, -1px -1px 0 #000;
  animation: ctkJudgeVerdict 1.5s ease-out both;
}
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-pindian-fx .ctk-judge-card, :where(html:not(.ctk-force-fx)) .ctk-pindian-vs,
  :where(html:not(.ctk-force-fx)) .ctk-pindian-slot.is-winner .ctk-judge-card, :where(html:not(.ctk-force-fx)) .ctk-pindian-verdict { animation-duration: 0.001s !important; }
}

/* ===== Option A "Fate Roulette" judgement + shared shockwave/particles =====
 * The three delayed-trick judgements spin a suit card fast→slow, land, then
 * stamp the verdict with a shockwave. --jc = the live theme/outcome colour. */
.ctk-shock-ring {
  position: absolute; transform: translate(-50%,-50%);
  width: var(--rs,60px); height: var(--rs,60px);
  border: 4px solid var(--rc,#a855f7); border-radius: 50%;
  pointer-events: none; z-index: 40;
  animation: ctkShockRing 0.62s cubic-bezier(0.2,0.8,0.3,1) forwards;
}
@keyframes ctkShockRing {
  from { transform: translate(-50%,-50%) scale(0.3); opacity: 0.9; border-width: 6px; }
  to   { transform: translate(-50%,-50%) scale(3.6); opacity: 0;   border-width: 1px; }
}
.ctk-particle { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 40; border-radius: 1px; }

.ctk-roulette { z-index: 41; }
.ctk-roulette-lines {
  position: absolute; left: 0; top: 0; width: 132px; height: 132px; margin: -66px 0 0 -66px;
  border-radius: 14px; pointer-events: none; opacity: 0.5;
  background: repeating-conic-gradient(from 0deg, transparent 0 10deg, rgba(240,216,144,0.12) 10deg 12deg);
  animation: ctkRouletteSpin 1.8s linear;
}
@keyframes ctkRouletteSpin { from { transform: rotate(0deg); } to { transform: rotate(420deg); } }
.ctk-roulette-card {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  width: 52px; height: 72px; border-radius: 7px;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
  background: #efe6cf; border: 3px solid var(--jc,#9a72c8);
  box-shadow: 0 0 14px var(--jc,#9a72c8), 0 6px 0 rgba(0,0,0,0.45);
  font-family: 'VT323', monospace; font-weight: bold; line-height: 1;
  animation: ctkRoulettePop 0.24s cubic-bezier(0.2,1.4,0.4,1) both;
}
@keyframes ctkRoulettePop { from { transform: translate(-50%,-50%) scale(0.5); opacity: 0; } to { transform: translate(-50%,-50%) scale(1); opacity: 1; } }
.ctk-roulette-glyph { font-size: 34px; }
.ctk-roulette-rank  { font-size: 15px; }
.ctk-roulette-verdict {
  position: absolute; left: 50%; top: calc(50% + 52px); transform: translate(-50%,0);
  color: var(--jc,#9a72c8); font-family: 'VT323', monospace; font-weight: bold; font-size: 22px; letter-spacing: 0.12em;
  white-space: nowrap; text-shadow: 0 0 10px currentColor, 1px 1px 0 #000, -1px -1px 0 #000;
  animation: ctkJudgeVerdict 0.95s ease-out both;
}

@keyframes ctkStrikeFlash { 0% { opacity: 0; } 14% { opacity: 0.55; } 100% { opacity: 0; } }
.ctk-strike-flash {
  position: absolute; inset: 0; pointer-events: none; z-index: 38;
  background: radial-gradient(circle at center, rgba(168,85,247,0.5), rgba(59,130,246,0.22) 60%, transparent 80%);
  animation: ctkStrikeFlash 0.48s ease-out forwards;
}
@keyframes ctkStrikeBolt {
  0%   { transform: translate(-50%,-110%) scaleY(0.2); opacity: 0; }
  18%  { opacity: 1; }
  42%  { transform: translate(-50%,-50%) scaleY(1);    opacity: 1; }
  70%  { opacity: 1; }
  100% { transform: translate(-50%,-50%) scaleY(1);    opacity: 0; }
}
/* The bolt is an inline SVG (viz-floats _boltSvg): the jag as a polygon with a
 * wide translucent stroke on a copy behind it for the glow. It used to be a
 * clip-path'd gradient div under a CSS shadow filter, re-rasterised through
 * the blurred pass on every frame of its fall (juice audit R34). */
.ctk-strike-bolt {
  position: absolute; width: 18px; height: 96px; pointer-events: none; z-index: 41;
  animation: ctkStrikeBolt 0.52s cubic-bezier(0.2,0.9,0.3,1) forwards;
}
.ctk-strike-bolt svg { display: block; width: 100%; height: 100%; overflow: visible; }
.ctk-strike-bolt-glow { fill: rgba(168,85,247,0.55); stroke: rgba(168,85,247,0.5); stroke-width: 6; stroke-linejoin: round; }
@keyframes ctkLightningShake {
  0%,100% { transform: translate(0,0); }
  8%  { transform: translate(calc(-6px * var(--ctk-fx-scale)),calc(3px * var(--ctk-fx-scale))); }  18% { transform: translate(calc(7px * var(--ctk-fx-scale)),calc(-4px * var(--ctk-fx-scale))); }
  30% { transform: translate(calc(-7px * var(--ctk-fx-scale)),calc(2px * var(--ctk-fx-scale))); }  42% { transform: translate(calc(6px * var(--ctk-fx-scale)),calc(-3px * var(--ctk-fx-scale))); }
  56% { transform: translate(calc(-4px * var(--ctk-fx-scale)),calc(2px * var(--ctk-fx-scale))); }  70% { transform: translate(calc(4px * var(--ctk-fx-scale)),calc(-2px * var(--ctk-fx-scale))); }
  84% { transform: translate(calc(-2px * var(--ctk-fx-scale)),calc(1px * var(--ctk-fx-scale))); }
}
body.ctk-shake-hard { animation: ctkLightningShake 0.52s ease-in-out; }

/* ===== The brink — dying enter + Saved (audit 2026-09, fix 2). The
 * persistent stamp is .seat-dying::after (26% down the card, -8°, VT323 15px
 * × --ctk-fx-scale, 3px #ff5a5a border, hard shadow); the transient .ctk-dying-label
 * below wears the SAME face and lands on the SAME row (vizStampRowY), one
 * step larger in padding so the halo's scale(1.08) pulse never peeks out
 * from under it. The board paint is held to the slam (vizBoardHoldMs), so the
 * transient fades into the persistent one with no visible seam. The stamp
 * node opts OUT of the --ctk-fx-scale ratchet — the persistent stamp is not
 * scaled either, and the two must match. ===== */
@keyframes ctkAnimDying { 0%,100% { filter: none; box-shadow: none; } 40% { filter: grayscale(0.85) brightness(0.7); box-shadow: 0 0 0 3px rgba(239,68,68,0.65); } }
.ctk-anim-dying { animation: ctkAnimDying 0.6s ease-out !important; }
/* The drop: a blood vignette dims the whole table for the beat before the
 * flatline. Opacity only — one layer, no filter anywhere on the board. */
@keyframes ctkBrinkVignette { 0% { opacity: 0; } 22% { opacity: 1; } 62% { opacity: 1; } 100% { opacity: 0; } }
.ctk-brink-vignette {
  position: absolute; inset: 0; pointer-events: none; z-index: 38;
  background: radial-gradient(ellipse at center, rgba(0,0,0,0) 30%, rgba(70,0,0,0.6) 100%), rgba(24,0,0,0.22);
  animation: ctkBrinkVignette 0.72s ease-out forwards;
}
.ctk-dying-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; color: #ef4444; }
#battleArena > .ctk-dying-fx.ctk-dying-stamp-fx { scale: none; }
@keyframes ctkDyingScan { 0% { transform: translate(-50%,-22px) scaleX(0.2); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(-50%,22px) scaleX(1); opacity: 0; } }
.ctk-dying-scan {
  position: absolute; left: 0; top: 0; width: 70px; height: 2px; margin-left: -35px;
  background: linear-gradient(90deg, transparent, #ef4444 50%, transparent);
  box-shadow: 0 0 8px #ef4444; animation: ctkDyingScan 0.7s ease-out forwards;
}
@keyframes ctkDyingStampIn {
  0%   { transform: translate(-50%,-50%) scale(1.7) rotate(-14deg); opacity: 0; }
  30%  { transform: translate(-50%,-50%) scale(1) rotate(-8deg); opacity: 1; }
  42%  { transform: translate(-50%,-50%) scale(1.06) rotate(-8deg); }
  55%  { transform: translate(-50%,-50%) scale(1) rotate(-8deg); }
  82%  { transform: translate(-50%,-50%) scale(1) rotate(-8deg); opacity: 1; }
  100% { transform: translate(-50%,-50%) scale(1) rotate(-8deg); opacity: 0; }
}
.ctk-dying-label {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  display: inline-block; white-space: nowrap; text-transform: uppercase;
  padding: calc(3px * min(var(--ctk-fx-scale), 1.3)) calc(10px * min(var(--ctk-fx-scale), 1.3)); background: rgba(120,16,16,0.95); border: 3px solid #ff5a5a; color: #fff0f0;
  font-family: 'VT323', monospace; font-size: calc(15px * min(var(--ctk-fx-scale), 1.3)); letter-spacing: 1px;
  box-shadow: 0 calc(4px * min(var(--ctk-fx-scale), 1.3)) 0 rgba(20,0,0,0.6), 0 0 14px rgba(239,68,68,0.7); animation: ctkDyingStampIn 1.05s cubic-bezier(0.2,1.1,0.3,1) both;
}
html[lang="th"] .ctk-dying-label { font-family: 'Bai Jamjuree', 'Kanit', sans-serif; letter-spacing: 0; text-transform: none; }
.ctk-saved-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; color: #22c55e; }
@keyframes ctkSavedStamp { 0% { transform: translate(-50%,-50%) scale(0.4); opacity: 0; } 45% { transform: translate(-50%,-50%) scale(1.15); opacity: 1; } 65% { transform: translate(-50%,-50%) scale(0.96); } 80% { transform: translate(-50%,-50%) scale(1); } 100% { transform: translate(-50%,-50%) scale(1); opacity: 0; } }
.ctk-saved-label {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
  padding: 3px 10px; border-radius: 4px; background: rgba(4,24,10,0.95); border: 3px solid #22c55e; color: #4ade80;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 17px; letter-spacing: 0.06em; text-transform: uppercase;
  box-shadow: 0 4px 0 rgba(0,20,8,0.6), 0 0 12px rgba(34,197,94,0.55); animation: ctkSavedStamp 0.95s ease-out both;
}
html[lang="th"] .ctk-saved-label { font-family: 'Bai Jamjuree', 'Kanit', sans-serif; letter-spacing: 0; text-transform: none; }
.ctk-saved-label .ctk-viz-ico { width: 14px; height: 14px; }
.ctk-saved-label .ctk-viz-ico svg { width: 100%; height: 100%; display: block; }
/* ===== Defiance stand (Zhou Tai, Buqu) — the payoff beat: he should be
   dead and is not. Ember ring pair (red, then gold) + a red-gold stamp
   carrying the HP he stands at; the seat takes a hot glow. ===== */
@keyframes ctkAnimBuqu { 0%,100% { filter: none; box-shadow: none; } 35% { filter: brightness(1.25) saturate(1.3); box-shadow: 0 0 0 3px rgba(248,113,113,0.8), 0 0 18px 4px rgba(251,191,36,0.45); } }
.ctk-anim-buqu { animation: ctkAnimBuqu 0.7s ease-out !important; }
.ctk-buqu-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; color: #f87171; }
@keyframes ctkBuquRing { from { transform: translate(-50%,-50%) scale(0.3); opacity: 0.9; } to { transform: translate(-50%,-50%) scale(2.6); opacity: 0; } }
.ctk-buqu-ring {
  position: absolute; left: 0; top: 0; width: 50px; height: 50px; margin: -25px 0 0 -25px;
  border: 2px solid currentColor; border-radius: 50%; box-shadow: 0 0 10px rgba(251,191,36,0.6);
  animation: ctkBuquRing 0.8s ease-out forwards;
}
.ctk-buqu-ring.ctk-buqu-ring-2 { animation-delay: 0.18s; border-color: #fbbf24; }
@keyframes ctkBuquStamp { 0% { transform: translate(-50%,-50%) scale(1.7) rotate(-6deg); opacity: 0; } 30% { transform: translate(-50%,-50%) scale(1) rotate(-4deg); opacity: 1; } 42% { transform: translate(-50%,-50%) scale(1.06) rotate(-4deg); } 55% { transform: translate(-50%,-50%) scale(1) rotate(-4deg); } 85% { opacity: 1; } 100% { transform: translate(-50%,-50%) scale(1) rotate(-4deg); opacity: 0; } }
.ctk-buqu-label {
  position: absolute; left: 0; top: var(--row-dy, 0px); transform: translate(-50%,-50%);
  display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
  padding: 3px 10px; border-radius: 4px; background: rgba(40,6,6,0.95); border: 3px solid #f87171; color: #fde68a;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 17px; letter-spacing: 0.06em;
  box-shadow: 0 4px 0 rgba(20,0,0,0.6), 0 0 12px rgba(248,113,113,0.6), inset 0 0 8px rgba(251,191,36,0.15); animation: ctkBuquStamp 1.3s ease-out both;
}
.ctk-buqu-label .ctk-viz-ico { width: 14px; height: 14px; color: #f87171; }
.ctk-buqu-label .ctk-viz-ico svg { width: 100%; height: 100%; display: block; }
.ctk-buqu-hp { color: #f87171; margin-left: 2px; }

/* ===== Jushou / Entrench (Cao Ren) — round-5 juice: a jade turtle-shell
 * dome drops over the seat as he turns face-down. ===== */
@keyframes ctkAnimEntrench { 0%,100% { box-shadow: none; } 50% { box-shadow: 0 0 0 3px rgba(49,151,149,0.55), inset 0 0 12px rgba(49,151,149,0.4); } }
.ctk-anim-entrench { animation: ctkAnimEntrench 0.6s ease-out !important; }
.ctk-shell-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; color: #319795; }
@keyframes ctkShellDrop {
  0%   { transform: translate(-50%,-90%) scale(0.6); opacity: 0; }
  45%  { transform: translate(-50%,-50%) scale(1.1); opacity: 1; }
  65%  { transform: translate(-50%,-50%) scale(0.95); }
  100% { transform: translate(-50%,-50%) scale(1);   opacity: 0; }
}
.ctk-shell-dome {
  position: absolute; left: 0; top: 0; width: 64px; height: 34px; margin: -17px 0 0 -32px;
  border: 3px solid currentColor; border-bottom: none; border-radius: 34px 34px 0 0;
  background: linear-gradient(180deg, rgba(49,151,149,0.35), rgba(49,151,149,0.05));
  box-shadow: 0 0 12px rgba(49,151,149,0.6);
  animation: ctkShellDrop 1.0s cubic-bezier(0.2,1.1,0.3,1) both;
}
@keyframes ctkShellLabel { 0% { opacity: 0; transform: translate(-50%,-50%) scale(0.7); } 40% { opacity: 1; transform: translate(-50%,-50%) scale(1); } 80% { opacity: 1; } 100% { opacity: 0; } }
.ctk-shell-label {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
  padding: 2px 8px; border-radius: 5px; background: rgba(4,18,18,0.85); border: 1px solid currentColor; color: currentColor;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 14px; letter-spacing: 0.05em;
  box-shadow: 0 0 10px rgba(49,151,149,0.5); animation: ctkShellLabel 1.0s ease-out both;
}
.ctk-shell-label .ctk-viz-ico { width: 13px; height: 13px; }
.ctk-shell-label .ctk-viz-ico svg { width: 100%; height: 100%; display: block; }
/* Shell soak — the dome takes a hit while Cao Ren is face-down: a teal impact
   ring flares out and snaps clear as the seat pulses. Reuses .ctk-shell-fx /
   .ctk-shell-label; the ring is distinct from the drop so an eaten hit reads
   as an impact, not another turtle-up. */
@keyframes ctkShellRing {
  0%   { transform: translate(-50%,-50%) scale(0.35); opacity: 0; }
  30%  { opacity: 1; }
  100% { transform: translate(-50%,-50%) scale(1.55); opacity: 0; }
}
.ctk-shell-ring {
  position: absolute; left: 0; top: 0; width: 58px; height: 58px; margin: -29px 0 0 -29px;
  border: 3px solid currentColor; border-radius: 50%;
  box-shadow: 0 0 14px rgba(49,151,149,0.7), inset 0 0 10px rgba(49,151,149,0.5);
  animation: ctkShellRing 0.6s cubic-bezier(0.2,0.7,0.3,1) both;
}

/* ===== Role Unmasked + Turn Begins — round-6 juice. Faction placard
 * flips up over the dead seat (colour set inline per faction); the turn
 * kick is a light amber rim pulse on the seat whose turn just began. ===== */
.ctk-role-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 42; color: #cbd5e0; }
@keyframes ctkRoleFlip {
  0%   { transform: translate(-50%,-50%) rotateY(90deg) scale(0.7);  opacity: 0; }
  40%  { transform: translate(-50%,-50%) rotateY(0deg)  scale(1.18); opacity: 1; }
  58%  { transform: translate(-50%,-50%) rotateY(0deg)  scale(0.96); }
  74%  { transform: translate(-50%,-50%) rotateY(0deg)  scale(1.04); }
  86%  { transform: translate(-50%,-50%) rotateY(0deg)  scale(1); }
  100% { transform: translate(-50%,-50%) rotateY(0deg)  scale(1);    opacity: 0; }
}
.ctk-role-card {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  display: inline-flex; flex-direction: column; align-items: center; gap: 2px; white-space: nowrap;
  padding: 6px 10px; border-radius: 6px; background: rgba(8,8,12,0.9); border: 2px solid currentColor; color: currentColor;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 16px; letter-spacing: 0.05em;
  box-shadow: 0 0 16px currentColor; animation: ctkRoleFlip 1.5s cubic-bezier(0.2,1.1,0.3,1) both;
}
.ctk-role-card .ctk-viz-ico { width: 22px; height: 22px; }
.ctk-role-card .ctk-viz-ico svg { width: 100%; height: 100%; display: block; }
@keyframes ctkRoleRing { from { transform: translate(-50%,-50%) scale(0.4); opacity: 0.85; } to { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } }
.ctk-role-ring {
  position: absolute; left: 0; top: 0; width: 60px; height: 60px; margin: -30px 0 0 -30px;
  border: 2px solid currentColor; border-radius: 50%; animation: ctkRoleRing 0.8s ease-out 0.2s both;
}
@keyframes ctkTurnBeginKick { 0% { box-shadow: none; } 30% { box-shadow: 0 0 0 4px rgba(245,158,11,0.6), 0 0 18px rgba(245,158,11,0.5); } 100% { box-shadow: none; } }
.ctk-anim-turnbegin { animation: ctkTurnBeginKick 0.6s ease-out !important; }

/* ===== Draft role-reveal — the "who am I this round?" beat. Layered over the
 * hero-draft modal as a play-once curtain (see RoleReveal in DraftModal.tsx).
 * A neutral face-DOWN card (deliberately NOT role-tinted, so a shoulder-surfer
 * can't read your secret role early) flips to your role with an icon punch-in
 * and a colour-wash ring keyed off --reveal-tint, then dissolves to reveal the
 * pick list. Adapts the match-end Act-1 flip + in-board ctkRoleFlip idioms;
 * degrades to a plain fade under prefers-reduced-motion. ===== */
.ctk-draft-reveal {
  position: absolute; inset: 0; z-index: 2;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;
  padding: 24px; text-align: center; cursor: pointer;
  -webkit-tap-highlight-color: transparent; touch-action: manipulation;
  background: radial-gradient(circle at 50% 42%, rgba(32,24,13,0.94) 0%, rgba(9,6,4,0.98) 100%);
  animation: ctk-draft-reveal-in 0.3s var(--ctk-spring) both;
}
/* The curtain keeps catching taps THROUGH the dissolve (pointer-events stay
   on) — otherwise the dismiss gesture, or a stray double-tap while the card is
   still visibly fading, would fall through to a candidate button and instantly
   commit a hero for the match. A pick must be a fresh, deliberate tap made
   only once the card has fully cleared. */
.ctk-draft-reveal.is-dismissing { animation: ctk-draft-reveal-out 0.38s cubic-bezier(0.22,1,0.36,1) both; }
@keyframes ctk-draft-reveal-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes ctk-draft-reveal-out { from { opacity: 1; } to { opacity: 0; visibility: hidden; } }

.ctk-draft-reveal-kicker {
  font-family: 'VT323', monospace; font-size: 16px; letter-spacing: 0.16em;
  text-transform: uppercase; color: #b8a888;
}
html[lang="th"] .ctk-draft-reveal-kicker {
  font-family: 'Bai Jamjuree', sans-serif; font-weight: 600;
  letter-spacing: 0.03em; text-transform: none;
}

.ctk-draft-reveal-cardwrap { position: relative; width: 132px; height: 176px; perspective: 850px; }
.ctk-draft-reveal-card {
  position: relative; width: 100%; height: 100%;
  transform-style: preserve-3d; transform: rotateY(0deg);
  transition: transform 0.6s cubic-bezier(0.2, 1.1, 0.3, 1);
}
.ctk-draft-reveal-card.is-flipped { transform: rotateY(180deg); }
.ctk-draft-reveal-face {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;
  border: 3px solid currentColor; border-radius: 12px;
  backface-visibility: hidden; -webkit-backface-visibility: hidden;
  box-shadow: inset 2px 2px 0 rgba(255,255,255,0.10), inset -2px -2px 0 rgba(0,0,0,0.5);
}
.ctk-draft-reveal-back {
  color: #6b5530;
  background: repeating-linear-gradient(135deg, #2a1c0a 0px, #2a1c0a 6px, #1a1208 6px, #1a1208 12px);
  animation: ctk-draft-reveal-beckon 1.5s ease-in-out infinite;
}
.ctk-draft-reveal-back span {
  font-family: 'VT323', monospace; font-size: 72px; font-weight: bold; line-height: 1; color: #7a6238;
}
@keyframes ctk-draft-reveal-beckon {
  0%, 100% { box-shadow: inset 2px 2px 0 rgba(255,255,255,0.10), inset -2px -2px 0 rgba(0,0,0,0.5), 0 0 0 rgba(240,216,144,0); }
  50%      { box-shadow: inset 2px 2px 0 rgba(255,255,255,0.10), inset -2px -2px 0 rgba(0,0,0,0.5), 0 0 24px rgba(240,216,144,0.5); }
}
.ctk-draft-reveal-front {
  transform: rotateY(180deg);
  color: var(--reveal-tint, #f0d890);
  background: linear-gradient(180deg, rgba(4,3,2,0.82) 0%, rgba(4,3,2,0.94) 100%);
  box-shadow: inset 2px 2px 0 rgba(255,255,255,0.10), inset -2px -2px 0 rgba(0,0,0,0.5), 0 0 30px currentColor;
}
.ctk-draft-reveal-ico { width: 58px; height: 58px; display: block; }
.ctk-draft-reveal-ico svg { width: 100%; height: 100%; display: block; image-rendering: pixelated; }
.ctk-draft-reveal-card.is-flipped .ctk-draft-reveal-ico {
  animation: ctk-draft-reveal-punch 0.6s cubic-bezier(0.2, 1.4, 0.4, 1) 0.32s both;
}
@keyframes ctk-draft-reveal-punch {
  0%   { transform: scale(0.4); opacity: 0; }
  60%  { transform: scale(1.2); opacity: 1; }
  100% { transform: scale(1);   opacity: 1; }
}
.ctk-draft-reveal-name {
  font-family: 'VT323', monospace; font-size: 32px; letter-spacing: 0.06em; line-height: 1;
}
html[lang="th"] .ctk-draft-reveal-name {
  font-family: 'Bai Jamjuree', sans-serif; font-weight: 700; font-size: 25px; letter-spacing: 0;
}
.ctk-draft-reveal-ring {
  position: absolute; left: 50%; top: 50%; width: 128px; height: 128px; margin: -64px 0 0 -64px;
  border: 3px solid var(--reveal-tint, #f0d890); border-radius: 50%; pointer-events: none;
  animation: ctk-draft-reveal-ring 0.85s ease-out 0.26s both;
}
@keyframes ctk-draft-reveal-ring {
  from { transform: scale(0.4);  opacity: 0.8; }
  to   { transform: scale(2.85); opacity: 0; }
}
.ctk-draft-reveal-desc {
  max-width: 330px; font-size: 13px; line-height: 1.5; color: #d8ccb4;
  animation: ctk-draft-reveal-textin 0.5s ease-out 0.42s both;
}
html[lang="th"] .ctk-draft-reveal-desc { font-family: 'Bai Jamjuree', sans-serif; }
.ctk-draft-reveal-hint {
  font-family: 'VT323', monospace; font-size: 13px; letter-spacing: 0.14em;
  text-transform: uppercase; color: #8a7a5a;
}
html[lang="th"] .ctk-draft-reveal-hint {
  font-family: 'Bai Jamjuree', sans-serif; letter-spacing: 0.02em; text-transform: none;
}
.ctk-draft-reveal-hint { animation: ctk-draft-reveal-textin 0.5s ease-out 0.72s both; }
@keyframes ctk-draft-reveal-textin {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-draft-reveal, :where(html:not(.ctk-force-fx)) .ctk-draft-reveal.is-dismissing { animation-duration: 0.12s; }
  :where(html:not(.ctk-force-fx)) .ctk-draft-reveal-card { transition: none; }
  :where(html:not(.ctk-force-fx)) .ctk-draft-reveal-back { animation: none; }
  :where(html:not(.ctk-force-fx)) .ctk-draft-reveal-card.is-flipped .ctk-draft-reveal-ico,
  :where(html:not(.ctk-force-fx)) .ctk-draft-reveal-ring,
  :where(html:not(.ctk-force-fx)) .ctk-draft-reveal-desc,
  :where(html:not(.ctk-force-fx)) .ctk-draft-reveal-hint { animation: none; }
}

/* ===== Generic skill trigger — round-7 baseline pulse for the ~10 passive
 * skills that previously fired nothing (gold rim, paired with a floating
 * rebranded skill name via vizFloatLabel). ===== */
/* A 4px rim in the seat's OWN kingdom colour (--k-lit, the panel frame's
 * variable; gold when a seat has none) over a gold glow, so every skill beat
 * reads as "this seat's power" and no longer vanishes against the panel border
 * (juice audit fix 5). Shared by every bespoke beat that pulses the seat —
 * Kongcheng, the converts, Roar, Awaken — one family, one rim. The color-mix
 * line trims the rim to 0.9 alpha where supported; the plain line stands in. */
/* .ctk-anim-skill is the panel MARKER; the kingdom-coloured rim is the
 * .ctk-rim-fx[data-rim="skill"] overlay (rim-flash block near .ctk-anim-damage). */

/* ===== Reach reaction — a gear swap that moves the seat's reach pulses its
 * ↔ range stat (or the defensive ↕ incoming chip). The target is a SMALL
 * inline stat span, not a panel, so this is a scale kick + gold glow rather
 * than a box-shadow rim: a 3px rim on a 14px chip reads as a smear. The svg
 * icon has no text to shadow, so brightness() carries the glow for it while
 * text-shadow carries it for the number. Fired by vizRangePulse(). ===== */
@keyframes ctkAnimRange {
  0%, 100% { transform: scale(1);    text-shadow: none;                              filter: none; }
  40%      { transform: scale(1.35); text-shadow: 0 0 8px rgba(240,216,144,0.95);    filter: brightness(1.6); }
}
.ctk-anim-range { animation: ctkAnimRange 0.6s ease-out !important; }

/* ===== Trick-transfer card motion — round-9. .ctk-card-fly reuses the
 * deck-deal card-back (ctkDrawDeal) with a neutral shadow so the inline
 * tint shows; the shatter collapses a card token + sprays fragments. ===== */
.ctk-card-fly { box-shadow: 0 0 8px rgba(0,0,0,0.5) !important; }
.ctk-shatter-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; color: #7c3aed; }
@keyframes ctkShatterCard {
  0%   { transform: translate(-50%,-50%) scale(1)    rotate(0deg);  opacity: 1; }
  40%  { transform: translate(-50%,-50%) scale(1.08) rotate(-4deg); opacity: 1; }
  100% { transform: translate(-50%,-50%) scale(0.2)  rotate(8deg);  opacity: 0; }
}
.ctk-shatter-card {
  position: absolute; left: 0; top: 0; width: 18px; height: 26px; margin: -13px 0 0 -9px;
  border-radius: 3px; background: currentColor; box-shadow: 0 0 8px currentColor;
  animation: ctkShatterCard 0.45s ease-in forwards;
}
@keyframes ctkShatterFrag {
  0%   { transform: translate(-50%,-50%); opacity: 1; }
  100% { transform: translate(calc(-50% + var(--sx,0px)), calc(-50% + var(--sy,0px))) rotate(120deg); opacity: 0; }
}
.ctk-shatter-frag {
  position: absolute; left: 0; top: 0; width: 5px; height: 6px; margin: -3px 0 0 -2px;
  background: currentColor; box-shadow: 0 0 4px currentColor;
  animation: ctkShatterFrag 0.5s ease-out forwards;
}

/* ===== Hand-card YANK (Steal of a hand card) — a PULL toward the thief, not
 * a destroy: the card token jerks up, tilts, and rips away behind a few
 * speed streaks; vizCardFly then carries it to the actor. Purple = steal.
 * Distinct from ctkShatterCard (Dismantle destroy) and the gear-break
 * (equipment knock-off). ===== */
.ctk-hand-yank-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; color: #a855f7; }
@keyframes ctkHandYank {
  0%   { transform: translate(-50%,-50%)  scale(1)    rotate(0deg);   opacity: 1; }
  22%  { transform: translate(-50%,-38%)  scale(1.08) rotate(-10deg); opacity: 1; }
  100% { transform: translate(-50%,-150%) scale(0.5)  rotate(16deg);  opacity: 0; }
}
.ctk-hand-yank-card {
  position: absolute; left: 0; top: 0; width: 18px; height: 26px; margin: -13px 0 0 -9px;
  border-radius: 3px; background: currentColor; box-shadow: 0 0 8px currentColor;
  animation: ctkHandYank 0.42s cubic-bezier(0.4,0,0.9,0.5) forwards;
}
@keyframes ctkHandYankStreak {
  0%   { transform: translate(-50%,10%)  scaleY(0.2); opacity: 0; }
  35%  { opacity: 0.8; }
  100% { transform: translate(-50%,-60%) scaleY(1.4); opacity: 0; }
}
.ctk-hand-yank-streak {
  position: absolute; left: 0; top: 0; width: 2px; height: 14px;
  margin: -7px 0 0 calc(-1px + var(--yx, 0px));
  background: currentColor; border-radius: 2px;
  animation: ctkHandYankStreak 0.42s ease-out forwards;
}

/* ==================================================================
 * Signature-hero bespoke beats (round 3) — Wusheng / Paoxiao / Mashu /
 * Jianxiong / Fankui. Namespaced .ctk-<skill>-* so nothing collides.
 * Global prefers-reduced-motion CSS suppresses these; audio stays live.
 * ================================================================== */
/* Wusheng (Guan Yu): crimson→gold saber slash + white-hot streak + sparks. */
.ctk-wusheng-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; color: #dc2626; }
@keyframes ctkWushengSlash {
  0%   { transform: translate(-50%,-50%) rotate(-38deg) translateX(-46px) scaleX(0.35); opacity: 0; }
  22%  { opacity: 1; }
  55%  { transform: translate(-50%,-50%) rotate(-38deg) translateX(0)     scaleX(1);    opacity: 1; }
  100% { transform: translate(-50%,-50%) rotate(-38deg) translateX(46px)  scaleX(0.5);  opacity: 0; }
}
.ctk-wusheng-blade {
  position: absolute; left: 0; top: 0; width: 78px; height: 7px; margin: -3.5px 0 0 -39px;
  background: linear-gradient(90deg, #7f1d1d, #dc2626 40%, #fbbf24 82%, #fde68a);
  border-radius: 4px;
  box-shadow: 0 0 8px rgba(251,191,36,0.55), 14px 0 10px rgba(220,38,38,0.28);
  animation: ctkWushengSlash 0.34s cubic-bezier(0.25,0.9,0.35,1) forwards;
}
@keyframes ctkWushengStreak {
  0%   { transform: translate(-50%,-50%) rotate(-38deg) translateX(-40px) scaleX(0.2); opacity: 0; }
  30%  { opacity: 0.95; }
  60%  { transform: translate(-50%,-50%) rotate(-38deg) translateX(8px)   scaleX(1);   opacity: 0.9; }
  100% { transform: translate(-50%,-50%) rotate(-38deg) translateX(52px)  scaleX(0.4); opacity: 0; }
}
.ctk-wusheng-streak {
  position: absolute; left: 0; top: 0; width: 86px; height: 2px; margin: -1px 0 0 -43px;
  background: linear-gradient(90deg, transparent, #ffffff 55%, #fde68a);
  box-shadow: 0 0 7px #fde68a;
  animation: ctkWushengStreak 0.3s ease-out forwards;
}
@keyframes ctkWushengSpark {
  0%   { transform: translate(0,0) scale(1);   opacity: 0; }
  20%  { opacity: 1; }
  100% { transform: translate(var(--sx,16px), var(--sy,-14px)) scale(0.3); opacity: 0; }
}
.ctk-wusheng-spark {
  position: absolute; left: 0; top: 0; width: 4px; height: 4px; margin: -2px 0 0 -2px;
  background: #ef4444; border-radius: 1px;
  box-shadow: 0 0 5px rgba(251,191,36,0.7);
  animation: ctkWushengSpark 0.4s ease-out forwards;
}
/* Duel finish (vizDuelSlash): reuse the Wusheng saber DOM but reverse the tint
 * to gold→crimson so the loser's blow reads as a decisive strike, not Guan Yu's
 * skill. Blade lands crimson (the hit), sparks gold. */
.ctk-duel-slash { color: #eab308; }
.ctk-duel-slash .ctk-wusheng-blade {
  background: linear-gradient(90deg, #fde68a, #fbbf24 30%, #dc2626 72%, #7f1d1d);
  box-shadow: 0 0 8px rgba(220,38,38,0.55), 14px 0 10px rgba(251,191,36,0.3);
}
.ctk-duel-slash .ctk-wusheng-spark { background: #eab308; }
/* Paoxiao / Roar (Zhang Fei): twin red-orange shockwave rings + ROAR! chip
 * + a light two-step shake (lighter than damage/lightning). */
.ctk-paoxiao-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 40; }
@keyframes ctkPaoxiaoRing {
  0%   { transform: translate(-50%,-50%) scale(0.4); opacity: 1; }
  100% { transform: translate(-50%,-50%) scale(1.8); opacity: 0; }
}
.ctk-paoxiao-ring {
  position: absolute; left: 0; top: 0; width: 64px; height: 64px; margin: -32px 0 0 -32px;
  border: 3px solid #ef4444; border-radius: 50%;
  animation: ctkPaoxiaoRing 0.5s cubic-bezier(0.15,0.7,0.35,1) forwards;
}
.ctk-paoxiao-ring2 { border-color: #f97316; animation: ctkPaoxiaoRing 0.5s cubic-bezier(0.15,0.7,0.35,1) 0.08s forwards; }
@keyframes ctkPaoxiaoRoar {
  0%   { transform: translate(-50%,-50%) scale(0.5) rotate(-6deg); opacity: 0; }
  22%  { transform: translate(-50%,-50%) scale(1.14) rotate(3deg); opacity: 1; }
  36%  { transform: translate(-50%,-50%) scale(0.98) rotate(-3deg); }
  50%  { transform: translate(-50%,-52%) scale(1.04) rotate(2deg); }
  64%  { transform: translate(-50%,-50%) scale(1) rotate(-2deg); }
  78%  { transform: translate(-50%,-52%) scale(1.02) rotate(2deg); opacity: 1; }
  100% { transform: translate(-50%,-64%) scale(1) rotate(0deg); opacity: 0; }
}
.ctk-paoxiao-label {
  position: absolute; left: 0; top: var(--row-dy, -6px);
  display: inline-flex; align-items: center; gap: 3px;
  padding: 3px 9px; border-radius: 4px; white-space: nowrap;
  background: #7f1d1d; border: 3px solid #ef4444; color: #fee2e2;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 17px; line-height: 1;
  text-shadow: 0 1px 0 #450a0a; box-shadow: 0 4px 0 #450a0a, 0 0 12px rgba(239,68,68,0.5);
  animation: ctkPaoxiaoRoar 0.72s cubic-bezier(0.2,1.1,0.3,1) both;
}
.ctk-paoxiao-label .ctk-viz-ico { display: inline-flex; }
.ctk-paoxiao-label .ctk-viz-ico svg { width: 13px; height: 13px; }
@keyframes ctkPaoxiaoShake {
  0%,100% { transform: translate(0,0); }
  25% { transform: translate(calc(-4px * var(--ctk-fx-scale)),calc(2px * var(--ctk-fx-scale))); }
  55% { transform: translate(calc(3px * var(--ctk-fx-scale)),calc(-1px * var(--ctk-fx-scale))); }
  80% { transform: translate(calc(-2px * var(--ctk-fx-scale)),calc(1px * var(--ctk-fx-scale))); }
}
body.ctk-shake-roar { animation: ctkPaoxiaoShake 0.3s ease-in-out; }
/* ===== Luanwu / Warstorm (Jia Xu) — the once-per-game table-wide ultimate
 * (vizLuanwuStorm / vizLuanwuMark / vizLuanwuEnd in viz-floats.ts). ===== */
/* The arena darkens under a violet vignette that rolls in from the edges. */
@keyframes ctkLuanwuWash {
  0%   { opacity: 0; }
  18%  { opacity: 1; }
  70%  { opacity: 0.75; }
  100% { opacity: 0; }
}
.ctk-luanwu-wash {
  position: absolute; inset: 0; pointer-events: none; z-index: 37;
  background:
    radial-gradient(ellipse at center, rgba(124,58,237,0.10) 0%, rgba(76,29,149,0.34) 62%, rgba(30,10,60,0.62) 100%);
  animation: ctkLuanwuWash 1.4s ease-out forwards;
}
/* Counter-rotating dashed rings + a breathing core on the caster. */
.ctk-luanwu-vortex { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; }
@keyframes ctkLuanwuRingA {
  0%   { transform: translate(-50%,-50%) rotate(0deg)   scale(0.35); opacity: 0; }
  15%  { opacity: 1; }
  100% { transform: translate(-50%,-50%) rotate(300deg) scale(1.9);  opacity: 0; }
}
@keyframes ctkLuanwuRingB {
  0%   { transform: translate(-50%,-50%) rotate(0deg)    scale(0.25); opacity: 0; }
  20%  { opacity: 1; }
  100% { transform: translate(-50%,-50%) rotate(-260deg) scale(1.45); opacity: 0; }
}
.ctk-luanwu-ring {
  position: absolute; left: 0; top: 0; width: 88px; height: 88px;
  border-radius: 50%; border: 4px dashed #c4b5fd;
  box-shadow: 0 0 10px rgba(167,139,250,0.55), inset 0 0 10px rgba(167,139,250,0.35);
  animation: ctkLuanwuRingA 1.05s cubic-bezier(0.2,0.8,0.3,1) forwards;
}
.ctk-luanwu-ring-b {
  width: 62px; height: 62px; border-color: #7c3aed; border-style: dotted; border-width: 5px;
  animation: ctkLuanwuRingB 1.05s cubic-bezier(0.2,0.8,0.3,1) 0.06s forwards;
}
@keyframes ctkLuanwuCore {
  0%   { transform: translate(-50%,-50%) scale(0.3); opacity: 0; }
  25%  { transform: translate(-50%,-50%) scale(1.2); opacity: 0.95; }
  60%  { transform: translate(-50%,-50%) scale(0.9); opacity: 0.7; }
  100% { transform: translate(-50%,-50%) scale(1.6); opacity: 0; }
}
.ctk-luanwu-core {
  position: absolute; left: 0; top: 0; width: 46px; height: 46px; border-radius: 50%;
  background: radial-gradient(circle, #f5f3ff 0%, #c4b5fd 35%, rgba(124,58,237,0.5) 65%, transparent 75%);
  animation: ctkLuanwuCore 0.9s ease-out forwards;
}
/* The centre banner: slams in oversized, settles, holds, drifts up and fades. */
@keyframes ctkLuanwuBanner {
  0%   { transform: translate(-50%,-50%) scale(1.7) rotate(-3deg); opacity: 0; filter: blur(3px); }
  16%  { transform: translate(-50%,-50%) scale(0.96) rotate(0.5deg); opacity: 1; filter: blur(0); }
  26%  { transform: translate(-50%,-50%) scale(1.04); }
  36%  { transform: translate(-50%,-50%) scale(1); }
  78%  { transform: translate(-50%,-50%) scale(1); opacity: 1; }
  100% { transform: translate(-50%,-62%) scale(1.02); opacity: 0; }
}
.ctk-luanwu-banner {
  position: absolute; left: 50%; top: 40%; transform: translate(-50%,-50%);
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  padding: 8px 18px 7px; pointer-events: none; z-index: 44; white-space: nowrap;
  background: linear-gradient(180deg, rgba(46,16,101,0.94) 0%, rgba(25,7,58,0.94) 100%);
  border: 2px solid #a78bfa; outline: 2px solid rgba(30,10,60,0.9);
  box-shadow: 0 0 0 4px rgba(124,58,237,0.35), 0 0 28px rgba(139,92,246,0.55), 0 6px 0 #0e0418;
  animation: ctkLuanwuBanner 1.75s cubic-bezier(0.2,0.9,0.3,1) both;
}
.ctk-luanwu-banner-title {
  display: inline-flex; align-items: center; gap: 8px;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 34px; line-height: 1;
  letter-spacing: 0.14em; text-transform: uppercase; color: #f5f3ff;
  text-shadow: 0 0 12px #a78bfa, 0 0 3px #c4b5fd, 2px 2px 0 #1e0a3c;
}
.ctk-luanwu-banner-title .ctk-viz-ico { display: inline-flex; color: #c4b5fd; filter: drop-shadow(0 0 5px #a78bfa); }
.ctk-luanwu-banner-title .ctk-viz-ico svg { width: 26px; height: 26px; image-rendering: pixelated; }
.ctk-luanwu-banner-sub {
  font-family: 'VT323', monospace; font-size: 17px; line-height: 1; letter-spacing: 0.06em;
  color: #ddd6fe; text-shadow: 1px 1px 0 #1e0a3c;
}
html[lang="th"] .ctk-luanwu-banner-title { font-family: "Bai Jamjuree", "Kanit", sans-serif; font-size: 26px; letter-spacing: 0.04em; text-transform: none; }
html[lang="th"] .ctk-luanwu-banner-sub   { font-family: "Bai Jamjuree", "Kanit", sans-serif; font-size: 14px; letter-spacing: 0; }
/* ===== Awakening (audit 2026-09, fix 1): the shared once-per-game signature
 * for Zhiji / Ruoyu / Zaoxian / Hunzi and Niepan's rise (vizAwaken). Same
 * family as the Warstorm / Soothsay banners; the accent comes from the fx
 * node's inline color (gold for the awakenings, phoenix orange for Rebirth)
 * and reaches the glows through currentColor. ===== */
.ctk-awaken-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 42; }
@keyframes ctkAwakenRing { from { transform: translate(-50%,-50%) scale(0.4) rotate(0deg); opacity: 0.9; } to { transform: translate(-50%,-50%) scale(3.2) rotate(90deg); opacity: 0; } }
.ctk-awaken-ring {
  position: absolute; left: 0; top: 0; width: 64px; height: 64px; margin: -32px 0 0 -32px;
  border: 3px dashed currentColor; border-radius: 50%; box-shadow: 0 0 14px currentColor;
  animation: ctkAwakenRing 1s cubic-bezier(0.2,0.8,0.3,1) 0.1s both;
}
.ctk-awaken-card {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  display: inline-flex; flex-direction: column; align-items: center; gap: 3px; white-space: nowrap;
  padding: 7px 12px; border-radius: 6px; background: rgba(12,10,6,0.92); border: 2px solid currentColor;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 17px; letter-spacing: 0.05em;
  box-shadow: 0 0 18px currentColor, 0 5px 0 rgba(0,0,0,0.5); animation: ctkRoleFlip 1.9s cubic-bezier(0.2,1.1,0.3,1) both;
}
.ctk-awaken-card .ctk-viz-ico { width: 24px; height: 24px; }
.ctk-awaken-card .ctk-viz-ico svg { width: 100%; height: 100%; display: block; }
.ctk-awaken-card-title { text-transform: uppercase; color: #fff7d6; text-shadow: 0 0 8px currentColor; }
.ctk-awaken-card-skill { font-size: 14px; letter-spacing: 0.03em; color: #fde68a; }
.ctk-awaken-banner {
  position: absolute; left: 50%; top: 40%; transform: translate(-50%,-50%);
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  padding: 8px 18px 7px; pointer-events: none; z-index: 44; white-space: nowrap;
  background: linear-gradient(180deg, rgba(64,44,10,0.95) 0%, rgba(34,22,4,0.95) 100%);
  border: 2px solid currentColor; outline: 2px solid rgba(30,20,4,0.9);
  box-shadow: 0 0 0 4px rgba(240,216,144,0.28), 0 0 28px currentColor, 0 6px 0 #150e02;
  animation: ctkLuanwuBanner 1.7s cubic-bezier(0.2,0.9,0.3,1) both;
}
.ctk-awaken-banner-title {
  display: inline-flex; align-items: center; gap: 8px;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 34px; line-height: 1;
  letter-spacing: 0.14em; text-transform: uppercase;
  text-shadow: 0 0 12px currentColor, 2px 2px 0 #2a1c04;
}
.ctk-awaken-banner-title > span:not(.ctk-viz-ico) { color: #fff7d6; }
.ctk-awaken-banner-title .ctk-viz-ico { display: inline-flex; filter: drop-shadow(0 0 5px currentColor); }
.ctk-awaken-banner-title .ctk-viz-ico svg { width: 26px; height: 26px; image-rendering: pixelated; }
.ctk-awaken-banner-sub {
  font-family: 'VT323', monospace; font-size: 17px; line-height: 1; letter-spacing: 0.06em;
  color: #fde68a; text-shadow: 1px 1px 0 #2a1c04;
}
html[lang="th"] .ctk-awaken-banner-title { font-family: "Bai Jamjuree", "Kanit", sans-serif; font-size: 26px; letter-spacing: 0.04em; text-transform: none; }
html[lang="th"] .ctk-awaken-banner-sub, html[lang="th"] .ctk-awaken-card { font-family: "Bai Jamjuree", "Kanit", sans-serif; letter-spacing: 0; }
html[lang="th"] .ctk-awaken-card-title { text-transform: none; }
/* The per-seat rim tick (cascade + "must attack…" prompt + the decline drain).
 * box-shadow only — seats are positioned via transform. */
@keyframes ctkLuanwuRim {
  0%   { box-shadow: 0 0 0 0 rgba(167,139,250,0); }
  30%  { box-shadow: 0 0 0 3px rgba(167,139,250,0.85), 0 0 16px rgba(139,92,246,0.7), inset 0 0 14px rgba(124,58,237,0.45); }
  100% { box-shadow: 0 0 0 0 rgba(167,139,250,0), 0 0 0 rgba(139,92,246,0), inset 0 0 0 rgba(124,58,237,0); }
}
.ctk-luanwu-rim { animation: ctkLuanwuRim 0.7s ease-out !important; }
/* The activation button: an ultimate should look like one before it fires —
 * a slow violet glow breathe + a sheen sweeping the face. ::before, because
 * .px-btn's ::after is the M3 hover state layer. */
@keyframes ctkLuanwuBtnGlow {
  0%, 100% { box-shadow: inset 1px 1px 0 rgba(233,213,255,0.25), 0 2px 0 #1e0a3c, 0 0 0 0 rgba(167,139,250,0.0), 0 0 8px rgba(139,92,246,0.35); }
  50%      { box-shadow: inset 1px 1px 0 rgba(233,213,255,0.25), 0 2px 0 #1e0a3c, 0 0 0 3px rgba(167,139,250,0.28), 0 0 18px rgba(139,92,246,0.75); }
}
@keyframes ctkLuanwuBtnSheen {
  0%, 55% { transform: translateX(-130%) skewX(-18deg); }
  100%    { transform: translateX(130%)  skewX(-18deg); }
}
.px-btn.px-btn-luanwu {
  background: linear-gradient(180deg, #8b5cf6 0%, #6d28d9 45%, #4c1d95 100%);
  border-color: #c4b5fd; color: #f5f3ff;
  text-shadow: 0 0 6px rgba(196,181,253,0.7), 1px 1px 0 #2e1065;
  animation: ctkLuanwuBtnGlow 1.9s ease-in-out infinite;
}
.px-btn.px-btn-luanwu:hover { background: linear-gradient(180deg, #a78bfa 0%, #7c3aed 45%, #5b21b6 100%); }
.px-btn.px-btn-luanwu::before {
  content: ""; position: absolute; top: -20%; bottom: -20%; left: 0; width: 45%;
  background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(245,243,255,0.45) 50%, rgba(255,255,255,0) 100%);
  animation: ctkLuanwuBtnSheen 2.8s ease-in-out infinite; pointer-events: none;
}
.px-btn.px-btn-luanwu svg { color: #ede9fe; filter: drop-shadow(0 0 3px #c4b5fd); }
/* The seat chip while the ultimate is still LIVE: a violet rim that breathes,
 * so the whole table can see the storm is still hanging over it. Spent →
 * the ordinary .skill-used dim (which wins: it is listed after). */
@keyframes ctkLuanwuChipBreathe {
  0%, 100% { box-shadow: 0 0 0 0 rgba(167,139,250,0.0), 0 0 4px rgba(139,92,246,0.35); }
  50%      { box-shadow: 0 0 0 1px rgba(167,139,250,0.45), 0 0 9px rgba(139,92,246,0.7); }
}
.px-badge.skill-limited {
  border-color: #8b5cf6; color: #ddd6fe; background: rgba(46,16,101,0.85);
  animation: ctkLuanwuChipBreathe 2.4s ease-in-out infinite;
}
/* The forced-choice picker for the seat on the hook: a violet callout. */
.picker-hint.ctk-luanwu-choice {
  display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px;
  border: 1px solid #8b5cf6; border-left-width: 4px; background: rgba(46,16,101,0.55); color: #ddd6fe;
}
.picker-hint.ctk-luanwu-choice .ctk-viz-ico svg { width: 14px; height: 14px; color: #c4b5fd; image-rendering: pixelated; }

/* ===== 左慈 Zuo Ci — 化身 Avatar / 新生 Renewal =============================
 * The revealed form is PUBLIC (亮出) and the seat's kingdom + gender already
 * mirror it on the wire, so "Zuo Ci · Shu" read as a contradiction with
 * nothing on the board to explain it. Three surfaces, one palette: Zuo Ci's
 * own mist is Qun violet (#c084fc); the FORM is painted in its own kingdom's
 * --k-lit, the same law the panel frame uses, so the colour of the beat is
 * the colour the seat wears afterwards. Renewal is jade (#6fd6a6) — the
 * flesh regrown, a heal-adjacent tint that is not the heal green. */

/* --- the seat medallion: the borrowed face pinned to the stage corner --- */
.ctk-huashen-form {
  position: absolute; top: 6px; right: 5px; z-index: 3; cursor: pointer;
  width: 34%; max-width: 52px; min-width: 22px;
  --k-lit: #a067c1; --k-frame: #482c59;
  transition: filter 0.12s ease-out;
}
.ctk-huashen-form[data-kingdom="shu"] { --k-lit: #c16d67; --k-frame: #592f2c; }
.ctk-huashen-form[data-kingdom="wei"] { --k-lit: #6794c1; --k-frame: #2c4259; }
.ctk-huashen-form[data-kingdom="wu"]  { --k-lit: #67c194; --k-frame: #2c5942; }
.ctk-huashen-form[data-kingdom="qun"] { --k-lit: #a067c1; --k-frame: #482c59; }
.ctk-huashen-form:hover, .ctk-huashen-form:focus-visible { filter: brightness(1.15) drop-shadow(0 0 4px rgba(232,212,168,0.5)); outline: none; }
.ctk-huashen-form-img {
  position: relative; display: block; width: 100%; aspect-ratio: 1 / 1; overflow: hidden;
  border: 2px solid var(--k-lit); background: var(--k-frame);
  box-shadow: 0 0 0 1px #0e0804, 0 2px 0 #0e0804, 0 0 8px rgba(0,0,0,0.6);
}
/* The thumbnail itself — shared by the medallion, the picker's form cards
   and the reveal placard. Same per-hero punch-in as the seat portrait. */
.ctk-hero-medallion-img {
  position: absolute; inset: 0; width: 100%; height: 100%; display: block; object-fit: cover;
  transform: scale(var(--hero-zoom, 1.3)); transform-origin: var(--hero-focus-x, 50%) var(--hero-focus-y, 34%);
}
.ctk-huashen-form-glyph {
  position: absolute; left: -6px; top: -5px; width: 16px; height: 16px; border-radius: 50%;
  background: #1a1208; border: 1px solid var(--k-lit); color: var(--k-lit);
  display: inline-flex; align-items: center; justify-content: center;
  box-shadow: 0 0 0 1px #0e0804;
}
.ctk-huashen-form-glyph svg { width: 10px; height: 10px; image-rendering: pixelated; }
/* The caption sits INSIDE the frame, on a dark band over the thumbnail's
   feet — below the box it collided with the seat's own "Zuo Ci · Shu" line. */
.ctk-huashen-form-name {
  position: absolute; left: 2px; right: 2px; bottom: 2px; z-index: 1; padding: 1px 2px;
  background: rgba(8,4,2,0.66); text-align: center;
  font-family: 'VT323', monospace; font-size: 10px; line-height: 1; color: #f0e0c8;
  text-shadow: 1px 1px 0 #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
html[lang="th"] .ctk-huashen-form-name { font-family: "Bai Jamjuree", "Kanit", sans-serif; font-size: 8px; font-weight: 600; }
/* Tight tiers: the face stays, the caption goes. */
@container (max-width: 110px) {
  .ctk-huashen-form { top: 3px; right: 3px; }
  .ctk-huashen-form-name { display: none; }
}
/* Face-down (Fangzhu / Jushou) turns the borrowed face over with the rest. */
.hero-stage.hero-flipped .ctk-huashen-form { filter: grayscale(0.55) brightness(0.82); }

/* --- the chips: Avatar (worn) sheens violet; the borrowed skill is dashed
   — on loan, it leaves with the form. Sealed (.skill-used) is listed after
   in the cascade and wins. --- */
@keyframes ctkHuashenChipSheen {
  0%, 100% { box-shadow: 0 0 3px rgba(192,132,252,0.3); }
  50%      { box-shadow: 0 0 0 1px rgba(216,180,254,0.5), 0 0 8px rgba(192,132,252,0.65); }
}
.px-badge.skill-avatar {
  border-color: #c084fc; color: #f3e8ff; background: rgba(59,7,100,0.8);
  animation: ctkHuashenChipSheen 3s ease-in-out infinite;
}
.px-badge.skill-borrowed { border-style: dashed; border-color: #d8b4fe; color: #f3e8ff; background: rgba(76,29,149,0.55); }

/* --- the pickers: form cards (step 1) and skill cards (step 2) --- */
.picker-hint.ctk-huashen-hint {
  display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px;
  border: 1px solid #c084fc; border-left-width: 4px; background: rgba(59,7,100,0.5); color: #f3e8ff;
}
.picker-hint.ctk-huashen-hint .ctk-viz-ico svg { width: 14px; height: 14px; color: #d8b4fe; image-rendering: pixelated; }
.px-btn.ctk-huashen-form-btn {
  --k-lit: #a067c1; --k-frame: #482c59;
  padding: 6px 12px 6px 6px; gap: 8px; text-align: left; border-color: var(--k-lit);
}
.px-btn.ctk-huashen-form-btn[data-kingdom="shu"] { --k-lit: #c16d67; --k-frame: #592f2c; }
.px-btn.ctk-huashen-form-btn[data-kingdom="wei"] { --k-lit: #6794c1; --k-frame: #2c4259; }
.px-btn.ctk-huashen-form-btn[data-kingdom="wu"]  { --k-lit: #67c194; --k-frame: #2c5942; }
.px-btn.ctk-huashen-form-btn[data-kingdom="qun"] { --k-lit: #a067c1; --k-frame: #482c59; }
.ctk-huashen-form-btn-img {
  position: relative; display: block; width: 46px; height: 46px; overflow: hidden; flex: none;
  border: 2px solid var(--k-lit); background: var(--k-frame); box-shadow: 0 0 0 1px #0e0804;
}
.ctk-huashen-form-btn-txt { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; min-width: 0; }
.ctk-huashen-form-btn-name { font-size: 18px; line-height: 1; color: #f8f0e0; }
.ctk-huashen-form-btn-sub { font-size: 13px; line-height: 1.1; color: #d4c4a0; opacity: 0.9; white-space: normal; max-width: 180px; text-align: left; }
.px-btn.ctk-huashen-form-btn.is-current {
  box-shadow: inset 0 0 0 1px var(--k-lit), 0 0 10px rgba(192,132,252,0.5), 0 2px 0 #0e0804;
}
.px-btn.ctk-huashen-form-btn.is-current .ctk-huashen-form-btn-sub { color: #d8b4fe; }
.px-btn.ctk-huashen-skill-btn {
  flex-direction: column; align-items: flex-start; gap: 4px; padding: 8px 12px;
  max-width: 260px; text-align: left; border-color: #8b5cf6;
}
.px-btn.ctk-huashen-skill-btn:hover { border-color: #c084fc; }
.ctk-huashen-skill-btn-name { display: inline-flex; align-items: center; gap: 6px; font-size: 18px; line-height: 1; color: #f3e8ff; }
.ctk-huashen-skill-btn-name svg { width: 15px; height: 15px; image-rendering: pixelated; color: #d8b4fe; flex: none; }
.ctk-huashen-skill-btn-desc {
  font-size: 13px; line-height: 1.15; color: #d4c4a0; white-space: normal; text-align: left;
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
html[lang="th"] .ctk-huashen-form-btn-sub, html[lang="th"] .ctk-huashen-skill-btn-desc { font-family: "Bai Jamjuree", "Kanit", sans-serif; font-size: 11px; }

/* --- the reveal beat: mist on the seat, portrait morph, the new-face placard --- */
.ctk-huashen-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 41; }
@keyframes ctkHuashenMistA {
  0%   { transform: translate(-50%,-50%) rotate(0deg)   scale(0.4); opacity: 0; }
  20%  { opacity: 0.9; }
  100% { transform: translate(-50%,-50%) rotate(200deg) scale(1.7); opacity: 0; }
}
@keyframes ctkHuashenMistB {
  0%   { transform: translate(-50%,-50%) rotate(0deg)    scale(0.3); opacity: 0; }
  25%  { opacity: 0.8; }
  100% { transform: translate(-50%,-50%) rotate(-160deg) scale(1.35); opacity: 0; }
}
.ctk-huashen-mist {
  position: absolute; left: 0; top: 0; width: 84px; height: 84px; border-radius: 50%;
  border: 3px solid currentColor; border-top-color: transparent; border-bottom-color: transparent;
  box-shadow: 0 0 12px currentColor; filter: blur(0.6px);
  animation: ctkHuashenMistA 1.15s cubic-bezier(0.2,0.8,0.3,1) forwards;
}
.ctk-huashen-mist-b {
  width: 58px; height: 58px; border-width: 4px; border-style: dotted; border-top-color: currentColor; border-bottom-color: currentColor;
  border-left-color: transparent; border-right-color: transparent;
  animation: ctkHuashenMistB 1.15s cubic-bezier(0.2,0.8,0.3,1) 0.08s forwards;
}
/* The seat: the portrait flickers through the mist (filter only — never a
   transform on the seat; the rim is box-shadow) and the medallion pops in
   once the face has changed. --huashen-tint is set per beat from JS. */
@keyframes ctkHuashenMorphRim {
  0%   { box-shadow: 0 0 0 0 rgba(192,132,252,0); }
  35%  { box-shadow: 0 0 0 3px var(--huashen-tint, #c084fc), 0 0 18px var(--huashen-tint, #c084fc); }
  100% { box-shadow: 0 0 0 0 rgba(192,132,252,0); }
}
@keyframes ctkHuashenMorphPortrait {
  0%   { filter: none; }
  30%  { filter: blur(2px) brightness(1.7) saturate(0.2); }
  55%  { filter: blur(1px) brightness(1.2) hue-rotate(40deg); }
  100% { filter: none; }
}
@keyframes ctkHuashenMedallionPop {
  0%   { transform: scale(0.4) rotate(-12deg); opacity: 0; }
  55%  { transform: scale(1.18) rotate(3deg); opacity: 1; }
  100% { transform: none; opacity: 1; }
}
.ctk-huashen-morph { animation: ctkHuashenMorphRim 1s ease-out !important; }
.ctk-huashen-morph .hero-portrait { animation: ctkHuashenMorphPortrait 0.9s ease-out; }
.ctk-huashen-morph .ctk-huashen-form { animation: ctkHuashenMedallionPop 0.7s cubic-bezier(0.2,1.1,0.3,1) 0.35s both; }
/* The placard: the new face + AVATAR + the form's name + the borrowed skill,
   flipped up on the seat like the role placard, in the form's colour. */
@keyframes ctkHuashenCard {
  0%   { transform: translate(-50%,-50%) rotateY(90deg) scale(0.7); opacity: 0; }
  28%  { transform: translate(-50%,-50%) rotateY(0deg)  scale(1.1); opacity: 1; }
  42%  { transform: translate(-50%,-50%) scale(0.98); }
  80%  { transform: translate(-50%,-50%) scale(1); opacity: 1; }
  100% { transform: translate(-50%,-64%) scale(1); opacity: 0; }
}
.ctk-huashen-card {
  --hs-tint: #c084fc;
  position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 43;
  display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 6px; border-radius: 6px;
  background: rgba(12,6,20,0.93); border: 2px solid var(--hs-tint); color: var(--hs-tint);
  box-shadow: 0 0 16px var(--hs-tint), 0 4px 0 #0e0418; white-space: nowrap;
  animation: ctkHuashenCard 1.9s cubic-bezier(0.2,1.1,0.3,1) both;
}
.ctk-huashen-card-img { position: relative; display: block; width: 42px; height: 42px; overflow: hidden; flex: none; border: 2px solid var(--hs-tint); background: #1a1208; }
.ctk-huashen-card-txt { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; font-family: 'VT323', monospace; line-height: 1; }
.ctk-huashen-card-cap { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--hs-tint); }
.ctk-huashen-card-cap .ctk-viz-ico svg { width: 11px; height: 11px; }
.ctk-huashen-card-name { font-size: 21px; font-weight: bold; color: #fdf4ff; text-shadow: 0 0 8px var(--hs-tint), 1px 1px 0 #1e0a3c; }
.ctk-huashen-card-skill { font-size: 14px; color: #e9d5ff; }
html[lang="th"] .ctk-huashen-card-txt { font-family: "Bai Jamjuree", "Kanit", sans-serif; }
html[lang="th"] .ctk-huashen-card-cap { font-size: 10px; letter-spacing: 0.04em; text-transform: none; }
html[lang="th"] .ctk-huashen-card-name { font-size: 16px; }
html[lang="th"] .ctk-huashen-card-skill { font-size: 12px; }
/* The turn-start / turn-end offer: a whisper of violet on Zuo Ci. */
@keyframes ctkHuashenOfferRim {
  0%, 100% { box-shadow: none; }
  50%      { box-shadow: 0 0 0 2px rgba(192,132,252,0.45), 0 0 12px rgba(192,132,252,0.4); }
}
.ctk-huashen-rim { animation: ctkHuashenOfferRim 0.9s ease-in-out !important; }

/* --- Renewal: a face-down hero card rises from the centre to the seat --- */
@keyframes ctkXinshengCard {
  0%   { transform: translate(-50%,-50%) scale(0.5) rotateY(80deg); opacity: 0; }
  22%  { transform: translate(-50%,-50%) scale(1)   rotateY(0deg);  opacity: 1; }
  78%  { transform: translate(calc(-50% + var(--dx,0px)), calc(-50% + var(--dy,0px))) scale(1)   rotate(4deg); opacity: 1; }
  100% { transform: translate(calc(-50% + var(--dx,0px)), calc(-50% + var(--dy,0px))) scale(0.6) rotate(0deg); opacity: 0; }
}
.ctk-xinsheng-card {
  position: absolute; width: 26px; height: 36px; border-radius: 3px; pointer-events: none; z-index: 40;
  display: inline-flex; align-items: center; justify-content: center; color: #d1fae5;
  background: linear-gradient(160deg, #2c5942 0%, #123326 100%); border: 1px solid #6fd6a6;
  box-shadow: 0 0 10px rgba(111,214,166,0.7), inset 0 0 4px rgba(167,243,208,0.4);
  animation: ctkXinshengCard 0.8s cubic-bezier(0.2,0.8,0.2,1) forwards;
}
.ctk-xinsheng-card::after { content: ''; position: absolute; inset: 3px; border: 1px solid rgba(167,243,208,0.45); border-radius: 2px; }
.ctk-xinsheng-card .ctk-viz-ico svg { width: 14px; height: 14px; }
@keyframes ctkXinshengRim {
  0%   { box-shadow: none; }
  35%  { box-shadow: 0 0 0 3px rgba(111,214,166,0.7), 0 0 16px rgba(111,214,166,0.55); }
  100% { box-shadow: none; }
}
.ctk-xinsheng-rim { animation: ctkXinshengRim 0.7s ease-out !important; }
/* ===== 蛊惑 Soothsay (Yu Ji) — the face-down bluff and its table-wide doubt
 * poll (renderGuhuoPollHtml / vizGuhuoClaim / vizGuhuoVerdict /
 * vizGuhuoReveal in viz-floats.ts). Indigo mist + a "?" card back, so it
 * reads as sorcery beside Warstorm's violet and never as a plain prompt. ===== */
.ctk-guhuo-panel {
  width: 100%; box-sizing: border-box; padding: 8px 10px 9px; margin: 2px 0;
  border: 1px solid #4f46e5; border-left-width: 4px; border-radius: 8px;
  background:
    radial-gradient(ellipse at 0% 0%, rgba(99,102,241,0.22) 0%, rgba(30,27,75,0) 55%),
    linear-gradient(180deg, rgba(30,27,75,0.72) 0%, rgba(17,14,48,0.82) 100%);
  box-shadow: 0 0 0 1px rgba(30,27,75,0.9), 0 0 16px rgba(99,102,241,0.22), 0 3px 0 #0b0820;
  display: flex; flex-direction: column; gap: 7px; position: relative; overflow: hidden;
}
.ctk-guhuo-panel::before {          /* drifting mist across the panel */
  content: ""; position: absolute; inset: -40% -20%; pointer-events: none;
  background: radial-gradient(ellipse at 30% 50%, rgba(165,180,252,0.10) 0%, rgba(165,180,252,0) 45%),
              radial-gradient(ellipse at 75% 40%, rgba(129,140,248,0.08) 0%, rgba(129,140,248,0) 40%);
  animation: ctkGuhuoMist 9s ease-in-out infinite alternate;
}
@keyframes ctkGuhuoMist { from { transform: translateX(-6%) } to { transform: translateX(6%) } }
.ctk-guhuo-panel > * { position: relative; }
.ctk-guhuo-head { display: flex; align-items: center; gap: 10px; }
.ctk-guhuo-headtext { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ctk-guhuo-title {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: 'VT323', monospace; font-size: 20px; line-height: 1; letter-spacing: 0.12em; text-transform: uppercase;
  color: #e0e7ff; text-shadow: 0 0 10px rgba(129,140,248,0.8), 1px 1px 0 #0b0820;
}
.ctk-guhuo-title .ctk-viz-ico { display: inline-flex; color: #a5b4fc; filter: drop-shadow(0 0 4px #818cf8); }
.ctk-guhuo-title .ctk-viz-ico svg { width: 16px; height: 16px; image-rendering: pixelated; }
html[lang="th"] .ctk-guhuo-title { font-family: "Bai Jamjuree", "Kanit", sans-serif; font-size: 16px; letter-spacing: 0.02em; text-transform: none; font-weight: 700; }
.ctk-guhuo-prompt { color: #eef2ff; font-size: 13px; line-height: 1.3; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.ctk-guhuo-rule { color: #a5b4fc; font-size: 11.5px; line-height: 1.3; }
/* The face-down card: a slow hover + a soft indigo pulse, the "?" glyph on top. */
@keyframes ctkGuhuoBackHover { 0%, 100% { transform: translateY(0) rotate(-4deg); } 50% { transform: translateY(-3px) rotate(-2deg); } }
@keyframes ctkGuhuoBackGlow  { 0%, 100% { box-shadow: 0 0 0 1px #1e1b4b, 0 0 8px rgba(129,140,248,0.35), 0 3px 0 #0b0820; } 50% { box-shadow: 0 0 0 1px #1e1b4b, 0 0 16px rgba(129,140,248,0.8), 0 3px 0 #0b0820; } }
.ctk-guhuo-back {
  flex: none; width: 38px; height: 52px; border-radius: 5px; border: 2px solid #c7d2fe;
  background:
    repeating-linear-gradient(135deg, rgba(199,210,254,0.18) 0 3px, rgba(199,210,254,0) 3px 8px),
    linear-gradient(180deg, #4338ca 0%, #312e81 60%, #1e1b4b 100%);
  display: flex; align-items: center; justify-content: center;
  animation: ctkGuhuoBackHover 3.2s ease-in-out infinite, ctkGuhuoBackGlow 2.6s ease-in-out infinite;
}
.ctk-guhuo-back-glyph {
  font-family: 'VT323', monospace; font-size: 30px; line-height: 1; color: #eef2ff;
  text-shadow: 0 0 8px #a5b4fc, 1px 1px 0 #0b0820;
}
.ctk-guhuo-group { display: flex; flex-direction: column; gap: 3px; }
.ctk-guhuo-group-label { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: #a5b4fc; }
.ctk-guhuo-panel .picker-row { border-left-color: #6366f1; }
.ctk-guhuo-foot { display: flex; justify-content: flex-end; }
.ctk-guhuo-claimcard {
  display: inline-block; padding: 1px 8px; border-radius: 4px; font-weight: 700; color: #1e1b4b;
  background: linear-gradient(180deg, #fde68a 0%, #f59e0b 100%); border: 1px solid #b45309;
  box-shadow: 0 1px 0 #0b0820; white-space: nowrap;
}
.ctk-guhuo-claimarrow { color: #a5b4fc; }
.ctk-guhuo-seatno {
  display: inline-block; padding: 0 5px; border-radius: 3px; font-family: 'VT323', monospace; font-size: 14px; line-height: 1.2;
  color: #e0e7ff; background: rgba(79,70,229,0.45); border: 1px solid #6366f1; margin-right: 4px;
}
.ctk-guhuo-seatname { color: #eef2ff; }
.px-btn.ctk-guhuo-seat-btn { gap: 2px; border-color: #6366f1; }
/* The Sworn Record: how many claims this seat has put on the table that nobody
   examined. Sits under the claim, above the verdict chips — evidence, not a
   verdict, so it reads muted rather than as another chip. */
.ctk-guhuo-record {
  font-family: 'VT323', monospace; font-size: 14px; line-height: 1.3;
  color: #c7d2fe; opacity: 0.85; margin: 2px 0 6px; letter-spacing: 0.02em;
}
/* The verdict chips: one per other living seat, filling in as answers land. */
.ctk-guhuo-seats { display: flex; flex-wrap: wrap; gap: 6px; }
.ctk-guhuo-chip {
  display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 6px;
  border: 1px solid #4f46e5; background: rgba(30,27,75,0.6); font-size: 12px; color: #c7d2fe; line-height: 1;
  transition: border-color 0.25s ease-out, background 0.25s ease-out;
}
.ctk-guhuo-chip .ctk-guhuo-seatno { margin-right: 0; }
.ctk-guhuo-chip-verdict { display: inline-flex; align-items: center; gap: 3px; margin-left: 2px; padding-left: 6px; border-left: 1px solid rgba(165,180,252,0.35); }
.ctk-guhuo-chip-verdict .ctk-viz-ico { display: inline-flex; }
.ctk-guhuo-chip-verdict .ctk-viz-ico svg { width: 12px; height: 12px; image-rendering: pixelated; }
@keyframes ctkGuhuoDeciding { 0%, 100% { box-shadow: 0 0 0 0 rgba(129,140,248,0); } 50% { box-shadow: 0 0 0 2px rgba(129,140,248,0.55), 0 0 10px rgba(99,102,241,0.6); } }
.ctk-guhuo-chip.is-deciding { animation: ctkGuhuoDeciding 1.6s ease-in-out infinite; }
.ctk-guhuo-chip.is-deciding .ctk-guhuo-chip-verdict { color: #a5b4fc; font-style: italic; }
@keyframes ctkGuhuoVerdictIn { 0% { transform: scale(0.85); filter: brightness(1.8); } 100% { transform: scale(1); filter: brightness(1); } }
.ctk-guhuo-chip.is-doubt { border-color: #ef4444; background: rgba(127,29,29,0.5); color: #fecaca; animation: ctkGuhuoVerdictIn 0.35s ease-out both; }
.ctk-guhuo-chip.is-doubt .ctk-guhuo-chip-verdict { color: #fca5a5; border-left-color: rgba(252,165,165,0.4); font-weight: 700; }
.ctk-guhuo-chip.is-pass  { border-color: #22c55e; background: rgba(20,83,45,0.45); color: #bbf7d0; animation: ctkGuhuoVerdictIn 0.35s ease-out both; }
.ctk-guhuo-chip.is-pass .ctk-guhuo-chip-verdict { color: #86efac; border-left-color: rgba(134,239,172,0.4); }
.ctk-guhuo-chip.is-me { outline: 2px solid #d9b45a; outline-offset: 1px; }
/* The two verdict buttons — big, opposite colours, side by side. */
.ctk-guhuo-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.ctk-guhuo-actions .px-btn { flex: 1 1 140px; font-size: 18px; min-height: 48px; }
.ctk-guhuo-actions .px-btn .ctk-viz-ico { display: inline-flex; margin-right: 2px; }
.ctk-guhuo-actions .px-btn .ctk-viz-ico svg { width: 16px; height: 16px; image-rendering: pixelated; }
.px-btn.px-btn-guhuo-doubt {
  background: linear-gradient(180deg, #ef4444 0%, #b91c1c 55%, #7f1d1d 100%); border-color: #fca5a5; color: #fff5f5;
  text-shadow: 1px 1px 0 #450a0a;
}
.px-btn.px-btn-guhuo-doubt:hover { background: linear-gradient(180deg, #f87171 0%, #dc2626 55%, #991b1b 100%); }
.px-btn.px-btn-guhuo-pass {
  background: linear-gradient(180deg, #22c55e 0%, #15803d 55%, #14532d 100%); border-color: #86efac; color: #f0fdf4;
  text-shadow: 1px 1px 0 #052e16;
}
.px-btn.px-btn-guhuo-pass:hover { background: linear-gradient(180deg, #4ade80 0%, #16a34a 55%, #166534 100%); }
.ctk-guhuo-status { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; font-size: 12.5px; color: #c7d2fe; }
.ctk-guhuo-status-mine { color: #eef2ff; font-weight: 700; }
.ctk-guhuo-status-wait { color: #a5b4fc; font-style: italic; }
/* The activation button: indigo mist + a slow sheen, the "?" glyph. */
@keyframes ctkGuhuoBtnGlow {
  0%, 100% { box-shadow: inset 1px 1px 0 rgba(224,231,255,0.25), 0 2px 0 #0b0820, 0 0 0 0 rgba(129,140,248,0), 0 0 8px rgba(99,102,241,0.35); }
  50%      { box-shadow: inset 1px 1px 0 rgba(224,231,255,0.25), 0 2px 0 #0b0820, 0 0 0 3px rgba(129,140,248,0.25), 0 0 18px rgba(99,102,241,0.7); }
}
.px-btn.px-btn-guhuo {
  background: linear-gradient(180deg, #6366f1 0%, #4338ca 50%, #312e81 100%); border-color: #c7d2fe; color: #eef2ff;
  text-shadow: 0 0 6px rgba(165,180,252,0.7), 1px 1px 0 #1e1b4b;
  animation: ctkGuhuoBtnGlow 2.4s ease-in-out infinite;
}
.px-btn.px-btn-guhuo:hover { background: linear-gradient(180deg, #818cf8 0%, #4f46e5 50%, #3730a3 100%); }
.px-btn.px-btn-guhuo::before {
  content: ""; position: absolute; top: -20%; bottom: -20%; left: 0; width: 45%;
  background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(238,242,255,0.4) 50%, rgba(255,255,255,0) 100%);
  animation: ctkLuanwuBtnSheen 3.4s ease-in-out infinite; pointer-events: none;
}
.px-btn.px-btn-guhuo .ctk-viz-ico { display: inline-flex; }
.px-btn.px-btn-guhuo svg { width: 16px; height: 16px; color: #e0e7ff; filter: drop-shadow(0 0 3px #a5b4fc); image-rendering: pixelated; }
/* The rescue / Lord-relay poll strip: same chips, a per-type accent. */
.ctk-poll-strip {
  width: 100%; box-sizing: border-box; padding: 7px 10px 8px; margin: 4px 0 2px;
  border: 1px solid #6b5530; border-left-width: 4px; border-radius: 8px;
  background: linear-gradient(180deg, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.34) 100%);
  display: flex; flex-direction: column; gap: 6px;
}
.ctk-poll-strip.ctk-poll-dying-rescue { border-color: #ef4444; background: linear-gradient(180deg, rgba(127,29,29,0.30) 0%, rgba(69,10,10,0.40) 100%); }
.ctk-poll-strip.ctk-poll-jijiang, .ctk-poll-strip.ctk-poll-hujia { border-color: #f6c453; background: linear-gradient(180deg, rgba(120,90,20,0.28) 0%, rgba(60,44,10,0.40) 100%); }
.ctk-poll-head { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #f1e6c8; line-height: 1.3; }
.ctk-poll-head .ctk-viz-ico { display: inline-flex; flex: none; }
.ctk-poll-head .ctk-viz-ico svg { width: 14px; height: 14px; image-rendering: pixelated; }
.ctk-poll-dying-rescue .ctk-poll-head .ctk-viz-ico { color: #fca5a5; filter: drop-shadow(0 0 4px #ef4444); }
.ctk-poll-jijiang .ctk-poll-head .ctk-viz-ico, .ctk-poll-hujia .ctk-poll-head .ctk-viz-ico { color: #fde68a; filter: drop-shadow(0 0 4px #f59e0b); }
.ctk-poll-dying-rescue .ctk-guhuo-chip { border-color: #b91c1c; }
.ctk-poll-jijiang .ctk-guhuo-chip, .ctk-poll-hujia .ctk-guhuo-chip { border-color: #b45309; }
/* ---- arena beats ---- */
/* The claim: an indigo wash, a face-down card flying from the claimant to
 * the centre, and a SOOTHSAY banner naming what it is sworn to be. */
@keyframes ctkGuhuoWash { 0% { opacity: 0; } 20% { opacity: 1; } 75% { opacity: 0.7; } 100% { opacity: 0; } }
.ctk-guhuo-wash {
  position: absolute; inset: 0; pointer-events: none; z-index: 37;
  background: radial-gradient(ellipse at center, rgba(99,102,241,0.08) 0%, rgba(49,46,129,0.30) 60%, rgba(17,14,48,0.58) 100%);
  animation: ctkGuhuoWash 1.5s ease-out forwards;
}
.ctk-guhuo-fly {
  position: absolute; left: 0; top: 0; width: 38px; height: 52px; border-radius: 5px; border: 2px solid #c7d2fe;
  background:
    repeating-linear-gradient(135deg, rgba(199,210,254,0.18) 0 3px, rgba(199,210,254,0) 3px 8px),
    linear-gradient(180deg, #4338ca 0%, #312e81 60%, #1e1b4b 100%);
  box-shadow: 0 0 14px rgba(129,140,248,0.7), 0 4px 0 #0b0820;
  display: flex; align-items: center; justify-content: center; pointer-events: none; z-index: 43;
  font-family: 'VT323', monospace; font-size: 30px; color: #eef2ff; text-shadow: 0 0 8px #a5b4fc;
  transform: translate(-50%,-50%);
  animation: ctkGuhuoFly 0.9s cubic-bezier(0.2,0.9,0.3,1) both, ctkGuhuoFlySettle 0.9s ease-out 0.9s forwards;
}
@keyframes ctkGuhuoFly {
  0%   { transform: translate(calc(-50% + var(--gh-dx)), calc(-50% + var(--gh-dy))) rotate(-18deg) scale(0.7); opacity: 0; }
  15%  { opacity: 1; }
  100% { transform: translate(-50%,-50%) rotate(-4deg) scale(1); opacity: 1; }
}
@keyframes ctkGuhuoFlySettle { 0% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; } }
@keyframes ctkGuhuoBanner {
  0%   { transform: translate(-50%,-50%) scale(1.5); opacity: 0; filter: blur(3px); }
  18%  { transform: translate(-50%,-50%) scale(0.97); opacity: 1; filter: blur(0); }
  28%  { transform: translate(-50%,-50%) scale(1.03); }
  38%  { transform: translate(-50%,-50%) scale(1); }
  80%  { transform: translate(-50%,-50%) scale(1); opacity: 1; }
  100% { transform: translate(-50%,-60%) scale(1.02); opacity: 0; }
}
.ctk-guhuo-banner {
  position: absolute; left: 50%; top: 40%; transform: translate(-50%,-50%);
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  padding: 8px 18px 7px; pointer-events: none; z-index: 44; white-space: nowrap;
  background: linear-gradient(180deg, rgba(49,46,129,0.94) 0%, rgba(17,14,48,0.95) 100%);
  border: 2px solid #818cf8; outline: 2px solid rgba(17,14,48,0.9);
  box-shadow: 0 0 0 4px rgba(99,102,241,0.3), 0 0 28px rgba(129,140,248,0.5), 0 6px 0 #0b0820;
  animation: ctkGuhuoBanner 1.9s cubic-bezier(0.2,0.9,0.3,1) both;
}
.ctk-guhuo-banner-title {
  display: inline-flex; align-items: center; gap: 8px;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 32px; line-height: 1;
  letter-spacing: 0.14em; text-transform: uppercase; color: #eef2ff;
  text-shadow: 0 0 12px #818cf8, 0 0 3px #a5b4fc, 2px 2px 0 #1e1b4b;
}
.ctk-guhuo-banner-title .ctk-viz-ico { display: inline-flex; color: #a5b4fc; filter: drop-shadow(0 0 5px #818cf8); }
.ctk-guhuo-banner-title .ctk-viz-ico svg { width: 24px; height: 24px; image-rendering: pixelated; }
.ctk-guhuo-banner-sub { font-family: 'VT323', monospace; font-size: 17px; line-height: 1; letter-spacing: 0.05em; color: #c7d2fe; text-shadow: 1px 1px 0 #1e1b4b; }
.ctk-guhuo-banner-sub b { color: #fde68a; }
html[lang="th"] .ctk-guhuo-banner-title { font-family: "Bai Jamjuree", "Kanit", sans-serif; font-size: 24px; letter-spacing: 0.04em; text-transform: none; }
html[lang="th"] .ctk-guhuo-banner-sub   { font-family: "Bai Jamjuree", "Kanit", sans-serif; font-size: 14px; letter-spacing: 0; }
/* Verdict rims on the answering seats (box-shadow only — seats are translated). */
@keyframes ctkGuhuoRimDoubt {
  0%   { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
  30%  { box-shadow: 0 0 0 3px rgba(239,68,68,0.9), 0 0 16px rgba(239,68,68,0.7), inset 0 0 14px rgba(185,28,28,0.45); }
  100% { box-shadow: 0 0 0 0 rgba(239,68,68,0), 0 0 0 rgba(239,68,68,0), inset 0 0 0 rgba(185,28,28,0); }
}
@keyframes ctkGuhuoRimPass {
  0%   { box-shadow: 0 0 0 0 rgba(34,197,94,0); }
  30%  { box-shadow: 0 0 0 2px rgba(34,197,94,0.7), 0 0 12px rgba(34,197,94,0.5); }
  100% { box-shadow: 0 0 0 0 rgba(34,197,94,0), 0 0 0 rgba(34,197,94,0); }
}
.ctk-guhuo-rim-doubt { animation: ctkGuhuoRimDoubt 0.7s ease-out !important; }
.ctk-guhuo-rim-pass  { animation: ctkGuhuoRimPass 0.6s ease-out !important; }
/* The reveal: the centre card turns over (back → face) and a TRUE / FALSE
 * stamp slams in over it. */
.ctk-guhuo-reveal { position: absolute; left: 50%; top: 40%; transform: translate(-50%,-50%); pointer-events: none; z-index: 44; perspective: 400px; }
.ctk-guhuo-reveal-card {
  position: relative; width: 52px; height: 72px; transform-style: preserve-3d;
  animation: ctkGuhuoFlipCard 1.8s cubic-bezier(0.3,0.9,0.3,1) both;
}
@keyframes ctkGuhuoFlipCard {
  0%   { transform: rotateY(0deg) scale(0.9); opacity: 0; }
  12%  { transform: rotateY(0deg) scale(1.1); opacity: 1; }
  30%  { transform: rotateY(0deg) scale(1.1); }
  55%  { transform: rotateY(180deg) scale(1.15); }
  85%  { transform: rotateY(180deg) scale(1.1); opacity: 1; }
  100% { transform: rotateY(180deg) scale(1.1); opacity: 0; }
}
.ctk-guhuo-reveal-face, .ctk-guhuo-reveal-back {
  position: absolute; inset: 0; border-radius: 6px; backface-visibility: hidden;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
  font-family: 'VT323', monospace; font-weight: bold; line-height: 1;
}
.ctk-guhuo-reveal-back {
  border: 2px solid #c7d2fe; color: #eef2ff; font-size: 34px; text-shadow: 0 0 8px #a5b4fc;
  background:
    repeating-linear-gradient(135deg, rgba(199,210,254,0.18) 0 3px, rgba(199,210,254,0) 3px 8px),
    linear-gradient(180deg, #4338ca 0%, #312e81 60%, #1e1b4b 100%);
  box-shadow: 0 0 16px rgba(129,140,248,0.7);
}
.ctk-guhuo-reveal-face {
  transform: rotateY(180deg); background: #f5efe0; border: 2px solid #2a2218; box-shadow: 0 0 16px rgba(0,0,0,0.55);
}
.ctk-guhuo-reveal-face .ctk-guhuo-reveal-pip { font-size: 24px; }
.ctk-guhuo-reveal-face .ctk-guhuo-reveal-name { font-size: 11px; color: #2a2218; max-width: 48px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ctk-guhuo-reveal.is-true .ctk-guhuo-reveal-face { border-color: #22c55e; box-shadow: 0 0 18px #22c55e; }
.ctk-guhuo-reveal.is-false .ctk-guhuo-reveal-face { border-color: #ef4444; box-shadow: 0 0 18px #ef4444; }
@keyframes ctkGuhuoStamp {
  0%, 50% { opacity: 0; transform: translate(-50%,-50%) rotate(-14deg) scale(2.2); }
  62%     { opacity: 1; transform: translate(-50%,-50%) rotate(-12deg) scale(0.95); }
  70%     { transform: translate(-50%,-50%) rotate(-12deg) scale(1.05); }
  88%     { opacity: 1; transform: translate(-50%,-50%) rotate(-12deg) scale(1); }
  100%    { opacity: 0; transform: translate(-50%,-50%) rotate(-12deg) scale(1); }
}
.ctk-guhuo-stamp {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%) rotate(-12deg);
  padding: 2px 8px; border: 3px solid currentColor; border-radius: 4px; white-space: nowrap;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 28px; line-height: 1; letter-spacing: 0.12em;
  background: rgba(17,14,48,0.75); text-shadow: 0 0 10px currentColor;
  animation: ctkGuhuoStamp 1.8s ease-out both;
}
html[lang="th"] .ctk-guhuo-stamp { font-family: "Bai Jamjuree", "Kanit", sans-serif; font-size: 22px; letter-spacing: 0.02em; }
.ctk-guhuo-reveal.is-true  .ctk-guhuo-stamp { color: #4ade80; }
.ctk-guhuo-reveal.is-false .ctk-guhuo-stamp { color: #f87171; }
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-guhuo-panel::before,
  :where(html:not(.ctk-force-fx)) .ctk-guhuo-back,
  :where(html:not(.ctk-force-fx)) .ctk-guhuo-chip.is-deciding,
  :where(html:not(.ctk-force-fx)) .px-btn.px-btn-guhuo,
  :where(html:not(.ctk-force-fx)) .px-btn.px-btn-guhuo::before { animation: none !important; }
  :where(html:not(.ctk-force-fx)) .ctk-guhuo-fly { animation: ctkGuhuoFlySettle 1.2s ease-out both !important; transform: translate(-50%,-50%); }
  :where(html:not(.ctk-force-fx)) .ctk-guhuo-banner { animation: ctkGuhuoWash 1.9s ease-out both !important; }
  :where(html:not(.ctk-force-fx)) .ctk-guhuo-reveal-card { animation: none !important; transform: rotateY(180deg); }
  :where(html:not(.ctk-force-fx)) .ctk-guhuo-stamp { animation: ctkGuhuoWash 1.8s ease-out both !important; }
  :where(html:not(.ctk-force-fx)) .ctk-guhuo-rim-doubt,
  :where(html:not(.ctk-force-fx)) .ctk-guhuo-rim-pass { animation: none !important; }
}
/* Mashu (Ma Chao): SUBTLE — tan gallop dust + faint "+1 Range" chip. */
.ctk-mashu-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 39; color: #d6b370; }
@keyframes ctkMashuDust {
  0%   { transform: translate(0,0) scale(0.5); opacity: 0; }
  25%  { opacity: 0.7; }
  100% { transform: translate(var(--mx), var(--my)) scale(1.1); opacity: 0; }
}
.ctk-mashu-dust {
  position: absolute; left: 0; top: 0; width: 6px; height: 6px; margin: -3px 0 0 -3px;
  border-radius: 50%; background: #d6b370; opacity: 0;
  animation: ctkMashuDust 0.62s ease-out forwards;
}
@keyframes ctkMashuChip {
  0%   { transform: translate(-50%,-50%) translateY(4px); opacity: 0; }
  30%  { transform: translate(-50%,-50%) translateY(-4px); opacity: 1; }
  75%  { opacity: 1; }
  100% { transform: translate(-50%,-50%) translateY(-16px); opacity: 0; }
}
.ctk-mashu-chip {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
  padding: 1px 7px; border-radius: 5px; background: rgba(18,12,4,0.82); border: 1px solid #d6b370; color: #d6b370;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 13px; letter-spacing: 0.05em;
  box-shadow: 0 0 8px rgba(214,179,112,0.35);
  animation: ctkMashuChip 0.72s ease-out both;
}
.ctk-mashu-chip .ctk-viz-ico { width: 12px; height: 12px; }
.ctk-mashu-chip .ctk-viz-ico svg { width: 100%; height: 100%; display: block; }
/* Jianxiong / Usurper (Cao Cao): seized card flies centre→seat, pops,
 * violet sparkles + USURP chip over a box-shadow rim pulse (NOT transform). */
@keyframes ctkJianxiongFly {
  0%   { transform: translate(-50%,-50%) scale(0.7) rotate(-10deg); opacity: 0; }
  12%  { opacity: 1; }
  70%  { transform: translate(calc(-50% + var(--dx) * 0.72), calc(-50% + var(--dy) * 0.72 - 14px)) scale(1) rotate(4deg); opacity: 1; }
  88%  { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(1.22) rotate(0deg); opacity: 1; }
  100% { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(1) rotate(0deg); opacity: 1; }
}
.ctk-jianxiong-card {
  position: absolute; left: 0; top: 0; width: 22px; height: 22px;
  pointer-events: none; z-index: 42; color: #a855f7;
  filter: drop-shadow(0 0 5px #a855f7);
  animation: ctkJianxiongFly 0.52s cubic-bezier(0.3,0.85,0.35,1.15) forwards;
}
.ctk-jianxiong-card svg { width: 100%; height: 100%; display: block; }
@keyframes ctkJianxiongRim { 0%,100% { box-shadow: none; } 45% { box-shadow: 0 0 0 3px rgba(126,34,206,0.75), inset 0 0 12px rgba(168,85,247,0.4); } }
.ctk-jianxiong-rim { animation: ctkJianxiongRim 0.6s ease-out !important; }
@keyframes ctkJianxiongSpark { 0% { transform: translate(-50%,-50%) scale(0.4); opacity: 0; } 25% { opacity: 1; } 100% { transform: translate(calc(-50% + var(--sx)), calc(-50% + var(--sy))) scale(0.9); opacity: 0; } }
.ctk-jianxiong-spark {
  position: absolute; left: 0; top: 0; width: 5px; height: 5px; border-radius: 1px;
  background: #c4b5fd; box-shadow: 0 0 5px #a855f7; pointer-events: none; z-index: 42;
  animation: ctkJianxiongSpark 0.5s ease-out forwards;
}
@keyframes ctkJianxiongStamp { 0% { transform: translate(-50%,-50%) scale(1.5) rotate(-6deg); opacity: 0; } 40% { transform: translate(-50%,-50%) scale(1) rotate(-3deg); opacity: 1; } 82% { opacity: 1; } 100% { transform: translate(-50%,-50%) scale(1) rotate(-3deg); opacity: 0; } }
.ctk-jianxiong-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 43; color: #c4b5fd; }
.ctk-jianxiong-label {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
  padding: 2px 8px; border-radius: 5px; background: rgba(24,8,36,0.88); border: 1px solid #a855f7; color: #d8c7ff;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 15px; letter-spacing: 0.06em;
  box-shadow: 0 0 10px rgba(168,85,247,0.55); animation: ctkJianxiongStamp 0.9s ease-out both;
}
.ctk-jianxiong-label .ctk-viz-ico { width: 14px; height: 14px; }
.ctk-jianxiong-label .ctk-viz-ico svg { width: 100%; height: 100%; display: block; }
/* Fankui (Sima Yi): violet card yanked source→Sima Yi along the reverse
 * arrow; a grey whiff ring on a miss. (No seat recoil — seats use a
 * positioning transform, so animating it would move the seat.) */
@keyframes ctkFankuiYank {
  0%   { transform: translate(-50%,-50%) scale(0.7) rotate(-10deg); opacity: 0; }
  18%  { opacity: 1; }
  82%  { transform: translate(calc(-50% + var(--dx,0px)), calc(-50% + var(--dy,0px))) scale(1) rotate(6deg); opacity: 1; }
  100% { transform: translate(calc(-50% + var(--dx,0px)), calc(-50% + var(--dy,0px))) scale(0.86) rotate(0deg); opacity: 0; }
}
.ctk-fankui-card {
  position: absolute; width: 18px; height: 26px; border-radius: 3px; pointer-events: none; z-index: 40;
  background: #6366f1; border: 1px solid rgba(224,231,255,0.9);
  box-shadow: 0 0 8px rgba(99,102,241,0.75), inset 0 0 4px rgba(199,210,254,0.5);
  animation: ctkFankuiYank 0.44s cubic-bezier(0.3,0.85,0.25,1) forwards;
}
.ctk-fankui-card::after { content: ''; position: absolute; inset: 3px; border: 1px solid rgba(224,231,255,0.5); border-radius: 2px; }
@keyframes ctkFankuiWhiff {
  0%   { transform: translate(-50%,-50%) scale(0.35); opacity: 0.7; }
  100% { transform: translate(-50%,-50%) scale(1.5);  opacity: 0; }
}
.ctk-fankui-whiff {
  position: absolute; width: 40px; height: 40px; margin: 0; pointer-events: none; z-index: 39;
  border-radius: 50%; border: 2px dashed rgba(148,163,184,0.75);
  animation: ctkFankuiWhiff 0.5s ease-out forwards;
}

/* ===== Gear destroyed (Dismantle on equipped weapon/armor/horse) — the gear
 * badge (struck through) is knocked off the victim, tumbling away + spraying
 * the shatter fragments above. Colour set inline per slot. ===== */
.ctk-gear-break-fx { position: absolute; transform: translate(-50%,-50%); pointer-events: none; z-index: 42; }
@keyframes ctkGearBreak {
  0%   { transform: translate(-50%,-50%) scale(1)    rotate(0deg);  opacity: 1; }
  25%  { transform: translate(-50%,-50%) scale(1.12) rotate(-6deg); opacity: 1; }
  100% { transform: translate(-50%,40%)  scale(0.7)  rotate(28deg); opacity: 0; }
}
.ctk-gear-badge {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
  padding: 3px 8px; border-radius: 6px;
  background: rgba(10,8,4,0.85); border: 1px solid currentColor; color: currentColor;
  font-family: 'VT323', monospace; font-size: 14px; font-weight: bold;
  box-shadow: 0 0 10px currentColor; text-decoration: line-through;
  animation: ctkGearBreak 0.66s cubic-bezier(0.4,0,0.7,1) forwards;
}
.ctk-gear-badge .ctk-viz-ico { width: 14px; height: 14px; }
.ctk-gear-badge .ctk-viz-ico svg { width: 100%; height: 100%; display: block; }

/* ===== Equip "slam" + Draw "deal" — Balatro juice for two formerly
 * silent actions. Both overlay #battleArena centred on the seat.
 * Colour is set inline per slot (vizEquip) / fixed cyan (deal); the
 * badge, ring and sparks inherit it via currentColor. ===== */
.ctk-equip-fx { position: absolute; pointer-events: none; z-index: 40; color: #f59e0b; }
@keyframes ctkEquipSlam {
  0%   { transform: translate(-50%,-50%) scale(0.2);  opacity: 0; }
  45%  { transform: translate(-50%,-50%) scale(1.35); opacity: 1; }
  65%  { transform: translate(-50%,-50%) scale(0.92); }
  100% { transform: translate(-50%,-50%) scale(1);    opacity: 1; }
}
@keyframes ctkEquipBadgeOut { 0%,72% { opacity: 1; } 100% { opacity: 0; } }
@keyframes ctkEquipRing {
  from { transform: translate(-50%,-50%) scale(0.3); opacity: 0.85; }
  to   { transform: translate(-50%,-50%) scale(2.4); opacity: 0; }
}
@keyframes ctkEquipSpark {
  0%   { transform: translate(-50%,-50%);                                            opacity: 1; }
  100% { transform: translate(calc(-50% + var(--sx,0px)), calc(-50% + var(--sy,0px))); opacity: 0; }
}
.ctk-equip-badge {
  position: absolute; left: 0; top: 0; transform: translate(-50%,-50%);
  display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
  padding: 3px 8px; border-radius: 6px;
  background: rgba(10,8,4,0.82); border: 1px solid currentColor; color: currentColor;
  font-family: 'VT323', monospace; font-size: 15px; font-weight: bold; letter-spacing: 0.03em;
  box-shadow: 0 0 10px currentColor;
  animation: ctkEquipSlam 0.34s cubic-bezier(0.2,1.4,0.4,1) both, ctkEquipBadgeOut 0.92s ease-out both;
}
.ctk-equip-badge .ctk-viz-ico { width: 15px; height: 15px; }
.ctk-equip-badge .ctk-viz-ico svg { width: 100%; height: 100%; display: block; }
.ctk-equip-ring {
  position: absolute; left: 0; top: 0; width: 46px; height: 46px; margin: -23px 0 0 -23px;
  border: 2px solid currentColor; border-radius: 50%;
  animation: ctkEquipRing 0.6s ease-out forwards;
}
.ctk-equip-spark {
  position: absolute; left: 0; top: 0; width: 4px; height: 4px; margin: -2px 0 0 -2px;
  border-radius: 50%; background: currentColor; box-shadow: 0 0 5px currentColor;
  animation: ctkEquipSpark 0.5s ease-out forwards;
}

/* Draw-phase deck-deal: card-backs arc from the deck origin to the seat. */
@keyframes ctkDrawDeal {
  0%   { transform: translate(-50%,-50%) scale(0.45) rotate(-8deg); opacity: 0; }
  15%  { opacity: 1; }
  80%  { transform: translate(calc(-50% + var(--dx,0px)), calc(-50% + var(--dy,0px))) scale(1)    rotate(3deg); opacity: 1; }
  90%  { transform: translate(calc(-50% + var(--dx,0px)), calc(-50% + var(--dy,0px))) scale(1.12) rotate(0deg); }
  100% { transform: translate(calc(-50% + var(--dx,0px)), calc(-50% + var(--dy,0px))) scale(1)    rotate(0deg); opacity: 0; }
}
.ctk-draw-card {
  position: absolute; width: 20px; height: 28px; border-radius: 3px; pointer-events: none; z-index: 39;
  background: linear-gradient(160deg, #2b6cb0 0%, #1a365d 100%); border: 1px solid #90cdf4;
  box-shadow: 0 0 8px rgba(96,165,250,0.7), inset 0 0 4px rgba(144,205,244,0.4);
  animation: ctkDrawDeal 0.52s cubic-bezier(0.2,0.8,0.2,1) forwards;
}
.ctk-draw-card::after { content: ''; position: absolute; inset: 3px; border: 1px solid rgba(144,205,244,0.45); border-radius: 2px; }
@keyframes ctkDrawPulse {
  0%,100% { box-shadow: none; }
  50%     { box-shadow: 0 0 0 3px rgba(96,165,250,0.55), 0 0 14px rgba(96,165,250,0.5); }
}
.ctk-anim-draw { animation: ctkDrawPulse 0.6s ease-out !important; }

/* Pending response preview arrow. Persists while game.pending is
 * active; redrawn on every applyState. Dashed + pulsing so it
 * reads as a *forthcoming* effect, distinct from the solid
 * post-action ctkArrowDraw that fires on resolution. Curve
 * direction encodes polarity (over = harm, under = ask/heal); the
 * apex glyph (⚔ / 🍑) carries the type signal since a single row
 * of players means "above/below" can't double as source/target. */
@keyframes ctkPendingArrowPulse {
  0%, 100% { opacity: 0.55; }
  50%      { opacity: 1; }
}
/* Marching dashes: one dash period per cycle, so the pattern flows
 * source → target and the arc reads as "this way", not just "waiting".
 * stroke-dashoffset is a main-thread paint animation on the SVG layer, so it
 * runs at 10 discrete steps a second (a pixel-art hop suits the dashes
 * anyway) instead of 60 smooth frames — ~6× fewer SVG repaints while a
 * response is pending. The glyph keeps the compositor-cheap opacity pulse. */
@keyframes ctkPendingArrowMarch {
  to { stroke-dashoffset: calc(-1 * var(--dash-period, 10px)); }
}
.ctk-pending-arrow {
  fill: none;
  /* Width + dash are the phone-token (fx 1) baseline; viz-arrows.ts writes
   * the fx-scaled values inline on each path. */
  stroke-width: 2.5;
  stroke-dasharray: 6 4;
  stroke-dashoffset: 0;
  opacity: 0.9;
  animation: ctkPendingArrowMarch 0.8s steps(8) infinite;
}
.ctk-pending-arrow-glyph {
  position: absolute;
  line-height: 0;
  pointer-events: none;
  z-index: 21;
  transform: translate(-50%, -50%);
  /* Sits ON the arc now (viz-arrows.ts anchors it at the curve's apex), so
   * a dark plate lets the dashes pass under the glyph instead of through it. */
  background: rgba(22, 15, 9, 0.8);
  border-radius: 50%;
  padding: calc(4px * var(--ctk-fx-scale));
  filter: drop-shadow(0 1px 2px rgba(0,0,0,0.9)) drop-shadow(0 0 4px rgba(0,0,0,0.55));
  animation: ctkPendingArrowPulse 1.2s ease-in-out infinite;
}
/* Glyph uses the same pixel-art ICONS as the rest of the UI
 * (currentColor-filled SVG paths) — sized + pixelated to match
 * the chip icons in the player overlays. */
.ctk-pending-arrow-glyph svg {
  width: calc(22px * var(--ctk-fx-scale));
  height: calc(22px * var(--ctk-fx-scale));
  image-rendering: pixelated;
  display: block;
}
/* ===== Rim flashes as an OPACITY overlay (juice audit R34) =====
   .ctk-anim-damage / -heal / -frost / -incoming / -skill / -judge / -dodge /
   -negate on the panel are MARKERS now (the classes every spec reads and the
   drip's re-flash drain keys on). The visible rim is the .ctk-rim-fx child
   vizFlash appends beside the class: it holds the peak outline + blush
   STATICALLY and animates opacity only — the ctkTurnGlow pattern, ::before
   two hundred lines up. The old keyframes interpolated outline-width / colour
   + background-color (or a box-shadow ring) on the panel itself, a repaint of
   the panel's layer on every frame of the 0.65 s flash, several panels at once
   on a bot's turn. The overlay hugs the border box (negative inset = the
   4/2/2/2 faction frame, 3px for the Lord) so its ring lands where the old
   outline did; z-index:-1 paints it where native outline + background paint —
   above the panel background, below content (the panel is a stacking context,
   see the ctkTurnGlow note). Peak alphas = the old 20% keyframes. No
   !important needed: the aria-current rule pins the PANEL's animation, and
   this is a child. Reduced motion: the global collapse ends the overlay at its
   100% frame (opacity 0) at once — the chip and the sound carry the beat. */
.ctk-rim-fx {
  position: absolute; inset: -4px -2px -2px -2px; z-index: -1; pointer-events: none;
  opacity: 0;
  box-shadow: 0 0 0 4px var(--rim-c, rgba(220,38,38,0.9));
  background: var(--rim-bg, transparent);
  animation: ctkRimFlash 0.65s ease-in-out forwards;
}
/* The Lord's double-gold frame is 3px on all sides — track it like ::before. */
.player-panel:has(.role-chip[data-role="Lord"]) > .ctk-rim-fx { inset: -3px; }
.ctk-rim-fx[data-rim="damage"]   { --rim-c: rgba(220,38,38,0.9);    --rim-bg: rgba(220,38,38,0.18); }
.ctk-rim-fx[data-rim="heal"]     { --rim-c: rgba(34,197,94,0.9);    --rim-bg: rgba(34,197,94,0.18); }
/* Ice Sword freeze (vizFrostWard): the same one-shot rim in frost cyan. */
.ctk-rim-fx[data-rim="frost"]    { --rim-c: rgba(103,232,249,0.95); --rim-bg: rgba(103,232,249,0.16); }
/* Defend / Dodge (blue) and Negate (slate): the response family. */
.ctk-rim-fx[data-rim="dodge"]    { --rim-c: rgba(96,165,250,0.9);   --rim-bg: rgba(96,165,250,0.14); animation-duration: 0.6s; }
.ctk-rim-fx[data-rim="negate"]   { --rim-c: rgba(203,213,224,0.95); --rim-bg: rgba(148,163,184,0.16); }
/* A table-wide trick rolling toward this seat (vizAoeWave, the "must respond"
   prompt): a thin warm rim — nobody has been hit yet, so pointedly NOT the
   damage flash. */
.ctk-rim-fx[data-rim="incoming"] { box-shadow: 0 0 0 3px rgba(249,115,22,0.85); animation: ctkRimPulse 0.5s ease-out forwards; }
/* The judgement pulse: a thin slate ring. */
.ctk-rim-fx[data-rim="judge"]    { box-shadow: 0 0 0 3px rgba(148,163,184,0.55); animation: ctkRimPulse 0.6s ease-out forwards; }
/* A 4px rim in the seat's OWN kingdom colour (--k-lit, inherited from the
   panel frame; gold when a seat has none) over a gold glow, so every skill
   beat reads as "this seat's power" (juice audit fix 5). The color-mix line
   trims the rim to 0.9 alpha where supported; the plain line stands in. */
.ctk-rim-fx[data-rim="skill"]    { box-shadow: 0 0 0 4px var(--k-lit, rgba(240,216,144,0.9)), 0 0 18px rgba(240,216,144,0.6);
                                   box-shadow: 0 0 0 4px color-mix(in srgb, var(--k-lit, #f0d890) 90%, transparent), 0 0 18px rgba(240,216,144,0.6);
                                   animation: ctkRimPulse 0.6s ease-out forwards; }
@keyframes ctkRimFlash { 0% { opacity: 0; } 20% { opacity: 1; } 60% { opacity: 0.55; } 100% { opacity: 0; } }
@keyframes ctkRimPulse { 0% { opacity: 0; } 45% { opacity: 1; } 100% { opacity: 0; } }
/* Card-juice pop: the ACTING seat's avatar squashes then lifts when it plays
 * a card / acts (vizSeatPop, fired from vizArrow's source seat). Animates the
 * inner .hero-stage only — the .player-panel's transform is its oval position.
 * transform-origin near the bottom edge so it reads as a card standing up off
 * the table. No !important, so the global reduced-motion collapse applies. */
@keyframes ctkSeatPop {
  0%   { transform: scale(1, 1); }
  28%  { transform: scale(1.13, 0.9); }
  52%  { transform: scale(0.95, 1.07); }
  76%  { transform: scale(1.03, 0.98); }
  100% { transform: scale(1, 1); }
}
.hero-stage.ctk-seat-pop {
  animation: ctkSeatPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
  transform-origin: 50% 78%;
}
/* Perf (2026-09, the Android "flicker on every bot card"): keep the seat
 * panel, its hero-stage and its corner badge on PERMANENT compositor
 * layers. The pop used to toggle will-change on .hero-stage: promoted for
 * 0.4s, demoted after — and its popped bounds overlap the corner badge, so
 * Chrome pulled every panel's badge into a transient 384×550 squashing
 * layer. Both the promotion and the demotion re-rasterised the whole
 * #players ring and re-decoded all eight portraits (checker-imaged
 * portraits blink out for a frame on a phone). Measured over CDP
 * LayerTree: with these static, an arrow creates only its own <line> +
 * label layers and repaints nothing else, and a damage flash repaints only
 * the flashed panel's own layer. Cost: ~24 small layers, a few MB of GPU
 * memory at DPR 2. Scoped to #players so the detail modal's clone (same
 * classes) is untouched. */
#players > .player-panel,
#players .hero-stage,
#players .player-corner-badge { will-change: transform; }
/* The bespoke seat rims below carry !important on `animation`, which would
   out-specify the global `*{animation-duration:.001s}` reduced-motion collapse.
   Re-collapse them here (placed AFTER their defs so the cascade tie-breaks in
   this rule's favour) so the accessibility preference still holds. (The eight
   vizFlash rims left this list in R34: they are .ctk-rim-fx overlay children
   now, which the global collapse reaches on its own.) */
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-jianxiong-rim, :where(html:not(.ctk-force-fx)) .ctk-luanwu-rim,
  :where(html:not(.ctk-force-fx)) .ctk-huashen-morph, :where(html:not(.ctk-force-fx)) .ctk-huashen-rim, :where(html:not(.ctk-force-fx)) .ctk-xinsheng-rim,
  :where(html:not(.ctk-force-fx)) .ctk-anim-range {
    animation-duration: 0.001s !important;
    animation-iteration-count: 1 !important;
  }
}

/* Phase status */
#phaseStatus { color: #b8a888; }

/* Input styling */
input[type="number"] {
  background: #2a2218;
  border: 2px solid #5a4530;
  color: #e8dcc8;
  font-family: "VT323", monospace;
  font-size: 16px;
  text-align: center;
}
input[type="checkbox"] { accent-color: #c28a3d; }

/* Helper text */
.muted { color: #b8a888; font-size: 14px; }
.danger { color: #e53e3e; font-size: 14px; }
/* Response prompts: force the .muted prompt line inside #pendingActions onto
 * its own full-width row so the response buttons wrap neatly BELOW it rather
 * than competing for space on the same flex line. (#pendingActions is a
 * flex-wrap container on both desktop and the mobile shell.) */
#pendingActions .muted { flex-basis: 100%; width: 100%; text-align: center; margin-bottom: 2px; }

/* ---- Mobile-first overrides ---- */
@media (max-width: 640px) {
  /* Hand cards keep their fixed 84x140 even on mobile so the row stays
   * predictable; only the modal + lobby input bits flex for narrow phones. */
  #lobbyModal > div { max-width: 100% !important; }
  /* Lobby join-code input goes fluid so it doesn't overflow 360px screens. */
  #lobbyJoinCode { width: auto !important; flex: 1 1 160px; min-width: 0; }
  /* Trim the modal's fixed 16+16px chrome on a 360px phone (~30px reclaimed).
   * !important because both elements carry an inline padding:16px that a
   * plain id/class rule can't beat. */
  #lobbyModal { padding: 8px !important; }
  #lobbyModal .lobby-modal-card { padding: 12px !important; }
  /* Collapse the lobby header controls to icon-only so title + EN/TH + How-to
   * + Close fit one row instead of flex-wrapping ~140px tall on a phone. */
  #lobbyHowto span:not(.lobby-ico), #lobbyClose span:not(.lobby-ico),
  #lobbyCodexHeroes span:not(.lobby-ico), #lobbyCodexCards span:not(.lobby-ico) { display: none; }
}

/* ---- Accessibility: respect prefers-reduced-motion. Collapse every animation and
       shorten every transition globally so the loud Balatro effects (screen-shake,
       turn-glow pulse, damage burst, card-play bounce) don't trigger. Gameplay
       still works; juice is muted. Universal selector covers inline-style
       animations (e.g. ctkTurnGlow applied directly via style attr).

       THE `:where(html:not(.ctk-force-fx))` GUARD — canonical note, it is on
       EVERY comma branch of EVERY reduced-motion block in this file, not just
       this one. (Two blocks that must also match on <html> itself spell it
       `html.ctk-shell:where(:not(.ctk-force-fx))` — same zero-specificity
       guard, folded into a branch that already targets the root; don't
       "normalise" those back to the leading form, it would stop matching.)
       Windows maps "Animation effects: Off" (and the very common Performance
       Options > "Adjust for best performance") onto prefers-reduced-motion, so a
       tuned desktop silently loses ALL juice with no way to ask for it back. The
       Settings modal's Motion-effects row (#ctkFxToggle) puts `.ctk-force-fx` on
       <html> to opt out. It MUST be `:where()`: that keeps the guard at ZERO
       specificity, so every rule below scores exactly what it scored before and
       the carve-outs that deliberately out-specify this block (the
       .ctk-feed-chip block, the !important combat flashes on .ctk-anim-damage
       and friends) still win. A bare `html:not(...)` prefix would add (0,1,1)
       to every one of them and silently invert those ties. The JS half of the switch reads the same class — see
       client/src/reduced-motion.ts. ---- */
@media (prefers-reduced-motion: reduce) {
  /* The bare selector keeps <html> itself covered; the descendant forms cannot
     match it, and `*` did. */
  :where(html:not(.ctk-force-fx)),
  :where(html:not(.ctk-force-fx)) *, :where(html:not(.ctk-force-fx)) *::before, :where(html:not(.ctk-force-fx)) *::after {
    animation-duration: 0.001s !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.05s !important;
  }
  :where(html:not(.ctk-force-fx)) .ctk-float-damage { opacity: 0 !important; }
  /* The arena arrows and their chips carry their information in the HELD
   * pose, and the collapse above would jump them straight to their final
   * keyframe (opacity 0) — nothing ever showed. Pin them static instead;
   * the JS removal timers (1.35s / 1.45s / 1.3s) still take them off. */
  :where(html:not(.ctk-force-fx)) .ctk-arrow-shaft { animation: none !important; stroke-dashoffset: 0; opacity: 0.95; }
  :where(html:not(.ctk-force-fx)) .ctk-arrow-head  { animation: none !important; transform: none; opacity: 0.95; }
  :where(html:not(.ctk-force-fx)) .ctk-arrow-label { animation: none !important; opacity: 1; transform: translateX(-50%); }
  :where(html:not(.ctk-force-fx)) .ctk-float-label { animation: none !important; opacity: 1; transform: translateX(-50%) translateY(calc(-14px * var(--ctk-fx-scale))); }
  /* The equip slam and the gear knock-off badges end at opacity 0: the global
     collapse jumped them straight there and nothing ever showed. Pin them
     static (the JS timers still remove them); vizEquip spawns no ring / sparks
     under reduced motion at all. */
  :where(html:not(.ctk-force-fx)) .ctk-equip-badge { animation: none !important; opacity: 1; transform: translate(-50%,-50%); }
  :where(html:not(.ctk-force-fx)) .ctk-gear-badge  { animation: none !important; opacity: 1; transform: translate(-50%,-50%); }
  :where(html:not(.ctk-force-fx)) .ctk-equip-ring, :where(html:not(.ctk-force-fx)) .ctk-equip-spark { animation: none !important; opacity: 0; }
  /* Warstorm: the banner pins static (the JS timer still removes it); the
     standing button glow / chip breathe / sheen loops stop outright. */
  :where(html:not(.ctk-force-fx)) .ctk-luanwu-banner { animation: none !important; opacity: 1; transform: translate(-50%,-50%); }
  :where(html:not(.ctk-force-fx)) .px-btn.px-btn-luanwu, :where(html:not(.ctk-force-fx)) .px-btn.px-btn-luanwu::before,
  :where(html:not(.ctk-force-fx)) .px-badge.skill-limited { animation: none !important; }
  /* Zuo Ci: the placard pins static (the JS timer still removes it), the
     portrait does not flicker, the medallion simply appears, the Avatar
     chip sheen stops. The mist rings fall to the global collapse. */
  :where(html:not(.ctk-force-fx)) .ctk-huashen-card { animation: none !important; opacity: 1; transform: translate(-50%,-50%); }
  :where(html:not(.ctk-force-fx)) .ctk-huashen-morph .hero-portrait, :where(html:not(.ctk-force-fx)) .ctk-huashen-morph .ctk-huashen-form,
  :where(html:not(.ctk-force-fx)) .px-badge.skill-avatar { animation: none !important; }
}

/* Inputs/selects share the 44px touch floor for consistency on mobile.
 * font-size:16px is deliberate: iOS Safari auto-zooms the page whenever a
 * focused field is <16px (and doesn't reliably zoom back), which bites the
 * TH default (15px body) and every room-name/password entry. 16px = no zoom. */
input[type="text"],
input[type="number"],
select {
  min-height: 44px;
  font-size: 16px;
  box-sizing: border-box;
}
/* The compact bots-count <input> is exempt (it sits inline in the toolbar). */
#bots { min-height: 32px; }

/* Touch hardening for the board's game art. Cards & hero portraits are
 * tap-to-play (no drag), so suppress the iOS long-press "Save Image / Copy"
 * callout + accidental text selection during the natural tap-and-hold of a
 * card game, and the Android grey tap-flash. No interaction cost. */
.pixel-card, .card-img, .hero-portrait {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

/* ===== Balatro-style error message animations =====
   The #error row stays full-width + centered for layout; the actual badge
   lives in an inner <span class="ctk-err-text"> so we can wildly transform
   (scale, rotate, glow) without nudging surrounding rows. Each message
   type gets its own personality: critical punches in, warning wobbles,
   info bounces down, blocked thumps with a pulse. */
#error { min-height: 22px; perspective: 600px; }
#error:empty { min-height: 0; }
#error .ctk-err-text {
  display: inline-block;
  padding: 3px 12px;
  border-radius: 6px;
  font-family: 'VT323', monospace;
  font-weight: bold;
  letter-spacing: 0.03em;
  color: #ef4444;
  transform-origin: center center;
  backface-visibility: hidden;
  will-change: transform, opacity, filter;
}
/* CRITICAL — hard punch-in with rotation jitter, intense red glow flash */
@keyframes ctkErrPunch {
  0%   { transform: scale(0) rotate(-12deg); opacity: 0; filter: brightness(2.2) saturate(2); }
  18%  { transform: scale(1.45) rotate(6deg); opacity: 1; filter: brightness(1.6) saturate(2.2); }
  35%  { transform: scale(0.88) rotate(-4deg); }
  50%  { transform: scale(1.18) rotate(3deg); }
  65%  { transform: scale(0.96) rotate(-2deg); }
  80%  { transform: scale(1.05) rotate(1deg); }
  100% { transform: scale(1) rotate(0); opacity: 1; filter: brightness(1) saturate(1); }
}
@keyframes ctkErrCritGlow {
  0%   { box-shadow: 0 0 0 rgba(220,38,38,0); background: rgba(220,38,38,0); }
  18%  { box-shadow: 0 0 36px rgba(255,80,80,0.95), inset 0 0 22px rgba(255,140,140,0.45); background: rgba(220,38,38,0.42); }
  100% { box-shadow: 0 0 12px rgba(220,38,38,0.45); background: rgba(220,38,38,0.14); }
}
#error.ctk-err-critical .ctk-err-text {
  color: #fff;
  border: 2px solid #ef4444;
  text-shadow: 0 0 10px rgba(255,70,70,1), 2px 2px 0 #4a0000, -1px -1px 0 #4a0000;
  animation:
    ctkErrPunch 0.7s var(--ctk-spring) forwards,
    ctkErrCritGlow 1.6s ease-out forwards;
}
/* WARNING — amber pop with side-to-side wobble (targeting/range issues) */
@keyframes ctkErrWobble {
  0%   { transform: scale(0.6) translateY(-6px) rotate(0); opacity: 0; }
  22%  { transform: scale(1.22) translateY(2px) rotate(0); opacity: 1; }
  36%  { transform: scale(1) translateY(0) rotate(-5deg); }
  50%  { transform: scale(1) rotate(5deg); }
  64%  { transform: scale(1) rotate(-3deg); }
  78%  { transform: scale(1) rotate(2deg); }
  100% { transform: scale(1) rotate(0); opacity: 1; }
}
#error.ctk-err-warning .ctk-err-text {
  color: #fbbf24;
  background: rgba(180,100,20,0.18);
  border: 1.5px solid rgba(251,191,36,0.55);
  text-shadow: 0 0 8px rgba(251,191,36,0.85), 2px 2px 0 #2a1908;
  animation: ctkErrWobble 0.75s var(--ctk-spring) forwards;
}
/* INFO — cyan slide-down with bounce (informational nudges) */
@keyframes ctkErrSlideBounce {
  0%   { transform: translateY(-26px) scale(0.85); opacity: 0; }
  45%  { transform: translateY(7px) scale(1.12); opacity: 1; }
  62%  { transform: translateY(-3px) scale(0.97); }
  80%  { transform: translateY(2px) scale(1.02); }
  100% { transform: translateY(0) scale(1); opacity: 1; }
}
#error.ctk-err-info .ctk-err-text {
  color: #67e8f9;
  background: rgba(8,145,178,0.15);
  border: 1px solid rgba(103,232,249,0.45);
  text-shadow: 0 0 6px rgba(103,232,249,0.75), 2px 2px 0 #082630;
  animation: ctkErrSlideBounce 0.55s cubic-bezier(0.22, 1.4, 0.36, 1) forwards;
}
/* BLOCKED — golden thump + lingering pulse (state precondition violated) */
@keyframes ctkErrThump {
  0%   { transform: scale(0.7); opacity: 0; }
  30%  { transform: scale(1.22); opacity: 1; }
  55%  { transform: scale(0.94); }
  80%  { transform: scale(1.04); }
  100% { transform: scale(1); opacity: 1; }
}
@keyframes ctkErrPulse {
  0%, 100% { box-shadow: 0 0 8px rgba(245,158,11,0.45); }
  50%      { box-shadow: 0 0 22px rgba(245,158,11,0.9); }
}
#error.ctk-err-blocked .ctk-err-text {
  color: #fcd34d;
  background: rgba(120,80,10,0.22);
  border: 1.5px solid rgba(252,211,77,0.55);
  text-shadow: 0 0 8px rgba(252,211,77,0.85), 2px 2px 0 #1f1408;
  animation:
    ctkErrThump 0.6s var(--ctk-spring) forwards,
    ctkErrPulse 1.4s ease-in-out 0.6s 2;
}

/* ===== "Waiting for your response" prompt — Balatro × Material 3 =====
   Distinct from #error (transient violation feedback): this is a
   persistent prompt telling the player the engine is blocked on them.
   Empty state collapses cleanly. Non-empty state renders as an M3 tonal
   "info" surface with a pixel border, an inline glyph, an entrance
   pop, then a slow amber breathing glow so it pulls the eye without
   feeling alarming. Seat badges injected by formatSeatBadgeHtml keep
   their own styling on top. */
#pendingHint { color: #9a8a70; font-size: 15px; }
#pendingHint:empty { padding: 0; border: 0; background: transparent; box-shadow: none; }
#pendingHint:not(:empty) {
  display: inline-block;
  margin: 6px auto 2px auto;
  padding: 6px 14px 6px 12px;
  max-width: 92%;
  font-family: 'VT323', monospace;
  font-size: 16px;
  letter-spacing: 0.04em;
  color: #fde68a !important;
  font-weight: bold;
  background: linear-gradient(180deg, rgba(120,82,18,0.32) 0%, rgba(90,60,16,0.22) 100%);
  border: 2px solid rgba(251,191,36,0.6);
  border-radius: 6px;
  text-shadow: 0 0 6px rgba(251,191,36,0.55), 2px 2px 0 #2a1908;
  box-shadow:
    inset 1px 1px 0 rgba(252,211,77,0.25),
    inset -1px -1px 0 rgba(0,0,0,0.35),
    0 0 12px rgba(251,191,36,0.32),
    0 2px 0 #0e0804;
  animation:
    ctkPendingEnter 0.45s var(--ctk-spring),
    ctkPendingBreathe 2.4s ease-in-out 0.45s infinite;
}
/* Muted "waiting on another seat / spectating" variant — same box shape,
 * but static + desaturated so the amber breathing #pendingHint state reads
 * unambiguously as "it's your move". Applied via setPendingHint(msg,{wait:true}). */
#pendingHint.pending-wait {
  color: #c2b69e !important;
  background: linear-gradient(180deg, rgba(58,52,40,0.34) 0%, rgba(40,36,28,0.24) 100%);
  border-color: rgba(150,140,118,0.5);
  text-shadow: 2px 2px 0 rgba(0,0,0,0.5);
  box-shadow:
    inset 1px 1px 0 rgba(255,255,255,0.05),
    inset -1px -1px 0 rgba(0,0,0,0.35),
    0 2px 0 #0e0804;
  animation: ctkPendingEnter 0.45s var(--ctk-spring);
  opacity: 0.85;
}
/* ===== On-board seat targeting (pickSeat) ===== */
.ctk-seatpick-banner {
  position: fixed; left: 50%; bottom: calc(92px + env(safe-area-inset-bottom, 0px) + var(--vv-bottom, 0px)); transform: translateX(-50%);
  z-index: 60; display: flex; align-items: center; gap: 10px;
  padding: 8px 12px; border-radius: 8px; max-width: 92vw;
  background: linear-gradient(180deg, rgba(30,42,64,0.97) 0%, rgba(20,28,44,0.97) 100%);
  border: 2px solid #5a82b5; color: #e8f0ff;
  box-shadow: 0 4px 16px rgba(0,0,0,0.5), 0 0 18px rgba(90,130,181,0.4);
  font-family: 'VT323', monospace; font-size: 17px; letter-spacing: 0.03em; font-weight: bold;
  animation: ctkPendingEnter 0.35s var(--ctk-spring);
}
html[lang="th"] .ctk-seatpick-banner { font-family: 'Bai Jamjuree', 'Kanit', sans-serif; font-weight: 700; font-size: 14px; }
.ctk-seatpick-banner .ctk-seatpick-cancel { padding: 2px 10px; font-size: 13px; }
.player-panel.seat-targetable {
  cursor: pointer;
  box-shadow: 0 0 0 2px rgba(110,231,168,0.45), 0 0 18px rgba(110,231,168,0.55);
  animation: ctkSeatPulse 1.2s ease-in-out infinite;
  border-radius: 4px;
  /* Non-colour shape cue so "targetable" survives colour-blind vision and
   * the grayscale dimming of untargetable seats. */
  outline: 2px dashed #eaf7ee;
  outline-offset: 2px;
}
.player-panel.seat-untargetable { opacity: 0.38; filter: grayscale(0.55); }
/* The source (attacker) seat: a distinct gold ring + higher opacity so it
 * reads as "this one is acting", not confusably-dimmed like untargetable. */
.player-panel.seat-target-source {
  opacity: 0.85;
  box-shadow: 0 0 0 2px rgba(240,200,80,0.7), 0 0 12px rgba(240,200,80,0.4);
  border-radius: 4px;
}
.player-panel.seat-nudge { animation: ctkSeatNudge 0.35s; }
@keyframes ctkSeatPulse {
  0%, 100% { box-shadow: 0 0 0 2px rgba(110,231,168,0.32), 0 0 14px rgba(110,231,168,0.45); }
  50%      { box-shadow: 0 0 0 3px rgba(110,231,168,0.58), 0 0 24px rgba(110,231,168,0.8); }
}
@keyframes ctkSeatNudge {
  0%, 100% { outline: 0 solid transparent; outline-offset: -1px; }
  50%      { outline: 2px solid #e85a5a; outline-offset: -1px; }
}
/* ===== Lobby cold-start: in-room head, occupancy, play-now ===== */
.lobby-room-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 10px 0 6px; }
.lobby-room-label { font-size: 14px; color: #c8b890; }
#lobbyRoomCode { color: #fbbf24; font-weight: bold; font-size: 22px; font-family: "VT323", monospace; letter-spacing: 2px; }
.lobby-icon-btn { padding: 7px 9px; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; line-height: 0; }
.lobby-icon-btn svg { width: 15px; height: 15px; }
/* Lobby modal: Pixelarticons placement + Balatro entrance pop. */
.lobby-ico { display: inline-flex; align-items: center; line-height: 0; }
.lobby-ico svg { display: block; }
.lobby-btn-ico { display: inline-flex; align-items: center; gap: 6px; }
/* Header: keep the title + action cluster (lang toggle, How-to, Close) from
 * staircasing on narrow phones. The action group stays a single row and
 * drops beneath the title as one unit (margin-left:auto right-aligns it);
 * under 420px the How-to/Close text labels collapse to their icons so the
 * row always fits the card width. */
.lobby-head { flex-wrap: wrap; row-gap: 6px; }
.lobby-head-actions { flex-wrap: nowrap; margin-left: auto; }
/* Six controls share that row (EN/TH, How-to, Heroes, Cards, Close). With the
 * stock 16px button padding they no longer fit the 524px card content with every
 * label on one line, and a flex item that cannot fit shrinks by wrapping its
 * label mid-phrase ("How to / play"), which read as a broken button. 9px keeps
 * all six labelled on one line in EN (456px) and TH (391px); never wrap. */
.lobby-head-actions .px-btn { padding-left: 9px; padding-right: 9px; white-space: nowrap; }
@media (max-width: 420px) {
  .lobby-head-actions .lobby-head-txt { display: none; }
}
/* Balatro-style entrance pop. .lobby-modal-card is the original user;
 * .ctk-modal-pop is the shared opt-in the other dialogs (profile, how-to,
 * support, history, leaderboard) tag their card with so every modal lands
 * with the same juice. Re-fires on each display:none -> visible toggle. */
.lobby-modal-card, .ctk-modal-pop { animation: ctkLobbyPop 0.3s var(--ctk-spring); }
@keyframes ctkLobbyPop {
  0%   { opacity: 0; transform: scale(0.94) translateY(6px); }
  60%  { opacity: 1; }
  100% { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) { :where(html:not(.ctk-force-fx)) .lobby-modal-card, :where(html:not(.ctk-force-fx)) .ctk-modal-pop { animation: none; } }
/* ---- Match History (#historyModal) ---- shares the .ctk-rk-* shell below;
 * these style what client/src/match-history.ts renders into #historyListView.
 * Static CSS on purpose (no runtime <style> injection — see the Android
 * flicker notes). The result reads three ways at once — the left bar, the
 * word, its colour — and the role rings the hero medallion in the shared
 * role tint (VM_ROLE_TINT). */
.ctk-mh-back { padding: 2px 6px; min-width: 32px; min-height: 32px; flex: none; }
.ctk-mh-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 10px 0 8px; }
.ctk-mh-tile { background: #2a2014; border: 1px solid #4a3828; padding: 8px 4px 6px; text-align: center; min-width: 0; }
.ctk-mh-tile .n { font-family: "VT323", monospace; font-size: 28px; line-height: 1; color: #f0d890; font-variant-numeric: tabular-nums; white-space: nowrap; }
.ctk-mh-tile .n.is-good { color: #8fd6a8; }
.ctk-mh-tile .l { color: #9a8a70; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ctk-mh-filter { display: flex; border: 1px solid #6b5530; margin: 0 0 8px; }
.ctk-mh-seg { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 0; border: 0; cursor: pointer; font: inherit; font-weight: bold; background: transparent; color: #9a895f; }
.ctk-mh-seg[aria-selected="true"] { background: #6b5530; color: #1a1208; }
.ctk-mh-seg:focus-visible { outline: 2px solid #f0d890; outline-offset: -2px; }
.ctk-mh-list { border: 1px solid #3a2c1c; }
.ctk-mh-row { display: grid; grid-template-columns: 4px 44px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; padding: 8px 8px 8px 0; border-bottom: 1px solid #3a2c1c; background: #191310; }
.ctk-mh-row:last-child { border-bottom: 0; }
.ctk-mh-bar { width: 4px; align-self: stretch; background: #5a4530; }
.ctk-mh-row.is-win .ctk-mh-bar { background: #8fd6a8; }
.ctk-mh-row.is-loss .ctk-mh-bar { background: #cf7a77; }
.ctk-mh-row.is-left .ctk-mh-bar { background: #7a4a48; }
.ctk-mh-med { position: relative; display: block; width: 44px; height: 44px; border-radius: 50%; overflow: hidden; background: #2a2218; border: 2px solid var(--mh-role, #6b5530); box-sizing: border-box; flex: none; }
.ctk-mh-med .ctk-mh-init { position: absolute; inset: 0; display: grid; place-items: center; font-family: "VT323", monospace; font-size: 17px; color: #f0d890; }
.ctk-mh-main { min-width: 0; }
.ctk-mh-l1 { display: flex; align-items: center; gap: 6px; min-width: 0; color: #e8dcc8; font-size: 16px; font-weight: bold; }
.ctk-mh-hero { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctk-mh-role { flex: none; font-size: 12px; font-weight: normal; line-height: 1.3; padding: 0 5px; border: 1px solid currentColor; border-radius: 2px; }
.ctk-mh-crown { flex: none; width: 14px; height: 14px; color: #fbbf24; }
.ctk-mh-crown svg { width: 14px; height: 14px; display: block; }
.ctk-mh-l2 { color: #9a8a70; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctk-mh-opp { display: flex; gap: 3px; margin-top: 4px; }
.ctk-mh-med.is-small { width: 18px; height: 18px; border-width: 1px; }
.ctk-mh-med.is-small .ctk-mh-init { font-size: 10px; }
.ctk-mh-res { text-align: right; min-width: 44px; }
.ctk-mh-word { font-family: "VT323", monospace; font-size: 22px; line-height: 1; color: #9a8a70; white-space: nowrap; }
.ctk-mh-row.is-win .ctk-mh-word { color: #8fd6a8; }
.ctk-mh-row.is-loss .ctk-mh-word { color: #cf7a77; }
.ctk-mh-row.is-left .ctk-mh-word { font-size: 17px; color: #b08a8a; }
.ctk-mh-row.is-live .ctk-mh-word { font-size: 17px; }
.ctk-mh-delta { font-size: 13px; color: #9a8a70; font-variant-numeric: tabular-nums; }
.ctk-mh-delta.is-up { color: #8fd6a8; }
.ctk-mh-delta.is-down { color: #cf7a77; }
.ctk-mh-logbtn { padding: 4px 6px; min-width: 36px; min-height: 36px; }
.ctk-mh-logbtn svg { width: 16px; height: 16px; }
.ctk-mh-foot { display: flex; justify-content: center; padding: 10px 0 0; }
.ctk-mh-foot:empty { display: none; }
.ctk-mh-msg { padding: 18px 10px; color: #9a8a70; text-align: center; font-size: 14px; line-height: 1.5; }
.ctk-mh-msg .px-btn { margin-top: 8px; }
.ctk-mh-skel { height: 60px; border-bottom: 1px solid #3a2c1c; background: linear-gradient(90deg, #191310 0%, #221a12 50%, #191310 100%); }
.ctk-mh-skel:last-child { border-bottom: 0; }
@media (max-width: 520px) {
  #historyClose span { display: none; }
  .ctk-mh-summary { gap: 4px; }
  .ctk-mh-tile .n { font-size: 24px; }
  .ctk-mh-row { grid-template-columns: 4px 40px minmax(0, 1fr) auto auto; gap: 8px; }
  .ctk-mh-med { width: 40px; height: 40px; }
  /* The detail line wraps on a phone rather than truncating behind a wide Thai result word. */
  .ctk-mh-l2 { white-space: normal; }
}
/* Rankings modal shell (#rankingsModal). The backdrop centres a card that is
 * capped to the viewport; the card is a flex column whose BODY scrolls, so
 * the title bar + Close never leave the screen. Phones get a near-edge card
 * (8px gutter) so the ladder's numeric columns keep their room. */
.ctk-rk-modal { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.8); padding: 16px; display: flex; align-items: flex-start; justify-content: center; }
.ctk-rk-card {
  width: 100%; max-width: 620px; margin: 0 auto;
  max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px);
  display: flex; flex-direction: column; min-height: 0;
  background: #191310; border: 2px solid #6b5530; border-radius: 6px;
  color: #d8c8a4; font-size: 15px; outline: none; /* focus target on open (tabindex=-1) — no ring on the card itself */
  box-shadow: 0 10px 30px rgba(0,0,0,0.6), inset 1px 1px 0 rgba(200,170,120,0.1);
}
.ctk-rk-head {
  flex: none; display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: 12px 16px 10px; border-bottom: 1px solid #4a3828;
  background: linear-gradient(180deg, #221a12 0%, #191310 100%);
  border-radius: 4px 4px 0 0;
  /* Soft shadow onto the scrolling body — reads as a pinned bar once the ladder moves. */
  box-shadow: 0 6px 10px -8px rgba(0,0,0,0.85);
  position: relative; z-index: 1;
}
.ctk-rk-title { font-size: 18px; color: #f0d890; font-weight: bold; display: flex; align-items: center; gap: 8px; min-width: 0; }
.ctk-rk-body {
  flex: 1 1 auto; min-height: 140px; overflow-y: auto; overflow-x: hidden;
  overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
  padding: 4px 16px 18px;
  scrollbar-width: thin; scrollbar-color: #5a4530 transparent;
}
@media (max-width: 520px) {
  .ctk-rk-modal { padding: 8px; }
  .ctk-rk-card { max-height: calc(100vh - 16px); max-height: calc(100dvh - 16px); }
  .ctk-rk-head { padding: 10px 12px 8px; }
  .ctk-rk-body { padding: 2px 12px 14px; }
  /* Icon-only Close on phones (same collapse the lobby header does). */
  #rankingsClose span { display: none; }
  #rankingsClose { padding: 8px 12px; }
}
/* ===== Codex modals (#heroCodexModal / #cardCodexModal, client/src/codex-modal.ts) =====
 * The rankings shell plus a pinned filter-chip row between head and body. Three
 * surfaces open it — the lobby (inline z 50; lobbyIsTopmost stands down only for
 * STRICTLY greater), the landing's How-to (landing 1300, how-to 1400) — so it
 * sits at 1450: above all of them, under the bug-report modal (1500), which must
 * stay reachable over everything. The inspector sheet a row opens on top rests at
 * 1100; html.ctk-codex-open (toggled by the module while a codex is open) lifts
 * it above the codex for that window only, so every other relation the sheet has
 * (hand sheet 1080 below it, reads 1150 above it) is untouched. Declared after
 * .ctk-rk-modal so the single-class override wins. */
.ctk-codex-modal { z-index: 1450; }
html.ctk-codex-open .ctk-card-inspector-backdrop { z-index: 1460; }
.ctk-codex-total { font-size: 13px; font-weight: normal; color: #9a8a70; padding: 1px 6px; border: 1px solid #4a3828; border-radius: 4px; }
.ctk-codex-total:empty { display: none; }
.ctk-codex-filters { flex: none; display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 16px; border-bottom: 1px solid #3a2c1c; background: #191310; }
.ctk-codex-chip {
  display: inline-flex; align-items: center; gap: 5px; min-height: 32px; padding: 4px 9px;
  font-family: "VT323", monospace; font-size: 15px; line-height: 1;
  color: #c8b890; background: rgba(0,0,0,0.18); border: 1px solid #6b5530; border-radius: 4px; cursor: pointer;
}
html[lang="th"] .ctk-codex-chip { font-family: "Bai Jamjuree", "Kanit", sans-serif; font-size: 13px; font-weight: 600; }
.ctk-codex-chip:hover { color: #f0e0c0; border-color: #8a6d3c; }
.ctk-codex-chip.on { color: #f0d890; background: #3a2c14; border-color: #b08d57; }
.ctk-codex-chip:focus-visible { outline: 2px solid #fbbf24; outline-offset: 2px; }
.ctk-codex-chip-n { font-size: 12px; color: #9a8a70; }
.ctk-codex-chip.on .ctk-codex-chip-n { color: #d8c8a4; }
.ctk-codex-note { font-size: 12px; line-height: 1.4; color: #9a895f; margin: 8px 0 2px; }
/* Section heading + row chips take the kingdom / category tint through --k-lit
 * (the same values as .player-panel[data-kingdom]) so one law colours both. */
.ctk-codex-group { display: flex; align-items: center; gap: 8px; margin: 12px 0 2px; font-size: 15px; font-weight: bold; color: var(--k-lit, #f0d890); }
.ctk-codex-count { font-size: 12px; font-weight: normal; color: #9a8a70; }
.ctk-codex-row { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 10px; padding: 9px 0; border-bottom: 1px solid #2e2418; }
.ctk-codex-row:last-child { border-bottom: 0; }
.ctk-codex-thumb {
  width: 64px; height: 64px; padding: 0; margin: 0; display: block; overflow: hidden;
  background: #0e0a06; border: 2px solid #6b5530; border-radius: 4px; box-shadow: 0 2px 0 #0e0804; cursor: pointer;
}
.ctk-codex-thumb:hover { border-color: #b08d57; }
.ctk-codex-thumb:focus-visible { outline: 2px solid #fbbf24; outline-offset: 2px; }
.ctk-codex-img { display: block; width: 100%; height: 100%; object-fit: cover; }
.ctk-codex-main { min-width: 0; }
.ctk-codex-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.ctk-codex-name { font-size: 16px; font-weight: bold; color: #f0e0c0; }
.ctk-codex-kchip, .ctk-codex-cchip, .ctk-codex-hp { font-size: 12px; line-height: 1; padding: 3px 6px; border-radius: 4px; border: 1px solid currentColor; white-space: nowrap; }
.ctk-codex-kchip { color: var(--k-lit, #c8b890); background: var(--k-frame, rgba(0,0,0,0.18)); }
.ctk-codex-cchip { color: var(--k-lit, #c8b890); }
.ctk-codex-hp { color: #d8c8a4; border-color: #4a3828; }
.ctk-codex-group[data-kingdom="shu"], .ctk-codex-kchip[data-kingdom="shu"] { --k-lit: #c16d67; --k-frame: #592f2c; }
.ctk-codex-group[data-kingdom="wei"], .ctk-codex-kchip[data-kingdom="wei"] { --k-lit: #6794c1; --k-frame: #2c4259; }
.ctk-codex-group[data-kingdom="wu"],  .ctk-codex-kchip[data-kingdom="wu"]  { --k-lit: #67c194; --k-frame: #2c5942; }
.ctk-codex-group[data-kingdom="qun"], .ctk-codex-kchip[data-kingdom="qun"] { --k-lit: #a067c1; --k-frame: #482c59; }
/* Card sections reuse the static /cards codex palette (gen-hero-pages.mjs CATS). */
.ctk-codex-group[data-cat="basic"],   .ctk-codex-cchip[data-cat="basic"]   { --k-lit: #e6b84e; }
.ctk-codex-group[data-cat="trick"],   .ctk-codex-cchip[data-cat="trick"]   { --k-lit: #5aa9e0; }
.ctk-codex-group[data-cat="delayed"], .ctk-codex-cchip[data-cat="delayed"] { --k-lit: #b184e6; }
.ctk-codex-group[data-cat="weapon"],  .ctk-codex-cchip[data-cat="weapon"]  { --k-lit: #e0803e; }
.ctk-codex-group[data-cat="armor"],   .ctk-codex-cchip[data-cat="armor"]   { --k-lit: #57ab6a; }
.ctk-codex-group[data-cat="mount"],   .ctk-codex-cchip[data-cat="mount"]   { --k-lit: #cf9d5a; }
.ctk-codex-skill { margin-top: 6px; }
.ctk-codex-skill-name { display: flex; align-items: center; gap: 5px; font-size: 14px; font-weight: bold; color: #e8c878; }
.ctk-codex-skill.is-lord .ctk-codex-skill-name { color: #fbbf24; }
.ctk-codex-crown { display: inline-flex; line-height: 0; color: #fbbf24; }
.ctk-codex-crown svg { width: 14px; height: 14px; }
.ctk-codex-lord-tag { font-size: 11px; font-weight: normal; color: #fbbf24; border: 1px solid rgba(251,191,36,0.5); border-radius: 4px; padding: 1px 5px; }
.ctk-codex-skill-desc { font-size: 13px; line-height: 1.45; color: #b8a888; margin-top: 2px; }
.ctk-codex-grants { margin: 5px 0 0 8px; padding-left: 8px; border-left: 2px solid #3a2c1c; }
.ctk-codex-grants-label { font-size: 11px; color: #9a8a70; margin-bottom: 3px; }
.ctk-codex-grant { display: block; width: 100%; text-align: left; padding: 4px 6px; margin: 0 0 3px; background: rgba(0,0,0,0.18); border: 1px solid #3a2c1c; border-radius: 4px; color: inherit; cursor: pointer; font: inherit; }
.ctk-codex-grant:hover { border-color: #6b5530; }
.ctk-codex-grant:focus-visible { outline: 2px solid #fbbf24; outline-offset: 2px; }
.ctk-codex-grant-name { display: block; font-size: 13px; font-weight: bold; color: #e8c878; }
.ctk-codex-grant-desc { display: block; font-size: 12px; line-height: 1.4; color: #b8a888; }
/* Ranked waiting view: the two opener buttons share one row. */
.lobby-codex-row { display: flex; gap: 8px; margin-bottom: 10px; }
.lobby-codex-row .lobby-codex-btn { flex: 1; min-width: 0; justify-content: center; }
@media (max-width: 520px) {
  .ctk-codex-filters { padding: 6px 12px; }
  .ctk-codex-row { grid-template-columns: 56px minmax(0, 1fr); gap: 8px; }
  .ctk-codex-thumb { width: 56px; height: 56px; }
  /* Icon-only Close on phones (same collapse the rankings header does). */
  #heroCodexClose span, #cardCodexClose span { display: none; }
  #heroCodexClose, #cardCodexClose { padding: 8px 12px; }
}
/* Lobby modal type scale + field tokens — one source of truth for the
 * modal's sizes/colours (M3 roles mapped onto the Sanguosha palette),
 * replacing per-element inline styles. */
.lobby-h1 { font-size: 18px; color: #f0d890; font-weight: bold; display: flex; align-items: center; gap: 8px; }
.lobby-h2 { font-size: 14px; color: #f0d890; font-weight: bold; display: flex; align-items: center; gap: 6px; }
.lobby-hint { font-size: 12px; color: #9a895f; }
.lobby-status { font-size: 13px; color: #9aa8c4; }
/* Inline create/join failure. Sanguosha-red tint rather than a browser
 * alert(), which blocked the modal and was English-only. */
.lobby-error {
  margin-top: 10px; padding: 8px 11px; font-size: 13px;
  color: #f2b8b0; background: #2c1512; border: 1px solid #7a3a35; border-radius: 6px;
  display: flex; align-items: center; gap: 7px;
}
/* Match-notifications toggle: a quiet utility row, separated by a rule so it
 * reads as chrome rather than as one of the play actions above it. */
.lobby-notify-row {
  margin-top: 12px; padding-top: 10px; border-top: 1px solid #3a2f1c;
  font-size: 13px; color: #d4c4a0;
}
/* Leave: quiet + faintly dangerous, on its own row away from the gold CTA. */
.lobby-leave-row { display: flex; justify-content: flex-end; margin-top: 10px; }
.lobby-leave { padding: 7px 14px; min-height: 38px; font-size: 14px; background: linear-gradient(180deg, #3a2420 0%, #2c1a17 100%); border-color: #6b3a34; color: #d8a9a2; }
.lobby-leave:hover { background: linear-gradient(180deg, #4a2c27 0%, #38211d 100%); border-color: #8a4a42; color: #f2c4bc; }
/* A control mid-request: dimmed and inert, but NOT .px-btn:disabled's 0.4
 * (which makes the swapped-in "Finding a room…" label hard to read). */
.px-btn.is-busy { opacity: 0.78; cursor: progress; pointer-events: none; }
.px-btn.is-busy:hover { transform: none; }
/* Room-list placeholders. A bare empty box used to cover BOTH "still
 * fetching" and "server unreachable", so an outage read as a quiet lobby. */
.lobby-skel { display: flex; flex-direction: column; gap: 8px; }
.lobby-skel-row {
  height: 62px; border-radius: 6px; border: 1px solid #3a2c1e;
  background: linear-gradient(100deg, #241a11 0%, #2e2216 42%, #241a11 84%);
  background-size: 260% 100%; animation: ctkSkelSweep 1.15s ease-in-out infinite;
}
@keyframes ctkSkelSweep { 0% { background-position: 130% 0; } 100% { background-position: -30% 0; } }
@media (prefers-reduced-motion: reduce) { :where(html:not(.ctk-force-fx)) .lobby-skel-row { animation: none; } }
.lobby-rooms-down { padding: 16px 8px; text-align: center; font-size: 13px; color: #f2b8b0; }
.lobby-rooms-down .lobby-retry { margin-top: 9px; }
.lobby-divider { text-align: center; letter-spacing: 6px; font-size: 11px; color: #7a5f3a; margin: 14px 0; }
.lobby-field { background: #2a2014; color: #e8dcc8; border: 1px solid #4a3828; border-radius: 4px; padding: 6px 8px; min-height: 40px; box-sizing: border-box; font: inherit; appearance: none; -webkit-appearance: none; }
/* The native <select> chevron is OS chrome that clashes with the pixel modal
 * — paint a pixel chevron instead. (Font is NOT forced to VT323 here: the
 * Pace options are Thai in TH, and VT323 has no Thai glyphs.) */
select.lobby-field { padding-right: 26px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23c8b890' d='M0 0h2v2H0zm8 0h2v2H8zM2 2h2v2H2zm4 0h2v2H6zM4 4h2v2H4z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 9px center; }
.lobby-field:focus-visible { outline: 2px solid rgba(251,191,36,0.6); outline-offset: 1px; }
.lobby-copied { display: none; color: #6ee7a8; font-size: 13px; font-weight: bold; animation: ctkPendingEnter 0.3s var(--ctk-spring); }
.lobby-occupancy { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; row-gap: 4px; font-size: 15px; color: #d8c8a4; margin-bottom: 8px; min-height: 20px; }
.lobby-occupancy .lobby-occ-ico { display: inline-flex; color: #9fb3d6; }
.lobby-occupancy .lobby-occ-ico svg { width: 16px; height: 16px; }
.lobby-occupancy .lobby-occ-count { font-weight: bold; color: #f0e0c0; display: inline-block; }
.lobby-occupancy .lobby-occ-count.bump { animation: ctkSeatPulse 0.5s ease; }
.lobby-occupancy .lobby-occ-note { color: #9a895f; font-size: 13px; }
.lobby-occupancy .lobby-occ-pips { display: inline-flex; gap: 3px; margin-left: 2px; }
.lobby-occupancy .lobby-pip { width: 9px; height: 9px; border-radius: 2px; background: #3a2e1f; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.4); }
.lobby-occupancy .lobby-pip.on { background: linear-gradient(180deg, #f0d890 0%, #c8a24a 100%); box-shadow: 0 0 5px rgba(240,216,144,0.6), inset 0 0 0 1px rgba(255,255,255,0.25); }
/* Ranked search spinner — a small gold ring rotating, for the "Finding a match…" view. */
.ctk-ranked-spinner { width: 18px; height: 18px; border-radius: 50%; border: 2px solid rgba(240,216,144,0.25); border-top-color: #f0d890; display: inline-block; animation: ctkRankedSpin 0.8s linear infinite; flex: 0 0 auto; }
@keyframes ctkRankedSpin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { :where(html:not(.ctk-force-fx)) .ctk-ranked-spinner { animation-duration: 2s; } }
.lobby-playnow { display: inline-flex; align-items: center; gap: 6px; font-weight: bold; }
.lobby-playnow .lobby-playnow-ico { display: inline-flex; line-height: 0; }
.lobby-playnow .lobby-playnow-ico svg { width: 14px; height: 14px; }
.lobby-playnow-hint { color: #9a895f; font-size: 12px; margin-bottom: 6px; }
/* ===== Lobby entry view — option A (re-weighted hierarchy) =====
 * Quick Play is the sole gold hero; Ranked + Learn drop to a quieter
 * side-by-side ghost pair beneath it (full text lives in each tooltip). */
.lobby-secondary-row { display: flex; gap: 8px; margin-bottom: 4px; }
#lobbyNoRoom .lobby-mode-ghost {
  flex: 1; min-width: 0; padding: 9px 8px; min-height: 44px;
  font-weight: bold; font-size: 14px; border-radius: 6px;
  background: rgba(0,0,0,0.18); box-shadow: none;
}
/* The two secondary modes sit either side of the gold Quick Play. They keep
 * DIFFERENT hues — Ranked reads as competitive, Learn as gentle, and that
 * distinction is worth a colour — but at the board's saturation instead of a
 * near-red and a near-violet that were the only cool notes in the lobby.
 * Same law as the role tints: one saturation, one lightness, hue does the
 * work (docs/arena-board-art-audit-2026-08.md). */
#lobbyNoRoom .lobby-mode-ghost.is-ranked { border: 1.5px solid rgba(207,122,119,0.62); color: #cf7a77; }
#lobbyNoRoom .lobby-mode-ghost.is-learn  { border: 1.5px solid rgba(175,119,207,0.62); color: #af77cf; }
#lobbyNoRoom .lobby-mode-ghost.is-ranked:hover { background: rgba(207,122,119,0.14); border-color: #cf7a77; color: #e2a6a3; }
#lobbyNoRoom .lobby-mode-ghost.is-learn:hover  { background: rgba(175,119,207,0.14); border-color: #af77cf; color: #cba3e2; }
/* Private play has a disclosure toggle, expanded by default, so create/join
 * fields are immediately visible when the lobby opens. */
.lobby-disclosure {
  display: flex; align-items: center; gap: 8px; width: 100%;
  padding: 10px 12px; min-height: 44px; border-radius: 6px;
  background: rgba(0,0,0,0.14); border: 1px dashed #6b5530; color: #c8b890;
  font: inherit; font-size: 13px; cursor: pointer; text-align: left;
}
.lobby-disclosure:hover { background: rgba(107,85,48,0.16); color: #e0d0a8; border-color: #8a6a3a; }
.lobby-disclosure:focus-visible { outline: 2px solid rgba(251,191,36,0.6); outline-offset: 1px; }
.lobby-disc-caret { flex: 0 0 auto; transition: transform 0.18s ease; }
.lobby-disclosure[aria-expanded="true"] .lobby-disc-caret { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) { :where(html:not(.ctk-force-fx)) .lobby-disc-caret { transition: none; } }
/* ===== Connection badge (sustained /state poll fallback) ===== */
.ctk-conn-badge {
  position: fixed; left: 50%; bottom: calc(8px + env(safe-area-inset-bottom, 0px) + var(--vv-bottom, 0px)); transform: translateX(-50%);
  z-index: 58; display: inline-flex; align-items: center; gap: 7px;
  padding: 5px 11px; border-radius: 14px; pointer-events: none;
  background: linear-gradient(180deg, rgba(60,48,28,0.96) 0%, rgba(42,32,18,0.96) 100%);
  border: 1px solid #8a6a3a; color: #f0d9a8;
  font-family: 'VT323', monospace; font-size: 14px; letter-spacing: 0.03em;
  box-shadow: 0 3px 12px rgba(0,0,0,0.5);
  animation: ctkPendingEnter 0.3s var(--ctk-spring);
}
html[lang="th"] .ctk-conn-badge { font-family: 'Bai Jamjuree', 'Kanit', sans-serif; font-size: 12px; }
.ctk-conn-badge .ctk-conn-dot {
  width: 8px; height: 8px; border-radius: 50%; background: #f0b86a;
  box-shadow: 0 0 6px rgba(240,184,106,0.8);
  animation: ctkConnPulse 1.1s ease-in-out infinite;
}
@keyframes ctkConnPulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
html[lang="th"] #pendingHint:not(:empty) {
  font-family: 'Bai Jamjuree', 'Kanit', sans-serif;
  font-weight: 700;
  font-size: 14px;
  letter-spacing: 0.04em;
}
#pendingHint:not(:empty)::before {
  content: '';
  display: inline-block;
  width: 14px; height: 14px;
  margin-right: 6px;
  vertical-align: -2px;
  background: currentColor;
  -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M11 7h2v2h-2zm0 4h2v6h-2zM4 4h16v2H4zm0 14h16v2H4zM2 6h2v12H2zm18 0h2v12h-2z'/></svg>") center/contain no-repeat;
          mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M11 7h2v2h-2zm0 4h2v6h-2zM4 4h16v2H4zm0 14h16v2H4zM2 6h2v12H2zm18 0h2v12h-2z'/></svg>") center/contain no-repeat;
  image-rendering: pixelated;
}
@keyframes ctkPendingEnter {
  0%   { transform: translateY(-8px) scale(0.92); opacity: 0; }
  55%  { transform: translateY(2px)  scale(1.04); opacity: 1; }
  100% { transform: translateY(0)    scale(1);    opacity: 1; }
}
@keyframes ctkPendingBreathe {
  0%, 100% {
    box-shadow:
      inset 1px 1px 0 rgba(252,211,77,0.25),
      inset -1px -1px 0 rgba(0,0,0,0.35),
      0 0 10px rgba(251,191,36,0.30),
      0 2px 0 #0e0804;
  }
  50% {
    box-shadow:
      inset 1px 1px 0 rgba(252,211,77,0.40),
      inset -1px -1px 0 rgba(0,0,0,0.35),
      0 0 22px rgba(251,191,36,0.65),
      0 2px 0 #0e0804;
  }
}
/* Mobile forced-response bottom sheet entrance (see the docked-sheet rule
 * in the @media-all block): a quick subtle rise, not a full slide, so a
 * time-pressured response surface registers instantly. */
@keyframes ctkResponseSheetUp {
  from { transform: translateY(10px); opacity: 0; }
  to   { transform: translateY(0);    opacity: 1; }
}

/* ============================================================
 * Multiplayer-only: the FFA solo-vs-bots entry ("New" + its bot-
 * count input) and bot-spectate ("Watch") are removed from the
 * toolbar so the lobby is the single way into a match. Hidden via
 * CSS (not deleted) because the JS handlers bind to these IDs at
 * init without optional-chaining, and the engine's new_game/watch
 * commands stay available for tests + the balance/sim tooling.
 * ============================================================ */
.toolbar-bots,
#newGame,
#watchGame { display: none !important; }

/* While a match is actually in progress, also fold away the discretionary
 * session buttons (Lobby, Support). They're between-games actions, not
 * needed mid-turn, and dropping them keeps .toolbar-bar to a single row so
 * the inline #phaseStatus chip fits without wrapping (a wrap would shave
 * arena height). The in-match exits (Reset for solo, Surrender for rooms)
 * and the hamburger stay; these buttons return at the result screen / idle
 * board. Toggled by body.ctk-match-live in the applyState wrapper
 * (turnNumber > 0 && !finished — the same signal as Reset/Surrender).
 *
 * EXCEPT for an eliminated viewer (body.ctk-viewer-dead): once your seat is
 * out you take no more turns, so there is no row-wrap to protect — and with
 * Surrender hidden (updateSurrenderButtonVisibility) Lobby would otherwise be
 * your only exit, hidden. Written as :not() on the same rule rather than a
 * second override so there's no specificity/!important fight. */
body.ctk-match-live:not(.ctk-viewer-dead) #lobbyBtn,
body.ctk-match-live:not(.ctk-viewer-dead) #supportBtn { display: none !important; }

/* ===== Match-over board (body.ctk-match-over — game.finished) =====
 * Once the match is finished the combat FABs are inert (Advance and the
 * Hand trigger are already .disabled in render on game.finished), so hide
 * them and surface a single centered "Scoreboard" button that reopens the
 * Act 2 result sheet after it's been dismissed. Mirrors the Arena-of-Valor /
 * MOBA post-match pattern (combat HUD off, one clear results affordance) and
 * fills the real gap: before this there was no way to reopen a closed sheet.
 * The hamburger (#menuBtn) intentionally STAYS live — it's the only route to
 * profile / lobby / history / sign-out. While the Act 2 sheet itself is open
 * its z-1300 scrim already covers this z-35 FAB, so there's never a double
 * affordance. Toggled alongside ctk-match-live in the applyState wrapper.
 * The "Game finished. Start a new game or reset." status hint (#pendingHint)
 * is also hidden here: it's redundant once the match is over (the centered
 * Scoreboard FAB + the result sheet's Rematch / Leave-to-lobby already convey
 * the finished state and next actions), and hiding it removes the FAB-vs-hint
 * overlap at its source on every viewport. The match-end Act 1/Act 2 overlays
 * still announce the result, so nothing is lost. */
body.ctk-match-over #advance,
body.ctk-match-over #handOpenBtn,
body.ctk-match-over #pendingHint { display: none !important; }

/* ============================================================
 * Spectator board (body.ctk-spectating) — watching a live room with NO seat.
 * The class is driven by the server's per-frame `spectating` flag, so it can
 * never disagree with what the wire actually sent us.
 *
 * Everything hidden below is an affordance that presupposes a seat. Left VISIBLE
 * on purpose: #pendingHint (it narrates who the table is waiting on — a
 * spectator's main "what's happening now" cue), the log button and log sheet (the
 * spectator's primary content), #bugReportBtn (a watcher spotting a rules bug is
 * useful), and the menu/lobby buttons (the way back out).
 * ============================================================ */
/* Ranked / Final chip on a Live-now watch row. */
.lobby-watch-tag {
  display: inline-block; vertical-align: middle; margin-left: 5px;
  padding: 0 5px; border-radius: 3px;
  font-family: 'VT323', monospace; font-size: 11px; letter-spacing: 0.06em;
  color: #f0d890; background: #3a2c16; border: 1px solid #6b5530;
}

#ctkSpectateBar { display: none; }
body.ctk-spectating #ctkSpectateBar {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  flex: 0 0 auto; width: 100%; box-sizing: border-box;
  padding: 5px 10px;
  font-family: 'VT323', monospace; font-size: 14px; letter-spacing: 0.04em;
  color: #f0d890;
  background: linear-gradient(180deg, #2f2614 0%, #241c10 100%);
  border-bottom: 1px solid #6b5530;
}
html.ctk-shell body.ctk-spectating #ctkSpectateBar { padding-left: 6px; }
#ctkSpectateRoom { color: #9a895f; font-size: 12px; }
#ctkSpectateStopBtn { margin-left: auto; padding: 3px 10px; font-size: 12px; }

/* Seat-owning affordances: a spectator holds no seat, so none of these can act. */
body.ctk-spectating #advance,
body.ctk-spectating #surrenderBtn,
body.ctk-spectating #fastForwardBtn,
body.ctk-spectating #reset,
body.ctk-spectating #handOpenBtn,
body.ctk-spectating #handSheet,
body.ctk-spectating #pendingActions,
body.ctk-spectating #pendingWatchdog,
body.ctk-spectating #ctkIdleHint,
body.ctk-spectating #showDeckDebug,
body.ctk-spectating #deckDebugPanel,
body.ctk-spectating #emoteBar .ctk-chat-open,
body.ctk-spectating #emoteBar .ctk-reads-open,
/* Act 2's Rematch dropdown: rematching is a seated player's call — a spectator has
 * no seat to bring to the next game, and ctkRematch would aim start_game at a room
 * they hold no chair in. The Share dropdown and the scoreboard itself STAY: seeing
 * (and sharing) the result is the point of having watched. */
body.ctk-spectating .ctk-act2-dropdown[data-ctk-dd="replay"] { display: none !important; }
.ctk-scoreboard-fab { display: none; }
/* ON THE TABLE, not over the hand band. This was a viewport-fixed
 * bottom-centre FAB, which put it on top of the farewell hold's
 * GG/Nice/Thanks say-buttons — and with z 35 it WON, so those buttons were
 * unclickable, not merely crowded (bug report #112: measured 129x32px of
 * overlap at 390x780 and 86x21px at 958x952). The bottom band is the one
 * crowded strip on the board — hand-open, Advance, #pendingActions and the
 * emote bar all live there — so the fix is to leave it rather than to
 * negotiate for space in it. Anchored to the same --ctk-felt-* centre as
 * #ctkTableCenter and dropped clear of the two pile stubs, which is both
 * where the reporter asked for it and the emptiest part of a finished board
 * (the felt is dead once the match is over). */
body.ctk-match-over .ctk-scoreboard-fab {
  display: inline-flex; align-items: center; justify-content: center; gap: 5px;
  position: absolute; left: var(--ctk-felt-cx, 50%); top: var(--ctk-felt-cy, 46%);
  /* Down by half a pile + a gap: the stubs are centred ON --ctk-felt-cy. */
  transform: translate(-50%, calc(var(--ctk-pile-h, 92px) / 2 + 16px));
  z-index: 35;                     /* == FAB tier; < modals (1000+) / Act 2 (1300) */
  padding: 6px 14px;               /* trimmed so it no longer collides with the "Game finished" hint */
  font-family: 'VT323', monospace; font-size: 15px; letter-spacing: 0.04em;
  background: linear-gradient(180deg, #f4d06a 0%, #c99a2e 100%);
  border: 2px solid #f7e6a2; color: #241804; font-weight: bold;
  text-shadow: 1px 1px 0 rgba(255,240,200,0.4);
  image-rendering: pixelated;
  box-shadow: inset 1px 1px 0 rgba(255,245,210,0.5), inset -1px -1px 0 rgba(0,0,0,0.3),
              0 3px 0 #6b5015, 0 6px 14px rgba(0,0,0,0.5);
}
body.ctk-match-over .ctk-scoreboard-fab:hover { filter: brightness(1.06); }
body.ctk-match-over .ctk-scoreboard-fab:active {
  transform: translate(-50%, calc(var(--ctk-pile-h, 92px) / 2 + 18px));
  box-shadow: inset 1px 1px 0 rgba(255,245,210,0.5), inset -1px -1px 0 rgba(0,0,0,0.3),
              0 1px 0 #6b5015, 0 3px 8px rgba(0,0,0,0.5);
}
.ctk-scoreboard-fab svg { flex: 0 0 auto; }
html[lang="th"] .ctk-scoreboard-fab { font-family: 'Bai Jamjuree', sans-serif; font-weight: 700; }

/* ============================================================
 * Portrait-first board, applied at ALL viewport sizes (was phone-only).
 * Every screen now renders the fixed-viewport carve + round-table oval
 * so the layout reads like the portrait board everywhere; large screens
 * additionally cap it to a centered portrait column (see the
 * (min-width:601px) override after the round-table block). Rules here
 * restructure the existing .game-frame > flex-col DOM into a
 * fixed-viewport carve: compact topbar → battle arena (opponents
 * + viewer banner) → bottom hand band with floating advance FAB.
 * Portrait-specific deltas live in the (orientation: portrait)
 * block further down.
 *
 * Element IDs and class names are preserved end-to-end so
 * `vizSeatRect`, coach-mark anchors, and the act-1/act-2 reveal
 * overlays keep working. `.is-viewer` is added by the renderer
 * to the viewer's `.player-panel` so we can grid-place the
 * viewer's row separately from opponents.
 * ============================================================ */
/* UNIVERSAL BY DESIGN — applies at ALL viewports. Intent (b): one
   portrait/ring board everywhere; desktop & tablet get the centered-column
   framing in the (min-width:601px) and (min-height:501px) block below.
   ⚠ DO NOT re-gate this to a viewport media query. Besides the mobile-only
   chrome (compact toolbar, hidden title/log), this block ALSO supplies the
   board STRUCTURE — .game-frame height:100dvh + flex column, #battleArena
   flex:1 — that the desktop column depends on; gating it collapses the
   desktop board (#battleArena → 0px). Genuinely shell-only rules belong on
   the html.ctk-shell class (see the JS SSOT), not here. The old
   (max-height:500px),(max-width:600px) gate is pre-unification history. */
@media all {
  html, body {
    height: 100vh;
    height: 100dvh;
    overflow: hidden;
  }
  body {
    padding-bottom: 0;
    min-height: 0;
  }

  /* Frame: fills viewport, no chrome padding, safe-area aware.
   * `!important` on padding/margin defeats the Tailwind `p-3`
   * utility (which otherwise wins the cascade as it's injected by
   * the CDN script after this stylesheet) — that p-3 was eating
   * ~30px of vertical budget and 15px on each side. */
  .game-frame {
    max-width: 100% !important;
    width: 100%;
    height: 100vh;
    height: 100dvh;
    margin: 0 !important;
    padding:
      env(safe-area-inset-top, 0px)
      env(safe-area-inset-right, 0px)
      env(safe-area-inset-bottom, 0px)
      env(safe-area-inset-left, 0px) !important;
    gap: 0 !important;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    box-sizing: border-box;
  }

  /* Hide chrome that eats vertical room: title banner, deck-debug
   * toggle, log, deck-debug panel. History modal already accessible
   * via the toolbar history button. */
  .game-frame > .game-title,
  .game-frame > label,
  .game-frame #deckDebugPanel,
  #debug,
  #versionDebug {
    display: none !important;
  }
  /* Inline log hidden on ALL viewports — the log now lives only in the
     bottom sheet (opened by the tools strip's Logs button), on desktop as
     well as mobile. openLogSheet() reparents #logFilterBar + #logScroll out
     of .game-frame (so this rule stops matching) and back on close. */
  .game-frame #logFilterBar,
  .game-frame #logScroll {
    display: none !important;
  }

  /* (The old DESKTOP inline-log filter-chip compaction was removed with the
   * inline desktop log: the filter bar now renders only inside the log
   * sheet, at the sheet's default sizing — identical to mobile.) */

  /* Compact icon toolbar — single row, horizontal scroll if needed.
   * Gated to html.ctk-shell so this mobile compaction no longer leaks onto
   * desktop. On desktop .toolbar-bar reverts to its base Tailwind layout
   * (flex-wrap, auto height) and .px-btn falls through to the 44px touch
   * floor + the container-query tiers (~L1313), per M3/HIG/repo-44px. */
  html.ctk-shell .toolbar-bar {
    flex-wrap: nowrap !important;
    min-height: 40px;
    height: 40px;
    padding: 2px 6px;
    margin-bottom: 0 !important;
    overflow-x: auto;
    overflow-y: hidden;
    gap: 6px !important;
    flex: 0 0 auto;
  }
  html.ctk-shell .toolbar-bar > div { flex-wrap: nowrap !important; gap: 4px !important; flex-shrink: 0; }
  html.ctk-shell .toolbar-bar .px-btn, html.ctk-shell #bugReportDock .px-btn {
    padding: 4px 8px !important;
    min-width: 40px !important;
    min-height: 36px !important;
  }
  .toolbar-bar .toolbar-bots { padding: 0 4px !important; }
  .toolbar-bar .toolbar-bots input { width: 32px !important; padding: 2px !important; }
  .toolbar-bar #status { font-size: 11px; white-space: nowrap; }
  /* Keep BOTH the GIS sign-in button (#googleSignInBtn) and the signed-in
   * chip (avatar + name + sign-out) VISIBLE on every viewport. This toolbar
   * block is now universal (min-width:0px). A prior rule hid the button here
   * with `display:none !important`, which ALSO defeated updateAuthUI's inline
   * display toggle — so a signed-out player had no way to sign in, and thus
   * never reached the signed-in chip (profile/sign-out) either. The themed-
   * button replacement that rule was waiting on was tried (#127) and dropped
   * (#128 → raw renderButton), so just surface the button. updateAuthUI shows
   * button XOR chip by sign-in state; only the chip needs narrow-bar compacting. */
  .toolbar-bar .google-chip { font-size: 11px !important; padding: 2px 4px !important; }
  /* The inline log is hidden on ALL viewports (above). It's surfaced through
   * the bottom sheet, opened by the Logs button in the #emoteBar tools strip
   * (shown in every match — solo or room — on desktop as well as mobile).
   * The toolbar #logBtn owns the same handler, but it's redundant with the
   * strip, so it stays hidden (base display:none) — no toolbar reveal here.
   * So #logBtn is now never surfaced; its handler stays wired by id for
   * safety + the balance/sim tooling. */

  /* Battle Board flex-col: take all remaining height. */
  .game-frame > div.flex.flex-col {
    flex: 1 1 0;
    min-height: 0;
    width: 100%;
    padding-bottom: 0 !important;
    gap: 2px !important;
  }

  /* Battle arena: flex:1, content (#players) fills it. */
  #battleArena {
    flex: 1 1 0;
    min-height: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    position: relative;
  }

  /* #players: flex row with wrap; opponents share row 1 capped
   * at the existing compact-tier breakpoint (109px) so the
   * @container queries that hide chip text and shrink fonts
   * trigger automatically. Viewer panel wraps to a full-width
   * row 2 via flex-basis:100% and `order: 999`. `data-seat`
   * selectors + #battleArena ancestor are preserved so
   * vizSeatRect coord math keeps working. */
  #players {
    display: flex !important;
    flex-direction: row;
    flex-wrap: wrap !important;
    gap: 4px !important;
    align-items: flex-start;
    align-content: flex-start;
    justify-content: center;
    height: 100%;
    width: 100%;
    margin-bottom: 0 !important;
    padding: 2px;
    box-sizing: border-box;
  }
  /* Opponents: small flex-basis (84px) so even the 10-seat max
   * (9 opponents) stays on ONE row — 9×84=756 < ~836 usable —
   * then grow to fill, capped at 116px so a 2-3 opponent game
   * doesn't balloon. At dense counts they settle ~90px wide,
   * tripping the @container compact tier automatically. */
  #players > .player-panel:not(.is-viewer) {
    flex: 1 1 84px;
    min-width: 0;
    max-width: 116px;
    margin-top: 0 !important;
  }
  /* ---- Viewer banner: wide hero strip ----
   * The viewer panel keeps its vertical DOM stack but is
   * compressed into a 72px banner: the "You" label and seat row
   * are tucked to the far right, and the hero-stage spans the
   * full banner width as a letterbox portrait with the
   * bottom-overlay flattened into a single horizontal info row
   * (hero name · hand/range · skill+equipment chips). The
   * overlay stays a child of .hero-stage so the portrait img's
   * absolute anchor is untouched — we just relayout it. */
  #players > .player-panel.is-viewer {
    order: 999;
    flex: 0 0 100%;
    max-width: 100% !important;
    height: 72px;
    max-height: 72px;
    position: relative;
    text-align: left !important;
    padding: 2px 8px 2px 56px !important; /* left pad clears HP corner */
    margin-top: 2px;
    overflow: hidden;
  }
  /* "You" name div (only unclassed direct child): pin top-right,
   * out of the portrait's way. */
  #players > .player-panel.is-viewer
    > div:not(.hero-stage):not(.seat-row):not(.player-hp-corner):not(.player-corner-badge) {
    position: absolute;
    top: 2px;
    right: 8px;
    margin: 0 !important;
    font-size: 13px !important;
    line-height: 1.1;
    z-index: 2;
  }
  /* Seat label: pin bottom-right. */
  #players > .player-panel.is-viewer .seat-row {
    position: absolute;
    bottom: 2px;
    right: 8px;
    margin: 0 !important;
    font-size: 11px !important;
    z-index: 2;
  }
  /* Self-badge icon is redundant with the "You" label in the
   * banner and collides with it top-right — hide it here. */
  #players > .player-panel.is-viewer .player-corner-badge {
    display: none !important;
  }
  /* Hero-stage: full banner width, letterbox portrait. */
  #players > .player-panel.is-viewer .hero-stage {
    aspect-ratio: auto !important;
    width: 100%;
    height: 64px !important;
    min-height: 0 !important;
    margin: 0 !important;
  }
  #players > .player-panel.is-viewer .hero-stage .hero-portrait {
    object-position: center 28%;
  }
   /* (Removed) The legacy "banner" layout flattened the viewer's
   * .player-bottom-overlay into a horizontal, overflow-x:auto info row.
   * The portrait/ring board is now universal (see the min-width:0px ring
   * block below), so the banner is always superseded — BUT these overlay
   * rules carried an extra `.hero-stage` qualifier (specificity 1,4,0),
   * out-specifying the ring's column "undo" at #L3179 (1,3,0). So
   * flex-direction:row + overflow-x:auto + the 80px right-pad leaked onto
   * the VIEWER's overlay only, giving it a horizontal scrollbar the bots
   * never had. Dropped so the viewer overlay inherits the base column
   * layout and matches the opponents. */
  /* Opponent hero-stage: fixed height (overrides 1:1 aspect-ratio
   * that would otherwise blow panel height to match its width).
   * Heights budgeted so opp(116) + gap(4) + viewer(72) +
   * #players padding(4) = 196 < ~210px arena → no clip. */
  #players > .player-panel:not(.is-viewer) .hero-stage {
    aspect-ratio: auto !important;
    height: 80px !important;
    min-height: 0 !important;
  }
  /* The fixed-height stage is a letterbox: object-fit:cover crops the
   * portrait, and the base `center top` shows foreheads/hair. Bias toward
   * the face (matches the viewer banner's tuned 28%) so opponents read as
   * faces, not scalps — most visible in the compressed pending state below
   * where the box turns wider-than-tall and the crop becomes vertical. */
  #players > .player-panel:not(.is-viewer) .hero-stage .hero-portrait {
    object-position: center 28%;
  }
  /* Cap opponent panel total height so they stack neatly above
   * the viewer banner. Viewer is sized via its own max-height. */
  #players > .player-panel:not(.is-viewer) {
    max-height: 116px;
    overflow: hidden;
  }

  /* Hand container (the flex-col wrapping #hand, #pendingHint,
   * #pendingActions, #advance row). `:has()` lets us target just
   * the right sibling without an ID rename. Capped + scrollable so
   * that when a pending-response prompt (#pendingHint +
   * #pendingActions) appears it can't grow tall enough to squeeze
   * the battle arena to nothing — the band scrolls internally and
   * the board keeps its space. */
  .game-frame .ctk-hand-band {
    flex: 0 0 auto;
    width: 100%;
    gap: 0 !important;
    padding: 0;
    /* overflow:visible so the focus-pop can grow upward out of the
     * band into the arena. Cards are compact now (~93px) so the band
     * no longer needs a height cap to avoid squeezing the board. */
    overflow: visible;
  }

  /* Pending-response surface → docked BOTTOM SHEET (mobile / html.ctk-shell).
   *
   * A forced response (#pendingActions populated) used to compress the
   * opponent portraits to a 44px letterbox so the ~93px response cards fit
   * the scarce phone vertical budget — players disliked the faces squishing.
   * Per the design refs (M3 standard bottom sheet, Balatro / Arena-of-Valor
   * "the play area is sacred", Sanguosha's docked prompt bar) the transient
   * response surface should FLOAT OVER the board, not reflow it — exactly
   * what the guanxing-modal scrim and own-skill-actions cluster already do.
   *
   * (1) Reserve the band's footprint on the Battle Board column so
   *     #battleArena (flex:1) keeps the SAME height it has when the hand is
   *     shown — the oval doesn't "breathe"/re-fit when a prompt appears.
   * (2) Lift the bottom band (hint + watchdog + cards) out of the flex
   *     column into a fixed sheet pinned to the bottom edge, where the hand
   *     normally sits. Hero-stages stay full-size; the board does not move.
   *
   * Excludes own-skill-actions (its own bottom-LEFT cluster; the hand stays
   * up) and guanxing-modal (its own full-screen scrim). The Advance FAB is
   * its own position:fixed element (viewport-anchored, no transformed
   * ancestor) so it stays pinned, not swept into the sheet; #phaseStatus
   * lives up in .toolbar-bar (outside this band) so it's never in the sheet
   * subtree at all; #hand is already display:none during a response (below),
   * so the sheet shows no empty hand gap. */
  html.ctk-shell body:not(.ctk-hud-layout) .game-frame
    > div.flex.flex-col:has(#pendingActions:not(:empty):not(.own-skill-actions):not(.guanxing-modal)) {
    /* !important to beat the carve's `padding-bottom: 0 !important` on this
     * same column (the board-structure rule above). Reserve the portrait
     * hand-band's full footprint so #battleArena (flex:1) keeps the SAME
     * height whether the hand-trigger band or the sheet is shown — no seat
     * "breathing". The band is now ONE grid row (tools strip / hand trigger /
     * in-flow Advance — see the portrait block below): 3px top pad + 44px row
     * + 6px bottom pad = 53px (measured at 360×740); the old 54px Advance-FAB
     * reserve strip is gone.
     * NB: shell+non-HUD is ALWAYS a portrait phone (landscape→HUD, excluded),
     * so 54px is the right value for every production case. The sheet itself
     * may be taller (a card-heavy response); it overlays the board's lower
     * edge, where the trigger sat. */
    padding-bottom: calc(54px + env(safe-area-inset-bottom, 0px)) !important;
  }
  html.ctk-shell body:not(.ctk-hud-layout) .game-frame
    .flex.flex-col.items-center:has(> #pendingActions:not(:empty):not(.own-skill-actions):not(.guanxing-modal)) {
    position: fixed;
    left: env(safe-area-inset-left, 0px);
    right: env(safe-area-inset-right, 0px);
    bottom: 0;
    width: auto;
    z-index: 36;
    margin: 0;
    padding: 8px 8px calc(10px + env(safe-area-inset-bottom, 0px) + var(--vv-bottom, 0px));
    background: linear-gradient(180deg, rgba(26,20,14,0.95) 0%, rgba(12,9,6,0.985) 100%);
    border-top: 2px solid #6b5530;
    box-shadow: 0 -10px 30px rgba(0,0,0,0.6);
    max-height: 64vh; max-height: 64dvh;
    /* overflow-x:hidden is explicit: the band base rule sets overflow:visible,
     * and a single non-visible axis forces the OTHER to compute to 'auto' too —
     * so without this the sheet would show a spurious horizontal scrollbar for
     * any child wider than its content box (today avoided only via Tailwind's
     * global border-box). We only ever want vertical scroll. */
    overflow-x: hidden;
    overflow-y: auto;
    animation: ctkResponseSheetUp 170ms var(--ctk-spring);
  }
  @media (prefers-reduced-motion: reduce) {
    html.ctk-shell:where(:not(.ctk-force-fx)) body:not(.ctk-hud-layout) .game-frame
      .flex.flex-col.items-center:has(> #pendingActions:not(:empty):not(.own-skill-actions):not(.guanxing-modal)) {
      animation: none;
    }
  }
  /* Hide the Advance FAB while the sheet is up. The Advance row is a
   * position:fixed child of the band; once the band becomes the z-index:36
   * sheet it forms a stacking context, so the Advance button's own
   * z-index:35 stacks it ABOVE the sheet's #pendingActions content — it was
   * rendering on top of the bottom-right Skip / response card. Advance is
   * never a valid move during a forced response (you Skip or play a card in
   * the sheet), so hiding it removes the collision. Only the button is
   * hidden — #phaseStatus (the toolbar chip, with the deadline ring) stays. */
  /* Driven by body.ctk-pending-sheet, which render() sets from the same three
     conditions this used to re-derive with `:has(> #pendingActions:not(:empty)…)`.
     A stale `:has()`/`:empty` invalidation in older WebKit left the button hidden
     after the node emptied, and only a rotation's forced recalc brought it back —
     bug report #7, "the End Phase button often disappears, I have to rotate the
     screen". The class carries no invalidation dependency. */
  html.ctk-shell body.ctk-pending-sheet:not(.ctk-hud-layout) .game-frame #advance {
    display: none !important;
  }
  /* Pending-response surface → docked BOTTOM SHEET (desktop / tablet /
   * html:not(.ctk-shell)). Same mechanism as the mobile shell sheet above,
   * generalized to the (width>600px AND height>500px) viewports the shell
   * fix — scoped html.ctk-shell = (max-width:600px),(max-height:500px) — never
   * reached: desktops, laptops, and tablets. On those, the response band was
   * left IN FLOW, so a hint/response grew it (~+118px), #battleArena (flex:1,
   * the sole grow/shrink sink) lost that height, and ctkReapplyOval() re-fit a
   * shorter oval — the whole seat ring jumped UP ~79px and snapped back on
   * answer. Players found that breathing confusing.
   *
   * (1) Reserve the band's resting footprint on the Battle Board column so
   *     #battleArena keeps the SAME height whether the hand trigger or the
   *     response sheet is shown — the oval doesn't re-fit. The desktop resting
   *     band is the shared one-row grid (3px pad + 44px row + 6px pad ≈ 53px;
   *     the old 54px Advance reserve strip is gone) — fixed-height children,
   *     width-independent.
   * (2) Float the band out of flow into a fixed sheet at the bottom edge.
   *     Unlike the full-width phone sheet, it's constrained to the centered
   *     board's ~760px width (margin:auto, NOT translateX — the
   *     ctkResponseSheetUp keyframe owns transform) so it reads as a docked
   *     card, not a screen-wide bar on a 1920px monitor.
   *
   * Same guards as the shell sheet: exclude .own-skill-actions (own bottom
   * cluster, hand stays up) and .guanxing-modal (own scrim); hide #advance
   * while the sheet is up — on desktop it's a band CHILD, so it would ride into
   * the fixed sheet and, as a stacking sibling, eclipse the response cards, and
   * Advance is never a valid move during a forced response. #hand is already
   * display:none during a response (rule below) and #phaseStatus lives in the
   * toolbar, so the deadline ring stays visible. */
  html:not(.ctk-shell) body:not(.ctk-hud-layout) .game-frame
    > div.flex.flex-col:has(#pendingActions:not(:empty):not(.own-skill-actions):not(.guanxing-modal)) {
    /* !important beats the universal carve's `padding-bottom: 0 !important`
     * on this same column; higher specificity also wins the !important tie. */
    padding-bottom: 54px !important;
  }
  html:not(.ctk-shell) body:not(.ctk-hud-layout) .game-frame
    .flex.flex-col.items-center:has(> #pendingActions:not(:empty):not(.own-skill-actions):not(.guanxing-modal)) {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    width: 100%;
    max-width: 760px;
    margin: 0 auto;
    z-index: 36;
    padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
    background: linear-gradient(180deg, rgba(26,20,14,0.95) 0%, rgba(12,9,6,0.985) 100%);
    border: 2px solid #6b5530;
    border-bottom: none;
    border-radius: 12px 12px 0 0;
    box-shadow: 0 -10px 30px rgba(0,0,0,0.6);
    max-height: 50vh;
    overflow-x: hidden;
    overflow-y: auto;
    animation: ctkResponseSheetUp 170ms var(--ctk-spring);
  }
  @media (prefers-reduced-motion: reduce) {
    html:not(.ctk-shell):where(:not(.ctk-force-fx)) body:not(.ctk-hud-layout) .game-frame
      .flex.flex-col.items-center:has(> #pendingActions:not(:empty):not(.own-skill-actions):not(.guanxing-modal)) {
      animation: none;
    }
  }
  /* Desktop/tablet twin of the shell rule above — same body class, same reason. */
  html:not(.ctk-shell) body.ctk-pending-sheet:not(.ctk-hud-layout) .game-frame #advance {
    display: none !important;
  }
  /* During a pending response/picker the actionable cards live in
   * #pendingActions (Defense options, skill picker, Skip, etc.) —
   * the full #hand row would be redundant.
   *
   * The rule that used to live here — `.game-frame > div.flex.flex-col
   * :has(#pendingActions...) #hand { display: none !important }` — was
   * DEAD and removed 2026-09-04. #hand has not been inside .game-frame
   * since the hand became a bottom sheet: its real chain is
   * #handSheet > .ctk-hand-sheet-panel > .ctk-hand-sheet-body > #hand,
   * a top-level sibling of the board (see the note near .ctk-hand-sheet).
   * Measured on a live board at 390px and 1280px, the selector matched 0
   * elements. Its comment claimed the hand was hidden and "restores
   * automatically when #pendingActions empties", which was misleading
   * every reader: the hand sheet is a separate modal and is dismissed by
   * its own close path. Removing it changes no pixel. */
  /* Pending hint: clamp to 2 lines so a verbose prompt doesn't
   * dominate the band. */
  #pendingHint {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }

  /* Hand row: compact cards (see resize below) fit a normal hand
   * without scrolling, so we use overflow:visible — NOT overflow-x:
   * auto. (A scroll container would force the cross-axis to clip
   * too, cutting off the focus-pop.) Cards may flex-shrink slightly
   * if the hand is unusually large. */
  #hand {
    flex-wrap: nowrap !important;
    overflow: visible;
    justify-content: center !important;
    padding: 2px 8px 4px 8px;
    width: 100%;
    margin-bottom: 0 !important;
    min-height: 100px;
    gap: 5px !important;
  }
  #hand > * { flex: 0 1 auto; min-width: 0; }

  /* ---- Compact hand/response cards (M3 48dp-floor) ----
   * Shrink the 84×140 cards to ~56×93 in landscape so a full hand
   * fits without scrolling and the board keeps its height. Internal
   * art / pips / title scale to match (~⅔). Applies to the hand and
   * the #pendingActions response cards; picker-compact chips already
   * have their own tiny form. */
  #hand .pixel-card:not(.picker-compact),
  #pendingActions .pixel-card:not(.picker-compact) {
    width: 56px !important;
    min-width: 48px !important;
    height: 93px !important;
    padding: 3px 4px 4px 4px;
    gap: 1px;
  }
  #hand .pixel-card:not(.picker-compact) .card-img,
  #pendingActions .pixel-card:not(.picker-compact) .card-img {
    width: 42px !important;
    height: 56px !important;
  }
  #hand .pixel-card:not(.picker-compact) .card-suit-rank,
  #pendingActions .pixel-card:not(.picker-compact) .card-suit-rank {
    font-size: 11px;
    padding: 0 3px;
  }
  #hand .pixel-card:not(.picker-compact) .card-suit-rank .suit-glyph,
  #pendingActions .pixel-card:not(.picker-compact) .card-suit-rank .suit-glyph {
    font-size: 10px;
  }
  #hand .pixel-card:not(.picker-compact) .card-title,
  #pendingActions .pixel-card:not(.picker-compact) .card-title {
    font-size: 11px;
  }

  /* ---- Focus pop (Balatro / SanGuoSha) ----
   * The hovered / pressed / focused hand card scales back up to a
   * readable size and lifts above its neighbors. Grows UPWARD from
   * the hand row into the arena (transform-origin bottom), which is
   * why #hand + its container must not clip vertically. Transforms
   * don't affect layout, so neighbors don't reflow. */
  #hand .pixel-card:not(.picker-compact):hover,
  #hand .pixel-card:not(.picker-compact):focus-visible,
  #hand .pixel-card:not(.picker-compact):active {
    transform: translateY(-8px) scale(1.6);
    transform-origin: center bottom;
    z-index: 30;
    box-shadow: 0 10px 22px rgba(0,0,0,0.65);
  }

  /* ============================================================
   * HAND-CARDS MODAL. The playable hand lives in #handSheet (a bottom
   * sheet), NOT inline in the band — a large hand wrapping to 2-3 rows
   * used to grow the band and shove the seat-ring up. The board shows a
   * fixed-height #handOpenBtn trigger; tapping it slides up the sheet with
   * the cards in a 4-per-row grid that scrolls vertically. Design refs:
   * M3 bottom-sheet, SanGuoSha hand-tray, Balatro card feel, pixelarticons.
   * #hand itself is authored inside the sheet (see markup), so render() and
   * the delegated play-handler keep targeting it by id, unchanged.
   * ============================================================ */

  /* --- Trigger button (sits where the inline #hand row used to) --- */
  .ctk-hand-open {
    display: inline-flex; align-items: center; gap: 7px;
    margin: 3px auto 2px; padding: 6px 14px; min-height: 40px;
    font-family: 'VT323', monospace; font-size: 17px; letter-spacing: 0.5px;
    color: #f0e0c0;
    background: linear-gradient(180deg, #5a4a2a 0%, #3f3318 100%);
    border: 2px solid #a78a4a; border-radius: 8px;
    box-shadow: inset 0 1px 0 rgba(255,235,190,0.22), 0 3px 8px rgba(0,0,0,0.5);
    cursor: pointer; image-rendering: pixelated;
    transition: transform 90ms ease, filter 120ms ease;
  }
  .ctk-hand-open:hover { filter: brightness(1.08); }
  .ctk-hand-open:active { transform: translateY(1px); }
  .ctk-hand-open:focus-visible { outline: 2px solid #fbbf24; outline-offset: 2px; }
  .ctk-hand-open .ctk-hand-open-ico { display: inline-flex; width: 20px; height: 20px; }
  .ctk-hand-open .ctk-hand-open-ico svg { width: 100%; height: 100%; image-rendering: pixelated; }
  .ctk-hand-open .ctk-hand-open-count {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: 20px; height: 20px; padding: 0 5px;
    font-size: 14px; font-weight: bold; line-height: 1;
    color: #1a1208; background: #f0d890; border-radius: 999px;
    box-shadow: inset 0 -1px 0 rgba(0,0,0,0.25);
  }
  /* Composition clue: a row of tiny category-colored pips (one per card,
     grouped by type, capped at 8) so a glance at the trigger hints what's in
     hand — reuses the exact per-card-type palette (.card-* ::before colors). */
  .ctk-hand-open .ctk-hand-open-pips {
    display: inline-flex; align-items: center; gap: 2px; margin: 0 2px;
  }
  .ctk-hand-open .ctk-hand-open-pips:empty { display: none; }
  .ctk-hand-pip {
    width: 7px; height: 7px; border-radius: 2px;
    box-shadow: inset 0 0 0 1px rgba(0,0,0,0.4);
    image-rendering: pixelated; flex: 0 0 auto;
  }
  .ctk-hand-pip.card-attack  { background: #e53e3e; }
  .ctk-hand-pip.card-defense { background: #3b82f6; }
  .ctk-hand-pip.card-peach   { background: #ec4899; }
  .ctk-hand-pip.card-tactic  { background: #8b5cf6; }
  .ctk-hand-pip.card-equip   { background: #f59e0b; }
  .ctk-hand-pip.card-delay   { background: #6b7280; }
  .ctk-hand-pip.card-negate  { background: #a0aec0; }
  /* Your-turn cue: a soft amber pulse that says "open me and play". */
  .ctk-hand-open.is-turn {
    border-color: #f6c453;
    animation: ctkHandOpenPulse 1.8s ease-in-out infinite;
  }
  @keyframes ctkHandOpenPulse {
    0%,100% { box-shadow: inset 0 1px 0 rgba(255,235,190,0.22), 0 3px 8px rgba(0,0,0,0.5), 0 0 0 rgba(246,196,83,0); }
    50%     { box-shadow: inset 0 1px 0 rgba(255,235,190,0.22), 0 3px 8px rgba(0,0,0,0.5), 0 0 16px rgba(246,196,83,0.55); }
  }
  @media (prefers-reduced-motion: reduce) { :where(html:not(.ctk-force-fx)) .ctk-hand-open.is-turn { animation: none; } }
  /* Thai title/label font — VT323 can't render Thai glyphs. */
  html[lang="th"] .ctk-hand-open { font-family: 'Bai Jamjuree', 'Noto Sans Thai', sans-serif; font-weight: 600; font-size: 15px; }
  /* During a forced response the response sheet owns the bottom edge — hide
     the trigger (mirrors the old "hide #hand while pending" rule). */
  .ctk-hand-band:has(> #pendingActions:not(:empty):not(.own-skill-actions):not(.guanxing-modal)) > #handOpenBtn {
    display: none !important;
  }

  /* --- The sheet (mirrors the card-inspector bottom sheet) --- */
  .ctk-hand-sheet {
    position: fixed; inset: 0; z-index: 1080;
    display: none; align-items: flex-end; justify-content: center;
    background: rgba(0,0,0,0.6);
    animation: ctk-sheet-fade 0.18s ease-out;
  }
  .ctk-hand-sheet.is-open { display: flex; }
  .ctk-hand-sheet-panel {
    position: relative;
    width: 100%; max-width: 460px;
    max-height: 78vh; max-height: 78dvh;
    display: flex; flex-direction: column;
    background: linear-gradient(180deg, #2a2218 0%, #16120c 100%);
    border: 2px solid #6b5530; border-bottom: none;
    border-radius: 14px 14px 0 0;
    padding: 8px 12px calc(12px + env(safe-area-inset-bottom, 0px));
    box-shadow: 0 -10px 30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(200,170,120,0.18);
    animation: ctk-sheet-rise 0.22s cubic-bezier(0.2, 0.8, 0.2, 1.05);
  }
  @media (prefers-reduced-motion: reduce) {
    :where(html:not(.ctk-force-fx)) .ctk-hand-sheet, :where(html:not(.ctk-force-fx)) .ctk-hand-sheet-panel { animation: none; }
  }
  .ctk-hand-sheet-handle {
    width: 40px; height: 4px; border-radius: 2px;
    background: #5a4530; margin: 0 auto 6px;
  }
  .ctk-hand-sheet-close {
    position: absolute; top: 2px; right: 2px;
    width: 44px; height: 44px;
    display: inline-flex; align-items: center; justify-content: center;
    background: transparent; color: #c8b890;
    border: 1px solid transparent; border-radius: 6px; cursor: pointer;
  }
  .ctk-hand-sheet-close svg { width: 16px; height: 16px; image-rendering: pixelated; }
  .ctk-hand-sheet-close:hover { color: #fde68a; border-color: #6b5530; }
  .ctk-hand-sheet-close:focus-visible { outline: 2px solid #fbbf24; outline-offset: 2px; }
  .ctk-hand-sheet-header {
    display: flex; align-items: center; justify-content: center;
    margin: 2px 34px 8px; padding-bottom: 8px; flex: 0 0 auto;
    border-bottom: 1px solid rgba(107,85,48,0.5);
  }
  .ctk-hand-sheet-title {
    display: inline-flex; align-items: center; gap: 8px;
    color: #f0d890; font-family: 'VT323', monospace; font-size: 22px;
    text-shadow: 0 1px 0 #0e0804, 0 0 10px rgba(240,216,144,0.22);
  }
  html[lang="th"] .ctk-hand-sheet-title { font-family: 'Bai Jamjuree', 'Noto Sans Thai', sans-serif; font-weight: 600; font-size: 18px; }
  .ctk-hand-sheet-title-ico { display: inline-flex; width: 18px; height: 18px; opacity: 0.9; }
  .ctk-hand-sheet-title-ico svg { width: 100%; height: 100%; image-rendering: pixelated; }
  .ctk-hand-sheet-count {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: 22px; height: 22px; padding: 0 6px;
    font-size: 15px; font-weight: bold; line-height: 1;
    color: #1a1208; background: #f0d890; border-radius: 999px;
  }
  .ctk-hand-sheet-count:empty { display: none; }
  .ctk-hand-sheet-body {
    flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden;
    padding: 6px 2px 2px;
  }

  /* --- The hand grid inside the sheet: 4 per row, wraps, body scrolls.
         Two ids beat the board's single-id compact #hand rules. --- */
  #handSheet #hand {
    display: grid !important;
    grid-template-columns: repeat(4, 1fr);
    gap: 12px 8px !important;
    width: 100%; min-height: 0; padding: 4px 2px 6px;
    justify-items: center; align-content: start;
    overflow: visible;
  }
  #handSheet #hand > * { width: 100%; }
  #handSheet #hand .ctk-tip-wrap { display: flex; width: 100%; justify-content: center; }
  /* Roomier, readable cards than the 56×93 board-compact form. */
  #handSheet #hand .pixel-card:not(.picker-compact) {
    width: 72px !important; min-width: 72px !important; height: 118px !important;
    padding: 4px 4px 5px 4px; gap: 1px;
  }
  #handSheet #hand .pixel-card:not(.picker-compact) .card-img {
    width: 56px !important; height: 74px !important;
  }
  #handSheet #hand .pixel-card:not(.picker-compact) .card-suit-rank { font-size: 12px; padding: 0 3px; }
  #handSheet #hand .pixel-card:not(.picker-compact) .card-suit-rank .suit-glyph { font-size: 11px; }
  #handSheet #hand .pixel-card:not(.picker-compact) .card-title { font-size: 12px; }
  /* Gentle hover — the body clips (overflow-y:auto), so the board's upward
     scale(1.6) pop would be cut off; a small in-cell lift instead. */
  #handSheet #hand .pixel-card:not(.picker-compact):hover,
  #handSheet #hand .pixel-card:not(.picker-compact):focus-visible,
  #handSheet #hand .pixel-card:not(.picker-compact):active {
    transform: translateY(-4px) scale(1.05);
    transform-origin: center bottom;
    z-index: 2;
    box-shadow: 0 8px 18px rgba(0,0,0,0.55);
  }

  /* pendingActions: same horizontal-scroll treatment. */
  #pendingActions {
    /* Wrap (was nowrap + horizontal-scroll) so the prompt sits on its own
     * line and the response buttons wrap neatly centered below it. */
    flex-wrap: wrap !important;
    overflow-x: visible;
    overflow-y: visible;
    justify-content: center !important;
    padding: 0 8px;
    width: 100%;
    margin-top: 0 !important;
  }
  #pendingActions:empty { display: none; }
  #pendingActions > * { flex-shrink: 0; }

  /* Own active-skill buttons (Zhiheng / Rende / Kurou / Serpent) — float
   * them as a bottom-LEFT cluster, mirroring the bottom-right Advance FAB,
   * so they no longer hijack the response band (which hid the hand) and sit
   * opposite Advance per the requested layout. They live in the 54px strip
   * the hand band already reserves at the bottom, so they clear the cards. */
  #pendingActions.own-skill-actions {
    position: fixed;
    bottom: calc(env(safe-area-inset-bottom, 0px) + var(--vv-bottom, 0px) + 8px);
    left: calc(env(safe-area-inset-left, 0px) + 8px);
    right: auto;
    width: auto;
    max-width: 56vw;
    z-index: 35;
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    justify-content: flex-start;
    overflow: visible;
    padding: 0;
    margin: 0;
  }
  #pendingActions.own-skill-actions .px-btn {
    font-size: 13px;
    padding: 10px 14px;
    min-height: 38px;
    box-shadow:
      0 4px 12px rgba(0,0,0,0.5),
      inset 1px 1px 0 rgba(255,255,255,0.15);
  }

  /* Pending hint: thin status strip above hand. */
  #pendingHint {
    font-size: 12px;
    padding: 0 8px;
    line-height: 1.3;
    text-align: center;
  }
  #pendingHint:empty { display: none; }
  #pendingWatchdog {
    font-size: 11px !important;
    margin-top: 0 !important;
    padding: 0 8px;
  }

  /* Error: thin row, no claim on space when empty. */
  #error {
    font-size: 12px !important;
    padding: 0 8px;
    line-height: 1.2;
  }
  #error:empty { display: none; }

  /* Phase + advance row: hoist advance to a floating FAB at
   * bottom-right; hide the phase status text (info is conveyed
   * by the turn-chevron + glow on the panel). */
  /* SUPERSEDED IN PRACTICE: the unconditional `@media all` hand-band grid
   * block further down resets this row to `position: static; grid-area:
   * adv` and, being later in source order, wins everywhere — see its own
   * comment, which says it is placed there on purpose. Measured 2026-09-04
   * on a live board: static at 390x844, 844x390 and 1280x800, so this
   * hoist never applies today. Left in place rather than deleted because
   * the override is pure source-order and reinstating the hoist may be
   * wanted; tests/board-has-rules-guard.spec.ts pins the outcome so the
   * two blocks cannot silently swap winners.
   *
   * SELECTOR HAZARD: `.flex.items-center.gap-2` also matches .toolbar-bar,
   * which holds #phaseStatus — so this compound is re-checked whenever
   * renderPhaseStatusTimer / renderMatchClock rewrite that text (measured
   * 2026-09-04: 284 ":has()-affected" recalcs per 20 s, costing 0 ms
   * resolvable). Harmless here, but any NEW rule written against that
   * compound will silently hit the toolbar too — scope it to #advance's
   * own row instead. */
  .game-frame .flex.items-center.gap-2:has(> #advance) {
    position: fixed;
    bottom: calc(env(safe-area-inset-bottom, 0px) + var(--vv-bottom, 0px) + 8px);
    right: calc(env(safe-area-inset-right, 0px) + 8px);
    z-index: 35;
    margin-top: 0 !important;
    gap: 4px !important;
  }
  /* Phase/turn indicator chip. Lives INLINE in the top toolbar (a direct
   * child of .toolbar-bar, between the button cluster and the lang/status
   * cluster) — i.e. ABOVE #battleArena, so it can never overlap a seat in
   * the round-table ring. (It was previously a position:fixed top-left chip
   * that buried the top-centre across-table seat; the seat-clearance hacks
   * that needed are gone now it's out of the board plane.) The deadline
   * ring/clock are appended by renderPhaseStatusTimer; :empty hides the
   * chrome before the first game state arrives. */
  #phaseStatus {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-family: "VT323", monospace;
    font-size: 13px;
    line-height: 1.2;
    background: linear-gradient(180deg, #2a2018, #1a140e);
    color: #f0d890;
    padding: 2px 10px;
    border-radius: 6px;
    border: 1px solid #6b5030;
    white-space: nowrap;
    max-width: 60vw;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  #phaseStatus:empty { display: none; }
  /* Quick-chat emote bar — hidden unless a match (solo or room) is in progress. */
  .ctk-emote-bar { display: none; margin-top: 3px; gap: 4px; flex-wrap: wrap; justify-content: center; }
  body.ctk-emote-ready .ctk-emote-bar { display: flex; }
  /* Hide while a response/pending is up (keeps the action buttons uncluttered),
     while the draft modal is open, and once the match is over. */
  body.ctk-emote-ready .ctk-hand-band:has(> #pendingActions:not(:empty)) .ctk-emote-bar,
  body.ctk-draft-open .ctk-emote-bar,
  body.ctk-match-over .ctk-emote-bar { display: none; }
  /* THE FAREWELL HOLD PUTS THE STRIP BACK. Placement is the whole trick: this is
   * (0,2,0), exactly the same specificity as the ctk-match-over hide directly
   * above and as the ctk-emote-ready show above that — so it has to come LATER in
   * the sheet to win, and it has to be inside this same @media all block to be
   * comparing like with like at all. `ctk-emote-ready` is false for the entire
   * hold (updateEmoteBar sets it from !game.finished), which is why narrowing the
   * hide would have achieved nothing on its own. See the Act 0 block above. */
  body.ctk-act0-farewell .ctk-emote-bar { display: flex; }
  body.ctk-act0-farewell .role-chip[data-role]:not([data-role="Lord"]) { visibility: hidden; }
  .ctk-emote-btn {
    display: inline-flex; align-items: center; gap: 3px;
    font-family: "VT323", monospace; font-size: 13px; line-height: 1;
    color: #f0d890; background: linear-gradient(180deg, #2a2018, #1a140e);
    border: 1px solid #6b5030; border-radius: 5px; padding: 3px 7px; cursor: pointer;
  }
  .ctk-emote-btn:hover { border-color: #a07840; color: #ffe0a0; }
  .ctk-emote-btn:active { transform: translateY(1px); }
  .ctk-emote-btn .ctk-viz-ico { width: 13px; height: 13px; display: inline-flex; }
  .ctk-emote-btn .ctk-viz-ico svg { width: 100%; height: 100%; }
  /* The GG / Nice / Thanks send-buttons exist in the strip's markup at all times
   * but are shown ONLY during the farewell hold. They were retired from live play
   * as rarely used and that stands — the hold is simply the one moment they are
   * the right control, so the gate is CSS rather than a second render path.
   *
   * PLACEMENT IS LOAD-BEARING, and this is the second time in this feature:
   * `.ctk-emote-say` is (0,1,0), exactly the same as the `.ctk-emote-btn` rule
   * above that sets display:inline-flex. Sitting BEFORE it, the hide lost on
   * source order and all three buttons showed during a live match, widening the
   * strip enough to break an unrelated elevation test. Keep these last. */
  .ctk-emote-say { display: none; }
  body.ctk-act0-farewell .ctk-emote-say { display: inline-flex; }
  /* Mute/Report footer inside the player-detail modal. */
  .ctk-modmenu { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 10px; }
  .ctk-modmenu .px-btn { font-size: 12px; padding: 3px 10px; }
  .ctk-modmenu .ctk-report-title { flex-basis: 100%; text-align: center; font-size: 12px; color: #b8a888; margin-bottom: 2px; }
  #phaseStatus .tb-dot { width: 7px; height: 7px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 5px #22c55e; flex: 0 0 auto; }
  /* The turn/response countdown reads as a discrete pixel-art PILL (its own
     bordered chrome) rather than loose digits, so the ~5-min pacing clock
     registers at a glance. The conic ring + urgent-red shift stay as-is. */
  #phaseStatus .tb-clock { color: #ffcb6b; display: inline-flex; align-items: center; gap: 4px; padding: 0 5px; border: 1px solid #6b5030; border-radius: 5px; background: linear-gradient(180deg, #2a2018, #1a140e); }
  #phaseStatus.tb-urgent .tb-clock { color: #ff5544; border-color: #7a3428; }
  /* Match-elapsed readout — a sibling pill next to the phase chip; makes the
     short (~5-min) session length visible/felt. Hidden until a match is live. */
  #matchElapsed {
    display: inline-flex; align-items: center; gap: 4px;
    font-family: "VT323", monospace; font-size: 13px; line-height: 1.2;
    color: #b8a888; background: linear-gradient(180deg, #2a2018, #1a140e);
    padding: 2px 8px; border-radius: 6px; border: 1px solid #6b5030;
    white-space: nowrap; font-variant-numeric: tabular-nums;
  }
  #matchElapsed:empty { display: none; }
  #matchElapsed::before { content: "◷"; color: #8a7a5a; font-size: 12px; }
  /* Mobile: minimal — drop the "Match" word (the .me-label span, hidden here)
     and tighten the pill to just the ◷ glyph + mm:ss. See renderMatchClock,
     which splits the label + time into spans so CSS can hide the label. */
  @media (max-width: 640px) {
    #matchElapsed { padding: 1px 6px; font-size: 12px; gap: 3px; }
    #matchElapsed .me-label { display: none; }
  }
  /* "N watching". Same pill as #matchElapsed, and the same :empty rule doing the
     real work: the renderer writes nothing at zero, so the chip is absent rather
     than reading "0 watching" all match. */
  #spectatorCount {
    display: inline-flex; align-items: center; gap: 4px;
    font-family: "VT323", monospace; font-size: 13px; line-height: 1.2;
    color: #b8a888; background: linear-gradient(180deg, #2a2018, #1a140e);
    padding: 2px 8px; border-radius: 6px; border: 1px solid #6b5030;
    white-space: nowrap; font-variant-numeric: tabular-nums;
  }
  #spectatorCount:empty { display: none; }
  #spectatorCount::before { content: "\1F441"; color: #8a7a5a; font-size: 12px; }
  @media (max-width: 640px) {
    #spectatorCount { padding: 1px 6px; font-size: 12px; gap: 3px; }
    #spectatorCount .sc-label { display: none; }
  }
  /* Depleting deadline ring + heartbeat — shared by the turn-idle bar and
   * the response watchdog. The conic pie shrinks with --deadline-pct (set
   * per tick in JS) and inherits currentColor, so it tracks the amber->red
   * urgency shift. The pulse runs only under the urgent threshold; the
   * global prefers-reduced-motion carve flattens it, leaving the red ring +
   * colour as the static cue. */
  .ctk-deadline-ring {
    display: inline-block; width: 11px; height: 11px; border-radius: 50%;
    vertical-align: -1px; margin-right: 4px; box-sizing: border-box;
    background: conic-gradient(currentColor calc(var(--deadline-pct, 100) * 1%), rgba(0,0,0,0.32) 0);
    box-shadow: inset 0 0 0 1px rgba(0,0,0,0.4);
  }
  @keyframes ctkDeadlineBeat { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.22); } }
  .ctk-deadline-urgent .ctk-deadline-ring { animation: ctkDeadlineBeat 0.7s ease-in-out infinite; }
  #advance {
    background: linear-gradient(180deg, #f0d890, #c9a14a) !important;
    border: 2px solid #6b5030 !important;
    color: #241a0c !important;
    font-size: 13px !important;
    padding: 10px 16px !important;
    min-height: 44px !important;
    box-shadow:
      0 4px 12px rgba(0,0,0,0.5),
      inset 1px 1px 0 rgba(255,255,255,0.25);
  }

  /* Act-2 result sheet: at 390px tall the full sheet (header +
   * stats + per-seat scoreboard + actions ≈ 437px) overflowed the
   * viewport, pushing the action buttons below the fold. Make it
   * a viewport-capped flex column: header/stats/actions stay
   * fixed, the per-seat scoreboard scrolls internally so the
   * Continue/History/Replay buttons are always reachable. */
  .ctk-act2-sheet {
    /* vh first as the fallback, dvh LAST so it wins where supported: on
     * mobile browsers vh is the *largest* (toolbar-hidden) viewport, so a
     * bare 94vh cap let the sheet run taller than the visible area with the
     * address bar showing. dvh tracks the actual visible height. */
    max-height: 100vh;
    max-height: 100dvh;
    display: flex !important;
    flex-direction: column;
    padding: 10px 16px 10px !important;
  }
  .ctk-act2-header,
  .ctk-act2-stats,
  .ctk-act2-actions { flex: 0 0 auto; }
  /* The scoreboard is a <table> (can't flex-shrink/scroll on its
   * own) so it's wrapped in .ctk-act2-scoreboard-scroll — that
   * wrapper is the flex:1 scroll region, keeping the actions row
   * pinned and visible. */
  .ctk-act2-scoreboard-scroll {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    width: 100%;
  }
  /* Trim the act-2 header portrait so the fixed chrome is lean. */
  .ctk-act2-portrait {
    width: 48px !important;
    height: 48px !important;
  }

  /* Act-1 reveal: shrink the seat ring so even a 10-seat game
   * keeps all seats on one row (10×56 + 9×6 = 614 < usable width)
   * instead of wrapping to a second row that blows past 390px.
   * `align-items: safe center` + overflow:auto is a belt-and-
   * suspenders fallback for the verdict banner if it ever still
   * overflows. */
  .ctk-act1-backdrop {
    overflow-y: auto;
    align-items: safe center;
    padding-block: 6px;
  }
  /* Act-1 reveal shrink — scoped to the mobile shell (html.ctk-shell) so it
   * no longer LEAKS onto desktop. Pre-fix these were bare selectors in this
   * universal @media-all block, so desktop got 56px seats while the role
   * card stayed 80px → every role label overlapped its neighbour. Driving
   * --act1-tile shrinks the portrait AND the role card together (no separate
   * portrait rule needed), and the smaller face font keeps the role word
   * inside the 50px tile. */
  html.ctk-shell .ctk-act1-seats { gap: 6px !important; margin: 4px 0 !important; }
  html.ctk-shell .ctk-act1-seat { width: 56px; --act1-tile: 50px; }
  html.ctk-shell .ctk-act1-card .face { font-size: 10px; }
  html.ctk-shell .ctk-act1-card .face-front { gap: 2px; padding: 0 2px; }
  html.ctk-shell .ctk-act1-card .face-front svg { width: 9px; height: 9px; }
}

/* ============================================================
 * Portrait-specific deltas. The mobile shell applies in portrait
 * (width ≤ 600px); the round-table oval block below lays out the
 * seats. This block only tunes the hand for the narrow aspect.
 * ============================================================ */
/* ============================================================
 * Bottom chrome — ONE row, at EVERY viewport (the portrait phone layout,
 * the centred desktop / tablet column, and the short-landscape fallback
 * that matches neither of the two media queries below):
 *
 *                                            [Chat] [Reads] [Logs]   <- floats over the
 *   [hero skills … ]        [Your hand]                [Advance]       arena corner
 *
 * The band is a symmetric 3-column grid (1fr / auto / 1fr), so the hand
 * trigger sits at the TRUE centre (over the viewer's own seat axis) and
 * stays put whether or not the left slot is occupied. The left slot is
 * reserved for the own active-skill cluster (Zhiheng / Rende / …), the
 * right slot holds Advance, IN FLOW (no longer a viewport-fixed FAB).
 * Hint / watchdog / pending rows span all three columns underneath.
 * The tools strip (#emoteBar) is lifted OUT of the row: absolutely docked
 * to the band's top-right, it hangs over the arena's empty bottom-right
 * corner (outside the seat oval at every width) directly above Advance and
 * costs the row no width and the arena no height. The old 54px Advance-FAB
 * reserve strip is gone, handing ~45px of height back to #battleArena.
 * Unconditional (`@media all`), placed AFTER the `@media all` hoists above
 * (fixed Advance / fixed own-skill cluster) so it overrides them
 * everywhere — the shell sheet reservation (54px, above) relies on every
 * shell layout having this same band footprint. The two media blocks below
 * add only their specifics (phone: safe-area + icon-only strip + width
 * valves; desktop: the 300px trigger and text labels).
 * The pending-response sheets (the :has(#pendingActions…) rules above)
 * re-fix the whole band and hide #advance + the trigger, so their rows
 * collapse to the hint + cards — unaffected by the grid.
 * ============================================================ */
@media all {
  .game-frame .ctk-hand-band {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    grid-template-areas:
      "skill hand adv"
      "hint  hint hint"
      "wd    wd   wd"
      "pend  pend pend";
    align-items: center;
    column-gap: 8px;
    padding: 3px 8px 6px;
    position: relative;
  }
  #handOpenBtn {
    grid-area: hand;
    justify-self: center;
    margin: 0;
    min-height: 44px;   /* = #advance (its 44px min-height is !important above) so the row reads as one bar */
  }
  #pendingHint { grid-area: hint; }
  #pendingWatchdog { grid-area: wd; }
  #pendingActions { grid-area: pend; }
  /* Own active-skill cluster takes the reserved LEFT slot in flow instead
   * of floating fixed at bottom-left (overrides the `@media all` hoist), so
   * it can never overlap the centred hand trigger; two skills wrap within
   * the slot. */
  #pendingActions.own-skill-actions {
    grid-area: skill;
    position: static;
    max-width: none;
    justify-self: start;
    width: auto;
    min-width: 0;
  }
  /* Tools strip: hangs above the row's right end, over the arena corner.
   * row-reverse puts Logs (first in DOM) at the far right: [Chat][Reads][Logs].
   * z-index 33 = the action-feed chip tier: above the arena / seat ring and
   * the hand focus-pop (30), under the FAB (35) and modal (1000+) tiers. */
  #emoteBar {
    position: absolute;
    right: 8px;
    bottom: calc(100% + 4px);
    left: auto; top: auto;
    margin: 0;
    height: auto;
    flex-direction: row-reverse;
    flex-wrap: nowrap;
    gap: 4px;
    z-index: 33;
  }
  /* The strip no longer shares a slot with the own-skill cluster, so it
   * need not hide while a hero skill is offered (the generic "hide while
   * #pendingActions is non-empty" rule above exists for the response sheet).
   * Draft / match-over stay hidden. */
  body.ctk-emote-ready:not(.ctk-draft-open):not(.ctk-match-over)
    .ctk-hand-band:has(> #pendingActions.own-skill-actions:not(:empty)) .ctk-emote-bar { display: flex; }
  /* Advance joins the row in flow at the right edge (overrides the
   * `@media all` fixed-FAB hoist above). Its own size stays the 44px
   * !important rule in the (max-width: 640px) block above. */
  .game-frame .flex.items-center.gap-2:has(> #advance) {
    grid-area: adv;
    position: static;
    justify-self: end;
    margin: 0 !important;
  }
}

@media (orientation: portrait) and (max-width: 600px) {
  /* Narrow width fits only ~6 of the 56px cards per row, so the
   * hand WRAPS to multiple rows (portrait has the vertical room)
   * instead of running off-screen. overflow stays visible so the
   * focus-pop isn't clipped. */
  #hand {
    flex-wrap: wrap !important;
    overflow: visible;
    align-content: flex-start;
    gap: 6px !important;
    min-height: 0;
  }

  /* ---- Bottom chrome, PHONE specifics. The one-row grid itself (skill /
   * hand / advance + the floating tools strip) is the shared block right
   * above this media query; here: safe-area insets, icon-only strip, and
   * the width safety valves a 360px row needs. */
  .game-frame .ctk-hand-band {
    padding: 3px calc(env(safe-area-inset-right, 0px) + 8px) 6px calc(env(safe-area-inset-left, 0px) + 8px);
    column-gap: 6px;
  }
  #emoteBar { right: calc(env(safe-area-inset-right, 0px) + 8px); gap: 3px; }
  /* If the trigger ever overflows its column the pips are what give — they
   * shrink/clip; the icon and count badge never do. Also cap the pips at 5
   * here: the count carries the true total and 8 pips cost ~30px. */
  #handOpenBtn { max-width: 100%; min-width: 0; }
  #handOpenBtn .ctk-hand-open-pips { flex: 0 1 auto; min-width: 0; overflow: hidden; }
  #handOpenPips .ctk-hand-pip:nth-child(n+6) { display: none; }
  /* Icon-only tools strip: the text labels are hidden, NOT removed — the
   * renderer, the i18n re-render and the tests keep the text; aria-label +
   * title carry the accessible name. Each button is a 36px square. */
  #emoteBar .ctk-emote-btn {
    width: 36px; height: 36px; padding: 0; justify-content: center; gap: 0;
  }
  #emoteBar .ctk-emote-btn > span:not(.ctk-viz-ico):not(.ctk-chat-badge) { display: none; }
  #emoteBar .ctk-emote-btn .ctk-viz-ico { width: 18px; height: 18px; }
}

/* ============================================================
 * Round-table oval. In portrait, EVERY seat — opponents AND the
 * viewer — becomes a compact seat token placed at its --seat-x /
 * --seat-y slot around an ellipse (viewer = bottom-center, so the
 * ring is complete and uniform). The center is left open for the
 * attack-arrow / floating-damage animations. A 74px token width
 * trips the existing @container micro-tier, which reduces each
 * seat to an avatar + HP + turn glow + role / critical pips so
 * high seat counts don't cram. The viewer gets an amber "you" ring.
 *
 * Applied at ALL sizes — every viewport renders the oval (the portrait
 * board is now universal). The adaptive vertical radius (ctkOvalRy)
 * keeps the top/bottom tokens inside the arena at any height, and large
 * screens cap the board to a centered portrait column (see the
 * (min-width:601px) override below) so the oval stays a sensible size.
 * Touch phones in landscape still get the rotate-to-portrait prompt,
 * with this oval as the underlying layout.
 * ============================================================ */
/* UNIVERSAL BY DESIGN — the round-table ring board (absolute seat tokens
   positioned via --seat-x/--seat-y) is the SINGLE board for ALL viewports.
   Desktop & tablet center it as a ~460px portrait column (see the
   (min-width:601px) and (min-height:501px) framing block below).
   ⚠ DO NOT re-gate this to portrait-only / a viewport query: that breaks the
   desktop and landscape board (seats lose their ring positioning). The old
   (orientation:portrait) and (max-width:600px) gate is pre-unification
   history. */
@media all {
  #players {
    display: block !important;
    position: relative;
    height: 100%;
    width: 100%;
    overflow: visible;
    padding: 0 !important;
  }
  /* All seats are ring tokens (display:block undoes the is-viewer
   * banner's flex-row). */
  #players > .player-panel {
    position: absolute !important;
    left: var(--seat-x, 50%);
    top: var(--seat-y, 50%);
    right: auto !important;
    bottom: auto !important;
    transform: translate(-50%, -50%);
    display: block !important;
    width: var(--seat-w, 108px) !important;
    min-width: 0 !important;
    max-width: var(--seat-w, 108px) !important;
    max-height: none !important;
    height: auto !important;
    flex: none !important;
    order: 0 !important;
    margin: 0 !important;
    /* Reserve a small top strip so the absolute bot/self corner badge sits
     * ABOVE the head row instead of over the avatar/name. The HP corner no
     * longer lives here (P3 moved it into the full-width .seat-hp-block),
     * so the strip shrank from 20px to a badge-clearing 8px:
     *   [        · badge ]   <- top strip (padding-top)
     *   [ avatar | name  ]
     *   [ full-width HP   ] */
    padding: 4px 4px 4px 4px !important;
    text-align: center !important;
    container-type: inline-size;
    z-index: 2;
  }
  /* Uniform avatar height for ALL tokens. Dual selectors match the
   * specificity of the landscape-shell hero-stage rules (which also
   * apply under this combined gate) so they win, keeping opponents
   * and the viewer the same size. */
  #players > .player-panel:not(.is-viewer) .hero-stage,
  #players > .player-panel.is-viewer .hero-stage {
    /* Square avatar that fills the token width, so it scales with the
     * responsive --seat-w: ~66px on a compact phone token, up to ~130px on
     * a roomy full-mode desktop token (where the @container tier then
     * reveals the hero name + chips + ⓘ). `width:100%` (not `auto`) keeps
     * the VIEWER's stage from collapsing under its is-viewer flex. */
    width: 100% !important;
    height: auto !important;
    aspect-ratio: 1 / 1 !important;
    min-height: 0 !important;
    margin: 0 auto !important;
  }
  /* Undo the wide-banner internals on the viewer so it renders as a
   * normal vertical token; the micro-tier then compacts it exactly
   * like the opponents. */
  #players > .player-panel.is-viewer .player-bottom-overlay {
    position: absolute !important;
    left: 0 !important; right: 0 !important; top: auto !important;
    transform: none !important;
    flex-direction: column !important;
    padding: 2px 3px 3px 3px !important;
  }
  #players > .player-panel.is-viewer
    > div:not(.hero-stage):not(.seat-row):not(.player-hp-corner):not(.player-corner-badge) {
    position: static !important;
    top: auto !important; right: auto !important; bottom: auto !important;
  }
  /* Viewer seat-row: your own role chip is always revealed, which
   * wrapped and ballooned the token height. Pin it static, single
   * line, clipped — so the viewer token matches the opponents. */
  #players > .player-panel.is-viewer .seat-row {
    position: static !important;
    top: auto !important; right: auto !important; bottom: auto !important;
    white-space: nowrap;
    overflow: hidden;
    max-width: 100%;
    font-size: 11px !important;
    margin: 0 !important;
  }
  /* "You" accent: a warm cream ring + soft static glow. On-palette with the
   * brown/gold UI (echoes the #e8dcc0 text tones) and neutral, so it separates
   * cleanly from every role tint — including the blue Loyalist tint, which the
   * old cyan ring (#38bdf8) blended into. Still distinct from the amber
   * active-turn glow (ctkTurnGlow): "you" is a static cream ring, the turn glow
   * is a pulsing amber box-shadow — on your own turn the animation drives the
   * box-shadow (amber) while the cream outline persists as the identity marker. */
  #players > .player-panel.is-viewer {
    box-shadow: 0 0 10px rgba(242, 232, 213, 0.5), 0 3px 8px rgba(0,0,0,0.6);
    z-index: 6;
    /* Override the leftover banner padding (2px 8px 2px 56px) so the
     * viewer token matches opponents: same 8px badge-clearing top strip,
     * and a symmetric box so the avatar centers (margin:auto) instead of
     * being shoved ~56px to the right. */
    padding: 4px 4px 4px 4px !important;
    /* Reset the leftover banner `text-align: left !important` (the old
     * horizontal-strip layout) — the oval token centers its hero-name +
     * hand/range row like every other seat. */
    text-align: center !important;
  }
  /* Cream "you" identity outline — split off its own rule so it YIELDS to
   * the targeting cues (.seat-targetable's dashed ring, .seat-nudge's red
   * flash), exactly like the Lord gold outline's :not() guard above. An
   * element has only one `outline`, and this ID-scoped selector out-specifies
   * .seat-targetable, so without the guard the solid cream ring masked the
   * dashed self-target ring (e.g. Hua Tuo picking himself for Qingnang/Jijiu,
   * room NZ7F). box-shadow/z-index/padding/text-align above stay unguarded. */
  #players > .player-panel.is-viewer:not(.seat-targetable):not(.seat-nudge) {
    outline: 2px solid #f2e8d5;
    outline-offset: 0;
  }
}

/* Removed 2026-07-11: the ~90-line body.ctk-hud-layout bottom-HUD/dome
 * landscape variant. It was locked out on 2026-06-24 (ctkReapplyOval forces
 * hudEligible=false — the class was never set), so every rule here was dead.
 * The board is now the universal oval ring at all viewports; git history has
 * the old rules if the width-gated HUD is ever revived. */

/* ============================================================
 * Tablet / desktop framing for the now-LANDSCAPE board.
 * The carve makes .game-frame full-width; on a wide screen that would
 * stretch the round-table oval across the whole monitor with the seat
 * tokens drifting far apart. So on anything bigger than a phone, cap
 * the board to a centered, full-height LANDSCAPE column (~760px) with
 * the starfield filling the surround. The wider column gives the oval
 * ring horizontal room so 8 bigger seat cards fit without overlap. The
 * floating controls (Advance FAB, own-skill cluster, phase chip) are
 * viewport-fixed, so re-hug them to the centered board's edges
 * (board half-width = 380px).
 * ============================================================ */
@media (min-width: 601px) and (min-height: 501px) {
  /* BOARD WIDTH. Was a hard 760px, so on a 1440px monitor the game was a
   * narrow portrait column with roughly half the screen empty either side
   * (docs/arena-board-art-audit-2026-08.md). Now it grows with the viewport
   * up to 1180px. --ctk-board-w is the single source of truth: every
   * viewport-fixed control below re-hugs the board's edge through it, so
   * the old hard-coded 380px half-width — repeated at seven sites, and
   * guaranteed to detach the moment the frame stopped being exactly 760px
   * — is gone.
   * The upper bound is `100vw - 140px`, not a bare vw fraction: it keeps a
   * PREDICTABLE >=70px gutter either side at every width above the floor,
   * which is what the viewport-fixed controls docked outside the board
   * (the bug button) need to stay on screen. */
  :root { --ctk-board-w: clamp(760px, calc(100vw - 140px), 1180px); }
  .game-frame {
    max-width: var(--ctk-board-w) !important;
    margin: 0 auto !important;
    border-left: 1px solid rgba(107,85,48,0.45);
    border-right: 1px solid rgba(107,85,48,0.45);
    box-shadow: 0 0 48px rgba(0,0,0,0.55);
  }
  /* Bottom chrome, DESKTOP specifics — the one-row grid + floating tools
   * strip are the shared block above. The band is a child of the centred
   * .game-frame column, so in-flow children already hug the board's edges;
   * only viewport-fixed chrome (the hamburger dropdown) needs the re-hug. */
  #handOpenBtn { min-width: 300px; }
  /* Re-hug the hamburger dropdown to the centered board's left edge, since
   * it's viewport-fixed. */
  .ctk-menu-panel {
    left: calc(50% - var(--ctk-board-w, 760px) / 2 + 6px) !important;
  }
}

/* ===== Bug-report second row (#bugReportDock) =====
 * Layout the toolbar is asked to read as:
 *     [☰] [Reset] [...]
 *     [🐞]
 * The bug button sits on its own line directly under the hamburger. It
 * cannot simply wrap inside .toolbar-bar — in the phone shell that bar is a
 * fixed 40px row with overflow-y:hidden (the #559 clipping bug; guarded by
 * tests/bug-report-reachable.spec.ts) — so it lives in a zero-height,
 * position:relative sibling placed right after the bar. The button is
 * absolutely positioned inside that sibling, so it hangs just below the bar's
 * bottom edge over the arena's empty top-left corner (outside the seat oval
 * at every width) and costs the arena no height. `left` mirrors the bar's
 * horizontal padding so the button lines up flush under the hamburger.
 * z-index: above the arena (which follows it in the DOM) and the seat ring,
 * below the hamburger dropdown (55) and the modal scrims. The muted-brick
 * tint from the in-row version is kept — no dock opacity. */
#bugReportDock {
  position: relative;
  height: 0;
  width: 100%;
  flex: 0 0 auto;
  overflow: visible;
  z-index: 30;
}
#bugReportDock #bugReportBtn {
  position: absolute;
  top: 4px;
  left: 0;
}
html.ctk-shell #bugReportDock #bugReportBtn { left: 6px; }   /* = html.ctk-shell .toolbar-bar padding-left */

/* ============================================================
 * Narrow-phone hand-trigger guard. The bottom row is [skills][hand][Advance]
 * (shared grid block above; the tools strip floats over the arena corner,
 * so it no longer competes for row width). On the narrowest phones a
 * full-hand trigger (icon + pips + count + label) still crowds Advance, so
 * drop the trigger's TEXT label there — the hand icon + count still read
 * as "your hand".
 * ============================================================ */
@media (max-width: 400px) {
  .ctk-hand-open-label { display: none; }
}

/* ============================================================
 * PORTRAIT LOCK — the game plays in PORTRAIT only. A touch phone held
 * in LANDSCAPE gets a full-screen "rotate to portrait" block with the
 * board hidden behind it. Pure CSS: gated on (orientation: landscape)
 * + short-side height (max-height: 500px catches phones, not tablets)
 * + touch (pointer: coarse + hover: none excludes PCs/laptops). No
 * dismiss — portrait is required, not a nudge. If a device misreports
 * touch and the block doesn't fire, the portrait shell still renders
 * as a silent, working fallback.
 * ============================================================ */
#rotatePrompt { display: none; }
@media (orientation: landscape) and (max-height: 500px) and (pointer: coarse) and (hover: none) {
  #rotatePrompt {
    display: flex;
    position: fixed;
    inset: 0;
    z-index: 9000;
    align-items: center;
    justify-content: center;
    padding: 24px;
    background:
      radial-gradient(ellipse at center, #2a1e14 0%, #1c1410 60%, #0a0604 100%);
    color: #e8dcc8;
  }
  /* Hide the portrait-built board behind the block while sideways. */
  body > .game-frame { visibility: hidden; }
  .rotate-inner {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 14px;
    text-align: center;
    max-width: 280px;
  }
  .rotate-icon {
    color: #f0d890;
    animation: ctkRotateHint 2.4s ease-in-out infinite;
  }
  .rotate-title {
    font-size: 22px;
    font-weight: bold;
    color: #f0d890;
    letter-spacing: 1px;
  }
  .rotate-sub {
    font-size: 15px;
    color: #b8a888;
    line-height: 1.4;
  }
}
/* Icon hint: a phone rotating from sideways (landscape) toward upright. */
@keyframes ctkRotateHint {
  0%, 55%, 100% { transform: rotate(90deg); }
  75%, 90%      { transform: rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .rotate-icon { animation: none !important; }
}

/* ===== Global chrome — aesthetics audit, step 1 ======================
 * Four gaps, all paint-free or one-shot:
 *   A. the UA still painted its OWN chrome in light mode over the board;
 *   B. every dialog hard-cut its scrim in — not one shell had a fade;
 *   C. .ctk-modal-pop is opt-in, so three dialogs landed without the
 *      spring the other eight have;
 *   E. 69 :hover rules shipped with no hover-capability guard, so the
 *      WebView left every button lifted and lit after a tap.
 * Measured before shipping (Pixel 5 @ 6x CPU throttle, 7-bot spectator,
 * same-page ON/OFF round-robin, n=4 per arm): 57.5 fps both arms;
 * drops >20ms per 10s 12.5 -> 14.7, inside the noise of either arm.
 * Nothing here animates a paint property.
 * NO reduced-motion block on purpose: the universal carve at L6463
 * already clamps animation-duration to 0.001s, and both keyframes below
 * END at their resting state (opacity 1 / transform none), so they land
 * correctly when collapsed. A scoped carve here could only out-specify
 * that block and re-enable what it is meant to kill.
 * ------------------------------------------------------------------ */

/* A. Native chrome. color-scheme flips the UA's own painting — unchecked
 * checkbox grounds, <select> option popups, the number spinner, the
 * pre-paint canvas — from light to dark. scrollbar-color INHERITS, so one
 * declaration reaches all ~26 nested scrollers; scrollbar-width does NOT
 * inherit and the root is overflow:hidden (L7096), hence the universal
 * selector. The three bespoke scrollbar rules (.guanxing-list L1158,
 * .log-scroll L1990, .ctk-rk-body L6822) are class-scoped and still win. */
:root { color-scheme: dark; scrollbar-color: #5a4530 transparent; }
* { scrollbar-width: thin; }

/* Tint only, deliberately no `color`: several selectable surfaces are
 * light gold (.px-btn-gold, .ctk-hand-open-count, .ctk-act2-mvp-label),
 * where forcing a cream foreground drops selected text to ~1.2:1. Letting
 * each element keep its own colour reads on both the felt and the chips. */
::selection { background: rgba(232,200,120,0.32); }

/* #9a8a70 is the palette's existing muted ink (already used ~50x below)
 * and clears AA at 4.75:1 on the #2a2014 field ground; the warmer #8a7a5e
 * first tried measured 3.82:1 and failed. `opacity: 1` restates Tailwind
 * preflight's own value so the rule still stands if that sheet fails. */
input::placeholder, textarea::placeholder { color: #9a8a70; opacity: 1; }
input, textarea { caret-color: #f0d890; }

/* B. Scrim fade. OPACITY, not the existing ctk-sheet-fade — that keyframe
 * animates `background` to a hard-coded rgba(0,0,0,0.55), so it would run
 * these scrims (0.6-0.86 alpha) to the wrong value and snap. Opacity also
 * leaves the bounding box still, which is why this costs Playwright's
 * stability check nothing. Scrims sit at the top of paint order, so
 * nothing paints after them and no sibling is pulled into a squashing
 * layer. `animation`, not `transition`: these shells toggle style.display
 * / [hidden], and transitions do not fire across a display change. */
@keyframes ctkScrimFade { from { opacity: 0; } to { opacity: 1; } }
.ctk-modal-backdrop,
.ctk-rk-modal,
.ctk-endplay-scrim,
.log-sheet-backdrop,
#lobbyModal { animation: ctkScrimFade 0.16s ease-out; }

/* C. The three dialogs that never opted into the shared entrance pop.
 * Written as id rules rather than a class on the markup so this stays a
 * CSS-only change (#draftMount's card is rendered by Preact anyway).
 * The two draft shells are deliberately NOT here: #draftMount already has
 * its own ctk-draft-reveal curtain, which would mask the pop, and
 * ctkLobbyPop's scale(0.94) shrinks the draft's 44x44 info toggle to
 * ~42px — tests/hero-draft.spec.ts:328 and :727 assert >= 44, and
 * boundingBox() reads the live animated rect rather than waiting it out. */
#ctkChatModMenu > .ctk-modal-card,
#ctkSettings > .ctk-modal-card,
#playerDetailModal > .ctk-modal-card { animation: ctkLobbyPop 0.3s var(--ctk-spring); }

/* E. Touch hover reset. On a touch device :hover latches after a tap, so
 * the WebView left buttons lifted (translateY) and lit (::after). Only the
 * transform and the state layer are reset — NOT `background`: this rule is
 * (0,2,0), the same as every colour variant's :hover (.px-btn-gold,
 * -danger, -jade ...) and later in the sheet, so a background declaration
 * here would flatten all of them to the base brown on touch. */
@media (hover: none) {
  .px-btn:hover { transform: none; }
  .px-btn:hover::after { opacity: 0; }
}
  

/* ===== 火攻 Fire Ships (ไหลเรือไฟ) — client/src/viz-fire-ships.ts (2026-09) =====
 * Three pixel fire-ship sprites sail the strike arc over a fading wake, moor
 * off the victim, flare when the fuse arrives and collapse on impact. Sized
 * with calc(px * --ctk-fx-scale) and moved by WAAPI transforms — deliberately
 * NOT in the arena container-`scale` list above (a scaled container would
 * scale the translate too). No filter, no will-change: the moored flicker is
 * opacity-only on at most three tiny layers, with a hard TTL in the module. */
.ctk-fire-ship {
  position: absolute; pointer-events: none; z-index: 39;
  width: calc(10px * var(--ctk-fx-scale)); height: calc(8px * var(--ctk-fx-scale));
  transform: translate(-50%, -50%);
  /* Silhouette from the card art (a battened sail ablaze over a dark junk hull):
   * one gradient clipped by a pixel polygon — mast 45–58%, sail 12–62%, hull 74–100%. */
  background: linear-gradient(180deg, #fde68a 0, #fb923c 30%, #f97316 55%, #7c2d12 74%, #3b1d0f 100%);
  clip-path: polygon(45% 0, 58% 0, 58% 12%, 92% 12%, 92% 62%, 58% 62%, 58% 74%, 100% 74%, 88% 100%, 12% 100%, 0 74%, 45% 74%);
  box-shadow: 0 0 calc(4px * var(--ctk-fx-scale)) rgba(249, 115, 22, 0.55);
}
@keyframes ctkFireFlicker { 0%, 100% { opacity: 0.95; } 50% { opacity: 0.7; } }
.ctk-fire-ship.is-moored { animation: ctkFireFlicker 0.18s steps(2) infinite; }
.ctk-fire-ship.is-doused {
  background: linear-gradient(180deg, #9ca3af 0, #6b7280 40%, #374151 74%, #1f2937 100%);
  box-shadow: none; animation: none;
}
/* The wake: a one-shot dash reveal along the sailing arc (the ctkArrowDraw
 * idiom), fading as the fleet moors. */
@keyframes ctkFireWake {
  0%   { stroke-dashoffset: var(--arr-len, 200); opacity: 0.55; }
  35%  { stroke-dashoffset: 0; opacity: 0.55; }
  70%  { opacity: 0.45; }
  100% { stroke-dashoffset: 0; opacity: 0; }
}
.ctk-fire-wake { fill: none; stroke-linecap: round; animation: ctkFireWake 1.6s ease-out forwards; }
/* The fuse: the same arc retraced in 360 ms from the attacker to the fleet,
 * with a spark riding it (WAAPI). */
@keyframes ctkFuseRun { from { stroke-dashoffset: var(--arr-len, 200); } to { stroke-dashoffset: 0; } }
.ctk-fire-fuse { fill: none; stroke-linecap: round; animation: ctkFuseRun 0.36s linear forwards; }
.ctk-fire-spark {
  position: absolute; pointer-events: none; z-index: 40;
  width: calc(4px * var(--ctk-fx-scale)); height: calc(4px * var(--ctk-fx-scale));
  background: #fff7d6; box-shadow: 0 0 calc(6px * var(--ctk-fx-scale)) #fbbf24;
  transform: translate(-50%, -50%);
}
/* The reveal at the victim: the judgement face (.ctk-judge-card, its own flip)
 * in a fire frame. Container-scaled like the judge fx — the inner card is
 * fixed-px art. The match prompt on the attacker scales the same way. */
.ctk-fire-reveal { position: absolute; transform: translate(-50%, -50%); pointer-events: none; z-index: 41; }
#battleArena > .ctk-fire-reveal, #battleArena > .ctk-fire-ask { scale: var(--ctk-fx-scale); }
.ctk-fire-reveal .ctk-judge-card { border-color: #f97316; box-shadow: 0 0 14px rgba(249, 115, 22, 0.75), 0 0 2px #7c2d12; }
/* The match question: a dashed suit outline that pulses until answered and
 * snaps solid on ignite. */
@keyframes ctkFireAskPulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
.ctk-fire-ask {
  position: absolute; pointer-events: none; z-index: 41;
  transform: translate(-50%, -50%);
  width: 44px; height: 60px; border-radius: 6px;
  border: 2px dashed #f97316; background: rgba(28, 16, 8, 0.55);
  display: flex; align-items: center; justify-content: center;
  font-family: 'VT323', monospace; font-weight: bold; font-size: 26px; line-height: 1; color: #fb923c;
  animation: ctkFireAskPulse 1.1s ease-in-out infinite;
}
@keyframes ctkFireAskLit {
  0%   { transform: translate(-50%, -50%) scale(1); }
  50%  { transform: translate(-50%, -50%) scale(1.18); }
  100% { transform: translate(-50%, -50%) scale(1.06); }
}
.ctk-fire-ask.is-lit { border-style: solid; background: #f5efe0; animation: ctkFireAskLit 0.18s cubic-bezier(0.2, 1.5, 0.4, 1) forwards; }
.ctk-fire-ask.is-lit.suit-red { color: #a8352a; }
.ctk-fire-ask.is-lit.suit-black { color: #241d14; }
/* The fire number: the damage float in the family's orange. */
.ctk-float-damage.ctk-float-fire {
  color: #fb923c;
  text-shadow:
    0 0 calc(6px * var(--ctk-fx-scale)) rgba(249, 115, 22, 0.9),
    calc(2px * var(--ctk-fx-scale)) calc(2px * var(--ctk-fx-scale)) 0 #1a0808,
    calc(-1px * var(--ctk-fx-scale)) calc(-1px * var(--ctk-fx-scale)) 0 #1a0808;
}
/* The burning rim on the victim's hero stage: a one-second paint-property
 * animation on a permanent layer, the way ctkDamageFlash animates the panel. */
@keyframes ctkBurnRim {
  0%   { box-shadow: inset 0 0 0 0 rgba(249, 115, 22, 0); }
  18%  { box-shadow: inset 0 0 calc(18px * var(--ctk-fx-scale)) rgba(249, 115, 22, 0.8), 0 0 calc(10px * var(--ctk-fx-scale)) rgba(249, 115, 22, 0.6); }
  100% { box-shadow: inset 0 0 0 0 rgba(249, 115, 22, 0); }
}
.hero-stage.ctk-anim-burn { animation: ctkBurnRim 1s ease-out; }
@media (prefers-reduced-motion: reduce) {
  /* The relocated reveal card and the match outline carry their information
   * in the held pose; the global collapse would jump them to opacity 0. The
   * module spawns no ships, wake or fuse under reduced motion at all. */
  :where(html:not(.ctk-force-fx)) .ctk-fire-reveal .ctk-judge-card { animation: none !important; opacity: 1; transform: translate(-50%, -50%); }
  :where(html:not(.ctk-force-fx)) .ctk-fire-ask { animation: none !important; opacity: 1; }
  :where(html:not(.ctk-force-fx)) .ctk-fire-ship, :where(html:not(.ctk-force-fx)) .ctk-fire-wake, :where(html:not(.ctk-force-fx)) .ctk-fire-fuse { animation: none !important; }
}

/* ===== 铁索连环 Iron chains — client/src/viz-chains.ts (2026-09) =====
 * Persistent sagging links between chained seats, in <g data-chain-layer>
 * right after <defs> in #battleSvg (under every transient arrow). Three static
 * paths per link: parchment halo, dashed black-iron core (each dash a link),
 * thin top-lit highlight. No idle animation — only the one-shot draw-in, the
 * untie fade and the snap flash. */
.ctk-chain-halo, .ctk-chain-core, .ctk-chain-hi, .ctk-chain-snap { fill: none; stroke-linecap: round; }
@keyframes ctkChainIn { from { stroke-dashoffset: var(--arr-len, 200); } to { stroke-dashoffset: 0; } }
.ctk-chain-in { animation: ctkChainIn 0.3s ease-out forwards; }
@keyframes ctkChainFadeIn { from { opacity: 0; } to { opacity: 1; } }
.ctk-chain-in-fade { animation: ctkChainFadeIn 0.3s ease-out both; }
@keyframes ctkChainOut { to { opacity: 0; } }
.ctk-chain-out { animation: ctkChainOut 0.24s ease-in forwards; }
@keyframes ctkChainSnapFlash { 0% { opacity: 1; } 100% { opacity: 0; } }
.ctk-chain-snap { animation: ctkChainSnapFlash 0.18s ease-out forwards; }
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-chain-in { animation: none !important; stroke-dashoffset: 0; }
  :where(html:not(.ctk-force-fx)) .ctk-chain-in-fade { animation: none !important; opacity: 1; }
}
/* The conduction comet (viz-chains.ts vizChainTravel): a charge riding a chain
 * link from the struck seat to the next. Sized with calc(), moved by WAAPI. */
.ctk-chain-comet {
  position: absolute; pointer-events: none; z-index: 40; border-radius: 1px;
  width: calc(6px * var(--ctk-fx-scale)); height: calc(6px * var(--ctk-fx-scale));
  transform: translate(-50%, -50%);
}
.ctk-chain-comet.is-fire { background: #fde68a; box-shadow: 0 0 calc(6px * var(--ctk-fx-scale)) #f97316, 0 0 calc(12px * var(--ctk-fx-scale)) rgba(249, 115, 22, 0.6); }
.ctk-chain-comet.is-thunder { background: #ffffff; box-shadow: 0 0 calc(6px * var(--ctk-fx-scale)) #c084fc, 0 0 calc(12px * var(--ctk-fx-scale)) rgba(168, 85, 247, 0.6); }

/* ===== Serpent venom — the "(Poison)" number in the venom green. ===== */
.ctk-float-damage.ctk-float-venom {
  color: #4ade80;
  text-shadow:
    0 0 calc(6px * var(--ctk-fx-scale)) rgba(22, 163, 74, 0.9),
    calc(2px * var(--ctk-fx-scale)) calc(2px * var(--ctk-fx-scale)) 0 #08120a,
    calc(-1px * var(--ctk-fx-scale)) calc(-1px * var(--ctk-fx-scale)) 0 #08120a;
}

/* ===== 五谷丰登 Harvest — the face-up spread (client/src/viz-harvest.ts) =====
 * A row of small parchment tiles at arena centre, one per revealed card; a
 * drafted tile's clone flies to the seat (WAAPI) while its slot closes. Sized
 * with calc() so it rides the seat token; never in the container-scale list. */
.ctk-harvest-fan {
  position: absolute; left: 50%; top: 30%; transform: translate(-50%, -50%);
  display: flex; flex-wrap: wrap; justify-content: center; gap: calc(4px * var(--ctk-fx-scale));
  max-width: calc(4 * 46px * var(--ctk-fx-scale) + 3 * 4px * var(--ctk-fx-scale));
  pointer-events: none; z-index: 34;
}
.ctk-harvest-fan.is-dissolving { animation: ctkHarvestOut 0.3s ease-in forwards; }
@keyframes ctkHarvestOut { to { opacity: 0; transform: translate(-50%, -50%) scale(0.92); } }
.ctk-harvest-tile {
  width: calc(44px * var(--ctk-fx-scale)); height: calc(58px * var(--ctk-fx-scale));
  border-radius: calc(5px * var(--ctk-fx-scale));
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: calc(2px * var(--ctk-fx-scale));
  background: #f5efe0; border: calc(2px * var(--ctk-fx-scale)) solid #2a2218;
  box-shadow: 0 calc(2px * var(--ctk-fx-scale)) calc(8px * var(--ctk-fx-scale)) rgba(0,0,0,0.5);
  font-family: 'VT323', monospace; font-weight: bold; line-height: 1; color: #241d14;
  animation: ctkHarvestTileIn 0.32s cubic-bezier(0.2, 1.3, 0.4, 1) both;
  transition: width 0.24s ease, opacity 0.24s ease, transform 0.24s ease;
}
.ctk-harvest-tile.suit-red { color: #a8352a; }
.ctk-harvest-tile .ctk-harvest-pip { font-size: calc(17px * var(--ctk-fx-scale)); }
.ctk-harvest-tile .ctk-harvest-name { font-size: calc(10px * var(--ctk-fx-scale)); max-width: 90%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #241d14; }
.ctk-harvest-tile.is-taken { opacity: 0; transform: scale(0.6); width: 0; border-width: 0; }
.ctk-harvest-tile.is-flying { position: absolute; transform: translate(-50%, -50%); z-index: 44; transition: none; }
@keyframes ctkHarvestTileIn {
  0%   { opacity: 0; transform: translateY(calc(-14px * var(--ctk-fx-scale))) scale(0.6) rotateY(90deg); }
  60%  { opacity: 1; transform: translateY(0) scale(1.06) rotateY(0deg); }
  100% { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  :where(html:not(.ctk-force-fx)) .ctk-harvest-tile { animation: none !important; transition: none !important; }
  :where(html:not(.ctk-force-fx)) .ctk-harvest-fan.is-dissolving { animation: none !important; opacity: 0; }
}
