/* gammonchain — one stylesheet, no build step.
 *
 * Structure: tokens, then skins, then components. Every colour in the app and on the
 * board comes from a variable defined in exactly one of the three skin blocks, which is
 * what lets a skin be a block of CSS rather than a second rendering path.
 *
 * Sizing rules that matter on a phone:
 *   - every tap target is at least 44px (Apple's floor, and it is the right one)
 *   - the board gets the viewport minus header and action bar, in dvh, so an iOS URL
 *     bar sliding away does not resize the game mid-move
 *   - nothing horizontal ever scrolls
 */

/* ====================================================================== tokens */
:root{
  --r-sm:8px; --r-md:12px; --r-lg:16px; --r-pill:999px;
  --s-1:4px; --s-2:8px; --s-3:12px; --s-4:16px; --s-5:24px; --s-6:32px;
  --tap:44px;
  /* One scrim for every overlay. Two values for the same job drifted apart, and the
     daylight skin dimmed to the same black as the night skins. */
  --scrim:rgba(0,0,0,.5);
  --safe-b:env(safe-area-inset-bottom,0px);
  --safe-t:env(safe-area-inset-top,0px);
  --sans:ui-sans-serif,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
  --mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
  --shadow-1:0 1px 2px rgba(0,0,0,.28);
  --shadow-2:0 8px 28px rgba(0,0,0,.34);
  --ease:cubic-bezier(.2,.7,.3,1);
}

/* ======================================================================= skins */

/* Walnut — the tournament board: warm wood, deep green felt, ivory and espresso. */
:root, :root[data-skin="walnut"]{
  color-scheme: dark;
  --bg:#15110e; --surface:#1e1815; --surface-2:#181310; --line:#3a2f27; --line-soft:#2a221d;
  --ink:#f2ebe1; --ink-dim:#a89b8d; --ink-faint:#8e8073;
  --accent:#d9963f; --accent-2:#b8762a; --accent-ink:#1a1208;
  --ok:#63bd74; --bad:#e0574a; --warn:#e2be6a;

  --frame-0:#5a3f2a; --frame-1:#33231a;
  --felt-0:#20443a; --felt-1:#16302a;
  --bar-0:#4a3324; --bar-1:#2e2018;
  --tray-0:#3b2a1e;
  --point-a0:#e6cda6; --point-a1:#c2a578;      /* light points */
  --point-b0:#9c5f38; --point-b1:#6f4126;      /* dark points */
  --chk-a0:#ffffff; --chk-a1:#f0e7d8; --chk-a2:#c9bda8; --chk-a-ink:#2a2119;
  --chk-b0:#5a4a3c; --chk-b1:#332821; --chk-b2:#171110; --chk-b-ink:#efe6d8;
  --die-0:#fbf6ec; --die-1:#ddd2c0; --die-pip:#241b13;
  --die-b-0:#3a2e26; --die-b-1:#221a15; --die-b-pip:#f0e6d6;
  --cube-0:#f5efe3; --cube-1:#d8cdb9; --cube-ink:#1d1610;
}

/* Slate — modern and cool, for people who find wood grain fussy. */
:root[data-skin="slate"]{
  color-scheme: dark;
  --bg:#0e1216; --surface:#161c22; --surface-2:#121820; --line:#2a343d; --line-soft:#1e262d;
  --ink:#e8eef3; --ink-dim:#93a3b0; --ink-faint:#748695;
  --accent:#4cc2d8; --accent-2:#2f9db2; --accent-ink:#05171c;
  --ok:#5ec98f; --bad:#e2685e; --warn:#e0bd6b;

  --frame-0:#2b353e; --frame-1:#161d23;
  --felt-0:#22333d; --felt-1:#182630;
  --bar-0:#2f3b45; --bar-1:#1b242b;
  --tray-0:#232e36;
  --point-a0:#cfdbe2; --point-a1:#9fb2bf;
  --point-b0:#4d6a78; --point-b1:#324853;
  --chk-a0:#ffffff; --chk-a1:#eef4f7; --chk-a2:#c2ced6; --chk-a-ink:#16202a;
  --chk-b0:#3c4a55; --chk-b1:#212b33; --chk-b2:#10161b; --chk-b-ink:#e6edf2;
  --die-0:#f7fbfd; --die-1:#dbe4ea; --die-pip:#15202a;
  --die-b-0:#31404a; --die-b-1:#1c252c; --die-b-pip:#e8eff4;
  --cube-0:#f2f7fa; --cube-1:#d3dee5; --cube-ink:#101820;
}

/* Ivory — a real daylight theme. Backgammon is an outdoor game half the time. */
:root[data-skin="ivory"]{
  --scrim:rgba(90,70,40,.38);
  color-scheme: light;
  --bg:#f4efe6; --surface:#fffdf9; --surface-2:#f7f2e9; --line:#ddd2bf; --line-soft:#e9e1d2;
  --ink:#241d16; --ink-dim:#6b5f51;
  /* Both of these failed AA against --surface at their first values (3.62:1 and
     3.28:1). Darkened until they clear 4.5:1 — a daylight theme that cannot be read in
     daylight is not a theme. */
  --ink-faint:#776b5c;
  --accent:#8f5a17; --accent-2:#734714; --accent-ink:#fffaf2;
  --ok:#39824f; --bad:#c0453a; --warn:#926f1b;

  --frame-0:#c9a274; --frame-1:#9c7245;
  --felt-0:#f0e3c8; --felt-1:#e2d2b2;
  --bar-0:#c3a077; --bar-1:#a37f56;
  --tray-0:#c9a97f;
  --point-a0:#fffaf0; --point-a1:#e8d9bd;
  --point-b0:#c08a52; --point-b1:#96663a;
  --chk-a0:#ffffff; --chk-a1:#fdf7ec; --chk-a2:#d9cdb6; --chk-a-ink:#3a2f22;
  --chk-b0:#6b503a; --chk-b1:#43301f; --chk-b2:#241a10; --chk-b-ink:#fdf6e9;
  --die-0:#ffffff; --die-1:#eae0cd; --die-pip:#2b2117;
  --die-b-0:#54402e; --die-b-1:#33261a; --die-b-pip:#fdf6e9;
  --cube-0:#ffffff; --cube-1:#e6dac2; --cube-ink:#2b2117;
  --shadow-1:0 1px 2px rgba(90,70,40,.18);
  --shadow-2:0 10px 30px rgba(90,70,40,.18);
}

/* ======================================================================== base */
*{box-sizing:border-box}
/* Several containers set display:flex, which would otherwise beat [hidden]. */
[hidden]{display:none !important}
html,body{height:100%}
html{overflow:hidden}
body{
  margin:0;background:var(--bg);color:var(--ink);
  font:15px/1.55 var(--sans);
  font-variant-numeric:tabular-nums;
  -webkit-text-size-adjust:100%;
  overscroll-behavior-y:none;
  transition:background .25s var(--ease),color .25s var(--ease);
  /* The shell is exactly the viewport: header, then one view that fits. Anything longer
     than a screen lives in the overlay and scrolls there, so the board never moves. */
  height:100dvh;display:flex;flex-direction:column;overflow:hidden;
}
button,.tier,.seek,.board,.tab{touch-action:manipulation}
.board{user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}

/* ====================================================================== header */
header{
  position:sticky;top:0;z-index:20;
  display:flex;align-items:center;gap:var(--s-3);justify-content:space-between;
  padding:calc(var(--s-2) + var(--safe-t)) var(--s-4) var(--s-2);
  background:color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter:blur(10px) saturate(1.2);
  border-bottom:1px solid var(--line-soft);
}
.brand{display:flex;align-items:center;gap:var(--s-3);min-width:0}
h1{margin:0;font-size:18px;letter-spacing:-.015em;font-weight:700;white-space:nowrap}
h1 span{color:var(--accent)}
h2{font-size:11.5px;text-transform:uppercase;letter-spacing:.09em;color:var(--ink-dim);
  margin:0 0 var(--s-3);display:flex;gap:var(--s-2);align-items:baseline;font-weight:600}
h3{font-size:11.5px;text-transform:uppercase;letter-spacing:.09em;color:var(--ink-dim);
  margin:0 0 var(--s-2);font-weight:600}
.pill{
  display:inline-flex;align-items:center;gap:var(--s-1);font-size:12px;color:var(--ink-dim);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;
}
.pill::before{
  content:"";width:7px;height:7px;border-radius:50%;background:var(--ok);flex:0 0 auto;
  box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 25%, transparent);
}
.pill.off::before{background:var(--bad);box-shadow:0 0 0 3px color-mix(in srgb, var(--bad) 25%, transparent)}
.headright{display:flex;gap:var(--s-2);align-items:center;min-width:0}
.chip{
  background:var(--surface);border:1px solid var(--line);color:var(--ink);
  border-radius:var(--r-pill);padding:7px 13px;font:inherit;font-size:12.5px;font-weight:600;
  cursor:pointer;max-width:42vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  min-height:36px;transition:border-color .15s var(--ease),background .15s var(--ease);
}
/* Every :hover rule below is wrapped in (hover:hover). A touch browser leaves :hover
   stuck on whatever was last tapped, so after a roll the button that moved into that
   spot — "resign" — sat there glowing red as if it were a warning. Seen in the phone
   recordings of the Playwright suite. */
@media (hover:hover){.chip:hover{border-color:var(--accent)}}
.chip.icon{padding:7px 11px;font-size:15px;line-height:1}
.dim{color:var(--ink-dim);font-weight:400}
.mono,code{font-family:var(--mono)}

/* The column is as wide as what is in it needs.
   READING wants a limit — a lobby stretched across a 27" screen is a worse lobby. PLAYING
   does not: on a 1920x1080 screen a 1240px cap made the board 1216x708 with 350px of
   black down each side, because the board was width-bound by a limit that exists for
   text. The game screen gets the room instead and becomes height-bound, which is the
   right constraint for a board: as big as the window is tall. */
main{max-width:1240px;width:100%;margin:0 auto;padding:var(--s-3) var(--s-3) var(--s-3);
  display:flex;flex-direction:column;gap:var(--s-3);flex:1;min-height:0;overflow:hidden}
/* ...except while a match is on screen. The cap is there so a lobby and a puzzle are not
   stretched across a 27" display, which is a READING concern; the board is not reading,
   and on a 1920x1080 screen that cap made it 1216x708 with 350px of black down each side.
   Lifted here, the board becomes height-bound instead — as big as the window is tall,
   which is the right constraint for a board.
   Widened on `main` rather than on `#game`, because `#game` is a flex item: capping it
   directly means centring it, and an item centred in a row-direction `main` (the
   landscape-phone layout) has no definite height for the board to resolve against. */
body:has(#game:not([hidden])) main{max-width:min(100%,1560px)}
.card,section#crypto,section#verify,section#logbox{
  background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-lg);
  padding:var(--s-4);box-shadow:var(--shadow-1);
}
/* SAFE centring. Plain `justify-content:center` centres by overflowing BOTH edges, so on
   a phone — where the lobby is taller than the screen — it pushed the first line up under
   the sticky header with no way to scroll back to it. `safe center` centres only while
   the content fits and falls back to start-aligned the moment it does not; the
   `flex-start` line before it is the fallback for browsers that do not know `safe`. */
#lobby{display:block;padding:0;border:0;background:none;
  box-shadow:none;min-height:0;flex:1;overflow-y:auto;overflow-x:hidden}
.lobbyinner{display:flex;flex-direction:column;gap:var(--s-3);
  min-height:100%;justify-content:center}

/* Launchers open the overlay; they never grow the page. */
.launchers{display:flex;justify-content:center;gap:var(--s-2);flex-wrap:wrap}
/* Sized to their words, not stretched across the page. Growing to fill the row made the
   two quietest actions the largest controls in the lobby, and the only full-bleed ones. */
.launch{
  background:var(--surface);border:1px solid var(--line-soft);color:var(--ink-dim);
  padding:9px 18px;font-size:13px;min-height:40px;border-radius:var(--r-md);flex:0 1 auto;
}
@media (hover:hover){.launch:hover{color:var(--ink);border-color:var(--accent)}}
.launch.mini{flex:0 0 auto;min-height:30px;padding:4px 10px;font-size:11.5px;border-radius:var(--r-sm)}
.badge{color:var(--accent);font-variant-numeric:tabular-nums}

/* ===================================================================== overlay */
.sheet{
  position:fixed;inset:0;z-index:40;display:flex;align-items:flex-end;justify-content:center;
  background:var(--scrim);backdrop-filter:blur(3px);padding:0;
}
.sheetcard{
  background:var(--surface);border:1px solid var(--line);border-top-left-radius:var(--r-lg);
  border-top-right-radius:var(--r-lg);width:min(760px,100%);max-height:92dvh;
  display:flex;flex-direction:column;box-shadow:var(--shadow-2);
}
.sheetbar{
  display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);
  padding:var(--s-3) var(--s-4);border-bottom:1px solid var(--line-soft);flex:0 0 auto;
}
.sheetbar h2{margin:0}
.sheetbody{
  padding:var(--s-4);overflow-y:auto;-webkit-overflow-scrolling:touch;
  padding-bottom:calc(var(--s-4) + var(--safe-b));
}
@media (min-width:720px){
  .sheet{align-items:center}
  .sheetcard{border-radius:var(--r-lg)}
}

/* ====================================================================== inputs */
.row{display:flex;gap:var(--s-3);flex-wrap:wrap;align-items:center}
.fields{display:flex;gap:var(--s-3);flex-wrap:wrap;align-items:center;margin-bottom:var(--s-4)}
label{font-size:12.5px;color:var(--ink-dim);display:flex;gap:var(--s-2);align-items:center}
label.check{gap:var(--s-2);cursor:pointer}
input,select{
  background:var(--surface-2);color:var(--ink);border:1px solid var(--line);
  border-radius:var(--r-sm);padding:9px 11px;font:inherit;font-size:15px;min-height:var(--tap);
  transition:border-color .15s var(--ease);
}
@media (hover:hover){input:hover,select:hover{border-color:var(--ink-faint)}}
input[type=checkbox]{min-height:0;width:19px;height:19px;accent-color:var(--accent)}
input[type=range]{min-height:0;accent-color:var(--accent)}
#name{width:11ch}
button{
  background:var(--surface-2);color:var(--ink);border:1px solid var(--line);
  border-radius:var(--r-md);padding:11px 18px;font:inherit;font-weight:600;font-size:14.5px;
  cursor:pointer;min-height:var(--tap);
  transition:transform .08s var(--ease),border-color .15s var(--ease),background .15s var(--ease);
}
@media (hover:hover){button:hover{border-color:var(--accent)}}
button:active{transform:translateY(1px)}
button.primary{
  background:linear-gradient(180deg,var(--accent),var(--accent-2));
  border-color:var(--accent-2);color:var(--accent-ink);box-shadow:var(--shadow-1);
}
@media (hover:hover){button.primary:hover{filter:brightness(1.06)}}
/* A ghost button is still a BUTTON. With a transparent border it rendered as a stray
   word floating under the board — "resign" read as a rendering fault rather than a
   control. Keep it quiet (no fill) but give it an edge so it is unmistakably clickable
   and aligns with its neighbours. */
button.ghost{background:transparent;color:var(--ink-dim);border-color:var(--line)}
@media (hover:hover){button.ghost:hover{color:var(--ink);border-color:var(--ink-faint);
  background:color-mix(in srgb,var(--ink) 6%,transparent)}}
button.big{flex:1 1 auto;min-width:44%}
button[disabled]{opacity:.45;cursor:default;transform:none}
/* A dimmed accent is still legible on a dark skin and is NOT on a light one: on ivory the
   roll button faded to pale tan under near-white text (~2.2:1) for the whole round trip
   after every tap. A disabled primary stops being a primary instead of going translucent. */
button.primary[disabled]{
  opacity:1;background:var(--surface-2);color:var(--ink-faint);
  border-color:var(--line);box-shadow:none;
}
button.on{border-color:var(--accent);color:var(--accent)}
.primaryrow{margin-bottom:var(--s-3)}
.hint{font-size:12.5px;color:var(--ink-dim);margin:var(--s-3) 0 0;line-height:1.5}
label.file{
  display:inline-flex;align-items:center;justify-content:center;
  background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-md);
  padding:11px 18px;color:var(--ink);font-weight:600;cursor:pointer;min-height:var(--tap);
}
@media (hover:hover){label.file:hover{border-color:var(--accent)}}
label.file input{display:none}

/* ======================================================================= lobby */
/* The lobby used to be a stack of controls pinned to the top of a mostly empty page,
   with no statement of what the thing IS and three buttons of identical weight for
   three jobs of very different importance. It is now centred, captioned, and grouped. */

.hero{text-align:center;margin-bottom:var(--s-2)}
.tagline{margin:0 auto;max-width:52ch;font-size:15px;line-height:1.55;color:var(--ink-dim)}
.tagline em{font-style:normal;color:var(--ink);font-weight:600}

/* Group headings inside the card. Small, quiet, and doing the work that three
   accent-coloured button labels were failing to do. */
.grouphead{font-size:10.5px;text-transform:uppercase;letter-spacing:.11em;
  color:var(--ink-dim);font-weight:700;margin:0 0 var(--s-3)}
.grouphead + .row,.grouphead + .fields,.grouphead + .tiers{margin-top:0}
.play .row.primaryrow,.play .fields{margin-bottom:var(--s-5)}
.play .tiers{margin-top:0}

.tiers{display:flex;gap:var(--s-2);flex-wrap:wrap}
.tier{
  flex:1 1 30%;min-width:150px;display:flex;flex-direction:column;align-items:flex-start;gap:2px;
  padding:10px var(--s-4);text-align:left;background:var(--surface-2);
}
/* Not accent. The accent belongs to the ONE primary action on the screen; three more
   competing for it is why the page read as noisy. */
.tier b{font-size:14px;color:var(--ink);font-weight:700}
@media (hover:hover){.tier:hover b{color:var(--accent)}}
.tier span{font-size:12px;color:var(--ink-dim);font-weight:400;line-height:1.4}

.seek{
  display:flex;align-items:center;gap:var(--s-3);padding:var(--s-3) var(--s-1);
  border-bottom:1px solid var(--line-soft);font-size:14px;flex-wrap:wrap;
}
.seek:last-child{border-bottom:0}
.seek .who{flex:1 1 40%;min-width:0;display:flex;flex-direction:column;gap:1px}
.seek .who b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
.seek .meta{color:var(--ink-dim);font-size:12px}
.seek button{padding:8px 16px;font-size:13.5px;min-height:38px}
.cls{
  font-size:10px;text-transform:uppercase;letter-spacing:.08em;padding:3px 8px;
  border-radius:var(--r-sm);background:var(--surface-2);color:var(--ink-dim);font-weight:700;
}
.cls.bullet{background:color-mix(in srgb,var(--bad) 22%,transparent);color:var(--bad)}
.cls.blitz{background:color-mix(in srgb,var(--warn) 22%,transparent);color:var(--warn)}
.cls.rapid{background:color-mix(in srgb,var(--ok) 22%,transparent);color:var(--ok)}
.cls.long{background:color-mix(in srgb,var(--accent) 22%,transparent);color:var(--accent)}
.cls.money{background:color-mix(in srgb,var(--ink-dim) 22%,transparent);color:var(--ink-dim)}


.puzzlehead{margin-bottom:var(--s-3)}
#puzzleBoard{margin-bottom:var(--s-3)}
#puzzleBoard .board{max-height:min(66dvh,540px)}
#puzzleActions{margin-bottom:var(--s-2)}

table.rank{width:100%;border-collapse:collapse;font-size:13.5px}
table.rank td,table.rank th{padding:9px 6px;border-bottom:1px solid var(--line-soft);text-align:left}
table.rank th{color:var(--ink-dim);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.07em}
table.rank td.n{color:var(--ink-faint);width:2.5ch}
table.rank td.r{text-align:right;font-family:var(--mono)}
.prov{color:var(--ink-faint);font-size:11px}

/* ======================================================================== game */
/* The game screen is ONE composition, centred as a block.
   It used to be top-pinned with `margin-top:auto` on the action bar, which on any window
   taller than the board left a dead band between the board and its own controls — the
   board looked pasted to the ceiling and the buttons looked orphaned at the floor. The
   board is usually WIDTH-bound (it is 1.72:1 inside a max-width column), so that spare
   height is real and has to be composed rather than dumped in the middle. */
#game{display:flex;flex-direction:column;gap:var(--s-3);padding:0;background:none;border:0;
  box-shadow:none;flex:1;min-height:0;justify-content:center;align-items:center}
#game > *{width:100%}

/* A scoreboard, not two banners. Each card is sized to hold a name and a pip count and
   then stops; stretched to half the board each they were mostly empty, with the writing
   crammed against the score in the middle. */
.players{display:flex;align-items:stretch;gap:var(--s-2);justify-content:center}
.pcard{
  flex:0 1 auto;min-width:158px;max-width:min(38vw,300px);
  display:flex;align-items:center;gap:var(--s-2);
  background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-md);
  padding:8px 12px;position:relative;overflow:hidden;
  transition:border-color .2s var(--ease),box-shadow .2s var(--ease),background .2s var(--ease);
}
/* On turn: a filled edge rather than a ring. A ring around a box that already has a
   border reads as a rendering artefact; a bar down the leading edge reads as "this one". */
.pcard.on{border-color:color-mix(in srgb,var(--accent) 55%,var(--line));
  background:color-mix(in srgb,var(--accent) 7%,var(--surface))}
.pcard.on::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;
  background:var(--accent)}
#pOpp{flex-direction:row-reverse;text-align:right}
#pOpp.on::before{left:auto;right:0}
/* Right-aligned by text-align, NOT align-items:flex-end — that sized the name to its
   content, so it could never ellipsize and ran underneath the clock on a phone. */
#pOpp .pinfo{align-items:stretch}

/* Whose checkers are whose, said once, in the place you look first. */
.pchk{flex:0 0 auto;width:20px;height:20px;border-radius:50%;
  box-shadow:inset 0 -2px 3px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.3)}
.pchk.p0{background:radial-gradient(circle at 36% 30%,var(--chk-a0),var(--chk-a1) 62%,var(--chk-a2));
  border:1px solid rgba(0,0,0,.25)}
.pchk.p1{background:radial-gradient(circle at 36% 30%,var(--chk-b0),var(--chk-b1) 62%,var(--chk-b2));
  border:1px solid rgba(0,0,0,.4)}

.pinfo{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1 1 auto}
/* No clock in this match: the element is empty, and an empty element still claims a gap.
   Collapse it so the card closes up around what it actually has to say. */
.pclock:empty,.ppip:empty{display:none}
.pscore b{font-weight:700}
.pscore .ssub{margin-left:.5em}
.pname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  font-size:13.5px;font-weight:600;line-height:1.25}
.ppip{font:500 11px/1.2 var(--mono);color:var(--ink-dim);
  font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pcard.on .ppip{color:var(--ink)}
.pclock{flex:0 0 auto;font-family:var(--mono);font-size:15px;color:var(--ink-faint);font-weight:600;
  font-variant-numeric:tabular-nums}
.pcard.on .pclock{color:var(--ink)}
.pclock.low{color:var(--bad)}
.pscore{
  font:700 15px/1 var(--mono);padding:0 var(--s-4);white-space:nowrap;
  display:flex;align-items:center;justify-content:center;color:var(--ink);
  letter-spacing:.02em;flex:0 0 auto;
}

/* `flex:0 1 auto` — the board box takes the board's height and no more. With `1 1 auto`
   it swallowed the column's spare height and then pinned the board to the top of itself,
   which is where the dead band came from. */
.boardwrap{width:100%;display:flex;justify-content:center;align-items:center;
  flex:0 1 auto;min-height:0}
.board{max-width:100%;max-height:100%;width:auto;height:auto;display:block;
  filter:drop-shadow(0 10px 24px rgba(0,0,0,.28))}
/* The puzzle board is a board: it takes the room a board takes. It was capped at 460px
   because it used to sit inside a scrolling card in the overlay. */
/* basis 0, and the board pinned inside it. `max-height:100%` is not trustworthy here —
   WebKit resolved it against the SVG's own box, so the board overflowed its wrapper and
   drew over the text above it. Out of flow, the wrapper's height is purely the row's, and
   the drawing scales to fit it through the viewBox. */
#puzzleBoard{flex:1 1 0;min-height:0;position:relative}
#puzzleBoard .board{position:absolute;inset:0;width:100%;height:100%;max-width:none;max-height:none}

/* ---- board paint: every fill is a variable, so a skin is just tokens */
.grad-frame .g0{stop-color:var(--frame-0)} .grad-frame .g1{stop-color:var(--frame-1)}
.grad-felt .g0{stop-color:var(--felt-0)}   .grad-felt .g1{stop-color:var(--felt-1)}
.grad-bar .g0{stop-color:var(--bar-0)}     .grad-bar .g1{stop-color:var(--bar-1)}
.grad-point-a .g0{stop-color:var(--point-a0)} .grad-point-a .g1{stop-color:var(--point-a1)}
.grad-point-b .g0{stop-color:var(--point-b0)} .grad-point-b .g1{stop-color:var(--point-b1)}
.grad-die .g0{stop-color:var(--die-0)}     .grad-die .g1{stop-color:var(--die-1)}
.grad-die-b .g0{stop-color:var(--die-b-0)} .grad-die-b .g1{stop-color:var(--die-b-1)}
.grad-cube .g0{stop-color:var(--cube-0)}   .grad-cube .g1{stop-color:var(--cube-1)}
.grad-chk-a .g0{stop-color:var(--chk-a0)} .grad-chk-a .g1{stop-color:var(--chk-a1)} .grad-chk-a .g2{stop-color:var(--chk-a2)}
.grad-chk-b .g0{stop-color:var(--chk-b0)} .grad-chk-b .g1{stop-color:var(--chk-b1)} .grad-chk-b .g2{stop-color:var(--chk-b2)}

.frame{fill:url(#gFrame);stroke:rgba(0,0,0,.35);stroke-width:1}
.felt{fill:url(#gFelt);stroke:rgba(0,0,0,.4);stroke-width:2}
.point.light{fill:url(#gPointA)}
.point.dark{fill:url(#gPointB)}
.bar{fill:url(#gBar);stroke:rgba(0,0,0,.45);stroke-width:1.5}
.tray{fill:var(--tray-0);stroke:rgba(0,0,0,.45);stroke-width:1.5}

.chk .body{stroke:rgba(0,0,0,.5);stroke-width:1.5}
/* Ivory is a light board with light checkers, so a pale checker on a pale point has
   almost no fill contrast — measured at 1.03:1, versus 1.25 on the dark skins. The rim
   is the ONLY thing separating them there, so it carries more weight. Darker and
   thicker only in this skin; on the dark boards the fill already does the work and a
   heavy outline would just look drawn-on. */
:root[data-skin="ivory"] .chk .body{stroke:rgba(60,40,20,.72);stroke-width:2.2}
.chk.p0 .body{fill:url(#gChkA)}
.chk.p1 .body{fill:url(#gChkB)}
.chk .inner{fill:none;stroke:rgba(0,0,0,.16);stroke-width:1.5}
.chk .core{fill:none;stroke:rgba(255,255,255,.14);stroke-width:2}
.chk.p0 .core{stroke:rgba(0,0,0,.10)}
.chk.sel .body{stroke:var(--accent);stroke-width:4}
/* "You can move this one" belongs on the CHECKER. Tinting the hit rectangle instead
   filled the whole half-board column, so three movable points merged into one translucent
   slab with a stepped edge across open felt — it did not say which point, and the shape
   described nothing on the board. */
.chk.movable .body{stroke:color-mix(in srgb,var(--accent) 70%,transparent);stroke-width:3}
/* "This checker just moved" — used by the analysis viewer, where the move has already
   happened and the board has to say which checkers it was. */
.chk.moved .body{stroke:var(--accent);stroke-width:3.5}
.count{fill:var(--chk-a-ink);font:700 15px var(--mono);text-anchor:middle;pointer-events:none}
.count.p1{fill:var(--chk-b-ink)}
.ptnum{fill:color-mix(in srgb,var(--ink) 40%,transparent);font:600 11px var(--sans);
  text-anchor:middle;pointer-events:none}
.offchk{fill:url(#gChkA);stroke:rgba(0,0,0,.45);stroke-width:1}
.offchk.p1{fill:url(#gChkB)}

.hot{fill:transparent;cursor:default}
.hot.pickable{cursor:pointer}
.hot.target{fill:color-mix(in srgb,var(--ok) 16%,transparent);cursor:pointer}
.targetdot{fill:color-mix(in srgb,var(--ok) 70%,transparent);pointer-events:none;
  stroke:rgba(0,0,0,.25);stroke-width:1}
/* Dice and cube sit ABOVE the point hit-layer so a click can reach them, which means
   they must be inert whenever they are not actionable — otherwise they would shadow the
   point underneath. `pointer-events` carries that, not z-order. */
.die,.cube{pointer-events:none}
.die.swappable,.cube.live{pointer-events:auto;cursor:pointer}
.die .pip,.die text{pointer-events:none}
/* Affordance WITHOUT touching `transform`. In portrait `die()` carries its own
   `rotate(-90 cx cy)` in SVG user space; adding `transform-box:fill-box` here re-based
   that rotation and threw the dice clean off the board. */
.die.swappable rect{cursor:pointer;transition:stroke .12s ease,stroke-width .12s ease}
@media (hover:hover){.die.swappable:hover rect{stroke:var(--accent);stroke-width:3}}
.die.swappable:active rect{stroke:var(--accent);stroke-width:4}

.die rect{fill:url(#gDie);stroke:rgba(0,0,0,.45);stroke-width:1.5}
.die .pip{fill:var(--die-pip)}
/* p1 is the dark side. This used to point at the LIGHT gradient, so the dark player
   rolled white dice and the --die-b-* tokens in every skin were dead. */
.die.p1 rect{fill:url(#gDieB)}
.die.p1 .pip{fill:var(--die-b-pip)}
/* A die that has been played is SPENT and says so, so the only dice that still look live
   are the ones you still have. A double shows four plays on two dice, so a die can be
   HALF spent: the veil covers the half that has gone. */
.die.spent{opacity:.34;filter:blur(1.2px)}
.die.part{opacity:.84}
.die .spentveil{fill:color-mix(in srgb,var(--bg) 74%,transparent);stroke:none;pointer-events:none}
/* The space around and between the dice: a target for "take the last move back", which is
   why it is transparent rather than `none` — `fill:none` would not receive the tap. */
.dicehot{fill:transparent;cursor:pointer}
.cube rect{fill:url(#gCube);stroke:rgba(0,0,0,.5);stroke-width:1.5}
.cube .cubeInner{fill:none;stroke:rgba(0,0,0,.14);stroke-width:1.5}
.cubetext{fill:var(--cube-ink);font:700 22px var(--mono);text-anchor:middle;pointer-events:none}
.cube.live rect{stroke:var(--accent);stroke-width:3.5}
/* An offer in flight: the value on the cube is the one being offered, and it is waiting
   for an answer. */
.cube.offered rect{stroke:var(--accent);stroke-width:3.5}
.cube.offered .cubetext{fill:var(--accent)}
.cube.dead{opacity:.42}

/* Status reads under the board, centred with it. Spread edge-to-edge across a 1000px
   column the turn text and the panel buttons looked like two unrelated widgets. */
.turnline{display:flex;justify-content:center;align-items:center;gap:var(--s-4);
  font-size:13.5px;flex:0 0 auto;flex-wrap:wrap;min-height:0}
.turnline:empty,#turn:empty{display:none}
.turnright{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
#turn{color:var(--ink-dim);font-weight:600}
#turn:empty{display:none}

.actionbar{
  flex:0 0 auto;z-index:10;
  display:flex;gap:var(--s-2);flex-wrap:wrap;justify-content:center;align-items:center;
  padding:0 0 var(--safe-b);min-height:var(--tap);
}
/* Buttons are sized by their job, not by how many happen to be visible: a lone
   "swap dice" stretching across the whole screen looks like the main action, and it
   is not. */
.actionbar button{flex:0 1 auto;min-width:136px}
.actionbar button.primary{min-width:176px}
/* Leaving the match is always available and is never the thing you came to do. It sits
   apart from the row of play actions and is quiet until you go looking for it.
   `margin-left:auto` did that by pushing it to the far edge — but for most of a turn the
   play buttons are hidden and resign is the ONLY thing in the bar, so "apart from the
   row" became "exiled to the bottom-right corner of an empty strip", which reads as a
   layout fault. A fixed gap separates it from its neighbours when they exist and leaves
   it centred with everything else when they do not. */
.actionbar button.ghost{min-width:0;font-size:12.5px;min-height:32px;padding:4px 12px;
  color:var(--ink-dim);margin-left:var(--s-4)}
.actionbar button.ghost:first-child{margin-left:0}
.actionbar button.ghost ~ button.ghost{margin-left:var(--s-2)}
@media (hover:hover){.actionbar button.ghost:hover{color:var(--bad);border-color:var(--bad)}}

/* ========================================================= verify / analysis */
/* A key/value row.
   This was a flex row with space-between, which pushed the label to one edge and the
   value to the other: on a wide dialog the eye had to cross the whole card to pair them,
   nothing lined up down the column because every value started wherever it happened to
   end, and a long value wrapped underneath its own label. A two-column grid with a fixed
   label column puts every value on the same vertical line. */
.kv{display:grid;grid-template-columns:minmax(0,10em) minmax(0,1fr);gap:2px var(--s-3);
  align-items:baseline;padding:var(--s-2) 0;
  border-bottom:1px solid var(--line-soft);font-size:13px}
.kv span:first-child{color:var(--ink-dim)}
.kv > :last-child{min-width:0;overflow-wrap:anywhere}
.kv code{font-size:12px;word-break:break-all}
/* Narrow screens have no room for two columns; stack, and keep the label above. */
@media (max-width:420px){
  .kv{grid-template-columns:minmax(0,1fr);gap:0}
}
/* A value with a copy button beside it. The button keeps a full tap target — it is a
   control like any other — so the row grows rather than the button shrinking. */
.copyline{display:flex;align-items:center;gap:var(--s-2);flex-wrap:wrap}
.copyline code{flex:1 1 auto;min-width:0}
.copyline button{flex:0 0 auto;padding:6px 14px;font-size:12.5px}
.outcome{margin:0 0 var(--s-3);font-size:17px;line-height:1.35}
.outcome b{font-weight:700}
.verdict{font-weight:800;letter-spacing:.14em;padding:var(--s-3);border-radius:var(--r-md);
  text-align:center;margin-bottom:var(--s-3);font-size:14px}
.verdict.ok{background:color-mix(in srgb,var(--ok) 16%,transparent);color:var(--ok);
  border:1px solid color-mix(in srgb,var(--ok) 50%,transparent)}
.verdict.bad{background:color-mix(in srgb,var(--bad) 16%,transparent);color:var(--bad);
  border:1px solid color-mix(in srgb,var(--bad) 50%,transparent)}
.checks{list-style:none;padding:0;margin:0 0 var(--s-3);font-size:13px}
.checks li{padding:var(--s-1) 0 6px 24px;position:relative;color:var(--ink-dim)}
.checks li:before{position:absolute;left:2px;font-weight:700}
.checks li.ok:before{content:"✓";color:var(--ok)}
.checks li.bad:before{content:"✕";color:var(--bad)}
.checks b{color:var(--ink);margin-right:var(--s-1)}
table.dice{width:100%;font-size:12px;border-collapse:collapse;margin-top:var(--s-2)}
table.dice td{padding:4px 6px;border-bottom:1px solid var(--line-soft)}
td.ok,p.ok{color:var(--ok)}td.bad,p.bad{color:var(--bad)}
details summary{cursor:pointer;font-size:13px;color:var(--ink-dim);margin:var(--s-2) 0;
  min-height:var(--tap);display:flex;align-items:center}
@media (hover:hover){details summary:hover{color:var(--ink)}}
/* The proof, folded. It is the point of the project and it still runs in front of the
   player — but after a match the thing you reach for is "analyse", and eight checks
   between the result and that button put it below the fold on a phone. */
details.proof{border-top:1px solid var(--line-soft);margin-top:var(--s-3)}
details.proof > summary{font-weight:600}
details.proof[open] > summary{color:var(--ink)}
/* After a match: analyse first — it is what people want next — and the record download
   stays available without competing with it. */
.resultactions{margin:var(--s-3) 0 0}
.resultactions #an{flex:1 1 auto}
.ratingmove{display:flex;gap:var(--s-4);align-items:baseline;font-size:15px;margin:6px 0 var(--s-3)}
.ratingmove b{font-size:22px;font-family:var(--mono)}
.up{color:var(--ok)}.down{color:var(--bad)}

#log{max-height:240px;overflow:auto;font-size:12.5px}
.logline{display:flex;gap:var(--s-3);padding:var(--s-1) 0;border-bottom:1px solid var(--line-soft)}
.logline .n{color:var(--ink-faint);width:30px;text-align:right;font-family:var(--mono)}
.logline .ty{flex:1}
.logline code{color:var(--ink-faint)}

/* A message, not a control. `pointer-events:none` is the important line: it floats over
   whatever is on screen, and on a landscape phone that is sometimes the result panel's
   buttons — a toast that swallows the tap meant for "analyse" is worse than no toast. */
#flash{
  position:fixed;left:50%;bottom:calc(20px + var(--safe-b));transform:translateX(-50%);
  background:var(--surface);border:1px solid var(--accent);color:var(--ink);
  padding:12px 20px;border-radius:var(--r-md);font-size:14px;z-index:50;
  max-width:92vw;text-align:center;box-shadow:var(--shadow-2);
  pointer-events:none;
}

dialog{
  background:var(--surface);color:var(--ink);border:1px solid var(--line);
  /* The 280px floor matters: a viewport that momentarily reports 0 (some mobile
     browsers do this mid-rotation, and any hidden/offscreen frame does) collapsed
     `min(540px,92vw)` to 0, so the dialog opened as an empty ~34px stub — content
     present, size gone. It reads as "the settings button does nothing". */
  border-radius:var(--r-lg);padding:var(--s-5);
  max-width:min(540px,max(280px,92vw));
  box-shadow:var(--shadow-2);
  /* Settings is taller than a laptop window. Without a height bound the dialog simply
     ran off the bottom of the screen and the last few rows — including "reset to
     defaults" — could not be reached at all. Header and footer stay put; the body
     scrolls. */
  max-height:min(max(220px,86dvh),760px);display:flex;flex-direction:column;gap:var(--s-3);
}
/* MUST come after the rule above. The browser's own `dialog:not([open]){display:none}`
   is a USER-AGENT rule, and any author declaration beats it no matter how specific the
   UA selector is — so the `display:flex` we need for the scrolling body also forced every
   CLOSED dialog to stay painted. Settings is position:fixed and 540px wide, so it sat on
   top of the board permanently, eating the clicks aimed at whatever was underneath. That
   is the "the settings button does nothing" bug: the panel was already there. */
dialog:not([open]){display:none}
dialog > h2{flex:0 0 auto;margin-bottom:0}
dialog > .row:last-child{flex:0 0 auto;margin-top:0}
#settingsBody{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain}
/* The account dialog has TWO blocks of content (who you are, then the forms). Letting
   only the first scroll squeezed it to a single row on a phone, with the key and record
   hidden in a tiny scroller. The dialog scrolls as a whole instead. */
#account{overflow-y:auto;overscroll-behavior:contain}
#account > *{flex-shrink:0}
dialog::backdrop{background:var(--scrim);backdrop-filter:blur(2px)}
/* A column of equal-weight choices, each one a full-width target — the shape a "pick
   one of three" question actually wants, rather than three buttons crowded on a line. */
.row.stack{flex-direction:column;align-items:stretch;gap:8px;margin-top:var(--s-4)}
.row.stack button{justify-content:flex-start;text-align:left;width:100%}
.row.stack .dim{color:var(--ink-faint);font-weight:500}
.row.stack .dim::before{content:"— "}
/* Account forms: fields grow to share the line and the button sits at their end; when
   the line is too narrow everything wraps to full width. With fixed-width inputs a phone
   got a half-width field, a stray button beside one of them and the other alone below. */
.row.form > input{flex:1 1 180px;min-width:0}
.row.form > button{flex:1 0 auto}
#resignWho{margin-top:var(--s-4)}
#resignWho button{flex:1 1 0;min-width:0}
dialog.confirm{max-width:min(420px,max(280px,92vw))}
dialog.confirm .row{margin-top:var(--s-5);justify-content:flex-end}
button.danger{background:linear-gradient(180deg,var(--bad),color-mix(in srgb,var(--bad) 78%,#000));
  border-color:color-mix(in srgb,var(--bad) 70%,#000);color:#fff}
@media (hover:hover){button.danger:hover{filter:brightness(1.08)}}

/* ==================================================================== settings */
.setgroup{margin-bottom:var(--s-5)}
.setrow{display:flex;gap:var(--s-3);align-items:flex-start;padding:10px 0;
  border-bottom:1px solid var(--line-soft);color:var(--ink);font-size:14px;cursor:pointer}
.setrow input[type=checkbox]{margin-top:2px;flex:0 0 auto}
.setrow select{margin-left:auto;flex:0 0 auto;min-height:38px;padding:6px 9px;font-size:14px}
.setrow.range{flex-direction:column;align-items:stretch;gap:var(--s-1)}
.setrow.range input{width:100%}
.sethint{display:block;color:var(--ink-dim);font-size:12px;margin-top:2px;line-height:1.45}

/* Skin picker: three swatches that show what they are rather than naming a colour. */
.skins{display:flex;gap:var(--s-3);margin:var(--s-2) 0 var(--s-4)}
.skin{
  flex:1;display:flex;flex-direction:column;gap:var(--s-1);align-items:stretch;padding:8px;
  background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-md);cursor:pointer;
  min-height:0;
}
.skin.on{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset}
.skin .swatch{height:34px;border-radius:var(--r-sm);position:relative;overflow:hidden;
  border:1px solid rgba(0,0,0,.3)}
.skin .swatch i{position:absolute;top:50%;width:15px;height:15px;border-radius:50%;
  transform:translateY(-50%);border:1px solid rgba(0,0,0,.4)}
.skin .swatch i.a{left:9px}
.skin .swatch i.b{left:29px}
.skin b{font-size:12.5px;font-weight:600;text-align:center}

/* ==================================================================== analysis */
.prrow{display:flex;gap:var(--s-3);margin:var(--s-3) 0;flex-wrap:wrap}
.prcard{flex:1 1 45%;min-width:180px;background:var(--surface-2);border:1px solid var(--line-soft);
  border-radius:var(--r-md);padding:var(--s-3)}
.prcard .who{font-size:11.5px;color:var(--ink-dim);text-transform:uppercase;letter-spacing:.07em}
.prcard .pr{font:800 32px/1.1 var(--mono);color:var(--accent)}
.prcard .pr span{font-size:12px;color:var(--ink-dim);margin-left:var(--s-1);letter-spacing:.1em}
.prcard .tags{font-size:12px;color:var(--ink-dim);margin-top:4px}
.prcard .tags b{color:var(--ink)}
.scroller{overflow-x:auto;-webkit-overflow-scrolling:touch}
table.moves{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:var(--s-3);min-width:420px}
table.moves td{padding:5px 8px;border-bottom:1px solid var(--line-soft);vertical-align:top}
table.moves td:first-child{color:var(--ink-faint);font-family:var(--mono)}
tr.blunder td{color:var(--bad)}
tr.error td{color:var(--warn)}
tr.inaccuracy td{color:var(--ink-dim)}
tr.cube td{color:var(--ink-dim);font-style:italic}
.best{color:var(--ok);margin-left:var(--s-2)}

/* Whose move was that? A swatch in every row, and a filter above the table. */
.mvfilter{display:flex;gap:var(--s-2);flex-wrap:wrap;margin:var(--s-3) 0 var(--s-2)}
.mvfilter button{font-size:12.5px;min-height:var(--tap);padding:var(--s-2) var(--s-3);
  display:inline-flex;align-items:center;gap:var(--s-2)}
.mvfilter button.on{border-color:var(--accent);color:var(--accent)}
.mvwho{display:inline-flex;align-items:center;gap:6px}
/* The swatch is a flex item in the player cards; here it sits in a table cell and a
   plain inline span ignores width and height. */
.mvfilter .pchk,.moves .pchk{display:inline-block;width:14px;height:14px;flex:0 0 auto;vertical-align:middle}
table.moves td.mvp{width:18px;padding-right:0}
/* The roll and the rank are short and must never break across two lines; only the play
   and the engine's suggestion may wrap. */
table.moves td:nth-child(3),table.moves td:nth-child(5){white-space:nowrap}
table.moves tr[hidden]{display:none}

/* Bug reports, at the top of the log panel. */
.bugreport{border-bottom:1px solid var(--line-soft);padding-bottom:var(--s-3);margin-bottom:var(--s-3)}
.bugreport .row{gap:var(--s-2)}
.bugreport button.on{border-color:var(--bad);color:var(--bad)}

/* The decision viewer: one move from the analysis, on the board it was played on. */
.anviewer{
  background:var(--surface-2);border:1px solid var(--line-soft);border-radius:var(--r-md);
  padding:var(--s-3);margin-bottom:var(--s-4);
}
.anhead{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);
  font-size:13px;color:var(--ink-dim);margin-bottom:var(--s-2)}
.anhead b{color:var(--ink)}
.anright{display:flex;gap:var(--s-2);align-items:center}
.anboard{height:min(46dvh,340px);position:relative}
.anboard .board{position:absolute;inset:0;width:100%;height:100%;max-width:none;max-height:none}
.antoggle{display:flex;gap:var(--s-2);flex-wrap:wrap;margin-top:var(--s-3)}
.antoggle button{flex:1 1 auto;font-size:13px;min-height:var(--tap)}
.antoggle button.on{border-color:var(--accent);color:var(--accent)}
/* A row you can open looks like one. */
table.moves tr.playable{cursor:pointer}
@media (hover:hover){table.moves tr.playable:hover td{background:color-mix(in srgb,var(--ink) 6%,transparent)}}

/* ------------------------------------------------------------------ views */
/* The puzzle is a board, so it is laid out like the game screen: a bar to get back, then
   the board taking the space that is left. */
/* Laid out exactly like #game: the board sizes itself against a column whose height is
   settled, and shrink-to-fit (0 1 auto) is what lets it come back down when the head
   wraps to a second line. */
#puzzle{display:flex;flex-direction:column;gap:var(--s-3);flex:1;min-height:0;
  justify-content:center;align-items:center}
#puzzle > *{width:100%}
#puzzle[hidden]{display:none}
.viewbar{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto}
.viewbar h2{margin:0;flex:1 1 auto;text-align:center}
.viewbar-end{width:76px;flex:0 0 auto}
#puzzleHead{flex:0 0 auto}

#puzzleResult{flex:0 0 auto}

.puzzleline{margin:0;font-size:14px}
.puzzlemeta{margin:2px 0 0;font-size:12px;color:var(--ink-dim)}

/* A fold, not a wall of buttons. Closed it is one quiet line; open it is the same
   launchers as before. */
.more{margin-top:var(--s-3)}
.more > summary{
  list-style:none;cursor:pointer;color:var(--ink-dim);font-size:13px;
  /* `summary` is a flex container here, so text-align never centred it. */
  padding:var(--s-2) 0;justify-content:center;
}
.more > summary::-webkit-details-marker{display:none}
.more > summary::after{content:" ▾";color:var(--ink-faint)}
.more[open] > summary::after{content:" ▴"}
@media (hover:hover){.more > summary:hover{color:var(--ink)}}
.more .launchers{margin-top:var(--s-2)}
a.launch{text-decoration:none;display:inline-flex;align-items:center;justify-content:center}

/* Disconnected. Fixed and inert: it must not move the board or swallow a tap. */
.offline{
  position:fixed;left:0;right:0;top:0;z-index:60;pointer-events:none;
  padding:calc(6px + var(--safe-t)) var(--s-3) 6px;text-align:center;
  font-size:12.5px;font-weight:600;letter-spacing:.02em;
  color:var(--bad);background:color-mix(in srgb,var(--bad) 14%,var(--bg));
  border-bottom:1px solid color-mix(in srgb,var(--bad) 45%,transparent);
}

/* Waiting for an opponent, said in the page. */
.seekcard{background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-lg);
  padding:var(--s-3) var(--s-4);margin-top:var(--s-3)}
.seekhead{display:flex;align-items:center;gap:var(--s-2);font-size:15px}
.seekhead .dim{margin-left:auto;font-family:var(--mono);font-size:13px;color:var(--ink-dim)}
.seekcard .hint{margin:var(--s-2) 0}
.seekcard .row{margin-top:var(--s-3)}
/* A heartbeat, not a spinner: it says "still going" without implying work in progress. */
.pulse{width:9px;height:9px;border-radius:50%;background:var(--accent);flex:0 0 auto;
  animation:pulse 1.8s var(--ease) infinite}
@keyframes pulse{0%,100%{opacity:.35;transform:scale(.8)}50%{opacity:1;transform:scale(1.15)}}
@media (prefers-reduced-motion:reduce){.pulse{animation:none;opacity:.8}}

/* The tagline explains the project to someone who has never seen it. On a short screen it
   explains it INSTEAD of showing them the controls: at 734x343 the hero plus header took a
   third of the height and clipped the bot buttons off the bottom. */
@media (max-height:430px){
  .hero{display:none}
}

/* ================================================================== responsive */
@media (max-width:620px){
  main{padding:var(--s-3) var(--s-2) var(--s-4);gap:var(--s-3)}
  .card{padding:var(--s-3)}
  h1{font-size:16px}
  .fields{gap:var(--s-2)}
  #name{width:9ch}
  .tier{flex:1 1 100%}
  button.big{min-width:0;flex:1 1 30%}
  /* The main action gets its own line. Sharing a row with "two players, one device" it
     wrapped to two lines while its neighbour stayed on one — a ragged pair where the
     primary button looked like the afterthought. */
  /* `stretch`, so the pair below it match: one label wraps to two lines and the other
     does not, and two buttons of different heights side by side read as a mistake. */
  .primaryrow{flex-wrap:wrap;align-items:stretch}
  .primaryrow > #playHuman,.primaryrow > #cancel{flex:1 1 100%}
  .primaryrow > #playLocal,.primaryrow > #challenge{flex:1 1 calc(50% - var(--s-2))}
  /* One row, always. At most three controls show at once (roll·double·resign,
     play·undo·resign, take·beaver·pass), and letting them wrap to a second row took
     that height from the board — which then jumped smaller and back every turn. */
  .actionbar{flex-wrap:nowrap}
  .actionbar button{flex:1 1 0;min-width:0;padding-left:8px;padding-right:8px}
  .actionbar button.primary{min-width:0;flex-grow:1.4}
  .actionbar button.ghost{flex:0 1 auto;margin-left:0}
  dialog{padding:var(--s-4)}
  /* A 158px floor on each card plus the score does not fit across a 375px phone — both
     cards were clipped at the edges. Here they share the row instead. */
  .pcard{min-width:0;flex:1 1 0;padding:6px 9px;gap:var(--s-1);max-width:none}
  .pchk{width:16px;height:16px}
  .pname{font-size:12.5px}
  .ppip{font-size:10px}
  .pscore{padding:0 var(--s-1);font-size:15px;flex-direction:column;gap:2px;flex:0 0 auto}
  /* The engine's suggestion gets its own line rather than being sliced in half at the
     edge of the scroller. */
  table.moves .best{display:block;margin-left:0}
  .pscore .ssub{margin-left:0;font:600 10.5px/1.1 var(--sans);color:var(--ink-dim);letter-spacing:0}
  .pclock{font-size:13px}
  /* In a game the score line already says "to 1 · Crawford"; the header repeating it
     only fitted as "1 point · no c…". Keep the connection dot, drop the clipped words. */
  /* The turn line shares the action bar's row: on a 393px screen its own row cost the
     board ~11% in both dimensions, and it repeats what the lit player card already says. */
  #turn{font-size:12px}
  .turnright .launch.mini{padding:4px 10px;font-size:11px}
  body:has(#game:not([hidden])) #status{font-size:0;gap:0}
  /* The lobby header had room for "connec…" and a squeezed name chip. The dot still
     says connected or not; the words stay available as the tooltip and to screen
     readers, and anything that matters (waiting for an opponent) is shown in the page. */
  #status{font-size:0;gap:0}
  .chip{max-width:48vw}

  /* WHILE A MATCH IS ON SCREEN, the chrome gets out of the way.
     On a phone the board is height-bound — 35% of a 659px screen was going to header,
     padding, gaps and captions — and every pixel saved here comes back as a bigger board
     in both dimensions. Nothing is removed: the header, the cards, the turn line and the
     controls are all still there, just not padded for a desktop. */
  body:has(#game:not([hidden])) header{padding:calc(4px + var(--safe-t)) var(--s-3) 4px}
  body:has(#game:not([hidden])) main{padding:6px var(--s-2) 8px;gap:var(--s-2)}
  body:has(#game:not([hidden])) #game{gap:var(--s-2)}
  body:has(#game:not([hidden])) .turnline{font-size:12px;line-height:1.1}
}

/* Landscape phone. The board is the whole point and vertical space is the scarce
   resource, so the chrome moves BESIDE it rather than above and below: player cards and
   controls become a narrow column, and the board gets the full height. Previously 204px
   of fixed chrome squeezed the board to 36% of the width with 14px checkers. */
@media (max-height:520px) and (orientation:landscape){
  header{padding:2px var(--s-3)}
  header h1{font-size:14px}
  main{padding:4px var(--s-2);flex-direction:row;gap:var(--s-2);align-items:stretch}
  /* ONE sidebar, not three. Laid out as flex columns these three blocks each claimed
     their own track and ate ~400px of an 812px phone, starving the board. A grid puts
     the board in a full-height first column and stacks the chrome in the second, which
     is what actually fits in a hand. */
  #game{
    display:grid;grid-template-columns:minmax(0,1fr) 184px;
    grid-template-rows:auto auto 1fr;gap:var(--s-2);flex:1;min-width:0;
    /* STRETCH, not centre. The column layout centres the composition, but in this grid
       `align-items:center` makes the board cell size to its own content instead of to
       its track — and then the board's `max-height:100%` has no definite height to
       resolve against, so it grows past the viewport. */
    align-items:stretch;justify-content:stretch;
  }
  .boardwrap{grid-column:1;grid-row:1/-1;min-width:0;min-height:0;align-items:center}
  .players{grid-column:2;grid-row:1;flex-direction:column;gap:4px;justify-content:flex-start}
  /* min-width:0 — the desktop floor on a card is wider than this whole column, and the
     cards were being cut off at the right edge of the screen. */
  .pcard{padding:4px 8px;flex:0 0 auto;min-width:0;max-width:none}
  .pscore{padding:2px 0;justify-content:center;font-size:14px}
  .turnline{
    grid-column:2;grid-row:2;flex-direction:column;align-items:stretch;gap:4px;
    justify-content:flex-start;font-size:11.5px;
  }
  .turnright{flex-wrap:wrap;gap:4px}
  /* Two to a row, the main action alone on top. One per row stacked play, undo and
     resign past the bottom of a 390px-tall screen — the controls you need to finish a
     turn were below the fold of a page that does not scroll. */
  .actionbar{
    grid-column:2;grid-row:3;flex-direction:row;flex-wrap:wrap;align-content:flex-start;
    justify-content:stretch;margin-top:0;
    padding:0 0 var(--safe-b);background:none;gap:4px;min-height:0;
  }
  .actionbar button{flex:1 1 40%;min-width:0;min-height:40px;
    padding:5px 8px;font-size:12.5px}
  .actionbar button.primary{flex-basis:100%;min-width:0}
  /* Answering a double is a choice between equals: take and pass share one row, or the
     pair plus the board chrome ran off the bottom of the screen. */
  .actionbar #take{flex-basis:40%}
  /* A 390px-tall screen cannot hold three full-width levels plus "who" and "cancel";
     "a backgammon" and cancel were below the fold. Levels go side by side here. */
  dialog{padding:var(--s-3) var(--s-4);gap:var(--s-2)}
  dialog .hint{margin-top:0}
  #resignWho{margin-top:0}
  #resignDlg .row.stack{flex-direction:row;margin-top:0}
  #resignDlg .row.stack button{flex:1 1 0;min-width:0;width:auto;flex-direction:column;
    align-items:center;text-align:center;gap:2px;padding:6px 8px}
  #resignDlg .row.stack .dim{font-size:11px;display:block;white-space:nowrap}
  #resignDlg .row.stack .dim::before{content:none}
  .actionbar button.ghost,.actionbar button.ghost ~ button.ghost{margin-left:0;min-height:40px}
}

@media (min-width:900px){
  button.big{min-width:0;flex:0 0 auto}
}

/* Fingers, not cursors. The quiet secondary controls (fairness, log, resign, close) are
   sized for a mouse at 30-36px; on a touch screen every control gets a 40px target so a
   thumb hits the one it means. Measured by the Playwright invariants on phone and tablet. */
@media (pointer:coarse){
  .launch.mini,.chip,.actionbar button.ghost,.seek button,
  .launch,.setrow,.setrow select{min-height:var(--tap)}
  .chip.icon{min-width:var(--tap);display:inline-flex;align-items:center;justify-content:center}
  .launch.mini{padding:6px 14px}
}

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

/* ===================================================== standalone documents
   privacy.html / terms.html are plain pages served by the static handler, outside the
   app shell. The app's <body> is a locked, non-scrolling flex column so the board never
   moves; a document is the opposite and must scroll normally. */
body.doc{display:block;overflow:auto;height:auto;min-height:100dvh;background:var(--bg)}
.prose{max-width:64ch;margin:0 auto;padding:var(--s-5) var(--s-4) 18vh;
  font-size:15.5px;line-height:1.7;color:var(--ink)}
.prose h1{font-size:30px;margin:var(--s-4) 0 var(--s-3);letter-spacing:-.01em}
.prose h2{font-size:18px;margin:var(--s-6) 0 var(--s-2);color:var(--accent)}
.prose p{margin:0 0 var(--s-3)}
.prose ul{margin:0 0 var(--s-3);padding-left:1.25em}
.prose li{margin:0 0 var(--s-2)}
.prose a{color:var(--accent)}
.prose code{font:13px var(--mono);background:var(--surface);padding:2px 5px;
  border-radius:4px;border:1px solid var(--line)}
.prose .hint{color:var(--ink-dim);font-size:14px}
.prose strong{color:var(--ink);font-weight:600}

/* A destructive action that is rare and deliberate.
   `class="danger ghost"` did NOT work: `button.danger` is declared later with equal
   specificity, so it won and produced a solid red button — the loudest thing in a
   settings panel, for the one control nobody should click by accident. This follows the
   pattern already used for "end match": quiet until you go looking for it, unmistakably
   red once you are on it. */
button.destructive{
  background:transparent;border:1px solid var(--line);color:var(--ink-dim);
}
@media (hover:hover){button.destructive:hover{
  color:var(--bad);border-color:var(--bad);
  background:color-mix(in srgb,var(--bad) 8%,transparent);
}}

/* There was no global link rule, so the only links in the app shell — Privacy and Terms
   in the account panel — rendered in the browser's default blue/purple, the one colour
   that appears nowhere else in any skin. */
a{color:var(--accent);text-decoration-color:color-mix(in srgb,var(--accent) 45%,transparent);
  text-underline-offset:2px}
@media (hover:hover){a:hover{text-decoration-color:var(--accent)}}
/* Sections inside a dialog need air above them; `h2` only has margin below. */
dialog h2:not(:first-child){margin-top:var(--s-5)}
