  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

  :root {
    --bg:        #14100c;
    --panel:     #241c14;
    --panel-hi:  #3a2d20;
    --ink:       #f3e7cd;
    --ink-dim:   #b8a684;
    /* Third text tier, below --ink-dim. Was referenced by .hint and never
       defined, so that text silently inherited whatever was around it. */
    --dim:       #8d7f66;
    --felt:      #1d5c3f;
    --gold:      #d8a544;
    --red:       #c0392b;
    --green:     #2f8f4e;

    /* Typography. No web fonts are loadable (single file, strict CSP), so the
       theme comes from picking the right SYSTEM faces. 宋体/明體 is the
       traditional Chinese signage and woodblock face — it carries the parlour
       feeling that a UI sans like YaHei flattens out. Serif for anything
       display-sized; the sans stays for dense body copy, where a small serif
       turns to mush on a low-DPI screen. */
    --font-display: "Songti SC", "STSong", "SimSun", "Noto Serif CJK SC",
                    "Source Han Serif SC", "MingLiU", Georgia, "Times New Roman", serif;
    --font-ui: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "SimHei",
               system-ui, sans-serif;
  }

  /* ══════════════════════════════════════════════════════════════════════
     SCROLLBARS — a bamboo tally strip, not an operating-system control.

     Windows Chrome/Edge draw a WHITE scrollbar by default, and it appeared as
     a bright column down the side of every dark panel in the game — the one
     piece of the interface that was visibly not part of it.

     `scrollbar-width: thin` was already set in six places, which does the job
     on Firefox and nothing at all on Chromium. This styles both engines, once,
     for every scrollable surface — panels, dialogs, the chat log, the rules
     screen, the tutorial slides, and the page itself.

     ⚠️ NO `border-radius`, deliberately. The whole interface is an 8-bit
     dialog box (see THE MENU SKIN at the foot of this file) and a rounded pill
     sliding down a square frame is the exact tell that a control came from
     somewhere else.

     The texture is the game's own materials: the track is dark lacquer with a
     wood grain, and the thumb is a bamboo segment — the repeating-gradient
     notches are the joints, and the gold top/bottom edges are the brass caps a
     mahjong counting stick has. It reads as an object from the table rather
     than as a widget. */
  /* ⚠️ THE TWO SYNTAXES ARE MUTUALLY EXCLUSIVE, and getting that wrong is
     silent. Chromium supports BOTH `scrollbar-width`/`scrollbar-color` and
     `::-webkit-scrollbar` — and when the standard properties are present it
     honours those and throws the whole ::-webkit- block away. The first
     version of this section set both, so Chromium rendered a flat two-colour
     bar and every gradient below was dead code that looked fine in the source.
     Measured: `offsetWidth - clientWidth` was 6px, not the 12 declared.

     So the standard properties are fenced behind a query that only Firefox
     answers, and Chromium/Safari get the texture. */
  @supports not selector(::-webkit-scrollbar) {
    * { scrollbar-width: thin; scrollbar-color: #6b5636 rgba(10, 8, 6, .55); }
  }
  ::-webkit-scrollbar { width: 12px; height: 12px; }
  ::-webkit-scrollbar-track {
    background-color: rgba(10, 8, 6, .55);
    /* The grain. Two hairlines a few pixels apart, so the trough looks like
       planed wood rather than a flat gutter. */
    background-image: repeating-linear-gradient(
      180deg,
      rgba(255, 232, 168, .045) 0px, rgba(255, 232, 168, .045) 1px,
      transparent 1px, transparent 6px);
    border-left: 1px solid rgba(0, 0, 0, .5);
  }
  ::-webkit-scrollbar-thumb {
    background-color: #6b5636;
    /* The bamboo joints, plus a lit left edge and a shaded right one so the
       stick reads as round. */
    background-image:
      /* 18px apart, not 12 — at 12 the joints read as a ladder rather than as
         a bamboo stick. Measured against a real thumb, not guessed. */
      repeating-linear-gradient(180deg,
        transparent 0px, transparent 18px,
        rgba(0, 0, 0, .30) 18px, rgba(0, 0, 0, .30) 20px),
      linear-gradient(90deg,
        rgba(255, 232, 168, .22) 0px, rgba(255, 232, 168, .22) 2px,
        transparent 2px, transparent calc(100% - 2px),
        rgba(0, 0, 0, .35) calc(100% - 2px));
    border-top: 2px solid var(--gold);
    border-bottom: 2px solid var(--gold);
  }
  ::-webkit-scrollbar-thumb:hover { background-color: #8a7047; }
  ::-webkit-scrollbar-thumb:active { background-color: #9c7f52; }
  /* The little stepper arrows are the ugliest part of the Windows default and
     nothing in this game needs them. */
  ::-webkit-scrollbar-button { display: none; height: 0; width: 0; }
  ::-webkit-scrollbar-corner { background: rgba(10, 8, 6, .55); }

  html, body { height: 100%; }

  body {
    background: var(--bg);
    background-image:
      radial-gradient(circle at 50% 0%, #2a2018 0%, #14100c 70%);
    color: var(--ink);
    font-family: var(--font-ui);
    display: flex;
    flex-direction: column;
    align-items: center;
    min-height: 100%;
    padding: 4px;
    -webkit-font-smoothing: none;

    /* ⚠️ iOS Safari inflates text when a page rotates into landscape, to
       "help" pages laid out for desktop. This one is not: the landscape phone
       layout is solved to the pixel — a rail whose labels come off at a
       measured threshold, a canvas fitted in sixteenths — and text arriving
       ~10% bigger than the CSS asked for is enough to wrap the toolbar it was
       tuned not to wrap. `100%` declines the favour without preventing the
       reader's own zoom, which `user-scalable=no` would. */
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;

    /* Double-tap-to-zoom costs every tap a ~300ms wait to find out whether a
       second one is coming. That is a tax on a game whose whole input is
       tapping, and it is worse than a delay here: the portrait rack's core
       gesture is tapping the SAME tile twice — once to select, once to throw —
       which is character-for-character the double-tap zoom gesture. Without
       this, throwing a tile on an iPhone zooms the board instead.

       Set on `body` so it is inherited by everything. The canvas sets it
       again for itself; that rule is now redundant but harmless, and worth
       leaving where a reader of that block will find it. Panning and pinch
       still work — `manipulation` only disables double-tap zoom, not the
       reader's ability to zoom deliberately. */
    touch-action: manipulation;

    /* No rubber-band. The table is a fixed board that does not scroll, so a
       vertical flick that starts on the felt has nothing to move and iOS
       hands it to the page — the whole board slides and springs back mid-hand
       for no reason. */
    overscroll-behavior: none;
  }

  /* ── Nailed down while a hand is live ────────────────────────────────────
     `overscroll-behavior` above stops the rubber-band. It does NOT stop a real
     scroll, and on a phone the page has one to give: the felt, the tile bar,
     the claim buttons and the rack are laid out to fill the viewport almost
     exactly, so a few pixels of overflow — a chat strip opening, an on-screen
     keyboard retracting, an address bar sliding back in — is enough to make
     the document scrollable by one or two steps. The result is a board that
     slides out from under the thumb mid-discard, and the tiles you were
     aiming at are somewhere else by the time the finger lands.

     ⚠️ THE LOCK IS NOT A CROP, AND THE TEST IS WHAT KEEPS IT HONEST. Clipping
     a page that genuinely overflows would hide the bottom of the hand rack and
     make the game unplayable, which is far worse than the scroll being fixed.
     So `fullscreen.py` asserts BOTH halves at four phone viewports: that the
     page cannot scroll, AND that `scrollHeight` never exceeded `clientHeight`
     in the first place — i.e. that this rule takes nothing away. If that
     second assertion ever goes red the fix is in `fit()`'s chrome budget, and
     never in this rule.

     `pointer: coarse`, not a width breakpoint, for the reason set out at the
     bottom of this file: it is the FINGER this protects, and a narrow desktop
     window has a mouse, a scroll wheel, and a status log below the fold that
     `fit()` deliberately lets fall there to buy the canvas a whole step.

     `body.playing` — set by fit() from the game state, so it covers the table,
     the result sheet and the tutorial, and releases the moment you are back at
     the menu. Overflow set on the body PROPAGATES to the viewport because
     `html` is left `visible`, which is the whole trick: no `position: fixed`
     body, no scroll-position bookkeeping, and no layout moves — every dialog
     and sheet in the game keeps its own internal `overflow-y: auto`, so the
     rules screen and the table sheet still scroll inside themselves. */
  @media (pointer: coarse) {
    body.playing { overflow: hidden; }
  }

  #wrap { width: 100%; max-width: 1760px; display: flex; flex-direction: column; align-items: center; gap: 5px; }

  header {
    width: 100%;
    display: flex; align-items: center; justify-content: space-between;
    gap: 10px; flex-wrap: wrap;
    padding: 4px 2px 6px;
    border-bottom: 2px solid var(--panel-hi);
  }
  header h1 {
    font-family: var(--font-display);
    font-size: 1.22rem; letter-spacing: .10em; color: var(--gold); font-weight: 700;
    text-shadow: 0 1px 0 #2a1d0f, 0 0 12px rgba(216,165,68,.22);
  }
  /* Display face on everything chrome-sized: buttons, titles, seat plates.
     Body copy deliberately stays on the sans. */
  button, .card h2, .card h3, .chip b,
  table.placings th, #coach b { font-family: var(--font-display); letter-spacing: .045em; }
  button { font-weight: 700; }
  .card h2 { letter-spacing: .07em; text-shadow: 0 1px 0 #2a1d0f; }
  header h1 small { color: var(--ink-dim); font-weight: 400; font-size: .72rem; letter-spacing: 0; margin-left: 8px; }

  /* ── The toolbar rail ──────────────────────────────────────────────────
     One rail, not eight boxes. `nowrap` is load-bearing: the whole reason
     this bar looked like a pile was that it wrapped to two and three rows
     the moment the window narrowed, and a menu that changes shape as you
     resize is a menu you have to re-read every time. It cannot wrap now,
     because narrow enough the labels come off instead (see below). */
  .toolbar {
    display: flex; align-items: center; gap: 2px; flex-wrap: nowrap;
    background: rgba(0, 0, 0, .22);
    border: 1px solid #3a2e20;
    border-radius: 4px;
    padding: 2px 3px;
  }

  /* Flat by default — a toolbar item earns ink only when you point at it or
     when it is switched on. `border: 0` and the explicit background beat the
     shared `button` rule below without needing !important. */
  .navbtn {
    display: inline-flex; align-items: center; gap: 5px;
    background: transparent; border: 0; border-radius: 3px;
    color: var(--ink-dim); font-size: .76rem; font-weight: 700;
    letter-spacing: .04em; line-height: 1;
    padding: 6px 8px; cursor: pointer; white-space: nowrap;
    text-decoration: none;                      /* Tiles is an <a> */
    transition: background .12s, color .12s;
  }
  /* One slot, two rooms — see the comment on the pair in index.html. Stated
     here rather than toggled from JS so the rail is never briefly wrong: the
     `mp` class and the buttons arrive in the same frame. */
  #btnLeave { display: none; }
  body.mp #btnNew { display: none; }
  body.mp #btnLeave { display: inline-flex; }
  /* ⚠️ HOME IS NOT A DOOR OUT OF A LIVE TABLE. It walks you back to the
     landing page while three other people are still playing your seat — the
     same act as Leave, with none of the warning and none of the accounting.
     Leave is the only way off an online table, and it is the one that knows
     what it costs. */
  body.mp #btnHome { display: none; }

  .navbtn:hover:not(:disabled) { background: rgba(216, 165, 68, .13); color: var(--ink); }
  .navbtn:active:not(:disabled) { transform: none; background: rgba(216, 165, 68, .2); }
  .navbtn:focus-visible { outline: 2px solid var(--gold); outline-offset: 1px; }

  /* The glyph column. Fixed width so the labels line up as a column of text
     rather than being pushed around by how wide each symbol happens to be,
     and dimmer than the label because it is the label's marker, not a second
     thing to read. */
  .navbtn .ic {
    display: inline-block; width: 12px; text-align: center;
    font-size: .84rem; color: var(--gold); opacity: .78;
  }
  .navbtn:hover .ic, .navbtn[aria-pressed="true"] .ic { opacity: 1; }

  /* The group hairlines. Two of them, and they are the only structure in the
     bar: leave/restart | look it up | see and hear. */
  .navsep {
    width: 1px; align-self: stretch; margin: 3px 4px;
    background: #4a3b2a; flex: 0 0 auto;
  }

  /* Mid-hand the page furniture gets out of the way — every pixel here is a
     pixel the table cannot have. Set by fit() from the game state. */
  body.playing { padding: 2px; }
  body.playing header { padding: 1px 2px 3px; gap: 6px; }
  body.playing header h1 { font-size: 1rem; }
  body.playing header h1 small { display: none; }
  /* `.navbtn`, not `button`: the Tiles item is an <a> and has to shrink with
     the rest of the rail or the bar grows a step taller for one link. */
  body.playing header .navbtn { padding: 4px 7px; font-size: .73rem; }
  body.playing #wrap { gap: 3px; }
  body.playing #actions { min-height: 34px; padding: 0; }

  button {
    font-family: inherit; font-size: .8rem; font-weight: 700;
    color: var(--ink); background: var(--panel-hi);
    border: 2px solid #55432e; border-bottom-width: 3px;
    border-radius: 3px; padding: 6px 10px; cursor: pointer;
    letter-spacing: .02em; line-height: 1.1;
  }
  button:hover:not(:disabled) { background: #4b3a28; }
  button:active:not(:disabled) { transform: translateY(1px); border-bottom-width: 2px; }
  button:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
  button:disabled { opacity: .35; cursor: default; }
  button.primary { background: var(--green); border-color: #1e6a37; }
  button.primary:hover:not(:disabled) { background: #37a75c; }
  button.danger  { background: var(--red);  border-color: #8e2a1f; }
  button.danger:hover:not(:disabled) { background: #d4483a; }
  button.ghost { background: transparent; border-color: #4a3b2a; color: var(--ink-dim); }

  #stage { width: 100%; display: flex; justify-content: center; }

  canvas#table {
    image-rendering: pixelated;
    image-rendering: crisp-edges;
    background: #0b1a12;
    border: 3px solid #3d2f1f;
    border-radius: 2px;
    touch-action: manipulation;
    max-width: 100%;
    display: block;
    /* The pendant lamp inside the canvas spills warm light onto the page —
       the table is the only lit object in an otherwise dark parlour. */
    box-shadow:
      0 10px 34px rgba(0, 0, 0, .78),
      0 0 76px -14px rgba(255, 190, 104, .30);
  }

  /* Action bar: real buttons, keyboard reachable — never canvas-only hit targets */
  #actions {
    min-height: 44px;
    display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; align-items: center;
    width: 100%; padding: 2px;
  }
  #actions .hint { color: var(--ink-dim); font-size: .78rem; }

  /* ── The quick-key badge ───────────────────────────────────────────────
     W / K / P / C / ESC, or 1 2 3 when one action has several options. Drawn
     as generated content and NOT appended to the button's text, on purpose:
     `innerText`, the tutorial's prefix match on the label and every suite that
     reads `#actions button` text keep seeing the label the view wrote. The key
     reaches a screen reader through `aria-label` instead (view.js), because a
     pseudo-element does not.

     `currentColor` so it works on the green 食糊 button as well as the brown
     ones without a second rule, and dimmed because it is a hint sitting next
     to the thing you are actually reading. */
  #actions button[data-key]::after {
    content: attr(data-key);
    margin-left: 6px; padding: 0 3px;
    font-size: .6rem; font-weight: 700; letter-spacing: .04em;
    color: inherit; opacity: .55;
    border: 1px solid currentColor; border-radius: 2px;
    vertical-align: middle;
  }

  /* An action bar holding nothing must cost the table nothing. Removing the
     "your turn" hint is only half the fix: the empty bar still reserved its
     min-height, so the canvas kept shrinking the instant a claim moved onto
     the felt (`.ontable`, height 0) and growing back when it left. Both
     states are now zero, so the table is one size for the whole hand.
     Listed for `body.playing` too — that selector carries a class and would
     otherwise out-specify this one. */
  #actions:empty, body.playing #actions:empty { min-height: 0; padding: 0; }

  /* ── Acting on the felt ────────────────────────────────────────────────
     A claim is a decision about ONE tile, so the buttons hang off that tile
     instead of sitting in a bar under the canvas. `position: fixed` because
     view.js places them from the canvas's own bounding rect, which is also
     viewport-relative — the two compose exactly, with no positioned ancestor
     to arrange and no dependence on where #actions sits in the document.

     They are still real <button>s, so the keyboard path, the screen reader,
     the tutorial's `.want` outline and every test that clicks `#actions
     button` carry on working; only the position changed.

     Stacked, not strung out in a row. Laid out as a row this cluster came to
     430px — more than a third of the table wide — so centring it on a tile in
     the left-hand pond pushed it off the felt and it clamped to the middle of
     the table, which is precisely the "look somewhere else" problem it exists
     to remove. A narrow column stays pinned to its tile wherever it landed. */
  #actions.ontable {
    position: fixed; z-index: 25;
    min-height: 0; padding: 0;
    flex-direction: column; align-items: stretch; flex-wrap: nowrap;
    width: max-content; max-width: min(58vw, 186px);
    gap: 3px;
    filter: drop-shadow(0 3px 2px rgba(0,0,0,.75));
  }
  #actions.ontable button {
    font-size: .72rem; padding: 4px 8px; text-align: center;
    box-shadow: 0 0 0 1px rgba(8,6,4,.85);
  }
  /* Above the buttons, not below: it names the tile they are about to take,
     and reading the answer before the question is backwards. */
  #actions.ontable .hint {
    order: -1; text-align: center;
    font-size: .66rem; line-height: 1.25; color: var(--ink);
    background: rgba(10,8,6,.92); border: 1px solid #55432e;
    border-radius: 3px; padding: 2px 6px;
  }

  /* The session readout (#infopanel: status line, standings, log) has been
     removed. What it reported is on the table itself — the centre plaque
     carries the round, the wall and the dealer, and the seat badges carry the
     chips — so the panel was a second copy that cost the felt real estate
     every time it was open. */

  /* The hand panel's toggle shows its state. Without it the button looked
     identical whether the panel was showing or not, so it did not read as a
     toggle at all — and somebody who had closed the panel had no way to tell
     that this was the thing that brings it back.

     A solid gold block was the right answer while every item in the bar was
     a bordered button; on a flat rail it is the loudest thing on the page,
     for a preference. Lit text on a faint gold wash says "on" just as
     plainly and leaves the rail quiet. */
  #btnHand[aria-pressed="true"] { background: rgba(216, 165, 68, .18); color: var(--gold); }
  #btnHand[aria-pressed="true"]:hover { background: rgba(216, 165, 68, .26); color: var(--gold); }

  /* Overlays */
  .overlay {
    position: fixed; inset: 0; background: rgba(10,8,6,.86);
    display: none; align-items: center; justify-content: center; padding: 16px; z-index: 40;
  }
  .overlay.show { display: flex; }
  .card {
    background: var(--panel); border: 3px solid #55432e; border-radius: 4px;
    padding: 18px 20px; max-width: 560px; width: 100%; max-height: 92vh; overflow-y: auto;
  }
  .card h2 { color: var(--gold); font-size: 1.15rem; margin-bottom: 4px; }
  .card h3 { color: var(--ink); font-size: .9rem; margin: 14px 0 6px; }
  .card p  { font-size: .82rem; line-height: 1.6; color: var(--ink-dim); margin-bottom: 8px; }
  .card ul { font-size: .8rem; line-height: 1.6; color: var(--ink-dim); margin: 0 0 8px 18px; }
  .card ol { font-size: .8rem; line-height: 1.6; color: var(--ink-dim); margin: 6px 0 8px 20px; }
  .card b  { color: var(--ink); }

  /* ── 配器 · the instrument audition rows ────────────────────────────────
     One row per melody voice: a play trigger, what it is, and a button that
     adopts it. The whole row is clickable to play, so the ▶ is an affordance
     rather than the only target — you audition these dozens of times in a
     sitting and a 34-pixel button gets old fast. */
  .vrow {
    display: flex; align-items: center; gap: 10px;
    padding: 8px; margin-bottom: 6px;
    background: #1a140e; border: 2px solid #3a2d20; border-radius: 3px;
    cursor: pointer;
  }
  .vrow:hover { border-color: #6b5334; background: #201810; }
  /* The voice currently in the arrangement. */
  .vrow.sel { border-color: var(--gold); background: #241c11; }
  .vrow .vmeta { flex: 1; min-width: 0; }
  .vrow .vmeta b { display: block; color: var(--ink); font-size: .85rem; }
  .vrow .vmeta p { margin: 2px 0 0; font-size: .72rem; line-height: 1.5; color: var(--ink-dim); }
  .vrow .vplay {
    flex: 0 0 auto; width: 34px; height: 34px; padding: 0;
    font-size: .8rem; line-height: 1; color: var(--gold);
    background: #241c11; border-color: #5b4526;
  }
  /* Held for as long as the voice is actually sounding — audio.js returns the
     length, so this cannot drift out of step with what you are hearing. */
  .vrow .vplay.playing { background: var(--gold); color: #241c14; border-color: #9a7526; }
  .vrow .vuse {
    flex: 0 0 auto; font-size: .72rem; padding: 6px 10px;
    background: transparent; border-color: #4a3b2a; color: var(--ink-dim);
  }
  .vrow .vuse:disabled { opacity: .55; cursor: default; color: var(--gold); border-color: #5b4526; }
  /* 混合 is the last row and is not an instrument. It is set apart by a gap
     and a dashed edge rather than by colour: the gold border already means
     "in use", and a second gold thing in the list would compete with it. */
  .vrow.vmix { margin-top: 14px; border-style: dashed; }

  /* ── The tuning drawer ──────────────────────────────────────────────────
     One slider per layer the voice is built from. It is a sibling of the row
     rather than a child, so it spans the full width instead of becoming a
     fourth column in the row's flex line; the negative margin closes the gap
     the row leaves under itself so the two read as one object. */
  .vtune { display: none; margin: -6px 0 8px; padding: 10px 10px 8px;
           background: #150f0a; border: 2px solid #3a2d20; border-top: 0;
           border-radius: 0 0 3px 3px; }
  .vtune.open { display: block; }
  /* The row a drawer hangs off loses its bottom corners, for the same reason. */
  .vrow.tuning { border-bottom-left-radius: 0; border-bottom-right-radius: 0;
                 border-bottom-color: #2a2016; }
  .vknob { display: flex; align-items: center; gap: 10px; margin-bottom: 7px; }
  .vknob label { flex: 0 0 42%; font-size: .72rem; color: var(--ink-dim); }
  .vknob input[type="range"] { flex: 1 1 auto; min-width: 80px; margin: 0; }
  /* Tabular figures: the number is read while it is moving, and a proportional
     font makes it jump sideways on every digit that changes width. */
  .vknobval { flex: 0 0 58px; text-align: right; font-size: .72rem;
              color: var(--ink-dim); font-variant-numeric: tabular-nums; }
  .vknobval.moved { color: var(--gold); }      /* off its shipped value */
  .vtune-foot { display: flex; align-items: center; gap: 10px;
                margin-top: 10px; padding-top: 8px; border-top: 1px solid #2a2016; }
  .vtune-foot span { font-size: .68rem; color: var(--ink-dim); }
  .vreset { font-size: .7rem; padding: 5px 9px; background: transparent;
            border-color: #4a3b2a; color: var(--ink-dim); }

  /* Phones: the label above the slider rather than beside it. At 42% of a
     narrow card the label wraps to three lines and the slider it belongs to
     ends up 60 pixels wide, which is not a control anybody can aim at. */
  @media (max-width: 520px) {
    .vknob { flex-wrap: wrap; gap: 4px 10px; }
    .vknob label { flex: 1 1 100%; }
    .vknob input[type="range"] { flex: 1 1 auto; }
  }

  /* ── The result is a DOCK, not a blackout ───────────────────────────────
     Every other overlay here is genuinely modal: the menu and the rules are
     things you read INSTEAD of the table. The result is not. It is the one
     moment in a hand when all four hands are face up, and it used to be shown
     over a 86%-opaque blackout with a card parked in the middle — so the
     scoring was explained to you while the thing being scored was hidden.

     So: no backdrop, and the card sits along an edge with the felt shrunk to
     clear it. Which edge is the viewport's decision (see the two queries),
     and view.js re-runs fit() when this opens or closes so the canvas is
     resized to whatever is left. `pointer-events` is dropped on the sheet and
     restored on the card, so the exposed table is not covered by an invisible
     click blocker. */
  :root { --res-w: 400px; }

  #result { background: transparent; padding: 0; pointer-events: none; }
  #result .card {
    pointer-events: auto;
    border-radius: 0;
    box-shadow: 0 0 46px rgba(0,0,0,.7);
  }

  @media (min-width: 1000px) {
    #result { justify-content: flex-end; align-items: stretch; }
    #result .card {
      width: var(--res-w); max-width: var(--res-w); max-height: 100vh;
      border-width: 0 0 0 3px;
    }
    body.result-open #wrap {
      width: calc(100% - var(--res-w));
      margin-right: var(--res-w);
    }
  }
  @media (max-width: 999px) {
    #result { align-items: flex-end; }
    #result .card {
      width: 100%; max-width: none; max-height: 56vh;
      border-width: 3px 0 0;
    }
  }

  /* The rules page is a read, not a reference card — give it room. */
  #rules .card { max-width: 720px; }
  #rules .card h3 {
    color: var(--gold); font-size: .95rem; margin: 18px 0 7px;
    padding-bottom: 4px; border-bottom: 1px solid #3a2d20;
  }
  /* Pulled-out passages: the four-line summary at the top, and the note for
     players arriving from the international rules. */
  .rulebox {
    background: #1a140e; border: 2px solid #55432e; border-left: 4px solid var(--gold);
    border-radius: 3px; padding: 10px 12px 4px; margin: 10px 0 4px;
  }
  .rulebox > b { color: var(--gold); font-family: var(--font-display); letter-spacing: .04em; }
  .rulebox p:last-child, .rulebox ol:last-child { margin-bottom: 8px; }
  /* Secondary asides — true, but not needed on a first read. */
  .card p.note {
    font-size: .76rem; color: var(--dim); border-left: 2px solid #3a2d20;
    padding-left: 9px; margin-left: 1px;
  }

  .field { display: flex; align-items: center; gap: 8px; margin: 8px 0; font-size: .82rem; flex-wrap: wrap; }
  .field label { min-width: 120px; color: var(--ink-dim); }
  .seg { display: flex; gap: 4px; }
  .seg button.on { background: var(--gold); color: #241c14; border-color: #9a7526; }
  /* ⚠️ A SEVEN-BUTTON SEG HAS TO WRAP, and it has to opt out of the phone rule
     at 1007 that makes every seg button `flex: 1 1 0`. That rule is right for
     the two- and three-button segs it was written for; applied to the tune
     picker it crushes seven titles onto one line and every one of them reads
     as a sliver. `0 1 auto` lets a title be as wide as its own two characters
     and pushes the overflow onto a second row, which is what the field has
     room for. */
  .seg.wrap { flex-wrap: wrap; }
  .seg.wrap button { flex: 0 1 auto; }
  /* ⚠️ `type=email` IS NOT `type=text` TO A SELECTOR. The register level's
     address field was the one input in the game wearing the browser's default
     white box — narrower and brighter than the four fields stacked around it —
     because every input rule in this file was written before that field
     existed and named `text` and `password` only. Anything that grows a third
     input type has to be added here, at `#acctDialog` below, at the recessed
     slot in THE MENU SKIN, and at the `pointer: coarse` tap floor. */
  input[type=text], input[type=email] {
    font-family: inherit; font-size: .82rem; background: #1a140e; color: var(--ink);
    border: 2px solid #4a3b2a; border-radius: 3px; padding: 5px 8px; width: 150px;
  }
  input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--gold); }

  table.faan { width: 100%; border-collapse: collapse; font-size: .8rem; margin: 6px 0 10px; }
  table.faan td { padding: 3px 6px; border-bottom: 1px solid #3a2d20; }
  table.faan td:last-child { text-align: right; color: var(--gold); font-weight: 700; white-space: nowrap; }
  table.faan tr.total td { border-top: 2px solid #55432e; border-bottom: none; color: var(--ink); font-weight: 700; padding-top: 6px; }

  /* Searchable faan reference */
  .faan-search { display: flex; gap: 6px; margin: 6px 0 8px; }
  .faan-search input { flex: 1 1 auto; width: auto; min-width: 0; }
  .faan-none { display: none; font-size: .8rem; font-style: italic; color: var(--ink-dim); }
  .faan-none.show { display: block; }
  table.faan .ex { color: var(--ink-dim); font-size: .7rem; line-height: 1.45; margin-top: 3px; opacity: .85; }
  table.faan tr.off { opacity: .45; }
  /* The worked hand, drawn with the real tile art.
     ⚠️ CAPPED BY WIDTH, and that is what keeps every hand on this page drawn
     at the same tile size: the strips are `tight`, so their HEIGHT is one row
     or two depending on the hand, but their width is always the same nine
     columns. Cap the height instead and a pung and a fourteen-tile hand come
     out at two different scales. See `tileStrip` in faanexamples.js. */
  table.faan .faanart { margin-top: 4px; }
  table.faan .faanart canvas {
    display: block; width: auto; height: auto; max-width: min(100%, 270px);
    image-rendering: pixelated; image-rendering: crisp-edges;
  }

  .chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
  .chip {
    flex: 1 1 110px; background: #1a140e; border: 2px solid #3a2d20; border-radius: 3px;
    padding: 6px 8px; font-size: .76rem;
  }
  .chip b { display: block; color: var(--ink); font-size: .82rem; }
  .chip .up { color: #5fce85; } .chip .down { color: #e2705f; }

  .tiles-row { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 3px; margin: 6px 0 10px; }
  .tiles-row canvas { image-rendering: pixelated; border-radius: 2px; display: block; }
  .tiles-row .wintile { padding-bottom: 4px; border-bottom: 3px solid var(--gold); }
  .tiles-row .wintile::after {
    content: attr(data-label); display: block; text-align: center;
    font-size: .6rem; color: var(--gold); margin-top: 2px; letter-spacing: .04em;
  }
  .tiles-row .gap { width: 10px; }

  /* Post-hand analysis + alternate-parse disclosure */
  .analysis {
    border-top: 2px solid #3a2d20; margin-top: 10px; padding-top: 8px;
    font-size: .76rem; line-height: 1.65; color: var(--ink-dim);
  }
  .analysis h4 { font-size: .78rem; color: var(--ink); margin-bottom: 4px; font-weight: 700; }
  .analysis .row { display: flex; gap: 6px; padding: 2px 0; align-items: baseline; flex-wrap: wrap; }
  .analysis .who { color: var(--ink); min-width: 62px; font-weight: 700; }
  .analysis .near { color: var(--gold); }
  .analysis .tenpai { color: #5fce85; font-weight: 700; }

  /* The saved-hand receipt. `word-break` because the returned id is opaque
     base64url and would otherwise push the docked card wider than the felt. */
  #resShare {
    margin-top: 8px; font-size: .72rem; line-height: 1.6; color: var(--ink-dim);
    padding: 6px 8px; background: #1a140e; border-left: 3px solid var(--gold);
    border-radius: 2px;
  }
  #resShare a { color: var(--gold); word-break: break-all; }

  /* Why the chips moved the way they did — now ASKED FOR rather than printed.
     Sits directly under the four chip cards, because that is the thing it is
     explaining, and it is one ⓘ high until somebody presses it. */
  .paywhy { position: relative; margin: -2px 0 8px; }
  .paywhy-btn {
    width: 22px; height: 22px; padding: 0;
    font-size: .8rem; font-weight: 700; font-style: italic; line-height: 1;
    color: var(--gold); background: #1a140e; border: 2px solid #4a3b2a;
    cursor: pointer;
  }
  .paywhy-btn:hover { border-color: var(--gold); background: #241c11; }
  /* Held while the bubble is open, so the button reads as the thing that opened
     it rather than as a control that did nothing the second time. */
  .paywhy-btn.on { border-color: var(--gold); background: #241c11; }

  /* The bubble. IN FLOW, not floating: the card scrolls and clips, so an
     absolutely positioned bubble two lines from the top edge would be cut in
     half on a phone. It pushes the 計數 working down instead, which is what a
     disclosure is supposed to do.

     The tail is two solid steps rather than a rotated square — everything on
     this screen is plotted in whole pixels, and a 45° CSS triangle is the one
     shape that gives that away. It has no border of its own for the same
     reason the bubble's border is only on the left: a stepped outline drawn in
     borders shows its own internal joins.

     ⚠️ IT IS `--panel-hi`, AND IT HAS TO BE LIGHTER THAN THE CARD. It first
     shipped on `#241c11`, which is `--panel` (#241c14) to within three points
     of blue — the slab and its whole tail were invisible against the card
     behind them, so the "bubble" was a paragraph with a gold bar next to it.
     A thing that just appeared has to be lit differently from the thing it
     appeared on top of; the two neighbouring slabs here are DARKER than the
     card, which is what makes them read as recessed panels rather than as
     something somebody said. */
  .paybubble {
    position: relative;
    font-size: .72rem; line-height: 1.55; color: var(--ink-dim);
    margin: 10px 0 8px; padding: 6px 9px;
    background: var(--panel-hi); border-left: 3px solid var(--gold);
  }
  .paybubble b { color: var(--gold); }
  .paybubble::before, .paybubble::after {
    content: ''; position: absolute; background: var(--panel-hi);
  }
  .paybubble::before { top: -4px; left: 4px; width: 15px; height: 4px; }
  .paybubble::after  { top: -8px; left: 7px; width: 9px;  height: 4px; }

  /* 計數 — the working behind the payment. Deliberately a LEDGER, not prose:
     label left, figure right, the collect line ruled off underneath, so the
     arithmetic can be checked at a glance instead of read. Nearly the same slab
     as .paybubble because they are two halves of one explanation — the bubble
     is a shade warmer only because it is a thing that just opened. */
  .paycalc {
    font-size: .72rem; line-height: 1.5; color: var(--ink-dim);
    margin: -2px 0 8px; padding: 6px 8px;
    background: #1a140e; border-left: 3px solid #4a3b2a; border-radius: 2px;
  }
  .paycalc .pc-head {
    display: block; color: var(--gold); margin-bottom: 3px;
    letter-spacing: .04em;
  }
  .paycalc .pc-row {
    display: flex; justify-content: space-between; gap: 10px;
    padding: 1px 0;
  }
  .paycalc .pc-row b { color: var(--ink); font-variant-numeric: tabular-nums; }
  /* A payment is negative here so the column adds up in front of the reader.
     Same red as the chip card above it — the two are the same fact, and giving
     one figure two colours is how a reader concludes they are two figures. */
  .paycalc .pc-row b.pc-minus { color: #e2705f; }
  /* The reason a row differs from the row above it — supporting detail, so it
     must not compete with the figure it is explaining. */
  .paycalc .pc-row i { color: var(--dim); font-style: normal; }
  /* Why the table owes more than the rung: a sentence, with no figure of its
     own. It sits between the value and the seats that make it up, so it is
     indented to read as a note on the line above rather than as a third
     quantity. `display: block` overrides `.pc-row`'s flex — there is nothing
     to push to the right. */
  .paycalc .pc-why {
    display: block; color: var(--dim); padding: 0 0 2px 10px;
  }
  .paycalc .pc-total {
    border-top: 1px solid #4a3b2a; margin-top: 3px; padding-top: 3px;
  }
  .paycalc .pc-total b { color: var(--gold); }

  .altparse {
    font-size: .72rem; color: var(--ink-dim); margin: -4px 0 8px;
    padding: 5px 8px; background: #1a140e; border-left: 3px solid #4a3b2a; border-radius: 2px;
  }
  .altparse b { color: var(--ink); }

  /* The 流局 verdict. Two colours because they are two different lessons:
     green means the hand was right and the draw was luck, gold means the hand
     was never going to clear the table minimum. Red was deliberately avoided —
     "you aimed too low" is information, not a failure. */
  .verdict {
    font-size: .76rem; line-height: 1.6; color: var(--ink-dim);
    margin: 2px 0 8px; padding: 7px 9px; background: #1a140e;
    border-left: 4px solid var(--gold); border-radius: 2px;
  }
  .verdict b { color: var(--ink); }
  .verdict.good { border-left-color: var(--green); }
  .verdict.good b { color: #5fce85; }
  .verdict.short { border-left-color: var(--gold); }
  .verdict.short b { color: var(--gold); }
  /* A dead wait is the one case that really was unwinnable, so it gets the
     only cool colour on the screen — distinct from both "unlucky" and
     "aimed too low". */
  .verdict.dead { border-left-color: #5a86b8; }
  .verdict.dead b { color: #8fb8e0; }

  /* A tile you were waiting on, at 流局. Same call-out the winning tile gets,
     dashed rather than solid — it is the tile that did not arrive. */
  .tiles-row .wintile.wait { border-bottom-style: dashed; opacity: .92; }
  .tiles-row .wintile.wait::after { color: var(--ink-dim); }

  .streak {
    display: inline-block; background: var(--red); color: #fff; font-weight: 700;
    font-size: .68rem; padding: 1px 6px; border-radius: 2px; margin-left: 6px;
    letter-spacing: .04em;
  }

  .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }

  [hidden] { display: none !important; }

  /* ── Coach strip — the tutorial's voice. Sits above the table so the whole
        board stays visible while it talks. ───────────────────────────────── */
  #coach {
    width: 100%; display: flex; gap: 10px; align-items: flex-start;
    background: #1b2a20; border: 2px solid #3d5a45; border-left: 5px solid var(--gold);
    border-radius: 3px; padding: 8px 10px;
  }
  #coach .body { flex: 1 1 auto; min-width: 0; }
  #coach .step { color: var(--gold); font-size: .72rem; font-weight: 700; letter-spacing: .05em; }
  /* The step's own name, under the chapter. Separated from `.txt` by weight
     rather than by a colour of its own — the alt theme at the bottom of this
     file remaps --gold and --ink, and a literal here would not follow it.
     Gold stays on the chapter, because five chapters is the promise the
     player is tracking and the step is only where they are inside it. */
  #coach .head { color: var(--ink); font-size: .88rem; font-weight: 700; margin-top: 1px; }
  #coach .head:empty { display: none; }
  /* ⚠️ .92rem, up from .80. The lesson is the one screen in the game a player
     reads WORD BY WORD rather than glancing at, and it was set two notches
     smaller than the tips box — the tutorial was the smallest prose in the
     game and the only prose that has to be understood the first time.

     It costs vertical space, and the coach is pinned above the felt, so the
     board moves down: `H.fit()` reflows for that already and `tut_test.py`,
     `portrait.py` and `homefit.py` are what prove the table still fits. */
  #coach .txt  { color: var(--ink); font-size: .92rem; line-height: 1.55; margin-top: 3px; }
  #coach .nudge { color: #ffcf7a; font-size: .86rem; line-height: 1.5; margin-top: 5px; font-weight: 700; }
  #coach .nudge:empty { display: none; }

  /* ── Emphasis in the lesson ─────────────────────────────────────────────
     Written as `*bold*` and `_key_` in the script and turned into markup by
     `markup()` in tutorslides.js. Both live here rather than in THE MENU SKIN
     because the skin may not set a layout property and weight is one.

     The key phrase is a CHIP, not just a colour: at .92rem on a dark strip a
     recoloured word is easy to skim past, and the whole point of the mark is
     that the eye lands on it before it starts reading the sentence. One per
     step — two chips is no chip. */
  #coach .txt b, #coach .nudge b, #tutslide > .card > p b, .slide-say b,
  .slide-ask b, .slide-note b, .tally-why b { color: #fff3d6; font-weight: 700; }
  /* ⚠️ AND BACK OFF THE DISPLAY FACE. `#coach b` is in the chrome rule near the
     top of this file — the one whose own comment says "body copy deliberately
     stays on the sans" — and it was matching nothing, because the coach strip
     had no <b> in it until the lesson gained emphasis. The first bold word the
     tutorial ever printed therefore came out in the pixel display face with
     .045em tracking, in the middle of a sans sentence: "d r a w   o n e".
     Higher specificity, and lower in the file, so this wins both ways. */
  #coach .txt b, #coach .nudge b { font-family: inherit; letter-spacing: normal; }
  .tut-key {
    font-style: normal; font-weight: 700;
    color: #23180a; background: var(--gold);
    padding: 0 5px; border-radius: 2px;
    box-decoration-break: clone; -webkit-box-decoration-break: clone;
  }
  #coach .side { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; flex: 0 0 auto; }
  #coach .pips { display: flex; gap: 4px; }
  #coach .pips i { width: 8px; height: 8px; border-radius: 50%; background: #35503c; display: block; }
  #coach .pips i.done { background: var(--green); }
  #coach .pips i.now  { background: var(--gold); }
  /* Wide enough that "Next" and "Finish" do not resize the strip between
     steps — the coach is pinned above the table and a reflow there moves the
     whole board down by a few pixels mid-sentence. */
  #coach .side button { min-width: 74px; }

  /* ── Accessible tile bar — one real <button> per tile in your hand.
        Doubles as the phone tap target: a 24px canvas tile is not tappable,
        and suit is carried by SHAPE as well as hue, because red/green is the
        worst pair you can hand a colourblind player. ─────────────────────── */
  #tilebar {
    width: 100%; display: flex; gap: 4px; flex-wrap: wrap; justify-content: center; padding: 1px 0;
  }
  #tilebar button {
    min-width: 40px; min-height: 46px; padding: 3px 4px;
    display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
    font-size: .88rem; background: #f0e6cd; color: #22201c;
    border: 2px solid #8a7550; border-bottom-width: 3px; line-height: 1.05;
  }
  #tilebar button:hover:not([aria-disabled="true"]) { background: #fffaea; }
  #tilebar button .sub { font-size: .58rem; letter-spacing: .08em; color: #6b5735; font-weight: 700; }
  #tilebar button.tb-m { border-top: 5px solid  #2b2b2b; }
  #tilebar button.tb-p { border-top: 5px dotted #25538f; }
  #tilebar button.tb-s { border-top: 5px double #1f6b3a; }
  #tilebar button.tb-z { border-top: 5px dashed #8a6a1f; background: #e4d9bd; }
  #tilebar button.sel { background: var(--gold); border-color: #7d5d1c; }
  #tilebar button[aria-disabled="true"] { opacity: .5; }
  /* ── "Press THIS one" ──────────────────────────────────────────────────
     The tutorial names a button in prose and marks it here, and for a long
     time the mark was a 3px gold outline and nothing else. On the claim steps
     that is `Pung 9 Bamboo` outlined in gold beside `Pass` outlined in brown,
     at 13px, on a dark cluster on a green table — a difference you can see
     once you already know which one to look for, which is the one thing a
     beginner does not.

     So the wanted control is now the only lit thing in its group: it is
     FILLED gold with dark text, it carries a ▸ pointing at itself, it wears a
     halo, and `haswant` stands its neighbours down to 45%. Three signals, one
     direction. The `.want` class name is unchanged — tut_test.py asks the
     page which button the lesson wants by reading it. */
  #actions button.want, #tilebar button.want, #handrack button.want {
    outline: 3px solid var(--gold); outline-offset: 2px;
    box-shadow: 0 0 0 6px rgba(216,165,68,.22);
  }
  #actions button.want {
    background: var(--gold); border-color: #f0d18a; color: #17120a; font-weight: 700;
  }
  /* The caret sits OUTSIDE the button, in the padding the cluster already has,
     so it cannot reflow the row or push the label onto a second line. */
  #actions button.want::before {
    content: '\25B8'; position: absolute; left: -14px; top: 50%;
    transform: translateY(-50%); color: var(--gold); font-size: .9rem; line-height: 1;
  }
  #actions button { position: relative; }
  /* Everything the lesson is NOT asking for. Still legible, still clickable —
     a nudge explains a wrong press, and reading that nudge is part of the
     lesson — but no longer competing for the eye. */
  #actions.haswant button:not(.want),
  #tilebar.haswant button:not(.want),
  #handrack.haswant button:not(.want) { opacity: .45; }

  @media (prefers-reduced-motion: no-preference) {
    #tilebar button.sel { transform: translateY(-4px); transition: transform .12s ease-out; }
    #tilebar button.want, #actions button.want { animation: wantpulse 1.2s ease-in-out infinite; }
    #coach { transition: background-color .2s linear; }
  }
  /* Both rings breathe together — the outline alone was a 1px-wide event at
     arm's length on a phone. */
  @keyframes wantpulse {
    0%, 100% { outline-color: var(--gold); box-shadow: 0 0 0 6px rgba(216,165,68,.22); }
    50%      { outline-color: rgba(216,165,68,.40); box-shadow: 0 0 0 10px rgba(216,165,68,.05); }
  }

  /* ── The portrait hand rack — your tiles, out of the canvas ─────────────
     The table is one landscape board scaled uniformly, so on a portrait
     phone the width binds and two thirds of the page is felt-coloured
     nothing. This rack is what that space is for: real tile faces at a real
     tap size, pinned to the bottom edge where a thumb already is.

     It GROWS. handrack.js reserves a minimum height from fit(), and whatever
     the canvas then leaves over — a rounding step in the scale, a shorter
     action bar than measured — is spread between the rows by
     `align-content`, rather than left as a black band under the table. */
  #handrack {
    width: 100%; flex: 1 1 auto;
    /* The name plate below is positioned against this box. */
    position: relative;
    display: flex; flex-wrap: wrap;
    justify-content: center; align-content: space-evenly;
    column-gap: 5px; row-gap: 5px;
    padding: 6px 6px calc(6px + env(safe-area-inset-bottom, 0px));
  }
  /* Only while the rack is live: #wrap has to fill the viewport for the rack
     to have anything to grow into, and on every other layout that would
     stretch a page that is currently content-sized. */
  body.rackon #wrap { flex: 1 1 auto; }
  /* ── The tapped tile's name ────────────────────────────────────────────
     A phone has no hover, so this is the portrait stand-in for the little
     plate the canvas rack draws over a hovered tile — and it is deliberately
     the SAME OBJECT: dark plate, gold hairline, a tail pointing down at the
     tile it names.

     ⚠️ IT SITS OVER ITS TILE, not at the top of the rack. It was a full-width
     row up there, which put the reading as far from the tile as it could get
     on the one screen where the tile IS the interface — you tapped 六萬 and
     the answer appeared somewhere else. handrack.js measures the button and
     sets `left`/`top`; `translate(-50%, 0)` is why it can hand over a CENTRE
     rather than a corner.

     ⚠️ handrack.js still reserves NAME_H in the height it asks fit() for,
     even though this no longer takes any. That room is now clearance above
     the top row rather than a row of its own, and a reserve that stayed
     honest is the reason moving it could not push a row of tiles under the
     home bar.

     `pointer-events: none` because it overlaps its own tile, and the second
     tap on that tile is the discard. */
  #handrack .hrname {
    position: absolute; left: 0; top: 0; z-index: 3;
    transform: translate(-50%, 0);
    height: 20px; line-height: 16px; padding: 1px 7px; margin: 0;
    white-space: nowrap; pointer-events: none;
    text-align: center; font-size: .8rem; font-weight: 700;
    letter-spacing: .02em; color: var(--gold);
    background: rgba(12, 9, 7, .94); border: 1px solid var(--gold);
  }
  /* The tail. Flipped to the top edge when the plate had to go below its tile
     — the top row of the rack is right under the action bar, and a plate
     drawn off the top of the rack is a plate nobody reads.

     `--tail` is set by handrack.js and is usually the middle. It moves when a
     plate at the end of a row has been clamped inside the rack's edge: the
     words stop, the tail carries on to the tile they are about. */
  #handrack .hrname::after {
    content: ''; position: absolute; left: var(--tail, 50%); margin-left: -3px;
    bottom: -4px; width: 7px; height: 3px; background: rgba(12, 9, 7, .94);
  }
  #handrack .hrname.below::after { bottom: auto; top: -4px; }
  /* Nothing selected, nothing to name. The element STAYS — `portrait.py`
     reads its text, and an element that comes and goes is one a suite cannot
     ask a question about. */
  #handrack .hrname.empty { display: none; }
  #handrack button {
    flex: 0 0 auto; position: relative;
    width: var(--hrw, 48px); height: var(--hrh, 72px);
    padding: 0; border: 0; background: none; line-height: 0;
    cursor: pointer;
  }
  /* Nearest-neighbour, because a 28px pixel-art face blown up to 70px must
     stay pixel art. This is the same rule the canvas runs under. */
  #handrack button canvas {
    display: block; width: 100%; height: 100%;
    image-rendering: pixelated; image-rendering: crisp-edges;
  }
  #handrack button.sel { outline: 3px solid var(--gold); outline-offset: 2px; }
  #handrack button.want { outline: 3px solid var(--gold); outline-offset: 2px; }
  #handrack button:focus-visible { outline: 3px solid var(--ink); outline-offset: 2px; }
  /* The tile you have just drawn. It is held apart from the rack at a real
     table; here the rows are solved to the pixel, so it is marked instead of
     moved — a gap would drop a tile onto the next row. */
  #handrack button.drawn::after {
    content: ''; position: absolute; left: 0; right: 0; bottom: -4px;
    height: 3px; background: var(--gold);
  }
  #handrack button[aria-disabled="true"] { opacity: .55; }
  @media (prefers-reduced-motion: no-preference) {
    #handrack button.sel { transform: translateY(-6px); transition: transform .12s ease-out; }
    #handrack button.want { animation: wantpulse 1.2s ease-in-out infinite; }
  }

  /* ── Run summary ─────────────────────────────────────────────────────── */
  table.placings { width: 100%; border-collapse: collapse; font-size: .82rem; margin: 8px 0 4px; }
  table.placings th { color: var(--ink-dim); font-size: .7rem; font-weight: 700; letter-spacing: .04em; }
  table.placings th, table.placings td { padding: 5px 6px; border-bottom: 1px solid #3a2d20; text-align: left; }
  table.placings th.n, table.placings td.n { text-align: right; font-family: ui-monospace, Consolas, monospace; }
  table.placings td.n { color: var(--gold); }
  table.placings td.n.down { color: #e2705f; }
  table.placings td.n.up   { color: #5fce85; }
  table.placings tr.you td { color: var(--ink); font-weight: 700; }

  /* ── The hand panel ─────────────────────────────────────────────────────
     A running faan count and the tiles that would help. Fixed to the bottom
     right rather than placed in the document, because the canvas scales in
     integer steps: anything that takes page height can cost the table a whole
     step. This costs it none. */
  #handpanel {
    position: fixed; right: 10px; bottom: 10px; z-index: 22;
    width: min(232px, 42vw);
    background: rgba(20, 15, 11, .95);
    border: 2px solid #55432e; border-radius: 4px;
    padding: 7px 9px 8px;
    font-size: .72rem; color: var(--ink-dim);
    box-shadow: 0 8px 22px rgba(0,0,0,.6);
  }
  #handpanel[hidden] { display: none; }
  #handpanel header { padding: 0; border: none; }
  /* The header IS the collapse control. Clicking a panel's title bar to fold
     it is the one gesture nobody has to be taught, and — more importantly —
     it means the way back in is the same thing you clicked to get out. A bare
     × in the corner hides the panel and takes its own affordance with it. */
  .hptoggle {
    display: flex; align-items: center; justify-content: space-between;
    gap: 6px; width: 100%;
    background: none; border: none; border-bottom: 1px solid #3a2d20;
    border-radius: 0; padding: 0 0 4px; cursor: pointer; min-height: 0;
    text-align: left;
  }
  .hptoggle:hover { background: none; }
  .hptoggle:hover b { color: var(--gold); }
  .hptoggle b {
    font-family: var(--font-display); color: var(--ink);
    font-size: .78rem; letter-spacing: .04em;
  }
  .hpx { color: var(--dim); font-size: 1rem; line-height: 1; font-weight: 700; }
  .hptoggle:hover .hpx { color: var(--gold); }

  /* Collapsed: the panel becomes its own title bar. Still on screen, still
     labelled, one click from being back — which is the whole point. */
  #handpanel.collapsed { width: auto; padding: 5px 8px; }
  #handpanel.collapsed .hptoggle { border-bottom: none; padding-bottom: 0; }
  #handpanel.collapsed #hpBody { display: none; }

  /* The panel grows with the hand — the routes section adds up to five rows of
     two lines each — and it is anchored by its BOTTOM edge, so anything it
     cannot fit grows upward and off the top of the window. That is the one
     failure mode worse than being too tall: the faan total and the title bar
     are the first things to leave, and the way back in goes with them.
     A ceiling plus an internal scroll keeps the bar reachable at every height.
     `overscroll-behavior` so a flick inside the panel never scrolls the page
     out from under the table. */
  #hpBody {
    max-height: calc(100vh - 190px); overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* The number is the point of the panel, so it is the biggest thing in it. */
  .hpvalue { display: flex; align-items: baseline; gap: 7px; margin: 6px 0 4px; }
  .hpvalue .n {
    font-family: ui-monospace, Consolas, monospace;
    font-size: 1.5rem; font-weight: 700; color: var(--gold); line-height: 1;
  }
  .hpvalue .lbl { font-size: .68rem; color: var(--dim); letter-spacing: .04em; }
  #handpanel.ready   .hpvalue .n { color: #5fce85; }
  #handpanel.short   .hpvalue .n { color: #e2705f; }
  #handpanel.building .hpvalue .n { color: var(--ink-dim); }

  /* One row per faan, named the way the rules name it — the panel is the
     teaching surface, so it uses the real vocabulary rather than a summary. */
  table.hprows { width: 100%; border-collapse: collapse; font-size: .68rem; }
  table.hprows td { padding: 1px 0; vertical-align: baseline; }
  table.hprows td.v {
    text-align: right; color: var(--gold); font-weight: 700; white-space: nowrap;
    font-family: ui-monospace, Consolas, monospace;
  }
  table.hprows td .en { color: var(--dim); font-size: .64rem; margin-left: 4px; }
  table.hprows tr.dim td { opacity: .62; }

  .hpverdict {
    margin-top: 5px; padding-top: 5px; border-top: 1px solid #3a2d20;
    font-size: .66rem; line-height: 1.45; color: var(--ink-dim);
  }
  .hpverdict b { color: var(--ink); }
  #handpanel.ready .hpverdict b { color: #5fce85; }
  #handpanel.short .hpverdict b { color: #e2705f; }
  /* The throw the reading assumes, while you are holding fourteen tiles. A
     condition on everything above it, so it is set apart rather than inline. */
  .hpverdict .hpassume {
    display: block; margin-top: 3px; color: var(--gold);
    font-size: .62rem; letter-spacing: .03em;
  }

  /* ── Ways to get there ──────────────────────────────────────────────────
     The routes to the faan minimum. Same two-column shape as .hprows above,
     deliberately: what you HAVE and what you could GET should read as the same
     kind of statement, so the eye carries the faan column straight down. The
     difference is the second line — a price in tiles, which the banked rows
     never need because they are already paid for. */
  table.hptargets { width: 100%; border-collapse: collapse; font-size: .68rem; }
  table.hptargets td { padding: 2px 0; vertical-align: baseline; }
  table.hptargets td.v {
    text-align: right; color: var(--gold); font-weight: 700; white-space: nowrap;
    font-family: ui-monospace, Consolas, monospace;
  }
  table.hptargets .cost {
    display: block; color: var(--dim); font-size: .61rem; line-height: 1.35;
  }
  /* ── What to look for ───────────────────────────────────────────────────
     The tiles a route is actually asking for, under its price. The price says
     how far; this says which direction, and it is the half a player can act
     on. Faces rather than names: this list is read against the pond and the
     racks, where there are no names to read.

     Laid out as a flowing line rather than a grid — one row wants one tile and
     另一 wants three, and a grid would reserve the space for three on every
     row of a panel that has to sit in the corner of the felt. */
  table.hptargets .want {
    display: flex; align-items: center; flex-wrap: wrap; gap: 3px;
    margin-top: 2px;
  }
  table.hptargets .want .wl {
    color: var(--dim); font-size: .58rem; letter-spacing: .04em;
  }
  table.hptargets .want .wt {
    display: inline-flex; align-items: center; gap: 1px;
    background: #1a140e; border: 1px solid var(--gold); border-radius: 2px;
    padding: 1px 2px;
  }
  /* Same reason as .hptiles below: this canvas is drawn at 32x48 and shown at
     16x24, so it is a DOWNSCALE, and `pixelated` on a downscale throws away
     every other pixel instead of averaging it. */
  table.hptargets .want .wt canvas { display: block; }
  /* How many MORE of it. Monospace and gold like every other count in the
     panel, so the eye reads it as a quantity and not as part of the tile. */
  table.hptargets .want .wt i {
    font-style: normal; font-size: .55rem; font-weight: 700; color: var(--gold);
    font-family: ui-monospace, Consolas, monospace;
  }
  /* A tile whose four copies are all visible. The ROUTE can still be live —
     one dead tile out of three does not shut a 對對糊 — so this dims the tile
     and never the row, which is the opposite of the tr.dead rule below. */
  table.hptargets .want .wt.gone { opacity: .4; border-color: #4a3b2a; }
  table.hptargets .want .wt.gone i { color: #e2705f; }
  /* A family to keep rather than a tile to hunt — the flushes, 字一色, 清老頭.
     Text, because there is nothing to draw: every tile of the suit serves it. */
  table.hptargets .keep {
    display: block; margin-top: 2px; color: var(--gold);
    font-size: .58rem; line-height: 1.35; letter-spacing: .02em;
  }
  table.hptargets tr.dead .keep, table.hptargets tr.dead .want { display: none; }

  /* The route that clears the minimum ON ITS OWN. Marked rather than merely
     ordered, because "cheapest" and "sufficient" are different questions and
     the panel is sorted by the first one. Green is the same green the panel
     already uses for a hand that can be declared, which is exactly what this
     row is a route to. */
  table.hptargets tr.solo td { color: var(--ink); }
  table.hptargets tr.solo td.v { color: #5fce85; }
  table.hptargets tr.solo td.v::after {
    content: ' ✓'; font-size: .6rem;
  }
  /* Shut doors. Dimmed, and the NAME struck through — not the row: the reason
     line under it is the whole value of showing a dead route at all, and
     text-decoration on the cell propagates into children that cannot refuse
     it. So the strike goes on the name only. */
  table.hptargets tr.dead td { opacity: .5; }
  table.hptargets tr.dead .nm { text-decoration: line-through; }
  table.hptargets tr.dead td.v { color: var(--dim); }
  table.hptargets tr.dead td.v::after { content: ''; }

  /* The recipe line. Green like the ✓ above and like a declarable hand, because
     it is the same promise: this adds up to the minimum. Set off with its own
     rule so it reads as a conclusion drawn from the rows rather than as one
     more row. */
  .hpplan {
    margin: 5px 0 0; padding-top: 4px; border-top: 1px dashed #3a2d20;
    font-size: .63rem; line-height: 1.45; color: var(--ink-dim);
  }
  .hpplan[hidden] { display: none; }
  /* Everything respecified rather than inherited, so a future change to the
     section-title rule cannot reach in here again. */
  .hpplan b {
    display: inline; font-family: inherit; font-size: inherit;
    letter-spacing: normal; margin: 0; color: #5fce85; font-weight: 700;
  }
  .hpplan .pf { color: var(--gold); font-weight: 700; }
  .hpplan .pc { color: var(--dim); }

  .hphelp { margin-top: 6px; padding-top: 5px; border-top: 1px solid #3a2d20; }
  /* `>` deliberately: this is the SECTION TITLE, and as a descendant selector it
     also caught the <b>s inside the recipe line below — handing them the pixel
     display font and `display: block`, which put every faan name on a line of
     its own with the "+" stranded between them. */
  .hphelp > b {
    display: block; font-family: var(--font-display); color: var(--ink);
    font-size: .7rem; letter-spacing: .04em; margin-bottom: 4px;
  }
  .hptiles { display: flex; flex-wrap: wrap; gap: 4px; }
  /* Each useful tile with how many are still out there. Drawn from the real
     tile art, so what you are hunting for looks like what is on the table. */
  .hptiles .t {
    display: flex; flex-direction: column; align-items: center; gap: 1px;
    background: #1a140e; border: 1px solid #4a3b2a; border-radius: 2px;
    padding: 2px 3px;
  }
  /* ⚠️ NO `image-rendering: pixelated` here, and it is not an oversight —
     this is the one canvas in the game that is drawn BIGGER than it is shown.
     handpanel.js renders 56x84 of art into a 28x42 cell, so the browser is
     downscaling, and `pixelated` makes a downscale nearest-neighbour: it
     discards every other pixel instead of averaging it, which throws away the
     entire point of drawing at double size. Everywhere else the art is shown
     at or above its own size, an upscale, and there `pixelated` is exactly
     right — hence the keyword on every other tile canvas in this file. */
  .hptiles .t canvas { display: block; }
  .hptiles .t span {
    font-size: .58rem; color: var(--gold); font-weight: 700;
    font-family: ui-monospace, Consolas, monospace;
  }
  /* Which call the tile would make. Dimmer than the count — the number is
     what you act on, the glyph only says what you would be shouting. */
  .hptiles .t .k {
    font-family: var(--font-display); font-size: .56rem;
    color: var(--ink-dim); font-weight: 400; letter-spacing: .04em;
  }
  /* A tile you can CLAIM, not just draw. Now that the two lists are one, the
     badge alone was doing all the work of distinguishing them and a glyph at
     .56rem is not enough to catch an eye that has a second to spare — so the
     cell is edged as well. Gold, because this is the one row on the panel that
     is an action rather than a reading. */
  .hptiles .t.call { border-color: var(--gold); background: #221a10; }
  .hptiles .t.call .k { color: var(--gold); }
  .hptiles .t.gone { opacity: .4; }
  .hptiles .t.gone span { color: #e2705f; }
  .hptiles .t.gone .k { color: var(--dim); }
  .hptiles .t.gone.call { border-color: #4a3b2a; }
  .hptiles .none { font-size: .66rem; color: var(--dim); font-style: italic; }

  /* ══ "TURN YOUR PHONE" ════════════════════════════════════════════════
     Shown once, on a phone held upright, while a hand is running. The
     numbers behind it, measured on a 390x844 / 844x390 device: portrait is
     width-bound at 0.625x, landscape height-bound at 0.875x — 40% more
     board for a quarter turn of the wrist.

     ⚠️ THE SAME QUERY THE HAND RACK USES, and that is not a coincidence: the
     rack's media query is the definition of "a phone held upright" in this
     stylesheet, and a nudge that appeared on a screen the rack did not
     consider portrait would be recommending a rotation that changes nothing.

     ⚠️ IN THE HEADER BAND, BESIDE THE ☰ — not over the felt. Mid-hand on a
     portrait phone that band is 50px of nothing: the masthead's <h1> measures
     0x0 there (the subtitle is off and the English title lives inside it) and
     the balance is hidden below 700px, so the only thing in it is one 44px
     button at the right-hand end. `right: 58px` is that button plus its
     margin. Over the felt instead — where this first sat — it covered the top
     wall and the seat plate opposite for nine seconds, which is a hint that
     takes the table away in order to offer you a bigger one.

     Fixed, so it costs the table no height. `body.playing` because at the
     menu there is no table to be bigger yet — the recommendation has to
     arrive when it is actionable, which is the moment the tiles land. */
  #rotatehint { display: none; }
  @media (orientation: portrait) and (max-width: 820px) {
    body.playing #rotatehint {
      display: flex; align-items: center; gap: 9px;
      position: fixed; z-index: 27;
      left: 6px; right: 58px; top: 3px;
      padding: 5px 9px; text-align: left;
      background: #241c11; border: 2px solid #9a7526; border-left-width: 5px;
      border-radius: 3px; box-shadow: 0 6px 18px rgba(0,0,0,.6);
      animation: rhin .35s ease-out;
    }
    /* ⚠️ (1,2,1), which BEATS the (1,1,1) rule above. `[hidden]` alone is
       (1,1,0) and loses to it, so the hint would be on screen from the first
       paint of every hand with nothing able to switch it off. */
    body.playing #rotatehint[hidden] { display: none; }
    #rotatehint .rh-ic { color: var(--gold); font-size: 1.25rem; line-height: 1; flex: 0 0 auto; }
    /* ⚠️ `flex: 1 1 auto` — WITHOUT IT THIS BOX IS 97px WIDE IN A 326px BAR.
       A flex item with `flex-basis: auto` sizes to its own content, and the
       content here is two block lines, so it settled on the width of the
       longest English one and the 中文 that is five characters longer than it
       wrapped inside it. That looked exactly like a wrap you fix by
       shortening the copy, and it is not: measured, the box was 97px in BOTH
       languages while ~297px of the bar sat empty beside it. Take the space,
       and there is nothing to wrap. */
    #rotatehint .rh-tx { display: block; flex: 1 1 auto; min-width: 0; }
    /* ⚠️ ONE LINE — see the note in index.html for why there is no second one.
       A 中文 line box runs ~28px against Latin's ~15 at this size, and a
       `line-height` on the block does not pull an inline CJK run down to it,
       so a two-line bar is ~50px in 中文 and does not fit the header band it
       sits in. Nothing here may grow to two lines without moving the bar. */
    #rotatehint b {
      display: block; color: var(--gold); font-family: var(--font-display);
      font-size: .74rem; letter-spacing: .03em; line-height: 1.25;
    }
  }
  @keyframes rhin { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
  @media (prefers-reduced-motion: reduce) { #rotatehint { animation: none; } }

  /* ══ THE TABLE SHEET ══════════════════════════════════════════════════
     Every seat's melds and discards, in the DOM, because on a portrait phone
     the felt is 0.625x and a meld tile is 17 real pixels across. The board
     cannot be made bigger there — the WIDTH binds, so the page's spare height
     buys nothing and there is nothing at the felt's edges to crop — which is
     the whole argument for reading it out here instead. tablesheet.js says it
     at length.

     Same sheet treatment as the hand panel below, deliberately: one gesture
     to learn, one shape to recognise, and one z-index rule — 22, UNDER
     `#actions.ontable` at 25, so a claim on a clock is never behind a thing
     you opened to read.

     Only the felt-sized screens get the button that opens it. On a desktop
     the table already shows all of this at a size you can read, and a second
     way to look at what is in front of you is clutter, not help. */
  #btnTable { display: none; }
  @media (max-width: 760px), (max-height: 470px) and (orientation: landscape) {
    #btnTable { display: inline-flex; }
  }

  /* ══ ⛶ FULL SCREEN ═════════════════════════════════════════════════════
     A phone spends 90-130px of its screen on an address bar and a gesture bar,
     and the felt is scaled in sixteenths — that band is a whole step of board
     in portrait and two in landscape, where the table is height-bound. The
     button that reclaims it is gated TWICE, and both gates matter:

       · the same folded-rail threshold `#btnTable` uses above, and here for a
         blunter reason than clutter. `.toolbar` is `flex-wrap: nowrap`, so a
         ninth item on a screen still wide enough to show labels is a rail that
         overflows the header rather than one that wraps. Below this threshold
         the rail is a dropdown COLUMN, which has no width to overflow — so
         this is the one toolbar item that never appears in the bar itself.

       · `body.canfull`, written by fullscreen.js only where the Fullscreen API
         exists. Safari on iPhone gives it to <video> and to nothing else, and
         a menu row that does nothing when tapped is worse than an absent one.

     ⚠️ `body.canfull #btnFull` IS WHAT MAKES THE `display: none` ABOVE
     BEATABLE. An id is (1,0,0) and a bare `#btnFull` inside the media query
     would tie with it and win only on source order — which is true here but
     stops being true the moment anything is inserted between them. The class
     takes it to (1,1,0) and it wins on specificity, the way `body.mp #btnHome`
     does at the top of this file. */
  #btnFull { display: none; }
  @media (max-width: 760px), (max-height: 470px) and (orientation: landscape) {
    body.canfull #btnFull { display: inline-flex; }
  }
  /* Two labels, one button, swapped on the pressed state rather than rewritten
     from JS — the label carries a 中文 gloss inside it, and a control that
     rebuilds bilingual markup in script is a control that will be monolingual
     by the second edit. Both are (1,1,0) or better, so neither the (0,2,0) that
     hides `.lbl` on the rail nor the (0,4,0) that brings it back in the
     dropdown reaches inside them. */
  #btnFull .lblon { display: none; }
  #btnFull[aria-pressed="true"] .lbloff { display: none; }
  #btnFull[aria-pressed="true"] .lblon { display: inline; }
  /* Lit while it is on, the same way the hand panel's toggle is. This is the
     only toolbar item whose effect is invisible from inside it — everything
     else opens something you can see — so the row itself has to say so. */
  #btnFull[aria-pressed="true"] { background: rgba(216, 165, 68, .18); color: var(--gold); }
  #btnFull[aria-pressed="true"]:hover { background: rgba(216, 165, 68, .26); color: var(--gold); }

  #tablesheet[hidden] { display: none; }
  #tablesheet {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 22;
    max-height: min(78vh, 620px); overflow-y: auto;
    background: #150f0a;
    border-top: 2px solid #55432e;
    padding: 8px 12px calc(10px + env(safe-area-inset-bottom, 0px));
    color: var(--ink-dim); font-size: .78rem;
    box-shadow: 0 -8px 22px rgba(0,0,0,.6);
  }
  #tablesheet header { padding: 0; border: none; }

  .tsseat { padding: 7px 0 8px; border-top: 1px solid #2f2418; }
  /* The seat whose turn it is. A hairline down the left rather than a fill:
     this list is read while a hand is running and the turn moves every few
     seconds, so it has to be findable without being the loudest thing on a
     screen full of tiles. */
  .tsseat.turn { border-left: 3px solid var(--gold); padding-left: 7px; }
  .tsname {
    display: block; color: var(--ink); font-family: var(--font-display);
    font-size: .82rem; letter-spacing: .04em; margin-bottom: 4px;
  }
  .tsseat.you .tsname { color: var(--gold); }
  .tsdealer {
    margin-left: 6px; padding: 1px 5px; border-radius: 2px;
    background: #4a1f18; color: #f0c0b4; font-size: .62rem;
    font-family: var(--font-ui); letter-spacing: .06em; vertical-align: 2px;
  }
  .tsrow { display: grid; grid-template-columns: 46px 1fr; gap: 8px; padding: 2px 0; align-items: start; }
  .tsk { color: var(--dim); font-size: .66rem; font-weight: 700; letter-spacing: .04em; padding-top: 5px; }
  .tsv { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 2px; }
  .tsnone { color: var(--dim); font-size: .68rem; font-style: italic; padding-top: 4px; }
  /* A meld is a GROUP. The gap between sets is what turns nine tiles back
     into three pungs, which is the reading this sheet exists to give. */
  .tsg { display: inline-flex; gap: 1px; margin-right: 9px; }
  .tsflowers { opacity: .85; }
  /* 2.1x the 17x23 the felt draws at, and nearest-neighbour on the way up —
     these are the same pixel-art faces, enlarged, never re-rendered. Same
     rule the hand rack runs under. */
  .tst {
    display: block; width: 36px; height: 48px;
    image-rendering: pixelated; image-rendering: crisp-edges;
  }
  /* Discards are the long list — a full pond is 20-odd tiles and there are
     four of them — so they are set smaller than the melds. Still well over
     the 17px the felt gives them, which is the only bar that matters. */
  .tsdiscards .tst { width: 26px; height: 35px; }

  /* ── Phones: the dock becomes a sheet ──────────────────────────────────
     This rule used to be `display: none !important`, and the reasoning was
     sound as far as it went — the felt has almost no room, and a panel over
     the corner of it costs more than it gives. What it missed is that the
     panel is the ONLY place the game explains the faan minimum, so a phone
     player was not being spared a cramped panel, they were being refused the
     explanation. The toolbar even said the panel was on: 番 Hand read
     `aria-pressed="true"` while the stylesheet had it switched off, which is
     the toolbar lying about the state of the game. Nathan, 8 Aug 2026:
     "hand section in mobile is not appearing."

     So it is a SHEET, not a dock: full width, pinned to the bottom edge,
     scrolling inside itself, and covering the rack while it is up. Covering
     the rack is the point — this is a thing you open to READ, and it closes
     on one tap of its own title bar. It is not a corner readout you play
     around, because at this size there is no corner to put one in.

     ⚠️ It defaults to CLOSED on a phone, which is decided in handpanel.js and
     cannot be decided here — on a desktop the panel being open is what makes
     a beginner find it, and on a phone the same default would put a sheet
     over your tiles on the first deal.

     ⚠️ z-index stays 22, BELOW `#actions.ontable` at 25. A claim comes up on
     a clock; if Pung and Kong were ever behind this sheet the player would
     lose the hand to a readout. The claim buttons draw over it, on purpose. */
  @media (max-width: 700px), (max-height: 470px) {
    #handpanel {
      left: 0; right: 0; top: auto; bottom: 0;
      width: auto; max-height: min(70vh, 520px); overflow-y: auto;
      border-width: 2px 0 0; border-radius: 0;
      padding: 8px 12px calc(10px + env(safe-area-inset-bottom, 0px));
      font-size: .78rem;
      /* OPAQUE. The dock is 95% because it floats over a corner of the felt
         and a hint of table under it reads as "this is on top of the game".
         A sheet is not on top of the game, it IS the screen for as long as it
         is up — and at this size the thing showing through is a row of tile
         faces, i.e. exactly the kind of detail that makes small text on top
         of it unreadable. */
      background: #150f0a;
    }
    /* Folded away is GONE here, not folded to a bar. A bar pinned across the
       bottom of a phone is a row of the rack you cannot see, and the way back
       is 番 Hand in the ☰ menu — which is what the announcement already
       says. `setHandPanel(true)` clears `collapsed`, so that route always
       reopens it. */
    #handpanel.collapsed { display: none; }
    /* A title bar wide enough to be the close target it now is. */
    .hptoggle { padding: 2px 0 7px; }
    .hptoggle b { font-size: .88rem; }
    .hpx { font-size: 1.3rem; }
  }

  /* The offer to move up a table. Gold-edged like the Continue button: this is
     an invitation, and it should read as progress rather than as a warning. */
  .graduate {
    margin-top: 10px; padding: 8px 10px; border-radius: 3px;
    background: #241c11; border: 2px solid #9a7526; border-left-width: 5px;
  }
  .graduate b { display: block; color: var(--gold); font-size: .84rem; }
  .graduate span {
    display: block; color: var(--ink-dim); font-size: .74rem;
    line-height: 1.5; margin-top: 3px;
  }
  .graduate .row { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
  .graduate .row button { flex: 1 1 auto; font-size: .76rem; padding: 7px 9px; }

  /* Two-column key/text list. Not `.analysis` — that one is shared with the
     result screen and its rows wrap, which turns a numbered list into rubble. */
  .lessons {
    border-top: 2px solid #3a2d20; margin-top: 10px; padding-top: 8px;
    font-size: .78rem; line-height: 1.6; color: var(--ink-dim);
  }
  .lessons h4 { font-size: .78rem; color: var(--ink); margin-bottom: 6px; font-weight: 700; }
  .lessons .row { display: grid; grid-template-columns: 30px 1fr; gap: 8px; padding: 3px 0; align-items: start; }
  .lessons .row .k { color: var(--gold); font-weight: 700; }
  .lessons b { color: var(--ink); }

  /* ── The rail sheds its words before it sheds its shape ────────────────
     Past the threshold the labels go and the glyph column carries each item
     on its own: a phone gets one tidy row of eight symbols instead of three
     ragged rows of buttons, and the `title` and `aria-label` on every item
     are what keep that legible. The glyph grows on the way, because it has
     just become the entire hit target and 12px is not a thumb target.

     TWO thresholds, because this rail is two different widths: 725px in
     English and 890px in 中文, which sets a CJK gloss in front of every
     label. On one shared 760px threshold 中文 overflowed the viewport
     everywhere between 760 and 900 — a game screen with a horizontal
     scrollbar. Both blocks say the same thing; they differ only in when. */
  @media (max-width: 760px) {
    .navbtn .lbl { display: none; }
    .navbtn { padding: 7px; gap: 0; }
    .navbtn .ic { width: 18px; font-size: 1rem; opacity: .9; }
    .navsep { margin: 3px 2px; }
  }
  @media (max-width: 920px) {
    html[data-lang="zh"] .navbtn .lbl { display: none; }
    html[data-lang="zh"] .navbtn { padding: 7px; gap: 0; }
    html[data-lang="zh"] .navbtn .ic { width: 18px; font-size: 1rem; opacity: .9; }
    html[data-lang="zh"] .navsep { margin: 3px 2px; }
  }

  /* ── …and on a phone it sheds the row itself ───────────────────────────
     Shedding the labels (above) buys one row of eight symbols. That row is
     still chrome the felt pays for on EVERY hand, to hold eight things a
     player touches perhaps twice a game — and once the words are gone it is
     eight glyphs nobody reads at a glance anyway. So below the same
     threshold the rail folds behind one ☰ and drops down over the table.

     Two things are gained:
       · the header stops being a row of controls and becomes ONE, which is
         what lets the landscape-phone block below take it out of the flow
         altogether — see there for what that is worth (a sixteenth step of
         board). In portrait it buys no board: the canvas is width-bound
         there and the height goes to the rack's spread. Worth saying,
         because "the menu folds and the table grows" is only true on one
         of the two orientations;
       · the dropped panel is a COLUMN, which has room, so the labels come
         back. Glyph-only was the compromise the one-row rule forced on the
         rail; a panel is not on that budget.

     ⚠️ ABSOLUTE, so opening it costs the table nothing. The header does not
     change height, fit() never has to run, and the felt does not resize
     under a player who only wanted to look at the menu.

     ⚠️ `display: none` when shut, not a transform and not `visibility` — a
     closed menu has to be untabbable and untappable, not merely invisible.
     It is also what keeps `taptargets.py` honest: that suite skips
     `display: none` controls, so the items are measured when they are
     reachable and not before.

     ONE threshold, not the two above. The 920px 中文 rule exists because the
     rail is WIDER in 中文 and overflows sooner; a dropdown has no width to
     overflow, and folding the rail on a 900px tablet that has room for it
     would be taking something away for nothing.

     z-index 26: one step above `#actions.ontable` at 25, which is the
     highest thing on the table shell and would otherwise float its claim
     buttons over the open menu — and far below `#homebg` at 30, which is
     the room the menus are a backdrop for. */
  .navwrap { position: relative; display: flex; align-items: center; }
  .navwrap > .navtoggle { display: none; }
  /* Keeps the balance and the rail together on the right. The header is
     `space-between`, and with three children that would strand the chip at
     one edge and put the rail in the middle of the masthead. */
  header > .walletchip { margin-left: auto; }

  @media (max-width: 760px), (max-height: 470px) and (orientation: landscape) {
    .navwrap > .navtoggle { display: inline-flex; }
    /* Pressed-looking while it is open, because the ☰ is the only thing on
       screen that says where the menu went and it has to read as a switch. */
    .navwrap.open > .navtoggle { background: rgba(216,165,68,.2); color: var(--ink); }
    .navwrap.open > .navtoggle .ic { opacity: 1; }

    .navwrap > .toolbar {
      display: none;
      position: absolute; z-index: 26;
      top: calc(100% + 5px); right: 0;
      flex-direction: column; align-items: stretch;
      gap: 1px; padding: 4px;
      min-width: 168px;
      background: #241c14;
      border: 2px solid #55432e; border-radius: 4px;
      box-shadow: 0 6px 20px rgba(0,0,0,.65);
    }
    .navwrap.open > .toolbar { display: flex; }

    /* The words come back. Specificity, not source order, is what wins here:
       `.navwrap > .toolbar .navbtn .lbl` is (0,4,0) against the (0,2,0) that
       hid it above and the (0,3,1) that hides it in 中文, so neither has to
       be repeated or undone. Same for the padding, which `body.playing
       header .navbtn` (0,2,2) trims for the rail. */
    .navwrap > .toolbar .navbtn {
      justify-content: flex-start; padding: 9px 10px; gap: 9px;
      font-size: .8rem;
    }
    .navwrap > .toolbar .navbtn .lbl { display: inline; }
    .navwrap > .toolbar .navbtn .ic { width: 16px; font-size: .95rem; opacity: .85; }
    /* The group hairlines lie across the column instead of standing in it. */
    .navwrap > .toolbar .navsep { width: auto; height: 1px; margin: 3px 2px; }
  }

  /* ── Portrait phones. The table was never designed for these; below 1x the
        canvas scales fractionally, so every control has to earn its height. */
  @media (max-width: 700px) {
    body { padding: 4px; }
    #wrap { gap: 5px; }
    header { padding: 2px 0 4px; gap: 6px; }
    header h1 { font-size: .84rem; }
    header h1 small { display: none; }
    .toolbar { gap: 0; }
    #actions { gap: 5px; min-height: 0; }
    #actions button { flex: 1 1 44%; min-height: 46px; }
    #actions .hint { flex: 1 1 100%; text-align: center; font-size: .72rem; }
    #tilebar { gap: 3px; }
    #tilebar button { min-width: 34px; flex: 0 1 auto; padding: 3px 2px; }
    #coach { padding: 6px 8px; gap: 8px; }
    #coach .txt { font-size: .75rem; line-height: 1.45; }
    #coach .side button { padding: 5px 6px; font-size: .68rem; }
    .card { padding: 14px; max-height: 90vh; }
    .card h2 { font-size: 1rem; }
    .field { gap: 6px; }
    .field label { min-width: 100%; }
    .seg { width: 100%; }
    .seg button { flex: 1 1 0; min-height: 40px; }
  }
  @media (max-width: 400px) {
    #tilebar button { min-width: 30px; font-size: .78rem; padding: 2px 1px; }
    #tilebar button .sub { font-size: .5rem; }
  }

  /* Landscape phone: the chrome is taller than the whole viewport, and the
     table loses. Everything below is trimmed so the board does not have to be. */
  @media (max-height: 470px) and (orientation: landscape) {
    body { padding: 2px; }
    header { padding: 0 0 2px; }
    header h1 { font-size: .72rem; }
    #wrap { gap: 3px; }
    #tilebar button { min-height: 34px; min-width: 28px; }
    #actions button { min-height: 34px; }
    .navbtn { padding: 4px 6px; }
    #coach { max-height: 92px; overflow-y: auto; padding: 5px 8px; }
    #coach .txt { font-size: .72rem; }

    /* ── …and once the rail has folded, the header can leave the flow ─────
       Mid-hand on a landscape phone this element contains ONE ☰ and an <h1>
       that measures 0x0, because the subtitle is off and the English
       masthead is inside it. Forty-two pixels of page for one button, on the
       orientation where the board is height-bound and every one of them
       comes straight off the felt: without this the table sits at 0.75x, and
       with it at 0.8125x — a whole sixteenth step, ~8% more board.

       ⚠️ `position: fixed` IS THE MECHANISM, not a side effect. `boxH()` in
       view.js returns 0 for anything the stylesheet has taken out of the
       flow, so fit() stops budgeting for the header the moment this applies
       and hands the height to the canvas. Nothing in JS knows this rule
       exists — same arrangement as the chat dock, which is written up there.

       ⚠️ `body.playing` ONLY. At the menu the masthead is a masthead and
       belongs in the page; it is also under `#homebg` there, so a floating
       copy of it would be a title nobody can see over a felt nobody asked
       for. And the canvas is centred, so at 844px wide the ~190px of black
       either side is where this lands: the ☰ floats beside the table, not
       over it.

       z-index 26 to match the dropdown it opens — over `#actions.ontable`
       at 25, under `#homebg` at 30. */
    /* ⚠️ `#wrap > header`, NOT `header`. The masthead is not the only <header>
       in this document — `#handpanel` has one of its own, and it is the panel's
       title bar and its close control. A bare `body.playing header` here
       teleported that title bar to the top-right corner of the screen and
       parked it under the ☰, so on a landscape phone the hand sheet could be
       opened and never closed. (The rules above it get away with the loose
       selector because `#handpanel header` overrides them on id specificity;
       nothing was overriding `position`.) */
    body.playing #wrap > header {
      position: fixed; z-index: 26;
      top: 2px; right: 2px; left: auto; width: auto;
      padding: 0; gap: 6px; border-bottom: 0;
    }
    /* ⚠️ THE ☰ AND NOTHING ELSE, or the thing that was floating beside the
       table starts floating ON it. In English these two measure 0px and the
       header was 50px wide; in 中文 the masthead keeps 像素碰 and the balance
       is a monospace `2,000 chips`, and at 740x360 that made the header 191px
       — wide enough to sit over the felt's right-hand wall. Measured, not
       guessed: 740x360 zh is the case that failed.

       Neither is a loss mid-hand. The masthead is orientation for someone at
       the menu, which is why `body.playing` already drops the subtitle; the
       balance is the same number the result screen and the home card show,
       and it is already hidden outright below 700px. */
    body.playing #wrap > header h1,
    body.playing #wrap > header > .walletchip { display: none; }
  }

  /* The finger-sized-control floors that used to sit here now live at the
     very END of this file. They are last-resort overrides of rules scattered
     all through it — `.langtoggle button { min-height: 0 }` is 400 lines
     below this point and was quietly winning on source order — and the only
     position that reliably beats all of them is last. */

/* ── Volume slider ──────────────────────────────────────────────────────
   Styled explicitly because the default control is a grey system widget that
   looks pasted on top of a wooden parlour. */
input[type=range] {
  flex: 1 1 120px; min-width: 90px; max-width: 210px;
  -webkit-appearance: none; appearance: none;
  height: 18px; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track {
  height: 6px; background: #1a140e; border: 2px solid #4a3b2a; border-radius: 2px;
}
input[type=range]::-moz-range-track {
  height: 6px; background: #1a140e; border: 2px solid #4a3b2a; border-radius: 2px;
}
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 12px; height: 18px; margin-top: -8px;
  background: var(--gold); border: 2px solid #9a7526; border-radius: 2px;
}
input[type=range]::-moz-range-thumb {
  width: 12px; height: 18px;
  background: var(--gold); border: 2px solid #9a7526; border-radius: 2px;
}
input[type=range]:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
#setVolOut {
  min-width: 3.2em; text-align: right; color: var(--gold);
  font-family: ui-monospace, Consolas, monospace; font-size: .78rem; font-weight: 700;
}

/* An <a> that has to sit in a row of <button>s and look identical. */
/* `.btnlink-inline` — an <a> dressed as one of the old bordered toolbar
   buttons — lived here. Its only user was the 牌譜 Tiles link, which is a
   `.navbtn` on the rail now like everything else beside it. */

/* ══════════════════════════════════════════════════════════════════════════
   RULES SCREEN

   It was a column of near-identical paragraphs: nothing to anchor on, no way
   to find one rule again, and the single most important fact (the 3 faan
   minimum) buried in the middle of the seventh one. Now it opens with the
   three things you actually need, gives every step a number you can jump to,
   and puts the comparisons in tables where they can be scanned rather than
   read. Same content, roughly a third the reading effort.
   ══════════════════════════════════════════════════════════════════════════ */

#rules .card { max-width: 760px; }
#rules .card h2 { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
#rules .card h2 small {
  font-family: var(--font-ui); font-size: .7rem; font-weight: 400;
  color: var(--dim); letter-spacing: .04em;
}

/* ── The three-panel summary ──────────────────────────────────────────── */
.tldr {
  display: grid; gap: 8px; margin: 12px 0 16px;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
.tldr-goal {
  background: #1a140e; border: 2px solid #55432e; border-top: 3px solid var(--gold);
  border-radius: 3px; padding: 9px 11px 11px;
  display: flex; flex-direction: column; gap: 2px;
}
.tldr-goal.warn { border-top-color: var(--red); }
.tldr-k {
  font-size: .62rem; letter-spacing: .12em; text-transform: uppercase;
  color: var(--dim);
}
.tldr-v {
  font-family: var(--font-display); font-size: 1.02rem; font-weight: 700;
  color: var(--gold); letter-spacing: .04em; line-height: 1.25;
}
.tldr-goal.warn .tldr-v { color: #e8867a; }
.tldr-n { font-size: .74rem; line-height: 1.5; color: var(--ink-dim); margin-top: 3px; }

/* ── Jump bar ─────────────────────────────────────────────────────────── */
.rulenav {
  display: flex; gap: 5px; flex-wrap: wrap;
  padding: 9px 0 11px; margin-bottom: 4px;
  border-top: 1px solid #3a2d20; border-bottom: 1px solid #3a2d20;
}
.rulenav a {
  font-size: .72rem; color: var(--ink-dim); text-decoration: none;
  border: 1px solid #4a3b2a; border-radius: 2px; padding: 3px 9px;
  letter-spacing: .03em;
}
.rulenav a:hover { border-color: var(--gold); color: var(--gold); background: #1a140e; }

/* ── Numbered steps ───────────────────────────────────────────────────── */
.rstep { margin: 18px 0 0; scroll-margin-top: 8px; }
.rstep h3 {
  display: flex; align-items: center; gap: 9px;
  color: var(--gold); font-size: 1rem; margin: 0 0 8px;
  padding-bottom: 6px; border-bottom: 1px solid #3a2d20;
}
.rnum {
  flex: 0 0 auto;
  width: 21px; height: 21px; border-radius: 2px;
  background: var(--gold); color: #241c14;
  font-family: ui-monospace, Consolas, monospace; font-size: .78rem; font-weight: 700;
  display: inline-flex; align-items: center; justify-content: center;
}

.rgrid {
  display: grid; gap: 8px; margin: 4px 0 8px;
  grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
}
.rcard {
  background: #1a140e; border: 2px solid #3a2d20; border-radius: 3px;
  padding: 9px 11px 10px;
}
.rcard > b {
  display: block; color: var(--gold); font-family: var(--font-display);
  font-size: .86rem; letter-spacing: .04em; margin-bottom: 4px;
}
.rcard p { font-size: .76rem; line-height: 1.5; margin-bottom: 5px; }
ul.tight { margin: 0 0 0 15px; font-size: .78rem; line-height: 1.55; }
ul.tight li { color: var(--ink); }

/* ── Scannable comparison tables ──────────────────────────────────────── */
table.rtable {
  width: 100%; border-collapse: collapse; font-size: .78rem; margin: 7px 0 9px;
}
table.rtable td { padding: 5px 8px; border-bottom: 1px solid #33281c; vertical-align: top; }
table.rtable tr:last-child td { border-bottom: none; }
table.rtable td.k {
  font-family: var(--font-display); color: var(--gold);
  white-space: nowrap; font-weight: 700; letter-spacing: .03em; width: 1%;
}
table.rtable td.from {
  text-align: right; white-space: nowrap; color: #7fc79a; font-size: .72rem;
}

/* The worked example. The zero rows carry as much of the lesson as the
   scoring ones — "everything else was structure" is the thing beginners get
   wrong — so they are dimmed rather than dropped. */
h4.rsub {
  font-family: var(--font-display); color: var(--ink);
  font-size: .88rem; letter-spacing: .05em; margin: 14px 0 4px;
  padding-top: 10px; border-top: 2px solid #3a2d20;
}
table.counting td.v { color: var(--gold); }
table.counting tr td.v:not(:empty) .nm {
  display: block; font-size: .66rem; color: var(--dim);
  font-weight: 400; letter-spacing: 0;
}
table.counting tr:has(td.v:only-of-type) td { color: var(--ink-dim); }
table.counting td.v { font-family: ui-monospace, Consolas, monospace; }
table.counting tr.total td {
  border-top: 2px solid #55432e; color: var(--ink); font-weight: 700;
  padding-top: 7px;
}
table.counting tr.total td.v { font-size: .92rem; }
table.rtable td.from.bad { color: #e8b06a; }
table.rtable td.v {
  text-align: right; width: 1%; white-space: nowrap;
  color: var(--gold); font-weight: 700;
  font-family: ui-monospace, Consolas, monospace;
}
table.rtable.faanmini td { padding-top: 4px; padding-bottom: 4px; }

/* The payout ladder reads as a grid of pairs, not as prose. */
table.rtable.ladder td { text-align: center; padding: 5px 3px; border-bottom: none; }
table.rtable.ladder td:nth-child(odd) { color: var(--dim); font-size: .7rem; }
table.rtable.ladder td:nth-child(even) {
  color: var(--gold); font-weight: 700;
  font-family: ui-monospace, Consolas, monospace;
  border-right: 1px solid #33281c; padding-right: 10px;
}
table.rtable.ladder td:last-child { border-right: none; }

.rsee { font-size: .76rem; color: var(--dim); margin-top: 6px; }
a.rlink {
  color: var(--gold); text-decoration: none;
  border-bottom: 1px dotted #9a7526; font-weight: 700;
}
a.rlink:hover { border-bottom-style: solid; }

/* ── The aside for people arriving from another ruleset ───────────────── */
.rdetails {
  margin: 20px 0 4px; background: #1a140e;
  border: 2px solid #3a2d20; border-radius: 3px; padding: 0 12px;
}
.rdetails summary {
  cursor: pointer; padding: 9px 0; font-size: .82rem;
  font-family: var(--font-display); color: var(--gold); letter-spacing: .03em;
}
.rdetails summary:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
.rdetails p { padding-bottom: 10px; margin: 0; }

/* ── A disclosure that looks like the control it is ────────────────────────
   `⚙ Table options` in the Rooms panel was a bare line of dim text with
   `list-style: none` — no border, no hover, no focus ring, and with the
   default triangle suppressed it had no open/closed state either. So the one
   control that decides whether strangers can walk into your table advertised
   itself as a caption. Nothing about it said "press me", and nothing about it
   said whether it was already open.

   It gets the same surface, hover and focus as a `.homebtn`, plus a chevron
   that turns when the panel opens. The chevron is `::after` on the summary
   rather than the browser's own marker so it can be placed at the END of the
   row, where it reads as a state and not as a bullet.

   Sized for a thumb by the `pointer: coarse` block at the bottom of this file,
   which already names `summary` — this needs no phone rule of its own. */
.optbtn {
  display: flex; align-items: center; gap: 8px;
  list-style: none; cursor: pointer; user-select: none;
  background: var(--panel-hi); border: 2px solid #55432e; border-bottom-width: 3px;
  border-radius: 3px; padding: 7px 11px; margin-top: 6px;
  font-family: var(--font-ui); font-size: .78rem; color: var(--ink-dim);
  letter-spacing: .03em;
}
/* Safari and old Chrome draw their own marker from a pseudo-element that
   `list-style: none` alone does not always reach. */
.optbtn::-webkit-details-marker { display: none; }
.optbtn::after {
  content: '▸'; margin-left: auto; color: var(--gold); font-size: .9rem; line-height: 1;
}
.optbtn:hover { background: #4b3a28; border-color: var(--gold); color: var(--ink); }
.optbtn:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
/* Open is a STATE, and it is drawn twice — the chevron turns and the border
   lights — because the panel below it is the only other evidence, and that
   evidence scrolls off a phone. */
details[open] > .optbtn { color: var(--ink); border-color: #9a7526; }
details[open] > .optbtn::after { content: '▾'; }
@media (prefers-reduced-motion: no-preference) {
  .optbtn { transition: background-color .12s linear, border-color .12s linear; }
}

/* ══════════════════════════════════════════════════════════════════════════
   HOME SCREEN

   The options panel used to be the first thing you saw, which put difficulty
   and 包 settlement in front of someone who had not yet decided to play. Home
   is now the landing page and the options live one click in.
   ══════════════════════════════════════════════════════════════════════════ */

/* The table's own header is chrome for a game in progress — on the landing
   page it is just a row of buttons competing with the menu. */
body.athome header, body.athome #stage, body.athome #actions,
body.athome #tilebar { display: none; }

/* The landing page is a SCREEN, not a document: it fills the viewport exactly
   and never scrolls. `100dvh` rather than `100vh` because on a phone the
   address bar makes vh taller than what you can actually see, which is the
   usual reason a "full height" page still scrolls by 60px. Everything inside
   is sized in vh below so the card shrinks to fit instead of overflowing. */
body.athome { overflow: hidden; height: 100dvh; padding: 0; }
/* flex-start, not center — see the auto-margin note on .homecard. The overlay
   itself may scroll if the card somehow still outgrows it; the PAGE never
   does, which is the rule this screen was built on. */
#home {
  /* Less vertical padding than horizontal: height is the scarce axis here, and
     the English card came within 1px of the old 22px inset at 768. */
  padding: clamp(4px, 1.2vh, 12px) clamp(8px, 2.5vh, 22px);
  align-items: flex-start;
  overflow-y: auto;
  background: #0b1a13;
}

/* The painted felt + tile wall. Behind every menu card, above the table shell.

   FIXED, not absolute. It used to be a child of #home, which is a scroll
   container, and an absolutely positioned child of one scrolls with its
   content: on a short window the felt and the wall slid up with the menu and
   left the page background showing under them. The backdrop is the ROOM — it
   stays where it is and the card moves over it, which is also why it is sized
   to the viewport rather than to the content.

   ⚠️ z-index 30 puts it ABOVE the page (the highest thing on the table shell
   is the toolbar rail at 25) and BELOW every `.overlay` at 40. Both halves
   matter: at 0 it painted behind the dimmed table instead of over it, and at
   40+ it would bury the very menus it is a backdrop for.

   Hidden by default and revealed by `body.menuscene`, because the felt is
   OPAQUE. Settings opened during a hand has to keep letting the table show
   through its wash; only a screen with no game behind it can afford a room. */
#homebg,
#homefg {
  position: fixed; inset: 0;
  width: 100%; height: 100%;
  image-rendering: pixelated;
  z-index: 30;
  pointer-events: none;
  display: none;
}
/* ⚠️ 31, AND STILL UNDER 40. The scattered face-up tiles are drawn at half
   device resolution rather than a third (see the comment on the canvas in
   index.html), so they cannot share a canvas with the wall — they sit on their
   own one pixel-step above it. It also carries the vignette, which is why it
   has to be the upper of the two: the wash falls over the wall as well.
   Everything else about it is `#homebg`'s rule above, deliberately. */
#homefg { z-index: 31; }
body.menuscene #homebg,
body.menuscene #homefg { display: block; }

/* ⚠️ THE BACKDROP IS THE WASH — so the overlay must stop being one.
   `.overlay` carries `rgba(10,8,6,.86)` to dim whatever is behind it, and the
   backdrop already ends in its own vignette (see `vignette()` in home.js,
   .34 at the centre to .88 at the corners). Left stacked, the two multiplied
   and the painted room read as a black rectangle with a hint of green. This is
   the same arrangement the home screen always had, where the backdrop sat
   INSIDE the overlay and hid its wash by being drawn over it.

   ⚠️ #home NEEDS ITS OWN LINE. It sets `background: #0b1a13` a few rules up,
   and an id beats `body.menuscene .overlay` on specificity no matter which is
   written last — so the one screen this feature started from would have been
   the one screen it did not reach. */
body.menuscene .overlay { background: transparent; }
body.menuscene #home { background: transparent; }

/* ⚠️ AND THE CARD UNDERNEATH HAS TO GO WITH IT. Dropping the wash also
   dropped the thing that was hiding the screen a dialog was opened FROM:
   Settings over the home card came up with the title, the language toggle and
   the footer all still legible around its edges, two panels deep, because the
   .86 had been doing that job invisibly for free.

   So the base screen's card is hidden while anything is stacked on it. The
   dialog then floats on the painted room exactly as the home card does, which
   is the look this was all for. `visibility` rather than `display`: the card
   keeps its box, so nothing reflows underneath and `fit()` still measures the
   same page when the dialog closes.

   Only #home and #menu are named because only those two are base screens —
   every other overlay in the file is a dialog opened FROM one of them, and
   dialogs do not stack on each other. */
body.menuscene:has(.overlay.show:not(#home):not(#menu)) #home > .homecard,
body.menuscene:has(.overlay.show:not(#home):not(#menu)) #menu > .card {
  visibility: hidden;
}

#home .homecard {
  position: relative; z-index: 1;
  background: var(--panel); border: 3px solid #55432e; border-radius: 5px;
  padding: clamp(12px, 2.4vh, 22px) 24px; max-width: 520px; width: 100%;
  text-align: center;
  box-shadow: 0 18px 50px rgba(0, 0, 0, .6), 0 0 90px -30px rgba(255, 190, 104, .3);

  /* SAFE CENTERING. `align-items: center` on the overlay looks identical to
     this while everything fits, and behaves completely differently when it
     does not: centring pushes the overflow off BOTH ends, so the top of the
     card — which is where the language toggle lives — goes to a negative
     offset that `overflow: hidden` then makes unreachable. In 中文 the card is
     ~86px taller (CJK line boxes on six buttons, plus a wrapped subtitle), and
     on a 768px laptop that put the toggle at -25px: you could switch to
     Chinese and then not switch back.

     An auto margin absorbs free space when there IS some (so it still centres)
     and collapses to zero when there is not (so the top edge stays reachable).
     Scrolling is left to #home rather than capping the card here: a cap set to
     exactly the available height turns a 1px overflow into a visible
     scrollbar, and the English card sits within a pixel of that boundary at
     768. The rules below keep either from being needed. */
  margin: auto;
}

/* ── The two dragons ────────────────────────────────────────────────────
   Real tiles are chunky blocks, so these are drawn WITH their extruded side
   and then given a perspective tilt in CSS. Rotating the canvas itself would
   resample the pixel art into mush; rotating the element keeps every source
   pixel square and just presents it at an angle. */
/* Height is the scarce axis on a laptop in landscape, so the dragons are the
   first thing allowed to give it back — they are ornament, and the buttons
   are not. Below ~430px of viewport they disappear entirely rather than
   squeezing the menu off the bottom. */
.hometiles {
  display: flex; justify-content: center; align-items: flex-end;
  gap: 2px; height: clamp(0px, 20vh, 176px); margin: 0 0 clamp(6px, 2vh, 18px);
  perspective: 700px;
}
.hometile {
  image-rendering: pixelated;
  display: block;
  height: 100%; width: auto;
  filter: drop-shadow(0 14px 18px rgba(0, 0, 0, .65));
  /* ⚠️ STEPPED, NOT EASED. These sit directly on top of the travelling wall,
     and that wall reads as "laggy" on purpose: it is drawn on a canvas at a
     third of resolution with every position rounded, so at 9px/s it changes
     position nine times a second inside a 60fps loop — a stop-motion cadence,
     which is the look. A half-second cubic-bezier glide right next to it was
     the one piece of motion on the screen that gave the game away as smooth.
     `steps(6)` settles the dragons in six visible jerks over the same .48s,
     ~12.5 a second, which is the same order as the wall's nine.

     Six, not fewer: at three the tilt reads as broken rather than stylised,
     and not more, because past about eight the steps stop being separable and
     you have paid for a jerky animation that looks smooth anyway. */
  transition: transform .48s steps(6, end);
}
@media (max-height: 430px) { .hometiles { display: none; } }
.hometile.left {
  transform: rotateY(26deg) rotateZ(-11deg) translateX(9px) scale(.95);
  transform-origin: 100% 85%;
  z-index: 2;
}
.hometile.right {
  transform: rotateY(-26deg) rotateZ(11deg) translateX(-9px) scale(.95);
  transform-origin: 0% 85%;
}
#home .homecard:hover .hometile.left  { transform: rotateY(14deg) rotateZ(-6deg) scale(.99); }
#home .homecard:hover .hometile.right { transform: rotateY(-14deg) rotateZ(6deg) scale(.99); }

/* ── The build stamp ──────────────────────────────────────────────────────
   ⚠️ `position: absolute` IS THE REQUIREMENT, NOT A PREFERENCE. `.homecard`
   is the positioning context (it is `position: relative` above), and taking
   this out of flow is what stops it adding a single pixel of height —
   `homefit.py` asserts this page never scrolls at five viewports and has
   caught an addition here before.

   `pointer-events: none` because it sits over the card's own padding and a
   dead 40x10 patch that swallows a click near the bottom edge is a bug
   nobody would think to look for. Deliberately quiet: it is for whoever is
   asking "did my deploy land", not for the player. */
#home .homever {
  position: absolute; right: 10px; bottom: 5px;
  font-family: var(--font-ui); font-size: .56rem; letter-spacing: .04em;
  color: var(--dim); opacity: .5;
  pointer-events: none; user-select: none;
}
@media (prefers-reduced-motion: reduce) { .hometile { transition: none; } }

/* ── The mark ─────────────────────────────────────────────────────────────
   The heading is now a wrapper around a canvas — see `paintHomeLogo` in
   home.js — so everything that used to make this look like a title (the face,
   the tracking, the text shadow) is drawn into the bitmap instead. What is
   left here is the BOX, and the glow, which is light on the room rather than
   an effect on type. */
.hometitle {
  margin: 0;
  line-height: 0;                 /* the canvas is the whole line box */
  font-size: 0;                   /* no stray space around a replaced element */
}
.homelogo {
  /* ⚠️ NO HEIGHT, AND THAT IS THE RULE HERE. The mark is drawn at exactly the
     size it will occupy — `paintHomeLogo` picks the pixel size and the integer
     upscale — so the canvas's own dimensions are the layout. The first cut set
     `height: clamp(30px, 5.6vh, 42px)` and the browser squeezed a 63px bitmap
     into a 32px box: every other row of a mark drawn to the pixel was thrown
     away. Anything that wants this smaller belongs in `LOGO` in home.js.
     `pixelated` is left in for the device-pixel-ratio upscale on a retina
     screen, which is the one resample that is still integer and still ours. */
  image-rendering: pixelated;
  display: block; margin: 0 auto;
  max-width: 100%;
  filter: drop-shadow(0 0 26px rgba(216, 165, 68, .30));
}
.homesub {
  font-size: .82rem; color: var(--ink-dim);
  margin: clamp(4px, 1.2vh, 10px) 0 clamp(8px, 2.2vh, 18px);
}
/* The 中文 gloss rides the same line. Without this it would be caught by
   `.homesub > span` below — which exists to make a SECOND line — and the
   subtitle would grow back the row it was just shortened by. */
.homesub > .mj-zh {
  display: inline; font-size: inherit; color: inherit;
  letter-spacing: inherit; margin: 0;
}
/* `>` throughout: these buttons now contain <b><span class="mj-zh">開局 </span>
   Play</b>, and a descendant selector caught that prefix too — giving it
   display:block and the small dim caption font, so "開局 Play" rendered as two
   lines with the Chinese shrunk to a subtitle of the English. That single
   selector was most of why the 中文 card came out 86px taller. */
.homesub > span {
  display: block; font-size: .68rem; color: var(--dim);
  letter-spacing: .1em; margin-top: 3px;
}

.homebtns { display: grid; gap: clamp(4px, 1vh, 7px); text-align: left; }
.homebtn {
  display: block; width: 100%; text-decoration: none; cursor: pointer;
  background: var(--panel-hi); border: 2px solid #55432e; border-bottom-width: 3px;
  border-radius: 3px; padding: clamp(5px, 1.2vh, 9px) 13px;
  font-family: var(--font-ui);
}
/* ⚠️ `font-weight: 700`, AND IT IS NOT COSMETIC HOUSEKEEPING. `<b>` computes to
   `bolder`, which against the 700 these buttons inherit from the global
   `button` rule resolves to 900 — and none of the faces in `--font-display`
   ships a bold, so the browser SYNTHESISES one. At 900 that smear is heavy
   enough to close the counters: "More" and "Multiplayer" came out with an M
   that reads as a blob. 700 is the same synthesis at the weight the Google
   button uses (`.gbtn`), which is the only place in the game this face was
   rendered clean, and it is why that one label looked like a different font
   when it is the identical stack. Tracking matches `.gbtn` for the same
   reason. Size deliberately does NOT — .8rem there is a secondary control; a
   menu title dropped to it would sit only .08rem above its own subtitle. */
.homebtn b {
  display: block; font-family: var(--font-display);
  color: var(--ink); font-size: .96rem;
  font-weight: 700; letter-spacing: .03em;
}
.homebtn > span {
  display: block; color: var(--ink-dim); font-size: .72rem; margin-top: 2px;
  font-weight: 400;
}
/* Collection and Tiles, side by side. The gap is repeated rather than
   inherited because this is a grid item inside .homebtns, not a subgrid. */
.homerow { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(4px, 1vh, 7px); }
.homerow .homebtn { display: flex; align-items: center; }
/* No subtitle, so the row is one title tall — which is the whole reason two
   buttons fit in the height one used to take. */
.homebtn.compact { text-align: center; justify-content: center; }
.homebtn:hover { background: #4b3a28; border-color: var(--gold); }
.homebtn:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
.homebtn.primary { background: var(--green); border-color: #1e6a37; }
.homebtn.primary b { color: #fff; }
.homebtn.primary > span { color: #cdeed9; }
.homebtn.primary:hover { background: #37a75c; border-color: #7fe0a4; }
.homebtn.resume { background: #4a3a12; border-color: #9a7526; }
.homebtn.resume b { color: var(--gold); }
.homebtn.resume:hover { background: #5c4818; }

/* ── 排名 Ranked ───────────────────────────────────────────────────────────
   The only button in the game behind which a rating and a coin actually move,
   and until now it wore the same brown as Rooms — which is a friendly game
   that moves neither. Two buttons with opposite consequences cannot look
   identical.

   ⚠️ GOLD RATHER THAN A SECOND GREEN, and that is the point of the choice.
   Green is already "go" here — Play, Multiplayer, Quick Play all wear it, and
   all three are free. Gold is already "this is the valuable one": the selected
   table, the rank ladder, every faan figure on every screen. Ranked joins that
   vocabulary instead of inventing a third.

   ⚠️ DECLARED BEFORE `.shut`. Both are `.homebtn.x`, so the later one wins on
   equal specificity — and a gated Ranked button MUST read as shut rather than
   as the most inviting thing on the screen. Moving this block below `.shut`
   would silently make the locked door gold. */
.homebtn.ranked {
  background: var(--gold); border-color: #f0d08a; border-bottom-color: #8a6a1e;
}
.homebtn.ranked b { color: #241c14; }
/* The subtitle quotes a PRICE, so it has to stay readable on gold rather than
   inheriting the dim ink meant for a brown button. */
.homebtn.ranked > span { color: #4e3d16; }
.homebtn.ranked:hover { background: #edba57; border-color: #fff0c4; }
/* One button on the width of two. A .homerow holds three items — Rules,
   Options, More on the root pane; Collection, Tiles, Saved Hands behind it —
   and a two-column grid drops the third onto a half-width second row, where it
   reads as something left over rather than as the door it is. Spanning the
   whole line costs no height (the row was already two lines tall) and makes
   the pane look built instead of wrapped. */
.homerow .homebtn.wide { grid-column: 1 / -1; }

/* ── A door you can see and cannot open yet ────────────────────────────────
   Ranked play is gated by rank and by balance, and the gated state is drawn
   rather than hidden: a button that is not there tells a player nothing, while
   a greyed one with a reason under it tells them there is somewhere to get to
   and what it takes. The subtitle carries the server's own sentence. */
.homebtn.shut,
.homebtn.shut:hover {
  background: #2a2118; border-color: #4a3c2c; cursor: not-allowed;
}
.homebtn.shut b { color: var(--dim); }
/* ⚠️ THE REASON IS NOT DIMMED. Everything else about a shut button recedes;
   the sentence explaining it is the only thing on it worth reading. */
.homebtn.shut > span { color: var(--gold); }

/* ── The player card ───────────────────────────────────────────────────────
   The rank rung, the rating, and the last ranked result — the "who am I"
   every surveyed multiplayer menu answers before you press anything, and the
   only element on the online pane that adds height. See the ⚠️ block above
   `#homePaneOnline` in index.html for why it is the only one.

   ⚠️ ONE LINE TALL, AND THE BAR COSTS NOTHING. The progress bar is absolutely
   positioned along the card's own bottom border rather than stacked under the
   text, so the whole card is the height of a line of type plus its padding.
   `homefit.py` asserts no scrolling at 780x400 — a two-line card is the
   difference between passing that and not, and the bar is decoration on a
   number the text already gives you.

   ⚠️ NOT A BUTTON, and it must not become one. It is `div`/`span`, has no
   click handler and no focus ring: the pane's whole design rule is that state
   is read-only and navigation stays in the six controls below it. */
.mpcard {
  position: relative; display: flex; align-items: center; gap: 9px;
  background: #2c2418; border: 2px solid #4a3c2c; border-radius: 3px;
  padding: clamp(4px, 1vh, 7px) 11px calc(clamp(4px, 1vh, 7px) + 2px);
  font-family: var(--font-ui); overflow: hidden;
}
/* The rung, in the language in play. Gold because the ladder is already gold
   everywhere else it appears. */
.mpbadge {
  flex: 0 0 auto; font-family: var(--font-display); font-size: .8rem;
  color: var(--gold); letter-spacing: .06em; white-space: nowrap;
}
.mpwho { flex: 1 1 auto; min-width: 0; display: flex; align-items: baseline; gap: 7px; }
.mpwho b {
  font-family: var(--font-display); color: var(--ink); font-size: .82rem;
  letter-spacing: .04em;
  /* A long handle must shorten rather than push the rating off the card. */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mpwho > span { flex: 0 0 auto; color: var(--ink-dim); font-size: .7rem; }
/* Last result. Green up, red down — the only two-colour thing on the card,
   because a delta is the one figure here that has a direction. */
.mplast {
  flex: 0 0 auto; font-size: .7rem; color: var(--ink-dim);
  letter-spacing: .03em; white-space: nowrap;
}
.mplast.up { color: #7fe0a4; }
.mplast.down { color: #d98b7a; }
.mpbar {
  position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
  background: #4a3c2c;
}
.mpbar > i { display: block; height: 100%; width: 0; background: var(--gold); }

/* The invitation count, on the Friends button. Inside the <b>, so
   `.homebtn > span` (the subtitle rule) deliberately does not reach it — that
   descendant-vs-child trap has bitten this stylesheet twice already. */
.mpbadge-n {
  display: inline-block; margin-left: 6px; padding: 0 5px;
  background: var(--gold); color: #241c14; border-radius: 8px;
  font-family: var(--font-ui); font-size: .64rem; font-weight: 700;
  letter-spacing: 0; vertical-align: 1px;
}

.homefoot {
  margin-top: clamp(8px, 1.8vh, 16px); padding-top: clamp(6px, 1.4vh, 12px);
  border-top: 2px solid #3a2d20;
  font-size: .7rem; color: var(--dim); letter-spacing: .03em;
}
/* The house line, when the footer is showing the multiplayer figures rather
   than this browser's bot-game tally. Gold on the goal money only — the point
   of the line is the coins nobody has claimed yet. */
.homefoot .paid { color: var(--gold); }

/* ── 中文 / English ───────────────────────────────────────────────────────
   Static markup carries both languages and CSS hides the inactive one. No JS
   runs for any of it, nothing is duplicated in a string table that could
   drift from the markup, and the Rules prose can differ in STRUCTURE rather
   than being forced into a word-for-word swap.

   `.mj-zh` holds Chinese (usually followed by its English gloss, which stays
   visible in both modes); `.mj-en` holds text that only makes sense when the
   Chinese is gone. Selecting on the root attribute means one write flips the
   entire document. */
html[data-lang="en"] .mj-zh { display: none !important; }
html[data-lang="zh"] .mj-en { display: none !important; }
/* Inline by default so a hidden span never collapses a line of prose. */
.mj-zh, .mj-en { display: inline; }

/* ⚠️ THE TITLE'S SIZING IS NO LONGER A CSS PROBLEM AT ALL. It used to be two
   rules: the display face's .18em tracking was set for four CJK glyphs and
   像素碰 is three, and the same .18em on the Latin "Pixel Pung" stretched it
   wider than the tile art below it — so English carried its own tighter
   tracking AND its own font-size clamp. All four numbers now live in `LOGO` in
   home.js, per language, in whole pixels, because that is the only place they
   can be honoured exactly. There is deliberately nothing here. */

/* The toggle itself. Deliberately tiny and always in the same place: someone
   who cannot read the interface needs to find this without reading it, so it
   says 中文 and EN and nothing else. */
.langtoggle { display: flex; gap: 3px; }
.langtoggle button {
  font-size: .68rem; padding: 3px 7px; min-height: 0;
  background: #1a140e; border: 2px solid #3a2d20; color: var(--ink-dim);
  border-radius: 3px; cursor: pointer; font-family: var(--font-ui);
}
.langtoggle button:hover { border-color: var(--gold); color: var(--ink); }
.langtoggle button.on {
  background: var(--gold); color: #241c14; border-color: #9a7526; font-weight: 700;
}
.langtoggle button:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }

/* On the home card it sits above the title, right-aligned — out of the way of
   開局 Play, but the first thing in the tab order after the page loads. */
.homelang { display: flex; justify-content: flex-end; margin-bottom: 2px; }

/* ── Account strip ────────────────────────────────────────────────────────
   Sits above the buttons because it is a statement about who you are, not
   another thing to do. Deliberately quieter than .homebtn — it must not
   compete with 開局 Play for the eye of someone who just wants to play. */
.acct {
  display: flex; align-items: center; gap: 8px; text-align: left;
  background: #1a140e; border: 2px solid #3a2d20; border-radius: 3px;
  padding: 6px 9px; margin-bottom: clamp(5px, 1.2vh, 9px);
}
.acct .who { flex: 1 1 auto; min-width: 0; }
.acct .who b {
  font-family: var(--font-display); color: var(--ink);
  font-size: .84rem; letter-spacing: .04em;
}
/* One line per balance, and the label is not optional — the whole reason this
   strip has two rows is that a figure without its origin cannot be read. They
   are laid out as a row that wraps rather than a grid: the Chinese labels are
   a different width from the English ones and a fixed first column left a gap
   in one language or clipped the other. */
.acct .wline {
  display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
  line-height: 1.5;
}
/* The online row is quieter than the local one, deliberately: until
   multiplayer exists it is the balance that never moves, and it should not be
   the one that catches the eye. */
.acct .wline.online b { color: var(--dim); font-size: .76rem; }
.acct .wline.online { font-size: .76rem; color: var(--dim); }
.acct .wline.online .bal { font-size: .74rem; }
.acct .wsub { color: var(--dim); font-size: .66rem; }
/* `.bal`, not `.chips` — that name is taken by the result screen's chip cards,
   which are display:flex, and reusing it stretched the balance to its own
   full-width line. */
.acct .bal {
  color: var(--gold); font-weight: 700; margin-left: 7px;
  font-family: ui-monospace, Consolas, monospace; font-size: .8rem;
}
/* Under water is a real state the engine allows — it does not clamp a seat at
   zero — so the wallet shows it rather than pretending it did not happen. */
.acct .bal.broke { color: #e2705f; }
.acct .note {
  display: block; color: var(--dim); font-size: .68rem; margin: 2px 0 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The third line is now for errors and one-off notices only, so it must take
   no height when there is nothing to say — the strip grew a row for the second
   balance and a permanent empty line on top of that pushed the home card past
   the fold at 720px. */
.acct .note:empty { display: none; }
.acct button { flex: 0 0 auto; font-size: .72rem; padding: 6px 9px; }

.acct-err {
  color: #e2705f; font-size: .74rem; min-height: 1em; margin: 2px 0 0;
}
.acct-err:empty { min-height: 0; }
/* ⚠️ THE SAME SLOT, SAYING THE OPPOSITE THING. When the dialog opens itself —
   because a player pressed Multiplayer and multiplayer needs a name — this line
   explains WHY they are looking at a password box. That is not a failure and
   must not be red: red here reads as "your last sign-in was rejected" to
   somebody who has not typed anything yet.

   ⚠️ THE RULE THAT ACTUALLY WINS IS `.card p.note`, four hundred lines up, and
   that is deliberate rather than an accident left in place. It is (0,2,1)
   against this rule's (0,2,0), so it takes the colour either way — and what it
   provides IS the house treatment for an explanatory note in a card: muted
   text behind a hairline down the left. Overriding it would have made the
   sign-in dialog the only card in the game whose notes look different, to no
   benefit. So this rule sets no colour at all and exists to say so; `.note`
   here means the same thing it means everywhere else. (The name collided the
   way `.en` collided with gallery.css — check before inventing a class.)

   All this rule does is undo the error colour, which is the only part
   `.card p.note` cannot do on its own — `.acct-err` is (0,1,0) and would
   otherwise still be red on specificity had the cascade fallen the other way.
   `signingate.py` asserts the computed colour is NOT the error red rather than
   asserting any particular colour, so the house style stays free to change. */
.acct-err.note { color: inherit; }
.acct-hint { color: var(--dim); font-size: .68rem; margin: 4px 0 0; line-height: 1.5; }

/* ── Continue with Google ─────────────────────────────────────────────────
   The one control in this game that is allowed to look like it came from
   somewhere else, and deliberately so: a sign-in button that does not look
   like Google's is a sign-in button people hesitate over. Google's brand
   guidelines want their mark unmodified on a light or dark neutral, so the
   button keeps the game's border weight and radius — which is what makes it
   sit in the card at all — and nothing else from the pixel palette.

   `hidden` on the wrapper is what turns the whole thing off on a server with
   no client id, so the rule below must not set a `display` that outranks it. */
.acct-alt[hidden] { display: none; }
.acct-alt { margin: 10px 0 0; }
.gbtn {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  width: 100%; padding: 10px 12px;
  background: #f5f2ec; color: #1f1b16;
  border: 2px solid #0d0a07; border-radius: 0; cursor: pointer;
  font-family: var(--font-display); font-size: .8rem; letter-spacing: .03em;
  /* The pixel-panel shadow: offset, opaque, and with no blur at all. A blurred
     shadow is the one thing that instantly reads as "not drawn by a machine
     with 8 bits of colour". */
  box-shadow: 3px 3px 0 rgba(0, 0, 0, .55);
}
.gbtn:hover:not(:disabled) { background: #fff; }
.gbtn:active:not(:disabled) { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(0, 0, 0, .55); }
.gbtn:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
/* Not configured on this server. Shown rather than hidden — see the note in
   index.html — so it has to look unmistakably inert: no press, no shadow to
   suggest it would move, and the mark drained of its colour so the button is
   not a bright white bar competing with the form that DOES work. */
/* ⚠️ STILL PARCHMENT, just drained. The point of showing it at all is that
   somebody can see the control they are about to switch on, so a dark inert
   slab in the game's own palette would defeat it — at #6e675d on a #241c14
   panel the button read as a smudge rather than as a button that is off. */
.gbtn:disabled {
  cursor: default; background: #cdc6ba; color: #5f574a;
  border-color: #3a2d20; box-shadow: none; opacity: .75;
}
.gbtn:disabled .glogo { filter: grayscale(1); opacity: .55; }
/* The mark is a fixed size in a face that scales: at `1em` it grew with the
   Chinese gloss and shrank on a phone, and a logo that changes size beside its
   own wordmark reads as a rendering fault. */
.glogo { width: 18px; height: 18px; flex: 0 0 auto; }
.glabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* "or" on a hairline. A bare word would read as a heading for the fields under
   it rather than as the join between two ways in. */
.acct-or {
  display: flex; align-items: center; gap: 10px;
  margin: 14px 0 4px; color: var(--dim); font-size: .68rem;
  text-transform: uppercase; letter-spacing: .18em;
}
.acct-or::before, .acct-or::after {
  content: ''; flex: 1 1 auto; height: 2px; background: #3a2d20;
}

/* ══════════════════════════════════════════════════════════════════════════
   THE SIGN-IN CARD

   ⚠️ THE PANEL TREATMENT THAT WAS BORN HERE HAS LEFT. It used to be scoped to
   `#acctDialog` and a `.pxpanel` class only this card carried, on the argument
   that this was the one overlay that is a THING TO OPERATE rather than a
   document to read. That argument was wrong twice over: New Game, Settings,
   Rooms and 配器 are all things to operate too, and one 8-bit card in a stack
   of rounded web forms does not read as the special one — it reads as the one
   that does not match. The treatment now dresses every menu in the game and
   lives at the BOTTOM OF THIS FILE under "THE MENU SKIN", which is also where
   the reasoning for each part of it moved.

   What is left here is what is genuinely only true of this card: its lead
   paragraph, its stacked `.pxfield` rows, the Google button's notes, the
   consent box and the delete-account block.
   ══════════════════════════════════════════════════════════════════════════ */
/* ⚠️ #nameDialog SHARES IT, and the shared selector is deliberate rather than
   a second copy of three declarations. The name card is the same shape of
   thing — a lead line, a `.pxfield`, a note — and the two are read one after
   the other by the same guest, so a lead that is .76rem on one and 1rem on the
   other is a difference the player sees and cannot explain. Adding the id here
   rather than un-scoping the rule keeps it off every OTHER card in the game,
   which is why it was scoped in the first place. */
#acctDialog .acct-lead,
#nameDialog .acct-lead {
  font-size: .76rem; line-height: 1.55; color: var(--ink-dim); margin: 0;
}
/* The one line under the Google button. Dimmer than the lead and never more
   than two lines — account.js writes both the ready and the not-set-up
   wording into it. */
.acct-note {
  font-size: .68rem; line-height: 1.5; color: var(--dim); margin: 6px 0 0;
}
/* "Not set up yet" is a FACT about this server, not a warning to the player —
   amber here made the card look like it had failed at something. It keeps a
   marker class so the state is still addressable from a test. */
.acct-note.warn { color: var(--dim); font-style: italic; }

/* ── Fields ────────────────────────────────────────────────────────────────
   Stacked, full width, and identical in shape. The shared `.field` is a flex
   ROW with a 120px label, which put the username's box on its label's line and
   the password's on the line below — the reveal button makes that wrapper
   wider than the label's remainder. Two fields, two shapes, on a card with
   exactly two fields on it. */
.pxfield { margin: 10px 0 0; }
.pxfield label {
  display: flex; align-items: baseline; gap: 4px;
  font-size: .72rem; color: var(--ink-dim); margin: 0 0 4px;
  font-family: var(--font-display); letter-spacing: .05em;
}
/* The rule for the field, ON the field, instead of a sentence underneath it.
   This is where two of the card's eleven lines of prose went.

   ⚠️ `margin-left: auto` RATHER THAN `justify-content: space-between` on the
   label. The label's children are the 中文 gloss span, a bare text node, and
   this — three flex items, not two — so space-between shoved 名 and Username
   to opposite ends of the row with the rule stranded between them. Pushing
   only this one right keeps the gloss beside the word it glosses. */
.pxfield label em {
  margin-left: auto; padding-left: 8px;
  font-style: normal; font-size: .64rem; color: var(--dim);
  font-family: var(--font-ui); letter-spacing: 0; white-space: nowrap;
}
/* Only the SHAPE is stated here. The recessed slot — dark fill, square corners,
   inset shadow, gold caret and focus ring — is the menu skin's, and every text
   box in every dialog now wears it. */
#acctDialog input[type=text],
#acctDialog input[type=email],
#acctDialog input[type=password],
#nameDialog input[type=text] {
  display: block; width: 100%; min-width: 0;
  font-size: .84rem; padding: 9px 10px;
}
#acctDialog .pwwrap { display: flex; width: 100%; }

/* ── A refused name, marked on the box ────────────────────────────────────
   The error sentence in `.acct-err` was the only signal that a name had been
   rejected, and on a card where the box sits above it that is a sentence you
   read after you have stopped looking. Same red as `.acct-consent.missing`,
   which is the treatment the registration form already uses for "this control
   is why the form did not go through".

   ⚠️ SCOPED BY ID ON PURPOSE. The menu skin at the foot of this file sets
   `border-color: var(--gold)` on `.overlay input[type=text]:focus` — equal
   specificity to `.overlay input.bad` and LATER in the file, so a class-only
   rule here would lose the moment the player clicked back into the box to fix
   the name. `#nameDialog` wins on specificity instead, which is what lets this
   live beside the field it belongs to rather than inside the skin. The skin
   stays last, and stays colours-only.

   ⚠️ Colours only, no layout. `border-width` must not appear here — the field
   would change size as it turned red, and the account suites measure it. */
#nameDialog input[type=text].bad,
#nameDialog input[type=text].bad:focus {
  border-color: #c0392b;
  background: #241511;
  box-shadow: inset 2px 2px 0 rgba(0, 0, 0, .5), 0 0 0 2px rgba(192, 57, 43, .3);
}

/* ── The two ways to press ────────────────────────────────────────────────
   Sign in is the press this card exists for and takes twice the width.
   Register is the same press for a name that does not exist yet, so it sits
   BESIDE rather than under — stacking them implies a sequence that isn't one. */
.acct-actions { display: flex; gap: 8px; margin-top: 14px; }
/* Square corners, the hard shadow and the press-in are the menu skin's now —
   this pair only asks to be TALLER than an ordinary dialog button, because
   they are the press the whole card exists for. */
.acct-actions button { padding: 11px 10px; }
/* ⚠️ `flex-basis: auto` AND `nowrap`, not `flex: 2 1 0`. A zero basis divides
   the row 2:1 regardless of what is written on the buttons, which in 中文 left
   註冊 Register a hundred pixels wide and broke it over two lines — one button
   two lines tall beside one that is not. Content first, then the leftover
   space in a 2:1 share. */
.acct-actions button { white-space: nowrap; }
.acct-actions .primary { flex: 2 1 auto; }
.acct-actions .ghost { flex: 1 1 auto; }

/* ── Level two: making an account ─────────────────────────────────────────
   The register form is a separate level, not a second button on the first. It
   asks for things signing in does not, and each of them would be dead weight
   on the screen people use every day. */
#acctRegister[hidden], #acctSignin[hidden] { display: none; }
.acct-h3 {
  font-family: var(--font-display); color: var(--gold);
  font-size: .95rem; letter-spacing: .05em; margin: 2px 0 10px;
}
/* The way back, above the heading, where a back control belongs. Left-aligned
   because `.linkish` centres itself and a back link in the middle of a card
   reads as a footer. */
/* ⚠️ `.linkish.acct-back`, not `.acct-back`. `.linkish` centres itself with
   `margin: 8px auto 0`, is declared LATER in this file, and carries the same
   (0,1,0) specificity — so a lone `.acct-back { margin: 0 0 6px }` lost the
   cascade and the back link sat in the middle of the card looking like a
   footer. Two classes beat one wherever either is written. */
.linkish.acct-back { margin: 0 0 6px; padding-left: 0; text-decoration: none; }
.linkish.acct-back:hover:not(:disabled) { color: var(--gold); }
/* "No account yet? Create one" — one sentence with a control inside it, so the
   line reads as prose rather than as a second button competing with Sign in. */
.acct-switch {
  margin: 10px 0 0; text-align: center;
  font-size: .72rem; color: var(--dim);
}
.acct-switch .linkish { display: inline; margin: 0; padding: 0 2px; font-size: .72rem; }

/* The Google button's consent line. Dimmer than the note above it — it is the
   small print, and it should look like small print without being unreadable. */
.acct-consent-note { font-size: .66rem; line-height: 1.5; color: var(--dim); margin: 4px 0 0; }
.acct-consent-note a { color: var(--ink-dim); }

/* ── The consent box ──────────────────────────────────────────────────────
   ⚠️ THE INPUT IS NOT `appearance: none`. A checkbox that is drawn by CSS is a
   checkbox that some browser somewhere renders as nothing at all, and this is
   the one control on the card whose STATE is the legal record. It keeps the
   platform's own box, scaled up, with an accent colour — so "is it ticked" is
   answered by the same widget the player has ticked ten thousand times. */
.acct-consent {
  display: flex; align-items: flex-start; gap: 10px;
  margin: 14px 0 0; padding: 10px;
  background: #1c1610; border: 2px solid #3a2d20;
  font-size: .72rem; line-height: 1.5; color: var(--ink-dim);
  cursor: pointer;
}
.acct-consent:hover { border-color: #55432e; }
.acct-consent input[type=checkbox] {
  flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0;
  accent-color: var(--gold); cursor: pointer;
}
.acct-consent a { color: var(--gold); text-underline-offset: 3px; }
/* Marked when the form was submitted without it. Not red-on-load — nobody has
   refused anything yet at that point. */
.acct-consent.missing { border-color: #c0392b; background: #241511; }

/* Cancel. Still a <button> — it has to be reachable by keyboard and it has to
   clear the tap floor on a phone, and an <a href="#"> does neither honestly. */
.linkish {
  display: block; margin: 8px auto 0; padding: 6px 10px;
  background: none; border: 0; box-shadow: none;
  color: var(--dim); font-size: .72rem; font-weight: 400;
  text-decoration: underline; text-underline-offset: 3px; cursor: pointer;
}
.linkish:hover:not(:disabled) { color: var(--ink-dim); background: none; }
.linkish:active:not(:disabled) { transform: none; }

/* The policy links. They were rendering as default browser blue on a brown
   card, which is the loudest thing a page can do by accident. */
.acct-legal {
  display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: center;
  margin: 12px 0 0; padding-top: 10px; border-top: 2px solid #3a2d20;
}
.acct-legal a {
  color: var(--dim); font-size: .68rem; text-decoration: none;
  text-underline-offset: 3px;
}
.acct-legal a:hover { color: var(--gold); text-decoration: underline; }

/* ── The heading's × ───────────────────────────────────────────────────────
   One way out per card, in the heading rather than at the foot: on the account
   card it replaced the two text "Cancel" buttons on the sign-in and register
   levels, and on New Game the ghost "← Home" that shared the bottom row with
   Rules.

   ⚠️ THE FLEX IS SCOPED TO THE TWO IDS THAT WANT IT, never to `.card h2` — a
   blanket `display: flex` there would push Collection's and Saved Hands'
   `<small>` counters to the far edge of every one of those headings. Any card
   that grows a × belongs in this selector, and its heading text has to be
   wrapped in a `<span>` or `space-between` has nothing to push against.

   FLAT, deliberately: it joins `.linkish` and `.pweye` in section 4 of THE
   MENU SKIN at the bottom of this file, because a × that depresses onto its
   own shadow reads as an action you take rather than as a way out. */
#acctTitle, #menuTitle, #nameTitle, #quickTitle {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
}
.cardx {
  flex: 0 0 auto; width: 30px; height: 30px; padding: 0;
  display: grid; place-items: center;
  background: none; border: 2px solid transparent; border-radius: 2px;
  color: var(--ink-dim); font-size: 1.15rem; line-height: 1; cursor: pointer;
}
.cardx:hover { color: var(--ink); border-color: #6b5334; background: #1a140e; }
.cardx:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }

/* The password field is not covered by the shared input[type=text] rule. */
input[type=password] {
  font-family: inherit; font-size: .82rem; background: #1a140e; color: var(--ink);
  border: 2px solid #4a3b2a; border-radius: 3px; padding: 5px 8px; width: 150px;
}

/* ── Show / hide password ──────────────────────────────────────────────────
   The eye lives INSIDE the field's box. As a sibling of the input it took a
   share of the row's flex and shrank the field on a phone — which is the one
   place where typing a password you cannot see hurts most, and so the one
   place the control must not cost you room to type. */
.pwwrap { position: relative; display: flex; align-items: stretch; }
.pwwrap input[type=password], .pwwrap input[type=text] {
  flex: 1 1 auto; width: auto; min-width: 0;
  /* Room for the button, so a long password never slides under it. */
  padding-right: 52px;
}
/* A word rather than an eye: every other glyph here is a monochrome character
   in the UI face, and "Show"/"Hide" says which way the button goes, which no
   eye icon does. The visible label and aria-label are kept in step by
   showPassword() so a screen reader and a sighted user are told the same
   thing. */
.pweye {
  position: absolute; right: 2px; top: 2px; bottom: 2px; width: 46px;
  display: flex; align-items: center; justify-content: center;
  background: none; border: 0; border-radius: 2px; cursor: pointer;
  color: var(--dim); font-size: .66rem; line-height: 1; padding: 0;
  font-family: var(--font-display); letter-spacing: .03em;
  opacity: .8;
}
.pweye:hover { opacity: 1; color: var(--ink); }
.pweye[aria-pressed="true"] { opacity: 1; color: var(--gold); }
/* Keyboard users must be able to see where they are — the button has no
   border of its own to inherit a focus style from. */
.pweye:focus-visible { outline: 2px solid var(--gold); outline-offset: -2px; opacity: 1; }

/* ── The local balance, in the account dialog ─────────────────────────────
   Here rather than on the home card because it carries a Reset, and a button
   that wipes a chip count must not sit one stray tap away from 開局 Play. */
.acct-local {
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
  margin-top: 14px; padding-top: 12px; border-top: 2px solid #3a2d20;
}
.acct-local b {
  font-family: var(--font-display); color: var(--ink); font-size: .8rem;
  letter-spacing: .04em;
}
.acct-local .bal {
  color: var(--gold); font-weight: 700; flex: 1 1 auto;
  font-family: ui-monospace, Consolas, monospace; font-size: .82rem;
}
.acct-local .bal.broke { color: #e2705f; }
.acct-local button { flex: 0 0 auto; font-size: .72rem; padding: 5px 10px; }

/* ── Deleting the account ─────────────────────────────────────────────────
   Bordered rather than merely separated, because unlike the local reset above
   it this one cannot be undone by playing more hands. The red is the frame,
   not the fill: a block that is entirely red reads as an error the player has
   already made rather than as a door they may choose to open. */
.acct-danger {
  margin-top: 14px; padding: 10px 12px 12px;
  border: 2px solid #6d2b20; border-radius: 6px;
  background: rgba(109, 43, 32, .12);
}
.acct-danger > b {
  font-family: var(--font-display); color: #e2705f; font-size: .8rem;
  letter-spacing: .04em; display: block;
}
.acct-danger .acct-hint { margin: 5px 0 9px; }
.acct-danger button { font-size: .72rem; padding: 5px 10px; }
/* The confirm step sits under its own rule so the two presses read as two
   steps rather than as one row of four buttons. */
#acctDelConfirm { margin-top: 10px; padding-top: 10px; border-top: 1px solid #6d2b20; }

/* The banked balance in the toolbar. A readout, so it is styled like the
   standings counters rather than like the buttons it sits beside. */
.walletchip {
  display: inline-flex; align-items: center; gap: 5px;
  /* 1px, matching the rail's own frame. At 2px it was the heaviest line in a
     bar that otherwise has no boxes left in it, and the eye went to the
     balance before it went to the menu. */
  background: #1a140e; border: 1px solid #4a3b2a; border-radius: 3px;
  margin-left: 4px;
  padding: 5px 8px; font-size: .72rem; color: var(--gold); font-weight: 700;
  font-family: ui-monospace, Consolas, monospace;
  white-space: nowrap; max-width: 190px; overflow: hidden; text-overflow: ellipsis;
}
@media (max-width: 700px) { .walletchip { display: none; } }

/* The sub-labels are the last thing to go: six buttons that still say what
   they do beat six buttons that fit perfectly and explain nothing. */
@media (max-height: 620px) {
  /* `>` matters: since the labels became bilingual, .homebtn also contains
     <b><span class="mj-zh">開局 </span>Play</b>, and a descendant selector hid
     the Chinese off the buttons in 中文 — the one thing this screen must not
     do. Only the direct-child description span is decoration. */
  .homebtn > span { display: none; }
  /* ⚠️ …EXCEPT A REFUSAL, which is not decoration. Cropping the reason off a
     disabled button leaves a dead control with no explanation, which is the
     one thing worse than not offering it at all. Higher specificity, so it
     wins here without `!important`. */
  .homebtn.shut > span { display: block; }
  /* The whole line, not its second half: the subtitle is one line now that
     "Four-player tile game" has gone, so there is no second half to drop and
     hiding the lot recovers more than the old rule ever did. */
  .homesub { display: none; }
  /* Same reasoning: the two balances and their labels are the point, the
     sentence explaining what an account is for is not — and neither is the
     hand count trailing each figure. The LABELS stay in every case: a number
     whose origin has been cropped away is worse than no number, and telling
     the two balances apart is the reason the strip has two lines. */
  .acct .note, .acct .wsub { display: none; }
  .acct { padding: 4px 8px; }
}

/* Between 620px and 780px tall the card used to overflow its own padding by
   8–40px and rely on `overflow: hidden` to swallow it. That was invisible
   while nothing needed to be reachable above the fold; now that the card can
   scroll, the same overflow shows up as a scrollbar. The tiles are decoration
   and go first — they are the cheapest 35px on the screen. */
@media (max-height: 780px) {
  .hometiles { height: clamp(0px, 15vh, 132px); }
}
@media (max-height: 700px) {
  /* Was `.homesub > span` — the tagline's second line, which no longer exists.
     One line of subtitle is worth keeping this much longer than two were. */
  .homesub { display: none; }
}

/* ── The multiplayer pane is the tall one, and it buys its height back ─────
   It carries one row more than the root pane (a Back button), three subtitles
   that are the product rather than decoration, a player card, and a Ranked
   button whose refusal — "your rating is still being worked out — play 10 more
   ranked matches at New Style and the higher tables open up" — is the longest
   string on the landing page by a wide margin. Measured with all of that at
   once it ran 9-87px past the viewport at every size except a full desktop,
   and it had been doing so before the card was added: `homefit.py` only ever
   opened the ROOT pane, so this was the one pane whose fit nothing asserted.
   It does now, in both languages, at all five sizes.

   The concessions follow the same order as the rules above — decoration goes
   first and an explanation goes last — with one addition specific to this
   pane: THE DRAGONS GO IMMEDIATELY, at every height. They are the front door's
   decoration, and past the front door the player card is doing that job with
   real information in it. Two 117px tiles pushing a rank badge off a phone is
   decoration competing with the thing it decorates. */
body.mppane .hometiles { display: none; }
@media (max-height: 620px) {
  body.mppane .homesub { display: none; }
}
/* Last, and only on a phone lying down, where the title is already the least
   readable thing on the card and Quick Play is the most important. The card
   and the row gaps tighten with it — that is the final handful of pixels, and
   taking them out of PADDING rather than out of content is the whole point of
   spending them in this order. Nothing here stops being readable; it stops
   being roomy. */
@media (max-height: 470px) {
  body.mppane .hometitle { display: none; }
  body.mppane .homebtns { gap: 3px; }
  .mpcard { padding: 2px 9px 4px; }
}

/* ── 中文 runs a little taller ─────────────────────────────────────────────
   Only a little, now that `.homebtn > span` no longer catches the 開局 prefix:
   measured at 1366x768 with the account strip showing, the card is 692px in
   English and 713px in Chinese. Twenty-one pixels is the honest cost of a CJK
   line box on six buttons, and it only matters at the shortest viewports,
   where the same concessions the English rules make simply arrive sooner. */
@media (max-height: 660px) {
  html[data-lang="zh"] .homebtn > span { display: none; }
}
@media (max-height: 430px) {
  html[data-lang="zh"] .homefoot { display: none; }
}

/* ══════════════════════════════════════════════════════════════════════════
   番種 COLLECTION

   Wider than the other dialogs (900px against 560px) because the content is a
   grid of thirty hands, and at the standard card width it degenerates into
   one long column that hides the ladder shape the grouping exists to show.

   The completed / not-completed distinction is carried by THREE signals, not
   one: the tiles are face-down, the card is dimmed, and the footer says so in
   words. A colour difference alone fails for anyone who cannot see it, and a
   face-down strip alone is ambiguous on a screen where tile backs are also
   just what an opponent's rack looks like.
   ══════════════════════════════════════════════════════════════════════════ */
#collection .card { max-width: 900px; }
#collection .card h2 { display: flex; align-items: baseline; gap: 10px; }
#collection .card h2 small {
  font-family: var(--font-ui); font-size: .78rem; font-weight: 400;
  letter-spacing: .04em; color: var(--ink-dim);
}

.colband { margin-top: 14px; }
.colband h3 {
  display: flex; align-items: baseline; gap: 8px;
  margin: 0 0 6px; padding-bottom: 3px;
  border-bottom: 1px solid var(--panel-hi);
}
.colband h3 .v { color: var(--gold); font-size: .84rem; }
.colband h3 .n {
  font-family: var(--font-ui); font-size: .7rem; font-weight: 400;
  color: var(--dim); letter-spacing: .03em;
}
/* Sits after the count, never instead of it — the count is what the screen is
   for, and a band at the payout cap still has progress worth reading. */
.colband h3 .cap {
  font-family: var(--font-ui); font-size: .64rem; font-weight: 400;
  color: var(--gold); opacity: .75; letter-spacing: .03em; margin-left: auto;
}

/* auto-fill, not auto-fit: an odd row keeps its card at column width instead
   of stretching one lonely 8-faan hand across the whole dialog. */
.colgrid {
  display: grid; gap: 8px;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}

.colcard {
  background: #1b150f; border: 1px solid #3a2d20; border-radius: 3px;
  padding: 8px 9px 7px;
  display: flex; flex-direction: column; gap: 2px;
}
.colcard.got { border-color: #6b5432; background: #221a11; }
.colcard b { color: var(--ink); font-size: .84rem; }
.colcard .en { color: var(--dim); font-size: .68rem; }
.colcard .val { color: var(--gold); font-size: .7rem; letter-spacing: .04em; }
.colcard p { font-size: .72rem; line-height: 1.5; color: var(--ink-dim); margin: 2px 0 0; }
.colcard p.note { color: var(--dim); font-size: .68rem; }
/* `auto` pins it to the bottom of the card. Cards in a row stretch to the
   tallest, and without this the verdict floated wherever its own prose ended
   — so the one line you scan across a row was the one line never aligned. */
.colcard .state {
  margin-top: auto; padding-top: 5px; font-size: .66rem; letter-spacing: .05em;
  text-transform: uppercase; color: var(--dim);
}
.colcard .state.yes { color: var(--green); }

/* Hands here run from one tile (正花) to eighteen (十八羅漢). Sizing each
   canvas to its own contents and capping it at `max-width: 100%` meant each
   one met that cap at a DIFFERENT scale — measured on the built screen: a
   14-tile hand landed at 27px tall, the four-kong hand at 21px, and the
   single flower at its full 42px, three tile sizes side by side in one row of
   cards. That is what made the screen look ragged.

   tileStrip now draws every hand into the same 9×2 tile box (faanexamples.js),
   so every canvas has identical dimensions, meets the cap at an identical
   scale, and every tile on the screen is the same size. The band is two rows
   tall for all of them, which also keeps the names on one baseline. */
.colart {
  height: 88px; margin-bottom: 4px;
  display: flex; align-items: center; justify-content: center; overflow: hidden;
}
.colart canvas {
  display: block; width: auto; height: auto;
  max-width: 100%; max-height: 100%;
  image-rendering: pixelated; image-rendering: crisp-edges;
}
.colcard:not(.got) .colart { opacity: .55; }

@media (max-width: 560px) {
  .colgrid { grid-template-columns: 1fr; }
}

/* ── 存局 Saved Hands ──────────────────────────────────────────────────────
   A LIST, not a grid, and that is the one layout decision here. The collection
   shows thirty things you might chase and wants them scannable side by side; a
   saved hand is a thing you already did, and each card carries a strip of up to
   eighteen tiles, a faan table and four chip boxes. Two of those abreast on a
   laptop would squeeze the tiles to the size the collection's ragged-scale bug
   produced, for no gain — you read these one at a time. */
#savedhands .card { max-width: 720px; }
#savedhands .card h2 { display: flex; align-items: baseline; gap: 10px; }
#savedhands .card h2 small {
  font-family: var(--font-ui); font-size: .78rem; font-weight: 400;
  letter-spacing: .04em; color: var(--ink-dim);
}

#shList { display: flex; flex-direction: column; gap: 9px; margin-top: 12px; }

.shempty {
  font-size: .76rem; line-height: 1.6; color: var(--dim);
  border: 1px dashed #3a2d20; border-radius: 3px; padding: 12px; margin: 0;
}

.shcard {
  background: #1b150f; border: 1px solid #3a2d20; border-radius: 3px;
  padding: 9px 11px 8px;
  display: flex; flex-direction: column; gap: 5px;
}
.shhead { display: flex; align-items: baseline; gap: 10px; }
.shhead b {
  font-family: var(--font-display); color: var(--ink); font-size: .92rem;
  letter-spacing: .04em;
}
/* Pushed right and never wrapped: the date is how you find a hand again, and
   it has to sit in the same place on every card to be scanned down. */
.shhead .when {
  margin-left: auto; flex: 0 0 auto;
  font-size: .68rem; color: var(--dim); letter-spacing: .03em;
}
.shsub { margin: 0; font-size: .74rem; line-height: 1.5; color: var(--ink-dim); }
.shflow { margin: 0; font-size: .7rem; color: var(--dim); letter-spacing: .03em; }
/* Gold rather than red: this is "read this line", not "something broke". A
   record that no longer replays is still a hand you saved. */
.shwarn {
  margin: 0; font-size: .72rem; line-height: 1.5; color: var(--gold);
  background: #241c10; border-left: 2px solid #9a7526; padding: 5px 8px;
}

/* Every strip is the same fixed eighteen-slot canvas (savedhands.js), so they
   all meet this cap at the same scale and every tile in the list is the same
   size — the lesson .colart was rewritten to learn. */
.shstrip {
  display: block; width: auto; height: auto; max-width: 100%;
  image-rendering: pixelated; image-rendering: crisp-edges;
}

.shfaan { width: 100%; border-collapse: collapse; font-size: .74rem; }
.shfaan td { padding: 1px 0; color: var(--ink-dim); }
.shfaan td:last-child { text-align: right; color: var(--gold); width: 4em; }
.shfaan .en { color: var(--dim); font-size: .66rem; }
.shfaan tr.total td {
  border-top: 1px solid #3a2d20; padding-top: 3px; color: var(--ink); font-weight: 700;
}

.shchips { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
.shchip {
  background: #150f0a; border: 1px solid #2e241a; border-radius: 3px;
  padding: 3px 5px; text-align: center;
}
.shchip b { display: block; font-size: .62rem; color: var(--dim); font-weight: 400; }
.shchip span { font-size: .82rem; color: var(--ink-dim); }
.shchip span.up { color: var(--green); }
.shchip span.down { color: var(--red); }

.shfoot { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.shfoot .ghost { font-size: .68rem; padding: 3px 8px; min-height: 0; }
/* The id, not the whole URL. The link is a share handle, and printing
   http://host/api/replay/xxx on every card would be the widest thing on the
   screen and the least worth reading — the title attribute has the full one. */
.shlink {
  flex: 1 1 auto; min-width: 0; font-size: .68rem; color: var(--dim);
  letter-spacing: .04em; text-decoration: none;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.shlink:hover { color: var(--gold); text-decoration: underline; }
.shdel.armed { color: var(--red); border-color: var(--red); }

@media (max-width: 560px) {
  .shhead { flex-wrap: wrap; }
  .shhead .when { margin-left: 0; flex-basis: 100%; }
  .shchips { grid-template-columns: repeat(2, 1fr); }
}

/* ── Table chat (multiplayer only) ─────────────────────────────────────────
   Hidden entirely in local play — a chat box in a game against bots is
   furniture nobody asked for.

   ⚠️ IT SITS BETWEEN THE FELT AND THE HAND (7 Aug 2026), where it used to be
   the last thing in the document, under the rack and — on a phone — under the
   fold. Three things follow from the move and all three are load bearing:

     · Every pixel of height here is a pixel off the table. `fit()` charges the
       canvas for this element, so it is kept SHALLOW on purpose: nothing in
       here grows without a reason, and the log has a hard ceiling.
     · The transcript is no longer the point. What was said arrives as a bubble
       over the seat that said it (`chatbubble.js`); this is the scrollback, the
       place client-side notices land ("Recorded.", "X muted"), and the
       `aria-live` region a screen reader reads. Three lines is enough for all
       three jobs.
     · The presets stay ONE ROW. Wrapping eight buttons at a 44px tap floor is
       three rows and 140px of felt, so past the width they fit in, the row
       scrolls sideways instead of growing downward. */
#chatBox { width: 100%; max-width: 640px; margin: 0 auto; padding: 0 10px; }
/* Collapsed chat keeps its hint and drops everything that costs height. The
   toggle is Enter (see view.js); this is only what the two states look like. */
#chatBox.collapsed > #chatLog,
#chatBox.collapsed > #chatMutes,
#chatBox.collapsed > #chatPresets,
#chatBox.collapsed > #chatNote,
#chatBox.collapsed > #chatTypeRow { display: none; }
#chatHint {
  display: block; width: 100%; text-align: left;
  background: none; border: 0; padding: 1px 0; cursor: pointer;
  font-family: inherit; font-size: .70rem; color: var(--dim);
  letter-spacing: .02em;
}
#chatHint:hover { color: var(--ink-dim); }
/* The seal that stands in for the sentence when this button is a phone tab.
   Off everywhere else — see the ⚠️ on `#chatHint` in index.html. */
#chatHint .chattab { display: none; }
/* Why there is no typing box. One dim line, in the slot the box would have
   taken, so the rule is stated where the control would have been. */
.chatnote {
  margin-top: 4px; font-size: .66rem; line-height: 1.45; color: var(--dim);
}
#chatLog { max-height: 54px; overflow-y: auto; font-size: .74rem; line-height: 1.4; }
#chatLog .who { color: var(--gold, #d8a544); font-weight: 700; }
#chatLog div { margin: 1px 0; }
/* `nowrap` + `overflow-x` rather than `flex-wrap: wrap`: see the ⚠️ above.
   `scrollbar-width: none` because a horizontal scrollbar under eight buttons
   is another row of height, and the row is obviously draggable without it. */
#chatPresets {
  display: flex; flex-wrap: nowrap; gap: 4px; margin-top: 4px;
  overflow-x: auto; scrollbar-width: none;
}
#chatPresets::-webkit-scrollbar { display: none; }
#chatPresets button {
  flex: 0 0 auto; white-space: nowrap;
  padding: 4px 9px; font-size: .74rem; border-radius: 6px; cursor: pointer;
  background: rgba(255,255,255,.06); color: inherit;
  border: 1px solid rgba(255,255,255,.12);
}
#chatPresets button:hover { background: rgba(216,165,68,.22); }
#chatTypeRow { display: flex; gap: 4px; margin-top: 4px; }
#chatTypeRow input { flex: 1 1 auto; font-size: .8rem; padding: 5px 8px; }
/* Short screens have no height to spend on a scrollback at all — and least of
   all on the one that duplicates what is already on the felt. The bubbles, the
   presets and the composer all survive; only the history goes. */
@media (max-height: 620px) {
  #chatLog { max-height: 20px; }
}

/* ══════════════════════════════════════════════════════════════════════════
   THE CHAT DOCK — the hanging scroll down the right-hand side (8 Aug 2026)

   A wide screen has hundreds of pixels of empty page down either side of the
   felt and none at all above the hand, so on a wide screen the strip stops
   being a strip: it lifts out of the document and hangs on the right as a
   mounted scroll (掛軸) — a lacquer panel with a silk-gold rule, corner
   brackets and a seal in the corner.

   ⚠️ IT MUST COST THE TABLE NOTHING, and that is the whole brief. Three
   separate things had to be true at once, and each one has already been a bug
   in this file:

     · NO HEIGHT. `fit()` charges the canvas for everything in the band
       between the felt and the hand (see the `chrome` sum in view.js). A
       fixed element is out of flow, so `boxH` now returns 0 for one — that is
       what keeps the felt exactly the size it was, rather than stepping up
       when the dock appears and back down when it collapses.
     · NO WIDTH. `fit()`'s width budget is `#wrap.clientWidth`. The dock is
       NOT a flex child of `#wrap` and is not a sibling column — putting it in
       the row would take ~260px off the table at the one moment the player
       most wants to see it.
     · NO REFLOW WHEN IT TALKS. The panel has a definite height, so the log
       scrolls inside it and an arriving message can never resize the dock.
       A dock that grew by a line would move the seal, the presets, and — via
       the hand panel clamp below — the panel underneath it.

   ⚠️ POSITIONED FROM THE CANVAS, NOT THE VIEWPORT. `top` is set by view.js
   (`placeChat`), the same way the hand panel is pinned to the felt's lower
   right. It has to clear the seat badge in the felt's TOP right, which is a
   fraction of the felt's height rather than a fixed number of pixels — the
   value here is only the pre-JS fallback.

   ⚠️ EVERY COLOUR IS A VARIABLE. The Jade Hall (below) is a variable override
   and nothing else, and chat is the one component that is ONLY ever seen in
   the hall — a hard-coded brown here would be the single widget that never
   got the memo. `--gold` is jade in a match, and so is this frame.

   ⚠️ AND IT IS NOT ALWAYS CLEAR OF THE FELT. There is no empty lane down the
   right-hand side of the table: seat 1's exposed melds run at x 500-517 of
   576 and its own hand at 534-560, so the dock takes the empty PAGE beside
   the table first (`placeChat` measures it) and only encroaches when there is
   not enough of it. Above roughly 1550x900 it touches nothing at all; on a
   1280-wide laptop it lies over the right-hand seat the way `#handpanel`
   already lies over the corner beneath it. Below 1200px it does not dock at
   all and the in-flow strip above is exactly what it was.
   ══════════════════════════════════════════════════════════════════════════ */
@media (min-width: 1200px) and (min-height: 620px) {
  #chatBox {
    position: fixed; right: 10px; top: 96px; z-index: 21;
    /* Overwritten by `placeChat` from the real gutter; this is the fallback. */
    width: 232px; max-width: none; margin: 0;
    /* Definite, so the log scrolls instead of the panel growing — see ⚠️ 3. */
    height: min(54vh, 392px);
    display: flex; flex-direction: column;
    padding: 8px 10px 10px;
    /* Woven silk over lacquer: a hairline weave, a top-down sheen, and the
       themed panel colour underneath. Neutral whites and blacks on top of
       `var(--panel)` so the whole stack follows the room. */
    background:
      repeating-linear-gradient(0deg,
        rgba(255,255,255,.014) 0 1px, transparent 1px 3px),
      linear-gradient(180deg, rgba(255,255,255,.05), rgba(0,0,0,.30)),
      var(--panel);
    border: 2px solid var(--panel-hi); border-radius: 3px;
    box-shadow:
      0 12px 30px rgba(0,0,0,.62),
      inset 0 0 0 1px rgba(255,255,255,.05),
      inset 0 0 26px rgba(0,0,0,.45);
  }
  /* ⚠️ The UA's `[hidden] { display: none }` loses to the author `display:
     flex` above, so a chat that is not at a table would hang there empty
     through the whole home screen. The hand panel carries the same line. */
  #chatBox[hidden] { display: none; }
  /* Collapsed: the mount becomes its own title plate, still hanging. */
  #chatBox.collapsed { height: auto; padding-bottom: 5px; }

  /* The scroll's corner brackets — eight one-pixel rules, four corners.
     ⚠️ `z-index` because a pseudo-element is the FIRST child box: without it
     the title band below, which bleeds into the padding, paints over the two
     top corners and the frame loses half of itself. */
  #chatBox::before {
    content: ''; position: absolute; inset: 4px; pointer-events: none;
    z-index: 2; opacity: .6;
    background:
      linear-gradient(var(--gold) 0 0) left  0 top    0 / 11px 1px no-repeat,
      linear-gradient(var(--gold) 0 0) left  0 top    0 / 1px 11px no-repeat,
      linear-gradient(var(--gold) 0 0) right 0 top    0 / 11px 1px no-repeat,
      linear-gradient(var(--gold) 0 0) right 0 top    0 / 1px 11px no-repeat,
      linear-gradient(var(--gold) 0 0) left  0 bottom 0 / 11px 1px no-repeat,
      linear-gradient(var(--gold) 0 0) left  0 bottom 0 / 1px 11px no-repeat,
      linear-gradient(var(--gold) 0 0) right 0 bottom 0 / 11px 1px no-repeat,
      linear-gradient(var(--gold) 0 0) right 0 bottom 0 / 1px 11px no-repeat;
  }
  /* The seal a mounted scroll is stamped with. `var(--red)` rather than a
     vermilion literal, for the same reason as everything else in here. */
  #chatBox::after {
    content: '話'; position: absolute; top: 8px; right: 9px; z-index: 3;
    font-family: var(--font-display); font-size: .56rem; line-height: 1;
    padding: 2px 3px; color: #f2e4cd;
    background: var(--red); border: 1px solid rgba(255,255,255,.28);
    border-radius: 1px; box-shadow: 0 0 0 1px rgba(0,0,0,.55);
    pointer-events: none;
  }

  /* The mount's top rod: the title sits on a band that bleeds out through the
     panel's padding to both edges, so it reads as the dowel a scroll hangs
     from rather than as a line of text with a rule under it. */
  #chatHint {
    flex: 0 0 auto; position: relative;
    margin: -8px -10px 0; padding: 7px 28px 7px 10px;
    font-family: var(--font-display); font-size: .70rem;
    letter-spacing: .04em; color: var(--ink-dim);
    /* ⚠️ One line, always. The narrowest the dock ever gets is 196px and the
       label is a whole sentence ("Chat — Enter to hide"); wrapped, the mount
       band grows a second row, the seal ends up beside nothing, and the panel
       loses that height off the scrollback underneath. */
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    background: linear-gradient(180deg, rgba(255,255,255,.085), rgba(0,0,0,.20));
    border-bottom: 1px solid var(--panel-hi);
  }
  #chatHint:hover { color: var(--gold); }
  /* 雷紋 — the beaded band a mounted scroll carries under its title strip.
     ⚠️ `bottom: 0`, INSIDE the band: the ellipsis above needs `overflow:
     hidden`, and the first version hung the beads 3px below the box, where
     that clipped them away entirely. */
  #chatHint::after {
    content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
    background: repeating-linear-gradient(90deg,
      var(--gold) 0 3px, transparent 3px 7px);
    opacity: .5;
  }

  /* ⚠️ `flex: 1 1 auto` + `min-height: 0` is what makes the log the part that
     gives: without the `min-height` a flex item refuses to shrink below its
     content and the presets and the composer get pushed out of the panel. */
  #chatLog {
    /* ⚠️ `min-height` in EM, not 0. The log is the flexible part, so at the
       196px floor — where the presets wrap to five rows — it is what gets
       squeezed, and it was squeezed down to one and a half lines of a
       scrollback. Three lines is the floor the strip has always had; past
       that the presets give instead (they scroll, below). */
    flex: 1 1 auto; min-height: 3.6em; max-height: none;
    margin-top: 9px; padding-right: 4px;
    font-size: .72rem; line-height: 1.5;
    overscroll-behavior: contain;
    /* The scrollback is pinned to its LAST line, so what overflows is cut at
       the top — and a line guillotined halfway through reads as a rendering
       fault. It fades under the title band instead. */
    -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 12px);
    mask-image: linear-gradient(180deg, transparent 0, #000 12px);
  }
  #chatLog div { margin: 2px 0; }

  #chatMutes { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 3px 4px; margin-top: 7px; }
  /* ⚠️ ONLY FOR A MOUSE. The 44px floor these buttons inherit is the phone tap
     target, and a touch device wide enough for the dock (a tablet in landscape
     is over 1000px) still has a thumb on it. `pointer: fine` is the question
     actually being asked — "is this a pointer that can hit a 22px button" —
     rather than a width standing in for it. */
  @media (pointer: fine) {
    #chatMutes button { min-height: 0; font-size: .66rem; }
  }

  /* The one place the dock pays for itself: there is vertical room here, so
     the presets stop being a sideways scroll nobody discovers and become the
     grid of phrases they always wanted to be. */
  #chatPresets {
    /* `0 1 auto`: the phrases give way after the log has given all it will,
       and they scroll DOWNWARD here rather than sideways — the one direction
       the strip below the felt could never spare. */
    flex: 0 1 auto; min-height: 0; flex-wrap: wrap;
    overflow-x: hidden; overflow-y: auto;
    margin-top: 7px; padding-top: 7px;
    border-top: 1px solid var(--panel-hi);
  }
  #chatPresets button {
    padding: 4px 7px; font-size: .70rem; border-radius: 2px;
    color: var(--ink-dim);
    background: linear-gradient(180deg, rgba(255,255,255,.07), rgba(0,0,0,.22));
    border: 1px solid var(--panel-hi);
  }
  #chatPresets button:hover {
    color: var(--ink); border-color: var(--gold);
    background: linear-gradient(180deg, rgba(255,255,255,.12), rgba(0,0,0,.18));
  }

  #chatTypeRow, #chatNote { flex: 0 0 auto; margin-top: 7px; }
  #chatTypeRow input {
    min-width: 0; font-size: .74rem; padding: 4px 7px;
    background: rgba(0,0,0,.34); border: 1px solid var(--panel-hi);
  }
  #chatTypeRow .ghost { flex: 0 0 auto; padding: 4px 8px; font-size: .7rem; min-height: 0; }

  /* ⚠️ NOTHING HERE MOVES FOR THE RESULT CARD, and that is deliberate. It
     docks to this same edge at this same width and simply covers the scroll,
     which is the right outcome: stepping the dock inboard to stay visible
     would put it in the middle of the felt at the one moment every hand on
     the table is face up and being read. */

  /* ⚠️ THE HAND PANEL IS ON THIS RAIL TOO, pinned to the felt's bottom-right
     and growing UPWARD, so left alone it grows straight through the dock.
     `--chat-dock-bottom` is set by `placeChat` and removed the moment the dock
     is not there, and the fallback below resolves to the `calc(100vh - 190px)`
     this was before — so the parlour, which has no chat at all, is untouched. */
  #hpBody {
    max-height: max(120px, calc(100vh - var(--chat-dock-bottom, 104px) - 86px));
  }

  /* It arrives from off the right edge, like something being hung. */
  @media (prefers-reduced-motion: no-preference) {
    #chatBox { animation: chatdock-in .26s ease-out both; }
  }
}
@keyframes chatdock-in {
  from { opacity: 0; transform: translateX(14px); }
  to   { opacity: 1; transform: none; }
}

/* ══════════════════════════════════════════════════════════════════════════
   THE CHAT ON A PHONE — the same scroll, hung off the edge (8 Aug 2026)

   Nathan, 8 Aug 2026: "the hand and chat could not co-exist. If hand is open,
   chat is covered by it. But if hand is closed, chat is always present."

   Both halves of that were true, and they were the SAME bug wearing two faces.
   The strip sat in the flow between the felt and the rack, so:

     · closed  it was permanent furniture in the one band a phone has no room
               for, and `fit()` charged the canvas for every pixel of it;
     · open    `#handpanel` becomes a full-width bottom sheet at this size
               (see "Phones: the dock becomes a sheet"), which covered it
               completely — a chat you cannot see is a chat you have lost.

   So on a phone it does what it already does on a wide screen: it LEAVES THE
   FLOW and hangs off the right-hand edge. Three consequences, all of them the
   point rather than side effects:

     · IT COSTS THE FELT NOTHING. `boxH()` returns 0 for a fixed element, so
       the height the strip used to take goes back to the table and the rack.
       This is the same mechanism the wide dock uses and it is written up
       there; nothing in view.js knows either breakpoint.
     · IT CO-EXISTS WITH THE HAND SHEET. z-index 23 is ABOVE `#handpanel` at
       22 and BELOW `#actions.ontable` at 25 — so the tab still floats over an
       open hand sheet (chat is never lost again) while a claim, which arrives
       on a clock, still draws over everything.
     · THE PHRASES ARE THE CHAT. Below, `#chatPresets` is the flexible middle
       and becomes a two-column grid of eight thumb-sized buttons; the
       scrollback is capped at three lines. On a phone the transcript was never
       the product — what was said arrives as a bubble on the felt
       (`chatbubble.js`) — and free text is friends-only anyway, so for most
       tables the phrases are the entire interface.

   ⚠️ `--chat-side` IS A FLAG FOR view.js, and it is the only thing JS reads.
   `placeChat` used to tell the two layouts apart by `position: fixed`, which
   stopped working the moment there were two fixed ones; it would then have
   applied the WIDE dock's geometry here — a gutter-derived width and a top
   measured off the felt, both meaningless on a 390px screen. The breakpoint
   stays in this file.

   ⚠️ THE SHUT TAB IS CENTRED ON THE FELT, NOT ON THE PAGE, and `placeChat`
   sets that `top` because only JS can measure it. Centred on the VIEWPORT it
   landed squarely on the fifth tile of the top rack row — the rack is the
   interface on a portrait phone, and covering a tile you have to tap is worse
   than anything the strip was doing. Over the felt it covers the right-hand
   seat's tile backs, which are decoration at this scale: the same trade
   `#handpanel` and the wide dock already make at the other corner. The `top`
   here is only the pre-JS fallback.
   ══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 700px), (max-height: 470px) {
  #chatBox {
    --chat-side: 1;                       /* read by placeChat — see ⚠️ above */
    position: fixed; z-index: 23;
    right: 0; top: 34vh;                  /* overwritten by placeChat */
    width: min(248px, 74vw); max-width: none; margin: 0;
    /* Definite, so an arriving line scrolls the log instead of resizing the
       drawer — the same rule the wide dock keeps, for the same reason. */
    height: min(74vh, 430px);
    display: flex; flex-direction: column;
    padding: 0 10px calc(10px + env(safe-area-inset-bottom, 0px));
    background:
      linear-gradient(180deg, rgba(255,255,255,.05), rgba(0,0,0,.30)),
      var(--panel);
    border: 2px solid var(--panel-hi); border-right: 0;
    border-radius: 4px 0 0 4px;
    box-shadow: -10px 0 30px rgba(0,0,0,.62);
  }
  /* ⚠️ The UA's `[hidden] { display: none }` loses to the author `display:
     flex` above, so a chat that is not at a table would hang off the side of
     the home screen. Same line as the wide dock and the hand panel. */
  #chatBox[hidden] { display: none; }

  /* ── Collapsed: the whole chat is one tab ──────────────────────────────
     Which is the default on a thumb (`CHAT_OPEN_DEFAULT` in view.js). The
     panel gives up its box entirely and the mount band becomes the tab, so
     what is on screen between messages is 44px of edge and nothing else. */
  #chatBox.collapsed {
    width: auto; height: auto; padding: 0;
    background: none; border: 0; box-shadow: none;
  }
  #chatBox.collapsed > #chatHint {
    position: relative;
    display: flex; align-items: center; justify-content: center;
    width: 44px; min-height: 116px; padding: 10px 0; margin: 0;
    font-family: var(--font-display); font-size: 1rem; color: var(--ink-dim);
    background:
      linear-gradient(90deg, rgba(255,255,255,.07), rgba(0,0,0,.26)),
      var(--panel);
    border: 2px solid var(--panel-hi); border-right: 0;
    border-radius: 4px 0 0 4px;
    box-shadow: -6px 0 18px rgba(0,0,0,.5);
  }
  #chatBox.collapsed > #chatHint .chatlbl { display: none; }
  #chatBox.collapsed > #chatHint .chattab { display: block; }
  /* Somebody spoke while the tab was shut. Cleared on open (view.js) — the dot
     only ever means "since you last looked", never "there is a chat here". */
  #chatBox.unread.collapsed > #chatHint::after {
    content: ''; position: absolute; top: 7px; right: 7px;
    width: 9px; height: 9px; border-radius: 50%;
    background: var(--red); box-shadow: 0 0 0 1px rgba(0,0,0,.6);
  }

  /* ── Open: the title bar is also the way out ───────────────────────────
     There is no Enter on a phone, so this row is the only close control there
     is and it is sized as one. It bleeds through the drawer's padding to both
     edges so it reads as a bar rather than as a line of text. */
  #chatBox:not(.collapsed) > #chatHint {
    flex: 0 0 auto;
    display: flex; align-items: center;
    margin: 0 -10px; padding: 0 10px; min-height: 44px;
    font-family: var(--font-display); font-size: .74rem;
    letter-spacing: .03em; color: var(--ink-dim);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    background: linear-gradient(180deg, rgba(255,255,255,.085), rgba(0,0,0,.20));
    border-bottom: 1px solid var(--panel-hi);
  }

  /* The scrollback, kept to three lines. ⚠️ This overrides the 20px ceiling the
     `max-height: 620px` block above sets, and it does it on source order at
     equal specificity — moving this block up the file would silently squeeze
     the log back to a line and a half. */
  #chatLog {
    flex: 0 0 auto; max-height: 4.7em; margin-top: 8px;
    font-size: .74rem; line-height: 1.55;
    overflow-y: auto; overscroll-behavior: contain;
    scrollbar-width: none;
  }
  #chatLog::-webkit-scrollbar { display: none; }
  #chatLog div { margin: 2px 0; }

  /* ── The phrases, which on a phone ARE the chat ────────────────────────
     Two columns, thumb-sized, taking every pixel the rest of the drawer does
     not need. `flex-wrap` and a DOWNWARD scroll, where the in-flow strip had
     to scroll sideways: the one thing a drawer has that a strip never did is
     vertical room, so this is what it is spent on.

     No `min-height` here on purpose — the `pointer: coarse` floors at the
     bottom of the file own that number, including the lower one they use in
     landscape, and repeating it here would beat them on specificity. */
  #chatPresets {
    flex: 1 1 auto; min-height: 0;
    flex-wrap: wrap; align-content: flex-start;
    gap: 6px; margin-top: 9px; padding-top: 9px;
    overflow-x: hidden; overflow-y: auto;
    border-top: 1px solid var(--panel-hi);
  }
  #chatPresets button {
    flex: 1 1 calc(50% - 6px); white-space: normal;
    padding: 6px 8px; font-size: .8rem; border-radius: 3px;
    color: var(--ink);
    background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(0,0,0,.24));
    border: 1px solid var(--panel-hi);
  }

  /* ⚠️ THE PHRASES COME BEFORE THE MUTE ROW HERE, and only here. In the
     document the mute buttons sit next to the transcript, which is right on a
     wide screen: the dock is tall enough to show everything at once and the
     controls belong beside the thing they control. In a drawer they were four
     buttons of moderation between the player and the eight buttons they opened
     the drawer for. `order` rather than a second block of markup — one DOM,
     two readings, and no element that exists twice. */
  #chatHint { order: 0; }
  #chatLog { order: 1; }
  #chatPresets { order: 2; }
  #chatMutes { order: 3; flex: 0 0 auto; gap: 5px 6px; margin-top: 9px; }
  #chatTypeRow, #chatNote { order: 4; flex: 0 0 auto; margin-top: 9px; }
  #chatTypeRow { flex-wrap: wrap; }
  #chatTypeRow input {
    min-width: 0; font-size: .8rem;
    background: rgba(0,0,0,.34); border: 1px solid var(--panel-hi);
  }
  #chatNote { font-size: .7rem; }

  /* It slides in from the edge it is hung on — the same keyframes as the wide
     dock, which it can share because the vertical placement is a `top` set by
     `placeChat` rather than a transform. An animation with `fill-mode: both`
     ends up OWNING the transform (its last frame beats the rule), so a
     `translateY(-50%)` centring trick here would have been silently wiped and
     the drawer would sit half its own height too low, permanently. */
  @media (prefers-reduced-motion: no-preference) {
    #chatBox { animation: chatdock-in .22s ease-out both; }
  }
}

/* ══════════════════════════════════════════════════════════════════════════
   THE TIP — the readable thing on the waiting screen.

   It sits under the seats in `#lobbyRoom`, which is the queue AND the private
   lobby, and it rolls every ten seconds (js/tips.js).

   Three decisions worth keeping:

   · A RESERVED HEIGHT, not `auto`, and it is MEASURED rather than guessed.
     A box that grew and shrank with each tip would shove Start and Leave up
     and down the card every ten seconds — under a player's thumb, on the one
     screen where the only thing they might press is Leave. The reserve was
     taken by rendering all 100 tips at 1280px, 390px and 360px viewports and
     reading `height / line-height` back:

         English   3-4 lines, worst case 4 at a 270px body
         中文       1 line, every tip, every width

     which is why the two languages get different reserves. Giving 中文 the
     English box would be three blank lines under every tip. The first version
     reserved three lines for everyone and 24 tips overflowed it — the note
     that says "no magic numbers" is why that got caught rather than shipped.

     ⚠️ A TIP THAT NEEDS FIVE LINES ON A PHONE BRINGS THE JITTER BACK. Twenty
     of them did; they were shortened, not accommodated. Roughly 150 English
     characters is the ceiling, and a tip nobody can read in ten seconds was
     too long anyway.

   · A LEFT RULE, NOT A PANEL. The card is already a bordered dialog inside a
     bordered menu skin; a third box inside it reads as an error banner. One
     rule and a lift in the background is enough to say "this is a different
     kind of sentence from the one above it".

   · ⚠️ `var(--gold)`, NEVER THE LITERAL. Multiplayer repaints the whole room
     jade (see THE JADE HALL below) by remapping that variable, so a hardcoded
     `#d8a544` on the rule left an amber stripe beside a jade label — on the
     one screen that only ever appears inside the jade room.

   · THE FADE IS ON THE WRAPPER, so the label does not blink with the words —
     it is the one part of this component that never changes.
   ══════════════════════════════════════════════════════════════════════════ */
.lobbytip {
  margin-top: 12px;
  padding: 8px 10px 8px 11px;
  border-left: 3px solid var(--gold, #d8a544);
  border-radius: 0 6px 6px 0;
  background: rgba(255,255,255,.045);
  transition: opacity .22s ease;
}
.lobbytip[hidden] { display: none; }
.lobbytip.fading { opacity: 0; }

.lobbytip-label {
  display: block;
  font-size: .62rem;
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--gold, #d8a544);
  opacity: .8;
  margin-bottom: 3px;
}

/* The reserve is written as `font-size × line-height × lines` rather than a
   pixel figure, so changing either of the first two cannot silently undo the
   measurement above. */
.lobbytip-body {
  margin: 0;
  font-size: .74rem;
  line-height: 1.5;
  color: var(--ink-dim, #c9bda8);
  min-height: calc(.74rem * 1.5 * 4);
}

/* 中文 is one line per tip at every width measured. Two is the reserve, not
   one: the second line is headroom for a tip somebody writes later, and one
   blank line is a far smaller cost than a card that jumps. */
html[data-lang="zh"] .lobbytip-body { min-height: calc(.74rem * 1.5 * 2); }

/* ══════════════════════════════════════════════════════════════════════════
   QUICK PLAY'S DIFFICULTY ROWS

   Three rungs, each a plain <button> inside an .overlay — which means the
   pixel button skin at the bottom of this file already dresses them and
   NOTHING HERE SETS A COLOUR THAT COMPETES WITH IT. What is here is layout:
   a title on the left, a live population on the right, one line of what the
   rung means underneath.

   ⚠️ THE COUNT IS ON THE RIGHT AND IS NOT THE HEADLINE. A number that shouted
   would turn a difficulty choice into a popularity contest and push everybody
   into whichever rung happened to be busiest that minute — which is the exact
   feedback loop that empties the other two. It is there to be found by
   somebody who is looking for people, and to be ignored by somebody who came
   to pick a difficulty.

   ⚠️ AND A ZERO IS PRINTED, NOT HIDDEN. "Nobody here" is the answer to the
   question the row is answering; blanking it would make an empty rung
   indistinguishable from one that had not loaded yet. What the row must not
   do is treat zero as a REFUSAL — every rung seats you inside thirty seconds
   whatever the count says, which is what the footnote is for.
   ══════════════════════════════════════════════════════════════════════════ */
.qrow {
  display: block;
  width: 100%;
  text-align: left;
  margin: 6px 0;
  padding: 9px 11px;
}
.qrow-top {
  display: flex;
  align-items: baseline;
  gap: 8px;
}
.qrow-name { font-weight: 700; font-size: .92rem; flex: 1 1 auto; }
/* Tabular figures so a count going from 9 to 10 does not shuffle the row. */
.qrow-pop {
  flex: 0 0 auto;
  font-size: .68rem;
  font-variant-numeric: tabular-nums;
  color: var(--ink-dim, #c9bda8);
  opacity: .85;
}
/* Somebody is actually here. The one place in this component that spends a
   colour, and it spends the game's own "this is the live one" amber. */
.qrow-pop.live { color: var(--gold, #d8a544); opacity: 1; }
/* ⚠️ `font-weight: 400` IS NOT A DEFAULT HERE. This sits inside a <button>,
   and the button rule sets 700 — so without it the explanatory line comes out
   as bold as the rung's own name and the row has two headlines. */
.qrow-why {
  display: block;
  margin-top: 2px;
  font-weight: 400;
  font-size: .7rem;
  line-height: 1.45;
  color: var(--ink-dim, #c9bda8);
  white-space: normal;
}
/* The rung you played last, so a returning player's eye lands on it. It is a
   MEMORY, not a selection — pressing any row starts a queue immediately, so
   this must not look like `.seg button.on`, which means "this is what the
   setting is set to". A left edge, and nothing else. */
.qrow.last { border-left-color: var(--gold, #d8a544); border-left-width: 3px; }

/* ══════════════════════════════════════════════════════════════════════════
   THE JADE HALL — multiplayer's room.

   The single-player game is a private parlour: one warm lamp, dark wood, amber
   light. A match is a bigger, cooler, public room, and it should not be a
   surprise which one you are in — you should know before reading a word.

   ⚠️ IT IS TWO CLASSES ON <body>, AND EVERY RULE BELOW IS A VARIABLE OVERRIDE.
   No component knows there are two rooms; no dialog, button, panel or table
   needed editing. That is deliberate and it is the thing to preserve: the
   moment a specific widget hard-codes a jade colour, the theme stops being a
   theme and becomes a hundred places to keep in step.

   ⚠️ WHY TWO. `body.mp` is "at an online TABLE" and is set by `enterRoom`;
   `body.mppane` is "went through the multiplayer DOOR" and is set by
   `showPane`. The room used to arrive with the first dealt tile, which is the
   one moment a player is looking at the tiles and not at the room — so the
   whole change was spent on nobody. It arrives at the door now, and the queue,
   Rooms, the profile and the ladder are all inside it on the way to the table.
   The two classes never disagree, they only overlap: `showPane` clears
   `mppane` on the way home and `enterRoom('local')` clears `mp`.

   Every rule here names both. A rule that names only `body.mp` is a thing that
   waits until the table, and that is now a decision rather than an oversight —
   there are none.

   ⚠️ AND IT NEVER TOUCHES A TILE. Tile art is canvas, drawn from `C` in
   config.js, and the room palette there leaves every tile key alone — faces,
   backs, ivory, the red and green of the dragons. Their contrast is measured
   at every size by `glyphweight.py`, and 索/萬 is this project's own worst
   pair to separate by hue. Atmosphere never gets to cost legibility.
   ══════════════════════════════════════════════════════════════════════════ */

  body.mp,
  body.mppane {
    /* Jade for gold, at the same weight, so nothing needs relaying out. */
    --gold:     #57c9a5;
    --red:      #d1594a;
    --green:    #3fae86;
    /* Colder, deeper panels: lacquer rather than lamplit wood. */
    --bg:       #0a1416;
    --panel:    #14252a;
    --panel-hi: #1f3a42;
    --ink:      #dbe9e4;
    --ink-dim:  #93b0aa;
    --dim:      #6f8b86;
    --felt:     #12564f;
  }

  /* The room announces itself once, at the top, and then stops talking: a hair
     of jade along the header rather than a banner saying MULTIPLAYER. */
  body.mp header,
  body.mppane header {
    border-bottom: 1px solid rgba(87, 201, 165, .32);
    box-shadow: 0 1px 0 rgba(87, 201, 165, .10), 0 10px 30px -22px #57c9a5;
  }
  body.mp header h1,
  body.mppane header h1 { color: #8fe6c8; }

  /* Cards in the hall sit in cooler light and cast a jade edge rather than a
     black one — the same trick the felt does, one level up. */
  body.mp .card,
  body.mppane .card {
    border-color: #24424a;
    box-shadow: 0 0 0 1px rgba(87, 201, 165, .10) inset,
                0 24px 60px -30px rgba(87, 201, 165, .40);
  }
  body.mp .homebtn.primary,
  body.mppane .homebtn.primary { border-color: #2f6f62; }

  /* ── 排名 Ranked keeps its gold, and it is the one thing in here that does ──
     Everything else on this pane follows `--gold`, which the block above turns
     jade — and Ranked's whole reason for being gold (see its rule ~900 lines
     up) is that it must not look like Quick Play, which is green. Recoloured
     with the room it became jade beside a jade green, i.e. exactly the pair it
     was separated from in the first place, and the one button in the game that
     spends a rating and a coin went back to looking like the one that spends
     nothing.

     So it is stated in literal amber rather than in the variable. That is the
     "hard-coded colour" the section header warns about, done knowingly and
     once: gold on jade is the pairing this game already uses everywhere it
     means "this is the valuable one" — the rank ladder, the faan figures.

     ⚠️ THE HOVER IS NOT OPTIONAL HERE. `body.mp* button:hover` further down is
     three classes and beats `.homebtn.ranked:hover` at two, so without this
     line the button is gold at rest and teal under the pointer. */
  body.mp .homebtn.ranked,
  body.mppane .homebtn.ranked {
    background: #d8a544; border-color: #f0d08a; border-bottom-color: #8a6a1e;
  }
  body.mp .homebtn.ranked b,
  body.mppane .homebtn.ranked b { color: #241c14; }
  body.mp .homebtn.ranked > span,
  body.mppane .homebtn.ranked > span { color: #4e3d16; }
  body.mp .homebtn.ranked:hover:not(:disabled),
  body.mppane .homebtn.ranked:hover:not(:disabled) {
    background: #edba57; border-color: #fff0c4;
  }
  /* And a shut door stays shut. `.homebtn.shut` is declared AFTER `.ranked` up
     there for exactly this reason, and the amber above would have undone it. */
  body.mp .homebtn.ranked.shut,
  body.mppane .homebtn.ranked.shut,
  body.mp .homebtn.ranked.shut:hover,
  body.mppane .homebtn.ranked.shut:hover {
    background: #14252a; border-color: #2c4a52;
  }
  body.mp .homebtn.ranked.shut b,
  body.mppane .homebtn.ranked.shut b { color: var(--dim); }
  body.mp .homebtn.ranked.shut > span,
  body.mppane .homebtn.ranked.shut > span { color: #57c9a5; }
  body.mp .navbtn:focus-visible,
  body.mppane .navbtn:focus-visible,
  body.mp button:focus-visible,
  body.mppane button:focus-visible { outline-color: #57c9a5; }

  /* ── The live dot ────────────────────────────────────────────────────────
     On the Multiplayer door, and only there. A slow breath rather than a
     blink: this is a "the lights are on" signal, not an alert, and anything
     that flashes in the corner of the eye while somebody is choosing a menu
     item is a thing they will resent. `prefers-reduced-motion` stops it dead
     and leaves it lit, because the INFORMATION is the dot, not the movement. */
  #btnPaneOnline b::after {
    content: '';
    display: inline-block;
    width: 7px; height: 7px;
    margin-left: 8px;
    vertical-align: middle;
    border-radius: 50%;
    background: #57c9a5;
    box-shadow: 0 0 0 0 rgba(87, 201, 165, .55);
    animation: mp-live 2.4s ease-in-out infinite;
  }
  @keyframes mp-live {
    0%, 100% { box-shadow: 0 0 0 0 rgba(87, 201, 165, .50); opacity: .75; }
    50%      { box-shadow: 0 0 0 5px rgba(87, 201, 165, 0);  opacity: 1; }
  }
  @media (prefers-reduced-motion: reduce) {
    #btnPaneOnline b::after { animation: none; opacity: 1; }
  }

  /* ── The room, while it fills ───────────────────────────────────────────
     Among Us's lobby reads as a PLACE, and the reason is that the seats look
     like seats. The wind plate does that work here; this only has to make the
     empty ones feel like somewhere a person is about to be, rather than a
     greyed-out row that failed to load. */
  body.mp #lobbySeats > div,
  body.mppane #lobbySeats > div { border: 1px solid rgba(87, 201, 165, .12); }
  #lobbyCode {
    text-shadow: 0 0 22px rgba(87, 201, 165, .45);
  }

  /* Chat belongs to the hall and is never present in the parlour, so it can
     take the room's colour without a second thought. */
  body.mp #chatLog .who,
  body.mppane #chatLog .who { color: #7fdcbc; }
  body.mp #chatPresets button,
  body.mppane #chatPresets button { border-color: rgba(87, 201, 165, .22); }
  body.mp #chatPresets button:hover,
  body.mppane #chatPresets button:hover { background: rgba(87, 201, 165, .20); }

  /* The profile's ladder: the rung you are on is jade, the ones behind you are
     lit faintly, the ones ahead are dark. A road, not a number. */
  body.mp #profLadder > div,
  body.mppane #profLadder > div { border: 1px solid rgba(87, 201, 165, .10); }

  /* ── The panels the variables could not reach ───────────────────────────
     Three wood-brown literals that only became visible when the room moved to
     the door: the account strip, the player card and the footer rule all live
     on the HOME card, which until then was only ever seen in the parlour. In
     jade they were three brown boxes inside a lacquer panel.

     Same rule as the buttons below — colour only, and only where the value was
     a literal rather than a variable. */
  body.mp .acct,
  body.mppane .acct { background: #0d1a1d; border-color: #24424a; }
  body.mp .mpcard,
  body.mppane .mpcard { background: #172e33; border-color: #2c4a52; }
  body.mp .homefoot,
  body.mppane .homefoot { border-top-color: #24424a; }
  body.mp .card h2,
  body.mppane .card h2 { border-bottom-color: #24424a; }
  /* The language pair and the segmented controls in Rooms and the profile —
     recessed slots, and a slot cut into a jade panel is not a brown one. */
  body.mp .overlay .seg button,
  body.mppane .overlay .seg button,
  body.mp .langtoggle button,
  body.mppane .langtoggle button { background: #0d1a1d; border-color: #24424a; }

  /* ⚠️ AND THE CHOSEN ONE HAS TO BE NAMED AGAIN, or the rule above eats it.
     THE MENU SKIN raises the selected item on `.langtoggle button.on` — two
     classes and a type, (0,2,1). The rule directly above is `body.mppane
     .langtoggle button`, which is (0,2,2) and therefore WINS on specificity no
     matter which is written later. Only `background` and `border-color`
     collide, so the hall got the recessed slot's fill with the raised item's
     `color: #241c14` still on top of it: near-black on near-black. The EN/中文
     pair — which homefit.py calls the way OUT of a language you cannot read —
     rendered as a blank black box on every multiplayer screen.

     (0,3,2) here, so it outranks the slot rule the same way the slot rule
     outranks the skin. The fill follows `--gold`, which is jade in this room,
     against the skin's own dark `color` — the same pairing the parlour has. */
  body.mp .langtoggle button.on,
  body.mppane .langtoggle button.on,
  body.mp .overlay .seg button.on,
  body.mppane .overlay .seg button.on {
    background: var(--gold);
    border-color: #8fe6c8; border-bottom-color: #2f6f62;
  }

  /* ── The buttons the variables could not reach ──────────────────────────
     Four literals in the base button rules — the wood-brown borders and the
     warm hover — predate there being two rooms. A brown-edged button sitting
     in a jade card is exactly the detail that makes a theme read as
     half-finished, so these are the only four component-level overrides in
     the hall, and they exist because those values are literals rather than
     because the room needs special cases. If they ever become variables,
     delete this block. */
  body.mp button,
  body.mppane button { border-color: #2c4a52; }
  body.mp button:hover:not(:disabled),
  body.mppane button:hover:not(:disabled) { background: #24414a; }
  body.mp button.ghost,
  body.mppane button.ghost { border-color: #2c4a52; }
  body.mp button.primary,
  body.mppane button.primary { background: #2f9e7a; border-color: #1f6b53; }
  body.mp button.primary:hover:not(:disabled),
  body.mppane button.primary:hover:not(:disabled) { background: #3ab88e; }
  body.mp .card h2,
  body.mppane .card h2 { text-shadow: 0 1px 0 #0a1a1d; }


/* ══════════════════════════════════════════════════════════════════════════
   FINGER-SIZED CONTROLS — last in the file, on purpose.

   Every rule above this point sizes a control by how much ROOM it needs. That
   is the right question for a mouse, whose hit area is one pixel, and the
   wrong one for a thumb. The toolbar rail is the worked example: the labels
   come off below 760px, the glyph shrinks to fit what is left, and then
   `body.playing` trims the padding again to buy the felt some height. Three
   reasonable rules, compounding into a 32x24 target — about a third of the
   finger aiming at it.

   `pointer: coarse` rather than a width breakpoint, deliberately. What decides
   how big a target must be is what is POINTING at it, not how wide the screen
   is: a 1024px tablet needs the big targets and a 500px desktop window does
   not. It also keeps the promise `portrait.py` makes at 1280x900 — a mouse
   never matches this block, so the desktop layout is untouched by all of it.

   LAST in the file because these are floors that have to beat rules scattered
   all through it, at the same specificity. `.langtoggle button` sets
   `min-height: 0` four hundred lines up and was quietly winning on source
   order alone. Raising specificity to beat each one individually would mean
   guessing which rules exist; being last beats all of them at once.

   Only ever `min-*`, never a fixed size. Padding stays where each rule put it,
   so nothing here re-lays-out a control that already cleared the floor.
   ══════════════════════════════════════════════════════════════════════════ */
@media (pointer: coarse) {
  /* The blanket floor. Stated once over every control rather than as a list of
     ids, because the list is the thing that goes stale — `.example`, `.ghost`,
     `summary` and the settings Close button were each missed by an
     element-by-element pass over the same screens. */
  button, summary, .btnlink, .navbtn, a.homebtn {
    min-height: 44px;
  }
  /* The rail's items also have to be wide enough. Once the labels come off
     they are a bare glyph in a 44px-tall box, so without this they stay 32px
     wide and only the mis-taps change direction. */
  .navbtn { min-width: 44px; justify-content: center; }

  /* ⚠️ Being last in the file is not enough for these three. Source order only
     breaks ties BETWEEN EQUAL SPECIFICITY, and each of them sets its own
     min-height under a class selector — `.langtoggle button` is (0,1,1) and
     the blanket `button` above is (0,0,1), so the blanket loses no matter
     where it sits. They have to be named. Anything else that grows a
     class-level `min-height` on a button belongs in this list too; the rest
     of the game's buttons are covered by the blanket. */
  .langtoggle button { min-width: 44px; min-height: 44px; }
  .seg button { min-height: 44px; }
  .hptoggle { min-height: 44px; }
  /* ⚠️ SQUARE ICON BUTTONS NEED THE WIDTH, NOT THE HEIGHT. The ⓘ on the win box
     is 22px on a mouse and the heading × is 30px, both on purpose — neither sits
     in a button row — and the blanket rule above hands them 44px of height and
     nothing else. That is a 22x44 sliver: taller than a finger and half as wide.
     Any square icon button added anywhere belongs in this line. */
  .paywhy-btn, .cardx { min-width: 44px; }
  /* Portrait phones already give these 46px via the max-width:700px block, so
     this is a no-op there. It is here for the case that block does not cover:
     a portrait TABLET, wider than 700px and still a thumb. */
  #actions button { min-height: 44px; }

  /* No keyboard, no key hint. On a phone the badge is three characters of
     noise crowding the one row of the game where the label has to be read at
     a glance and on a clock. Anyone with a coarse pointer AND a keyboard can
     still press the keys — only the badge goes. */
  #actions button[data-key]::after { display: none; }

  /* Sliders. `height: 18px` is set on the element itself; `min-height` beats it
     without disturbing the track or thumb, which are drawn by pseudo-elements
     and stay centred in whatever box they are given. */
  input[type=range] { min-height: 44px; }
  select, input[type=text], input[type=email], input[type=password],
  .search { min-height: 44px; }

  /* A 16px checkbox was the smallest target anywhere in the game. The box is
     scaled rather than replaced, so it stays the platform control and keeps
     its native checked state, focus ring and screen-reader behaviour.

     Scaling the box alone is not enough — 24px is still half a fingertip — so
     the LABEL has to be a target too, and the game writes labels two different
     ways:

       tiles.html   <label><input type=checkbox> self-drawn</label>   wrapper
       index.html   <label for=optPao>…</label> <input type=checkbox>  sibling

     Both are already clickable (a wrapper label forwards the click, a `for`
     label targets by id), so neither needs JS — they just need to be big. Only
     the first form was covered here at first, which is why the New Game and
     Settings checkboxes stayed 22px targets while the tiles page was fine. */
  input[type=checkbox], input[type=radio] { width: 24px; height: 24px; }
  label:has(> input[type=checkbox]),
  label:has(> input[type=radio]),
  .field label[for] {
    min-height: 44px; display: flex; align-items: center; gap: 8px;
  }
}

/* Landscape phone is the one place the floor has to bend. At 844x390 the felt
   is HEIGHT-bound — every pixel the chrome takes comes straight off the board —
   and a 44px rail is 11% of the viewport. Width is not the problem there: the
   labels are still on at that width, so the rail items already measure 60-100px
   across and only their height is short.

   36px clears the WCAG 2.2 AA target-size minimum (24px) with room to spare,
   and costs the table one fit() step instead of three. The 44px figure is a
   platform-HIG comfort target, not a conformance one, so this is the cheaper
   of the two things being traded — written down here rather than left as a
   mystery override for whoever measures it next. */
@media (pointer: coarse) and (max-height: 470px) and (orientation: landscape) {
  button, summary, .btnlink, .navbtn, a.homebtn { min-height: 36px; }
  .langtoggle button, .seg button, .hptoggle { min-height: 36px; }
  /* `#actions button` is (1,0,1) — an id beats the blanket rule above no
     matter how late it is declared, so the landscape trim at 34px was still
     winning here. Worth naming rather than rounding down to meet it: these
     are the claim buttons. Pung, Kong, Chow, Win, Pass — the highest-stakes
     taps in the game, offered on a clock, and the only ones where a mis-tap
     costs the hand rather than a moment. Two pixels is nothing to the felt
     and they are the last two that should be traded. */
  #actions button { min-height: 36px; }
  input[type=range], select { min-height: 36px; }
  label:has(> input[type=checkbox]),
  label:has(> input[type=radio]),
  .field label[for] { min-height: 36px; }
}

/* ── The notch, in landscape ───────────────────────────────────────────────
   A rotated phone puts its cutout and its rounded corners on the LEFT and
   RIGHT edges, which is the one case the rest of this file does not handle:
   `#handrack` already pads for `safe-area-inset-bottom`, and that was the
   whole of the game's safe-area handling. In landscape the rail's first item
   (Home) and last (Settings) are exactly what sits under the cutout.

   `max()` rather than plain `env()`, so the existing padding is a floor and
   the inset only ever adds to it — on every device without a cutout each of
   these resolves to the number that was already there, which is why this is
   safe to state unconditionally.

   Applied to `#wrap` and `#home`, NOT to `body`. Padding on the body would
   inset `#homebg` too, and that backdrop is deliberately full-bleed `position:
   fixed; inset: 0` — the painted room behind the card. Insetting it would
   leave a bar of page background down the side of the screen, which is a
   worse artefact than the one being fixed. */
#wrap {
  padding-left: max(0px, env(safe-area-inset-left));
  padding-right: max(0px, env(safe-area-inset-right));
}
#home {
  padding-left: max(clamp(8px, 2.5vh, 22px), env(safe-area-inset-left));
  padding-right: max(clamp(8px, 2.5vh, 22px), env(safe-area-inset-right));
}

/* ── A door that is open, but needs a name ─────────────────────────────────
   `.locked` is NOT `.shut`. Shut means the build cannot do this at all — no
   database, no online play, nothing the player can do about it — so it is
   greyed and `cursor: not-allowed`. Locked means the door works and wants a
   sign-in, which is one click away and worth taking. It therefore keeps the
   full-strength button it always was, and only the subtitle changes colour to
   carry the condition.

   Deliberately NOT `disabled` and NOT dimmed: the button still does something
   when pressed (it opens the sign-in), and a control that looks unavailable
   but is the way forward is the worst of both. */
.homebtn.locked > span { color: var(--gold); }

/* ── Picking a rack, by looking at one ─────────────────────────────────────
   A `.seg` whose buttons each carry a canvas painted by the renderer that
   button selects (`tilepick.js`). Layout only — the on/off treatment is
   inherited from THE MENU SKIN below.

   ⚠️ Declared BEFORE the menu skin on purpose: that block wins by source
   order and nothing else, so a `.seg` rule after it would un-skin the
   selected state. */
.seg.tilepick { display: flex; gap: 8px; flex-wrap: wrap; }
.seg.tilepick button {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 6px; line-height: 1;
}
/* `pixelated` is load-bearing: a browser smoothing these on a fractional
   device ratio would show a softness the game itself does not have. */
.tilepickart {
  display: block; width: 118px; height: 66px; max-width: 100%;
  image-rendering: pixelated; border-radius: 2px;
}
.tilepicklbl { font-size: .74rem; }

/* ══════════════════════════════════════════════════════════════════════════
   TUTORIAL SLIDES — the interactive half of the lesson.

   ⚠️ ABOVE "THE MENU SKIN", NOT INSIDE IT. The skin is colours, radii and
   shadows only — everything here is layout, and the skin's own rule is that it
   must not set a layout property. `#tutslide .card` picks up the skin's frame
   from below for free, which is the whole reason these slides look like the
   rest of the game without restating anything.
   ══════════════════════════════════════════════════════════════════════════ */

/* Scrolls rather than overflowing the viewport. The gallery is five labelled
   groups on a laptop and taller again on a phone, and a modal whose Next
   button is below the fold is a modal you cannot leave.

   ⚠️ 940px, not the 640 it started at, and the number is not arbitrary: the
   basics slide shows a FULL suit — nine tiles at 28px logical, doubled, plus
   gaps and the card's own padding. Anything narrower wraps a suit onto two
   lines, which is the one thing that row must never do, because the row IS
   the lesson that 1-9 runs in order. */
#tutslide .card { max-width: 940px; max-height: 92vh; overflow-y: auto; }
.tutslide-chapter {
  color: var(--gold); font-size: .72rem; font-weight: 700; letter-spacing: .05em;
  margin-bottom: 2px;
}
#tutslide h2 { margin-top: 0; }
/* Same reasoning as `#coach .txt` — and `--ink-dim` went with the size, because
   a dimmed body at .82rem was the lesson's own text being the quietest thing on
   its own card. The slide has room; it is a modal, not a strip over the felt. */
#tutslide > .card > p { color: var(--ink); font-size: .95rem; line-height: 1.6; }
.tutslide-foot {
  display: flex; justify-content: space-between; align-items: center;
  gap: 10px; margin-top: 14px;
}
/* The "you have got what this screen was for" state. A HINT — the button has
   been live since the slide opened and this only draws the eye to it. Nothing
   here disables anything; see the rule at the top of tutorslides.js. */
#btnSlideNext.ready {
  background: var(--green); border-color: #7fe0a4;
  box-shadow: 0 0 0 2px rgba(47,143,78,.35);
}

/* ── tiles, shared by every slide kind ─────────────────────────────────── */
.slide-tiles { display: flex; flex-wrap: wrap; gap: 3px; }
.slide-tiles canvas { display: block; image-rendering: pixelated; height: auto; }
/* The tap target floor. A 28x42 tile at scale 2 is 56x84 CSS px before any
   shrinking, so this only bites once `max-width` has squeezed a long row —
   which is exactly when a finger needs the help. */
.slide-tile {
  padding: 2px; background: transparent; border: 2px solid transparent;
  line-height: 0; min-width: 0; min-height: 0; cursor: pointer;
}
.slide-tile:hover { border-color: #4a3b2a; }
.slide-tile.on { border-color: var(--gold); background: rgba(216,165,68,.14); }

/* ── gallery ───────────────────────────────────────────────────────────── */
.slide-group { margin-top: 10px; }
.slide-group h5 {
  margin: 0 0 4px; font-size: .82rem; font-weight: 700; color: var(--ink-dim);
  letter-spacing: .03em;
}
.slide-note { font-size: .82rem; line-height: 1.5; color: var(--ink-dim); margin: 6px 0 0; }

/* The line that answers a tap. `min-height` is reserved so the card does not
   jump the first time something is tapped — a slide that grows under the
   finger reads as a misclick. */
.slide-say {
  margin-top: 10px; padding: 7px 9px; min-height: 2.6em;
  background: #1b2a20; border-left: 3px solid #3d5a45;
  font-size: .88rem; line-height: 1.5; color: var(--ink);
}
.slide-say.good { border-left-color: var(--green); }
.slide-say.bad  { border-left-color: #c2542f; }

/* ── quiz ──────────────────────────────────────────────────────────────────
   ⚠️ THE RIGHT ANSWER HAS TO BE THE LOUDEST THING ON THE SLIDE. It used to be
   the quietest: `.right` was a `var(--green)` border (#2f8f4e) against a
   resting #3d5a45 one, while `.wrong` was #c2542f — so after answering wrong
   and then right, the only box that read as marked was the mistake. Green as
   "the correct colour" is not a design if the correct colour is invisible.
   The right answer now owns a bright rim, a glow, a lift and a ✓; wrong marks
   are demoted the moment `.solved` lands on the group. */
.slide-ask { font-weight: 700; font-size: .95rem; margin: 0 0 8px; }
/* The shared picture a question is ABOUT — the discard being claimed, the
   melds being scored. Centred, because it belongs to the question and not to
   any one option. */
.slide-show { justify-content: center; margin: 0 0 10px; }
.slide-opts { display: flex; flex-wrap: wrap; gap: 8px; }
.slide-opt {
  position: relative;
  display: flex; flex-direction: column; align-items: center; gap: 5px;
  padding: 7px; background: #17231b; border: 2px solid #3d5a45; cursor: pointer;
}
/* A word, not a picture — "8 faan", "the pung". Sized so a row of three reads
   as three buttons rather than as a sentence that happens to be clickable. */
.slide-opt.wordy { flex: 1 1 8em; min-height: 3.2em; justify-content: center; }
.slide-opt.wordy .slide-optlabel { font-size: .86rem; color: var(--ink); }
.slide-opt:hover:not(:disabled) { border-color: var(--gold); }
.slide-opt.right {
  border-color: #7fe0a4; background: #1c3324;
  box-shadow: 0 0 0 3px rgba(95,206,133,.34);
}
.slide-opt.wrong { border-color: #c2542f; }
/* The verdict, in the corner of the option it belongs to. The line under the
   options says WHY; this says which box the sentence is about, which is the
   part that was missing. */
.slide-opt.right::after, .slide-opt.wrong::after {
  position: absolute; top: -1px; right: -1px;
  padding: 0 4px; font-size: .78rem; font-weight: 700; line-height: 1.35;
}
.slide-opt.right::after { content: '✓'; background: #7fe0a4; color: #10240f; }
.slide-opt.wrong::after { content: '✗'; background: #c2542f; color: #1a0f0a; }
/* Answered. The mistakes have already explained themselves, so they step back
   and leave one lit box on the screen.

   ⚠️ THE `opacity: 1` ON `.right` IS THE LOAD-BEARING LINE. Answering locks
   the group, and `button:disabled { opacity: .35 }` up at the top of this file
   then applies to every option INCLUDING the correct one — so the right answer
   was being faded to .35 while a wrong pick sat at .4, i.e. the fix for "the
   wrong one lights up" would have shipped still being true, just less. The
   suite measures the two opacities against each other for exactly this
   reason. */
.slide-opt:disabled { cursor: default; }
.slide-opt.right { opacity: 1; }
.slide-opts.solved .slide-opt.wrong { opacity: .34; border-color: #4a3b2a; }
.slide-opts.solved .slide-opt.wrong::after { background: #6b4a3a; color: #d8cbb8; }
.slide-opts.solved .slide-opt:not(.right):not(.wrong) { opacity: .3; }
.slide-optlabel { font-size: .72rem; color: var(--ink-dim); font-weight: 700; }
.slide-opt.right .slide-optlabel { color: var(--ink); }
@media (prefers-reduced-motion: no-preference) {
  .slide-opt { transition: transform .12s ease-out, box-shadow .12s ease-out; }
  .slide-opt.right { transform: translateY(-3px); }
}

/* ── compass ───────────────────────────────────────────────────────────── */
.compass-board {
  position: relative; width: 100%; max-width: 300px; aspect-ratio: 1 / 1;
  margin: 10px auto 0; background: #17231b; border: 2px solid #3d5a45;
}
.compass-seat {
  position: absolute; display: flex; flex-direction: column; align-items: center;
  gap: 1px; font-size: .72rem; color: var(--ink-dim); text-align: center;
}
.compass-seat b { color: var(--ink); font-size: .8rem; }
/* The round wind, wherever it currently sits. This is the callout that makes
   the 門風 + 圈風 stack visible rather than merely stated. */
.compass-seat.isround b { color: var(--gold); }
.compass-seat.bottom { left: 0; right: 0; bottom: 8px; }
.compass-seat.top    { left: 0; right: 0; top: 8px; }
.compass-seat.left   { left: 8px; top: 50%; transform: translateY(-50%); }
.compass-seat.right  { right: 8px; top: 50%; transform: translateY(-50%); }
.compass-mid {
  position: absolute; inset: 0; display: flex; flex-direction: column; gap: 3px;
  align-items: center; justify-content: center; color: var(--gold);
  font-weight: 700; font-size: .84rem;
}
.compass-mid span { color: var(--ink-dim); font-weight: 400; font-size: .74rem; }
.compass-mid .compass-turn { color: #6f8a78; font-size: .7rem; letter-spacing: .04em; }
.slide-step { display: block; margin: 10px auto 0; }

/* ── tally ─────────────────────────────────────────────────────────────── */
.tally-rows { display: flex; flex-direction: column; gap: 6px; }
.tally-row {
  display: grid; grid-template-columns: auto 3.5em; gap: 2px 8px;
  text-align: left; padding: 7px 9px; background: #17231b;
  border: 2px solid #3d5a45; cursor: pointer;
}
.tally-row:hover:not(.on) { border-color: var(--gold); }
.tally-row.on { border-color: var(--green); background: #1c3324; }
.tally-name { font-weight: 700; font-size: .8rem; }
.tally-val { text-align: right; font-weight: 700; color: var(--gold); }
/* The reason, always visible — it is the teaching, not a reveal. */
.tally-why {
  grid-column: 1 / -1; font-size: .82rem; color: var(--ink-dim);
  font-weight: 400; line-height: 1.45;
}
.tally-total {
  margin-top: 10px; padding: 7px 9px; text-align: right; font-weight: 700;
  background: #1b2a20; color: var(--ink-dim);
}
.tally-total.done { color: var(--gold); }
.slide-trap {
  margin: 10px 0 0; padding: 7px 9px; font-size: .76rem; line-height: 1.5;
  background: rgba(194,84,47,.10); border-left: 3px solid #c2542f;
}

/* ── faantable ─────────────────────────────────────────────────────────────
   Banded 番種 list, styled after the collection screen: name, worth, one line
   of plain English. Collapsed by default — thirty rows at once is the wall
   this slide system exists to avoid. */
.faan-band {
  margin-top: 8px; background: #17231b; border: 2px solid #3d5a45;
}
.faan-band > summary {
  display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  padding: 8px 10px; cursor: pointer; list-style: none; user-select: none;
}
/* Safari draws its own disclosure triangle unless this is said too. */
.faan-band > summary::-webkit-details-marker { display: none; }
.faan-band > summary::after {
  content: '+'; color: var(--gold); font-weight: 700; font-size: 1rem; line-height: 1;
}
.faan-band[open] > summary::after { content: '−'; }
.faan-band > summary:hover { background: #1c2c22; }
.faan-band > summary b { font-size: .84rem; color: var(--ink); }
.faan-count { font-size: .72rem; color: var(--dim); margin-left: auto; }
.faan-band > .slide-note { padding: 0 10px 6px; margin: 0; }
.faan-list { padding: 0 10px 10px; }
.faan-row {
  display: grid; grid-template-columns: 1fr auto; gap: 1px 10px;
  padding: 6px 0; border-top: 1px solid rgba(255,232,168,.09);
}
.faan-name { font-weight: 700; font-size: .8rem; }
.faan-name i { color: var(--dim); font-weight: 400; font-style: normal; font-size: .72rem; }
.faan-val {
  color: var(--gold); font-weight: 700; font-size: .8rem; text-align: right;
  white-space: nowrap;
}
.faan-ex {
  grid-column: 1 / -1; font-size: .74rem; line-height: 1.45; color: var(--ink-dim);
}
/* The worked hand under its row. Width-capped, for the reason given on
   `table.faan .faanart` — same strips, same rule. */
.faan-art { grid-column: 1 / -1; margin-top: 3px; }
.faan-art canvas {
  display: block; width: auto; height: auto; max-width: min(100%, 250px);
  image-rendering: pixelated; image-rendering: crisp-edges;
}

@media (max-width: 560px) {
  .compass-board { max-width: 240px; }
  .slide-opt { flex: 1 1 100%; flex-direction: row; justify-content: space-between; }
}

/* ══════════════════════════════════════════════════════════════════════════
   THE MENU SKIN

   Every menu in the game, dressed as the same 8-bit dialog box: the home card,
   New Game, Settings, Rules, Rooms, the lobby, the profile, 配器, the result
   and the run summary. The treatment was written for the sign-in card and
   scoped to it, on the argument that it was the one overlay you OPERATE rather
   than read. Wrong on both counts — New Game is nothing but controls, and one
   8-bit card among a dozen rounded ones does not read as the important one, it
   reads as the one that does not belong.

   The five parts, and why each:

     SQUARE CORNERS   the 8-bit dialog box has no radius. A 3-5px rounding was
                      the single most "web form" thing about these panels.
     A DOUBLE FRAME   light outer, dark inner — how you draw a panel when you
                      have two colours and no gradients.
     A HARD SHADOW    offset, opaque, ZERO BLUR. A blurred shadow is a lighting
                      model; a hard one is a second sprite, which is what the
                      hardware this look comes from could actually afford.
     CORNER STUDS     four gold pixels in `background-image` rather than
                      pseudo-elements — `::before`/`::after` on `.card` are
                      already spoken for by several overlays, and a fifth
                      stacking context is not worth four pixels.
     RECESSED INPUTS  an inset shadow makes a text box a slot cut INTO the
                      panel rather than a rectangle drawn on top of it.

   ⚠️ THIS SECTION IS LAST IN THE FILE ON PURPOSE. Nearly every rule here is a
   deliberate override of something declared earlier at equal specificity —
   `.card`'s radius, `.homebtn`'s, `.seg button.on`'s fill — so it wins by
   source order and by nothing else. Moving it up silently un-skins the menus.
   The `pointer: coarse` tap-target blocks above set `min-height` and nothing
   else; nothing here touches a height, so those floors still hold.

   ⚠️ AND IT MOVES ALMOST NO LAYOUT. Border WIDTHS, padding, gaps and flex are
   left exactly as they were — only colours, radii, shadows and one heading
   rule change. That is what makes a whole-game reskin safe on a tree where
   forty-odd browser suites measure these screens to the pixel. The one
   exception is `.card h2`, which grows 16px, and it is called out below.
   ══════════════════════════════════════════════════════════════════════════ */

/* ── 1 · The panel ─────────────────────────────────────────────────────── */
.card,
#home .homecard {
  border-radius: 0;
  border-color: #6b5334;
  box-shadow:
    inset 0 0 0 2px #17110b,          /* the inner line of the double frame */
    7px 7px 0 rgba(0, 0, 0, .55);     /* hard, offset, no blur */
  /* Four 3x3 studs, one per corner, inset by the frame so they sit ON it
     rather than beside it. `no-repeat` on each, or the first one tiles. */
  background-image:
    linear-gradient(var(--gold), var(--gold)),
    linear-gradient(var(--gold), var(--gold)),
    linear-gradient(var(--gold), var(--gold)),
    linear-gradient(var(--gold), var(--gold));
  background-size: 3px 3px;
  background-position: 3px 3px, calc(100% - 3px) 3px, 3px calc(100% - 3px),
                       calc(100% - 3px) calc(100% - 3px);
  background-repeat: no-repeat;
}
/* The home card keeps its warm halo — the felt is painted behind it, and the
   glow is what seats the card in that room. ADDED to the hard shadow rather
   than replacing it: the halo is the light, the offset block is the sprite. */
#home .homecard {
  box-shadow:
    inset 0 0 0 2px #17110b,
    7px 7px 0 rgba(0, 0, 0, .55),
    0 0 90px -30px rgba(255, 190, 104, .3);
}
/* Behind the multiplayer door the felt is jade (see THE JADE HALL, and
   `FELT.online` in home.js), and an amber halo on a jade room is a brown
   smudge round the card — the exact "half-themed" tell the hall's own comment
   warns about. Same halo, same geometry, the room's colour.

   ⚠️ AND THE FRAME, WHICH THE HALL CANNOT REACH. `body.mppane .card` is two
   classes; `#home .homecard` above is an ID, so the home card kept the warm
   `#6b5334` frame while every other panel in the room went jade — one brown
   rectangle around a lacquer card. It is fixed here rather than by raising the
   hall's specificity, because this rule is the one that has to be beaten and
   a jade room that only shows up on the card's border is not worth an ID
   selector five hundred lines away. */
body.mppane #home .homecard {
  border-color: #2c4a52;
  box-shadow:
    inset 0 0 0 2px #0a1416,
    7px 7px 0 rgba(0, 0, 0, .55),
    0 0 90px -30px rgba(87, 201, 165, .38);
}

/* Blocks INSIDE a panel — voice rows, the pulled-out rule boxes, the account
   strip, the player card, the settlement notes, the collection and saved-hand
   cards. A square panel full of rounded boxes is the tell that a skin was
   applied to the outside only, and the inside of a dialog is where a player
   spends the time.

   Every one of these is a `border-radius` and nothing else — no fill, no
   border, no spacing — so the list is long and costs nothing. */
.vrow, .vtune, .rulebox, .acct, .mpcard, .mpbadge-n, .acct-danger,
.acct-consent, .rdetails, #voiceDump, #resShare, .graduate, .verdict,
.chip, .paybubble, .paycalc, .altparse, .streak,
.tldr-goal, .rulenav a, .rnum, .rcard,
.colcard, .shcard, .shchip {
  border-radius: 0;
}
/* The volume sliders in Settings and in the 配器 tuning drawer. The track and
   the thumb are pseudo-elements, and each vendor needs its own selector —
   there is no shorthand that reaches both. */
.overlay input[type=range]::-webkit-slider-runnable-track,
.overlay input[type=range]::-webkit-slider-thumb { border-radius: 0; }
.overlay input[type=range]::-moz-range-track,
.overlay input[type=range]::-moz-range-thumb { border-radius: 0; }

/* ── 2 · The title, and a rule under it ────────────────────────────────────
   Full width, unlike a heading in running prose: on a dialog this line is the
   top edge of the panel's CONTENT, which is exactly what it should read as.
   The one rule in this section that costs height — 16px per dialog — and it
   buys every one of them a header instead of a coloured first line. */
.card h2 {
  margin: 0 0 10px;
  padding-bottom: 8px;
  border-bottom: 2px solid #3a2d20;
}
/* `<small>` counters ride the same line — Collection's "12 / 30", Saved Hands'
   "3 / 30" — and must not come out as heavy as the heading they annotate. */
.card h2 small { font-weight: 400; }

/* ── 3 · Buttons ───────────────────────────────────────────────────────────
   Square, with the hard shadow, and the press MOVES THE BUTTON ONTO ITS OWN
   SHADOW — translate by two, shrink the shadow to one. That is the whole
   trick, and it is why the shadow has no blur: a blurred one cannot be landed
   on.

   Scoped to `.overlay`, plus `.homebtn` (an `<a>` on the Tiles item, so not a
   `button`) and `.optbtn` (a `<summary>`). The toolbar rail, the tile bar, the
   hand rack and the claim buttons are NOT menus — they sit on the felt, they
   are measured by the table suites, and they keep the flat treatment they
   have. */
.overlay button, .homebtn, .optbtn {
  border-radius: 0;
  box-shadow: 3px 3px 0 rgba(0, 0, 0, .45);
}
.overlay button:active:not(:disabled),
.homebtn:active, .optbtn:active {
  transform: translate(2px, 2px);
  box-shadow: 1px 1px 0 rgba(0, 0, 0, .45);
}
/* A disabled control still casts a shadow — a flat one reads as already
   pressed, which is a different state and a misleading one. Just fainter, the
   same way the button itself is. */
.overlay button:disabled { box-shadow: 2px 2px 0 rgba(0, 0, 0, .22); }
/* ⚠️ A SHUT DOOR MUST NOT ANSWER. `.homebtn.shut` is not `disabled` — it is a
   live button carrying its own refusal in the subtitle — so without this it
   would depress convincingly on a press that does nothing. */
.homebtn.shut:active { transform: none; box-shadow: 3px 3px 0 rgba(0, 0, 0, .45); }

/* ── 4 · What is not a button, however it is built ─────────────────────────
   Declared AFTER the rule above and at equal-or-greater specificity, which is
   the only reason these stay flat. */
.overlay .linkish,        /* "Create one" — a text link that has to be a <button> */
.overlay .pweye,          /* Show/Hide, which lives INSIDE the password box */
.overlay .cardx,          /* the × on the account card's heading */
.overlay .vplay {         /* ▶ on an audition row that is itself pressable */
  box-shadow: none;
}
.overlay .linkish:active, .overlay .pweye:active,
.overlay .cardx:active { transform: none; }

/* ── 5 · Segmented choices ─────────────────────────────────────────────────
   Table, difficulty, speed, decision time, the language pair, the 配器 mode.
   These are not five buttons — they are ONE control showing which of five it
   is set to. So the unchosen ones are recessed into the panel and only the
   chosen one is raised on a shadow. Giving all five the button treatment is
   what made a segmented control read as a row of equally live actions.

   Layout untouched: same gap, same padding, same border widths. */
.overlay .seg button, .langtoggle button {
  border-radius: 0; box-shadow: none;
  background: #17110b; border-color: #3a2d20; color: var(--ink-dim);
}
.overlay .seg button:hover:not(:disabled), .langtoggle button:hover {
  background: #241c14; border-color: #6b5334; color: var(--ink);
}
.overlay .seg button.on, .langtoggle button.on {
  background: var(--gold); color: #241c14;
  border-color: #f0d08a; border-bottom-color: #8a6a1e;
  box-shadow: 2px 2px 0 rgba(0, 0, 0, .45);
}
.overlay .seg button.on:hover, .langtoggle button.on:hover {
  background: #edba57; border-color: #fff0c4;
}
.overlay .seg button:active:not(:disabled), .langtoggle button:active {
  transform: translate(1px, 1px); box-shadow: none;
}

/* ── 6 · Text boxes are slots ──────────────────────────────────────────────
   Darker than the panel, square, and cut in with an inset shadow. The gold
   caret and the gold focus ring are the same amber everything else in the
   game uses for "this is the live one". */
.overlay input[type=text],
.overlay input[type=email],
.overlay input[type=password],
.overlay input[type=search],
.overlay textarea {
  border-radius: 0;
  background: #17110b;
  box-shadow: inset 2px 2px 0 rgba(0, 0, 0, .5);
  caret-color: var(--gold);
}
.overlay input[type=text]:focus,
.overlay input[type=email]:focus,
.overlay input[type=password]:focus,
.overlay input[type=search]:focus,
.overlay textarea:focus {
  outline: none;
  border-color: var(--gold);
  box-shadow: inset 2px 2px 0 rgba(0, 0, 0, .5), 0 0 0 2px rgba(216, 165, 68, .3);
}
.overlay input::placeholder { color: #6b5f4c; }

/* ── 7 · Drawers ───────────────────────────────────────────────────────────
   `.optbtn` — the pressable `<summary>` written for Rooms' ⚙ Table options —
   now also carries New Game's ⚙ fold, which was a bare line of dim text with
   the marker suppressed: no border, no hover, no focus ring, and no open or
   closed state either. Two disclosures in the same game that do the same thing
   now look the same.

   Open, the summary sits DOWN on the panel it opened — it is the lid, and a
   lid that keeps casting a shadow over its own contents has not opened. */
.overlay details[open] > .optbtn {
  box-shadow: none; border-bottom-color: #9a7526;
}
.overlay details[open] > .optbtn:active { transform: none; }
