:root {
  /* Mahjong is a deliberate, fixed-boundary exception to the portfolio's
     light/near-white Nordic-minimalist system (see THEME.md #0). Dark,
     warm, tactile shell. No prefers-color-scheme branch: dark-committed. */

  /* ---------- the table ---------- */
  --wood-hi:    #6d4726;
  --wood:       #43290f;
  --wood-lo:    #22150a;
  --wood-edge:  #100903;

  /* .table-frame's wooden bezel -- tokenized 2026-08-18 (was hardcoded
     literal hex inline on .table-frame's own background rule) so a
     table cloth ([data-cloth], §"cloths" below) can override it. Exact
     same three values that were literals, so the default look is
     byte-identical to before this change. */
  --bezel-hi:   #7a5230;
  --bezel:      #4d3016;
  --bezel-lo:   #2b1a0b;

  --felt-hi:    #245446;   /* lit centre of the cloth */
  --felt:       #17392f;
  --felt-lo:    #0a1d18;   /* vignette edge */

  /* jade's own weave -- billiard baize's fine, one-directional brushed
     nap. Consumed by the NEW .board-scroll::after layer (see "table
     cloths" below); .board-scroll::before is a separate, shared
     fractal-noise grain every cloth keeps regardless of which weave is
     active. Needs no [data-cloth="jade"] override block below for the
     same reason --felt-* and --wood-* don't -- these ARE jade's values. */
  --cloth-weave: repeating-linear-gradient(100deg,
    rgba(255,255,255,0.55) 0px, rgba(255,255,255,0.55) 1px,
    rgba(255,255,255,0)    1px, rgba(255,255,255,0)    3px);
  --cloth-weave-size:    auto;
  --cloth-weave-opacity: 0.07;
  --cloth-weave-repeat:  repeat;

  /* ---------- tile: bone / ivory face ---------- */
  --bone-hi:    #fffdf6;
  --bone:       #f6ecd4;
  --bone-lo:    #e6d5b0;
  --bone-shade: #9c8152;   /* the colour all bone-on-bone shading uses */

  /* .tile::before's recessed face panel -- tokenized 2026-08-18 (was
     hardcoded literal hex; that panel is essentially the whole visible
     face, so a tile-set theme that only overrode --bone/--bone-lo
     reached just a 3px rim). Exact same three values that were
     literals -- default look stays byte-identical. */
  --panel-hi:   #fbf3df;
  --panel:      #f3e7cb;
  --panel-lo:   #eaddbb;

  /* ---------- tile: bamboo backing = the extruded side ---------- */
  --side-1:     #e2cfa4;
  --side-2:     #cbb27f;
  --side-3:     #ab8e57;
  --side-cast:  rgba(10, 6, 2, 0.48);
  --tile-ambient: rgba(6, 4, 2, 0.34);  /* tokenized 2026-08-18 -- the tile's ambient drop shadow; sand cloth lightens this so tiles don't look like they're floating over a pale felt */

  /* ---------- ink & paint carved into the face ---------- */
  --ink:        #23201b;
  --vermilion:  #b53a2b;
  --jade:       #1c6d58;
  --jade-deep:  #12503f;
  --azure:      #2b5f86;
  --gold-leaf:  #ab8322;

  /* ---------- interactive ---------- */
  --brass:      #e0bd6d;
  --brass-lo:   #a8822f;
  --brass-hi:   #f6e3ab;
  --gold-glow:  rgba(224, 189, 109, 0.55);
  --jade-lit:   #4ed6a4;
  --jade-glow:  rgba(78, 214, 164, 0.50);
  --alert:      #e2603f;
  --alert-glow: rgba(226, 96, 63, 0.42);

  /* ---------- chrome (everything that is not a tile) ---------- */
  --chrome-ink:      #f3e9d5;
  --chrome-ink-soft: #c3b393;
  --chrome-ink-dim:  #8d7f66;
  --lacquer:         #1b120a;
  --lacquer-hi:      #2a1d11;
  --lacquer-lo:      #120b05;
  --hairline:        rgba(224, 189, 109, 0.20);
  --hairline-strong: rgba(224, 189, 109, 0.38);

  /* ---------- geometry ---------- */
  --tile-r:     7px;
  --face-inset: 3px;
  --panel-r:    16px;
  /* Vestigial as of 2026-08-20, and kept only so the declaration is not a
     lie for the one element still outside the board that sets it
     (.daily-tile). app.js republishes it on #board every render -- see
     sizeForBoard(), NOT the long-gone sizeForViewport() this comment used
     to name -- but the value it publishes is now 0, and the three
     extrusion shadows that consumed it are gone with the isometric offset
     (playtest #9a, flat top-down board). Depth is the drop-shadow pair on
     .tile and nothing else. */
  --z-shift:    6px;

  /* ---------- type ---------- */
  --font-display: "Iowan Old Style", "Palatino Linotype", Palatino,
                  "Book Antiqua", "Hoefler Text", Georgia,
                  "Times New Roman", serif;
  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text",
             "Segoe UI", Roboto, Inter, Helvetica, Arial, sans-serif;
  --font-cjk: "PingFang SC", "Hiragino Sans GB", "Heiti SC",
              "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC",
              "WenQuanYi Micro Hei", sans-serif;

  /* ---------- motion ---------- */
  --ease-out-soft: cubic-bezier(0.22, 0.90, 0.24, 1);
  --ease-press:    cubic-bezier(0.34, 1.30, 0.64, 1);
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  font-family: var(--font-ui);
  color: var(--chrome-ink);
  -webkit-font-smoothing: antialiased;
  background:
    radial-gradient(120% 80% at 50% -10%, rgba(255, 214, 150, 0.10) 0%,
                    rgba(255, 214, 150, 0) 60%),
    repeating-linear-gradient(96deg,
      rgba(0,0,0,0.16)     0px,  rgba(0,0,0,0.16)     2px,
      rgba(0,0,0,0)        2px,  rgba(0,0,0,0)        9px,
      rgba(255,238,200,0.035) 9px, rgba(255,238,200,0.035) 10px,
      rgba(0,0,0,0)       10px,  rgba(0,0,0,0)       23px),
    linear-gradient(168deg, var(--wood-hi) 0%, var(--wood) 34%,
                    var(--wood-lo) 78%, var(--wood-edge) 100%);
  background-attachment: scroll;   /* NOT fixed — janky on iOS Safari */
}

/* one static grain pass over everything, painted once, never animated */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  opacity: 0.055;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
}

.app-shell {
  position: relative;
  z-index: 1;
  max-width: 1080px;      /* was 860 — the board needs the room */
  margin: 0 auto;
  padding: 28px 20px 88px;
}

/* ===================== topbar / brand ===================== */

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 0 28px;
}

.brand { display: flex; align-items: baseline; gap: 10px; }

.brand .mark {                       /* a jade seal, not a blue app icon */
  width: 34px; height: 34px;
  border-radius: 8px;
  font-family: var(--font-cjk); font-weight: 700; font-size: 18px;
  color: #fff3d8;
  background: linear-gradient(150deg, #2a8368, #123f30);
  border: 1px solid var(--hairline-strong);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.24), 0 4px 10px rgba(0,0,0,.5);
  display: inline-flex; align-items: center; justify-content: center;
}
.brand .name {
  font-family: var(--font-display);
  font-size: 21px; font-weight: 600; letter-spacing: 0.02em;
  color: var(--chrome-ink);
}

/* ===================== panels ===================== */

.panel { display: none; }
.panel.active { display: block; animation: fade-in 0.15s ease-out; }

@keyframes fade-in {
  from { opacity: 0; transform: translateY(2px); }
  to { opacity: 1; transform: translateY(0); }
}

/* ===================== lacquer panels (§8.4) ===================== */

.card {
  position: relative;
  background: linear-gradient(180deg, #221710 0%, #150e08 100%);
  border: 1px solid var(--hairline);
  border-radius: var(--panel-r);
  padding: 24px;
  margin-bottom: 18px;
  box-shadow: 0 20px 44px rgba(0, 0, 0, 0.52),
              inset 0 1px 0 rgba(224, 189, 109, 0.12);
}
.card::before {                     /* brass rule along the top edge */
  content: "";
  position: absolute; left: 22px; right: 22px; top: 0; height: 1px;
  background: linear-gradient(90deg,
    transparent, var(--brass-lo) 22%, var(--brass) 50%, var(--brass-lo) 78%, transparent);
  opacity: 0.55;
}
.card h2 {
  font-family: var(--font-display);
  font-weight: 600; font-size: 19px;
  letter-spacing: 0.01em;
  color: var(--chrome-ink);
  margin: 0 0 4px;
}
.card .sub { color: var(--chrome-ink-soft); font-size: 13.5px; line-height: 1.55; margin: 0 0 16px; }

input, select {
  font-family: var(--font-ui); font-size: 14px;
  padding: 11px 13px; width: 100%;
  border-radius: 9px;
  color: var(--chrome-ink);
  background: rgba(0, 0, 0, 0.38);
  border: 1px solid var(--hairline);
}
input:focus, select:focus { outline: 2px solid var(--brass); outline-offset: 1px; }
input::placeholder { color: var(--chrome-ink-dim); }
label {
  display: block; margin: 14px 0 6px;
  font-size: 11px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.10em;
  color: var(--chrome-ink-dim);
}
/* 2026-08-19: the restore-key card (.receipt-box, .danger-disclosure,
   .key-disclosure and everything under them) was removed along with the
   server behind it -- mahjong web is fully local now (see
   ../PORT_SPEC.md). Nothing in the shipped page uses these selectors any
   more; removed rather than left dormant, since none of client/index.html
   is going to grow that card back. */

/* One or more short lines of monetization/storage-honesty copy above the
   banner, outside any card so each reads as a note rather than a card of
   its own. */
.ad-note {
  margin: 0 0 10px;
  padding: 0 4px;
  color: var(--chrome-ink-dim);
  font-size: 12.5px;
}

.state-block {
  border-radius: 10px;
  padding: 12px 14px;
  font-size: 13.5px;
  margin-top: 14px;
  border: 1px solid transparent;
}
.state-block.loading { background: rgba(224, 189, 109, 0.10); color: var(--brass); border-color: var(--hairline); }
.state-block.error   { background: rgba(226, 96, 63, 0.12); color: #f0a58e; border-color: rgba(226, 96, 63, 0.40); }
.state-block.success { background: rgba(78, 214, 164, 0.12); color: var(--jade-lit); border-color: rgba(78, 214, 164, 0.35); }

/* ===================== brand / price (§8.5) ===================== */

.pricing-hero { text-align: center; padding: 28px 16px 10px; }
.pricing-hero .headline {
  font-family: var(--font-display);
  font-size: 30px; font-weight: 600; line-height: 1.28; letter-spacing: 0;
  margin: 0 0 8px;
}
.pricing-hero .subhead { color: var(--chrome-ink-soft); font-size: 14px; margin: 0 auto 20px; max-width: 480px; line-height: 1.5; }

.price-tag {                          /* gold foil */
  font-family: var(--font-display);
  font-size: 44px; font-weight: 700; letter-spacing: -0.01em;
  color: var(--brass);                                  /* fallback first */
  background: linear-gradient(178deg, #f8e9b8 0%, #d5a94a 46%, #a8822f 62%, #f2dda2 100%);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent;
}
.price-tag .once {
  -webkit-text-fill-color: var(--chrome-ink-dim);
  font-family: var(--font-ui); font-size: 14px; font-weight: 600;
  letter-spacing: 0; margin-left: 8px;
}

.empty-hint { color: var(--chrome-ink-dim); font-size: 13.5px; padding: 10px 0; }

/* ===================== saved-game rows (§8.7) ===================== */

.game-row {
  display: flex; align-items: center; gap: 12px;
  padding: 12px 4px;
  border-top: 1px solid rgba(224, 189, 109, 0.12);
  font-size: 13.5px; color: var(--chrome-ink-soft);
}
.game-row:first-child { border-top: none; }
.game-row .thumb { width: 28px; height: 26px; flex: none; opacity: .8; }
.game-row .thumb svg { width: 100%; height: 100%; display: block; }
.game-row .title { font-family: var(--font-display); font-size: 15px; color: var(--chrome-ink); }
.game-row .meta { color: var(--chrome-ink-dim); }
.game-row .won-tag {
  color: #7ee0b6; font-weight: 700; font-size: 11px;
  text-transform: uppercase; letter-spacing: .09em;
}
.game-row button { margin-left: auto; }

.footer-note { text-align: center; color: var(--chrome-ink-dim); font-size: 11.5px; margin-top: 34px; line-height: 1.6; }

/* ===================== modals (incl. §7.7 win) ===================== */

.modal-backdrop {
  position: fixed; inset: 0;
  background: rgba(6, 14, 11, 0.74);
  backdrop-filter: blur(7px) saturate(0.9);
  -webkit-backdrop-filter: blur(7px) saturate(0.9);
  display: flex; align-items: center; justify-content: center;
  z-index: 50; padding: 20px;
}
.modal-backdrop.hidden { display: none; }
.modal {
  position: relative;
  background: linear-gradient(180deg, #221710 0%, #150e08 100%);
  border: 1px solid var(--hairline-strong);
  border-radius: var(--panel-r);
  padding: 26px 22px 22px;
  max-width: 400px; width: 100%;
  box-shadow: 0 30px 70px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(224, 189, 109, 0.14);
}
.modal::before {                     /* brass double rule */
  content: "";
  position: absolute; left: 22px; right: 22px; top: 0; height: 3px;
  background:
    linear-gradient(90deg, transparent, var(--brass-lo) 15%, var(--brass) 50%, var(--brass-lo) 85%, transparent) top / 100% 1px no-repeat,
    linear-gradient(90deg, transparent, var(--brass-lo) 15%, var(--brass) 50%, var(--brass-lo) 85%, transparent) bottom / 100% 1px no-repeat;
  opacity: 0.75;
}
.modal h3 {
  font-family: var(--font-display);
  margin: 6px 0 10px; font-size: 26px; font-weight: 600; color: var(--chrome-ink);
}
.modal p { margin: 0 0 20px; color: var(--chrome-ink-soft); font-size: 14px; line-height: 1.55; }
.modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; }

/* ===================== game screen toolbar (§8.2) ===================== */

.game-toolbar {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 10px 14px;
  margin-bottom: 12px;
  border-radius: 14px;
  border: 1px solid var(--hairline);
  background: linear-gradient(180deg, var(--lacquer-hi), var(--lacquer-lo));
  box-shadow:
    inset 0 1px 0 rgba(224, 189, 109, 0.26),
    inset 0 -1px 0 rgba(0, 0, 0, 0.65),
    0 8px 22px rgba(0, 0, 0, 0.45);
}
.game-actions { display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap; }

/* stat chips — tabular so the number doesn't jitter as it counts down */
.game-stats { display: flex; gap: 8px; flex-wrap: wrap; }
.game-stats span {
  font-variant-numeric: tabular-nums;
  font-size: 12px; font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--brass);
  padding: 5px 11px;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  background: rgba(0, 0, 0, 0.30);
  display: inline-block;
}
#stat-remaining.tick { animation: chip-tick 300ms var(--ease-press); }
@keyframes chip-tick {
  0% { transform: scale(1); }
  40% { transform: scale(1.16); color: var(--brass-hi); }
  100% { transform: scale(1); }
}

.move-state { min-height: 20px; font-size: 13px; color: var(--chrome-ink-soft); margin: 0 0 8px 4px; }
.move-state.error { color: #f0a58e; }
.move-state.info  { color: var(--brass); }

/* ===================== buttons (§8.3) ===================== */

button.secondary {
  font-family: var(--font-ui);
  text-transform: uppercase;
  letter-spacing: 0.09em;
  font-size: 11.5px;
  font-weight: 700;
  color: var(--brass);
  padding: 9px 15px;
  border-radius: 9px;
  border: 1px solid var(--hairline-strong);
  background: linear-gradient(180deg, #33240f, #1c1308);
  box-shadow: inset 0 1px 0 rgba(255, 222, 150, 0.18),
              0 3px 0 #0c0703, 0 6px 12px rgba(0, 0, 0, 0.40);
  cursor: pointer;
  transition: transform 0.08s var(--ease-press), filter 0.12s;
}
button.secondary:hover { filter: brightness(1.18); }
button.secondary:active {
  transform: translateY(3px);
  box-shadow: inset 0 1px 0 rgba(255, 222, 150, 0.10), 0 0 0 #0c0703;
}
button.secondary:disabled { opacity: 0.42; cursor: not-allowed; transform: none; }

button.primary {
  font-family: var(--font-ui);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 13px; font-weight: 800;
  color: #fff7e4;
  padding: 13px 22px;
  border-radius: 11px;
  border: 1px solid rgba(224, 189, 109, 0.45);
  background: linear-gradient(180deg, #2d8a6d 0%, #1a5c44 100%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.26),
              0 4px 0 #0b3325, 0 10px 22px rgba(0, 0, 0, 0.45);
  cursor: pointer;
  transition: transform 0.08s var(--ease-press), filter 0.12s;
}
button.primary:hover { filter: brightness(1.10); }
button.primary:active { transform: translateY(4px); box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 0 0 #0b3325; }
button.primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }

:focus-visible { outline: 2px solid var(--brass); outline-offset: 2px; border-radius: 4px; }

/* ===================== the table (§3.2) ===================== */

.table-frame {
  border-radius: 22px;
  padding: 12px;
  background:
    repeating-linear-gradient(88deg,
      rgba(0,0,0,0.20) 0px, rgba(0,0,0,0.20) 2px,
      rgba(0,0,0,0)    2px, rgba(0,0,0,0)    8px),
    linear-gradient(150deg, var(--bezel-hi) 0%, var(--bezel) 40%, var(--bezel-lo) 100%);
  box-shadow:
    inset 0 1px 0 rgba(255, 226, 180, 0.28),
    inset 0 -2px 3px rgba(0, 0, 0, 0.55),
    0 24px 50px rgba(0, 0, 0, 0.55),
    0 2px 0 rgba(255, 226, 180, 0.05);
}

.board-scroll {                     /* the felt */
  overflow: auto;
  border-radius: 13px;
  /* With --z-shift 0 on the board (playtest #9a) the constraint is no
     longer the extrusion but the ambient shadow's reach alone: 8px of
     offset + 14px of blur = 22px. 34px clears it on all four sides.
     Values left as they were -- the mobile override further down drops
     this to 14px, which clips roughly 8px off the bottom row's ambient
     against an already-vignetted felt edge. That is deliberate and worth
     it: the alternative is 20px less felt on the screen where felt is
     scarcest. */
  padding: 34px 34px 34px 34px;
  position: relative;
  background:
    radial-gradient(120% 90% at 42% 26%,
      var(--felt-hi) 0%, var(--felt) 46%, var(--felt-lo) 100%);
  box-shadow:
    inset 0 2px 10px rgba(0, 0, 0, 0.55),
    inset 0 0 90px rgba(0, 0, 0, 0.40);      /* vignette */
  scrollbar-width: thin;
  scrollbar-color: var(--brass-lo) transparent;
}

/* felt nap — finer, tighter noise than the wood */
.board-scroll::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0.10;
  mix-blend-mode: overlay;
  border-radius: inherit;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.4' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23f)'/%3E%3C/svg%3E");
}

/* cloth weave -- each material's actual woven/tiled texture, or, for
   black-lacquer, a glossy specular sweep instead of a weave at all.
   Tokenised per [data-cloth] (see "table cloths" below) so this ONE
   rule paints all six; ::before above is untouched and keeps doing the
   fine fractal-noise nap every cloth still shares underneath this. */
.board-scroll::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: inherit;
  mix-blend-mode: overlay;
  opacity: var(--cloth-weave-opacity);
  background-image: var(--cloth-weave);
  background-size: var(--cloth-weave-size);
  background-repeat: var(--cloth-weave-repeat);
  background-position: center;
}

/* z-index:1 (both texture layers above stay at the auto default) so
   the board and its 144 .tile children always paint ABOVE ::before AND
   ::after regardless of DOM order -- without it, ::after (generated
   AFTER #board in tree order, same z-index:auto stacking level) would
   paint on top of every tile instead of behind them as a felt layer
   should. Each tile's own inline z-index (see app.js's renderBoard(),
   "10 + t.z * 10") keeps ordering tiles among THEMSELVES unaffected --
   that comparison happens inside #board's own stacking context now. */
.board { position: relative; z-index: 1; margin: 0 auto; }

/* ===================== the tile — carved body (§4) ===================== */

.tile {
  position: absolute;
  width: var(--tile-w);
  height: var(--tile-h);
  border-radius: var(--tile-r);
  border: 0;
  cursor: default;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  background:
    linear-gradient(158deg,
      var(--bone-hi) 0%, var(--bone) 44%, var(--bone-lo) 100%);
  box-shadow:
    /* --- the bamboo backing, 1px and only 1px. This is the tile's own
           BODY seen edge-on, not a stack offset, which is why it survives
           the flat projection while the three --z-shift-derived shadows
           that used to follow it (two extrusion steps and a contact cast,
           all offset down-left by the layer depth) did not -- playtest
           #9a. The four convex rim insets that used to head this list
           moved onto .tile::before below, which now paints over them. --- */
    -1px 1px 0 var(--side-1),
    /* --- depth is carried by this pair and by nothing else. Contact
           first: tight, dark, close in -- it says the tile is resting ON
           the felt. Ambient second: soft and wide -- it says the tile is
           above it. Together they read as a slab lying on cloth, which is
           the whole job the isometric offset used to share and did badly,
           because the offset also dragged every stacked tile sideways
           over its neighbours.
           The ambient was 0 12px 20px. 12px of drop floats badly at
           TILE_W_MIN 22, where the whole tile is only 30px tall -- the
           shadow was reading as a separate object below the tile rather
           than as the tile's own. 8/14 holds the same reading across
           every size the fit solver can produce.
           IF A TILE EVER READS AS A STICKER RATHER THAN A SLAB, DEEPEN
           THIS PAIR -- the projection is meant to be flat, the tile is
           not. Never reintroduce a lateral offset to buy depth back: a
           lateral offset is what buried the neighbouring tiles in the
           first place. --- */
    0 2px 3px var(--side-cast),
    0 8px 14px var(--tile-ambient);
  transition: transform 0.13s var(--ease-out-soft),
              filter 0.13s ease-out;
}

/* layer 3 — the face, and now the whole tile. It used to be a panel inset
   by --face-inset, leaving a rim of bare .tile background around it; that
   ring is exactly what the first outside player reported as "an odd
   border around tiles, a gap between the tile border and the tile"
   (playtest #7). There is no gap any more, because there is no panel any
   more: the panel IS the tile.
   Two consequences, both load-bearing:
     - full-bleed means it paints OVER .tile's own convex rim, so that rim
       moved here verbatim. Without the move a tile reads as a recessed
       dish instead of a carved tile, which is worse than the gap was.
     - convex (light top-left, shade bottom-right) replaces the old
       concave mirror. The mirror only made sense as the inside of an
       inset panel; with nothing outside it to be the mirror OF, it read
       as a dent. This is the direction [data-tileset="slate-ink"]'s
       ::before override further down has used all along.
   --face-inset is deliberately NOT zeroed at the token: the lobby's
   .tile-preview swatches still consume it, and zeroing it there would
   reflow the pickers. Board tiles only. */
.tile::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--tile-r);   /* at inset 0 the old calc(... - 2px) would clip the rim off */
  background: linear-gradient(160deg, var(--panel-hi) 0%, var(--panel) 58%, var(--panel-lo) 100%);
  box-shadow:
    inset  0  1px 0 rgba(255, 255, 255, 0.95),
    inset  1px 0  0 rgba(255, 255, 255, 0.62),
    inset  0 -1px 0 rgba(156, 129, 82, 0.50),
    inset -1px 0  0 rgba(156, 129, 82, 0.34);
}

/* layer 5 — state ring. Transparent and inert at rest. Only its OPACITY and
   its two custom-property colours ever change; the box-shadow geometry is
   constant, which is what keeps 144 of these cheap. */
.tile::after {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: calc(var(--tile-r) + 3px);
  pointer-events: none;
  opacity: 0;
  box-shadow:
    0 0 0 2px var(--ring, transparent),
    0 0 18px 3px var(--halo, transparent);
  transition: opacity 0.14s ease-out, box-shadow 0.14s ease-out;
}

/* ===================== tile faces (§5) ===================== */

/* inset 0 to match .tile::before above -- the face fills the tile, with
   no rim gap (playtest #7). .tile-preview .face keeps its own 3px, since
   a 32x44 swatch still wants the miniature-panel reading. */
.tile > .face {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.face-svg {
  width: 100%; height: 100%;
  /* engraved: a 1px light shadow BELOW dark paint = carved into the bone */
  filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.85));
}

.face-text {
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: 1px;
  font-family: var(--font-cjk);
  font-weight: 600;
  line-height: 1;
  color: var(--ink);
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.85);   /* engrave */
}
.face-text .t-top  { font-size: calc(var(--tile-h) * 0.30); }
.face-text .t-main { font-size: calc(var(--tile-h) * 0.36); }
.face-text.single .t-main { font-size: calc(var(--tile-h) * 0.50); }
.face-idx {
  position: absolute; top: 2px; left: 4px;
  font-family: var(--font-ui); font-weight: 700;
  font-size: calc(var(--tile-h) * 0.16);
  color: var(--chrome-ink-dim); opacity: 0.8;
  text-shadow: none;
}

/* dragon_white — the traditional blank frame, drawn not written */
.dragon-frame { position: absolute; inset: 0; }
.dragon-frame::before {
  content: "";
  position: absolute; inset: 14% 18%;
  border: 1.5px solid var(--azure); border-radius: 2px;
}
.dragon-frame i {
  position: absolute; inset: 22% 26%;
  border: 1.5px solid var(--azure); border-radius: 2px;
  font-style: normal;
}

/* "pips" theme — geometric/latin faces, colour never load-bearing alone */
.face-pips { flex-direction: column; gap: 2px; }
.pip-bar {
  position: absolute; top: 0; left: 14%; right: 14%; height: 4px;
  border-radius: 0 0 2px 2px;
}
.pip-num {
  font-family: var(--font-display); font-weight: 700; font-style: normal;
  font-size: calc(var(--tile-h) * 0.46); line-height: 1;
  color: var(--ink);
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.85);
}
.pip-tag {
  font-family: var(--font-ui); font-weight: 800; font-style: normal;
  letter-spacing: .06em;
  font-size: calc(var(--tile-h) * 0.15);
  color: var(--chrome-ink-dim);
}

/* "ink" theme — same geometry, monochrome sumi-e paint */
[data-tileset="ink"] {
  --vermilion: #3a352e;
  --jade: #3a352e;
  --azure: #3a352e;
  --gold-leaf: #3a352e;
  --bone: #f7f3e8;
  --bone-lo: #e8e1cf;
  /* A live regression, found while making the face full-bleed above.
     This set only ever overrode --bone/--bone-lo, which was enough while
     the visible face was mostly .tile's own background and the panel was
     a small inset rectangle. Now .tile::before IS the whole face, so an
     ink tile painted the DEFAULT ivory --panel-* and the set's own paler
     body simply disappeared. Same three values as its --bone* above, one
     step apart, so ink reads exactly as it always did -- this restores
     the old look, it does not restyle the set. It is also the same trap
     the --panel-* tokenisation comment at the top of this file warns
     about, sprung a second time by a different change. */
  --panel-hi: #fdfbf5;
  --panel: #f7f3e8;
  --panel-lo: #e8e1cf;
}

/* "slate-ink" theme — the first non-ivory tile body (P2.2). Same
   classicFaceHtml() geometry as classic/ink; every visual difference is
   these custom properties, resolved through the now-tokenized --bone/
   --panel/--side chain above. Pairs with the "sand" table cloth, whose
   pale felt fails contrast under the classic ivory body (see [data-cloth
   ="sand"] below). */
[data-tileset="slate-ink"] {
  --bone-hi: #6d6a63; --bone: #545049; --bone-lo: #3e3a34; --bone-shade: #23201b;
  --panel-hi: #5a564e; --panel: #4a463f; --panel-lo: #3a3630;
  --side-1: #4a463f; --side-2: #3b3831; --side-3: #2a2722;
  --ink: #f2ece0; --vermilion: #e08a6f; --jade: #6fc9a8; --jade-deep: #3f8a72; --azure: #86b4d8; --gold-leaf: #d9be74;
}
/* Engrave direction inverts on a dark body -- a carved face reads as
   light-catches-the-groove; on ivory that's a light top-left edge /
   dark bottom-right edge, and on slate it has to be the reverse or the
   face reads as embossed rubber instead of engraved stone. */
[data-tileset="slate-ink"] .face-svg {
  filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.55));
}
[data-tileset="slate-ink"] .face-text,
[data-tileset="slate-ink"] .pip-num {
  text-shadow: 0 1px 0 rgba(0, 0, 0, 0.55);
}
/* Three selector shapes: real board tiles carry data-tileset on the
   ancestor #board (descendant combinator reaches .tile::before);
   picker/preview swatches AND the daily card's own tile carry it
   directly on themselves (.tile-preview / .daily-tile), so their OWN
   ::before needs the attribute-selector form instead -- a descendant
   combinator never matches an attribute and a class on the identical
   element. */
[data-tileset="slate-ink"] .tile::before,
.tile-preview[data-tileset="slate-ink"]::before,
.daily-tile[data-tileset="slate-ink"]::before {
  box-shadow:
    inset 0  1.5px 2px rgba(255, 255, 255, 0.22),
    inset 1.5px 0  2px rgba(255, 255, 255, 0.22),
    inset 0 -1px 0 rgba(0, 0, 0, 0.55),
    inset -1px 0 0 rgba(0, 0, 0, 0.55);
}

/* "porcelain" theme — blue-and-white ware. No JS/geometry change at
   all: faces.js's coin() already draws its ring in var(--jade), middle
   in var(--bone), pip in var(--vermilion) -- pointing all three at one
   deep-blue value turns a coin into a blue ring on white automatically,
   the same mechanism "ink" already uses. */
[data-tileset="porcelain"] {
  --bone-hi: #ffffff; --bone: #f4f8fb; --bone-lo: #dfe8f0;
  --panel-hi: #fbfcfe; --panel: #f2f6fa; --panel-lo: #e2eaf2;
  --ink: #1d3f66; --vermilion: #1d3f66; --jade: #2f6fa8; --jade-deep: #16314f; --azure: #1d3f66; --gold-leaf: #2f6fa8;
}

/* ===================== table cloths (P2.2) =====================
   The lacquer score box's own rail — six felt/bezel/wood/weave token
   sets on [data-cloth], set on <body> to switch the real table and the
   page wood around it, and set locally on .cloth-swatch (see above) so
   each picker card can preview its OWN cloth regardless of which is
   active. jade is the default/current look and needs no override block
   here for --felt-*, --wood-*, or --cloth-weave* -- its values already
   ARE the :root ones above, byte-identical.

   Every felt trio preserves jade's value structure (lit centre ~18-22%
   brighter than mid, vignette edge ~22-26% darker) so .board-scroll's
   existing radial-gradient and inset shadows work unmodified on all six;
   hue is the only variable, each drawn from a real table covering (baize
   / linen / damask / flannel / cotton / lacquer) rather than a brand
   palette.

   --wood-* is now independently art-directed per cloth to an actual
   wood species that PAIRS with that covering (walnut under indigo linen,
   rosewood under oxblood damask, weathered oak under slate flannel, pale
   ash under sand cotton, ebony under black-lacquer) instead of the OLD
   rule of "track this cloth's own --bezel-* one step darker" -- that
   mechanical derivation produced five wood tones that were really just
   the bezel again, dimmed, and is gone; every --wood-* quad below is
   its own deliberate choice, independent of that cloth's --bezel-*.

   --cloth-weave/--cloth-weave-size/--cloth-weave-opacity/--cloth-weave-
   repeat drive the .board-scroll::after layer (above, painted BEHIND
   the tiles -- see .board's z-index) with each material's actual
   woven/tiled texture -- crosshatch, brocade lattice, twill diagonal,
   check -- or, for black-lacquer, a glossy specular sweep instead of a
   weave at all. .board-scroll::before is untouched and keeps painting
   the fine fractal-noise nap every cloth still shares underneath this;
   without this second layer all six cloths were really one flat radial
   gradient in six hues, which is the defect this section now fixes. */
/* jade DOES need its own block, despite being the :root default (fixed
   2026-08-19). The picker sets [data-cloth] locally on each .cloth-swatch
   so every card previews its OWN cloth -- but a local attribute can only
   RESTORE a token if there is a rule to restore it from. jade had none, so
   whenever the ACTIVE cloth was anything else, body[data-cloth="sand"] (or
   indigo, or...) overrode the tokens on an ancestor and jade's swatch
   inherited them: pick sand, and the jade card renders sand. Every other
   cloth was immune purely because it had an override block of its own.
   These values are byte-identical to :root's -- the duplication is the
   fix, not an oversight. */
[data-cloth="jade"] {
  --felt-hi: #245446; --felt: #17392f; --felt-lo: #0a1d18;
  --bezel-hi: #7a5230; --bezel: #4d3016; --bezel-lo: #2b1a0b;
  --wood-hi: #6d4726; --wood: #43290f; --wood-lo: #22150a; --wood-edge: #100903;
  --cloth-weave: repeating-linear-gradient(100deg,
    rgba(255,255,255,0.55) 0px, rgba(255,255,255,0.55) 1px,
    rgba(0,0,0,0) 1px, rgba(0,0,0,0) 3px);
  --cloth-weave-size: auto;
  --cloth-weave-opacity: 0.07;
  --cloth-weave-repeat: repeat;
}
[data-cloth="indigo"] {
  --felt-hi: #2b4a6e; --felt: #1a2f49; --felt-lo: #0b1626;
  --bezel-hi: #6d4a2c; --bezel: #432b13; --bezel-lo: #241407;
  /* walnut -- cooler and more neutral than jade's mahogany, paired with
     linen's own cool cast rather than a dimmed copy of --bezel-* above */
  --wood-hi: #5a4a3c; --wood: #362c22; --wood-lo: #1c1712; --wood-edge: #0d0a08;
  /* linen's visible crosshatch -- two perpendicular thread sets, crisp
     1px lines on a 7px repeat */
  --cloth-weave:
    repeating-linear-gradient(0deg,  rgba(255,255,255,0.45) 0px, rgba(255,255,255,0.45) 1px, transparent 1px, transparent 7px),
    repeating-linear-gradient(90deg, rgba(255,255,255,0.45) 0px, rgba(255,255,255,0.45) 1px, transparent 1px, transparent 7px);
  --cloth-weave-size: 7px 7px;
  --cloth-weave-opacity: 0.16;
  --cloth-weave-repeat: repeat;
}
[data-cloth="oxblood"] {
  --felt-hi: #6a2f2c; --felt: #451d1c; --felt-lo: #210c0c;
  --bezel-hi: #6b4526; --bezel: #40270f; --bezel-lo: #211205;
  /* rosewood -- deeper and redder than jade's mahogany, paired with
     oxblood damask rather than a dimmed copy of --bezel-* above */
  --wood-hi: #6b3a2c; --wood: #40231a; --wood-lo: #21120d; --wood-edge: #100907;
  /* damask's repeating brocade motif -- an ogee diamond lattice with a
     centre medallion, the classic woven-panel repeat, as an inline SVG
     tile (a stacked-gradient lattice can't get a real motif in the
     centre the way a drawn path can) */
  --cloth-weave: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Cg fill='none' stroke='white' stroke-width='1'%3E%3Cpath d='M20 2 L38 20 L20 38 L2 20 Z'/%3E%3Ccircle cx='20' cy='20' r='4' fill='white' stroke='none'/%3E%3C/g%3E%3C/svg%3E");
  --cloth-weave-size: 40px 40px;
  --cloth-weave-opacity: 0.12;
  --cloth-weave-repeat: repeat;
}
[data-cloth="slate"] {
  --felt-hi: #3f474c; --felt: #282e32; --felt-lo: #12161a;
  --bezel-hi: #5c5148; --bezel: #38302a; --bezel-lo: #1b1714;
  /* weathered oak -- cool and grayed instead of jade's warm mahogany,
     paired with flannel's own heathered gray rather than a dimmed copy
     of --bezel-* above */
  --wood-hi: #5f594c; --wood: #39362d; --wood-lo: #1e1c17; --wood-edge: #0f0e0b;
  /* flannel's heathered twill -- a 45deg diagonal ridge that alternates
     light AND dark bands (not just tone-on-tone like jade's nap), which
     is what reads as a woven diagonal instead of a flat brushed one */
  --cloth-weave: repeating-linear-gradient(45deg,
    rgba(255,255,255,0.42) 0px, rgba(255,255,255,0.42) 2px,
    rgba(255,255,255,0)    2px, rgba(255,255,255,0)    5px,
    rgba(0,0,0,0.30)       5px, rgba(0,0,0,0.30)       7px,
    rgba(0,0,0,0)          7px, rgba(0,0,0,0)          10px);
  --cloth-weave-size: auto;
  --cloth-weave-opacity: 0.11;
  --cloth-weave-repeat: repeat;
}
/* sand — the light, high-contrast table (P2.2's accessibility item).
   Two non-optional consequences of a pale felt, not just a colour swap:
   (a) the near-black --side-cast/--tile-ambient shadows below are
   invisible against it and the tiles read as floating, so both lighten
   here; (b) the classic ivory face measures ~1.43:1 against this felt
   (fails the ≥3:1 acceptance bar badly) -- app.js's cloth picker auto-
   switches the tile set to slate-ink whenever sand is selected under
   classic/pips/ink, which clears ~5.6:1 / ~8:1. Sand never ships under
   the ivory body. Neither of those is touched below. */
[data-cloth="sand"] {
  --felt-hi: #e9dcbe; --felt: #d8c69f; --felt-lo: #b9a377;
  --bezel-hi: #a2764a; --bezel: #6d4a28; --bezel-lo: #3d2814;
  /* pale ash -- the lightest of the six, instead of the OLD rule
     ("track this cloth's own --bezel-* one step darker"), which would
     still land on a mid-brown here -- wrong for a pale cotton's frame */
  --wood-hi: #8a7050; --wood: #5c4a34; --wood-lo: #362c1f; --wood-edge: #1a150e;
  --side-cast: rgba(56, 34, 10, 0.42);
  --tile-ambient: rgba(70, 45, 18, 0.30);
  /* cotton's soft check -- wider, softer bands than linen's crisp 1px
     crosshatch (9px solid bands, not hairlines), and dark-tinted rather
     than the white every other cloth uses: this is the one light felt,
     and mix-blend-mode: overlay barely shows white-on-light -- a dark
     tint is what stays visible against sand specifically. */
  --cloth-weave:
    repeating-linear-gradient(0deg,  rgba(94, 64, 28, 0.16) 0px, rgba(94, 64, 28, 0.16) 9px, transparent 9px, transparent 18px),
    repeating-linear-gradient(90deg, rgba(94, 64, 28, 0.16) 0px, rgba(94, 64, 28, 0.16) 9px, transparent 9px, transparent 18px);
  --cloth-weave-size: 18px 18px;
  --cloth-weave-opacity: 0.5;
  --cloth-weave-repeat: repeat;
}
[data-cloth="black-lacquer"] {
  --felt-hi: #2a2622; --felt: #191614; --felt-lo: #080706;
  --bezel-hi: #4e3a22; --bezel: #2c1f11; --bezel-lo: #150d05;
  /* ebony -- near-black and desaturated, instead of the OLD rule
     ("track this cloth's own --bezel-* one step darker"), which would
     still land on a warm dim brass-brown here -- wrong for ebony */
  --wood-hi: #2e2a26; --wood: #1a1714; --wood-lo: #0d0b09; --wood-edge: #050403;
  /* not cloth at all -- a single glossy specular sweep instead of a
     tiled weave, hence no-repeat and a background-size close to the
     panel's own footprint instead of a small repeating tile */
  --cloth-weave: linear-gradient(115deg,
    rgba(255,255,255,0)    22%,
    rgba(255,255,255,0.10) 40%,
    rgba(255,255,255,0.55) 49%,
    rgba(255,255,255,0.10) 58%,
    rgba(255,255,255,0)    76%);
  --cloth-weave-size: 180% 180%;
  --cloth-weave-opacity: 0.5;
  --cloth-weave-repeat: no-repeat;
}

/* ===================== tile states (§6) ===================== */

/* covered/blocked: darkened, NEVER translucent. Opacity < 1 on a stacked
   tile lets the tile beneath bleed through and destroys the whole illusion.
   This is the #1 fix in this document. */
.tile.covered,
.tile.blocked {
  filter: brightness(0.80) saturate(0.86) contrast(0.96);
  cursor: default;
}
/* ...and it carries no readable face detail either (§9b). Dimming alone
   left 144 fully-drawn faces competing for attention, so a player had to
   read glyphs before they could even tell which tiles were in play. Now
   an unavailable tile is a plain suppressed slab and the eye sorts
   playable from unplayable before it reads anything at all.
   Suppress the FACE, never the TILE. What sits underneath .face is the
   tile's OWN opaque panel, which is why 0.18 is safe here and would be
   catastrophic on .tile itself -- see the note above this block, the #1
   fix in this document.
   0.18 and not 0: it leaves a ghost for a player leaning in to plan two
   moves ahead, while carrying no contrast whatever at board-scanning
   distance, which is the mechanic. It is one number from 0 if the next
   playtest says go further.
   The aria-label is untouched. The tile is still announced; it is just
   not painted. */
.tile.covered > .face,
.tile.blocked > .face {
  opacity: 0.18;
  filter: grayscale(0.85);
}

/* free: playable. A permanent faint brass rim so the player can scan the
   board for legal moves without hovering. */
.tile.free { cursor: pointer; --ring: rgba(224, 189, 109, 0.30); --halo: transparent; }
.tile.free::after { opacity: 1; }

.tile.free:hover {
  transform: translate3d(1px, -3px, 0);
  filter: brightness(1.05);
  --ring: rgba(224, 189, 109, 0.62);
  --halo: rgba(224, 189, 109, 0.22);
}
.tile.free:active { transform: translate3d(0, 1px, 0); filter: brightness(0.97); }

/* --- wildcards: the flowers and the seasons (§9d, playtest #4) ---------
   Any flower matches any OTHER flower, and any season any other season
   (board.js matchGroupOf -- these ARE the "jokers" the tester meant).
   That is a real rule of the game which reads as a bug to a new player,
   because nothing on the tile said so. It does now: a free wildcard is a
   single loud brass tile in a field of 144 bone ones, and there is no
   version of "I didn't notice it" that survives that.
   SCOPED TO .free ON PURPOSE. A covered wildcard stays a suppressed slab
   like everything else, so this can never fight §9b by advertising a tile
   you are not allowed to play -- and a wildcard BECOMING free turns into
   a visible event on the board, which is worth more than a constant one.
   The cue is lightness AND chroma (brass sits around L* 78 against bone's
   L* 93), not colour alone, so it survives greyscale and colour-blind
   vision the same way the pips tile set's bars do.
   No gloss and no borrowed gradient: this is the same 160deg ramp every
   other face uses, pointed at the brass tokens already in :root. */
#board .tile.free[data-group="flower"]::before,
#board .tile.free[data-group="season"]::before {
  background: linear-gradient(160deg, var(--brass-hi) 0%, var(--brass) 54%, var(--brass-lo) 100%);
}
/* The !important is load-bearing, not laziness. faces.js writes
   style="color:var(--gold-leaf)" INLINE on flower_3 and season_3, and
   gold leaf on brass measures about 1.6:1 -- unreadable, and precisely on
   the tile this change is trying to make unmissable. An inline style can
   only be beaten from the sheet with !important, and faces.js is outside
   this change's file scope. Lacquer on brass is about 7:1. */
#board .tile.free[data-group="flower"] .face-text,
#board .tile.free[data-group="season"] .face-text {
  color: var(--lacquer) !important;
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45);
}
/* The little corner number rides along, at the same relative weight it
   has on bone -- present for a player counting the set, never louder
   than the glyph. */
#board .tile.free[data-group="flower"] .face-idx,
#board .tile.free[data-group="season"] .face-idx {
  color: var(--lacquer);
  opacity: 0.55;
}

/* --- selection -------------------------------------------------------- */
.tile.selected {
  transform: translate3d(2px, -5px, 0) scale(1.035);
  --ring: var(--brass);
  --halo: var(--gold-glow);
}
.tile.selected::after { opacity: 1; }

/* --- pair preview: hovering a second tile while one is selected -------- */
.tile.pairable {
  transform: translate3d(1px, -4px, 0) scale(1.03);
  --ring: var(--jade-lit);
  --halo: var(--jade-glow);
}
.tile.pairable::after { opacity: 1; }

.tile.nonpairable {
  filter: brightness(0.88) saturate(0.7);
  --ring: rgba(226, 96, 63, 0.70);
  --halo: var(--alert-glow);
}
.tile.nonpairable::after { opacity: 1; }

/* --- hint --------------------------------------------------------------
   The one state that gets its OWN ring geometry rather than the shared
   2px above. Two separate things were wrong when a real player pressed
   Hint (playtest #3): 2px of brass is not findable on a 144-tile board at
   a glance, and brass-on-bone vanishes exactly where a hinted tile abuts
   a bright neighbour -- which is the common case, not the edge case,
   because hints point at free tiles and free tiles sit on top of the
   stack surrounded by other free tiles. So the ring is four times as
   thick AND carries a dark lacquer contour outside it, which holds
   against a neighbour of any lightness. Brightening the brass alone would
   have fixed only the first half.
   The shared .tile::after geometry (inset -3px, radius +3px, 2px/18px/3px)
   is deliberately untouched: free, selected, pairable, nonpairable and
   shake all draw from that one box-shadow, and thickening it would
   thicken every state on the board at once. */
.tile.hinted { --ring: var(--brass-hi); --halo: var(--gold-glow); }
.tile.hinted::after {
  opacity: 1;
  box-shadow:
    0 0 0 4px var(--brass-hi),
    0 0 0 6px rgba(27, 18, 10, 0.55),
    0 0 26px 6px var(--gold-glow);
  animation: halo-breathe 1.25s ease-in-out infinite;
}

@keyframes halo-breathe {
  0%, 100% { opacity: 0.35; }
  50%      { opacity: 1; }
}

/* --- removed: the node is gone; the FX layer plays the animation ------- */
.tile.removed { display: none; }

/* --- mismatch shake (§7.3) --------------------------------------------- */
.tile.shake { animation: tile-shake 340ms cubic-bezier(.36,.07,.19,.97); }
.tile.shake { --ring: var(--alert); --halo: var(--alert-glow); }
.tile.shake::after { opacity: 1; }

@keyframes tile-shake {
  0%, 100% { transform: translate3d(0,0,0); }
  14% { transform: translate3d(-5px,0,0) rotate(-1.5deg); }
  30% { transform: translate3d(5px,0,0)  rotate(1.5deg); }
  46% { transform: translate3d(-3.5px,0,0) rotate(-1deg); }
  62% { transform: translate3d(3.5px,0,0)  rotate(1deg); }
  80% { transform: translate3d(-1.5px,0,0); }
}

/* --- hint sheen sweep (§7.4) -------------------------------------------- */
.tile .sheen {
  position: absolute; inset: 0;
  border-radius: inherit;
  overflow: hidden;
  pointer-events: none;
  z-index: 2;
}
.tile .sheen i {
  position: absolute; top: -20%; left: -60%;
  width: 45%; height: 140%;
  background: linear-gradient(100deg,
    rgba(255,255,255,0) 0%, rgba(255,243,210,0.85) 50%, rgba(255,255,255,0) 100%);
  transform: skewX(-18deg);
  animation: sheen-sweep 720ms ease-out forwards;
}
@keyframes sheen-sweep {
  0%   { transform: translateX(0)    skewX(-18deg); opacity: 0; }
  18%  { opacity: 1; }
  100% { transform: translateX(340%) skewX(-18deg); opacity: 0; }
}

/* --- deal / shuffle stagger (§7.5) -------------------------------------- */
.board.dealing .tile {
  animation: tile-deal 340ms var(--ease-out-soft) backwards;
  animation-delay: calc(var(--i) * 3ms);
}
@keyframes tile-deal {
  from { opacity: 0; transform: translate3d(0, -18px, 0) scale(0.90); }
  to   { opacity: 1; transform: none; }
}

/* --- undo restore (§7.6) ------------------------------------------------ */
.tile.restored { animation: tile-restore 400ms var(--ease-press); }
@keyframes tile-restore {
  0%   { transform: translate3d(0,-16px,0) scale(1.25); filter: brightness(1.5); opacity: 0; }
  55%  { opacity: 1; }
  100% { transform: none; filter: none; opacity: 1; }
}

/* ===================== FX layer (§7.1, §7.2) ===================== */

#fx-layer {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 60;
  overflow: hidden;
}

.fx-ghost {
  position: absolute;
  animation: ghost-clear 460ms var(--ease-out-soft) forwards;
}
.fx-ghost.b { animation-delay: 55ms; }   /* the second tile lags — reads as a pair, not a twin */

@keyframes ghost-clear {
  0%   { transform: translate3d(0,0,0)     scale(1)    rotate(0deg);   opacity: 1; filter: none; }
  20%  { transform: translate3d(0,-10px,0) scale(1.13) rotate(-2deg);  opacity: 1;
         filter: brightness(1.3) saturate(1.25); }
  48%  { transform: translate3d(0,-6px,0)  scale(1.02) rotate(1.5deg); opacity: 0.98; }
  100% { transform: translate3d(0,14px,0)  scale(0.26) rotate(7deg);   opacity: 0; }
}

/* the burst: one expanding brass ring + 10 sparks */
.fx-burst { position: absolute; width: 0; height: 0; }
.fx-burst .ring {
  position: absolute; left: -34px; top: -34px; width: 68px; height: 68px;
  border-radius: 50%;
  border: 2px solid var(--brass);
  box-shadow: 0 0 22px 4px var(--gold-glow), inset 0 0 14px var(--gold-glow);
  animation: fx-ring 520ms var(--ease-out-soft) forwards;
}
@keyframes fx-ring {
  0%   { transform: scale(0.30); opacity: 0.95; }
  100% { transform: scale(2.20); opacity: 0; }
}
.fx-burst i {
  position: absolute; left: -2px; top: -2px;
  width: 4px; height: 8px; border-radius: 2px;
  background: linear-gradient(180deg, var(--brass-hi), var(--brass-lo));
  animation: fx-spark 560ms var(--ease-out-soft) forwards;
}
@keyframes fx-spark {
  0%   { transform: rotate(calc(var(--a) * 1deg)) translateY(0)     scale(1);   opacity: 1; }
  100% { transform: rotate(calc(var(--a) * 1deg)) translateY(-52px) scale(0.2); opacity: 0; }
}

/* ===================== board-shape / tile-set pickers (§8.6) ===================== */

.shape-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
@media (min-width: 720px) { .shape-grid { grid-template-columns: repeat(6, 1fr); } }
/* theme-grid: 5 tile sets (was 3) -- 3/6-col from the base rule above no
   longer balances, so this gets its own 2/5-col breakpoint instead. */
.shape-grid.theme-grid { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 560px) { .shape-grid.theme-grid { grid-template-columns: repeat(5, 1fr); } }
/* cloth-grid: 6 cloths still divide evenly into the base rule's own 3/6
   columns (unlike theme-grid's 5, no orphan-card remainder to solve),
   so this keeps THOSE column counts -- what it borrows from theme-grid
   is only the 560px breakpoint. At the base rule's own 720px, cloth-grid
   would still be sitting at 3 columns for 160px longer than the
   tile-set row above it already sits at 5 -- short, wide cloth cards
   directly beside tall, narrow tile cards in that gap (confirmed live:
   exactly the width the "600px" acceptance check exists to catch). */
.shape-grid.cloth-grid { grid-template-columns: repeat(3, 1fr); }
@media (min-width: 560px) { .shape-grid.cloth-grid { grid-template-columns: repeat(6, 1fr); } }

.shape-card {
  padding: 12px 8px 10px;
  border-radius: 12px;
  border: 1px solid var(--hairline);
  background: rgba(0, 0, 0, 0.32);
  text-align: center; cursor: pointer;
  transition: transform .12s var(--ease-out-soft), border-color .12s, background .12s;
}
.shape-card:hover { transform: translateY(-2px); border-color: var(--hairline-strong); }
.shape-card[aria-pressed="true"] {
  border-color: var(--brass);
  background: rgba(224, 189, 109, 0.10);
  box-shadow: 0 0 0 1px var(--brass), 0 0 18px rgba(224,189,109,.20);
}
.shape-card svg { width: 46px; height: 42px; display: block; margin: 0 auto 7px; }
.shape-card span {
  font-size: 10.5px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--chrome-ink-soft);
}

/* tile-set picker: a live mini tile face instead of a shape silhouette.
   display:block fixes a latent defect found while wiring the two new
   sets in: a bare <span> ignores explicit width/height per CSS (non-
   replaced inline elements), so every swatch --- all 3 existing sets,
   not just the 2 new ones --- rendered at 0x12px, invisible, before this
   line existed (confirmed empirically; the existing margin:0 auto 7px
   below only makes sense on a block-level box, which is the tell that
   this was always the intent). ::before mirrors .tile::before's
   recessed panel so the swatch is a structurally accurate miniature of
   a real tile (rim + dominant inset panel), not just a flat --bone
   square -- without it every swatch would preview the SAME panel-less
   look regardless of tile set, once real tiles became panel-dominant
   above. */
.tile-preview {
  position: relative;
  display: block;
  width: 32px; height: 44px;
  margin: 0 auto 7px;
  border-radius: 5px;
  background: linear-gradient(158deg, var(--bone-hi) 0%, var(--bone) 44%, var(--bone-lo) 100%);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.75),
    inset 0 -1px 0 rgba(156,129,82,.42),
    0 3px 7px rgba(0,0,0,.38);
  --tile-w: 32px; --tile-h: 44px;
}
.tile-preview::before {
  content: "";
  position: absolute;
  inset: var(--face-inset);
  border-radius: 3px;
  background: linear-gradient(160deg, var(--panel-hi) 0%, var(--panel) 58%, var(--panel-lo) 100%);
}
.tile-preview .face { position: absolute; inset: 3px; z-index: 1; display: flex; align-items: center; justify-content: center; pointer-events: none; }

/* ===================== tile legend (playtest #2 + #4) =====================
   A STATIC PANEL, and the scope ceiling is the point of this comment:
   eight rows of "what this tile is", no modal, no tutorial, no carousel.
   It sits at the bottom of #panel-game, which is a bounded 100dvh flex
   chain, so every pixel it takes is a pixel of felt -- hence a <details>
   closed by default that costs one summary row until a player asks.
   Every swatch is a real .tile-preview above, filled by the same
   MJ_FACES.faceHtml() the board renders with and carrying the same
   data-tileset and data-group, so the two wildcard rows and the
   suppressed-slab row inherit the BOARD's rules rather than restating
   them in values that could drift.
   Every value in the closed state below is chosen against one
   measurement rather than for looks: a closed legend takes 26px out of
   .board-scroll on a 375x667 phone (measured live -- 487px of felt
   became 461px), and that is felt the fit solver has to give straight
   back in tile size. It is the irreducible cost of a permanently
   reachable affordance in a bounded flex column; the alternative was a
   floating button, which is chrome over the board and worse. Keep it
   this tight. */
.legend {
  flex: 0 0 auto;
  margin-top: 4px;
  border: 1px solid var(--hairline);
  border-radius: 9px;
  background: var(--lacquer-hi);
}
.legend > summary {
  cursor: pointer;
  list-style: none;              /* the default triangle, replaced below */
  padding: 2px 12px;             /* matches .legend-rows, so the label lines up with the swatches */
  font-family: var(--font-ui);
  font-size: 0.68rem;
  line-height: 1.5;
  font-weight: 700;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--chrome-ink-soft);
}
.legend > summary::-webkit-details-marker { display: none; }
.legend > summary::after { content: " +"; color: var(--brass-lo); }
.legend[open] > summary::after { content: " \2212"; }   /* minus, not a hyphen */
.legend > summary:focus-visible { outline: 2px solid var(--brass); outline-offset: 2px; }

/* Open, this is a scrolling region with its own ceiling rather than a
   block that shoves the board off the screen: a legend that made the
   board unreadable while explaining it would be a strange trade.
   The column counts are the load-bearing half of that, not the
   max-height. Eight single-column rows measure ~430px, which is more
   felt than a phone has to give; at 1280x900 the same eight in two
   columns still took 224px and drove the fit solver to its 22px floor
   (measured). Wide viewports have horizontal room going spare, so spend
   the legend there instead: four columns is two rows, ~110px, and the
   board barely notices. The max-height is only the phone's backstop. */
.legend-rows {
  max-height: 30vh;
  overflow-y: auto;
  padding: 2px 12px 10px;
  display: grid;
  grid-template-columns: 1fr;
  gap: 10px;
}
@media (min-width: 560px)  { .legend-rows { grid-template-columns: 1fr 1fr; } }
@media (min-width: 1040px) { .legend-rows { grid-template-columns: repeat(4, 1fr); } }

.legend-row { display: flex; align-items: center; gap: 10px; }
.legend-row .tile-preview { margin: 0; flex: none; }
.legend-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.legend-text b {
  font-family: var(--font-display); font-weight: 600;
  font-size: 13px; color: var(--chrome-ink);
}
.legend-text em {
  font-style: normal; font-family: var(--font-ui);
  font-size: 11.5px; line-height: 1.35; color: var(--chrome-ink-dim);
}

/* The two wildcard rows wear the brass a FREE flower or season wears on
   the board (§9d), for the same reason and from the same ramp -- a
   legend row that showed a bone tile beside the words "on a brass tile"
   would be worse than no row. !important for the identical reason the
   board rule carries it: faces.js writes an inline colour on these
   glyphs, and an inline style cannot be beaten from a sheet otherwise. */
.legend .tile-preview[data-group="flower"]::before,
.legend .tile-preview[data-group="season"]::before {
  background: linear-gradient(160deg, var(--brass-hi) 0%, var(--brass) 54%, var(--brass-lo) 100%);
}
.legend .tile-preview[data-group="flower"] .face-text,
.legend .tile-preview[data-group="season"] .face-text { color: var(--lacquer) !important; }
.legend .tile-preview[data-group="flower"] .face-idx,
.legend .tile-preview[data-group="season"] .face-idx { color: var(--lacquer); opacity: 0.55; }

/* ...and the last row wears exactly what a covered board tile wears
   (§9b): the same brightness/saturate/contrast on the body, the same
   0.18 grayscale on the face. Duplicated as values here rather than
   selected for, because .legend-covered is not .tile.covered and never
   should be -- but if either number moves, both move. */
.legend .tile-preview.legend-covered { filter: brightness(0.80) saturate(0.86) contrast(0.96); }
.legend .tile-preview.legend-covered .face { opacity: 0.18; filter: grayscale(0.85); }

/* the "Any shape" card used to render the SAME solid-rectangle
   silhouette as the Rectangle card (only distinguished by the opacity
   rule this replaces) -- two near-identical adjacent cards in the first
   picker a new player sees. Its markup is now a real 2x2 arrangement of
   4 distinct family silhouettes (see anyShapeThumbSvg() in app.js), so
   it no longer needs a dimming rule to read as "different." */

/* table-cloth picker swatch: a literal miniature of the table (its own
   felt gradient + weave inside its own wood-bezel frame), not an icon or
   a flat colour chip -- the only preview that tells the truth about the
   choice. data-cloth is set directly on this element (mirrors
   .tile-preview's own data-tileset pattern), so [data-cloth="x"]'s
   --felt-*, --bezel-*, and --cloth-weave* overrides below resolve
   locally per swatch regardless of which cloth is globally active on
   <body>.
   display:block is load-bearing, not decoration: a bare <span> ignores
   explicit width/height per CSS (non-replaced inline elements) --
   exactly .tile-preview's own already-documented defect, independently
   re-found here (confirmed empirically: this rendered 138.7x19 instead
   of the declared 46x42, pushing the label below the card's own bottom
   border on all six cloth cards). */
.cloth-swatch {
  display: block;
  width: 46px; height: 42px;
  margin: 0 auto 7px;
  border-radius: 11px;
  border: 3px solid var(--bezel);
  overflow: hidden;
}
.cloth-swatch i {
  position: relative;
  display: block; width: 100%; height: 100%;
  border-radius: 8px; font-style: normal;
  overflow: hidden;   /* clip ::after's weave to this inner 8px rounding, not just the outer 11px one */
  background: radial-gradient(120% 90% at 42% 26%, var(--felt-hi) 0%, var(--felt) 46%, var(--felt-lo) 100%);
}
/* the swatch's own weave layer -- same tokens, same mechanism as
   .board-scroll::after, so every swatch is a true miniature of its
   cloth's real material rather than just a felt-coloured chip. */
.cloth-swatch i::after {
  content: "";
  position: absolute;
  inset: 0;
  mix-blend-mode: overlay;
  opacity: var(--cloth-weave-opacity);
  background-image: var(--cloth-weave);
  background-size: var(--cloth-weave-size);
  background-repeat: var(--cloth-weave-repeat);
  background-position: center;
}

/* ===================== today's board card (P1.3) =====================
   The lacquer score box on the rail. Uses the base .card treatment
   entirely (lacquer gradient, brass top rule, 24px padding) -- nothing
   here overrides it, only adds the card's own internal layout. */

.daily-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 16px; }
.daily-date {
  font-family: var(--font-ui); font-size: 11px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.10em; color: var(--chrome-ink-dim);
}

.daily-body { display: flex; gap: 20px; align-items: center; }
@media (max-width: 560px) {
  .daily-body { flex-direction: column; align-items: flex-start; }
}

/* The daily tile is a REAL .tile (class="tile" in the markup) so it
   inherits the entire carved body for free -- this block only adds the
   sizing/positioning .tile's own absolute positioning needs overridden. */
.daily-tile {
  position: relative;
  --tile-w: 74px; --tile-h: 100px; --tile-r: 9px; --z-shift: 7px;
  margin: 0 7px 7px 0;
  flex: none;
}
/* Cat's-eye: one narrow bright band travels across the daily tile and
   then rests (playtest #6). This is the lobby's single moving thing and
   the only reason it earns the frame time is that it is what makes a
   player look at today's board at all.
   WHERE IT LIVES IS FORCED, not chosen. It cannot go on ::after -- that
   is the shared state ring, and [data-daily-state="in_progress"] already
   overrides it below. It cannot go on the tile's own background either,
   because .tile::before is full-bleed now (playtest #7) and covers it. So
   it is an extra background LAYER on ::before, above the same panel ramp
   .tile::before already paints, and only the layer's POSITION animates --
   no repaint of the tile, no new element, no filter.
   The rest is most of the cycle on purpose: a band that swept
   continuously would be a spinner. Roughly 1.6s of travel, then ~3s
   still. Cost is one 74x100px element, which is the whole of "only for
   devices that can handle it" at this size -- the honest reduced-motion
   answer is in the single prefers-reduced-motion block near the end of
   this file, and there must not be a second one. */
.daily-tile::before {
  background:
    linear-gradient(105deg,
      transparent 40%,
      rgba(255, 247, 228, 0.90) 50%,
      rgba(224, 189, 109, 0.60) 57%,
      transparent 68%),
    linear-gradient(160deg, var(--panel-hi) 0%, var(--panel) 58%, var(--panel-lo) 100%);
  background-size: 300% 100%, 100% 100%;
  background-repeat: no-repeat;
  animation: cats-eye 4.6s ease-in-out infinite;
}
/* Travels right-to-left, entirely off one edge to entirely off the other,
   then holds at rest for the remainder. Not autoreversed -- a band that
   slid back would read as a wobble, not a highlight catching the stone. */
@keyframes cats-eye {
  0%, 12% { background-position: 140% 0, 0 0; }
  46%     { background-position: -40% 0, 0 0; }
  100%    { background-position: -40% 0, 0 0; }
}
/* loading / error: the tile itself IS the placeholder -- no spinner,
   which would be a second loading idiom on a screen that already has one */
[data-daily-state="loading"] .daily-tile,
[data-daily-state="error"] .daily-tile {
  filter: brightness(0.86) saturate(0.8);
}
/* in_progress: reuses the .selected ring vocabulary WITHOUT the lift
   transform, so it reads as "bookmarked", not "picked up" */
[data-daily-state="in_progress"] .daily-tile {
  --ring: var(--brass); --halo: var(--gold-glow);
}
/* The daily tile overrides .tile::after's shared glow geometry. On a
   board tile the 18px/3px halo sits against felt and reads as a lift;
   here it wraps a tile that ALREADY carries its own extruded side
   shadow, and the two stack into what looks like a detached glowing
   box around the card's corner rather than a ring on the tile. A tight
   1.5px brass ring with a short, spreadless glow keeps the
   "bookmarked" reading without the box. */
[data-daily-state="in_progress"] .daily-tile::after {
  opacity: 1;
  box-shadow:
    0 0 0 1.5px var(--brass),
    0 0 8px rgba(224, 189, 109, 0.32);
}
[data-daily-state="done"] .daily-tile { filter: brightness(0.9) saturate(0.85); }
.daily-tile .done-mark {
  position: absolute; right: -6px; bottom: -6px; z-index: 2;
  width: 26px; height: 26px;
  display: flex; align-items: center; justify-content: center;
  background: var(--lacquer);
  border: 1px solid var(--hairline-strong);
  border-radius: 50%;
}
.daily-tile .done-mark svg { width: 14px; height: 14px; display: block; }

.daily-meta { flex: 1; min-width: 0; }
.daily-shape { font-family: var(--font-display); font-size: 15px; color: var(--chrome-ink); margin: 0 0 4px; }
.daily-note { margin: 0; }
.daily-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }

.streak-rail-wrap { border-top: 1px solid var(--hairline); padding-top: 16px; margin-top: 20px; }
.streak-line { margin: 0 0 10px; font-family: var(--font-ui); font-size: 12px; color: var(--chrome-ink-dim); }
.streak-line b {
  font-family: var(--font-display); font-size: 26px; font-weight: 600;
  color: var(--brass); font-variant-numeric: tabular-nums;
}

/* A compact band, deliberately NOT stretched to the rail's full width.
   space-between was tried and reverted: on a ~990px rail it pushed 30
   7px dots to 26.8px apart (measured live) -- nearly 4x each dot's own
   width, which reads as a dotted horizontal rule rather than as thirty
   consecutive days. Days are a sequence, so they have to sit close
   enough to scan as one run. A fixed 7px gap (= one dot width) keeps
   the band ~413px and self-evidently a streak; the leftover rail space
   is fine empty. */
.streak-rail { display: flex; flex-wrap: wrap; gap: 7px; justify-content: flex-start; }
.streak-dot {
  width: 7px; height: 7px; padding: 0; border: 0; border-radius: 50%;
  cursor: pointer;
  /* was rgba(0,0,0,.34) -- near-black on .card's own near-black lacquer
     gradient, so an unplayed day was invisible and only the
     brass-ringed "today" dot read as a mark at all. --chrome-ink-dim is
     already this app's "present but quiet" token elsewhere (labels,
     dim text); reused here for the same reason, and opaque rather than
     translucent so it reads as a real dot regardless of what's behind
     it, not just a wash over the card colour. */
  background: var(--chrome-ink-dim);
  transition: transform 120ms var(--ease-out-soft);
}
.streak-dot:hover { transform: scale(1.35); }
.streak-dot.completed { background: var(--bone); box-shadow: 0 0 0 1px var(--hairline-strong); }
/* today always gets the brass ring regardless of completed state, which
   is why this comes after .completed and only touches box-shadow, never
   background -- a completed today keeps its bone fill AND the ring. */
.streak-dot.today { width: 9px; height: 9px; box-shadow: 0 0 0 1px var(--brass), 0 0 10px var(--gold-glow); }

/* ===================== the tally (P2.1 lifetime-stats half) ===================== */

.tally-head { display: flex; gap: 32px; margin-bottom: 20px; }
.tally-figure b {
  display: block; font-family: var(--font-display); font-size: 30px; font-weight: 600;
  line-height: 1; color: var(--chrome-ink); font-variant-numeric: tabular-nums;
}
.tally-figure span {
  display: block; margin-top: 4px;
  font-family: var(--font-ui); font-size: 11px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.10em; color: var(--chrome-ink-dim);
}

/* byte-identical to .game-row's own rule, deliberately -- the tally and
   the saved-games list read as one system, not two lists that almost
   match. */
.tally-row { display: flex; align-items: center; gap: 12px; padding: 11px 4px; border-top: 1px solid rgba(224, 189, 109, 0.12); }
.tally-row:first-child { border-top: none; }
.tally-row.zero-clears { opacity: 0.5; }
.tally-row .thumb { width: 28px; height: 26px; flex: none; opacity: .8; }
.tally-row .thumb svg { width: 100%; height: 100%; display: block; }
.tally-row .fam-name { flex: 1; font-family: var(--font-display); font-size: 15px; color: var(--chrome-ink); }
.tally-row .cleared-count {
  flex: none; width: 92px; text-align: right;
  font-family: var(--font-ui); font-size: 13px; font-weight: 700;
  color: var(--brass); font-variant-numeric: tabular-nums;
}
.tally-row .best-time {
  flex: none; width: 84px; text-align: right;
  font-family: var(--font-ui); font-size: 13px; color: var(--chrome-ink-dim);
  font-variant-numeric: tabular-nums;
}

.tally-foot { font-family: var(--font-ui); font-size: 12px; color: var(--chrome-ink-dim); margin: 16px 0 0; }

/* ===================== Otto (../MASCOT.md) =====================

   Otto is DECORATIVE. Nothing in this block may ever become the only
   rendering of something a player needs: the free-tile brass rim above
   stays the only functional cue on the board (§4.1). Delete this whole
   section and the game is exactly as playable and exactly as legible.

   Three rules govern every declaration here:
     §4.2  he never intercepts input   -> pointer-events: none, everywhere
     §4.3  reduced motion holds the POSE and stops the MOTION, so the fade
           is a transition (collapsible) and never a keyframe animation
           that would strand him mid-state
     §4.1  he is never over a control, and never over a tile             */

/* The lane: a strip below .table-frame in the game panel, bottom-left.
   It COLLAPSES to 0 height and 0 margin when data-otto-visible="false"
   (see the rule near the end of this file) -- it does not reserve space
   for him while he is away, which is what this comment used to claim and
   what the code stopped doing on 2026-08-20. Reserving it cost a
   permanent 106px strip of a phone screen for an owl who is absent most
   of the game, and playtest #5 is explicit that he must not hold empty
   layout space. iOS and Android already collapsed their equivalent row.
   See index.html's own comment for why he is outside the felt rather
   than perched on it. */
.otto-lane {
  position: relative;
  display: flex;
  align-items: center;
  gap: 16px;
  height: 106px;
  margin-top: 10px;
  pointer-events: none;         /* §4.2 -- the lane, the owl and the line */
}
@media (min-width: 900px) { .otto-lane { height: 126px; } }

.otto-figure {
  flex: none;
  width: 88px;
  pointer-events: none;
  opacity: 0;
  transform: translateY(7px);
  transition: opacity 420ms var(--ease-out-soft), transform 420ms var(--ease-out-soft);
  /* Grounds him on the walnut. NOT a redraw and not one of the three
     techniques -- see the bleed-ellipse note below. */
  filter: drop-shadow(0 7px 16px rgba(0, 0, 0, 0.55));
}
@media (min-width: 900px) { .otto-lane .otto-figure { width: 104px; } }

.otto-figure svg { display: block; width: 100%; height: auto; pointer-events: none; }

/* MASCOT.md §3.2, the explicit per-surface port decision this platform is
   required to make rather than leave to chance: the wash-bleed ellipse
   behind the body (opacity .09) is authored to read on a near-white
   ground. Mahjong's shell is dark walnut and lacquer, where it is
   invisible at best and a muddy grey smear at worst, so web DROPS it and
   grounds him with the drop-shadow above instead. The three techniques
   that actually make the character -- edge displacement, pigment pooling,
   offset ink -- are untouched, which is the part §3.5 fails a port on.
   Selector is exact: #otto has precisely one direct-child <ellipse>
   (the lifted-highlight ellipse lives inside #otto-body). */
.otto-figure #otto > ellipse { display: none; }

/* MASCOT.md §5 -- with dialogue suppressed (or on Gaze, which is wordless
   by design) lineFor() returns null and app.js writes "". No empty
   bubble, no stray container, no layout gap where text would have been.
   The pose stands on its own; that is the point of the rule. */
.otto-line:empty { display: none; }

.otto-line {
  margin: 0;
  max-width: 30ch;
  font-family: var(--font-display);
  font-size: 15.5px;
  line-height: 1.45;
  color: var(--chrome-ink-soft);
  pointer-events: none;
  opacity: 0;
  transform: translateY(7px);
  transition: opacity 420ms var(--ease-out-soft), transform 420ms var(--ease-out-soft);
}

/* One attribute on the host flips both halves. app.js sets it on
   #otto-lane or on #win-modal, never on both -- §2.3 rule 1 again. */
[data-otto-visible="true"] .otto-figure,
[data-otto-visible="true"] .otto-line {
  opacity: 1;
  transform: translateY(0);
}

/* The win modal's perch. Top-right, clear of the two buttons at the
   bottom and of the heading, which reserves room for him below. */
.otto-perch {
  position: absolute;
  top: 6px;
  right: 12px;
  width: 62px;
  pointer-events: none;
}
.otto-perch .otto-figure { width: 62px; filter: drop-shadow(0 5px 12px rgba(0, 0, 0, 0.6)); }
#win-modal .modal h3 { padding-right: 72px; }
#win-modal .otto-line { margin: -10px 0 20px; font-size: 15px; }

/* ===================== motion, performance, accessibility (§10) ===================== */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  .tile.free:hover, .tile.selected, .tile.pairable { transform: none; }
  .fx-burst { display: none; }
  /* state still communicated by the ring + halo, which are instant */

  /* MASCOT.md §4.3 -- still, NOT absent. Otto still appears, still holds
     the gaze, still has the teacup; the pose is set as SVG transform
     attributes by app.js and nothing here touches it. What stops is the
     motion: no fade, no rise. Removing him entirely would be wrong -- a
     player who dislikes motion has not asked to lose the artwork. */
  .otto-figure, .otto-line {
    transition: none !important;
    animation: none !important;
    transform: none !important;
  }

  /* Playtest #6's shimmer, stopped. Unlike Otto directly above there is
     no pose worth holding here -- the band IS the motion, and at rest it
     parks off the face, so the daily tile simply reads as the ordinary
     carved tile it is under every other tile set. Stated explicitly
     rather than left to the blanket 0.01ms rule at the top of this
     block, because that rule only clamps animation-duration and an
     infinite background sweep deserves to be named. */
  .daily-tile::before { animation: none; }
}

/* ============================================================
   Fit the game to the viewport — 2026-08-20
   ------------------------------------------------------------
   The felt used to have NO height constraint, so it grew to whatever
   the board needed. That made app.js's sizeForBoard() circular on the
   vertical axis: it measured .board-scroll to decide how big tiles
   should be, but .board-scroll's height was itself a consequence of
   the tiles. Measured live at 375x812: availH 627, board height 627 —
   a perfect, useless match, while the document ran to 1298px and the
   page scrolled.

   Bounding the panel to the viewport breaks that circularity. Now
   .board-scroll gets a real leftover height, sizeForBoard() sizes
   tiles to it, and the whole game fits on one screen.

   Uses dvh so mobile browser chrome (the collapsing address bar) is
   accounted for; vh would leave the action bar under it. ============ */
.app-shell {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
}

/* min-height alone is not enough: it lets the shell GROW past the
   viewport, and a flex child never shrinks below its content unless the
   container is actually bounded. So bound it — but only while the game
   is on screen, because the lobby is a genuinely long scrolling page and
   must not be trapped at one viewport. */
.app-shell:has(#panel-game.active) {
  height: 100dvh;
  overflow: hidden;
}

#panel-game.active {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;              /* without this a flex child refuses to shrink */
}

.table-frame {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.board-scroll {
  flex: 1 1 auto;
  min-height: 0;
}

/* Otto's lane reserved a hardcoded 106px whether or not he was there —
   he is hidden by opacity, not display, so the gap was permanent. iOS
   already collapses this row when there is no moment (GameView.otto's
   doc comment says so explicitly); web simply never did. Reuses the
   data-otto-visible attribute ottoShow/ottoHide already maintain. */
/* Otto FLOATS. He is an overlay, not a row.
   ------------------------------------------------------------------
   He used to be a real flex item with a hardcoded 106px height, which
   meant his empty lane stole 106px of felt from the board on every
   screen, forever. Collapsing it to 0 when absent fixed that but bought
   a worse bug: the lane re-expanding the instant he SPOKE shrank the
   felt mid-game (measured 743px -> 615px at 1280x900) and pushed a
   fitting board into overflow, because nothing re-ran the fit.
   Taking him out of flow entirely removes both problems at the root --
   he now costs exactly zero layout at all times, speaking or silent,
   so the board is sized once and never moves under him.
   pointer-events:none (already set on .otto-lane) means he can never
   intercept a tap, so overlapping the felt is safe by construction. */
.otto-lane {
  position: absolute;
  right: 10px;
  bottom: 8px;
  height: auto;
  margin-top: 0;
  background: none;
  z-index: 5;
  transition: opacity 0.25s ease;
}
.otto-lane[data-otto-visible="false"] {
  opacity: 0;
}
/* the positioning context for the overlay above */
#panel-game.active { position: relative; }

/* ---- Reclaim the screen while a game is on ----------------------
   Measured at 375x812 with a game active: topbar 70 + game-toolbar 173
   + move-state 20 + shell padding 116 + footer 18 = 397px of chrome,
   leaving 257px of felt on an 812px screen. Half the display was spent
   on furniture, which is why 144 tiles could not fit at any tappable
   size. None of this chrome is worth more than the board it surrounds. */
.app-shell:has(#panel-game.active) {
  padding-top: 12px;
  padding-bottom: 12px;
}
/* The wordmark is a lobby affordance -- in a game you already know what
   you are playing, and the toolbar's own "Boards" button is the way out. */
.app-shell:has(#panel-game.active) .topbar,
.app-shell:has(#panel-game.active) .footer-note {
  display: none;
}
/* One compact row instead of three wrapped ones. */
#panel-game.active .game-toolbar {
  gap: 8px;
  padding: 6px 10px;
  margin-bottom: 8px;
  flex-wrap: wrap;
  /* NO overflow here. It used to be `overflow-x: auto` to keep the row
     compact, and that silently broke the legend: an overflow container
     CLIPS absolutely-positioned descendants, so the popover opened
     correctly and was simply invisible. Nothing in the DOM or in
     getBoundingClientRect showed it -- clipping does not change either --
     which is exactly why it read as "the legend will not expand".
     Wrapping instead of scrolling costs a few pixels and keeps every
     control both reachable and on screen. */
  scrollbar-width: none;
}
#panel-game.active .game-toolbar > * { flex: 0 0 auto; }

@media (max-width: 620px) {
  /* Phones: the stats line is the least load-bearing thing on screen --
     tiles-left is also readable from the board itself. */
  #panel-game.active .game-stats { display: none; }
  .table-frame { padding: 6px; }
  .board-scroll { padding: 14px; }
}

@media (max-width: 620px) {
  /* Four controls have to fit 335px without clipping. Making the row
     scrollable hid Shuffle off-screen, which is worse than small text:
     a control you cannot see is a control you do not have. */
  #panel-game.active .game-toolbar { overflow-x: visible; flex-wrap: wrap; }
  #panel-game.active .game-toolbar button {
    padding: 7px 10px;
    font-size: 0.74rem;
    letter-spacing: 0.06em;
    min-width: 0;
  }
}


/* Legend as a POPOVER, not a panel -- 2026-08-20.
   It was a <details> inside the bounded flex column, so it charged 26px of
   felt while shut and, opened on a phone, cut the felt to ~289px and made a
   board already at the tappable floor scroll. A reference card may not tax
   the board it describes. The summary is now a toolbar control and the rows
   float above everything, dismissed by the summary again or Esc. */
.legend { position: relative; }
.legend > summary { white-space: nowrap; }
.legend[open] > .legend-rows {
  position: absolute;
  z-index: 40;
  top: calc(100% + 8px);
  right: 0;
  /* A fixed width, NOT max-content. With max-content the grid grew as wide
     as its longest line and max-width then clamped only the BOX, so the
     popover scrolled left-to-right instead of wrapping and scrolling down. */
  width: min(86vw, 340px);
  max-height: min(62vh, 460px);
  overflow-y: auto;
  overflow-x: hidden;
  padding: 12px 14px;
  border-radius: 12px;
  border: 1px solid var(--hairline);
  background: linear-gradient(180deg, var(--lacquer-hi), var(--lacquer-lo));
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.66);
}

/* Ad card before an ad has filled it: width intact (AdSense needs it to
   size the request), everything else collapsed so nothing shows. */
.card.ad-pending { max-height: 0; padding: 0; margin: 0; border-width: 0; overflow: hidden; }
