/* SewConnect — upper-atmosphere theme.
   A bright purple sky: lavender→periwinkle gradient, white glass cards,
   birds drifting by, stars only barely visible at the very top. */

@font-face {
  font-family: 'Figtree';
  font-style: normal;
  font-weight: 300 900;
  font-display: swap;
  src: url(fonts/figtree-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Figtree';
  font-style: normal;
  font-weight: 300 900;
  font-display: swap;
  src: url(fonts/figtree-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* The diary's hand. Bundled rather than a `cursive` stack, because Android
   ships no handwriting face at all — on the actual product the stack resolves
   to Roboto and the diary just looks like the rest of the app in italics.
   Caveat, SIL OFL 1.1, licence beside the files. Only text that uses it pulls
   it down, so it costs nothing until the diary is opened. */
@font-face {
  font-family: 'Caveat';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(fonts/caveat-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Caveat';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(fonts/caveat-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  /* The sky, R19 item 6. These are the defaults; applySky() in app.js
     overwrites all three on the root element from the chosen sky. */
  --sky-top: #EAE4FC;
  --sky-mid: #CDBFF3;
  --sky-low: #A48DE2;
  /* The frosted bar behind the nav and the chat header. A property rather than
     a literal so it follows the chosen sky — a lavender dock under a green page
     is the seam that says a theme was only half applied. */
  --sky-dock: #E2D9FA;
  --ink: #2B2350;
  --ink-soft: #473C78;
  --muted: #665C97;
  --dim: #7F75AB;
  --faint: #9A91C4;
  --card: rgba(255, 255, 255, 0.52);
  --card-brd: rgba(88, 70, 160, 0.16);
  --chip-bg: rgba(255, 255, 255, 0.46);
  --accent: #7C5CE0;
  --accent-dim: rgba(124, 92, 224, 0.3);
  --accent-ink: #FFFFFF;
  --good: #2F9F7B;
  --bad: #D9558C;
  --flow: #E0699E;
  --fertile: #4A79C9;
  /* the diary's paper and ink — warm, so it reads as a page rather than a card */
  --paper: #FFFBF0;
  --paper-rule: #C9D8EF;
  --paper-margin: #EFA9BE;
  --pen: #3A4E86;
  --pen-soft: #7C8AB4;
}

/* [hidden] must always beat any display rule. */
[hidden] { display: none !important; }

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

html, body { margin: 0; padding: 0; height: 100%; }

/* ⚠️ SCROLL ANCHORING, OFF, ON THE ONE SCROLLER THAT IS NEVER MEANT TO MOVE.
   2026-09-11, found by ENGINE=83. `adopt-ui` reported the Adopt button ending
   at 753 with the tab bar starting at 744 — and the button had not moved, the
   whole APP had:

       83  : document.documentElement.scrollTop = 12   navTop 744
       152 : document.documentElement.scrollTop =  0   navTop 756

   Nothing was touched and nothing was focused. The bottom 12px of #nav was off
   the bottom of the phone on every signed-in screen, not just that one.

   There IS something to scroll, on both engines: `.screen:not(.active)` is
   `transform: translateY(12px)` and `.page:not(.active)` another
   `translateY(10px)`, they nest, and a transform contributes scrollable
   overflow even at `opacity: 0` — so 844 of viewport carries 866 of content.
   ⚠️ `body { overflow: hidden }` does NOT clip that: an overflow on the body
   PROPAGATES to the viewport while `html` is `visible`, so the body never
   clips, and a viewport that is `hidden` is still scrollable by script and by
   the engine. Which is what happened: the active screen animates from +12 to 0,
   Chromium 83's scroll anchoring reads that as content moving up and scrolls
   the root down by exactly 12 to compensate. Modern Chrome suppresses
   anchoring here; 83 does not.

   Proved by reversal, not by reasoning: with this one line the root stays at 0
   on 83 and nothing changes on 152; take it off again and 83 goes back to 12.

   ⚠️ ON `html` ALONE, AND `html, body` IS THE WRONG WRITING OF IT. This
   property is not inherited: on the root it turns anchoring off for the
   VIEWPORT scroller, which is the one misbehaving. Adding `body` would exclude
   the entire body subtree from anchor selection and take anchoring away from
   `.page-body`, `.messages` and every other inner scroller, which want it.

   ⚠️ AND NOT `#app { overflow: hidden }`, WHICH ALSO FIXES IT AND WAS
   WRITTEN FIRST. Clipping #app takes the root's scrollHeight to 844 and cures
   the symptom — and blinds the seven harnesses that ask
   `documentElement.scrollWidth - clientWidth` whether a screen runs off the
   side of the phone (adopt-ui, chat-ui, c4, you-ui, avatar-ui, difficulty-ui,
   ingame-chat). A fix that makes seven assertions pass no matter what the app
   does is worse than the bug. */
html { overflow-anchor: none; }

body {
  font-family: 'Figtree', system-ui, sans-serif;
  color: var(--ink);
  background: var(--sky-mid);
  min-height: 100vh; min-height: 100dvh;
  overflow: hidden;
  /* Kills double-tap-to-zoom (and the click delay that comes with it) on every
     screen. ⚠️ It does NOT stop pinch -- "manipulation" is an alias for
     "pan-x pan-y pinch-zoom", so pinch is still permitted here; the viewport tag
     in index.html is what stops that. Safe against the games: touch-action is
     intersected down the ancestor chain, so this can only ever restrict, and the
     per-board "touch-action: none" rules still swallow everything. Panning is
     still allowed, so no scroll container changes. */
  touch-action: manipulation;
  /* This is a toy, not a document. A long press should pet the animal, not
     start a text selection or raise a "copy / share" bar over the room. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
/* Except where the words are the point: what you wrote to each other, and
   anything you are typing. */
.messages, .bubble, input, textarea { -webkit-user-select: text; user-select: text; }

/* Tapping a control must not leave a focus box sitting on it afterwards. On
   touch this fires on every tap; :focus-visible still keeps a real ring for
   anyone driving the app from a keyboard. */
button:focus, [role="button"]:focus, a:focus { outline: none; }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* SVG groups draw the UA ring as a hard black rectangle — the boxes that were
   showing up around the fridge, the arcade and the notepad. */
svg .hot:focus, svg .hot:focus-visible, svg [tabindex]:focus { outline: none; }

#sky {
  position: fixed;
  top: 0; right: 0; bottom: 0; left: 0;
  z-index: 0;
  background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-mid) 48%, var(--sky-low) 100%);
}

#app { position: relative; z-index: 1; height: 100vh; height: 100dvh; display: flex; flex-direction: column; }

input, button, textarea, select { font-family: 'Figtree', system-ui, sans-serif; }
input:focus, textarea:focus { outline: none; }
::placeholder { color: #8D84B8; opacity: 1; }
::-webkit-scrollbar { display: none; }
button { cursor: pointer; }

@keyframes glowPulse { 0%,100% { opacity: .55; } 50% { opacity: 1; } }
/* glowPulse's half-fade suits a small sparkle glyph but leaves the logo looking
   washed out, so the mark breathes instead of blinking. */
@keyframes markBreathe {
  0%,100% { transform: translateY(0) scale(1); opacity: .9; }
  50% { transform: translateY(-5px) scale(1.03); opacity: 1; }
}
@keyframes floaty { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
@keyframes popIn { 0% { transform: scale(.5); opacity: 0; } 70% { transform: scale(1.08); } 100% { transform: scale(1); opacity: 1; } }
@keyframes riseIn { 0% { transform: translateY(14px); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } }
@keyframes dotB { 0%,60%,100% { opacity: .25; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } }
@keyframes petBob { 0%,100% { transform: translateY(0) rotate(-1deg); } 50% { transform: translateY(-7px) rotate(1.4deg); } }
@keyframes petSleep { 0%,100% { transform: scaleY(1) translateY(0); } 50% { transform: scaleY(0.96) translateY(2px); } }
@keyframes heartUp { 0% { transform: translateY(0) scale(.6); opacity: 0; } 20% { opacity: 1; } 100% { transform: translateY(-70px) scale(1.25); opacity: 0; } }
@keyframes zz { 0% { transform: translate(0,0); opacity: 0; } 25% { opacity: .9; } 100% { transform: translate(14px,-34px); opacity: 0; } }
@keyframes toastIn { 0% { transform: translate(-50%, 18px); opacity: 0; } 100% { transform: translate(-50%, 0); opacity: 1; } }

/* ---------- the boot cloak (P47) ---------- */
/*
 * Deniz, P47: "when the app opens, for a brief moment so many things flash
 * at screen, many icons, illustrations and stuff."
 *
 * Measured on his own phone (WebView 83) rather than guessed. The flash is
 * NOT "every page is in the DOM until JavaScript hides it" -- .screen and
 * .page below are opacity:0 in their BASE rules and this stylesheet is
 * render-blocking, so the app is invisible from the first paint. A build
 * with a deliberate six-second pause at the top of boot() showed nothing
 * but empty sky for all six seconds.
 *
 * What actually flashes is the app ASSEMBLING ITSELF: show() lights #main
 * and the bottom tab bar appears alone over the sky for ~650ms, then
 * goPage() fades a page in under it, and then the tab bar rewrites its own
 * contents. About 1.3 seconds of half-built app on that engine.
 *
 * So the cloak is held until the router has chosen AND painted its first
 * page -- revealApp() in public/app.js.
 *
 * VISIBILITY, NEVER display:none. See the note above PAGES in public/app.js:
 * a .page "measures its full height while being invisible and untappable",
 * and every assertion of the shape "the panel is N px tall" depends on it.
 * display:none would take the boxes out of layout, keep all of those green,
 * and silently measure nothing. visibility:hidden preserves layout exactly.
 *
 * The cloak is ON BY DEFAULT and lifted by adding .sew-booted, so it needs
 * no script to arm it and cannot lose the race with first paint. It cannot
 * get stuck: public/index.html carries an inline timeout that lifts it even
 * if app.js never loads at all.
 */
html:not(.sew-booted) #app,
html:not(.sew-booted) #sheet,
html:not(.sew-booted) #sheet-veil,
html:not(.sew-booted) #update-bar { visibility: hidden; }

/* The first screen arrives FINISHED. Both rules below carry an opacity
   transition, and lifting the cloak in the middle of one is the same flash
   in a prettier costume: a tab bar at full strength over a page at 30%. */
html.sew-boot-instant .screen,
html.sew-boot-instant .page { transition: none !important; }

/* ---------- the cloak's FACE: it IS the splash screen (P50) ----------
 *
 * Deniz, P50: "splash logo stays for so short, there is a period where only the
 * flying birds background is shown and the foreground elements are still
 * loading, I want that period to be hidden behind the splash screen too."
 *
 * ⚠️ THE CLOAK WAS ALREADY THERE AND ALREADY HELD LONG ENOUGH. What was wrong
 * is that it was INVISIBLE: it hid #app with visibility:hidden and deliberately
 * left #sky showing, so the native splash handed over to a DIFFERENT PICTURE —
 * the bird canvas — and the change of picture is the thing he is describing.
 * "The splash is too short" and "the cloak has no face" are the same defect.
 *
 * So the handover has to be invisible, which means matching the native launch
 * screen exactly rather than approximately. Read out of
 * android/app/src/main/res/ (owned by another agent; matched, never edited):
 *
 *   values/colors.xml       splashBackground  #C8B9F1
 *   drawable/splash.xml     that colour, with the mark centred over it
 *   drawable/splash_mark.xml  two overlapping outlined hearts, stroke #A742BA,
 *                             width 5 on a 120 viewport, round caps and joins,
 *                             160dp — and 1dp is 1 CSS px in a WebView.
 *
 * ⚠️ If any of those three files changes, this rule and the <svg> in
 * public/index.html change with it, or the app blinks on launch again.
 *
 * ⚠️ NOT display: none, in either state. The note above PAGES in public/app.js
 * is why: a .page measures its full height while invisible, and every assertion
 * of the form "the panel is N px tall" depends on that staying true. This
 * element is a fixed overlay so it takes nothing out of flow either way, and
 * keeping it to visibility means one rule for the whole mechanism.
 *
 * ⚠️ It cannot stick. It is keyed on .sew-booted exactly like the rest of the
 * cloak, and public/index.html carries an inline 6000ms timeout that adds that
 * class even if app.js never loads at all.
 */
#boot-cloak {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0;
  /* Over everything except #upgrade-gate (9999), which is a refusal to run and
     outranks a loading screen. */
  z-index: 9998;
  background: #C8B9F1;
  display: flex; align-items: center; justify-content: center;
}
#boot-cloak svg { display: block; width: 160px; height: 160px; }
/* A short cross-fade rather than a cut: the app underneath is already finished
   when this starts (revealApp's sew-boot-instant sees to that), so the fade is
   between two complete pictures and never shows a half-built one. */
html.sew-booted #boot-cloak {
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .18s ease, visibility 0s linear .18s;
}

/* ---------- screens & pages ---------- */

.screen {
  position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  display: flex; flex-direction: column;
  opacity: 0; transform: translateY(12px);
  pointer-events: none;
  transition: opacity .45s ease, transform .45s ease;
}
.screen.active { opacity: 1; transform: translateY(0); pointer-events: auto; }
/* An inactive screen must never swallow taps — not even via descendants that
   set their own pointer-events (e.g. the still-.active page inside #main
   after logout, which sat invisibly on top of the auth screen). */
.screen:not(.active), .screen:not(.active) * { pointer-events: none !important; }

.page {
  position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  display: flex; flex-direction: column;
  opacity: 0; transform: translateY(10px);
  pointer-events: none;
  transition: opacity .35s ease, transform .35s ease;
}
.page.active { opacity: 1; transform: translateY(0); pointer-events: auto; }

/* ---------- shared ---------- */

.star { color: var(--accent); text-shadow: 0 0 18px var(--accent-dim); }

.h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.01em; color: var(--ink); }
.sub { font-size: 15px; color: var(--muted); line-height: 1.55; }
.label {
  font-size: 12px; letter-spacing: .12em; text-transform: uppercase;
  font-weight: 700; color: var(--dim);
}

.card {
  background: var(--card);
  border: 1px solid var(--card-brd);
  border-radius: 22px;
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  box-shadow: 0 8px 26px rgba(63, 48, 130, 0.1);
}

.btn {
  height: 54px; width: 100%;
  border-radius: 999px; border: none;
  background: var(--accent); color: var(--accent-ink);
  font-size: 16px; font-weight: 700;
  box-shadow: 0 8px 26px var(--accent-dim);
  transition: transform .12s ease, opacity .2s ease;
}
.btn:active { transform: translateY(1px) scale(.99); }
.btn:disabled { opacity: .38; box-shadow: none; }

.btn-ghost {
  height: 46px; border-radius: 999px;
  background: var(--chip-bg); color: var(--ink);
  border: 1px solid var(--card-brd);
  font-size: 14px; font-weight: 700;
  padding: 0 18px;
}
.btn-text { background: none; border: none; color: var(--dim); font-size: 14px; font-weight: 700; padding: 8px; }

/* ⚠️ Destructive controls are NOT settings, and .ydanger below is where they
   live now — see "the one treatment" in docs/reference/you-menu.md. These two colours are
   the vocabulary that survived from R11 and are used nowhere else. */
.btn-danger {
  background: rgba(214, 69, 90, 0.10); border: 1px solid rgba(214, 69, 90, 0.30);
  color: #C0384E; font-weight: 700; font-size: 14px;
  padding: 11px 14px; border-radius: 14px;
}
.btn-danger:active { background: rgba(214, 69, 90, 0.18); }

/* ⚠️ Leaving a partnership is SERIOUS BUT NOT PERMANENT; deleting an account is
   permanent. They sat side by side in identical .btn-danger red, which told the
   reader they cost the same. They do not — one can be followed by pairing again,
   the other cannot be followed by anything.

   Found by opening the screenshot; every assertion passed on it, and it is the
   same defect as major-vs-blocker in the admin flag sheet. Amber, not red: the
   palette already means "stop and think" with this hue elsewhere, and borrowing
   it beats inventing a third vocabulary. */
.btn-caution {
  background: rgba(203, 137, 42, 0.10); border: 1px solid rgba(203, 137, 42, 0.32);
  color: #9A6516; font-weight: 700; font-size: 14px;
  padding: 11px 14px; border-radius: 14px;
}
.btn-caution:active { background: rgba(203, 137, 42, 0.18); }

/* The confirm step. Typing the username is the point: a two-tap "are you sure"
   is muscle memory, spelling your own name out is a decision. */
.danger-box { display: flex; flex-direction: column; gap: 10px; }
.danger-box .db-list { font-size: 13.5px; line-height: 1.6; color: var(--ink); margin: 0; padding-left: 18px; }
.danger-box .db-list li { margin-bottom: 3px; }
.danger-box .db-warn {
  font-size: 13px; line-height: 1.5; color: #A32F44;
  background: rgba(214, 69, 90, 0.08); border-radius: 12px; padding: 10px 12px;
}

/* Consent. Two rules it has to obey to be worth anything: the box is never
   pre-ticked, and the cycle opt-in is visibly its own decision rather than a
   sub-clause of the first one. Hence the tinted card around it. */
.consent { display: flex; flex-direction: column; gap: 12px; }
.consent-row {
  display: flex; gap: 10px; align-items: flex-start;
  font-size: 13px; line-height: 1.5; color: var(--ink-soft);
  text-align: left;
}
.consent-row input[type="checkbox"] {
  width: 20px; height: 20px; margin: 0; flex: none;
  accent-color: var(--accent);
}
.consent-row a { color: var(--accent); font-weight: 700; }
.consent-sep {
  border: 1px solid var(--card-brd); background: var(--chip-bg);
  border-radius: 14px; padding: 12px 13px;
}
.consent-sep .consent-why {
  font-size: 12px; color: var(--faint); line-height: 1.45; margin: 7px 0 0 30px;
}
/* "Our relationship" is a page of sections. Period tracking is the first one;
   the heading exists now so that adding the second is not a redesign. */
.rel-head {
  font-size: 12px; font-weight: 800; letter-spacing: .08em;
  text-transform: uppercase; color: var(--faint); margin: 2px 0 -4px 6px;
}
.rel-more {
  font-size: 12px; color: var(--faint); line-height: 1.5;
  text-align: center; padding: 6px 14px 2px;
}

.dob-row { display: flex; flex-direction: column; gap: 6px; }
.dob-row .dob-hint { font-size: 12px; color: var(--faint); line-height: 1.45; }
/* Safari and Chrome render a bare date input a head shorter than a text one
   and leave it text-left-aligned inside our pill, so it is pinned by hand. */
input[type="date"].input {
  height: 52px; -webkit-appearance: none; appearance: none;
  font-family: inherit; color: var(--ink);
}

.chip {
  padding: 9px 18px; border-radius: 999px;
  background: var(--chip-bg); color: var(--ink-soft);
  border: 1px solid var(--card-brd);
  font-size: 13px; font-weight: 700;
  transition: all .22s ease;
}
.chip.on { background: var(--accent); color: var(--accent-ink); border-color: transparent; box-shadow: 0 4px 16px var(--accent-dim); }

.input {
  width: 100%; height: 52px;
  border-radius: 999px;
  border: 1px solid var(--card-brd);
  background: rgba(255, 255, 255, 0.62);
  color: var(--ink); font-size: 15px;
  padding: 0 20px;
  transition: border-color .2s ease;
}
.input:focus { border-color: var(--accent); }

.error { color: var(--bad); font-size: 13.5px; font-weight: 700; }

.swatch {
  width: 36px; height: 36px; border-radius: 50%;
  border: 2px solid rgba(255, 255, 255, 0.65); padding: 0;
  transition: all .15s ease;
  box-shadow: 0 3px 10px rgba(63, 48, 130, 0.18);
}
.swatch.on { border-color: var(--ink); transform: scale(1.12); }

#toast {
  position: fixed; left: 50%; bottom: 108px; transform: translateX(-50%);
  /* Above #gallery (70) and #game-layer (80). A toast is the app talking to
     you — "your partner wants to play" arrives while the games menu is open,
     which is exactly when it was being drawn underneath it. */
  z-index: 90;
  background: rgba(255, 255, 255, 0.96); color: var(--ink);
  border: 1px solid var(--accent-dim);
  border-radius: 999px; padding: 11px 20px;
  font-size: 13.5px; font-weight: 700;
  animation: toastIn .3s ease both;
  max-width: 86vw; text-align: center;
  box-shadow: 0 10px 30px rgba(63, 48, 130, 0.18);
}

/* ---------- install gate ---------- */

#gate { align-items: center; justify-content: center; padding: 32px; text-align: center; }
#gate .gate-icon {
  width: 92px; height: 92px; border-radius: 26px;
  box-shadow: 0 16px 40px rgba(63, 48, 130, 0.3);
  animation: floaty 5s ease-in-out infinite;
}
#gate .gate-box { max-width: 340px; display: flex; flex-direction: column; gap: 14px; align-items: center; }
#gate .manual {
  font-size: 13.5px; color: var(--muted); line-height: 1.6;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  border-radius: 16px; padding: 12px 16px;
}

/* ---------- auth ---------- */

#auth, #consent { align-items: center; justify-content: center; padding: 28px; }
/* The consent screen can outgrow a short phone, and a gate you cannot scroll to
   the bottom of is a gate nobody gets through. */
#consent { overflow-y: auto; justify-content: flex-start; padding-top: 40px; }
.auth-hero { text-align: center; margin-bottom: 26px; }
/* The mark is the logo image now, not a glyph, so it is sized by width and
   keeps the artwork's own proportions. */
.auth-hero .mark { width: 96px; height: auto; }
.auth-hero .hero-mark { animation: markBreathe 3.6s ease-in-out infinite; }
.auth-hero h1 { font-size: 40px; font-weight: 700; letter-spacing: -0.02em; margin: 10px 0 4px; color: var(--ink); }
.auth-hero .tag { font-size: 16px; color: var(--muted); }

.auth-box { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; }

.seg {
  display: flex; gap: 4px; padding: 4px;
  background: rgba(88, 70, 160, 0.14); border-radius: 999px;
}
.seg button {
  flex: 1; padding: 10px; border: 0; border-radius: 999px;
  background: transparent; color: var(--dim); font-weight: 700; font-size: 14px;
  transition: all .25s ease;
}
.seg button.on { background: rgba(255, 255, 255, 0.75); color: var(--ink); box-shadow: 0 2px 8px rgba(63, 48, 130, 0.12); }

.lang-row { display: flex; justify-content: center; gap: 8px; margin-top: 18px; }
.lang-row button { background: none; border: none; color: var(--faint); font-size: 13px; font-weight: 700; padding: 6px 10px; }
.lang-row button.on { color: var(--accent); }

/* ---------- topbar + nav ---------- */

.topbar {
  display: flex; align-items: center; gap: 8px;
  padding: 14px 22px 10px;
  padding-top: max(14px, env(safe-area-inset-top), calc(var(--notice-top, 0px) + 10px));
}
.topbar .brand {
  display: flex; align-items: center; gap: 8px;
  font-size: 17px; font-weight: 700; color: var(--ink);
  /* The home bar carries a coin chip and the customize button too; the title
     is the part that can afford to give ground.

     ⚠️ text-overflow, added 2026-08-19. With the tour mark in the bar as well
     the home title runs out of room and was being cut mid-glyph — "Evimiz"
     rendering as "Evimi:", which reads as a typo rather than as a title that
     did not fit. Giving ground should look deliberate. */
  flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap;
}

/* The ellipsis has to be on the SPAN, not on .brand. .brand is a flex
   container and text-overflow applies to a block container's inline content —
   on a flex parent it does nothing at all, which is why the first attempt at
   this changed the screenshot not one pixel. */
.topbar .brand > span {
  min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
/* flex-shrink:0 or the logo is the first thing squeezed when a long room title
   runs out of room, and the brand loses its mark instead of its text. */
.topbar .brand .mark { width: 24px; height: auto; flex: 0 0 auto; }

/* Redecorating lives up here now, in the corner, out of the room's way.

   ⚠️ IT IS A DRAWING IN THE BAR, NOT A PILL — app-updates1 A4, 2026-08-27.
   Deniz: *"Edit room button should be just a pen button, not inside a pill."*
   It was 🖌️🛍️ — two emoji squeezed together with letter-spacing: -1px so
   they would fit — plus the word "Customize room", inside a 999px pill. That
   is the exact shape WORKING-RULES §4.4 exists to stop, and it survived this
   long because it worked.

   `.bar-icon` is the shared topbar control: no box, no border, no word. The
   same idiom as .tour-ask, which has been sitting two elements away doing it
   correctly the whole time.

   ⚠️ 40px, NOT the 22px the mark is drawn at. The picture is small; the thing
   you hit is not. Under about 40 is a control you miss with a thumb, and an
   icon-only button gives no second chance — there is no word beside it to
   catch the near miss.

   The negative block margin is what keeps the bar the height it was: a 40px
   box contributing 32px to the flex line, against the old pill's 31. The
   target grew by nine pixels and the header did not move. */
.bar-icon {
  flex: 0 0 auto; width: 40px; height: 40px; padding: 0;
  /* ⚠️ NOT `margin-block`, WHICH IS CHROME 87 AND IS THEREFORE NOTHING AT ALL
     ON THE ENGINE THE STORE APP SHIPS IN. This said `margin-block: -4px` until
     2026-09-11 and WebView 83 dropped the whole declaration, so every topbar in
     the app was 8px taller there than on a modern Chrome and everything below
     it 8px lower — which is how the Adopt button ended up sliced by the tab bar
     a second time (ENGINE=83 `adopt-ui`: "button ends 753, nav starts 744").
     The long-hand pair is what this always meant: the app is horizontal-tb and
     LTR everywhere, so block == top/bottom on every engine. Same fix on
     .tour-ask, which is the rule this one was copied from — and whose own
     comment describes this exact defect being fixed once already. */
  margin-top: -4px; margin-bottom: -4px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 0; background: none; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: transform .15s ease;
}
.bar-icon svg { width: 22px; height: 22px; display: block; }
.bar-icon:active { transform: scale(.9); }
@media (prefers-reduced-motion: reduce) { .bar-icon { transition: none; } }

#nav {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 30;
  height: 88px;
  padding: 8px 30px calc(14px + env(safe-area-inset-bottom));
  display: flex; align-items: center; justify-content: space-between;
  background: linear-gradient(180deg, transparent, var(--sky-dock) 46%);
}
.nav-tab {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  background: none; border: none; padding: 6px 8px; width: 66px;
  color: var(--faint); transition: color .25s ease;
}
.nav-tab svg { display: block; }
.nav-tab span { font-size: 11px; font-weight: 700; }
.nav-tab.on { color: var(--accent); }
.nav-pet {
  width: 60px; height: 60px; margin-top: -26px;
  border-radius: 50%;
  background: linear-gradient(135deg, #9678EC, var(--accent));
  border: 2px solid rgba(255, 255, 255, 0.55);
  box-shadow: 0 10px 30px var(--accent-dim);
  display: flex; align-items: center; justify-content: center;
  color: #fff;
  transition: transform .2s ease;
}
.nav-pet.on { transform: scale(1.08); }

.page-body { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 20px 120px; }
/* With a pet, the home page is a fixed-height column rather than a scroller:
   the room fills whatever the room bar and pet bar leave, like the chat screen.
   The adopt and "find a partner" views keep the normal scrolling body. */
#pet-body.room-mode {
  display: flex; flex-direction: column;
  overflow: hidden; padding-bottom: 96px;
}

/* ---------- match ---------- */

#page-match .page-body { display: flex; flex-direction: column; gap: 16px; }
.match-hero { text-align: center; padding: 26px 12px 6px; }
.match-hero .orbit { font-size: 34px; animation: floaty 5s ease-in-out infinite; }

.req-item {
  display: flex; align-items: center; gap: 10px;
  padding: 13px 16px; border-radius: 18px;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
}
.req-item .who { flex: 1; font-size: 14.5px; color: var(--ink-soft); }
.req-item .who strong { color: var(--ink); }
.btn-mini {
  border: none; border-radius: 999px; padding: 9px 16px;
  font-size: 13px; font-weight: 700;
}
.btn-mini.yes { background: var(--accent); color: var(--accent-ink); }
.btn-mini.no { background: var(--chip-bg); color: var(--ink-soft); border: 1px solid var(--card-brd); }

/* ---------- chat ---------- */

.chat-head {
  display: flex; align-items: center; gap: 12px;
  padding: 14px 20px 10px;
  padding-top: max(14px, env(safe-area-inset-top), calc(var(--notice-top, 0px) + 10px));
  background: linear-gradient(180deg, var(--sky-dock), transparent);
}
.avatar {
  width: 44px; height: 44px; flex: 0 0 auto;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.65);
  border: 1px solid var(--card-brd);
  display: flex; align-items: center; justify-content: center;
  font-size: 17px; font-weight: 700; color: var(--accent);
}
.chat-head .meta { flex: 1; min-width: 0; }
.chat-head .name { font-size: 16px; font-weight: 700; color: var(--ink); }
.chat-head .status { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 5px; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); display: inline-block; }
.dot.online { background: var(--good); box-shadow: 0 0 8px var(--good); }

/* Chat settings, dropped under the chat header by the ⋮ button. It pushes the
   message list down rather than floating over it: an overlay here would cover
   the newest messages, which is the part of the screen people are looking at. */
.chat-menu {
  margin: 0 20px 10px;
  padding: 14px 16px;
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.72);
  border: 1px solid var(--card-brd);
  display: flex; flex-direction: column; gap: 10px;
}
.chat-menu[hidden] { display: none; }
.chat-menu-title { font-size: 13px; font-weight: 700; color: var(--ink); }
/* Three tiles across, always three — this is a choice between three fixed
   things and a wrapping flex row turns it into "two and then one", which reads
   as a layout accident rather than a set. */
.chat-menu-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.keep-tile {
  position: relative;
  display: flex; flex-direction: column; align-items: center; gap: 9px;
  padding: 15px 6px 13px;
  border-radius: 18px;
  border: 1px solid var(--card-brd);
  background: rgba(255, 255, 255, 0.5);
  color: var(--ink-soft); text-align: center; cursor: pointer;
  transition: transform .14s ease, background .2s ease, border-color .2s ease,
              box-shadow .2s ease;
}
/* The radio is still the state — see the comment in index.html — it is only
   invisible. Not display:none: a removed radio is removed from the form, and
   #chat-menu input[name="keepDays"] is what app.js reads and writes. */
.keep-tile input {
  position: absolute; opacity: 0; pointer-events: none;
  width: 1px; height: 1px; margin: 0;
}
.keep-tile .keep-mark {
  width: 30px; height: 30px; display: block; color: var(--dim);
  transition: color .2s ease, transform .2s ease;
}
.keep-tile .keep-label { font-size: 12px; font-weight: 800; line-height: 1.3; }
.keep-tile.on {
  background: rgba(255, 255, 255, 0.94);
  border-color: var(--accent);
  box-shadow: 0 6px 20px var(--accent-dim);
}
.keep-tile.on .keep-mark { color: var(--accent); transform: scale(1.09); }
.keep-tile.on .keep-label { color: var(--ink); }
.keep-tile:active { transform: scale(.96); }
@media (prefers-reduced-motion: reduce) {
  .keep-tile, .keep-tile .keep-mark { transition: none; }
}
/* R23 freeze-lastseen. Two tiles, not three — same tile, same selected state,
   same everything else. A second grid rule rather than a flex row for the
   reason the three-across one gives: a set of fixed choices must look like a
   set. */
.chat-menu-row-2 { grid-template-columns: repeat(2, 1fr); }
/* The second heading on the page needs air above it; the first one does not,
   because the card's own padding is already there. */
.chat-menu-title-2 { margin-top: 6px; }
.chat-menu-note { font-size: 12px; line-height: 1.5; color: var(--muted); margin: 0; }
.chat-menu-note:empty { display: none; }

.messages {
  flex: 1; min-height: 0; overflow-y: auto;
  padding: 8px 20px 12px;
  display: flex; flex-direction: column;
  /* THE SCROLLBACK'S ARITHMETIC IS THE ONLY ONE ALLOWED TO RUN.
     Browsers anchor scrolling by default: insert content above the viewport
     and they silently adjust scrollTop to keep what you are reading still —
     which is exactly what loadOlder() does by hand.

     ⚠️ AND THIS LINE IS BELT-AND-BRACES, NOT THE FIX. Say so, because the
     comment that stood here first said the opposite and was wrong. It claimed
     both mechanisms together would apply the delta twice and leap a page.
     They do not, and the reason is worth keeping: loadOlder() assigns an
     ABSOLUTE target (`beforeTop + (after - before)`, all three measured across
     the insert) rather than adding a delta to whatever scrollTop happens to
     be. An absolute assignment is idempotent — it lands on the same number
     whether or not the engine already moved it. Deleting this line and
     re-running chat-scroll-ui-check.js changes the measured drift by nothing
     at all, at scrollTop 0 and at 90px alike.

     It stays because it costs nothing and it makes the hold DETERMINISTIC:
     one mechanism, ours, on every engine, including the ones that have not
     shipped anchoring yet and the ones that will change how it behaves. The
     harness asserts it is still here for that reason and no stronger one. */
  overflow-anchor: none;
}
.bubble {
  max-width: 76%; padding: 10px 15px;
  font-size: 15px; line-height: 1.4; margin: 3px 0;
  word-wrap: break-word; white-space: pre-wrap;
  animation: riseIn .3s ease both;
}
.bubble.me {
  align-self: flex-end;
  background: var(--accent); color: var(--accent-ink);
  border-radius: 20px 20px 6px 20px;
  box-shadow: 0 4px 14px var(--accent-dim);
}
.bubble.them {
  align-self: flex-start;
  background: rgba(255, 255, 255, 0.8); color: var(--ink);
  border-radius: 20px 20px 20px 6px;
  box-shadow: 0 4px 14px rgba(63, 48, 130, 0.08);
}
.bubble .time { display: block; font-size: 10.5px; opacity: .6; margin-top: 2px; text-align: right; }
.day-sep {
  align-self: center; font-size: 11.5px; font-weight: 700; color: var(--muted);
  background: rgba(88, 70, 160, 0.12);
  padding: 6px 14px; border-radius: 999px; margin: 10px 0 6px;
}

/* ---- the top of the message list · the scrollback ---------------------------
   One element, four states, and they are four different PICTURES rather than
   four labels in the same box. A spinner that has quietly finished looks
   identical to a conversation that has ended, and "are my messages gone?" is
   the question this whole feature exists to answer. So: a chevron to go up, a
   pulse while it is fetching, a warning that is tappable, and — the one that
   is not a control at all — a hairline through the words for the beginning of
   the conversation. */
.chat-top {
  flex: 0 0 auto; align-self: center;
  display: flex; align-items: center; justify-content: center; gap: 8px;
  min-height: 34px; margin: 4px 0 10px; padding: 7px 16px;
  font-size: 12px; font-weight: 700; letter-spacing: .01em;
  border-radius: 999px; color: var(--muted);
  max-width: 100%;
}
.chat-top.hidden { display: none; }
.chat-top-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* MORE — the only state that is a button, and it is a button because a first
   page that does not overflow the list cannot be scrolled at all: a feature
   reachable only by a gesture the screen makes impossible is not reachable. */
.chat-top.more {
  cursor: pointer; color: var(--ink);
  background: rgba(255, 255, 255, 0.72);
  box-shadow: 0 3px 12px rgba(63, 48, 130, 0.09);
  transition: transform .12s ease, box-shadow .12s ease, background .12s ease;
}
.chat-top.more:hover { background: rgba(255, 255, 255, 0.9); }
.chat-top.more:active { transform: scale(.95); box-shadow: 0 1px 5px rgba(63, 48, 130, 0.1); }
.chat-top-chev { width: 15px; height: 8px; flex: 0 0 auto; color: var(--accent); }

/* LOADING — the same three dots as the typing bubble, because it is the same
   idea: somebody is on their way. Reusing dotB keeps them in step. */
.chat-top.loading { background: rgba(255, 255, 255, 0.5); }
.chat-top-dots { display: flex; gap: 4px; align-items: center; flex: 0 0 auto; }
.chat-top-dots span {
  width: 5px; height: 5px; border-radius: 50%;
  background: var(--accent); animation: dotB 1.2s infinite;
}
.chat-top-dots span:nth-child(2) { animation-delay: .18s; }
.chat-top-dots span:nth-child(3) { animation-delay: .36s; }

/* FAILED — deliberately not the same grey as "loading". A failure that looks
   like progress is a spinner nobody ever taps. */
.chat-top.retry {
  cursor: pointer; color: #8a3b52;
  background: rgba(216, 122, 148, 0.16);
  box-shadow: inset 0 0 0 1px rgba(216, 122, 148, 0.34);
}
.chat-top.retry:active { transform: scale(.95); }

/* THE BEGINNING — a boundary, so it is drawn as one. No pill, no shadow,
   nothing that invites a tap, because there is nothing left to fetch. */
.chat-top.start {
  align-self: stretch; padding: 4px 0; margin: 6px 0 14px;
  gap: 12px; font-size: 11px; font-weight: 700;
  letter-spacing: .1em; text-transform: lowercase;
  color: var(--dim);
}
.chat-top-rule {
  flex: 1 1 auto; height: 1px; min-width: 12px;
  background: linear-gradient(to right, transparent, rgba(88, 70, 160, 0.28), transparent);
}

/* ⚠️ History does not rise in. riseIn is a translate so it would not disturb
   the scroll arithmetic — but two hundred bubbles animating above the message
   you are reading is the whole screen rippling, and the entire point of
   loadOlder() is that nothing visible moves. */
.bubble.no-rise { animation: none; }

@media (prefers-reduced-motion: reduce) {
  .chat-top.more, .chat-top.retry { transition: none; }
  .chat-top-dots span { animation: none; opacity: .6; }
}
.typing-bubble {
  align-self: flex-start;
  background: rgba(255, 255, 255, 0.8);
  padding: 13px 16px; border-radius: 20px 20px 20px 6px;
  margin: 3px 0; display: flex; gap: 5px;
}
.typing-bubble span { width: 6px; height: 6px; border-radius: 50%; background: var(--dim); animation: dotB 1.2s infinite; }
.typing-bubble span:nth-child(2) { animation-delay: .18s; }
.typing-bubble span:nth-child(3) { animation-delay: .36s; }

.chat-form {
  display: flex; gap: 10px; align-items: center;
  padding: 10px 16px calc(96px + env(safe-area-inset-bottom));
}
.chat-form input { flex: 1; height: 48px; }
.send-btn {
  width: 48px; height: 48px; flex: 0 0 auto;
  border-radius: 50%; border: none;
  background: var(--accent); color: var(--accent-ink);
  display: grid; place-items: center;
  box-shadow: 0 4px 16px var(--accent-dim);
  transition: transform .12s ease, box-shadow .12s ease;
}
/* The plane sits very slightly off-centre because the drawing's visual weight
   is left of its bounding box — centring the BOX leaves it looking like it is
   drifting backwards out of the circle. */
.send-btn .plane { width: 21px; height: 21px; display: block; transform: translateX(1px); }
.plane-body { fill: currentColor; }
/* ⚠️ The crease is a SHADOW, not the accent. Stroking it in var(--accent) was
   the obvious idea and it failed the sheet: against a white body it has plenty
   of contrast on the deep purple and almost none on the pink or the pale
   lilac, so the plane flattened into a triangle on two of the six accents a
   user can actually choose. A translucent ink line is a crease on white paper
   whatever colour the circle behind it is — and it is the physically true
   answer as well as the robust one. */
.plane-fold {
  fill: none; stroke: rgba(43, 35, 80, .38); stroke-width: 1.6;
  stroke-linecap: round;
}
/* Rule 4 of the design standard: something must acknowledge the tap. The plane
   leaves, it does not merely dim. */
.send-btn:active { transform: scale(.94); box-shadow: 0 2px 8px var(--accent-dim); }
.send-btn:active .plane { animation: plane-away .28s ease-out; }
@keyframes plane-away {
  40% { transform: translate(7px, -5px) rotate(12deg); opacity: .35; }
  41% { transform: translate(-6px, 4px) rotate(-6deg); opacity: 0; }
  to  { transform: translateX(1px) rotate(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .send-btn, .send-btn:active .plane { transition: none; animation: none; }
}

/* ---------- 20a · the on-screen keyboard ---------- */

/* ⚠️ WHAT ACTUALLY SHOVES THE SCREEN UP, because the fix makes no sense
   without it.

   Every screen in this app is position:absolute inside #app{height: 100vh; height: 100dvh},
   over a body with overflow:hidden. On a phone the keyboard normally shrinks
   the VISUAL viewport only and leaves the LAYOUT viewport alone — so 100dvh,
   .page and .chat-form do not move a pixel, and the composer ends up
   underneath the keyboard. The browser then tries to scroll the focused field
   into view, finds nothing scrollable that would help (.messages scrolls, but
   the input is not inside it, and the document cannot scroll at all), and so
   scrolls the VISUAL VIEWPORT instead. That translates the whole rendered page
   upward — header, avatar, sky and all. That is the shove: it is not a resize
   and window.onresize never fires for it.

   So the app has to make room itself. app.js measures the overlap from
   window.visualViewport and publishes it as --kb; the chat page gives up
   exactly that much of its own bottom. The composer is then already on screen,
   the browser has nothing to scroll into view, and the header stays put. */
body.kb-open #page-chat { bottom: var(--kb, 0px); }
/* The bottom nav is under the keyboard while it is open, so the 96px the
   composer reserves for it is 96px of keyboard. Give it back to the
   conversation. */
body.kb-open .chat-form { padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
body.kb-open .chat-head { padding-bottom: 6px; }

/* ---------- 20c · the emoji + effects tray ---------- */

.emoji-btn {
  width: 44px; height: 44px; flex: 0 0 auto;
  border-radius: 50%;
  border: 1px solid var(--card-brd);
  background: var(--chip-bg); color: var(--dim);
  display: grid; place-items: center;
  transition: transform .12s ease, color .18s ease, background .18s ease;
}
/* Deliberately smaller and quieter than the 48px accent-filled send button
   beside it. Two equal circles either side of the field would read as two
   equally important actions, and one of them is the whole point of the bar. */
.emoji-btn .smile { width: 23px; height: 23px; display: block; }
.smile-face, .smile-mouth { fill: none; stroke: currentColor; stroke-width: 1.7; }
.smile-mouth { stroke-linecap: round; }
.smile-eye { fill: currentColor; }
.emoji-btn.on { color: var(--accent-ink); background: var(--accent); border-color: transparent; }
.emoji-btn:active { transform: scale(.92); }

.chat-tray {
  flex: 0 0 auto;
  height: min(46vh, 292px);
  margin: 0 14px 2px;
  padding: 11px 13px 12px;
  border-radius: 22px;
  background: rgba(255, 255, 255, 0.74);
  border: 1px solid var(--card-brd);
  backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
  box-shadow: 0 10px 30px rgba(63, 48, 130, 0.12);
  display: flex; flex-direction: column; gap: 8px;
  overflow: hidden;
  animation: trayUp .2s ease both;
}
@keyframes trayUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.tray-title {
  font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
  font-weight: 800; color: var(--dim);
}
/* ⚠️ A GRID OF FOUR, NOT A FLEX ROW, since P29 item 12 took this from four
   effects to eight. Eight `flex: 1 1 0` tiles across a 320px phone is 28px
   each: the drawing stops being a drawing and the name becomes an ellipsis.
   Two rows of four keeps every tile the size it was. */
.fx-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 9px 8px; }
/* Rule 1 of the design standard: the drawing IS the control. A row of buttons
   reading "Confetti / Hearts / Starfall / Petals" is the version of this screen
   that works and looks unfinished. */
.fx-tile {
  min-width: 0;
  border: 1px solid var(--card-brd); border-radius: 16px;
  background: rgba(255, 255, 255, 0.62);
  padding: 9px 3px 7px;
  display: flex; flex-direction: column; align-items: center; gap: 5px;
  color: var(--ink-soft);
  transition: transform .12s ease, background .18s ease;
}
/* ⚠️ `> svg` AND NOT ` svg`, and this was a real bug for about ten minutes.
   The price line contains an <svg class="cur-mark"> with its own width and
   height attributes, and a descendant selector setting width:100%;height:28px
   blew the crumb and gem tokens up to the size of the artwork. The tile art is
   the button's only direct SVG child; every other one is nested. */
.fx-tile > svg { width: 100%; max-width: 46px; height: 28px; display: block; }
.fx-tile .fx-name {
  font-size: 10px; font-weight: 800; letter-spacing: .01em;
  max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.fx-tile:active { transform: scale(.93); background: var(--accent-dim); }

/* ---- priced effects (P29 item 12) ----
   WORKING-RULES §4.4: owned / locked / priced belong ON the tile, never on a
   chip beside it. Six of the eight are the couple's already, and those tiles
   carry nothing at all — an owned tray is a grid of pictures, not a grid of
   noughts. The conventions are the wardrobe's, deliberately: dashed edge for
   "not bought", drained artwork for "not yours yet", accent for "this is the
   one you are looking at". Somebody who has bought a hat already knows this. */
.fx-tile .fx-price {
  display: inline-flex; align-items: center; gap: 3px;
  font-size: 10px; font-weight: 800; line-height: 1.25;
  color: #7A5A14; white-space: nowrap;
  max-width: 100%; overflow: hidden;
}
.fx-tile .fx-price.gem { color: #2E8F6B; }
/* ⚠️ REQUIRED. The `hidden` attribute only works through the UA's
   display:none, and any author `display` beats it — so without this line every
   owned tile shows an empty price row and the grid grows a ragged second line
   of whitespace. */
.fx-tile .fx-price[hidden] { display: none; }
.fx-tile.locked { border-style: dashed; }
.fx-tile.locked > svg { filter: grayscale(1) opacity(.62); }
.fx-tile.locked .fx-name { color: var(--faint); }
/* Armed: they have tapped it once, watched it play, and the next tap spends.
   The artwork comes back to full colour — draining a tile is how it says "not
   yours"; keeping it drained while it is the one being previewed would defeat
   the point of letting them preview it. Same sentence the wardrobe states. */
.fx-tile.armed {
  border-style: solid; border-color: var(--accent);
  background: rgba(124, 92, 224, 0.16);
  box-shadow: 0 6px 18px var(--accent-dim);
}
.fx-tile.armed > svg { filter: none; }
.fx-tile.armed .fx-name { color: var(--ink); }
.fx-tile.armed .fx-price b { color: var(--ink); font-weight: 800; }
/* Not "you cannot afford it" — "there is no wallet yet". Crumbs are the pet's
   coins and a couple who has paired but not adopted has none and can get none,
   so the tile says so in words instead of showing a number they cannot reach. */
.fx-tile.needpet .fx-price {
  color: var(--muted); font-size: 9px; font-weight: 700;
  letter-spacing: 0; white-space: normal; text-align: center; line-height: 1.15;
}

.emoji-grid {
  flex: 1; min-height: 0; overflow-y: auto;
  display: grid; grid-template-columns: repeat(8, 1fr); gap: 1px;
  -webkit-overflow-scrolling: touch;
}
.emoji-grid button {
  background: none; border: none; padding: 4px 0;
  font-size: 23px; line-height: 1.15; border-radius: 12px;
}
.emoji-grid button:active { background: var(--accent-dim); }
/* 320px: eight columns leaves 36px a cell, and the emoji is 23px of it. Seven
   is the point where a thumb stops missing. */
@media (max-width: 340px) {
  .emoji-grid { grid-template-columns: repeat(7, 1fr); }
  .fx-tile .fx-name { font-size: 9.5px; }
  /* Four columns is kept rather than dropped to three: three would leave a
     two-tile orphan row, and the tile is still ~62px wide here. */
  .fx-row { gap: 8px 6px; }
  .fx-tile { padding: 8px 2px 6px; }
  .fx-tile .fx-price { font-size: 9.5px; }
  .chat-tray { margin: 0 10px 2px; padding: 10px; }
}

/* ---------- 20c · the effect itself ---------- */

/* Above the nav (z-index 30) and the tray, below the sheet. pointer-events:none
   throughout — an effect must never eat a tap, least of all a tap on Send. */
#fx-layer {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 60;
  pointer-events: none; overflow: hidden;
}
.fx-bit { position: absolute; will-change: transform, opacity; }
.fx-bit svg { display: block; }
@keyframes fxFall {
  0%   { transform: translate3d(0, 0, 0) rotate(0deg); opacity: 0; }
  8%   { opacity: 1; }
  100% { transform: translate3d(var(--dx, 0px), calc(100vh + 90px), 0) rotate(var(--spin, 540deg)); opacity: .12; }
}
@keyframes fxRise {
  0%   { transform: translate3d(0, 0, 0) scale(.55) rotate(0deg); opacity: 0; }
  14%  { opacity: 1; }
  100% { transform: translate3d(var(--dx, 0px), calc(-100vh - 70px), 0) scale(1.06) rotate(var(--spin, 0deg)); opacity: 0; }
}
@keyframes fxBurst {
  0%   { transform: translate3d(0, 0, 0) scale(.2) rotate(0deg); opacity: 0; }
  16%  { opacity: 1; }
  100% { transform: translate3d(var(--dx, 0px), var(--dy, 0px), 0) scale(1) rotate(var(--spin, 0deg)); opacity: 0; }
}
/* ---- fireworks · 6 gems (P29 item 12) ----
   Two keyframe sets, because the effect has two phases and that is exactly
   what it is being paid for. Nothing else in this file animates in stages. */

/* The shell climbing. `--rise` is how far up its burst point is, in vh, so
   three shells bursting at three heights are three different journeys. */
@keyframes fxLift {
  0%   { transform: translate3d(0, 0, 0) scale(.7); opacity: 0; }
  18%  { opacity: .95; }
  80%  { opacity: .75; }
  100% { transform: translate3d(0, calc(-1 * var(--rise, 60vh)), 0) scale(.42); opacity: 0; }
}

/* ⚠️ THE GRAVITY TERM, and the reason there is a keyframe at 55% at all.
   Every other effect in this file is one interval with one ease. This is two:

     0% → 55%   fly outward to (--dx, --dy), DECELERATING — the burst opening
     55% → 100% carry on outward a little (×1.14) while ACCELERATING DOWNWARD
                by --fall — the sparks peaking and dropping

   The per-keyframe `animation-timing-function` is what makes that possible:
   each one applies to the interval STARTING at its own keyframe, so the two
   halves ease in opposite directions. Put a single ease on the element and the
   sparks travel out and simply stop, which is a dandelion. */
@keyframes fxSpark {
  0% {
    transform: translate3d(0, 0, 0) scale(.3); opacity: 1;
    animation-timing-function: cubic-bezier(.08, .72, .28, 1);
  }
  55% {
    transform: translate3d(var(--dx, 0px), var(--dy, 0px), 0) scale(1); opacity: .95;
    animation-timing-function: cubic-bezier(.45, 0, .78, .42);
  }
  100% {
    transform: translate3d(calc(var(--dx, 0px) * 1.14),
                           calc(var(--dy, 0px) + var(--fall, 150px)), 0) scale(.66);
    opacity: 0;
  }
}

/* ---- bloom · 4 gems (P29 item 12) ----
   No particles. Two vines DRAW themselves from the bottom corners and flowers
   spring open on them. `--len` is the path's measured length, written by
   app.js from getPointAtLength's sibling getTotalLength — the dash pattern has
   to be the whole path for "undrawn" to mean invisible. */
.fx-bloom { position: absolute; top: 0; right: 0; bottom: 0; left: 0; animation: fxBloomOut 900ms ease 2450ms both; }
.fx-vine { position: absolute; bottom: 0; width: 44vmin; height: 71vmin; }
.fx-vine.left { left: -1vmin; }
/* The right vine is the left one mirrored rather than a second path: one curve
   maintained, and the two halves read as a pair because they are one. */
.fx-vine.right { right: -1vmin; transform: scaleX(-1); }
.fx-vine svg { width: 100%; height: 100%; display: block; overflow: visible; }
.fx-vine .vine {
  fill: none; stroke: #6FBF8F; stroke-width: 2; stroke-linecap: round;
  animation: fxDraw 900ms cubic-bezier(.3, .7, .32, 1) both;
}
@keyframes fxDraw { from { stroke-dashoffset: var(--len, 240); } to { stroke-dashoffset: 0; } }
/* transform-origin is 0 0 — which IS the flower's centre, because FX_SHAPE.bloom
   draws it around the origin. See the two-nested-<g> note in app.js for why the
   position is an attribute and only the scale is CSS. */
.fx-vine .flower { animation: fxOpen 640ms cubic-bezier(.22, 1.5, .4, 1) both; }
@keyframes fxOpen {
  from { transform: scale(0) rotate(-45deg); opacity: 0; }
  to   { transform: scale(1) rotate(0deg); opacity: 1; }
}
@keyframes fxBloomOut { from { opacity: 1; } to { opacity: 0; } }

/* The still version, for a reader who has asked for no motion. It is not the
   animation slowed down — nothing moves at all. app.js builds this instead of
   the particles, and the rule below is the belt to that braces. */
.fx-still {
  position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%);
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  padding: 20px 26px; border-radius: 24px;
  background: rgba(255, 255, 255, 0.9);
  border: 1px solid var(--card-brd);
  box-shadow: 0 14px 40px rgba(63, 48, 130, 0.18);
  color: var(--ink);
}
.fx-still svg { width: 84px; height: 54px; }
.fx-still .fx-name { font-size: 13px; font-weight: 800; }
@media (prefers-reduced-motion: reduce) {
  .fx-bit { animation: none !important; display: none !important; }
  /* ⚠️ Belt and braces, exactly like .fx-bit above. playEffect returns from its
     reduced-motion branch long before either of these is built, so this rule
     should never do anything — which is the point: the day somebody adds a
     fifth branch and forgets that check, the person who asked for no motion
     still gets none. */
  .fx-bloom { animation: none !important; display: none !important; }
  .chat-tray { animation: none; }
  .emoji-btn, .fx-tile { transition: none; }
}

/* ---------- 20d · the chat settings screen ---------- */

.back-btn {
  width: 38px; height: 38px; flex: 0 0 auto;
  border-radius: 50%;
  border: 1px solid var(--card-brd);
  background: var(--chip-bg); color: var(--ink-soft);
  display: grid; place-items: center;
  transition: transform .12s ease;
}
.back-btn:active { transform: scale(.9); background: var(--accent-dim); }
#page-chatset .page-body { display: flex; flex-direction: column; gap: 16px; }
/* Whose conversation this is. The dropdown never needed to say — it hung two
   centimetres under their name. A full screen with no subject on it is the way
   this move goes wrong. */
.chatset-who {
  display: flex; align-items: center; gap: 12px;
  padding: 14px 16px;
  border-radius: 20px;
  background: rgba(255, 255, 255, 0.56);
  border: 1px solid var(--card-brd);
}
.chatset-who .avatar { width: 40px; height: 40px; font-size: 16px; }
.chatset-who .who-name { font-size: 15.5px; font-weight: 800; color: var(--ink); }
.chatset-who .who-sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
/* On its own screen it is the page, not a card dropped on top of one. */
#page-chatset .chat-menu {
  margin: 0; padding: 18px 18px 20px;
  border-radius: 22px;
  background: rgba(255, 255, 255, 0.56);
  gap: 12px;
}
#page-chatset .chat-menu-title {
  font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--dim);
}
#page-chatset .btn-danger { margin-top: 6px; }

/* ---------- cycle ---------- */

/* The ring. One picture of the whole cycle with today's place in it — what
   every dedicated tracker leads with, and what this screen did not have. It
   replaces a phase pill, a big line of text and a sub-line that between them
   said the same thing in 107px of stacked card. */
.ring-wrap { position: relative; display: grid; place-items: center; padding: 4px 0 2px; }
.ring { width: min(58vw, 230px); height: auto; display: block; }
.ring-track { fill: none; stroke: rgba(124, 92, 224, .13); stroke-width: 13; }
.ring-flow { fill: none; stroke: var(--flow); stroke-width: 13; stroke-linecap: round; }
.ring-fertile { fill: none; stroke: var(--fertile); stroke-width: 13; stroke-linecap: round; opacity: .5; }
.ring-tick { fill: rgba(43, 35, 80, .22); }
.ring-today { fill: #fff; stroke: var(--accent); stroke-width: 3; }
.ring-today-in { fill: var(--accent); }
/* the numbers live in the hole, which is what the hole is for */
.ring-mid {
  position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 1px; pointer-events: none;
}
.ring-day { font-size: 44px; font-weight: 800; color: var(--ink); line-height: 1; }
.ring-of { font-size: 11.5px; font-weight: 700; color: var(--faint); letter-spacing: .04em; }
.ring-phase {
  margin-top: 5px; font-size: 12px; font-weight: 800;
  letter-spacing: .1em; text-transform: uppercase;
}
.ring-phase.period { color: var(--flow); }
.ring-phase.fertile { color: var(--fertile); }
.ring-phase.luteal, .ring-phase.follicular { color: var(--dim); }
.ring-next {
  text-align: center; font-size: 13px; font-weight: 600;
  color: var(--muted); padding: 2px 0 2px;
}

/* the two things you do here. Not a row of pills — two wide targets with the
   action's own mark on them, the primary one carrying the flow colour. */
.cycle-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.cycle-actions.single { grid-template-columns: 1fr; }
/* the third one spans both columns — history and settings are one door, not a
   third peer of the two things you actually do */
.cyc-act.wide { grid-column: 1 / -1; flex-direction: row; justify-content: center; gap: 8px; padding: 11px 8px; }
.cyc-act.wide .ca-ico { font-size: 16px; }
.cyc-act {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 13px 8px; border-radius: 18px;
  background: var(--card); border: 1px solid var(--card-brd);
  color: var(--ink-soft); font-size: 12.5px; font-weight: 700; line-height: 1.25;
  text-align: center;
}
.cyc-act .ca-ico { font-size: 21px; }
.cyc-act.primary {
  background: linear-gradient(180deg, #F7A8C6, var(--flow));
  border-color: transparent; color: #fff;
  box-shadow: 0 8px 18px rgba(224, 105, 158, .28);
}
.cyc-act.primary.ending { background: linear-gradient(180deg, #C9A6F0, var(--accent)); box-shadow: 0 8px 18px var(--accent-dim); }

/* the sheet the forms live in */
.cycle-sheet {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 72;
  display: flex; align-items: flex-end;
  background: rgba(43, 35, 80, .34);
  opacity: 0; transition: opacity .2s ease;
}
.cycle-sheet.open { opacity: 1; }
.cycle-sheet-in {
  width: 100%; max-height: 86vh; overflow-y: auto;
  background: linear-gradient(180deg, #F7F3FE, #EDE5FB);
  border-radius: 22px 22px 0 0;
  padding: 16px 16px calc(20px + env(safe-area-inset-bottom));
  display: flex; flex-direction: column; gap: 12px;
  transform: translateY(14px); transition: transform .2s ease;
}
.cycle-sheet.open .cycle-sheet-in { transform: none; }
.cycle-sheet-close {
  align-self: flex-end; width: 34px; height: 34px; border-radius: 50%;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  color: var(--ink-soft); font-size: 14px; font-weight: 700;
}
/* inside the sheet they are sections of one surface, not two floating cards */
.cycle-sheet .cta-card { background: rgba(255,255,255,.62); border-radius: 16px; padding: 14px; }

.cycle-status { padding: 18px; display: flex; flex-direction: column; gap: 8px; }
.cycle-status .big { font-size: 22px; font-weight: 700; color: var(--ink); }
.phase-pill {
  align-self: flex-start;
  font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase;
  border-radius: 999px; padding: 6px 13px;
}
.phase-pill.period { background: rgba(224, 105, 158, .16); color: #C24B82; }
.phase-pill.fertile { background: rgba(74, 121, 201, .15); color: #3D68B2; }
.phase-pill.follicular { background: rgba(47, 159, 123, .15); color: #22815F; }
.phase-pill.luteal { background: rgba(176, 126, 43, .15); color: #96691E; }

.cta-card { padding: 20px 18px; display: flex; flex-direction: column; gap: 14px; }
.cta-card .cta-line { font-size: 15px; color: var(--ink-soft); font-weight: 600; }
.when-row { display: flex; gap: 8px; flex-wrap: wrap; }
.when-row .chip { flex: 1; text-align: center; white-space: nowrap; }
.when-row input[type="date"] {
  flex: 1; height: 40px; border-radius: 999px;
  border: 1px solid var(--card-brd); background: var(--chip-bg);
  color: var(--ink-soft); font-size: 13px; font-weight: 700; padding: 0 14px;
}

.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.stat-tile { padding: 16px 8px; text-align: center; display: flex; flex-direction: column; gap: 5px; }
.stat-tile .stat-val { font-size: 20px; font-weight: 800; color: var(--ink); }
.stat-tile .stat-val small { font-size: 12px; font-weight: 700; color: var(--muted); }
.stat-tile .stat-lbl { font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; font-weight: 700; color: var(--dim); }

.hist-row {
  display: flex; align-items: center; gap: 10px;
  padding: 12px 4px; border-bottom: 1px solid var(--card-brd);
}
.hist-row:last-child { border-bottom: none; }
.hist-row .h-dates { flex: 1; font-size: 14px; color: var(--ink-soft); font-weight: 600; }
.hist-row .h-days { font-size: 12.5px; color: var(--muted); font-weight: 700; }
.hist-row .h-del { background: none; border: none; color: var(--faint); font-size: 12px; font-weight: 700; padding: 6px; }

.feel-row { display: flex; gap: 10px; align-items: baseline; padding: 9px 4px; border-bottom: 1px solid var(--card-brd); }
.feel-row:last-child { border-bottom: none; }
.feel-row .f-date { font-size: 12px; font-weight: 700; color: var(--dim); white-space: nowrap; }
.feel-row .f-body { flex: 1; font-size: 13.5px; color: var(--ink-soft); }

/* bottom sheet */
#sheet-veil {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 40;
  background: rgba(43, 35, 80, 0.4);
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  opacity: 0; pointer-events: none; transition: opacity .3s ease;
}
#sheet-veil.open { opacity: 1; pointer-events: auto; }
#sheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 41;
  background: rgba(248, 245, 255, 0.99);
  border-top: 1px solid var(--accent-dim);
  border-radius: 26px 26px 0 0;
  padding: 14px 22px calc(26px + env(safe-area-inset-bottom));
  transform: translateY(105%);
  transition: transform .34s cubic-bezier(.3, 1, .4, 1);
  max-height: 82vh; max-height: 82dvh; overflow-y: auto;
  box-shadow: 0 -14px 44px rgba(63, 48, 130, 0.22);
}
#sheet.open { transform: translateY(0); }
.sheet-grip { width: 44px; height: 5px; border-radius: 999px; background: var(--card-brd); margin: 0 auto 14px; }

.flow-row { display: flex; gap: 8px; }
.flow-row .chip { flex: 1; text-align: center; padding: 11px 4px; }
.chip.flow-on-light { background: rgba(224, 105, 158, .3); color: #8E2F5C; border-color: transparent; }
.chip.flow-on-medium { background: rgba(224, 105, 158, .6); color: #fff; border-color: transparent; }
.chip.flow-on-heavy { background: var(--flow); color: #fff; border-color: transparent; }

.sym-grid { display: flex; flex-wrap: wrap; gap: 8px; }

/* ---------- house ---------- */

.house-stage {
  position: relative;
  border-radius: 26px;
  overflow: hidden;
  border: 1px solid var(--card-brd);
  box-shadow: 0 12px 34px rgba(63, 48, 130, 0.16);
  line-height: 0;
  /* Fill the height the room bar and pet bar leave behind.

     ⚠️ A ZERO BASIS, NOT `auto`. With `flex-basis: auto` the base size is the
     content height, and the content is an <svg> whose own height is a
     percentage of that same auto height — circular, and with the
     `aspect-ratio` below in play the intrinsic contribution stops being
     obviously well defined. A zero basis says "all the free space", which is
     what this line always meant, and .pet-bar (flex: 0 0 auto) is the only
     other child so the used height is identical. */
  flex: 1 1 0;
  min-height: 0;
  /* ⚠️ THE STAGE IS THE ROOM, AND THIS IS WHAT KEEPS IT TRUE.
     Everything on this screen is placed as a fraction of the stage —
     #pet-walker's 34%, the room arrows' 6px, the walk's 4-66% in app.js, and
     the sleeping pet's stageW/390 bed scale. All four are correct only while
     the room art actually FILLS the stage.

     houseSVG draws a 390-wide viewBox with preserveAspectRatio="xMidYMax meet"
     and renderPet generates its height as Math.max(330, 390 * stageH/stageW).
     So the art can fill a stage of any shape UP TO 390/330 = 1.18:1 — and past
     that the 330 floor stops it, the room renders full-height but narrower than
     the stage, and all four rules above start measuring a box the room is not
     in. On a 1200x400 stage the room draws 473px wide while the pet is 408px
     and the arrows sit 357px outside the wall. Deniz, item 4: "the pet seems
     very big after a certain wideness has been exceeded".

     Capping the PET would fix one of those and leave three. Capping the STAGE
     makes the stage be the room again and all four come right by construction,
     with no second number to keep in step. So on a very wide screen the room
     PILLARBOXES — blank at the sides, not above and below, because the height
     is what it is derived from — which is the normal behaviour for a scene of a
     fixed shape.

     ⚠️ THIS BLOCK IS ONLY HALF THE MECHANISM. .pet-bar is the stage's sibling
     and is still stretched by the column, so on a wide screen it would run far
     wider than the room it is captioning. It cannot be fixed here — the room's
     width comes from the room's HEIGHT, which is what is left after the pet bar
     — so matchPetBarToRoom() in app.js reads the laid-out stage rect back and
     clamps the bar to it.

     Same three declarations as .mem-grid, the memory board, and for the same
     measured reason its comment gives: max-width is the only clamp and it
     degrades in the SAFE direction — on a tall phone the ratio-derived width
     overflows, is clamped to the column, and the HEIGHT is untouched, so
     nothing about a phone changes. align-self is here because .mem-grid gets
     its definite height from height:100% inside a wrapper, and this element is
     the flex item itself: without it the column's default `stretch` would
     override the ratio and hand back the full width.

     ⚠️ 390 and 330 are house-svg.js's viewBox width and its shortest room.
     stage-aspect-check.js reads all three out of the source and fails if they
     stop agreeing. */
  aspect-ratio: 390 / 330;
  max-width: 100%;
  /* ⚠️ NO HEIGHT CAP — Q60 a, 2026-09-13. There was one (max-height: 84.6vw)
     from 2026-09-11, and measured on a 390x844 phone it left the page as
     header 7% · room 39% · pet bar 9% · 45% NOTHING · nav 9%, identically on
     Chrome 152 and WebView 83. Deniz: "the room grows into the space".

     So on a phone the stage takes all the height the column leaves, exactly
     as "flex: 1 1 0" always meant, and the ROOM ART is what changed: houseSVG
     no longer pads a tall stage with blank wall — roomGeometry() in
     house-svg.js gives most of the height to the floor, sets the furniture on
     the new floor line and brings rugs and tables forward, so a tall room is
     drawn for its shape rather than stretched to it.

     ⚠️ Do not put a cap back here without matchPetBarToRoom() in app.js,
     whose WebView-83 arm had a copy of this one: the two engines must agree.
     And house-tall-probe.js (PROBE_W/PROBE_H) is how to see whether they do. */
  align-self: center;
  touch-action: pan-y;
}
.house-stage svg.house { width: 100%; height: 100%; display: block; }

/* The wandering pet: absolutely positioned over the room, JS moves it.

   ⚠️ R20.9/F2 — THE LEFT AND BOTTOM BELOW ARE THE PRE-JS DEFAULT AND NOTHING
   ELSE. startWander() pins both to the stage’s bottom-left corner the moment it
   runs and moves the animal with a `transform` from there, because `left` and
   `bottom` are LAYOUT properties: transitioning them laid out a document
   holding all thirteen pages, 22 times a second, for the whole of every amble.
   Measured 22 -> 0 layouts/s on Home; see docs/archive/r209/F2-walker.md.

   So do not add a rule here that positions this element, and do not read its
   `left` back out expecting the animal to be there — use a rect. There is
   deliberately no `will-change`: gotcha 122 measured that exact addition as a
   NET LOSS on this project. */
#pet-walker {
  position: absolute;
  left: 34%;
  bottom: 5%;
  width: 34%;
  background: none;
  border: none;
  padding: 0;
  z-index: 6;
  transform-origin: bottom center;
}
#pet-walker svg { width: 100%; height: auto; display: block; }
#pet-flip { transition: transform .4s ease; }
#pet-walker.walking #pet-flip svg { animation: walkBounce .55s ease-in-out infinite; }
#pet-walker.asleep #pet-flip svg { animation: petSleep 5s ease-in-out infinite; }
#pet-walker:not(.walking):not(.asleep) #pet-flip svg { animation: petBob 4.2s ease-in-out infinite; }
@keyframes walkBounce {
  0%, 100% { transform: translateY(0) rotate(-2deg); }
  50% { transform: translateY(-6px) rotate(2deg); }
}


.house-preview {
  border-radius: 18px;
  overflow: hidden;
  border: 1px solid var(--card-brd);
  margin-top: 12px;
  line-height: 0;
}

/* ---------- pet ---------- */

.pet-stage {
  position: relative;
  border-radius: 26px;
  overflow: hidden;
  border: 1px solid var(--card-brd);
  min-height: 300px;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 12px 34px rgba(63, 48, 130, 0.16);
}
.pet-stage svg.pet { position: relative; z-index: 2; }
.pet-stage .wrap-bob { animation: petBob 4.2s ease-in-out infinite; }
.pet-stage .wrap-sleep { animation: petSleep 5s ease-in-out infinite; }
.scene-night { background: radial-gradient(140% 100% at 50% 0%, #201A56 0%, #0C0A28 70%); }
.scene-meadow { background: linear-gradient(180deg, #9FD8F0 0%, #7FCF9E 78%, #5FBE7E 100%); }
.scene-beach { background: linear-gradient(180deg, #8FD4F2 0%, #5EB1E8 60%, #F2DCA6 88%, #E7C687 100%); }
.scene-aurora { background: linear-gradient(160deg, #1B1650 10%, #1E6E82 45%, #45B58C 70%, #1B1650 100%); }

.pet-id .pname { font-size: 24px; font-weight: 700; color: var(--ink); }
.pet-id .plevel { font-size: 12.5px; font-weight: 800; color: var(--accent); letter-spacing: .08em; }

.meters { display: flex; flex-direction: column; gap: 10px; padding: 14px 4px 4px; }
.meter { display: flex; align-items: center; gap: 10px; }
.meter .m-label { width: 84px; font-size: 12px; font-weight: 700; color: var(--dim); display: flex; gap: 5px; align-items: center; }
.meter .m-bar { flex: 1; height: 9px; border-radius: 999px; background: rgba(43, 35, 80, 0.14); overflow: hidden; }
.meter .m-fill { height: 100%; border-radius: 999px; transition: width .6s cubic-bezier(.3, 1, .4, 1); }
.m-fill.hunger { background: linear-gradient(90deg, #E8B54A, #E0699E); }
.m-fill.happiness { background: linear-gradient(90deg, var(--accent), #E0699E); }
.m-fill.energy { background: linear-gradient(90deg, #4A79C9, #2F9F7B); }
.meter .m-val { width: 34px; font-size: 11.5px; color: var(--muted); text-align: right; font-weight: 700; }

.xp-row { padding: 2px 4px 0; display: flex; align-items: center; gap: 10px; }
.xp-bar { flex: 1; height: 6px; border-radius: 999px; background: rgba(43, 35, 80, 0.14); overflow: hidden; }
.xp-fill { height: 100%; background: var(--accent); border-radius: 999px; transition: width .6s ease; }
.xp-row .xp-txt { font-size: 11px; color: var(--faint); font-weight: 700; }

/* The row of action buttons that used to live under the room is gone: you
   cuddle by tapping the pet, sleep by tapping the bed, dress it at the
   wardrobe and redecorate from the header. */

/* ---------- the diary: a page the pet writes on ---------------------------
   This was eight rows of 13px grey text with an emoji and a timestamp — a log,
   not a diary, and the stacked-row shape the whole app is moving away from.

   --line is the one number the illusion hangs on. The rules repeat every
   --line px and every line box in an entry is exactly --line tall, so the
   SECOND line of a long entry lands on the next rule instead of drifting off
   it. Change one and you must change the other.                              */
.diary-page {
  --line: 30px;
  --margin-x: 46px;
  /* Fill the screen. A short page floats in the middle of the gallery looking
     like a card that ran out of content; ruled paper running off the bottom
     reads as a page you have not finished writing.

     R19 item 8 — with seven of them the page can no longer be the thing that
     grows: the tabs and the page-turn have to stay put while the paper moves,
     so .gal-body.fit-diary is the fixed frame and the PAGE is the only part
     that scrolls. `min-height: 0` is load-bearing on a flex child, exactly as
     it is on .fit-room's shelf, or the page keeps its content height and the
     turn strip is pushed off the bottom of the screen.

     ⚠️ background-attachment: local is load-bearing too. Without it the ruling
     stays nailed to the element while the handwriting scrolls over it, and
     every line after the first screenful sits between two rules. */
  flex: 1 1 auto; min-height: 0;
  overflow-y: auto; overflow-x: hidden;
  /* ⚠️ The page is turned by dragging it sideways, and the default `auto`
     lets the browser claim a horizontal drag as a pan — at which point it
     CANCELS the pointer sequence and the swipe silently dies on touch
     hardware while still working perfectly with a mouse. pan-y hands the
     browser the axis the paper really uses and keeps the other one. */
  touch-action: pan-y;
  background-attachment: local;
  position: relative;
  /* Square-ish at the top: the index tabs sit directly on that edge, and a
     14px curve leaves the outer two hanging over nothing. */
  border-radius: 5px 5px 18px 18px;
  padding: 18px 14px 8px 0;
  background-color: var(--paper);
  background-image: repeating-linear-gradient(180deg,
    transparent 0 calc(var(--line) - 1px),
    var(--paper-rule) calc(var(--line) - 1px) var(--line));
  background-position: 0 18px;   /* == padding-top, so rule 1 sits under line 1 */
  box-shadow: 0 10px 28px rgba(43,35,80,.16), 0 1px 0 rgba(255,255,255,.9) inset;
}
/* the margin rule, drawn the length of the page */
.diary-page::before {
  content: ''; position: absolute; top: 0; bottom: 0;
  left: var(--margin-x); width: 1.5px;
  background: var(--paper-margin); opacity: .55;
}
.diary-entry {
  display: grid;
  grid-template-columns: var(--margin-x) 1fr auto;
  align-items: start; column-gap: 10px;
  font-family: 'Caveat', cursive;
  font-size: 21px; font-weight: 500; line-height: var(--line);
  color: var(--pen);
}
/* the sticker lives OUT in the margin, like something doodled beside the line */
.diary-entry .dk {
  grid-column: 1; text-align: center;
  font-family: 'Figtree', system-ui, sans-serif; font-size: 15px;
  line-height: var(--line);
}
.diary-entry .dsaid { grid-column: 2; }
.diary-entry .dsaid b { font-weight: 700; }
.diary-entry .dwhen {
  grid-column: 3; padding-right: 4px;
  font-size: 15px; color: var(--pen-soft); white-space: nowrap;
}
/* signed at the foot of the page, by the animal, with a drawing of itself */
.diary-sign {
  display: flex; align-items: center; justify-content: flex-end; gap: 8px;
  padding: 4px 8px 0 0; line-height: var(--line);
  font-family: 'Caveat', cursive; font-size: 22px; color: var(--pen);
}
.diary-sign svg { width: 54px; height: 54px; }
.diary-empty {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  padding: calc(var(--line) * 2) 10px;
  font-family: 'Caveat', cursive; font-size: 22px; color: var(--pen-soft);
  text-align: center;
}
.diary-empty svg { width: 96px; height: 96px; }

/* ---------- R19 item 8: seven days, a page each ---------------------------
   The frame. Tabs on top, one sheet of paper in the middle doing all the
   scrolling, the page-turn along the bottom. Same shape as .fit-room and for
   the same reason: the controls must not slide away with the content. */
.gal-body.fit-diary {
  display: flex; flex-direction: column;
  overflow: hidden;
  padding: 10px 18px calc(8px + env(safe-area-inset-bottom));
}

/* Index tabs, cut from the same paper as the page and bottom-aligned to it.
   Not a row of pills: the live one is flush with the sheet and full height,
   the rest sit a few pixels lower, duller and in shadow, the way tabs in a
   real notebook do. The number is the day of the month — the one label that
   needs no translating and cannot collide (four Turkish weekdays start with
   the same letter). A day with nothing written on it carries no ink dot, so
   the strip says where the week's life was before you turn to any of it. */
.diary-tabs {
  flex: 0 0 auto;
  display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr);
  gap: 3px; padding: 0 5px; align-items: end;
}
.dtab {
  /* ⚠️ Top-aligned, not centred. The tab is translated 4px DOWN so it tucks
     behind the sheet, so anything centred in it loses its bottom few pixels to
     the paper — which is precisely what happened to the ink dot. */
  display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
  /* 34px, not 28: the content is 4 + 16 + 3 + 4 = 27px tall and the tab is
     tucked 4px behind the sheet, so anything under 31px puts the ink dot
     under the paper. diary-ui-check measures this rather than trusting it. */
  gap: 3px; height: 34px; padding: 4px 0 0; border: 0;
  border-radius: 9px 9px 0 0;
  background: #F0E6D4;
  color: var(--pen-soft);
  font-family: 'Caveat', cursive; font-size: 16px; font-weight: 700;
  box-shadow: inset 0 -4px 7px rgba(43,35,80,.09);
  transform: translateY(4px);
  /* ⚠️ NOT height, AND NOT transform. R20.9 F4: turning a page used to rebuild
     the whole tab strip, and a freshly inserted element does not transition —
     so this declaration had never once run in the app's life. The moment the
     turn started REUSING the seven buttons and only moving the .on class,
     it woke up: two tabs easing their HEIGHT, a layout property, took a page
     turn from 1 layout to 7.9, measured. Same family of bug as the pet
     walking by transitioning left/bottom (R20.9 F2) and the catcher before it
     (R20.4). Background and colour are paint, cost no layout, and are the half
     of this that a reader can actually see. */
  /* ⚠️ AND NOT BACKGROUND OR COLOUR EITHER, on measurement. They cost no
     layout, but easing them on two tabs for 160ms of every turn put roughly
     130ms of extra style and paint into a twelve-turn run — which is why this
     rule now declares no transition at all. The tab snaps, exactly as it has
     always snapped: until this round it was REBUILT rather than restyled, so
     nothing here had ever animated and nobody has ever seen it move. */
}
.dtab .dtn { line-height: 1; }
.dtab .dtd { width: 4px; height: 4px; border-radius: 50%; background: var(--paper-margin); }
.dtab.quiet { color: rgba(124,138,180,.55); }
.dtab.quiet .dtd { background: transparent; }
.dtab.on {
  height: 38px; transform: none; padding-top: 8px;
  background: var(--paper); color: var(--pen); font-size: 18px;
  box-shadow: 0 -4px 12px rgba(43,35,80,.10);
}
.dtab.on .dtd { background: var(--paper-margin); }
.dtab.on.quiet .dtd { background: transparent; }

/* The date, written across the first rule of the page, underlined in the same
   pink as the margin. ⚠️ Exactly one --line tall and nowrap, because every
   entry below it is positioned by counting rules: a heading that wraps or
   carries a margin takes the whole page off its lines. */
.diary-date {
  font-family: 'Caveat', cursive;
  font-size: 20px; font-weight: 700; line-height: var(--line);
  color: var(--pen-soft);
  padding-left: calc(var(--margin-x) + 10px); padding-right: 8px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  text-decoration: underline; text-decoration-color: var(--paper-margin);
  text-decoration-thickness: 1.5px; text-underline-offset: 4px;
  /* ⚠️ One continuous stroke. The default skips descenders, which on a Turkish
     date ("19 Ağustos Çarşamba") breaks the line into three pieces around the
     ğ, ş and the cedilla — and a broken underline reads as a bug, where a line
     drawn straight through a descender reads as a pen. */
  text-decoration-skip-ink: none;
}

/* A day nobody came. Every height here is a whole number of rules so the
   signature underneath still lands on one. */
.diary-quiet {
  display: flex; flex-direction: column; align-items: center;
  padding: var(--line) 18px 0;
  font-family: 'Caveat', cursive; font-size: 21px; line-height: var(--line);
  color: var(--pen-soft); text-align: center;
}
.diary-quiet svg { width: 90px; height: 90px; }

/* The page-turn. Two folded corners of paper and the page number in the same
   hand as everything else. 52x44 is the tap target; the drawing is 30px. */
.diary-turn {
  flex: 0 0 auto;
  display: flex; align-items: center; justify-content: space-between;
  padding: 6px 2px 0;
}
.dturn {
  width: 52px; height: 44px; padding: 0; border: 0; background: none;
  display: flex; align-items: center; justify-content: center;
  /* ⚠️ transform only — the press. R20.9 F4, and the same story as .dtab
     above: the opacity half of this only ever had one job, fading an arrow out
     when it reaches the end of the book, and it had never once run, because
     turning a page rebuilt the whole strip and a freshly inserted element does
     not transition. Repainting in place woke it up, and the four extra layouts
     a twelve-turn run then cost were all on the turns that reach or leave an
     end. The arrow still dims to .22 the moment it dies — it just does not
     take 140ms to get there, which is what it did before this line existed in
     any working form. */
  transition: transform .14s ease;
}
.dturn svg { width: 30px; height: 30px; }
.dturn.fwd svg { transform: scaleX(-1); }
.dturn[disabled] { opacity: .22; }
.dturn:not([disabled]):active { transform: translateY(1px) scale(.92); }
.dpage {
  font-family: 'Caveat', cursive; font-size: 19px; font-weight: 700;
  color: var(--pen); opacity: .62; letter-spacing: .4px;
}

/* The turn itself: the new page arrives from the side it was turned from, with
   the faintest tilt so it reads as paper rather than a slide. */
@keyframes diaryTurnFwd {
  from { opacity: 0; transform: translateX(16px) rotate(.4deg); }
  to   { opacity: 1; transform: none; }
}
@keyframes diaryTurnBack {
  from { opacity: 0; transform: translateX(-16px) rotate(-.4deg); }
  to   { opacity: 1; transform: none; }
}
.diary-page[data-turn="fwd"]  { animation: diaryTurnFwd .19s ease-out both; }
.diary-page[data-turn="back"] { animation: diaryTurnBack .19s ease-out both; }
@media (prefers-reduced-motion: reduce) {
  .diary-page[data-turn] { animation: none; }
  .dtab, .dturn { transition: none; }
}

.fx-layer { position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 5; pointer-events: none; overflow: hidden; }
.fx-heart { position: absolute; font-size: 22px; animation: heartUp 1.15s ease-out both; }
.fx-zz { position: absolute; font-size: 17px; color: rgba(255,255,255,.8); animation: zz 2.4s ease-out infinite; }

/* adopt / intro */
.adopt-preview { display: flex; align-items: center; justify-content: center; padding: 6px 0 2px; }
.intro-list { display: flex; flex-direction: column; gap: 14px; padding: 16px 18px; }
.intro-item { display: flex; gap: 12px; align-items: flex-start; }
.intro-item .ii { font-size: 17px; margin-top: 1px; }
.intro-item .it { flex: 1; }
.intro-item .it b { display: block; font-size: 14.5px; color: var(--ink); }
.intro-item .it span { font-size: 13px; color: var(--muted); line-height: 1.45; }

.opt-row { display: flex; gap: 9px; flex-wrap: wrap; padding: 4px 0 2px; }
.adopt-rail {
  border-radius: 24px; padding: 10px 14px 12px;
  background: linear-gradient(180deg, rgba(255, 255, 255, .5), rgba(255, 255, 255, .22));
  border: 1.5px solid var(--card-brd);
}
.species-opt {
  width: 64px; height: 64px; border-radius: 20px;
  background: var(--chip-bg); border: 1.5px solid var(--card-brd);
  display: flex; align-items: center; justify-content: center;
  transition: all .2s ease; padding: 6px;
}
.species-opt.on { border-color: var(--accent); background: rgba(124, 92, 224, 0.12); box-shadow: 0 4px 16px var(--accent-dim); }
.species-opt svg { width: 100%; height: 100%; }
/* ---------- the dressing room (R10b/R10c, 2026-08-17) ------------------------
   Deniz: "the wardrobe page should have the pet fixed, large in the middle, and
   the items should be on a lower card which has categories on top and items
   horizontally scrollable on bottom and an okay and buy button."

   So the page is the animal, and everything that changes it is one card at the
   foot. Nothing here scrolls vertically — the rail scrolls sideways and is the
   only scroller on the screen, which is what keeps the pet you are dressing in
   front of you while you dress it. Same `min-height: 0` trap as the edit-room
   page: without it the stage refuses to shrink and the whole page scrolls with
   every rule below applied and looking right in the inspector. */
.gal-body.fit-closet {
  display: flex; flex-direction: column;
  overflow: hidden; padding: 0;
}
.closet-stage {
  position: relative;
  flex: 1 1 auto; min-height: 0;
  display: flex; align-items: center; justify-content: center;
  padding: 10px 18px 4px;
}
/* "Edit pet", on the stage rather than in the header — the header at 390px is
   already close button, title, crumb chip and gem chip, with no room for a
   fifth thing carrying a word. Here it has space to say what it does, and it
   sits on the animal it edits instead of in a corner of the chrome. */
.edit-pet {
  position: absolute; top: 6px; right: 16px; z-index: 2;
  display: inline-flex; align-items: center; gap: 7px;
  padding: 8px 13px 8px 11px; border-radius: 16px;
  background: rgba(255, 255, 255, .74); border: 1.5px solid var(--card-brd);
  color: var(--ink-soft); font: inherit; font-size: 13px; font-weight: 800;
  white-space: nowrap; cursor: pointer;
  box-shadow: 0 4px 12px rgba(63, 48, 130, .10);
  transition: transform .12s ease;
}
.edit-pet svg { display: block; flex: 0 0 auto; }
.edit-pet:active { transform: translateY(1px) scale(.98); }

/* The sparkle marks rise, fade out, and come again from the bottom (R11/G6,
   corrected 2026-08-18). Deniz: they should "bubble up, and disappear and new
   ones re-appear from bottom bubbling up, at the sides of the pet".

   ⚠️ `infinite` and NOT `alternate`. Alternate is what made the first version
   wrong: it ran the rise backwards on every other pass, so a mark sank back
   down the way it came instead of a new one arriving from below.

   ⚠️ A keyframe that passes through zero opacity is only safe because the
   delays are negative and staggered (see pet-svg.js) — at any instant the six
   marks are spread across the cycle, so a static render always catches several
   of them mid-rise rather than a picture of nothing. */
@keyframes sparkle-rise {
  0%   { transform: translateY(15px) scale(.5);   opacity: 0; }
  22%  { transform: translateY(7px)  scale(1);    opacity: 1; }
  72%  { opacity: .8; }
  100% { transform: translateY(-24px) scale(.72); opacity: 0; }
}
.sp-mk {
  animation: sparkle-rise 3.4s ease-out infinite;
  transform-origin: center;
}
@media (prefers-reduced-motion: reduce) {
  .sp-mk { animation: none; }
}
.closet-stage .wrap-bob {
  height: 100%; width: 100%;
  display: flex; align-items: center; justify-content: center;
}
/* ⚠️ WIDTH-driven, height auto. petSVG writes width/height attributes and its
   viewBox keeps the drawing square whatever box it lands in — so the first
   version, `height: 100%; width: auto` with a max-width, produced a 296x531
   element with a 296px drawing letterboxed inside it and 235px of empty stage
   above and below the pet. It measured as "large" and looked like a thumbnail
   floating in a void. Height still caps it, for a short screen. */
.closet-stage svg.pet {
  width: min(100%, 340px); height: auto;
  max-height: 100%;
}

.closet-card {
  flex: 0 0 auto;
  background: rgba(255, 255, 255, 0.6);
  border-top: 1px solid var(--card-brd);
  border-radius: 26px 26px 0 0;
  box-shadow: 0 -8px 28px rgba(63, 48, 130, 0.13);
  padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
  backdrop-filter: blur(8px);
}

/* Five tabs, each one a small DRAWING of what the category holds — a crown, a
   pair of glasses, a bowtie, a sparkle, a colour wheel. Five words in five
   rounded rectangles is the pill-button shape again, and this strip is the one
   place on the screen that has to be readable before you have read it. */
.cat-strip {
  /* ⚠️ HOWEVER MANY TABS THERE ARE, not five. It was repeat(5,1fr) when there
     were exactly five categories; there are now six on a beaked pet (no mouth
     rail) and seven on everything else, so a fixed count is wrong for both.
     grid-auto-flow:column with equal auto-columns fits whatever catsFor()
     returns, which is the only list that knows. */
  display: grid; grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: 3px; padding: 2px 10px 7px;
  border-bottom: 1px solid var(--card-brd);
}
.cat-tab {
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  padding: 6px 2px 5px; border-radius: 16px;
  background: none; border: 0; cursor: pointer; font: inherit;
  color: var(--faint);
  transition: background .16s ease, color .16s ease;
}
.cat-tab .ct-face {
  width: 30px; height: 30px; display: block; opacity: .48;
  transition: opacity .16s ease, transform .16s ease;
}
.cat-tab .ct-face > svg { width: 100%; height: 100%; display: block; }
.cat-tab .ct-name {
  font-size: 10.5px; font-weight: 700; line-height: 1.1;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.cat-tab.on { background: rgba(124, 92, 224, 0.13); color: var(--ink); }
.cat-tab.on .ct-face { opacity: 1; transform: translateY(-1px) scale(1.06); }
.cat-tab.on .ct-name { font-weight: 800; }

/* The rail. Sideways, snapping, and with its scrollbar hidden — on a phone the
   bar is an overlay anyway, and on desktop a visible one under a row of 72px
   tiles reads as a mistake. The right-hand fade is what says there is more. */
.rail {
  display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden;
  padding: 11px 14px 13px;
  scroll-snap-type: x proximity;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 12px, #000 calc(100% - 22px), transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0, #000 12px, #000 calc(100% - 22px), transparent 100%);
}
.rail::-webkit-scrollbar { display: none; }
.rt {
  position: relative; flex: 0 0 auto; scroll-snap-align: center;
  width: 72px; padding: 0; border: 0; background: none; cursor: pointer;
}
.rt-frame {
  display: block; width: 72px; height: 72px; border-radius: 22px; padding: 9px;
  background: linear-gradient(180deg, rgba(255, 255, 255, .72), rgba(255, 255, 255, .3));
  border: 1.5px solid var(--card-brd);
  box-shadow: inset 0 1px 3px rgba(88, 70, 160, .09);
  overflow: hidden;
  transition: transform .16s ease, border-color .16s ease,
              box-shadow .16s ease, background .16s ease;
}
.rt-frame > svg { width: 100%; height: 100%; display: block; }
.rt.on .rt-frame {
  border-color: var(--accent); background: rgba(124, 92, 224, 0.16);
  box-shadow: 0 6px 18px var(--accent-dim); transform: translateY(-3px);
}
.rt:active .rt-frame { transform: scale(.94); }
/* Not bought: dashed, price on the tile. Locked by level: drained, level on the
   tile — but the moment it is the thing being previewed it comes back to full
   colour, because letting you see it is the whole point of letting you tap it. */
.rt.buy .rt-frame { border-style: dashed; }
.rt.locked .rt-frame > svg { filter: grayscale(1) opacity(.42); }
.rt.locked.on .rt-frame > svg { filter: none; }
.rt.col .rt-frame { padding: 7px; }
.rt-disc {
  display: block; width: 100%; height: 100%; border-radius: 50%;
  box-shadow: inset 0 -4px 9px rgba(43, 35, 80, .18),
              inset 0 3px 7px rgba(255, 255, 255, .55);
}
.rt-tag {
  position: absolute; right: -3px; top: -5px;
  padding: 2px 7px; border-radius: 999px;
  font-size: 10.5px; font-weight: 800; line-height: 1.45;
  background: rgba(255, 255, 255, .94); color: #7A5A14;
  box-shadow: 0 2px 6px rgba(88, 70, 160, .18);
}
.rt-tag.lock { color: var(--muted); }
.rt-tag.cant { color: var(--bad); }

/* Row 2 (R23): which colour of the accessory chosen in row 1. Same tile
   language, smaller — a detail of the choice above it, not a second equal
   choice. The first tile is always the accessory's own original colour.
   ⚠️ HIDDEN WITH `visibility`, NEVER `display`, and it is a fixed height
   whether it has five tiles or none — this card is pinned to the bottom of a
   page that does not scroll, so a row that came and went would move Buy,
   Clear and Okay out from under a thumb already on its way to one. That is
   the exact rule `.btn-clear.off` follows below, applied one row up. */
.tint-rail {
  display: flex; align-items: center; gap: 8px;
  overflow-x: auto; overflow-y: hidden;
  padding: 0 14px 10px; height: 62px; box-sizing: border-box;
  scroll-snap-type: x proximity;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.tint-rail::-webkit-scrollbar { display: none; }
.tint-rail.off { visibility: hidden; }
.tint-rail .rt { width: 48px; }
.tint-rail .rt-frame {
  width: 48px; height: 48px; border-radius: 15px; padding: 6px;
}
.tint-rail .rt-tag { font-size: 9px; padding: 1.5px 5px; right: -4px; top: -4px; }

/* Both buttons are always here, even when Buy has nothing to do — this card is
   pinned to the bottom of a page that does not scroll, so a button that comes
   and goes moves the other one out from under the thumb reaching for it. */
.closet-foot { display: flex; gap: 10px; padding: 3px 16px 2px; }
.closet-foot .btn, .btn-buy {
  flex: 1 1 0; min-width: 0; width: auto; height: 50px;
  border-radius: 18px; font-size: 15px; white-space: nowrap;
}
.btn-buy {
  background: rgba(255, 255, 255, .72); border: 1.5px solid var(--card-brd);
  color: var(--ink); font: inherit; font-size: 14.5px; font-weight: 800;
  cursor: pointer;
  transition: transform .12s ease, opacity .2s ease;
}
.btn-buy:active { transform: translateY(1px) scale(.99); }
.btn-buy:disabled { opacity: .45; }

/* Clear — "put it back". A fixed square rather than a third flexible column, so
   Buy and Okay keep the same widths whether or not it has anything to do.
   ⚠️ It hides with `visibility`, never `display`: this row is pinned to the
   bottom of a page that does not scroll, and collapsing it would slide Buy
   sideways under a thumb that was already on its way to Okay. */
.btn-clear {
  flex: 0 0 auto; width: 50px; height: 50px; border-radius: 18px;
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(255, 255, 255, .72); border: 1.5px solid var(--card-brd);
  color: var(--ink-soft); cursor: pointer;
  transition: transform .12s ease;
}
.btn-clear:active { transform: translateY(1px) scale(.97); }
.btn-clear.off { visibility: hidden; }

/* The ✎ beside the coin chip, which opens "modify pet". */
.gal-act {
  width: 38px; height: 38px; border-radius: 50%; flex: 0 0 auto;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  color: var(--ink-soft); font-size: 15px; font-weight: 700;
}

/* ---------- artwork tiles ----------------------------------------------------
   Every "pick one of these" surface: accessories, window scenes, furniture.
   These replaced rows of pill buttons that had the item's NAME written on them
   while the drawing of it sat unused in pet-svg.js / house-svg.js. The rule is
   in WORKING-RULES.md §4.4 — show the thing, and let the tile itself carry
   owned / equipped / locked / priced rather than a chip parked alongside. */

.tile-grid {
  display: grid; gap: 10px; padding: 4px 0 2px;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  /* ⚠️ Load-bearing, and the reason is not obvious. A grid's default
     align-content is `stretch`, so an auto-sized ROW grows to eat any leftover
     height the container was given. In normal page flow there is never any
     leftover and this line does nothing — but the edit-room shelf is a flex
     child with a height, and on the bedroom (five items, one row) that row took
     all 340 spare pixels: five tiles 100px wide and 340px tall, artwork stranded
     at the top of each. Deniz, 2026-08-17: "When all of the furniture fit in one
     row they all get long like this."
     Same family as gotcha 64 — a grid axis spending space it was handed. */
  align-content: start;
}
/* Scenes are landscapes; three across reads better than four. */
.tile-grid.wide { grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); }

.tile {
  position: relative; display: flex; flex-direction: column; align-items: center;
  gap: 6px; padding: 10px 8px 9px; border-radius: 20px;
  background: var(--chip-bg); border: 1.5px solid var(--card-brd);
  color: var(--ink-soft); font: inherit; cursor: pointer;
  transition: transform .16s ease, border-color .16s ease,
              background .16s ease, box-shadow .16s ease;
}
.tile:active { transform: scale(.95); }

/* The stage the artwork stands on. A faint inset so a pale item (the moon lamp,
   the stars) has something to be pale against. */
.tile-frame {
  /* Relative because the level chip lives inside it since P54 (tileHTML). The
     @supports-not-aspect-ratio block at the foot of this file sets this too,
     for its own reason; it is unconditional here so the badge is anchored on
     every engine rather than only on the old one. */
  position: relative;
  width: 100%; aspect-ratio: 1; border-radius: 14px;
  background: linear-gradient(180deg, rgba(255,255,255,.55), rgba(255,255,255,.16));
  box-shadow: inset 0 1px 3px rgba(88,70,160,.09);
  display: flex; align-items: center; justify-content: center;
  overflow: hidden; padding: 6px;
}
.tile-grid.wide .tile-frame { aspect-ratio: 4 / 3; padding: 0; }
/* petSVG() writes width/height ATTRIBUTES; a stylesheet rule beats those, which
   is what lets one drawing serve both a fixed-size preview and a fluid tile. */
.tile-frame > svg, .tile-art { width: 100%; height: 100%; display: block; }

.tile-name {
  font-size: 12px; font-weight: 700; line-height: 1.15; text-align: center;
  color: var(--ink-soft);
}

/* Selected. The ring is on the tile and the tile lifts — no tick needed, the
   whole thing changes state. */
.tile.on {
  border-color: var(--accent); background: rgba(124, 92, 224, 0.13);
  box-shadow: 0 6px 18px var(--accent-dim);
  transform: translateY(-2px);
}
.tile.on .tile-name { color: var(--ink); font-weight: 800; }

/* Not bought yet: a dashed edge, the price on the artwork. */
.tile.buy { border-style: dashed; }
.tile.buy .tile-art { opacity: .72; }
/* ...except in the grocery shop, where food is a CONSUMABLE and nothing is ever
   "already owned". Every tile would carry the not-yet wash permanently, which
   makes it say nothing at all and leaves a screen of drained food. A distinction
   that applies to everything is not a distinction. */
.tile-grid.shop .tile.buy .tile-art { opacity: 1; }
.tile.previewing {
  border-style: solid; border-color: var(--accent);
  background: rgba(124, 92, 224, 0.16);
  box-shadow: 0 6px 18px var(--accent-dim);
}
.tile.previewing .tile-art { opacity: 1; }

/* Locked by level: drained of colour, with the level over it. Deliberately
   still legible — you should be able to want it. */
.tile.locked { cursor: pointer; }
/* .4 was too far. The comment above says "you should be able to want it", and on
   the bedroom shelf the moon lamp and the glowing stars — both pale drawings to
   begin with — came out as empty tiles with a level badge floating on them. You
   cannot want what you cannot see. */
.tile.locked .tile-art { filter: grayscale(1) opacity(.58); }
.tile.locked .tile-name { color: var(--faint); }
/* ...but the moment you tap it, you get to SEE it. Draining a locked tile is
   how it says "not yet"; keeping it drained while it is the thing being
   previewed would defeat the point of letting you preview it at all. */
.tile.locked.on .tile-art,
.tile.locked.previewing .tile-art { filter: none; }
.tile.locked.on, .tile.locked.previewing { border-style: dashed; }
/* `.tile.on .tile-name` and `.tile.locked .tile-name` have the same specificity,
   so the locked one wins on order alone and the tile you just tapped keeps a
   greyed-out name. It is the active selection; it should read like one. */
.tile.locked.on .tile-name,
.tile.locked.previewing .tile-name { color: var(--ink); font-weight: 800; }

/* One badge slot, bottom-right of the artwork, whatever the state is saying. */
.tile-tag {
  position: absolute; right: 7px; top: 7px;
  padding: 3px 8px; border-radius: 999px;
  font-size: 11px; font-weight: 800; line-height: 1.4;
  background: rgba(255, 255, 255, .9); color: #7A5A14;
  box-shadow: 0 2px 6px rgba(88, 70, 160, .16);
}
.tile-tag.lock { color: var(--muted); }
.tile-tag.owned { color: var(--good); }
.tile.on .tile-tag.owned { background: var(--accent); color: var(--accent-ink); }
.tile-tag.cant { color: var(--bad); }

/* ⚠️ THE SECOND GATE (P54) — the level, on a tile that ALSO carries a price.
   See tileHTML() in app.js for what went wrong without it.

   It takes the corner .tile-qty used to have. That badge said how many arrived
   for one price and was written for P53's food bundles; Deniz answered Q40 b and
   food went back to single items, so nothing draws it any more. The corner is
   inherited along with the reasoning that put it there: what you pay and what
   you get are two facts, and side by side in one corner they read as one number.
   Here it is what you pay and WHEN YOU MAY HAVE IT, which is the same trap.

   ⚠️ AMBER FILL, not the grey of .tile-tag.lock. Grey is this app's disabled
   colour and it is right on a tile that says only "✦ 7" — the whole tile is
   drained, and the badge belongs to it. Beside a live gold price it would read
   as the price being dead. A level is not a refusal, it is a date. */
.lvl-tag {
  position: absolute;
  padding: 2px 7px; border-radius: 999px;
  font-size: 10.5px; font-weight: 800; line-height: 1.45;
  background: #F5C765; color: #5B3B00;
  box-shadow: 0 2px 6px rgba(88, 70, 160, .2);
}
.tile .lvl-tag { left: 6px; bottom: 6px; padding: 3px 8px; font-size: 11px; line-height: 1.4; }
/* ⚠️ THE BOTTOM CORNER ON A RAIL TILE, NOT THE TOP ONE, AND THIS WAS FOUND BY
   OPENING THE PICTURE RATHER THAN BY AN ASSERTION. Top-left is genuinely the
   opposite corner on a 100px .tile, where the two badges end up ~86px apart.
   On a 72px .rt they are ~30px apart and merely crowd; on a 48px .tint-rail .rt
   THEY OVERLAP — the amber chip's right edge sits under the white price pill,
   and the level is unreadable. Every assertion in wardrobe-ui-check was green
   through both. Diagonally opposite is the only placement that survives all
   three sizes, and a rail tile has nothing at its bottom to compete with. */
.rt .lvl-tag { left: -3px; bottom: -5px; }
.tint-rail .rt .lvl-tag { font-size: 9px; padding: 1.5px 5px; left: -4px; bottom: -3px; }
/* A view chip clips (overflow: hidden, so the landscape keeps its rounded
   corners), so both of its badges have to sit INSIDE it — and its price is at
   top-right, so this one goes bottom-left for the same reason as the rail. */
.view-chip .lvl-tag { left: 3px; bottom: 3px; font-size: 9.5px; padding: 1.5px 5px; }

/* A section heading above a grid, with the coin count on the same line. */
.tile-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 10px; margin: 18px 0 8px;
}
.tile-head .label { margin: 0; }
.tile-head .hint { font-size: 11.5px; font-weight: 700; color: var(--faint); }

/* The build stamp under everything on the You page. Deliberately quiet: it is
   for answering "which version am I on", not something to read. */
/* ---------- test tools (non-production tiers only) ----------
   Deliberately unlike everything else on the page. The rest of You is soft
   cards on a pastel ground; this is a taped-off corner of a workshop — hatched
   top edge, squared corners, a monospace tier stamp. The visual job is to be
   unmistakably NOT the product, so that a screenshot of staging can never be
   mistaken for a screenshot of the app. */
.test-tools {
  margin-top: 26px; padding: 0 0 2px;
  border: 1px dashed rgba(88, 70, 160, 0.30);
  border-radius: 6px;
  background: rgba(255, 255, 255, 0.24);
  /* Clips the hazard stripe to the rounded corner. It also, less obviously,
     sets this box's automatic minimum size to ZERO — a scroll container has no
     content-based floor — and the You page is a flex column that overflows, so
     the box was crushed to the 4px of its own stripe with everything inside it
     clipped away. Every sibling here survives on `min-height: auto` resolving
     to its content; this one cannot, so it says so. */
  overflow: hidden;
  flex: 0 0 auto;
}
/* The hazard stripe. Two-stop repeating gradient rather than an image: it has
   to tint with the accent, and an asset would not. */
.test-tools::before {
  content: ''; display: block; height: 6px;
  background: repeating-linear-gradient(
    -45deg,
    rgba(88, 70, 160, 0.34) 0 6px,
    transparent 6px 12px);
}
.test-tools .tt-head {
  display: flex; align-items: flex-start; gap: 10px;
  padding: 12px 14px 10px;
}
/* Top-aligned, not centred: the subtitle wraps to two lines, and a centred mark
   then sits beside the explanation rather than beside the thing it marks. */
.test-tools .tt-mark { flex: 0 0 auto; color: var(--dim); margin-top: -1px; }
.test-tools .tt-text { flex: 1; min-width: 0; }
.test-tools .tt-title {
  font-size: 12.5px; font-weight: 800; letter-spacing: .08em;
  text-transform: uppercase; color: var(--dim);
}
.test-tools .tt-sub { font-size: 12px; line-height: 1.45; color: var(--faint); margin-top: 3px; }
.test-tools .tt-tier {
  flex: 0 0 auto; align-self: flex-start;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 10.5px; font-weight: 700; letter-spacing: .06em;
  color: var(--dim); background: rgba(88, 70, 160, 0.10);
  border: 1px solid rgba(88, 70, 160, 0.22);
  border-radius: 4px; padding: 3px 6px;
}
/* One row per tool. A full-width row with its own explanation, not a pill with
   a verb on it — you should be able to read what it does without tapping it. */
.test-tools .tt-action {
  display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 12px 14px; text-align: left;
  background: transparent; border: 0;
  border-top: 1px dashed rgba(88, 70, 160, 0.22);
  border-radius: 0; color: var(--ink);
}
.test-tools .tt-action:active { background: rgba(88, 70, 160, 0.07); }
.test-tools .tt-act-text { flex: 1; min-width: 0; }
.test-tools .tt-act-title { display: block; font-size: 14px; font-weight: 700; color: #C0384E; }
.test-tools .tt-act-sub { display: block; font-size: 12px; line-height: 1.45; color: var(--faint); margin-top: 2px; }
.test-tools .tt-act-go { flex: 0 0 auto; color: var(--faint); }
/* The reset is destructive and says so in red. Granting yourself gems is not,
   so it must not borrow that colour — the whole reason the red is legible as a
   warning is that the row above it is the only thing wearing it. */
.test-tools .tt-act-title.tt-act-calm { color: var(--ink); }

.build-foot {
  margin: 26px 0 8px; text-align: center;
  font-size: 11.5px; font-weight: 700; letter-spacing: .04em;
  color: var(--faint); font-variant-numeric: tabular-nums;
}

.rs-keeps {
  margin: 16px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--dim);
  background: rgba(255, 255, 255, 0.34); border-radius: 16px; padding: 12px 14px;
}

/* ---------- you ---------- */

/* The You root and its six sub-screens. The attribute selector covers
   #page-you-store and friends without listing them: a seventh sub-screen must
   not need a CSS edit to be laid out. */
[id^="page-you"] .page-body { display: flex; flex-direction: column; gap: 20px; }
.you-head { display: flex; align-items: center; gap: 16px; padding-top: 8px; }
.you-avatar {
  width: 76px; height: 76px; border-radius: 50%;
  background: rgba(255, 255, 255, 0.65);
  border: 1px solid var(--accent-dim);
  display: flex; align-items: center; justify-content: center;
  font-size: 28px; font-weight: 700; color: var(--accent);
}
.you-head .uname { font-size: 21px; font-weight: 700; color: var(--ink); }
.you-head .usub { font-size: 13px; color: var(--dim); }

/* ⚠️ MERGE POINT: `.settings-group` was deleted here by R19 item 21, which
   replaced that treatment with `.ycard`/`.yrow` across the whole You page —
   deleted rather than left orphaned, so nobody restores the old look by
   reaching for a class that still styles. Item 18 landed its avatar CSS in the
   same place at the same time; that stays, and its picker is a `.yrow` now. */

/* ---------- R19 item 18 · emoji avatars ----------

   Two sizes of the same idea. `.avatar` and `.you-avatar` above were built for
   a bold capital letter, and a colour emoji at a letter's size and weight
   reads as a typo rather than a face — so `is-emoji` re-sizes them and drops
   the 700 weight, which does nothing to an emoji but does thin the ring of
   antialiasing around it. */
.avatar.is-emoji { font-size: 24px; font-weight: 400; line-height: 1; }
.you-avatar.is-emoji { font-size: 42px; font-weight: 400; line-height: 1; }

/* The face beside a pending invitation. Smaller than the chat header's,
   because the row is a row and not a header — and 30px rather than 34px because
   .req-item is the tightest row in the app: an avatar, a sentence with a name
   in it, and two buttons, in Turkish, at 320px.

   ⚠️ The two rules under it are load-bearing, not polish. At 34px with the
   default button padding the Turkish "Kabul et" broke onto a second line and
   the whole row grew — measured at 33px high without the face and 48px with
   it. A button that wraps is a button that looks broken, so the text column is
   what gives way here, never the control. */
.avatar.av-sm { width: 30px; height: 30px; font-size: 14px; }
.avatar.av-sm.is-emoji { font-size: 18px; }
.req-item { gap: 8px; }
.req-item .btn-mini { white-space: nowrap; padding: 9px 13px; }

/* Six across. At 390px the page body leaves ~354px, so a tile is
   (354 - 5x8) / 6 = 52px — comfortably over the 44px a thumb needs, and the
   thirty-one tiles land in a tidy block of five rows rather than a scroller. */
.av-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
.av-tile {
  width: 100%; aspect-ratio: 1; padding: 0;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.62);
  border: 1px solid var(--card-brd);
  display: flex; align-items: center; justify-content: center;
  font-size: 25px; line-height: 1;
  transition: transform .15s ease, box-shadow .15s ease, background .15s ease;
}
/* The "no emoji" tile draws your initial, exactly as the head above draws it
   when nothing is chosen — so the option is a picture of its own result. */
.av-tile.av-plain { font-size: 17px; font-weight: 800; color: var(--accent); }
/* State on the tile, never on a chip beside the grid (§4.4). */
.av-tile.on {
  background: var(--accent-dim);
  border-color: transparent;
  box-shadow: 0 0 0 2.5px var(--accent), 0 8px 18px -8px var(--accent);
  transform: translateY(-2px) scale(1.08);
}
.av-tile:active { transform: scale(.9); }
.av-note { font-size: 12px; color: var(--dim); line-height: 1.45; }

/* R21 — the game-animal grid, directly under the faces. Five across rather than
   the faces' six: these are DRAWINGS, not glyphs, and at six across on a 390px
   phone the animal inside a tile is under 40px and the species stop being
   tellable apart. Ten of them makes two clean rows.
   State on the tile (§4.4) — ringed and lifted, never a chip beside the grid. */
.gp-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
.gp-tile {
  width: 100%; aspect-ratio: 1; padding: 5px;
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.62);
  border: 1px solid var(--card-brd);
  display: flex; align-items: center; justify-content: center;
  transition: transform .15s ease, box-shadow .15s ease, background .15s ease;
}
.gp-tile svg { width: 100%; height: 100%; }
.gp-tile.on {
  background: var(--accent-dim);
  border-color: transparent;
  box-shadow: 0 0 0 2.5px var(--accent), 0 8px 18px -8px var(--accent);
  transform: translateY(-2px) scale(1.06);
}
.gp-tile:active { transform: scale(.9); }

/* 320px: five across, or the tiles drop under 44px and the grid becomes a
   dexterity test. */
@media (max-width: 340px) {
  .av-grid { grid-template-columns: repeat(5, 1fr); }
  /* Four across at 320: same reasoning as the faces dropping to five — below
     44px a tile is a dexterity test, and below 40px of drawing a panda and an
     owl are the same grey circle. */
  .gp-grid { grid-template-columns: repeat(4, 1fr); }
}

/* ⚠️ And the invite row gives up on one line below 380px, which covers every
   phone narrower than this design's 390. Measured at 320 in Turkish: "Reddet"
   hung 24px outside the card BEFORE this item existed and 61px after the face
   went in. Two lines — who, then what to do — is both the fix and the better
   row: three words per line wedged between an avatar and two buttons was never
   readable. */
@media (max-width: 380px) {
  .req-item { flex-wrap: wrap; }
  .req-item .who { flex: 1 0 calc(100% - 38px); }
  .req-item .btn-mini { flex: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .av-tile { transition: none; }
}

/* ---------- You, in sections (R11/G7) ----------
   CLAUDE.md permits rows on this page, so the fix for "it is a mess" is
   hierarchy rather than tiles: five named sections, each opening with a drawn
   mark, instead of eleven groups in a flat column with historical ordering.

   The mark is drawn rather than an emoji for the same reason everywhere else
   in the app: an emoji is a different picture on every platform, and five of
   them stacked down one page is five chances to look like somebody else's
   design system. */
/* The menu. One row per category, and the row opens its own SCREEN —
   goPage('you-store') and friends — rather than swapping a panel in place.
   Deniz, 2026-08-21: "Make the categories not tabs, but rather list menu items,
   and each category should be a different sub menu."

   Rows are legitimate here and only here: CLAUDE.md's design rule says in as
   many words that "the You page is allowed to look like the You page". What was
   not allowed is what was INSIDE them — see .ycard below. */
.you-menu {
  display: flex; flex-direction: column;
  border-radius: 24px; overflow: hidden;
  border: 1px solid var(--card-brd);
  background: var(--card);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  box-shadow: 0 8px 26px rgba(63, 48, 130, 0.1);
  /* ⚠️ The You body is a flex COLUMN that overflows, so every child of it is a
     shrinkable flex item. The old tab strip was silently crushed to 20px this
     way — present in the DOM, invisible on screen, every assertion passing. */
  flex: 0 0 auto;
}
/* ⚠️ 11px, not 15 — app-updates1, 2026-08-27. The About screen made this an
   eight-row menu and it stopped fitting: 911px in an 800px body. Fitting was
   the point (see the assertion in you-ui-check, and the 1639px it records as
   the before), so the rows got tighter rather than the check getting kinder.
   12px, not 11: at 11 the row measured 59px and you-ui refuses under 60. The
   row stays over that floor, because a menu that fits by
   becoming a list of links has solved the measurement and not the screen. */
.you-item {
  display: flex; align-items: center; gap: 13px;
  width: 100%; padding: 12px 16px; text-align: left;
  background: none; border: 0; cursor: pointer; font: inherit;
  color: var(--ink);
  transition: background .16s ease;
}
.you-item + .you-item { border-top: 1px solid var(--card-brd); }
.you-item:active { background: rgba(124, 92, 224, 0.10); }
/* The panel's own drawn mark, on its own tinted disc so the row has a shape at
   the left rather than a floating glyph. */
.you-item .yi-face {
  width: 36px; height: 36px; flex: 0 0 auto;
  border-radius: 12px;
  background: rgba(124, 92, 224, 0.12);
  color: var(--accent);
  display: grid; place-items: center;
}
.you-item .yi-face > svg { width: 20px; height: 20px; display: block; }
.you-item .yi-text { flex: 1; min-width: 0; display: block; }
.you-item .yi-name {
  display: block; font-size: 15.5px; font-weight: 700; color: var(--ink);
}
/* Two lines, then clamp. A caption cut mid-word by an ellipsis reads as a
   layout bug; two lines is enough for every one of them and the rows stay the
   same height as each other because the text is the same shape. */
.you-item .yi-sub {
  font-size: 12.5px; line-height: 1.32; color: var(--faint);
  margin-top: 1px;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden;
}
.you-item .yi-go { flex: 0 0 auto; color: var(--faint); }
/* The test row is not the product, and says so before you tap it. */
.you-item.scaffold .yi-face {
  background: repeating-linear-gradient(45deg,
    rgba(88, 70, 160, 0.10) 0 5px, rgba(88, 70, 160, 0.03) 5px 10px);
  color: var(--dim);
}
.you-item.scaffold .yi-name { color: var(--ink-soft); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; }

.you-sec { display: flex; flex-direction: column; gap: 14px; }
/* The mark and the title moved onto the menu row and into the sub-screen's own
   topbar. They stay in the markup because that is where the menu reads the mark
   FROM — one drawing, beside the thing it labels — but showing the title again
   under the topbar would be the same word twice, 40px apart. The one-line
   caption stays: it is the half a topbar has no room for. */
.you-sec .ys-head .ys-mark,
.you-sec .ys-head .ys-title { display: none; }
.you-sec .ys-head { display: block; padding: 2px 4px 0; }
.you-sec .ys-mark { flex: 0 0 auto; color: var(--accent); margin-top: 1px; }
.you-sec .ys-text { flex: 1; min-width: 0; }
.you-sec .ys-title {
  font-size: 13px; font-weight: 800; letter-spacing: .06em;
  text-transform: uppercase; color: var(--ink-soft);
}
.you-sec .ys-sub { font-size: 13px; line-height: 1.45; color: var(--faint); margin: 0; }

/* ---------- ONE card treatment for everything on a You sub-screen ----------
   ⚠️ THIS IS THE ACTUAL FIX for R19 item 21. Deniz: "the contents of the
   categories look ugly". Three vocabularies coexisted inside those panels — a
   bare uppercase label over loose pills (.settings-group), a horizontal rule
   with an uppercase caption and loose buttons (.data-rights), and in several
   places a button with nothing around it at all. Three ways of saying the same
   thing on one screen is what "ugly" was pointing at.

   Now: settings live in rows, rows live in a card, and the card is the only
   container. The one thing that is deliberately NOT a card is .ydanger. */
.ycard {
  border-radius: 22px; overflow: hidden;
  border: 1px solid var(--card-brd);
  background: var(--card);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  box-shadow: 0 8px 26px rgba(63, 48, 130, 0.1);
  flex: 0 0 auto;
}
.yrow {
  display: flex; align-items: center; gap: 14px;
  padding: 15px 17px; width: 100%; text-align: left;
  background: none; border: 0; font: inherit; color: var(--ink);
}
.yrow + .yrow { border-top: 1px solid var(--card-brd); }
/* A control too wide for a right-hand slot — swatches, language chips, a
   disclosed form — sits under the text instead of fighting it for room. */
.yrow.stack { display: block; }
/* ⚠️ `.yrow` sets `gap: 14px`, and `.stack` sets `display: block` — where gap
   does NOTHING, on every engine, not just WebView 83. Every control that goes
   under the text therefore has to state its own top margin, and three of them
   did not: the sky strip, the avatar grid and the game-pet grid sat flush
   against the description above. The selected avatar makes it visible —
   `.av-tile.on` is `translateY(-2px) scale(1.08)` inside a 2.5px ring, so it
   stood ~7px PROUD of the grid and cut through the sentence. 2026-09-08. */
.yrow.stack .opt-pills,
.yrow.stack .sky-strip,
.yrow.stack .av-grid,
.yrow.stack .gp-grid { margin-top: 12px; }
.yrow.tap { cursor: pointer; transition: background .16s ease; }
.yrow.tap:active { background: rgba(124, 92, 224, 0.10); }
.yrow-text { flex: 1; min-width: 0; display: block; }
.yrow-title { display: block; font-size: 14.5px; font-weight: 700; color: var(--ink); }
.yrow-sub {
  display: block; font-size: 12.5px; line-height: 1.45; color: var(--dim);
  margin-top: 3px;
}
.yrow-go { flex: 0 0 auto; color: var(--faint); }
/* A button parked in a row's right-hand slot must not stretch to the width the
   .btn-ghost default assumes. */
.yrow .yrow-btn { width: auto; flex: 0 0 auto; }
/* The disclosed half of a row: the inputs, the error and the save button. */
/* The disclosed half sits UNDER the control that opens it, so the margin is on
   this rather than on .yactions. */
.yfield { display: flex; flex-direction: column; gap: 9px; margin-top: 13px; }
.yfield .error { margin: 0 0 0 6px; }
.yactions { display: flex; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }

/* ⚠️ SET APART, AND DELIBERATELY NOT A CARD. Deleting an account and ending
   every other session are not settings, and drawing them in the same white
   panel as "Sound effects" would say that they are. Red ground, a rule, a drawn
   warning mark and its own heading — and it is always the LAST thing on its
   screen, never something you scroll past on the way to something else. */
.ydanger {
  margin-top: 10px; padding: 15px 17px 17px;
  border-radius: 22px;
  border: 1px solid rgba(214, 69, 90, 0.26);
  /* ⚠️ White FIRST, then the red wash. A bare translucent red over the sky
     gradient came out muddy and the amber button on top of it read as a smear
     rather than as a warning — found by opening the screenshot, with every
     assertion green. The panel has to be its own ground before it is tinted. */
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0.44)),
    rgba(214, 69, 90, 0.10);
  display: flex; flex-direction: column; gap: 9px;
  flex: 0 0 auto;
}
.ydanger .yd-head {
  display: flex; align-items: center; gap: 8px;
  padding-bottom: 11px; margin-bottom: 2px;
  border-bottom: 1px solid rgba(214, 69, 90, 0.18);
  color: #C0384E;
}
.ydanger .yd-mark { flex: 0 0 auto; }
.ydanger .yd-title {
  font-size: 12px; font-weight: 800; letter-spacing: .10em;
  text-transform: uppercase;
}
.ydanger .yd-note {
  font-size: 12.5px; line-height: 1.45; color: var(--muted);
  margin: -3px 2px 6px;
}
.ydanger .yd-note:last-child { margin-bottom: 0; }
/* On the panel above, both tints need a touch more body than they carry on a
   plain page, or the ground shows through and the colour stops meaning
   anything. */
.ydanger .btn-caution, .ydanger .btn-danger { width: 100%; }
.ydanger .btn-caution { background: rgba(203, 137, 42, 0.16); border-color: rgba(203, 137, 42, 0.42); }
.ydanger .btn-danger { background: rgba(214, 69, 90, 0.14); border-color: rgba(214, 69, 90, 0.40); }

/* The two links every data screen has to carry. Centred and quiet: they are a
   footnote, not a control. */
.you-legal {
  margin: 6px 0 2px; text-align: center;
  font-size: 12.5px; color: var(--faint);
}
.you-legal a { color: var(--dim); }

/* ---------- ABOUT ----------
   ⚠️ WORKING-RULES §4.4, and an About screen is the single easiest place in an
   app to ship a settings form: four grey rows reading Version / Released /
   Developer / Contact with the answers right-aligned. That is the shape this
   deliberately is not.

   Three objects instead, each a different weight:

     the STAMP    the mark, the number set large enough to read across a room,
                  the sha and the date beneath it in small caps. It is the one
                  thing on the screen anyone came for.
     the PLATE    who makes it. Two lines on tinted glass, no chevron, because
                  nothing here opens anything.
     the TIMELINE the changelog as a ruled spine with a node per release, the
                  newest one filled and haloed. Not a stack of cards: the point
                  is that these are one sequence, and a card per release says
                  the opposite.

   Every VALUE inside them arrives from /api/about. See the note in app.js. */

.ab-stamp {
  position: relative;
  border-radius: 26px;
  border: 1px solid var(--card-brd);
  background:
    radial-gradient(130% 90% at 18% -20%, rgba(124, 92, 224, 0.20), transparent 66%),
    var(--card);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  box-shadow: 0 10px 30px rgba(63, 48, 130, 0.12);
  padding: 16px 18px;
}
.ab-stamp:empty { display: none; }

/* ⚠️ SIDE BY SIDE, NOT STACKED AND CENTRED. A centred column of mark / label /
   number / name / date is four vertical decisions and 420px; the same facts on
   one row with the mark beside them is 96. It also reads as a masthead rather
   than as a certificate, which is what "treat this page as an advertising
   element" asks for. */
.abs-hero { display: flex; align-items: center; gap: 14px; }
.abs-face { position: relative; flex: 0 0 auto; display: grid; place-items: center; }
.abs-mark { width: 52px; height: 52px; display: block; }
/* The tier badge rides on the mark rather than sitting in a row of its own,
   and never appears on production — where its absence is the message. */
.abs-tier {
  position: absolute; top: -4px; right: -10px;
  font-size: 9.5px; font-weight: 800; letter-spacing: .10em; text-transform: uppercase;
  color: var(--accent-ink); background: var(--accent);
  padding: 2px 7px; border-radius: 999px;
}
.abs-words { min-width: 0; }
/* The NAME is the object now. It was 14px under a 42px version number. */
.abs-app {
  font-size: 25px; line-height: 1.1; font-weight: 800; color: var(--ink);
  letter-spacing: -.015em;
}
.abs-pitch {
  margin-top: 3px; font-size: 13px; line-height: 1.4; color: var(--muted);
  font-weight: 600;
}

/* ⚠️ THE VERSION IS A CHIP — app-updates1 A2, Deniz: "don't write the version
   number like a title or anything". It was .abs-num at 42px, tabular figures,
   tight leading, dead centre: the largest text on the screen by three times.
   A build number is a footnote on a page whose job is to say what the app is.
   about-ui-check asserts it is not the biggest thing here, so this cannot
   quietly grow back. */
.abs-chips {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  margin-top: 13px;
}
.abs-chip {
  font-size: 11.5px; font-weight: 700; color: var(--ink-soft);
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  border-radius: 999px; padding: 4px 10px; white-space: nowrap;
}
.abs-chip.abs-ver { font-variant-numeric: tabular-nums; font-weight: 800; color: var(--ink); }
.abs-sha {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 11px; color: var(--dim);
}
/* ⚠️ APP ONLY. The bundle and the server can disagree — that is the whole
   reason MIN_APP_BUILD exists — so in the app both numbers are named. On the
   web this block is absent, because the page IS the server's build. */
.abs-pair {
  display: flex; flex-wrap: wrap; gap: 8px;
  margin-top: 13px; padding-top: 12px; border-top: 1px solid var(--card-brd);
}
.abs-pair .abp {
  display: flex; flex-direction: column; gap: 2px;
  min-width: 116px; padding: 8px 12px;
  border-radius: 14px; background: var(--chip-bg);
  font-size: 13.5px; font-weight: 700; color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.abs-pair .abp b {
  font-weight: 800; font-size: 9.5px; letter-spacing: .10em;
  text-transform: uppercase; color: var(--faint);
}
/* Only when they actually differ. Amber, not red: an update waiting is not a
   fault, and the red on the data screen only reads as a warning because
   nothing else in the app is wearing it. */
.abs-behind {
  margin-top: 12px; padding: 9px 12px; border-radius: 14px;
  background: rgba(214, 138, 38, 0.13);
  border: 1px solid rgba(214, 138, 38, 0.34);
  font-size: 12.5px; line-height: 1.45; color: var(--ink-soft);
}

/* ⚠️ ONE LINE — A2. This was a two-row plate with uppercase keys down the left,
   210px for two facts, and the first of them read "MADE BY: a private
   individual based in Türkiye" — a data-controller formula standing where a
   name goes. The formula moved to .ab-legal-stamp, beside the documents it
   belongs to; this is the maker and the address, on one line. */
.ab-maker {
  border-radius: 18px; padding: 11px 15px 12px;
  border: 1px solid var(--card-brd); background: var(--chip-bg);
}
.ab-maker:empty { display: none; }
.abm-line {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 7px;
  font-size: 13.5px;
}
.abm-name { font-weight: 800; color: var(--ink); }
.abm-dot { color: var(--faint); }
.abm-mail { color: var(--accent); text-decoration: none; font-weight: 700; word-break: break-all; }
.abm-note { margin-top: 5px; font-size: 11.5px; line-height: 1.4; color: var(--faint); }
.ab-log:empty { display: none; }
.abl-cap {
  margin: 22px 0 10px;
  font-size: 11px; font-weight: 800; letter-spacing: .14em;
  text-transform: uppercase; color: var(--faint);
}
.abl-line { list-style: none; margin: 0; padding: 0 0 0 20px; position: relative; }
.abl-line::before {
  content: ''; position: absolute; left: 4px; top: 6px; bottom: 6px;
  width: 2px; border-radius: 2px; background: var(--card-brd);
}
.abl-rel { position: relative; padding: 0 0 14px; }
.abl-rel:last-child { padding-bottom: 4px; }
.abl-node {
  position: absolute; left: -20px; top: 5px;
  width: 10px; height: 10px; border-radius: 50%;
  background: var(--card); border: 2px solid var(--card-brd);
}
.abl-rel.is-now .abl-node {
  background: var(--accent); border-color: var(--accent);
  box-shadow: 0 0 0 4px var(--accent-dim);
}
.abl-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
/* ⚠️ A div with role="button" gets NO keyboard behaviour from the browser, so
   app.js writes Enter and Space out by hand. It is a div rather than a <button>
   because a button element cannot contain the chips without fighting its own
   default styling in three browsers. The focus ring is not optional here. */
.abl-head[role="button"] { cursor: pointer; }
.abl-head[role="button"]:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px;
}
.abl-ver {
  font-size: 15px; font-weight: 800; color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.abl-now {
  font-size: 9.5px; font-weight: 800; letter-spacing: .10em; text-transform: uppercase;
  color: var(--accent-ink); background: var(--accent);
  padding: 3px 8px; border-radius: 999px;
}
/* The chevron is the only thing saying a row opens, so it has to be visible
   without being loud. It turns rather than swapping glyph. */
.abl-chev {
  margin-left: auto; width: 9px; height: 9px; flex: 0 0 auto;
  border-right: 2px solid var(--faint); border-bottom: 2px solid var(--faint);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .18s ease;
}
.abl-rel.is-open .abl-chev { transform: rotate(-135deg) translate(-2px, -2px); }
@media (prefers-reduced-motion: reduce) { .abl-chev { transition: none; } }

.abl-items { margin: 5px 0 0; padding: 0; list-style: none; }
.abl-items li {
  position: relative; padding-left: 13px;
  font-size: 13px; line-height: 1.45; color: var(--ink-soft);
}
.abl-items li + li { margin-top: 2px; }
.abl-items li::before {
  content: ''; position: absolute; left: 2px; top: 8px;
  width: 4px; height: 4px; border-radius: 50%; background: var(--faint);
}
/* The full detail, which is only on screen after a tap. It is allowed to be
   long — that is what it is for — and it is visibly the SAME list, one step
   quieter, so opening a release reads as more of the same thing rather than as
   a different screen. */
.abl-more-wrap { margin-top: 9px; padding-left: 2px; }
.abl-title { font-size: 13px; line-height: 1.4; color: var(--muted); margin-bottom: 6px; }
.abl-sec {
  margin: 10px 0 3px;
  font-size: 10px; font-weight: 800; letter-spacing: .10em;
  text-transform: uppercase; color: var(--faint);
}
.abl-items.abl-full li { font-size: 12.5px; color: var(--muted); }
.abl-more {
  margin-top: 4px; padding: 10px 14px; border-radius: 999px;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  font-size: 12.5px; font-weight: 800; color: var(--ink-soft);
}
.abl-more:active { background: rgba(124, 92, 224, 0.10); }
.abl-more[disabled] { opacity: .55; }
.abl-more[disabled] { opacity: .5; }

/* Nothing to show, and it says why rather than showing a version it guessed. */
.ab-fail {
  border-radius: 20px; padding: 18px 18px 16px;
  border: 1px solid var(--card-brd); background: var(--chip-bg);
  text-align: center;
}
.abf-text { margin: 0 0 12px; font-size: 13.5px; line-height: 1.5; color: var(--muted); }
.abf-retry {
  padding: 10px 20px; border-radius: 999px; border: 0;
  background: var(--accent); color: var(--accent-ink);
  font: inherit; font-weight: 700; font-size: 13.5px; cursor: pointer;
}
.ab-legal-stamp { margin-top: 4px; font-size: 11.5px; color: var(--faint); }
.ab-legal-stamp:empty { display: none; }

/* ---------- R13.2 safety: person cards ----------
   ⚠️ WORKING-RULES §4.4. A block is about a PERSON, so the control carries
   that person — their initial in a disc, their name in full — and the action
   sits on the card with them. The shape an agent ships by default here is a
   settings row reading "Block" with the name on a chip beside it, which is
   exactly the defect: it is the row you tap by accident, and it is the row
   that makes a safety control look like a preference.

   The disc is drawn in caution amber rather than the accent purple used for a
   partner elsewhere. This card is not a friendly one and should not open in
   the same colour as the chat header. */
.person-cards { display: flex; flex-direction: column; gap: 10px; }
.person-card {
  display: flex; align-items: center; gap: 12px;
  padding: 13px 14px; border-radius: 20px;
  border: 1.5px solid var(--card-brd);
  background: linear-gradient(180deg, rgba(255, 255, 255, .58), rgba(255, 255, 255, .26));
}
.person-card .pc-face {
  width: 46px; height: 46px; flex: 0 0 auto; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 19px; font-weight: 800;
  color: #9A6516;
  background: rgba(203, 137, 42, 0.12);
  border: 1.5px solid rgba(203, 137, 42, 0.28);
}
/* A blocked account that has since been deleted still has a card — the block
   is still being enforced, so it still has to be liftable. It gets a slashed
   grey disc instead of an initial, because there is no name to take one from. */
.person-card.gone .pc-face {
  color: var(--faint);
  background: rgba(88, 70, 160, 0.07);
  border-color: var(--card-brd);
  font-size: 17px;
}
.person-card .pc-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.person-card .pc-name {
  font-size: 15px; font-weight: 800; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.person-card.gone .pc-name { color: var(--muted); font-style: italic; font-weight: 700; }
.person-card .pc-what { font-size: 12px; line-height: 1.45; color: var(--faint); }
.person-card .pc-act { flex: 0 0 auto; }
/* Amber, not red: blocking is serious and reversible. Red is reserved for the
   one control that is neither — deleting the account. Same distinction, and
   the same reasoning, as .btn-caution against .btn-danger above. */
.person-card .pc-act.block {
  background: rgba(203, 137, 42, 0.12); border: 1px solid rgba(203, 137, 42, 0.34);
  color: #9A6516; font-weight: 800; font-size: 13px;
  padding: 9px 15px; border-radius: 999px;
}
.person-card .pc-act.block:active { background: rgba(203, 137, 42, 0.22); }
.person-card .pc-act.unblock {
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  color: var(--ink-soft); font-weight: 800; font-size: 13px;
  padding: 9px 15px; border-radius: 999px;
}
.person-card .pc-act.unblock:active { background: rgba(255, 255, 255, .8); }

/* ⚠️ The empty states are NOT an absent list. Somebody who opens Safety, finds
   nothing, and is told nothing concludes the app has no such feature — which
   is the exact gap Play looks for. Drawn as a dashed card so it reads as a
   place where something goes, not as a broken one. */
.person-empty {
  padding: 15px 16px; border-radius: 20px;
  border: 1.5px dashed var(--card-brd);
  background: rgba(255, 255, 255, .22);
  display: flex; flex-direction: column; gap: 3px;
}
.person-empty .pe-title { font-size: 13.5px; font-weight: 800; color: var(--ink-soft); }
.person-empty .pe-sub { font-size: 12px; line-height: 1.5; color: var(--faint); }

.safety-sub { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }

/* ---------- GO-TILE ----------
   A card that TAKES you somewhere: its destination's own mark on a tinted
   disc, a name, one line of why, and an arrow saying which way. Two users,
   2026-09-13: the Child Safety Standards on Safety (leaves the app, so the
   arrow points out of a box) and "Find your person" on the unpaired Home (stays
   in the app, so a chevron). WORKING-RULES §4.4: not a pill with a label on it.
   ⚠️ No flex gap — WebView 83 has none; the sibling margin below is the gap. */
.go-tile {
  display: flex; align-items: center;
  width: 100%; margin-top: 18px; padding: 13px 15px;
  border-radius: 20px; border: 1px solid var(--card-brd);
  background: var(--card); color: var(--ink);
  font: inherit; text-align: left; text-decoration: none; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  box-shadow: 0 8px 26px rgba(63, 48, 130, 0.08);
  transition: background .16s ease;
}
.go-tile > * + * { margin-left: 13px; }
.go-tile:active { background: rgba(124, 92, 224, 0.10); }
.go-tile .gt-face {
  width: 38px; height: 38px; flex: 0 0 auto; border-radius: 12px;
  background: rgba(124, 92, 224, 0.12); color: var(--accent);
  display: flex; align-items: center; justify-content: center;
}
.go-tile .gt-face > svg { display: block; }
.go-tile .gt-text { flex: 1; min-width: 0; display: block; }
.go-tile .gt-name { display: block; font-size: 15px; font-weight: 700; color: var(--ink); }
.go-tile .gt-sub {
  display: block; font-size: 12.5px; line-height: 1.35; color: var(--faint); margin-top: 2px;
}
.go-tile .gt-go { flex: 0 0 auto; color: var(--faint); }
/* On Home it sits under the one sentence that explains why it is there. */
.go-tile.home-find { margin-top: 22px; }
.safety-sub .ss-title {
  font-size: 13px; font-weight: 700; letter-spacing: .04em;
  text-transform: uppercase; color: var(--faint);
}

/* The confirmation gallery. Same danger-box vocabulary as unpair and delete,
   with one addition: the person is drawn at the top, big, so what is being
   confirmed is a face and a name rather than a sentence. */
.block-subject {
  display: flex; align-items: center; gap: 13px;
  padding: 14px 15px; border-radius: 20px;
  border: 1.5px solid rgba(203, 137, 42, 0.28);
  background: rgba(203, 137, 42, 0.08);
}
.block-subject .bs-face {
  width: 52px; height: 52px; flex: 0 0 auto; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 22px; font-weight: 800; color: #9A6516;
  background: rgba(255, 255, 255, .6);
  border: 1.5px solid rgba(203, 137, 42, 0.30);
}
.block-subject .bs-name {
  font-size: 17px; font-weight: 800; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis;
}

/* ---------- R23 · report ----------
   ⚠️ REQUIRED BY GOOGLE PLAY, and it is the half that did not ship with R13.2.
   An app with 1:1 messaging must offer blocking AND reporting in the app.

   The report action is a STRIP FUSED TO THE BOTTOM OF THE PERSON'S CARD, not a
   second pill beside Block. Two equal pills would make somebody choose between
   two things they do not yet understand at the worst possible moment; the fused
   strip says "this is also about this person, and it is the quieter door".
   The card loses its bottom corners so the two read as one drawn object. */
.person-unit { display: flex; flex-direction: column; }
.person-card.has-more {
  border-bottom-left-radius: 0; border-bottom-right-radius: 0;
  border-bottom-color: transparent;
}
.pc-report {
  display: flex; align-items: center; gap: 10px;
  padding: 11px 14px 12px;
  border: 1.5px solid var(--card-brd); border-top: 0;
  border-radius: 0 0 20px 20px;
  background: rgba(255, 255, 255, .30);
  color: var(--muted); text-align: left; cursor: pointer;
  font: inherit; font-size: 12.5px; font-weight: 700;
  transition: background .18s ease, color .18s ease;
}
.pc-report:active { background: rgba(255, 255, 255, .62); color: var(--ink-soft); }
.pc-report .pcr-mark { width: 17px; height: 17px; flex: 0 0 auto; color: var(--dim); }
.pc-report .pcr-text {
  flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pc-report .pcr-go { flex: 0 0 auto; font-size: 17px; color: var(--faint); line-height: 1; }

.report-box { display: flex; flex-direction: column; gap: 13px; }
.report-box .rep-q {
  font-size: 14px; font-weight: 800; color: var(--ink); margin-top: 2px;
}
.report-box .rep-q.sm { font-size: 12.5px; font-weight: 700; color: var(--ink-soft); margin-top: 4px; }

/* ⚠️ THE ARTWORK IS THE CONTROL — WORKING-RULES §4.4. A radio group with six
   labels is what ships by default here, works perfectly, and is a settings form
   on a screen that is entirely about choosing a thing. Each reason is drawn,
   the tile carries its own selected state, and the mark is the biggest thing
   on it. Same vocabulary as .keep-tile on the screen this opens from. */
.reason-grid {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
}
.reason-tile {
  display: flex; flex-direction: column; align-items: center; gap: 7px;
  padding: 13px 5px 11px;
  border-radius: 18px;
  border: 1px solid var(--card-brd);
  background: rgba(255, 255, 255, .5);
  color: var(--ink-soft); text-align: center; cursor: pointer;
  font: inherit;
  transition: transform .14s ease, background .2s ease, border-color .2s ease,
              box-shadow .2s ease;
}
.reason-tile .rt-mark {
  width: 32px; height: 32px; display: block; color: var(--dim);
  transition: color .2s ease, transform .2s ease;
}
.reason-tile .rt-label {
  font-size: 11px; font-weight: 800; line-height: 1.25; letter-spacing: -.01em;
}
.reason-tile.on {
  background: rgba(255, 255, 255, .95);
  border-color: var(--accent);
  box-shadow: 0 6px 20px var(--accent-dim);
}
.reason-tile.on .rt-mark { color: var(--accent); transform: scale(1.08); }
.reason-tile.on .rt-label { color: var(--ink); }
.reason-tile:active { transform: scale(.96); }
/* ⚠️ Child safety reads as its own thing rather than a flavour of harassment.
   That is the mechanism the Child Safety Standards self-certification asks for
   by name, and a tile indistinguishable from the other five is how it ends up
   being tapped as "harassment" by somebody in a hurry. */
.reason-tile.urgent { border-color: rgba(203, 137, 42, .4); background: rgba(203, 137, 42, .07); }
.reason-tile.urgent .rt-mark { color: #B67B23; }
.reason-tile.urgent.on {
  border-color: #B67B23; background: rgba(255, 255, 255, .95);
  box-shadow: 0 6px 20px rgba(203, 137, 42, .26);
}
.reason-tile.urgent.on .rt-mark { color: #9A6516; }
@media (prefers-reduced-motion: reduce) {
  .reason-tile, .reason-tile .rt-mark, .pc-report { transition: none; }
}

.rep-detail {
  width: 100%; box-sizing: border-box; resize: none;
  padding: 11px 13px; border-radius: 16px;
  border: 1px solid var(--card-brd);
  background: rgba(255, 255, 255, .62);
  font: inherit; font-size: 14px; line-height: 1.45; color: var(--ink);
}
.rep-detail:focus { outline: none; border-color: var(--accent); background: rgba(255, 255, 255, .92); }
.rep-detail::placeholder { color: var(--faint); }
/* ⚠️ STACKED, NOT A ROW, and this was a row until somebody opened the
   screenshot. Side by side, the counter sat in the middle of the sentence and
   the sentence wrapped around it — "Nobody but us reads this, and you do not
   need [954 characters left] to copy your messages in." Every assertion about
   both strings passed. Counter tight under the box it belongs to, sentence
   underneath at full width. */
.rep-meta { display: flex; flex-direction: column; gap: 5px; margin-top: -5px; }
.rep-count { align-self: flex-end; font-size: 11px; font-weight: 700; color: var(--faint); }
/* Says out loud that the conversation need not be pasted in. Left unsaid,
   people paste their messages — which would put a plaintext copy of an
   encrypted column into the reports table. */
.rep-private { font-size: 11.5px; line-height: 1.45; color: var(--faint); }

/* ⚠️ TWO THINGS THIS SHEET INHERITED AND DID NOT GET, both invisible to every
   assertion and both obvious in the screenshot:
   1. `.db-list` is styled as `.danger-box .db-list`, and this is a
      `.report-box` — so the consequence list came out at the browser's default
      16px with default bullets, visibly bigger and plainly unstyled next to
      everything around it. Same numbers as the danger box, so the two sheets
      now read as the same family.
   2. `.btn-caution` has NO disabled state anywhere in this file. The primary
      action of this screen is disabled until a reason is chosen, and it looked
      exactly as tappable as the live one — press it, nothing happens, nothing
      says why. */
.report-box .db-list {
  font-size: 13.5px; line-height: 1.6; color: var(--ink); margin: 0; padding-left: 18px;
}
.report-box .db-list li { margin-bottom: 3px; }
.report-box .btn-caution:disabled { opacity: .45; }

@media (max-width: 340px) {
  .reason-grid { grid-template-columns: repeat(2, 1fr); }
}

/* ---------- the store ----------
   Two purses, and WHOSE each one is written on it. Crumbs are the couple's and
   die with the partnership; gems are yours and survive it (Apple 3.1.1). Two
   bare numbers side by side is precisely how that distinction gets lost. */
.purses { display: flex; gap: 10px; }
.purse {
  flex: 1 1 0; min-width: 0;
  display: flex; flex-direction: column; gap: 3px;
  padding: 13px 14px; border-radius: 20px;
  border: 1.5px solid var(--card-brd);
  background: linear-gradient(180deg, rgba(255, 255, 255, .58), rgba(255, 255, 255, .26));
}
.purse .p-amt {
  display: flex; align-items: center; gap: 6px;
  font-size: 20px; font-weight: 800; color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.purse .p-amt svg { flex: 0 0 auto; }
.purse .p-who {
  font-size: 11.5px; font-weight: 700; letter-spacing: .03em;
  color: var(--faint); line-height: 1.35;
  overflow: hidden; text-overflow: ellipsis;
}

.ex-title {
  font-size: 14px; font-weight: 800; color: var(--ink); margin-top: 4px;
}
/* ---------- crumb packs (P53, redrawn P56) -----------------------------------
   ⚠️ THE CARD SHOWS CRUMBS. Four cards reading "400 / 5💎" are four numbers
   nobody can judge, so each one carries a picture — but the picture is the
   CRUMBS, not what they would buy. Deniz, P56: a pack is an amount, not a
   shopping list. The bar underneath says how much of the whole shop the pack
   pays for, which is what makes the volume discount visible without printing a
   rate, and a rate is precisely what P53 removed.

   Two on a row rather than a sideways rail: there are exactly four, they are
   the busiest commercial surface in the app, and a rail hides half of them
   below the right-hand edge where nobody scrolls. */
.cp-grid {
  display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
}
.cp-card {
  position: relative; min-width: 0;
  display: flex; flex-direction: column; align-items: center;
  padding: 12px 10px 11px; border-radius: 22px;
  border: 1.5px solid var(--card-brd);
  background: linear-gradient(160deg, rgba(255, 255, 255, .78), rgba(255, 248, 232, .46));
  color: var(--ink); cursor: pointer; text-align: center;
  transition: transform .12s ease;
}
/* ⚠️ `> * + *` and not `gap`: flex gap is Chrome 84 and the app's baseline is
   83, where it collapses to nothing. webview-baseline.md. */
.cp-card > * + * { margin-top: 5px; }
.cp-card:active { transform: translateY(1px) scale(.97); }
/* Short of gems: still readable, still says what it would cost, just plainly
   not available. Hiding it would leave a shelf that changes length with your
   balance, which reads as a bug. */
.cp-card.cant { opacity: .5; }
/* The pile is the offer, so it gets the space. A fixed height keeps four cards
   on one optical baseline whatever the pile does, and the tokens hang off the
   BOTTOM of it so every pile stands on the same floor — four mounds resting on
   one line is what makes their heights comparable at a glance. */
.cp-art {
  position: relative; display: block;
  width: 100%; height: 64px;
}
/* ⚠️ ONE TOKEN, ONE SPAN, ABSOLUTELY PLACED. app.js computes left/bottom/rotate
   per token (crumbPile) — the geometry is there rather than here because it is
   arithmetic over a row list, not a style. Rows are emitted bottom first, so
   later tokens paint over earlier ones and the stack reads as a mound.
   ⚠️ No transition and no animation: this is drawn twenty times on one screen
   and re-rendered on every balance change. */
.cp-art .cp-crumb { position: absolute; display: flex; line-height: 0; }
.cp-card .cp-get {
  display: flex; align-items: center;
  font-size: 17px; font-weight: 800; color: #7A5A14;
  font-variant-numeric: tabular-nums;
}
.cp-card .cp-get > * + * { margin-left: 5px; }
/* Share of the whole crumb shop, as a bar rather than as a sentence — the four
   packs are 1.5%, 4%, 10% and 38%, and four bars of visibly different length
   say that faster than four numbers do. */
.cp-card .cp-bar {
  display: block; width: 100%; height: 5px; border-radius: 999px;
  background: rgba(88, 70, 160, .12); overflow: hidden;
}
.cp-card .cp-bar i {
  display: block; height: 100%; border-radius: 999px;
  background: linear-gradient(90deg, #E8B33A, #F2CE6E);
}
.cp-card .cp-share {
  font-size: 10.5px; font-weight: 700; line-height: 1.35; color: var(--faint);
}
/* The price is the action, so it is the heaviest thing on the bottom edge. */
.cp-card .cp-price {
  display: flex; align-items: center;
  margin-top: 7px; padding: 6px 12px; border-radius: 999px;
  background: rgba(88, 70, 160, .1); color: #2E6E56;
  font-size: 14px; font-weight: 800; font-variant-numeric: tabular-nums;
}
.cp-card .cp-price > * + * { margin-left: 5px; }
/* Found, not marked in the data — whichever pack pays the most crumbs per gem
   wears it. */
.cp-card .cp-best {
  position: absolute; top: -7px; left: 50%; transform: translateX(-50%);
  padding: 2px 9px; border-radius: 999px; white-space: nowrap;
  font-size: 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
  background: var(--accent); color: var(--accent-ink);
  box-shadow: 0 3px 8px var(--accent-dim);
}
/* ⚠️ A DISCLOSURE, NOT A SUBTITLE. Gems are personal and crumbs are the
   couple's, so a pack spends your gems into a wallet you both draw from. It is
   above the cards because somebody has to read it before tapping, not after. */
.ex-both {
  font-size: 12px; line-height: 1.5; font-weight: 700; color: var(--ink-soft);
}
/* ---------- the day's ad ----------------------------------------------------
   ⚠️ THE OFFER IS A TILE, NOT A ROW WITH A BUTTON ON IT. The picture carries
   the whole trade — a screen you watch, an arrow, the gem — exactly the way the
   exchange rail below does, and for the reason written over it: the picture IS
   the offer. A pill reading "Watch an ad" beside a paragraph is the
   settings-form shape CLAUDE.md rejects by name.

   ⚠️ And when the day's gem is taken this element is EMPTIED by
   renderAdOffer(). It has no padding, border or min-height of its own, so an
   empty block collapses to nothing and leaves no gap where an offer used to be.
   That is what "withdraw the offer" has to look like on screen. */
#ad-offer:empty { display: none; }
.ad-tile {
  width: 100%;
  display: flex; align-items: center;
  padding: 14px 16px; border-radius: 22px;
  border: 1.5px solid var(--card-brd);
  background: linear-gradient(135deg, rgba(255,255,255,.72), rgba(240,236,255,.42));
  color: var(--ink); cursor: pointer; text-align: left;
  transition: transform .12s ease;
}
/* ⚠️ `> * + *` and not `gap`: flex gap is Chrome 84 and the app's
   baseline is 83, where it collapses to nothing. webview-baseline.md. */
.ad-tile > * + * { margin-left: 14px; }
.ad-tile:active { transform: translateY(1px) scale(.985); }
.ad-tile[disabled] { opacity: .55; cursor: default; }
.ad-trade { display: flex; align-items: center; flex: 0 0 auto; }
.ad-trade > * + * { margin-left: 8px; }
.ad-trade .ad-give { color: #5B49A6; display: flex; }
.ad-trade .ad-arrow { color: var(--faint); display: flex; }
.ad-trade .ad-get { display: flex; }
.at-text { display: flex; flex-direction: column; min-width: 0; }
.at-text > * + * { margin-top: 2px; }
.at-text b { font-size: 14.5px; font-weight: 800; line-height: 1.3; }
.at-text span { font-size: 11.5px; font-weight: 700; color: var(--faint); }

/* ---------- the consent screen ----------------------------------------------
   ⚠️ THE INFORMATION IS ABOVE THE RULE AND THE CHOICE IS BELOW IT. KVKK keeps
   *aydınlatma* (being informed) separate from *açık rıza* (consenting), and a
   horizontal rule with air on both sides is what that separation looks like on
   a phone. It is not decoration and it should not be tidied away.

   ⚠️ TWO PILLS, STACKED AND FULL WIDTH — Deniz, P56: "make the allow button
   contrast darker, and unpersonalized button default pill." They are identical
   in size and neither is pre-selected; what differs is weight.

   ⚠️ The heavier Allow is a STYLING choice and it is safe only because the
   reward is identical either way (ads-policy.md §13). If the two paths ever
   differ by one crumb, this styling becomes a regulator's exhibit rather than a
   preference. The styling may move; the parity may not. */
.ad-consent { display: flex; flex-direction: column; }
.ac-scene { display: flex; justify-content: center; color: #5B49A6; }
.ac-scene svg { width: min(100%, 300px); height: auto; }
.ac-lines { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.ac-lines > li + li { margin-top: 10px; }
.ac-lines li { display: flex; align-items: flex-start; }
.ac-lines li > * + * { margin-left: 10px; }
.ac-lines .ac-mark { flex: 0 0 auto; color: #5B49A6; display: flex; margin-top: 1px; }
.ac-lines li span:last-child { font-size: 13px; line-height: 1.5; color: var(--ink-soft); }
.ac-rule { height: 1px; background: var(--card-brd); margin: 0; }
.ac-ask { font-size: 14.5px; font-weight: 800; color: var(--ink); }
/* ⚠️ STACKED, not side by side. Full width each, so they are exactly the same
   size — "neither disguised as the other" is about SIZE, and a stacked pair
   keeps that while letting one carry more weight. It also survives Turkish,
   which is the longer language: two 10-word labels in half-width columns wrap
   to four lines and the pair stops looking like one control. */
.ac-faces { display: flex; flex-direction: column; }
.ac-faces > * + * { margin-top: 10px; }
/* ⚠️ 999px, not 20px: this is the app's own pill, the shape .btn and .btn-ghost
   have carried since R11. "Default pill" is a shape somebody can point at. */
.ac-face {
  width: 100%; min-width: 0;
  display: flex; align-items: center;
  padding: 13px 18px; border-radius: 999px;
  border: 1.5px solid var(--card-brd); background: var(--chip-bg);
  color: var(--ink); cursor: pointer; text-align: left;
  transition: transform .12s ease;
}
.ac-face > * + * { margin-left: 12px; }
.ac-face:active { transform: translateY(1px) scale(.99); }
.ac-face .acf-art { color: #5B49A6; display: flex; flex: 0 0 auto; }
.ac-face .acf-text { display: flex; flex-direction: column; min-width: 0; }
.ac-face .acf-text > * + * { margin-top: 2px; }
.ac-face b { font-size: 14.5px; font-weight: 800; line-height: 1.3; }
.ac-face .acf-text span { font-size: 11px; font-weight: 600; color: var(--faint); line-height: 1.35; }
/* ⚠️ DARKER THAN THE APP'S ACCENT, ON PURPOSE. --accent (#7C5CE0) is 4.7:1
   against white and #5B49A6 — the confirm sheet's Watch — is about 7:1. This is
   11.4:1, which is what "contrast darker" asks for and is a step somebody can
   see rather than a shade. It sits in the same indigo family as --ink-soft, so
   it is a deeper version of the app's own colour and not a new one. */
.ac-face.yes {
  background: #3B2F73; border-color: #3B2F73; color: #FFFFFF;
  box-shadow: 0 8px 22px rgba(59, 47, 115, .28);
}
.ac-face.yes .acf-art { color: rgba(255, 255, 255, .92); }
.ac-face.yes .acf-text span { color: rgba(255, 255, 255, .74); }
/* The tapped one, for the moment between the tap and the screen closing.
   ⚠️ NOTHING carries .on when the screen opens — ads-ui-check reads exactly
   that, and a pre-selected consent is not consent. */
.ac-face.on { transform: translateY(1px) scale(.99); }
.ac-face.no.on { border-color: #5B49A6; background: rgba(255, 255, 255, .92); }
.ac-foot { font-size: 11.5px; line-height: 1.5; color: var(--faint); margin: 0; }
.ac-foot a { color: var(--ink-soft); }
/* ⚠️ LAST ON PURPOSE, and this is not tidiness. Every rule above is the
   same specificity, so whichever comes last owns margin-top — and when
   this sat at the top, `.ac-rule`'s own margin beat it and the rule that
   IS the aydınlatma/açık rıza separation ended up flush against the
   sentence above it. One rule owns the vertical rhythm; the children own
   none of it. */
.ad-consent > * + * { margin-top: 14px; }
/* ⚠️ CENTRED IN THE SHEET, BY AUTO MARGINS rather than justify-content. At
   390×844 the screen finished about 200px above the bottom edge and the gap
   read as unfinished — the same defect the confirm sheet above was fixed for,
   and found the same way: by opening the picture, with every assertion green.

   ⚠️ Auto margins and NOT `justify-content: center`, which is what .ad-sheet
   can afford and this cannot. This screen is long enough to overflow on a
   short phone in Turkish, and a centred flex column that overflows puts its
   own TOP out of reach in a scroll container. Auto margins resolve to zero
   exactly when there is no free space, so the overflow case is the safe one. */
.ad-consent { min-height: 100%; }
.ad-consent > .ac-scene { margin-top: auto; }
.ad-consent > .ac-foot { margin-bottom: auto; }

/* ---------- the confirm sheet, before EVERY ad ------------------------------
   AdMob requires the reward to be disclosed "prior to each instance", so this
   is never remembered and never suppressed. It is small on purpose: it is a
   confirmation, not a screen. */
/* ⚠️ CENTRED, AND THE ARTWORK AT A REAL SIZE. The first version was a full
   page with two stacked pills and sixty per cent of the screen empty below
   them — it passed every assertion and read as unfinished, which is the exact
   defect WORKING-RULES §4.4 is about. Found by opening the screenshot. */
.ad-sheet, .ad-web {
  display: flex; flex-direction: column; justify-content: center;
  min-height: 100%;
}
.ad-sheet > * + *, .ad-web > * + * { margin-top: 18px; }
.as-stage .ad-give svg { width: 86px; height: 86px; }
.as-stage .ad-get svg { width: 58px; height: auto; }
.as-stage .ad-arrow svg { width: 36px; height: 22px; }
.as-stage .ad-trade { margin-bottom: 4px; }
.as-stage .ad-trade > * + * { margin-left: 14px; }
.as-stage, .aw-stage {
  display: flex; flex-direction: column; align-items: center;
  padding: 24px 16px; border-radius: 24px;
  border: 1.5px solid var(--card-brd);
  background: linear-gradient(180deg, rgba(255,255,255,.66), rgba(255,255,255,.3));
  color: #5B49A6;
}
.as-stage > * + *, .aw-stage > * + * { margin-top: 8px; }
.as-what { font-size: 17px; font-weight: 800; color: var(--ink); }
.as-how, .aw-note {
  font-size: 12.5px; line-height: 1.5; color: var(--faint);
  text-align: center; max-width: 34ch;
}
.aw-screen { color: #5B49A6; }
.as-faces { display: flex; flex-direction: column; }
.as-faces > * + * { margin-top: 9px; }
.as-face {
  padding: 14px 16px; border-radius: 18px;
  border: 1.5px solid var(--card-brd); background: rgba(255, 255, 255, .6);
  font-size: 15px; font-weight: 800; color: var(--ink); cursor: pointer;
}
.as-face.go { background: #5B49A6; border-color: #5B49A6; color: #fff; }
.as-face:active { transform: translateY(1px); }
.as-face[disabled] { opacity: .6; cursor: default; }

/* ---------- counting your gem ------------------------------------------------
   ⚠️ The reward is paid by the ad network's signed callback, which can arrive
   seconds after the video ends, so this screen exists rather than a gem that
   appears late and unexplained. After the server's own waitMs it says the gem
   has not arrived AND that the day is still unspent — which is true, because
   an abandoned offer releases its slot. Q28 ★ a. */
.ad-wait {
  display: flex; flex-direction: column; align-items: center;
  justify-content: center; text-align: center;
}
.ad-wait > * + * { margin-top: 14px; }
.aw-spin { color: #5B49A6; }
/* ⚠️ SCOPED TO THE WAITING SCREEN. Unscoped, this animated the identical mark
   wherever else it appeared — and the consent screen used to carry one. */
.ad-wait .ad-wait-spin { animation: ad-spin 1.1s linear infinite; }
@keyframes ad-spin { to { transform: rotate(360deg); } }
.aw-line { font-size: 14px; font-weight: 700; color: var(--ink-soft); max-width: 32ch; line-height: 1.5; }
.ad-wait.late .aw-spin { opacity: .55; }
.ad-wait .as-faces { align-self: stretch; width: min(100%, 340px); }
.ad-wait.late .ad-wait-spin { animation: none; }
/* A scripted screenshot catches a spinning SVG at an arbitrary frame, and
   somebody who asked their phone to stop moving things meant this too. */
@media (prefers-reduced-motion: reduce) { .ad-wait .ad-wait-spin { animation: none; } }

.ex-foot {
  font-size: 12px; line-height: 1.5; color: var(--faint);
  display: flex; flex-wrap: wrap; gap: 4px 8px;
}
.ex-foot .ex-oneway { font-weight: 700; }

/* Gem packs. purchasable:false on every one of them until Play Billing exists,
   so they are not buttons — they carry the word instead of a price. */
.pack-grid { display: flex; gap: 10px; }
.pack {
  flex: 1 1 0; min-width: 0;
  display: flex; flex-direction: column; align-items: center; gap: 5px;
  padding: 14px 8px 12px; border-radius: 20px;
  border: 1.5px dashed rgba(88, 70, 160, 0.26);
  background: rgba(255, 255, 255, .3);
}
.pack .pk-art { display: flex; align-items: flex-end; gap: 2px; min-height: 30px; }
.pack .pk-n {
  font-size: 14px; font-weight: 800; color: var(--ink-soft);
  font-variant-numeric: tabular-nums;
}
.pack .pk-soon {
  font-size: 10.5px; font-weight: 800; letter-spacing: .05em;
  text-transform: uppercase; color: var(--faint);
}
.opt-pills { display: flex; gap: 10px; flex-wrap: wrap; }

.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.switch-row .sw-text { flex: 1; }
.switch-row .sw-title { font-size: 14.5px; font-weight: 700; color: var(--ink); }
.switch-row .sw-sub { font-size: 12.5px; color: var(--dim); margin-top: 2px; }
.switch {
  width: 52px; height: 30px; border-radius: 999px; flex: 0 0 auto;
  background: rgba(43, 35, 80, 0.18); border: 1px solid var(--card-brd);
  position: relative; transition: background .25s ease;
}
.switch::after {
  content: ''; position: absolute; top: 3px; left: 3px;
  width: 22px; height: 22px; border-radius: 50%;
  background: #fff; transition: all .25s ease;
  box-shadow: 0 2px 6px rgba(43, 35, 80, 0.25);
}
.switch.on { background: var(--accent); border-color: transparent; }
.switch.on::after { left: 25px; }

.num-row { display: flex; align-items: center; gap: 12px; }
.num-row .num-label { flex: 1; font-size: 14px; color: var(--ink-soft); font-weight: 600; }
.stepper { display: flex; align-items: center; gap: 2px; }
.stepper button {
  width: 34px; height: 34px; border-radius: 50%;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  color: var(--ink); font-size: 16px; font-weight: 700;
}
.stepper .num { min-width: 34px; text-align: center; font-size: 15px; font-weight: 700; color: var(--ink); }

#install-card {
  display: flex; align-items: center; gap: 14px;
  padding: 16px 18px; text-align: left; width: 100%;
  background: linear-gradient(135deg, rgba(255, 255, 255, 0.6), rgba(124, 92, 224, 0.14));
  border: 1px solid var(--accent-dim); border-radius: 22px;
}
#install-card .ic { font-size: 24px; }
#install-card .it { flex: 1; }
#install-card .it b { display: block; font-size: 15px; color: var(--ink); }
#install-card .it span { font-size: 12.5px; color: var(--muted); }

/* update prompt bar */
#update-bar {
  position: fixed; left: 50%; transform: translateX(-50%);
  /* ⚠️ THE FIFTH CONSUMER OF --notice-top, and the only one that is not a
     header. This bar is DORMANT — index.html carries it with `hidden` and no
     script ever unhides it — so nothing collides today. It is here because it
     is the one other thing in the app anchored to the top edge, and the day
     somebody wires up the service worker’s update prompt it would land under
     the notice stack (z 70 against 95), centred on the same pixels, and be
     invisible exactly when an invitation is up. Costs nothing while
     --notice-top is 0px, which is whenever no notice is showing. */
  top: max(14px, env(safe-area-inset-top), calc(var(--notice-top, 0px) + 10px));
  z-index: 70;
  display: flex; align-items: center; gap: 10px;
  background: rgba(255, 255, 255, 0.97);
  border: 1px solid var(--accent-dim);
  border-radius: 999px; padding: 9px 10px 9px 18px;
  box-shadow: 0 10px 30px rgba(63, 48, 130, 0.22);
  animation: toastIn .35s ease both;
}
#update-bar .ut { font-size: 13.5px; font-weight: 700; color: var(--ink); }
#update-bar button {
  border: none; border-radius: 999px;
  background: var(--accent); color: var(--accent-ink);
  font-size: 13px; font-weight: 700; padding: 9px 16px;
}

/* ---------- rooms, coins, fridge & shop ---------- */

/* ⚠️ THE ROOM HEADER IS GONE. `.room-bar`, `.room-name` and `.room-dots` were
   DELETED for R19 item 22 (Deniz, 2026-08-21) rather than left behind, so that
   nobody restores the strip by reaching for a class that still styles. It held
   two arrows, a name in a pill and five position dots; it cost the room about
   50px of height, and it named a room the artwork already identifies.

   What is here instead: the two arrows live on the room's own left and right
   edges, and the name is a title card that appears for about a second when you
   move and fades over two. */

/* The arrows, at the sides of the screen and over the room art.

   ⚠️ `top` is overwritten by placeRoomArrows() in app.js, which measures the
   drawn room so the arrows sit as low as they can while covering nothing you
   might tap — including a hotspot's call-to-action label, which is part of the
   tappable group and reaches the room's left edge in the bedroom. The value
   here is only what applies for the frame before that measurement. */
.room-arrow {
  /* ⚠️ Above .room-flash, not below it. The name band spans the full width of
     the room, so at z-index 8 it laid its scrim straight over both arrows and
     they went to two grey ghosts for three seconds after every move. (They were
     white discs at the time; R20.8 removed the disc, and a dark chevron under
     a dark scrim disappears at least as thoroughly.) Caught in the 615x888
     shot, not by any assertion. */
  position: absolute; z-index: 10;
  top: 10px;
  /* R20.8 — Deniz, 2026-08-23: "remove the pill shape around the room change
     buttons, make them taller and have a shadow."

     ⚠️ 46 WIDE IS NOT AN OVERSIGHT. Only the height moved, 46 -> 68. The width
     is the finger: 46 clears the 44px phone minimum, room-games-check asserts
     it, and a mark on the very edge of a room is the one you reach for with
     your thumb tucked round the side of the phone. "Taller" was the request;
     "narrower" was not, and the two are easy to do by accident together. */
  width: 46px; height: 68px;
  display: flex; align-items: center; justify-content: center;
  /* ⚠️ WHAT THIS MARK HAS ALREADY BEEN, so nobody re-tries either of them.
     Both were found by opening a screenshot and by nothing else.
       1. A soft dark radial scrim behind a WHITE chevron, on the theory that
          darkening the wallpaper is the safest way to be legible in a room
          somebody else decorated. At 390x844 it read as two blurred thumbprints
          on the glass — correct by every assertion, and ugly. That is the
          shape "give it a shadow" pulls you back towards; see the drop-shadow
          note below for the version that does not do it.
       2. A white glass disc at 82% with no outline. It vanished against the
          game room's pale blue wall: the control was there and you could not
          find it. It got a hairline, and R20.8 then took the whole disc off —
          which is why the CHEVRON is now the dark half of the contrast rather
          than the disc. A pale mark here is the failure mode, every time.
     ⚠️ And the dark half needs a pale casing, which roomChevron() draws — the
     wall is a pastel but the WINDOW is #1B1650 and the arrow lands on it in the
     game room at 390x844. Read the comment on that function before touching
     either colour; the two are one decision. */
  /* The pill: gone. No fill, no hairline, no radius — with the disc off, the
     chevron is the control and nothing is drawn around it. */
  background: none; border: 0; border-radius: 0; padding: 0;
  /* ⚠️ A DEEPENED ACCENT, NOT --accent ITSELF. The disc used to guarantee this
     mark a white ground; without it the chevron is read straight off the
     wallpaper, and every wall in the game is a light pastel (WALLS in
     house-svg.js: cream, blue, lilac, mint, rose, each a gradient to a deeper
     tone of itself). --accent #7C5CE0 lands at 3.8:1 against the palest of them
     and dips toward 3:1 on the deeper halves; this is 5.5:1 or better against
     all ten. It is the same purple, further down. */
  color: #4E33B4;
  /* ⚠️ THE SHADOW IS NOT HERE, AND THAT IS DELIBERATE. Two ways to give this
     mark a shadow are wrong and one is right:
       box-shadow — painted around the element's BORDER BOX, so on a transparent
         46x68 button it is a soft grey rectangle floating on the wallpaper with
         nothing inside it. A smudge; the same word this screen already earned
         once, for a radial scrim.
       filter: drop-shadow() — the right shape, and it would work: the button
         sits 6px from the room's edge, but .house-stage is overflow:hidden, so
         nothing here can paint onto the page beside it.
     What is there instead is an SVG filter inside roomChevron() in app.js,
     because an svg is overflow:hidden too — so the shadow is held by the button
     as well as by the room, rather than depending on a clip that belongs to
     another rule — and because a CSS filter would promote both buttons to their
     own composited layers on a phone. Read that comment before putting a filter
     back on this rule. */
}
.room-arrow.prev { left: 6px; }
.room-arrow.next { right: 6px; }
/* Something still has to answer the tap (WORKING-RULES §4.4). There is no disc
   left to light up, so the mark itself presses in. No filter here either — see
   above; a filter that exists only for 120ms would produce the same ghost, and
   only while somebody's thumb was on the screen to see it. */
.room-arrow:active { transform: scale(.9) translateY(1px); }

/* The room's name, inside the room, only when you move. Built by
   flashRoomName() in app.js — see the comment there for why it is appended
   rather than rendered with the room. */
.room-flash {
  /* ⚠️ ANCHORED TO THE TOP OF THE ROOM, not floated at 34% of it. R20.1,
     Deniz 2026-08-23: "The room texts when swithcing a room should be at more
     top of the screen, and without that distinct of a black bar behind it."
     Those are one change, not two, and the reason is below. */
  position: absolute; left: 0; right: 0; top: 0;
  z-index: 9; pointer-events: none;
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  /* The bottom padding is the gradient's fade RUNWAY, not spacing. Shorten it
     and the wash finishes inside 80px, which puts a visible soft line back
     across the wall — tried at 48px, and the edge was there in the shot. */
  padding: 30px 24px 96px;
  text-align: center; line-height: 1;
  /* ⚠️ NOT a pill and NOT an oval, and the history matters because it has been
     got wrong in both directions.
     The FIRST attempt (R19) was a radial scrim sized to the words. On screen
     that was a dark oval floating on the wallpaper: a smudge, not design.
     The SECOND (R19, shipped) was a full-width band that faded out top AND
     bottom, peaking at .46 in the middle of the room. That fixed the oval and
     created this one: two soft edges bracketing a dark strip read as an object
     lying on the wall — his "distinct black bar".
     ⚠️ So do not answer this by shrinking the band back around the text. The
     answer is that the band now has only ONE edge. It starts at its darkest at
     the room's own top edge — which is a real edge already, rounded and
     bordered, so there is nothing new to see there — and runs out downwards
     over ~160px. One-sided, it reads as light falling into the room; two-sided,
     it reads as a bar. Same technique a film title card uses.
     ⚠️ AND RAISING IT IS WHY IT CAN BE WEAKER. At 34% the card sat across the
     furniture, the busiest part of the picture, where only a strong scrim
     flattens what is behind the letters. Up here the backdrop is wall — one
     flat pastel — so .26 does what .46 had to do down there. The two halves of
     his sentence pay for each other. */
  background: linear-gradient(to bottom,
    rgba(24, 15, 52, .26) 0%,
    rgba(24, 15, 52, .25) 32%,
    rgba(24, 15, 52, .17) 56%,
    rgba(24, 15, 52, .06) 82%,
    rgba(24, 15, 52, 0) 100%);
  animation: roomFlash 3.3s ease both;
}
.room-flash .rf-name {
  font-size: 26px; font-weight: 800; letter-spacing: -0.02em; color: #fff;
  /* ⚠️ This halo is what pays for the weaker band, and it is NOT the oval
     coming back. An oval is a `background` with a silhouette of its own; a
     text-shadow follows the letterforms and has none — it is what every
     subtitle renderer does.
     The guard is the BLUR RADIUS: tight and dark first, soft and faint last,
     and nothing wider than the 12px R19 already shipped without complaint. A
     30px blur at .9 IS an oval by another name — it just has soft edges — so
     room-name-check.js caps every radius at 12px and requires that a tight one
     (<= 5px, alpha >= .8) exists, because the tight one is what actually
     carries the letterform against a pale wall.
     Worth knowing before weakening it: on a tall phone houseSVG paints a
     rgba(255,255,255,.34) CEILING band at the very top of a room with a bare
     wall, so the strip this card sits on is the LIGHTEST part of the picture,
     not the darkest. */
  text-shadow:
    0 1px 1px rgba(18, 10, 40, .95),
    0 0 5px rgba(18, 10, 40, .94),
    0 0 9px rgba(18, 10, 40, .80),
    0 2px 12px rgba(18, 10, 40, .60);
}
.room-flash .rf-dots { display: flex; gap: 5px; }
.room-flash .rf-dots i {
  width: 5px; height: 5px; border-radius: 999px;
  /* ⚠️ The unlit dots carry their own shadow because the band no longer carries
     them. At .46 a 5px dot at 50% white was legible on every wall; at .26 it
     disappears on mint and on pale blue, which is a "one of five" reading lost
     to a change that was about something else. */
  background: rgba(255, 255, 255, .62);
  box-shadow: 0 1px 3px rgba(18, 10, 40, .55);
}
.room-flash .rf-dots i.on { width: 15px; background: #fff; }

/* In by 0.30s, held to 1.25s, gone at 3.30s — "show for 1 second and fade away
   in 2s", read off the clock rather than off a feeling. Untouched by R20.1: he
   asked for that timing in R19 and has not complained about it.

   ⚠️ THE BOX ITSELF NEVER MOVES ANY MORE, and that is not tidiness. The wash is
   anchored to the room's top edge and is at its DARKEST there, so translating
   the box by even a few pixels in either direction tears a lighter sliver open
   along the top of the room — up, and the wash lifts off the edge; down, and it
   leaves the edge behind. Either way the one thing this design depends on, an
   edge you cannot see, becomes an edge you can. R19 could move the whole box
   because its band was floating in the middle of the room with fades on both
   sides; this one cannot.

   ⚠️ So the arrival moves the CONTENT and fades the BOX. Two animations of the
   same length, which also keeps flashRoomName's animationend cleanup honest —
   they finish together, and remove() on an already-removed node is harmless.

   ⚠️ And the lift goes UPWARD FROM BELOW (+12 → 0), never from above. A card
   that starts above its resting place starts outside the room, and
   room-games-check asserts the card is drawn inside the room premises — it
   samples 260ms in, which is before the 9% mark. An entry from -10px fails that
   check for a reason that has nothing to do with what it is testing. */
@keyframes roomFlash {
  0%   { opacity: 0; }
  9%   { opacity: 1; }
  38%  { opacity: 1; }
  100% { opacity: 0; }
}
@keyframes roomFlashLift {
  0%        { transform: translateY(12px); }
  9%, 100%  { transform: translateY(0); }
}
.room-flash .rf-name, .room-flash .rf-dots { animation: roomFlashLift 3.3s ease both; }

/* ⚠️ Reduced motion still SHOWS the name, and that is deliberate: with the
   header deleted this card is the only place a room is ever named, so hiding it
   would take information away from the people who asked for less movement. What
   it loses is the movement and the cross-fade — it appears, holds 1.8s and is
   gone in one step. It is still an animation, so animationend still fires and
   flashRoomName's cleanup still runs.
   ⚠️ The lift has to be switched off here too, or it outlives the box it is
   inside by 1.5s and fires a second animationend after the card is gone. */
@media (prefers-reduced-motion: reduce) {
  .room-flash { animation: roomFlashStill 1.8s linear both; }
  .room-flash .rf-name, .room-flash .rf-dots { animation: none; }
}
@keyframes roomFlashStill {
  0%, 99% { opacity: 1; }
  100%    { opacity: 0; }
}

/* The pet's own strip, below the room. */
.pet-bar { flex: 0 0 auto; padding-top: 10px; }
.pet-id { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.pet-id .pmood { font-size: 13px; color: var(--muted); font-weight: 600; }
/* Cuddling lost its button to the pet itself, so this is where the timer that
   used to sit on that button went. */
.pet-tip {
  margin-top: 8px; text-align: center;
  font-size: 12.5px; font-weight: 700; color: var(--dim);
}

/* Tappable things drawn inside the room SVG. */
.house-stage .hot { cursor: pointer; }
.house-stage .hot-hit { fill: transparent; }
.house-stage .hot-ring {
  fill: rgba(255, 255, 255, 0.14);
  stroke: var(--accent); stroke-width: 2.5; stroke-dasharray: 7 6;
  animation: hotPulse 2.4s ease-in-out infinite;
}
.house-stage .hot-tag rect { fill: var(--accent); }
.house-stage .hot-tag text {
  fill: #fff; font-size: 11px; font-weight: 800;
  font-family: 'Figtree', system-ui, sans-serif;
}
.house-stage .hot:active .hot-ring { fill: rgba(255, 255, 255, 0.3); }
.house-stage .hot.quiet .hot-ring { opacity: .3; animation: none; stroke-dasharray: 4 7; }

/* ⚠️ The fridge stops shouting while its own picker is open (R20.7). Until the
   plate step was removed this was done in the markup — hotLabels dropped the
   fridge's label the moment food was waiting on the table, and oneHotspot()
   drew it `quiet`. The tray cannot use that route: it opens without a
   re-render, on purpose, because renderPet() rebuilds #pet-walker and would
   teleport the pet mid-flight. So it is a class on <body> and pure CSS, and it
   reaches the same two elements the quiet variant does.

   It matters because the label is the loudest thing on the screen and it reads
   "tap for food" while the food is already on offer six inches below it —
   photographed at both viewports before this rule existed. */
body.tray-open .house-stage .hot[data-hot="fridge"] .hot-tag { opacity: 0; }
body.tray-open .house-stage .hot[data-hot="fridge"] .hot-ring {
  opacity: .3; animation: none; stroke-dasharray: 4 7;
}
.house-stage .hot-tag { transition: opacity .25s ease; }
@keyframes hotPulse {
  0%, 100% { opacity: .55; }
  50% { opacity: 1; }
}
/* ⚠️ NOTHING SETS A PLATE ANY MORE (R20.7). Food used to be taken out of the
   fridge and left on the kitchen table until somebody tapped it; the fridge
   tray below feeds in one step. These two rules stay because house-svg.js can
   still be asked to draw a plate, and a client running from a service worker
   cached before this deploy still asks. */
.plate-food.tappable { cursor: pointer; animation: floaty 2.6s ease-in-out infinite; }
.plate-hint {
  position: absolute; left: 50%; transform: translateX(-50%);
  top: 10px; z-index: 7;
  background: rgba(255, 255, 255, 0.94); color: var(--ink);
  border: 1px solid var(--accent-dim); border-radius: 999px;
  padding: 7px 16px; font-size: 12.5px; font-weight: 700; line-height: 1.2;
  box-shadow: 0 6px 18px rgba(63, 48, 130, 0.18);
}

/* ---------- the fridge tray (R20.7) ----------

   Tapping the fridge slides this up from the bottom of the screen: the contents
   of the fridge as a carousel, one food large in the middle with its neighbours
   peeking at either side, arrows at the edges. Built by renderFridgeTray() in
   app.js, where the reasoning for the shape is written out.

   ⚠️ It deliberately has NO veil behind it, unlike #sheet. Every other bottom
   sheet in this app is a screen you go into; this one is a control you use
   WHILE watching the room, because the food you pick flies across to the pet.
   Dimming the room would hide the animation the whole feature is about.

   ⚠️ It must not cover the pet. That is measured, not assumed —
   kitchen-ui-check.js compares its top edge against #pet-walker's box at both
   viewports, which is the assertion emote-panel-check learned the hard way
   after a sheet sat on top of three game controls with every check green. */
.fridge-tray {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 41;
  /* ⚠️ FULLY OPAQUE, and that is not fussiness. #sheet gets away with 0.99
     because a veil is drawn behind it; this one has no veil, and it sits
     directly over the pet's name and xp strip — at 0.98 those ghosted through
     the tray as grey text under the food, which is the sort of thing that
     never fails an assertion and looks broken in a screenshot. */
  background: #F7F4FF;
  border-top: 1px solid var(--accent-dim);
  border-radius: 26px 26px 0 0;
  padding: 12px 10px calc(10px + env(safe-area-inset-bottom));
  box-shadow: 0 -14px 44px rgba(63, 48, 130, 0.22);
  transform: translateY(106%);
  transition: transform .3s cubic-bezier(.3, 1, .4, 1);
}
.fridge-tray.open { transform: translateY(0); }

/* ⚠️ In the FOOTER, not floated in the top-right corner, and that was decided
   by opening the 390x844 shot: up there it sat on the top of the next-arrow key
   and read as one control growing out of another. Down here it balances the
   market chip on the other side of the dots, and the row that used to hold an
   empty spacer now holds something. */
.ft-close {
  justify-self: start;
  width: 28px; height: 28px; border-radius: 999px;
  background: rgba(124, 92, 224, 0.10); border: none;
  color: var(--muted); font-size: 13px; font-weight: 800; line-height: 1;
}
.ft-close:active { background: rgba(124, 92, 224, 0.2); }

/* ⚠️ Centred, with the hero CAPPED — not stretched across the bar. At 615x888
   a hero that grew to fill the width was a 72px strawberry adrift in a 400px
   panel with its own count badge stranded at the far end. A carousel has to
   stay carousel-sized however wide the screen gets; below about 430px the cap
   is never reached and the group fills the bar on its own. */
.ft-bar {
  display: flex; align-items: center; justify-content: center;
  gap: 5px; touch-action: pan-y;
}

/* ⚠️ Not discs, and not the room's arrows. `.room-arrow` is a 46px circle
   floating over artwork and has to survive any wallpaper; these stand on the
   tray's own solid panel, so they are tall soft-cornered keys that read as part
   of the bar rather than as two more bubbles on the screen. */
.ft-arrow {
  flex: 0 0 auto;
  width: 36px; height: 64px; border-radius: 15px;
  display: flex; align-items: center; justify-content: center;
  padding: 0 0 3px;
  border: 1px solid rgba(120, 96, 190, 0.18);
  background: #fff; color: var(--accent);
  font-size: 24px; font-weight: 800; line-height: 1;
  box-shadow: 0 4px 12px rgba(63, 48, 130, 0.12);
}
.ft-arrow:active { transform: scale(.93); background: var(--accent-dim); }

/* The next food along, small and dimmed — so the arrows visibly have somewhere
   to go, and so the bar says how much is in the fridge without a word. */
.ft-side {
  flex: 0 0 52px; height: 66px;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 1px; padding: 0; background: none; border: none;
  opacity: .5; transition: opacity .2s ease, transform .2s ease;
}
.ft-side:active { opacity: .85; transform: scale(.94); }
.ft-side.blank { visibility: hidden; }
.ft-side-art { width: 40px; height: 40px; display: block; }
.ft-side-n { font-size: 10.5px; font-weight: 800; color: var(--muted); }

/* The one you would feed. The artwork IS the control; the name is a caption
   under it, and how many you have is a badge ON it rather than a chip beside
   it — WORKING-RULES.md §4.4 rules 1 and 2. */
.ft-hero {
  flex: 1 1 auto; min-width: 0; max-width: 236px; position: relative;
  display: flex; flex-direction: column; align-items: center; gap: 1px;
  padding: 7px 8px 8px;
  border: 1px solid var(--accent-dim); border-radius: 20px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(124, 92, 224, 0.12));
  box-shadow: 0 6px 16px rgba(63, 48, 130, 0.10);
}
.ft-hero:active { transform: scale(.97); }
.ft-art { width: 72px; height: 72px; display: block; animation: floaty 2.8s ease-in-out infinite; }
.ft-n {
  position: absolute; top: 6px; right: 9px;
  font-size: 11.5px; font-weight: 800; color: #7A5A14;
  background: rgba(245, 215, 142, 0.62);
  border: 1px solid rgba(201, 162, 74, 0.5); border-radius: 999px;
  padding: 1px 7px; line-height: 1.35;
}
.ft-name {
  font-size: 13.5px; font-weight: 800; color: var(--ink); line-height: 1.2;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ft-fills { font-size: 11.5px; font-weight: 700; color: var(--muted); line-height: 1.2; }

.ft-foot {
  display: grid; grid-template-columns: 1fr auto 1fr;
  align-items: center; margin-top: 8px;
}
.ft-dots { display: flex; gap: 5px; justify-content: center; }
.ft-dots i {
  width: 5px; height: 5px; border-radius: 999px;
  background: var(--card-brd); transition: width .2s ease;
}
.ft-dots i.on { width: 15px; background: var(--accent); }
.ft-shop {
  justify-self: end;
  display: flex; align-items: center; gap: 5px;
  background: rgba(124, 92, 224, 0.10); border: 1px solid var(--accent-dim);
  border-radius: 999px; padding: 5px 12px 5px 10px;
  color: var(--accent); font-size: 11.5px; font-weight: 800; line-height: 1.4;
}
.ft-shop:active { background: var(--accent-dim); }

/* The food in the air, between the tray and the pet's mouth.

   ⚠️ Fixed to the viewport and parented to <body>, NOT to .fx-layer where every
   other floating glyph lives — that layer is inset inside .house-stage with
   overflow:hidden, and the tray is outside the stage, so the food would be
   clipped the moment it left the bar. z-index sits above the tray (41) and the
   nav (30) and below the gallery (80), so a flight can never outlive a screen
   that opened over it. */
.food-flight {
  position: fixed; z-index: 50; pointer-events: none;
  filter: drop-shadow(0 6px 12px rgba(63, 48, 130, 0.3));
}
.food-flight svg { width: 100%; height: 100%; display: block; }

/* ⚠️ Reduced motion: the tray appears instead of sliding, the hero stops
   bobbing, and the flight is skipped in app.js rather than shortened — a fast
   food flying across the screen is still a food flying across the screen. */
@media (prefers-reduced-motion: reduce) {
  .fridge-tray { transition: none; }
  .ft-art { animation: none; }
}
/* The drawn currency tokens (coin-svg.js). `vertical-align` is doing real work
   here: an inline SVG sits on the text baseline by default, so its bottom edge
   lines up with the bottom of the digits and the token appears to float above
   them. -0.14em drops it to look centred against the number it belongs to. */
.cur-mark { vertical-align: -0.14em; }

.coin-chip {
  flex: 0 0 auto;
  background: rgba(245, 215, 142, 0.55); color: #7A5A14;
  border: 1px solid rgba(201, 162, 74, 0.5);
  border-radius: 999px; padding: 7px 13px;
  font-size: 13px; font-weight: 800; white-space: nowrap;
}
/* Same chip, the gem's colours. A separate class rather than a modifier on
   .coin-chip because the two are never interchangeable: one is the couple's
   money and one is yours, and they should not be able to drift into looking
   alike through a shared rule. */
.gem-chip {
  flex: 0 0 auto;
  background: rgba(95, 199, 155, 0.28); color: #2E6E56;
  border: 1px solid rgba(46, 143, 107, 0.45);
  border-radius: 999px; padding: 7px 13px;
  font-size: 13px; font-weight: 800; white-space: nowrap;
}
/* Two balances side by side, wherever one used to be. */
.wallet { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }

/* ---- app-updates1 A5, 2026-08-27 — no pill in the home bar --------------
   Deniz: *"The crumbs and gems too should not be inside a pill."*

   ⚠️ SCOPED TO .topbar ON PURPOSE. These two classes are also used by
   galHead() in the wardrobe and the edit-room sheet headers, where a chip on
   a card is doing a different job and reads correctly. He was looking at the
   home header. Stripping the pill everywhere is a second change he did not
   ask for.

   ⚠️ AND THE PILL WAS CARRYING THE CONTRAST. .coin-chip's ink is #7A5A14 on
   a pale gold background and .gem-chip's is #2E6E56 on pale mint — both of
   them chosen against their own pill, not against the sky. Taken out of the
   pill they sit straight on the room's gradient, and on the NIGHT sky a dark
   gold number is very nearly invisible. So the number takes the bar's own ink,
   which already flips (see :root[data-sky="night"] .topbar .brand), and the
   DRAWN TOKEN carries the identity — which is the right way round anyway: the
   coin and the gem are different pictures, not different shades of text. */
.topbar .wallet .coin-chip,
.topbar .wallet .gem-chip {
  background: none; border: 0; border-radius: 0; padding: 0 2px;
  color: var(--ink); font-size: 14px; font-weight: 800;
}
:root[data-sky="night"] .topbar .wallet .coin-chip,
:root[data-sky="night"] .topbar .wallet .gem-chip { color: #F1EDFF; }
/* The shop sits against the balances rather than floating in the bar's gap. */
.topbar .wallet #home-shop { margin-left: 2px; }
.sheet-title-row .wallet { margin-left: auto; }
/* In sheets the coin chip is pushed to the right of its title row. */
.sheet-title-row .coin-chip { margin-left: auto; }

.sleep-hint {
  position: absolute; left: 50%; bottom: 10%;
  transform: translateX(-50%); z-index: 6;
  background: rgba(255, 255, 255, 0.88); color: var(--ink-soft);
  border-radius: 999px; padding: 9px 16px;
  font-size: 13px; font-weight: 700; white-space: nowrap;
}

.sheet-title-row { display: flex; align-items: center; gap: 10px; }
.sheet-title-row .coin-chip { margin-left: auto; }

/* `.food-grid`/`.food-item` and `.shop-list`/`.shop-row` used to live here. Both
   were emoji-plus-a-word lists, and both are gone as of R7 (2026-08-17): the
   fridge's stock is `.gal-card`s with the food drawn, and the grocery shop is a
   `.tile-grid` with the price on the tile — the same shape as the furniture,
   which is the same kind of screen. Removed rather than left behind, so nobody
   restores the row layout by reaching for a class that still styles. */

.acc-opt .price { font-size: 11.5px; font-weight: 800; color: #7A5A14; }
.acc-opt.buy { border-style: dashed; }
.acc-opt.buy.previewing {
  border-style: solid; border-color: var(--accent);
  background: rgba(124, 92, 224, 0.16); color: var(--ink);
}

/* Preview-before-buy: a tag under the room preview + a buy bar. */
.preview-tag {
  text-align: center; margin: 10px 0 0;
  font-size: 12px; letter-spacing: .08em; text-transform: uppercase;
  font-weight: 800; color: var(--accent);
}
.buy-bar { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.buy-bar .bb-name { text-align: center; font-size: 15px; font-weight: 700; color: var(--ink); }
.buy-bar .btn { height: 50px; }

/* ---------- pet behavior FX + animations ---------- */

.fx-thought {
  position: absolute; z-index: 7;
  font-size: 19px; line-height: 1;
  background: rgba(255, 255, 255, 0.92);
  border-radius: 14px 14px 14px 3px;
  padding: 7px 9px;
  box-shadow: 0 4px 14px rgba(63, 48, 130, 0.18);
  animation: thoughtUp 2.2s ease-out both;
}
.fx-zz-once { position: absolute; z-index: 7; font-size: 17px; animation: zz 2.2s ease-out both; }
@keyframes thoughtUp {
  0% { transform: translateY(6px) scale(.5); opacity: 0; }
  15% { transform: translateY(0) scale(1); opacity: 1; }
  80% { opacity: 1; }
  100% { transform: translateY(-16px) scale(1); opacity: 0; }
}

#pet-walker.eating #pet-flip svg { animation: chomp .28s ease-in-out 3; }
#pet-walker.loved #pet-flip svg { animation: wiggle .5s ease-in-out 2; }
#pet-walker.yawning #pet-flip svg { animation: yawnStretch .95s ease-in-out 1; }
@keyframes chomp {
  0%, 100% { transform: scale(1, 1); }
  50% { transform: scale(1.08, .9); }
}
@keyframes wiggle {
  0%, 100% { transform: rotate(0); }
  25% { transform: rotate(-7deg) scale(1.05); }
  75% { transform: rotate(7deg) scale(1.05); }
}
@keyframes yawnStretch {
  0%, 100% { transform: scale(1, 1); }
  40% { transform: scale(.94, 1.1) translateY(-4px); }
  70% { transform: scale(1.05, .94); }
}

/* MSN nudge: the whole app rattles */
.shaking { animation: appShake .6s ease-in-out both; }
@keyframes appShake {
  0%, 100% { transform: translate(0, 0); }
  10% { transform: translate(-7px, 2px); }
  25% { transform: translate(6px, -3px); }
  40% { transform: translate(-6px, -2px); }
  55% { transform: translate(5px, 3px); }
  70% { transform: translate(-4px, 1px); }
  85% { transform: translate(3px, -1px); }
}

.nudge-btn {
  width: 40px; height: 40px; flex: 0 0 auto;
  border-radius: 50%; border: 1px solid var(--card-brd);
  background: var(--chip-bg); font-size: 17px;
  transition: transform .15s ease;
}
.nudge-btn:active { transform: scale(.88) rotate(-10deg); }

/* ---------- games ---------- */

.game-list { display: flex; flex-direction: column; gap: 10px; }
.game-card {
  display: flex; align-items: center; gap: 14px;
  padding: 15px 16px; text-align: left; width: 100%;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  border-radius: 20px; transition: transform .15s ease;
}
.game-card:active:not(:disabled) { transform: scale(.98); }
.game-card:disabled { opacity: .5; }
.game-card.ping { border-style: dashed; border-color: var(--accent-dim); }
.game-card.ping .gt span { color: var(--accent); font-weight: 700; }
.game-card .gi { font-size: 26px; }
.game-card .gt { flex: 1; }
.game-card .gt b { display: block; font-size: 15px; color: var(--ink); }
.game-card .gt span { font-size: 12.5px; color: var(--muted); }
.game-card .ga { color: var(--faint); font-size: 20px; }

#game-layer {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 80;
  display: flex; flex-direction: column;
  background: linear-gradient(180deg, #EAE4FC 0%, #CDBFF3 60%, #A48DE2 100%);
  /* The bigger boards (Reversi, Snakes & Ladders) can outgrow a short phone. */
  overflow-y: auto; overflow-x: hidden;
  /* ⚠️ THE LINE THAT STOPS THE REACTION PANEL COVERING THE BOARD, and it is
     the whole R19/2 fix. --emote-dock is the dock's MEASURED height, published
     on :root by syncInset() in games.js; --emote-lane is the strip the
     bottom-right toggle needs whether or not the dock is up. Together they are
     padding rather than an overlay, so every duel screen lays itself out in
     what is left instead of having its bottom quarter hidden.
     Before this: at 390x844, all three Rock Paper Scissors hands and all three
     Rooftop Run controls were 100% covered with the panel open.

     ⚠️ R23: max(), NOT the sum it used to be, and that one word is 60px of an
     844px phone. --emote-lane exists for the toggle, which used to FLOAT above
     the dock — so the layer paid for the panel and again for the button
     standing on the panel's roof: 253 + 60 = 314px reserved, 37% of the
     screen, which is the half of Deniz's complaint that reads "or pushes
     them". The toggle now sits INSIDE the dock's header row while the dock is
     up (see .g-emote.open), so the dock's own height already contains it, and
     when the dock is shut --emote-dock is 0px and the lane wins on its own.
     No class, no JavaScript, no second measurement. */
  --emote-lane: 0px;
  padding-bottom: max(var(--emote-dock, 0px), var(--emote-lane));
  transition: padding-bottom .22s cubic-bezier(.22,.61,.36,1);
}
#game-layer.has-emote { --emote-lane: calc(60px + env(safe-area-inset-bottom)); }
@media (prefers-reduced-motion: reduce) { #game-layer { transition: none; } }
.g-head {
  display: flex; align-items: center; gap: 12px;
  padding: 14px 18px 10px;
  padding-top: max(14px, env(safe-area-inset-top), calc(var(--notice-top, 0px) + 10px));
}
.g-close {
  width: 38px; height: 38px; border-radius: 50%;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  color: var(--ink-soft); font-size: 15px; font-weight: 700;
}
.g-title { font-size: 16px; font-weight: 800; color: var(--ink); }
.g-right {
  margin-left: auto; font-size: 13.5px; font-weight: 700; color: var(--ink-soft);
  /* A long status must never widen the layer and push the board off-screen. */
  min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.g-hint {
  text-align: center; font-size: 12.5px; color: var(--muted); font-weight: 600;
  padding: 10px 20px calc(16px + env(safe-area-inset-bottom));
}

/* ---------- reactions ----------
   The button is drawn art, the payload is system emoji or a translated line
   chosen by INDEX, and that split is deliberate — see the note in games.js.
   Everything below is fixed to the viewport rather than living inside
   #game-layer, because a duel frame replaces that element's innerHTML and
   would delete a floating reaction mid-flight — and, since R19/2, would delete
   the toggle whose whole job is to stay where the user left it. */

/* ⚠️ BOTTOM RIGHT, fixed, and 89 so it is never buried by its own panel.
   Deniz asked for it there by name. It also rides UP when the dock opens, so
   one control sits in one place and reads as the panel's handle rather than as
   a second thing that appeared. */
.g-emote {
  position: fixed; z-index: 89;
  right: 14px;
  /* Shut: its own lane above the bottom edge. Up: see .g-emote.open. */
  bottom: calc(var(--emote-dock, 0px) + 12px + env(safe-area-inset-bottom));
  width: 46px; height: 46px; padding: 8px;
  border-radius: 50%; border: 1px solid var(--card-brd);
  background: #FBFAFF;
  box-shadow: 0 6px 18px rgba(63, 48, 130, 0.24);
  display: grid; place-items: center;
  transition: bottom .22s cubic-bezier(.22,.61,.36,1), transform .12s ease;
}
.g-emote svg { width: 100%; height: 100%; display: block; }
.g-emote:active { transform: scale(0.92); }
.g-emote[hidden] { display: none; }
/* Open and shut are the same button in two states, so the difference is a mark
   and a ring, never a different silhouette.
   ⚠️ The face stays WHITE in both. The open state was a --accent-dim fill,
   which is a pale lilac — and #game-layer's background is a lilac gradient, so
   the button quietly dissolved into the board exactly when it was the control
   you most needed to find again. Found by opening the screenshot; every
   assertion about the button was green. */
.g-emote.open {
  /* ⚠️ R23: WHILE THE DOCK IS UP THE BUTTON LIVES IN IT, not above it.
     57px down from the dock's top edge puts a 46px circle centred on the tab
     pill's row, in the empty space to the right of it — the card is a centred
     flex column, so that space is always there. z-index 89 over the sheet's 88
     keeps it drawn on top without any new machinery, and it is still a
     body-level element outside #game-layer, which is what lets it survive a
     duel repaint (emote-panel-check asserts that).
     This is where the 60px comes from. It also reads better: the toggle is the
     dock's handle rather than a second thing hovering over it.
     ⚠️ The order of paintButton() and syncInset() in games.js is load-bearing
     for this, and there is a comment there saying why. */
  bottom: calc(var(--emote-dock, 0px) - 57px);
  border-color: var(--accent);
  box-shadow: 0 6px 18px rgba(63, 48, 130, 0.24), 0 0 0 3px var(--accent-dim);
}

/* Above the board (80), below the toast (90) — a toast is the app talking to
   you and outranks your partner pulling a face.
   ⚠️ It STOPS AT THE TOP OF THE DOCK. Without that a reaction rising from
   "your half" on a short screen would begin its life behind the panel, which
   is z-index 88 and opaque. */
#emote-fly {
  position: fixed; top: 0; left: 0; right: 0; bottom: var(--emote-dock, 0px);
  z-index: 85; pointer-events: none; overflow: hidden;
}
/* ⚠️ Up the RIGHT MARGIN, not the middle. The first version rose from the
   centre at 20%/62% and the screenshot showed exactly what that means: a
   party popper parked squarely over the partner's three question marks in
   Rock Paper Scissors, hiding a third of the board for two and a half
   seconds. Every duel in this app draws its pieces down the middle and
   leaves the side margins empty, so that is where a decoration belongs.
   Nothing asserted this and nothing could have — the emote arrived, was on
   the right half, and was moving. */
.emote-fly-one {
  position: absolute; left: 78%; font-size: 44px; line-height: 1;
  will-change: transform, opacity;
  animation: emoteRise 2.5s cubic-bezier(.22,.61,.36,1) both;
}
/* Them above, you below — the same halves every duel screen already uses, so an
   arriving reaction reads as coming FROM your partner without a label saying
   so. Percentages of #emote-fly, which now ends where the dock begins. */
.emote-fly-one.theirs { top: 26%; }
.emote-fly-one.mine { top: 64%; }
@keyframes emoteRise {
  0%   { transform: translate(-50%, 16px) scale(0.4); opacity: 0; }
  18%  { transform: translate(calc(-50% + var(--x, 0px) * 0.3), -6px) scale(1.15); opacity: 1; }
  40%  { transform: translate(calc(-50% + var(--x, 0px) * 0.6), -34px) scale(1); opacity: 1; }
  100% { transform: translate(calc(-50% + var(--x, 0px)), -108px) scale(0.86); opacity: 0; }
  /* --x is capped at ±34px in games.js rather than ±45, because the lane is
     against the right edge now and a wide scatter would push a 44px glyph off
     the side of a 390px screen. */
}

/* A taunt is a sentence, so it arrives as a SPEECH BUBBLE rather than as loose
   text rising up the margin — text with nothing behind it is unreadable over a
   game board, and a bare line in the corner reads as a system message rather
   than as your partner saying something. It gets its own animation because the
   glyph one is written around translate(-50%), which a right-anchored bubble
   must not inherit, and its own duration because you have to be able to finish
   reading it (3.2s here, 3200ms in games.js — they have to agree). */
.emote-fly-one.words {
  left: auto; right: 12px; max-width: 66%;
  font-size: 15px; font-weight: 800; line-height: 1.25;
  color: var(--ink); text-align: right;
  background: #FFFFFF; border: 1px solid var(--card-brd);
  border-radius: 18px 18px 6px 18px;
  padding: 9px 13px;
  box-shadow: 0 8px 22px rgba(63, 48, 130, 0.22);
  animation: tauntRise 3.2s cubic-bezier(.22,.61,.36,1) both;
}
.emote-fly-one.words.theirs { background: #F3EEFF; }
@keyframes tauntRise {
  0%   { transform: translate(var(--x, 0px), 18px) scale(0.72); opacity: 0; }
  12%  { transform: translate(var(--x, 0px), -4px) scale(1.04); opacity: 1; }
  70%  { transform: translate(var(--x, 0px), -46px) scale(1); opacity: 1; }
  100% { transform: translate(var(--x, 0px), -96px) scale(0.96); opacity: 0; }
}

#emote-sheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 88;
  transform: translateY(100%); transition: transform .22s cubic-bezier(.22,.61,.36,1);
}
#emote-sheet.up { transform: translateY(0); }
.emote-card {
  /* ⚠️ R23 — THE DOCK IS THE FLOOR OF THE GAME, NOT A CARD ON TOP OF IT.
     Deniz, 2026-08-23: "remove it's ugly white backdrop that makes it look like
     a popup menu, make it blend more."
     It looked like a popup because it was drawn as one. Four separate things
     said "separate surface, floating", and all four are gone: a 26px top
     radius, a #FBFAFF face, a 1px card border and a 40px drop shadow — against
     #game-layer's lilac gradient, which is about #BBA9EC where the dock's top
     edge falls and #A48DE2 at the bottom of the screen.
     So: square corners, edge to edge, and a colour that CONTINUES that gradient
     one step deeper. The seam is a single hairline of white; the shadow is
     gone. The chips stay light, because they are the things you press. */
  border-radius: 0;
  /* ⚠️ STILL OPAQUE, and this is the trap inside "blend more".
     rgba(255,255,255,.94) was tried and the screenshot showed Rock Paper
     Scissors' hand row and its hint line ghosting THROUGH the second row of
     emoji — six per cent of a busy game board is more than it sounds. The board
     lays out above the dock now, but #game-layer SCROLLS, so on any screen that
     scrolls the content passes behind the dock again. Blending is a colour
     problem, not an alpha one. */
  background: linear-gradient(180deg, #A991E4 0%, #9076D6 100%);
  border-top: 1px solid rgba(255, 255, 255, 0.30);
  box-shadow: none;
  /* ⚠️ The bottom padding carries the gesture bar. On a phone
     env(safe-area-inset-bottom) adds one and the dock looks fine; in a browser
     and on an Android without one it is zero, and the bottom row of emoji sat
     flush against the screen edge with nothing under it. The screenshot is the
     only place that showed up — every assertion about the sheet passed. */
  padding: 6px 12px calc(10px + env(safe-area-inset-bottom));
  display: flex; flex-direction: column; align-items: center; gap: 7px;
  transition: opacity .14s ease;
  /* ⚠️ ONE HEIGHT, WHATEVER THE TAB, and capped in vh before px.
     Two reasons, and the second is the one that was measured. Switching tabs
     must not move the board — a dock that grows when you look at the words
     reflows the game underneath you. And a dock sized to its contents takes
     roughly 30% of an 844px phone and roughly 40% of a 667px one, which is how
     a panel that correctly reserves its space still ruins a screen: at 360x740
     the words tab pushed Rock Paper Scissors' hand row down onto its own hint
     line. Nothing was under the panel and the screen was still wrong.
     Only the screenshot showed it.

     ⚠️ R23 — 300/30vh WAS STILL TOO BIG, and that is the other half of the
     complaint. 253px of sheet plus the toggle's 60px lane reserved 314px of an
     844px phone: Rooftop Run's canvas was down to about 460px of a 740px
     screen with its three controls flush underneath. Nothing covered,
     everything squashed. 190/23vh with the lane folded in reserves 190px —
     22% of the screen instead of 37%.
     ⚠️ AND BOTH TABS MUST STILL OVERFLOW THIS CAP, or the two stop being the
     same height and the board reflows when you switch tab. Shrink the BOX, not
     the content; emote-panel-check measures both tabs and compares them. */
  max-height: min(190px, 23vh);
}
/* The panel's own answer to a tap it had to refuse (one reaction per 1.5s, the
   same window the server keeps). Without it a dropped tap is silence, and
   silence reads as a broken button. */
/* ⚠️ R23: the FADE MOVED OFF THE CARD AND ONTO ITS CONTENTS. Fading a card
   that used to be white over a lilac board was obvious; fading a lilac dock
   over a lilac board is invisible, so the cue for a refused tap would have
   quietly stopped existing the moment the skin changed — a defect no assertion
   about .cool could have seen, because the class still goes on and comes off. */
.emote-card.cool .emote-body,
.emote-card.cool .emote-tabs { opacity: .4; }
.emote-grip {
  width: 40px; height: 4px; border-radius: 999px;
  background: rgba(255, 255, 255, 0.42);
}

/* The two tabs. A segmented control, not a row of links: each half carries its
   own drawn mark so the choice is legible before the words are read, and the
   selected half is a filled surface rather than an underline. */
.emote-tabs {
  display: flex; gap: 4px; padding: 3px;
  /* A well sunk into the lilac floor rather than a tint on white — the
     selected half has to be the lightest thing in the row. */
  background: rgba(40, 24, 80, 0.32);
  border-radius: 999px;
}
.emote-tab {
  display: flex; align-items: center; gap: 6px;
  padding: 6px 14px; border: 0; border-radius: 999px;
  background: transparent; color: #FFFFFF;
  font-size: 12.5px; font-weight: 800; letter-spacing: .01em;
  transition: background .16s ease, color .16s ease;
}
.emote-tab svg { width: 15px; height: 15px; display: block; opacity: .72; }
.emote-tab.on { background: #FFFFFF; color: var(--ink); box-shadow: 0 2px 6px rgba(31, 18, 66, 0.24); }
.emote-tab.on svg { opacity: 1; }

/* ⚠️ The one scrolling thing, and it is capped in vh as well as px. Thirty
   faces and twenty lines cannot all be on screen at once without the dock
   eating half a phone — which is the defect this whole change exists to fix.
   So the dock stays short and the CONTENT scrolls, and the part-row you can
   see at the bottom edge is the affordance that says so. */
.emote-body {
  width: 100%; max-width: 420px;
  overflow-y: auto; overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* Shrinks to whatever the card's cap leaves it. Never grows past its content,
     so the dock is never taller than it has to be. */
  flex: 0 1 auto; min-height: 0;
  /* ⚠️ R23 — THE OTHER AXIS, WHICH NOBODY EVER CHOSE. `overflow-y: auto`
     with no `overflow-x` is not "scrolls down only": CSS resolves a `visible`
     on one axis to `auto` when the other is not `visible`, so this box has
     silently been a two-way scroller since the day it was written. It only
     mattered when the faces grid went from six columns to eight and started
     asking for 369px inside 366 — the eighth disc then sat off the right of
     the panel and had to be dragged into view, worse at 360 (369 of 336).
     `hidden` is belt and braces behind the real fix below; it does NOT hide
     the defect from emote-panel-check, because scrollWidth still reports
     content that overflows a clipped box. It only decides whether the next
     regression is a clipped pixel or a sideways-scrolling dock. */
  overflow-x: hidden;
}
.emote-card.faces .taunt-grid { display: none; }
.emote-card.words .emote-grid { display: none; }

/* ⚠️ R23: EIGHT across, not six. Thirty faces at six is five rows and 304px of
   content, which never fit the old 253px box either — it already scrolled, so
   the extra row width was buying nothing and costing a disc's diameter. Eight
   is four rows of discs — 40.5px at 390x844, 36.75px at 360x740 — so what you
   can see in a much shorter dock is very nearly what you could see in the tall
   one. ⚠️ 36.75 plus a 6px gutter either side is still past a 40px tap target,
   but it is the smallest the drawing gets and it is the number to argue with if
   anybody wants seven columns back. */
/* ⚠️ minmax(0, 1fr), NOT 1fr, and this is the whole of the sideways bug.
   `1fr` is `minmax(auto, 1fr)`, and `auto` as a track MINIMUM is the item's
   min-content width — so eight discs refuse to be narrower than eight discs'
   worth of content however little room the card has. Measured: 369px wanted
   in 366 at 390x844 and in 336 at 360x740, the SAME 369 at both, because it
   is a floor rather than a proportion. minmax(0, ...) is what tells a track
   it may go below that, and it is the right shape for any grid whose columns
   are a fraction of a container they do not control. */
.emote-grid {
  display: grid; grid-template-columns: repeat(8, minmax(0, 1fr));
  gap: 6px; width: 100%;
}
.emote-pick {
  aspect-ratio: 1; border-radius: 50%; border: 0;
  /* ⚠️ padding: 0 IS PART OF THE OVERFLOW FIX, not tidying. Chrome gives every
     <button> a UA padding of `1px 6px`, so each disc's min-content width was
     the emoji's own advance PLUS 12px it never used — the glyph is centred by
     place-items, not by padding. That 12px is most of the 40.9px floor that
     made eight columns need 369px. Without this, minmax(0, 1fr) above only
     moves the overflow from the grid into the disc: at 360 the track becomes
     36.75px and a 40.9px min-content button hangs out of it instead, which is
     why emote-panel-check measures the TILE against its grid as well as the
     grid against the card. */
  padding: 0;
  font-size: 21px; line-height: 1;
  display: grid; place-items: center;
  transition: transform .12s ease;
}
.emote-pick:active { transform: scale(0.88); }

/* The words tab. Each line is a little speech bubble — the same shape it will
   arrive in — rather than a row in a list, because a stack of full-width rows
   with text on them is exactly the settings-form look WORKING-RULES §4.4 is
   about. The clipped corner is the bubble's tail. */
/* minmax(0, 1fr) here for the same reason as .emote-grid, before it is the same
   bug: two `1fr` tracks with an automatic minimum are one long unbroken word
   in one taunt away from pushing this grid wider than the dock. Nothing is
   over today (366 of 366 at 390, 336 of 336 at 360) and the guard costs a
   word. */
.taunt-grid {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 6px; width: 100%;
}
.taunt-pick {
  border: 0; border-radius: 15px 15px 15px 5px;
  padding: 9px 11px; min-height: 40px;
  font-size: 12.5px; font-weight: 800; line-height: 1.2;
  color: var(--ink); text-align: left;
  display: flex; align-items: center;
  transition: transform .12s ease;
}
.taunt-pick:active { transform: scale(0.95); }

/* Five tints rather than one, so the panel reads as a designed set of discs and
   bubbles instead of glyphs on a grey grid.
   ⚠️ R23: the two sets SPLIT when the floor went lilac, and they had to. The old
   pair of rules assumed a near-white card underneath — a colour at 14–20% alpha
   over #FBFAFF is a pastel, and the same colour over #9C84DE is mud. So the
   discs became light veils (an emoji brings its own colour and only needs to sit
   on something lighter than the floor), and the bubbles kept a light face with
   dark ink on it, because a taunt is READ. They are also the colours a bubble
   arrives in — the words tab is a tray of the very things you are about to
   send. */
.emote-pick.t0 { background: rgba(255, 255, 255, 0.20); }
.emote-pick.t1 { background: rgba(255, 205, 225, 0.26); }
.emote-pick.t2 { background: rgba(255, 238, 180, 0.28); }
.emote-pick.t3 { background: rgba(190, 245, 220, 0.26); }
.emote-pick.t4 { background: rgba(200, 228, 255, 0.26); }
.taunt-pick.t0 { background: rgba(255, 255, 255, 0.82); }
.taunt-pick.t1 { background: rgba(255, 236, 244, 0.84); }
.taunt-pick.t2 { background: rgba(255, 248, 225, 0.86); }
.taunt-pick.t3 { background: rgba(230, 250, 241, 0.84); }
.taunt-pick.t4 { background: rgba(232, 242, 255, 0.84); }
@media (prefers-reduced-motion: reduce) {
  /* The reaction still has to appear and go away on its own — it is the only
     thing telling you your partner reacted — so this keeps the fade and drops
     the travel. */
  .emote-fly-one { animation: emoteFade 2.5s ease both; }
  .emote-fly-one.words { animation: emoteFade 3.2s ease both; }
  @keyframes emoteFade {
    0%, 100% { opacity: 0; transform: translate(-50%, 0); }
    18%, 62% { opacity: 1; transform: translate(-50%, 0); }
  }
  .emote-fly-one.words { transform: none; }
  #emote-sheet, .g-emote { transition: none; }
}

.g-stage { position: relative; flex: 1; min-height: 0; overflow: hidden; touch-action: none; }
/* ⚠️ left: 0, and startCatch() places the pet with a transform from here on.
   `left` is a layout property: writing it once per pointer event re-laid-out
   and repainted the 84px pet SVG on every move of the finger. R20.4.
   ⚠️ No will-change, deliberately — it was tried and measured SLOWER than the
   bug it was meant to fix. See the block above drawPet() in games.js. */
.g-catcher {
  position: absolute; bottom: 6px; left: 0;
  transform: translateX(-50%);
  pointer-events: none; z-index: 2;
}
.g-item {
  position: absolute; top: 0; font-size: 26px; z-index: 1;
  pointer-events: none; will-change: transform;
}
.g-item.caught { animation: popIn .22s ease reverse both; }
.g-item.boom { animation: glowPulse .22s ease 1; filter: saturate(3); }

.g-result {
  flex: 1; display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: 14px; padding: 20px 32px;
}
.g-big { font-size: 54px; animation: popIn .4s ease both; }
.g-score { font-size: 19px; font-weight: 800; color: var(--ink); }
.g-earn {
  font-size: 15px; font-weight: 800; color: #7A5A14;
  background: rgba(245, 215, 142, 0.55);
  border: 1px solid rgba(201, 162, 74, 0.5);
  border-radius: 999px; padding: 9px 18px;
}
.g-earn.dim { background: var(--chip-bg); color: var(--muted); border-color: var(--card-brd); }
.g-result .btn-ghost { min-width: 140px; }

/* ---------- the result screen (R19/3) ----------
   Two tiles and a quiet way out, replacing two stacked pill buttons. The
   rematch tile's picture is the GAME'S OWN drawing — §4.4 rule 1 — and its
   three states (idle, asked, being-asked) are faces of the same tile rather
   than a chip beside it — rule 2. */
.g-result-end { justify-content: center; gap: 16px; padding: 12px 20px 20px; }
.g-end { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 14px; }
.g-end-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
/* ⚠️ Being asked takes the WHOLE WIDTH. Someone has just asked you for
   something; squeezing "{name} wants a rematch" into half a phone next to an
   unrelated control is how a request reads as a status line. */
.g-end.asking .g-end-tiles { grid-template-columns: 1fr; }

.g-end-tile {
  position: relative;
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 16px 12px 14px;
  border-radius: 22px; border: 1px solid rgba(255, 255, 255, 0.7);
  background: rgba(255, 255, 255, 0.72);
  box-shadow: 0 8px 22px rgba(63, 48, 130, 0.16);
  transition: transform .12s ease, box-shadow .12s ease, background .2s ease;
}
.g-end-tile:active { transform: scale(0.96); }
.g-end-art { width: 62px; height: 62px; display: block; }
.g-end-art svg { width: 100%; height: 100%; display: block; }
.g-end-name {
  font-size: 15px; font-weight: 800; color: var(--ink);
  line-height: 1.25; text-align: center;
}
.g-end-sub {
  font-size: 12px; font-weight: 700; color: var(--muted);
  line-height: 1.3; text-align: center;
  /* A long partner name or a long game title must not widen the grid column
     and push the other tile off the screen. */
  max-width: 100%; overflow-wrap: anywhere;
}
/* R23 — the level this rematch will be played at, under the game's name. It is
   a whole line of its own rather than a chip beside the tile (§4.4: state
   belongs to the item), and it only appears on the four-and-counting duels that
   carry a level. `display: flex` is what breaks it onto its own line — the base
   .diff-chip is inline-flex, which would leave it trailing the title. */
.g-end-sub .diff-chip {
  display: flex; justify-content: center;
  margin: 3px 0 0; font-size: 11.5px; color: var(--accent);
}
.g-end-tile.ask .g-end-sub .diff-chip { justify-content: flex-start; }
/* The little loop that says "again". A badge on the artwork, so the tile still
   shows the GAME and the mark only qualifies it. */
.g-end-badge {
  position: absolute; top: 10px; right: 10px;
  width: 22px; height: 22px; border-radius: 50%;
  background: #FFFFFF; padding: 3px;
  box-shadow: 0 2px 8px rgba(63, 48, 130, 0.22);
}
.g-end-badge svg { width: 100%; height: 100%; display: block; }
/* Asked, and waiting on them. Deliberately not a spinner: the tile itself
   breathes, so the thing that is waiting is the thing you pressed. */
.g-end-tile.sent { background: rgba(255, 255, 255, 0.5); box-shadow: none; }
.g-end-tile.sent .g-end-art { animation: endWait 1.8s ease-in-out infinite; }
@keyframes endWait { 0%, 100% { opacity: .45; } 50% { opacity: .9; } }
/* Being asked. The accent ring and the warm ground are the whole difference —
   the silhouette is the same tile, because it is the same control.
   ⚠️ A GRID, not a flex row. As a row the name and the instruction sat side by
   side in two columns and the instruction ran underneath the badge; as a grid
   the picture takes both rows and the two lines stack where a request's title
   and subtitle belong. Found by opening rematch-390-ask.png. */
.g-end-tile.ask {
  display: grid;
  grid-template-columns: 52px 1fr;
  column-gap: 14px; row-gap: 2px;
  align-items: center;
  text-align: left; padding: 15px 18px;
  background: #FFFFFF;
  border-color: var(--accent);
  box-shadow: 0 8px 22px rgba(63, 48, 130, 0.16), 0 0 0 3px var(--accent-dim);
  animation: endAsk 2.2s ease-in-out infinite;
}
.g-end-tile.ask .g-end-art { grid-column: 1; grid-row: 1 / span 2; width: 52px; height: 52px; }
.g-end-tile.ask .g-end-name { grid-column: 2; grid-row: 1; font-size: 16px; text-align: left; }
.g-end-tile.ask .g-end-sub { grid-column: 2; grid-row: 2; text-align: left; color: var(--accent); }
/* ⚠️ AND THE BADGE GOES. It is absolutely positioned top-right, and in this
   state the text runs the full width — so "tap to accept" was printed
   underneath a white disc. The tile is no longer offering "again" here; it is
   showing somebody's request, and the loop has nothing left to say. */
.g-end-tile.ask .g-end-badge { display: none; }
/* The request is the screen. The chat tile sits under it, trimmed, rather than
   growing into a full-width slab that competes with it. */
.g-end.asking .g-end-tile.talk { padding: 12px 12px 11px; }
.g-end.asking .g-end-tile.talk .g-end-art { width: 40px; height: 40px; }
@keyframes endAsk {
  0%, 100% { box-shadow: 0 8px 22px rgba(63, 48, 130, .16), 0 0 0 3px var(--accent-dim); }
  50%      { box-shadow: 0 8px 22px rgba(63, 48, 130, .16), 0 0 0 7px var(--accent-dim); }
}
/* The unread count belongs to the chat tile, not to a line under it. */
.g-end-count {
  position: absolute; top: 8px; right: 8px; min-width: 22px; height: 22px;
  padding: 0 6px; border-radius: 999px;
  background: var(--accent); color: var(--accent-ink);
  font-size: 12px; font-weight: 800;
  display: grid; place-items: center;
  box-shadow: 0 2px 8px rgba(63, 48, 130, 0.28);
}
.g-end-count[hidden] { display: none; }
.g-end-leave {
  align-self: center; background: none; border: none;
  font-size: 13.5px; font-weight: 700; color: var(--ink-soft);
  padding: 8px 20px; text-decoration: underline; text-underline-offset: 4px;
}
@media (prefers-reduced-motion: reduce) {
  .g-end-tile.ask, .g-end-tile.sent .g-end-art { animation: none; }
}

/* ---------- the end-game chat (R19/3) ----------
   ⚠️ A SHEET, NOT A DOCK. The reaction panel is a dock because it must never
   cover a board being played on; this opens only on a result screen, where
   there is no board left to protect and where a cramped strip would make a
   conversation feel like a status bar. z-index 88 puts it over #game-layer (80)
   and under the toast (90), the same order the reaction sheet keeps. */
/* ⚠️ THE REACTION TOGGLE IS PUT AWAY WHILE THIS IS UP. .g-emote is fixed at
   z-index 89 — deliberately, so its own dock cannot bury it — which means it
   lands squarely on top of this sheet's send button. One floating control over
   another is not a layering problem to solve with a z-index; the reaction
   button simply has nothing to do while a conversation is open. */
body.ec-open .g-emote { display: none; }
#end-chat {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 88;
  display: flex; align-items: flex-end;
  background: rgba(40, 30, 80, 0.42);
  opacity: 0; transition: opacity .22s ease;
}
#end-chat.up { opacity: 1; }
#end-chat[hidden] { display: none; }
.ec-card {
  width: 100%; max-height: 78vh;
  display: flex; flex-direction: column;
  /* ⚠️ OPAQUE. --card is rgba(255,255,255,.52) and this sheet sits over a
     dimmed backdrop, so the variable would let the dark veil through and the
     conversation would be read against a grey wash. */
  background: #FBFAFF;
  border-radius: 26px 26px 0 0;
  padding: 8px 16px calc(12px + env(safe-area-inset-bottom));
  box-shadow: 0 -10px 30px rgba(40, 30, 80, 0.28);
  transform: translateY(14px); transition: transform .22s cubic-bezier(.22,.61,.36,1);
}
#end-chat.up .ec-card { transform: translateY(0); }
.ec-grip {
  width: 42px; height: 4px; border-radius: 999px;
  background: var(--card-brd); margin: 4px auto 8px;
}
.ec-head { display: flex; align-items: center; gap: 10px; }
.ec-title { font-size: 15.5px; font-weight: 800; color: var(--ink); }
.ec-x {
  margin-left: auto; width: 34px; height: 34px; border-radius: 50%;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  color: var(--ink-soft); font-size: 14px; font-weight: 700;
}
/* ⚠️ THE WINDOW IS STATED IN WORDS. A chat that silently shows a slice of a
   conversation is the exact thing that felt like data loss on 2026-08-21
   (gotcha 106) — so this line is not decoration, it is the difference between
   a deliberate view and an apparent bug. */
.ec-since {
  font-size: 11.5px; font-weight: 700; color: var(--muted);
  padding: 4px 2px 8px; margin: 0;
}
.ec-list {
  flex: 1; min-height: 120px; overflow-y: auto;
  display: flex; flex-direction: column;
  padding: 4px 2px 8px;
}
.ec-empty {
  margin: auto; text-align: center; font-size: 13px;
  font-weight: 700; color: var(--muted);
}
.ec-form { display: flex; gap: 10px; align-items: center; padding: 8px 0 4px; }
.ec-input { flex: 1; height: 46px; }
.ec-full {
  align-self: center; background: none; border: none;
  font-size: 12.5px; font-weight: 700; color: var(--ink-soft);
  padding: 6px 16px 2px; text-decoration: underline; text-underline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
  #end-chat, .ec-card { transition: none; }
}

/* ---------- the in-game chat (R23 `ingame-chat`) ----------
   Deniz: *"Add an in game chat that's similiar to the end game chat that's
   easily openable. And have previews of the messages without opening the
   menu."*

   ⚠️ A DOCK, NOT A SHEET — the opposite choice to #end-chat one block up, and
   the difference is whether there is a board left to protect. The end-game
   chat opens on a result screen where nothing is at stake, so it is a 78vh
   sheet over a dimmed backdrop. This opens over a LIVE MATCH, so it reserves
   its space and the board lays out above it: no veil, no inset:0, nothing
   covered. That is also the entire answer to "does opening the chat pause a
   timed game" — it does not, and it does not need to, because nothing is
   hidden. The clock and the turn line live in .g-head at the TOP of
   #game-layer, which this never reaches.

   ⚠️ AND IT READS --emote-dock, NEVER A NUMBER. `emote-dock` reskinned the
   reaction dock and changed its height in the same wave this was written;
   both controls move together because both are written in terms of the
   variable syncInset() publishes. */

/* ⚠️ THE ONE ADDED RULE, and the only shared-surface claim this feature makes.
   #game-layer's padding-bottom is max(var(--emote-dock), var(--emote-lane)) and
   belongs to `emote-dock` — so this reserves its lane THROUGH the variable
   already inside that declaration rather than adding a term to it.
   --ig-lane is measured and published by syncIgLane() in games.js as the
   distance from the viewport floor to the top of whatever this feature is
   occupying, so it already contains --emote-dock and max() always resolves to
   it. ⚠️ If --emote-lane is ever renamed, this line follows the rename. */
#game-layer.has-igchat { --emote-lane: var(--ig-lane, 76px); }

/* ---- the preview strip -----------------------------------------------------
   ⚠️ THE HALF OF THE TASK WITH NO PRECEDENT IN THIS APP: the messages are
   legible without opening anything. It is a STRIP, NOT A TOAST — it does not
   time out, it does not slide away, and it holds the same two rows until the
   match ends.

   The lane it stands in is the empty half of the one .g-emote already
   reserves: that toggle is 46px hard against right:14px, so right:72px clears
   it with a 12px gutter. The `bottom` expression below is character-for-
   character the one .g-emote uses, which is what makes the two of them move
   together when the reaction dock changes height. */
#ig-peek {
  position: fixed; z-index: 84;
  left: 12px; right: 72px;
  bottom: calc(var(--emote-dock, 0px) + 12px + env(safe-area-inset-bottom));
  /* ⚠️ `bottom` IS DELIBERATELY NOT TRANSITIONED, and .g-emote's is. That is
     not an inconsistency to tidy up: syncIgLane() MEASURES this element's top
     edge the instant --emote-dock changes, and an animated bottom means it
     measures the position the strip is leaving rather than the one it is going
     to. The board would then be under-padded for 220ms with the strip already
     over it. #game-layer's own padding-bottom transitions instead, so the
     board and the strip travel together and only the board animates. */
  transition: opacity .2s ease;
}
#ig-peek[hidden] { display: none; }
.ig-peek-btn {
  width: 100%; display: flex; align-items: center; gap: 10px;
  /* ⚠️ OPAQUE, like .ec-card and for a sharper reason: this one stands over a
     lilac gradient AND over a board. --card is rgba(255,255,255,.52) and a
     Reversi disc reading through the message underneath it is not a preview. */
  background: #FBFAFF;
  border: 1px solid var(--card-brd);
  border-radius: 16px;
  box-shadow: 0 6px 18px rgba(63, 48, 130, 0.24);
  padding: 8px 10px;
  text-align: left;
  transition: box-shadow .2s ease, border-color .2s ease;
}
.ig-peek-btn:active { transform: scale(0.985); }
/* ⚠️ TWO ROWS, ALWAYS, AND A FIXED HEIGHT. 2 x 17px line boxes plus the 2px
   gap. This number existing is the whole reason the board never reflows when a
   message arrives — a strip that grew to fit would move the grid under a thumb
   that is mid-move. min-height AND max-height, so neither a long message nor
   an empty one can change it. */
.ig-peek-rows {
  flex: 1; min-width: 0;
  display: flex; flex-direction: column; justify-content: flex-end; gap: 2px;
  min-height: 36px; max-height: 36px; overflow: hidden;
}
/* One line each, clipped with an ellipsis. A preview is a preview. */
.ig-row {
  display: block; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  font-size: 11.5px; line-height: 17px;
  font-weight: 700; color: var(--muted);
}
/* The newest message is the bottom row and carries the weight; the one above
   it is the context, dimmed and a shade smaller. */
.ig-row.new { font-size: 12.5px; font-weight: 800; color: var(--ink); }
.ig-row.empty { color: var(--faint); font-weight: 700; }
.ig-who { color: var(--accent); font-weight: 800; margin-right: 5px; }
.ig-row.mine .ig-txt { color: var(--ink-soft); }
.ig-row.new.mine .ig-txt { color: var(--ink); }

.ig-peek-mark {
  position: relative; flex: 0 0 auto;
  width: 30px; height: 30px; display: grid; place-items: center;
  opacity: .8;
}
.ig-peek-mark svg { width: 100%; height: 100%; display: block; }
/* The unread mark. A dot rather than a count: the count is in the strip you
   are already reading, so a number here would be saying it twice. */
.ig-dot {
  position: absolute; top: -1px; right: -1px;
  width: 10px; height: 10px; border-radius: 50%;
  background: var(--accent); border: 2px solid #FBFAFF;
}
.ig-dot[hidden] { display: none; }

/* ⚠️ WHEN IT IS YOUR MOVE THE CHAT RECEDES AND THE BOARD LEADS. It does not
   hide and it does not move — nothing is concealed, so nothing can be lost by
   not reading it. It simply stops competing with the move you owe. */
#ig-peek.myturn { opacity: .62; }
#ig-peek.myturn .ig-peek-btn { box-shadow: none; }
/* The arrival highlight. ⚠️ games.js suppresses this entirely while it is your
   move — two attention cues firing at once is how somebody misses the one that
   matters. */
#ig-peek.flash .ig-peek-btn {
  border-color: var(--accent);
  box-shadow: 0 6px 18px rgba(63, 48, 130, 0.24), 0 0 0 3px var(--accent-dim);
}

/* ---- the dock --------------------------------------------------------------
   ⚠️ z-index 87: OVER the turn card (86) and under #end-chat / #emote-sheet
   (88). That is deliberate rather than incidental — the turn card is a
   full-screen inset:0 veil held for up to 1.9s, and it is an announcement
   ABOUT THE BOARD. Veiling the board while somebody types is right; veiling
   the composer they are typing into is not. The card is pointer-events:none,
   so nothing is stolen either way.

   ⚠️ AND IT RIDES THE KEYBOARD. `bottom` carries --kb, which app.js §20a
   measures with visualViewport and publishes — so the composer sits ON TOP of
   the keyboard rather than under it. No scrollIntoView and no shoved page:
   that is the bug §20a exists to have already fixed, and this reuses it rather
   than reinventing it. */
#ig-dock {
  position: fixed; left: 0; right: 0; z-index: 87;
  bottom: calc(var(--emote-dock, 0px) + var(--kb, 0px));
  /* ⚠️ THE SLIDE IS ON .ig-card, NOT ON THIS ELEMENT — the same split #end-chat
     and .ec-card already keep, and here it is load-bearing rather than stylistic.
     syncIgLane() publishes `innerHeight - this.getBoundingClientRect().top`, and
     a rect INCLUDES the element's own transform: with the slide here, every
     measurement taken during the 220ms open was 12px short, so #game-layer's
     padding came out 165px against a 177px dock and the bottom of the board sat
     behind it. It passed at 390x844 and failed at 360x740 purely on whether the
     fetch outran the animation — a race, and it read as a layout bug.
     A transform on the CHILD does not move this box, so the measurement is
     exact from the first frame. */
  opacity: 0;
  transition: opacity .18s ease, bottom .18s ease;
}
#ig-dock.up { opacity: 1; }
#ig-dock[hidden] { display: none; }
.ig-card {
  display: flex; flex-direction: column;
  transform: translateY(12px);
  transition: transform .22s cubic-bezier(.22,.61,.36,1);
  /* ⚠️ Capped in vh BEFORE px — the discipline the emote card learned at
     360x740 — and then capped a third time against the keyboard, so dock plus
     keyboard can never add up to more than the screen and eat the .g-head
     that has the clock in it. */
  max-height: min(280px, 34vh, calc(100dvh - var(--kb, 0px) - 132px));
  background: #FBFAFF;
  border-radius: 22px 22px 0 0;
  border-top: 1px solid var(--card-brd);
  box-shadow: 0 -10px 30px rgba(40, 30, 80, 0.28);
  padding: 6px 14px calc(10px + env(safe-area-inset-bottom));
}
/* While the keyboard is up the safe-area padding is doubled-counted by the
   keyboard itself, so give it back. */
body.kb-open .ig-card { padding-bottom: 10px; }
.ig-head { display: flex; align-items: center; position: relative; min-height: 22px; }
.ig-grip {
  width: 42px; height: 4px; border-radius: 999px;
  background: var(--card-brd); margin: 5px auto 3px;
}
.ig-x {
  position: absolute; right: -4px; top: -2px;
  width: 30px; height: 30px; border-radius: 50%;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  color: var(--ink-soft); font-size: 13px; font-weight: 700;
}
/* ⚠️ THE WINDOW IS STATED IN WORDS, exactly as .ec-since states it. A chat
   that silently shows a slice of a conversation is the shape of the bug that
   felt like data loss on 2026-08-21. ⚠️ There is deliberately NO "open the
   whole conversation" link here — the end-game chat has one, but from inside a
   live match that link means quitting the match to read a message. It is on
   the result screen, one tap away, thirty seconds later. */
.ig-since {
  font-size: 10.5px; font-weight: 700; color: var(--muted);
  padding: 1px 2px 5px; margin: 0;
}
.ig-list {
  flex: 1; min-height: 64px; overflow-y: auto;
  display: flex; flex-direction: column;
  padding: 2px 2px 6px;
}
.ig-empty {
  margin: auto; text-align: center; font-size: 12.5px;
  font-weight: 700; color: var(--muted);
}
.ig-form { display: flex; gap: 9px; align-items: center; padding: 5px 0 2px; }
.ig-input { flex: 1; height: 42px; }

/* ⚠️ THE REACTION TOGGLE AND THE PREVIEW BOTH GET OUT OF THE WAY while the
   dock is up. .g-emote is fixed at z-index 89 — deliberately, so its own dock
   cannot bury it — which means it lands squarely on this dock's send button,
   the one control a chat cannot do without. The same fix body.ec-open already
   makes, one screen earlier. The peek is hidden because it would be stranded
   behind the dock (and behind the keyboard) saying what the dock is already
   saying in full. */
body.ig-open .g-emote { display: none; }
body.ig-open #ig-peek { display: none; }

#ig-dock.up .ig-card { transform: translateY(0); }

@media (prefers-reduced-motion: reduce) {
  #ig-dock, #ig-peek, .ig-peek-btn, .ig-card { transition: none; }
}

/* ---- Memory, solo and duel --------------------------------------------------
   R23 `memory-vertical`, 2026-08-24. Deniz: *"The memory game should have the
   cards laid more vertically."*

   ⚠️ WHAT WAS WRONG WAS NOT THE COLUMN COUNT, IT WAS THAT NOTHING OWNED THE
   BOARD'S SHAPE. `.mem-grid` was a `flex: 1` box filling the whole leftover
   column with `align-content: center` parking a squat strip of cards in the
   middle of it: at hard the container was 390x691 and the cards were 344x188,
   so 27% of the height was used and the board read as a wide strip on a screen
   2.16 times taller than it is wide.

   Now the wrap owns the space and the grid owns the shape:

     .mem-wrap   takes the leftover column and centres whatever is in it
     .mem-grid   `height: 100%` + `aspect-ratio` derived from --c and --r, so
                 the board is exactly as tall as the room allows and as wide as
                 that height and its shape imply
     .mem-card   fills its cell. It has NO aspect-ratio of its own any more —
                 the cell is shaped by the board, the board by --c and --r, and
                 one place decides.

   ⚠️ `max-width` is the only clamp and it degrades in the SAFE direction: a
   board wider than the phone (the duel's 4x4) has its width clamped while its
   height stays, so the cards get *more* portrait. There is no arrangement of
   --c and --r in which this scrolls, which is the property the old fixed-card
   layout could not offer and why `server/difficulty.js` used to hold rows at 6
   to keep the last one above the fold.

   3 / 4 is the card's shape when the room allows it: portrait enough to read as
   a card rather than a tile, not so tall that a 6-row board goes thin. */
.mem-wrap {
  flex: 1; min-height: 0;
  display: flex; align-items: center; justify-content: center;
  padding: 10px 20px;
}
.mem-grid {
  --c: 3; --r: 4;
  display: grid;
  grid-template-columns: repeat(var(--c), 1fr);
  grid-template-rows: repeat(var(--r), 1fr);
  /* One calc for all six boards, instead of a class per game and a table of
     pixel sizes: a wider board gets a tighter gap and smaller corners. */
  gap: calc(12px - var(--c) * 1px);
  height: 100%;
  aspect-ratio: calc(var(--c) * 3 / (var(--r) * 4));
  max-width: min(100%, 400px);
  margin: 0 auto;
}
.mem-card {
  position: relative;
  border-radius: calc(20px - var(--c) * 1.4px); border: none; padding: 0;
  background: transparent; perspective: 400px; transform-style: preserve-3d;
}
.mem-card .mf, .mem-card .mb {
  position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  display: flex; align-items: center; justify-content: center;
  border-radius: inherit;
  /* The face scales with the board rather than with a media query: 34px on a
     2-column board down to 22px on a 6-column one. */
  font-size: calc(40px - var(--c) * 3px);
  backface-visibility: hidden;
  transition: transform .35s ease;
}
.mem-card .mf {
  background: var(--accent); color: rgba(255, 255, 255, 0.75);
  font-size: calc((40px - var(--c) * 3px) * 0.62);
}
.mem-card .mb { background: #FFFFFF; transform: rotateY(180deg); }
.mem-card.open .mf, .mem-card.done .mf { transform: rotateY(-180deg); }
.mem-card.open .mb, .mem-card.done .mb { transform: rotateY(0); }
.mem-card.done .mb { background: #C6E9D3; }

.xox-status { text-align: center; font-size: 15px; font-weight: 700; color: var(--muted); padding: 10px; }
.xox-status.mine { color: var(--accent); }
.g-board {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
  width: min(78vw, 330px); margin: 8px auto;
}
.g-cell {
  aspect-ratio: 1; border-radius: 18px;
  background: rgba(255, 255, 255, 0.62); border: 1px solid var(--card-brd);
  font-size: 42px; font-weight: 800; color: var(--ink);
  transition: background .2s ease;
}
.g-cell:disabled { color: var(--ink); opacity: 1; }
.g-cell:not(.set):not(:disabled):active { background: var(--accent-dim); }
.g-cell.hit { background: rgba(183, 229, 200, 0.9); border-color: var(--good); }

/* ---- the turn ribbon -------------------------------------------------------
   `turn-cue`, 2026-08-25 (worklog `new-games-1` §1). Every duel already
   renders this line every frame (abStatus() or its own status string) and
   already toggled `.mine`'s TEXT colour — this is the same box, painted as a
   filled strip rather than just recoloured text, so it reads as the "thin
   strip under the header that changes text and colour" Deniz asked for in
   place of the blocking card in a one-tap duel. No new element, no new lane:
   this box was already reserved by every duel screen. Cannon Duel, Line
   Battle and Hedge Race already render through this exact component
   (abStatus()/`.duel-status`), so this one change reaches all ten turn-based
   duels without touching any of their own files. See turn-cue.md. */
.duel-status { text-align: center; font-size: 15px; font-weight: 700; color: var(--muted); padding: 10px;
  transition: background-color .25s ease, color .25s ease; }
.duel-status.mine { color: var(--accent); background: var(--accent-dim); }

/* Connect Four: tighter grid of round discs. */
.g-board.c4 {
  width: min(94vw, 380px);
  /* ⚠️ THE SEVEN COLUMNS WERE DECLARED AND THE SIX ROWS WERE NOT, AND ON
     WEBVIEW 83 THAT IS THE DIFFERENCE BETWEEN A BOARD THAT CAN BE INSPECTED
     AND ONE THAT CANNOT. 42 cells in 7 columns make 6 rows by IMPLICIT track
     generation, and Chromium 83 resolves `grid-template-rows` to `none` for
     implicit tracks where a modern Chrome hands back the six used sizes. So
     `c4-check` read "6 rows (1)" on 83 and the ENGINE=83 triage recorded it as
     "the board does not exist on 83" — ⚠️ it does: c4-board.png from that very
     run is a correct 7x6 board with the discs in the right holes.
     Six rows is the GAME, not an accident of how many cells were appended, so
     it is written down. `auto`, which is what implicit rows already were, so
     nothing moves on any engine. 2026-09-11. */
  grid-template-rows: repeat(6, auto);
  gap: 5px; padding: 8px; border-radius: 18px;
  background: rgba(124, 92, 224, 0.14);
}
.g-board.c4 .g-cell {
  aspect-ratio: 1; border-radius: 50%; font-size: 0;
  background: rgba(255, 255, 255, 0.78); border: none;
  display: flex; align-items: center; justify-content: center;
}
.g-board.c4 .g-cell.hit { background: rgba(183, 229, 200, 0.95); }
.disc { width: 78%; height: 78%; border-radius: 50%; display: block; }
.disc.x { background: radial-gradient(circle at 35% 30%, #FF8FA8, #E0405F); }
.disc.o { background: radial-gradient(circle at 35% 30%, #FBE28A, #E7B93E); }
.g-board.c4 .g-cell.drop .disc { animation: dropIn .28s cubic-bezier(.4,1.4,.5,1) both; }
@keyframes dropIn { 0% { transform: translateY(-120px); } 100% { transform: translateY(0); } }

/* Star Hunt (whack-a-mole) */
.star-grid {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
  width: min(80vw, 340px); margin: 14px auto; flex: 1; align-content: center;
}
.star-cell {
  aspect-ratio: 1; border-radius: 22px; font-size: 34px;
  background: rgba(255, 255, 255, 0.5); border: 1px solid var(--card-brd);
  /* ⚠️ NO TRANSITION ON `background` — R20.5, 2026-08-23. It used to be
     `transform .12s ease, background .2s ease`, and that 200ms did two bad
     things at once. Going out, it left the VACATED cell glowing violet for a
     fifth of a second after the star had gone: a ghost that looks exactly like
     a star fading away and cannot be clicked, which is what Deniz was aiming
     at. Coming in, it eased the tint up over 200ms of a star that only lives
     400ms at hard. The star fades on its own now (.fading below); the cell
     underneath it snaps. */
  transition: transform .12s ease;
}
.star-cell.lit { background: rgba(124, 92, 224, 0.18); animation: starPulse .5s ease-in-out infinite alternate; }
/* The star's last third. `--star-fade` is written by startStarHunt() from the
   difficulty plan, so easy fades for 320ms and hard for 140ms — and the cell
   stays catchable for every frame of it. Must stay AFTER .lit: same
   specificity, so it is source order that stops the pulse. */
.star-cell.fading { animation: starFade var(--star-fade, 300ms) linear forwards; }
@keyframes starFade {
  from { opacity: 1; transform: scale(1); }
  to { opacity: 0; transform: scale(.82); }
}
.star-cell.pop { transform: scale(.86); background: rgba(183, 229, 200, 0.7); }
@keyframes starPulse { from { transform: scale(1); } to { transform: scale(1.06); } }

/* Sequence (Simon-says) */
.seq-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 22px; }
.seq-pads {
  /* grid-auto-rows: 1fr — item 15a's sweep. See .su-grid and
     scripts/checks/grid-guard.js. Not broken today (a pad holds nothing), but
     a definite height with auto rows is the shape of the bug. */
  display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px;
  grid-auto-rows: 1fr;
  width: min(74vw, 300px); aspect-ratio: 1;
}
/* ⚠️ HARD ONLY, AND A MODIFIER RATHER THAN AN EDIT TO THE RULE ABOVE. The
   sequence game is a 3x3 at hard (2026-08-24); easy and normal keep the 2x2
   they have always had, so changing the base rule would have resized two
   levels nobody asked about. The gap and the radius come down with the column
   count — nine 26px-radius pads at a 14px gap in a 300px box read as one
   quilt rather than as nine buttons. At 300px wide these are ~93px each,
   which is still more than double the 44px tap target. */
.seq-pads.g3 { grid-template-columns: repeat(3, 1fr); gap: 10px; }
.seq-pads.g3 .seq-pad { border-radius: 18px; }
.seq-pad {
  border-radius: 26px; border: none; background: var(--pad);
  opacity: .5; transition: opacity .12s ease, transform .12s ease;
  box-shadow: 0 6px 18px rgba(63, 48, 130, 0.18);
}
.seq-pad.lit { opacity: 1; transform: scale(1.05); box-shadow: 0 0 28px var(--pad); }
.seq-status { font-size: 16px; font-weight: 700; color: var(--ink-soft); }

/* ---- Rock · Paper · Scissors ------------------------------------------------
   Their unknowns along the top, the arena in the middle, your three hands
   waiting faintly along the bottom. Nothing is in a circle: the emoji are the
   whole picture, and a ring around them just made them look like buttons in a
   settings screen. */
.rps-arena {
  flex: 1; display: flex; flex-direction: column;
  padding: 4px 0; min-height: 0;
}
.rps-row {
  display: flex; justify-content: center; gap: 26px;
  padding: 6px 20px;
}
/* Their unknowns stay at the top, your hands sit at the bottom of the screen
   where your thumb already is, and the arena floats between them — the auto
   margins split whatever height is left over. */
.rps-row.mine { padding-bottom: 10px; }
.rps-row .rps-q, .rps-row .rps-pick {
  font-size: 44px; line-height: 1;
  background: none; border: none; padding: 0;
  opacity: .32; filter: saturate(.55);
  transition: opacity .25s ease, transform .18s ease, filter .25s ease;
}
.rps-row.them .rps-q { font-size: 36px; }
.rps-row .rps-pick:not(:disabled):active { transform: scale(.86); }
.rps-row .rps-pick:not(:disabled) { opacity: .5; }
/* The one you chose has left the row — it is up in the arena now. */
.rps-row .gone { opacity: 0; transform: translateY(-14px) scale(.6); }

/* ⚠️ SHRINKABLE, and it has to be. `flex: 0 0 auto; height: 230px` made this a
   block that could not give an inch, and `margin: auto 0` then resolves a
   shortfall by pushing the two hand rows OUT OF THE ARENA — up past the score
   and down off the bottom — without the layer's scrollHeight growing, so
   nothing scrolls and nothing reports a problem. Measured at 360x740 with the
   reaction dock open: the row of hands you play with ended up 25% underneath
   the panel, on a screen where the panel is correctly reserving its space.
   A shrinkable basis with a floor gives the arena somewhere to take the
   shortfall from, and leaves the 230px look untouched whenever there is room.
   Nothing about this is specific to the panel: RPS on any short phone was
   pushing its own controls off the bottom edge. */
/* ⚠️ The result screen replaces a 40px hint line with a payout chip and two
   buttons — about 150px more — so the arena has that much less to work with,
   and with the reaction dock open as well it ran out: the winning hand was
   drawn straight over the payout chip and the greyed hands over "play again".
   Found in a screenshot; nothing could have asserted it, because every button
   was present, the right size and perfectly tappable.
   The two rows are what give way, and they should: by then the opponent's row
   of question marks has been answered and your own row is three DISABLED
   buttons. Neither is doing anything except taking 116px away from the picture
   the screen exists to show. */
.rps-arena.over .rps-row { display: none; }
.rps-mid {
  flex: 0 1 230px; min-height: 96px; margin: auto 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 4px; position: relative;
}
.rps-hand {
  font-size: 62px; line-height: 1;
  filter: drop-shadow(0 4px 10px rgba(63, 48, 130, .22));
  transition: transform .2s ease;
}
/* Facing each other: theirs comes from the far side, so it is mirrored. */
.rps-hand.top { transform: scaleX(-1); }
.rps-hand.empty { font-size: 30px; opacity: .22; }
.rps-vs {
  font-size: 13px; font-weight: 800; color: var(--dim);
  text-transform: uppercase; letter-spacing: .12em;
  transition: opacity .2s ease;
}
.rps-vs.hush { opacity: 0; }

/* The clash. The winner travels into the loser and does what its shape does:
   the rock lands on top, the scissors snip, the paper swells over the rock. */
.rps-hand.win { z-index: 2; }
.rps-hand.lose { z-index: 1; animation: rpsCrush .7s ease both .18s; }
.rps-hand.tie { animation: rpsTie .6s ease both; }

.rps-hand.bot.win.rock { animation: rpsSlamUp .8s cubic-bezier(.4, 1.5, .5, 1) both; }
.rps-hand.top.win.rock { animation: rpsSlamDown .8s cubic-bezier(.4, 1.5, .5, 1) both; }
.rps-hand.bot.win.scissors { animation: rpsSnipUp .8s ease both; }
.rps-hand.top.win.scissors { animation: rpsSnipDown .8s ease both; }
.rps-hand.bot.win.paper { animation: rpsWrapUp .8s ease both; }
.rps-hand.top.win.paper { animation: rpsWrapDown .8s ease both; }

@keyframes rpsSlamUp {
  0% { transform: translateY(0); }
  30% { transform: translateY(14px) scale(.94); }
  55% { transform: translateY(-52px) scale(1.2) rotate(-14deg); }
  70% { transform: translateY(-42px) scale(1.24) rotate(4deg); }
  100% { transform: translateY(-40px) scale(1.14) rotate(0); }
}
@keyframes rpsSlamDown {
  0% { transform: scaleX(-1) translateY(0); }
  30% { transform: scaleX(-1) translateY(-14px) scale(.94); }
  55% { transform: scaleX(-1) translateY(52px) scale(1.2) rotate(14deg); }
  70% { transform: scaleX(-1) translateY(42px) scale(1.24) rotate(-4deg); }
  100% { transform: scaleX(-1) translateY(40px) scale(1.14) rotate(0); }
}
@keyframes rpsSnipUp {
  0% { transform: translateY(0) rotate(0); }
  40% { transform: translateY(-38px) rotate(-30deg) scale(1.1); }
  55% { transform: translateY(-38px) rotate(10deg) scale(1.12); }
  70% { transform: translateY(-38px) rotate(-22deg) scale(1.12); }
  100% { transform: translateY(-36px) rotate(-6deg) scale(1.08); }
}
@keyframes rpsSnipDown {
  0% { transform: scaleX(-1) translateY(0) rotate(0); }
  40% { transform: scaleX(-1) translateY(38px) rotate(30deg) scale(1.1); }
  55% { transform: scaleX(-1) translateY(38px) rotate(-10deg) scale(1.12); }
  70% { transform: scaleX(-1) translateY(38px) rotate(22deg) scale(1.12); }
  100% { transform: scaleX(-1) translateY(36px) rotate(6deg) scale(1.08); }
}
@keyframes rpsWrapUp {
  0% { transform: translateY(0) scale(1); }
  100% { transform: translateY(-40px) scale(1.7); }
}
@keyframes rpsWrapDown {
  0% { transform: scaleX(-1) translateY(0) scale(1); }
  100% { transform: scaleX(-1) translateY(40px) scale(1.7); }
}
@keyframes rpsCrush {
  0% { opacity: 1; }
  100% { transform: scale(.62) rotate(-12deg); opacity: .45; }
}
@keyframes rpsTie {
  0%, 100% { transform: translateY(0); }
  40% { transform: translateY(-10px) scale(1.06); }
}
/* The mirrored hand needs its own versions, or scaleX(-1) is thrown away. */
.rps-hand.top.lose { animation-name: rpsCrushFlip; }
.rps-hand.top.tie { animation-name: rpsTieFlip; }
@keyframes rpsCrushFlip {
  0% { transform: scaleX(-1); opacity: 1; }
  100% { transform: scaleX(-1) scale(.62) rotate(12deg); opacity: .45; }
}
@keyframes rpsTieFlip {
  0%, 100% { transform: scaleX(-1) translateY(0); }
  40% { transform: scaleX(-1) translateY(10px) scale(1.06); }
}

/* ---- shared duel palette ---------------------------------------------------
   The a/b games colour one player purple and the other pink, matching the
   🦊 / 🐧 tokens the scoreboards use. */
#game-layer { --pa: #7C5CE0; --pb: #D9558C; }

/* ---- the cold open (R11/G10d) ----------------------------------------------
   Full bleed, and the only screen in the app that is. There is nothing behind
   it to point at — nobody has an account yet — so the picture IS the screen
   rather than a decoration on one. Everywhere else onboarding is the spotlight.

   The art is given the room: roughly half the height, centred, on the same
   lavender the sign-in screen uses so the handover at the end is a fade of
   content rather than a change of place. */
#cold {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 130;
  display: flex; flex-direction: column; align-items: center;
  background: linear-gradient(180deg, #EFE9FE 0%, #DCD2FA 55%, #C9BAF4 100%);
  padding: calc(18px + env(safe-area-inset-top)) 22px
           calc(22px + env(safe-area-inset-bottom));
  animation: coldIn .3s ease-out both;
}
@keyframes coldIn { from { opacity: 0; } to { opacity: 1; } }

.cold-skip {
  align-self: flex-end;
  border: 0; background: none; cursor: pointer;
  font: inherit; font-size: 15px; color: var(--muted);
  padding: 8px 6px; min-height: 40px;
}

/* The picture. Sized off the viewport height rather than the width, because the
   thing it must never do is push the words off a short phone. */
/* ⚠️ auto top margin, and .cold-words gives up its flex-grow below. Between
   them they centre the picture and the copy as one block between the skip row
   and the footer. Before this the words were the growing element, which pinned
   the pair to the top and left roughly 400px of empty lavender under them on
   every one of the four scenes — visible in one glance at a screenshot and in
   nothing else. */
.cold-art {
  position: relative;
  flex: 0 0 auto; width: min(78vw, 44vh); aspect-ratio: 1 / 1;
  margin: auto 0 4vh;
}
.cold-art > svg { width: 100%; height: 100%; display: block; }
/* The real pet drawing, dropped into the third scene. Standing on the floor of
   the drawn room rather than floating in the middle of it. */
/* 50%, not 58%: at the larger size the cat's ears came through the apex of
   the drawn roof, which reads as a mistake rather than as a cat in a house.
   The scene is drawn around this number, so changing one means looking at the
   other. */
.cold-pet {
  position: absolute; left: 50%; bottom: 12%;
  transform: translateX(-50%);
  width: 50%; display: flex; justify-content: center;
}
.cold-pet svg { width: 100%; height: auto; display: block; }

.cold-words { flex: 0 0 auto; margin-bottom: auto; text-align: center; max-width: 400px; }
.cold-words h2 {
  margin: 0 0 10px; font-size: 25px; line-height: 1.2; color: var(--ink);
}
.cold-words p {
  margin: 0; font-size: 16px; line-height: 1.5; color: var(--ink-soft);
}

.cold-foot {
  flex: 0 0 auto; width: 100%; max-width: 400px;
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
}
.cold-dots { display: flex; gap: 8px; }
.cold-dots i {
  display: block; width: 8px; height: 8px; border-radius: 50%;
  background: rgba(75, 59, 143, .24);
  transition: width .22s cubic-bezier(.22,.61,.36,1), background .22s;
}
/* The current one stretches rather than growing — a row of dots where one is
   fatter reads as a mistake; one that has become a bar reads as progress. */
.cold-dots i.on { width: 22px; border-radius: 4px; background: var(--accent); }

.cold-next {
  border: 0; border-radius: 999px; cursor: pointer;
  background: var(--accent); color: var(--accent-ink);
  font: inherit; font-size: 16px; font-weight: 600;
  padding: 14px 28px; min-height: 48px;
  box-shadow: 0 6px 18px rgba(124, 92, 224, .34);
}
.cold-next:active { transform: scale(.97); }

@media (prefers-reduced-motion: reduce) {
  #cold { animation: none; }
  .cold-dots i { transition: none; }
}

/* ---- the tour spotlight (R11/G10b) ----------------------------------------
   The screen you are on, dimmed, with a hole cut over the thing being talked
   about. The dim IS the hole's box-shadow — see the note at the top of
   tour.js for why this is not an SVG mask. */
#tour-veil {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 120;
  /* No background of its own. Everything you see dimmed is the shadow thrown
     outward by #tour-hole, which means the lit area and the dark area can never
     disagree about where the edge is. */
  cursor: pointer;
  animation: tourIn .22s ease-out both;
}
@keyframes tourIn { from { opacity: 0; } to { opacity: 1; } }

#tour-hole {
  position: absolute;
  box-shadow: 0 0 0 9999px rgba(24, 18, 48, .72);
  /* A hairline of light around the cut-out, so the thing being pointed at
     reads as lifted rather than as a gap in a sheet. */
  outline: 2px solid rgba(255, 255, 255, .55);
  outline-offset: 0;
  transition: top .24s cubic-bezier(.22,.61,.36,1), left .24s cubic-bezier(.22,.61,.36,1),
              width .24s cubic-bezier(.22,.61,.36,1), height .24s cubic-bezier(.22,.61,.36,1);
  pointer-events: none;
}
/* A step with nothing to point at still has to dim the screen — so the hole
   shrinks to a zero-size point rather than disappearing. It must keep being
   PAINTED: the dim is this element's box-shadow, so an unpainted hole is an
   undimmed screen. See the note in tour.js about why this is a class and not
   the hidden attribute. */
#tour-hole.no-target {
  top: 50%; left: 50%; width: 0; height: 0;
  outline: 0; border-radius: 0;
}

#tour-card {
  position: absolute; transform: translateX(-50%);
  width: min(88vw, 340px);
  background: var(--paper); color: var(--ink);
  border-radius: 20px; padding: 16px 18px 12px;
  box-shadow: 0 14px 40px rgba(24, 18, 48, .42);
  pointer-events: none;
}
#tour-card.centre { top: 50%; left: 50%; transform: translate(-50%, -50%); }
/* The little tail, so the card is visibly attached to the hole rather than
   floating near it. Drawn with a rotated square, which cannot collide with
   anything the way a shared SVG marker id could. */
#tour-card.below::before, #tour-card.above::after {
  content: ''; position: absolute; left: 50%; margin-left: -8px;
  width: 16px; height: 16px; background: var(--paper); transform: rotate(45deg);
}
#tour-card.below::before { top: -7px; }
#tour-card.above::after { bottom: -7px; }

.tour-title {
  margin: 0 0 6px; font-size: 18px; font-weight: 700; color: var(--ink);
}
.tour-body { margin: 0; font-size: 15px; line-height: 1.45; color: var(--muted); }

.tour-foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; margin-top: 14px;
}
/* Progress as ink dots that fill in, not "3 / 5" in a chip. */
.tour-dots { display: flex; gap: 7px; align-items: center; }
.tour-dots i {
  display: block; width: 7px; height: 7px; border-radius: 50%;
  background: var(--faint); transition: transform .2s, background .2s;
}
.tour-dots i.done { background: var(--accent-dim); }
.tour-dots i.on { background: var(--accent); transform: scale(1.5); }

.tour-next {
  pointer-events: auto;
  border: 0; background: var(--accent); color: var(--accent-ink);
  width: 44px; height: 44px; border-radius: 50%;
  font-size: 19px; line-height: 1; cursor: pointer;
  box-shadow: 0 4px 12px rgba(124, 92, 224, .38);
}
.tour-next:active { transform: scale(.94); }

.tour-skip {
  position: absolute; top: max(12px, env(safe-area-inset-top)); right: 14px;
  pointer-events: auto;
  width: 40px; height: 40px; border-radius: 50%; border: 0; cursor: pointer;
  background: rgba(255, 255, 255, .16); color: #FFFFFF; font-size: 17px;
}

/* The "show me around" mark in a page's top bar. Drawn (Tour.MARK), never a
   text "?" in a pill. */
/* ⚠️ The negative block margin is load-bearing. A flex line is as tall as its
   tallest item, and at 34px this mark was taller than everything else in a
   .topbar — so every page carrying one grew by 8px and the adopt screen's
   button slid under the tab bar (adopt-ui: "button ends 764, nav starts 756").
   The margin gives that 8px back to the layout while the button keeps its full
   34px of tap target, which onboarding-ui-check asserts separately. */
.tour-ask {
  flex: 0 0 auto; width: 34px; height: 34px; padding: 0;
  /* ⚠️ `margin-block` IS CHROME 87. The bug described just above came BACK on
     WebView 83 because the fix for it was written in a property 83 has never
     heard of — the declaration is dropped, the mark is 34px on a 24px line
     again, and every topbar grows the same 8px. Long-hand, so the fix reaches
     the engine it was written for. 2026-09-11, ENGINE=83. */
  margin-top: -4px; margin-bottom: -4px;
  border: 0; background: none; cursor: pointer; color: var(--accent);
  -webkit-tap-highlight-color: transparent;
}
.tour-ask svg { width: 100%; height: 100%; display: block; }
.tour-ask:active { transform: scale(.92); }

@media (prefers-reduced-motion: reduce) {
  #tour-veil { animation: none; }
  #tour-hole { transition: none; }
  .tour-dots i { transition: none; }
}

/* ---- Doodle Duel ----------------------------------------------------------
   Ink on paper, not a toolbar. The controls are drawn objects — pots you tip
   over, nibs whose size IS the line they make, an eraser — because a row of
   coloured pills with "thin / medium / thick" beside them is the settings-form
   shape WORKING-RULES.md §4.4 exists to keep out of this app.

   The paper borrows the diary's warm --paper and its rule colours on purpose:
   the two surfaces in this app that are meant to read as paper should be the
   same paper. --dd-line is local because the diary's --line is declared on the
   diary, not on :root. */
/* ⚠️ justify-content: center, and it is not a detail. Stacked from the top,
   the paper and its ink pots filled 550px of an 844px screen and left a third
   of the phone as empty lavender under them — which the screenshot showed and
   no assertion would ever have asked about. */
.dd-page {
  --dd-line: 26px;
  flex: 1; display: flex; flex-direction: column; align-items: center;
  justify-content: center;
  gap: 12px; padding: 4px 16px calc(18px + env(safe-area-inset-bottom));
  min-height: 0;
}

/* Handwriting, with a stack that degrades to something still not a UI font. */
.dd-hand {
  margin: 0; text-align: center;
  font-family: 'Segoe Script', 'Bradley Hand', 'Snell Roundhand', cursive;
  font-size: 19px; color: var(--pen); letter-spacing: .2px;
}
.dd-lead { font-size: 17px; }
.dd-lead em { font-style: normal; font-weight: 700; color: var(--ink); }
.dd-meta { font-size: 13px; color: rgba(255, 255, 255, .82); white-space: nowrap; }
.dd-meta b { font-variant-numeric: tabular-nums; }

/* The sheet. Ruled, with a margin down the left the way a school pad is —
   drawn in the background rather than as elements, so the canvas can sit on top
   of it without anything to lay out. */
.dd-paper {
  position: relative;
  width: min(100%, 62vh); aspect-ratio: 1 / 1;
  border-radius: 10px 10px 14px 14px;
  background:
    linear-gradient(90deg, transparent 0 30px, var(--paper-margin) 30px 32px, transparent 32px),
    repeating-linear-gradient(180deg,
      transparent 0 calc(var(--dd-line) - 1px),
      var(--paper-rule) calc(var(--dd-line) - 1px) var(--dd-line)),
    var(--paper);
  box-shadow: 0 10px 26px rgba(43, 35, 80, .28), 0 1px 0 rgba(255, 255, 255, .7) inset;
  overflow: hidden;
  touch-action: none;
}
.doodle-canvas {
  position: absolute; top: 0; right: 0; bottom: 0; left: 0; margin: auto;
  display: block; touch-action: none;
}
.doodle-canvas.drawable { cursor: crosshair; }

/* ---- the drawer's things ---- */
.dd-tools {
  display: flex; align-items: center; justify-content: center;
  gap: 14px; flex-wrap: wrap; width: 100%;
}
.dd-tools.full { opacity: .45; pointer-events: none; }
.dd-pots, .dd-nibs { display: flex; align-items: flex-end; gap: 2px; }
.dd-pot, .dd-nib, .dd-erase {
  background: none; border: 0; padding: 0; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: transform .16s cubic-bezier(.22,.61,.36,1);
}
/* ⚠️ 44px so the tap target is a real one. The pots overlap slightly by
   design — a shelf of bottles, not a row of swatches — which is why the
   selected one is lifted rather than outlined. */
.dd-pot { width: 46px; height: 46px; margin-right: -6px; }
.dd-pot svg, .dd-nib svg, .dd-erase svg { width: 100%; height: 100%; display: block; }
.dd-pot.on { transform: translateY(-7px); }
.dd-nib { width: 44px; height: 44px; }
.dd-nib.on { transform: translateY(-5px); }
.dd-erase { width: 44px; height: 44px; margin-left: 6px; }
.dd-erase:active { transform: rotate(-8deg) scale(.94); }
.dd-full {
  margin: 0; font-size: 13px; color: #FFFFFF; opacity: .9;
  font-family: 'Segoe Script', 'Bradley Hand', cursive;
}

/* ---- the guesser's things ---- */
/* Blanks, one per letter, grouped into words. */
.dd-blanks { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; }
.dd-word { display: flex; gap: 5px; }
.dd-word i {
  display: block; width: 17px; height: 3px; border-radius: 2px;
  background: rgba(255, 255, 255, .92);
  box-shadow: 0 1px 2px rgba(43, 35, 80, .22);
}

/* An input that is a ruled line on the page, not a field in a card. */
.dd-line {
  border: 0; border-bottom: 2px solid var(--paper-rule);
  background: transparent; outline: none;
  font: inherit; font-size: 18px; color: #FFFFFF;
  padding: 6px 4px; text-align: center;
}
.dd-line::placeholder {
  color: rgba(255, 255, 255, .55);
  font-family: 'Segoe Script', 'Bradley Hand', cursive;
}
.dd-line:focus { border-bottom-color: #FFFFFF; }
.dd-guessline { width: min(100%, 320px); }

/* Wrong guesses, crossed out the way you would cross them out on the page.
   Newest last, older ones fading up the list. */
.dd-misses {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: 6px 12px; justify-content: center;
  max-height: 62px; overflow: hidden;
}
.dd-misses li {
  font-family: 'Segoe Script', 'Bradley Hand', cursive;
  font-size: 15px; color: rgba(255, 255, 255, .62);
  text-decoration: line-through; text-decoration-thickness: 1.5px;
}
.dd-misses li:last-child { color: rgba(255, 255, 255, .92); }

/* ---- choosing a word ---- */
.dd-page-pick { justify-content: center; gap: 18px; }
.dd-slips { display: flex; flex-direction: column; gap: 12px; width: min(100%, 330px); }
.dd-slip {
  position: relative; background: none; border: 0; padding: 0; cursor: pointer;
  height: 78px; -webkit-tap-highlight-color: transparent;
  transition: transform .16s cubic-bezier(.22,.61,.36,1);
}
/* Each slip sits at its own slight angle, so three of them read as torn paper
   rather than as three list rows. */
.dd-slip:nth-child(1) { transform: rotate(-1.4deg); }
.dd-slip:nth-child(2) { transform: rotate(.8deg); }
.dd-slip:nth-child(3) { transform: rotate(-.5deg); }
.dd-slip:active { transform: scale(.97); }
.dd-slip svg {
  position: absolute; top: 0; right: 0; bottom: 0; left: 0; width: 100%; height: 100%;
  filter: drop-shadow(0 4px 10px rgba(43, 35, 80, .26));
}
/* Centred INSIDE the paper rather than laid out under it, and allowed to
   shrink — "gökkuşağı" is nine letters and it has to fit on the slip. */
.dd-slip-word {
  position: absolute; top: 0; right: 18px; bottom: 14px; left: 18px;
  display: flex; align-items: center; justify-content: center;
  font-family: 'Segoe Script', 'Bradley Hand', cursive;
  font-size: clamp(17px, 6.2vw, 24px); color: var(--pen);
  white-space: nowrap; overflow: hidden;
}
.dd-own { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.dd-own-say { font-size: 13px; color: rgba(255, 255, 255, .72); }
.dd-own .dd-line { width: min(100%, 260px); }
.dd-waiting { width: 74px; opacity: .8; animation: ddHover 2.2s ease-in-out infinite; }
.dd-waiting svg { width: 100%; height: auto; display: block; }
@keyframes ddHover {
  0%, 100% { transform: translateY(0) rotate(-6deg); }
  50% { transform: translateY(-10px) rotate(4deg); }
}

/* ---- the answer ---- */
/* Smaller on the result screen: the drawing is being looked back at rather
   than drawn on, and the answer under it is what the eye should land on. */
.dd-paper-end { width: min(78%, 44vh); }
.dd-page-end { justify-content: center; gap: 14px; }
.dd-verdict { font-size: 24px; color: #FFFFFF; }
.dd-answer {
  margin: 0; font-size: 17px; color: rgba(255, 255, 255, .84);
  font-family: 'Segoe Script', 'Bradley Hand', cursive;
}
/* The right answer, circled. A drawn ellipse rather than a border, because a
   rectangle around a word is a badge and this is meant to be a pen mark. */
.dd-answer em {
  position: relative; font-style: normal; font-weight: 700;
  color: #FFFFFF; padding: 2px 12px;
}
.dd-answer em::after {
  content: ''; position: absolute; top: -6px; right: -4px; bottom: -4px; left: -4px;
  border: 2.5px solid var(--paper-margin); border-radius: 48% 52% 46% 54%;
  transform: rotate(-2.5deg);
}
.dd-tally { display: flex; gap: 22px; font-size: 14px; color: rgba(255, 255, 255, .7); }
.dd-tally .me { color: #FFFFFF; }
.dd-tally b { font-size: 20px; margin-left: 6px; font-variant-numeric: tabular-nums; }

@media (prefers-reduced-motion: reduce) {
  .dd-waiting { animation: none; }
  .dd-pot, .dd-nib, .dd-slip { transition: none; }
}

/* ---- the duel token (R21) ---------------------------------------------------
   The animal that stands for a person on an a/b board. It replaced two emoji
   keyed on the ROLE (so they swapped on a rematch); the drawing comes from
   petSVG() and the choice from the You page.

   ⚠️ ONE RULE HERE AND EVERYTHING DEPENDS ON IT: the token is sized in `em`,
   never in px. It lands in THIRTEEN places whose font sizes run from 11.5px
   (Hot Potato's caption, the score row) to 26px (the tug rope), and every one
   of them used to size its emoji exactly this way. A px size here would need
   thirteen overrides and would still be wrong on the next one.

   1.35em, not 1em: petSVG() draws inside a 200x200 box whose animal occupies
   about 70% of the height, so a 1em box would put a visibly smaller mark where
   an emoji used to be. 1.35em lands the animal at roughly an emoji's optical
   size. ⚠️ Worth an eye on the Snakes & Ladders board — ten columns, the
   smallest the token ever gets — before this number is trusted. */
.ab-tok {
  display: inline-block; width: 1.35em; height: 1.35em;
  vertical-align: -.3em; line-height: 0;
  /* It is a picture standing in for a glyph, so it must not be selectable or
     draggable the way an <img> would be. */
  flex: none; pointer-events: none;
}
.ab-tok svg { width: 100%; height: 100%; display: block; }

/* ---- the score ------------------------------------------------------------
   It used to be 13px of grey inside a pill, in the corner of the header, in
   every game. You cannot see what you are winning by out of the corner of your
   eye, which is the only time anyone looks at a score mid-game. So: no pill,
   no corner, and big enough to read while looking at something else. */
.score-hud {
  display: flex; align-items: flex-start; justify-content: center;
  gap: 18px; padding: 0 14px 2px; flex-wrap: nowrap;
}
.score-col {
  display: flex; flex-direction: column; align-items: center; gap: 0;
  min-width: 0; max-width: 46vw;
}
.score-who {
  font-size: 11.5px; font-weight: 800; color: var(--muted);
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.score-big {
  font-size: 34px; font-weight: 900; line-height: 1.02; color: var(--ink);
  font-variant-numeric: tabular-nums; letter-spacing: -0.02em;
  /* Tabular figures plus a floor on the width: a score rolling from 9 to 10
     must not shove the other player's number sideways. */
  min-width: 1.1em; text-align: center;
  transition: color .18s ease;
}
.score-cap {
  font-size: 10.5px; font-weight: 800; letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted); margin-top: -1px;
}
.score-col.on .score-big, .score-col.on .score-who { color: var(--accent); }
/* The moment it goes up: swell, glow, settle. */
.score-big.bump { animation: scoreBump .42s cubic-bezier(.2, 1.5, .4, 1); }
@keyframes scoreBump {
  0% { transform: scale(1); }
  35% { transform: scale(1.34); color: var(--accent); text-shadow: 0 0 14px var(--accent); }
  100% { transform: scale(1); }
}
/* And what it went up by, rising out of the number. */
.score-col { position: relative; }
.score-pop {
  position: absolute; left: 50%; top: 10px; transform: translateX(-50%);
  font-size: 15px; font-weight: 900; color: var(--accent);
  pointer-events: none; white-space: nowrap;
  animation: scorePop .85s ease-out forwards;
}
@keyframes scorePop {
  0% { opacity: 0; transform: translate(-50%, 6px) scale(.7); }
  22% { opacity: 1; transform: translate(-50%, -6px) scale(1.1); }
  100% { opacity: 0; transform: translate(-50%, -34px) scale(1); }
}
/* The clock and other running commentary stay small — they are not the score. */
.score-side {
  font-size: 13px; font-weight: 800; color: var(--ink-soft);
  align-self: center;
}
@media (prefers-reduced-motion: reduce) {
  .score-big.bump { animation: none; }
  .score-pop { animation: scorePopStill .85s ease-out forwards; }
  @keyframes scorePopStill { 0% { opacity: 1; } 100% { opacity: 0; } }
}

/* Memory Duel: 4x4 / 5x6 / 6x6, plus a tint for who won each pair.
   ⚠️ `--c` and `--r` are set inline and BOTH come from the server's frame —
   the columns it named and the deck it dealt, divided. The server owns the
   board size; this file owns nothing but the skin.
   ⚠️ There is deliberately no size override left here. The duel used to carry
   its own max-width, gap, card aspect-ratio and two font sizes, all of which
   are now derived from --c by the shared rules above — one board, one set of
   arithmetic, and a level added to difficulty.js needs no CSS at all. */
.mem-grid.duo { max-width: min(100%, 380px); }
/* Solid faces: a translucent tint over the room gradient washed the emoji out. */
.mem-card.own-a .mb { background: #DCD2F7; }
.mem-card.own-b .mb { background: #F6D3E2; }
/* Cards you may not tap are marked with a class, never `disabled` — the browser
   greys a disabled button's contents, which faded the emoji faces. */
.mem-card.dead { cursor: default; }

/* Dots & Boxes: the grid tracks are written inline (they depend on board size). */
.db-grid {
  display: grid; place-content: stretch;
  width: min(86vw, 340px); aspect-ratio: 1;
  margin: 6px auto; flex: 0 0 auto;
}
.db-dot { display: block; margin: auto; width: 7px; height: 7px; border-radius: 50%; background: rgba(43, 35, 80, 0.5); }
.db-edge {
  border: none; background: transparent; padding: 0;
  border-radius: 4px; margin: 2px;
}
.db-edge::after {
  content: ''; display: block; border-radius: 4px;
  background: rgba(255, 255, 255, 0.5);
  width: 100%; height: 100%;
}
.db-edge.h::after { height: 4px; margin-top: 5px; }
.db-edge.v::after { width: 4px; margin-left: 5px; }
.db-edge:disabled::after { opacity: 0; }
.db-edge:active::after { background: var(--accent); }
.db-line { display: block; border-radius: 4px; margin: 2px; }
.db-line.h { height: 5px; align-self: center; }
.db-line.v { width: 5px; justify-self: center; height: 100%; }
.db-line.own-a { background: var(--pa); }
.db-line.own-b { background: var(--pb); }
.db-line.fresh { animation: popIn .22s ease both; }
.db-box {
  display: flex; align-items: center; justify-content: center;
  font-size: 15px; font-style: normal; border-radius: 6px;
}
.db-box.own-a { background: rgba(124, 92, 224, 0.24); animation: popIn .3s ease both; }
.db-box.own-b { background: rgba(217, 85, 140, 0.22); animation: popIn .3s ease both; }

/* Reversi: a felt-green 8x8 with dots marking the legal moves. */
.oth-board {
  /* grid-auto-rows: 1fr — item 15a's sweep. See .su-grid and
     scripts/checks/grid-guard.js. */
  display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px;
  grid-auto-rows: 1fr;
  width: min(92vw, 400px); aspect-ratio: 1; margin: 6px auto; flex: 0 0 auto;
  padding: 5px; border-radius: 12px;
  background: rgba(47, 159, 123, 0.5);
}
.oth-cell {
  position: relative; padding: 0; border: none;
  background: rgba(47, 159, 123, 0.35); border-radius: 4px;
  display: flex; align-items: center; justify-content: center;
}
.oth-cell.fresh { box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.8); }
.odisc {
  width: 78%; height: 78%; border-radius: 50%;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.odisc.a { background: radial-gradient(circle at 34% 28%, #6B5AA8, #2B2350); }
.odisc.b { background: radial-gradient(circle at 34% 28%, #FFFFFF, #D8D2EC); }
.odisc.turn { animation: popIn .25s ease both; }
.ohint {
  position: absolute; width: 28%; height: 28%; border-radius: 50%;
  background: rgba(255, 255, 255, 0.55);
}
.oth-cell:active .ohint { background: #fff; transform: scale(1.3); }

/* Snakes & Ladders: 10x10, numbered from the bottom-left and snaking upward.
   ⚠️ REDRAWN 2026-08-22, R19 item 15b. Deniz: "the board is convoluted, tidy it
   up a bit without taking away from the game." NOTHING WAS TAKEN AWAY — every
   snake, ladder and fork the server ships is still on it, in the same place.
   What went was duplication: twenty-six of the hundred cells carried a coloured
   ring or a white fill saying what the drawing over them already said. And the
   drawing was painted UNDERNEATH the cell fills, which is most of why it all
   looked washed out. */
.snl-board {
  display: grid; grid-template-columns: repeat(10, 1fr); gap: 1px;
  /* ⚠️ TEN EXPLICIT ROWS, AND THE BOARD WAS CROOKED WITHOUT THEM. Deniz,
     2026-08-26: "the tiles are taller on explosion squares, the grid streches
     at those squares and breaks the symmetry." There was no row rule here at
     all, so the rows were implicit and `auto`: each took its tallest in-flow
     child's height and THEN an equal share of the leftover. Ninety-six cells
     have nothing in flow — the number is position: absolute — and the four
     blast squares carried a ~26px marker, so four of the ten rows came out at
     roughly 51px against 24px. Measured at 390px wide; four double-height
     bands are plainly visible in scripts/checks/snl-board-390.png.
     ⚠️ AND IT PUT THE DRAWING ON THE WRONG SQUARES, which is the worse half
     and was not in the complaint. snlXY() and .snl-paint place the snakes, the
     ladders and both tokens in a UNIFORM 0-100 space, so against non-uniform
     rows a token stood beside the square it was standing on. Equal rows make
     snlXY() true again — nothing in games.js had to change for it.
     ⚠️ minmax(0, 1fr), NEVER a bare 1fr: a bare 1fr means minmax(auto, 1fr),
     and that `auto` floor is exactly the content minimum this defect is made
     of — a taller marker would stretch its row all over again.
     ⚠️ align-content: start IS THE WRONG FIX, and it is the one to reach for
     by habit because it is gotcha 66's remedy for the furniture shelf. It
     leaves rows at content size — 0px and 26px — so ninety-six cells collapse
     to nothing and the board stops filling its own square. */
  grid-template-rows: repeat(10, minmax(0, 1fr));
  /* 380px was a phone's number being used on a tablet as well; 94vw still wins
     on a phone, so nothing about the phone layout moves. */
  width: min(94vw, 420px); aspect-ratio: 1; margin: 4px auto 14px; flex: 0 0 auto;
  padding: 4px; border-radius: 14px;
  background: rgba(124, 92, 224, 0.26);
  box-shadow: 0 10px 28px rgba(43, 35, 80, 0.16),
              inset 0 0 0 1px rgba(255, 255, 255, 0.45);
  /* Tokens travel over the board and start just off its corner. */
  position: relative; overflow: visible;
}
/* The drawing and the tokens share the grid's box exactly — inset to the
   board's own padding — because the token positions are computed from square
   numbers in the same 0-100 space the SVG is drawn in. If these two boxes
   disagree, a token climbing a ladder walks beside it. */
.snl-tokens { position: absolute; top: 4px; right: 4px; bottom: 4px; left: 4px; pointer-events: none; }
/* ⚠️ z-index: 1 IS THE SINGLE BIGGEST THING THAT WAS WRONG WITH THIS BOARD.
   .snl-sq is position: relative and comes later in the DOM, so without it the
   CELLS paint over the SVG and every snake and ladder is seen through a 50%
   white veil. The layers, bottom to top: cells, the drawing, the numbers, the
   tokens. */
.snl-paint {
  /* ⚠️ THE SIZE IS WRITTEN OUT, AND `width: auto` WITH FOUR OFFSETS IS WHY.
     This is an <svg> — a REPLACED element — and stretching one with
     `left/right/top/bottom` and `auto` sizes is behaviour WebView 83 does not
     have. Measured on Chromium 83 vs Chrome 152, same DOM, board 366.6px wide:

         83 : svg 366.6 x 366.6   <- the whole padding box, `right: 4px` ignored
        152 : svg 358.6 x 358.6   <- the grid, inset to the board's padding

     So on the engine the store app ships in, the drawing was 8px wider and
     8px taller than the squares it is drawn against, and hanging 4px off the
     right edge. `.snl-tokens` is a DIV and stretches correctly on both, so the
     two boxes disagreed — which is precisely the failure the comment above
     describes: a token climbing a ladder walks beside it. (ENGINE=83,
     `snl-check`: "the drawing sits exactly on the grid, not on the padding".)

     `calc()` says the same thing in a form every engine resolves, and the
     right/bottom offsets stay only as documentation of the same inset. */
  position: absolute; top: 4px; right: 4px; bottom: 4px; left: 4px;
  width: calc(100% - 8px); height: calc(100% - 8px);
  pointer-events: none; overflow: visible; z-index: 1;
}
/* Ladders: two rails and their rungs, drawn end to end and wider at the foot,
   so the ladder itself says which end takes you up. */
.snl-rail { stroke: #B97B31; stroke-width: 1.05; fill: none; stroke-linecap: round; }
.snl-rung { stroke: #E0A85E; stroke-width: .85; fill: none; stroke-linecap: round; }
/* Snakes. Green, and not as a matter of taste: the risky fork road is #E0567F
   and the snakes used to be the SAME pink, so a hazard and a choice were the
   same line. The body is a filled outline rather than a stroke because a
   stroke cannot taper, and the taper is what makes it a snake. */
.snl-snake-body {
  fill: #45A97A; stroke: #1F6046; stroke-width: .32;
  stroke-linejoin: round; opacity: .96;
}
.snl-snake-scales {
  fill: none; stroke: #D8F2E2; stroke-width: .8; stroke-linecap: round;
  stroke-dasharray: .5 2.1; opacity: .75;
}
.snl-snake-head { fill: #2C8760; stroke: #1F6046; stroke-width: .32; }
.snl-snake-eye { fill: #FFF6E4; }
.snl-snake-pupil { fill: #17251E; }
.snl-snake-tongue { fill: none; stroke: #D93A5E; stroke-width: .58; stroke-linecap: round; }
/* ⚠️ .snl-road WAS HERE — two dashed curves out of every fork, painted so the
   gamble was visible from square one. Path choosing was removed on 2026-08-24
   and a blast square has no line to draw: it does not send YOU anywhere, and
   where it sends your partner is not known until it goes off. */

.snl-sq {
  position: relative; border-radius: 2px; font-style: normal;
  background: #FCFAFF;
  display: flex; align-items: center; justify-content: center;
  /* The same belt-and-braces pair .warm-grid/.warm-cell carries, for the same
     reason. min-height: 0 lets a cell be shorter than its content, which is
     the row rule's argument applied at the item; aspect-ratio is inert while
     that rule holds (a stretched grid item's height is definite and wins) and
     is what keeps a cell square if anybody ever deletes it. */
  min-height: 0; aspect-ratio: 1;
}
.snl-sq.alt { background: #EFE9FE; }
/* Above the drawing and haloed, so a number a snake runs across is still a
   number. It is the one thing on a cell a player actually reads. */
.snl-sq b {
  position: absolute; top: 1px; left: 3px; z-index: 2;
  font-size: 7.5px; font-weight: 700; color: #6F65A0;
  text-shadow: 0 0 2px #FFF, 0 0 2px #FFF, 0 0 3px #FFF;
}
/* ⚠️ THERE IS DELIBERATELY NO RULE for a snake's mouth, a ladder's foot or a
   landing square. Those three used to ring or fill twenty-six cells to repeat
   what the drawing says better — the snake's head is ON its square with eyes
   on it, and the ladder's foot is its wide end. A blast square keeps its mark
   because it is a RULE rather than a drawn path, and so does the square
   somebody has just landed on. */

/* ⚠️ WARM, WHERE EVERYTHING ELSE ON THIS BOARD IS COOL. The cells are white
   and lavender, the ladders yellow, the snakes green, the accent violet — so a
   hazard tinted violet like the fork was would be one more shade of the board
   furniture. A red square is the only thing here that reads as "careful" at a
   glance, which is the entire job: this has to be seen from square one, or it
   is a surprise rather than a thing you play around. */
.snl-sq.blast {
  box-shadow: inset 0 0 0 1.5px #E8617A;
  background: rgba(232, 97, 122, 0.20);
}
/* ⚠️ ABSOLUTE, SO THE STRETCH ABOVE IS CURED AND NOT MERELY ABSORBED. In flow
   this was the only thing on the whole board with a height: `height: 76%`
   resolved against the row that was being sized, which is circular, so it fell
   back to auto — and a square viewBox with a definite width then made the
   height equal the width. Out of flow the cell's content height is 0 like
   every other cell's, and inset: 12% reproduces the identical 76% box
   (12 + 76 + 12 = 100). The row rule alone would have hidden this; both. */
.snl-sq u.blast {
  text-decoration: none; font-style: normal; position: absolute; z-index: 2;
  top: 12%; right: 12%; bottom: 12%; left: 12%; display: block; opacity: .95;
}
.snl-sq u.blast svg { display: block; width: 100%; height: 100%; }
/* It goes off. Bigger than the cell on purpose — an explosion contained inside
   a 10% square is a flicker, not a bang. z-index clears the numbers. */
.snl-sq.blast.boom { background: rgba(232, 97, 122, 0.42); }
.snl-sq.blast.boom u.blast {
  animation: snlBoom .62s cubic-bezier(.2, .8, .3, 1) 1;
  transform-origin: 50% 50%; z-index: 4;
}
@keyframes snlBoom {
  0% { transform: scale(.6) rotate(-30deg); opacity: 1; }
  45% { transform: scale(2.5) rotate(10deg); opacity: 1; }
  100% { transform: scale(1) rotate(0); opacity: .95; }
}
.snl-sq.fresh { box-shadow: inset 0 0 0 2px var(--accent); animation: glowPulse 1.4s ease-in-out 2; }
/* The token that is being thrown. ⚠️ It keeps translateX(-50%) — `left` is the
   token's CENTRE, so a keyframe that forgets it slides the token half its own
   width sideways for the length of the animation, and back again at the end.
   The comment below the token rules says so; this is the rule that has to obey
   it. The flight itself is the ordinary `left`/`top` transition, slowed. */
.snl-tok.blown {
  animation: snlBlown .5s ease-in-out 2;
  transition: left .34s cubic-bezier(.5, -0.3, .5, 1.3),
              top .34s cubic-bezier(.5, -0.3, .5, 1.3);
  z-index: 5;
}
@keyframes snlBlown {
  0%, 100% { transform: translateX(-50%) rotate(0) scale(1); }
  25% { transform: translateX(-50%) rotate(-22deg) scale(1.25); }
  60% { transform: translateX(-50%) rotate(18deg) scale(1.1); }
}
/* JS sets left/top as percentages of the grid; the transition travels. `left`
   is the token's centre, hence the translate — and every keyframe below has to
   carry it, or the token jumps sideways for the length of the animation. */
.snl-tok {
  position: absolute; font-size: 15px; font-style: normal; line-height: 1;
  transform: translateX(-50%);
  transition: left .15s linear, top .15s linear;
  filter: drop-shadow(0 1px 1px rgba(43, 35, 80, .35));
  will-change: left, top;
  z-index: 4;
}
.snl-tok.hop { animation: tokHop .16s ease; }
/* A snake, a ladder or a chosen road is walked in stages along the drawn line,
   so each leg is a short glide rather than one long teleport. */
.snl-tok.travel {
  transition: left .13s linear, top .13s linear;
  filter: drop-shadow(0 2px 5px rgba(43, 35, 80, .45));
}
@keyframes tokHop {
  0%, 100% { transform: translateX(-50%) translateY(0) scale(1); }
  50% { transform: translateX(-50%) translateY(-7px) scale(1.15); }
}

/* ---- the throw -------------------------------------------------------------
   ⚠️ ONE DIE, 2026-08-26. Deniz: "the dice should not be both a different
   visual and a button to say roll, it should be just a dice, it should say on
   top of the dice either "You roll" or "X is rolling" with a notifying text,
   and if its their move the dice button will glow."

   FOUR elements used to draw one die and TWO of them were dice: a 58px violet
   felt tray, the 48px die that actually rolled on it, a 320x86 violet mat with
   a dashed inner edge, and a SECOND 62px die frozen on a five sitting on the
   mat. The die you watched and the die you tapped were different objects, in
   different places, at different sizes. The complaint is exactly right.

   There is one now — .snl-die inside #snl-roll, whose only jobs are to be the
   hit area and to carry the glow. Top to bottom: state line, die, result line.

   ⚠️ NOTHING HERE MAY CHANGE THE ORDERING. `settled = over && !animating` in
   games.js is what stops a match ending while the die is still in the air, and
   scripts/checks/snl-win-check.js films the screen every 60ms and asserts
   die-settles → token-arrives → result. A longer animation is precisely the
   change that breaks it.

   ⚠️ THE GLOW IS ON THE BUTTON'S ::before, NEVER ON THE DIE, and this is the
   trap in the whole rewrite. A rule like
       .snl-roll-btn:not(:disabled) .snl-die { animation: … }
   is (0,3,0) and would BEAT .snl-die.tumbling and .snl-die.landed at (0,2,0) —
   silently killing the throw and the landing bounce. Not theoretical:
   playSnlRoll re-enables the button WITHOUT a re-render, while `landed` is
   still on the die. The same argument deleted cubeNudge rather than moving it. */
.snl-roll {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  padding: 2px 18px 0; text-align: center;
  font-size: 13.5px; font-weight: 700; color: var(--ink-soft);
}
/* The notifying text. ⚠️ IT IS A SENTENCE ABOUT THE GAME, NOT A LABEL ON THE
   CONTROL — it says what the die is DOING, and it is above the die rather than
   printed on it. WORKING-RULES §4.4: a control wearing the name of the thing
   it does is the shape R23 already took off this button, and a caption under
   it would be the same mistake wearing a hat.
   The height is pinned so the die does not step up and down as the wording
   changes between the three states. */
.snl-state {
  min-height: 19px; line-height: 19px;
  font-size: 14.5px; font-weight: 800; color: var(--ink-soft);
}
.snl-roll.dim .snl-state { color: var(--muted); font-weight: 700; }
/* The result sentence. ⚠️ IT KEEPS #snl-text AND IT KEEPS EXISTING ONCE THE
   MATCH IS OVER — three harnesses read it (snl-check, snl-win-check and
   snl-art-check's reduced-motion tail), and it is the standing evidence for
   the most expensive bug this game has had: nothing is announced before it has
   been shown.
   ⚠️ TWO LINES OF ROOM ARE RESERVED. The die sits ABOVE this now, so a
   two-line BOOM sentence arriving after a one-line ordinary one cannot shove
   the die — but without the reservation it would still make the page under it
   jump from one roll to the next. */
.snl-said {
  max-width: 260px; min-height: 34px;
  font-size: 13px; font-weight: 600; line-height: 1.3;
}
.snl-roll.dim .snl-said { color: var(--muted); }

/* ⚠️ THE BUTTON HAS NO BUTTON IN IT. "it should be just a dice" — so no slab,
   no gradient, no dashed edge, no border and no background; #snl-roll is an
   invisible hit area with the die centred in it. What says it is live is the
   glow, which is the thing he asked for, and the press.

   ⚠️ 104x116 REPLACES A 320x86 ASSERTION, DELIBERATELY. snl-art-check.js
   encoded his R19 "bigger button etc." as w >= 280 && h >= 64, and one die
   cannot be 320px wide. That instruction was about a fat tap target, not about
   a rectangle: this clears the 48dp Android and 44pt Apple minimum in BOTH
   axes with room to spare, and the die inside it is 92px against the 48px one
   the eye was actually aiming at before. The assertion moved with it, and the
   reasoning is repeated in that file next to the number.

   ⚠️ THE GEOMETRY IS ARITHMETIC, not taste, because nobody could look at it
   while it was written. Content box 104x104 after the 12px bottom padding; the
   die is 92 and starts 6 down, so it occupies y 6..98. The shadow below sits
   at y 102..110 — clear of the die by 4px, which is what stops a dark ellipse
   being painted across the die's own bottom face. ::after is generated last,
   so it paints OVER the die; an overlap here would be a defect, not a shadow. */
.snl-roll-btn {
  position: relative; flex: 0 0 auto;
  display: flex; align-items: flex-start; justify-content: center;
  width: 104px; height: 116px; padding: 6px 0 12px;
  border: none; border-radius: 50%; background: none;
  -webkit-tap-highlight-color: transparent;
  transition: transform .1s ease, opacity .2s ease;
}
/* The glow: a halo BEHIND the die, centred on the die rather than on the
   button, because the button is taller than it is wide. Live is
   `:not(:disabled)`, which games.js already sets to exactly
   `myTurn && !animating` — so "it is your move" and "it glows" are one
   condition and no new state has to be tracked for it. */
.snl-roll-btn::before {
  content: ''; position: absolute; left: 50%; top: 52px;
  width: 132px; height: 132px; margin: -66px 0 0 -66px; border-radius: 50%;
  background: radial-gradient(circle at 50% 50%,
              rgba(255, 255, 255, 0.62) 0%, rgba(255, 255, 255, 0.22) 46%,
              rgba(255, 255, 255, 0) 72%);
  opacity: 0; pointer-events: none; transition: opacity .25s ease;
}
.snl-roll-btn:not(:disabled)::before {
  opacity: 1; animation: dieGlow 1.9s ease-in-out infinite;
}
@keyframes dieGlow {
  0%, 100% { transform: scale(.9); opacity: .55; }
  50% { transform: scale(1.14); opacity: 1; }
}
/* ⚠️ THE TRAY WENT BUT ITS SHADOW DID NOT, and that is not sentiment. The
   shrinking shadow is the whole of what sells the HEIGHT of a throw: without
   it a die that lifts 18px merely looks as though it got bigger. Same duration
   and easing as dieTumble, and both classes land in the same render, so it
   shrinks exactly as the die goes up instead of drifting out of step. */
.snl-roll-btn::after {
  content: ''; position: absolute; left: 50%; bottom: 6px;
  width: 42px; height: 8px; margin-left: -21px; border-radius: 50%;
  background: rgba(48, 30, 106, 0.30); pointer-events: none;
}
.snl-roll.rolling .snl-roll-btn::after {
  animation: dieShadow .44s cubic-bezier(.45, .05, .55, .95) infinite;
}
@keyframes dieShadow {
  0%, 100% { transform: scale(1); opacity: .34; }
  50% { transform: scale(.55); opacity: .12; }
}
.snl-roll-btn:active:not(:disabled) { transform: translateY(3px) scale(.96); }
/* Dimmed, never hidden. Down here the die is the only picture of the game's
   state there is, so on somebody else's throw it stays on the screen and
   simply stops glowing. */
.snl-roll-btn:disabled { opacity: .55; }

/* ⚠️ NOT A GLYPH ANY MORE (R23 · snl-dice). It was a 40px U+2680..2685, which
   is a thin outline drawn in `color` — the reason the tray had to be dark felt
   in the first place. The face is an SVG from game-svg.js now, so this is a
   BOX rather than a font size, and the die brings its own contrast: white
   paper, violet edge, dark pips, against #game-layer's violet gradient.
   ⚠️ 92px, up from 48. It is the whole control now, not a chip beside one. */
.snl-die {
  position: relative; display: inline-block; width: 92px; height: 92px;
  animation: popIn .3s ease both;
  filter: drop-shadow(0 6px 8px rgba(24, 14, 58, 0.42));
}
.snl-die svg { display: block; width: 100%; height: 100%; }
/* The die leaves under your thumb, rather than a network round-trip later.
   ⚠️ IT COMES BACK, AND IT MUST. The old flick threw the mat's second cube off
   the top to opacity 0 and left it there (`forwards`) — survivable only
   because another die was still on screen in the tray. With one die, a slow
   reply would leave this screen with no die on it at all.
   ⚠️ DECLARED BEFORE .tumbling AND .landed ON PURPOSE. All three are (0,2,0),
   so a tie is broken by source order and the throw itself has to win. */
.snl-die.tossing { animation: dieToss .26s ease-out; }
@keyframes dieToss {
  0% { transform: translateY(0) rotate(0) scale(1); }
  45% { transform: translateY(-14px) rotate(70deg) scale(1.06); }
  100% { transform: translateY(0) rotate(0) scale(1); }
}
/* Thrown, not spun in place: it lifts, turns over and comes back down, while
   games.js changes the face underneath it. */
.snl-die.tumbling { animation: dieTumble .44s cubic-bezier(.45, .05, .55, .95) infinite; }
@keyframes dieTumble {
  0% { transform: translateY(0) rotate(0) scale(1); }
  25% { transform: translateY(-13px) rotate(140deg) scale(1.14); }
  50% { transform: translateY(-18px) rotate(250deg) scale(1.18); }
  75% { transform: translateY(-8px) rotate(320deg) scale(1.07); }
  100% { transform: translateY(0) rotate(360deg) scale(1); }
}
/* It comes to rest with a bounce rather than simply stopping, and a ring goes
   out from where it landed. */
.snl-die.landed { animation: dieLand .5s cubic-bezier(.2, 1.6, .35, 1); }
@keyframes dieLand {
  0% { transform: translateY(-15px) rotate(-38deg) scale(1.34); }
  45% { transform: translateY(2px) rotate(7deg) scale(.9); }
  72% { transform: translateY(-3px) rotate(-3deg) scale(1.05); }
  100% { transform: translateY(0) rotate(0) scale(1); }
}
/* ⚠️ 96px BECAUSE THE DIE IS 92. This ring was 50px around a 48px die; left at
   50 it would have burst INSIDE the new one and never been seen again. */
.snl-die::before {
  content: ''; position: absolute; left: 50%; top: 50%;
  width: 96px; height: 96px; margin: -48px 0 0 -48px;
  border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.92);
  opacity: 0; pointer-events: none;
}
.snl-die.landed::before { animation: dieBurst .5s ease-out; }
@keyframes dieBurst {
  0% { transform: scale(.45); opacity: .8; }
  100% { transform: scale(2); opacity: 0; }
}
/* ⚠️ .snl-tray, .snl-roll-cube, .snl-actions, trayLive AND cubeNudge WERE HERE
   and went on 2026-08-26 with the second die. .snl-actions was a flex row whose
   only job was to hold one button; the button lives in .snl-roll now, so the
   row went with it and .g-hint is rendered on its own. */
/* ⚠️ .snl-road-btn AND .snl-waiting WERE HERE, and went with path choosing on
   2026-08-24. They were the two road buttons that replaced the roll button
   whenever somebody stood on a fork — the only reason this screen ever had two
   shapes. There is one control on it now. */
/* ⚠️ The die is the only thing on this screen that strobes, so it is the thing
   that has to stop. Everything below stops here; the FACE CYCLING stops in
   games.js (snlCalm), because no stylesheet can reach it. The ordering is
   untouched either way — the die still settles before anything is announced,
   it simply settles without the performance. */
@media (prefers-reduced-motion: reduce) {
  .snl-die,
  .snl-die.tossing,
  .snl-die.tumbling,
  .snl-die.landed,
  .snl-die::before,
  .snl-die.landed::before,
  /* ⚠️ THE GLOW STOPS BREATHING BUT STAYS LIT. Killing the animation
     leaves the :not(:disabled) rule's own opacity: 1 standing, so whose throw
     it is is still legible without a thing pulsing on the screen — which is
     the entire bargain of this media query. Deleting the glow here instead
     would take the answer away rather than the motion. */
  .snl-roll-btn:not(:disabled)::before,
  .snl-roll.rolling .snl-roll-btn::after,
  .snl-tok.hop,
  /* ⚠️ The blast is the loudest thing on this board, so it is the first thing
     that has to stop. The victim still MOVES — where they end up is the game,
     not the decoration — they simply stop being thrown about on the way. */
  .snl-sq.blast.boom u.blast,
  .snl-tok.blown,
  .snl-sq.fresh { animation: none !important; }
  .snl-die { transform: none !important; }
}


/* ---- Gem Rush + Gem Duel ---------------------------------------------------
   One grid style, three sizes: your solo board, your board in the duel, and
   the partner's board underneath it at a glance-able size. */
.gem-grid {
  display: grid; gap: 3px;
  width: min(92vw, 360px); margin: 6px auto; flex: 0 0 auto;
  padding: 5px; border-radius: 14px; background: rgba(255, 255, 255, 0.32);
}
/* ⚠️ `.mine` means A BOARD YOU PLAY ON — the solo Gem Rush grid and your half
   of the duel, not the partner's. It is dragged across, so the browser must
   not read a swipe over it as a page scroll. On `.gem` alone this was not
   enough: a drag that started in the 3px gutter BETWEEN two tiles panned the
   page instead. The partner's mini board deliberately does not take the
   gesture — that one you should be able to scroll past. */
.gem-grid.mine { touch-action: none; }
.gem {
  position: relative; overflow: hidden;
  aspect-ratio: 1; padding: 0; border: none; border-radius: 9px;
  background: rgba(255, 255, 255, 0.55);
  font-size: 22px; line-height: 1;
  transition: transform .12s ease, background .16s ease;
  /* The board is dragged, not scrolled. Without this a swipe across it pans
     the page on a phone and the swap never happens. */
  touch-action: none;
}
.gem.on {
  background: var(--accent); transform: scale(1.12);
  box-shadow: 0 0 0 2px var(--accent), 0 4px 14px var(--accent-dim);
}
.gem.pop { animation: gemPop .19s ease forwards; }
@keyframes gemPop {
  0% { transform: scale(1); }
  60% { transform: scale(1.35); filter: brightness(1.5); }
  100% { transform: scale(0); opacity: 0; }
}
/* Everything above a cleared cell has moved down a row, so it arrives from
   above rather than simply being there. */
.gem.fall { animation: gemFall .17s cubic-bezier(.3, 1.4, .5, 1); }
@keyframes gemFall {
  0% { transform: translateY(-42%); }
  100% { transform: translateY(0); }
}
/* A swap that matched nothing: lean in, bounce back. Silence read as a
   dropped tap. */
.gem.nope { animation: gemNope .3s ease; }
@keyframes gemNope {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-18%) rotate(-6deg); }
  75% { transform: translateX(18%) rotate(6deg); }
}
/* A powerup arriving. */
.gem.born { animation: gemBorn .34s cubic-bezier(.2, 1.6, .4, 1); }
@keyframes gemBorn {
  0% { transform: scale(.3) rotate(-40deg); filter: brightness(2.2); }
  100% { transform: scale(1) rotate(0); filter: none; }
}

/* ---- the three powerups ----------------------------------------------------
   Drawn in CSS over the fruit rather than replacing it, because you still need
   to read the colour to plan the match that fires it. Comet, Supernova and
   Constellation are our names for them — see docs/hazards/ip-risk.md. */
/* ⚠️ CAPS AT THE TWO ENDS OF THE AXIS IT CLEARS, NOT A BAND ACROSS THE
   MIDDLE — and that was settled by opening a PNG, not by an assertion.

   It used to be `transparent -> white .95 across 42-58% -> transparent` at
   opacity .75, and three things were wrong with it at once:

     1. A ::before paints ABOVE the button's own text, so the band sat on
        top of the fruit and washed it out — the exact thing the paragraph
        above says this design exists to avoid.
     2. White, on a tile that is already rgba(255,255,255,.55) over
        lavender. Nothing to contrast with. The Supernova reads because it
        is GOLDEN and the Constellation because it is a dashed ring; the
        Comet was white on white.
     3. ⚠️ A 46px tile washed at 90deg and one washed at 0deg are the same
        picture — and which axis a Comet clears is the one thing you have
        to know before firing it.

   Every assertion passed throughout: gem-ui-check.js compares the classes,
   compares computed backgroundImage + boxShadow, checks the three powerups
   differ from each other, and checks the glyph is still in textContent. All
   four were true of a drawing that had erased the glyph and collapsed its
   two variants into one. gem-rush-check.js now measures a comet-row against
   a comet-col and fails if they render the same. */
.gem.comet-row::before, .gem.comet-col::before {
  content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; border-radius: inherit;
  background: linear-gradient(var(--comet-angle, 90deg),
    var(--accent) 0%, var(--accent) 13%,
    rgba(255, 255, 255, 0) 29%, rgba(255, 255, 255, 0) 71%,
    var(--accent) 87%, var(--accent) 100%);
}
.gem.comet-row { --comet-angle: 90deg; }
.gem.comet-col { --comet-angle: 0deg; }
.gem.comet-row, .gem.comet-col {
  box-shadow: inset 0 0 0 2px var(--accent), 0 0 8px var(--accent-dim);
}
.gem.supernova {
  background: radial-gradient(circle at 50% 50%, #FFF 0%, #FFE9A8 38%, var(--accent) 100%);
  box-shadow: 0 0 10px var(--accent-dim), inset 0 0 0 2px rgba(255, 255, 255, .95);
  animation: gemNova 1.6s ease-in-out infinite;
}
@keyframes gemNova {
  0%, 100% { filter: brightness(1); }
  50% { filter: brightness(1.28); }
}
.gem.constellation {
  box-shadow: inset 0 0 0 2px #FFF, 0 0 8px var(--accent-dim);
}
.gem.constellation::after {
  content: ''; position: absolute; top: 14%; right: 14%; bottom: 14%; left: 14%; border-radius: 50%;
  border: 2px dashed rgba(255, 255, 255, .95);
  animation: gemSpin 4s linear infinite;
}
@keyframes gemSpin { to { transform: rotate(360deg); } }

.gem-grid.mini { width: min(56vw, 210px); gap: 2px; padding: 3px; margin: 2px auto; }
.gem-grid.mini .gem { font-size: 12px; border-radius: 5px; }
.gem-grid.mini .gem.constellation::after { top: 18%; right: 18%; bottom: 18%; left: 18%; border-width: 1px; }
.gem.dead { cursor: default; pointer-events: none; }
.gem-theirs {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  padding: 4px 0 2px; opacity: .82;
}
.gem-theirs .gt-name {
  font-size: 11.5px; font-weight: 800; color: var(--dim);
  max-width: 70vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ---- Pet Jump --------------------------------------------------------------
   The pet runs on the spot, the world slides past. Cactuses want jumping over,
   birds want staying put. */
.jump-stage {
  position: relative; flex: 1 0 auto; min-height: 260px;
  margin: 8px 0; overflow: hidden; touch-action: none;
  background: linear-gradient(180deg, rgba(255,255,255,.28) 0%, rgba(255,255,255,.06) 100%);
  border-top: 1px solid var(--card-brd); border-bottom: 1px solid var(--card-brd);
}
.jump-ground {
  position: absolute; left: 0; right: 0; bottom: 0; height: 4px;
  background: rgba(63, 48, 130, 0.35);
}
/* Centred on the 20% mark with a margin rather than a translate, because the
   loop owns `transform` — that is where the jump lives. */
.jump-pet {
  position: absolute; left: 20%; bottom: 4px; width: 74px; margin-left: -37px;
  will-change: transform; z-index: 2;
}
.jump-pet svg { width: 100%; height: auto; display: block; }
.jump-pet.hurt { animation: jumpHurt .5s ease both; }
@keyframes jumpHurt {
  0%, 100% { filter: none; }
  50% { filter: saturate(2.4) hue-rotate(-30deg); transform: translateY(6px) rotate(-14deg); }
}
.jump-ob {
  position: absolute; bottom: 0; font-size: 26px; line-height: 1;
  font-style: normal; z-index: 1; will-change: left;
}
.jump-ob.bird { animation: birdFlap .34s ease-in-out infinite alternate; }
@keyframes birdFlap { from { transform: translateY(0); } to { transform: translateY(-7px); } }

/* ---- Ping Pong -------------------------------------------------------------
   Your baseline is always the near one, whichever role you drew. */
.pong-table {
  position: relative; width: min(92vw, 380px); aspect-ratio: 3 / 4.2;
  margin: 8px auto; flex: 0 0 auto; touch-action: none;
  border-radius: 16px; overflow: hidden;
  background: linear-gradient(180deg, rgba(94, 139, 224, .28), rgba(124, 92, 224, .18));
  border: 2px solid rgba(255, 255, 255, 0.5);
}
.pong-net {
  position: absolute; left: 6%; right: 6%; top: 50%; height: 2px;
  background: repeating-linear-gradient(90deg, rgba(255,255,255,.8) 0 10px, transparent 10px 20px);
}
.pong-pad {
  position: absolute; height: 10px; border-radius: 999px;
  transform: translateX(-50%);
  transition: left .05s linear, width .3s ease;
}
.pong-pad.you { bottom: 3.4%; background: var(--pa); box-shadow: 0 0 14px var(--pa); }
.pong-pad.them { top: 3.4%; background: var(--pb); box-shadow: 0 0 14px var(--pb); }
.pong-ball {
  position: absolute; width: 14px; height: 14px; border-radius: 50%;
  background: #fff; box-shadow: 0 0 12px rgba(255, 255, 255, .9);
  transform: translate(-50%, 50%);
  transition: left .04s linear, bottom .04s linear;
}

/* ---- Sumo Push ---------------------------------------------------------------
   A hidden dash, revealed together, then the server's own physics. Positions
   arrive already in the shared 0..1 square both sides use — neither view is
   flipped, because a dash direction is an angle on the arena, not "your
   baseline" the way Ping Pong's paddle line is. .sumo-body is sized to 15% of
   the arena on purpose — it is server/sumo.js's own BODY_R (0.075) doubled,
   so the circle you see is the circle the collision math actually uses. */
.sumo-arena {
  position: relative; width: min(92vw, 380px); aspect-ratio: 1 / 1;
  margin: 8px auto; flex: 0 0 auto; touch-action: none;
  border-radius: 20px; overflow: hidden;
  background: linear-gradient(160deg, rgba(124, 92, 224, .16), rgba(216, 85, 140, .12));
  border: 2px solid rgba(255, 255, 255, 0.5);
}
.sumo-ring {
  position: absolute; left: 8%; top: 8%; right: 8%; bottom: 8%;
  border-radius: 50%; border: 3px dashed rgba(75, 59, 143, .35);
  box-shadow: inset 0 0 26px rgba(75, 59, 143, .12);
}
.sumo-arrows { position: absolute; top: 0; right: 0; bottom: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
.sumo-arrow { stroke-width: 3.2; stroke-linecap: round; opacity: 0; transition: opacity .15s ease; }
.sumo-arrow.you { stroke: var(--pa); }
.sumo-arrow.them { stroke: var(--pb); }
.sumo-arrow.live { stroke-dasharray: 4 3; opacity: .75; }
/* ⚠️ .full MUST STAY BELOW .live — same specificity, so the cascade decides,
   and it is .full that has to win the dash. It is the only thing on screen
   that says "this is as hard as it goes": at SUMO_ARROW_MAX the preview stops
   growing, and without this it stops growing SILENTLY and the amplitude
   Deniz asked for is invisible. Solid instead of dashed, and thicker — a
   change you cannot miss mid-drag without looking away from your own hand. */
.sumo-arrow.full { stroke-dasharray: none; stroke-width: 4.4; }
.sumo-tip { opacity: 0; transition: opacity .15s ease; }
.sumo-tip.you { fill: var(--pa); }
.sumo-tip.them { fill: var(--pb); }
.sumo-tip.live { opacity: .75; }
.sumo-tip.full { r: 4.6; }
.sumo-body {
  position: absolute; width: 15%; aspect-ratio: 1 / 1; border-radius: 50%;
  transform: translate(-50%, -50%); touch-action: none;
  transition: left .04s linear, top .04s linear, opacity .2s ease;
}
.sumo-body.you { background: var(--pa); box-shadow: 0 0 14px var(--pa); cursor: grab; }
.sumo-body.them { background: var(--pb); box-shadow: 0 0 14px var(--pb); }
.sumo-body.you.waiting { opacity: .55; cursor: default; }
.sumo-body.you:active { cursor: grabbing; }

/* ---- the lock band: two pips and a fuse (game-updates1 sumo-amp) -----------
   Deniz chose "it didn't FEEL simultaneous" over any complaint about the rules,
   and the rules were already right: both dashes are hidden until both are in.
   What the screen never showed was that the two of you are on ONE clock, at the
   SAME moment.

   ⚠️ THE PIPS ARE MINIATURES OF THE TWO CIRCLES, in the two players' own
   --pa / --pb. Not a labelled chip, not a row of pill buttons — CLAUDE.md's
   standard is that if it has artwork, the artwork is the control, and the
   artwork of this game is two coloured circles. Side by side and filling
   independently, they are the picture of "we are both choosing"; a caption
   saying so would have been the defect, not the fix.

   ⚠️ 14px pips and a 6px rail, on ONE 24px row above a min(92vw, 380px) arena.
   Kept deliberately short because the arena, the score bar, the status line and
   the hint already fill a phone — anything taller pushes the hint below the
   fold, which a desktop screenshot will not show you. */
.sumo-lock {
  display: flex; align-items: center; gap: 10px;
  width: min(92vw, 380px); min-height: 16px;
  margin: 4px auto 0; flex: 0 0 auto;
  opacity: .4; transition: opacity .2s ease;
}
.sumo-lock.live { opacity: 1; }
.sumo-pip {
  width: 14px; height: 14px; border-radius: 50%; flex: 0 0 auto;
  border: 2px solid currentColor; background: transparent;
  transition: background .18s ease, box-shadow .18s ease, transform .18s ease;
}
.sumo-pip.you { color: var(--pa); }
.sumo-pip.them { color: var(--pb); }
.sumo-pip.in {
  background: currentColor; box-shadow: 0 0 10px currentColor;
  transform: scale(1.15);
}
.sumo-fuse {
  flex: 1; height: 6px; border-radius: 999px; overflow: hidden;
  background: rgba(43, 35, 80, 0.14);
}
/* ⚠️ --left IS SET BY sumoDrawFuse FROM THE SERVER'S OWN endsAt, and is 0 with
   the bar unlit whenever there is no deadline to project — a rail that sat full
   and never moved would be the screen lying about a clock. */
.sumo-fuse i {
  display: block; height: 100%; border-radius: 999px;
  width: calc(var(--left, 0) * 100%);
  background: linear-gradient(90deg, var(--pa), var(--pb));
  opacity: 0; transition: width .1s linear, opacity .2s ease;
}
.sumo-fuse.live i { opacity: 1; }
/* The reveal beat is Deniz's two frozen seconds. Draining it in the warmer
   pair the rest of the app uses for a countdown gives the pause visible
   content, so it reads as a held breath rather than as a hang. */
.sumo-fuse.reveal i { background: linear-gradient(90deg, #E8B54A, #D9558C); }

/* ---- Mind Meld -------------------------------------------------------------- */
.meld-dots { display: flex; justify-content: center; gap: 7px; padding: 8px 0 2px; }
.meld-dots .md {
  width: 10px; height: 10px; border-radius: 50%;
  background: rgba(43, 35, 80, 0.16);
}
.meld-dots .md.hit { background: var(--good); box-shadow: 0 0 8px var(--good); }
.meld-dots .md.miss { background: var(--bad); opacity: .55; }
.meld-mid {
  flex: 1; min-height: 150px;
  display: flex; align-items: center; justify-content: center; gap: 22px;
  padding: 10px 0;
}
.meld-face {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  font-size: 52px; line-height: 1;
}
.meld-face span {
  font-size: 12px; font-weight: 800; color: var(--dim);
  max-width: 34vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.meld-mid.met .meld-face { animation: popIn .4s ease both; }
.meld-grid {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
  width: min(84vw, 320px); margin: 0 auto 4px;
}
.meld-card {
  aspect-ratio: 1; border-radius: 20px; font-size: 46px;
  background: rgba(255, 255, 255, 0.6); border: 1px solid var(--card-brd);
  transition: transform .12s ease, background .2s ease;
}
.meld-card:active:not(:disabled) { transform: scale(.9); }
.meld-card.on { background: var(--accent-dim); border-color: var(--accent); }
.meld-card:disabled:not(.on) { opacity: .38; }
/* The board is dealt now, so cards come and go between rounds. A second,
   quieter line under the hint says so — without it the first time a card
   disappears reads as a glitch. */
.meld-fresh { display: block; margin-top: 3px; opacity: .62; }

/* ---- Reaction Duel ---------------------------------------------------------- */
.react-pad {
  flex: 1; min-height: 220px; margin: 10px 20px 4px;
  border-radius: 30px; border: 2px solid transparent;
  font-size: 68px; line-height: 1;
  transition: background .1s ease, transform .1s ease;
}
.react-pad.wait { background: rgba(217, 85, 140, 0.2); border-color: rgba(217, 85, 140, .5); }
.react-pad.go {
  background: rgba(47, 159, 123, 0.55); border-color: var(--good);
  box-shadow: 0 0 40px rgba(47, 159, 123, .6); transform: scale(1.02);
}
.react-pad.result { background: var(--chip-bg); border-color: var(--card-brd); }
.react-pad:active:not(:disabled) { transform: scale(.98); }

/* ---- Hot Potato ------------------------------------------------------------- */
.bomb-fuse {
  height: 8px; border-radius: 999px; margin: 6px 22px 0;
  background: rgba(43, 35, 80, 0.14); overflow: hidden;
}
.bomb-fuse i {
  display: block; height: 100%; border-radius: 999px;
  background: linear-gradient(90deg, #E8B54A, #D9558C);
  transition: width .3s linear;
}
.bomb-arena {
  flex: 1; min-height: 240px;
  display: flex; flex-direction: column; align-items: center; justify-content: space-between;
  padding: 18px 20px 8px;
}
.bomb-arena .bomb-side {
  font-size: 12.5px; font-weight: 800; color: var(--dim);
  max-width: 70vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bomb {
  border: none; background: none; padding: 0;
  font-size: 76px; line-height: 1;
  /* It creeps toward whoever is holding it, and gets more agitated as the fuse
     burns down. */
  transition: transform .35s cubic-bezier(.4, 1.4, .5, 1);
  animation: bombShake calc(.7s - var(--heat, 0) * .45s) ease-in-out infinite;
}
.bomb-arena.here .bomb { transform: translateY(46px) scale(1.12); }
.bomb-arena.there .bomb { transform: translateY(-46px) scale(.86); opacity: .7; animation: none; }
.bomb-arena.boom .bomb { animation: popIn .35s ease both; transform: scale(1.5); }
@keyframes bombShake {
  0%, 100% { rotate: -5deg; }
  50% { rotate: 5deg; }
}

/* ---------- full-screen gallery (fridge, arcade, diary) ---------- */

#gallery {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 70;
  display: flex; flex-direction: column;
  background: linear-gradient(180deg, #F2EDFD 0%, #E2D8F8 55%, #CDBFF3 100%);
  opacity: 0; transition: opacity .22s ease;
}
#gallery.open { opacity: 1; }
.gal-head {
  display: flex; align-items: center; gap: 12px; flex: 0 0 auto;
  padding: 14px 18px 12px;
  padding-top: max(14px, env(safe-area-inset-top), calc(var(--notice-top, 0px) + 10px));
  border-bottom: 1px solid var(--card-brd);
}
.gal-close {
  width: 38px; height: 38px; border-radius: 50%; flex: 0 0 auto;
  background: var(--chip-bg); border: 1px solid var(--card-brd);
  color: var(--ink-soft); font-size: 15px; font-weight: 700;
}
.gal-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.gal-titles b { font-size: 17px; font-weight: 800; color: var(--ink); }
.gal-titles span {
  font-size: 12px; color: var(--muted); font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.gal-body {
  flex: 1; min-height: 0; overflow-y: auto;
  padding: 14px 18px calc(24px + env(safe-area-inset-bottom));
}

/* ---------- the edit-room page, which must not scroll (R6) ----------
   Deniz, 2026-08-16: "there should be no scrolling too, like on edit room
   page". It scrolled 1196px into 779, so the room you were editing slid off the
   top the moment you reached the furniture — which is the actual complaint, not
   the scrollbar.

   Nine items cannot fit at once, so the fix is not "make it shorter". The room,
   the swatches and the buttons are pinned; the shelf of furniture is the only
   thing that moves, and it keeps the room in view while you change it.

   ⚠️ `min-height: 0` on the shelf is load-bearing. A flex child defaults to
   min-height:auto and refuses to shrink below its content, so without it the
   grid keeps its full height and .gal-body scrolls exactly as before — with
   every rule here applied and looking correct in the inspector. */
.gal-body.fit-room {
  display: flex; flex-direction: column;
  overflow: hidden;
  padding-bottom: 0;
}
.fit-room .room-top { flex: 0 0 auto; }
.fit-room .room-shelf {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  /* A scroll gutter that does not shove the tiles sideways when it appears. */
  padding-bottom: 6px; scrollbar-gutter: stable;
  /* The shelf's own bottom edge is the only thing telling you there is more
     furniture below, and whether a row happens to land on that edge is luck —
     on the living room it landed exactly, so six items read as "that is all".
     Fading the last few pixels makes a cut row look cut at any shelf height. */
  -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 14px, #000 calc(100% - 20px), transparent 100%);
  mask-image: linear-gradient(180deg, transparent 0, #000 14px, #000 calc(100% - 20px), transparent 100%);
}
.fit-room .room-foot {
  flex: 0 0 auto;
  padding: 6px 0 calc(12px + env(safe-area-inset-bottom));
}
.fit-room .room-foot .buy-bar { margin: 0 0 8px; gap: 6px; }
.fit-room .room-foot .bb-name { font-size: 13.5px; }
.fit-room .room-foot .buy-bar .btn { height: 46px; }
/* The room is a picture, so it is centred and capped by the height available
   rather than stretched to the column width. 390x330 is the houseSVG viewBox. */
.fit-room .house-preview {
  width: min(100%, calc(30vh * 390 / 330));
  margin: 4px auto 0;
}
.fit-room .preview-tag { margin-top: 8px; }
.fit-room .tile-head { margin: 12px 0 8px; }

/* Walls and floors are the same kind of choice and five wide apiece, so they sit
   side by side. Stacked, with their labels, they cost 158px on a screen with
   none to spare. */
.surface-pair {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px;
  margin-top: 12px;
}
.surface-pair .label { margin: 0 0 6px; }
/* Five swatches had to fit ONE line in half a phone: (354 - 14 gap) / 2 = 170
   per column, and 5x28 + 4x6 = 164. The old note said a five-swatch palette on
   two lines reads as a layout that broke rather than one that was designed, and
   that was true of FIVE.
   ⚠️ P54 MAKES IT TEN OF EACH, and ten on one nowrap line is not a tight fit,
   it is five of them squeezed to nothing or pushed off the edge. Two rows of
   five is a palette; it is what a paint chart looks like. So it wraps, with a
   row gap that makes the two rows read as one block rather than as two rows
   that happen to be near each other. */
.surface-pair .opt-row { gap: 6px; row-gap: 7px; flex-wrap: wrap; }
.surface-pair .swatch { width: 28px; height: 28px; flex: 0 0 auto; overflow: hidden; }
/* ⚠️ A WALL AND A FLOOR ARE THINGS YOU BUY SINCE P54, and a 28px paint chip has
   nowhere to put a number — the note above says why it cannot be made bigger,
   and a "🍪 80" shrunk onto one would be a smudge with a smudge on it. So the
   chip says only "this one is not yours yet", and the PRICE is said in the buy
   bar at the bottom of the same screen, where the furniture is already bought.
   One tap, one price, one place to look.

   Dashed is the app's existing not-yet-yours vocabulary (.tile.buy, .rt.buy) —
   no new idea, just applied to a circle. The gold ring INSIDE it is what makes
   the state survive on a pale chip, where a white dashed border on a cream wall
   is invisible against a lavender page.

   ⚠️ ON THE BORDER, NOT INSIDE IT, AND THAT IS MEASURED. The first version put
   the gold on as a 2px inset ring, which rendered correctly and was still
   wrong: a 2px ring inside a 28px circle is 30% of the visible colour
   (pi*(12^2-10^2) / pi*12^2), so the chip stopped showing the paint it exists
   to show — the rose chip read peach. A corner pip is not available either;
   the chip is round and clips its own overflow, so anything in a corner is cut
   away by the circle. The border is the one place a marker costs no colour.

   ⚠️ And the .on state has to win it back. The .surface-pair .swatch.buy rule
   outranks .swatch.on, so without the second rule a SELECTED unowned chip keeps
   the gold ring and loses the dark one — the wall you are looking at would stop
   saying so. */
.surface-pair .swatch.buy { border-style: dashed; border-color: #E8B23A; }
.surface-pair .swatch.buy.on { border-color: var(--ink); }
/* The chip draws the real paper / the real floor since P54 — five of the ten in
   each set are a pattern, and two of the papers share a ground colour with
   `cream`, so a flat chip would be the same button twice. */
.surface-pair .swatch > svg { width: 100%; height: 100%; display: block; }

/* The view out of the window (R10c). It used to be a tile grid in the wardrobe,
   which was always slightly wrong — a sky is not something the pet wears — and
   became impossible once the wardrobe went to five categories. It belongs beside
   the window it is seen through.

   Full width under the surfaces rather than a third column: these are four
   landscapes, and a landscape 28px wide is a smudge. */
.view-row { margin-top: 12px; }
.view-row .label { margin: 0 0 6px; }
/* ⚠️ NINE VIEWS SINCE P54, NOT FOUR. 9 x 66 + 8 x 9 = 666px against a ~354px
   phone, so this had to either shrink the chips or scroll. It scrolls: the
   comment above says why a view chip cannot be shrunk — a landscape 28px wide
   is a smudge, and these are already the smallest a window can be drawn at.
   -webkit-overflow-scrolling is for the WebView 83 floor the app ships on. */
.view-strip {
  display: flex; gap: 9px;
  overflow-x: auto; overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  padding-bottom: 2px;
}
.view-strip::-webkit-scrollbar { display: none; }
/* ⚠️ Fixed size, NOT flex: 1. Stretched to fill the row these were 290x44 on a
   wide screen — a 6.6:1 letterbox through a window drawn 104x118, so
   preserveAspectRatio="slice" cropped away everything except a horizontal band
   of sky and three of the four chips were blank grey. A picture of a view has
   to keep roughly the shape of the view. */
.view-chip {
  position: relative; flex: 0 0 auto; width: 66px; height: 58px; padding: 0;
  border-radius: 14px; overflow: hidden; cursor: pointer;
  border: 1.5px solid var(--card-brd); background: var(--chip-bg);
  transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease;
}
.view-chip > svg { width: 100%; height: 100%; display: block; }
.view-chip.on {
  border-color: var(--accent); box-shadow: 0 4px 14px var(--accent-dim);
  transform: translateY(-2px);
}
.view-chip.locked > svg { filter: grayscale(.75) opacity(.62); }
.view-chip.locked.on > svg { filter: none; }
.view-chip .rt-tag { right: 3px; top: 3px; }
.gal-label {
  font-size: 12px; letter-spacing: .12em; text-transform: uppercase;
  font-weight: 700; color: var(--dim); margin: 18px 2px 10px;
}
.gal-label:first-child { margin-top: 2px; }
/* The arcade has two shelves — alone and together — each split into moods. */
.gal-shelf {
  font-size: 17px; font-weight: 800; color: var(--ink);
  margin: 20px 2px 2px; padding-bottom: 8px;
  border-bottom: 1px solid var(--card-brd);
  display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
}
/* "Your partner is away" is one fact about the whole shelf, so it is said once
   here rather than repeated on every card. */
.gal-shelf .shelf-note {
  font-size: 12px; font-weight: 700; color: var(--faint);
  letter-spacing: 0; line-height: 1.35;
}
.gal-shelf:first-child { margin-top: 2px; }
.gal-shelf + .gal-label { margin-top: 12px; }

/* The app-gallery grid: big art, name under it. */
.gal-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 10px;
}
.gal-card {
  position: relative;
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  padding: 14px 8px 12px; border-radius: 20px;
  background: var(--card); border: 1px solid var(--card-brd);
  box-shadow: 0 6px 18px rgba(63, 48, 130, 0.08);
  transition: transform .14s ease, box-shadow .2s ease;
  text-align: center;
}
.gal-card:active { transform: scale(.95); box-shadow: 0 2px 8px rgba(63, 48, 130, 0.1); }
/* R7: this holds a DRAWING now, not an emoji — see game-svg.js. The font-size
   stays because the emoji path is still reachable as the fallback for a game
   that has no artwork yet, and a fallback that renders at 0px would hide the
   very thing the fallback exists to make visible. */
.gal-card .gc-art {
  width: 46px; height: 46px; display: grid; place-items: center;
  font-size: 34px; line-height: 1.1;
  filter: drop-shadow(0 2px 3px rgba(63, 48, 130, .2));
}
.gal-card .gc-art > svg { width: 100%; height: 100%; display: block; }
.gal-card .gc-name {
  font-size: 13px; font-weight: 800; color: var(--ink); line-height: 1.25;
  /* Two lines rather than an ellipsis — "Snakes & Ladders" deserves its name. */
  max-width: 100%; display: -webkit-box;
  -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.gal-card .gc-sub {
  font-size: 11px; color: var(--muted); font-weight: 600; line-height: 1.3;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.gal-card .gc-badge {
  position: absolute; top: 7px; right: 8px;
  background: var(--accent); color: var(--accent-ink);
  border-radius: 999px; padding: 2px 8px;
  font-size: 11px; font-weight: 800;
}
/* Tapping one of these nudges the partner instead of starting a game. A dashed
   edge says "not quite ready" without shouting; the shelf heading above already
   said why, and the sub-line is back to describing the game. */
.gal-card.ping { border-color: var(--accent-dim); border-style: dashed; }

/* ---- the arcade's difficulty rail (R19 item 10; shrunk R20.2, 2026-08-23) --
   Three CLIMBS, not three pill buttons — WORKING-RULES.md §4.4. The tile is
   the artwork and the word underneath is a caption; selection is a lift, a
   ring and a colour on the tile itself, never a chip parked beside it.
   game-svg.js draws diff-1/2/3, and diff-gauge-N is the same three levels
   shrunk to a corner mark for every arcade tile the setting reaches.

   ⚠️ R20.2 — Deniz: *"the game difficulty selector is taking so much screen
   space"*. It was a CARD: a heading on its own line, a 3-up grid of 46x40
   tiles, and two wrapped paragraphs under them — 213px of a 604px window at
   390x844, 35% of the arcade, sitting above every shelf.

   It is a STRIP now, and the thing that went is the prose. The caption moved
   to the left of the tiles, on their line, where it costs no height at all;
   the two .diff-note paragraphs are gone from diffRail() (the reasoning, and
   why both i18n keys survive, is in the comment above that function). The
   drawings themselves barely moved: a 38px SQUARE box instead of 46x40, which
   at a 0 0 100 100 viewBox is the same 100 units tall and 3px less dead space
   either side — gotcha 68, a viewBox letterboxes to its SHORT axis, so a
   square box is the one that wastes nothing.

   Measured: 213px -> 80px at 390x844 and 182px -> 85px at 615x888, and the
   first shelf heading rose from 305px to 168px — a whole row of games. Held by
   scripts/checks/diff-rail-check.js, which fails over 96px AND fails if the
   tiles stop being three different drawings. */
.diff-rail {
  display: flex; align-items: center; gap: 10px;
  margin: 0 0 10px; padding: 5px 10px;
  border-radius: 18px; background: var(--card);
  border: 1px solid var(--card-brd);
  box-shadow: 0 4px 12px rgba(63, 48, 130, 0.05);
}
/* ---- R23, 2026-08-24 — it stops scrolling away --------------------------
   ---- app-updates1 A3, 2026-08-27 — and it does so at the BOTTOM ---------

   Deniz, 2026-08-24: *"Make the difficulty selector don't scroll with
   scrolling the game page."* Then, 2026-08-27, having used it: *"Move the
   hardness of the game to the bottom of the games selection screen, but it
   should stay with the screen, not scrolled away."*

   ⚠️ THE SECOND ASK IS A MOVE, NOT A NEW FEATURE. Everything R23 measured
   still applies; it applies upside down. The four notes below are R23's,
   kept because every one of them cost something to learn, with what changed
   for the bottom marked.

   The selector is rendered in exactly one place — inside #gallery's
   .gal-body, and now BELOW both arcade panes — so "the game page" is the
   arcade and this is the element that pins.

   ⚠️ IT MUST BE THE LAST CHILD, AND app.js MOVED IT THERE. `position: sticky`
   keeps the element in normal flow and reserves its space; only its PAINTED
   position moves. A first child with `bottom: 0` therefore paints against the
   bottom edge while its 80px of reserved space sits at the top of the shelf,
   as a hole. Neither half of this change works without the other.

   ⚠️ IT BECOMES A BAR RATHER THAN A FLOATING CARD, and that is forced rather
   than chosen. `position: sticky` pins to the SCROLLPORT, not to the padding
   box, so a pinned card keeping its 18px side margins would have twenty-seven
   game cards scrolling up each side of it and through its rounded corners.
   Full bleed, square corners, one border on the edge that faces the shelves
   (the bottom until 2026-08-27, the TOP since A3 turned it over): a strip that
   reads as chrome, which is what it now is. The background is the opaque --card, and that is
   the only thing stopping the shelves showing through it.

   ⚠️ THE PADDING MOVES OFF THE BODY RATHER THAN BEING CANCELLED ON THE
   RAIL, AND THAT WAS MEASURED, NOT REASONED. (R23 wrote this about the TOP
   padding; A3 does the identical thing to the BOTTOM one, for the identical
   reason, and the reason is a browser behaviour that does not care which
   edge you are on.) The first attempt kept
   .gal-body's `padding-top: 14px` and pulled the rail back up with
   `margin-top: -14px`. It did not work, and it failed in the one way that
   looks like success in a diff: diff-rail-check §4 reported the rail
   `at 14px -> 14px` at every size — pinned, perfectly still, and 14px DOWN the
   scrollport, with a transparent band above it that the game cards scrolled
   through. The cause is that Chrome holds a sticky box's **margin** box inside
   the sticky view rectangle, not its border box, so a negative top margin is
   given straight back the moment the box sticks.

   ⚠️ And "just use `top: -14px` to cancel it again" was rejected on purpose:
   whether the margin box or the border box is constrained is exactly the point
   the engines are free to differ on, and this app ships in a WKWebView on iOS
   as well as a Chrome one on Android. So the layout is arranged to need NO
   offset at all: the body gives up its top padding, the rail's resting
   position already IS the top of the scrollport, and `top: 0` therefore never
   has to move it. It cannot jump, lag or land differently on another engine,
   because on every engine it is already where it is going.

   ⚠️ AND THIS IS WHERE R23 LEFT AN INSTRUCTION FOR TODAY. Its text read: "If
   anything in this rail ever grows a bottom anchor it must read
   calc(var(--emote-dock, 0px) + …) and never a number." The rail has now
   grown one, so it does.

   --emote-dock is the reaction dock's measured height, published on :root for
   #game-layer (z-index 80), .g-emote and #emote-fly. It resolves to 0px while
   the arcade is open — syncInset() publishes 0 whenever the dock is shut, and
   #gallery (z-index 70) sits behind the game layer and is never on screen
   during a match. So it costs exactly nothing today. It is written this way
   because the day it stops costing nothing, nobody will be reading this file.
   The reasoning is docs/archive/r23/difficulty-carry.md.

   ⚠️ AND env(safe-area-inset-bottom) NOW MATTERS, WHICH IT DID NOT AT THE TOP.
   A bar held against the bottom edge of a phone lands under the home
   indicator; the same bar at the top could ignore it because .gal-head above
   it had already absorbed the top inset.

   Measured and held by scripts/checks/diff-rail-check.js §4, which scrolls the
   arcade to its end and asserts the rail is at the body's BOTTOM edge, IN THE
   SAME PLACE it was before the scroll, still the element under your thumb, and
   still under the 96px budget — at 390x844 and at 390x600, because a pinned bar
   costs the same on every window and the short one is where that hurts. */
.gal-body.arcade { padding-bottom: 0; }
.gal-body.arcade > .diff-rail {
  position: sticky; z-index: 3;
  bottom: calc(var(--emote-dock, 0px) + 0px);
  margin: 10px -18px 0;
  padding: 9px 18px calc(9px + env(safe-area-inset-bottom));
  border-radius: 0;
  border-bottom: none; border-left: none; border-right: none;
  border-top: 1px solid var(--card-brd);
  box-shadow: 0 -6px 14px rgba(63, 48, 130, 0.10);
  /* ⚠️ OPAQUE, AND IT HAS TO BE SAID IN TWO LAYERS BECAUSE --card IS NOT.
     This was written first as "the background stays var(--card), which is
     opaque" — and var(--card) is rgba(255,255,255,.52), or .80 on the night
     sky. It has never been opaque. As a floating card that reads as frosted
     glass over a gradient and is the whole look; under a bar with twenty
     game cards travelling behind it, it is a strip you can read a shelf
     heading through. ⚠️ EVERY ASSERTION IN §4 PASSED WHILE IT WAS WRONG —
     elementFromPoint hits the top-most box whether or not you can see it.
     It was found by opening r23-sticky-390-duo-bottom.png.

     So: --arc-top (the 0% stop of the tab’s own gradient, and therefore
     exactly the colour behind a rail pinned to the top of it) as an opaque
     base, with the translucent --card painted over it as an image layer.
     The result is pixel-identical to the old frosted card at rest, and
     solid. §4 now asserts the computed alpha is 1. */
  background-color: var(--arc-top, #FDF1F6);
  background-image: linear-gradient(var(--card), var(--card));
}

/* ⚠️ On the LEFT and allowed to wrap. Two short stacked lines ("HOW / HARD?")
   are still shorter than the tiles beside them, so a wrap costs nothing — and
   giving it its own line, which is what it had, costs 23px on every phone. */
.diff-cap {
  flex: 0 0 auto; max-width: 68px;
  font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase;
  font-weight: 800; color: var(--dim); line-height: 1.2; margin: 0;
}
.diff-row {
  flex: 1 1 auto; min-width: 0;
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
}
.diff-tile {
  display: flex; flex-direction: column; align-items: center; gap: 0;
  padding: 5px 4px 3px; border-radius: 13px;
  background: var(--chip-bg);
  border: 1.5px solid transparent;
  transition: transform .14s ease, border-color .18s ease,
              background .18s ease, box-shadow .2s ease;
}
/* Fixed box, so the three drawings sit at one optical scale and the row does
   not change height when the selected one grows a shadow. SQUARE, because the
   viewBox is — see the banner above.
   ⚠️ The negative bottom margin is a crop, not a nudge, and the number is
   measured rather than chosen: the ink in diff-1/2/3 stops at y=85 of 100, so
   the bottom 15 units are transparent by construction and 38px * .15 = 5.7.
   -3 takes most of it and leaves the caption room to breathe: at -5 the word
   was glued to the ground line under the hill. There is NO top margin, and
   the reason changed on 2026-08-26: the summit stars are gone and all three
   marks now top out at y=15, so the 5.7px of clear air above the ink is what
   stops three tiles that share one summit line from looking cropped. Take a
   crop off the bottom, never off the top. */
.diff-tile .dt-art { width: 38px; height: 38px; display: block; margin-bottom: -3px; }
.diff-tile .dt-art > svg {
  width: 100%; height: 100%; display: block;
  filter: drop-shadow(0 2px 3px rgba(63, 48, 130, .18));
}
.diff-tile .dt-name {
  font-size: 10.5px; font-weight: 800; color: var(--muted);
  line-height: 1.2; letter-spacing: .01em;
}
.diff-tile:active { transform: scale(.95); }
.diff-tile.on {
  background: rgba(124, 92, 224, 0.14);
  border-color: var(--accent);
  box-shadow: 0 4px 11px rgba(124, 92, 224, .2);
}
.diff-tile.on .dt-name { color: var(--ink); }
/* ⚠️ A wider screen buys a BIGGER DRAWING, not more air around a small one.
   At 615 each segment is 185px holding a 38px climb, and the artwork read as
   lost in it — which is the same defect as a word where a picture belongs,
   arrived at from the other direction. 44px puts the drawings back above the
   size they were before R20.2 while the whole strip is still a third of what
   it was. Same breakpoint the arcade grid already uses. */
@media (min-width: 560px) {
  .diff-tile .dt-art { width: 44px; height: 44px; margin-bottom: -4px; }
  .diff-tile .dt-name { font-size: 11.5px; }
  .diff-row { gap: 8px; }
}

/* The corner mark. TOP LEFT and not top right, because .gc-badge already owns
   the right corner and two marks in one corner is one unreadable mark. */
.gal-card .gc-diff {
  position: absolute; top: 8px; left: 9px;
  width: 14px; height: 14px; color: var(--accent); opacity: .8;
}
.gal-card .gc-diff > svg { width: 100%; height: 100%; display: block; }
/* And beside a solo game's hint, so you can see what you are playing without
   leaving it to go and look. */
.diff-chip {
  display: inline-flex; align-items: center; gap: 5px;
  margin-right: 8px; color: var(--accent); font-weight: 800;
}
.diff-chip > svg { width: 12px; height: 12px; display: block; }
/* On the invite and waiting screens, where there is room for it to be read at
   arm's length rather than glanced at. ⚠️ A size is REQUIRED: an inline <svg>
   with none stretches to fill its row, which is how a 12px gauge turned into a
   150px bar chart over the Accept button. diff-invite.png caught it. */
.g-earn .diff-chip { color: var(--muted); font-weight: 700; margin-right: 0; }
.g-earn .diff-chip > svg { width: 15px; height: 15px; color: var(--accent); }

.gal-empty {
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  padding: 34px 20px; text-align: center;
  font-size: 13.5px; color: var(--muted); font-weight: 600;
}
.gal-empty { font-size: 40px; }
.gal-empty span { font-size: 13.5px; }

/* The minimum-version gate. Covers everything and cannot be dismissed: the
   server has refused to serve this build, so any UI still showing underneath
   would be describing a state we can no longer reach. Deliberately plain — it
   may be rendered by a build old enough that the rest of the CSS has moved on,
   so it leans on nothing but the tokens that have always existed. */
#upgrade-gate {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 9999;
  display: flex; align-items: center; justify-content: center;
  padding: 24px; background: var(--bg, #C8B9F1);
}
#upgrade-gate .upgrade-card {
  max-width: 320px; text-align: center;
  background: var(--card, #fff); color: var(--ink, #2b2440);
  border-radius: 18px; padding: 28px 22px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, .18);
}
#upgrade-gate .upgrade-emoji { font-size: 44px; line-height: 1; margin-bottom: 10px; }
#upgrade-gate h2 { margin: 0 0 8px; font-size: 19px; font-weight: 800; }
#upgrade-gate p { margin: 0 0 18px; font-size: 14px; line-height: 1.5; color: var(--muted, #6b6480); }
#upgrade-gate .upgrade-btn {
  display: inline-block; text-decoration: none; font-weight: 800; font-size: 15px;
  background: var(--accent, #7C5CE0); color: var(--accent-ink, #fff);
  border-radius: 999px; padding: 12px 26px;
}

/* ==== R18 — eight new games. 2026-08-21. =====================================

   ⚠️ Nothing here is a row of pill buttons with a label on it, which is the
   defect WORKING-RULES.md §4.4 exists to stop and the one an agent ships by
   default. Every control below is the thing itself: a tile of colour, an ink
   pot, a rope, a beat.

   ⚠️ And nothing here reflows when its state changes. A foot that grows a row
   when a button appears is the R10 lesson — the fix is a fixed slot that goes
   `visibility: hidden`, never `display: none`. */

/* ---- Shade, solo and duel ---------------------------------------------------
   The grid IS the game, so it gets the whole stage and nothing shares it. The
   tiles are square, seamless and unlabelled: a gap between them would give the
   eye an edge to compare against, which makes a hard level easy for the wrong
   reason. */
/* ⚠️ A COLUMN SINCE R23 `hue-timer`, because the clock lives above the board.
   It was a row with one child in it, so the direction had never mattered. */
.shade-wrap {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  min-height: 0; padding: 6px 0; gap: 9px;
}
.shade-grid {
  display: grid;
  grid-template-columns: repeat(var(--n), 1fr);
  /* grid-auto-rows: 1fr — item 15a's sweep. See .su-grid and
     scripts/checks/grid-guard.js. ⚠️ .shade-cell's reveal ring is already an
     inset pseudo-element rather than a border "so the tile does not change size
     and drag the whole grid with it" — that comment is this same bug, worked
     around at the cell instead of fixed at the track. It is fixed here now; the
     ring stays as it is. */
  grid-auto-rows: 1fr;
  width: min(86vw, 340px); aspect-ratio: 1;
  /* ⚠️ The clock costs about 17px of a stage that was already tight, and a
     board that pushes itself under the fold is worse than one that is 4px
     short of square. When there is room this does nothing at all. */
  max-height: 100%;
  border-radius: 20px; overflow: hidden;
  box-shadow: 0 10px 30px rgba(63, 48, 130, .22);
  gap: 0;
}

/* ---- the round clock (R23 `hue-timer`) --------------------------------------
   §4.4: a number in the top corner is a fact about time, not a feeling of it.
   The number stays — it is the detail — and this is the pressure: a bar exactly
   the width of the board, draining continuously, going to the warning colour
   and pulsing under three seconds.

   ⚠️ `--left` IS THE ONLY THING JAVASCRIPT SETS, and it is a fraction of the
   deadline rather than a width in pixels, so the same two lines drive a solo
   board's own clock and a duel's projection of the server's `endsAt`.

   ⚠️ scaleX from the LEFT edge, not a width: a width animates the layout and a
   transform animates the compositor, and this repaints ten times a second on a
   screen that also has a game on it. */
.shade-bar {
  --left: 1;
  width: min(86vw, 340px); height: 7px; flex: none;
  border-radius: 99px; overflow: hidden;
  background: rgba(63, 48, 130, .16);
}
.shade-bar > i {
  display: block; height: 100%; width: 100%;
  transform-origin: left center;
  transform: scaleX(var(--left));
  border-radius: 99px;
  background: linear-gradient(90deg, var(--accent), #D9558C);
  transition: transform .1s linear, background-color .2s ease;
}
.shade-bar.low > i { background: linear-gradient(90deg, #E8617A, #E8617A); }
.shade-bar.low { animation: shadeBarPulse .7s ease-in-out infinite; }
/* Between rounds the bar is a leftover, not a clock. Fading it says so without
   removing an element and reflowing the board mid-reveal. */
.shade-bar.done { opacity: .35; }
@keyframes shadeBarPulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
@media (prefers-reduced-motion: reduce) {
  .shade-bar.low { animation: none; }
  .shade-bar > i { transition: none; }
}

/* The round number, evicted from the head by the clock.
   ⚠️ INLINE, NOT A LINE OF ITS OWN. A second line here costs ~17px of a stage
   the bar has already taken 16px from, and .shade-grid would start clamping
   itself off square to pay for it. It reads perfectly well as a lead-in. */
.duel-round {
  font-size: 11.5px; font-weight: 800;
  letter-spacing: .06em; text-transform: uppercase;
  color: var(--muted); margin-right: 7px;
}
.shade-cell {
  border: none; padding: 0; background: var(--c);
  transition: transform .16s ease, box-shadow .16s ease;
  position: relative;
}
.shade-cell:active:not(:disabled) { transform: scale(.94); }
/* The reveal. An inset ring rather than a border, so the tile does not change
   size and drag the whole grid with it. */
.shade-cell.right::after,
.shade-cell.wrong::after {
  content: ''; position: absolute; top: 8%; right: 8%; bottom: 8%; left: 8%;
  border-radius: 26%; border: 4px solid #FFFFFF;
  box-shadow: 0 0 0 2px rgba(38, 34, 58, .35);
}
.shade-cell.wrong::after { border-color: #E8617A; }
.shade-cell.you::after { content: ''; position: absolute; top: 22%; right: 22%; bottom: 22%; left: 22%; border-radius: 50%;
  background: rgba(255, 255, 255, .85); }
.shade-cell.them::after { content: ''; position: absolute; top: 22%; right: 22%; bottom: 22%; left: 22%; border-radius: 50%;
  background: rgba(38, 34, 58, .5); }

.shade-lives { display: inline-flex; gap: 5px; align-items: center; }
.shade-life { width: 17px; height: 17px; display: inline-block; }
.shade-life svg { width: 100%; height: 100%; display: block; }
/* A spent life stays in place rather than being removed. Three hearts becoming
   two is a number changing; three hearts with one greyed is a life lost. */
.shade-life.gone { opacity: .22; filter: grayscale(1); }

/* ---- Tug of War -------------------------------------------------------------
   One rope across the screen with a knot on it, and one enormous pad. There is
   nothing else to look at because there is nothing else to do. */
.tug-field {
  display: flex; align-items: center; gap: 10px;
  padding: 12px 14px;
}
.tug-side { font-size: 26px; }
.tug-rope {
  flex: 1; height: 12px; border-radius: 6px; position: relative;
  background: repeating-linear-gradient(90deg,
    rgba(247, 201, 72, .95) 0 10px, rgba(232, 119, 159, .8) 10px 20px);
  box-shadow: inset 0 2px 4px rgba(38, 34, 58, .2);
}
.tug-knot {
  position: absolute; top: 50%;
  width: 30px; height: 30px; border-radius: 50%;
  background: #E8617A; border: 4px solid #FFF6E9;
  /* --at runs -100..100 and is the server's number.
     ⚠️ THE OFFSET IS ON `left`, NOT INSIDE `translate`. A percentage in a
     transform resolves against the ELEMENT'S OWN box — so the first version
     moved the knot by a percentage of thirty pixels and a rope pulled almost to
     the end drifted five pixels. It looked like the game was not working.
     A percentage on `left` resolves against the parent, which is the rope, and
     is what was meant all along. Caught by party-ui-check.js measuring the knot
     against the rope rather than asserting the class was applied. */
  left: calc(50% + var(--at) * 0.44%);
  transform: translate(-50%, -50%);
  transition: left .18s ease-out;
  box-shadow: 0 4px 12px rgba(63, 48, 130, .3);
}
.tug-pad {
  /* ⚠️ `auto` top AND bottom, inside #game-layer's flex column. The first
     version pinned the pad under the rope and left the bottom half of the
     screen empty — on a tall phone the thing you are meant to hammer sat in
     the middle with nothing under it. Auto margins hand the pad the leftover
     space instead of leaving it as a void. */
  margin: auto; display: block; border: none; padding: 0;
  width: min(58vw, 210px); aspect-ratio: 1; border-radius: 32%;
  background: rgba(247, 201, 72, .18);
  box-shadow: 0 10px 26px rgba(63, 48, 130, .2);
  transition: transform .06s ease;
}
.tug-pad svg { width: 68%; height: 68%; display: block; margin: 16% auto; }
.tug-pad.hit { animation: tug-hit .12s ease; }
@keyframes tug-hit { from { transform: scale(.9); } to { transform: scale(1); } }
.tug-pad:disabled { opacity: .4; }

/* ---- Cannon Duel --------------------------------------------------------
   new-games-1 game-cannon. Two hills, a drag-to-aim scene, a wind cue and
   two health bars. docs/archive/new-games-1/game-cannon.md is the decision
   record — the drag mapping, the wind numbers, and Decision 6 on why there
   is no blocking turn card here: this screen relies on the shared
   `.duel-status` ribbon (already used by every other duel screen) plus the
   `.active` glow below, instead of turnCard()'s full-screen overlay. */
.cn-wrap { position: relative; margin: 6px 14px 0; }
.cn-scene {
  display: block; width: 100%; aspect-ratio: 100 / 78;
  border-radius: 18px; touch-action: none;
  background: linear-gradient(180deg, rgba(111, 168, 232, .22), rgba(111, 168, 232, .05));
}
.cn-ground { fill: rgba(95, 164, 107, .34); }
.cn-cannon-a circle, .cn-cannon-a line { fill: #7C5CE0; stroke: #7C5CE0; }
.cn-cannon-b circle, .cn-cannon-b line { fill: #E8779F; stroke: #E8779F; }
/* The board-edge glow — the second of Decision 6's two non-blocking cues. */
.cn-cannon.active circle { filter: drop-shadow(0 0 6px currentColor); }
.cn-cannon.active { animation: cn-pulse 1.1s ease-in-out infinite; }
@keyframes cn-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .6; } }
.cn-arc { fill: none; stroke: #F7C948; stroke-width: .9; stroke-linecap: round; stroke-dasharray: 1.4 2.2; }
.cn-shell { fill: #4B3B8F; }
.cn-burst-ring { fill: #F7C948; opacity: .85; }
.cn-burst-core { fill: #FFF6E9; }
/* ⚠️ The animation lives on the INNER group, never on #cn-burst itself —
   see the note beside where #cn-burst is built in games.js. A CSS
   animation of `transform` on the same element JS positions with a
   `transform` ATTRIBUTE would override that attribute outright. */
.cn-burst-anim {
  animation: cn-burst .5s ease-out;
  /* ⚠️ `transform-box: fill-box` is required here — without it a percentage
     `transform-origin` on an SVG element resolves against the whole SVG
     VIEWPORT, not this group's own content, and the "pop" would scale
     around the middle of the scene instead of around the impact point. */
  transform-box: fill-box; transform-origin: center;
}
@keyframes cn-burst { from { transform: scale(.3); opacity: 1; } to { transform: scale(1.6); opacity: 0; } }

/* The wind cue: a value AND a drawn arrow, per the brief — never hidden,
   drawn on every frame including the very first one. */
.cn-wind {
  position: absolute; top: 8px; right: 12px;
  display: flex; align-items: center; gap: 4px;
  background: rgba(38, 34, 58, .55); color: #FFF6E9;
  border-radius: 999px; padding: 4px 10px; font-size: 13px; font-weight: 700;
}
.cn-wind-arrow { font-size: 15px; letter-spacing: -2px; }

.cn-healths { display: flex; gap: 10px; padding: 10px 14px 4px; }
.cn-hp { flex: 1; min-width: 0; }
.cn-hp-who {
  display: block; font-size: 12px; font-weight: 700; color: var(--muted);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 3px;
}
.cn-hp.mine .cn-hp-who { color: var(--accent); }
.cn-hp-bar { height: 10px; border-radius: 6px; background: rgba(140, 127, 192, .22); overflow: hidden; }
.cn-hp-bar i {
  display: block; height: 100%; border-radius: 6px;
  background: linear-gradient(90deg, #E8617A, #F7C948);
  transition: width .4s ease;
}

/* ---- Warmer -----------------------------------------------------------------
   R23, 2026-08-24. Deniz asked for this screen twice, five days apart:
   "fixed-height boxes, carrot-under-grass theme, artwork instead of the words
   hot and cold", and then "should have many animations, should be like hiding
   something under grass and the other one trying to find it".

   ⚠️ THE OLD COMMENT HERE SAID THE OPPOSITE and it is gone rather than left to
   rot: "The heat words go ON the square that was guessed. A legend beside the
   grid would be a key you have to learn". A word on a tile was the right answer
   to the question "where does the reading live"; it was never an answer to "why
   is this game a spreadsheet". At 8x8 those words were 9px, and in Turkish they
   were `yanıyor` and `soğuk`, which is what grew a row and broke the boxes.

   Now: the field is grass, a guess digs a hole, and the hole holds a drawing.
   The five-mark rail under the grid states the ORDER of five shapes once —
   which is a different job from labelling each square, and is why it does not
   reintroduce the key-you-have-to-learn. */
.warm-wrap { flex: 1; display: flex; align-items: center; justify-content: center; min-height: 0; }

/* The ground the grid stands on. A single soft mound behind the squares, so
   the tufts read as one field rather than as 36 unrelated pictures. */
.warm-field {
  position: relative;
  padding: 10px;
  border-radius: 26px;
  background:
    radial-gradient(120% 70% at 50% 8%, rgba(168, 230, 203, .5), rgba(168, 230, 203, 0) 70%),
    linear-gradient(180deg, rgba(95, 199, 155, .16), rgba(95, 164, 107, .26));
  box-shadow: inset 0 -10px 22px rgba(62, 138, 85, .16);
}
.warm-grid {
  display: grid; grid-template-columns: repeat(var(--n), 1fr);
  /* ⚠️ FIXED-HEIGHT BOXES, HIS FIRST CLAUSE, AND IT IS TWO PROPERTIES.
     `grid-auto-rows: 1fr` is the one that matters: the rows used to be `auto`,
     so a cell whose label wrapped to two lines made its whole ROW taller and
     the board jumped as you played. `aspect-ratio` on the cell keeps a square
     square if a later edit ever unpins the row. Both, deliberately. */
  grid-auto-rows: 1fr;
  gap: 5px;
  width: min(84vw, 322px); aspect-ratio: 1;
}
.warm-cell {
  position: relative;
  border: none; border-radius: 13px; padding: 0;
  aspect-ratio: 1;
  overflow: hidden;
  background: linear-gradient(180deg, rgba(143, 207, 143, .5), rgba(95, 164, 107, .42));
  display: flex; align-items: center; justify-content: center;
  /* No font-size and no colour: nothing in a cell is text any more. A stray
     string would show up as a 16px default rather than hide at 9px. */
  font-size: 0;
  transition: background .2s ease, transform .12s ease;
}
.warm-cell:active:not(:disabled) { transform: scale(.93); }
.warm-cell:disabled { opacity: 1; }     /* a dug hole is not a disabled control */

/* The grass. Each tuft leans on its own beat — --d is set per cell in
   renderWarm as a diagonal ripple, so the field moves like wind over a lawn
   instead of pulsing as one rectangle. */
.wc-turf, .wc-mark, .wc-buried { display: block; width: 100%; height: 100%; }
.wc-turf svg, .wc-mark svg, .wc-buried svg { display: block; width: 100%; height: 100%; }
.wc-turf {
  transform-origin: 50% 100%;
  animation: warm-sway 3.6s ease-in-out infinite;
  animation-delay: var(--d, 0s);
}
@keyframes warm-sway {
  0%, 100% { transform: rotate(-2.4deg) scaleY(1); }
  50%      { transform: rotate(2.4deg) scaleY(.985); }
}

/* A dug hole: earth, with the reading sitting in it. The heat ramp stays on
   the tile because it is the only thing that reads the whole board at a glance;
   the drawing is what reads ONE square. Both, not either. */
.warm-cell.dug {
  background: #8E6E52;
  box-shadow: inset 0 0 0 3px rgba(74, 55, 40, .5), inset 0 4px 10px rgba(40, 28, 18, .35);
}
.warm-cell.dug .wc-mark {
  animation: warm-dig .34s cubic-bezier(.2, 1.5, .4, 1) both;
  padding: 12%;
  box-sizing: border-box;
}
@keyframes warm-dig {
  from { transform: scale(.35) translateY(18%); opacity: 0; }
  to   { transform: scale(1) translateY(0); opacity: 1; }
}
/* Cold to burning, as a temperature rather than as five arbitrary colours. The
   halo behind the mark, not the whole tile, so the hole still reads as a hole. */
.warm-cell.h-1 { background: radial-gradient(circle at 50% 50%, rgba(111, 168, 232, .95) 38%, #8E6E52 72%); }
.warm-cell.h0  { background: radial-gradient(circle at 50% 50%, rgba(127, 216, 232, .95) 38%, #8E6E52 72%); }
.warm-cell.h1  { background: radial-gradient(circle at 50% 50%, rgba(247, 227, 154, .98) 38%, #8E6E52 72%); }
.warm-cell.h2  { background: radial-gradient(circle at 50% 50%, rgba(247, 201, 72, .98) 40%, #8E6E52 74%); }
.warm-cell.h3  { background: radial-gradient(circle at 50% 50%, rgba(232, 119, 159, 1) 42%, #8E6E52 76%); }
.warm-cell.h4  { background: radial-gradient(circle at 50% 50%, rgba(95, 199, 155, .95) 44%, #8E6E52 78%); }
/* Burning is the best clue on the board, so it is the one thing that keeps
   moving after it lands — the eye goes to motion before it goes to colour. */
.warm-cell.h3 .wc-mark { animation: warm-dig .34s cubic-bezier(.2, 1.5, .4, 1) both, warm-throb 1.1s ease-in-out .34s infinite; }
@keyframes warm-throb {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.13); }
}

/* Where it is buried, drawn for the hider only (and for both once the round is
   over) — the carrot showing through the blades rather than instead of them. */
.warm-cell.it { background: linear-gradient(180deg, rgba(143, 207, 143, .55), rgba(95, 164, 107, .5)); }
.warm-cell.it .wc-buried {
  position: absolute; top: 18%; right: 18%; bottom: 18%; left: 18%;
  width: auto; height: auto;
  filter: drop-shadow(0 2px 3px rgba(40, 28, 18, .35));
}
.warm-cell.it .wc-turf { opacity: .92; }
/* Just buried: it drops in and the grass closes over it. Fires once, on the
   frame that turns `hide` into `hunt`, because renderWarm only puts `planted`
   on the cell when the phase key has actually changed. */
.warm-cell.planted .wc-buried { animation: warm-plant .5s cubic-bezier(.3, 1.1, .5, 1) both; }
.warm-cell.planted .wc-turf { animation: warm-sway 3.6s ease-in-out infinite, warm-close .5s ease both; }
@keyframes warm-plant {
  0%   { transform: translateY(-70%) scale(1.15); opacity: 0; }
  60%  { transform: translateY(6%) scale(.96); opacity: 1; }
  100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes warm-close {
  0%   { transform: scaleX(1.25) rotate(0deg); }
  55%  { transform: scaleX(.86) rotate(-4deg); }
  100% { transform: scaleX(1) rotate(0deg); }
}
/* THE REVEAL AT THE END OF A ROUND — the payoff, and it has to look like one.
   ⚠️ Until this was photographed, the revealed square was drawn with EXACTLY the
   rules above: `.warm-cell.it`, a small carrot at inset 18% half behind the
   blades. That is the HIDER'S PRIVATE MARKER, whose whole job is to be discreet
   while only one person may see it — so 'here is the answer, to both of you' and
   'only you know this' were the same picture, and the answer was the quietest
   tile on a board where two wrong guesses shouted in amber and blue. Taking the
   turn card off the top of it was necessary and not sufficient.
   So the revealed square takes the found-green that `.h4` was already holding
   for it, the only ring left on the board, and a carrot drawn nearly full-bleed
   in FRONT of grass that parts to let it through. */
.warm-cell.up {
  background: radial-gradient(circle at 50% 58%, rgba(95, 199, 155, .95) 38%, rgba(104, 176, 116, .8) 80%);
  box-shadow: inset 0 0 0 3px rgba(52, 132, 96, .75), 0 0 0 3px rgba(255, 255, 255, .6);
  z-index: 1;
}
/* The blades push apart rather than closing over, which is warm-close reversed:
   the same square did the opposite of this when the carrot went in. */
.warm-cell.up .wc-turf {
  opacity: .55;
  animation: warm-sway 3.6s ease-in-out infinite, warm-part .6s ease both;
}
@keyframes warm-part {
  0%   { transform: scaleX(1) rotate(0deg); }
  45%  { transform: scaleX(1.3) rotate(3deg); }
  100% { transform: scaleX(1.12) rotate(0deg); }
}
/* Bigger than the buried one and over the top of it — inset, not the 18% the
   `.it` rule above sets, and it wins on source order at equal specificity. */
.warm-cell.up .wc-buried {
  top: 5%; right: 5%; bottom: 5%; left: 5%;
  z-index: 2;
  filter: drop-shadow(0 3px 5px rgba(30, 60, 40, .45));
  animation: warm-pop .6s cubic-bezier(.2, 1.5, .4, 1) both;
}
@keyframes warm-pop {
  0%   { transform: translateY(46%) scale(.5); opacity: .15; }
  70%  { transform: translateY(-14%) scale(1.14); opacity: 1; }
  100% { transform: translateY(0) scale(1); opacity: 1; }
}

/* Three seeds drifting over the field. Cheap — three transforms — and it is
   the difference between a board and a place. */
.warm-mote {
  position: absolute; left: 8%; top: 70%;
  width: 7px; height: 7px; border-radius: 50%;
  background: rgba(255, 255, 255, .75);
  box-shadow: 0 0 6px rgba(255, 255, 255, .6);
  pointer-events: none;
  animation: warm-drift 11s linear infinite;
}
.warm-mote.m2 { top: 42%; width: 5px; height: 5px; animation-duration: 15s; animation-delay: -5s; }
.warm-mote.m3 { top: 84%; width: 6px; height: 6px; animation-duration: 13s; animation-delay: -9s; }
@keyframes warm-drift {
  0%   { transform: translate(0, 0); opacity: 0; }
  12%  { opacity: .85; }
  85%  { opacity: .7; }
  100% { transform: translate(310px, -46px); opacity: 0; }
}

/* The rail: the five readings in order, cold on the left. No words, fixed
   height, never changes — so it says which way the ramp runs without becoming
   a thing you have to consult. */
.warm-rail {
  display: flex; justify-content: center; align-items: center; gap: 7px;
  height: 34px; margin: 8px 0 0;
}
.warm-rail .wr {
  width: 26px; height: 26px; border-radius: 8px;
  display: flex; align-items: center; justify-content: center;
  background: rgba(142, 110, 82, .22);
}
.warm-rail .wr svg { width: 84%; height: 84%; display: block; }
.warm-rail .wr.h-1 { background: rgba(111, 168, 232, .3); }
.warm-rail .wr.h0  { background: rgba(127, 216, 232, .3); }
.warm-rail .wr.h1  { background: rgba(247, 227, 154, .45); }
.warm-rail .wr.h2  { background: rgba(247, 201, 72, .45); }
.warm-rail .wr.h3  { background: rgba(232, 119, 159, .4); }

/* ---- how many guesses each hunt gets ----------------------------------------
   game-updates1 item 10. Deniz asked Warmer to "ask for guess number in the
   every beginning of the game, ranging form 3 to 7."

   ⚠️ FIVE PLOTS, NOT FIVE PILLS. CLAUDE.md: "if it has artwork, the artwork is
   the control", and "a row of buttons with the item's NAME on them is the
   defect". Here the name is a NUMBER, which makes the pill row unusually
   tempting and no less wrong — so each choice is a plot of the same field the
   board is made of with that many holes already dug in it, and the count is
   read as a pattern the way a die is read.

   ⚠️ THE ROW MUST NOT WRAP AT ANY WIDTH, AND IT IS SIZED SO THAT IT CANNOT
   RATHER THAN SO THAT IT HAPPENS NOT TO. The first version sized each tile
   `min(17vw, 62px)` with an 8px gap and an arithmetic comment proving it fit a
   390px phone — which it did, and it WRAPPED AT 320. A 320px screen is an
   iPhone SE and plenty of Androids, and five plots on two lines destroys the
   only thing this layout is for: reading 3 / 4 / 5 / 6 / 7 as one row of
   patterns. So the tiles are flex children with a zero basis instead: they
   divide whatever the row has, the row never wraps at any width because
   nothing is asking it to, and max-width only stops them ballooning on a
   tablet. Turkish is the longer language and its title sits ABOVE the row, so
   it cannot push the plots sideways either. */
.warm-pick {
  flex: 1; min-height: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 18px; padding: 0 16px;
}
.warm-pick-title {
  margin: 0; text-align: center; font-size: 17px; font-weight: 800;
  color: var(--ink);
}
.warm-plots {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  width: 100%; max-width: 348px;
}
.warm-plot {
  /* ⚠️ flex: 1 1 0 with min-width: 0 is the no-wrap guarantee — see above. */
  flex: 1 1 0; min-width: 0; max-width: 62px; aspect-ratio: 1;
  border: none; padding: 0; border-radius: 15px;
  background: transparent;
  /* Nothing in a plot is text. A stray glyph would arrive at a 16px default
     rather than hide, the same guard .warm-cell carries. */
  font-size: 0;
  opacity: .58;
  filter: saturate(.7);
  transition: transform .16s ease, opacity .16s ease, filter .16s ease, box-shadow .16s ease;
}
.warm-plot svg { display: block; width: 100%; height: 100%; border-radius: 15px; }

/* ⚠️ SELECTED IS ON THE TILE, NOT ON A CHIP BESIDE IT — CLAUDE.md again. The
   chosen plot is raised, fully saturated, ringed in the accent and its grass
   sways; the other four sit flat and dimmed. Four differences, not one, so it
   survives a colour-blind eye and a bright screen. */
.warm-plot.on {
  opacity: 1;
  filter: none;
  transform: translateY(-6px) scale(1.06);
  box-shadow: 0 0 0 3px var(--accent), 0 10px 22px rgba(63, 48, 130, .26);
}
.warm-plot.on svg { animation: warm-sway 3.6s ease-in-out infinite; transform-origin: 50% 100%; }
.warm-plot:active:not(:disabled) { transform: scale(.94); }
.warm-plot:disabled { opacity: .4; }
.warm-plot.on:disabled { opacity: .78; }

/* The one numeral on this screen. It restates the current choice as you tap,
   so the count is stated unambiguously ONCE rather than printed onto every
   tile — which would be the pill row wearing a picture. */
.warm-pick-count {
  margin: 0; text-align: center; font-size: 15px; font-weight: 800;
  color: var(--accent);
}
.warm-pick-go {
  width: min(100%, 280px); border: none; border-radius: 22px;
  padding: 15px; background: var(--accent); color: #FFFFFF;
  font-size: 17px; font-weight: 800;
  box-shadow: 0 8px 22px rgba(124, 92, 224, .32);
  transition: transform .1s ease;
}
.warm-pick-go:active { transform: scale(.97); }

/* ---- the turn card ----------------------------------------------------------
   Deniz, 2026-08-24: "Every partner game should signify the other partner's
   turn change more apperantly, like in the warmer game, when the partner
   finishes hiding, only a slient text change notifies that, that should not be
   the case."

   ⚠️ Fixed to the VIEWPORT and parented to <body>, not to #game-layer — every
   duel frame replaces that element's innerHTML, so a card living inside it
   would be swept away by the next frame that arrived during its own hold.
   pointer-events: none throughout: it is an announcement, never a dialog, and
   it must not eat the tap that comes half a second after it. */
.turn-card {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 86;
  display: flex; align-items: center; justify-content: center;
  pointer-events: none;
  background: rgba(24, 18, 48, .34);
  animation: tc-veil .26s ease both;
}
.turn-card.out { animation: tc-veil-out .4s ease both; }
@keyframes tc-veil { from { opacity: 0; } to { opacity: 1; } }
@keyframes tc-veil-out { from { opacity: 1; } to { opacity: 0; } }
.tc-in {
  min-width: 232px; max-width: 76vw;
  padding: 22px 26px 20px;
  border-radius: 28px;
  text-align: center;
  /* Solid, not var(--card): that token is rgba(255,255,255,.52) and a
     translucent card over a translucent veil is a grey smear, not a card. */
  background: #FFF6E9;
  box-shadow: 0 18px 44px rgba(38, 26, 84, .34);
  border: 3px solid rgba(124, 92, 224, .3);
  animation: tc-in .44s cubic-bezier(.2, 1.4, .4, 1) both;
}
.turn-card.out .tc-in { animation: tc-out .4s ease both; }
.turn-card.mine .tc-in { border-color: var(--accent, #7C5CE0); }
@keyframes tc-in {
  0%   { transform: translateY(34px) scale(.86); opacity: 0; }
  100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes tc-out {
  0%   { transform: translateY(0) scale(1); opacity: 1; }
  100% { transform: translateY(-16px) scale(.96); opacity: 0; }
}
.tc-art { display: block; width: 92px; height: 92px; margin: 0 auto 8px; }
.tc-art svg { display: block; width: 100%; height: 100%; }
/* The drawing arrives a beat after the card so the eye lands on it second —
   the card is the interruption, the picture is the message. */
.turn-card.mine .tc-art { animation: tc-art-mine .7s cubic-bezier(.2, 1.5, .4, 1) .1s both; }
.turn-card:not(.mine) .tc-art { animation: tc-art-theirs .8s ease .1s both; }
@keyframes tc-art-mine {
  0%   { transform: scale(.3) rotate(-16deg); opacity: 0; }
  60%  { transform: scale(1.14) rotate(6deg); opacity: 1; }
  100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
@keyframes tc-art-theirs {
  0%   { transform: translateX(-14px); opacity: 0; }
  100% { transform: translateX(0); opacity: .85; }
}
.tc-title {
  display: block; font-size: 21px; font-weight: 900; letter-spacing: -.01em;
  color: var(--ink, #2B2350);
}
.turn-card.mine .tc-title { color: var(--accent, #7C5CE0); }
.tc-sub { display: block; margin-top: 3px; font-size: 13.5px; font-weight: 700; color: var(--muted, #6B6390); }

/* ---- Guess Me ---------------------------------------------------------------
   The question on a card, the four answers as cards under it. Each answer
   carries a drawn mark so the four are told apart by shape as well as by
   position — which is what stops it reading as a form. */
.quiz-card {
  flex: 1; min-height: 0; overflow-y: auto;
  padding: 4px 14px 10px; display: flex; flex-direction: column; gap: 12px;
}
.quiz-q {
  margin: 0; text-align: center; font-size: 19px; font-weight: 800;
  line-height: 1.32; color: var(--ink);
}
.quiz-opts { display: flex; flex-direction: column; gap: 9px; }
.quiz-opt {
  display: flex; align-items: center; gap: 12px; text-align: left;
  padding: 12px 14px; border-radius: 18px; border: 2px solid transparent;
  background: var(--card-bg, rgba(255, 255, 255, .8));
  box-shadow: 0 4px 14px rgba(63, 48, 130, .1);
  font-size: 15px; font-weight: 700; color: var(--ink);
  transition: transform .12s ease, border-color .16s ease;
  position: relative;
}
.quiz-opt:active:not(:disabled) { transform: scale(.985); }
.quiz-mark { width: 26px; height: 26px; flex: 0 0 26px; }
.quiz-mark svg { width: 100%; height: 100%; display: block; }
.quiz-text { flex: 1; }
.quiz-opt.picked { border-color: var(--accent); }
.quiz-opt.truth { border-color: #5FA46B; }
.quiz-opt.guess { border-color: #F7C948; }
.quiz-tag {
  font-size: 10px; font-weight: 900; text-transform: uppercase;
  letter-spacing: .06em; color: #5FA46B; white-space: nowrap;
}
.quiz-tag.alt { color: #C79A16; }
.quiz-opt:disabled { opacity: 1; }   /* a revealed answer is not a dead control */

/* ---- Together ---------------------------------------------------------------
   Four dots. Three of them light; the fourth never does, and that is the one
   you are aiming at. It is drawn hollow from the start so nobody is waiting for
   a flash that is not coming. */
.sync-field {
  flex: 1; display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: 26px; min-height: 0;
}
.sync-beats { display: flex; gap: 16px; align-items: center; }
.sync-dot {
  width: 20px; height: 20px; border-radius: 50%;
  background: rgba(124, 92, 224, .22);
  transition: transform .1s ease, background .1s ease;
}
.sync-dot.lit { background: var(--accent); transform: scale(1.45); }
.sync-dot.silent {
  background: none; border: 3px dashed rgba(232, 119, 159, .75);
  width: 24px; height: 24px;
}
.sync-pad {
  border: none; padding: 0; background: none;
  width: min(52vw, 190px); aspect-ratio: 1;
  transition: transform .1s ease;
}
.sync-pad svg { width: 100%; height: 100%; display: block; }
.sync-pad:active:not(:disabled) { transform: scale(.94); }
.sync-pad.done { opacity: .45; }

/* ---- Colour Call ------------------------------------------------------------
   The word is the stage and the pots are the control. The pots are drawn, not
   swatches with names on them — same rule as the doodle screen's ink pots,
   which is where the shape came from.

   new-games-1: which of ink/word is being asked changes every prompt, so it
   gets its OWN banner rather than reusing .duel-status's old fixed "Tap the
   ink" line. It stays up through the penalty lockout too, deliberately — that
   is exactly when a player most needs reminding what they are about to tap
   next. Icon shape + text carry the distinction, never colour alone: the two
   pot colours this banner sits above are the one thing on this screen that
   DOES need colour precision (see call-color-test.js), so the ask itself is
   built to survive being read by someone who cannot rely on colour at all. */
.call-ask {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  margin: 2px 16px 6px; padding: 10px 20px;
  background: var(--ink); color: #fff;
  clip-path: polygon(4% 0, 100% 0, 96% 100%, 0% 100%);
  font-size: clamp(15px, 4.4vw, 20px); font-weight: 900; letter-spacing: .01em;
  animation: callAskPop .28s ease;
}
.call-ask svg { width: 24px; height: 24px; flex: 0 0 auto; }
/* Decorative only — the shape (droplet vs text-lines) is what actually tells
   the two apart, not this colour. */
.call-ask.is-ink svg { color: #F0C13B; }
.call-ask.is-word svg { color: #5C97F0; }
@keyframes callAskPop {
  0% { transform: scale(.85); opacity: .4; }
  60% { transform: scale(1.05); }
  100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .call-ask { animation: none; }
}
.call-stage {
  flex: 1; display: flex; align-items: center; justify-content: center;
  min-height: 0; transition: opacity .2s ease;
}
.call-stage.locked { opacity: .3; }
.call-word {
  font-size: clamp(38px, 13vw, 66px); font-weight: 900;
  letter-spacing: .01em;
  /* ⚠️ --pen, NOT --ink. The app already has an --ink design token for its
     darkest text colour, and the first draft of this rule set a custom property
     of the same name inline on the element — which would have shadowed the
     token for that element and everything inside it. A game that quietly
     recolours a design token is a bug that turns up three screens away. */
  color: var(--pen, var(--ink));
}
.call-pots {
  display: flex; justify-content: center; gap: 8px; flex-wrap: wrap;
  padding: 8px 12px 4px;
}
.call-pot {
  border: none; padding: 0; background: none;
  width: min(15vw, 62px); aspect-ratio: 1;
  color: var(--c);                  /* the pot's ink is currentColor */
  transition: transform .12s ease, opacity .2s ease;
}
.call-pot svg { width: 100%; height: 100%; display: block; }
.call-pot:active:not(:disabled) { transform: translateY(3px) scale(.96); }
.call-pot:disabled { opacity: .35; }
/* var(--bad), not one of CALL_COLORS: the old value (#E8617A) WAS the previous
   Red pot's own hex, which coupled this generic "wrong" colour to one specific
   swatch — and that swatch's hex is exactly what changed. */
.duel-status.bad { color: var(--bad); }

/* ---- the turn glow: REMOVED 2026-08-26, DO NOT RE-ADD ----------------------
   `turn-cue` (2026-08-25) painted a pulsing accent ring on a `mine` class each
   render function added to its own board element, because Deniz — asked what
   should replace the blocking "your turn" card in a one-tap duel — answered
   "both": a ribbon AND a board-edge glow. He then played it: *"The edge glow in
   the games when the turns pass over should be removed."*

   ⚠️ THE RIBBON ABOVE IS THE WHOLE VISUAL CUE NOW — `.duel-status.mine`, plus
   the sound and the buzz turnBuzz() still makes on every handover. Line
   Battle's `.lb-board.mine` and Hedge Race's `.hr-wrap.mine .hr-board` were
   separate implementations of the same idea and went with it. Cannon Duel's
   `.cn-cannon.active` STAYS: it glows the PIECE that is yours, not the board's
   edge, and is that game's only board-level cue. `.gem-grid.mine` is unrelated
   — there `.mine` means "a board you play on".
   The record is docs/archive/game-updates1/turn-glow.md. */

/* ---- Rooftop Run ------------------------------------------------------------
   A canvas, a strip saying which of the two you are, and a thumb pad. The pad
   sits under the stage rather than over it: a control floating on the course
   covers exactly the part of it you are trying to look at. */
.roof-bar {
  display: flex; align-items: center; justify-content: space-between;
  padding: 2px 14px 6px; font-size: 13px; font-weight: 700; color: var(--muted);
}
.roof-who { display: inline-flex; align-items: center; gap: 7px; }
.roof-who svg { width: 26px; height: 26px; display: block; }
.roof-who.heavy b { color: #7C5CE0; }
.roof-who.light b { color: #2F9F7B; }
.roof-stage {
  flex: 1; min-height: 0; position: relative;
  display: flex; align-items: center; justify-content: center;
  padding: 0 8px;
}
.roof-stage canvas {
  display: block; border-radius: 16px;
  box-shadow: 0 10px 30px rgba(38, 34, 58, .35);
  /* ⚠️ The canvas must not eat the touch that was meant for the pad, and on a
     small screen the two are close together. */
  touch-action: none;
}
.roof-note {
  position: absolute; left: 50%; top: 42%;
  transform: translate(-50%, -50%) scale(.9);
  padding: 10px 18px; border-radius: 16px;
  background: rgba(38, 34, 58, .82); color: #FFF6E9;
  font-size: 16px; font-weight: 800; white-space: nowrap;
  opacity: 0; pointer-events: none;
  transition: opacity .25s ease, transform .25s ease;
}
.roof-note.up { opacity: 1; transform: translate(-50%, -50%) scale(1); }
.roof-tally {
  display: flex; justify-content: center; gap: 22px;
  font-size: 14px; font-weight: 700; color: var(--muted); padding: 6px;
}
.roof-pad {
  display: grid; grid-template-columns: 1fr 1fr 1.15fr;
  gap: 10px; padding: 10px 14px 4px;
  /* ⚠️ A fixed slot. The pad is here whether or not the level is running, so
     the stage above it never changes height mid-match. */
  min-height: 84px;
}
.rp {
  border: none; border-radius: 22px; padding: 0;
  background: rgba(124, 92, 224, .16);
  color: #4B3B8F;
  display: flex; align-items: center; justify-content: center;
  transition: transform .08s ease, background .08s ease;
  touch-action: none; user-select: none; -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}
.rp svg { width: 46%; height: 46%; display: block; }
.rp.right svg { transform: scaleX(-1); }
.rp.jump { background: rgba(95, 199, 155, .2); color: #2F9F7B; }
.rp.on { transform: scale(.95); background: rgba(124, 92, 224, .34); }
.rp.jump.on { background: rgba(95, 199, 155, .4); }

/* ⚠️ The keyboard is the point on a desktop, so say so there and nowhere else.
   A phone showing "press W to jump" is an instruction it cannot follow. */
@media (hover: hover) and (pointer: fine) {
  .roof-pad { opacity: .55; }
}

/* ---- Word Bridge -------------------------------------------------------
   The round screen mirrors the tile that opens it (game-svg.js): a card
   holding the word you were given, a card of blanks for the word you owe,
   an arc between them. Decision 5 in the record: a stage, not a pill row —
   the pick phase below is two plaques and a swap arrow, never a menu. */

/* -- choosing the direction, once, before round one -- */
.lg-pick {
  flex: 1; min-height: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 22px; padding: 0 16px;
}
.lg-pick-title { margin: 0; text-align: center; font-size: 17px; font-weight: 800; color: var(--ink); }
.lg-plaques { display: flex; align-items: center; gap: 14px; }
.lg-plaque {
  min-width: 96px; padding: 14px 20px; border-radius: 20px; text-align: center;
  background: var(--card-bg, rgba(255, 255, 255, .85));
  box-shadow: 0 6px 18px rgba(63, 48, 130, .14);
  font-size: 17px; font-weight: 800; color: var(--ink);
}
.lg-swap {
  width: 52px; height: 52px; border: none; border-radius: 50%;
  background: var(--accent); color: #FFFFFF; font-size: 22px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 6px 16px rgba(124, 92, 224, .4);
  transition: transform .18s ease;
}
.lg-swap:active:not(:disabled) { transform: rotate(180deg) scale(.94); }
.lg-swap:disabled { opacity: .35; }
.lg-waiting { font-size: 28px; color: var(--muted); opacity: .6; }
.lg-start {
  width: min(100%, 280px); margin-top: 4px; border: none; border-radius: 22px;
  padding: 15px; background: var(--accent); color: #FFFFFF;
  font-size: 17px; font-weight: 800;
  box-shadow: 0 8px 22px rgba(124, 92, 224, .32);
  transition: transform .1s ease;
}
.lg-start:active { transform: scale(.97); }

/* -- one round: the given word, the arc, the word you owe -- */
.lg-stage {
  flex: 1; min-height: 0; display: flex; align-items: center;
  justify-content: center; gap: 2px; padding: 10px 12px;
}
.lg-card {
  flex: 1; max-width: 42%; border-radius: 18px; padding: 14px 10px;
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  box-shadow: 0 6px 18px rgba(63, 48, 130, .14);
}
.lg-given { background: var(--accent); color: #FFFFFF; transform: rotate(-3deg); }
.lg-owed { background: var(--card-bg, rgba(255, 255, 255, .9)); transform: rotate(2deg); }
.lg-card-lbl {
  font-size: 10px; font-weight: 900; text-transform: uppercase;
  letter-spacing: .07em; opacity: .72;
}
.lg-given .lg-card-lbl { color: rgba(255, 255, 255, .82); }
.lg-owed .lg-card-lbl { color: var(--muted); }
.lg-prompt {
  margin: 0; font-size: clamp(17px, 5.6vw, 25px); font-weight: 800;
  text-align: center; word-break: break-word;
}
/* The bridge, said the same way the tile says it: a bold gold stroke, not a
   plain arrow — an arrow would point somewhere; this joins two things. */
.lg-arc {
  flex: none; align-self: center; width: 26px; height: 6px; border-radius: 3px;
  background: #F7C948; transform: rotate(-20deg);
}
.lg-blanks { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
.lg-word { display: flex; gap: 4px; }
.lg-word i {
  display: block; width: 13px; height: 3px; border-radius: 2px;
  background: rgba(75, 59, 143, .3);
}
/* The one letter a hint reveals — codepoint-safe on the server, and drawn as
   an actual letter here rather than a wider blank, so it reads as "revealed"
   instead of "different size". */
.lg-word i.lg-lit {
  width: auto; height: auto; background: none; border-radius: 0;
  font-size: 18px; font-weight: 800; color: var(--accent); font-style: normal;
}

.lg-stage-reveal { flex-direction: column; gap: 6px; }
/* ⚠️ The ONE flex gap in this file the generated block missed, because the
   rule sets flex-direction without setting display — the display comes from
   .lg-stage elsewhere. Found by webview-compat-guard.js as finding 173 of
   248, which is the whole argument for grading a guard's output. */
html.no-flexgap .lg-stage-reveal > * + * { margin-top: 6px; }
.lg-answer { margin: 0; font-size: 19px; font-weight: 700; color: var(--ink); text-align: center; }
.lg-answer em { font-style: normal; color: var(--accent); }

.lg-input-row { display: flex; gap: 8px; padding: 4px 14px 8px; align-items: center; }
.lg-line {
  flex: 1; min-width: 0; border: 2px solid rgba(124, 92, 224, .25);
  border-radius: 16px; padding: 11px 14px; font: inherit; font-size: 16px;
  color: var(--ink); background: var(--card-bg, rgba(255, 255, 255, .85));
  outline: none;
}
.lg-line:focus { border-color: var(--accent); }
.lg-hint-btn {
  border: none; border-radius: 16px; padding: 11px 16px; white-space: nowrap;
  background: rgba(247, 201, 72, .3); color: #8A6A0F;
  font-size: 14px; font-weight: 800;
}
.lg-hint-btn:disabled { opacity: .4; }

@media (prefers-reduced-motion: reduce) {
  .shade-cell, .tug-knot, .tug-pad, .sync-dot, .sync-pad, .call-pot, .rp,
  .quiz-opt, .warm-cell, .roof-note, .lg-swap { transition: none; animation: none; }
  /* R23: the whole Warmer field goes still — the swaying grass, the seeds
     on the wind, the throb on the hottest hole, the carrot dropping in and
     coming back out. */
  .wc-turf, .wc-mark, .wc-buried, .warm-mote { animation: none; }
  /* ⚠️ THE TURN CARD IS NOT IN THAT LIST, and that is the point of writing
     this rule by hand rather than reaching for a blanket `* { animation:
     none }`. Somebody who turned motion off did not ask to stop being told
     whose turn it is — the whole task is that a handover must not be a
     silent text change. So the sliding and the spin stop, the card and its
     sound stay, and it crossfades instead. */
  .tc-in, .tc-art { animation: none; }
  .turn-card { animation: tc-veil .26s ease both; }
  .turn-card.out { animation: tc-veil-out .4s ease both; }
}

/* ---------- R19/9: the arcade is two rooms, not one shelf ----------
   Deniz, 2026-08-22: "make the distinction between partner games and solo
   games with different tabs, but they should be very apparent, not like a
   small part at the top."

   So the switch is 124px tall, sits between the header and the scroller (never
   scrolls away), and each half is a picture rather than a word. The live tab
   GROWS, keeps its colour and is anchored to the shelf by a bar in that
   colour; the other one shrinks, desaturates and sinks. And the whole page
   under it changes from rose to violet, because two tabs that differ only in
   which one is underlined is exactly the small part at the top he is
   complaining about.

   ⚠️ Everything here is scoped to .arcade-tabs / .gal-pane / .gal-grid.solo /
   .gal-grid.duo. The fridge and the diary share .gal-card and .gal-grid and
   must keep the plain version. */

.arcade-tabs {
  flex: 0 0 auto;
  display: flex; align-items: stretch; gap: 8px;
  padding: 12px 14px 14px;
  border-bottom: 1px solid var(--card-brd);
  transition: background .3s ease;
}
.arcade-tabs.duo { background: linear-gradient(180deg, rgba(255, 255, 255, .62), rgba(255, 214, 232, .55)); }
.arcade-tabs.solo { background: linear-gradient(180deg, rgba(255, 255, 255, .62), rgba(219, 205, 250, .55)); }

.atab {
  position: relative; overflow: hidden;
  flex: 1 1 0; min-width: 0; min-height: 124px;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 0; padding: 12px 8px 16px;
  border-radius: 24px; border: 2px solid transparent;
  color: var(--ink); text-align: center; cursor: pointer;
  transition: flex-grow .3s cubic-bezier(.34, 1.3, .5, 1), filter .22s ease,
              opacity .22s ease, box-shadow .24s ease, transform .18s ease;
}
.atab.duo { background: linear-gradient(168deg, #FFE7F0 0%, #FFC8DE 100%); }
.atab.solo { background: linear-gradient(168deg, #EDE4FE 0%, #C9B6F5 100%); }
/* The one that is NOT open has to look shut, not merely unmarked — and not
   DISABLED either. ⚠️ The first two attempts desaturated the whole tab with a
   filter, and a desaturated pink is beige: the shut half read as a greyed-out
   control rather than as the other room. It gets a pale, flat version of its
   own colour instead, so it still says which room it is, and only the artwork
   is dimmed. */
.atab:not(.on) {
  flex-grow: .74; transform: scale(.96);
  box-shadow: inset 0 2px 9px rgba(63, 48, 130, .12);
}
.atab:not(.on).duo { background: #F7EBF1; }
.atab:not(.on).solo { background: #EEE9F8; }
.atab:not(.on) .atab-art { filter: saturate(.55) opacity(.82); }
.atab:not(.on) .atab-name,
.atab:not(.on) .atab-kicker,
.atab:not(.on) .atab-count { opacity: .74; }
.atab.on { flex-grow: 1.26; transform: translateY(-1px); box-shadow: 0 12px 24px rgba(63, 48, 130, .2); }
.atab.on.duo { border-color: #E8779F; }
.atab.on.solo { border-color: #7C5CE0; }
.atab:active { transform: scale(.94); }
/* The base: the live tab stands on a bar of its own colour. ⚠️ Full width and
   square — inset and rounded it read as a floating lozenge somebody forgot to
   finish, which is what the first version looked like in the screenshots. The
   tab's own overflow:hidden and 24px radius do the shaping. */
.atab.on::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 7px;
}
.atab.on.duo::after { background: #E8779F; }
.atab.on.solo::after { background: #7C5CE0; }

.atab-art { width: 60px; height: 60px; display: block; transition: width .24s ease, height .24s ease; }
.atab:not(.on) .atab-art { width: 40px; height: 40px; }
.atab-art svg { width: 100%; height: 100%; display: block; filter: drop-shadow(0 3px 4px rgba(63, 48, 130, .22)); }
.atab-kicker {
  margin-top: 6px; max-width: 100%;
  font-size: 10px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase;
  color: rgba(43, 35, 80, .58);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.atab-name {
  font-size: 18px; font-weight: 900; line-height: 1.18; color: var(--ink); max-width: 100%;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  transition: font-size .2s ease;
}
.atab:not(.on) .atab-name { font-size: 15px; }
.atab-count {
  margin-top: 2px;
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 11.5px; font-weight: 700; color: rgba(43, 35, 80, .6);
}
/* Presence, as a dot rather than a sentence — the tab has no room for a
   sentence and the strip below already carries one. */
.pdot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); display: inline-block; flex: 0 0 auto; }
.pdot.on { background: var(--good); box-shadow: 0 0 0 3px rgba(47, 159, 123, .22); }

/* The world under the tabs. */
.gal-body.arcade { transition: background .3s ease; }
/* The 0% stop of each is ALSO the opaque base the sticky difficulty rail paints
   itself on — it pins to the very top of this element, so this is exactly the
   colour behind it. Named rather than repeated: the rail rule is 800 lines up
   and a second copy of the literal is a second thing to forget. */
.gal-body.arcade.duo { --arc-top: #FDF1F6; background: linear-gradient(180deg, var(--arc-top) 0%, #F7DCEA 58%, #F0C9DE 100%); }
.gal-body.arcade.solo { --arc-top: #F4EFFE; background: linear-gradient(180deg, var(--arc-top) 0%, #E4DAF9 58%, #D2C4F4 100%); }

/* Who you are playing with. Only the duo half has this, and the asymmetry is
   the point: the solo half has nobody to report on and a matching strip there
   would be a heading saying what the tab above it just said. */
.gal-shelf.duo-strip {
  /* arcade-density, 2026-08-25: margin-top 10->6, padding 10->8 — the same
     handful of pixels back everything else on this shelf is giving up. */
  margin: 6px 2px 2px; padding: 8px 14px;
  border: 0; border-radius: 16px;
  background: rgba(255, 255, 255, .68);
  box-shadow: 0 4px 14px rgba(140, 50, 95, .09);
  align-items: center;
}
.gal-shelf.duo-strip b { font-size: 15px; }
.gal-shelf.duo-strip .ds-live {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: 12px; font-weight: 800; color: var(--good);
}

/* ---- the together half: arenas ----
   A wide card whose field is split diagonally into your side and theirs, with
   the game sitting on the seam. It is the only thing that says "this one takes
   two of you" without writing the words on the tile. */
/* ⚠️ arcade-density, 2026-08-25: 9px -> 7px. new-games-1's `timebattle` row
   (7 games, 3 more rows) pushed this shelf to a 4th screenful; every legible
   px this card can give back without touching the seam (below) goes back.
   docs/archive/new-games-1/arcade-density.md has the full arithmetic — this
   alone is not enough to reach 3 screens and is not trying to be. */
.gal-grid.duo .gal-card {
  padding: 0 0 7px; gap: 0; overflow: hidden; border-radius: 18px;
  background: #FFFFFF;
  border-color: rgba(232, 119, 159, .32);
  box-shadow: 0 5px 14px rgba(140, 50, 95, .11);
}
/* ⚠️ R20.3 took this stage from 16:9 to 4:3-capped-at-112px. At a third of a
   390px screen the tile is 111px wide, and a 16:9 field on it is 62px tall —
   shorter than the 64px badge that is supposed to sit in the middle of it. The
   SEAM is what carries the meaning, not the size of the field, and a full-width
   diagonal still reads at 111px.

   ⚠️ The max-height is not belt-and-braces. A ratio does not stop growing: 4:3
   is 83px tall on a phone tile and 139px on a 615px one, which made the WIDE
   shelf a screenful deeper than it was before this item started. The cap turns
   the wide tile back into roughly the field item 9 drew.

   ⚠️⚠️ AND overflow: hidden IS LOAD-BEARING, NOT TIDINESS. This stage is a FLEX
   ITEM, so its automatic minimum size is its min-content height — and that is
   computed with the percentage height on .gc-art treated as indefinite, so the
   badge contributes its replaced SVG's fallback size rather than 52px. Without
   this line the stage's minimum blows past its 4:3 height and max-height
   catches it at 112px ON EVERY SCREEN, phone included: the tile went 129px to
   157px and three fewer games fit on the shelf, while every 615px number looked
   perfect. An overflow other than visible zeroes that automatic minimum. Same
   family as the "min-height: 0 is load-bearing" note on .gal-body.fit-room, and
   the solo stage below has had it all along. */
.gal-grid.duo .gc-stage {
  position: relative; overflow: hidden;
  width: 100%; aspect-ratio: 4 / 3; max-height: 112px;
  /* arcade-density, 2026-08-25: 7px -> 5px, same reason as .gal-card's own
     padding cut just above — the SIZE of the field is untouched (still 4:3,
     still capped at 112px) because that ratio is what keeps the diagonal seam
     legible; only the air below it shrank. */
  display: grid; place-items: center; margin-bottom: 5px;
  background: linear-gradient(104deg, #FFD3E4 0%, #FFD3E4 49.5%, #DED0FB 50.5%, #DED0FB 100%);
  border-bottom: 1px solid rgba(232, 119, 159, .22);
}
.gal-grid.duo .gc-stage::before {
  content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  background: linear-gradient(104deg, transparent 49.2%, rgba(255, 255, 255, .9) 49.2%,
              rgba(255, 255, 255, .9) 50.8%, transparent 50.8%);
}
/* ⚠️ A PERCENTAGE of the stage, not a fixed size — and a FIXED padding, not a
   percentage one. R20.3 first tried 46px, which is 63% of the stage on a 390px
   phone and 33% on a 615px tablet: the same tile, two different drawings. And a
   percentage padding would have been worse than wrong-looking, because
   percentage padding resolves against the CONTAINING BLOCK's width — the stage,
   not the badge — which collapses a 52px badge's drawing to 26px.

   ⚠️⚠️ AND IT IS A PERCENTAGE OF THE WIDTH WITH A min() CEILING, because the
   obvious version of this — 63% of the stage's HEIGHT, so the badge stops when
   the stage's max-height stops it — DOES NOT RESOLVE THE WAY THE ARITHMETIC
   SAYS. A percentage height on a grid item in an auto-sized row came out at
   69px inside an 83px stage, so the disc was clipped top and bottom by the
   stage's own overflow and read as a white BAND across the tile — covering the
   diagonal seam, which is now the only thing left saying "this one takes two of
   you". Every assertion passed; the screenshot is what said it (§4.4).

   A width percentage resolves predictably. The min() is there for the capped
   stage, where a width percentage would otherwise run away:

     111px tile → min(46% = 51px, 66px) = 51px  →  61% of an 83px stage
     186px tile → min(46% = 86px, 66px) = 66px  →  59% of a 112px stage */
.gal-grid.duo .gc-art {
  position: relative; width: min(46%, 66px); height: auto; aspect-ratio: 1; padding: 6px;
  background: #FFFFFF; border-radius: 50%;
  box-shadow: 0 4px 10px rgba(140, 50, 95, .2);
}
.gal-grid.duo .gc-name { padding: 0 6px; }
/* Away: the arena is still legible, just drained. The dashed edge and the note
   on the strip say why; the card keeps describing its own game. */
.gal-grid.duo .gal-card.ping { border-style: dashed; border-color: rgba(232, 119, 159, .55); }
.gal-grid.duo .gal-card.ping .gc-stage { filter: saturate(.4); }

/* ---- the on-your-own half: cabinets ----
   Narrower, more of them to a row, and the artwork is RECESSED into a lit
   screen instead of floating on a card. Same games, a different room. */
.gal-grid.solo .gal-card {
  padding: 7px 7px 9px; gap: 0; border-radius: 16px;
  background: linear-gradient(180deg, #FFFFFF 0%, #F0E9FF 100%);
  border-color: rgba(124, 92, 224, .28);
  box-shadow: 0 4px 12px rgba(63, 48, 130, .12);
}
/* ⚠️ max-height for the same reason as the arena's — see the R20.3 block below.
   An aspect-ratio alone made this cabinet 131px tall on a 615px screen, taller
   than the 92px it was before three-across, and the shelf grew rather than
   shrank. */
.gal-grid.solo .gc-stage {
  position: relative; overflow: hidden;
  width: 100%; aspect-ratio: 1 / .76; max-height: 116px; margin-bottom: 7px;
  border-radius: 12px;
  background: radial-gradient(120% 110% at 50% 14%, #4B3B8F 0%, #2B2350 72%);
  display: grid; place-items: center;
  box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .13), inset 0 10px 20px rgba(0, 0, 0, .34);
}
.gal-grid.solo .gc-stage::after {
  content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; pointer-events: none;
  background: repeating-linear-gradient(180deg, rgba(255, 255, 255, .075) 0 1px, transparent 1px 3px);
}
.gal-grid.solo .gc-art {
  width: 66%; height: 66%;
  filter: drop-shadow(0 0 7px rgba(167, 139, 240, .7));
}

/* ---- R20.3, 2026-08-23: three to a row, and one card height ---------------
   Deniz, having driven R19: *"the games grid is too large, 2 games per row
   really makes it difficult to see things, you end up having to scroll so
   much, make it 3 and make them a bit tidier"*. On the screen he was driving,
   Together showed TWO games at a time and ran four screenfuls deep.

   ⚠️ THIS IS A DELIBERATE PARTIAL REVERSAL OF R19 ITEM 9, and what it costs is
   written down rather than discovered later. Item 9 told the two halves apart
   two ways: a different SURFACE (a split rose arena against a lit violet
   cabinet) and a different SIZE (*"a duel is a bigger card than a score run"*).
   Three-across on both sides makes them the same width, so **the surface is now
   carrying that distinction alone.** If the two halves ever start to look like
   the same wall of tiles twice, this rule is what did it — put the size
   difference back before blaming the artwork.

   ⚠️ AND THE COLUMN COUNT IS NOW CHOSEN, NOT COMPUTED. An auto-fill track list
   with a minmax floor is what made the duo half two-across at 390px and the
   solo half four-across at 615px: the number moved with the width and neither
   value was ever picked by anyone. Three fixed columns are the answer to the
   question he actually asked. Above 700px — a desktop browser, never a phone —
   it goes back to auto-fill, because three tiles across 1200px is not a grid,
   it is three posters. */
.gal-grid.duo,
.gal-grid.solo { grid-template-columns: repeat(3, 1fr); }
/* arcade-density, 2026-08-25: the duo shelf's own row gap tightened 10px ->
   8px, same reason as the card/stage trims above — it grew a 4th category and
   this gives a little back. The solo shelf keeps 10px: it already fits three
   screenfuls with room to spare and there is no reason to touch it. */
.gal-grid.duo { gap: 8px; }
.gal-grid.solo { gap: 10px; }

@media (min-width: 700px) {
  .gal-grid.duo,
  .gal-grid.solo { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
}

/* One height for every tile on the shelf. The name gets a fixed TWO-LINE box —
   the clamp was already two lines, this is the matching floor — so a one-word
   game and a three-word one produce the same card and the rows stop being
   ragged. That raggedness was half of what "tidier" meant.

   ⚠️ 12px is not a taste decision. At 111px wide, "Rock · Paper · Scissors" and
   "Yılan ve Merdiven" have to land inside two lines or they are CLIPPED, and a
   clipped name is invisible in a screenshot of a shelf where nineteen other
   names are fine. arcade-density-check.js counts clipped names in English and
   in Turkish; if that count is not zero, this number is wrong. */
.gal-grid.duo .gc-name,
.gal-grid.solo .gc-name {
  font-size: 12px; line-height: 1.2; min-height: 2.4em;
}

/* ⚠️ THE BLURB COMES OFF THE TILE, AND THIS IS THE DELIBERATE PART.
   There is no font size at which "Best of five against your partner." is a
   readable thing on a 111px tile, and three wrapped lines of it under every
   game is what made the Together shelf 2117px tall. The tile is the artwork and
   its name; that is what a three-across grid can carry — WORKING-RULES §4.4
   rule 1, "show the thing, not its name", with the corollary that a paragraph
   is not the thing either.

   ⚠️ IT IS HIDDEN, NOT DELETED, and it is worth knowing why on both counts.
   Visually hidden it is still the tile's accessible description and still read
   aloud, which an icon grid needs. It is also read by three harnesses —
   game-art-check ("every card has a blurb"), room-games-check and
   arcade-tabs-check (both count DISTINCT blurbs) — so deleting it would fail
   them, and that is a fact to state rather than a reason to pretend.

   ⚠️ AND IT IS HIDDEN AT EVERY WIDTH, WHICH WAS THE SECOND ATTEMPT. The first
   brought it back at 560px, where a tile is 186px and the sentence genuinely
   fits — and that made the 615px shelf WORSE than before this item started:
   card 171px → 216px, three screenfuls → four. A tidy-up that improves the
   phone and regresses the tablet is not a tidy-up. One design at every size.

   ⚠️ THE COST, STATED PLAINLY: the twenty-seven game blurbs are now visible
   NOWHERE in the app. The invite and waiting screens never showed them, and a
   solo game launches with no screen in between. The right home for them is the
   invite screen, which is games.js and belongs to nobody in this batch —
   docs/archive/r20/R20-3-arcade-grid.md carries it as the follow-up. */
.gal-grid.duo .gc-sub,
.gal-grid.solo .gc-sub {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; border: 0;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}

/* The shelf headings inside the arcade only — the fridge and the diary keep
   theirs. Three category labels down a shelf that just lost a third of its
   height were spending 84px on air.
   arcade-density, 2026-08-25: tightened again, 13/7 -> 10/6, now that a 4th
   category (`timebattle`) means FOUR of these on the duo shelf, not three. */
.gal-pane .gal-label { margin: 10px 2px 6px; }
.gal-pane .gal-label:first-child { margin-top: 2px; }

@media (prefers-reduced-motion: reduce) {
  .atab, .atab-art, .atab-name, .arcade-tabs, .gal-body.arcade { transition: none; }
}
/* ---------- the sky you sit under (R19 item 6) ----------

   Deniz: "the accent color is changable in the setting, but there is no such
   setting for the background, add couple more soft colors like the one we
   already have, add a night one too, but make it premium."

   Two parts: the picker, and the one thing NIGHT has to change about the rest
   of the app.

   ⚠️ NIGHT DOES NOT INVERT THE PALETTE. The ink stays dark and the glass is
   lifted until it reads as moonlit frosted panel rather than a grey smear —
   which is a real look, and is also the only version of this that does not
   mean rewriting the 72 hard-coded rgba(255,255,255,...) surfaces in this
   file. The numbers below are not taste: white at 0.52 over #3E3470 lands on
   #A79EBF and gives dark ink 5.7:1, and at 0.74 it lands near #C8C4D5 for
   8.3:1. Both pass AA; the second one looks deliberate.  */

.sky-strip {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
}
/* A tile is the sky, not the word for it. Name underneath, which §4.4 allows —
   two pastels a shade apart need something to tell them by. */
.sky-tile {
  position: relative; padding: 0; border: none; background: none;
  border-radius: 16px;
  display: flex; flex-direction: column; align-items: stretch; gap: 5px;
  transition: transform .14s ease;
}
.sky-tile .skt-art {
  display: block; width: 100%; height: 54px;
  border-radius: 14px; overflow: hidden;
  border: 1.5px solid var(--card-brd);
  box-shadow: 0 4px 12px rgba(63, 48, 130, 0.14);
  transition: border-color .16s ease, box-shadow .16s ease;
}
.sky-tile .skt-name {
  font-size: 11.5px; font-weight: 800; color: var(--ink-soft);
  letter-spacing: .01em; line-height: 1.2;
}
.sky-tile:active { transform: scale(.95); }
/* Selected is said by the tile, not by a chip beside it (§4.4 rule 2). */
.sky-tile.on .skt-art {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent-dim), 0 6px 16px rgba(63, 48, 130, 0.2);
}
.sky-tile.on .skt-name { color: var(--ink); }
/* Locked, but still tappable — the first tap tries it on. */
.sky-tile.buy .skt-art { filter: saturate(.9) brightness(.97); }
.sky-tile.buy.trying .skt-art { filter: none; }
.sky-tile .rt-tag { right: 3px; top: 3px; }
/* Once it is on the screen the price stops being a label and becomes the
   button that finishes the purchase, so it stops looking like a label. */
.sky-tile .rt-tag.go {
  background: var(--accent); color: var(--accent-ink);
  right: 3px; top: 3px;
  box-shadow: 0 3px 10px var(--accent-dim);
}

/* Night. Everything here is a lift, not an inversion. */
:root[data-sky="night"] {
  /* ⚠️ 0.80, not 0.74. Measured, not chosen: at 0.74 the panel over indigo
     lands near #C7C5D6, and the AMBER accent as a mark on it is 1.83:1 —
     against 2.45:1 for the same mark on the lavender sky the app already
     ships. Six more per cent of white puts it back at 2.4 and costs almost
     nothing visually, because a panel this size reads as glass either way.
     sky-sheet.js prints both numbers and refuses a regression. */
  --card: rgba(255, 255, 255, 0.80);
  --chip-bg: rgba(255, 255, 255, 0.70);
  --card-brd: rgba(255, 255, 255, 0.34);
}
/* The one surface that carries text and hard-codes its own white at an alpha
   picked against a pale sky. 0.34 over indigo lands near 3:1, under AA. */
:root[data-sky="night"] .rs-keeps { background: rgba(255, 255, 255, 0.62); }

/* ⚠️ THE SIX PLACES THAT SIT ON THE SKY WITH NOTHING UNDER THEM.

   Everything else in the app is on a white panel, so the dark ink is right.
   These six are painted straight onto the gradient, which is fine on five
   pale skies and invisible on the sixth. Every one was found by opening a
   screenshot of the night sky — sky-ui-check asserted all six were present,
   correct and the right size, because that is all an assertion can see.

   The list is short because it was measured, not guessed. If a new bare-on-sky
   label appears, it belongs here. */

/* 1. The name under each sky tile — and worst on the SELECTED one, which uses
      the darkest ink of all. */
:root[data-sky="night"] .sky-tile .skt-name { color: rgba(255, 255, 255, 0.72); }
:root[data-sky="night"] .sky-tile.on .skt-name { color: #FFFFFF; }

/* 2. The pet's name, level, mood and tip. This strip hangs under the room —
      the room is an opaque drawing, the strip is not. */
:root[data-sky="night"] .pet-id .pname { color: #F3F0FF; }
:root[data-sky="night"] .pet-id .pmood { color: #BDB4E4; }
:root[data-sky="night"] .pet-tip { color: #CFC8F0; }
:root[data-sky="night"] .xp-row .xp-txt { color: #ADA4D4; }

/* 3. The selected tab on the You page. Its pill is a translucent VIOLET, which
      goes darker than the sky rather than lighter, and its label is the darkest
      ink in the palette — so the tab you are actually on was the one tab with
      nothing legible in it. */
:root[data-sky="night"] .you-tab.on { background: rgba(255, 255, 255, 0.18); color: #FFFFFF; }

/* 4. The page title in the bar — "You", "Our relationship". `.topbar` has no
      background of its own, by design: the title floats on the sky. */
:root[data-sky="night"] .topbar .brand { color: #F1EDFF; }

/* 5. And your own username at the top of the You page, for the same reason.
      The line under it is --dim, which survives the dark ground; the name is
      --ink, which does not. */
:root[data-sky="night"] .you-head .uname { color: #F3F0FF; }
:root[data-sky="night"] .you-head .usub { color: #BDB4E4; }

/* 6. "online", under the partner's name in the chat header. The header's
      frosted bar fades to nothing within about 40px, and this line is BELOW
      the fade — so on five skies it sits on a pale ground and on the sixth it
      does not. */
:root[data-sky="night"] .chat-head .status { color: #BDB4E4; }

/* ⚠️ #nav and .chat-head follow the sky (see --sky-dock) but they do NOT go
   dark at night, and that is deliberate rather than unfinished. Their labels
   are --faint and the lit tab is --accent; on a dock dark enough to match a
   night sky those land near 1.5:1 and 3.4:1 respectively. A lit dock under a
   dark sky is the look anyway — it is what the glass panels are doing too. */


/* ---------- the notice bar (R23) -------------------------------------------
   ONE surface, two callers — an invitation to a game, and a message that
   arrived while you were anywhere but the conversation. Deniz asked for both in
   two sentences and the second one begins "Similiarly", which is the whole
   design brief. The behaviour, the decisions and the reasons are at the top of
   public/notice.js; this is only how it looks.

   ⚠️ IT IS NOT A GREY RECTANGLE WITH TEXT IN IT, which is the default an agent
   ships and the thing WORKING-RULES §4.4 is about. It is a glass card on the
   app's lilac with the ARTWORK as its subject: the game's own drawing from
   game-svg.js for an invitation, the partner's avatar disc — the same one the
   chat header draws — for a message. The two are told apart before a word is
   read, by the colour of the rail down the left edge.

   ⚠️ THE STRIP OF SCREEN THIS CLAIMS is the top edge, from the safe-area inset
   down. It publishes its own bottom edge as --notice-top and the four headers
   above take it as padding, so a notice never covers a Back button, a ⋮ menu or
   the way out of a game. It does not read or write --emote-dock; the bottom
   edge belongs to the reaction dock. */

#notice-stack {
  position: fixed; left: 0; right: 0; top: 0;
  /* Over #toast (90), .g-emote (89), #end-chat and #emote-sheet (88),
     .turn-card (86) and #game-layer (80). UNDER #tour-veil (120), #cold (130)
     and the upgrade gate (9999) — those are focused tasks and a notice must
     not sit on one. */
  z-index: 95;
  display: flex; flex-direction: column; gap: 8px;
  padding: calc(env(safe-area-inset-top) + 8px) 10px 0;
  /* The gaps between bars are not a surface. Only the cards take taps. */
  pointer-events: none;
}
.notice {
  pointer-events: auto;
  position: relative;
  display: flex; align-items: center; gap: 11px;
  padding: 10px 10px 10px 16px;
  border-radius: 20px;
  /* Glass, not grey: near-white over whatever sky is in force, with the blur
     doing the work that a flat panel would need a border for. */
  background: rgba(255, 255, 255, 0.94);
  -webkit-backdrop-filter: blur(14px) saturate(1.25);
  backdrop-filter: blur(14px) saturate(1.25);
  border: 1px solid rgba(255, 255, 255, 0.85);
  box-shadow:
    0 14px 34px rgba(63, 48, 130, 0.20),
    0 2px 6px rgba(63, 48, 130, 0.10);
  /* The "before" state. .nt-in is added on the next frame so the slide runs. */
  transform: translateY(calc(-100% - 18px));
  opacity: 0;
  transition: transform .34s cubic-bezier(.2, .9, .28, 1.06), opacity .22s ease;
}
.notice.nt-in { transform: translateY(0); opacity: 1; }
.notice.nt-out {
  transform: translateY(calc(-100% - 18px)); opacity: 0;
  transition: transform .2s ease-in, opacity .2s ease-in;
}

/* The rail. 3px, full height of the card less its corners, and it is the ONE
   thing that differs between the two kinds — warm rose for an invitation
   (a decision, with a deadline), the couple's accent for a message. */
.nt-rail {
  position: absolute; left: 6px; top: 11px; bottom: 11px;
  width: 3px; border-radius: 3px;
}
.nt-invite .nt-rail { background: linear-gradient(180deg, #F79A73, #E8628E); }
.nt-msg .nt-rail { background: linear-gradient(180deg, var(--accent), #9E86EC); }

/* The medallion — the artwork, at the size the eye reads first. */
.nt-med {
  width: 42px; height: 42px; flex: 0 0 auto;
  border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 19px; font-weight: 800; color: var(--accent);
  background: radial-gradient(circle at 32% 28%, #FFFFFF, #EFE9FD 70%, #E4DBFA);
  border: 1px solid rgba(124, 92, 224, 0.18);
  box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.9);
  overflow: hidden;
}
.nt-invite .nt-med {
  background: radial-gradient(circle at 32% 28%, #FFFFFF, #FDEBE6 70%, #FBDDD6);
  border-color: rgba(232, 98, 142, 0.22);
}
.nt-med svg { width: 30px; height: 30px; display: block; }
/* The partner disc for a message. The medallion is already the right circle,
   so this is only the glyph inside it — an emoji avatar at the size it was
   drawn for, or the initial the chat header falls back to. */
.nt-av { font-size: 20px; line-height: 1; font-weight: 800; }

.nt-lines { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.nt-title {
  font-size: 14.5px; font-weight: 800; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* ⚠️ TWO LINES, NOT ONE, and it was found in a screenshot rather than by an
   assertion. On a 390px phone with the Play pill taking the right-hand end,
   "seni Dört Taş oyununa davet etti" does not fit on one line and was being
   truncated to "…davet …" — cutting the only sentence that says why the bar
   is there. Turkish is the longer of our two languages and it is the one
   that has to fit. The NAME above still gets one line and an ellipsis: a
   long username is a nuisance, a lost verb is a broken notification. */
.nt-body {
  font-size: 12.5px; font-weight: 600; color: var(--muted);
  overflow: hidden;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  line-height: 1.28;
}

/* The Play pill. The whole bar is tappable too — this is the affordance that
   says so, not the only way in. */
.nt-go {
  flex: 0 0 auto;
  border: none; border-radius: 999px;
  padding: 9px 16px;
  font-size: 13px; font-weight: 800;
  color: var(--accent-ink);
  background: linear-gradient(135deg, #F79A73, #E8628E);
  box-shadow: 0 4px 12px rgba(232, 98, 142, 0.34);
}
.nt-msg .nt-go {
  background: linear-gradient(135deg, var(--accent), #9E86EC);
  box-shadow: 0 4px 12px rgba(124, 92, 224, 0.32);
}

/* ⚠️ SMALL, QUIET AND ON THE EDGE, on purpose. Dismissing is not declining —
   it hides the bar on this device and nothing else — but it is still the exit,
   and the exit must never out-shout the thing being offered. */
.nt-x {
  flex: 0 0 auto;
  width: 30px; height: 30px; padding: 0;
  border: none; border-radius: 50%;
  background: transparent; color: var(--faint);
  display: flex; align-items: center; justify-content: center;
}
.nt-x svg { width: 15px; height: 15px; display: block; }
.nt-x:active { background: var(--chip-bg); color: var(--ink-soft); }

/* A repeat of the same notice updates in place. The bump says "that changed"
   without replaying the slide-in, which on a chatty partner would be a bar
   flickering once a second. */
@keyframes ntBump {
  0% { transform: translateY(0) scale(1); }
  35% { transform: translateY(0) scale(1.026); }
  100% { transform: translateY(0) scale(1); }
}
.notice.nt-bump { animation: ntBump .3s cubic-bezier(.3, .9, .3, 1) both; }

@media (prefers-reduced-motion: reduce) {
  .notice, .notice.nt-in, .notice.nt-out {
    transition: opacity .2s ease;
    transform: none;
  }
  .notice.nt-bump { animation: none; }
}
/* ═══════════════════════════════════════════════════════════════════════════
   R23 — THE BREAKUP SHEET. Two independent choices, four outcomes.

   Deniz, 2026-08-20: "let's ask for pet and for messages with different
   buttons so they are independent."

   ⚠️ THE SHAPE THIS FILE EXISTS TO REFUSE. WORKING-RULES §4.4 / CLAUDE.md
   "screens have to look designed": the default build of this feature is two
   labelled pill buttons in a stack — "Keep pet" / "Delete pet", "Keep
   messages" / "Delete messages" — which works, passes every assertion, and is
   the shape an agent ships because it is the shortest path from two booleans
   to something clickable. It is also the wrong screen for the worst evening of
   somebody's year.

   So each choice is a STAGE, and the state lives ON the stage:

     1. The picture is the thing itself. The pet stage draws the ACTUAL
        ANIMAL — their species, their colour, what it is wearing — with its
        name under it, in an alcove that reads as a home. The messages stage
        draws their conversation as a stack of bubbles carrying the real
        count. (Not the messages themselves: this screen gets opened in front
        of other people, which is also why the count is all the server sends.)

     2. The control is the stage's own plinth, not a pair of buttons parked
        below it. Two halves of one base, full-bleed to the card's bottom
        corners, no pill and no chip; the chosen half lifts, takes a coloured
        lip and keeps the tick.

     3. ⚠️ CHOOSING "DELETE" CHANGES THE PICTURE. The alcove drains to grey,
        the animal fades, the name is struck through, and the bubble stack
        empties to dashed outlines. That is the feedback — a radio dot beside
        the word "delete" is not a picture of what is about to happen, and
        this is the one screen where the person has to actually feel it.

   Nothing here is reused from .danger-box, which stays exactly as it is for
   account deletion. Two screens that cost different things should not look
   the same; that is the note above .btn-caution, applied one level up.
   ═══════════════════════════════════════════════════════════════════════════ */

.bs-body { display: flex; flex-direction: column; gap: 13px; }

/* ⚠️ ONE LINE, AND IT IS THE WHOLE DEFECT. Do not delete it to "tidy up".
   Measured, twice, by opening the PNGs.
      * keep · keep      — the plinth lost 26px: "Nothing happens to them
                           today." rendered as "Nothing happens to them".
      * delete · delete  — the plinth lost 83px, which is ALL of it. Four
                           labels, both cards, gone from the screen entirely,
                           with 46 assertions green above them.
   THE CAUSE IS NOT IN THE PLINTH. `.bs-body` IS `.gal-body` — one element,
   class="gal-body bs-body" — so it is `flex: 1; min-height: 0; overflow-y:
   auto` AND `display: flex; flex-direction: column` at the same time. Its
   children are therefore flex items with the default `flex-shrink: 1`.
   That is normally harmless, because a flex item's automatic minimum size
   (`min-height: auto`) is its min-content height and it refuses to shrink past
   its own contents. But CSS Flexbox §4.5 zeroes that automatic minimum for any
   item whose `overflow` is not `visible` — and `.bs-stage` is `overflow:
   hidden`, because it has to be, to round the corners on the picture inside it.
   So the two stages are the only children of this sheet that can be squeezed
   to nothing, and when the sheet is taller than the phone the flex algorithm
   squeezes them instead of letting the scroller scroll. `.bs-scene` has an
   explicit height and does not give; the plinth is the only elastic part;
   `overflow: hidden` does the cutting. THE TALLER THE SHEET, THE MORE IT TAKES
   — which is why the sharpest state, the one carrying both the outcome line
   and the warning box, is the state that lost everything, and why the shorter
   after-the-fact sheet was fine until its warning appeared.
   `flex: 0 0 auto` restores plain block flow down the column, and `.gal-body`'s
   own `overflow-y: auto` — already there, never reached — scrolls.
   breakup-ui-check's noClip() asserts this in every state, both languages: it
   printed 18 FAILURES against the line below removed, and 0 with it. */
.bs-body > * { flex: 0 0 auto; }

/* The line that opens it. Not a bullet list — the three facts about leaving
   are already in unpairItem*, and they belong ABOVE the decisions, small,
   because they are context rather than a choice. */
.bs-lede {
  font-size: 12.5px; line-height: 1.55; color: var(--muted); font-weight: 600;
  margin: 2px 2px 0;
}
.bs-lede b { color: var(--ink-soft); font-weight: 800; }

/* ---- one stage ---------------------------------------------------------- */
.bs-stage {
  position: relative; border-radius: 22px; overflow: hidden;
  background: var(--card); border: 1px solid var(--card-brd);
  box-shadow: 0 10px 26px rgba(63, 48, 130, 0.09);
  transition: box-shadow .28s ease, border-color .28s ease;
}
/* The card itself carries the decision, so the card itself changes colour. */
.bs-stage.gone {
  border-color: rgba(214, 69, 90, 0.34);
  box-shadow: 0 3px 10px rgba(214, 69, 90, 0.13);
}

/* The heading strip sits INSIDE the card, over the picture, because a heading
   floating above a card belongs to the page and this one belongs to the pet. */
.bs-cap {
  position: absolute; z-index: 2; top: 10px; left: 14px; right: 14px;
  display: flex; align-items: baseline; gap: 8px;
  font-size: 13.5px; font-weight: 800; color: var(--ink);
  text-shadow: 0 1px 0 rgba(255, 255, 255, .55);
}
.bs-cap .bs-count {
  margin-left: auto; font-size: 11.5px; font-weight: 700; color: var(--muted);
  white-space: nowrap;
}

/* ---- the alcove: a home, drawn in light rather than in furniture ---------
   ⚠️ Deliberately NOT houseSVG. The other half of this feature — the sheet the
   partner who did NOT choose the breakup sees — has no house payload to draw
   from (partnerState sends the pet row and a message count, and nothing else),
   so a real room on one sheet and a real room on the other would need the
   whole parseRooms/CATALOG apparatus on the wire for a thumbnail. An alcove
   made of light is true on both sides and lies on neither. */
.bs-scene {
  position: relative; height: 168px;
  display: grid; place-items: end center;
  background:
    radial-gradient(88% 78% at 50% 6%, #FFF9EE 0%, #F6EBD9 46%, #EADCC4 100%);
  transition: filter .3s ease;
}
/* the arch of the wall behind the animal */
.bs-scene::before {
  content: ''; position: absolute; left: 50%; top: 14px;
  width: 168px; height: 122px; transform: translateX(-50%);
  border-radius: 84px 84px 12px 12px;
  background: radial-gradient(70% 60% at 50% 22%, rgba(255,255,255,.72), rgba(255,255,255,0) 72%);
  box-shadow: inset 0 0 0 1px rgba(120, 96, 60, .10);
}
/* the floor it is standing on */
.bs-scene::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 44px;
  background: linear-gradient(180deg, #E3CFAE 0%, #D8C09A 100%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .35);
}
.bs-scene .bs-art {
  position: relative; z-index: 1; margin-bottom: 26px;
  transition: filter .3s ease, opacity .3s ease, transform .3s ease;
}
.bs-scene .bs-art > svg { display: block; }

/* ⚠️ THE POINT OF THE WHOLE FILE. Deleting is shown, not labelled. */
.bs-stage.gone .bs-scene { filter: grayscale(.9) brightness(1.04); }
.bs-stage.gone .bs-art { filter: grayscale(1); opacity: .30; transform: scale(.94); }
.bs-stage.gone .bs-name { color: var(--faint); text-decoration: line-through; }

/* The name, on the floor of the alcove where a nameplate would be. */
.bs-name {
  position: absolute; z-index: 2; left: 0; right: 0; bottom: 11px; padding: 0 14px;
  text-align: center; font-size: 13px; font-weight: 800; color: #6B5535;
  transition: color .3s ease;
}

/* ---- the messages stage: same alcove, different light -------------------- */
.bs-stage.msgs .bs-scene {
  height: 152px;
  background: radial-gradient(88% 78% at 50% 8%, #F4F0FE 0%, #E7DFFA 48%, #D7CBF2 100%);
}
.bs-stage.msgs .bs-scene::after {
  background: linear-gradient(180deg, #C9BAEE 0%, #BCAAE8 100%);
  height: 38px;
}
.bs-stage.msgs .bs-scene .bs-art { margin-bottom: 32px; }
.bs-stage.msgs .bs-scene::before { top: 10px; height: 104px; width: 200px; border-radius: 100px 100px 14px 14px; }
.bs-stage.msgs .bs-name { color: #574689; }

/* ---- the plinth: ONE base in two halves, never two buttons -------------- */
/* Flex rather than grid, and `align-items: stretch` is the point of it: the two
   halves are one plinth, so a one-line caption on the left and a two-line one
   on the right must produce halves of equal height. min-height is the floor
   that keeps the plinth from reading as a thin strip under a big picture.

   ⚠️ AND A CORRECTION, because the note that stood here was wrong and a wrong
   note is worse than none. It claimed this rule was the fix for the labels
   being sliced off, and blamed a grid `auto` row measuring at max-content.
   It is not, and it was not: the clipping is caused one level up, by the
   stages being shrinkable flex items of `.bs-body`, and the full diagnosis is
   over `.bs-body > *` above. Making the faces TALLER, as `min-height` does,
   made the stage's content taller and so made the clipping WORSE — the state
   that had lost one word of a caption lost the entire plinth. Both PNGs exist.

   The half of that note that was right, and is worth keeping: the defect was
   found by opening a screenshot, because the DOM was correct, the classes were
   correct and `textContent` read every word that was not on the screen. What
   it got wrong is that nothing in a harness could see it. Geometry can, and
   now does — see noClip() in breakup-ui-check.js. */
.bs-faces { display: flex; align-items: stretch; }
.bs-face {
  flex: 1 1 0; min-width: 0; min-height: 66px;
  position: relative; appearance: none; border: 0; background: rgba(255, 255, 255, .16);
  border-top: 1px solid var(--card-brd);
  padding: 11px 12px 13px 13px;
  display: flex; flex-direction: column; gap: 2px; text-align: left;
  color: var(--dim); cursor: pointer;
  transition: background .2s ease, color .2s ease;
}
.bs-face + .bs-face { border-left: 1px solid var(--card-brd); }
.bs-face b { font-size: 13px; font-weight: 800; line-height: 1.25; }
.bs-face span { font-size: 11px; font-weight: 600; line-height: 1.35; opacity: .92; }
.bs-face:active { background: rgba(255, 255, 255, .40); }

/* The chosen half. A lip along the top edge of the plinth — the same idea as
   the lit nav tab, and the reason there is no chip anywhere on this screen. */
.bs-face.on { background: rgba(255, 255, 255, .70); color: var(--ink); }
.bs-face.on::before {
  content: ''; position: absolute; top: -1px; left: 13px; right: 13px; height: 3px;
  border-radius: 0 0 3px 3px; background: var(--accent);
}
.bs-face.on.hot { color: #A32F44; }
.bs-face.on.hot::before { background: #D6455A; }
.bs-face.on.hot span { color: #B4566A; }
.bs-face .bs-tick {
  position: absolute; top: 10px; right: 11px;
  width: 15px; height: 15px; opacity: 0;
  transition: opacity .18s ease;
}
.bs-face.on .bs-tick { opacity: 1; }

/* ---- the live outcome, which is what makes the two choices legible as two -
   Four sentences for four states. A single line covering both choices would
   hide exactly the independence he asked for. */
.bs-outcome {
  display: flex; gap: 9px; align-items: flex-start;
  font-size: 12.5px; line-height: 1.5; font-weight: 600; color: var(--ink-soft);
  background: rgba(255, 255, 255, .40); border: 1px solid var(--card-brd);
  border-radius: 16px; padding: 11px 13px;
}
.bs-outcome::before { content: '✦'; color: var(--accent); font-size: 13px; line-height: 1.4; }
.bs-outcome.hot { background: rgba(214, 69, 90, .07); border-color: rgba(214, 69, 90, .24); color: #A32F44; }
.bs-outcome.hot::before { color: #D6455A; }

/* The sharp warning he asked for by name, shown ONLY when something is set to
   be destroyed — a warning on every state is wallpaper. */
.bs-sharp {
  display: flex; gap: 9px; align-items: flex-start;
  font-size: 12.5px; line-height: 1.5; font-weight: 700; color: #A32F44;
  background: rgba(214, 69, 90, .10); border-radius: 14px; padding: 11px 13px;
}
.bs-sharp::before { content: '⚠'; font-size: 13px; }

/* ---- the confirm. The type-their-name gate from R13.1 stays: a two-tap
   "are you sure" is muscle memory, spelling their name out is a decision. --- */
.bs-confirm { display: flex; flex-direction: column; gap: 9px; margin-top: 2px; }
.bs-confirm .bs-ask { font-size: 12px; font-weight: 700; color: var(--muted); margin-left: 4px; }
.bs-confirm .btn-caution, .bs-confirm .btn-danger { width: 100%; height: 48px; font-size: 15px; }
.bs-err { font-size: 12px; font-weight: 700; color: #C0384E; margin-left: 4px; }

/* ---- the way back in: the card on the Partner page ----------------------
   ⚠️ Without this the ratchet in /api/partner/unpair is unreachable code and
   the partner who did not choose the breakup has no say at all about their own
   conversation. It is a card carrying the PET, for the same reason the stage
   above is: "your last partnership" as a text row is a settings row about the
   worst thing that happened this month. */
.bs-back {
  position: relative; width: 100%; text-align: left;
  display: flex; align-items: center; gap: 13px;
  padding: 13px 14px; border-radius: 22px;
  background: linear-gradient(180deg, rgba(255,255,255,.62), rgba(255,255,255,.38));
  border: 1px solid var(--card-brd);
  box-shadow: 0 8px 20px rgba(63, 48, 130, 0.08);
}
.bs-back:active { transform: scale(.99); }
.bs-back .bs-back-art {
  width: 54px; height: 54px; flex: 0 0 auto; border-radius: 16px;
  display: grid; place-items: center;
  background: radial-gradient(80% 70% at 50% 22%, #FFF8EC, #EFE2CB);
  box-shadow: inset 0 0 0 1px rgba(120, 96, 60, .12);
}
.bs-back .bs-back-art > svg { display: block; }
.bs-back .bs-back-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.bs-back .bs-back-txt b { font-size: 14px; font-weight: 800; color: var(--ink); }
.bs-back .bs-back-txt span {
  font-size: 11.5px; font-weight: 600; color: var(--muted); line-height: 1.4;
}
/* The countdown is the only number on the card, so it is the only thing that
   gets to be loud. */
.bs-back .bs-days {
  flex: 0 0 auto; font-size: 11px; font-weight: 800; color: var(--accent);
  background: rgba(124, 92, 224, .12); border-radius: 999px; padding: 5px 10px;
  white-space: nowrap;
}
.bs-back .bs-days.last { color: #C0384E; background: rgba(214, 69, 90, .12); }

/* At night the alcove keeps its own light — it is a lit room, and a lit room
   in a dark app is the picture. Only the page-level text follows the sky. */
:root[data-sky="night"] .bs-lede { color: #BDB4E4; }
:root[data-sky="night"] .bs-lede b { color: #E6E0FF; }

/* ---- Fireflies (glow), R23 --------------------------------------------------
   A NIGHT MEADOW, not a grid of buttons. The panel is the one dark surface in
   the whole app and that is the point: fireflies need night to read as
   fireflies, and against #game-layer's pale violet a "lit" cell has nothing to
   be lit AGAINST — a gold dot on lavender is a gold dot, while the same dot on
   deep indigo is a light. The state IS the artwork here (WORKING-RULES §4.4);
   nothing on this screen says the word "on".

   ⚠️ The lantern's colour comes through `color` and currentColor, so the
   drawing in game-svg.js is written once and both states are CSS. The halo is
   a drop-shadow rather than a second circle in the SVG, for the reason the
   art file records: a ring drawn behind the animal reads as a balloon. */
.gl-wrap {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 16px; padding: 0 16px;
}
.gl-grid {
  /* grid-auto-rows: 1fr — item 15a's sweep. See .su-grid and
     scripts/checks/grid-guard.js. Columns are inline (the size is a difficulty
     parameter); every cell holds an SVG, so the rows agreed by accident. */
  display: grid; gap: 7px; padding: 11px; grid-auto-rows: 1fr;
  width: min(84vw, 330px); aspect-ratio: 1;
  border-radius: 28px;
  background: linear-gradient(180deg, #2B2350 0%, #3B3070 62%, #4A3C86 100%);
  box-shadow: inset 0 3px 20px rgba(0, 0, 0, 0.42), 0 10px 30px rgba(63, 48, 130, 0.28);
  transition: box-shadow .3s ease;
}
.gl-cell {
  border: none; padding: 0; border-radius: 17px;
  background: rgba(255, 255, 255, 0.045);
  display: flex; align-items: center; justify-content: center;
  color: #574C93;                       /* the lantern, asleep */
  transition: color .24s ease, background .24s ease, transform .1s ease;
}
.gl-cell svg {
  width: 84%; height: 84%; display: block;
  opacity: .40; transition: opacity .24s ease, filter .24s ease;
}
.gl-cell.lit { color: #FFD34E; background: rgba(255, 211, 78, 0.10); }
.gl-cell.lit svg { opacity: 1; filter: drop-shadow(0 0 10px rgba(255, 211, 78, 0.7)); }
.gl-cell:active { transform: scale(.9); }
/* The cell that was just tapped, and its four neighbours, so a tap is legible
   as an EVENT with a shape rather than as five things that silently changed. */
@keyframes glRipple { from { box-shadow: inset 0 0 0 2px rgba(255, 211, 78, .85); } to { box-shadow: inset 0 0 0 2px rgba(255, 211, 78, 0); } }
.gl-cell.hit { animation: glRipple .42s ease-out 1; }
/* Solved: the meadow goes quiet and cool. */
.gl-grid.done { box-shadow: inset 0 3px 20px rgba(0, 0, 0, .3), 0 0 34px rgba(95, 199, 155, .5); }
.gl-grid.done .gl-cell { background: rgba(95, 199, 155, 0.10); color: #6B7FA8; }
@media (prefers-reduced-motion: reduce) { .gl-cell.hit { animation: none; } }
/* ---- Weave (weave), R23 ------------------------------------------------------
   A PIECE OF CLOTH, not a grid of buttons, and the two decisions that make it
   one are both in this block.

   ⚠️ 1. THE TILES TOUCH — `gap: 0`. Each arm of thread runs to the edge of its
   own box, so a tile's arm and its neighbour's meet exactly and a finished
   board is one continuous thread rather than a dotted line across a grid. The
   faint rounded square in ::before is what says "these are separate pieces you
   can turn", and it sits UNDER the thread so it never breaks a join.

   ⚠️ 2. THE TILE IS DRAWN ONCE AND THE WRAPPER IS ROTATED. .wv-turn carries
   transform: rotate(turns*90deg) with a transition on it, so turning a tile is
   an animation the browser owns and the picture cannot fall out of step with
   the mask — there is only one mask, and the drawing is derived from it once.

   Joined thread is the app's rose; loose thread is a pale violet-grey. That is
   the whole HUD: the board fills with colour as you work, and it is full at the
   exact moment the puzzle is over (see the proof in games.js). */
.wv-wrap {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 16px; padding: 0 16px;
}
.wv-grid {
  /* grid-auto-rows: 1fr — item 15a's sweep. See .su-grid and
     scripts/checks/grid-guard.js. */
  display: grid; gap: 0; padding: 10px; grid-auto-rows: 1fr;
  width: min(84vw, 330px); aspect-ratio: 1;
  border-radius: 26px; background: var(--paper);
  box-shadow: 0 10px 30px rgba(63, 48, 130, 0.18), inset 0 0 0 1px rgba(88, 70, 160, 0.10);
  transition: box-shadow .32s ease;
}
.wv-cell {
  border: none; padding: 0; background: none; position: relative;
  color: #CDC4E8;                       /* thread not yet on the spool */
  transition: color .28s ease;
}
.wv-cell::before {
  content: ''; position: absolute; top: 3px; right: 3px; bottom: 3px; left: 3px; border-radius: 12px;
  background: rgba(124, 92, 224, 0.05);
}
.wv-turn {
  position: relative; display: block; width: 100%; height: 100%;
  transition: transform .19s cubic-bezier(.22, .61, .36, 1);
}
.wv-turn svg, .wv-spool svg { display: block; width: 100%; height: 100%; }
.wv-cell.on { color: var(--flow); }
.wv-cell:active .wv-turn { transform-origin: 50% 50%; }
/* The spool sits OUTSIDE .wv-turn on purpose: its own tile turns like any
   other, but a bobbin that rolled over on its side would stop reading as a
   bobbin, and the board would lose the one fixed point it is read from. */
.wv-spool { position: absolute; top: 17%; right: 17%; bottom: 17%; left: 17%; pointer-events: none; }
.wv-grid.done {
  box-shadow: 0 0 34px rgba(224, 105, 158, 0.42), inset 0 0 0 1px rgba(224, 105, 158, 0.35);
}
@media (prefers-reduced-motion: reduce) { .wv-turn { transition: none; } }
/* ---- Quilt (quilt), R23 ------------------------------------------------------
   ⚠️ THE 2x2 BLOCKS HAVE TO BE VISIBLE OR THE PUZZLE IS UNREADABLE. Rows and
   columns you can see for free; the corner squares are the third rule and they
   are invisible in an evenly-spaced grid, so a player would be deducing against
   a constraint the screen never showed them. The wider seam between columns 2/3
   and rows 2/3 is that rule, drawn. It is nth-child arithmetic rather than four
   wrapper divs so the sixteen cells stay one flat list and the index into the
   board is the index into the DOM.

   A patch is a whole square of cloth (game-svg.js draws ground and motif
   together), so nothing here paints a background — the artwork is the tile, and
   an empty square is a tacking-stitch outline rather than a grey box. */
.qt-wrap {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 16px; padding: 0 16px;
}
.qt-grid {
  position: relative;
  display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; padding: 12px;
  width: min(84vw, 320px); aspect-ratio: 1;
  border-radius: 26px; background: var(--paper);
  box-shadow: 0 10px 30px rgba(63, 48, 130, 0.18), inset 0 0 0 1px rgba(88, 70, 160, 0.10);
  transition: box-shadow .32s ease;
}
.qt-cell {
  border: none; padding: 0; background: none; position: relative;
  border-radius: 13px; overflow: hidden;
  transition: transform .12s ease, box-shadow .2s ease;
}
.qt-cell svg { display: block; width: 100%; height: 100%; }
/* ---- the four corner squares, drawn ----------------------------------------
   ⚠️ THE MARGINS ALONE DID NOT SHOW THE RULE, AND THE ASSERTION SAID THEY DID.
   The first version put a 7px margin on column 3 and row 3 and asserted
   `marginLeft > 0` on the right cells. That went green, and the finished board
   still read as a plain, evenly-spaced 4x4: every patch is a PALE square on a
   cream ground, so four extra pixels of cream between two creams is nothing to
   look at. A player would have been deducing against a constraint the screen
   never showed them — which is the whole reason this game is not just rows and
   columns. Found by opening puzzle3-quilt-solved.png, not by any check.

   So the seam is now two things. The gap is SPLIT EVENLY between the cells on
   either side of it, which puts its centre exactly at the grid s centre — and
   that is what lets the stitch below be drawn at a plain 50% instead of at some
   calc() that would drift the moment the padding changed. */
.qt-cell:nth-child(4n+2) { margin-right: 4px; }
.qt-cell:nth-child(4n+3) { margin-left: 4px; }
.qt-cell:nth-child(n+5):nth-child(-n+8) { margin-bottom: 4px; }
.qt-cell:nth-child(n+9):nth-child(-n+12) { margin-top: 4px; }
/* ...and the stitch itself, which is the half a person can actually see. Dashed
   because a quilt is sewn, and the same mark the shelf tile already draws across
   its own four patches — the tile and the board now say the same thing. */
.qt-grid::before,
.qt-grid::after {
  content: ''; position: absolute; pointer-events: none;
  border: 0 dashed rgba(75, 59, 143, 0.42);
}
.qt-grid::before { left: 15px; right: 15px; top: 50%; margin-top: -1px; border-top-width: 2px; }
.qt-grid::after { top: 15px; bottom: 15px; left: 50%; margin-left: -1px; border-left-width: 2px; }
.qt-cell:not(.given):active { transform: scale(.93); }
/* Pre-sewn. The dashes are a tacking stitch and say "this one is not yours"
   without a word, which is the same job the outline does on an empty square. */
.qt-cell.given::after {
  content: ''; position: absolute; top: 5px; right: 5px; bottom: 5px; left: 5px; border-radius: 9px;
  border: 2px dashed rgba(75, 59, 143, 0.20); pointer-events: none;
}
/* Two of the same patch in a row, a column or a corner square.

   ⚠️ A PSEUDO-ELEMENT, NOT AN INSET BOX-SHADOW, AND THE DIFFERENCE IS THE WHOLE
   MARK. This was `box-shadow: inset 0 0 0 3px var(--bad)` and drew NOTHING a
   player could see: an inset shadow paints above the element s background but
   BELOW its children, and a patch is a full-bleed <rect> covering the entire
   cell — so the artwork sat on top of the ring every time. The check went green
   throughout, because it asked whether the cell carried the .clash CLASS, which
   it always did. Found by opening puzzle3-quilt-clash.png and seeing two leaves
   in one column with nothing wrong with either of them.

   ::before is a child, so it paints after the SVG, the same way .given::after
   already does — which is precisely why THAT mark was visible in the same
   screenshot and this one was not. */
.qt-cell.clash::before {
  content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; border-radius: 13px;
  border: 3px solid var(--bad); pointer-events: none; z-index: 2;
}
@keyframes qtNope { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } }
.qt-cell.nope { animation: qtNope .22s ease 1; }
.qt-grid.done {
  box-shadow: 0 0 34px rgba(47, 159, 123, 0.42), inset 0 0 0 1px rgba(47, 159, 123, 0.35);
}
@media (prefers-reduced-motion: reduce) { .qt-cell.nope { animation: none; } }
/* ============================================================================
   R23 games-fun5 — five more simple partner games. 2026-08-24.
   ============================================================================
   ⚠️ APPENDED IN ONE BLOCK AT THE END OF THE FILE, on purpose. Four other
   agents are adding games in the same wave; a hunk in the middle of this file
   is a merge conflict and a hunk at the end is an append. Design record:
   docs/archive/r23/games-fun5.md.

   ---- Who's More Likely -------------------------------------------------------
   Two faces, and the faces are the buttons. Nothing here is a pill with a
   username printed on it — WORKING-RULES.md §4.4. */
.lk-card {
  margin: 2px 14px 12px; padding: 16px 16px 18px; border-radius: 22px;
  background: var(--card-bg, rgba(255, 255, 255, .82));
  box-shadow: 0 6px 18px rgba(63, 48, 130, .11);
  border: 2px solid transparent; transition: border-color .18s ease;
}
.lk-card.met { border-color: #5FA46B; }
.lk-card.split { border-color: #E8779F; }
.lk-lead {
  margin: 0 0 6px; text-align: center; font-size: 11px; font-weight: 900;
  letter-spacing: .08em; text-transform: uppercase; color: var(--muted);
}
.lk-q {
  margin: 0; text-align: center; font-size: 20px; font-weight: 800;
  line-height: 1.3; color: var(--ink);
}
.lk-faces {
  flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 1fr;
  gap: 12px; padding: 0 14px 8px; align-content: center;
}
.lk-face {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  padding: 20px 8px 16px; border-radius: 24px; border: 3px solid transparent;
  background: var(--card-bg, rgba(255, 255, 255, .8));
  box-shadow: 0 6px 18px rgba(63, 48, 130, .12);
  transition: transform .14s ease, border-color .16s ease, box-shadow .16s ease;
}
.lk-face:active:not(:disabled) { transform: scale(.97); }
.lk-face.on { border-color: var(--accent); box-shadow: 0 8px 22px rgba(124, 92, 224, .26); }
.lk-face.agreed { border-color: #5FA46B; }
/* A revealed face is not a dead control — it is the answer, and greying it out
   is how a result screen ends up looking broken. */
.lk-face:disabled { opacity: 1; }
/* ⚠️ Sized against the viewport, not fixed at 84px. At 84 the two tiles were
   small islands with a third of the screen empty above them — fun5-likely-ask.png
   was taken to judge exactly this, and the first version passed every assertion
   while looking unfinished. The faces are the control, so they get the room. */
.lk-art { width: min(33vw, 136px); height: min(33vw, 136px); }
.lk-art svg { width: 100%; height: 100%; display: block; }
.lk-name {
  font-size: 15px; font-weight: 800; color: var(--ink);
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Who pointed here, as the two role tokens the rest of the arcade already uses.
   An empty row still takes its height so the two tiles cannot jump apart when
   the reveal lands. */
.lk-ptrs { display: flex; gap: 6px; min-height: 26px; align-items: center; }
.lk-ptr {
  font-size: 17px; line-height: 1; padding: 3px 5px; border-radius: 999px;
  background: rgba(124, 92, 224, .13);
}
.lk-ptr.you { background: rgba(232, 119, 159, .2); }

/* ---- Two Hands ---------------------------------------------------------------
   A square field that must not push the arrows off a short phone, so it is
   sized against the smaller of the two axes and the pad is what gets the rest. */
.dt-tally { display: flex; gap: 7px; justify-content: center; padding: 6px 0 2px; }
.dt-pip { width: 20px; height: 20px; opacity: .22; transition: opacity .2s ease, transform .2s ease; }
.dt-pip.on { opacity: 1; transform: scale(1.12); }
.dt-pip svg { width: 100%; height: 100%; display: block; }
.dt-field {
  --dt-n: 7;
  width: min(88vw, 46vh); aspect-ratio: 1; margin: 2px auto 10px;
  display: grid; gap: 4px;
  grid-template-columns: repeat(var(--dt-n), 1fr);
  grid-template-rows: repeat(var(--dt-n), 1fr);
}
.dt-cell {
  border-radius: 8px; background: rgba(255, 255, 255, .5);
  display: flex; align-items: center; justify-content: center;
  box-shadow: inset 0 0 0 1px rgba(88, 70, 160, .07);
}
.dt-cell.star { background: rgba(247, 201, 72, .18); }
.dt-cell.heart { background: rgba(232, 119, 159, .16); }
.dt-mark { width: 82%; height: 82%; }
.dt-mark svg { width: 100%; height: 100%; display: block; }
.dt-pad {
  display: flex; gap: 18px; justify-content: center; align-items: center;
  padding: 2px 14px 6px;
}
.dt-pair { display: flex; gap: 8px; padding: 6px; border-radius: 20px; }
.dt-pair.live { background: rgba(124, 92, 224, .1); }
.dt-arw {
  width: 58px; height: 58px; border-radius: 18px; padding: 10px;
  background: var(--card-bg, rgba(255, 255, 255, .85)); color: var(--accent);
  box-shadow: 0 4px 14px rgba(63, 48, 130, .14);
  transition: transform .1s ease;
}
.dt-arw svg { width: 100%; height: 100%; display: block; }
.dt-arw:active:not(:disabled) { transform: scale(.92); }
/* One chevron drawing, turned. The same trick Rooftop Run uses, and the reason
   there is no second, third and fourth arrow to keep in step with the first. */
.dt-arw.r svg { transform: rotate(180deg); }
.dt-arw.u svg { transform: rotate(90deg); }
.dt-arw.d svg { transform: rotate(-90deg); }
/* Your partner's arrows. Shown, so the split is visible; unpressable, because
   they are not yours. Not hidden — half the joke is watching them wait. */
.dt-arw.theirs {
  opacity: .34; color: var(--muted); box-shadow: none;
  background: rgba(255, 255, 255, .4);
}

/* ---- Don't Let Go ------------------------------------------------------------
   One control, the size of a thing you would actually put a thumb on. The
   heart is the button; the two prints on it are the only readout there is. */
.hd-stage {
  flex: 1; min-height: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 14px;
}
.hd-heart {
  position: relative; width: min(62vw, 260px); aspect-ratio: 1;
  background: none; box-shadow: none; padding: 0;
  transition: transform .16s cubic-bezier(.34, 1.56, .64, 1), filter .2s ease;
}
.hd-art { position: absolute; top: 0; right: 0; bottom: 0; left: 0; }
.hd-art svg { width: 100%; height: 100%; display: block; }
.hd-heart.held { transform: scale(.94); }
/* Both of you on it: the heart beats. The animation IS the round — a screen
   that only says "holding" in words is the defect this replaces. */
.hd-heart.live { animation: hd-beat 1.05s ease-in-out infinite; }
.hd-heart.done { animation: none; filter: grayscale(.5); transform: scale(.88); }
@keyframes hd-beat {
  0%, 100% { transform: scale(.94); }
  14% { transform: scale(1.02); }
  28% { transform: scale(.96); }
  42% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) { .hd-heart.live { animation: none; } }
.hd-prints {
  position: absolute; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center;
  justify-content: center; gap: 12%;
}
.hd-print {
  width: 21%; height: 21%; opacity: .22; color: var(--accent);
  transition: opacity .18s ease, transform .18s ease;
}
.hd-print.you { color: #E8617A; }
.hd-print.on { opacity: 1; transform: scale(1.12); }
.hd-print svg { width: 100%; height: 100%; display: block; }
.hd-clock {
  font-size: 26px; font-weight: 900; color: var(--ink);
  font-variant-numeric: tabular-nums; min-height: 32px;
}
/* Two rows of round pips, one per player, in the same order as the score HUD
   above them. */
.hd-rounds { display: flex; flex-direction: column; gap: 6px; align-items: center; padding-bottom: 8px; }
.hd-row { display: flex; gap: 7px; }
.hd-pip {
  width: 12px; height: 12px; border-radius: 50%;
  background: rgba(124, 92, 224, .18);
}
.hd-pip.on { background: var(--accent); }

/* ===========================================================================
   R23 games-fun5 — Our Sentence (`story`)

   THE SENTENCE IS THE SCREEN. The page is the diary's paper and the diary's
   handwriting, because the thing being made here is a keepsake and a keepsake
   in Figtree is a form field. Caveat is already self-hosted with its licence
   beside it, and its two unicode-ranges cover Turkish in full — ı, İ, ğ, ş, ç,
   ö and ü all resolve, so the Turkish sentence is handwriting too rather than
   silently falling back to the UI face.

   ⚠️ The four words on offer are torn SCRAPS, tilted, not a stack of pills.
   WORKING-RULES.md §4.4: there is no artwork for a word, so the paper is the
   artwork.
   ========================================================================== */
.st-pips {
  display: flex; justify-content: center; gap: 8px; padding: 4px 0 10px;
}
.st-pip {
  width: 11px; height: 11px; border-radius: 50%;
  background: rgba(124, 92, 224, .16);
  transition: background .2s ease, transform .2s ease;
}
/* Filled by a person. */
.st-pip.on { background: var(--accent, #7C5CE0); }
/* Filled by the clock. It counts towards the sentence and NOT towards the
   payout, so it is present but plainly not one of yours. */
.st-pip.auto { background: rgba(124, 92, 224, .38); }
.st-pip.now { transform: scale(1.45); box-shadow: 0 0 0 3px rgba(124, 92, 224, .18); }

.st-page {
  position: relative; margin: 0 14px 12px; padding: 26px 20px 24px;
  border-radius: 4px 18px 4px 18px;
  background: var(--paper, #FFFBF0);
  box-shadow: 0 8px 24px rgba(63, 48, 130, .14);
  border: 1px solid rgba(88, 70, 160, .13);
  min-height: 148px; display: flex; flex-direction: column; justify-content: center;
}
.st-page.done { box-shadow: 0 10px 30px rgba(124, 92, 224, .26); }
.st-quill {
  position: absolute; right: 10px; bottom: 8px; width: 34px; height: 34px;
  opacity: .28; pointer-events: none;
}
.st-quill svg { width: 100%; height: 100%; display: block; }

.st-line {
  margin: 0; font-family: 'Caveat', cursive; font-size: 30px; line-height: 1.45;
  color: var(--pen, #3A4E86); text-align: center; word-break: break-word;
}
.st-w { color: var(--pen, #3A4E86); }
/* A word the clock chose. Lighter, because the payout counts the ones a
   PERSON put there and the page should not pretend otherwise. */
.st-w.auto { color: var(--pen-soft, #7C8AB4); }
.st-w.fresh { animation: st-ink .5s ease-out; }
@keyframes st-ink {
  from { opacity: 0; transform: translateY(-5px) rotate(-3deg); }
  to { opacity: 1; transform: none; }
}
/* A slot still to come: a ruled gap, the width of a word. */
.st-blank {
  display: inline-block; width: 54px; height: 2px; margin: 0 3px 7px;
  vertical-align: middle; background: var(--paper-rule, #C9D8EF);
  border-radius: 2px;
}
.st-blank.now {
  background: var(--accent, #7C5CE0);
  animation: st-wait 1.1s ease-in-out infinite;
}
@keyframes st-wait { 50% { opacity: .3; } }
.st-by {
  margin: 12px 0 0; text-align: center; font-family: 'Caveat', cursive;
  font-size: 19px; color: var(--pen-soft, #7C8AB4);
}

.st-scraps {
  flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 1fr;
  gap: 10px; padding: 0 16px 10px; align-content: center;
}
.st-scrap {
  padding: 16px 10px; font-family: 'Caveat', cursive; font-size: 26px;
  font-weight: 700; color: var(--pen, #3A4E86);
  background: var(--paper, #FFFBF0);
  /* Four different corner radii is what makes it read as torn rather than as
     a card with rounded corners. */
  border-radius: 14px 4px 12px 5px;
  border: 1px solid rgba(88, 70, 160, .14);
  box-shadow: 0 4px 12px rgba(63, 48, 130, .12);
  transform: rotate(var(--tilt, 0deg));
  transition: transform .14s ease, box-shadow .16s ease, opacity .2s ease;
}
.st-scrap:active:not(:disabled) {
  transform: rotate(var(--tilt, 0deg)) scale(.96);
}
/* ⚠️ NOT greyed out while it is your partner's turn. Their four words are the
   thing you are watching — dimming them to 40% would hide the half of the
   game that is theirs, which is the half this game exists for. */
.st-scrap:disabled { box-shadow: 0 2px 7px rgba(63, 48, 130, .08); }
.st-scrap.taken {
  transform: rotate(var(--tilt, 0deg)) scale(1.04);
  box-shadow: 0 8px 20px rgba(124, 92, 224, .3);
  border-color: var(--accent, #7C5CE0);
}

/* ===========================================================================
   R23 games-fun5 — Twins (`twins`)

   ⚠️ THE CARD IS THE BUTTON. The card the player taps IS the artwork, so there
   is no pill under the table saying what to press. The card before this one
   sits behind it, small and tilted, because the whole game is a comparison and
   hiding half of it makes the player hold it in their head.
   ========================================================================== */
.tw-table {
  position: relative; flex: 1; min-height: 0;
  display: flex; align-items: center; justify-content: center;
  padding: 6px 14px 10px;
}
.tw-card {
  display: flex; align-items: center; justify-content: center;
  background: var(--paper, #FFFBF0);
  border-radius: 18px;
  box-shadow: 0 8px 22px rgba(63, 48, 130, .16);
  border: 1px solid rgba(88, 70, 160, .12);
}
.tw-card svg { width: 62%; height: 62%; display: block; }
/* The card before this one. Behind and to the left, tilted, and deliberately
   NOT faded to 30% — it is half of the comparison, not decoration. */
.tw-card.prev {
  position: absolute; left: 16%; top: 50%;
  width: 96px; height: 124px;
  transform: translateY(-50%) rotate(-9deg);
  z-index: 1; box-shadow: 0 5px 14px rgba(63, 48, 130, .13);
}
.tw-cur {
  position: relative; z-index: 2; padding: 0; border: 0; background: none;
  transition: transform .12s ease;
  touch-action: manipulation;
}
.tw-cur .tw-card.face {
  width: 148px; height: 190px; border-width: 3px;
  border-color: rgba(124, 92, 224, .22);
}
.tw-cur:active:not(:disabled) { transform: scale(.95); }
/* Two the same. The table itself lights up, which is the only cue this game
   needs and the reason it has no tutorial. */
.tw-table.pair .tw-cur .tw-card.face {
  border-color: var(--accent, #7C5CE0);
  box-shadow: 0 0 0 6px rgba(124, 92, 224, .2), 0 12px 30px rgba(124, 92, 224, .3);
  animation: tw-beat .5s ease-in-out infinite alternate;
}
@keyframes tw-beat { to { transform: scale(1.035); } }
/* Locked out after a wrong tap, until the next card. Dimmed, because it is
   genuinely not yours to press — unlike the partner's scraps in Our Sentence,
   which stay bright because watching them is the game. */
.tw-cur.locked { opacity: .5; }

.tw-flash {
  position: absolute; left: 50%; bottom: 4px; transform: translateX(-50%);
  z-index: 3; padding: 7px 15px; border-radius: 999px;
  font-size: 14px; font-weight: 800; white-space: nowrap;
  animation: tw-pop .28s ease-out;
  background: rgba(255, 255, 255, .92); color: var(--ink);
  box-shadow: 0 4px 14px rgba(63, 48, 130, .18);
}
.tw-flash.got { background: #5FA46B; color: #fff; }
.tw-flash.miss { background: #E8617A; color: #fff; }
@keyframes tw-pop {
  from { opacity: 0; transform: translateX(-50%) translateY(8px); }
}

.tw-target { font-weight: 900; font-size: 15px; color: var(--ink); }
/* How far through the deck. A bar rather than a number: nobody reads a number
   at one card a second. */
.tw-run {
  height: 5px; margin: 0 16px 10px; border-radius: 3px;
  background: rgba(124, 92, 224, .14); overflow: hidden;
}
.tw-run span {
  display: block; height: 100%; border-radius: 3px;
  background: var(--accent, #7C5CE0); transition: width .3s linear;
}

/* ===========================================================================
   Chess (`chess`). R23 `game-chess`, 2026-08-24.

   ⚠️ THE PIECES ARE DRAWN AND THE BOARD IS THE CONTROL. Every square is the
   button; nothing on this screen carries a piece's NAME. The two things that
   are not squares — resign and offer a draw — are a mark with a word under it
   rather than a pill with a word in it, which is WORKING-RULES §4.4.

   ⚠️ EVERY PIECE IS ONE DRAWING IN `currentColor`, so White and Black are the
   same paths at two `color:` values. That is what makes the rim below load
   bearing rather than decorative: a cream king on a cream square and an ink
   king on a dark square are each invisible without it, and the drawing is
   identical in both cases so no assertion could tell.
   ========================================================================== */
.chess-wrap {
  position: relative;
  padding: 2px 12px 6px;
  display: flex; align-items: center; justify-content: center;
}
.chess-board {
  --sq-light: #F6EDDC;
  --sq-dark: #A794D4;
  width: min(92vw, 400px);
  display: grid; grid-template-columns: repeat(8, 1fr);
  /* grid-auto-rows: 1fr — item 15a's sweep. See .su-grid and
     scripts/checks/grid-guard.js. A rank with pieces on it and an empty rank
     were two content measurements against eight ratio-sized files; they happen
     to agree because .csq carries its own aspect-ratio, which is a second
     mechanism doing the row track's job. Now the track does it. */
  grid-auto-rows: 1fr;
  aspect-ratio: 1;
  border-radius: 14px; overflow: hidden;
  box-shadow: 0 10px 26px rgba(63, 48, 130, .22), 0 0 0 3px rgba(88, 70, 160, .16);
}
.csq {
  position: relative; padding: 0; border: 0; margin: 0;
  display: flex; align-items: center; justify-content: center;
  aspect-ratio: 1; touch-action: manipulation; cursor: default;
  background: var(--sq-light);
}
.csq.dark { background: var(--sq-dark); }
.csq:not([disabled]) { cursor: pointer; }

/* The piece. 82% rather than 100% so a knight's ears do not touch the square
   above it — the pieces read as standing ON the board, not tiling it. */
.cman { width: 82%; height: 82%; display: block; pointer-events: none; }
/* ⚠️ THE RIM IS NOT A SHADOW. Four one-pixel drop-shadows in the four
   directions are an outline; a single offset one is a drop shadow and leaves
   the top-left edge of a cream piece touching a cream square with nothing
   between them. Both sides get a rim in the OTHER side's colour, because the
   light square is too pale for an ink rim to save the white piece and the
   dark square too deep for a cream one to save the black. */
.cman.w { color: #FFFCF4; --rim: rgba(38, 30, 74, .92); }
.cman.b { color: #2E2554; --rim: rgba(255, 252, 244, .82); }
.cman svg {
  width: 100%; height: 100%; display: block;
  filter:
    drop-shadow(1px 0 0 var(--rim)) drop-shadow(-1px 0 0 var(--rim))
    drop-shadow(0 1px 0 var(--rim)) drop-shadow(0 -1px 0 var(--rim));
}

/* The two squares of the move just played, and the square a piece has been
   picked up from. `fresh` is dimmer than `sel` on purpose: one is something
   that already happened and the other is something you are doing. */
.csq.fresh::before {
  content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  background: rgba(240, 200, 90, .38);
}
.csq.sel::before {
  content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  background: rgba(124, 92, 224, .42);
  box-shadow: inset 0 0 0 3px rgba(124, 92, 224, .85);
}
/* The king that is in check. Red from the middle outwards, so it cannot be
   read as one more legal-move marker. */
.csq.inchk::before {
  content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  background: radial-gradient(circle at 50% 50%, rgba(226, 62, 96, .95) 12%, rgba(226, 62, 96, 0) 78%);
}
/* Where it can go. A dot on an empty square and a RING on an occupied one:
   they mean different things — a step and a capture — and a dot printed over
   a piece hides the very piece you are deciding whether to take. */
.cdot { position: absolute; pointer-events: none; }
.csq.go .cdot {
  width: 26%; aspect-ratio: 1; border-radius: 50%;
  background: rgba(90, 62, 190, .45);
}
.csq.take .cdot {
  top: 6%; right: 6%; bottom: 6%; left: 6%; border-radius: 50%;
  border: 3.5px solid rgba(216, 74, 104, .85);
}
.csq:not([disabled]):active { filter: brightness(1.08); }

/* ---- the two clocks -------------------------------------------------------
   One row above the board and one below it, each on its owner's side, so
   which clock is yours is a matter of where you are looking rather than of
   reading a label. */
.cclock {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; margin: 0 16px 4px; padding: 6px 12px;
  border-radius: 12px;
  background: rgba(255, 255, 255, .42);
  border: 1px solid rgba(88, 70, 160, .12);
}
.cclock.me { margin: 4px 16px 0; }
/* Whose move it is. The running clock IS the handover signal on this screen —
   the note above renderChess() says why there is no turn card. */
.cclock.on {
  background: rgba(124, 92, 224, .16);
  border-color: rgba(124, 92, 224, .42);
  box-shadow: 0 3px 12px rgba(124, 92, 224, .18);
}
.cc-who {
  display: flex; align-items: center; gap: 7px; min-width: 0;
  font-weight: 800; font-size: 14px; color: var(--ink-soft, #473C78);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cc-dot {
  width: 13px; height: 13px; border-radius: 50%; flex: none;
  border: 1.5px solid rgba(46, 37, 84, .7);
}
.cc-dot.w { background: #FFFCF4; }
.cc-dot.b { background: #2E2554; }
.cc-t {
  font-variant-numeric: tabular-nums; font-weight: 900; font-size: 19px;
  color: var(--ink, #2B2350); flex: none;
}
.cc-t.low { color: #D8446A; }
.cclock.on .cc-t.low { animation: cc-blink .9s steps(1) infinite; }
@keyframes cc-blink { 50% { opacity: .35; } }

/* ---- the move list --------------------------------------------------------
   One line that scrolls sideways, not a column. A column of eighty moves is
   the whole screen, and the board is the screen. `direction: rtl` on the
   strip keeps the newest move in view with no scroll script; each entry sets
   itself back to ltr so the notation is not reversed. */
.cmoves {
  display: flex; gap: 12px; align-items: center;
  margin: 6px 16px 0; padding: 5px 2px;
  overflow-x: auto; overflow-y: hidden;
  scrollbar-width: none;
  font-size: 13px; font-weight: 700; color: var(--ink-soft, #473C78);
  direction: rtl;
}
.cmoves::-webkit-scrollbar { display: none; }
.cmv { direction: ltr; white-space: nowrap; flex: none; }
.cmv i { font-style: normal; opacity: .5; font-weight: 800; }

/* ---- resign, and offering a draw ------------------------------------------
   ⚠️ A MARK WITH A WORD UNDER IT, not a pill with a word in it. */
.cacts { display: flex; gap: 10px; justify-content: center; margin: 8px 16px 0; }
.cact {
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  padding: 7px 14px; border-radius: 14px;
  background: rgba(255, 255, 255, .5);
  border: 1px solid rgba(88, 70, 160, .14);
  font-size: 11.5px; font-weight: 800; color: var(--ink-soft, #473C78);
  cursor: pointer; touch-action: manipulation;
}
.cact svg { width: 26px; height: 26px; display: block; }
.cact:active { transform: scale(.95); }
/* A draw is standing against you, so this control now ACCEPTS one. Lit, not
   merely re-labelled: the word changes and nobody reads the word. */
.cact.lit {
  background: rgba(124, 92, 224, .2);
  border-color: rgba(124, 92, 224, .5);
  color: var(--ink, #2B2350);
}
.cact.waiting { opacity: .55; }
.cact.no { justify-content: center; padding: 7px 16px; }
.cact:disabled { cursor: default; }

/* ---- choosing what the pawn becomes ---------------------------------------
   Four pieces, drawn, over the board it is happening on. Not a dropdown, and
   not four buttons reading Queen / Rook / Bishop / Knight. */
/* ⚠️ INSET TO THE BOARD, not to the wrap. `inset: 0` covers the wrap's padding
   too, so the veil showed as a hard-edged rectangle sticking out past the
   board's rounded corners. The numbers are .chess-wrap's padding. */
.cpromo-veil {
  position: absolute; top: 2px; right: 12px; bottom: 6px; left: 12px; z-index: 5;
  display: flex; align-items: center; justify-content: center;
  background: rgba(43, 35, 80, .42); border-radius: 14px;
}
.cpromo {
  background: var(--paper, #FFFBF0);
  border-radius: 18px; padding: 12px 14px;
  box-shadow: 0 14px 34px rgba(43, 35, 80, .34);
  display: flex; flex-direction: column; align-items: center; gap: 8px;
}
.cpromo-cap { font-size: 13px; font-weight: 800; color: var(--ink-soft, #473C78); }
.cpromo-row { display: flex; gap: 8px; }
.cpromo-pick {
  width: 56px; height: 56px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  border-radius: 14px; cursor: pointer; touch-action: manipulation;
  background: #EFE6FA; border: 2px solid rgba(124, 92, 224, .3);
}
.cpromo-pick .cman { width: 84%; height: 84%; }
.cpromo-pick:active { transform: scale(.94); }
/* ---- Kızma Birader (R23, online) -------------------------------------------
   A ring of 24 squares drawn as the perimeter of a 7x7 CSS grid — 49 cells
   minus the inner 5x5 is exactly 24, so every square's grid-row/grid-column
   is set inline from the same ring-index arithmetic games.js and
   server/kizma.js share. The inner 5x5 is .kz-camp, spanning grid-row 2/7 and
   grid-column 2/7, which never overlaps a ring cell — the ring only ever
   occupies row/column 1 and 7, or rows/columns 2-6 of column/row 1 and 7. */
.kz-board {
  display: grid; grid-template-columns: repeat(7, 1fr); grid-template-rows: repeat(7, 1fr);
  gap: 2px; width: min(92vw, 380px); aspect-ratio: 1; margin: 6px auto; flex: 0 0 auto;
  padding: 4px; border-radius: 16px;
  background: rgba(124, 92, 224, .16);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .45);
}
.kz-sq {
  position: relative; font-style: normal; border-radius: 6px;
  background: #FCFAFF;
  display: flex; align-items: center; justify-content: center;
}
/* The two entry squares — where a peg leaving the nest lands — carry a soft
   ring in that player's own colour, so the gate has a place on the board
   before anyone is standing on it. */
.kz-sq.entry::before {
  content: ''; position: absolute; top: 3px; right: 3px; bottom: 3px; left: 3px; border-radius: 5px;
  box-shadow: inset 0 0 0 2px currentColor; opacity: .35;
}
.kz-sq.entry.a { color: #F0873C; }
.kz-sq.entry.b { color: #6FA8E8; }

/* A HAVEN — ring 6 and ring 18, the two corners between the entries. A peg
   standing here cannot be captured, and an enemy standing here blocks the
   square. It is drawn as a soft mint DISC rather than the entry's square ring,
   on purpose and in two ways at once: a different shape and a different colour,
   because the two marks sit on the same board and a player has to tell them
   apart at 40px without a legend. Deliberately player-NEUTRAL — a haven belongs
   to whoever gets there, unlike an entry.
   ⚠️ Drawn in CSS rather than added to the SVG art: the ring squares are bare
   .kz-sq boxes with no drawing of their own, so a mark here costs nothing and
   cannot drift from server/kizma.js's SAFE set, which the client is TOLD. */
.kz-sq.safe { background: #F2FBF6; }
.kz-sq.safe::before {
  content: ''; position: absolute; top: 4px; right: 4px; bottom: 4px; left: 4px; border-radius: 50%;
  box-shadow: inset 0 0 0 2px #4FBF8B; opacity: .5;
}

.kz-camp {
  grid-row: 2 / 7; grid-column: 2 / 7;
  display: flex; flex-direction: column;
  border-radius: 14px; padding: 6px 4px;
  background: rgba(255, 255, 255, .6);
  box-shadow: inset 0 0 0 1px rgba(88, 70, 160, .14);
}
/* ⚠️ flex: 1 on both rows, not `justify-content: space-between` on the
   parent — that put the two rows flush against the top and bottom of the
   5x5 panel with the whole middle of the board sitting empty, exactly the
   "dead space" the design note argued against. Each row now owns half the
   panel's height and centres its own content in it. Found by rendering
   kz-01-start.png and looking. */
.kz-camp-row {
  flex: 1; display: flex; align-items: center; justify-content: space-between; gap: 4px;
  padding: 4px 6px; border-radius: 10px;
}
.kz-camp-row.a { background: rgba(240, 135, 60, .1); }
.kz-camp-row.b { background: rgba(111, 168, 232, .12); }
/* ⚠️ A 2x2 GRID, NOT A ROW, AND FOUR PEGS IS WHY. As a flex row four 20px pegs
   with 4px gaps are 92px wide, and on a narrow phone the camp row only has
   about 158px for two pockets and a name — so a player whose four pegs were all
   knocked back into the nest could push the name out of its own panel. A 2x2
   cluster is 44px whatever the peg count, which also reads better: a nest is a
   pile, not a queue. A min-content auto row keeps the cluster centred
   rather than stretching the discs when only one or two are in it. */
.kz-pocket, .kz-rosette {
  display: grid; grid-template-columns: repeat(2, auto); grid-auto-rows: min-content;
  gap: 4px; min-width: 30px; min-height: 26px; align-content: center;
}
.kz-rosette { justify-content: end; }
.kz-camp-name {
  flex: 1; text-align: center; font-size: 10.5px; font-weight: 800; color: var(--ink-soft);
  max-width: 68px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.kz-camp-name.you { color: var(--accent); }

/* One peg. A plain disc in the player's colour — the shelf drawing already
   carries the "peg" shape; on the board it only has to read as a piece and be
   told apart from its partner's at 26px. */
/* ⚠️ position + z-index, and it is not decoration. A peg flying between a
   ring square and a camp slot (games.js's FLIP animation, kzFreeze/kzRelease)
   is transformed OUTSIDE its resting cell for the length of the move — and
   without this, whichever cell it is passing over painted on top of it,
   because .kz-sq and .kz-camp are later DOM siblings with no stacking of
   their own. A captured peg vanished behind the ring on its way to the nest;
   found by rendering kz-02-mid-roll.png and looking, not by any assertion. */
.kz-peg {
  position: relative; z-index: 3;
  width: 24px; height: 24px; border-radius: 50%; padding: 0; border: none;
  box-shadow: 0 2px 0 rgba(0, 0, 0, .18), inset 0 2px 0 rgba(255, 255, 255, .35);
  flex: 0 0 auto;
}
.kz-peg:disabled { opacity: 1; } /* most pegs are inert tokens, not dead controls */
.kz-peg.a { background: radial-gradient(circle at 34% 28%, #FFC08A, #F0873C); }
.kz-peg.b { background: radial-gradient(circle at 34% 28%, #B9D9FF, #6FA8E8); }
.kz-peg.nest, .kz-peg.home { width: 20px; height: 20px; }
/* A pickable peg: it is the one thing on the board that IS a control right
   now, so it gets the affordances a token otherwise has none of. */
.kz-peg.can {
  cursor: pointer; box-shadow: 0 2px 0 rgba(0, 0, 0, .18), 0 0 0 3px rgba(255, 255, 255, .9),
    0 0 0 5px var(--accent);
  animation: kzPick 1.1s ease-in-out infinite;
}
.kz-peg.can:active { transform: translateY(1px); }
@keyframes kzPick {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.12); }
}

.kz-roll-row {
  display: flex; align-items: center; justify-content: center; gap: 12px;
  font-size: 13.5px; font-weight: 700; color: var(--ink-soft);
  padding: 2px 18px; text-align: left; min-height: 60px;
}
.kz-roll-row.dim { color: var(--muted); font-weight: 600; }
.kz-roll-row.dim .kz-die-tray { opacity: .82; }
.kz-said { max-width: 230px; line-height: 1.25; }
.kz-die-tray {
  position: relative; flex: 0 0 auto;
  width: 52px; height: 52px; border-radius: 15px;
  display: flex; align-items: center; justify-content: center;
  background: linear-gradient(165deg, #8468E8 0%, #6B4FCB 55%, #543BA6 100%);
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, .24),
              inset 0 -5px 12px rgba(28, 18, 64, .35),
              0 8px 18px rgba(43, 35, 80, .22);
}
.kz-die { width: 36px; height: 36px; display: block; }
.kz-die svg { width: 100%; height: 100%; display: block; }
.kz-roll-row.rolling .kz-die-tray { animation: kzTrayLive .4s ease-in-out infinite; }
@keyframes kzTrayLive {
  0%, 100% { box-shadow: inset 0 2px 0 rgba(255, 255, 255, .24), inset 0 -5px 12px rgba(28, 18, 64, .35),
    0 8px 18px rgba(43, 35, 80, .22), 0 0 0 2px rgba(255, 255, 255, .5); }
  50% { box-shadow: inset 0 2px 0 rgba(255, 255, 255, .24), inset 0 -5px 12px rgba(28, 18, 64, .35),
    0 8px 18px rgba(43, 35, 80, .22), 0 0 0 8px rgba(255, 255, 255, .04); }
}

.kz-roll-btn {
  display: flex; align-items: center; justify-content: center; gap: 13px;
  width: 100%; max-width: 320px; height: 70px; margin: 0 auto;
  border: none; border-radius: 22px;
  background: linear-gradient(168deg, #9C7EF2 0%, #7C5CE0 52%, #6444C6 100%);
  color: var(--accent-ink); font-size: 20px; font-weight: 800;
  box-shadow: 0 6px 0 #563AA8, 0 16px 28px rgba(88, 70, 160, .34),
              inset 0 2px 0 rgba(255, 255, 255, .32);
  transition: transform .1s ease, box-shadow .1s ease, opacity .2s ease;
}
.kz-roll-cube {
  display: flex; align-items: center; justify-content: center;
  width: 42px; height: 42px; border-radius: 13px;
  background: rgba(255, 255, 255, .94);
  box-shadow: inset 0 -3px 0 rgba(88, 70, 160, .18);
}
.kz-roll-cube svg { width: 30px; height: 30px; display: block; }
.kz-roll-word { letter-spacing: .2px; }
.kz-roll-btn:active:not(:disabled) {
  transform: translateY(4px);
  box-shadow: 0 2px 0 #563AA8, 0 8px 16px rgba(88, 70, 160, .28),
              inset 0 2px 0 rgba(255, 255, 255, .28);
}
.kz-roll-btn:disabled { opacity: .4; box-shadow: 0 4px 0 rgba(86, 58, 168, .5); }
.kz-roll-btn:not(:disabled) .kz-roll-cube { animation: kzCubeNudge 2.8s ease-in-out infinite; }
@keyframes kzCubeNudge {
  0%, 82%, 100% { transform: rotate(0) scale(1); }
  88% { transform: rotate(-15deg) scale(1.08); }
  94% { transform: rotate(10deg) scale(1.04); }
}

@media (prefers-reduced-motion: reduce) {
  .kz-roll-row.rolling .kz-die-tray,
  .kz-roll-btn:not(:disabled) .kz-roll-cube,
  .kz-peg.can { animation: none; }
}

/* Line Battle. new-games-1 `game-lines`, 2026-08-25.
   docs/archive/new-games-1/game-lines.md.

   The board is drawn straight in the server's own 0..1000 coordinate space
   (viewBox="0 0 1000 1000") rather than a CSS grid — twelve scattered dots
   are nowhere near a grid, which is the whole visual point of this game next
   to Dots & Boxes. No client-side coordinate math anywhere.

   ⚠️ Every animation below is opacity/stroke-width/box-shadow, deliberately
   never `transform: scale()` on the SVG shapes themselves — an SVG circle's
   transform-origin does not reliably centre on the shape without
   `transform-box: fill-box`, which is unverified here (no browser check may
   run yet, see the design doc). Opacity and stroke have no such ambiguity. */
.lb-board {
  display: block; width: min(92vw, 400px); aspect-ratio: 1;
  margin: 6px auto; flex: 0 0 auto;
  border-radius: 18px;
  background: rgba(124, 92, 224, 0.08);
}
.lb-hit { fill: transparent; cursor: pointer; }
.lb-line { stroke-width: 14; stroke-linecap: round; }
.lb-line.own-a { stroke: var(--pa); }
.lb-line.own-b { stroke: var(--pb); }
.lb-line.fresh { animation: lbFadeIn .22s ease both; }

.lb-dot { fill: var(--ink); stroke: #FFFFFF; stroke-width: 8; }
.lb-dot.stuck { fill: var(--muted); opacity: .4; }
.lb-dot.legal { fill: var(--accent); }
.lb-dot.sel { fill: var(--accent); animation: lbSelPulse 1s ease-in-out infinite; }
.lb-dot.fresh { animation: lbFadeIn .3s ease both; }
@keyframes lbFadeIn { 0% { opacity: 0; } 100% { opacity: 1; } }
@keyframes lbSelPulse {
  0%, 100% { stroke-width: 8; opacity: 1; }
  50% { stroke-width: 18; opacity: .8; }
}

/* This game's own board-edge turn glow (`.lb-board.mine` + `lbEdgeGlow`) was
   REMOVED 2026-08-26 — Deniz asked for every board-edge glow to go and for the
   ribbon (`.duel-status.mine`) plus the sound and buzz to be the whole handover
   cue. It is still one tap a turn, so there is still no blocking `turn-card`
   here. docs/archive/game-updates1/turn-glow.md. */

@media (prefers-reduced-motion: reduce) {
  .lb-line.fresh, .lb-dot.sel, .lb-dot.fresh { animation: none; }
}

/* ---------- Hedge Race / Çit Yarışı (new-games-1) --------------------------

   docs/archive/new-games-1/game-wallrace.md has the whole design. NOT the
   well-known wall-and-pawn game's own board: that one is a grid of plain grey
   bars on a beige field, which is the single picture that would read as
   "somebody's game" at a glance — so the blocking piece is drawn as a trimmed
   garden hedge instead, in this app's own palette.

   ⚠️ PREFIXED `hr`, NOT `wr` — `wr` already means "wardrobe" throughout this
   file and i18n.js (`wrBow`, `wrCrown`, …). Same two letters, two features,
   reads fine at a glance and confuses the next grep.

   The board is a 13x13 CSS grid: seven `1fr` tracks for the actual cells and
   six 8px tracks between them for the wall slots, THE SAME template on rows
   and columns — that equality is what keeps every cell square without a
   second calculation, because the board itself is `aspect-ratio: 1`. A cell
   sits on an ODD track (1, 3, 5, …), a wall slot's single short axis sits on
   an EVEN one; server/wallrace.js's own (r, c) maps straight onto them via
   `2r + 1` / `2r + 2`, which is why the renderer never has to keep a second
   coordinate system in step with this one. */
.hr-wrap {
  position: relative;
  padding: 2px 12px 6px;
  display: flex; align-items: center; justify-content: center;
}
.hr-board {
  width: min(92vw, 400px);
  aspect-ratio: 1;
  display: grid;
  grid-template-columns: repeat(6, 1fr 8px) 1fr;
  grid-template-rows: repeat(6, 1fr 8px) 1fr;
  border-radius: 14px; overflow: hidden;
  background: rgba(255, 255, 255, .82);
  box-shadow: 0 10px 26px rgba(63, 48, 130, .18), 0 0 0 3px rgba(88, 70, 160, .14);
}
/* ⚠️ THE RIBBON IS THE TURN CUE, AND IT IS THE ONLY ONE THIS GAME ADDS. This
   game's own board-edge glow (`.hr-wrap.mine .hr-board` + `hrGlow`) was
   REMOVED 2026-08-26 at Deniz's request; `.hr-board` above keeps its ORIGINAL
   resting shadow, which is why a harness cannot assert the computed box-shadow
   is "none" here — assert the `mine` class is absent instead. There is still no
   blocking `turn-card`: a turn here is one tap. See renderWallrace's own header
   in games.js, and docs/archive/game-updates1/turn-glow.md. */

.hr-cell {
  position: relative; padding: 0; margin: 0; border: 0;
  display: flex; align-items: center; justify-content: center;
  background: transparent; touch-action: manipulation; cursor: default;
}
.hr-cell:not([disabled]) { cursor: pointer; }
/* A faint wash along each side's OWN goal row, so which way to race reads off
   the board itself rather than off a caption above it. */
.hr-cell.goal-a, .hr-cell.goal-b { background: rgba(95, 164, 107, .16); }
.hr-cell.fresh::before {
  content: ''; position: absolute; top: 10%; right: 10%; bottom: 10%; left: 10%;
  border-radius: 50%; background: rgba(240, 200, 90, .38);
}
.hr-pawn {
  position: relative; font-size: min(6.4vw, 26px); line-height: 1;
  filter: drop-shadow(0 1px 1px rgba(43, 35, 80, .3));
}
.hr-dot {
  position: absolute; width: 26%; height: 26%; border-radius: 50%;
  background: rgba(124, 92, 224, .5);
}
.hr-cell.go:active .hr-dot { background: rgba(124, 92, 224, .8); }

/* A placed hedge — permanent, never a button — and a legal-but-unplanted slot
   — always live, tapping it IS the move, matched against
   server/wallrace.js's own legalWallMoves() before it was ever offered here. */
.hr-wall {
  border-radius: 4px;
  background: linear-gradient(180deg, #6FBE87, #4C9A63);
  box-shadow: inset 0 -2px 0 rgba(46, 94, 58, .5);
}
.hr-wall.fresh {
  box-shadow: inset 0 -2px 0 rgba(46, 94, 58, .5), 0 0 0 2px rgba(240, 200, 90, .7);
}
.hr-slot {
  padding: 0; margin: 0; border: 0; border-radius: 4px;
  background: rgba(95, 164, 107, .3);
  touch-action: manipulation; cursor: pointer;
}
.hr-slot:active { background: rgba(95, 164, 107, .62); }

/* ===========================================================================
   new-games-1 — Rocket Duo (`rocket`)
   One shared rocket in a continuous field, drawn from the wire every ~50ms
   with no client-side prediction — the short `left`/`top` transitions below
   are the only smoothing, the same trick .pong-ball already uses at its own
   40ms tick. docs/archive/new-games-1/game-rocket.md is the design record.
   =========================================================================== */
.rk-tally { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; max-width: 280px; margin: 0 auto; padding: 6px 0 2px; }
.rk-pip {
  width: 10px; height: 10px; border-radius: 50%;
  background: rgba(124, 92, 224, .18);
  transition: background .2s ease, transform .2s ease;
}
.rk-pip.on { background: var(--accent); transform: scale(1.15); }

.rk-field {
  position: relative; width: min(88vw, 46vh); aspect-ratio: 1; margin: 4px auto 10px;
  border-radius: 20px; overflow: hidden;
  background: radial-gradient(circle at 30% 22%, rgba(111, 168, 232, .22),
              rgba(124, 92, 224, .14) 60%, rgba(43, 35, 80, .12));
  border: 2px solid rgba(255, 255, 255, .5);
}
/* Hard mode's spin — cosmetic only. The two players' screens are never
   compared against each other pixel for pixel (unlike the rocket's actual
   position, which both read off the same server frame), so each client is
   free to spin on its own clock. */
.rk-field.spin { animation: rk-spin 9s linear infinite; }
@keyframes rk-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .rk-field.spin { animation: none; } }

.rk-food {
  position: absolute; width: 13%; aspect-ratio: 1; transform: translate(-50%, -50%);
  transition: left .05s linear, top .05s linear;
  filter: drop-shadow(0 0 6px rgba(95, 199, 155, .7));
}
.rk-food svg { width: 100%; height: 100%; display: block; }

.rk-ship {
  position: absolute; width: 17%; aspect-ratio: 1; transform: translate(-50%, -50%);
  transition: left .05s linear, top .05s linear;
}
.rk-ship > svg { width: 100%; height: 100%; display: block; }
/* The four jets ride ON the ship (absolutely positioned children of it), so
   "which of us is thrusting right now" is visible on the ship itself and not
   only on the distant pad. */
.rk-jet {
  position: absolute; width: 46%; color: #F7C948; opacity: 0;
  transition: opacity .08s ease;
}
.rk-jet svg { width: 100%; height: 100%; display: block; }
.rk-jet.on { opacity: 1; }
/* ⚠️ EACH JET IS ON THE FACE OPPOSITE ITS OWN DIRECTION, VENTING AWAY FROM THE
   HULL — and that is the whole of Deniz's "the rocket seems to be pulled
   towards the active nozzle" (game-updates1 item 4b, 2026-08-26). The physics
   in server/rocket.js was never wrong; these four lines were. They put the
   flame on the TRAVEL side pointing ALONG travel, so thrusting up drew a plume
   above the ship pointing up and the eye read a ship chasing its own exhaust.
   A rocket accelerating up vents DOWN, so '.u' — the nozzle that flies you up —
   draws below the hull (top: 96%) rotated 180deg. rocket-flame points up by
   default. Do not "tidy" these back into agreement with the class names.
   Record: docs/archive/game-updates1/rocket-art.md. */
.rk-jet.u { left: 50%; top: 96%; transform: translateX(-50%) rotate(180deg); }
.rk-jet.d { left: 50%; bottom: 96%; transform: translateX(-50%); }
.rk-jet.l { top: 50%; left: 96%; transform: translateY(-50%) rotate(90deg); }
.rk-jet.r { top: 50%; right: 96%; transform: translateY(-50%) rotate(-90deg); }

/* The pad. Five cells in a plus: one nozzle button on each side, a quiet dot
   in the middle where nothing is ever pressed. */
.rk-pad {
  display: grid; grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 1fr 1fr 1fr;
  justify-items: center; align-items: center; gap: 6px;
  width: min(64vw, 210px); margin: 0 auto; padding: 4px 0 8px;
}
/* The visible half of a reshuffle, alongside the sound stateSound() plays and
   the lit/theirs swap on the buttons themselves. */
.rk-pad.flash { animation: rk-flash .4s ease; }
@keyframes rk-flash { 30% { transform: scale(1.08); } }
.rk-hub { grid-column: 2; grid-row: 2; width: 10px; height: 10px; border-radius: 50%; background: rgba(124, 92, 224, .18); }
.rk-noz {
  width: 56px; height: 56px; border-radius: 16px; padding: 10px;
  background: var(--card-bg, rgba(255, 255, 255, .85)); color: var(--accent);
  box-shadow: 0 4px 14px rgba(63, 48, 130, .14);
  transition: transform .1s ease, background .15s ease, color .15s ease, box-shadow .15s ease;
}
.rk-noz svg { width: 100%; height: 100%; display: block; }
/* One flame drawing, turned — the same trick Two Hands' pad (.dt-arw) and
   Rooftop Run's lean buttons already use, so there is no second, third and
   fourth flame to keep in step with the first. */
.rk-noz.u { grid-column: 2; grid-row: 1; }
.rk-noz.l { grid-column: 1; grid-row: 2; transform: rotate(-90deg); }
.rk-noz.r { grid-column: 3; grid-row: 2; transform: rotate(90deg); }
.rk-noz.d { grid-column: 2; grid-row: 3; transform: rotate(180deg); }
.rk-noz.u:active:not(:disabled) { transform: scale(.92); }
.rk-noz.l:active:not(:disabled) { transform: rotate(-90deg) scale(.92); }
.rk-noz.r:active:not(:disabled) { transform: rotate(90deg) scale(.92); }
.rk-noz.d:active:not(:disabled) { transform: rotate(180deg) scale(.92); }
/* ⚠️ LIT LIGHTS THE BUTTON IN ITS OWN COLOUR (game-updates1 item 4a). It used
   to paint every held button the one var(--accent) with a white flame, which
   threw the per-nozzle identity away at the exact moment a player is looking
   for it — and white ink on the amber is 1.57:1, unreadable besides. The ring
   and the glow are currentColor, which rkPaint has set inline to this
   nozzle's colour, and the background stays pale so the coloured flame still
   reads on it. */
.rk-noz.lit {
  background: var(--card-bg, rgba(255, 255, 255, .85));
  box-shadow: 0 0 0 3px currentColor, 0 0 18px currentColor;
}
/* Your partner's two nozzles. Shown, so the split is visible; unpressable,
   because they are not yours — the same reasoning .dt-arw.theirs gives.
   ⚠️ IT DIMS, IT DOES NOT RECOLOUR. `color: var(--muted)` used to replace the
   hue with grey, which is the second place the identity was destroyed. */
.rk-noz.theirs {
  opacity: .42; box-shadow: none;
  background: rgba(255, 255, 255, .4);
}
/* Your partner thrusting. Dimmed with the rest of `theirs`, but the ring
   stays, so their colour is still what tells you which nozzle they are
   firing — the same colour burning on the ship. */
.rk-noz.theirs.lit { box-shadow: 0 0 0 3px currentColor; }
/* ============================================================================
   new-games-1 `time-battle` — Sudoku and Minesweeper, solo and duel.
   docs/archive/new-games-1/time-battle.md is the design record.
   ============================================================================
   Own prefixes: .su- (Sudoku), .ms- (Minesweeper), .tb- (shared race chrome —
   the clock, the partner's progress, resign/pass). Checked free before use:
   `grep -c '\.tb-\|\.su-\|\.ms-' public/styles.css` was 0 before this task.
   .eq- (Equation Rush, game-equations, added later — `grep -c '\.eq-'
   public/styles.css` was 0 before that task) reuses .tb-/.shade-life rather
   than duplicating them. */

/* ---- Sudoku --------------------------------------------------------------- */

.su-wrap {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 14px; padding: 0 12px;
}
.su-grid {
  display: grid; grid-template-columns: repeat(9, 1fr); gap: 1px;
  /* ⚠️ THE ROW TRACK IS PINNED, AND WITHOUT THIS LINE THE BOARD GOES UNEVEN AS
     YOU PLAY IT. game-updates1 item 15a — Deniz: "Sudoku game grid also becomes
     uneven ... grid proportions changing aspect ratio has bitten us many
     times." docs/archive/game-updates1/grid-sweep.md is the sweep;
     scripts/checks/grid-guard.js is what stops it coming back.

     The columns are `1fr` — a RATIO, which never asks the content anything. The
     rows were implicit, so `auto` — a CONTENT measurement. `align-content`
     defaults to stretch, and CSS Grid §12.8 hands the leftover space out
     EQUALLY, so any difference between two rows' content survives forever. Two
     separate things made those differences here:

       · a blank cell is a flex box with no children, so its height is 0 — an
         untouched row was shorter than one you had written a digit into;
       · `.bB`'s 2px border-bottom is part of the cell's border-box and so is
         part of its ROW's base size, while `.bR`'s border-right costs a `1fr`
         column nothing. The two axes paid different prices for the same 3x3
         separator.

     `1fr` rows stop asking, exactly as the columns already do. */
  grid-auto-rows: 1fr;
  width: min(92vw, 340px); aspect-ratio: 1; padding: 6px;
  border-radius: 18px; background: var(--paper);
  box-shadow: 0 10px 30px rgba(63, 48, 130, 0.18), inset 0 0 0 1px rgba(88, 70, 160, 0.10);
  transition: box-shadow .3s ease;
}
.su-cell {
  border: none; padding: 0; background: rgba(124, 92, 224, 0.04);
  display: flex; align-items: center; justify-content: center;
  font-size: min(4.4vw, 17px); font-weight: 600; color: var(--accent);
  transition: background .15s ease;
}
/* A clue — fixed for the whole puzzle, never a button the player can press. */
.su-cell.given {
  background: none; color: var(--ink); cursor: default;
}
.su-cell:not(.given):not([disabled]) { cursor: pointer; }
.su-cell.sel { background: var(--accent-dim); }
/* The three-by-three boxes, the rule the whole game is built on — the same
   nth-child-free technique .hr-board's own slot grid uses: a border on the
   cell itself rather than a wrapper div, so the 81 cells stay one flat list
   and the index into the board stays the index into the DOM. */
.su-cell.bR { border-right: 2px solid var(--ink-soft); }
.su-cell.bB { border-bottom: 2px solid var(--ink-soft); }
.su-grid.done {
  box-shadow: 0 0 34px rgba(95, 199, 155, .5), inset 0 0 0 1px rgba(95, 199, 155, .35);
}
.su-pad {
  display: flex; flex-wrap: wrap; gap: 7px; justify-content: center;
  max-width: 340px;
}
.su-key {
  width: 32px; height: 40px; border: none; border-radius: 10px;
  background: var(--card); color: var(--ink); font-size: 17px; font-weight: 700;
  box-shadow: 0 2px 0 rgba(88, 70, 160, 0.14);
  transition: transform .1s ease, background .15s ease;
}
.su-key:active { transform: translateY(1px) scale(.96); }
.su-erase {
  width: auto; padding: 0 14px; font-size: 13px; font-weight: 600;
  color: var(--muted); background: rgba(217, 85, 140, 0.10);
}

/* ---- Minesweeper ------------------------------------------------------------ */

.ms-wrap {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 12px; padding: 0 12px;
}
.ms-grid {
  /* Columns come from an inline style in games.js (the count is a difficulty
     parameter); the rows are pinned here. Without this an opened cell's digit
     makes its whole row taller and the board drifts as you clear it — the same
     defect as .su-grid's, see that block and scripts/checks/grid-guard.js. */
  display: grid; gap: 3px; padding: 8px; grid-auto-rows: 1fr;
  width: min(92vw, 360px); aspect-ratio: 1;
  border-radius: 18px; background: var(--paper);
  box-shadow: 0 10px 30px rgba(63, 48, 130, 0.18), inset 0 0 0 1px rgba(88, 70, 160, 0.10);
  transition: box-shadow .18s ease;
}
/* Unrevealed — a raised tile, the classic Minesweeper "button waiting to be
   pressed" read, done with a bevel rather than a border so it survives at
   28px on a phone without the corners going muddy. */
.ms-cell {
  position: relative; border: none; padding: 0; border-radius: 5px;
  background: linear-gradient(160deg, #E9E2FA 0%, #D6C9F0 100%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .6), inset 0 -2px 0 rgba(88, 70, 160, .25);
}
.ms-cell:not(.open):not([disabled]):active {
  box-shadow: inset 0 2px 4px rgba(63, 48, 130, .3);
  transform: translateY(1px);
}
.ms-cell.open {
  background: var(--paper); box-shadow: inset 0 0 0 1px rgba(88, 70, 160, 0.08);
  display: flex; align-items: center; justify-content: center;
  font-size: min(3.6vw, 15px); font-weight: 800;
}
/* The classic colour-per-count read, so a number is legible without reading
   the digit — the same reasoning the difficulty gauge's three colours are
   picked for. */
.ms-cell.open.n1 { color: var(--fertile); }
.ms-cell.open.n2 { color: var(--good); }
.ms-cell.open.n3 { color: var(--bad); }
.ms-cell.open.n4 { color: var(--accent); }
.ms-cell.open.n5 { color: #8B4A2E; }
.ms-cell.open.n6 { color: #2E9C9C; }
.ms-cell.open.n7 { color: var(--ink); }
.ms-cell.open.n8 { color: var(--dim); }
/* A flag — drawn, never an emoji (see i18n.js's own note on msFlag: neither
   bundled-subset flag glyph exists). A pole and a small pennant, in two
   pseudo-elements so no image or gradient id is needed. */
.ms-cell.flag::before {
  content: ''; position: absolute; left: 44%; top: 22%; width: 8%; height: 58%;
  background: var(--ink-soft); border-radius: 1px;
}
.ms-cell.flag::after {
  content: ''; position: absolute; left: 50%; top: 22%; width: 34%; height: 24%;
  background: var(--bad); clip-path: polygon(0 0, 100% 25%, 0 50%);
}
/* A mine — a dark bead with a highlight, deliberately round rather than
   faceted (CLAUDE.md's hard IP rule about jewelled grids is about a
   different game, but the same picture would read wrong here too). */
.ms-cell.open.mine {
  background: rgba(217, 85, 140, 0.16);
}
.ms-cell.open.mine::before {
  content: ''; position: absolute; width: 46%; height: 46%; border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #6B6480, var(--ink) 70%);
}
.ms-grid.boom { animation: msBoom .32s ease 1; }
@keyframes msBoom {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-4px); }
  75% { transform: translateX(4px); }
}
@media (prefers-reduced-motion: reduce) { .ms-grid.boom { animation: none; } }
.ms-flagbtn.on { background: var(--accent); color: #fff; }

/* ---- Shared race chrome (.tb-) — the clock, the opponent line, resign/pass */

.tb-clock {
  font-variant-numeric: tabular-nums; font-weight: 700; font-size: 14px;
  color: var(--ink-soft);
}
.tb-opp {
  position: relative; height: 30px; margin: 4px 16px 0;
  border-radius: 15px; overflow: hidden; background: var(--card);
  box-shadow: inset 0 0 0 1px var(--card-brd);
}
.tb-opp i {
  position: absolute; top: 0; right: auto; bottom: 0; left: 0; display: block;
  background: linear-gradient(90deg, var(--accent-dim), var(--accent));
  transition: width .35s ease; border-radius: 15px;
}
.tb-opp b {
  position: relative; display: flex; height: 100%; align-items: center;
  justify-content: center; font-size: 12px; font-weight: 700; color: var(--ink-soft);
}
.tb-opp.done { background: rgba(47, 159, 123, 0.16); }
.tb-opp.done b { color: var(--good); }
.tb-opp.out { background: rgba(217, 85, 140, 0.14); }
.tb-opp.out b { color: var(--bad); }
.tb-actions {
  display: flex; gap: 10px; justify-content: center; margin: 6px 0;
}
.tb-actions .btn-ghost.on { background: var(--accent-dim); color: var(--accent); }

/* ---- the pick screen (game-updates2 item 8) --------------------------------
   Deniz: "ask in the beginning of the game (just like warmer) the amount of
   time, and there should be option to choose not timed."

   ⚠️ THE TILE IS THE DRAWING, NOT A PILL WITH A NUMBER ON IT — CLAUDE.md, and
   his own "it should not look like a standard layout with pill buttons etc".
   Four clock faces, each filled to the amount of time it offers, and one with
   a figure-eight instead of a hand. The number is stated ONCE, under the row.

   ⚠️ THE ROW MUST NEVER WRAP, and this is the identical trap .warm-plots
   records paying for: a min-width big enough to look right on a 390px phone
   wraps at 320, which is an iPhone SE and plenty of Androids, and four dials
   on two lines destroys the only thing this layout is for — reading the row as
   a progression. So the tiles are flex children with a ZERO basis: they divide
   whatever the row has, nothing asks for more than it has, and max-width only
   stops them ballooning on a tablet. Turkish is the longer language and its
   title sits ABOVE the row, so it cannot push the dials sideways either. */

.tb-pick {
  flex: 1; min-height: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 18px; padding: 0 16px;
}
.tb-pick-title {
  margin: 0; text-align: center; font-size: 17px; font-weight: 800;
  color: var(--ink);
}
.tb-dials {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  width: 100%; max-width: 320px;
}
.tb-dial {
  /* ⚠️ flex: 1 1 0 with min-width: 0 is the no-wrap guarantee — see above. */
  flex: 1 1 0; min-width: 0; max-width: 66px; aspect-ratio: 1;
  border: none; padding: 4px; border-radius: 18px;
  background: transparent;
  /* Nothing in a dial is text. A stray glyph would arrive at a 16px default
     rather than hide, the same guard .warm-plot carries. */
  font-size: 0;
  /* The SVG is drawn entirely in currentColor, so selection is one property. */
  color: var(--ink-soft);
  opacity: .62;
  transition: transform .16s ease, opacity .16s ease, color .16s ease, box-shadow .16s ease;
}
.tb-dial svg { display: block; width: 100%; height: 100%; }

/* ⚠️ SELECTED IS ON THE TILE, NOT ON A CHIP BESIDE IT — CLAUDE.md again. The
   chosen dial is raised, drawn in the accent, ringed and its hand sweeps once;
   the others sit flat and grey. Four differences, not one, so it survives a
   colour-blind eye and a bright screen in sunlight. */
.tb-dial.on {
  opacity: 1;
  color: var(--accent);
  transform: translateY(-6px) scale(1.06);
  box-shadow: 0 0 0 3px var(--accent), 0 10px 22px rgba(63, 48, 130, .26);
}
.tb-dial.on svg { animation: tb-dial-in .5s ease-out; transform-origin: 50% 50%; }
@keyframes tb-dial-in {
  from { transform: rotate(-16deg) scale(.9); }
  to { transform: rotate(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) { .tb-dial.on svg { animation: none; } }
.tb-dial:active:not(:disabled) { transform: scale(.94); }
.tb-dial:disabled { opacity: .34; }
.tb-dial.on:disabled { opacity: .78; }

/* The one number on this screen. It restates the current choice as you tap, so
   the amount is said unambiguously ONCE rather than printed onto every tile —
   which would be the pill row wearing a picture. */
.tb-pick-count {
  margin: 0; text-align: center; font-size: 15px; font-weight: 800;
  color: var(--accent);
}
.tb-pick-go {
  width: min(100%, 280px); border: none; border-radius: 22px;
  padding: 15px; background: var(--accent); color: #FFFFFF;
  font-size: 17px; font-weight: 800;
  box-shadow: 0 8px 22px rgba(124, 92, 224, .32);
  transition: transform .1s ease;
}
.tb-pick-go:active { transform: scale(.97); }

/* ⚠️ The ∞ in the header is NOT a countdown of the invisible backstop — there
   is genuinely no clock on this match. Drawn a shade lighter than a running
   clock so it reads as an absence rather than as a number. */
.tb-clock.none { font-size: 17px; opacity: .6; line-height: 1; }

/* ---- Lights Out (new-games-1 game-lightsout) ------------------------------
   docs/archive/new-games-1/game-lightsout.md is the design record.
   Own prefix: .lo-. Checked free before use: `grep -c '\.lo-' public/styles.css`
   was 0. Shares .tb- above for the clock/opponent-line/resign-pass chrome. */

.lo-wrap {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 14px; padding: 0 12px;
}
.lo-grid {
  /* Pinned for the reason .su-grid's block gives. ⚠️ Honestly: this board is
     not broken today — a .lo-cell never holds content, so every row's base is
     0 and they come out equal by luck. It is one label away from breaking, and
     the fix is one word. */
  display: grid; gap: 5px; padding: 8px; grid-auto-rows: 1fr;
  width: min(92vw, 340px); aspect-ratio: 1;
  border-radius: 18px; background: var(--paper);
  box-shadow: 0 10px 30px rgba(63, 48, 130, 0.18), inset 0 0 0 1px rgba(88, 70, 160, 0.10);
  transition: box-shadow .3s ease;
}
/* Unlit — a flat, muted tile, deliberately dark rather than merely
   desaturated so "off" reads at a glance next to a genuinely glowing "on". */
.lo-cell {
  border: none; border-radius: 8px;
  background: linear-gradient(160deg, #6B6480 0%, #4B3B8F 100%);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06);
  transition: background .15s ease, box-shadow .15s ease, transform .1s ease;
}
.lo-cell:not([disabled]):active { transform: scale(.94); }
/* Lit — a warm glow, both the fill AND a soft halo outside the tile's own
   box, so "on" reads as light spilling out rather than a colour swap. */
.lo-cell.on {
  background: var(--sun, #F7C948);
  box-shadow: 0 0 14px 3px rgba(247, 201, 72, .65), inset 0 0 0 1px rgba(255, 255, 255, .5);
}
.lo-grid.done {
  box-shadow: 0 0 34px rgba(95, 199, 155, .5), inset 0 0 0 1px rgba(95, 199, 155, .35);
}

/* ---- Equation Rush (game-equations) --------------------------------------
   docs/archive/new-games-1/game-equations.md is the design record. .eq-life
   reuses .shade-life's own drawing (artSVG('shade-life')) rather than a
   near-duplicate icon; sizing kept identical to .shade-life on purpose so
   the two rows read as the same kind of thing wherever they appear. */

.eq-wrap {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 18px; padding: 0 16px;
}
.eq-lives { display: inline-flex; gap: 5px; align-items: center; }
.eq-life { width: 17px; height: 17px; display: inline-block; }
.eq-life svg { width: 100%; height: 100%; display: block; }
.eq-life.gone { opacity: .22; filter: grayscale(1); }

/* The per-question clock — shade-bar's own idiom (a scaled inner bar rather
   than a redrawn width, so it animates on the GPU) reused rather than
   duplicated as a second near-identical block further down this file. */
.eq-bar {
  --left: 1;
  width: min(86vw, 340px); height: 7px; flex: none;
  border-radius: 99px; overflow: hidden;
  background: rgba(63, 48, 130, .16);
}
.eq-bar > i {
  display: block; height: 100%; width: 100%;
  transform-origin: left center; transform: scaleX(var(--left));
  border-radius: 99px;
  background: linear-gradient(90deg, var(--accent), #D9558C);
  transition: transform .1s linear, background-color .2s ease;
}
.eq-bar.low > i { background: linear-gradient(90deg, #E8617A, #E8617A); }
.eq-bar.low { animation: eqBarPulse .7s ease-in-out infinite; }
@keyframes eqBarPulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
@media (prefers-reduced-motion: reduce) {
  .eq-bar.low { animation: none; }
  .eq-bar > i { transition: none; }
}

.eq-prompt {
  font-size: min(9vw, 34px); font-weight: 800; color: var(--ink);
  text-align: center; letter-spacing: .01em;
}
.eq-q { color: var(--accent); }

/* Four options (eval/missing/estimate) sit 2x2 — a phone-width row of four
   numbers is too cramped to read at speed, which is the one thing this game
   cannot afford. Two options (compare) sit side by side — it is a genuine
   two-way call, not a set of four to scan. */
.eq-options {
  display: grid; gap: 10px; width: min(92vw, 360px);
}
.eq-options.n4 { grid-template-columns: repeat(2, 1fr); }
.eq-options.n2 { grid-template-columns: repeat(2, 1fr); }
.eq-opt {
  border: none; border-radius: 16px; padding: 18px 10px;
  background: var(--card); color: var(--ink);
  font-size: min(6vw, 22px); font-weight: 700;
  box-shadow: 0 3px 0 rgba(88, 70, 160, 0.14);
  transition: transform .1s ease, background .15s ease, box-shadow .15s ease;
}
.eq-opt:not([disabled]):active { transform: translateY(1px) scale(.97); }
.eq-opt[disabled] { opacity: .55; }
/* The reveal after a tap — right glows good, wrong glows bad; both fade back
   out the moment the next question paints, so nothing lingers into it. */
.eq-opt.right { background: rgba(95, 199, 155, .28); box-shadow: 0 0 0 2px var(--good) inset; }
.eq-opt.wrong { background: rgba(232, 97, 122, .22); box-shadow: 0 0 0 2px var(--bad) inset; }

/* ---- Memory Grid (.mg-) — new-games-1 game-memgrid ------------------------
   docs/archive/new-games-1/game-memgrid.md. Flat rounded cells, no gems, no
   faceted look — CLAUDE.md's hard rule against that look is aimed at a
   different game, but a memory-flash grid is its own genre and should read
   as one regardless. */

.mg-wrap { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.mg-status, .mg-round { font-weight: 600; color: var(--ink-soft); }
.mg-grid {
  /* ⚠️ THE BOARD IS NOT ALWAYS SQUARE, AND THIS RULE USED TO SAY IT WAS.
     game-updates1 item 15a. `aspect-ratio: 1` was hard-coded while level 2 is
     a 4x5 board (server/difficulty.js MEMGRID), and .mg-cell carries an
     aspect-ratio of its own — so five square rows had to fit a box only four
     square rows tall, and the board overflowed the rounded card it is drawn on.

     Same answer .mem-grid already uses one screen away: the SHAPE comes from
     the board, --c and --r are written by the render function, and one place
     decides. The defaults below are level 1's 4x4, so a missing variable
     degrades to a square rather than to nonsense. */
  --c: 4; --r: 4;
  display: grid; gap: 6px; width: min(88vw, 340px);
  grid-template-columns: repeat(var(--c), 1fr);
  grid-template-rows: repeat(var(--r), 1fr);
  aspect-ratio: calc(var(--c) / var(--r));
}
.mg-cell {
  border-radius: 8px; border: 1px solid var(--card-brd); background: var(--card);
  aspect-ratio: 1; padding: 0; transition: background .15s ease, transform .1s ease;
}
.mg-cell:not([disabled]) { cursor: pointer; }
.mg-cell[disabled] { cursor: default; }
.mg-cell.flash { background: var(--accent); transform: scale(1.04); }
.mg-cell.lit { background: var(--good); }
.mg-grid.done { animation: mgClear .3s ease 1; }
@keyframes mgClear {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.03); }
}
@media (prefers-reduced-motion: reduce) {
  .mg-cell { transition: none; }
  .mg-grid.done { animation: none; }
}

/* ---- Number Sequence (.ns-) — new-games-1 game-memgrid ---------------------
   Targets are absolutely positioned inside a fixed-aspect field, matching the
   328x460 reference box public/numseq.js's layout() generates against — see
   the design note's own minimum-separation section. */

.ns-wrap { display: flex; justify-content: center; }
.ns-field {
  position: relative; width: min(88vw, 328px); aspect-ratio: 328 / 460;
  background: var(--card); border-radius: 14px; box-shadow: inset 0 0 0 1px var(--card-brd);
}
.ns-target {
  position: absolute; width: 42px; height: 42px; transform: translate(-50%, -50%);
  border-radius: 50%; border: 1px solid var(--card-brd); background: var(--paper);
  color: var(--ink); font-weight: 700; font-size: 15px; padding: 0;
  transition: background .15s ease, color .15s ease;
}
.ns-target:not([disabled]) { cursor: pointer; }
.ns-target[disabled] { cursor: default; }
.ns-target.done { background: var(--good); color: #fff; }
.ns-target.bad { background: var(--bad); color: #fff; }
@media (prefers-reduced-motion: reduce) {
  .ns-target { transition: none; }
}
/* ---- Maze Sprint (.mz-) — new-games-1 `game-maze` ------------------------
   docs/archive/new-games-1/game-maze.md is the design record. The board is
   an inline SVG whose viewBox IS maze-space (1 unit = 1 cell) — geometry
   (stroke widths, radii) is set as SVG attributes in public/maze-ui.js, not
   here, because those have to scale with the viewBox's own unit size; this
   file only owns colour. Checked free before use: `grep -c '\.mz-'
   public/styles.css` was 0 before this task. */

.mz-wrap {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 12px; padding: 0 12px;
}
.mz-board {
  display: block; margin: 0 auto; width: min(92vw, 320px);
  border-radius: 18px; background: var(--paper);
  box-shadow: 0 10px 30px rgba(63, 48, 130, 0.18), inset 0 0 0 1px rgba(88, 70, 160, 0.10);
  touch-action: none; /* the drag IS the game — the page must never scroll under it */
}
.mz-wall { stroke: rgba(124, 92, 224, .55); }
.mz-start { fill: var(--good); }
.mz-end { fill: var(--accent); }
.mz-path { stroke: var(--fertile); fill: none; }
/* The two-second freeze after a wall touch (docs/archive/new-games-1/
   game-maze.md's own "the penalty is time" section) — the touched wall
   flashes and the board itself shakes, the same beat `.ms-grid.boom` already
   gives a Minesweeper mine. */
.mz-board.bump .mz-wall.hit { stroke: var(--bad); }
.mz-board.bump { animation: mzBump .32s ease 1; }
@keyframes mzBump {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-4px); }
  75% { transform: translateX(4px); }
}
@media (prefers-reduced-motion: reduce) { .mz-board.bump { animation: none; } }
.mz-bump-label {
  position: absolute; font-size: 13px; font-weight: 800; color: var(--bad);
  pointer-events: none; animation: mzBumpLabel .9s ease forwards;
}
@keyframes mzBumpLabel {
  0% { opacity: 0; transform: translateY(0) scale(.8); }
  20% { opacity: 1; transform: translateY(-6px) scale(1); }
  100% { opacity: 0; transform: translateY(-26px) scale(1); }
}
@media (prefers-reduced-motion: reduce) { .mz-bump-label { animation: none; opacity: 0; } }

/* ---- Typing Sprint (game-updates2 `typing-game`) --------------------------

   ⚠️ THE WORD IS THE CONTROL. CLAUDE.md / WORKING-RULES.md §4.4: if a screen
   has artwork, the artwork is what you touch. Here the artwork is the WORD —
   large, letter by letter, lighting up as it is typed — and the lane beneath
   it is a rule you write on rather than a form field with a border and a
   label. The first version of this screen was a centred word with a bordered
   text box under it and a Submit button beside it, which is exactly the
   "standard layout with pill buttons" this standard exists to refuse.

   The lane has no box: a baseline, a caret colour and nothing else. It is
   still a real focused <input>, because that is the only thing that reliably
   opens a phone keyboard — the design is what it looks like, not what it is.
   ------------------------------------------------------------------------ */
.tp-wrap {
  flex: 1; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 20px; padding: 0 16px;
}

/* How far through the list. Scale-X off a custom property, the same shape
   .eq-bar already uses, so it animates on the compositor. */
.tp-bar {
  --done: 0;
  width: min(86vw, 360px); height: 7px; flex: none;
  border-radius: 99px; overflow: hidden;
  background: rgba(63, 48, 130, .16);
}
.tp-bar > i {
  display: block; height: 100%; width: 100%;
  transform-origin: left center; transform: scaleX(var(--done));
  border-radius: 99px;
  background: linear-gradient(90deg, var(--accent), #D9558C);
  transition: transform .12s ease-out;
}
@media (prefers-reduced-motion: reduce) { .tp-bar > i { transition: none; } }

.tp-stage {
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  min-height: 96px; justify-content: center; cursor: text;
}

/* The live word. ⚠️ Sized off vw so an eight-letter Turkish word still fits
   a 360px phone without wrapping — a word that breaks across two lines is a
   word you cannot read at speed, and this game has nothing else on screen. */
.tp-live {
  font-size: min(11vw, 44px); font-weight: 800; letter-spacing: .02em;
  color: var(--dim); line-height: 1.1; text-align: center;
  display: flex; flex-wrap: nowrap;
}
.tp-ch { transition: color .08s linear; }
/* Typed correctly so far. */
.tp-ch.done { color: var(--good); }
/* The letter the caret is on — an underline rather than a block, so the
   letter stays readable underneath it. */
.tp-ch.at {
  color: var(--ink);
  box-shadow: inset 0 -4px 0 var(--accent);
}
/* The whole word goes wrong at once, not letter by letter: you have gone off
   the word, and pointing at WHICH letter would be advice nobody can act on
   at typing speed. */
.tp-live.bad .tp-ch { color: var(--bad); box-shadow: none; }
.tp-live.shake { animation: tpShake .3s ease 1; }
@keyframes tpShake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-5px); }
  75% { transform: translateX(5px); }
}
@media (prefers-reduced-motion: reduce) { .tp-live.shake { animation: none; } }

/* The two words waiting behind. Small, faint, and NOT clickable-looking:
   they are a horizon, not a menu. */
.tp-next {
  display: flex; gap: 16px; font-size: 15px; font-weight: 600;
  color: var(--faint); letter-spacing: .01em;
}
.tp-next span:nth-child(2) { opacity: .55; }

/* The lane. A rule to write on — no border box, no rounded field, no label. */
.tp-form { width: min(86vw, 360px); }
.tp-lane {
  width: 100%; border: none; background: transparent; outline: none;
  border-bottom: 3px solid var(--accent-dim);
  padding: 6px 2px; text-align: center;
  font-family: inherit; font-size: min(7vw, 26px); font-weight: 700;
  color: var(--ink); caret-color: var(--accent);
  transition: border-color .15s ease;
  /* ⚠️ 16px is the iOS zoom floor and this is well over it, but the rule is
     worth stating: a font-size under 16px on a focused input makes Safari
     zoom the whole page, which on this screen would push the word out of
     frame the moment the keyboard opened. */
}
.tp-lane:focus { border-bottom-color: var(--accent); }
.tp-lane[disabled] { opacity: .4; }
.tp-lane::placeholder { color: var(--faint); font-weight: 500; }

/* ---- P35 Q21: the one gender change, in You › Account ----------------------

   Same vocabulary as .sky-tile above, deliberately: a drawn face, the word as
   a caption under it, and the state said ON the tile. Two chips carrying the
   words "Girl" and "Boy" is what signup already is, and CLAUDE.md names that
   shape — a pill button with an item's name on it — as the defect.

   ⚠️ The card is a .ycard and the row is a .yrow, which is the settings-page
   allowance in WORKING-RULES §4.4 rule 5 being used on purpose: the PLACEMENT
   is a settings row, beside Password and Recovery email. The CHOICE inside it
   is not. */
.gender-tiles {
  display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
  margin-top: 13px;
}
.gender-tile {
  position: relative; padding: 0; border: none; background: none;
  border-radius: 18px;
  display: flex; flex-direction: column; align-items: stretch; gap: 6px;
  transition: transform .14s ease;
}
.gender-tile .gt-art {
  position: relative; display: flex; align-items: center; justify-content: center;
  height: 92px; border-radius: 16px;
  background: var(--chip-bg);
  border: 1.5px solid var(--card-brd);
  color: var(--ink-soft);
  box-shadow: 0 4px 12px rgba(63, 48, 130, 0.1);
  transition: border-color .16s ease, box-shadow .16s ease, color .16s ease;
}
/* The mark fills more of the tile than the first pass allowed: at 76x56 in
   a 92px box the drawing read as a small icon parked in a large empty card. */
.gender-tile .gt-art > svg:first-child { width: 92px; height: 66px; }
.gender-tile .gt-name {
  font-size: 12.5px; font-weight: 800; color: var(--ink-soft);
  letter-spacing: .01em; line-height: 1.2; text-align: center;
}
.gender-tile:active { transform: scale(.96); }

/* Selected. The ring and the tick are both ON the tile — §4.4 rule 2 — and
   the tick is drawn rather than a "✓" character, for the same reason the
   chevrons in index.html are drawn. */
.gender-tile.on .gt-art {
  border-color: var(--accent);
  color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent-dim), 0 6px 16px rgba(63, 48, 130, 0.18);
}
.gender-tile.on .gt-name { color: var(--ink); }
/* Tapped, not yet saved. Dashed and un-ticked on purpose: the solid ring plus
   the tick is what "this IS your profile" looks like, and this is not that
   yet — the confirm step below has to be answered first. */
.gender-tile.picking .gt-art {
  border-color: var(--accent); border-style: dashed;
  box-shadow: 0 0 0 2px var(--accent-dim);
  color: var(--accent);
}
.gender-tile.picking .gt-name { color: var(--ink); }

.gender-tile .gt-tick, .gender-tile .gt-lock {
  position: absolute; right: 8px; top: 8px; width: 17px; height: 17px;
  opacity: 0; transition: opacity .16s ease;
}
.gender-tile.on .gt-tick { opacity: 1; color: var(--accent); }

/* Spent. ⚠️ The other tile stays on screen and stays legible — a control that
   vanishes after use reads as a bug. The padlock sits exactly where the tick
   sits on the chosen one, so the pair of tiles still says the whole state
   without a sentence beside them. */
.gender-tile[disabled] { cursor: default; }
.gender-tile[disabled]:active { transform: none; }
.gender-tile[disabled]:not(.on) .gt-art {
  opacity: .5; border-style: dashed; box-shadow: none;
}
.gender-tile[disabled]:not(.on) .gt-name { opacity: .5; }
.gender-tile[disabled]:not(.on) .gt-lock { opacity: 1; }

.gender-note {
  font-size: 12px; line-height: 1.5; color: var(--faint);
  margin: 11px 2px 0;
}
/* The confirm step. Amber, matching .btn-caution under it: red in this app
   means destruction, and nothing here is destroyed. */
.gender-warn {
  font-size: 13px; line-height: 1.5; color: #9A6516;
  background: rgba(203, 137, 42, 0.09); border-radius: 12px; padding: 10px 12px;
}
.gender-once {
  font-size: 12px; line-height: 1.45; color: var(--faint); margin-left: 4px;
}

/* Five flex containers used to carry `display:flex;gap:N` as an INLINE style
   in index.html. ⚠️ An inline gap is invisible to the fallback generator —
   it reads the stylesheet — so every one of them lost its spacing on
   WebView 83 while looking correct in the markup. The worst was
   `#page-cycle .page-body`: `.rel-head` carries `margin-bottom:-4px` to trim
   the 14px gap to 10, and with the gap gone that -4 is all that is left, so
   the REGL TAKİBİ heading ended up 4px BEHIND the pink button. 2026-09-08. */
.gender-pair { display: flex; gap: 8px; }
#request-form { display: flex; gap: 10px; }
.req-list { display: flex; flex-direction: column; gap: 8px; }
#page-cycle .page-body { display: flex; flex-direction: column; gap: 14px; }

/* ==========================================================================
   WEBVIEW 83 — the engine the store app actually runs on

   ⚠️ A TWA renders in Chrome. Capacitor renders in the SYSTEM WebView, and on
   a phone without Google's updatable WebView that is frozen at the OS
   version: 83.0.4103.120 on Android 11, unupdatable. Every browser check in
   this repo drives the developer machine's Chrome, so nothing here had ever
   executed on the engine we ship to. Found 2026-09-08 by holding a phone —
   docs/tracking/agent-notes/device-qa.md.

   Everything below is a FALLBACK, gated so a modern engine never sees it.
   ========================================================================== */

/* --- aspect-ratio is Chrome 88 -------------------------------------------
   ⚠️ This is why the game icons vanished and the house page was enormous. A
   tile is `width:100%; aspect-ratio:1; display:flex` and its child is
   `height:100%`. Drop the ratio and the parent has no height, so 100% of
   nothing is nothing — the drawing is invisible while every assertion about
   it stays green.

   The ::before with percentage padding is the pre-2020 answer: percentage
   padding resolves against WIDTH, so a zero-width flex item forces the box
   to the height we want and align-items keeps the content centred. */
@supports not (aspect-ratio: 1 / 1) {
  .tile-frame::before, .av-tile::before {
    content: ""; display: block; width: 0; padding-top: 100%;
  }
  .tile-grid.wide .tile-frame::before { padding-top: 75%; }   /* 4 / 3 */

  /* ⚠️ AND THE ARTWORK MUST NOT DECIDE THE HEIGHT — measured on a real
     Chromium 83, 2026-09-08. A `.tile-grid.wide .tile-frame` came out SQUARE
     instead of 4:3 even though its ::before was correctly 147px tall: the
     drawing is `> svg { width: 100%; height: 100% }`, that percentage height
     cannot resolve against a height the pseudo is still deciding, and Chrome
     falls back to the SVG's OWN intrinsic ratio — so the picture sized the box.

     ⚠️ IT LOOKS RIGHT WHEREVER THE ART HAPPENS TO MATCH. Every decor drawing
     carries its own viewBox (itemViewBox / measuredViewBox in app.js), so this
     is correct by luck, item by item, and wrong the first time somebody draws
     one a different shape. No screenshot would fail on it. Out of flow, and
     the pseudo decides alone.
     ⚠️ top/left/width/height, NEVER `inset` (Chrome 87), and the offsets are
     each element's own padding. */
  .tile-frame, .gp-tile { position: relative; }
  .tile-frame > svg {
    position: absolute; top: 6px; left: 6px;
    width: calc(100% - 12px); height: calc(100% - 12px);
  }
  .tile-grid.wide .tile-frame > svg {   /* that variant sets padding: 0 */
    top: 0; left: 0; width: 100%; height: 100%;
  }
  .gp-tile > svg {
    position: absolute; top: 5px; left: 5px;
    width: calc(100% - 10px); height: calc(100% - 10px);
  }

  /* ⚠️ THE ARCADE ART. Same mechanism, and I missed it the first time by
     fixing the classes I had SEEN rather than every element that depends on
     aspect-ratio for its height. .gc-art is `height: auto; aspect-ratio: 1`
     with an `svg { height: 100% }` inside, so on 83 every game logo on the
     shelf was a zero-height box. grid-area pins the ::before into the same
     cell as the artwork instead of adding a row beside it. */
  .gal-grid.duo .gc-art::before {
    content: ""; grid-area: 1 / 1; width: 0; padding-top: 100%;
  }
  .gal-grid.duo .gc-art > svg { grid-area: 1 / 1; }

  /* ⚠️ THE ROOM IS NOT HERE, AND THE FIRST ATTEMPT PUT IT HERE AND WAS WRONG.
     .house-stage is `flex: 1 1 0` in a column, so its HEIGHT is whatever the
     bars leave behind and `aspect-ratio` derives its WIDTH from that height.
     CSS before aspect-ratio cannot express width-from-height. The fallback is
     in matchPetBarToRoom() in app.js, which already reads this element back.
     A `height: 84.6vw` here measured the VIEWPORT and made the room half as
     tall again as it should be — which is exactly what Deniz saw. */

  /* ══════════════════════════════════════════════════════════════════════════
     ⚠️ ALL FIFTY-FOUR SITES — 2026-09-08, P50.

     The four rules above were the ones somebody had SEEN. Deniz: "almost all
     games are totally broken … I don't wanna be hunting bugs one by one,
     please find the root cause for all these." He was right: aspect-ratio
     appears 54 times in this file (56 greps, two of them inside comments) and
     the other fifty were the same defect arriving at the speed a person can
     notice it.

     ⚠️ THEY ARE NOT ONE PROBLEM. Reading every rule's own width, its parent's
     display and its children gives FIVE shapes, and a fallback that is right
     for one is wrong for the next:

       A  an explicit width expression    ->  restate it as `height`
       B  a percentage or flex width      ->  the ::before padding idiom
       C  a cell in a `1fr` grid row      ->  ⚠️ NOTHING. The row already won
       D  a percentage of a square box    ->  the same percentage as `height`
       E  the WIDTH comes from the height ->  impossible; app.js measures it

     The whole table, with why each selector is in the bucket it is in, is
     docs/tracking/agent-notes/aspect-83.md. Add to that table before adding a
     rule here. */

  /* ---- A. the height IS the width expression, restated (26) ----------------
     Exact, not approximate — and only because `* { box-sizing: border-box }`
     (line 80) makes `aspect-ratio` and `height` govern the same box. Under
     content-box the two would disagree by the padding on every board that has
     any: .su-grid's 6px, .oth-board's 5px, .gl-grid's 11px.

     ⚠️ EACH EXPRESSION IS CHARACTER-IDENTICAL TO THE `width` IT MIRRORS. A
     bare `340px` here against a `min(92vw, 340px)` there is square on a tablet
     and wrong on every phone ever sold. Change a width above and you must
     change its twin here — scripts/checks/aspect-fallback-check.js reads both
     out of this file and fails if any pair stops agreeing. */
  .seq-pads    { height: min(74vw, 300px); }
  .cold-art    { height: min(78vw, 44vh); }
  .db-grid     { height: min(86vw, 340px); }
  .oth-board   { height: min(92vw, 400px); }
  .snl-board   { height: min(94vw, 420px); }
  .sumo-arena  { height: min(92vw, 380px); }
  .shade-grid  { height: min(86vw, 340px); }
  .tug-pad     { height: min(58vw, 210px); }
  .warm-grid   { height: min(84vw, 322px); }
  .sync-pad    { height: min(52vw, 190px); }
  .call-pot    { height: min(15vw, 62px); }
  .gl-grid     { height: min(84vw, 330px); }
  .wv-grid     { height: min(84vw, 330px); }
  .qt-grid     { height: min(84vw, 320px); }
  .dt-field    { height: min(88vw, 46vh); }
  .hd-heart    { height: min(62vw, 260px); }
  .chess-board { height: min(92vw, 400px); }
  .kz-board    { height: min(92vw, 380px); }
  .lb-board    { height: min(92vw, 400px); }
  .hr-board    { height: min(92vw, 400px); }
  .rk-field    { height: min(88vw, 46vh); }
  .su-grid     { height: min(92vw, 340px); }
  .ms-grid     { height: min(92vw, 360px); }
  .lo-grid     { height: min(92vw, 340px); }
  /* The two that are not square. The ratio stays written as the division it
     is, rather than pre-multiplied, so it can be read against the rule it
     mirrors without arithmetic. */
  .pong-table  { height: calc(min(92vw, 380px) * 4.2 / 3); }
  .ns-field    { height: calc(min(88vw, 328px) * 460 / 328); }

  /* ---- B. the ::before percentage-padding idiom (13) -----------------------
     B1 — a flex box with the pseudo as a ZERO-WIDTH item. Every one of these
     is a <button> holding one glyph, so `align-items: center` keeps the glyph
     in the middle while the pseudo alone carries the height. */
  .gp-tile::before,
  .emote-pick::before,
  .g-cell::before,
  .star-cell::before,
  .meld-card::before,
  .gem::before {
    content: ""; display: block; width: 0; padding-top: 100%;
  }
  /* ⚠️ AND THE DISPLAY HAS TO CHANGE WITH IT. .emote-pick is `display: grid`
     and the other four are bare buttons. A ::before inside a GRID becomes a
     grid ITEM, and auto-placement then puts the glyph in row 2 — the box comes
     out exactly the right height with the emoji sitting underneath it, which
     looks like a different bug and is this one. Flex is the one display where
     a zero-width pseudo costs nothing. */
  .emote-pick, .g-cell, .star-cell, .meld-card, .gem {
    display: flex; align-items: center; justify-content: center;
    /* ⚠️ AND `padding: 0`, WHICH IS NOT TIDYING — IT IS 10px OF SQUARE.
       Chrome gives every <button> a UA padding of `1px 6px`, and a percentage
       padding resolves against the CONTENT box: 12px narrower across, 2px
       taller down, so a 92px cell came out 92 x 82. Measured on a real
       Chromium 83, not reasoned about — it passed every assertion and looked
       almost right on the phone, which is the shape of bug this whole wave is
       about. .emote-pick already carries padding: 0 in its own rule for a
       related reason (see the comment there); the other four did not.
       Nothing is lost: the glyph is centred by flex, never by padding. */
    padding: 0;
  }
  /* Connect Four's disc was `width/height: 78%` of a flex parent, and a
     percentage height against a parent whose own height is still coming from a
     sibling pseudo is not something to rely on. Out of flow, centred by its
     offsets, and the dropIn transform still applies to it. */
  .g-board.c4 .g-cell { position: relative; }
  .g-board.c4 .disc { position: absolute; top: 11%; left: 11%; }

  /* B2 — a plain block, because every child is already out of flow. .dd-paper's
     only child is .doodle-canvas (position: absolute), so nothing competes
     with the pseudo for the height, and doodle.js's fit() measures the host
     back afterwards. */
  .dd-paper::before { content: ""; display: block; padding-top: 100%; }

  /* ⚠️ .cn-scene IS AN <svg> ELEMENT (games.js:6613), NOT A DIV. ::before does
     not apply to a replaced element at all, so the pseudo goes on the wrapper
     — already position: relative — and the board is stretched over it. A thing
     that looks like a div and is not fails silently and looks perfect in the
     diff. */
  .cn-wrap::before { content: ""; display: block; padding-top: 78%; }
  .cn-scene { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }

  /* B3 — the two arcade stages, and this one took three measurements on a
     real Chromium 83 to get right rather than one reading of the spec.

     Both stages are one-cell `place-items: center` GRIDS, so the obvious
     fallback is `grid-area: 1 / 1` on the pseudo, in the same cell as the
     artwork. ⚠️ It does not work for the duo card: the stage came out
     117 x 55 instead of 117 x 88. The column is auto-sized, a grid item's
     percentage padding is taken as ZERO while that track is being measured,
     and the row then took its height from the BADGE — the picture sizing the
     field, which is this whole wave's bug in miniature. Taking the badge out
     of flow instead gave 117 x 1: the same fact from the other side.

     A FLEX container has no such circularity. A flex item's percentage
     padding resolves against the container's own content width, which is
     definite here (100% of the card), and the line's cross size is then
     whichever is taller — the pseudo or the badge. It is also the same idiom
     as every other tile in this block, so there is one pattern to know
     instead of two. Measured after: 117 x 88 and 102 x 78, dead on 4/3 and
     1 / .76.

     ⚠️ The solo badge stays absolutely positioned. `height: 66%` against a
     flex line whose cross size is still being decided is the same
     circularity again, and 66% of nothing is exactly the defect Deniz
     reported as "single player game icons are not visible". */
  .gal-grid.duo .gc-stage, .gal-grid.solo .gc-stage {
    display: flex; align-items: center; justify-content: center;
  }
  /* ⚠️ ::after ON THE DUO STAGE, ::before ON THE SOLO ONE, AND THE ASYMMETRY
     IS NOT A TYPO. `.gal-grid.duo .gc-stage::before` IS ALREADY TAKEN — it
     draws the white diagonal seam (line 7839), absolutely positioned on all
     four offsets. Writing the fallback there overwrites the seam's own
     `content` and `width` and contributes NO height, because an absolutely
     positioned pseudo is out of flow: the stage stayed 117 x 55 and looked
     exactly like a fallback that had simply not worked. The solo stage uses
     ::after for its scanlines, so there the free one is ::before.
     ⚠️ CHECK WHICH PSEUDO IS FREE BEFORE REACHING FOR THIS IDIOM. Two of the
     four arcade pseudo-elements in this file are already carrying artwork. */
  .gal-grid.duo .gc-stage::after {
    content: ""; display: block; width: 0; padding-top: 75%;        /* 4 / 3 */
  }
  .gal-grid.solo .gc-stage::before {
    content: ""; display: block; width: 0; padding-top: 76%;        /* 1 / .76 */
  }
  .gal-grid.solo .gc-art {
    position: absolute; top: 17%; left: 17%; width: 66%; height: 66%;
  }

  /* B4 — a square button wrapped round an <svg> that fills it. The svg is
     `width/height: 100%`, which cannot resolve against a height the pseudo is
     still deciding, so it goes out of flow instead.
     ⚠️ WRITTEN AS top/left/width/height AND NEVER `inset` — that shorthand is
     Chrome 87, and this whole block exists because the engine is 83. */
  .warm-plot, .tb-dial { position: relative; }
  .warm-plot::before, .tb-dial::before {
    content: ""; display: block; padding-top: 100%;
  }
  .warm-plot > svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
  /* .tb-dial carries padding: 4px, and the pseudo's percentage is of the
     CONTENT box, so the border box still comes out exactly square. */
  .tb-dial > svg {
    position: absolute; top: 4px; left: 4px;
    width: calc(100% - 8px); height: calc(100% - 8px);
  }

  /* ---- C. the four that need NOTHING, and saying so IS the work ------------
     .snl-sq, .warm-cell, .csq and .mg-cell all sit in a grid whose rows are
     `grid-auto-rows: 1fr` or an explicit `repeat(n, 1fr)`, inside a container
     that A or E has just given a definite height. A stretched grid item's
     height is then definite and the ratio never gets a say — which is what
     .snl-sq's own comment in this file already says.

     ⚠️ ADDING A ::before TO THESE WOULD BE ACTIVELY WRONG: a second height
     contribution to a row that is already sized. The absence of a rule here is
     deliberate. Do not "finish" it. */

  /* ---- D. a percentage of a box that is square anyway (4) ------------------
     All four are position: absolute inside a container A has just made square,
     so a percentage height resolves against a definite padding box and is
     exactly the percentage width it mirrors. */
  .sumo-body    { height: 15%; }
  .csq.go .cdot { height: 26%; }
  .rk-food      { height: 13%; }
  .rk-ship      { height: 17%; }

  /* ---- E. width-from-height, which no pre-2020 CSS can express -------------
     .house-stage (below), plus .mem-grid, .mg-grid and .mz-board, are measured
     in JavaScript instead: fitRatio() in public/app.js, called by games.js and
     maze-ui.js as each board is built. See the note above that function. */
}

/* --- flexbox gap is Chrome 84 --------------------------------------------
   ⚠️ 247 selectors, and it is the whole of "the text is pushed a bit to
   the left": a label sits flush against its icon instead of a comfortable
   distance from it. On the store page two separate sentences ran together
   into one unreadable line.

   GENERATED from the rules above by c:/tmp/sew-p/wv3.js. `html.no-flexgap` is
   set by a measurement in app.js, because no @supports test can tell flex gap
   from grid gap — grid gap is Chrome 66, so `@supports (gap: 1px)` is true
   here and would gate nothing. */
/* ⚠️ FOUR of the generated rules below were a BROKEN SELECTOR LIST: the
   generator appended `> * + *` to the LAST selector only, so
   `.xt-give`, `.ad-sheet`, `.as-stage` and `.dd-pots` got the margin on
   THEMSELVES — no gap where one was wanted, and a spurious offset where
   none was. Fixed by hand 2026-09-08; check for it after any regen. */
html.no-flexgap .danger-box > * + * { margin-top: 10px; }
html.no-flexgap .consent > * + * { margin-top: 12px; }
html.no-flexgap .consent-row > * + * { margin-left: 10px; }
html.no-flexgap .dob-row > * + * { margin-top: 6px; }
html.no-flexgap #gate .gate-box > * + * { margin-top: 14px; }
html.no-flexgap .auth-box > * + * { margin-top: 12px; }
html.no-flexgap .seg > * + * { margin-left: 4px; }
html.no-flexgap .lang-row > * + * { margin-left: 8px; }
html.no-flexgap .topbar > * + * { margin-left: 8px; }
html.no-flexgap .topbar .brand > * + * { margin-left: 8px; }
html.no-flexgap .nav-tab > * + * { margin-top: 4px; }
html.no-flexgap #page-match .page-body > * + * { margin-top: 16px; }
html.no-flexgap .req-item > * + * { margin-left: 10px; }
html.no-flexgap .chat-head > * + * { margin-left: 12px; }
html.no-flexgap .chat-head .status > * + * { margin-left: 5px; }
html.no-flexgap .chat-menu > * + * { margin-top: 10px; }
html.no-flexgap .keep-tile > * + * { margin-top: 9px; }
html.no-flexgap .chat-top > * + * { margin-left: 8px; }
html.no-flexgap .chat-top-dots > * + * { margin-left: 4px; }
html.no-flexgap .typing-bubble > * + * { margin-left: 5px; }
html.no-flexgap .chat-form > * + * { margin-left: 10px; }
html.no-flexgap .chat-tray > * + * { margin-top: 8px; }
html.no-flexgap .fx-tile > * + * { margin-top: 5px; }
html.no-flexgap .fx-tile .fx-price > * + * { margin-left: 3px; }
html.no-flexgap .fx-still > * + * { margin-top: 8px; }
html.no-flexgap #page-chatset .page-body > * + * { margin-top: 16px; }
html.no-flexgap .chatset-who > * + * { margin-left: 12px; }
html.no-flexgap .ring-mid > * + * { margin-top: 1px; }
html.no-flexgap .cyc-act > * + * { margin-top: 4px; }
html.no-flexgap .cycle-sheet-in > * + * { margin-top: 12px; }
html.no-flexgap .cycle-status > * + * { margin-top: 8px; }
html.no-flexgap .cta-card > * + * { margin-top: 14px; }
html.no-flexgap .when-row > * { margin: calc(8px / 2) calc(8px / 2); }
html.no-flexgap .when-row { margin: calc(-1 * calc(8px / 2)) calc(-1 * calc(8px / 2)); }
html.no-flexgap .stat-tile > * + * { margin-top: 5px; }
html.no-flexgap .hist-row > * + * { margin-left: 10px; }
html.no-flexgap .feel-row > * + * { margin-left: 10px; }
html.no-flexgap .flow-row > * + * { margin-left: 8px; }
html.no-flexgap .sym-grid > * { margin: calc(8px / 2) calc(8px / 2); }
html.no-flexgap .sym-grid { margin: calc(-1 * calc(8px / 2)) calc(-1 * calc(8px / 2)); }
html.no-flexgap .meters > * + * { margin-top: 10px; }
html.no-flexgap .meter > * + * { margin-left: 10px; }
html.no-flexgap .meter .m-label > * + * { margin-left: 5px; }
html.no-flexgap .xp-row > * + * { margin-left: 10px; }
html.no-flexgap .diary-sign > * + * { margin-left: 8px; }
html.no-flexgap .diary-empty > * + * { margin-top: 6px; }
html.no-flexgap .dtab > * + * { margin-top: 3px; }
html.no-flexgap .intro-list > * + * { margin-top: 14px; }
html.no-flexgap .intro-item > * + * { margin-left: 12px; }
html.no-flexgap .opt-row > * { margin: calc(9px / 2) calc(9px / 2); }
html.no-flexgap .opt-row { margin: calc(-1 * calc(9px / 2)) calc(-1 * calc(9px / 2)); }
html.no-flexgap .edit-pet > * + * { margin-left: 7px; }
html.no-flexgap .cat-tab > * + * { margin-top: 3px; }
html.no-flexgap .rail > * + * { margin-left: 10px; }
html.no-flexgap .tint-rail > * + * { margin-left: 8px; }
html.no-flexgap .closet-foot > * + * { margin-left: 10px; }
html.no-flexgap .tile > * + * { margin-top: 6px; }
html.no-flexgap .tile-head > * + * { margin-left: 10px; }
html.no-flexgap .test-tools .tt-head > * + * { margin-left: 10px; }
html.no-flexgap .test-tools .tt-action > * + * { margin-left: 12px; }
html.no-flexgap [id^="page-you"] .page-body > * + * { margin-top: 20px; }
html.no-flexgap .you-head > * + * { margin-left: 16px; }
html.no-flexgap .you-item > * + * { margin-left: 13px; }
html.no-flexgap .you-sec > * + * { margin-top: 14px; }
html.no-flexgap .yrow > * + * { margin-left: 14px; }
html.no-flexgap .yfield > * + * { margin-top: 9px; }
html.no-flexgap .yactions > * { margin: calc(8px / 2) calc(8px / 2); }
html.no-flexgap .yactions { margin: calc(-1 * calc(8px / 2)) calc(-1 * calc(8px / 2)); }
html.no-flexgap .ydanger > * + * { margin-top: 9px; }
html.no-flexgap .ydanger .yd-head > * + * { margin-left: 8px; }
html.no-flexgap .abs-hero > * + * { margin-left: 14px; }
html.no-flexgap .abs-chips > * { margin: calc(6px / 2) calc(6px / 2); }
html.no-flexgap .abs-chips { margin: calc(-1 * calc(6px / 2)) calc(-1 * calc(6px / 2)); }
html.no-flexgap .abs-pair > * { margin: calc(8px / 2) calc(8px / 2); }
html.no-flexgap .abs-pair { margin: calc(-1 * calc(8px / 2)) calc(-1 * calc(8px / 2)); }
html.no-flexgap .abs-pair .abp > * + * { margin-top: 2px; }
html.no-flexgap .abm-line > * { margin: calc(4px / 2) calc(7px / 2); }
html.no-flexgap .abm-line { margin: calc(-1 * calc(4px / 2)) calc(-1 * calc(7px / 2)); }
html.no-flexgap .abl-head > * { margin: calc(9px / 2) calc(9px / 2); }
html.no-flexgap .abl-head { margin: calc(-1 * calc(9px / 2)) calc(-1 * calc(9px / 2)); }
html.no-flexgap .person-cards > * + * { margin-top: 10px; }
html.no-flexgap .person-card > * + * { margin-left: 12px; }
html.no-flexgap .person-card .pc-meta > * + * { margin-top: 2px; }
html.no-flexgap .person-empty > * + * { margin-top: 3px; }
html.no-flexgap .safety-sub > * + * { margin-top: 10px; }
html.no-flexgap .block-subject > * + * { margin-left: 13px; }
html.no-flexgap .pc-report > * + * { margin-left: 10px; }
html.no-flexgap .report-box > * + * { margin-top: 13px; }
html.no-flexgap .reason-tile > * + * { margin-top: 7px; }
html.no-flexgap .rep-meta > * + * { margin-top: 5px; }
html.no-flexgap .purses > * + * { margin-left: 10px; }
html.no-flexgap .purse > * + * { margin-top: 3px; }
html.no-flexgap .purse .p-amt > * + * { margin-left: 6px; }
/* ⚠️ .cp-grid needs NOTHING here, and that is worth a line rather than a
   silence: it is `display: grid`, and GRID gap has worked since Chrome 66. It
   is FLEX gap that is Chrome 84 and collapses on the app's 83 baseline. The
   pack cards' own children use `> * + *` because those are flex. */
html.no-flexgap .ad-tile > * + * { margin-left: 14px; }
html.no-flexgap .ad-trade > * + * { margin-left: 8px; }
html.no-flexgap .at-text > * + * { margin-top: 2px; }
html.no-flexgap .ad-consent > * + * { margin-top: 14px; }
html.no-flexgap .ac-lines > * + * { margin-top: 10px; }
html.no-flexgap .ac-lines li > * + * { margin-left: 10px; }
/* ⚠️ P56 turned this pair from a side-by-side row into a stacked column, so
   BOTH of these flipped axis. A fallback left pointing the old way is invisible
   in a diff and wrong only on WebView 83 — which is every store user. */
html.no-flexgap .ac-faces > * + * { margin-top: 10px; }
html.no-flexgap .ac-face > * + * { margin-left: 12px; }
html.no-flexgap .ac-face .acf-text > * + * { margin-top: 2px; }
html.no-flexgap .ad-sheet > * + *,
html.no-flexgap .ad-web > * + * { margin-top: 18px; }
html.no-flexgap .as-stage > * + *,
html.no-flexgap .aw-stage > * + * { margin-top: 8px; }
html.no-flexgap .as-faces > * + * { margin-top: 9px; }
html.no-flexgap .ad-wait > * + * { margin-top: 14px; }
html.no-flexgap .ex-foot > * { margin: calc(4px / 2) calc(8px / 2); }
html.no-flexgap .ex-foot { margin: calc(-1 * calc(4px / 2)) calc(-1 * calc(8px / 2)); }
html.no-flexgap .pack-grid > * + * { margin-left: 10px; }
html.no-flexgap .pack > * + * { margin-top: 5px; }
html.no-flexgap .pack .pk-art > * + * { margin-left: 2px; }
html.no-flexgap .opt-pills > * { margin: calc(10px / 2) calc(10px / 2); }
html.no-flexgap .opt-pills { margin: calc(-1 * calc(10px / 2)) calc(-1 * calc(10px / 2)); }
html.no-flexgap .switch-row > * + * { margin-left: 12px; }
html.no-flexgap .num-row > * + * { margin-left: 12px; }
html.no-flexgap .stepper > * + * { margin-left: 2px; }
html.no-flexgap #install-card > * + * { margin-left: 14px; }
html.no-flexgap #update-bar > * + * { margin-left: 10px; }
html.no-flexgap .room-flash > * + * { margin-top: 10px; }
html.no-flexgap .room-flash .rf-dots > * + * { margin-left: 5px; }
html.no-flexgap .pet-id > * { margin: calc(8px / 2) calc(8px / 2); }
html.no-flexgap .pet-id { margin: calc(-1 * calc(8px / 2)) calc(-1 * calc(8px / 2)); }
html.no-flexgap .ft-bar > * + * { margin-left: 5px; }
html.no-flexgap .ft-side > * + * { margin-top: 1px; }
html.no-flexgap .ft-hero > * + * { margin-top: 1px; }
html.no-flexgap .ft-dots > * + * { margin-left: 5px; }
html.no-flexgap .ft-shop > * + * { margin-left: 5px; }
html.no-flexgap .wallet > * + * { margin-left: 6px; }
html.no-flexgap .sheet-title-row > * + * { margin-left: 10px; }
html.no-flexgap .buy-bar > * + * { margin-top: 8px; }
html.no-flexgap .game-list > * + * { margin-top: 10px; }
html.no-flexgap .game-card > * + * { margin-left: 14px; }
html.no-flexgap .g-head > * + * { margin-left: 12px; }
html.no-flexgap .emote-card > * + * { margin-top: 7px; }
html.no-flexgap .emote-tabs > * + * { margin-left: 4px; }
html.no-flexgap .emote-tab > * + * { margin-left: 6px; }
html.no-flexgap .g-result > * + * { margin-top: 14px; }
html.no-flexgap .g-end > * + * { margin-top: 14px; }
html.no-flexgap .g-end-tile > * + * { margin-top: 4px; }
html.no-flexgap .ec-head > * + * { margin-left: 10px; }
html.no-flexgap .ec-form > * + * { margin-left: 10px; }
html.no-flexgap .ig-peek-btn > * + * { margin-left: 10px; }
html.no-flexgap .ig-peek-rows > * + * { margin-top: 2px; }
html.no-flexgap .ig-form > * + * { margin-left: 9px; }
html.no-flexgap .seq-wrap > * + * { margin-top: 22px; }
html.no-flexgap .rps-row > * + * { margin-left: 26px; }
html.no-flexgap .rps-mid > * + * { margin-top: 4px; }
html.no-flexgap .cold-foot > * + * { margin-left: 16px; }
html.no-flexgap .cold-dots > * + * { margin-left: 8px; }
html.no-flexgap .tour-foot > * + * { margin-left: 12px; }
html.no-flexgap .tour-dots > * + * { margin-left: 7px; }
html.no-flexgap .dd-page > * + * { margin-top: 12px; }
html.no-flexgap .dd-tools > * { margin: calc(14px / 2) calc(14px / 2); }
html.no-flexgap .dd-tools { margin: calc(-1 * calc(14px / 2)) calc(-1 * calc(14px / 2)); }
html.no-flexgap .dd-pots > * + *,
html.no-flexgap .dd-nibs > * + * { margin-left: 2px; }
html.no-flexgap .dd-blanks > * { margin: calc(12px / 2) calc(12px / 2); }
html.no-flexgap .dd-blanks { margin: calc(-1 * calc(12px / 2)) calc(-1 * calc(12px / 2)); }
html.no-flexgap .dd-word > * + * { margin-left: 5px; }
html.no-flexgap .dd-misses > * { margin: calc(6px / 2) calc(12px / 2); }
html.no-flexgap .dd-misses { margin: calc(-1 * calc(6px / 2)) calc(-1 * calc(12px / 2)); }
html.no-flexgap .dd-slips > * + * { margin-top: 12px; }
html.no-flexgap .dd-own > * + * { margin-top: 4px; }
html.no-flexgap .dd-tally > * + * { margin-left: 22px; }
html.no-flexgap .score-hud > * + * { margin-left: 18px; }
html.no-flexgap .score-col > * + * { margin-top: 0; }
html.no-flexgap .snl-roll > * + * { margin-top: 6px; }
html.no-flexgap .gem-theirs > * + * { margin-top: 2px; }
html.no-flexgap .sumo-lock > * + * { margin-left: 10px; }
html.no-flexgap .meld-dots > * + * { margin-left: 7px; }
html.no-flexgap .meld-mid > * + * { margin-left: 22px; }
html.no-flexgap .meld-face > * + * { margin-top: 6px; }
html.no-flexgap .gal-head > * + * { margin-left: 12px; }
html.no-flexgap .gal-titles > * + * { margin-top: 1px; }
html.no-flexgap .view-strip > * + * { margin-left: 9px; }
html.no-flexgap .gal-shelf > * { margin: calc(8px / 2) calc(8px / 2); }
html.no-flexgap .gal-shelf { margin: calc(-1 * calc(8px / 2)) calc(-1 * calc(8px / 2)); }
html.no-flexgap .gal-card > * + * { margin-top: 3px; }
html.no-flexgap .diff-rail > * + * { margin-left: 10px; }
html.no-flexgap .diff-tile > * + * { margin-top: 0; }
html.no-flexgap .diff-chip > * + * { margin-left: 5px; }
html.no-flexgap .gal-empty > * + * { margin-top: 10px; }
html.no-flexgap .shade-wrap > * + * { margin-top: 9px; }
html.no-flexgap .shade-lives > * + * { margin-left: 5px; }
html.no-flexgap .tug-field > * + * { margin-left: 10px; }
html.no-flexgap .cn-wind > * + * { margin-left: 4px; }
html.no-flexgap .cn-healths > * + * { margin-left: 10px; }
html.no-flexgap .warm-rail > * + * { margin-left: 7px; }
html.no-flexgap .warm-pick > * + * { margin-top: 18px; }
html.no-flexgap .warm-plots > * + * { margin-left: 8px; }
html.no-flexgap .quiz-card > * + * { margin-top: 12px; }
html.no-flexgap .quiz-opts > * + * { margin-top: 9px; }
html.no-flexgap .quiz-opt > * + * { margin-left: 12px; }
html.no-flexgap .sync-field > * + * { margin-top: 26px; }
html.no-flexgap .sync-beats > * + * { margin-left: 16px; }
html.no-flexgap .call-ask > * + * { margin-left: 10px; }
html.no-flexgap .call-pots > * { margin: calc(8px / 2) calc(8px / 2); }
html.no-flexgap .call-pots { margin: calc(-1 * calc(8px / 2)) calc(-1 * calc(8px / 2)); }
html.no-flexgap .roof-who > * + * { margin-left: 7px; }
html.no-flexgap .roof-tally > * + * { margin-left: 22px; }
html.no-flexgap .lg-pick > * + * { margin-top: 22px; }
html.no-flexgap .lg-plaques > * + * { margin-left: 14px; }
html.no-flexgap .lg-stage > * + * { margin-left: 2px; }
html.no-flexgap .lg-card > * + * { margin-top: 8px; }
html.no-flexgap .lg-blanks > * { margin: calc(10px / 2) calc(10px / 2); }
html.no-flexgap .lg-blanks { margin: calc(-1 * calc(10px / 2)) calc(-1 * calc(10px / 2)); }
html.no-flexgap .lg-word > * + * { margin-left: 4px; }
html.no-flexgap .lg-input-row > * + * { margin-left: 8px; }
html.no-flexgap .arcade-tabs > * + * { margin-left: 8px; }
html.no-flexgap .atab > * + * { margin-top: 0; }
html.no-flexgap .atab-count > * + * { margin-left: 5px; }
html.no-flexgap .gal-shelf.duo-strip .ds-live > * + * { margin-left: 6px; }
html.no-flexgap .sky-tile > * + * { margin-top: 5px; }
html.no-flexgap #notice-stack > * + * { margin-top: 8px; }
html.no-flexgap .notice > * + * { margin-left: 11px; }
html.no-flexgap .nt-lines > * + * { margin-top: 2px; }
html.no-flexgap .bs-body > * + * { margin-top: 13px; }
html.no-flexgap .bs-cap > * + * { margin-left: 8px; }
html.no-flexgap .bs-face > * + * { margin-top: 2px; }
html.no-flexgap .bs-outcome > * + * { margin-left: 9px; }
html.no-flexgap .bs-sharp > * + * { margin-left: 9px; }
html.no-flexgap .bs-confirm > * + * { margin-top: 9px; }
html.no-flexgap .bs-back > * + * { margin-left: 13px; }
html.no-flexgap .bs-back .bs-back-txt > * + * { margin-top: 2px; }
html.no-flexgap .gl-wrap > * + * { margin-top: 16px; }
html.no-flexgap .wv-wrap > * + * { margin-top: 16px; }
html.no-flexgap .qt-wrap > * + * { margin-top: 16px; }
html.no-flexgap .lk-face > * + * { margin-top: 6px; }
html.no-flexgap .lk-ptrs > * + * { margin-left: 6px; }
html.no-flexgap .dt-tally > * + * { margin-left: 7px; }
html.no-flexgap .dt-pad > * + * { margin-left: 18px; }
html.no-flexgap .dt-pair > * + * { margin-left: 8px; }
html.no-flexgap .hd-stage > * + * { margin-top: 14px; }
html.no-flexgap .hd-prints > * + * { margin-left: 12%; }
html.no-flexgap .hd-rounds > * + * { margin-top: 6px; }
html.no-flexgap .hd-row > * + * { margin-left: 7px; }
html.no-flexgap .st-pips > * + * { margin-left: 8px; }
html.no-flexgap .cclock > * + * { margin-left: 10px; }
html.no-flexgap .cc-who > * + * { margin-left: 7px; }
html.no-flexgap .cmoves > * + * { margin-left: 12px; }
html.no-flexgap .cacts > * + * { margin-left: 10px; }
html.no-flexgap .cact > * + * { margin-top: 3px; }
html.no-flexgap .cpromo > * + * { margin-top: 8px; }
html.no-flexgap .cpromo-row > * + * { margin-left: 8px; }
html.no-flexgap .kz-camp-row > * + * { margin-left: 4px; }
html.no-flexgap .kz-roll-row > * + * { margin-left: 12px; }
html.no-flexgap .kz-roll-btn > * + * { margin-left: 13px; }
html.no-flexgap .rk-tally > * { margin: calc(6px / 2) calc(6px / 2); }
html.no-flexgap .rk-tally { margin: calc(-1 * calc(6px / 2)) calc(-1 * calc(6px / 2)); }
html.no-flexgap .su-wrap > * + * { margin-top: 14px; }
html.no-flexgap .su-pad > * { margin: calc(7px / 2) calc(7px / 2); }
html.no-flexgap .su-pad { margin: calc(-1 * calc(7px / 2)) calc(-1 * calc(7px / 2)); }
html.no-flexgap .ms-wrap > * + * { margin-top: 12px; }
html.no-flexgap .tb-actions > * + * { margin-left: 10px; }
html.no-flexgap .tb-pick > * + * { margin-top: 18px; }
html.no-flexgap .tb-dials > * + * { margin-left: 10px; }
html.no-flexgap .lo-wrap > * + * { margin-top: 14px; }
html.no-flexgap .eq-wrap > * + * { margin-top: 18px; }
html.no-flexgap .eq-lives > * + * { margin-left: 5px; }
html.no-flexgap .mg-wrap > * + * { margin-top: 10px; }
html.no-flexgap .mz-wrap > * + * { margin-top: 12px; }
html.no-flexgap .tp-wrap > * + * { margin-top: 20px; }
html.no-flexgap .tp-stage > * + * { margin-top: 10px; }
html.no-flexgap .tp-next > * + * { margin-left: 16px; }
html.no-flexgap .gender-tile > * + * { margin-top: 6px; }

/* --- the five that were inline, and one the generator could not see ------
   `.cyc-act.wide` sets its own `gap: 8px` inside a `.cyc-act` rule that had
   already been given a column fallback, so the row gap on the wide variant
   was dropped: the clipboard mark sat flush against "Geçmiş ve ayarlar". */
html.no-flexgap .gender-pair > * + * { margin-left: 8px; }
html.no-flexgap #request-form > * + * { margin-left: 10px; }
html.no-flexgap .req-list > * + * { margin-top: 8px; }
html.no-flexgap #page-cycle .page-body > * + * { margin-top: 14px; }
html.no-flexgap .cyc-act.wide > * + * { margin-left: 8px; margin-top: 0; }

/* ⚠️ THE NINETEEN THAT GOT AWAY.

   The first generated block required `display:flex` and `gap` in the SAME
   rule. These set the gap on a modifier — `.chat-top.start`, `.g-board.c4` —
   while the display lives on the base class somewhere else, so they
   inherit their display from another rule and got no fallback at all.
   Which is precisely what Deniz reported as "the whitespace below texts is
   missing": spacing that is right everywhere else and absent here.

   Resolved by reading the display off whichever rule actually sets it.
   ⚠️ A grid is deliberately skipped — grid gap has worked since Chrome 66,
   so a margin here would DOUBLE the spacing on a modern engine. */
html.no-flexgap .chat-top.start > * + * { margin-left: 12px; }
html.no-flexgap #page-chatset .chat-menu > * + * { margin-left: 12px; }
html.no-flexgap .cyc-act.wide > * + * { margin-left: 8px; }
html.no-flexgap .req-item > * + * { margin-left: 8px; }
/* ⚠️ THESE TWO WERE GENERATED WRONG AND ARE FIXED BY HAND — 2026-09-09, p54-public.
   The generator reads the DISPLAY off whichever rule sets it (see the note at
   the top of this block) and then assumes a row. Neither of these is one:

   .buy-bar is flex-direction: column in its base rule at the top of this
   file, and .fit-room .room-foot .buy-bar only narrows the gap — so 6px of
   margin-left put a stray indent on the Buy button and contributed no
   vertical space at all. It looked almost right because the base
   html.no-flexgap .buy-bar rule was still supplying 8px of margin-top.

   .surface-pair .opt-row WRAPS, and it also inherits the generator's own
   wrapping idiom from html.no-flexgap .opt-row above (a plus/minus 4.5px margin
   on every child, for the 9px base gap). Adding margin-left: 6px on top of that
   made the horizontal gap 4.5 + 4.5 + 6 = 15px, so five 28px chips needed
   5x28 + 4x15 = 200px in a 170px column and the palette wrapped to 4 + 1 on
   WebView 83 — with the wrapped chip indented, because a star-plus-star
   selector cannot know it starts a new line. The wrapping idiom has to be
   RESTATED at this gap, not added to. The generator still has both bugs and
   the fix has to go back into it too, so it is handed back in
   docs/tracking/agent-notes/p54-public.md. */
html.no-flexgap .fit-room .room-foot .buy-bar > * + * { margin-top: 6px; }
html.no-flexgap .surface-pair .opt-row { margin: 0 -6px -7px 0; }
html.no-flexgap .surface-pair .opt-row > * { margin: 0 6px 7px 0; }
html.no-flexgap .surface-pair .opt-row > * + * { margin-left: 0; }
html.no-flexgap .gal-grid.duo .gal-card > * + * { margin-left: 0; }
html.no-flexgap .gal-grid.solo .gal-card > * + * { margin-left: 0; }

/* ---- the daily gift box's popup (P59) ---------------------------------------
   Deniz: "when either partner clicks it, it opens a popup and gives the prize,
   and shows the next day's prize."

   ⚠️ WORKING-RULES §4.4, and the shape of this screen is the argument. It could
   have been three rows reading "Today: 150 crumbs / Tomorrow: 200 crumbs /
   [Claim]", and nothing would have failed. What is here instead: the box itself
   is the header, the prize is a piece of ARTWORK with its amount under it, and
   the ladder is eight pips coloured by what each day pays — so you can see that
   two more days is a gem day without reading a table.

   ⚠️ WebView 83 is the engine. No `gap` in any flex block here (Chrome 84 — the
   margins below are what a gap would have drawn), no `inset`, no
   `aspect-ratio`, no `dvh`, no `:focus-visible`, no `accent-color`, and no
   standalone translate/rotate/scale. */
.gift-veil {
  position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 42;
  display: flex; align-items: center; justify-content: center;
  padding: 22px;
  background: rgba(43, 35, 80, .46);
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  opacity: 0; pointer-events: none; transition: opacity .24s ease;
}
.gift-veil.open { opacity: 1; pointer-events: auto; }
.gift-pop {
  position: relative;
  width: 100%; max-width: 340px; max-height: 86vh; overflow-y: auto;
  padding: 6px 20px 22px;
  border-radius: 28px;
  background: var(--paper, #FFFBF0);
  border: 1px solid var(--card-brd, rgba(88, 70, 160, .16));
  box-shadow: 0 22px 54px rgba(43, 35, 80, .3);
  text-align: center;
  transform: scale(.9);
  transition: transform .26s cubic-bezier(.3, 1.4, .5, 1);
}
.gift-veil.open .gift-pop { transform: scale(1); }

/* The box, big, at the top — it is the thing you just tapped, so it is the
   thing that opens the card. */
.gift-pop .gp-box { width: 176px; height: 114px; margin: 0 auto; }
.gift-pop .gp-box svg { width: 100%; height: 100%; display: block; }

.gift-pop .gp-streak {
  font-size: 12px; font-weight: 800; letter-spacing: .04em;
  text-transform: uppercase; color: var(--dim, #7C6FA8);
  margin-top: 2px;
}

/* The eight rungs. A pip per day, tinted by what that day pays — amber crumb,
   green gem, lilac accessory — with today's larger and the days behind it
   filled. The colours are the two currency drawings' own, so the pips and the
   prize tile cannot disagree. */
.gift-pop .gp-rungs {
  display: flex; align-items: center; justify-content: center;
  margin: 9px 0 2px;
}
.gift-pop .gp-rung {
  width: 8px; height: 8px; border-radius: 50%;
  margin: 0 3px;
  background: currentColor; opacity: .26;
}
.gift-pop .gp-rung.k-crumbs { color: #C9A24A; }
.gift-pop .gp-rung.k-gems { color: #2E8F6B; }
.gift-pop .gp-rung.k-wear { color: #B486D8; }
.gift-pop .gp-rung.done { opacity: .62; }
.gift-pop .gp-rung.now {
  width: 13px; height: 13px; opacity: 1;
  box-shadow: 0 0 0 3px rgba(255, 255, 255, .8), 0 2px 6px rgba(43, 35, 80, .22);
}

.gift-pop .gp-title {
  margin: 10px 0 0; font-size: 19px; font-weight: 800;
  color: var(--ink, #2B2350);
}

/* The prize: the drawing first and the words under it. */
.gift-pop .gp-prize {
  display: block; margin: 12px 0 0;
  padding: 14px 12px 12px;
  border-radius: 22px;
  background: rgba(88, 70, 160, .055);
  border: 1px solid var(--card-brd, rgba(88, 70, 160, .16));
}
.gift-pop .gp-prize.won {
  background: rgba(95, 199, 155, .12);
  border-color: rgba(46, 143, 107, .26);
  animation: gpPop .5s cubic-bezier(.3, 1.5, .5, 1);
}
@keyframes gpPop {
  0% { transform: scale(.72); opacity: 0; }
  60% { transform: scale(1.06); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}
.gift-pop .gp-art { display: block; width: 82px; height: 82px; margin: 0 auto; }
.gift-pop .gp-art svg { width: 100%; height: 100%; display: block; }
.gift-pop .gp-amt {
  display: block; margin-top: 8px;
  font-size: 17px; font-weight: 800; color: var(--ink, #2B2350);
}
.gift-pop .gp-who {
  display: block; margin-top: 3px;
  font-size: 12.5px; color: var(--dim, #7C6FA8);
}
.gift-pop .gp-note {
  margin-top: 9px; font-size: 12.5px; line-height: 1.35;
  color: var(--dim, #7C6FA8);
}

.gift-pop .gp-open {
  display: block; width: 100%; margin: 14px 0 0;
  padding: 14px 18px;
  border: 0; border-radius: 18px;
  font-size: 16px; font-weight: 800; color: #fff;
  background: var(--accent, #E0699E);
  box-shadow: 0 8px 20px rgba(224, 105, 158, .34);
  cursor: pointer; touch-action: manipulation;
}
.gift-pop .gp-open:active { transform: scale(.97); }
.gift-pop .gp-open:disabled { opacity: .6; cursor: default; box-shadow: none; }
.gift-pop .gp-done {
  margin-top: 14px; font-size: 15px; font-weight: 800;
  color: #2E8F6B;
}

/* Tomorrow's, quietly — it is a promise, not an offer, so it is a strip rather
   than a second tile. */
.gift-pop .gp-next {
  display: flex; align-items: center; justify-content: center;
  margin-top: 16px; padding-top: 13px;
  border-top: 1px solid var(--card-brd, rgba(88, 70, 160, .14));
}
.gift-pop .gp-next-cap {
  font-size: 11.5px; font-weight: 800; letter-spacing: .05em;
  text-transform: uppercase; color: var(--dim, #7C6FA8);
  margin-right: 9px;
}
.gift-pop .gp-next-art { display: block; width: 26px; height: 26px; margin-right: 7px; }
.gift-pop .gp-next-art svg { width: 100%; height: 100%; display: block; }
.gift-pop .gp-next-amt { font-size: 14px; font-weight: 700; color: var(--ink-soft, #473C78); }

.gift-pop .gp-x {
  position: absolute; top: 10px; right: 10px;
  width: 34px; height: 34px; padding: 0;
  border: 0; border-radius: 50%;
  background: rgba(88, 70, 160, .08); color: var(--dim, #7C6FA8);
  font-size: 15px; line-height: 1; cursor: pointer; touch-action: manipulation;
}
.gift-pop .gp-x:active { background: rgba(88, 70, 160, .16); }

/* The box in the room wears the accent's dashed ring like every other tappable
   thing; the tag is drawn with its own fill attribute in house-svg.js so it
   survives an art sheet, and this is what makes it follow the chosen accent in
   the live room. */
.house-stage .hot.gift .hot-ring { stroke: var(--accent); }
.house-stage .hot.gift .hot-tag rect { fill: var(--accent); }
