/* frontend/squeak.css — SQUEAK (Rodent's Revenge shape; push blocks, box cats in, race for cheese).
 *
 * Its own stylesheet for the same reason Critical and Gem Duel have one: the board is a dense grid
 * with several absolute overlays, and folding that into the shared sheet makes every other game pay
 * for it. Everything here is prefixed `sq-` (memory predicely_cascade_collisions: a reskin rule that
 * is not tied to its own game leaks into the next one).
 *
 * ⭐ LOCKED LAYOUT (predicely_locked_layout_rule). Every slot that can change during play RESERVES
 * its final size up front: the status line, the moves row, the cat panel (at FOUR cats — SQ-B3 says
 * four is the normal state, not the edge), the feed (at its maximum lines) and the seat cards. The
 * mock's phone column failed exactly here — it sized the feed and the cat panel from their CONTENTS,
 * so its own "tallest state" frame came out 32px short and END TURN lost its label.
 *
 * ⛔ PHONE IS DELIBERATELY NOT FINISHED HERE. Re-round 1 (docs/design/squeak-reround-1.md) asks
 * Claude Design to re-budget the phone column at 360×740 with the feed reserved at 4 lines and the
 * cat panel at 4 rows. This sheet carries a WORKING narrow fallback so the game is never unusable,
 * but the considered phone layout lands when that answer does. Desktop is the signed-off surface.
 *
 * Colours come from the app's palette variables so the 12 board schemes and the `gm-arcade`
 * recolour reach it (SQ-M2: Arcade is the app's recolour, NOT the mock's palette; Pixel is a
 * later slice of its own).
 */

/* ⭐ THE HOUSE ACTION-BUTTON PAIR, on the SCREEN so every surface on it inherits — the board AND
 * the setup card, which is a sibling of .sq-root rather than a child of it.
 * ⛔ Taken verbatim from Gem Duel's `--gd-accent`/`--gd-accentInk` (Skewers, Thin Ice and Critical
 * all carry the same pair). ⛔ A LIGHT ACCENT WITH NEAR-BLACK INK, not a mid green with white on
 * it: `--sq-good` is #3f9d54, which reads 3.2:1 against white and fails AA at the 15px the label
 * wants. This pair is ~8:1. ⚠️ It is deliberately the same green the other games use rather than a
 * squeak-only one — the ask was that the button "fit our standard button looks", and a per-game
 * accent is how it stopped fitting.
 * ⚠️ TUNING POINT MOVED (2026-09-20, the bot slice): these two used to sit on .sq-root with the
 * feel knobs. In devtools select #squeak-game-screen for these; every other knob is still on
 * .sq-root, which is also what squeak.js's cssMs() reads. */
#squeak-game-screen {
  --sq-accent: oklch(.78 .13 152);
  --sq-accentInk: #06210f;
}

#squeak-game-screen .sq-root {
  /* ⛔ squeak.js READS --sq-c, --sq-gap and --sq-pad to place the wrap gates, the ghosts and the
   * intent bars. They are read from the computed style, never duplicated as JS constants, so the
   * two cannot drift. Change them here and the overlays follow. */
  --sq-c: 42px;                  /* one tile; the only size the board is measured in */
  --sq-gap: 2px;
  --sq-pad: 5px;                 /* the wall ring — also how wide a wrap gate is */
  --sq-rail: 268px;
  --sq-ink: var(--text, #1b1e1c);
  /* ⛔ LIGHT tints, not dark ones. The app ships a DARK theme, so the rgba(0,0,0,.06) this sheet
   * first used vanished into the background and the rail panels had no visible edge at all.
   * Critical hit the same thing and landed on the same answer (--cr-panel). */
  --sq-panel: rgba(255, 255, 255, .04);
  --sq-line: rgba(255, 255, 255, .13);
  --sq-sunk: rgba(255, 255, 255, .10);
  --sq-floor: #cdc9be;
  --sq-floor2: #c4c0b4;          /* the checker, so a pushed row reads as MOVING across tiles */
  --sq-block: #907c5d;
  --sq-imm: #55504a;
  --sq-wall: #6d6659;
  --sq-cheese: #c9d13a;
  /* ⭐ BOARD COINS (2026-09-21). Drawn in CSS until the owner supplies art. The RIM carries the
   * contrast against the floor (#7a5410 vs #cdc9be / #c4c0b4 is well over 3:1); the gold face is
   * the identity, not the edge. --sq-coin is also the pickup/crush droplet colour. */
  --sq-coin: #e2b33c;
  --sq-coin-rim: #7a5410;
  --sq-good: #3f9d54;
  /* ⭐ THE HOUSE ACTION-BUTTON PAIR, taken verbatim from Gem Duel's `--gd-accent`/`--gd-accentInk`
   * (Skewers, Thin Ice and Critical all carry the same pair). ⛔ A LIGHT ACCENT WITH NEAR-BLACK
   * INK, not a mid green with white on it: `--sq-good` is #3f9d54, which reads 3.2:1 against white
   * and fails AA at the 15px the label wants. This pair is ~8:1. ⚠️ It is deliberately the same
   * green the other games use rather than a squeak-only one — the ask was that the button "fit our
   * standard button looks", and a per-game accent is how it stopped fitting. */
  /* ⛔⛔ DECLARED ON THE SCREEN, NOT HERE — see the #squeak-game-screen rule above. The setup card
   * is a SIBLING of .sq-root (the root only exists once a match starts), so a token scoped to
   * .sq-root is simply absent on the card: `var(--sq-accent)` there resolves to nothing and falls
   * back to the inherited colour, which is a no-op that looks exactly like a working style.
   * ⚠️ Caught by measuring the computed colour of the roster line, which came back IDENTICAL to
   * its body text. Same shape as Gem Duel's `.425` lesson (`--gd-accent` resolving to
   * `currentColor`), and the reason the knob rule says to measure rather than eyeball. */
  /* ⭐⭐ THE FOUR MICE, AS COLOURS. ⛔ SAMPLED FROM THE SPRITES, NOT PICKED BY EYE: each is the
   * dominant saturated colour of `squeak/mouseN.png` measured off a canvas (greys, outline and
   * eye-whites excluded). A hand-chosen "reddish" would drift from the art the moment either
   * changed, and the whole job of these is to say WHICH MOUSE — so the sprite is the source.
   * ⚠️ They are used as a WASH over the dark panel, never as a card fill. The rail's text is the
   * app's light ink; a card bright enough to show the hue properly is a card the name cannot be
   * read on. The owner raised this before it was built — "we would have to be careful of the
   * fonts… we wouldn't want too light colors on the card" — and a test now measures all eight
   * name/sub pairs rather than trusting anyone's eye, this one included. */
  --sq-mouse1: #a54cab;          /* mouse1 — purple-magenta */
  --sq-mouse2: #b43c3c;          /* mouse2 — red */
  --sq-mouse3: #6d5edc;          /* mouse3 — blue-violet */
  --sq-mouse4: #85bb58;          /* mouse4 — green */
  --sq-seat-wash: 16%;           /* how much hue the resting card carries */
  --sq-seat-wash-on: 28%;        /* …and the one whose turn it is */
  --sq-seat-ring: 32%;           /* how much darker the inset ring is than the hue */
  --sq-card-wash: 30%;           /* …and how much of it the handover card carries */
  /* ⛔ THE CARD'S RING IS LIGHTENED, THE SEAT CARDS' IS DARKENED, AND BOTH ARE MEASURED.
   * A ring is decoration, so 3:1 against what it sits on is the bar. Drawn as the raw hue it
   * came out at 2.25–2.52:1 on three of the four cards — the ring and the pill are the same
   * colour, one just diluted — so it was there and you could not see it. Lifting it toward white
   * clears the bar on all four. ⚠️ Retune this WITH --sq-card-wash: they move against each other,
   * and a deeper wash eats the separation this buys back. */
  --sq-card-ring: 26%;
  /* ⛔ THE REACHABLE-CHEESE HALO, AND IT IS NOT `--sq-cheese`. The owner asked for "a slightly
   * larger yellow circle" — but the cheese's own #c9d13a against the floor tiles (#cdc9be /
   * #c4c0b4) measures about 1.1:1, which is not a subtle ring, it is no ring. This is the same
   * hue driven dark enough to clear the 3:1 a piece of decoration has to clear against BOTH floor
   * shades, measured in a browser. Keep it yellow-family; check it if you move the floor. */
  --sq-prize-ring: #5f620e;    /* measured 3.92 / 3.56 vs the two floors, 3.91 vs the cheese */
  --sq-breathe-ms: 1.8s;         /* the reachable cheese's breathing — contextual, not ambient */
  /* ⛔ THE OPENING DEAL'S HOLD — how long the landed trap dice stay up before the card leaves.
   * NOT part of SQ_BEAT_TOTAL_MS and it may never be added to it: the deal runs once, before the
   * first turn, on the SOLO screen only, where there is no server clock to outgrow. */
  --sq-deal-ms: 900ms;
  --sq-warn: #d8a13a;
  --sq-bad: #cf3a2e;
  /* ⛔ THE REACHABLE-TRAP RING, AND IT IS NOT `--sq-bad` — SAME TRAP AS THE CHEESE, SAME ANSWER.
   * `--sq-bad` measures 2.95 / 2.69 against the two floor shades, i.e. under the 3:1 a piece of
   * decoration has to clear, and 2.95 is literally one of the three numbers the knob rule cites as
   * "looked fine, was invisible". This is the same danger hue (h 4.5, s .636) driven dark until it
   * clears, measured: 5.13 / 4.67 vs #cdc9be / #c4c0b4.
   * ⚠️ IT CANNOT CLEAR 3:1 AGAINST THE TRAP'S OWN WOOD and does not try — mousetrap.png's
   * alpha-weighted mean is #695134 and every red in this hue scores 1.0–1.4 against it, because
   * the wood sits at almost exactly the luminance the ring wants. That bar does not apply: at 104%
   * against an 84% sprite the ring is an annulus on the FLOOR with a floor gap inside it, so what
   * it has to separate from is the floor, which it does. (The prize ring quotes a cheese number
   * because a bright yellow cheese inside a dark yellow ring is one hue family; this is not.) */
  --sq-trap-ring: #8e2820;
  /* ⭐ FEEL KNOBS — live on the root ON PURPOSE, so they can be tuned in devtools without a
   * rebuild. The owner does this for a living ("it's often a very precise thing getting things to
   * feel right… something I have to test a lot") and three rounds of "still not quite right" are
   * cheaper as three number edits than as three sessions. Change them here for a new default. */
  --sq-poof-ms: 460ms;           /* the whole cartoon poof, frames 1→4 */
  /* ⛔ KEEP THIS NEAR 100%. It multiplies with sq-poof-grow's final scale, so 132% × 1.42 came out
   * at 187% of a tile — and frame 4 throws its puffs to the CORNERS of that box, which put the
   * cloud's visual mass a half-tile off the square it was supposed to be consuming. */
  --sq-poof-size: 112%;          /* how big the cloud reads against one tile */
  --sq-crouch-ms: 130ms;         /* the anticipation before something is poofed away */
  --sq-gulp-ms: 380ms;           /* the mouse's squash-and-stretch when it eats */
  --sq-bob-ms: 2.4s;             /* idle bob on board pieces (mock :30's own duration) */
  --sq-splat-ms: 900ms;          /* the cheese droplets — the arc now fills the clip, see sq-splat */
  --sq-turn-ms: 620ms;           /* the whole turn-change flourish: ring, shockwave and head pulse */
  --sq-spook-ms: 3200ms;         /* a soul leaving — long, and linear (see sq-spook) */
  /* ⚠️ NOT independently tunable: it must stay SQ_FLY_MS + SQ_SETTLE_MS, because sq-squash-x/y
   * put the impact at a fixed 53% of it. Changing travel speed means changing both. */
  --sq-squash-ms: 380ms;
  /* ⭐⭐ THE HANDOVER CARD'S WHOLE LIFE, AND IT IS FREE NOW. It used to be 580ms and every one of
   * those milliseconds was a frozen board, because one timer hid the card AND released the lock —
   * so "let me read it" and "don't make me wait" were the same dial pulling opposite ways. The
   * board is given back after --sq-handover-lock-ms and the card fades on alone, over the next
   * player's first moves. ⚖️ TURN THIS ONE FREELY: it costs the table nothing and is not in the
   * MP beat. --sq-handover-lock-ms is the one that is expensive. */
  --sq-turncard-ms: 2200ms;       /* 1100 → 2400 → 2200 on 2026-09-22 ("barely read"); a tap or a bot's push closes it */
  --sq-handover-fade-ms: 420ms;
  --sq-home-ms: 460ms;            /* the handover card's exit INTO its mouse (squeak.js aimHome) */  /* the tail of that life: how long the leaving takes */
  /* ⭐ HOW LONG A BOT APPEARS TO THINK between its own pushes (squeak.js botThinkMs).
   * ⛔ THE JS FLOORS THIS AT 420ms — one push's flight plus settle plus air — and the floor wins
   * however low this is set, because a bot that pushes again before its last clone has landed
   * repaints the board out from under a flier. Tune it UPWARD; downward stops at the floor. */
  --sq-bot-think-ms: 620ms;
  /* ⭐ HOW LONG THE WINNING BOARD STAYS UP, stinger showing, before the solo end screen (squeak.js
   * sqEndBeat). Counted from the winning push, so it must outlast that push's own landing and gulp;
   * the JS floors it there for the same reason the bot knob is floored. */
  --sq-end-ms: 1600ms;
  display: flex;
  flex-direction: column;
  gap: 10px;
  align-items: center;
  font-variant-numeric: tabular-nums;
  /* ⛔ THE ROOT MUST HUG ITS CONTENT. Measured 2026-09-20: without this the root took the shell's
   * width (592px) while .sq-body was 774px, so the rail hung 91px off the left of the root and was
   * clipped — the seat names read "yer 1", "yer 2". BoardFit scales .sq-root, so the root has to BE
   * the content's size or it scales the wrong box. */
  width: max-content;
  margin: 0 auto;
}
/* And the shell must not cap it. The app's .wrap has a max-width and side padding meant for text
 * columns; Critical overrides the same two for the same reason. */
#squeak-game-screen .wrap { max-width: none; padding-left: 0; padding-right: 0; }

/* ── the bar above the board ─────────────────────────────────────────────── */
#squeak-game-screen .sq-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  min-height: 34px;              /* reserved: the status text swaps constantly */
  padding: 0 4px;
}
#squeak-game-screen .sq-round { font-weight: 700; letter-spacing: .02em; }
#squeak-game-screen .sq-status { opacity: .85; text-align: right; flex: 1; min-height: 1.2em; }

/* ⭐ THE CAT-STEP CHIP. SQ-B1: the mock TYPED this and got it wrong in three of five frames, so it
 * is computed from the schedule and never written by hand. `data-soon="1"` is the imminent state —
 * the cats move the moment this turn ends. */
#squeak-game-screen .sq-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 10px;
  border-radius: 999px;
  font-size: .8rem;
  font-weight: 700;
  letter-spacing: .03em;
  text-transform: uppercase;
  background: var(--sq-panel);
  border: 1px solid var(--sq-line);
  white-space: nowrap;
}
#squeak-game-screen .sq-chip[data-soon="1"] { background: var(--sq-bad); color: #fff; border-color: transparent; }

/* ⭐ THE TURN CLOCK — ONLINE ONLY (⚖️ SQM2: 45s for the WHOLE turn, not for each of its four
 * pushes). It is a PICTURE of the server's deadline; nothing here decides anything.
 * ⛔ IT DECLARES ITS OWN WIDTH. Gem Duel's clock shipped TWO PIXELS ACROSS because it was dropped
 * into a container somebody else had laid out and every unit test still passed — .sq-bar is a flex
 * row whose .sq-status takes flex:1, so without flex:none + a width this would be squeezed to its
 * content. Measured in a browser, not assumed. */
#squeak-game-screen .sq-clock {
  display: none;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
  width: 132px;
}
#squeak-game-screen .sq-clock[data-on="1"] { display: inline-flex; }
#squeak-game-screen .sq-clocktrack {
  flex: 1 1 auto;
  height: 6px;
  border-radius: 999px;
  background: rgba(255, 255, 255, .10);
  overflow: hidden;
}
#squeak-game-screen .sq-clockfill {
  display: block;
  height: 100%;
  width: 100%;
  border-radius: 999px;
  background: var(--sq-good);
  transition: width .25s linear;
}
#squeak-game-screen .sq-clocknum {
  flex: 0 0 34px;
  text-align: right;
  font-size: .78rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  opacity: .8;
}
/* Yours, and running out. */
#squeak-game-screen .sq-clock[data-mine="1"] .sq-clocknum { opacity: 1; }
#squeak-game-screen .sq-clock[data-low="1"] .sq-clockfill { background: var(--sq-bad); }
#squeak-game-screen .sq-clock[data-low="1"][data-mine="1"] .sq-clocknum { color: var(--sq-bad); }
#squeak-game-screen .sq-clock[data-run="0"] .sq-clockfill { width: 100%; opacity: .35; }
#squeak-game-screen .sq-clock[data-run="0"] .sq-clocknum { visibility: hidden; }
/* A finished or forfeited seat cannot act, and a dead button must look dead. */
#squeak-game-screen .sq-endbtn:disabled { opacity: .45; cursor: not-allowed; }

/* ── body: rail beside the board ─────────────────────────────────────────── */
#squeak-game-screen .sq-body { display: flex; gap: 14px; align-items: flex-start; }
#squeak-game-screen .sq-rail {
  width: var(--sq-rail);
  flex: 0 0 var(--sq-rail);
  display: flex;
  flex-direction: column;
  gap: 10px;
}

/* ── seat cards ──────────────────────────────────────────────────────────── */
#squeak-game-screen .sq-seats { display: flex; flex-direction: column; gap: 6px; }
#squeak-game-screen .sq-seat {
  display: grid;
  /* ⛔⛔ minmax(0, 1fr), NOT 1fr — AND THE min-width: 0 ON .sq-seatwho AS WELL. It takes both,
   * and with only one of them `.sq-seatnm`'s ellipsis is decoration that can never fire.
   * `1fr` is shorthand for `minmax(auto, 1fr)`, so the TRACK refuses to go under its content's
   * width; `min-width` on the ITEM defaults to `auto` and refuses the same thing separately.
   * Measured: a 22-character online name rendered 199px wide inside a 177px card and simply
   * overflowed — `scrollWidth > clientWidth`, no ellipsis, at a width where four such cards fit
   * on a phone. ⚠️ This was ALWAYS wrong, not a two-up regression: the rail is a fixed 268px, so
   * a long enough name would have pushed out of it on the desktop too. Pass-and-play names are
   * "Player N" and nothing ever pushed on it, which is exactly how a latent layout bug survives
   * a whole build — the only names it was ever tested with were the short ones it generates. */
  grid-template-columns: 30px minmax(0, 1fr) auto;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  border-radius: 10px;
  background: var(--sq-panel);
  border: 2px solid var(--sq-line);
  min-height: 42px;              /* reserved, so the sit-out line cannot resize the rail */
  transition: background .18s ease, border-color .18s ease, opacity .18s ease;
}

/* ⭐⭐ EACH CARD WEARS ITS OWN MOUSE. The seat cards were four identical grey panels telling you
 * apart by a 30px face and a name, and the board's mice are strongly coloured — so the rail was
 * throwing away identity the board already had. `--sq-hue` is set once per seat and everything
 * below is derived from it, so retinting all four is ONE number each.
 * ⛔ THE RING IS AN INSET `outline`, NOT A SECOND BORDER — the owner asked for a border that
 * "goes into the card, doesn't grow its size", and that is exactly the difference: a real border
 * is in the box model and would push `min-height: 42px` to 46 and the rail with it, which is
 * .sq-root's height, which is BoardFit's input. An outline costs zero layout.
 * ⛔⛔ AND IT IS NOT A `box-shadow`, WHICH IS WHERE IT WAS FIRST BUILT AND WHERE IT DISAPPEARED.
 * The seat card whose turn it is runs `sq-ring` (mock :616), and that pulse ANIMATES box-shadow —
 * an animation replaces the property outright, so the inset ring was wiped on the one card the
 * eye actually lands on. Found by reading the computed value, which still said the gold pulse.
 * ⚖️ Two independent signals must not share a property. `outline` is the ring's own channel.
 * ⛔ A WASH, NOT A FILL. `color-mix(… , transparent)` keeps the card dark under the app's light
 * ink; the hue reads as identity without ever competing with the name for contrast. */
#squeak-game-screen .sq-seat[data-seat="0"] { --sq-hue: var(--sq-mouse1); }
#squeak-game-screen .sq-seat[data-seat="1"] { --sq-hue: var(--sq-mouse2); }
#squeak-game-screen .sq-seat[data-seat="2"] { --sq-hue: var(--sq-mouse3); }
#squeak-game-screen .sq-seat[data-seat="3"] { --sq-hue: var(--sq-mouse4); }
#squeak-game-screen .sq-seat[data-seat] {
  background: color-mix(in oklch, var(--sq-hue) var(--sq-seat-wash), transparent);
  border-color: color-mix(in oklch, var(--sq-hue) 55%, transparent);
  outline: 2px solid color-mix(in oklch, var(--sq-hue), #000 var(--sq-seat-ring));
  outline-offset: -2px;
}
/* ⛔⛔ THE ACTIVE CARD KEEPS BOTH SIGNALS, AND THE ORDER OF THESE TWO RULES IS THE BUG WAITING.
 * This used to paint a flat `rgba(255,255,255,.11)` over the card, which — now that the card has
 * a hue — would have WIPED the identity off the one card the eye actually lands on. Colour says
 * WHICH MOUSE, the light ring says WHOSE TURN; they are different questions and must not share a
 * channel. So "on" deepens the wash rather than replacing it. ⚠️ `[data-seat][data-on]` outranks
 * the `[data-seat]` tint above on specificity, which is why the tint cannot simply be written
 * later in the file and left to source order. */
#squeak-game-screen .sq-seat[data-seat][data-on="1"] {
  border-color: var(--sq-ink);
  background: color-mix(in oklch, var(--sq-hue) var(--sq-seat-wash-on), transparent);
}
#squeak-game-screen .sq-seat[data-out="1"] { opacity: .5; }
#squeak-game-screen .sq-seatface { width: 30px; height: 30px; position: relative; }
/* ⛔ CENTRED RATHER THAN `inset: 0`, and pixel-identical to it (50% − 100%/2 = 0 on every side).
 * Same reason as .sq-art: the mock's sq-bob is written against a translate(-50%,-52%) origin, so
 * the box it animates has to have one, or the curve's first frame throws the face up and left. */
#squeak-game-screen .sq-seatface img {
  position: absolute;
  left: 50%; top: 50%;
  width: 100%; height: 100%;
  transform: translate(-50%, -50%);
  object-fit: contain;
}
/* ⛔ min-width: 0 is the ITEM half of the pair the seat grid needs — see the note on
 * grid-template-columns above. Without it the ellipsis below cannot fire at any width. */
#squeak-game-screen .sq-seatwho { min-width: 0; }
/* ⭐ THE BOARD-COIN BADGE on the seat face. Absolute, so a pickup never resizes the card (locked
 * layout); visibility-hidden (not display:none) at 0 so fxEat can still MEASURE it as the target. */
#squeak-game-screen .sq-seatcoin {
  position: absolute;
  right: -6px; bottom: -5px;
  width: 16px; height: 16px;
  z-index: 2;
  visibility: hidden;
  pointer-events: none;
}
#squeak-game-screen .sq-seatcoin:not([data-n="0"]) { visibility: visible; }
#squeak-game-screen .sq-seatcoin b {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: 9px; font-weight: 800; line-height: 1;
  color: #3b2a05;
}
#squeak-game-screen .sq-seatcoin[data-got="1"] { animation: sq-tally .42s cubic-bezier(.3, 1.5, .5, 1); }
/* ⛔⛔ display: block, AND THIS IS THE ONE THAT ACTUALLY MADE THE ELLIPSIS WORK. `.sq-seatnm` is a
 * <span>, and `overflow` and `text-overflow` DO NOTHING on an inline box — so the truncation this
 * rule has declared since the game was built has never once been capable of firing. Proved by
 * measuring: with the grid track correctly clamped to 61px, the span still reported 199px of text
 * and spilled straight out of the card, because an inline box is sized by its content and simply
 * ignores the clamp. ⚠️ `.sq-seatsub` was never affected — it says `display: block` explicitly.
 * ⚖️ THE THREE PIECES ARE ALL REQUIRED and each is invisible on its own: `minmax(0, 1fr)` lets
 * the TRACK shrink, `min-width: 0` lets the ITEM shrink, and `display: block` lets the TEXT be
 * clipped. Two out of three still overflows, which is how this survived: nothing ever pushed on
 * it, because pass-and-play only ever generates "Player N". */
#squeak-game-screen .sq-seatnm { display: block; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ⭐ THE SEAT MARQUEE (squeak.js sqMarquee): two copies, each ending in " · ", slid by exactly one copy,
 * so the loop is seamless. A short pause before it starts, so the start of the text is read first. */
#squeak-game-screen .sq-marq-on { text-overflow: clip; overflow: hidden; }
#squeak-game-screen .sq-marq { display: inline-flex; white-space: nowrap; animation: sq-marq var(--sq-marq-dur, 8s) linear 1.2s infinite; }
@keyframes sq-marq { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@media (prefers-reduced-motion: reduce) { #squeak-game-screen .sq-marq { animation: none; } }
#squeak-game-screen .sq-seatsub {
  display: block;
  font-size: .72rem;
  font-weight: 400;
  opacity: .7;
  min-height: 1em;               /* reserved: "sits out" / "boxed in" appear and vanish */
}
#squeak-game-screen .sq-cheesecount { display: flex; gap: 2px; align-items: center; }
#squeak-game-screen .sq-pip {
  width: 13px; height: 13px; border-radius: 3px;
  background: var(--sq-sunk);
}
#squeak-game-screen .sq-pip[data-on="1"] { background: var(--sq-cheese); box-shadow: 0 0 0 1px rgba(0, 0, 0, .35) inset; }

/* ── the moves row ───────────────────────────────────────────────────────── */
#squeak-game-screen .sq-moves {
  display: flex; align-items: center; gap: 8px;
  padding: 8px 10px; border-radius: 10px;
  border: 1px solid var(--sq-line);
  background: var(--sq-panel);
  min-height: 44px;              /* reserved */
}
#squeak-game-screen .sq-movelabel { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; opacity: .7; }
#squeak-game-screen .sq-movedots { display: flex; gap: 5px; margin-left: auto; }
#squeak-game-screen .sq-movedot {
  width: 15px; height: 15px; border-radius: 50%;
  background: var(--sq-sunk);
  transition: background .15s ease, transform .15s ease;
}
#squeak-game-screen .sq-movedot[data-on="1"] { background: var(--sq-good); }
#squeak-game-screen .sq-movedot[data-spend="1"] { transform: scale(.7); background: rgba(255, 255, 255, .22); }

/* ── the cat panel — RESERVED AT FOUR ROWS (SQ-B3 / SQ-R3) ───────────────── */
#squeak-game-screen .sq-cats {
  padding: 8px 10px; border-radius: 10px;
  border: 1px solid var(--sq-line);
  background: var(--sq-panel);
}
#squeak-game-screen .sq-catshead {
  font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; opacity: .7;
  display: flex; justify-content: space-between; gap: 8px; margin-bottom: 6px;
}
/* ⛔ FOUR rows always, whatever cats.length is — 2 cats at 2p, 4 above it, and cats come and go
 * DURING a turn as they are boxed in. An empty row keeps its height. */
#squeak-game-screen .sq-catrow {
  display: grid; grid-template-columns: 22px 1fr auto;
  align-items: center; gap: 8px;
  height: 26px;
  font-size: .82rem;
}
#squeak-game-screen .sq-catrow[data-empty="1"] { opacity: 0; }
#squeak-game-screen .sq-catface { width: 22px; height: 22px; position: relative; }
#squeak-game-screen .sq-catface img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
#squeak-game-screen .sq-catarrow { font-weight: 700; font-size: 1rem; }
#squeak-game-screen .sq-cattarget { opacity: .75; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A cat that is about to become cheese: the scoring action, so it gets the loudest row. */
#squeak-game-screen .sq-catrow[data-boxed="1"] { color: var(--sq-good); font-weight: 700; }

/* ── the feed — RESERVED AT ITS MAXIMUM LINES ────────────────────────────── */
#squeak-game-screen .sq-feed {
  padding: 8px 10px; border-radius: 10px;
  border: 1px solid var(--sq-line);
  background: var(--sq-panel);
  font-size: .82rem; line-height: 1.32;
  display: flex; flex-direction: column; gap: 2px;
  overflow: hidden;
}
#squeak-game-screen .sq-feedline {
  height: 1.32em;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  opacity: .85;
}
#squeak-game-screen .sq-feedline:first-child { opacity: 1; font-weight: 600; }
#squeak-game-screen .sq-feedline[data-empty="1"] { opacity: 0; }

/* ⭐ END TURN, IN THE HOUSE SHAPE. ⛔⛔ IT USED TO BE A RAW OS BUTTON ON THE LIVE SITE: the markup
 * said `class="btn sq-endbtn"` and **`.btn` is defined nowhere in the frontend** — squeak.js was
 * the only file in the whole app that used the name, so the only rules reaching it were the two
 * below and the browser's own `2px outset` default. The owner read it as "plain, boring, doesn't
 * really fit our standard button looks (from other games)", which is exactly what it was.
 * ⚠️ There is NO shared button class to reach for — every game styles its own action button
 * (`gd-start`, `cs-btn`, `sk-start`), so the house look is a PATTERN, not a class: full width, a
 * 12px radius, no border, the accent pair, and a press that moves 2px. Copied from `gd-start`.
 * ⛔ min-height STAYS 44px. The rail's height is `.sq-body`'s height is `.sq-root`'s height, and
 * `.sq-root` must keep measuring 774×631 because that is BoardFit's input — so this button cannot
 * grow to Gem Duel's 46px without moving a number a test pins. 44 is also the touch-target floor. */
#squeak-game-screen .sq-endbtn,
#squeak-game-screen .sq-setup-go {
  width: 100%;
  border: 0;
  border-radius: 12px;
  background: var(--sq-accent);
  color: var(--sq-accentInk);
  font-size: 15px;
  font-weight: 800;
  letter-spacing: .06em;
  cursor: pointer;
  transition: filter .15s, transform .1s;
}
#squeak-game-screen .sq-endbtn:hover:not(:disabled),
#squeak-game-screen .sq-setup-go:hover { filter: brightness(1.08); }
#squeak-game-screen .sq-endbtn:active:not(:disabled),
#squeak-game-screen .sq-setup-go:active { transform: translateY(2px); filter: brightness(.92); }
/* ⛔ 44 in the rail, 46 on the setup card. NOT a stray inconsistency: the rail's height is
 * `.sq-body`'s is `.sq-root`'s, and `.sq-root` must keep measuring 774×631 because that is
 * BoardFit's input — so the in-game button cannot take Gem Duel's 46px without moving a number a
 * test pins. The setup card is not inside the root and is free to match the house height exactly.
 * 44 is also the touch-target floor, so the rail loses nothing by staying there. */
#squeak-game-screen .sq-endbtn { min-height: 44px; }

/* ── the board ───────────────────────────────────────────────────────────── */
#squeak-game-screen .sq-boardwrap { position: relative; }
#squeak-game-screen .sq-grid {
  display: grid;
  grid-template-columns: repeat(var(--sq-n), var(--sq-c));
  grid-auto-rows: var(--sq-c);
  gap: var(--sq-gap);
  padding: var(--sq-pad);
  background: var(--sq-wall);
  border-radius: 8px;
  position: relative;
}
#squeak-game-screen .sq-tile {
  position: relative;
  border-radius: 4px;
  background: var(--sq-floor);
  border: 0;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: default;
}
#squeak-game-screen .sq-tile[data-checker="1"] { background: var(--sq-floor2); }
#squeak-game-screen .sq-tile[data-t="3"] {       /* pushable block */
  background: var(--sq-block);
  box-shadow: inset 0 -3px 0 rgba(0, 0, 0, .28), inset 0 2px 0 rgba(255, 255, 255, .22);
}
#squeak-game-screen .sq-tile[data-t="2"] {       /* fixed block — visibly NOT the same thing */
  background: var(--sq-imm);
  box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .14);
}
/* ⛔⛔ CENTRED, NOT `inset: 8%` — AND THE TWO ARE THE SAME BOX ON PURPOSE.
 * 50% − 84%/2 = 8% on every side, so this renders pixel-identically to the `inset: 8%` it replaced.
 * The reason for the rewrite: EVERY transform keyframe the mock specifies is written against a
 * `translate(-50%,-50%)` origin (sq-pop, sq-hop, sq-squish, sq-spook, sq-bob, sq-splat all start
 * there). Pasted onto an `inset`-positioned box they would each yank the piece half a tile up and
 * left on their first frame. Moving the box model to match the spec lets the eleven curves be
 * lifted VERBATIM — which is the whole point of this slice — instead of eleven hand-rewrites that
 * would each be a chance to get the feel wrong.
 * ⚠️ Consequence: any transform on `.sq-art` must now CARRY the centring. A bare `translateY(-9%)`
 * silently drops it and the piece jumps. That is why sq-bob/sq-hop/sq-spook all restate it. */
#squeak-game-screen .sq-art {
  position: absolute;
  left: 50%; top: 50%;
  width: 84%; height: 84%;
  transform: translate(-50%, -50%);
  pointer-events: none;
}
#squeak-game-screen .sq-art img { width: 100%; height: 100%; object-fit: contain; display: block; }

/* ⭐ THE SPRITE-WEIGHT CORRECTION — item 7, and the owner's hunch was half right.
 * Measured alpha bounding boxes (all ten sprites are 256×256 RGBA): the mice fill 74.6% of their
 * canvas where cheese fills 93.4% and ghost1 93%. So a mouse in the same CSS box as a cheese IS
 * genuinely ~20% smaller as a *subject* — which is what "the art feels slightly small" was seeing.
 * ⛔ A uniform trim would not have fixed it: the tightest margin on any sprite is 8px, so trimming
 * everything by a safe common amount buys +6.7% — invisible. The padding is not uniform; that is
 * the finding. Correcting per sprite in CSS costs no art work and is reversible.
 * ⛔ IT GOES ON THE `img`, NEVER THE WRAPPER: the wrapper's transform belongs to the animations
 * above, and a scale written there would be wiped the moment sq-bob or sq-hop took it over.
 * ⚠️ The four mice are currently consistent with each other to within 2px (191×172 / 191×172 /
 * 191×173 / 189×172). If these are ever re-exported, that has to hold or they will start looking
 * like four different sizes on one board. Re-measure with scratchpad/alpha.cjs. */
#squeak-game-screen .sq-mouse img { transform: scale(1.2); }
#squeak-game-screen .sq-cat img   { transform: scale(1.12); }
#squeak-game-screen .sq-trapart img { transform: scale(1.06); }

/* the mouse a seat owns; the drawn faces carry identity, colour only reinforces it */
#squeak-game-screen .sq-mouse { z-index: 3; }
#squeak-game-screen .sq-mouse[data-me="1"]::after {
  content: ''; position: absolute; inset: -12%;
  border-radius: 50%;
  box-shadow: 0 0 0 2px var(--sq-ink);
  opacity: .55;
}
#squeak-game-screen .sq-cat { z-index: 3; }
#squeak-game-screen .sq-cheeseart { z-index: 2; }
/* ⭐ THE BOARD COIN — smaller than a sprite (62% vs 84%) so it reads as a pickup, not a piece. */
#squeak-game-screen .sq-coinart { z-index: 2; width: 62%; height: 62%; }
#squeak-game-screen .sq-coin {
  position: relative;
  display: block;
  width: 100%; height: 100%;
  border-radius: 50%;
  background: radial-gradient(circle at 34% 30%, #fff4c2 0 10%, var(--sq-coin) 42%, #b8861d 100%);
  box-shadow: inset 0 0 0 2px var(--sq-coin-rim), 0 1px 2px rgba(0, 0, 0, .35);
}
#squeak-game-screen .sq-coin::after {
  content: '';
  position: absolute; inset: 22%;
  border-radius: 50%;
  border: 2px solid rgba(122, 84, 16, .5);
}
/* the coin inside a flying clone (.sq-fly-press / .sq-fly-bop are 84% boxes): back to 62% of a tile */
#squeak-game-screen .sq-coinfly { position: absolute; inset: 13%; }
/* ⛔ THE GHOST NO LONGER STAYS ON THE TILE AT ALL — two rounds of owner feedback ended here.
 * Round 1: the ghosts "just stay" (a sq-haunt loop that drew the eye forever to news already
 * read) → replaced by a one-shot rise plus a faint still sprite. Round 3: they "still linger
 * around where they died" → so the sprite is gone too, and what marks the spot until that mouse
 * respawns is .sq-mark, a small stain. The ghost is now purely the EVENT (fxOneDeath), which is
 * what the owner meant both times. The .431 requirement — that a death tile stays marked — is
 * still met, just by something that does not look like a creature sitting there. */
#squeak-game-screen .sq-trapart { z-index: 1; opacity: .95; }

/* ── legal-move markers ──────────────────────────────────────────────────── */
/* SQ-B5: the mock's frames mark these inconsistently. The build COMPUTES them, so every legal
 * neighbour is marked and nothing else is. */
/* ⛔⛔ `[data-legal]`, NOT `[data-legal="1"]` — AND THIS WAS A LIVE BUG, NOT A TIDY-UP.
 * The push marker below only ever overrode width/height/background, and relied on inheriting
 * `content`, `position` and the centring from this rule. But a tile marked data-legal="2" does not
 * match data-legal="1", so it inherited NOTHING: with no `content`, a ::before generates no box at
 * all, and the marker for "you can push this block" HAS NEVER BEEN DRAWN. Measured in a browser —
 * computed content came back `none` on a real 3-block push.
 * ⭐ That is the true cause of the owner's item 6 ("a white dot shows a walkable tile; nothing
 * shows that a block is pushable"). Nothing showed it because nothing was rendered — the marker
 * was not too subtle, it was absent. Every unit test still passed, because nothing asserts that a
 * pseudo-element has a box. */
#squeak-game-screen .sq-tile[data-legal] { cursor: pointer; }
#squeak-game-screen .sq-tile[data-legal]::before {
  content: '';
  position: absolute;
  left: 50%; top: 50%;
  width: 34%; height: 34%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: rgba(255, 255, 255, .5);
  box-shadow: 0 0 0 2px rgba(0, 0, 0, .35);
  pointer-events: none;
  z-index: 4;
}
/* ⭐⭐ A LEGAL STEP ONTO A CHEESE IS NOT A DOT, IT IS A HALO — and the dot could not simply be
 * moved under the sprite, which is the whole reason this needed a second shape.
 * The owner, with a screenshot of a mouse with a clear run at a cheese: "the 'dot' that appears
 * on the ground tiles is drawn over the cheese… maybe a slightly larger yellow circle, underneath
 * the cheese?" ⛔ THE ARITHMETIC IS WHY "SLIGHTLY LARGER" IS NOT A PREFERENCE: a sprite is 84% of
 * its tile and the dot is 34%, so a dot dropped below the sprite would not be tidier, it would be
 * GONE. A marker may only go under a sprite if it is bigger than one — so on a cheese tile the
 * marker stops being a dot at 34% over the art and becomes a ring at 104% beneath it.
 * ⛔ NO JS KNOWS ABOUT THIS. Both facts are already on the tile — `data-legal` from the move
 * preview and `data-t` from the grid (4 = SQ_CHEESE) — so the whole behaviour is a selector.
 * ⚠️ The ring colour is NOT the cheese's own `--sq-cheese`: measured against the floor tiles that
 * is about 1.1:1, i.e. invisible. It is the cheese hue driven dark enough to clear the 3:1 bar a
 * piece of decoration has to clear, which is checked in a browser rather than eyeballed. */
/* ⭐ A REACHABLE BOARD COIN (data-t 6) is the same sentence as a reachable cheese — "worth it" —
 * so it takes the same ring beneath and the same breathing. The ring (104%) is bigger than the coin
 * (62%), so it may go under the art; the 34% dot over it would deface it (the .444 arithmetic). */
#squeak-game-screen .sq-tile[data-legal="1"][data-t="6"]::before,
#squeak-game-screen .sq-tile[data-legal="1"][data-t="4"]::before {
  width: 104%; height: 104%;
  background: none;
  border: 3px solid var(--sq-prize-ring);
  box-shadow: none;
  z-index: 0;
}
/* ⭐ …AND THE CHEESE BREATHES WHILE IT IS REACHABLE. Two channels, which is the point: the ring
 * says "you can get there", the breathing says "and it is worth it". It is CONTEXTUAL — it starts
 * when the cheese becomes a legal destination for whoever is up and stops when it is not, so it
 * is feedback rather than ambient decoration.
 * ⛔ IT RESTATES translate(-50%,-50%) IN EVERY FRAME, because `.sq-art` is centred with it and a
 * transform that dropped the centring would fling the cheese to the tile's corner — the same trap
 * `sq-bob-board`, `sq-hop` and `sq-spook` all restate it for.
 * ⚖️ TWO KEYFRAMES AND ONE CURVE (rule 4), and a genuine there-and-back, which is the documented
 * exception that earns a middle stop.
 * ✅ AND IT CANNOT FIGHT THE CRUSH, which was the hazard this was queued with: the crush is
 * `.sq-fly-squish`, a CLONE on the FX overlay, and never touches `.sq-cheeseart`. Checked before
 * building rather than after. */
@keyframes sq-breathe {
  0%, 100% { transform: translate(-50%, -50%) scale(1) }
  50%      { transform: translate(-50%, -50%) scale(1.09) }
}
#squeak-game-screen .sq-tile[data-legal="1"][data-t="6"] .sq-coinart,
#squeak-game-screen .sq-tile[data-legal="1"][data-t="4"] .sq-cheeseart {
  animation: sq-breathe var(--sq-breathe-ms) ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-tile[data-legal="1"][data-t="6"] .sq-coinart,
  #squeak-game-screen .sq-tile[data-legal="1"][data-t="4"] .sq-cheeseart { animation: none; }
}

/* ⭐⭐ …AND A REACHABLE TRAP GETS THE SAME TREATMENT, BECAUSE IT IS THE SAME BUG.
 * The owner, on the cheese fix: "now I noticed that same issue with mousetraps — the ground dot is
 * drawn over the mousetrap." ⭐ THE TRAP IS THE LAST CASE, and that closes the open question this
 * file asked ("whether any OTHER marker/sprite pair still overlaps badly"): the only tiles that can
 * carry `data-legal="1"` are SQ_E, SQ_CHEESE and SQ_TRAP — a push's `pv.to` is always the block's
 * tile, so `data-legal="2"` can never land on a sprite — and empty tiles have no sprite to deface.
 * The same 34%-dot-over-an-84%-sprite arithmetic forces the same shape: a ring beneath, not a dot
 * moved down.
 * ⛔⛔ BUT THE COLOUR CANNOT BE THE ONLY CHANNEL, AND THAT IS A MEASUREMENT, NOT A SCRUPLE.
 * The prize ring and this one are the SAME SHAPE AT THE SAME SIZE, so hue is all that separates
 * "free cheese" from "this ends your turn". Simulated: under deuteranopia --sq-prize-ring renders
 * #61610f and a red trap ring renders #626215 — **1.02:1 apart**, i.e. the two rings are the same
 * colour, and the most dangerous tile on the board would read as the most rewarding one. So the
 * trap ring is DASHED. The dash survives every dichromacy, it is the hazard idiom already, and it
 * costs one declaration. ⚠️ Re-run the sim if either ring's colour moves; they were 1.02 apart.
 * ⛔ AND IT MUST NOT BREATHE. The cheese breathes because motion says "come here" — which is the
 * exact sentence a trap must not say. The hover line already carries the words ("a trap — turn
 * ends", tone `bad`); this only has to be legible and never inviting. */
#squeak-game-screen .sq-tile[data-legal="1"][data-t="5"]::before {
  width: 104%; height: 104%;
  background: none;
  border: 3px dashed var(--sq-trap-ring);
  box-shadow: none;
  z-index: 0;
}

/* a legal PUSH reads differently from a legal step — an arrow, not a dot */
#squeak-game-screen .sq-tile[data-legal="2"]::before {
  width: 46%; height: 46%;
  border-radius: 3px;
  background: rgba(255, 255, 255, .34);
}
#squeak-game-screen .sq-tile[data-armed="1"] { outline: 3px solid var(--sq-ink); outline-offset: -3px; }

/* ── ⭐ THE PUSH PREVIEW ─────────────────────────────────────────────────── */
/* The ghost: where a block WILL be. Drawn on the destination tile, under the pieces. */
#squeak-game-screen .sq-ghost {
  position: absolute; inset: 10%;
  border-radius: 4px;
  border: 2px dashed rgba(0, 0, 0, .55);
  background: rgba(144, 124, 93, .38);
  pointer-events: none;
  z-index: 5;
}
#squeak-game-screen .sq-ghost[data-kind="trap"] { border-style: dotted; background: rgba(160, 110, 60, .4); }
/* the tile your mouse ends on */
#squeak-game-screen .sq-dest {
  position: absolute; inset: 6%;
  border-radius: 50%;
  border: 2px solid var(--sq-ink);
  pointer-events: none;
  z-index: 5;
}
/* the cheese this push destroys — the owner's favourite moment, so it is unmistakable */
#squeak-game-screen .sq-crush {
  position: absolute; inset: -4%;
  border-radius: 50%;
  border: 3px solid var(--sq-bad);
  box-shadow: 0 0 10px rgba(207, 58, 46, .7);
  pointer-events: none;
  z-index: 6;
}
/* a cat this push boxes in — the SCORING action */
#squeak-game-screen .sq-boxes {
  position: absolute; inset: -4%;
  border-radius: 6px;
  border: 3px solid var(--sq-good);
  box-shadow: 0 0 12px rgba(63, 157, 84, .75);
  pointer-events: none;
  z-index: 6;
}

/* ⭐ THE WRAP GATES — a glowing bar at BOTH walls of the previewed row, so it is obvious that the
 * row leaves one edge and arrives at the other. The single best idea in the mock; lifted. */
#squeak-game-screen .sq-gate {
  position: absolute;
  background: var(--sq-warn);
  box-shadow: 0 0 10px rgba(216, 161, 58, .9);
  border-radius: 2px;
  pointer-events: none;
  z-index: 7;
}

/* The in/out pills. ⛔ They sit OVER live tiles, so they must never eat a tap (build note). */
#squeak-game-screen .sq-pill {
  position: absolute;
  transform: translate(-50%, -50%);
  padding: 3px 9px;
  border-radius: 999px;
  --sq-pill-bg: var(--sq-ink);   /* ⭐ a var, so the tail (::after) can be the same colour as the pill */
  background: var(--sq-pill-bg);
  color: var(--bg, #f2efe6);
  font-size: .72rem;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
  white-space: nowrap;
  pointer-events: none;      /* ⛔ load-bearing */
  z-index: 8;
}
#squeak-game-screen .sq-pill[data-tone="bad"] { --sq-pill-bg: var(--sq-bad); color: #fff; }
#squeak-game-screen .sq-pill[data-tone="good"] { --sq-pill-bg: var(--sq-good); color: #fff; }

/* ── cat intent on the board ─────────────────────────────────────────────── */
/* ⭐ The whole point of SQ-M1. When a cat step is NOT imminent the arrow is dimmed and dashed,
 * because SQ-B1 notes it goes stale after the intervening turns. */
#squeak-game-screen .sq-intent {
  position: absolute;
  inset: 18%;
  border-radius: 50%;
  border: 2px solid var(--sq-bad);
  pointer-events: none;
  z-index: 4;
}
#squeak-game-screen .sq-intent[data-soon="0"] { border-style: dashed; opacity: .45; }
/* ⛔⛔ THE LINE GOES UNDER THE PIECES, THE RING STAYS ABOVE THEM — and the difference is not
 * fussiness, it is that one of them is SMALLER than a sprite and the other is not.
 * The owner: "maybe the cat should be on top of the line rather than the line on top of the cat?"
 * They named the LINE, and that is the whole of the correct fix. A line runs from the cat's tile
 * centre to its destination's, so at z-index 4 it was drawn straight across the cat's face — the
 * marker defacing the very thing it is pointing at. Under the sprites it is hidden exactly where
 * a piece stands and visible across every gap, which is the only place it carries information.
 * ⚠️ AND THE RING MUST NOT FOLLOW IT DOWN. `.sq-intent` is `inset: 18%`, i.e. 64% of a tile, and
 * a sprite is 84% — so a ring moved below the sprites would be COMPLETELY COVERED rather than
 * merely tidied. That is not hypothetical: `.sq-intent[data-kind="catch"]` lands on a MOUSE's
 * tile (it is the warning that a cat is about to take you, and the one thing on the board that
 * animates to be noticed), so burying it would hide the most important mark in the game.
 * ⚖️ A MARKER MAY ONLY MOVE UNDER THE SPRITES IF IT IS BIGGER THAN ONE. The same arithmetic is
 * why the legal-move dot (34%) cannot simply be flipped for the cheese case in the resume brief,
 * and why the owner's instinct there — a LARGER ring underneath — is the necessary shape. */
#squeak-game-screen .sq-intentline {
  position: absolute;
  background: var(--sq-bad);
  pointer-events: none;
  z-index: 0;
  border-radius: 2px;
}
#squeak-game-screen .sq-intentline[data-soon="0"] { opacity: .4; }

/* ── the locked state ────────────────────────────────────────────────────── */
/* House rule 3, and F5 in the mock is exemplary at it: every control stays PRESENT and at FULL
 * SIZE, visibly dead, with hover removed entirely. Nothing collapses, so nothing reflows when the
 * turn comes back. */
#squeak-game-screen .sq-root[data-locked="1"] .sq-tile { cursor: default; }
#squeak-game-screen .sq-root[data-locked="1"] .sq-tile[data-legal]::before { display: none; }
/* ⚠️ `border-style: dashed` used to be the second half of this signal, and it stopped meaning
 * anything the moment the button got `border: 0` — a dashed border of zero width draws nothing.
 * Desaturating the accent is the equivalent that survives a borderless button. */
#squeak-game-screen .sq-root[data-locked="1"] .sq-endbtn {
  opacity: .45; pointer-events: none; filter: saturate(.25);
}
#squeak-game-screen .sq-root[data-locked="1"] .sq-moves { opacity: .55; }
/* ⭐ PT0922-SQ-1 — a rival's (or a bot's) move hints stay drawn but GREYED, never hidden: the owner
 * wants to see which mouse is moving without it looking like theirs to move. data-mine is written by
 * paint() beside data-locked. The filter greys every marker shape at once (dot, arrow, prize ring,
 * dashed trap ring) without restating any of them, and a prize stops breathing "come here". */
#squeak-game-screen .sq-root[data-mine="0"] .sq-tile[data-legal] { cursor: default; }
#squeak-game-screen .sq-root[data-mine="0"] .sq-tile[data-legal]::before {
  filter: grayscale(1) brightness(.45);
  opacity: .7;
}
#squeak-game-screen .sq-root[data-mine="0"] .sq-tile[data-legal="1"][data-t="6"] .sq-coinart,
#squeak-game-screen .sq-root[data-mine="0"] .sq-tile[data-legal="1"][data-t="4"] .sq-cheeseart { animation: none; }
/* SQ-3 — a tap that arrived while my own push was still landing is QUEUED; show it was heard. */
#squeak-game-screen .sq-endbtn[data-queued="1"] { filter: brightness(.9); transform: translateY(2px); }
#squeak-game-screen .sq-endbtn { touch-action: manipulation; }

/* ── the board-cover beats ───────────────────────────────────────────────── */
#squeak-game-screen .sq-stinger {
  position: absolute;
  left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  padding: 14px 22px;
  border-radius: 14px;
  background: var(--sq-ink);
  color: var(--bg, #f2efe6);
  font-weight: 800;
  font-size: 1.1rem;
  letter-spacing: .04em;
  text-transform: uppercase;
  pointer-events: none;
  /* ⛔ ONE LINE. "The cats move" wrapped after "cats", which reads as two thoughts instead of one
   * announcement. ⚠️ The ellipsis guard is not decoration: pass-and-play names are "Player N" but
   * an online table carries real ones, and a long name in a nowrap card would otherwise push past
   * the board. The lines are kept short for the same reason. */
  white-space: nowrap;
  max-width: 92%;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: 0;
  z-index: 9;
  /* ⛔ THE LEAVING IS SLOWER THAN THE ARRIVING, and it drifts up as it goes. The card now fades
   * while the next player is ALREADY PUSHING, so a hard 200ms blink reads as the card being
   * snatched away mid-read; a slow rise reads as it getting out of the way by itself. ⚖️ TWO
   * PROPERTIES, ONE CURVE, NO KEYFRAMES — a monotonic exit gets a bezier, not more stops.
   * ⛔ The translate restates -50%,-50% because .sq-stinger is centred with it; a transform that
   * dropped the centring would fling the card to the corner on its way out. */
  transform: translate(-50%, -50%);
  transition: opacity var(--sq-handover-fade-ms) cubic-bezier(.4, 0, .9, .5),
              transform var(--sq-handover-fade-ms) cubic-bezier(.4, 0, .9, .5);
}
#squeak-game-screen .sq-stinger[data-on="0"] { transform: translate(-50%, calc(-50% - 22px)); }

/* ⭐⭐ THE OPENING DEAL CARD — the four dice that decide how many mouse traps the board gets.
 * ⛔ IT BORROWS THE STINGER'S BOX AND ITS EXIT, deliberately: the two are the same kind of thing
 * (a full-board announcement that arrives, is read, and gets out of the way), and one visual
 * language for both is one thing to keep right. Absolute + centred, so it adds nothing to the
 * 774×631 .sq-root that BoardFit scales.
 * ⚠️ z-index 10, one above .sq-stinger's 9 — they never coexist today (the deal runs before the
 * first handover card), but "never" is a thing that stops being true. */
#squeak-game-screen .sq-deal {
  position: absolute;
  left: 50%; top: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 18px 24px;
  border-radius: 14px;
  background: var(--sq-ink);
  color: var(--bg, #f2efe6);
  pointer-events: none;
  opacity: 0;
  z-index: 10;
  /* ⛔⛔ THE 92% BELONGS ON THE CARD, NOT ON THE CAPTION, AND THAT IS NOT A TIDY-UP — IT SHIPPED
   * TRUNCATED. Lifted verbatim from `.sq-stinger`, where the card IS the text and 92% resolves
   * against `.sq-boardwrap`, i.e. "never wider than the board". Put on a child of a
   * `flex-direction: column` card it means something else entirely: the card's own width is the
   * width of its widest child, so a caption capped at 92% OF THAT can never be the widest child
   * — it clamps to 92% of the dice row and ellipsises itself. Measured: "Trap rolls average 1.8
   * — 4 mouse traps" came out as "…4 mouse traps …" with room to spare on the board.
   * ⚖️ A percentage is only meaningful with its containing block named. Same shape as the token
   * that resolved to nothing on the setup card: the value was right, the box was not. */
  max-width: 92%;
  transform: translate(-50%, -50%);
  transition: opacity var(--sq-handover-fade-ms) cubic-bezier(.4, 0, .9, .5),
              transform var(--sq-handover-fade-ms) cubic-bezier(.4, 0, .9, .5);
}
/* ⛔⛔ THE ARRIVAL NEEDS ITS OWN CURVE, AND THIS FILE ALREADY SAID SO. `.sq-deal` inherits the base
 * `transition` above for BOTH directions, so without this the card entered on the EXIT's 420ms
 * `cubic-bezier(.4, 0, .9, .5)` — measured at 0.385 opacity 303ms in, still arriving while the dice
 * were already tumbling underneath it. ⭐ `.sq-stinger[data-on="1"]` carries exactly this override
 * and its comment is the warning verbatim: *"Keep the two curves different or the entry inherits
 * the exit's 420ms and the card is still arriving when it should be read."* Copying the base
 * transition without the override is how you re-earn a bug the file already documents.
 * ⚠️ It is NOT given `sq-card` (the stinger's springy arrival): those keyframes are a whole LIFE —
 * they end at `opacity: 0` — which suits a card with a fixed 620ms span and would fade this one out
 * a third of the way through its own dice roll. A fast ease-out in, the considered drift-up out. */
#squeak-game-screen .sq-deal[data-on="1"] {
  opacity: 1;
  transform: translate(-50%, -50%);
  transition: opacity .18s ease-out, transform .18s ease-out;
}
#squeak-game-screen .sq-deal[data-on="0"] { transform: translate(-50%, calc(-50% - 22px)); }
#squeak-game-screen .sq-dealhead {
  font-weight: 800; font-size: .78rem; letter-spacing: .1em; text-transform: uppercase;
  opacity: .72;
}
/* the row DieRenderer owns — it rewrites this element's innerHTML on every tumble frame */
#squeak-game-screen .sq-dealdice { display: flex; gap: 8px; align-items: center; }
/* ⛔ A RESERVED LINE, NOT ONE THAT APPEARS. The caption lands when the dice do, and a card that
 * grows a row at that moment would jump under the reader's eye — the locked-layout rule. It is
 * `&nbsp;` until then, so the box is its final height from the first frame. */
#squeak-game-screen .sq-dealsay {
  font-weight: 700; font-size: .92rem; min-height: 1.2em;
  /* 100%, so the caption may BE the widest child and set the card's width; the ellipsis is a
   * last-resort guard against the card's own 92% board cap, not a routine clip. */
  white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
}
/* the no-DieRenderer fallback (the board suite and the FX preview do not always load it) */
#squeak-game-screen .sq-dealfb { font-size: 38px; line-height: 1; }
/* ⛔⛔ THE TRAPS ARE HIDDEN WHILE THE DICE ARE DECIDING HOW MANY THERE ARE — otherwise the answer
 * is already sitting on the board behind the card and the roll is theatre. `visibility`, not
 * `display`: the tiles keep their boxes, so nothing reflows when they arrive.
 * ⭐ IT RIDES ON .sq-root, NOT ON THE TILES. paint() rewrites innerHTML on all 121 tiles, so a
 * flag written onto a trap sprite would be destroyed by the next repaint; an attribute on the
 * root survives every one of them. Same reason the bob's phase lives on the tile and not the art.
 * ⚠️ Only sqDealTraps ever sets it, and its single exit always removes it. */
#squeak-game-screen .sq-root[data-dealing="1"] .sq-trapart { visibility: hidden; }

/* ⭐⭐ THE HANDOVER CARD WEARS THE INCOMING PLAYER'S MOUSE. Same four tokens as the seat cards, so
 * the rail and the announcement can never disagree about who is who.
 * ⛔ IT MIXES INTO `--sq-ink`, NOT INTO `transparent` LIKE THE SEAT CARDS DO — and the difference
 * is the BACKDROP, not taste. A seat card washes over the dark rail, so transparent keeps it dark.
 * This card floats over the BOARD, which is light cream tiles: mixed with transparent it would go
 * translucent and the cream text would land on cream tiles. Mixing into the dark ink keeps the
 * pill opaque and dark, which is what the text was always readable against.
 * ⛔ AND THE RING IS THE HUE ITSELF, not the darker mix the seat cards use — for the same reason
 * inverted. A darker-than-hue ring reads as an edge on a light wash and vanishes on a dark pill.
 * ⚠️ Only the handover carries a seat. "The cats move" and the MP notes have no player, and
 * showStinger clears the attribute so they stay the plain dark pill they have always been. */
#squeak-game-screen .sq-stinger[data-seat="0"] { --sq-hue: var(--sq-mouse1); }
#squeak-game-screen .sq-stinger[data-seat="1"] { --sq-hue: var(--sq-mouse2); }
#squeak-game-screen .sq-stinger[data-seat="2"] { --sq-hue: var(--sq-mouse3); }
#squeak-game-screen .sq-stinger[data-seat="3"] { --sq-hue: var(--sq-mouse4); }
#squeak-game-screen .sq-stinger[data-seat] {
  background: color-mix(in oklch, var(--sq-hue) var(--sq-card-wash), var(--sq-ink));
  outline: 2px solid color-mix(in oklch, var(--sq-hue), #fff var(--sq-card-ring));
  outline-offset: -2px;
}
/* ⛔ ARRIVING IS FAST: the card must be readable the instant it lands, so the entry overrides the
 * long exit rather than sharing it. ⚠️ It is not flat — the card rests 22px high while hidden, so
 * a show DROPS it into place over 140ms. That is deliberate and it is also unavoidable: one
 * element, one transform, and the exit needs somewhere to go. Keep the two curves different or
 * the entry inherits the exit's 420ms and the card is still arriving when it should be read. */
#squeak-game-screen .sq-stinger[data-on="1"] {
  opacity: 1;
  transform: translate(-50%, -50%);
  transition: opacity .14s ease-out, transform .14s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-seat, #squeak-game-screen .sq-movedot, #squeak-game-screen .sq-stinger { transition: none; }
}

/* ── the design box ──────────────────────────────────────────────────────── */
/* ⛔ A MEASUREMENT WITH THREE COPIES — squeak.js SQ_FIT_W/SQ_FIT_H, board-fit.js's registry entry
 * and here. All three must agree or BoardFit scales a box that is not the board.
 * 774 × 631 is the WORST CASE, measured in the browser: four seat cards, the feed at its reserved
 * four lines, the cat panel at its reserved four rows. Because those are RESERVED and not sized
 * from their contents, there is no taller state — a 2-player table is shorter and sits in the same
 * box, which is exactly what stops the board resizing when the seat count changes. */
@media (min-width: 1000px) {
  #squeak-game-screen .sq-root { width: 774px; height: 631px; }
}

/* ── the setup card ──────────────────────────────────────────────────────── */
#squeak-game-screen .sq-setup-card { max-width: 520px; margin: 0 auto; text-align: left; }
#squeak-game-screen .sq-setup-eyebrow {
  font-size: .76rem; text-transform: uppercase; letter-spacing: .1em;
  opacity: .65; text-align: center;
}
#squeak-game-screen .sq-setup-title { text-align: center; margin: .2rem 0 .6rem; }
#squeak-game-screen .sq-setup-desc { font-size: .9rem; line-height: 1.45; opacity: .9; margin: 0 0 14px; }
#squeak-game-screen .sq-setup-row {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; margin: 10px 0; font-size: .9rem;
}
#squeak-game-screen .sq-optrow { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
#squeak-game-screen .sq-opt {
  padding: 6px 12px; border-radius: 999px;
  border: 2px solid var(--sq-line);
  background: transparent; color: inherit; font: inherit; font-size: .84rem;
  cursor: pointer;
}
#squeak-game-screen .sq-opt[data-on="1"] { border-color: var(--sq-ink); background: rgba(0, 0, 0, .12); font-weight: 700; }
#squeak-game-screen .sq-setup-note { text-align: center; opacity: .7; font-size: .85rem; margin: 14px 0 0; }
/* The roster line. ⛔ It sits ABOVE the cats/target note and must not look like a second copy of
 * it: this one names who is coming, which is the only line on the card about the opponents. The
 * names carry the accent so they read as the subjects of the sentence, and the whole line is a
 * touch brighter than .sq-setup-note because it is information, not a footnote.
 * ⚠️ Wraps rather than truncating — at 3-4 players this is three name/blurb pairs, and a clipped
 * blurb would leave a character half-described. */
#squeak-game-screen .sq-setup-bots { opacity: .85; margin-top: 12px; line-height: 1.5; }
#squeak-game-screen .sq-setup-bots b { color: var(--sq-accent); font-weight: 650; }
#squeak-game-screen .sq-setup-go { width: 100%; margin-top: 12px; min-height: 46px; }

/* ── narrow fallback (NOT the considered phone layout — see the header) ───── */
@media (max-width: 999px) {
  #squeak-game-screen .sq-root { --sq-c: 30px; --sq-rail: 100%; }
  #squeak-game-screen .sq-body { flex-direction: column-reverse; align-items: center; }
  #squeak-game-screen .sq-rail { width: 100%; flex: 1 1 auto; }

  /* ⛔⛔ THE TOP BAR WAS SETTING THE WHOLE COLUMN'S WIDTH, and it was doing so BEFORE the online
   * arm existed. `.sq-root` is `width: max-content` (BoardFit scales the ROOT, so the root has to
   * BE the content's size) — which means the widest thing in it wins, and the widest thing is the
   * cat-step chip, `white-space: nowrap` at 323px. MEASURED IN A BROWSER at 375x812: the board
   * grid is a correct 360px, but the root came out 587px in pass-and-play and 737px once the MP
   * clock joined the row, with the overflow simply clipped — half the board off-screen and no way
   * to scroll to it. That is not "a narrow fallback", so it is fixed here rather than left for the
   * phone re-budget, because a friend link that opens on a phone is the entire point of the online
   * arm. ⬜ THE CONSIDERED PHONE COLUMN IS STILL OWED (re-round 1: the feed at 4 lines, the cat
   * panel at 4 rows, drawn at 360x740) — this is the floor, not that answer.
   * The three pieces: cap the root so max-content cannot exceed the viewport, let the bar WRAP
   * onto a second line, and let the two long children shrink instead of demanding their width. */
  #squeak-game-screen .sq-root { max-width: 100%; }
  #squeak-game-screen .sq-bar { flex-wrap: wrap; row-gap: 4px; }
  #squeak-game-screen .sq-chip { white-space: normal; min-width: 0; }
  #squeak-game-screen .sq-status { min-width: 0; flex: 1 1 120px; }
  /* The clock keeps its own width — a squeezed countdown track is the Gem Duel bug — but it must
   * be allowed onto the second line rather than widening the first. */
  #squeak-game-screen .sq-clock { flex: 0 0 132px; }

  /* ⭐⭐ TWO MICE PER LINE. The owner, looking at the narrow layout: "I noticed a LOT of dead
   * space on each players card… can we fit 2 mice per line? so instead of 4 lines on mobile, it
   * would be 2." ⛔ MEASURED BEFORE IT WAS PROMISED, at 360px: the card is 360×58 with columns
   * `30px 236px 58px`, and the name's natural width is **58px** — so 178px of every card is
   * empty, exactly as it looked. Two-up leaves each card **177px** against **178px** of content
   * (16 padding + 30 face + 2×8 gap + 58 name + 58 pips), i.e. it missed by ONE PIXEL with no
   * reflow at all. Trimming the card's own padding and gap by 4px each is the whole fix; the pips
   * keep their size and nothing moves to a second row.
   * ⭐ The payoff is the rail, not the card: four rows of 58 + three 6px gaps = 250px becomes two
   * rows = 122px, so the phone rail loses **128px** — real estate the board actually wants back.
   * ⛔ min-width: 0 ON THE NAME BLOCK OR NONE OF IT WORKS. `.sq-seatnm` has carried
   * `text-overflow: ellipsis` all along, and it could never fire: its parent is a grid item, and
   * a grid item's min-width defaults to `auto`, which means "never narrower than my content". At
   * full width nothing ever pushed on it; at half width a real online name would have widened the
   * card instead of truncating. ⚠️ The sub line gets the same treatment — it is not reserved
   * space that matters, it is "caught — back next turn" WRAPPING and taking the card to two rows,
   * which would undo the 128px and break the reserved 42px the locked-layout rule depends on. */
  #squeak-game-screen .sq-seats {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 6px;
  }
  #squeak-game-screen .sq-seat { padding: 6px 6px; gap: 6px; }
  #squeak-game-screen .sq-seatsub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

  /* ⭐⭐ THE PHONE RAIL, OWNER'S LAYOUT (2026-09-22, after the .479 playtest): "you have to scroll
   * to the bottom of the page to even see the end turn button". So, on a phone only:
   *   · THE CATS card is shelved. The owner never used it, and the board already shows cat intent
   *     (the arrows and red rings). paintCats() still runs; the card just isn't drawn.
   *   · MOVES LEFT and END TURN share one line, half each, the same way the mice sit two to a line.
   *   · the log moves to the bottom, below both.
   * ⛔ ORDER BY GRID AREA, NOT BY MOVING NODES. The DOM order is the desktop rail's, and paint()
   * and the FX layer find everything by id, so the phone layout is placement alone. */
  #squeak-game-screen .sq-rail {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-areas: "seats seats" "moves end" "feed feed";
    gap: 6px;
  }
  #squeak-game-screen .sq-seats { grid-area: seats; }
  /* border-box so the row is 46px and not the 62px that content-box + padding made of min-height 44 */
  #squeak-game-screen .sq-moves { grid-area: moves; min-width: 0; padding: 6px 8px; gap: 6px; box-sizing: border-box; min-height: 46px; }
  #squeak-game-screen .sq-movelabel { white-space: nowrap; font-size: .72rem; letter-spacing: .02em; }
  #squeak-game-screen .sq-movedots { gap: 4px; }
  #squeak-game-screen .sq-movedot { width: 13px; height: 13px; }
  #squeak-game-screen .sq-endbtn { grid-area: end; min-height: 44px; }
  #squeak-game-screen .sq-feed { grid-area: feed; }
  #squeak-game-screen .sq-cats { display: none; }

  /* ⭐ THE BAR, CENTRED. "Round N" keeps the left edge on a line of its own. The cat-step chip gets
   * its own centred line, and the clock and whose-turn text are centred together below it
   * (they had been pushed right, where they were easy to miss). */
  #squeak-game-screen .sq-bar { justify-content: center; column-gap: 12px; }
  #squeak-game-screen .sq-round { flex: 0 0 100%; text-align: center; }
  #squeak-game-screen .sq-status { flex: 0 1 auto; text-align: center; }
}

/* ── 🌐 THE MP END SCREEN'S SCORE ROWS ─────────────────────────────────────
 * ⛔ NOT scoped to #squeak-game-screen like everything else in this file: GameEndScreen mounts its
 * popup OUTSIDE the screen element, so a scoped rule here would never match and the rows would
 * come out unstyled. The `sq-end` prefix is what keeps them from colliding with anything else
 * (predicely_cascade_collisions).
 * ⭐ IDENTITY IS THE MOUSE'S OWN FACE, not a colour swatch — the four mice differ by FACE as well
 * as colour (buck teeth / plain / sleepy / eyepatch), which is what answers SQ-B6 on the board, so
 * the end screen uses the same drawing a player actually played with. */
.sq-endrows { display: flex; flex-direction: column; gap: 6px; }
.sq-endrow {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: 'nm sc' 'st sc';
  align-items: center;
  gap: 2px 10px;
  padding: 8px 10px;
  border-radius: 10px;
  background: rgba(255, 255, 255, .04);
  border: 1px solid rgba(255, 255, 255, .08);
}
.sq-endrow[data-win="1"] { border-color: #3f9d54; background: rgba(63, 157, 84, .13); }
.sq-endnm {
  grid-area: nm;
  display: flex;
  align-items: center;
  gap: 8px;
  font-weight: 700;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sq-endface { width: 22px; height: 22px; flex: 0 0 22px; image-rendering: auto; }
.sq-endst { grid-area: st; font-size: .78rem; opacity: .7; }
.sq-endsc { grid-area: sc; font-weight: 800; font-size: 1.05rem; white-space: nowrap; }
.sq-endnote { margin-top: 10px; font-size: .8rem; opacity: .72; line-height: 1.45; }

/* ══════════════════════════════════════════════════════════════════════════════
 * ⭐⭐⭐ THE FEEL LAYER — the mock's eleven keyframes, lifted verbatim
 * ══════════════════════════════════════════════════════════════════════════════
 * Source: docs/design/batch7/Squeak.dc.html, lines :21–:31. Each curve below carries the mock
 * line it came from, and the numbers are EXACT — no rounding, no "close enough". The owner fought
 * several Design rounds for feel on Gem Duel and the same care applies here.
 *
 * ⚠️ WHY LIFTING IS ALLOWED HERE, when docs/design/squeak-mock-review.md SQ-B1–B5 rules that no
 * mock frame is a test fixture: that verdict is about RULES FACTS — unmirrored boards, wrong
 * cat-step chips, mis-stated trap thresholds. A keyframe has no rules answer it could contradict.
 * It is pure design intent, and there is no other record of it. ⛔ Animations yes; positions no.
 *
 * ⚠️ IN THE MOCK THEY ARE ALL `infinite`, because it is a static showcase with nothing to react
 * to. In the build almost every one fires ONCE, on the event it describes. The durations and
 * easings below are the mock's; only the iteration count changes.
 * ────────────────────────────────────────────────────────────────────────────── */

/* mock :21 */
@keyframes sq-ring{0%,100%{box-shadow:0 0 0 0 rgba(233,185,73,.55)}55%{box-shadow:0 0 0 6px rgba(233,185,73,0)}}
/* mock :22 */
@keyframes sq-alert{0%,100%{box-shadow:0 0 0 0 rgba(226,96,63,.6)}55%{box-shadow:0 0 0 7px rgba(226,96,63,0)}}
/* mock :23 */
@keyframes sq-pop{0%{transform:translate(-50%,-50%) scale(.4);opacity:0}55%{transform:translate(-50%,-50%) scale(1.18);opacity:1}100%{transform:translate(-50%,-50%) scale(1);opacity:1}}
/* mock :24 */
@keyframes sq-slide{0%{transform:translateX(0)}50%{transform:translateX(-38%)}100%{transform:translateX(0)}}
/* mock :25 */
@keyframes sq-dash{to{background-position:-24px 0}}
/* mock :26 */
@keyframes sq-blink{0%,100%{opacity:1}50%{opacity:.35}}
/* mock :27 */
@keyframes sq-hop{0%,100%{transform:translate(-50%,-50%)}35%{transform:translate(-50%,-72%)}}
/* mock :28 */
@keyframes sq-squish{0%{transform:translate(-50%,-50%) scale(1,1)}6%{transform:translate(-50%,-56%) scale(.9,1.14)}14%{transform:translate(-50%,-34%) scale(1.34,.42)}22%{transform:translate(-50%,-52%) scale(.88,1.1)}30%,100%{transform:translate(-50%,-50%) scale(1,1)}}
/* mock :29 — ⚠️ RETIMED, the third curve whose showcase shape did not survive being played once.
 * The mock's own numbers, for the record:
 *     0% translate(-50%,-28%) scale(.45) o0 · 16% (-50%,-58%) scale(1) o.95
 *    62% translate(-46%,-124%) scale(1) o.8 · 100% (-54%,-205%) scale(1.04) o0   @ 2.6s ease-out
 * ⛔ IT CLIMBED AT THREE DIFFERENT SPEEDS AND THEN EASED OUT ON TOP OF THAT. The vertical travel
 * per segment is 30% / 66% / 81% over spans of 16% / 46% / 38% of the clip — so the rise sped up,
 * slowed down and sped up again, and ease-out applied to EVERY interval (the sq-splat lesson)
 * added a hesitation at each join. The owner: "let them live slightly longer with a linear motion
 * up instead of pausing a bit".
 * ⭐ Now: one quick birth (the pop out of the cloud), then a CONSTANT climb — after 12% every
 * segment covers the same 56% of travel in the same 29% of time, and the animation is driven
 * linear, so it is genuinely constant rather than approximately so. The mock's sideways SWAY is
 * kept, because that is the part of its curve that gives the ghost life. Bigger and longer too;
 * both are knobs (--sq-spook-ms, and .sq-fly-spook's width). */
@keyframes sq-spook{0%{transform:translate(-50%,-40%) scale(.5);opacity:0}12%{transform:translate(-48%,-66%) scale(1.14);opacity:.95}41%{transform:translate(-52%,-122%) scale(1.15);opacity:.92}70%{transform:translate(-47%,-178%) scale(1.17);opacity:.6}100%{transform:translate(-53%,-234%) scale(1.2);opacity:0}}
/* mock :30 */
@keyframes sq-bob{0%,100%{transform:translate(-50%,-52%)}50%{transform:translate(-50%,-64%)}}
/* mock :31 — ⚠️ RETIMED TWICE, and the second time is the interesting one.
 * FIRST: the mock's version spends 12% of its clip invisible, throws the ring between 16% and 40%,
 * and holds an empty frame for the remaining 60% — in an infinite loop that tail IS the gap before
 * the next throw. Played once, the droplets were gone by 600ms of a 1500ms clip, which is why the
 * owner reported them as "barely visible": brief as well as small.
 * ⛔⛔ SECOND, AND THE REAL LESSON: rewriting it with intermediate keyframes made it STUTTER —
 * "it's like it has a slight pause in the middle of the animation". A CSS animation applies its
 * timing function to EVERY KEYFRAME INTERVAL, not once across the whole clip. Five keyframes
 * therefore meant five separate ease-outs stitched together: the throw decelerated, re-accelerated
 * into the next segment, and decelerated again. That reads exactly as a hitch.
 * ⭐ So this is now TWO keyframes and one easing. A burst is a single gesture and wants a single
 * curve; every intermediate stop you add is another place for it to hesitate. Anything that needs
 * shaping goes in the cubic-bezier, never in more stops. */
@keyframes sq-splat{0%{transform:translate(-50%,-50%) scale(.12);opacity:1}100%{transform:translate(-50%,-50%) scale(1.78);opacity:0}}

/* ⭐ THE TURN-CHANGE SHOCKWAVE — item 3, and the only curve here the mock does NOT define.
 * The owner asked for a circle shrinking INTO the next mouse, which is precisely the inverse of
 * sq-ring: ring pushes a halo outward and fades it, this draws one in from outside and lands it.
 * Written in sq-ring's own idiom (a box-shadow spread on a tiny element, same amber) so the two
 * read as one family rather than as two authors. */
@keyframes sq-gather{
  0%   { box-shadow: 0 0 0 26px rgba(233,185,73,0);   opacity: 0 }
  30%  { opacity: 1 }
  100% { box-shadow: 0 0 0 2px  rgba(233,185,73,.85); opacity: 0 }
}

/* ── where each one lands ─────────────────────────────────────────────────── */

/* sq-ring (mock :616) — the seat card of whoever is on the clock. ⭐ This is the mock's OWN answer
 * to item 3 ("more focus on whose turn it is"), which the resume brief recorded as having no spec:
 * the mock puts `animation:sq-ring 2s ease-out infinite` on the current seat's card and nothing on
 * the others. Lifted as written; the board-side shockwave is the part that had to be designed. */
#squeak-game-screen .sq-seat[data-on="1"] { animation: sq-ring 2s ease-out infinite; }

/* sq-blink (mock :26) — defined in the mock and applied NOWHERE in it. ⚠️ TWO of the eleven are
 * dead in the mock, not one: sq-blink and sq-slide (:24) are both declared and never used (checked
 * by grepping `animation:sq-` across the whole mock — the other nine all land somewhere).
 * Both are lifted for completeness of the spec and both are given the one job in the build that
 * wants exactly them. For sq-blink that is the online clock in its last 8 seconds, which already
 * sets data-low; sq-slide's home is the refused tap, below. */
#squeak-game-screen .sq-clock[data-low="1"] .sq-clocknum { animation: sq-blink 1s ease-in-out infinite; }

/* sq-dash (mock :447) — the marching-ants outline on a previewed block landing spot. The build
 * already drew this ghost as a STATIC dashed box; the mock animates the hatching so it reads as
 * "about to happen" rather than as furniture. The 45° hatch is the mock's too. */
#squeak-game-screen .sq-ghost {
  background-image: repeating-linear-gradient(45deg,
    rgba(255, 255, 255, .34) 0 2px, rgba(0, 0, 0, 0) 2px 6px);
  animation: sq-dash 1.1s linear infinite;
}

/* sq-alert (mock :450) — the cheese a push is about to destroy. The build's crush ring was a
 * static glow; the mock pulses it, and this is the owner's favourite moment on the board. */
#squeak-game-screen .sq-crush { animation: sq-alert 1.5s ease-out infinite; }
/* sq-alert (mock :464) — a cat whose next step CATCHES someone. Faster than the crush pulse by
 * the mock's own choice (1.4s vs 1.5s): the scarier of the two reads as slightly more urgent. */
#squeak-game-screen .sq-intent[data-kind="catch"] { animation: sq-alert 1.4s ease-out infinite; }

/* ── the FX overlay ───────────────────────────────────────────────────────────
 * ⛔⛔ EVERY MOVING THING LIVES HERE, NOT IN A TILE, AND THE REASON IS STRUCTURAL.
 * paint() rewrites `innerHTML` on all 121 tiles. Anything animating inside a tile is destroyed and
 * rebuilt mid-flight — a running animation restarts from frame 0, and a piece travelling between
 * two tiles cannot exist at all, because its source element is deleted the instant the model says
 * it has already arrived. So travel, splats and rising ghosts are drawn on a sibling layer that
 * paint() never touches, and the tile piece underneath is hidden for the duration.
 * ⛔ LOCKED LAYOUT: position:absolute, inset:0, inside .sq-grid (already position:relative), so it
 * adds NOTHING to the measured box. .sq-root is BoardFit's target at exactly 774×631 and is
 * `width: max-content` — a child that grew would drag the whole column with it, which is what
 * clipped half the board at 375px in .432. overflow is visible on purpose: a ghost rises ~2 tiles
 * above the board and must be allowed to leave it WITHOUT the layout noticing. */
#squeak-game-screen .sq-fx {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: visible;
  z-index: 7;
}
/* one travelling piece: placed at its ORIGIN tile, then transformed by the delta. A keyframe
 * cannot do this — the distance is only known at spawn — so travel is a TRANSITION, and the
 * eleven curves decorate it rather than drive it. */
#squeak-game-screen .sq-fly {
  position: absolute;
  width: var(--sq-c);
  height: var(--sq-c);
  will-change: transform;
}
#squeak-game-screen .sq-fly img {
  position: absolute;
  left: 50%; top: 50%;
  width: 84%; height: 84%;
  transform: translate(-50%, -50%);
  object-fit: contain;
}
/* ⛔ A FLYING PIECE RESTS WHERE A BOBBING ONE DOES. sq-bob-board sits at translate(-50%,-50%)
 * at the start and end of its cycle, so a clone that also rests there lands without a step. If the
 * bob's rest offset is ever changed, change it here too or every piece will jump on arrival.
 * (The mock's own sq-bob rests at -52%; the board version uses -50% precisely so this pair is
 * trivially equal rather than needing a second magic number.) */
/* the sprite-weight correction again, so a flying piece is the same size as the one it replaced */
#squeak-game-screen .sq-fly[data-kind="mouse"] img { transform: translate(-50%, -50%) scale(1.2); }
#squeak-game-screen .sq-fly[data-kind="cat"] img   { transform: translate(-50%, -50%) scale(1.12); }
/* ⭐ A PUSHED BLOCK IS A TILE BACKGROUND, NOT A SPRITE — there is no element to fly, so the flier
 * paints itself to match .sq-tile[data-t="3"] exactly. If that tile's look ever changes, this
 * changes with it or a pushed block will change colour while it is in the air. */
#squeak-game-screen .sq-fly[data-kind="block"]::after {
  content: '';
  position: absolute; inset: 0;
  border-radius: 4px;
  background: var(--sq-block);
  box-shadow: inset 0 -3px 0 rgba(0, 0, 0, .28), inset 0 2px 0 rgba(255, 255, 255, .22);
}
/* sq-hop (mock :421) — the mock hops the CAT, not the mouse, and that is the right call: the cats
 * are the threat and a hop makes their step land. It rides ON TOP of the travel transform by
 * living on an inner element, so the two never fight over `transform`. */
#squeak-game-screen .sq-fly-hop {
  position: absolute;
  left: 50%; top: 50%;
  width: 100%; height: 100%;
  transform: translate(-50%, -50%);
  animation: sq-hop .6s ease-in-out;
}

/* a piece the FX layer is currently flying: the tile keeps its space and its layout and only the
 * sprite is hidden, so nothing reflows while a piece is in the air */
#squeak-game-screen .sq-art[data-travel="1"] { visibility: hidden; }

/* sq-spook (mock :471) — the soul of a caught mouse, leaving. One shot, on the tile it died on. */
#squeak-game-screen .sq-fly-spook {
  position: absolute;
  left: 50%; top: 50%;
  /* bigger — the owner asked for it and a soul leaving is worth seeing */
  width: 82%; height: 82%;
  opacity: 0;
  transform: translate(-50%, -40%) scale(.5);
  /* ⛔ LINEAR, and that is the whole point of the retime above: a rising ghost has nothing
   * decelerating it, so any ease makes it look like it is running out of puff halfway up. */
  animation: sq-spook var(--sq-spook-ms) linear;
  filter: drop-shadow(0 0 7px rgba(240, 245, 240, .5));
}
#squeak-game-screen .sq-fly-spook img { width: 100%; height: 100%; object-fit: contain; }

/* sq-squish (mock :456) + sq-splat (mock :518) — the cheese crush, which is TWO effects and not
 * one: the wedge squashes flat AND a ring of droplets flies off it. */
#squeak-game-screen .sq-fly-squish {
  position: absolute;
  left: 50%; top: 50%;
  width: 84%; height: 84%;
  transform: translate(-50%, -50%);
  animation: sq-squish 1.5s cubic-bezier(.3, 1.5, .5, 1);
}
#squeak-game-screen .sq-fly-squish img { width: 100%; height: 100%; object-fit: contain; }
/* ⭐ TEN DROPLETS AS ONE ELEMENT'S BOX-SHADOW LIST — the mock's splat() at :505–518, not a particle
 * system. That matters for more than cost: a box-shadow ring is deterministic by construction, so
 * two clients watching the same table see the same burst and a repaint cannot reshuffle it.
 * The angles, radii and sizes are computed in squeak.js from the mock's own constants. */
#squeak-game-screen .sq-fly-splat {
  position: absolute;
  left: 50%; top: 50%;
  border-radius: 50%;
  background: var(--sq-cheese);
  opacity: 0;
  transform: translate(-50%, -50%) scale(.15);
  animation: sq-splat var(--sq-splat-ms) cubic-bezier(.08, .74, .28, 1);
}

/* sq-pop (mock :413) — something ARRIVING: a new cheese, a new cat, a mouse back from the dead. */
#squeak-game-screen .sq-fly-pop {
  position: absolute;
  left: 50%; top: 50%;
  width: 84%; height: 84%;
  opacity: 0;
  transform: translate(-50%, -50%) scale(.4);
  animation: sq-pop .5s ease-out;
}
#squeak-game-screen .sq-fly-pop img { width: 100%; height: 100%; object-fit: contain; }

/* sq-gather — the turn-change shockwave, at the tile of the mouse now on the clock */
#squeak-game-screen .sq-fly-gather {
  position: absolute;
  left: 50%; top: 50%;
  width: 6px; height: 6px;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  animation: sq-gather var(--sq-turn-ms) cubic-bezier(.25, .9, .35, 1);
}

/* ⭐⭐ THE TURN CHANGE IS NOW THREE THINGS THAT ARRIVE TOGETHER — the owner's round-3 note.
 * The shockwave (sq-gather) was doing all the work alone, and the ring under the current mouse
 * simply BLINKED into existence at its final size, which is the one part of a handover the eye is
 * actually looking at. Their fix, and it is the right one: "make it way larger at first and then
 * shrink into its current size… quickly fade in instead of instantly appearing", plus "a quick
 * mousehead grows and shrinks with the pulse".
 * ⛔ TWO KEYFRAMES, ONE EASING, for the ring — the sq-splat lesson. A stop in the middle would
 * split the shrink into two eased segments and make it hesitate exactly where it should be
 * settling. The fade rides the same curve, so it is fast at the start and eases in as it lands.
 * ⛔ GATED ON data-turn, NOT ON data-me. data-me marks whoever is on the clock, and paint()'s diff
 * guard rewrites that tile on every push as well as on every handover — so hanging the flourish on
 * it would fire the ring four times a turn. data-turn is a one-shot the sequencer sets and clears. */
@keyframes sq-turnring {
  0%   { opacity: 0;   transform: scale(2.7) }
  100% { opacity: .55; transform: scale(1) }
}
#squeak-game-screen .sq-mouse[data-turn="1"]::after {
  animation: sq-turnring var(--sq-turn-ms) cubic-bezier(.16, .82, .3, 1);
}
/* the head takes the pulse too — a real direction change, so three stops here are correct rather
 * than a stutter: the hesitation the sq-splat note warns about is a MONOTONIC motion cut in two. */
@keyframes sq-turnpop {
  0%   { transform: translate(-50%, -50%) scale(1) }
  42%  { transform: translate(-50%, -54%) scale(1.2) }
  100% { transform: translate(-50%, -50%) scale(1) }
}
#squeak-game-screen .sq-mouse[data-turn="1"] {
  animation: sq-turnpop var(--sq-turn-ms) ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-mouse[data-turn="1"],
  #squeak-game-screen .sq-mouse[data-turn="1"]::after { animation: none; }
}

/* sq-bob (mock :621) — the mock bobs the ghost token of a seat that is currently OUT, in the SEAT
 * CARD, not on the board. Lifted where the mock put it.
 * ⛔ DE-PHASED BY SEAT INDEX, NEVER BY A RANDOM OFFSET (--sq-seat is set from the seat number in
 * squeak.js). Four cards bobbing in lockstep read as one mechanism twitching; four bobbing out of
 * phase read as four creatures. A random offset would do the same job and break the rule that a
 * shared board must look identical on every client — the same rule that makes the ghost sprite
 * pick (seat + times caught) instead of a roll. */
#squeak-game-screen .sq-seat[data-out="1"] .sq-seatface img {
  transform: translate(-50%, -52%);
  animation: sq-bob 2.4s ease-in-out infinite;
  animation-delay: calc(var(--sq-seat, 0) * -0.6s);
}
/* ⭐ THE GRAVESTONE DOES NOT BOB. The mock floats a ghost token in this slot (:621) and floating is
 * exactly right for a ghost; a headstone that bobbed would read as polystyrene. It gets a one-shot
 * PLANT instead — drops in, overshoots, settles — which is the same squash-and-stretch vocabulary
 * the rest of the board speaks. ⛔ Three stops is correct here: it is a real direction change, not
 * a monotonic move cut in two (see the sq-splat note for when that becomes a stutter). */
@keyframes sq-plant {
  0%   { transform: translate(-50%, -140%) scale(.86, 1.12); opacity: 0 }
  55%  { transform: translate(-50%, -44%) scale(1.16, .84); opacity: 1 }
  100% { transform: translate(-50%, -50%) scale(1, 1); opacity: 1 }
}
#squeak-game-screen .sq-seat[data-dead="1"] .sq-seatface img {
  animation: sq-plant 420ms cubic-bezier(.3, 1.3, .5, 1);
  transform: translate(-50%, -50%);
}
@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-seat[data-dead="1"] .sq-seatface img { animation: none; }
}

/* ⛔ REDUCED MOTION — constraint 4, and it is not just "animation: none".
 * A rising ghost under reduced motion should APPEAR AND FADE, not travel; a travelling piece
 * should simply BE where it ended up. squeak.js's reduced() already collapses every beat to ≤30ms
 * so the sequencer stops waiting; this stops the drawing. The two have to agree, or a beat would
 * wait on an animation that is not running — or worse, not wait for one that is. */
@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-seat[data-on="1"],
  #squeak-game-screen .sq-seat[data-out="1"] .sq-seatface img,
  #squeak-game-screen .sq-ghost,
  #squeak-game-screen .sq-crush,
  #squeak-game-screen .sq-intent[data-kind="catch"],
  #squeak-game-screen .sq-clock[data-low="1"] .sq-clocknum,
  #squeak-game-screen .sq-fly-hop,
  #squeak-game-screen .sq-fly-squish,
  #squeak-game-screen .sq-fly-gather { animation: none; }
  #squeak-game-screen .sq-fly { transition: none !important; }
  #squeak-game-screen .sq-fly-spook {
    animation: none;
    opacity: .9;
    transform: translate(-50%, -58%) scale(1);
  }
  #squeak-game-screen .sq-fly-pop { animation: none; opacity: 1; transform: translate(-50%, -50%) scale(1); }
  #squeak-game-screen .sq-fly-splat { display: none; }
}

/* ⛔ A TILE WHOSE BLOCK IS CURRENTLY IN THE AIR. A pushed block is drawn by the TILE (data-t="3"),
 * not by a sprite, so there is no child to hide — the tile itself has to stop looking like a block
 * while its clone flies, or the block appears at its destination before it has arrived there.
 * ⚠️ It must out-specify the data-t rules above it, hence the doubled attribute selector. */
#squeak-game-screen .sq-tile[data-flying="1"][data-t] {
  background: var(--sq-floor);
  box-shadow: none;
}
#squeak-game-screen .sq-tile[data-flying="1"][data-checker="1"] { background: var(--sq-floor2); }

/* ⭐ ITEM 6 — "a white dot shows a walkable tile; nothing shows that a block is PUSHABLE."
 * The build already distinguished the two (data-legal 1 vs 2) but only by size and corner radius,
 * which the owner did not read as a difference. This makes the push marker an ARROWHEAD pointing
 * the way the block will go. ⛔ Nothing is derived to do it: sqPreview already answers legal/
 * illegal per direction, and paint() now records WHICH direction on the tile as data-dir.
 * 0 = UP, 1 = RIGHT, 2 = DOWN, 3 = LEFT (squeak-rules.js :118, the tie order). */
#squeak-game-screen .sq-tile[data-legal="2"]::before {
  width: 46%; height: 46%;
  border-radius: 0;
  background: rgba(255, 255, 255, .62);
  clip-path: polygon(50% 0%, 100% 100%, 50% 74%, 0% 100%);
}
#squeak-game-screen .sq-tile[data-legal="2"][data-dir="0"]::before { transform: translate(-50%, -50%) rotate(0deg); }
#squeak-game-screen .sq-tile[data-legal="2"][data-dir="1"]::before { transform: translate(-50%, -50%) rotate(90deg); }
#squeak-game-screen .sq-tile[data-legal="2"][data-dir="2"]::before { transform: translate(-50%, -50%) rotate(180deg); }
#squeak-game-screen .sq-tile[data-legal="2"][data-dir="3"]::before { transform: translate(-50%, -50%) rotate(270deg); }

/* ⭐ THE BLOCKED ✕ — the other half of item 6, and deliberately NOT drawn on every illegal side.
 * The owner floated "a small ✕ on blocked directions" and then said "I'm not sure", and they were
 * right to hesitate: a mouse hemmed in on three sides would wear three ✕s and the board would read
 * as noise. It is drawn only on the ONE direction the player is currently pointing at, so it
 * answers the question actually being asked — "why can't I go there?" — and vanishes with the
 * pointer. ⛔ On a touch screen there is no hover, so this only ever appears on a deliberate tap.
 * ⚠️ If the owner wants the always-on version instead, showPreview is the only place to change. */
#squeak-game-screen .sq-blocked {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--sq-bad);
  font-weight: 800;
  font-size: 1.1rem;
  line-height: 1;
  text-shadow: 0 1px 3px rgba(0, 0, 0, .7);
  border: 2px solid var(--sq-bad);
  border-radius: 6px;
  background: rgba(207, 58, 46, .16);
  pointer-events: none;
  z-index: 6;
}
/* ⭐ sq-slide (mock :24) — THE REFUSED TAP, and the second of the mock's two never-applied curves.
 * A horizontal shake is the universal "no", and this is the one moment on the board that wants
 * one: you tapped a direction the rules refuse. ⛔ It fires on a TAP only, never on hover — the ✕
 * shows whenever the pointer crosses a blocked tile, and a board that twitched every time the
 * pointer moved across it would be the same fault the owner reported about the blinking cat lines.
 * ⚠️ The mock's curve is written against an untransformed box, so .sq-blocked must not carry a
 * centring transform of its own — it is placed by left/top, which is why it can take this as-is. */
#squeak-game-screen .sq-blocked[data-refused="1"] { animation: sq-slide .32s ease-in-out; }

@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-tile[data-legal="2"]::before,
  #squeak-game-screen .sq-blocked[data-refused="1"] { animation: none; }
}

/* ══════════════════════════════════════════════════════════════════════════════
 * FEEL ROUND 2 — the owner's second playtest (2026-09-20)
 * ══════════════════════════════════════════════════════════════════════════════ */

/* ⭐ THE CARD NOW ARRIVES, instead of simply being there.
 * It used to cross-fade opacity over .2s while the cats moved underneath it. The owner asked for
 * "some squishy animation instead of a instant appearance", and — the bigger half — for the card
 * and the cat movement to stop competing. squeak.js now plays them in sequence; this is the
 * arrival itself: an overshoot in, a settle, a short drop out.
 * ⛔ It restates translate(-50%,-50%) in every frame because .sq-stinger is centred that way; a
 * bare scale() would drop the centring and throw the card into the corner. */
@keyframes sq-card {
  0%   { transform: translate(-50%, -50%) scale(.55) rotate(-3deg); opacity: 0 }
  38%  { transform: translate(-50%, -54%) scale(1.09) rotate(1.5deg); opacity: 1 }
  56%  { transform: translate(-50%, -50%) scale(.97) rotate(0deg); opacity: 1 }
  70%  { transform: translate(-50%, -50%) scale(1) rotate(0deg); opacity: 1 }
  100% { transform: translate(-50%, -44%) scale(.92) rotate(0deg); opacity: 0 }
}
/* ⛔⛔ sq-card ENDS INVISIBLE, SO ONLY THE CATS' CARD MAY USE IT (`data-quick="1"`, set by showStinger for
 * a hold no longer than SQ_CARD_MS). Until 2026-09-22 it was the only arrival, and it faded EVERY card
 * out at 620ms: the handover card's 1100ms hold, the win line and the MP notes were all invisible for
 * most of their life. That was the owner's "barely read the texts".
 * ⭐ Every other card arrives with the same pop and wobble, the first 70% of sq-card rescaled with
 * the same stops, and then STAYS until it is hidden. It leaves through the data-on="0" transition. */
@keyframes sq-card-in {
  0%   { transform: translate(-50%, -50%) scale(.55) rotate(-3deg); opacity: 0 }
  54%  { transform: translate(-50%, -54%) scale(1.09) rotate(1.5deg); opacity: 1 }
  80%  { transform: translate(-50%, -50%) scale(.97) rotate(0deg); opacity: 1 }
  100% { transform: translate(-50%, -50%) scale(1) rotate(0deg); opacity: 1 }
}
#squeak-game-screen .sq-stinger[data-on="1"] {
  opacity: 1;
  animation: sq-card-in .43s cubic-bezier(.34, 1.42, .5, 1) both;
}
#squeak-game-screen .sq-stinger[data-on="1"][data-quick="1"] {
  animation: sq-card .62s cubic-bezier(.34, 1.42, .5, 1) both;
}
/* ⭐ THE HANDOVER CARD SHRINKS INTO THE MOUSE WHOSE TURN IT WAS (squeak.js aimHome writes the vector).
 * An ease-IN on the travel, so it reads as being drawn in, and the fade holds off until it is nearly
 * there, so the card is still recognisable while it flies. */
/* ⛔⛔ A KEYFRAME, NOT A TRANSITION, AND .481 SHIPPED THE TRANSITION DEAD. Leaving [data-on="1"] removes
 * the arrival ANIMATION (fill both) in the same style change that should start the transition, and
 * Chrome starts no transition at all: measured, the computed transform was already scale(.1) at t=0
 * and stayed there. The owner saw "just disappearing like it always has". Every exit that relied on
 * the data-on="0" transition blinked out the same way. An animation starts from its own 0% frame and
 * does not care what came before, so it cannot be skipped. */
@keyframes sq-card-home {
  0%   { transform: translate(-50%, -50%) scale(1); opacity: 1 }
  70%  { opacity: 1 }
  100% { transform: translate(calc(-50% + var(--sq-exit-x, 0px)), calc(-50% + var(--sq-exit-y, 0px))) scale(.1); opacity: 0 }
}
#squeak-game-screen .sq-stinger[data-on="0"][data-home="1"] {
  animation: sq-card-home var(--sq-home-ms) cubic-bezier(.55, 0, .75, .35) both;
}
/* …and the plain exit (a card with no seat, or a caught mouse) gets its drift up back the same way. */
@keyframes sq-card-out {
  0%   { transform: translate(-50%, -50%); opacity: 1 }
  100% { transform: translate(-50%, calc(-50% - 22px)); opacity: 0 }
}
#squeak-game-screen .sq-stinger[data-on="0"][data-was="1"]:not([data-home="1"]) {
  animation: sq-card-out var(--sq-handover-fade-ms) cubic-bezier(.4, 0, .9, .5) both;
}
@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-stinger[data-on="0"] { animation: none; opacity: 0; }
}

/* ══════════════════════════════════════════════════════════════════════════════
 * ⭐⭐⭐ THE POOF — the owner's own four-frame cloud, and it replaces a CSS circle
 * ══════════════════════════════════════════════════════════════════════════════
 * The first version drew smoke as one element's box-shadow lobes. The owner's verdict was that it
 * "is just a circle, which I'm not sure if it reads like smoke", and they were right: a blurred
 * blob has no silhouette, and silhouette is the whole of cartoon smoke. They drew four frames and
 * hedged about whether four is enough. It is — because the four are not four samples of one shape,
 * they are a SEQUENCE: form (cloud1, a tight puff) → grow (cloud2, full and round) → billow
 * (cloud3, big and hollow-centred) → BREAK APART (cloud4, separate drifting puffs). Frame 4 is
 * already the dissipation, so it is held longest and fades during the hold rather than being
 * cross-faded into nothing.
 *
 * ⛔ FOUR STACKED <img>, NOT ONE <img> WITH A SWAPPING src. Cycling src on a timer flickers on
 * the first play, because a frame is only decoded when it is first assigned; stacking them lets
 * the browser have all four ready and makes the swap a pure opacity step. They are preloaded in
 * squeak.js's preload() for the same reason.
 * ⛔ OPAQUE FROM FRAME ONE — the owner's explicit note ("not fully opaque even at the start").
 * A poof that fades IN announces itself as a fade; a poof that is simply THERE reads as a bang.
 * ⛔ And the frame windows are UNEVEN on purpose. Equal quarters made the old CSS puff seem to
 * "stop" mid-animation; the eye reads constant-rate scaling as a stall. 1→2→3 are quick, 4 holds. */
/* ⛔ AND IT BARELY DRIFTS. The first version climbed -50% → -66%, which on a top-row tile lifted
 * the cloud clear of the board. A poof is an event ON a square, not something leaving it — the
 * rising ghost is the thing that travels, and it has its own curve for exactly that. */
@keyframes sq-poof-grow {
  0%   { transform: translate(-50%, -50%) scale(.52); opacity: 1 }
  30%  { transform: translate(-50%, -51%) scale(1);   opacity: 1 }
  62%  { transform: translate(-50%, -53%) scale(1.12); opacity: 1 }
  /* ⛔ THE FADE STARTS AFTER FRAME 4 IS UP, NOT WITH IT. Frame 4 is the break-apart drawing —
   * the one that does the actual dissipating — and it only appears at 56%. Measured by scrubbing:
   * with the fade running from 62% the box was already down to 0.37 opacity by 70%, so the frame
   * that is supposed to read as smoke coming apart was a ghost of itself before anyone saw it.
   * The easing is front-loaded, which eats timeline percentages faster than they look. */
  80%  { transform: translate(-50%, -55%) scale(1.2); opacity: .96 }
  100% { transform: translate(-50%, -58%) scale(1.28); opacity: 0 }
}
/* one window per frame; the last is the long one because frame 4 IS the dissipation */
@keyframes sq-poof-f1 { 0%, 17.9% { opacity: 1 } 18%, 100% { opacity: 0 } }
@keyframes sq-poof-f2 { 0%, 17.9% { opacity: 0 } 18%, 35.9% { opacity: 1 } 36%, 100% { opacity: 0 } }
@keyframes sq-poof-f3 { 0%, 35.9% { opacity: 0 } 36%, 55.9% { opacity: 1 } 56%, 100% { opacity: 0 } }
@keyframes sq-poof-f4 { 0%, 55.9% { opacity: 0 } 56%, 100% { opacity: 1 } }
#squeak-game-screen .sq-poof {
  position: absolute;
  left: 50%; top: 50%;
  width: var(--sq-poof-size); height: var(--sq-poof-size);
  transform: translate(-50%, -50%) scale(.52);
  animation: sq-poof-grow var(--sq-poof-ms) cubic-bezier(.2, .75, .3, 1) forwards;
  z-index: 7;
  pointer-events: none;
}
#squeak-game-screen .sq-poof img {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  object-fit: contain;
  /* ⛔⛔ transform: none IS LOAD-BEARING, AND ITS ABSENCE WAS THE WHOLE "THE POOF IS OFFSET" BUG.
   * .sq-fly img — the rule that centres a TRAVELLING PIECE inside its clone — also matches these,
   * because a poof is spawned as a .sq-fly. It applies translate(-50%, -50%), which is right for a
   * sprite positioned at left/top 50% and catastrophic for one positioned at inset: 0: the whole
   * cloud was drawn half its own size up and to the LEFT of the box it belonged to, and because
   * the box scales up over the clip, that offset GREW. The owner reported it as “offset and moves
   * to the left, at quite a distance from the actual cat tile” — which is precisely what it did.
   * ⚠️ Measuring the BOX said it was centred (dx 0 every sample) and measuring the box is what
   * hid this for a round: the box was innocent, its contents were not. */
  transform: none;
  opacity: 0;
  animation-duration: var(--sq-poof-ms);
  animation-timing-function: steps(1, end);
  animation-fill-mode: forwards;
}
/* ⛔⛔ THE FRAMES ARE NOT DRAWN CONCENTRICALLY, SO THEY ARE RE-CENTRED HERE.
 * The owner: "it should grow equally into all directions, so basically each frame of the clouds
 * should be placed on the same position… the smaller initial frame seemed to start off toward one
 * corner compared to the later frames." Measured with scripts/alpha-bbox.cjs — each frame’s INK
 * centre against its 256×256 canvas centre:
 *     cloud1  content 121×102   offset  -0.98%, +1.95%
 *     cloud2  content 204×188   offset   0,      0
 *     cloud3  content 223×227   offset  +1.37%, -0.59%
 *     cloud4  content 229×239   offset  -2.54%, +1.76%
 * Small numbers, but frame 1’s ink is only 59% the width of frame 2’s, so the eye reads the
 * mismatch as the cloud growing out of one corner rather than outward from a point.
 * Each frame is nudged by the NEGATIVE of its own offset, which makes the four concentric without
 * touching the art. ⚠️ If the PNGs are ever redrawn, re-run the script and update these four
 * lines — or centre the ink in the canvas at export time and set them all back to zero.
 * ⛔ The transform is free to use here: these imgs are animated on OPACITY only. */
#squeak-game-screen .sq-poof img:nth-child(1) { transform: translate(0.98%, -1.95%) }
#squeak-game-screen .sq-poof img:nth-child(3) { transform: translate(-1.37%, 0.59%) }
#squeak-game-screen .sq-poof img:nth-child(4) { transform: translate(2.54%, -1.76%) }
#squeak-game-screen .sq-poof img:nth-child(1) { animation-name: sq-poof-f1 }
#squeak-game-screen .sq-poof img:nth-child(2) { animation-name: sq-poof-f2 }
#squeak-game-screen .sq-poof img:nth-child(3) { animation-name: sq-poof-f3 }
#squeak-game-screen .sq-poof img:nth-child(4) { animation-name: sq-poof-f4 }

/* ⭐ THE CROUCH — what a thing about to become smoke does, and ⛔ NOT the mock's sq-squish.
 * The owner: "it plays a super quick squish animation on the cat (but its back to full size when
 * the smoke comes)". That is sq-squish doing exactly what it was written to do — it is a SPRING,
 * built for a cheese that survives being squeezed and has to be ready to squeeze again, so it
 * returns to scale(1,1) at 30% of its duration and holds there. A piece that is about to be
 * replaced needs the opposite shape: compress, and stay compressed until the cloud takes it.
 * So this one ends small and never springs back, and the poof lands on its last frame. */
@keyframes sq-crouch {
  0%   { transform: translate(-50%, -50%) scale(1, 1) }
  45%  { transform: translate(-50%, -44%) scale(1.14, .82) }
  100% { transform: translate(-50%, -40%) scale(.72, .6) }
}
#squeak-game-screen .sq-fly-doomed {
  position: absolute;
  left: 50%; top: 50%;
  width: 84%; height: 84%;
  transform: translate(-50%, -50%);
  animation: sq-crouch var(--sq-crouch-ms) cubic-bezier(.4, 0, .7, .4) forwards;
}
#squeak-game-screen .sq-fly-doomed img { width: 100%; height: 100%; object-fit: contain; }

@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-stinger[data-on="1"] { animation: none; opacity: 1; }
  #squeak-game-screen .sq-fly-doomed { animation: none; opacity: 0; }
  /* ⛔ The poof KEEPS its frames — it is what hides the substitution, and removing it would
   * expose the swap it exists to cover. It simply does not travel or scale. */
  #squeak-game-screen .sq-poof { animation: none; transform: translate(-50%, -50%) scale(1); }
  #squeak-game-screen .sq-poof img { animation: none; }
  #squeak-game-screen .sq-poof img:nth-child(3) { opacity: 1; }
}

/* ⭐ THE CRUSHED CHEESE NOW LEAVES, instead of reassembling and blinking out.
 * ⛔ THE ONE PLACE A VERBATIM LIFT NEEDED AN ENDING ADDED, and it is worth understanding why:
 * sq-squish (mock :456) returns to scale(1,1) at 30% and holds it to 100%, because in the mock it
 * loops forever and has to be ready to squash again. Played ONCE over a cheese that has just been
 * destroyed, that restores the cheese, holds it whole for a second, and then deletes it when the
 * clone expires — "the cheese remains even after the block has gone over it, then it just
 * vanishes", exactly as the owner reported.
 * The mock's frames are still lifted untouched. The exit is a SECOND animation on the parent
 * element, so the two never compete for `transform` and the spec stays verbatim. */
@keyframes sq-vanish {
  0%, 28%   { transform: scale(1); opacity: 1 }
  46%       { transform: scale(.72); opacity: .85 }
  70%, 100% { transform: scale(.12); opacity: 0 }
}
#squeak-game-screen .sq-fly-vanish { animation: sq-vanish .62s cubic-bezier(.4, 0, .6, 1) forwards; }

/* ⭐⭐ EATING A CHEESE PAYS OUT — the owner's ruling, and the move this project already uses for
 * coins and chips: the thing you won travels to the counter that now holds it, so the connection
 * is made by the motion rather than by a label.
 * ⛔ THIS LAYER IS ON .sq-body, NOT ON THE GRID. #sq-fx lives inside .sq-grid and could never
 * reach the seat cards, which are in the rail beside it; .sq-body is the nearest box that holds
 * both. ⛔ LOCKED LAYOUT: position:absolute + inset:0 over a position:relative parent contributes
 * nothing to layout or to .sq-root's max-content width — verified by measuring 774×631 after. */
#squeak-game-screen .sq-body { position: relative; }
#squeak-game-screen .sq-fx-root {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: visible;
  z-index: 8;
}
#squeak-game-screen .sq-rootfly {
  position: absolute;
  width: var(--sq-c);
  height: var(--sq-c);
  margin-left: calc(var(--sq-c) / -2);
  margin-top: calc(var(--sq-c) / -2);
  will-change: transform;
}
/* the bop: a quick hop on the spot before the trip, so the cheese reads as "collected" first */
@keyframes sq-bop {
  0%   { transform: translate(-50%, -50%) scale(1) }
  30%  { transform: translate(-50%, -76%) scale(1.22) }
  60%  { transform: translate(-50%, -44%) scale(.94) }
  100% { transform: translate(-50%, -50%) scale(1.06) }
}
#squeak-game-screen .sq-fly-bop {
  position: absolute;
  left: 50%; top: 50%;
  width: 84%; height: 84%;
  transform: translate(-50%, -50%);
  animation: sq-bop .3s cubic-bezier(.3, 1.5, .5, 1) forwards;
}
#squeak-game-screen .sq-fly-bop img { width: 100%; height: 100%; object-fit: contain; }

/* and the counter answers as it lands, so the arrival is acknowledged rather than just ending */
@keyframes sq-tally {
  0%, 100% { transform: scale(1) }
  40%      { transform: scale(1.3) }
}
#squeak-game-screen .sq-cheesecount[data-got="1"] { animation: sq-tally .42s cubic-bezier(.3, 1.5, .5, 1); }

@media (prefers-reduced-motion: reduce) {
  /* The crushed cheese must still LEAVE — that is information, not decoration — so it fades
   * instead of shrinking. The payout keeps its counter flash and drops the journey. */
  #squeak-game-screen .sq-fly-vanish { animation: none; opacity: 0; }
  #squeak-game-screen .sq-fly-bop { animation: none; }
  #squeak-game-screen .sq-rootfly { transition: none !important; display: none; }
  #squeak-game-screen .sq-cheesecount[data-got="1"] { animation: none; }
  #squeak-game-screen .sq-seatcoin[data-got="1"] { animation: none; }
}

/* ⛔⛔ THE BOARD IS A VIEWPORT FOR TRAVEL, AND NOT FOR ANYTHING ELSE.
 * #sq-fx is overflow:visible on purpose — a rising ghost has to leave the board. But a
 * WRAPPING push must be clipped at the wall: the row leaves one edge and arrives at the other,
 * and a clone drifting outside the board on its way out contradicts the wrap gates. So travel
 * lives in this clipped child and every other effect in the visible parent.
 * ⛔ clip-path, NOT overflow:hidden with an inset box: clip-path clips without establishing a
 * new coordinate origin, so a clone still positions against the grid exactly as before. An
 * inset container would have shifted EVERY travel clone by the wall width, including the
 * non-wrapping ones that were previously correct.
 * ⚠️ The inset is --sq-pad, the wall ring, so a piece disappears exactly where the floor ends
 * rather than at the outer edge of the board’s frame. */
#squeak-game-screen .sq-fx-clip {
  position: absolute;
  inset: 0;
  clip-path: inset(var(--sq-pad));
  pointer-events: none;
}

/* ══════════════════════════════════════════════════════════════════════════════
 * FEEL ROUND 3 — the owner's third playtest (2026-09-20)
 * ══════════════════════════════════════════════════════════════════════════════ */

/* ⭐ THE IDLE BOB, ON THE BOARD AT LAST — item 4 of the original list, and the one thing from it
 * that had not been done. The mock applies sq-bob only to the ghost token in a seat card (:621),
 * so putting it on board pieces is an extension rather than a lift; the curve is still the mock's.
 * ⛔ DE-PHASED BY TILE INDEX, NEVER BY A ROLL. --sq-phase is written by paint() from the tile's own
 * index, so two clients watching one table see the same board breathing the same way. Pieces
 * bobbing in lockstep read as one mechanism twitching; out of phase they read as creatures.
 * ⛔ PAUSED WHILE A PIECE IS TRAVELLING. The sprite is hidden then (its clone is in the air), but
 * an animation left running would land it at a random point in the cycle — a visible hitch exactly
 * when the eye is on it. Pausing means it resumes from where it left off. */
@keyframes sq-bob-board {
  0%, 100% { transform: translate(-50%, -50%) }
  50%      { transform: translate(-50%, -58%) }
}
#squeak-game-screen .sq-mouse,
#squeak-game-screen .sq-cat {
  animation: sq-bob-board var(--sq-bob-ms) ease-in-out infinite;
  animation-delay: calc(var(--sq-phase, 0) * -0.37s);
}
/* the cats breathe a little slower and shallower than the mice — they are stalking, not scurrying */
#squeak-game-screen .sq-cat { animation-duration: calc(var(--sq-bob-ms) * 1.25); }
#squeak-game-screen .sq-art[data-travel="1"] { animation-play-state: paused; }

/* ⭐⭐ THE MOUSE EATS — "maybe we should make the mouse head grow slightly in size, and then shrink
 * again (kind of squishy motion)". A proper squash-and-stretch: anticipate down, punch up, settle.
 * ⛔ It goes on the MOUSE, not on the cheese. The cheese's job is the flight to the counter; the
 * reaction belongs to the thing that did the eating, which is what sells it as a reward.
 * ⛔ It must out-specify the idle bob above, or the two fight over `transform` and the gulp loses. */
@keyframes sq-gulp {
  0%   { transform: translate(-50%, -50%) scale(1, 1) }
  18%  { transform: translate(-50%, -46%) scale(1.1, .86) }
  46%  { transform: translate(-50%, -62%) scale(.92, 1.3) }
  70%  { transform: translate(-50%, -48%) scale(1.12, .92) }
  100% { transform: translate(-50%, -50%) scale(1, 1) }
}
#squeak-game-screen .sq-mouse[data-ate="1"] {
  animation: sq-gulp var(--sq-gulp-ms) cubic-bezier(.3, 1.4, .5, 1);
}

/* ⭐⭐ THE CHEESE IS CRUSHED *BY THE BLOCK*, IN THE BLOCK'S DIRECTION — the owner's note, and it is
 * a much better idea than what was there. Before, the cheese squashed symmetrically in place,
 * which reads as the cheese deciding to collapse. Squashing it against the wall the block is
 * arriving from reads as the block DOING it: the transform-origin is the far side, so the sprite
 * compresses into the corner the block is pushing it towards, over exactly the block's flight time.
 * ⛔ The four origins are keyed off the push direction the same way the arrowhead's rotations are
 * (0 = UP, 1 = RIGHT, 2 = DOWN, 3 = LEFT — squeak-rules.js :118).
 * ⛔ Duration is SQ_FLY_MS, written in from JS, so the squeeze and the block arrive together. */
@keyframes sq-press-x {
  0%   { transform: translate(-50%, -50%) scaleX(1) scaleY(1) }
  70%  { transform: translate(-50%, -50%) scaleX(.42) scaleY(1.16) }
  100% { transform: translate(-50%, -50%) scaleX(.26) scaleY(1.24) }
}
@keyframes sq-press-y {
  0%   { transform: translate(-50%, -50%) scaleY(1) scaleX(1) }
  70%  { transform: translate(-50%, -50%) scaleY(.42) scaleX(1.16) }
  100% { transform: translate(-50%, -50%) scaleY(.26) scaleX(1.24) }
}
#squeak-game-screen .sq-fly-press { position: absolute; left: 50%; top: 50%; width: 84%; height: 84%; }
#squeak-game-screen .sq-fly-press img { width: 100%; height: 100%; object-fit: contain; }
/* pushed RIGHT → it piles up against the right edge of its tile, and so on */
#squeak-game-screen .sq-fly-press[data-dir="1"] { transform-origin: right center; animation-name: sq-press-x; }
#squeak-game-screen .sq-fly-press[data-dir="3"] { transform-origin: left center;  animation-name: sq-press-x; }
#squeak-game-screen .sq-fly-press[data-dir="2"] { transform-origin: center bottom; animation-name: sq-press-y; }
#squeak-game-screen .sq-fly-press[data-dir="0"] { transform-origin: center top;    animation-name: sq-press-y; }
#squeak-game-screen .sq-fly-press {
  animation-timing-function: cubic-bezier(.45, 0, .85, .5);
  animation-fill-mode: forwards;
}

/* ⭐ THE DEATH MARK — replaces the lingering ghost sprite.
 * The owner's first note asked for the ghosts to rise; their third said the ghosts "still linger
 * around where they died", which closes the open ruling: the ghost is the EVENT, not the marker.
 * What stays is a small stain, deterministic by tile, that says "someone died here" until that
 * mouse respawns — without a creature sitting on the board drawing the eye forever.
 * ⛔ Drawn as one element's box-shadow, the same determinism trick as the splat ring. */
/* ⭐ THE DEATH SPOT IS A FAINT RED X (owner, 2026-09-22: "only the rising ghost, but maybe a faint red X
 * on the floor tile"). It replaced the three-drop stain. It is two rounded bars, drawn under the pieces
 * (z 1), and it stays until that mouse respawns. It FADES IN once the ghost has risen (its data-travel
 * clears), and it FADES OUT on respawn: paint() drops a fading copy on the FX layer at the moment the
 * tile stops drawing it (fxMarkFade), so it never just blinks away. */
#squeak-game-screen .sq-mark {
  position: absolute;
  left: 50%; top: 50%;
  width: 46%; height: 46%;
  transform: translate(-50%, -50%);
  pointer-events: none;
  /* ⛔⛔ z 0, UNDER EVERY PIECE (owner, 2026-09-22). The same stacking lesson as the cat-intent line and
   * the walk dots: a mark that says "this happened HERE" goes beneath what stands on it. Sprites are
   * 1–3 (trap 1, cheese/coin 2, mouse/cat 3), and at an equal z the mark would win, because it is
   * written LAST in the tile. */
  z-index: 0;
  opacity: var(--sq-mark-op);
  animation: sq-mark-in .5s ease-out both;
}
#squeak-game-screen .sq-mark::before,
#squeak-game-screen .sq-mark::after {
  content: '';
  position: absolute;
  left: 50%; top: 50%;
  width: 100%; height: 16%;
  border-radius: 999px;
  background: var(--sq-mark-red);
  transform: translate(-50%, -50%) rotate(45deg);
}
#squeak-game-screen .sq-mark::after { transform: translate(-50%, -50%) rotate(-45deg); }
@keyframes sq-mark-in  { from { opacity: 0; } to { opacity: var(--sq-mark-op); } }
@keyframes sq-mark-out { from { opacity: var(--sq-mark-op); } to { opacity: 0; } }
/* the fading copy (fxMarkFade), placed INSIDE the tile at z 0, so it fades under whatever stands there */
#squeak-game-screen .sq-mark.sq-mark-out { animation: sq-mark-out var(--sq-mark-out-ms) ease-in both; }
/* hidden while its own death is still being played out, so the mark cannot appear before the ghost */
#squeak-game-screen .sq-mark[data-travel="1"] { visibility: hidden; animation: none; }
#squeak-game-screen .sq-root { --sq-mark-red: #c0392b; --sq-mark-op: .42; --sq-mark-out-ms: 700ms; }

@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-mouse,
  #squeak-game-screen .sq-cat,
  #squeak-game-screen .sq-mouse[data-ate="1"] { animation: none; }
  /* the press still has to END compressed — it is the reason the cheese is gone */
  #squeak-game-screen .sq-fly-press { animation: none; opacity: 0; }
}

/* ⭐ THE DOOMED MOUSE — the clone that exists only so the cloud has something to take away.
 * It stands still (the model already removed the real one) until its killer lands, then braces
 * for one crouch and the poof swallows it. data-crouch is flipped by squeak.js at the moment the
 * cat arrives, so the brace is tied to the killer's arrival rather than to a timer of its own.
 * ⛔ Below the cat's travel clone in z, so the cat lands ON the mouse rather than behind it. */
#squeak-game-screen .sq-fly[data-kind="mouse"] { z-index: 2; }
#squeak-game-screen .sq-fly[data-kind="cat"]   { z-index: 3; }
#squeak-game-screen .sq-doomed-mouse {
  position: absolute;
  left: 50%; top: 50%;
  width: 84%; height: 84%;
  transform: translate(-50%, -50%);
}
/* ⛔ The mouse sprite-weight correction goes on the IMG, not here — sq-crouch owns this span's
 * transform and starts from scale(1,1), so a 1.2 written on the span would be dropped the instant
 * the crouch began and the mouse would shrink a fifth just before it is poofed away. */
#squeak-game-screen .sq-doomed-mouse img { width: 100%; height: 100%; object-fit: contain; transform: scale(1.2); }
#squeak-game-screen .sq-doomed-mouse[data-crouch="1"] {
  animation: sq-crouch var(--sq-crouch-ms) cubic-bezier(.4, 0, .7, .4) forwards;
}
@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-doomed-mouse[data-crouch="1"] { animation: none; opacity: 0; }
}

/* ⭐⭐ SQUASH AND STRETCH ON A TRAVELLING MOUSE — "imagine throwing a waterballoon → it changes
 * shape during the acceleration, then stops and bouncily returns its shape".
 *
 * ⛔⛔ IT COMPRESSES ALONG THE DIRECTION OF TRAVEL, NOT ACROSS IT, AND THE FIRST VERSION HAD THAT
 * BACKWARDS. The textbook squash-and-stretch stretches a fast object ALONG its motion vector (a
 * falling ball goes tall), and that is what was built — but it is not what was asked for. The
 * brief said "slightly squish them towards the direction they're moving (LIKE WE DO WITH THE
 * CHEESE, but not as much)", and the cheese press compresses into the wall it is driven at. So
 * the model here is a body being squeezed by its own acceleration, which then RELEASES on
 * arrival — not a body smeared by speed. Read back as "the squish motion directions are
 * flipped", and the mapping was innocent: vertical travel really did get the y curve. The
 * convention was wrong, not the wiring.
 *
 * ⛔ THE RELEASE IS AT 53%, WHICH IS WHERE THE PIECE ACTUALLY LANDS, and that is why
 * SQ_SETTLE_MS is DERIVED from SQ_FLY_MS rather than typed: flight and bounce are ONE keyframe
 * list, so the proportion is baked in here (200 / (200 + 180) = 52.6%). The previous version
 * also peaked its impact at 68% — a full 57ms AFTER the piece had stopped, which is part of why
 * the bounce read as detached from the landing.
 *
 * ⛔⛔ EXACTLY ONE EXTREMUM AFTER THE RELEASE, AND IT IS SMALLER THAN THE SQUASH IT LETS GO OF.
 * This number came down twice. Three overshoots decaying by half each read as "too jittery…
 * either too many bounces, or not enough reduction each bounce (kind of like happens if you use
 * a bounce interpolation curve in Blender, it nicely reduces bounces)"; two, decaying hard, was
 * still "too harsh, a bit too jittery… it feels like some glitchy jittery thing". The owner then
 * wrote the sequence out in full and it is the spec: "mouse moves, gets slightly squashed →
 * mouse stops → VERY SLIGHT overshoot as the squishing reduces since the mouse has stopped →
 * then goes to normal. So less multiple jiggles, just one overshoot → then correct shape."
 * ⚖️ So the release is a RELAXATION, not a bounce: .06 of overshoot against .15 of squash. A
 * second extremum is the thing being removed here — do not reintroduce one as "decay"; a test
 * now pins the count at one and pins the amplitude below the squash's.
 *
 * ⛔ LINEAR, with the shaping in the stop positions — an ease applied to each interval fights the
 * rebound, and the tail is left long on purpose so the last 6% bleeds off rather than snapping.
 * ⛔ It scales the WRAPPER, never the img: the img carries the mouse sprite-weight correction
 * (scale 1.2) and the two would overwrite each other. */
@keyframes sq-squash-x {
  0%   { transform: scale(1, 1) }
  14%  { transform: scale(.82, 1.12) }     /* squeezed INTO the direction of travel */
  53%  { transform: scale(.85, 1.10) }     /* still squeezed — this is the moment it lands */
  62%  { transform: scale(1.06, .96) }     /* the squeeze letting go: ONE very slight overshoot */
  100% { transform: scale(1, 1) }          /* …and straight back to shape. No second jiggle. */
}
@keyframes sq-squash-y {
  0%   { transform: scale(1, 1) }
  14%  { transform: scale(1.12, .82) }
  53%  { transform: scale(1.10, .85) }
  62%  { transform: scale(.96, 1.06) }
  100% { transform: scale(1, 1) }
}
#squeak-game-screen .sq-fly-squash {
  position: absolute;
  inset: 0;
  animation-duration: var(--sq-squash-ms);
  animation-timing-function: linear;
  animation-fill-mode: forwards;
}
#squeak-game-screen .sq-fly-squash[data-axis="x"] { animation-name: sq-squash-x }
#squeak-game-screen .sq-fly-squash[data-axis="y"] { animation-name: sq-squash-y }

@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-fly-squash { animation: none; }
}

/* ── ⭐⭐ RAGE (2026-09-21) — the chew and the ENRAGED indicator ─────────────────────────────
 * Knobs on .sq-root like every other feel number, read by squeak.js through cssMs().
 * ⚖️ DEBRIS IS TWO GREYS ON PURPOSE: a chip has to show against the FLOOR (#cdc9be) and against a
 * neighbouring BLOCK (#907c5d), and no single grey clears 3:1 on both. The dark chips carry the
 * floor, the light ones carry the blocks, and alternating them means every surface keeps pieces. */
#squeak-game-screen .sq-root {
  --sq-chew-ms: 260ms;
  --sq-debris: #efebe2;
  --sq-debris-dark: #4a453e;
  --sq-rage-flash-ms: 460ms;
  --sq-rage-hot: #ff6a45;
  --sq-rage-warm: #ffc83d;
}
/* The chewed block/trap: the clone just SHRINKS to nothing behind the poof (the owner's words). One
 * interval, one curve (rule 4). The block look is .sq-fly[data-kind="block"]::after, inherited. */
@keyframes sq-chew { from { transform: scale(1); opacity: 1 } to { transform: scale(0); opacity: .4 } }
#squeak-game-screen .sq-fly-chewed { animation: sq-chew var(--sq-chew-ms) cubic-bezier(.55, 0, .9, .45) forwards; }
#squeak-game-screen .sq-fly-chewed[data-kind="trap"] img { transform: translate(-50%, -50%); }
/* Debris = the splat ring, squared off into chips of rock rather than drops of cheese. */
/* ⛔ z-index 8: OVER the poof (z 7). Under it, the debris was invisible — it spent its whole flight
 * inside an opaque cloud bigger than the tile. */
#squeak-game-screen .sq-fly-debris { border-radius: 1px; background: var(--sq-debris-dark); z-index: 8; }

/* The ENRAGED node: a tile-sized box on #sq-fx that glides with the mouse. Invisible when off, and
 * never eats a tap. */
#squeak-game-screen .sq-rage {
  position: absolute;
  pointer-events: none;
  z-index: 9;
  opacity: 0;
}
#squeak-game-screen .sq-rage[data-on="1"] { opacity: 1; }
/* Fire: a fixed handful of big soft flames around the mouse, each rising and fading on its own
 * fixed delay (rule 5 — no roll). Few and big, the owner's standing note on particles. */
@keyframes sq-flame {
  from { transform: translate(-50%, -50%) scale(1); opacity: .95 }
  to   { transform: translate(-50%, -150%) scale(.35); opacity: 0 }
}
#squeak-game-screen .sq-flame {
  position: absolute;
  width: 34%; height: 34%;
  border-radius: 50% 50% 45% 45%;
  background: radial-gradient(circle at 50% 65%, var(--sq-rage-warm) 0 30%, var(--sq-rage-hot) 55%, rgba(255, 106, 69, 0) 72%);
  animation: sq-flame .66s cubic-bezier(.3, .6, .5, 1) infinite;
}
/* The chip: dark like every other pill, with the TEXT and a glow flashing hot ↔ warm. Two keyframes
 * and `alternate` — a pulse is the one genuine direction change rule 4 allows. */
@keyframes sq-rage-flash {
  from { color: var(--sq-rage-hot);  box-shadow: 0 0 0 2px var(--sq-rage-hot), 0 0 10px rgba(255, 106, 69, .7) }
  to   { color: var(--sq-rage-warm); box-shadow: 0 0 0 2px var(--sq-rage-warm), 0 0 12px rgba(255, 200, 61, .75) }
}
#squeak-game-screen .sq-ragechip {
  left: calc(50% + var(--sq-chip-shift, 0px));
  top: -34%;
  --sq-pill-bg: #211a14;
  letter-spacing: .08em;
  animation: sq-rage-flash var(--sq-rage-flash-ms) ease-in-out infinite alternate;
  /* ⭐ It SLIDES between above and below, and along the edge clamp, instead of jumping. The same curve
   * and length as a push (SQ_FLY_MS 200ms), so it moves with the mouse it is riding on. */
  transition: top .2s cubic-bezier(.32, .72, .35, 1), left .2s cubic-bezier(.32, .72, .35, 1);
}
/* ⭐ TOP ROW: below the mouse, tail up (squeak.js paintRage sets data-tail from the mouse's row).
 * 134% mirrors -34% about the tile's centre (50% ± 84%), so the gap to the mouse is the same both ways. */
#squeak-game-screen .sq-ragechip[data-tail="up"] { top: 134%; }
@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-ragechip { transition: none; }
}
/* ⭐ THE TAIL — a shared pill variant, because the owner asked for pointers on every chip: the tail
 * says exactly what the chip is about. `--sq-chip-shift` moves the tail against a clamped chip so it
 * still lands on the target. Colour follows the pill's own background. */
#squeak-game-screen .sq-pill[data-tail]::after {
  content: '';
  position: absolute;
  width: 0; height: 0;
  border: 6px solid transparent;
}
#squeak-game-screen .sq-pill[data-tail="down"]::after,
#squeak-game-screen .sq-pill[data-tail="up"]::after { left: calc(50% - var(--sq-chip-shift, 0px)); transform: translateX(-50%); }
#squeak-game-screen .sq-pill[data-tail="left"]::after,
#squeak-game-screen .sq-pill[data-tail="right"]::after { top: calc(50% - var(--sq-chip-shift-y, 0px)); transform: translateY(-50%); }
#squeak-game-screen .sq-pill[data-tail="down"]::after  { top: 100%;    border-top-color: var(--sq-pill-bg);    border-bottom-width: 0; }
#squeak-game-screen .sq-pill[data-tail="up"]::after    { bottom: 100%; border-bottom-color: var(--sq-pill-bg); border-top-width: 0; }
#squeak-game-screen .sq-pill[data-tail="right"]::after { left: 100%;   border-left-color: var(--sq-pill-bg);   border-right-width: 0; }
#squeak-game-screen .sq-pill[data-tail="left"]::after  { right: 100%;  border-right-color: var(--sq-pill-bg);  border-left-width: 0; }
/* The enraged sprite glows hot until the owner's own enraged faces land (squeak.js SQ_RAGE_ART). */
#squeak-game-screen .sq-mouse[data-rage="1"] img { filter: drop-shadow(0 0 3px var(--sq-rage-hot)) drop-shadow(0 0 6px rgba(255, 200, 61, .6)); }

@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-flame { animation: none; opacity: .8; transform: translate(-50%, -50%) scale(.8); }
  #squeak-game-screen .sq-ragechip { animation: none; color: var(--sq-rage-warm); box-shadow: 0 0 0 2px var(--sq-rage-hot); }
  #squeak-game-screen .sq-fly-chewed { animation-duration: 1ms; }
}

/* ── ⭐ THE PREVIEW CHIPS ARE LATE (owner, 2026-09-21) ─────────────────────────────────────────
 * "Only make them visible after some time of hovering instead of instantly." Every chip showPreview
 * draws carries data-late and fades in after `--sq-chip-delay-ms`. squeak.js will not re-insert the
 * same preview (grid.__sqPv), so a repaint cannot restart the dwell. One interval, one curve. */
#squeak-game-screen .sq-root { --sq-chip-delay-ms: 2000ms; }
@keyframes sq-pill-late { from { opacity: 0 } to { opacity: 1 } }
#squeak-game-screen .sq-pill[data-late] {
  opacity: 0;
  animation: sq-pill-late .22s ease-out var(--sq-chip-delay-ms) forwards;
}

/* ── ⭐ THE WRAP AS MOTION — marching chevrons in the mover's colour (owner, 2026-09-21) ─────────
 * A mask over a colour, so the seat hue is just --sq-chev-c. Lightly transparent, under the chips,
 * over the ghosts (the owner's mock draws the arrows across the hatched boxes). data-flip turns a
 * right/down strip into a left/up one about its own centre. */
#squeak-game-screen .sq-chev {
  position: absolute;
  pointer-events: none;
  z-index: 5;
  opacity: .62;
  background: var(--sq-chev-c, var(--sq-mouse1));
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'><path d='M1 1h3l4 4-4 4H1l4-4z'/></svg>");
          mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'><path d='M1 1h3l4 4-4 4H1l4-4z'/></svg>");
  -webkit-mask-repeat: repeat-x; mask-repeat: repeat-x;
  -webkit-mask-size: var(--sq-chev-s) 100%; mask-size: var(--sq-chev-s) 100%;
  animation: sq-chev-x .7s linear infinite;
}
#squeak-game-screen .sq-chev[data-axis="y"] {
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'><path d='M1 1v3l4 4 4-4V1L5 5z'/></svg>");
          mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'><path d='M1 1v3l4 4 4-4V1L5 5z'/></svg>");
  -webkit-mask-repeat: repeat-y; mask-repeat: repeat-y;
  -webkit-mask-size: 100% var(--sq-chev-s); mask-size: 100% var(--sq-chev-s);
  animation-name: sq-chev-y;
}
#squeak-game-screen .sq-chev[data-flip="1"] { transform: scale(-1, -1); }
@keyframes sq-chev-x { from { -webkit-mask-position: 0 0; mask-position: 0 0 } to { -webkit-mask-position: var(--sq-chev-s) 0; mask-position: var(--sq-chev-s) 0 } }
@keyframes sq-chev-y { from { -webkit-mask-position: 0 0; mask-position: 0 0 } to { -webkit-mask-position: 0 var(--sq-chev-s); mask-position: 0 var(--sq-chev-s) } }
@media (prefers-reduced-motion: reduce) {
  #squeak-game-screen .sq-chev { animation: none; }
  #squeak-game-screen .sq-pill[data-late] { animation-duration: 1ms; }
}
