/* wizard — phone first, because a fan of cards and five other people is only
 * difficult on a phone.
 *
 * TWO THEMES, AND LIGHT IS THE BASE, matching budget, pattern, mines, words and
 * gems. The palette below is the light one on bare :root; dark is a token swap
 * in two guarded blocks — the media query for "system", the attribute for a
 * choice the user made. Nothing outside those blocks names a literal colour, so
 * a rule written once is right in both. The duplication between the two dark
 * blocks is unavoidable: a media query cannot be OR-ed with an attribute
 * selector, and defining a colour in only one of them is how a theme ends up
 * half-applied.
 *
 * THE CARDS ARE THE EXCEPTION AND IT IS DELIBERATE, exactly as gems' jewels
 * are. A card face is white with a black rank in both themes, because a card is
 * a physical object and a hand that inverts is a hand you have to learn twice.
 * The FELT around them changes; the cards do not. The one concession is the
 * card's drop shadow, which has to differ or the cards vanish into a dark felt.
 *
 * Insets go on PADDING, never on layout, and every one carries a 0px fallback —
 * `calc(6px + env(...))` with no fallback is an invalid length and drops the
 * whole declaration on every desktop browser.
 */

:root {
  --bg:       #f2f2f7;
  --panel:    #ffffff;
  --panel-2:  #f6f6f9;
  --line:     rgba(60, 60, 67, .16);
  --line-2:   rgba(60, 60, 67, .08);
  --ink:      #000000;
  --ink-2:    #3c3c43;
  --dim:      #8a8a8e;
  --dim-2:    #b0b0b6;
  --pill:     #efeff0;
  --good:     #1a9e4b;
  --warn:     #b8730a;
  --bad:      #e5322b;
  --link:     #007aff;
  --shadow:   0 1px 2px rgba(0,0,0,.04), 0 6px 20px rgba(0,0,0,.05);

  /* ── THE TABLE: GREEN BAIZE INSIDE A WOOD RAIL ───────────────────────────
   *
   * Two surfaces, and they get their texture from two different places on
   * purpose — the same split `bookworm` made and for the same reason.
   *
   * THE WOOD IS A PHOTOGRAPH because grain is a texture, and a texture is the
   * one thing you cannot cheaply draw. `web/wood.png` is Kenney's Letter Tiles
   * pack (CC0), the same file `bookworm` uses.
   *
   * THE BAIZE IS DRAWN because felt is not a texture, it is NOISE — and noise
   * is the one thing you can generate for four hundred bytes. It is an SVG
   * feTurbulence in a data URI (see .felt below), which costs no request,
   * is sharp on any display, tiles with no seam by construction, and — the part
   * a photograph could not do — RECOLOURS WITH THE THEME from these tokens.
   *
   * A photographic baize would have been ~300 KB, one fixed green, and a
   * visible repeat. This is the gems argument about drawing the jewels, applied
   * to the one surface here that suits it.
   */
  --felt:     #2f7d55;   /* the baize */
  --felt-2:   #1f5c3d;   /* its shaded edge */
  --felt-ink: #eaf3ed;   /* what is legible ON the baize */
  --rail:     #a9744a;   /* the wood, tinted under the photograph */
  /* A CARD SITS ON A CLOTH — it does not float above a hole. Tuned on the
     green, not on the app background: the first values were picked against
     near-black, where a heavy shadow is invisible, and on baize the same values
     drew a solid dark plaque behind every card. */
  --card-shadow: 0 1px 1px rgba(0,0,0,.20), 0 2px 6px rgba(0,0,0,.16);

  /* SUIT COLOURS FOR THE INTERFACE, WHICH ARE NOT THE SUIT COLOURS ON A CARD.
   *
   * cards.js paints a pip onto a card face, and a card face is white in both
   * themes (see the note at the top of this file), so spades there are
   * #101014 and correct. The trump chip is a pip on the app's own surface —
   * and in dark mode #101014 on a near-black pill is invisible. It shipped
   * that way for one screenshot: the chip read "◆ spades", the word right and
   * the pip a black smudge that looked like the wrong suit.
   *
   * So the same four suits have two palettes, deliberately: one for ink on
   * paper and one for ink on the app. `bookworm` wrote the general version of
   * this down — one accent colour cannot be both a fill and a text colour
   * across two themes.
   *
   * THE PAIRS MUST MATCH cards.js. Since the deck went back to the
   * conventional two colours, diamonds are red and clubs are black — so the
   * chip has two pairs rather than four singletons, and a chip whose pip
   * disagreed with the card it names would be worse than one that is merely
   * hard to read. */
  --pip-clubs:    #101014;
  --pip-diamonds: #c8102e;
  --pip-hearts:   #c8102e;
  --pip-spades:   #101014;

  /* ── GOLD MEANS YOU ──────────────────────────────────────────────────────
   *
   * This app already had two accents and neither could say the one thing that
   * matters most on a phone you glance at: is the table waiting for ME. The
   * seat ring is blue and every seat can have it, so "somebody's turn" and
   * "your turn" looked identical at arm's length.
   *
   * Gold is reserved, absolutely, for the current user. It appears in exactly
   * three places at once — the rail around the table, your own seat, and the
   * edge of the cards you may play — and nowhere else, ever. An accent that
   * sometimes means you and sometimes means somebody else is not an accent.
   * It is also the Wizard card's own colour, so it is already this app's.
   */
  --gold:      #b8860b;
  --gold-lit:  #e0a92a;

  --r-card: 20px;
  --r-mid:  13px;
  --r-sm:   9px;

  --font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text",
          "Helvetica Neue", sans-serif;
  /* Scores, bids and trick counts. A proportional font makes a running number
     jitter sideways on every change, which is distracting in exactly the moment
     the number matters. */
  --font-num: ui-rounded, -apple-system, "SF Pro Rounded", system-ui, sans-serif;

  /* ── HOW FAST THE TABLE MOVES, IN ONE PLACE ──────────────────────────────
   *
   * These were here from the start with a comment saying app.js reads them back
   * out of the computed style so the JavaScript timings and the CSS transitions
   * cannot drift apart. NOTHING READ THEM: the comment described an intention,
   * and two sets of numbers sat in two files agreeing by luck. That is the
   * shape the root CLAUDE.md names in `shelf`'s model panel — a derived value
   * written by hand fails silently — so `anim.js:timing()` now genuinely parses
   * them, and changing a number here changes the animation.
   *
   * Tuned against the SERVER's own pacing rather than by eye. Game.php pauses
   * 2.6s on a finished trick and 7.0s on the scoreboard, and every sequence
   * below has to finish inside the pause it belongs to, with room to spare for
   * a slow phone: shuffle + deal + trump reveal is ~1.5s inside a 7s round
   * pause, and a collected trick is 0.38s inside 2.6s. */
  --t-deal:    220ms;   /* one card arriving in a hand */
  --t-play:    180ms;   /* a fan closing up; a card settling back down */
  --t-fly:     340ms;   /* a card crossing the table */
  --t-flip:    420ms;   /* a card turning over */
  --t-collect: 380ms;   /* a finished trick going to the winner */
  --t-shuffle: 520ms;   /* split, riffle, square up */
  --t-stagger:  46ms;   /* between two cards of one deal */
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    /* Brighter on a dark felt: #b8860b at 2am is a brown smudge. */
    --gold:      #e8c05a;
    --gold-lit:  #ffd77a;
    /* Brighter on a dark felt: #b8860b at 2am is a brown smudge. */
  --gold:      #e8c05a;
  --gold-lit:  #ffd77a;
  --pip-clubs:    #e6e6ea;
    --pip-diamonds: #ff6b6b;
    --pip-hearts:   #ff6b6b;
    --pip-spades:   #e6e6ea;
    --bg:       #000000;
    --panel:    #1c1c1e;
    --panel-2:  #2c2c2e;
    --line:     rgba(84, 84, 88, .5);
    --line-2:   rgba(84, 84, 88, .28);
    --ink:      #ffffff;
    --ink-2:    #ebebf5;
    --dim:      #8e8e93;
    --dim-2:    #636366;
    --pill:     #2c2c2e;
    --good:     #30d158;
    --warn:     #ffd60a;
    --bad:      #ff453a;
    --link:     #0a84ff;
    --shadow:   0 1px 2px rgba(0,0,0,.5), 0 6px 20px rgba(0,0,0,.4);
    /* A darker baize, not a black one: the table should still read as a
       green cloth at 2am, and cards on near-black lose their edges. */
    --felt:     #16472f;
    --felt-2:   #0d2f1f;
    --felt-ink: #cfe0d6;
    --rail:     #6f4b2e;
    --card-shadow: 0 1px 1px rgba(0,0,0,.45), 0 3px 8px rgba(0,0,0,.34);
  }
}
:root[data-theme="dark"] {
  --pip-clubs:    #e6e6ea;
  --pip-diamonds: #ff6b6b;
  --pip-hearts:   #ff6b6b;
  --pip-spades:   #e6e6ea;
  --bg:       #000000;
  --panel:    #1c1c1e;
  --panel-2:  #2c2c2e;
  --line:     rgba(84, 84, 88, .5);
  --line-2:   rgba(84, 84, 88, .28);
  --ink:      #ffffff;
  --ink-2:    #ebebf5;
  --dim:      #8e8e93;
  --dim-2:    #636366;
  --pill:     #2c2c2e;
  --good:     #30d158;
  --warn:     #ffd60a;
  --bad:      #ff453a;
  --link:     #0a84ff;
  --shadow:   0 1px 2px rgba(0,0,0,.5), 0 6px 20px rgba(0,0,0,.4);
  --felt:     #16472f;
  --felt-2:   #0d2f1f;
  --felt-ink: #cfe0d6;
  --rail:     #6f4b2e;
  --card-shadow: 0 1px 1px rgba(0,0,0,.45), 0 3px 8px rgba(0,0,0,.34);
}


/* ── THE HOST'S TABLE: the baize a supporter dressed it in ─────────────────
 * Set as `data-felt` on #screen-table from the payload (app.js), so the felt,
 * its shaded edge and what is legible on it change together and nothing else
 * does. `green` is the house cloth above and needs no rule. The dark values
 * are the same argument as the green's: darker, not black. */
#screen-table[data-felt="blue"] { --felt: #2f5f8d; --felt-2: #1f4363; --felt-ink: #e6eef6; }
#screen-table[data-felt="burgundy"] { --felt: #7d2f3c; --felt-2: #5c1f2a; --felt-ink: #f3e8ea; }
#screen-table[data-felt="slate"] { --felt: #4a5566; --felt-2: #333c49; --felt-ink: #eef0f3; }
#screen-table[data-felt="plum"] { --felt: #5b3a7d; --felt-2: #42285c; --felt-ink: #efe9f4; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) #screen-table[data-felt="blue"] { --felt: #163650; --felt-2: #0d2436; --felt-ink: #cfdce8; }
  :root:not([data-theme="light"]) #screen-table[data-felt="burgundy"] { --felt: #47161f; --felt-2: #2f0d14; --felt-ink: #e0cfd3; }
  :root:not([data-theme="light"]) #screen-table[data-felt="slate"] { --felt: #2a313b; --felt-2: #1b2028; --felt-ink: #d3d8df; }
  :root:not([data-theme="light"]) #screen-table[data-felt="plum"] { --felt: #33204a; --felt-2: #221532; --felt-ink: #d9cfe2; }
}
:root[data-theme="dark"] #screen-table[data-felt="blue"] { --felt: #163650; --felt-2: #0d2436; --felt-ink: #cfdce8; }
:root[data-theme="dark"] #screen-table[data-felt="burgundy"] { --felt: #47161f; --felt-2: #2f0d14; --felt-ink: #e0cfd3; }
:root[data-theme="dark"] #screen-table[data-felt="slate"] { --felt: #2a313b; --felt-2: #1b2028; --felt-ink: #d3d8df; }
:root[data-theme="dark"] #screen-table[data-felt="plum"] { --felt: #33204a; --felt-2: #221532; --felt-ink: #d9cfe2; }

/* The picker's swatches show the cloth itself, light theme's values. */
.swatch[data-felt="green"] { --sw: #2f7d55; }
.swatch[data-felt="blue"] { --sw: #2f5f8d; }
.swatch[data-felt="burgundy"] { --sw: #7d2f3c; }
.swatch[data-felt="slate"] { --sw: #4a5566; }
.swatch[data-felt="plum"] { --sw: #5b3a7d; }

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

html, body {
  margin: 0; padding: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font);
  font-size: 16px;
  -webkit-font-smoothing: antialiased;
  overscroll-behavior-y: none;
}

body { min-height: 100dvh; }

.screen { display: flex; flex-direction: column; min-height: 100dvh; }
[hidden] { display: none !important; }

/* ── the bar ───────────────────────────────────────────────────────────────
 * iOS 26 draws a blur behind the status bar that reaches PAST the safe-area
 * inset, so a header padded to exactly env(safe-area-inset-top) still has its
 * first line of text sitting under the glass. About 20px more, once per screen,
 * is what clears it. */
.bar {
  position: sticky; top: 0; z-index: 20;
  display: flex; align-items: center; gap: 10px;
  padding: calc(20px + env(safe-area-inset-top, 0px)) 14px 10px;
  background: color-mix(in srgb, var(--bg) 86%, transparent);
  backdrop-filter: saturate(180%) blur(20px);
  border-bottom: .5px solid var(--line-2);
}
.bar h1 { font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -.02em; }
.bar-title { flex: 1; min-width: 0; }
.bar-title h1 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar .sub { display: block; font-size: 12px; color: var(--dim); }
.bar > h1 { flex: 1; }

.icon {
  appearance: none; border: 0; background: var(--pill); color: var(--ink);
  width: 34px; height: 34px; border-radius: 17px; font-size: 17px;
  display: grid; place-items: center; cursor: pointer; flex: none;
}

.wrap {
  padding: 14px 14px calc(24px + env(safe-area-inset-bottom, 0px));
  max-width: 620px; width: 100%; margin: 0 auto;
}

/* ── cards, panels, forms ─────────────────────────────────────────────── */
.card {
  background: var(--panel); border-radius: var(--r-card);
  padding: 16px; margin: 0 0 14px; box-shadow: var(--shadow);
}
.card h2, .card h3 { margin: 0 0 12px; font-size: 16px; font-weight: 650; }
.section { font-size: 13px; text-transform: uppercase; letter-spacing: .06em;
           color: var(--dim); margin: 22px 4px 10px; font-weight: 600; }

.field { display: block; margin: 0 0 12px; }
.field > span { display: block; font-size: 13px; color: var(--dim); margin-bottom: 6px; }
input[type=text], select {
  width: 100%; font: inherit; color: var(--ink);
  background: var(--panel-2); border: .5px solid var(--line);
  border-radius: var(--r-mid); padding: 11px 12px;
}
.code-input { text-transform: uppercase; letter-spacing: .32em; font-weight: 700;
              font-family: var(--font-num); text-align: center; }
.check { display: flex; align-items: flex-start; gap: 10px; margin: 12px 0;
         font-size: 14px; color: var(--ink-2); }
.check input { margin-top: 2px; }

button {
  appearance: none; font: inherit; font-weight: 600; cursor: pointer;
  border: .5px solid var(--line); background: var(--panel-2); color: var(--ink);
  border-radius: var(--r-mid); padding: 11px 16px;
}
button.primary { background: var(--link); color: #fff; border-color: transparent; }
button.big { width: 100%; padding: 14px; font-size: 17px; }
button.quiet { background: transparent; border-color: transparent; color: var(--link);
               padding: 10px 0; }
button:disabled { opacity: .4; cursor: default; }
/* ── A CONTROL THAT HAS BEEN TAPPED SAYS SO BEFORE THE SERVER DOES ─────────
 *
 * Every write in this app is a round trip to the NAS and back, and until the
 * answer arrives the screen was identical to the screen before the tap. Adding
 * a computer player took long enough that it read as a dead button, so it got
 * tapped again — and the second tap was a real second request, so tables ended
 * up with more computers than anybody asked for. The bug was not the latency;
 * it was that nothing acknowledged the tap.
 *
 * `.working` is the acknowledgement: dimmed, un-tappable, with a bar crossing
 * it so it reads as busy rather than as broken. app.js's `press()` puts it on
 * before the request and takes it off after — and, for adding a player, ALSO
 * draws the seat immediately, because the honest answer to "did that work" is
 * the new seat rather than a spinner. */
button.working {
  opacity: .55; cursor: default; position: relative; overflow: hidden;
}
button.working::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
  background: currentColor; opacity: .5;
  animation: work-slide 900ms linear infinite;
}
@keyframes work-slide {
  0%   { transform: translate3d(-100%, 0, 0); }
  100% { transform: translate3d(100%, 0, 0); }
}
/* Ending a table is the one destructive control here, and it is styled as one
   — quietly. It sits in a sheet you have to open, and it asks first. */
button.danger { color: var(--bad); }

.row { display: flex; gap: 10px; align-items: center; }
.row > input { flex: 1; }
.wrap-row { flex-wrap: wrap; }
.wrap-row > button { flex: 1 1 auto; }
.fine { font-size: 12.5px; color: var(--dim); line-height: 1.45; margin: 8px 0 0; }
.fine a { color: var(--link); }

/* ── the model badge, and the three states it has ────────────────────────
 * `cold` and `down` are not errors and are not styled as errors. They are the
 * app saying which kind of opponent you are about to get, which is the whole
 * point of showing it at all. */
.badge { border-radius: var(--r-mid); padding: 10px 12px; font-size: 13px;
         margin: 0 0 14px; border: .5px solid var(--line); background: var(--panel); }
.badge[data-state="down"], .badge[data-state="off"] { color: var(--ink-2); }
.badge[data-state="cold"] { color: var(--warn); }
.badge[data-state="ok"]   { color: var(--dim); }

/* ── the lobby list ──────────────────────────────────────────────────── */
.list { display: flex; flex-direction: column; gap: 10px; }
.list .empty { color: var(--dim); font-size: 14px; padding: 18px 4px; text-align: center; }
.room {
  display: flex; align-items: center; gap: 12px; text-align: left; width: 100%;
  background: var(--panel); border-radius: var(--r-card); padding: 14px;
  border: .5px solid var(--line-2); box-shadow: var(--shadow);
}
.room .who { flex: 1; min-width: 0; }
.room .rname { font-weight: 650; }
.room .rsub { font-size: 12.5px; color: var(--dim); margin-top: 2px; }
.room .code { font-family: var(--font-num); font-weight: 700; letter-spacing: .14em;
              font-size: 15px; color: var(--dim); }

/* ── the felt ─────────────────────────────────────────────────────────── */
/* THE RAIL. A wood surround, and the photograph is REPEATED ONLY SIDEWAYS.
 *
 * Measured before being used: across `wood.png`'s left and right edges the
 * colour step is 9.0 per pixel, and the noise floor between two neighbouring
 * columns INSIDE the image is 8.9 — so a horizontal seam is literally
 * indistinguishable from the grain's own variation. Top to bottom it is 31.2,
 * three and a half times the floor, which would show as a band across the
 * table every 150px. So: `repeat-x`, sized to the rail's height, never
 * `repeat`. A texture that tiles in one direction and not the other looks
 * exactly like one that tiles in both until it is on a big screen. */
.felt {
  flex: 1; position: relative; margin: 10px;
  border-radius: 22px;
  /* The rail's width. Thin enough not to eat a phone's screen, wide enough that
     the grain is actually visible as grain rather than as a brown line. */
  padding: 14px;
  background-color: var(--rail);
  background-image: url("wood.png");
  background-repeat: repeat-x;
  background-size: auto 100%;
  /* The photograph is a mid brown; the tint is what makes it the same wood in
     both themes without shipping a second picture. */
  background-blend-mode: multiply;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.14), var(--shadow);
  display: flex; flex-direction: column;
  min-height: 240px; overflow: hidden;
}

/* THE BAIZE, drawn. feTurbulence at a high frequency is a very good felt: it is
   what the surface actually is, optically — a dense random nap. The vignette on
   top is the light falling on a table rather than a decoration, and it is what
   stops a flat rectangle of green reading as construction paper. */
.felt::before {
  content: ""; position: absolute; inset: 14px; border-radius: 12px;
  background-color: var(--felt);
  background-image:
    radial-gradient(120% 90% at 50% 0%, rgba(255,255,255,.10), transparent 60%),
    radial-gradient(140% 120% at 50% 100%, transparent 40%, rgba(0,0,0,.42)),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.35'/%3E%3C/svg%3E");
  /* `stitchTiles='stitch'` in the filter is what makes the noise seamless
     across the 120px tile — without it every repeat shows a grid. */
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.35), inset 0 2px 8px rgba(0,0,0,.35);
  pointer-events: none;
}
/* ── THE RAIL GOES GOLD WHEN IT IS YOUR TURN ─────────────────────────────
 *
 * The single most-asked question in a game played on a phone in a room with
 * other people is "is it me". It was answerable only by finding your own seat
 * among six and noticing a blue border that five other seats can also have.
 *
 * This is the whole table answering it. The ring is on the ::after rather than
 * the box-shadow because it has to be able to breathe — an inset shadow on
 * `.felt` would repaint the wood photograph and its blend mode sixty times a
 * second, where a bordered pseudo-element is one composited layer. */
.felt::after {
  content: ""; position: absolute; inset: 0; border-radius: 22px;
  border: 2px solid var(--gold); pointer-events: none; opacity: 0;
  transition: opacity 300ms ease;
  box-shadow: inset 0 0 14px color-mix(in srgb, var(--gold) 28%, transparent);
}
.felt.mine::after { opacity: 1; animation: rail-breathe 2.8s ease-in-out infinite; }
@keyframes rail-breathe {
  0%, 100% { opacity: .95; }
  50%      { opacity: .55; }
}

/* Everything real sits above the cloth. */
.felt > * { position: relative; z-index: 1; }
/* Everything that is not a seat is centred in what is left. */
.middle { flex: 1; display: flex; flex-direction: column;
          align-items: center; justify-content: center; gap: 8px; padding: 8px; }

.strip {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  padding: 8px 16px; font-size: 13px; color: var(--ink-2);
}
.strip .chip {
  font-weight: 650; border-radius: 999px; padding: 3px 10px;
  background: var(--pill); font-family: var(--font-num);
}
/* The pip carries the suit's own colour, the word does not. A red word on a
   pill reads as an error; a red heart reads as a heart. */
.strip .chip .pip { color: var(--pipcol, inherit); font-family: var(--font); }
.strip .tally { margin-left: auto; color: var(--dim); font-family: var(--font-num); }
.strip .tally.over  { color: var(--bad); }
.strip .tally.under { color: var(--good); }

/* ── THE PLAYERS, OFF THE TABLE ───────────────────────────────────────────
 *
 * These were panels ON the baize, each carrying a name, a bid, a card count, a
 * running total and a Remove button. Five things per seat, six seats, sitting
 * on the surface the cards are played on — and the trick, which is what the
 * game actually is, was squeezed into whatever green was left.
 *
 * At a real table none of that is on the cloth. So this is a rail above the
 * felt, and each seat carries the two facts people say out loud: who you are,
 * and how you are doing against your bid. The running total went to the sheet
 * (a scoreboard is not on the table either), the card count went entirely —
 * everyone holds the same number in Wizard except mid-trick — and Remove moved
 * to the sheet with the other things you go looking for.
 *
 * It WRAPS rather than scrolls. A row you have to push sideways to see who else
 * is playing is worse than two short rows: six pills at 78px is two rows of
 * about 44px, against the ~200px of felt the old panels ate. */
.players {
  display: flex; flex-wrap: wrap; gap: 6px;
  padding: 4px 12px 8px; justify-content: center;
}
.seat {
  flex: 1 1 78px; max-width: 132px; min-width: 0;
  position: relative;
  background: var(--panel-2);
  border: 1.5px solid var(--line-2); border-radius: var(--r-sm);
  padding: 5px 8px 4px;
}
/* WHOSE TURN IT IS, BREATHING — AND GOLD IF IT IS YOURS.
 *
 * Two rings, and the difference between them is the whole point. A quiet blue
 * ring says somebody is thinking; the gold one says the table is waiting for
 * you, and it appears at the same instant as the gold rail around the felt and
 * the gold edge on your playable cards. One colour, one meaning, three places.
 *
 * `transform` on the ring rather than on the seat, so the text inside never
 * resamples. */
.seat.turn { border-color: var(--link); }
.seat.turn::after {
  content: ""; position: absolute; inset: -1.5px; border-radius: var(--r-sm);
  border: 1.5px solid var(--link); pointer-events: none;
  animation: turn-breathe 2s ease-in-out infinite;
}
.seat.turn.you { border-color: var(--gold); }
.seat.turn.you::after { border-color: var(--gold); }
@keyframes turn-breathe {
  0%, 100% { opacity: .55; transform: scale(1); }
  50%      { opacity: 0;   transform: scale(1.035); }
}
/* A NUMBER THAT CHANGED SAYS SO ONCE. `took/bid` moves a few times a round and
   is the only score on the table now, so it is worth a beat — but a beat, not a
   celebration: 380ms and it is over. */
.seat .tally.bumped { animation: num-bump 380ms cubic-bezier(.22,.92,.28,1.02); }
@keyframes num-bump {
  0%   { transform: scale(1); }
  40%  { transform: scale(1.42); }
  100% { transform: scale(1); }
}
.seat.away { opacity: .55; }
/* A SEAT THAT HAS BEEN ASKED FOR BUT NOT YET CONFIRMED. It is drawn the moment
   the button is tapped and replaced by the real thing one poll later — see
   app.js on why an optimistic WRITE is not the failed READ the house rule is
   about. Dashed and dim so it is visibly not a fact yet. */
.seat.provisional { border-style: dashed; opacity: .6; }
/* THE NAME GETS THE WHOLE LINE, and the CPU tag sits on the second one beside
   the score. Sharing a line with the tag, at four players on a 414px screen,
   left about forty pixels for a name — so the table read "Bra… CPU",
   "Juni… CPU", and the one thing a seat is for was the thing that got cut. */
.seat .nm { font-weight: 650; font-size: 12.5px; line-height: 1.25;
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.seat .tag { font-size: 9px; font-weight: 700; letter-spacing: .04em;
             color: var(--dim); border: 1px solid var(--line); border-radius: 3px;
             padding: 0 2px; flex: none; }
.seat .nums { display: flex; gap: 5px; align-items: center; margin-top: 1px;
              font-family: var(--font-num); font-size: 12.5px; color: var(--dim); }
.seat .nums .tag { margin-left: auto; font-family: var(--font); }
.seat .tally { display: inline-block; color: var(--ink-2); font-weight: 600; }
.seat .made  { color: var(--good); }
.seat .over  { color: var(--bad); }
.seat .nm.dealer::after { content: "D"; font-size: 8px; font-weight: 800;
                       background: var(--dim-2); color: var(--panel);
                       border-radius: 3px; padding: 0 2.5px; margin-left: 3px;
                       vertical-align: 1px; }

/* ── THE TABLE TALK, ON THE TABLE ─────────────────────────────────────────
 *
 * `wizardd` spends a real model call between rounds producing one sentence in a
 * computer player's voice, and it was written to the log and nowhere else — so
 * the one thing in this app that exists purely to be read was invisible unless
 * you opened a sheet. It is on the cloth now, where somebody saying something
 * at a table is.
 *
 * It fades itself out after a while rather than sitting there: a line from two
 * rounds ago read as a thing that had just been said. Tapping it opens the log,
 * which is the history behind it. */
.talk {
  margin: 8px 10px 0; padding: 8px 12px; border-radius: 14px;
  background: color-mix(in srgb, var(--felt-2) 74%, transparent);
  border: 1px solid color-mix(in srgb, var(--felt-ink) 16%, transparent);
  color: var(--felt-ink); font-size: 13px; line-height: 1.35;
  cursor: pointer; transition: opacity 600ms ease;
  animation: talk-in 320ms cubic-bezier(.22,.92,.28,1.02);
}
.talk.faded { opacity: .32; }
.talk .talk-who { font-weight: 700; margin-right: 6px; }
.talk .talk-line { font-style: italic; }
@keyframes talk-in {
  0%   { opacity: 0; transform: translate3d(0, -6px, 0); }
  100% { opacity: 1; transform: none; }
}

/* The table's code, big, while nothing is being played. The felt is otherwise
   an empty rectangle of green during the lobby, and the code is the one thing
   somebody there actually needs to read out loud. */
.code-plate { text-align: center; color: var(--felt-ink); }
.code-plate .big { font-family: var(--font-num); font-weight: 800;
                   font-size: 44px; letter-spacing: .18em; display: block;
                   margin-left: .18em; }
.code-plate .cap { font-size: 12.5px; opacity: .75; }

/* ── THE TURNED CARD STAYS OUT ALL ROUND ──────────────────────────────────
 *
 * It used to be shown in the middle only until the first card was played, and
 * then it vanished for the rest of the trick. That was a space decision, not a
 * rules one: the panel-per-seat layout left no room for both. It IS the rule,
 * though — the card turned for trump lies face up on the pack for the whole
 * round at a real table, and it is public information that changes how you
 * play, since an ace of trumps face up is an ace nobody is holding.
 *
 * With the seats off the cloth there is room, so it stays. It also gives the
 * animation a stock anchor that exists for the whole round instead of one that
 * is destroyed and rebuilt after every trick. */
.stock { display: flex; justify-content: center; min-height: 0; }
.stock:empty { display: none; }

/* The trick in the middle. */
.trick {
  display: flex; align-items: center; justify-content: center;
  gap: 6px; flex-wrap: wrap; min-height: 96px;
}
.played { text-align: center; }
.played .pn { font-size: 11px; color: var(--felt-ink); margin-top: 4px;
              white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
              max-width: 76px; }
/* The turned card's caption is a phrase, not a name, so it gets the room a
   phrase needs — it was clipped to "turned for tr…" by the 76px meant for
   keeping six player names the same width. */
.played.turned .pn { max-width: none; }
/* THE WINNING CARD'S RING IS A PSEUDO-ELEMENT, NOT AN `outline`.
 *
 * It was `outline: 2.5px solid var(--link)`, which is correct and cannot be
 * animated: outline-width is layout-adjacent and outline-color transitions are
 * a repaint of the whole box on every frame. A border on an absolutely
 * positioned ::after animates with `opacity` and `transform` alone, which is
 * what a phone can do sixty times a second, and it can be drawn scaling OUT of
 * the card — which reads as the table pointing at it rather than as the card
 * acquiring a permanent blue edge. */
.played .pcard::after {
  content: ""; position: absolute; inset: -3px; border-radius: 11px;
  border: 2.5px solid var(--link); opacity: 0; pointer-events: none;
}
.played.won .pcard::after { opacity: 1; }
.played.won .pcard { animation: win-lift 460ms cubic-bezier(.22,.92,.28,1.02); }
.played.won .pcard::after { animation: win-ring 460ms cubic-bezier(.22,.92,.28,1.02); }
@keyframes win-lift {
  0%   { transform: scale(1); }
  38%  { transform: scale(1.13); }
  100% { transform: scale(1); }
}
@keyframes win-ring {
  0%   { opacity: 0; transform: scale(1.3); }
  38%  { opacity: 1; transform: scale(1.04); }
  100% { opacity: 1; transform: scale(1); }
}
/* The turned card is public but it is not IN the trick — dimmed so it never
   reads as somebody's play. */
.played.turned { opacity: .8; }
.played.turned .pn { font-style: italic; }
/* ── THE STOCK IS A PILE, AND THE TRUMP LIES ACROSS IT ─────────────────────
 * One face-up card with an italic caption read as somebody's play — it sat at
 * the same size, in the same middle, as the trick. So the pack is drawn: three
 * backs fanned a few pixels each behind, and the turned card on top, askew as
 * a card put down on a pile is. Nothing about it looks played, and the backs
 * are also where the table's deck is seen all round. The `.pcard` itself is
 * not rotated — anim.js measures it — only the face inside it. */
.pile { position: relative; width: 58px; height: 84px; margin: 0 auto 0 -10px; }   /* the fan leans right; nudge the whole thing left to sit centred */
.pile .under { position: absolute; left: 0; top: 0; width: 58px; }
.pile .under:nth-child(1) { transform: translate(14px, 3px) rotate(11deg); }
.pile .under:nth-child(2) { transform: translate(8px, 2px) rotate(6deg); }
.pile .under:nth-child(3) { transform: translate(3px, 1px) rotate(2deg); }
.pile .under > svg { border-radius: 8px; box-shadow: 0 1px 1px rgba(0,0,0,.25); }
.pile .pcard { position: absolute; left: 0; top: 0; width: 58px; }
.pile .pcard > svg { transform: rotate(-12deg); }
.pile .under.bare > svg { opacity: .9; }
.played.turned { opacity: 1; }                 /* the pile is not a play; it needs no dimming to say so */
.played.turned .pn { font-style: normal; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; font-size: 10px; margin-top: 8px; }
.nostock { width: 58px; height: 81px; border-radius: 8px; margin: 0 auto;
           border: 1.5px dashed var(--felt-ink); opacity: .35;
           display: grid; place-items: center; color: var(--felt-ink); }
.played.won .pn { color: var(--link); font-weight: 700; }

.table-msg { text-align: center; color: var(--felt-ink); font-size: 13.5px;
             padding: 0 16px; min-height: 20px; }

/* ── card faces ───────────────────────────────────────────────────────── */
.card-face { display: block; border-radius: 8px; filter: drop-shadow(0 1px 2px rgba(0,0,0,.2)); }
.face { display: block; width: 100%; height: auto; }
.face .card-bg { fill: #fdfdfb; stroke: rgba(0,0,0,.22); stroke-width: 1.5; }
.face .corner-rank { font: 700 30px var(--font-num); fill: var(--suit); }
.face .corner-pip  { font: 400 24px var(--font); fill: var(--suit); }
.face .mid-pip     { font: 400 46px var(--font); fill: var(--suit); opacity: .22; }
.face.special { color: #6a4bbd; }
/* Darkened from the icon-set's own gold: at the 34px a card is drawn at inside
   a trick, the lighter tone did not hold its edges on white. The Jester's
   violet was already dark enough and is unchanged. */
.face.wizard  { color: #96701a; }
.face .special-name { font: 700 12px var(--font-num); fill: currentColor;
                      text-anchor: middle; letter-spacing: .12em; }
.face.back .back-panel { fill: #3b4a63; }

/* A PLAYING CARD IS `.pcard`, NOT `.card`, AND THE RENAME WAS A BUG FIX.
 *
 * `.card` is this stylesheet's PANEL — the white rounded box a settings group
 * or a lobby row sits in, with a dark background in dark mode, a 20px radius
 * and 16px of padding. The playing cards were given the same class name, so
 * every card in the trick and every card in the hand was quietly wearing a
 * panel: a large dark plaque, bigger than the card, behind it.
 *
 * On the app's own near-black background it was invisible, which is why it
 * survived the first pass. The moment the table became green baize it was the
 * most obvious thing on the screen — and it still looked like an over-strong
 * drop shadow, which is what it was first blamed on. Softening the shadow
 * changed nothing, because the shadow was never the problem.
 *
 * Two things share a name only until one of them is on a different background.
 */
.pcard { position: relative; }
.pcard > svg { border-radius: 8px; box-shadow: var(--card-shadow); }

/* ── the hand ─────────────────────────────────────────────────────────────
 * A FAN THAT SCROLLS RATHER THAN SHRINKS PAST READING. cards.js stops
 * tightening the overlap at 26px of visible edge, below which the corner rank
 * is no longer legible; past that the fan is wider than the screen and this
 * scrolls. A twenty-card hand in the last round of a three-handed game is the
 * case that forces it, and a hand you cannot read would be worse than one you
 * have to push sideways. */
.handzone {
  padding: 6px 0 calc(10px + env(safe-area-inset-bottom, 0px));
  overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.handzone::-webkit-scrollbar { display: none; }
.hand { position: relative; height: 132px; margin: 0 auto; }
.hand .pcard {
  position: absolute; top: 8px; width: 84px;
  transition: transform var(--t-play) ease, opacity var(--t-play) ease;
}
/* ── PLAYABLE CARDS STAND UP; THE REST STAY DOWN ──────────────────────────
 *
 * Fading the cards you may not play was right and was not enough. Opacity is a
 * comparison — you have to look at two cards to know which one is dim — and on
 * a phone in a bright room the difference between 1.0 and 0.4 is much smaller
 * than it is on a desk. A HEIGHT difference needs no comparison: the cards you
 * can play are a row, and the cards you cannot are behind it.
 *
 * So the playable ones rise 10px and keep the fade as a second channel. Same
 * argument `gems` made about seven jewel colours needing seven SHAPES: when one
 * channel is doing all the work, add another rather than turning the first one
 * up. */
.hand .pcard.playable { cursor: pointer; transform: translate3d(0, -10px, 0); }
/* The gold edge, the third place gold appears and never anywhere else: the rail
   around the table, your own seat, and the cards you may play. */
.hand .pcard.playable > svg {
  box-shadow: var(--card-shadow), 0 0 0 1.5px var(--gold);
}
/* ── SELECTING A CARD IS A STATE, NOT A `:active` ────────────────────────────
 *
 * This was `:active { transform: translateY(-10px) }`, which rises on press and
 * drops the instant the finger leaves — including the moment the card is
 * played, so the card fell back into the fan and then teleported to the trick.
 *
 * `.picked` is set on pointerdown and cleared on pointerup, cancel, or a
 * refusal, so the rise has a beginning AND an end that the code controls: the
 * card lifts out of the fan, stays lifted while the move is posted, and its
 * flight to the trick starts from where it is actually standing. A card that
 * may not be played rises the same 12px and settles back — a refusal you can
 * feel is better than one you can only read.
 *
 * `translate3d` rather than `translateY`: it is the same pixels and it is the
 * form that reliably gets its own compositing layer in mobile Safari.
 *
 * -22px, not -12px, because a playable card already rests at -10: the RISE is
 * still the 12px it was, measured from where the card is actually standing. */
.hand .pcard.picked { transform: translate3d(0, -22px, 0); }
.hand .pcard.blocked.picked { transform: translate3d(0, -12px, 0); }
/* ── THE HIT BOX IS ITS OWN ELEMENT, AND IT IS NOT THE WHOLE CARD ─────────
 *
 * A fanned hand is a stack of 84px cards showing about 40px each, so most of
 * every card is UNDER the one after it — and the top card of an overlap wins
 * the tap whether or not it is the one you can play. With three unplayable
 * cards lying over a playable one, the card you are aiming at is a 40px strip
 * and the three you cannot use are eating the rest of the fan.
 *
 * So the tappable region is a child element rather than the card, and a card
 * you may not play only keeps the part of itself you can still SEE below the
 * raised row. Taps above that line fall through to the playable card
 * underneath, which is the one you were aiming at. A blocked card is still
 * tappable on purpose — it answers with the server's own sentence about why —
 * it just stops being a 84px-wide obstacle.
 *
 * `pointer-events` on a child is the mechanism because it is the only one that
 * changes hit testing without changing a pixel: clip-path would clip the
 * drawing too, and a transparent overlay per card is another element per card
 * in a twenty-card hand. */
.hand .pcard { pointer-events: none; }
.hand .hit { position: absolute; inset: 0; display: block; pointer-events: auto; }
.hand .pcard.blocked .hit { top: 40%; }
/* NO TAPS WHILE A CARD IS IN THE AIR. The guard that actually prevents a
   double play is in app.js — this is the second half of it, so a finger that
   lands during the flight gets no highlight either and the app never looks
   like it ignored something it received. */
.hand.busy .hit { pointer-events: none; }
/* NOT GREYED OUT — DIMMED AND UNTAPPABLE. A card you may not play is still a
   card you need to read, because knowing you are holding a spade you cannot
   use is most of what makes the next bid work. */
.hand .pcard.blocked { opacity: .38; cursor: default; }
/* A card still in the air is invisible in the fan; its ghost is what you see.
   Set from app.js per card id, so a re-render mid-deal (somebody else bids
   while the cards are still landing) redraws it hidden rather than popping it
   into place early. */
.hand .pcard.inflight, .played .pcard.inflight { opacity: 0; }
/* A CAPTION WITH NOTHING ABOVE IT IS A BUG ON THE SCREEN. The turned card is
   hidden for the second or so between the shuffle and its reveal, and without
   this the words "turned for trump" sat under an empty rectangle of baize the
   whole time — which reads as a card that failed to load rather than as one
   that has not been turned yet. The caption is the card's next sibling, so no
   `:has()` is needed. */
.played .pcard.inflight + .pn { opacity: 0; }
.played .pn { transition: opacity var(--t-play) ease; }

/* The lobby controls. This had NO RULE AT ALL and inherited the body's zero
   padding, so "Wizard needs at least three players" sat flush against the left
   edge of the phone. Caught by looking at it; no test in this project could
   have. */
.pregame {
  padding: 10px 14px calc(14px + env(safe-area-inset-bottom, 0px));
  display: flex; flex-direction: column; gap: 10px;
}
.pregame .fine { margin: 0; }
.pregame .quiet { align-self: flex-start; }

/* ── the prompt: bidding, trump, the scoreboard ───────────────────────── */
.prompt {
  position: sticky; bottom: 0; z-index: 15;
  background: color-mix(in srgb, var(--panel) 94%, transparent);
  backdrop-filter: saturate(180%) blur(20px);
  border-top: .5px solid var(--line);
  padding: 14px 14px calc(14px + env(safe-area-inset-bottom, 0px));
}
.prompt h3 { margin: 0 0 4px; font-size: 16px; }
.prompt .fine { margin: 0 0 12px; }
.bids { display: flex; flex-wrap: wrap; gap: 8px; }
.bids button { flex: 1 1 44px; min-width: 44px; font-family: var(--font-num);
               font-size: 17px; padding: 12px 0; }
.bids button.banned { opacity: .35; }
/* THE ONE YOU TAPPED, while the LAN thinks about it. `press()` disables the
   whole row — one bid, one tap — and this is what says which one it was, so a
   row of eight identical grey buttons still shows the answer you gave. */
.bids button.chosen, .suits button.chosen {
  background: var(--link); color: #fff; border-color: transparent; opacity: 1;
}
.suits { display: flex; gap: 8px; }
.suits button { flex: 1; font-size: 22px; padding: 10px 0; }

.scoreboard { width: 100%; border-collapse: collapse; font-family: var(--font-num);
              font-size: 14px; }
.scoreboard th { text-align: left; font-size: 11px; color: var(--dim);
                 font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
                 padding-bottom: 6px; }
.scoreboard td { padding: 5px 0; border-top: .5px solid var(--line-2); }
.scoreboard td.n { text-align: right; }
.scoreboard .plus { color: var(--good); }
.scoreboard .minus { color: var(--bad); }

/* ── sheets ───────────────────────────────────────────────────────────── */
.sheet {
  position: fixed; inset: 0; z-index: 40; background: var(--bg);
  display: flex; flex-direction: column;
}
.sheet-head { display: flex; align-items: center; gap: 10px;
  padding: calc(20px + env(safe-area-inset-top, 0px)) 14px 10px;
  border-bottom: .5px solid var(--line-2); }
.sheet-head h2 { flex: 1; margin: 0; font-size: 18px; }
.sheet-body { flex: 1; overflow-y: auto; padding: 14px 14px
              calc(24px + env(safe-area-inset-bottom, 0px)); max-width: 620px;
              width: 100%; margin: 0 auto; }

/* The talk log is a panel inside the table sheet now rather than the whole
   sheet, so it scrolls within its own box and is capped: the last twenty lines
   are the conversation, and the rest is history nobody scrolls back through on
   a phone. */
.log { max-height: 42vh; overflow-y: auto; -webkit-overflow-scrolling: touch; }
.log .e { font-size: 13.5px; color: var(--ink-2); padding: 5px 0;
          border-bottom: .5px solid var(--line-2); line-height: 1.4; }
.log .e.banter { color: var(--ink); font-style: italic; }
.log .e.deal   { font-weight: 650; color: var(--ink); margin-top: 8px; }
.log .e.score  { font-weight: 650; }
.log .empty { color: var(--dim); text-align: center; padding: 30px 0; font-size: 14px; }

/* The sheet's running scoreboard. This is where a total belongs: something you
   look up, not something the table shows you between tricks. */
.tally-list { display: flex; flex-direction: column; gap: 2px; }
.tally-row { display: flex; align-items: center; gap: 10px; padding: 7px 0;
             border-top: .5px solid var(--line-2); font-size: 14px; }
.tally-row:first-child { border-top: 0; }
.tally-row .rank { font-family: var(--font-num); color: var(--dim); width: 18px;
                   font-size: 13px; flex: none; }
.tally-row .who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
                  white-space: nowrap; }
.tally-row .who .tag { font-size: 9px; font-weight: 700; color: var(--dim);
                       border: 1px solid var(--line); border-radius: 3px;
                       padding: 0 2px; margin-left: 5px; }
.tally-row .pts { font-family: var(--font-num); font-weight: 700; font-size: 15px; }
.tally-row .drop { font-size: 12px; padding: 2px 6px; color: var(--dim);
                   background: none; border: 0; flex: none; }
.tally-row .drop:hover { color: var(--bad); }
.tally-row.you .who { font-weight: 700; }

/* Stacked, not side by side. Two `.quiet` buttons are two inline-blocks and sat
   on one line reading "End the table Leave the table" — a sentence, and one
   whose first half is destructive. */
#host-card button { display: block; width: 100%; text-align: left; }

.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; font-size: 13.5px; }
.kv dt { color: var(--dim); }
.kv dd { margin: 0; color: var(--ink-2); overflow-wrap: anywhere; }
.kv .no  { color: var(--bad); }
.kv .yes { color: var(--good); }
.kv .unknown { color: var(--warn); }

.segmented { display: flex; background: var(--pill); border-radius: 10px; padding: 2px;
             margin-bottom: 8px; }
.segmented button { flex: 1; border: 0; background: transparent; padding: 8px 0;
                    border-radius: 8px; font-size: 14px; }
.segmented button[aria-pressed="true"] { background: var(--panel); box-shadow: var(--shadow); }

/* ── sign-in ──────────────────────────────────────────────────────────── */
#gate { align-items: center; justify-content: center; padding: 24px; }
.signin-card { max-width: 380px; text-align: center; }
.signin-card h1 { font-size: 34px; margin: 14px 0 6px; letter-spacing: -.03em; }
.lede { color: var(--dim); margin: 0 0 24px; line-height: 1.5; }
.brand { width: 84px; height: 84px; margin: 0 auto; border-radius: 22px;
         background: linear-gradient(160deg, #6a4bbd, #40287d);
         display: grid; place-items: center; color: #e8c05a; }
.brand svg { width: 60%; height: 60%; display: block; }

/* ── toast, banner ────────────────────────────────────────────────────── */
.toast {
  position: fixed; left: 50%; transform: translateX(-50%);
  /* ABOVE THE HAND, NOT ON IT. At 90px this landed squarely on the fan of
     cards — so the message telling you why a card was refused covered the
     cards you were trying to tap. The prompt (bidding, the scoreboard) is
     taller still, so this clears both. */
  bottom: calc(178px + env(safe-area-inset-bottom, 0px)); z-index: 60;
  background: var(--ink); color: var(--bg); padding: 10px 16px;
  border-radius: 999px; font-size: 14px; max-width: 90vw; text-align: center;
  box-shadow: var(--shadow);
}
.banner {
  position: fixed; left: 0; right: 0; z-index: 70;
  top: env(safe-area-inset-top, 0px);
  display: flex; align-items: center; gap: 10px;
  background: var(--link); color: #fff; padding: 10px 14px; font-size: 14px;
}
.banner #banner-text { flex: 1; }
.banner button { background: rgba(255,255,255,.2); color: #fff;
                 border-color: transparent; padding: 5px 11px; font-size: 13px; }
.banner[data-tone="warn"] { background: var(--warn); color: #1a1a1a; }
.banner[data-tone="warn"] button { background: rgba(0,0,0,.14); color: #1a1a1a; }
.update-note { color: var(--link); }

/* ══ THE ANIMATION OVERLAY ════════════════════════════════════════════════
 *
 * Every card that travels between two places on this table is drawn HERE, not
 * where it came from and not where it is going. `anim.js`'s header has the
 * argument; the reason it has to be a fixed layer on the body is two lines of
 * this stylesheet: `.felt` is `overflow: hidden` because the baize is clipped
 * to its rounded rail, and `.handzone` is a horizontal scroller with
 * `overflow-y: hidden`. A card animated inside either one is cut off the moment
 * it leaves — it does not fly from the hand to the trick, it appears at the
 * felt's bottom edge.
 *
 * `pointer-events: none` is load-bearing rather than tidy. A ghost is a picture
 * of a card in flight; if it could be tapped it would be a second, tappable
 * copy of a card you already played, which is precisely the duplicate move this
 * whole pass has to avoid causing.
 */
.anim-stage {
  /* ABOVE THE TABLE, BELOW THE SHEETS. It was 55, which is above `.sheet`'s 40
   * — so a card still crossing the table was drawn on top of the scoreboard
   * somebody had just opened, floating over a list of names with nothing to
   * explain it. The layer has to clear the felt's clip and the sticky prompt
   * (20 and 15) and nothing else. */
  position: fixed; inset: 0; z-index: 30;
  pointer-events: none; overflow: hidden;
  /* Everything inside is transform-only and self-contained, so the browser is
     told it need not consider this layer when laying out or painting the page
     underneath it. */
  contain: strict;
}
.ghost {
  position: absolute; top: 0; left: 0;
  transform-origin: 50% 50%;
  will-change: transform, opacity;
}
.ghost .pcard { width: 100%; height: 100%; }
.ghost .pcard > svg { width: 100%; height: 100%; }

/* A GHOST WITH TWO FACES IS HOW A CARD TURNS OVER. Both faces are in the DOM
 * at once, the front pre-rotated a half turn and hidden by
 * `backface-visibility`; rotating the parent swaps which one the viewer is on.
 * The alternative — swapping innerHTML at the halfway point — is a paint in the
 * middle of a transform, which on a phone lands a frame late and shows the card
 * through itself for that frame. */
.g3d {
  position: absolute; inset: 0;
  transform-style: preserve-3d; -webkit-transform-style: preserve-3d;
  will-change: transform;
}
.gface {
  position: absolute; inset: 0;
  backface-visibility: hidden; -webkit-backface-visibility: hidden;
}
.gface.gback { transform: rotateY(180deg); }

/* ── the small emphases ────────────────────────────────────────────────── */

/* A WIZARD OR A JESTER TURNED FOR TRUMP. Neither answers the question the
   turned card is normally there to answer — a Wizard means the dealer is still
   choosing and a Jester means there is no trump — so the card gets one lift to
   send the eye up to the chip, which is where the real answer is. */
.pcard.trump-call { animation: trump-call 720ms cubic-bezier(.22,.92,.28,1.02); }
@keyframes trump-call {
  0%   { transform: translate3d(0, 0, 0) scale(1); }
  32%  { transform: translate3d(0, -7px, 0) scale(1.07); }
  62%  { transform: translate3d(0, 0, 0) scale(1); }
  78%  { transform: translate3d(0, -3px, 0) scale(1.02); }
  100% { transform: translate3d(0, 0, 0) scale(1); }
}

/* The trump chip, when the suit is decided. Restrained on purpose: this fires
   once a round, beside a card that has just been turned face up, and the card
   is the announcement. The chip only has to confirm it. */
.strip .chip.flash { animation: chip-flash 620ms cubic-bezier(.22,.92,.28,1.02); }
@keyframes chip-flash {
  0%   { transform: scale(1);    opacity: .55; }
  30%  { transform: scale(1.14); opacity: 1; }
  100% { transform: scale(1);    opacity: 1; }
}
.strip .chip { display: inline-block; transform-origin: center; }

/* The round number, when it becomes a different round. */
.strip #round-label.turned { animation: label-turn 420ms ease-out; }
@keyframes label-turn {
  0%   { opacity: 0; transform: translate3d(0, 6px, 0); }
  100% { opacity: 1; transform: none; }
}

/* The scoreboard and the bid pad arriving. The prompt is a sticky bar at the
   bottom of a phone, so it comes UP from the edge it is attached to — anything
   else reads as a panel that was already there and had been hidden. */
.prompt.entering { animation: prompt-rise 260ms cubic-bezier(.22,.92,.28,1.02); }
@keyframes prompt-rise {
  0%   { opacity: 0; transform: translate3d(0, 14px, 0); }
  100% { opacity: 1; transform: none; }
}
.scoreboard tr.settling { animation: row-in 300ms ease-out backwards; }
@keyframes row-in {
  0%   { opacity: 0; transform: translate3d(0, 5px, 0); }
  100% { opacity: 1; transform: none; }
}

/* Every control on the table. A press that does not move is a press you are not
   sure landed — and this app posts over the LAN, so "did that work" is a real
   question a person asks in the ~200ms before the answer arrives. */
button { transition: transform 120ms cubic-bezier(.22,.92,.28,1.02), opacity 120ms ease; }
button:not(:disabled):active { transform: scale(.965); }
.bids button:not(:disabled):active,
.suits button:not(:disabled):active { transform: scale(.92); }
.room { transition: transform 140ms cubic-bezier(.22,.92,.28,1.02); }
.room:active { transform: scale(.985); }

/* ══ prefers-reduced-motion ═══════════════════════════════════════════════
 *
 * A HARD OFF, IN BOTH HALVES. This rule has always killed the CSS half; the
 * other half is `anim.js:reduced()`, which is checked live on every call and
 * returns an already-resolved promise having created no ghost and hidden no
 * card. Both are required and neither is sufficient: killing only the CSS would
 * leave the Web Animations journeys running at full speed, and killing only the
 * JavaScript would leave the pulses and the turn indicator breathing.
 *
 * It is checked LIVE rather than read once, because on iOS this setting moves
 * while the app is open — Low Power Mode feeds it as well as the accessibility
 * switch.
 *
 * The `!important` sweep is deliberately total, including the ghost layer: with
 * no animation a ghost would be a card frozen mid-table forever. anim.js never
 * makes one, and this is the second lock on that door. */
@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; }
  .anim-stage { display: none !important; }
  /* The one thing that must NOT be suppressed: a card waiting for a landing it
     will never get would be a card missing from the hand. */
  .hand .pcard.inflight, .played .pcard.inflight { opacity: 1 !important; }
}


/* ── SUPPORTERS (README "Supporters") ─────────────────────────────────────── */
/* The wizard's mark: the W in gold, a button so a tap can say what it is,
   dressed as nothing — it sits in the name like a character would. */
.star { display: inline-block; vertical-align: -.12em; width: 1em; height: 1em; margin-left: 5px; padding: 0;
        border: 0; background: none; color: var(--gold); cursor: pointer; line-height: 0; }
.star > svg, .star-inline > svg { width: 100%; height: 100%; display: block; }
.star-inline { display: inline-block; vertical-align: -.12em; width: 1em; height: 1em; color: var(--gold); line-height: 0; }
.seat .nm .star { width: .95em; height: .95em; }
.toast .toast-link { color: var(--link); font-weight: 700; text-decoration: none; margin-left: 4px; }   /* the house blue: this one leaves the app */

/* The leaderboard, in the lobby: people and computers on one board. */
.ladder { display: grid; grid-template-columns: max-content 1fr max-content max-content max-content; gap: 6px 12px; margin-top: 8px;
          font-size: 14px; align-items: baseline; }
.ladder .h { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
.ladder .rank { color: var(--dim); font-family: var(--font-num); }
.ladder .num { font-family: var(--font-num); text-align: right; font-variant-numeric: tabular-nums; }
.ladder .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ladder .name .tag { color: var(--dim); font-size: 11px; margin-left: 6px; }
.ladder .you { color: var(--gold); font-weight: 650; }
.ladder .empty { grid-column: 1 / -1; color: var(--dim); }

/* THE HOST DRESSES THE TABLE: felt swatches and deck thumbnails, in the
   pregame block and in the table sheet (one renderer, two places). A
   non-supporter host sees them dimmed with the one-line note. */
.dressing { display: grid; gap: 10px; }
.dressing .dl { font-size: 12px; color: var(--dim); text-transform: uppercase; letter-spacing: .06em; }
.dressing .row { gap: 8px; }
.swatch { width: 34px; height: 34px; border-radius: 50%; background: var(--sw); border: 2px solid transparent;
          box-shadow: inset 0 0 0 1px rgba(0,0,0,.18); padding: 0; cursor: pointer; }
.swatch.on { border-color: var(--gold); box-shadow: 0 0 0 2px var(--panel), 0 0 0 3.5px var(--gold); }
.deckpick { width: 34px; padding: 0; background: none; border: 2px solid transparent; border-radius: 8px; cursor: pointer; }
.deckpick > svg { display: block; width: 100%; height: auto; border-radius: 6px; }
.deckpick.on { border-color: var(--gold); }
.dressing[aria-disabled="true"] .swatch, .dressing[aria-disabled="true"] .deckpick { opacity: .45; cursor: default; }
