@font-face {
  font-family: 'Bangers';
  src: url('assets/fonts/bangers.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
/* BoldPixels by YukiPixels, CC BY-SA 4.0, credited in Settings (js/app.js, the
   ABOUT note). Shipped UNMODIFIED: remixing the face itself would pull the
   ShareAlike clause onto our own work. 4KB, and it is in sw.js PRECACHE because a
   missing face is unstyled dialogue on one bad bar. */
@font-face {
  font-family: 'BoldPixels';
  src: url('assets/fonts/boldpixels.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* Tally design system: dark, quiet, fast. */
:root {
  --bg: #0d0c12;
  --dock-rgb: 15, 14, 20;
  --surface: #16151d;
  --surface-2: #1e1c26;
  --surface-3: #2a2734;
  --line: rgba(242, 233, 215, 0.09);
  --line-strong: rgba(242, 233, 215, 0.17);
  --text: #f2e9d7;
  --text-2: #b9ac97;
  --app-backdrop:
    radial-gradient(85% 50% at 22% 8%, rgba(96,74,150,.20), transparent 58%),
    radial-gradient(80% 48% at 90% 26%, rgba(52,80,120,.15), transparent 56%),
    radial-gradient(120% 58% at 50% 112%, rgba(110,80,168,.20), transparent 60%),
    linear-gradient(#08070e 0%, #0b0a15 46%, #0f0d1c 76%, #131120 100%);
  /* --text-3 is graded on the WORST surface it lands on, not the best. #8f8578
     was 5.00:1 on --surface and the comment stopped there, but it is 4.64:1 on
     --surface-2 and 4.03:1 on --surface-3, and the soft-light grain lightens
     all three. #9d9284 is 5.93 / 5.51 / 4.78 on the same three, so every one of
     them clears 4.5 with the grain still on top, and the hierarchy below
     --text-2 (6.54:1 on --surface-3) is untouched. */
  --text-3: #9d9284;
  --accent: #a5e847;
  --accent-ink: #16210b;
  --accent-soft: rgba(165, 232, 71, 0.16);
  --protein: #7cc4ff;
  --carbs: #ffb454;
  --fat: #ff9dc7;
  --danger: #ff6d5e;
  --amber: #ffb454;
  --gold: #ffc961;
  --gold-rgb: 255, 201, 97;
  --display: 'Bangers', 'Arial Black', system-ui, sans-serif;
  --dialogue: 'BoldPixels', ui-monospace, 'SFMono-Regular', Menlo, monospace;
  --radius: 20px;
  --radius-sm: 13px;
  --pad: 16px;
  /* ---- brand-deck tokens (Phase 1 foundation, added with the Tier 1 rebuild).
     These are the values the approved mockups were built on. Components move
     onto them screen by screen; nothing above is redefined. ---- */
  --coral: #fd6857;                       /* the deck's lead accent. Was absent entirely */
  --coral-soft: rgba(253, 104, 87, 0.15);
  --violet: #9b92e8;                      /* deck rarity: replaces the Tailwind #c084fc */
  --mint: #a2e0a6;
  --sh: 4px 5px 0 rgba(0, 0, 0, 0.55);    /* the sticker shadow: hard offset, never blurred */
  --sh-sm: 3px 4px 0 rgba(0, 0, 0, 0.55);
  /* Shared type ramp, derived from the source census. See docs/fix-type-scale.md.
     Keep art geometry independent of these text-only tokens. */
  --fs-0: .6875rem; --fs-1: .75rem; --fs-2: .8125rem; --fs-3: .9375rem;
  --fs-4: 1.0625rem; --fs-5: 1.3125rem; --fs-6: 1.75rem; --fs-7: 2.5rem;
  --fs-micro: .625rem; --fs-tiny: .6875rem; --fs-body: 1rem;
  --fs-title: 1.5rem; --fs-display: 2rem; --fs-hero: 3rem; --fs-jumbo: 4rem;
  /* --muted was referenced 12 times and declared nowhere. An unresolvable var()
     on `color` inherits, so every one of those rendered at full primary
     brightness and silently flattened the hierarchy it was meant to create. */
  --muted: #b9ac97;
  --sat: env(safe-area-inset-top, 0px);
  --sab: env(safe-area-inset-bottom, 0px);
}

* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
/* The system font shorthand connects rem to iOS Dynamic Type. Unsupported
   engines retain their user-selected default root size. Body keeps our font. */
html { color-scheme: dark; font-size: 100%; font: -apple-system-body; }
html, body { height: 100%; }
/* NO LONG-PRESS TEXT SELECTION ANYWHERE IN THE APP.
   Tom, 2026-08-18, after v402 fixed it on the Pit's move buttons: "i noticed you
   can press and hold to highlight text all over the app though thats annoying".

   This is a GAME, not a document. iOS treats every run of text as selectable, so
   a hold anywhere raises the magnifier, the blue highlight and the callout menu,
   over labels and buttons nobody would ever want to copy. v402 fixed one screen
   at a time; there are dozens, and .map-* and .fight-act had already each been
   patched individually, which is the pattern that says the rule belongs here.

   TWO EXEMPTIONS, AND BOTH ARE LOAD-BEARING. A blanket rule here is easy and
   quietly breaks two things that matter:

   1. FORM FIELDS. `user-select: none` inherits into <input> and <textarea>, and
      on iOS that interferes with selecting and editing what you have typed. Every
      numeric and text field in the app is downstream of this, so they are put
      back to `text` explicitly rather than being left to luck.
   2. .code-line IS THE RECOVERY CODE. That is the one string in the app a player
      MUST be able to copy, and losing it loses their save. It already carries
      `user-select: all`, which survives inheritance, but `-webkit-touch-callout`
      is how iOS offers "Copy" on a long press, so it is restored to default here.
      Killing the callout globally without this exemption would make the recovery
      code uncopyable, which is a far worse bug than the one being fixed.

   Guarded in tests/unit.test.js: Chromium does not implement
   -webkit-touch-callout, so no browser audit in this repo can see any of it. */
body { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
input, textarea, select, [contenteditable] {
  -webkit-touch-callout: default; -webkit-user-select: text; user-select: text;
}
.code-line { -webkit-touch-callout: default; }

/* Page pinch is off. The Boneyard zooms its MapLibre camera, whose canvas
   owns its gestures. Keep native scrolling on both axes everywhere else. */
html, body { touch-action: pan-x pan-y; }

body {
  /* Midnight backdrop: on-brand near-black with a faint purple/blue depth + glow.
     A TOKEN because Today's scroller has to paint the identical thing over its
     own bounce colour; see .screen--today. One declaration, so they cannot drift. */
  background: var(--app-backdrop);
  background-attachment: fixed;
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif;
  font-size: 1rem;
  line-height: 1.35;
  -webkit-font-smoothing: antialiased;
  overscroll-behavior-y: none;
}
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
input, select, textarea { font: inherit; color: inherit; }
svg { display: block; }
[hidden] { display: none !important; }
/* Visually hidden, still read by a screen reader. The only such utility in the
   file (QA round 25 M17: the add sheet's live result count). */
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

/* Flex column: the SCREEN scrolls, the tab bar is an in-flow child pinned at the
   bottom. This avoids the iOS quirk where a position:fixed bottom bar (with
   backdrop-filter) detaches and floats mid-screen during momentum scroll.

   THE vh LINE IS LOAD-BEARING, NOT NOISE. An unknown unit makes a declaration
   invalid at parse time, so a browser that predates dvh (Chrome below 108,
   iOS below 15.4) dropped `height: 100dvh` and left #app with no height at
   all. .tabbar is an in-flow flex child, so it sank to the bottom of the
   document: measured on Today at 390x844, #app 3083px and the tab bar 2173px
   below the fold, with no tabs, no FAB and no way off the screen. An old
   parser keeps the first line and a modern one takes the second.
   See tests/dvh-fallback-audit.mjs. */
#app { max-width: 600px; margin: 0 auto; height: 100vh; height: 100dvh; position: relative; display: flex; flex-direction: column; overflow: hidden; }

.screen {
  flex: 1; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
  padding: calc(var(--sat) + 14px) var(--pad) 24px;
  /* R40-22's 13px FAB exclusion now belongs to .tabbar's top padding.
     Moving it out of this border preserves the content clip edge without a
     separate paint band: transparent leaked Today's bounce colour, opaque
     painted a black line. Padding HERE would let rows scroll under the FAB. */
}

/* AND THE SCROLLER CARRIES THE ART'S OWN TOP EDGE, so a bounce does not uncover
   the page behind the app. Tom, 2026-08-24: "you didnt extend the coloured
   backdrop far enough when i pull down i can see the wordmark but the background
   colour just stops and shows that we are actually at the edge of the app."
   --hero-edge is sampled from the equipped backdrop at render time (paintHeroEdge
   in js/app.js) because the backdrop is a cosmetic; it falls back to --bg, which
   is exactly the old behaviour, if the sample ever fails. The block above
   .hero-scene carries why this shape and not the other one. */
/* TWO LAYERS, AND THE SPLIT IS THE POINT. Tom, 2026-08-24: "the background is now
   extending all down the app by the quests etc, that has changed to the background
   colour I have for my bonehead."
   He is right and the first version was one line too broad. The rubber band is
   painted from the scroller's background-COLOR, which is the only thing WebKit
   shows there, so that has to carry the art's edge. But a colour also shows
   through every transparent gap in the page below, and Today has plenty: the four
   doors sit on 68px of nothing and the seams between cards are more.
   A background-IMAGE paints over the colour across the content area and stops at
   it, so a player reading the page sees --bg everywhere, and the art's colour
   survives only where a bounce uncovers it. */
/* THE BOUNCE IS PAINTED FROM background-COLOR AND NOTHING ELSE. That is not a
   guess this time, it is the measurement already in this file at the top: on a
   booted iPhone 17 Pro, in a 126pt-deep held bounce, the exposed strip was the
   scroller's own background COLOUR edge to edge and ZERO px of anything else,
   with four other forms tried in the same page (a ::before at bottom:100%, a real
   div at bottom:100%, and a background-image at center -60px with attachment
   local AND scroll). WebKit sizes the scrolled-contents layer to the
   scrollable-overflow rect, which starts AT the origin, and fills the rubber-band
   region from that layer's solid backgroundColor. Nothing above the origin is in
   that layer.

   I ignored that note and burned three builds on it:
     v434  background-color only        -> bounce right, but the colour showed
                                           through every transparent gap
     v435  + an opaque background-image -> "no colour background above by the
                                           wordmark", and the page went flat black
     v436  no background, a ::before    -> still no colour, and the wordmark
           strip parked above the origin   lingered on release
   An opaque background-IMAGE is what breaks it: the layer no longer has a simple
   solid background for the compositor to promote. background-repeat cannot save
   that, because a gradient auto-sizes to the whole positioning area, so repeat
   and no-repeat render identically. So this element carries a COLOUR and no image
   at all, forever.

   THE PAGE IS COVERED FROM INSIDE THE CONTENT INSTEAD. .today-plate is an in-flow
   first child, so it begins at the scroll origin and can never reach the strip;
   sticky keeps it over the viewport as the page scrolls, which is how it matches
   body's fixed-attachment backdrop; and the negative margin removes its height so
   it changes no layout. It is a sibling rather than a wrapper on purpose:
   `.screen > .route-in` drives the route animation off DIRECT children, and
   wrapping Today's fifteen nodes would have silently killed it. */
/* isolation, and it is not decoration: .today-plate sits at z-index -1 so it
   paints behind Today's content, and a negative child paints into its nearest
   STACKING CONTEXT. Without this the scroller is not one, the plate escapes to
   #app, and it lands behind the scroller's own background-colour instead of over
   it. Measured: every page pixel came back as the backdrop colour, i.e. the v434
   bleed at full strength. */
/* THE WALLPAPER RUNS OFF THE TOP. This background-color is what an overscroll
   bounce reveals, and --hero-edge is the equipped backdrop's own top-edge colour
   (js/app.js paintHeroEdge, from the baked table in data/hero-edge.js), so
   pulling past the top shows more of the same colour rather than the edge of the
   app. Tom, 2026-08-26: "extend the boneheadz decided wallpaper colour upwards so
   if you scroll beyond it just shows the same colour. no seam, no wordmark."
   The overscroll wordmark used to print into this strip and was removed in the
   same change; the strip itself is the whole feature now. */
.screen--today {
  position: relative; isolation: isolate; background-color: var(--hero-edge, var(--bg));
}
.today-plate {
  /* ZERO HEIGHT, AND THE PAINT IS IN THE PSEUDOS. The first version was 100vh
     tall with margin-bottom: -100vh to cancel itself. Margins do not cancel, they
     COLLAPSE: the plate's -100vh collapsed with .hero-card's own negative top
     margin (the island bleed) and the most negative won, so the hero lost its
     bleed and every card below shifted ~69px down. today-peek caught it, nine
     rows at two viewports. A box with no height and no margins cannot do that.
     Sticky still pins it to the top of the scrollport for the whole scroll, which
     is how it matches body's fixed-attachment backdrop, and being in flow it
     starts AT the scroll origin so it can never paint into the bounce. */
  position: sticky; top: 0; z-index: -1; height: 0;
  pointer-events: none;
}
/* THE BACKDROP. Positioned off the plate rather than inset:0, because the plate
   has no height to inset from, and pushed out through .screen's var(--pad) side
   padding: an in-flow child inherits that inset, which measured as a strip of the
   Bonehead's backdrop colour down both edges of the page.
   background-COLOR as well as the gradients, and it is load-bearing twice: it is
   what makes the plate opaque enough to hide the bounce colour, and it is what
   tests/contrast-audit.mjs's ancestor walk stops on, so Today's text is graded
   against the page rather than against whatever backdrop is equipped. */
.today-plate::before {
  content: ''; position: absolute;
  top: 0; height: 100vh;
  left: calc(-1 * var(--pad)); right: calc(-1 * var(--pad));
  background-color: var(--bg);
  background-image: var(--app-backdrop);
  background-attachment: fixed;
}
/* The film grain body paints over the whole app. body::before sits under #app, so
   an opaque plate hides it and Today alone would go smooth: the mean colour would
   still measure right while the texture disappeared. */
.today-plate::after {
  content: ''; position: absolute;
  top: 0; height: 100vh;
  left: calc(-1 * var(--pad)); right: calc(-1 * var(--pad));
  background-image: var(--grain); background-size: 150px 150px;
  opacity: .5; mix-blend-mode: soft-light;
}

/* ---------- header ---------- */
.day-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; gap: 8px; }
/* `.day-strip`, the floating `< TODAY Aug 22 > [gear]` row, lived here until
   2026-08-22. It is the `.dayhdr` titlebar of the day container now (end of this
   file): the same three controls, inside the thing they govern. */
.day-title { text-align: center; flex: 1; position: relative; }
.day-title h1 { font-size: var(--fs-5); font-weight: 700; letter-spacing: -0.02em; }
.day-title .sub { font-size: var(--fs-2); color: var(--text-3); margin-top: 1px; font-weight: 500; }
.icon-btn {
  position: relative; width: 40px; height: 40px; border-radius: 13px; background: var(--surface);
  display: grid; place-items: center; color: var(--text-2); flex: none;
  border: 1px solid var(--line);
}
.icon-btn:active { background: var(--surface-2); }
.icon-btn::before { content: ''; position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); }
.icon-btn svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }

/* Settings gear. Tom, 2026-09-25 (answer 2A): it lives in each page's title row
   and scrolls away with it, as Today's does in the day strip. The floating
   #gearBtn it replaced was pinned over the content, and every page scrolled
   cards, chips and inputs under it. Its 44px hit area is the ::before in the
   tap-target block near the end of this file. Guard: tests/gear-title-audit.mjs. */
.gear-btn {
  position: relative; flex: none;
  width: 38px; height: 38px; display: grid; place-items: center;
  border-radius: 50%; background: var(--surface-2); border: 1px solid var(--line);
  color: var(--text-3);
}
.gear-btn svg { width: 20px; height: 20px; stroke: currentColor; }
.gear-btn:active { background: var(--surface-3); color: var(--text); }
.gear-btn[hidden] { display: none; }
/* The title row. The h1 and its gear are siblings, so the heading's accessible
   name stays the page's name. The gear drops by the h1's own 6px top margin so
   the two share a top edge, and gap + gear is the 46px the h1 used to pad for
   the floating gear, so no title rewraps. A hidden title takes its gear with it:
   the hub hides its heading on Shop, Wardrobe and Backpack, which carry their own. */
.page-head { display: flex; align-items: flex-start; gap: 8px; }
.page-head > .page-h1 { flex: 1; min-width: 0; }
.page-head > .gear-btn { margin-top: 6px; }
/* stranger-fixes4, 2026-09-26: the Build tab is the one hub tab whose gear sits in this outer row,
   and it measured 38x38 at y=52 against the character header's 44x44 at y=44 on Wardrobe and
   Backpack (320 and 390). Same size, same top, same right edge as .character-gear. */
.page-head:has(> .hub-title) > .gear-btn { width: 44px; height: 44px; margin: -2px -4px 0 0; }
.page-head:has(> h1[hidden]) { display: none; }
.page-h1 { font-size: var(--fs-6); font-weight: 800; letter-spacing: -0.02em; margin: 6px 2px 16px; }
.page-h1 .sub { display: block; font-size: var(--fs-2); color: var(--text-3); font-weight: 500; margin-top: 3px; letter-spacing: 0; }

/* ---------- cards ---------- */
.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--pad);
  margin-bottom: 12px;
}
.card-title { font-size: var(--fs-1); font-weight: 700; color: var(--accent); letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
.card-title .link { color: var(--accent); font-size: var(--fs-2); font-weight: 600; }

/* ---------- ring ---------- */
.ring-card { display: flex; align-items: center; gap: 18px; }
/* ---- the calorie ring charges (option B, Tom 2026-08-29) ----
   hud-low is the resting state and deliberately has no rule: calm is the
   default, not an effect. Reduced motion gets the hit glow without the
   breathe loop; the animation is a bounded alternate, never a crushed
   duration (the 0.001s trap runs a loop a thousand times a second). */
.hud-near .ring-fill { filter: drop-shadow(0 0 3px rgba(165, 232, 71, .3)); }
/* Tom, 2026-09-27 ("Fade between two glows"): the breath animated `filter`, which
   no compositor runs (~120 restyles/s). Now the fill above holds the dim glow and
   .ring-hi, a stacked copy with the bright glow, fades its opacity on the same
   3.2s alternate breath. */
.ring-wrap .ring-hi { position: absolute; inset: 0; opacity: 0; pointer-events: none; animation: hudFade 3.2s ease-in-out infinite alternate; }
.hud-near .ring-hi .ring-fill { filter: drop-shadow(0 0 8px rgba(165, 232, 71, .48)); } /* 0.8x: swing 245.8 vs 247.2 before (-0.6%), peak -0.1%, measured at 90% */
.hud-hit .ring-fill, .hud-hit .ring-fill.over {
  stroke: #c2f07d;
  filter: drop-shadow(0 0 6px rgba(190, 245, 120, .7)) drop-shadow(0 0 14px rgba(165, 232, 71, .35));
}
.hud-over .ring-fill.over { filter: drop-shadow(0 0 5px rgba(255, 180, 84, .45)); }
@keyframes hudFade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .hud-near .ring-fill { filter: drop-shadow(0 0 5px rgba(165, 232, 71, .45)); }
  .ring-wrap .ring-hi { animation: none; opacity: 0; }
}

.ring-wrap { position: relative; width: 158px; height: 158px; flex: none; }
.ring-wrap svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.ring-track { stroke: var(--surface-3); }
.ring-fill { stroke: var(--accent); transition: stroke-dashoffset 0.6s cubic-bezier(0.22, 1, 0.36, 1); }
.ring-fill.over { stroke: var(--amber); }
.ring-center { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; }
.ring-center .big { font-size: var(--fs-display); font-weight: 800; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; line-height: 1; }
.ring-center .lbl { font-size: var(--fs-1); color: var(--text-3); font-weight: 600; margin-top: 4px; }
/* The line that keeps "can I have dinner" answerable now the headline counts UP.
   Smaller and quieter than the label above it: it is the detail, not the reading. */
.ring-center .ring-sub { font-size: var(--fs-0); opacity: .72; margin-top: 2px; letter-spacing: .01em; }
.ring-side { flex: 1; display: grid; gap: 10px; }
.kv { display: flex; justify-content: space-between; align-items: baseline; font-size: var(--fs-3); color: var(--text-2); }
.kv b { color: var(--text); font-weight: 700; font-variant-numeric: tabular-nums; }

/* ---------- macro bars ---------- */
.macros { display: grid; gap: 12px; }
.macro { display: grid; gap: 5px; }
.macro .row { display: flex; justify-content: space-between; font-size: var(--fs-2); font-weight: 600; }
.macro .row .val { color: var(--text-2); font-weight: 600; font-variant-numeric: tabular-nums; }
/* THE VALUE IS ONE READING, NOT A SENTENCE.
   The macros sit in .ring-side, which is whatever the 158px ring leaves: 124px
   at 390, 109px at 375, 82.7px at 320. Measured 2026-09-01 at 375x667 in
   headless Chrome, which resolves this stack to the device's own .SF NS (checked with
   CSS.getPlatformFontsForNode, so these widths are not a fallback-font
   artefact): "Protein" (45.5px) beside "94 / 185 g" (64.8px) wants 110.3px of a
   109px row, so Protein and Carbs each broke the trailing "g" onto a second line
   and the label ran into the value. Carbs was 111px of the same 109.
   nowrap is enough on its own: a flex item will not shrink below min-content,
   and min-content for an unbreakable string is the whole string.
   HELD ABOVE 360px, and 320 is deliberately left exactly as it was. Below that
   the row is over budget for any font (label plus value is 110.3px of 82.7px),
   so nowrap there only trades a legible two-line value for a label squeezed to
   nothing: measured 2026-09-01, .ring-side ran 38.8px past its card and
   tests/grid-min-width-audit.mjs went red on SIDESCROLL 320x568. 360 is the
   next viewport that audit measures, and it has room to spare.
   SUPERSEDED at the end of this file (2026-10-02): the ring shrinks below 380 so
   the row fits, and nowrap now holds at every width. */
.macro .row .val { white-space: nowrap; }
.bar { height: 7px; border-radius: 4px; background: var(--surface-3); overflow: hidden; }
.bar i { display: block; height: 100%; border-radius: 4px; transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1); }
.bar.protein i { background: var(--protein); }
.bar.carbs i { background: var(--carbs); }
.bar.fat i { background: var(--fat); }

/* ---------- meals ---------- */
.meal { padding: 4px 0 2px; }
.meal-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 2px; }
.meal-head h2 { font-size: var(--fs-body); font-weight: 700; letter-spacing: -0.01em; }
.meal-head .kcal { color: var(--text-2); font-size: var(--fs-2); font-weight: 600; font-variant-numeric: tabular-nums; margin-left: auto; margin-right: 12px; }
.meal-add {
  width: 30px; height: 30px; border-radius: 10px; background: var(--accent-soft);
  color: var(--accent); display: grid; place-items: center; flex: none;
}
.meal-add svg { width: 15px; height: 15px; stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; }
.entry {
  width: 100%; text-align: left; display: flex; align-items: center; gap: 10px;
  background: var(--surface); border: 1px solid var(--line);
  padding: 11px 13px; border-radius: var(--radius-sm); margin-bottom: 6px;
}
.entry:active { background: var(--surface-2); }
.entry .n { flex: 1; min-width: 0; }
.entry .n .name { font-size: var(--fs-3); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.entry .n .sub { font-size: var(--fs-1); color: var(--text-3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.entry .kc { font-size: var(--fs-3); font-weight: 700; font-variant-numeric: tabular-nums; color: var(--text-2); flex: none; }
.chip-btn {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: var(--fs-2); font-weight: 600; color: var(--text-2);
  background: var(--surface); border: 1px dashed var(--line-strong);
  border-radius: 11px; padding: 9px 12px; margin: 2px 0 8px;
}
.micro-line { text-align: center; color: var(--text-3); font-size: var(--fs-2); margin: 14px 0 4px; font-variant-numeric: tabular-nums; }

.streak { display: inline-flex; align-items: center; gap: 5px; background: var(--surface); border: 1px solid var(--line); color: var(--text-2); font-size: var(--fs-2); font-weight: 700; border-radius: 999px; padding: 5px 11px; }
.streak b { color: var(--accent); }

/* ---------- tab bar ---------- */
.tabbar {
  flex: none; width: 100%; position: relative;
  display: flex; align-items: center; justify-content: space-around;
  /* 8px spacing + the former 13px screen border. The dock starts 13px higher,
     but its buttons and the screen's content clip stay at the same positions.
     The FAB and its 4px ring sit inside this sibling, outside every route's
     scrollport. One translucent dock surface paints the whole reserved area,
     independent of hero art, equipped backdrops or room colours. Pixel and
     native tap review remain required; see tests/manual/today-dock-pixels.md. */
  padding: 21px 10px calc(var(--sab) + 8px);
  background: rgba(var(--dock-rgb), 0.84);
  -webkit-backdrop-filter: blur(22px); backdrop-filter: blur(22px);
  border-top: 1px solid var(--line);
  z-index: 40;
}
.tab { position: relative; display: grid; justify-items: center; gap: 3px; color: var(--text-3); font-size: var(--fs-0); font-weight: 600; width: 62px; padding: 4px 0; }
.tab-badge {
  position: absolute; top: 1px; right: 12px; min-width: 16px; height: 16px; padding: 0 4px;
  display: grid; place-items: center; border-radius: 8px; background: var(--danger, #ff5c5c);
  /* --ink, NOT #fff. Measured off the render (r33 a11y, 390x844, the modal
     pixel inside the badge's own box): white on --danger is rgb(255,255,255) on
     rgb(255,109,94) = 2.76:1 at 10px/900, against the 4.5 small text needs.
     --ink on the same plate measures 5.13:1, and it is the token the active tab
     plate two rules down already uses for dark-ink-on-a-bright-plate. The plate
     colour is untouched: the badge still reads as the app's one alarm colour. */
  color: var(--ink, #2a2d28); font-size: var(--fs-tiny); font-weight: 900; font-style: normal; line-height: 1;
  box-shadow: 0 0 0 2px var(--bg, #0d0c12); animation: crewBadgePop 0.3s ease;
}
@keyframes crewBadgePop { 0% { transform: scale(0); } 70% { transform: scale(1.2); } 100% { transform: scale(1); } }
.tab svg { width: 23px; height: 23px; fill: currentColor; stroke: currentColor; }
.tab svg [fill="none"], .tab svg path[stroke], .tab svg circle { fill: none; }
.tab.active { color: var(--text); }
.fab {
  width: 58px; height: 58px; border-radius: 20px; background: var(--accent); color: var(--accent-ink);
  display: grid; place-items: center; margin-top: -26px;
  box-shadow: 0 8px 24px rgba(185, 239, 74, 0.35);
}
.fab:active { transform: scale(0.96); }
.fab svg { width: 26px; height: 26px; stroke: currentColor; }

/* ---------- sheets ---------- */
.sheet-backdrop {
  position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); z-index: 50;
  animation: fadein 0.2s ease;
}
.sheet {
  position: fixed; left: 50%; transform: translateX(-50%); bottom: 0; z-index: 51;
  width: 100%; max-width: 600px;
  background: var(--surface); border-radius: 22px 22px 0 0;
  border-top: 1px solid var(--line-strong);
  max-height: calc(100vh - var(--sat) - 24px);
  display: flex; flex-direction: column;
  animation: slideup 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.sheet.full { height: calc(100vh - var(--sat) - 24px); }
.sheet-grab { width: 38px; height: 4.5px; background: var(--line-strong); border-radius: 3px; margin: 9px auto 2px; flex: none; }
.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px var(--pad) 10px; flex: none; }
/* THE OVERHANG, NOT THE ADVANCE WIDTH. Tom spotted the Y in "The Laboratory"
   sheared flat. These titles render in Bangers (see .page-h1 group below), a
   SLANTED face whose Y, W, A and V extend past their own advance width.
   `overflow: hidden` clips to the padding box, and scrollWidth never counts
   glyph overhang, so a layout measurement reports no clipping while the pixels
   are cut. The ellipsis protection for genuinely long titles stays; the padding
   just gives the last glyph its overhang back. */
.sheet-head h2 { font-size: var(--fs-4); font-weight: 800; letter-spacing: -0.01em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 0.14em; }
/* 44px MINIMUM. Measured at 43.8 x 30.9 css px, under the project's own 40px
   floor, and on several sheets it is the ONLY way out. Padding alone could not
   fix the height because the line box is 19px; min-height plus centring does. */
.sheet-close { color: var(--text-3); font-size: var(--fs-3); font-weight: 700; padding: 6px 10px; min-height: 44px; min-width: 44px; display: inline-flex; align-items: center; justify-content: center; }
.sheet-body { overflow-y: auto; padding: 2px var(--pad) calc(var(--sab) + 20px); -webkit-overflow-scrolling: touch; overscroll-behavior: contain; }
@keyframes slideup { from { transform: translate(-50%, 60%); } to { transform: translate(-50%, 0); } }
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }

/* ---------- founding-player survey sheet (amethyst, prize-led) ---------- */
.sheet-survey { --amethyst: #b96cf0; max-height: calc(100vh - var(--sat) - 24px); }
.sheet-survey .survey { flex: 1; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: 2px var(--pad) calc(var(--sab) + 18px); }
.survey-hero { position: relative; display: grid; place-items: center; height: 148px; margin: 2px 0 4px; }
.survey-hero.big { height: 186px; }
.survey-liz { position: relative; z-index: 1; filter: drop-shadow(0 6px 14px rgba(0,0,0,0.45)); }
.survey-glow {
  position: absolute; z-index: 0; width: 210px; height: 210px; border-radius: 50%;
  background: radial-gradient(circle, rgba(185,108,240,0.42) 0%, rgba(185,108,240,0.14) 42%, transparent 70%);
  animation: survey-pulse 3.4s ease-in-out infinite;
}
.survey-glow.on { background: radial-gradient(circle, rgba(185,108,240,0.6) 0%, rgba(185,108,240,0.2) 45%, transparent 72%); }
@keyframes survey-pulse { 0%,100% { transform: scale(0.92); opacity: 0.8; } 50% { transform: scale(1.06); opacity: 1; } }
.survey-title { font-family: var(--display); font-weight: 400; letter-spacing: 0.6px; font-size: var(--fs-title); text-align: center; text-wrap: balance; margin: 2px 6px 6px; color: var(--amethyst); }
.survey-sub { font-size: var(--fs-2); line-height: 1.5; color: var(--text-2); text-align: center; margin: 0 4px 14px; }
.survey-sub b { color: var(--text); }
.survey-label { display: block; font-size: var(--fs-2); font-weight: 700; color: var(--text-2); margin: 12px 2px 5px; }
.survey-opt { color: var(--text-3); font-weight: 500; }
.survey-in {
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 12px;
  padding: 12px 13px; font-size: var(--fs-body); width: 100%; box-sizing: border-box; outline: none;
  min-height: 47px; resize: vertical;
}
.survey-in:focus { border-color: rgba(185,108,240,0.6); }
.survey-check { display: flex; align-items: center; gap: 9px; font-size: var(--fs-2); color: var(--text-2); margin: 9px 2px 2px; cursor: pointer; }
.survey-check input { width: 18px; height: 18px; min-height: 0; accent-color: var(--amethyst); flex: none; }
.survey-feats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.survey-feat { display: flex; align-items: center; gap: 8px; font-size: var(--fs-2); color: var(--text-2);
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 11px; cursor: pointer; }
.survey-feat input { width: 17px; height: 17px; min-height: 0; accent-color: var(--amethyst); flex: none; }
.survey-feat:has(input:checked) { border-color: rgba(185,108,240,0.6); color: var(--text); }
.survey-priv { font-size: var(--fs-1); color: var(--text-3); text-align: center; margin: 13px 4px 4px; line-height: 1.5; }
.survey-priv a { color: var(--amethyst); }
.sheet-survey .btn:not(.ghost) { background: var(--amethyst); color: #1a0f26; }

/* scanner sheet */
.sheet.scanner { background: #000; height: calc(100vh - var(--sat) - 24px); }

/* @supports, NOT A SECOND DECLARATION, AND THE DIFFERENCE IS MEASURED. The four
   sheet heights above are the conventional fallback pattern's blind spot: a
   value containing var() is assumed valid at parse time and only fails at
   computed-value time, after substitution, and per CSS Variables that makes the
   declaration UNSET rather than ignored. The earlier declaration is discarded
   with it. Verified in Chromium with a made-up unit standing in for dvh:
     max-height: calc(100vh - var(--sat) - 24px);
     max-height: calc(100xyz - var(--sat) - 24px);   computes to `none`
   which is a sheet with no ceiling at all, worse than the vh it replaced. So
   the dvh versions live behind a condition an old browser answers false, and
   the vh versions above are what it keeps. See tests/dvh-fallback-audit.mjs. */
@supports (height: 100dvh) {
  .sheet { max-height: calc(100dvh - var(--sat) - 24px); }
  .sheet.full { height: calc(100dvh - var(--sat) - 24px); }
  .sheet-survey { max-height: calc(100dvh - var(--sat) - 24px); }
  .sheet.scanner { height: calc(100dvh - var(--sat) - 24px); }
}
.scan-stage { position: relative; flex: 1; overflow: hidden; border-radius: 0 0 0 0; }
.scan-stage video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.reticle {
  position: absolute; left: 50%; top: 44%; transform: translate(-50%, -50%);
  width: min(78%, 340px); height: 150px; border-radius: 18px;
  box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.35);
  outline: 2.5px solid rgba(255, 255, 255, 0.9);
}
.reticle::after {
  content: ""; position: absolute; left: 10px; right: 10px; top: 50%; height: 2px;
  background: var(--accent); opacity: 0.85; border-radius: 2px;
  animation: scanline 1.6s ease-in-out infinite alternate;
}
@keyframes scanline { from { transform: translateY(-44px); } to { transform: translateY(44px); } }
.scan-hint { position: absolute; left: 0; right: 0; top: calc(44% + 95px); text-align: center; color: rgba(255,255,255,0.85); font-size: var(--fs-2); font-weight: 600; text-shadow: 0 1px 6px rgba(0,0,0,0.7); }
.scan-status { position: absolute; left: 0; right: 0; top: calc(44% - 130px); text-align: center; color: #fff; font-size: var(--fs-3); font-weight: 700; }
.scan-tools { position: absolute; right: 14px; top: 12px; display: flex; gap: 8px; }
.scan-foot { flex: none; padding: 12px var(--pad) calc(var(--sab) + 16px); background: #000; display: flex; gap: 8px; }
.scan-foot input { flex: 1; }

/* ---------- inputs ---------- */
.field { display: grid; gap: 6px; margin-bottom: 12px; }
.field label { font-size: var(--fs-2); font-weight: 700; color: var(--text-2); }
.input, .field input, .field select, .field textarea {
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 12px;
  padding: 12px 13px; font-size: var(--fs-body); width: 100%; outline: none; min-height: 47px;
}
.field input:focus, .input:focus { border-color: rgba(185, 239, 74, 0.5); }
.search-wrap { position: relative; margin-bottom: 12px; }
.search-wrap svg { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); width: 17px; height: 17px; stroke: var(--text-3); fill: none; stroke-width: 2.2; stroke-linecap: round; }
.search-wrap input { padding-left: 40px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
.grid4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }

.seg { display: flex; background: var(--surface-2); border: 1px solid var(--line); border-radius: 12px; padding: 3px; gap: 3px; }
/* A SEGMENT THAT SHRANK BELOW ITS OWN WIDTH. Every Settings seg asks for 130px
   inline and every one of them is a flex item, so a long label squeezed it: the
   Gear glow row's two buttons measured 30.9 and 33.4 wide. The author's width is
   the width; the label wraps instead. */
.settings-row .seg { flex: none; }
.seg button { flex: 1; padding: 9px 6px; border-radius: 9px; font-size: var(--fs-2); font-weight: 700; color: var(--text-2); }
.seg button.on { background: var(--surface-3); color: var(--text); box-shadow: 0 1px 4px rgba(0,0,0,0.3); }

.chips { display: flex; gap: 7px; flex-wrap: wrap; }
.chips.scroll { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 0 calc(var(--pad) * -1); padding: 2px var(--pad); }
.chips.scroll::-webkit-scrollbar { display: none; }
.chip {
  padding: 9px 13px; border-radius: 11px; background: var(--surface-2);
  border: 1px solid var(--line); font-size: var(--fs-2); font-weight: 600; color: var(--text-2);
  white-space: nowrap; flex: none;
}
.chip.on { background: var(--accent-soft); border-color: rgba(185, 239, 74, 0.45); color: var(--accent); }

.btn {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  width: 100%; padding: 15px; border-radius: 15px;
  background: var(--accent); color: var(--accent-ink);
  font-size: var(--fs-body); font-weight: 800; letter-spacing: -0.01em;
}
.btn:active { transform: scale(0.985); }
.btn:disabled, .btn.ghost:disabled { background: var(--surface-2); color: var(--text-3); box-shadow: var(--sh-sm); opacity: 1; }
.btn.ghost { background: var(--surface-2); color: var(--text); border: 1px solid var(--line); }
.btn.danger { background: transparent; color: var(--danger); border: 1px solid rgba(255, 122, 110, 0.35); }
.btn.small { width: auto; padding: 9px 14px; font-size: var(--fs-2); border-radius: 11px; }

.action-tiles { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; margin-bottom: 14px; }
.action-tile {
  display: grid; justify-items: center; gap: 7px; padding: 14px 6px 12px;
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 15px;
  font-size: var(--fs-1); font-weight: 700; color: var(--text-2);
}
.action-tile:active { background: var(--surface-3); }
.action-tile svg { width: 24px; height: 24px; stroke: var(--accent); fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }

/* stepper */
.stepper { display: flex; align-items: center; gap: 10px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 13px; padding: 5px; }
.stepper button { width: 44px; height: 42px; border-radius: 10px; background: var(--surface-3); font-size: var(--fs-5); font-weight: 600; color: var(--text); flex: none; }
.stepper .val { flex: 1; text-align: center; font-size: var(--fs-4); font-weight: 700; font-variant-numeric: tabular-nums; }
.stepper input { flex: 1; text-align: center; background: none; border: none; font-size: var(--fs-4); font-weight: 700; min-width: 0; }

/* food rows */
.food-row {
  width: 100%; text-align: left; display: flex; align-items: center; gap: 11px;
  padding: 12px 4px; border-bottom: 1px solid var(--line); background: none;
}
.food-row:active { background: var(--surface-2); }
.food-row .n { flex: 1; min-width: 0; }
.food-row .n .name { font-size: var(--fs-3); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.food-row .n .sub { font-size: var(--fs-2); color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.food-row .kc { font-size: var(--fs-2); color: var(--text-2); font-weight: 700; font-variant-numeric: tabular-nums; text-align: right; flex: none; }
.food-row .kc small { display: block; color: var(--text-3); font-weight: 500; font-size: var(--fs-0); }
.sect-h, .pw-h { font-size: var(--fs-1); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); margin: 16px 2px 4px; display: flex; align-items: center; gap: 8px; }
.sect-h .spin { margin-left: auto; }

.src-badge { font-size: var(--fs-tiny); font-weight: 800; letter-spacing: 0.05em; padding: 3px 7px; border-radius: 6px; background: var(--surface-3); color: var(--text-2); flex: none; }

/* portion sheet numbers */
.preview { display: flex; align-items: baseline; justify-content: center; gap: 14px; padding: 10px 0 16px; }
.preview .kcal { font-size: var(--fs-7); font-weight: 800; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.preview .kcal small { font-size: var(--fs-3); color: var(--text-3); font-weight: 600; margin-left: 3px; }
.preview .pcf { display: flex; gap: 12px; font-size: var(--fs-2); font-weight: 700; color: var(--text-2); font-variant-numeric: tabular-nums; }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; }
.dot.p { background: var(--protein); } .dot.c { background: var(--carbs); } .dot.f { background: var(--fat); }

/* charts */
.chart svg { width: 100%; height: auto; }
.big-stat { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; }
.big-stat .v { font-size: var(--fs-display); font-weight: 800; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.big-stat .d { font-size: var(--fs-2); font-weight: 700; color: var(--text-2); }
.trend-chip { font-size: var(--fs-1); font-weight: 700; padding: 3px 9px; border-radius: 999px; background: var(--surface-3); color: var(--text-2); }
.trend-chip.up { background: rgba(255, 201, 97, 0.15); color: var(--amber); }

/* toast */
.toast {
  position: fixed; bottom: calc(var(--sab) + 96px); left: 50%; transform: translateX(-50%);
  /* BONE CHIP (Tom, 2026-09-27: the grey pill felt off brand). Same paint as the bone
     tips (.map-poi-tip, .fchip-tip): cream ground, dark ink text, full ink keyline, hard
     drop, never a blurred shadow. Guard: tests/toast-bone.test.mjs. */
  background: #f2e9d7; border: 2.5px solid #14100b; color: #241c10;
  font-size: var(--fs-3); font-weight: 700; padding: 11px 18px; border-radius: 12px;
  /* ABOVE THE FULL-SCREEN VEILS (R43-7, 2026-09-07). At z-index 80 the toast sat
     under every takeover this app raises: the daily wheel (.dw, 210, up at 3.7s
     and it never times out), the gate intro and the mimic (200), the graverise
     (210), the wanderer (220), the drop veil (300) and a takeover sheet (310).
     Measured 3 of 3 returning boots with the one line telling a lapsed player
     they were PAID for the day they walked away drawn underneath the wheel, and
     the fault is not the wheel's: a message channel that is invisible whenever
     anything is on top of it is broken for every caller, not just this one, so
     the fix goes here rather than in the wheel's timing. 320 clears every veil
     and stays under the splash (400), which is the one cover a message must not
     jump. Safe BECAUSE of the rule below: pointer-events: none, so raising it
     cannot take a tap from the wheel it now paints over. */
  z-index: 320; box-shadow: 0 5px 0 rgba(0, 0, 0, 0.45);
  /* IT STOPS EATING TAPS. Fixed, z-index 80, and until 2026-08-31 it was
     hit-testable, so for the 2.2 to 3.6 seconds a message is up
     document.elementFromPoint over the Bonehead, Stable and Kitchen doors on
     Today returned #toast and the tap went nowhere. Anti-regression rule 6,
     broken by the toast itself. It is never interactive, so it never needed
     to be in the hit test at all. Guard: tests/a11y-audit.mjs. */
  pointer-events: none;
  animation: toastin 0.25s cubic-bezier(0.22, 1, 0.36, 1);
  /* `width: max-content` so the pill uses the room it is allowed instead of
     shrink-wrapping: a fixed element with auto width was sizing to ~50% of the
     screen, so a one-line message wrapped to four lines inside a pill shaped for
     one. Radius drops from 999px so the two-line case still looks deliberate. */
  width: max-content; max-width: 86%; text-align: center;
}
.toast.toast-error { border-color: #c2412f; box-shadow: 0 5px 0 rgba(0, 0, 0, 0.45), 0 0 0 1.5px #14100b; }
@keyframes toastin { from { opacity: 0; transform: translate(-50%, 12px); } to { opacity: 1; transform: translate(-50%, 0); } }
/* THE TOAST MUST NOT LAND ON THE MAP ACTION CARD. On the Boneyard the one
   surface that says what you can do right now is #mapAct ("Grab it" / "Slow
   down" / "Too fast to loot"), and the shipped bottom above put every toast
   directly on top of it: measured 2026-08-28 at 430x932, toast 30.1,771.5
   369.8x64.5 over card 14.0,787.8 402.0x64.0, so the "Slow down" nag hid the
   very card it was nagging about. Keyed on the card being VISIBLE rather than
   on the route, because the card is hidden unless something is in reach (or
   you are too fast), and when it is hidden the toast's normal seat is fine.
   158px clears the card's measured top (144.2px from the viewport bottom, and
   the card rides --sab the same way this rule does) with a 14px gap.
   Guard: tests/toast-map-audit.mjs. */
body:has(.map-act:not([hidden])) .toast { bottom: calc(var(--sab) + 158px); }

/* AND IT MUST NOT LAND ON A SHEET'S CONTROLS EITHER. R41-20: the welcome-kit
   toast fires 1.2s after onboarding and a new player is five taps from their
   first fight, so it arrives on top of the move tray it is not talking about.
   Measured 2026-09-07 against a real fight: at 375x667 the toast (26.3,506.5
   322.5x64.5) covered Jab whole (110.3x54.8 of a 110.3x54.8 button) and 100.0px
   of Peck the Eyes and Swing; at 393x852 it took 104.8x27.8 of Haymaker and
   116.3x27.8 of Bone Guard.
   Fixed generally rather than for the fight tray: EVERY sheet in this app puts
   its controls at the bottom (the move tray, a shop's buy row, the Wardrobe's
   bar, every .btn-row) and the toast's shipped seat is 96px off the bottom, so
   the collision is structural, not the Pit's. While a sheet is up the toast
   takes a seat under the sheet head instead: sheet top is --sat + 24, the grab
   handle is 15.5 and the head is ~62, so 110 clears it. A short bottom sheet
   leaves the toast over the backdrop, which asks nothing of anybody.
   Keyed on a sheet EXISTING rather than on any route or class, the same way the
   .map-act rule is keyed on the card being visible.
   Guard: tests/toast-sheet-audit.mjs. */
body:has(#sheets .sheet) .toast { top: calc(var(--sat) + 110px); bottom: auto; }

/* progress bar (ocr) */
.progress { height: 6px; border-radius: 4px; background: var(--surface-3); overflow: hidden; margin: 10px 0; }
.progress i { display: block; height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.3s ease; }

/* onboarding */
.onb { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; padding: calc(var(--sat) + 40px) 22px calc(var(--sab) + 26px); max-width: 600px; margin: 0 auto; }
.onb .logo { width: 76px; height: 76px; border-radius: 22px; margin-bottom: 22px; }
.onb h1 { font-size: var(--fs-display); font-weight: 800; letter-spacing: -0.03em; line-height: 1.1; }
.onb .tag { color: var(--text-2); font-size: var(--fs-4); margin: 12px 0 26px; line-height: 1.45; }
.onb .feature { display: flex; gap: 13px; align-items: flex-start; margin-bottom: 17px; }
.onb .feature svg { width: 22px; height: 22px; stroke: var(--accent); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; margin-top: 2px; }
.onb .feature b { display: block; font-size: var(--fs-3); }
.onb .feature span { color: var(--text-3); font-size: var(--fs-2); }
.onb .spacer { flex: 1; }

/* Demo labels occupy layout space, including beside a sheet's Back control. */
.demo-header { flex: none; padding: calc(var(--sat, 0px) + 4px) 64px 4px var(--pad); }
.demo-badge { position: static; display: inline-block; flex: none; align-self: center; background: var(--amber); color: #201500; font-size: var(--fs-tiny); line-height: 1.5; font-weight: 800; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; pointer-events: none; }
.sheet-head:has(> .demo-badge) { flex-wrap: wrap; }
/* Direct child only: the Pit sheet header wraps its h2 in a column flex title box, where a
   10rem basis became a 160px-tall title in demo mode (v658, measured at 375x667). */
.sheet-head:has(> .demo-badge) > h2 { flex: 1 1 10rem; white-space: normal; overflow-wrap: anywhere; }
.sheet-head:has(> .demo-badge) .sheet-close { margin-left: auto; max-width: 100%; overflow-wrap: anywhere; }
.sheet-head > .demo-badge { order: -1; }
/* 2026-09-25 (v730 gate): a sheet head that also carries Gwart's head (Stable,
   Laboratory, Kennel) needs 37px more, and the 10rem title basis above pushed
   Done onto a second line at 320 in demo (header 62 -> 95px, measured on the
   Kennel). Let the title give that width back instead; it wraps inside itself. */
.sheet-head:has(> .demo-badge):has(> .gw-head) > h2 { flex-basis: 0; }

.spin { width: 16px; height: 16px; border: 2px solid var(--line-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

.note { font-size: var(--fs-2); color: var(--text-3); line-height: 1.5; }
.warn { background: rgba(255, 201, 97, 0.1); border: 1px solid rgba(255, 201, 97, 0.25); color: var(--amber); font-size: var(--fs-2); border-radius: 11px; padding: 10px 12px; margin-bottom: 12px; line-height: 1.45; }
.divider { height: 1px; background: var(--line); margin: 16px 0; }
a { color: var(--accent); text-decoration: none; }

.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 13px 2px; border-bottom: 1px solid var(--line); }
.settings-row:last-child { border-bottom: none; }
.settings-row .lab b { display: block; font-size: var(--fs-3); font-weight: 600; }
.settings-row .lab span { font-size: var(--fs-2); color: var(--text-3); }

@media (min-width: 640px) {
  .screen { padding-top: 26px; }
}

/* ---------- game ---------- */
.chip-row { display: flex; gap: 8px; justify-content: center; margin: -2px 0 14px; }
.streak-chip, .level-chip {
  display: inline-flex; align-items: center; gap: 7px;
  background: var(--surface); border: 1px solid var(--line); border-radius: 999px;
  padding: 7px 13px; font-size: var(--fs-2); font-weight: 700; color: var(--text-2);
}
.streak-chip b { color: var(--text); font-variant-numeric: tabular-nums; }
.streak-chip .flame { font-size: var(--fs-3); filter: grayscale(1) opacity(0.55); }
.streak-chip.hot .flame { filter: none; display: inline-block; animation: flicker 1.5s ease-in-out infinite; }
@keyframes flicker {
  0%, 100% { transform: scale(1) rotate(-3deg); }
  50% { transform: scale(1.18) rotate(3deg); }
}
.level-chip .lvl-n { color: var(--accent); font-weight: 800; }
.xp-mini { width: 46px; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; display: inline-block; }
.xp-mini i { display: block; height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.5s ease; }
.xp-bar { height: 8px; border-radius: 5px; background: var(--surface-3); overflow: hidden; margin-top: 10px; }
.xp-bar i { display: block; height: 100%; border-radius: 5px; background: linear-gradient(90deg, #9ed32e, var(--accent)); transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1); }

.badge-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; margin-top: 12px; }
.badge {
  display: grid; justify-items: center; gap: 6px; padding: 11px 4px 9px;
  border-radius: 13px; background: var(--surface-2); border: 1px solid var(--line);
  font-size: var(--fs-tiny); font-weight: 700; color: var(--text-2); text-align: center; line-height: 1.2;
}
.badge .bicon { font-size: var(--fs-5); line-height: 1; }
.badge.locked { opacity: 0.3; filter: grayscale(1); }
.badge.new { animation: badgepop 0.55s cubic-bezier(0.34, 1.56, 0.64, 1); border-color: rgba(185, 239, 74, 0.5); }
@keyframes badgepop { from { transform: scale(0.5); } }

.cele-big { font-size: var(--fs-display); font-weight: 800; letter-spacing: -0.02em; }
.cele-sub { font-size: var(--fs-3); color: var(--text-2); font-weight: 600; margin-top: 3px; }
/* R41-21. The streak card's own line, and the counts under it. Deliberately
   quiet: the number is the headline, this is the sentence and the receipt.
   .bh-pill is the app's existing chip, the same one the level-up rewards row
   and the Wardrobe's "N found" wear, so nothing new is being taught here. */
.cele-line { margin-top: 12px; font-size: var(--fs-3); line-height: 1.45; color: var(--text-2); padding: 0 10px; }
.cele-stats { display: flex; flex-wrap: wrap; justify-content: center; gap: 7px; margin-top: 14px; padding: 0 6px; }

.cele-badge {
  display: flex; align-items: center; gap: 12px; text-align: left;
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px;
  padding: 12px 14px; margin: 0 8px;
}
.cele-badge > span { font-size: var(--fs-6); }
.cele-badge b { display: block; font-size: var(--fs-3); }
.cele-badge small { color: var(--text-3); font-size: var(--fs-1); }

.xp-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 2px; border-bottom: 1px solid var(--line); font-size: var(--fs-3); font-weight: 600; }
.xp-row b { color: var(--accent); font-variant-numeric: tabular-nums; }
.xp-row.dim { color: var(--text-3); border-bottom-style: dashed; }

.macro .hit-dot { color: var(--protein); font-weight: 800; font-size: var(--fs-0); }
/* The over state (QA r24 L8, see macroRow). The bar is still full; the end cap
   is what says "and then some", because carbs' own colour IS --amber, so no
   fill colour could carry it. --text is the one token that reads against all
   three macro fills. The line under it is the ring's "N over" at row scale. */
.macro.over .bar { position: relative; }
.macro.over .bar::after { content: ''; position: absolute; right: 0; top: 0; height: 100%; width: 3px; background: var(--text); }
.macro .over-by { font-size: var(--fs-0); color: var(--text-3); font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; margin-top: -2px; }
.bar.glow i { box-shadow: 0 0 10px rgba(111, 208, 255, 0.65); }
.bar.steps i { background: var(--accent); }

.hk-rows { display: grid; gap: 12px; }
.hk-row { display: flex; align-items: center; gap: 11px; }
.hk-ico { font-size: var(--fs-5); flex: none; }

.guide { padding-left: 20px; display: grid; gap: 8px; font-size: var(--fs-3); line-height: 1.45; color: var(--text-2); }
.guide b { color: var(--text); }
.code-line {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--fs-2);
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 11px;
  padding: 11px 13px; color: var(--accent); word-break: break-all; user-select: all;
  -webkit-user-select: all;
}

@media (prefers-reduced-motion: reduce) {
  /* The duration collapse alone is a TRAP on a loop: 0.001s does not stop an
     infinite animation, it runs one full cycle every millisecond. Measured with
     reduce emulated, 9 of the Hollow's 10 animations still reported playState
     "running", and the fireflies changed MORE pixels frame-to-frame than with
     the setting off. The iteration cap is what actually stops them: one instant
     cycle, then the element falls back to its own styles. One-shots that declare
     their own fill-mode (forwards/both) still hold their end state. */
  *, *::before, *::after {
    animation-duration: 0.001s !important; transition-duration: 0.001s !important;
    animation-iteration-count: 1 !important;
    /* AND THE DELAY, which the first version of this fix missed. A loop with a
       positive animation-delay still reports playState "running" for the whole
       length of that delay, so a 2.6s stagger meant 2.6s of an animation the
       player asked not to have. Found 2026-08-16 by the backdrop lane, which had
       to work around it with negative staggers inside its own file. Zero here
       means the one instant cycle above starts immediately and is done. */
    animation-delay: 0s !important; transition-delay: 0s !important;
  }
}

/* ---------- boneheadz ---------- */
.bh-card { display: flex; gap: 14px; align-items: stretch; }
.bh-stage {
  position: relative; width: 118px; min-height: 118px; flex: none;
  border-radius: 16px; overflow: hidden; background: var(--surface-2);
  border: 1px solid var(--line);
}
.bh-stage img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.bh-stage img.yard-decor { inset: auto; left: 4%; bottom: 6%; width: 24%; height: auto; object-fit: contain; }
.bh-stage.lg { width: 210px; height: 210px; margin: 0 auto; }
.bh-stage.bounce { animation: bhbounce 0.7s cubic-bezier(0.34, 1.56, 0.64, 1); }
@keyframes bhbounce { 0% { transform: scale(1); } 30% { transform: scale(1.07) rotate(-2deg); } 60% { transform: scale(0.98) rotate(1.5deg); } 100% { transform: scale(1); } }
.bh-side { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.bh-topline { display: flex; gap: 7px; margin-bottom: 8px; }
.bh-coin, .bh-crates {
  font-size: var(--fs-1); font-weight: 700; padding: 5px 10px; border-radius: 999px;
  background: var(--surface-2); border: 1px solid var(--line); color: var(--text-2);
}
.bh-coin b { color: var(--carbs); }
.bh-crates { color: var(--accent); border-color: rgba(185, 239, 74, 0.35); --crate-pulse-duration: 2.2s; }
/* Rasterize the peak shadow once; only the glow layer changes opacity.
   The old animation replaced any host shadow, including fight-button depth. */
:is(.bh-crates, .q-claim, .rare-banner, .road-stop.ready, .fight-act.glow, .fight-act.sig, .tal-node.can) { box-shadow: none; }
:is(.bh-crates, .q-claim, .rare-banner, .fight-act.glow, .fight-act.sig, .tal-node.can) { position: relative; }
:is(.bh-crates, .q-claim, .rare-banner, .road-stop.ready, .fight-act.glow, .fight-act.sig, .tal-node.can)::after {
  content: ''; position: absolute; inset: -1px; border-radius: inherit; pointer-events: none;
  box-shadow: 0 0 12px 0 rgba(185, 239, 74, 0.25); opacity: 0;
  animation: cratepulse var(--crate-pulse-duration) ease-in-out infinite;
}
.q-claim::after { inset: 0; }
:is(.road-stop.ready, .fight-act.glow, .fight-act.sig)::after { inset: -2px; }
@keyframes cratepulse { 0%, 100% { opacity: 0; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  :is(.bh-crates, .q-claim, .rare-banner, .road-stop.ready, .fight-act.glow, .fight-act.sig, .tal-node.can)::after { animation: none; }
}

.q-list { display: grid; gap: 7px; }
.q-row { display: flex; align-items: center; gap: 9px; }
.q-main { flex: 1; min-width: 0; }
.q-name { font-size: var(--fs-1); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.q-coins { color: var(--text-3); font-weight: 600; font-size: var(--fs-0); }
.q-bar { height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; margin-top: 4px; }
.q-bar i { display: block; height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1); }
.q-bar.gold i { background: var(--carbs); }
.q-claim {
  font-size: var(--fs-1); font-weight: 800; padding: 6px 11px; border-radius: 9px;
  background: var(--accent); color: var(--accent-ink); flex: none;
  --crate-pulse-duration: 1.8s;
}
.q-done { color: var(--accent); font-weight: 800; flex: none; font-size: var(--fs-3); }
.q-frac { color: var(--text-3); font-size: var(--fs-0); font-weight: 700; flex: none; font-variant-numeric: tabular-nums; }
.q-row.weekly { padding-top: 3px; border-top: 1px dashed var(--line); }
.q-tier + .q-tier { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--line); }
/* matches .sect-h: same size, weight, tracking and colour. These label the same
   KIND of thing (a group of rows), so they had no business being three different
   treatments on one screen. */
.q-tier-h { font-size: var(--fs-1); font-weight: 700; letter-spacing: 0.06em; color: var(--accent); text-transform: uppercase; margin-bottom: 7px; }
.q-tier.week .q-tier-h, .q-tier.month .q-tier-h { color: var(--accent); }
.q-desc { font-size: var(--fs-0); color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.q-coins svg { vertical-align: -1px; }

.bh-hero { display: grid; justify-items: center; gap: 8px; }
.bh-hero.mini { grid-template-columns: auto 1fr; justify-items: start; align-items: center; gap: 14px; }
.bh-hero.mini .bh-stage.lg { width: 76px; height: 76px; border-radius: 14px; }
.bh-hero.mini .bh-hero-meta { justify-items: start; }
.bh-hero-meta { display: grid; justify-items: center; gap: 5px; font-size: var(--fs-3); }

.ward-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; margin-top: 12px; }
.ward-cell {
  position: relative; aspect-ratio: 1; border-radius: 13px; overflow: hidden;
  background: var(--surface-2); border: 2px solid var(--line);
  display: grid; place-items: center; font-size: var(--fs-0); font-weight: 700; color: var(--text-3);
}
.ward-cell img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* item art is a trimmed canvas (hydratePackArt), so the ink fills the tile
   instead of drowning in the source PNG's transparent padding */
.ward-cell canvas.ward-art { position: absolute; inset: 0; width: 100%; height: 100%; }
.ward-cell.r-common, .melt-tile.r-common { border-color: rgba(159, 172, 159, 0.4); }
.ward-cell.r-uncommon, .melt-tile.r-uncommon { border-color: rgba(162, 224, 166, 0.55); }
.ward-cell.r-rare, .melt-tile.r-rare { border-color: rgba(124, 196, 255, 0.6); }
.ward-cell.r-epic, .melt-tile.r-epic { border-color: rgba(155,146,232,0.65); box-shadow: 0 0 11px rgba(155,146,232,0.22); }
.ward-cell.r-legendary, .melt-tile.r-legendary { border-color: rgba(255, 201, 97, 0.8); box-shadow: 0 0 12px rgba(255, 201, 97, 0.25); }

/* per-meal cap + auto-sync label */
.meal-head .kcal.over { color: var(--danger); }
.card-title .link.auto { color: var(--accent); font-weight: 700; cursor: default; }
/* kitchen */
.kitchen-card { cursor: pointer; }
.kitchen-card .kc-line { font-size: var(--fs-2); font-weight: 600; }
.crate-row.cookpot.ready { border: 1px solid rgba(165, 232, 71, 0.5); border-radius: 12px; box-shadow: 0 0 12px rgba(165, 232, 71, 0.18); }
.crate-row.recipe.lack { opacity: 0.55; }
.recipe-need { display: block; color: var(--text-3); font-variant-numeric: tabular-nums; margin-top: 2px; }
/* seed label: what this seed is FOR, so it reads above the grow-time fine print */
.seed-use { display: block; color: var(--text-2); font-weight: 600; margin-top: 2px; }
.ingredient-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 4px 0 6px; }
.ing-cell { display: grid; justify-items: center; gap: 1px; padding: 8px 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-2); }
.ing-cell.empty { opacity: 0.4; }
.ing-cell .ing-ico { font-size: var(--fs-5); }
.ing-cell .ing-n { font-weight: 800; font-size: var(--fs-3); }
.ing-cell .ing-name { font-size: var(--fs-tiny); color: var(--text-3); text-transform: uppercase; letter-spacing: .02em; }

/* ===== Daily wellness card (v80) ===== */
.wellness-card { display: block; }
.well-row { display: flex; align-items: center; gap: 11px; padding: 9px 2px; border-bottom: 1px solid var(--line); }
.well-row:last-child { border-bottom: none; }
.well-ico { flex: none; width: 30px; display: grid; place-items: center; }
.well-body { flex: 1; min-width: 0; }
.well-body b { display: block; font-size: var(--fs-3); }
.well-body small { color: var(--text-3); font-size: var(--fs-1); }
.well-bar { height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; margin-top: 5px; max-width: 160px; }
.well-bar i { display: block; height: 100%; border-radius: 3px; background: #7cc4ff; transition: width 0.3s ease; }
.well-row.done .well-body b { color: var(--accent); }
.well-check { flex: none; color: var(--accent); font-weight: 800; font-size: var(--fs-4); width: 30px; text-align: center; }

/* ===== The Haunted Kitchen (v78) ===== */
.kitchen-hero {
  position: relative; overflow: hidden; border-radius: 16px; margin: 2px 0 12px;
  padding: 22px 16px 18px; text-align: center;
  background: radial-gradient(120% 90% at 50% 0%, #24303a 0%, #161a22 55%, #10131a 100%);
  border: 1px solid var(--line);
}
.kitchen-hero-title { position: relative; z-index: 2; font-family: var(--display); font-size: var(--fs-6); letter-spacing: 1px; color: #cfe8b4; text-shadow: 0 0 14px rgba(139, 212, 80, 0.4); }
.kitchen-quote { position: relative; z-index: 2; margin-top: 3px; font-size: var(--fs-1); font-weight: 700; letter-spacing: 0.06em; color: rgba(207, 232, 180, 0.6); }
.kitchen-atmos { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.k-embers { position: absolute; inset: 0; background:
  radial-gradient(2px 2px at 20% 70%, rgba(255,180,84,.8), transparent),
  radial-gradient(2px 2px at 70% 40%, rgba(139,212,80,.7), transparent),
  radial-gradient(1.5px 1.5px at 45% 85%, rgba(255,180,84,.6), transparent);
  animation: kEmber 3.2s ease-in-out infinite; }
@keyframes kEmber { 0%,100% { opacity: .5; transform: translateY(0) } 50% { opacity: 1; transform: translateY(-6px) } }
.k-steam { position: absolute; bottom: 0; width: 40px; height: 60px; border-radius: 50%; filter: blur(9px);
  background: radial-gradient(circle, rgba(139,212,80,.28), transparent 70%); animation: kSteam 4s ease-in-out infinite; }
.k-steam.l { left: 26%; } .k-steam.r { right: 26%; animation-delay: 1.6s; }
@keyframes kSteam { 0%,100% { opacity: .3; transform: translateY(4px) scale(.9) } 50% { opacity: .7; transform: translateY(-10px) scale(1.1) } }

.cauldron-stage { display: grid; grid-template-columns: 88px 1fr; gap: 14px; align-items: center; margin: 0 2px 14px; padding: 12px; border-radius: 14px; background: var(--surface-2); border: 1px solid var(--line); }
.cauldron-stage.ready { border-color: rgba(165,232,71,.5); box-shadow: 0 0 14px rgba(165,232,71,.2); }
.cauldron { position: relative; width: 84px; height: 78px; }
.cauldron::after { content: ''; position: absolute; left: 6px; right: 6px; bottom: 0; height: 52px; border-radius: 0 0 42px 42px / 0 0 34px 34px; background: linear-gradient(#3a3550, #221f30); border: 3px solid #12101a; }
.cauldron::before { content: ''; position: absolute; left: 0; right: 0; top: 20px; height: 12px; border-radius: 50%; background: #14121c; z-index: 3; }
.cauldron-brew { position: absolute; left: 12px; right: 12px; top: 20px; height: 12px; border-radius: 50%; z-index: 2; background: radial-gradient(circle, #8bd450, #5aa02c); box-shadow: 0 0 12px rgba(139,212,80,.7); }
.cauldron-stage.idle .cauldron-brew { background: radial-gradient(circle, #4a4658, #2c2a38); box-shadow: none; }
.c-bub { position: absolute; z-index: 4; width: 7px; height: 7px; border-radius: 50%; background: #b6e88a; opacity: 0; top: 22px; }
.cauldron-stage.cooking .c-bub, .cauldron-stage.ready .c-bub { animation: cBub 1.6s ease-in infinite; }
.c-bub.c1 { left: 26px; animation-delay: 0s; } .c-bub.c2 { left: 40px; animation-delay: .5s; } .c-bub.c3 { left: 52px; animation-delay: 1s; }
@keyframes cBub { 0% { opacity: 0; transform: translateY(0) scale(.6) } 40% { opacity: .9 } 100% { opacity: 0; transform: translateY(-16px) scale(1) } }
.cauldron-fire { position: absolute; left: 16px; right: 16px; bottom: -4px; height: 14px; border-radius: 50%; opacity: 0; background: radial-gradient(circle, rgba(255,150,40,.9), rgba(255,90,0,.3) 60%, transparent 75%); }
.cauldron-stage.cooking .cauldron-fire, .cauldron-stage.ready .cauldron-fire { opacity: 1; animation: cFire .5s ease-in-out infinite alternate; }
@keyframes cFire { from { transform: scaleX(.9); opacity: .7 } to { transform: scaleX(1.1); opacity: 1 } }
.cauldron-status { min-width: 0; }
.cauldron-status b { display: block; font-size: var(--fs-3); }
.cauldron-status small { color: var(--text-3); font-size: var(--fs-1); }
.cauldron-ready { color: var(--accent); font-weight: 800; font-size: var(--fs-2); }
/* v143 multi-pot: a row of cauldron cards (idle / cooking / ready / buy) */
.pot-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; margin: 4px 2px 14px; }
.pot-card { display: grid; gap: 4px; justify-items: center; text-align: center; padding: 12px 8px 10px; border-radius: 14px; background: var(--surface-2); border: 2px solid var(--line); min-height: 104px; align-content: center; }
.pot-card.ready { border-color: rgba(165,232,71,.55); box-shadow: 0 0 12px rgba(165,232,71,.18); }
.pot-card.buy { cursor: pointer; border-style: dashed; color: var(--muted); }
/* a queued cook is paid for but not on the fire yet: dashed like the buy card
   says "not a pot", the dimming says "waiting its turn" */
.pot-card.queued { border-style: dashed; opacity: .72; }
.pot-card .pot-ico { font-size: var(--fs-title); line-height: 1; }
.pot-card b { font-size: var(--fs-2); }
.pot-card small { font-size: var(--fs-0); color: var(--muted); }
.pot-card .cook-bar { width: 100%; }
.pot-serve { width: auto; padding: 5px 12px; font-size: var(--fs-1); }
.crate-row.transmute { border: 1px solid rgba(180,140,255,.4); background: rgba(180,140,255,.07); border-radius: 12px; }
.crate-row.transmute.lack { opacity: .72; }
/* THE LABEL WAS SPILLING OUT OF ITS OWN STICKER (Tom, device pass). Nothing on
   this row can shrink except the button: the copy column is floored by the tmx
   strip (seven 30px sockets plus gaps, none of them shrinkable), so at 390px the
   button is handed less width than the word "Transmute", which is one unbreakable
   word and paints straight over its own border. The strip and the label are both
   incompressible, so the row gets a second line instead of squeezing one of them.
   flex-wrap only spends that line when the two genuinely do not fit, so a wider
   screen or a short label ("20h 4m") still reads as one row, and a longer label
   or larger Dynamic Type drops the button below rather than overflowing it. */
.crate-row.transmute { flex-wrap: wrap; }
.crate-row.transmute > .btn { flex: 0 0 auto; margin-left: auto; }
.cook-bar { height: 7px; border-radius: 4px; background: var(--surface-3); overflow: hidden; margin: 6px 0 4px; }
.cook-bar i { display: block; height: 100%; border-radius: 4px; background: linear-gradient(90deg, #ff9628, #8bd450); transition: width 0.8s linear; }
.cauldron-serve { grid-column: 1 / -1; width: 100%; }
.fight-act.potion { border-color: rgba(139, 212, 80, .5); }

/* ingredient badge on map spawn markers + readout */
.map-spawn { position: relative; }
.map-spawn .spawn-ing { position: absolute; bottom: -6px; right: -8px; font-size: var(--fs-2); line-height: 1; filter: drop-shadow(0 1px 1px rgba(0,0,0,.6)); }
.ro-ing { color: var(--text-2); font-weight: 600; }
/* R39-25's unread mark. THE APP ALREADY HAS THIS LANGUAGE and this borrows it
   rather than inventing a second one: accent plate, --bg ring, the crewBadgePop
   the tab badge uses, sized down to a dot because it counts nothing.
   TOP-LEFT on a tile, which is the one free corner: the equipped tick owns
   top-right (.ward-cell.equipped::after), and the colourway count uses
   .ward-fam-n. On a paper-doll
   slot it goes top-right, clear of .pd-mog (bottom-left) and of the label
   (.pd-tag, bottom), above .pd-gear's top strip so it is never painted over. */
.new-dot {
  position: absolute; width: 9px; height: 9px; border-radius: 50%;
  background: var(--accent); box-shadow: 0 0 0 2px var(--bg, #0d0c12);
  pointer-events: none; animation: crewBadgePop 0.3s ease;
}
.ward-cell .new-dot { top: 5px; left: 5px; z-index: 3; }
.pd-slot .new-dot { top: 4px; right: 4px; z-index: 4; }
@media (prefers-reduced-motion: reduce) { .new-dot { animation: none; } }

.ward-cell.equipped::after {
  content: "✓"; position: absolute; top: 4px; right: 4px; width: 20px; height: 20px;
  background: var(--accent); color: var(--accent-ink); border-radius: 7px;
  display: grid; place-items: center; font-size: var(--fs-1); font-weight: 900;
}

.crate-row { display: flex; align-items: center; gap: 12px; padding: 11px 2px; border-bottom: 1px solid var(--line); }
.crate-row b { display: block; font-size: var(--fs-3); }
.crate-row small { color: var(--text-3); font-size: var(--fs-1); display: block; }
.crate-ico { font-size: var(--fs-title); flex: none; }
.shop-cell {
  display: grid; justify-items: center; gap: 4px; padding: 13px 8px;
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px;
  font-size: var(--fs-2); font-weight: 700;
}
.shop-cell small { color: var(--carbs); font-weight: 700; }
.shop-cell:disabled { opacity: 0.4; }

.crate-shake { font-size: 74px; margin: 34px 0 40px; display: inline-block; animation: crateshake 0.95s ease-in-out; }
@keyframes crateshake {
  0%, 100% { transform: rotate(0) scale(1); }
  15% { transform: rotate(-9deg) scale(1.02); }
  30% { transform: rotate(8deg) scale(1.05); }
  45% { transform: rotate(-7deg) scale(1.08); }
  60% { transform: rotate(6deg) scale(1.12); }
  75% { transform: rotate(-4deg) scale(1.18); }
  90% { transform: rotate(2deg) scale(1.25); }
}
.reveal-card {
  display: flex; align-items: center; gap: 13px; text-align: left;
  background: var(--surface-2); border: 2px solid var(--line); border-radius: 15px;
  padding: 11px 13px; margin: 0 6px 9px;
  animation: badgepop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.reveal-card img, .reveal-ico { width: 54px; height: 54px; border-radius: 11px; background: var(--surface-3); object-fit: cover; flex: none; font-size: var(--fs-display); display: grid; place-items: center; }
.reveal-card b { display: block; font-size: var(--fs-3); }
.reveal-card small { color: var(--text-3); font-size: var(--fs-1); display: block; }
.rar-chip { font-size: var(--fs-0); font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; }
.reveal-card.r-rare { border-color: rgba(124, 196, 255, 0.5); }
.reveal-card.r-epic { border-color: rgba(155,146,232,0.55); }
.reveal-card.r-legendary { border-color: rgba(255, 201, 97, 0.7); box-shadow: 0 0 18px rgba(255, 201, 97, 0.2); }


/* ---------- Boneheadz Gym theme ---------- */
body::before {
  content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none;
  background-image: var(--grain);
  background-size: 150px 150px; opacity: 0.5; mix-blend-mode: soft-light;
}
#app { position: relative; z-index: 1; }

/* ============================================================================
   MIDNIGHT LOOK — a shared paint layer over the existing UI (v180). Analog film
   grain, hand-inked panels, and sticker-style buttons, tuned dark + on-brand.
   Backdrop lives on body + body::before above; components are re-skinned here.
   ============================================================================ */
:root {
  /* the hand-inked outline. Moved to the deck's warm ink (#2A2D28) with the Tier 1
     rebuild; the old #100c14 was a cold near-black the deck does not contain. */
  --ink: #2a2d28;
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='150' height='150'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
}
/* panels: grain blended into the surface + a thick dark keyline + soft drop edge */
.card {
  background-color: var(--surface);
  background-image: var(--grain);
  background-size: 150px 150px;
  background-blend-mode: soft-light;
  border: 2px solid var(--ink);
  box-shadow: 0 3px 0 rgba(0,0,0,0.32), inset 0 0 0 1.5px rgba(242,233,215,0.10);
}
/* buttons: chunky sticker — dark keyline + drop edge + top sheen */
.btn { border: 2.5px solid var(--ink); box-shadow: 0 4px 0 rgba(0,0,0,0.42), inset 0 2px 0 rgba(255,255,255,0.28); }
.btn:active { transform: translateY(2px); box-shadow: 0 2px 0 rgba(0,0,0,0.42), inset 0 2px 0 rgba(255,255,255,0.28); }
.btn.ghost { border-color: var(--ink); box-shadow: 0 4px 0 rgba(0,0,0,0.4); }
.btn.small { border-width: 2px; box-shadow: 0 3px 0 rgba(0,0,0,0.4); }
.btn.danger { box-shadow: none; } /* keep the destructive button quiet, no sticker edge */
/* action tiles + home action buttons: same hand-inked treatment */
.action-tile, .hero-act {
  border: 2px solid var(--ink);
  box-shadow: 0 3px 0 rgba(0,0,0,0.32), inset 0 0 0 1.5px rgba(242,233,215,0.09);
}

.page-h1, .day-title h1, .sheet-head h2, .onb h1, .cele-big {
  font-family: var(--display); font-weight: 400; letter-spacing: 0.8px;
}
.page-h1 { font-size: var(--fs-display); }
.day-title h1 { font-size: var(--fs-title); }
.sheet-head h2 { font-size: var(--fs-5); letter-spacing: 1px; }
.onb h1 { font-size: var(--fs-7); letter-spacing: 1.5px; }
.cele-big { font-size: var(--fs-7); letter-spacing: 1.5px; }
.ring-center .big { font-family: var(--display); font-weight: 400; font-size: var(--fs-7); letter-spacing: 1px; }
.preview .kcal { font-family: var(--display); font-weight: 400; font-size: var(--fs-hero); letter-spacing: 1px; }
.btn { font-family: var(--display); font-weight: 400; letter-spacing: 1.4px; font-size: var(--fs-4); }
.btn.small { font-size: var(--fs-3); letter-spacing: 1px; }
.level-chip .lvl-n, .meal-head h2 { letter-spacing: 0.2px; }
.big-stat .v { font-family: var(--display); font-weight: 400; letter-spacing: 0.8px; }

.ico { display: inline-block; vertical-align: -3px; }

/* idle shuffle: the whole layer stack sways gently */
.bh-anim { position: absolute; inset: 0; animation: bh-idle 3.4s ease-in-out infinite; transform-origin: 50% 94%; }
/* Hold the stack back until every layer has decoded, then fade the finished
   character in. Without this you watch the gear land on the skeleton one piece
   at a time on every render. JS clears .bh-composing (with a 1.5s safety net, so
   a missing asset can never leave a character invisible). */
.bh-anim.bh-composing { opacity: 0; }
.bh-anim { transition: opacity .18s ease-out; }
@media (prefers-reduced-motion: reduce) { .bh-anim { transition: none; } }
@keyframes bh-idle {
  0%, 100% { transform: translateY(0) rotate(-0.9deg); }
  22% { transform: translateY(-3px) rotate(0.4deg); }
  48% { transform: translateY(-1px) rotate(1.1deg); }
  74% { transform: translateY(-4px) rotate(-0.3deg); }
}
.bh-anim { --av-fit: cover; }  /* the sheen mask reads this too; keep it beside the object-fit below */
.bh-anim img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; pointer-events: none; }

.bh-bubble {
  position: relative; background: var(--surface-2); border: 1px solid var(--line-strong);
  border-radius: 13px; padding: 9px 12px; font-size: var(--fs-2); font-weight: 600; line-height: 1.4;
  margin-top: 2px;
}
.bh-bubble::before {
  content: ""; position: absolute; left: -6px; top: 15px; width: 10px; height: 10px;
  background: var(--surface-2); border-left: 1px solid var(--line-strong);
  border-bottom: 1px solid var(--line-strong); transform: rotate(45deg);
}

/* splash montage */
#splash {
  position: fixed; inset: 0; z-index: 400; background: var(--bg);
  display: grid; place-items: center; transition: opacity 0.35s ease;
}
#splash.out { opacity: 0; pointer-events: none; }
.splash-inner { display: grid; justify-items: center; gap: 18px; text-align: center; }
.splash-stage { position: relative; width: 200px; height: 200px; border-radius: 26px; overflow: hidden; background: var(--surface-2); border: 1px solid var(--line-strong); }
.splash-word {
  font-family: var(--display); font-size: var(--fs-jumbo); color: var(--text); letter-spacing: 3px;
  transform: rotate(-2deg); animation: stamp 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.splash-title { font-family: var(--display); font-size: var(--fs-hero); color: var(--accent); letter-spacing: 2px; line-height: 1; transform: rotate(-2deg); animation: stamp 0.45s cubic-bezier(0.34, 1.56, 0.64, 1); }
.splash-sub { font-size: var(--fs-2); color: var(--text-3); font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; }
@keyframes stamp { from { transform: scale(2.2) rotate(-7deg); opacity: 0; } to { transform: scale(1) rotate(-2deg); opacity: 1; } }

/* ---------- boneyard radar ---------- */
.bh-hunt {
  font-size: var(--fs-1); font-weight: 700; padding: 5px 10px; border-radius: 999px;
  background: var(--surface-2); border: 1px solid rgba(124, 196, 255, 0.35); color: var(--protein);
}
.radar {
  position: relative; aspect-ratio: 1; border-radius: 50%;
  background: radial-gradient(circle, #10160f 0%, #0c100c 55%, #0a0d0a 100%);
  border: 2px solid rgba(165, 232, 71, 0.25); overflow: hidden; margin: 4px auto 0;
  max-width: 330px;
}
.radar-ring { position: absolute; border: 1px solid rgba(165, 232, 71, 0.16); border-radius: 50%; }
.radar-sweep {
  position: absolute; inset: 0; border-radius: 50%;
  background: conic-gradient(from 0deg, rgba(165, 232, 71, 0.22), transparent 22%);
  animation: sweep 3.2s linear infinite;
}
@keyframes sweep { to { transform: rotate(360deg); } }
.radar-you {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: 52px; height: 52px; border-radius: 50%; overflow: hidden;
  border: 2px solid var(--accent); background: var(--surface-2); z-index: 2;
  box-shadow: 0 0 14px rgba(165, 232, 71, 0.35);
}
.radar-blip { z-index: 3; 
  position: absolute; transform: translate(-50%, -50%); z-index: 1;
  filter: drop-shadow(0 0 6px rgba(165, 232, 71, 0.4));
  animation: blip 2s ease-in-out infinite;
}
.radar-blip.rare { filter: drop-shadow(0 0 9px rgba(255, 201, 97, 0.8)); animation-duration: 1.1s; }
.radar-blip.inrange { animation: blipready 0.9s ease-in-out infinite; }
@keyframes blip { 0%, 100% { opacity: 0.75; } 50% { opacity: 1; } }
@keyframes blipready { 0%, 100% { transform: translate(-50%, -50%) scale(1); } 50% { transform: translate(-50%, -50%) scale(1.35); } }
.rare-banner {
  background: rgba(255, 201, 97, 0.12); border: 1px solid rgba(255, 201, 97, 0.4);
  color: var(--carbs); font-size: var(--fs-2); font-weight: 700; border-radius: 12px;
  padding: 9px 13px; margin-bottom: 10px; text-align: center;
  --crate-pulse-duration: 1.6s;
}

/* ---------- pogo-style hero scene ---------- */
/* NO FRAME. THE HERO IS THE TOP OF THE SCREEN.
   Tom, 2026-08-21: "today tab continued - why did you keep the playing card
   style frame? that wasnt what we agreed on."

   He is right and the agreed design is on disk: today-preview/today-mockup.html
   (the R3 mockup, rendered at today-preview/renders/render-390x844-peek.png).
   Its hero is `.hero{position:relative;height:620px;background:var(--olive)}`
   with no fill, no border, no radius and no shadow around it: the art runs edge
   to edge and off the top of the viewport, and dissolves into the page at the
   bottom. Every part of the playing-card read came from the frame that used to
   be here (a #f2e9d7 plate, a 22px radius, a 20px chin under the art and a hard
   drop shadow), so all of it is gone rather than softened.

   THIS ELEMENT IS NOW ONLY THE SIDEWAYS BLEED. The negative side margins cancel
   .screen's own --pad exactly, which is why they are written as that term rather
   than as a number, so the art runs to both edges of the phone the way the
   mockup's does.

   IT DOES NOT BLEED UPWARD, AND THAT IS A MEASUREMENT RATHER THAN A COMPROMISE.
   The mockup's hero starts at the top of the viewport, and taking this one there
   too (a negative top margin of `--sat + 14px`) was BUILT AND THEN REVERTED,
   because it silently killed the overscroll wordmark. Tom asked for that in
   these words: "when you scroll the phone on the today screen and can see the
   EMPTY SPACE ABOVE THE BONEHEAD CARD it has the boneheadz word mark as a nice
   branding touch." The mark is #app::before at z-index -1, so it paints BEHIND
   the screen's content and is revealed in exactly that strip; an opaque hero
   starting at y=0 covers it. Measured, on the full-bleed version:
     FAIL UNDERNOTCH  0 ink px below the status bar at 36px of pull (floor 6000);
          first pull with any visible ink: 72px
     FAIL VISIBLE     0 ink px in the 44px of the mark that is clear of the card
   which is the same shape as the bug three releases were spent on. So the strip
   Tom named stays, and the frame he complained about is what came off. */
.hero-card {
  position: relative;
  margin: 0 calc(-1 * var(--pad)) 12px;
}
/* THE SCROLL PEEK, and it is arithmetic rather than a tuned height. Tom,
   2026-08-21: "how will the player know to scroll down and see more right now?
   i have a feeling they wont know unless we show half a banner width or
   something to make them go look." Measured on the shipped tree before the
   change: the first card below the doors cleared the fold by 60px at 393x852
   with no inset, by 1.2px at --sat 59, by 14.8px at 320x568, and by nothing at
   all at 320x568 with an inset. Three of the four viewports showed a sliver or
   less, so a constant height cannot be the answer: the fold moves with the
   viewport and the notch, and the hero has to move with it.

   So the SCENE gives up exactly what the peek needs, and the 174px is measured,
   not chosen. From the fold upward, at both widths:
     66.2  the tab bar (plus --sab, which is why it is a separate term)
     14    .screen's own top padding, on top of --sat
     12    .hero-card's bottom margin
     67.8  .hero-actions, the four doors
     14    .hero-actions' bottom margin
   107.8 of that is below the scene and above the first card, 66.2 is the bar,
   and the sum is 174. It was 201 while the hero was a card: 7 of those were the
   cream frame's top padding and 20 its chin, and both went with the frame.

   AND THE CAP MOVED WITH THEM, 600 to 627, which is the same 27px. It is not a
   taste call, it is what keeps this screen identical below the hero. The cap
   only bites at 393x852 with no inset, and there the scene stays at its ceiling
   while the card around it got 27px shorter, so everything under it slid UP by
   27 and a DIFFERENT card ended up straddling the fold: measured, the peek went
   from 51px of the unlock nudge to 7.2px of the hype banner, which is the
   hairline PEEK exists to forbid. Giving the ceiling the 27px the frame gave up
   puts every card back where it was, at every configuration.
   Whatever is left after the peek is the scene's, capped so
   a tall screen does not get an absurd hero and floored so a short one keeps a
   figure. Verified by measuring the peek off the render at four configurations,
   not by trusting this comment: tests/today-peek-audit.mjs.

   --gw-h and --gw-top are the status column above the figure (the chips, then
   Gwart), and they live here because .hero-char's top is their sum. 90px is
   R3's ruling (Tom: "essentially half his current height"), the height at which
   his face measures 40.8px against a 40px legibility bar, and it scales down
   with the viewport rather than through a breakpoint: 10.6vh crosses 90 at an
   849px-tall screen, so every current phone gets the full plaque and only a
   short one trades it away. */
/* --fig IS THE FIGURE'S DRAWN SIDE, AND EVERY COMPOSITION NUMBER BELOW IS A
   MULTIPLE OF IT. Tom, 2026-08-21, on v422: "alignment on today is still off now
   they're too split and bonehead weapon is off edge of screen." Both halves of
   that come from mixing units: the Bonehead's size is derived from the viewport,
   while the pet's size and the pet's gutter were ABSOLUTE pixels, so the pair
   drifted apart on a wide phone and the pet swelled to the Bonehead's own size
   on a short one. A pair whose parts are measured in different units is not a
   pair.

   The Bonehead is `object-fit: contain` inside .hero-char, so the drawing is a
   square of side min(box width, box height). Both terms are written out here
   ONCE, and the height term reuses --scene-h so this and the scene cannot drift:
     width   the box is the scene plus its own -16/-34 overhang, capped by the
             444px art ceiling on .bh-anim (see there: Cam's masters are 640).
     height  the scene minus the status column (--gw-top + --gw-h) and the 79px
             the caption owns.
   Verified against the rendered box rather than trusted: the FIG row of
   tests/hero-share-audit.mjs fails if this arithmetic and the real .bh-anim
   disagree by more than a pixel. */
.hero-scene {
  --gw-top: 50px;                          /* .hero-top's 10px + its 32px + an 8px gap */
  --gw-h: clamp(58px, 10.6vh, 90px);
  --peek: clamp(28px, 5.2vh, 46px);
  --scene-h: min(627px, max(280px, calc(100vh - var(--sat) - var(--sab) - 174px - var(--peek))));
  --fig: min(calc(100vw + 50px), 444px, calc(var(--scene-h) - var(--gw-top) - var(--gw-h) - 79px));
  /* THE SCENE BLEEDS UP BEHIND THE STATUS BAR AND THE ISLAND. Tom, 2026-08-22:
     "can we have the boneheadz background extend upward into the dynamic island so
     that it doesnt feel like it just ends abruptly and waste's screen space?"
     Measured before: at --sat 59 the backdrop art started at y=73 (.screen's own
     calc(--sat + 14px)) and the strip above it was flat page navy.

     --bleed is exactly that padding. The box moves up by it and grows by it, so
     the CONTENT area is still --scene-h to the pixel and nothing below the hero
     moves: .hero-actions measured 659.7 both before and after.

     Two gotchas, both found by measuring rather than by reading:
     1. margin-top must come AFTER the `margin` shorthand. The first attempt put
        it before and the shorthand silently reset it, so the rule was in the file,
        matched the element, and did nothing.
     2. padding-top does NOT hold the absolutely positioned children. .hero-top
        and .hero-char are position:absolute off the scene's top, so they rode the
        box up 73px and sat under the island. They are pinned below. */
  position: relative; overflow: hidden; cursor: pointer;
  margin: 0;
  height: var(--scene-h);
  background: var(--coral);
  text-align: left;
}
/* THE BOTTOM DISSOLVES INTO THE PAGE. The frame used to end the art with a hard
   2.5px edge; with the frame gone a full-bleed plate that simply stops is a
   seam. Ported from the mockup's .hero-fade at its four stops. Written as rgba
   literals of --bg (#0d0c12) rather than color-mix, because the iOS 15 floor
   this project holds has no color-mix and a dropped declaration here is a hard
   edge again, not a slightly different fade. */
.hero-fade {
  position: absolute; inset: 0; z-index: 4; pointer-events: none;
  /* Tom 2026-09-17: "have the colour gradient go a little bit lower before it fades off".
     70/86/95 -> 78/90/96: the green plate holds behind the feet, same end. */
  background: linear-gradient(180deg, rgba(13, 12, 18, 0) 78%,
    rgba(13, 12, 18, .30) 90%, rgba(13, 12, 18, .82) 96%, rgba(13, 12, 18, 1) 100%);
}
/* a warm centre light, so the figure is lit rather than pasted onto a flat field */
.hero-scene::before {
  content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: radial-gradient(66% 44% at 50% 46%, rgba(255, 236, 180, .2), transparent 68%);
}
/* the deck's grain over the coral, pointer-transparent */
/* THE HERO CARRIES NO GRAIN, and that is the seam fix rather than a look choice.
   Tom, 2026-08-26: "there's still a slight seam and im 99% sure it's because
   youve added a grainy noise layer on top of the whole bonehead section. ive
   already brought this up to you but you glossed over it" and "to me it looks
   like the grain is not fading out it covers the entire block". Both correct.

   WHAT WAS HERE: .hero-scene::after painted var(--grain) at opacity .07, masked
   transparent for the first 28px so the top edge met the flat fill flat-on-flat.
   Its own comment claimed that "removes the boundary entirely". It did not. It
   MOVED the boundary 28px down and turned a step into a ramp.

   MEASURED, on a 90px displaced hero, luma averaged across the row:
     grain OFF   fill 111.45   art 111.45   identical, no seam anywhere
     grain ON    fill 111.45   art 111.92 rising to 112.27 further down
   The strip a bounce reveals is a background-COLOR on the scroller and it can
   never carry texture: v435 measured that an opaque background-image there stops
   the compositor promoting the layer and the page went flat black. So a grained
   hero and a flat strip cannot be reconciled by any colour, however well
   computed, which is exactly what four attempts at this kept rediscovering.
   Guard: the SEAM row in tests/hero-edge-audit.mjs. */
.hero-scene .bh-anim { animation-duration: 4s; }
.hero-scene .bh-anim { --av-fit: cover; }
.hero-scene .bh-anim img { object-fit: cover; }
.hero-tomb {
  position: absolute; left: 4%; bottom: 12%; width: clamp(70px, 22%, 110px);
  z-index: 2; opacity: 0.95; pointer-events: none;
  filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.4));
}

.hero-top {
  /* The scene's top edge is ALREADY below the notch: .screen pads the safe area
     (sat+14) and .hero-card only cancels the SIDE padding, never the top one, so
     the scene starts below the status bar. Adding var(--sat) here would
     double-count the notch (~59px of dead space on notched phones). */
  /* CURRENCIES AT THE TOP, AND THEY LEAD. Tom asked for the currencies at the
     top of the screen with Gwart under them, three times. They used to be a
     split row, the Trends dot pinned left and the wallet pinned right, with the
     art showing between them; that is two floating plates rather than a status
     column. Left-aligned and grouped, they are one object with the plaque
     directly beneath, which is the whole point of the arrangement. */
  /* 10px under the ISLAND, not under the scene's box. Tom, 2026-08-24: "the UI for
     currencies needs to move up a bit because now there is just a weird gap
     between the dynamic island and the currency."
     He is right and it is arithmetic: before the bleed the scene started at
     --sat + 14 and this sat 10px into it, so the chips were 24px below the inset
     and that read as the top of the screen. The bleed moved the BOX to y=0 without
     moving the inset, so the same 24px became a gap under the island with nothing
     in it. Measured at --sat 59: chips at 83, island edge at 59.
     Anchored to the inset now, so the rhythm is the same on every phone and on
     none at all. */
  position: absolute; top: calc(var(--sat) + 10px); left: 12px; right: 12px; z-index: 5;
  display: flex; justify-content: flex-start; align-items: center; gap: 7px;
  /* 32px WAS THE TRENDS DOT'S HEIGHT, and --gw-top above is 10 + that 32 + 8.
     The dot moved into .hero-actions on 2026-08-26 and the row collapsed to the
     wallet pill's 28px, which pushed Gwart 4px off the currencies and put the
     ORDER row of tests/today-peek-audit.mjs red at 24.5px against its 22 bound.
     Pinned rather than retuning --gw-top down to 46, because --fig is derived
     from --gw-top and 4px more Bonehead takes the Today hero past the 1.40x
     ceiling tests/art-resolution-audit.mjs pins on Cam's 640 masters. */
  min-height: 32px;
}
.hero-top-right { display: flex; gap: 7px; }
.hero-top .streak-chip, .hero-top .bh-coin, .hero-top .bh-crates {
  background: rgba(42, 45, 40, .82); border: 2px solid var(--ink); color: var(--text);
  box-shadow: var(--sh-sm);
  background: rgba(13, 12, 18, 0.62); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}

/* ============================ GWART, ON TODAY ==============================
   Under the currencies, which is where Tom put him. This is NOT the Emporium
   panel: that one is a full-bleed 328px header with a crimson radial behind a
   384px-wide wizard, and every number in it was solved for that panel's size.
   Nothing is copied from it. What IS shared is Cam's animation (.wz-body,
   .wz-enter, .wz-sway, .wz-stars-*, declared once, further down this file) and
   the art files, which are already precached for the Shop.

   THE PLATE IS #d5c8b0 AND THAT IS TOM'S NUMBER. The first version used the
   app's own cream and he came back with "gwart's background is too bright",
   then, given two darker options, "split the difference between A and B". Note
   what this hex is NOT: it is not --text. --text is #f2e9d7 and it is also the
   talk box's type colour, so a search-and-replace on the cream in this file
   changes the words in the dialogue box as well. Tom caught exactly that once:
   "why did you change the text colour in the textbox? i asked you to just
   change gwart's background colour." One literal, in one rule, on purpose.

   THE CROP IS THE ART'S, NOT A TASTE CALL. R3 measured the frame that keeps the
   whole pointed hat while making his face read: (293, 442) - (1753, 1136) in
   the 2048 square, 1460 x 694, which is where the tip of the cone lands on the
   top edge and the beard on the bottom. So the plate's proportions FOLLOW that
   rect instead of being declared: the height is the ruling (--gw-h), the
   content width is height x 1460/694, and the art is the untouched 2048 square
   blown up until 1460 of its columns span the plate and shoved left and up by
   the crop's own origin. No second copy of the PNG, no object-fit guessing, and
   the whole square stays intact underneath so Cam's clip-path thirds and his
   sway origin land where he drew them.

   NO PALM GLOWS. Measured in R3: his lime screen-blend is a 134-channel shift
   over his own near-black demo ground and 12 channels over this cream plate,
   which is invisible. They belong on the dark Emporium panel, and they are
   there. */
/* HIM AND HIS LINE, SIDE BY SIDE, which is the mockup's arrangement (.gwrow in
   today-preview/today-mockup.html: two halves of the row, an 8px gap). His
   plaque keeps its measured width, so the box takes whatever is left rather
   than being declared: at 393 that is 185 and 176, which is the mockup's split.
   The row is where the FLOW is; .gw-today keeps its own float animation. */
/* Gwart inside onboarding: the Today row's scene at a size that shares the
   step with a headline instead of owning a hero. The span is aria-hidden and
   non-interactive: the Guide he opens elsewhere references screens a brand-new
   player has not seen. */


.gw-row {
  /* pinned against the island bleed, same as .hero-top and .hero-char. This was
     the one I missed: the first pass pinned two absolute children, measured those
     two, and called it unchanged, while .gw-row rode the box up 73px and took
     Gwart and the talk box under the island with it (measured: gw-row 123 -> 50,
     the wizard art to -6.7, i.e. off the top of the screen). The rect I measured
     and the render I looked at disagreed because they were different elements. */
  position: absolute; left: 12px; right: 12px; top: calc(var(--gw-top) + var(--bleed, 0px)); z-index: 5;
  display: flex; align-items: flex-start; gap: 8px; pointer-events: none;
}
.gw-today {
  --gw-ch: calc(var(--gw-h) - 4px);                   /* inside the 2px border */
  --gw-cw: calc(var(--gw-ch) * 1460 / 694);           /* the crop's own aspect */
  position: relative; flex: none; padding: 0;
  width: calc(var(--gw-cw) + 4px); height: var(--gw-h);
  overflow: hidden;
  /* HE IS THE TAP TARGET. Tom, 2026-08-21: "gwart on the today page, isnt
     saying anything? when you tap him he should say things". Only the plaque
     takes pointer events; the row and the box stay transparent so the rest of
     the scene still opens the character on a tap. */
  pointer-events: auto; cursor: pointer;
  background: #d5c8b0;
  border: 2px solid var(--ink); border-radius: var(--radius-sm); box-shadow: var(--sh);
  animation: gwFloat 6.4s ease-in-out infinite;
}
/* .gw-box, the talk box that sits in this row, is declared AFTER .talkbox
   further down this file: same specificity, so source order is what makes its
   --tb-size win. Declaring it here rendered the box at 16px. */
/* 5.00px peak to peak, 6.4s, resting midpoint 0, so a frozen frame sits dead
   centre rather than at an extreme. */
@keyframes gwFloat { 0%, 100% { transform: translateY(2.5px); } 50% { transform: translateY(-2.5px); } }
/* The 2048 square, scaled so 1460 of its columns fill the plate, then offset by
   the crop's origin. .wz-scene's own position:relative and aspect-ratio are
   overridden here; both dimensions are given, so the ratio has nothing to do. */
.gw-today .wz-scene {
  position: absolute;
  width: calc(var(--gw-cw) * 2048 / 1460); height: calc(var(--gw-cw) * 2048 / 1460);
  left: calc(var(--gw-cw) * -293 / 1460); top: calc(var(--gw-cw) * -442 / 1460);
}
@media (prefers-reduced-motion: reduce) {
  .gw-today { animation: none; }
}
/* HE STEPS OUT ON A SHORT PHONE, and the arithmetic is why. Above the fold at
   320x568 there are 501.8px, of which 174 belong to the tab bar, the doors and
   the screen's own top padding. What is left is split between the scene and the
   peek, so the figure's box is `scene - peek - plaque` px however it is sliced:
   at a 24px peek and a plaque small enough to keep his face legible there is
   nothing left, and the measured result was a 144px Bonehead standing beside a
   108px pet, which is the figure contract's own failure mode. Above 600px of
   viewport height there is room for all three and he is drawn; below it the
   currencies and the peek are kept, because those are what Tom asked for, and
   the wizard is what gives way. One line to reverse if that is the wrong trade:
   the figure goes back to 144px and he comes back at 320x568. Graded in both
   directions by tests/today-peek-audit.mjs, so his disappearing at 852 is red
   and so is the figure collapsing here. */
@media (max-height: 600px) {
  .hero-scene { --gw-h: 0px; }
  /* The whole row, not just the plaque: his LINE costs the same band he does,
     and a talk box floating where he is not is worse than a quiet screen. */
  .gw-row:not(.gw-keep) { display: none; }   /* gw-keep: the row is the ONLY explanation, never hide it */
}
/* ===== THE TALK BOX: the app's one typing-dialogue surface =====
   Approved in the canvas "The Raising and the Talk Box". Every talking surface
   renders through js/talkbox.js, so these rules are the only place the four states
   are drawn. Everything scales off --tb-size, so a host surface rescales the whole
   box with one declaration instead of overriding six.

   THE CARET AND THE CHEVRON ARE STRUCTURALLY EXCLUSIVE. The caret hangs off the
   ABSENCE of .tb-done and the chevron off its PRESENCE, so a box that says "wait"
   twice is not a bug JS can reintroduce. Do not add a second state class. */
.talkbox {
  --tb-size: 1rem;
  font-family: var(--dialogue);
  font-size: var(--tb-size);
  line-height: 1.55;
  letter-spacing: 0.02em;
  color: var(--text);
  background: var(--surface);
  border: 2px solid var(--line-strong);
  border-radius: var(--radius-sm);
  box-shadow: var(--sh);
  padding: calc(var(--tb-size) * 0.88) calc(var(--tb-size) * 0.94);
  position: relative;
}
.talkbox .tb-name:empty { display: none; }
.talkbox .tb-name {
  font-size: calc(var(--tb-size) * 0.69);
  letter-spacing: 0.09em;
  line-height: 1.2;
  color: var(--accent);
  margin-bottom: calc(var(--tb-size) * 0.44);
}
/* the chevron sits in the corner, so a waiting box reserves the corner */
.talkbox.tb-hold { padding-bottom: calc(var(--tb-size) * 1.7); }
.tb-caret {
  display: inline-block; width: 0.52em; height: 1em;
  background: var(--accent); vertical-align: -0.12em; margin-left: 0.12em;
  animation: tbBlink 0.9s steps(1, end) infinite;
}
@keyframes tbBlink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
/* pre-line belongs on the TEXT, never on the box. On the box it also honours the
   newlines between the markup's own child elements, which printed four blank lines
   and made Today's box 148px tall for three lines of dialogue. */
.talkbox .tb-txt { white-space: pre-line; }
/* THE BOX IS SIZED BEFORE THE FIRST CHARACTER LANDS, and it never moves again.
   A box that contains only the typed prefix grows sideways on every character and
   downwards on every wrap: on Today that drags the tail across the artwork while
   the line arrives, which is precisely the jarring in-between moment this project
   keeps paying for. ::before is an invisible copy of the WHOLE line that lays the
   box out (its own width and height, so a short line still gets a short box), and
   the visible reveal is drawn over it. Identical width and font, so the ghost and
   the reveal wrap at exactly the same characters and the caret lands where the
   text really ends. */
.talkbox .tb-line { position: relative; }
.talkbox .tb-line::before {
  content: attr(data-tb);
  display: block; visibility: hidden; white-space: pre-line;
}
.talkbox .tb-reveal { position: absolute; inset: 0; }
.talkbox.tb-done .tb-caret { display: none; }
.tb-next {
  display: none; position: absolute;
  right: calc(var(--tb-size) * 0.81); bottom: calc(var(--tb-size) * 0.69);
  color: var(--gold); line-height: 0;
}
.tb-next svg { width: calc(var(--tb-size) * 0.94); height: calc(var(--tb-size) * 0.94); display: block; }
.talkbox.tb-done.tb-hold .tb-next { display: block; animation: tbNudge 1.1s ease-in-out infinite; }
@keyframes tbNudge { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
/* A finished line that is not waiting for anything must not eat the tap target it
   is sitting on: anti-regression rule 6, and Today's box floats over the stage. */
.talkbox.tb-done:not(.tb-hold) { pointer-events: none; }
.talkbox.tb-gone { animation: tbGone 0.24s ease-in forwards; pointer-events: none; }
@keyframes tbGone { to { opacity: 0; transform: scale(0.94); } }
@media (prefers-reduced-motion: reduce) {
  .tb-caret, .talkbox.tb-done.tb-hold .tb-next, .talkbox.tb-gone { animation: none; }
  .talkbox.tb-gone { opacity: 0; }
}

/* GWART'S LINE ON TODAY. PLACEMENT ONLY, the way .hero-bubble below is:
   everything that draws the box (fill, ink, radius, shadow, caret, chevron)
   comes from .talkbox above, so Today's dialogue cannot drift away from the rest
   of the app's. It has to come AFTER .talkbox: both are one class, so source
   order is the only thing that makes --tb-size 11px win.
   The border is an OPAQUE cream. .talkbox's default --line-strong is a 17% cream
   chosen against --surface, and over full-bleed artwork it dissolves, which is
   the same failure .hero-bubble::before already documents. */
/* 13px, AND THE 13 IS A CEILING RATHER THAN A PREFERENCE. Tom, 2026-08-21: "the
   gwart gwart font size is too smalll when he talks." It was 11px, which is the
   smallest type anywhere in this app and 5px under the same talk box in the
   Wanderer's encounter (.wnd-enc-box takes .talkbox's own 16px).

   WHAT BOUNDS IT, AND IT IS NOT THE WRAPPING. The box is the right half of
   .gw-row and its band is the plaque's height (--gw-h, 90px on every phone 849px
   tall and up); past that it starts painting over the Bonehead's head, because
   it is z-index 5 and he is 2. The binding case is 393x852, where the plaque
   leaves the box just 176px of width. Measured there against the longest line
   the pool can produce (59 characters, "A crate by his feet, still shut. I gave
   him hands for this."), which is THREE lines at every size below, because
   BoldPixels is a narrow face and the line count does not move:
     11px  76.1px    13px  89.2px    14px  95.8px    15px 102.3px    16px 108.9px
   So 13 is simply the largest that still fits 90, and it only fits because the
   chrome below pays for it. Raising it further means giving the box its own
   band, not a bigger number here.

   THE TIGHTER CHROME IS WHAT BUYS THE TWO POINTS, and it is deliberately only
   the VERTICAL half: 1.55 line-height and 0.88em of padding-block are generous
   for a 16px box with one or two lines and wasteful for a three-line one at this
   size. padding-inline is untouched at .talkbox's 0.94em, so the box wraps at
   exactly the same characters it did and the line count cannot move. Measured:
   at 13px the shipped chrome gives 103.8px (13.8 over the band) and this gives
   89.2px (0.8 under it); the same tightening also pulls the shipped 11px box
   from 88.4px down to 76.1px. Graded by the TYPE rows in tests/talkbox-audit.mjs,
   which harvest the real line pool through window.__gwartPool and measure the
   box EVERY line produces, in both directions: too small fails and too tall
   fails. Every line, not just the longest, since v424 took the catalogue from
   seventeen lines to eighty-nine: at that size "the longest one fits" and "they
   all fit" stopped being the same claim. Measured there, the whole catalogue
   comes out at 89.2px (three wrapped lines) or 71.7px (two), and nothing else
   fits in 176px, so a fourth wrapped line is what this is guarding against.
   The border is an OPAQUE cream, see below. */
.gw-box {
  flex: 1; min-width: 0; --tb-size: .8125rem;
  line-height: 1.35; padding-block: calc(var(--tb-size) * 0.62);
  text-align: left; border-color: #e6dcc8;
}

/* LEFT COLUMN, and the tail drops to the jaw. Tom: "people's mouths arent on top
   of their heads." It used to sit over the skull with a sideways tail. */
.hero-bubble {
  /* an explicit inset, not var(--safe): that variable was declared on the rule
     that also set the plate height, and when the duplicate height rule was
     removed the variable went with it, so `left` computed to nothing and the
     bubble sat flush against the card frame. */
  /* IT IS A TALK BOX NOW, and this rule is only its PLACEMENT. Everything that
     draws the box (fill, ink, radius, shadow, the pixel face, the caret, the
     chevron) comes from .talkbox above, so Today cannot drift away from the rest
     of the app's dialogue. What stays here is exactly what is about this stage:
     the left column, the sway, the tail to the jaw, and the auto-dismiss. */
  position: absolute; left: 14px; top: 52px; z-index: 3;
  --sway: rotate(-2deg);
  transform: var(--sway);
  max-width: 42%; width: max-content;
  /* LEFT, not centre. Centred was right for a three-word comic bubble; a line
     that types on has to grow rightwards off a fixed left edge or every single
     character shifts the whole line sideways. */
  text-align: left;
  --tb-size: .6875rem;
  animation: bubblePop 0.45s cubic-bezier(0.34, 1.6, 0.64, 1) both,
    bubbleFloat 2.2s ease-in-out 0.5s 2 alternate,
    /* 7s, was 5.2s. The longest line in speechLine() is 69 characters, which is
       1.8s of typing that the old instant-print bubble did not spend, so the same
       5.2s left barely three seconds to read the longest thing he says. */
    bubbleOut 0.32s ease-in 7s forwards;
}
@keyframes bubbleOut {
  to { transform: var(--sway) scale(0.6) translateY(-8px); opacity: 0; }
}
/* The tail still drops to the jaw (the figure contract, rule 4); only its colours
   moved to the talk box's. #3b393d is .talkbox's translucent --line-strong border
   FLATTENED over --surface: a triangle drawn with a transparent border colour
   picks up the artwork behind it and the outline dissolves over light gear. */
.hero-bubble::before { /* ink tail (drawn behind the fill tail) */
  content: ""; position: absolute; right: -14px; top: 42%;
  border: 10px solid transparent; border-left-color: #3b393d;
  border-right-width: 0; transform: rotate(12deg);
}
.hero-bubble::after { /* fill tail */
  content: ""; position: absolute; right: -8px; top: 45%;
  border: 7px solid transparent; border-left-color: var(--surface);
  border-right-width: 0; transform: rotate(12deg);
}
@keyframes bubblePop { from { transform: var(--sway) scale(0.7); opacity: 0; } to { transform: var(--sway) scale(1); opacity: 1; } }
@keyframes bubbleFloat { from { translate: 0 0; } to { translate: 0 4px; } }
@media (prefers-reduced-motion: reduce) { .hero-bubble { animation: none; } }
/* A BAND, not an overlay. It used to float over the character's legs and slice
   the pet in half. */
/* A FADE, not a shelf. The opaque band was 92px of the plate that the art could
   never use; this is 96px of gradient that the figure stands in front of. */
.hero-meta { position: absolute; left: 0; right: 0; bottom: 0; z-index: 4; height: 96px;
  display: flex; flex-direction: column; justify-content: flex-end; gap: 8px;
  padding: 0 14px 14px; background: linear-gradient(180deg, rgba(23, 21, 29, 0), rgba(23, 21, 29, .88) 46%); }
.hero-level { display: grid; gap: 3px; width: 100%; text-align: left; }
.hero-lv {
  /* GOLD, not accent. A level is something you HAVE; the accent is supposed to
     mark the one thing to DO next, and there were nine accent moments competing
     in the first viewport. Gold is already this system's prestige colour and it
     matches the XP bar directly beneath it.
     A PLATE, not stroked text: this is what the approved today.html mockup shows
     (`.lv .num`, gold fill + 2px ink + hard shadow, the deck's sticker recipe).
     The stroked-text version shipped instead and Tom read the screen as not
     built, 2026-08-08. */
  font-family: var(--display); font-size: var(--fs-6); line-height: 1; letter-spacing: .03em;
  color: var(--ink); background: var(--gold);
  border: 2px solid var(--ink); border-radius: 11px; box-shadow: var(--sh-sm);
  padding: 3px 10px; white-space: nowrap;
}
.hero-title {
  font-family: var(--display); font-size: var(--fs-4); color: var(--text); letter-spacing: 1px;
  -webkit-text-stroke: 3.5px #17151d; paint-order: stroke fill;
}
.hero-xpnum {
  display: inline-block; margin-top: 5px;
  background: rgba(13, 12, 18, 0.78); color: #d9d3c6;
  padding: 3px 10px; border-radius: 999px;
  -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
}
.hero-xpbar { display: block; height: 13px; border-radius: 999px; background: rgba(42, 45, 40, .75); border: 2px solid var(--ink); overflow: hidden; margin-top: 5px; }
.hero-xpbar i { display: block; height: 100%; border-radius: 999px; background: var(--gold); transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1); }
.hero-xpnum {
  font-size: var(--fs-1); color: var(--text); font-weight: 600;
  background: rgba(42, 45, 40, .82); border-radius: 999px;
  padding: 3px 10px; display: inline-block; margin-top: 6px;
}
/* THE BOUNCE RUNS ALONE, AND THE IDLE COMES BACK AFTER IT (QA round 26 O5).
   This rule used to read `bhbounce 0.7s ..., bh-idle 4s ease-in-out 0.7s
   infinite`: two animations claiming `transform` on ONE element. Chrome will not
   composite a property two animations claim, and it decides that when the
   animation STARTS and never again, so the infinite bh-idle ran on the main
   thread for as long as the element existed: 60 style recalcs a second and 15 to
   22% of a core after one logged food, still 59.6/s five minutes later, until the
   player left the tab (the innerHTML rebuild is the only thing that replaced the
   element). Pausing animations dropped it to 0.1/s. Same trap the Emporium's
   .wz-glow shipped (tests/idle-perf-audit.mjs, item 3).
   Now the bounce is the ONLY animation here, and renderToday removes .bounce on
   its animationend, so .bh-anim falls back to its own `bh-idle` (4s under
   .hero-scene, above), which starts as a NEW animation, alone, and is composited.
   Same picture: the idle began at 0.7s from phase 0 before, and it does now. The
   failure mode is also safe: if the class were never removed, the element would
   simply carry no animation, not a hot one. */
.hero-scene.bounce .bh-anim { animation: bhbounce 0.7s cubic-bezier(0.34, 1.56, 0.64, 1); }

/* 22px, not 14: the four doors used to be followed by whatever nudge or banner
   happened to be live, and the quests came after that stack. With the nudge gone
   and the banners evicted below the day, the quests butt straight onto the doors,
   so the break between "the game" and "the day" is this margin now. It is also
   what keeps the scroll peek honest: measured at 393x852 --sat 0 the quests bar's
   bottom edge landed 2px below the fold at 14px, against the 4px floor
   tests/today-peek-audit.mjs holds, so the first screen showed the whole bar and
   no sign that anything followed it. At 22px it hides 10px. */
.hero-actions { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 22px; }
.hero-act {
  display: grid; justify-items: center; gap: 5px; padding: 11px 2px 9px;
  background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm);
  font-size: var(--fs-0); font-weight: 700; color: var(--text-2);
  white-space: nowrap; overflow: hidden;
}
.hero-act:active { background: var(--surface-2); }
/* FIVE is the shipped row now (option B2, 2026-08-07), not an edge case, so it
   gets a label size you can actually read rather than the 9.5px it had while it
   was a fallback. Measured at 375px: 63px per tile, "Character" fits on one line. */
.hero-actions.five { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
.hero-actions.five .hero-act { font-size: var(--fs-0); padding: 10px 1px 8px; letter-spacing: -.01em; }
/* the Garden's door reads in growth green when something is ready to pick */
.hero-act.grow { border-color: color-mix(in srgb, var(--mint) 60%, var(--line)); }
.hero-act.grow span { color: var(--mint); }
.hero-badge.go { color: var(--mint); }
.hero-actions.six { grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 5px; }
.hero-actions.six .hero-act { font-size: var(--fs-micro); padding: 9px 1px 7px; }
.hero-actions.six .hero-act .ico { width: 21px; height: 21px; }
.hero-emoji { font-size: 22px; line-height: 23px; }
/* INLINE, as it was for 280 builds: this class is SHARED by every hero-door
   attention mark ("The Pit !"), and #314's absolute positioning, written for
   the Backpack crate COUNT, ripped every door's bang out of its label flow and
   planted it on the text (Tom saw it live within hours: "the exclamation point
   is on top of text"). The clip fix the count needed is scoped to the one
   badge that needed it, below. */
.hero-badge { color: var(--accent); font-style: normal; font-weight: 900; }
/* The crate COUNT on the Backpack door is the badge that overflowed at 375px
   (a two-digit count widens the label past the button's overflow:hidden edge).
   Only it gets the absolute anchor, and to the BUTTON's top-right corner, not
   the label span: #314 anchored it right:2px of the span, and since the span
   IS the shrink-wrapped label, the count landed on top of "Backpack"'s last
   glyphs (measured: "14" overlapped the "ck" at 375 AND 390). Padding the span
   instead would widen it right back to the width that clipped in the first
   place. The corner is empty at every viewport, fits two digits, and stays
   inside overflow:hidden. #314's `.hero-act > span { position: relative }` is
   gone with it: the span is the badge's parent, so while positioned it stays
   the containing block and the count can only ever land on the label. */
#charBtn { position: relative; }
#charBtn .hero-badge { position: absolute; right: 4px; top: 3px; }
/* Tom 2026-09-17: the Today Pit look (dark fill, lime underline, lime label) is THE
   attention/selected style everywhere. Room tabs below reuse these declarations. */
.hero-act.attn {
  background: var(--surface-2); border-color: transparent;
  box-shadow: inset 0 -3px 0 var(--accent); color: var(--accent);
}
/* The v146 unlock-nudge card (`.unlock-nudge`, `.ul-ico/.ul-txt/.ul-chev`) stood
   here until 2026-08-22, when Tom removed the last nudge it carried: "remove the
   'start with breakfast' button that's there". Its signals still drive the Pit
   "!" badge and the unlock toasts; nothing renders the card. */
/* v148 What's New (changelog) sheet */
.wn-update-note {
  display: flex; flex-direction: column; gap: 5px; margin: 0 0 16px;
  padding: 13px 15px; border-radius: 14px;
  background: linear-gradient(180deg, rgba(185,108,240,0.16), rgba(185,108,240,0.08));
  border: 2px solid rgba(185,108,240,0.55);
  box-shadow: 0 3px 0 rgba(122,63,176,0.4);
}
.wn-update-note b:first-child { font-size: var(--fs-3); color: #d7a6ff; }
.wn-update-note span { font-size: var(--fs-2); color: var(--text-2); line-height: 1.5; }
.wn-update-note b { color: var(--text); }
/* What's New: two panes. "Updates" is the version list, "News" is every
   announcement the game has ever popped, so a player who swiped one away while
   walking can get back to it. Added 2026-08-09 on Tom's ask. */
.wn-tabs { display: flex; gap: 8px; margin: 2px 0 14px; }
.wn-tab {
  flex: 1; padding: 9px 6px; border-radius: 999px; border: 2px solid var(--line-strong);
  background: var(--surface-2); color: var(--text-2);
  font-family: var(--display); font-size: var(--fs-3); letter-spacing: .04em;
}
.wn-tab.on { background: var(--ink); color: var(--paper, #F3EFE7); border-color: var(--ink); }
.nw-row {
  display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
  padding: 10px 12px; margin-bottom: 9px; border-radius: 14px;
  border: 1px solid var(--line); background: var(--surface);
}
.nw-thumb {
  width: 56px; height: 56px; flex: none; border-radius: 12px; overflow: hidden;
  background: var(--surface-2); border: 1px solid var(--line);
  display: flex; align-items: center; justify-content: center; position: relative;
}
.nw-thumb .nw-img { width: 100%; height: 100%; object-fit: contain; }
.nw-thumb .nw-fit { transform: scale(.42); transform-origin: center; display: block; }
.nw-thumb .nw-wall { transform: scale(.52); transform-origin: center; display: block; }
.nw-txt { flex: 1; min-width: 0; display: grid; gap: 2px; }
.nw-txt b { font-family: var(--display); font-size: var(--fs-body); letter-spacing: .02em; }
.nw-txt small { color: var(--text-3); font-size: var(--fs-2); line-height: 1.35; }
.nw-date { font-size: var(--fs-0); color: var(--text-3); font-weight: 700; letter-spacing: .06em; flex: none; }
.nw-chev { color: var(--text-3); font-size: var(--fs-5); flex: none; }
.wn-entry { padding: 12px 0; border-bottom: 1px solid var(--line); }
.wn-entry:last-child { border-bottom: 0; }
.wn-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
.wn-head b { font-size: var(--fs-body); }
.wn-date { font-size: var(--fs-1); color: var(--text-3); font-variant-numeric: tabular-nums; flex: 0 0 auto; }
.wn-list { margin: 0; padding-left: 18px; display: grid; gap: 5px; }
.wn-list li { font-size: var(--fs-2); line-height: 1.4; color: var(--text-2); }
/* weapon / off-hand rarity glow on the avatar (epic/legendary held items) */
.bh-anim img.wpn-glow.r-epic { filter: drop-shadow(0 0 5px rgba(155,146,232,0.85)); }
.bh-anim img.wpn-glow.r-legendary { filter: drop-shadow(0 0 6px rgba(255, 201, 97, 0.95)); }
.bh-anim img.wpn-glow.r-prestige { filter: drop-shadow(0 0 7px rgba(255, 107, 139, 0.98)); }
/* gear tier-set bonuses */
.set-note { display: grid; gap: 6px; margin: 0 2px 12px; }
.set-row { display: grid; gap: 1px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--carbs) 45%, transparent); border-radius: 10px; background: var(--surface-2); }
.set-row b { font-size: var(--fs-1); color: var(--carbs); }
.set-row small { font-size: var(--fs-0); color: var(--text-3); }

.splash-mark { width: min(78%, 320px); }

/* ---------- hero reframe: whole body + yard ---------- */
/* The plate's height is declared ONCE, up with the card. A second declaration
   lived here and won by source order, capping the plate at 470 while the card
   said 520, and every number derived from the plate came out short with it: the
   figure ended up 125px above the caption instead of 20, which is what Tom saw
   as "the bonehead is floating". */
/* THE ISLAND BLEED, behind .fx-island (?island) and deliberately NOT shipped.
   Tom, 2026-08-22: "can we have the boneheadz background extend upward into the
   dynamic island so that it doesnt feel like it just ends abruptly".

   It works: at --sat 59 the backdrop goes from y=73 to y=0, and NOTHING below the
   hero moves (.hero-actions measures 659.7 both ways, .hero-top 83, .hero-char
   209, all unchanged). Two gotchas cost a round each and are recorded so the next
   attempt does not repeat them: margin-top must come AFTER the `margin` shorthand
   or it is silently reset, and padding-top does not hold .hero-top or .hero-char
   because both are position:absolute off the scene's top.

   WHY IT IS GATED. With the scene starting at the island, the currency chips read
   as floating in the MIDDLE of the art rather than pinned to its top: today-peek's
   ORDER row measures them 83.0px below the scene's top edge and reds on it. The
   wordmark rows change meaning too, because the mark now composites over olive art
   instead of page navy. Those are design consequences for Tom to weigh, not
   defects to silence, so the guards stay strict and this stays behind a flag.

   UPDATED 2026-08-24. The composition is now CORRECT and the render agrees with
   the numbers. The earlier disagreement was my own error: I pinned .hero-top and
   .hero-char, measured those two, and called the hero unchanged, while .gw-row
   rode the box up and took Gwart and the talk box under the island (measured:
   gw-row 123 -> 50, the wizard art to -6.7, off the top of the screen). Three
   absolute children, not two. Pinned, and a full element diff now shows the only
   things that move are the four art layers: hero-card, hero-scene, hero-backdrop,
   hero-fade, all 73 -> 0.

   IT STILL DOES NOT SHIP, and this is the honest reason. Un-gated, two suites go
   red, and BOTH are metrics whose premise this change invalidates rather than
   defects it introduces:
     today-peek ORDER   "the currencies sit at the top of the hero" measures them
                        against the SCENE's top edge. That edge is now behind the
                        island, so the chips read 83.0px down when they are 10px
                        below the visible top. The reference wants to be the
                        safe-area line, not the box.
     wordmark REST      "nothing of the mark is on screen at rest" is a luminance
                        threshold over a band that used to be page navy and is now
                        bright olive art, so the ART trips the ink test.
   Re-deriving two guards' reference points to make my own change pass is the
   exact move this repo forbids, so it waits for someone to do that deliberately,
   on its own, with prove-reds. */
/* THE BOUNCE MUST NOT SHOW THE EDGE OF THE APP, AND AS OF 2026-08-24 IT DOES NOT.
   Tom: "you didnt extend the coloured backdrop far enough when i pull down i can
   see the wordmark but the background colour just stops and shows that we are
   actually at the edge of the app."

   The scene's box reaches y=0, but an overscroll bounce drags the content DOWN
   and what it uncovers is the SCROLLER's own background. Extending the box
   further up cannot fix that: a bounce has no fixed size. So the scroller carries
   the art's own top-edge colour, and the rule is up with .screen--today.

   THE COLOUR IS SAMPLED, NOT WRITTEN DOWN. The backdrop is an equipped cosmetic,
   so a hard-coded olive would be wrong for every other one. paintHeroEdge in
   js/app.js reads the top-centre pixel of the equipped backdrop into --hero-edge;
   the fallback is --bg, which is exactly the old behaviour, if the sample fails.

   WHY THE SCROLLER AND NOT A FILL HUNG OFF THE CARD, which was the other shape
   built and is the one that measures fine and would have shipped blind. A 420px
   `.hero-card::before` at `bottom: 100%` lives INSIDE the scroller and above its
   content origin, and this repo has already measured, on a booted iPhone 17 Pro,
   that NOTHING parked above a WKWebView scroller's content origin paints into the
   rubber-band strip: four forms tested in one page, including that exact one,
   zero pixels each. WebKit fills the rubber-band region from the scrolled-contents
   layer's solid backgroundColor, and a background COLOUR on the scroller is the
   one thing that mechanism does honour. The card fill would have looked right in
   Chromium and been invisible on Tom's phone, which is the same mistake that
   shipped three times as the wordmark itself. See the block above
   #app:has(.screen--today)::before for that measurement.

   MEASURED, on a bounce that translates the scroller's CHILDREN and not the
   scroller (translating the scroller takes its background with it and measures
   nothing):
     before   the revealed strip is page navy, rgb(24,20,38) fading to rgb(10,9,17)
     after    rgb(107,124,56) edge to edge, against the art's rgb(109,124,64) to
              rgb(113,127,69) in the rows just below it
   AND IT COSTS NOTHING AT REST. Whole-viewport A/B with animation and transitions
   frozen, at the top of Today, scrolled 600 and scrolled to the bottom: the fill
   changes zero pixels the content was not already covering.

   IT WAS BLOCKED FOR A RELEASE, and the blocker was the guard rather than the
   fix. Three rows of tests/overscroll-wordmark-audit.mjs found the mark by
   LUMINANCE, so any fill bright enough to look like the art also read as ink.
   Raising the threshold was measured and does not work: the strip already
   contains pixels at luminance 252 because Gwart's plaque is near-white. Those
   rows are on difference detection now, which is immune to what is behind the
   mark, and the audit's header carries their prove-reds. */
.hero-scene {
  --bleed: calc(var(--sat) + 14px);
  margin-top: calc(-1 * var(--bleed));
  padding-top: var(--bleed);
  height: calc(var(--scene-h) + var(--bleed));
}
/* A BACKDROP IS A FLAT FIELD, NOT A PICTURE, so it is painted and not decoded.
   Found 2026-09-18 by the ink measure in tests/art-resolution-audit.mjs, which
   reported the Today backdrop at 2.00x: the scene is 641 CSS px tall, which is
   1282 device pixels on a dpr-2 phone, and the art is a 640px square. No
   object-fit can fix that -- `cover` is load-bearing here (the field must fill
   the scene; `contain` letterboxes it 200px short) and every other fit is
   worse or identical, so the only lever was the drawn size.

   IT TURNED OUT THERE WAS NOTHING TO DRAW. All 22 backdrops in the catalogue are
   a SINGLE flat colour -- measured, every pixel of BG2-1 is rgb(107,124,56), and
   the one file with any variation at all (BG3-1) spans 2/255 across 5,104 of its
   409,600 pixels. So the 640x640 PNG was a 1.6 MB decode and a network request
   that painted one swatch, upscaled 2x, on the app's most-visited screen.

   data/hero-edge.js ALREADY HOLDS THAT COLOUR, baked, with saturate(0.92)
   applied -- which is why the filter is gone from here rather than moved. The
   table exists because a runtime canvas sample of this same art failed on Tom's
   phone; tests/hero-edge-audit.mjs recomputes every row off the PNGs and now
   also asserts the art is flat, so a future illustrated backdrop cannot land
   here silently as a swatch. paintHeroEdge() sets --hero-edge in the same
   synchronous render that writes this element, so there is no unpainted frame;
   --surface-2 is the fallback and is what .hero-scene already carries inline.
   Verified pixel-identical against the previous build at 375x667. */
.hero-backdrop { position: absolute; inset: 0; background: var(--hero-edge, var(--surface-2)); }
/* CENTRED and never anywhere else (Tom: "the bonehead should always be centred").
   Full width so `contain` centres him horizontally; bottom-aligned so his feet,
   not his box, sit on the line the pet also stands on. */
/* 352px of figure, bottom-aligned and centred, breathing on the same 4s clock as
   the pet. Shared phase on purpose: staggered timings read as drift, not life. */
/* The box is WIDER than the plate on purpose. avatarLayersHtml composes square
   640px canvases and object-fit: contain scales by that square, not by the ink,
   so a box narrower than it is tall makes the WIDTH the limit and the figure
   comes out short. Overflowing sideways lets the height rule, which is what gets
   the handoff's 352px of drawing; the plate clips the empty canvas either side. */
/* MEASURED, not eyeballed (tests/figure-audit.mjs reports all of these):
   bottom:0 buried 69px of him behind the caption and put his ink centre 104px
   below the art area's centre, which is what Tom saw as "look how low the
   bonehead is in the frame". His feet clear the caption's top edge by 10px now.
   The asymmetric left/right is not a typo: the ink inside the 640 canvas is NOT
   centred in it (bbox 94..509, centre 301 not 320), so a symmetric box hangs the
   drawing 17px to the left. This puts it on the card's centre line. */
/* THE FIGURE STARTS WHERE THE STATUS COLUMN ENDS, and that is a sum rather than
   a number so the plaque can never grow into his head: it is exactly the chips'
   top offset plus their height plus the gap (--gw-top) plus the plaque
   (--gw-h). This is also what closes the dead air over his skull. The scene had
   been taller than the figure could fill, so the top of the card was empty
   coral; the chips and Gwart occupy that band now and the figure sits under it
   rather than floating inside it. */
.hero-char { position: absolute; left: -16px; right: -34px;
  top: calc(var(--gw-top, 50px) + var(--gw-h, 90px) + var(--bleed, 0px)); bottom: 79px;
  z-index: 2; display: flex; align-items: flex-end; justify-content: center;
  animation: bhIdle 4s ease-in-out infinite;
  filter: drop-shadow(0 10px 12px rgba(0, 0, 0, .4)); }
/* THE SIDEWAYS SHIFT LIVES INSIDE THE KEYFRAMES, and it has to. bhIdle owns
   `transform`, so a static translateX on .hero-char is overwritten the moment
   the animation runs. --bh-shift defaults to 0, so every existing user of this
   animation is unchanged. */
@keyframes bhIdle { 0%, 100% { transform: translate(var(--bh-shift, 0px), 0); }
  50% { transform: translate(var(--bh-shift, 0px), -5px); } }
/* A BIG PET SHARES THE FRAME. Tom, 2026-08-21: "we need to start moving the
   bonehead off centre and to the left because it has to share the screen more
   with its pet." Set on .hero-char and NOT on the scene, because custom
   properties inherit and the companion runs the same animation: putting it on
   the parent would walk the pet left by the same amount.

   A FRACTION OF THE FIGURE, NOT A PIXEL COUNT, and the fraction is measured.
   Tom, on the build that shipped 58: "also my bonehead is kinda bigtime off
   centre now not like a little bit?", and on the 48 that replaced it: "alignment
   on today is still off now they're too split and bonehead weapon is off edge of
   screen." 48 absolute pixels is a sixth of a 300px figure and a ninth of a
   440px one, so the same declaration reads as a nudge on one phone and a shove
   on another.

   THE RE-MEASUREMENT THAT PRODUCED 0.0662. The v422 numbers were taken with a
   mask that counted a 1-to-3 pixel compositing hairline at the .hero-char box
   edge as ink, so "pair 8.5..380.5" was the pair's BOX, not its drawing, and the
   right-hand edge of it was a rounding artefact. Re-measured with a per-column
   density floor (a real drawing column scores 15 and up; the hairline scores 1
   to 3), the shipped v422 pair is 8.5..363.0 at 393x852: 8.5px of left gutter
   against 30px of right, and the weapon lives in that 8.5px. So the pair was
   never centred, it only measured that way.
     v422  48px      pair 8.5..363.0, off by -10.8px, gutters 8.5 / 30.0
     now   0.0662    pair ink centred with an even gutter at both edges
   0.0662 is 27px against the 408px figure a full-height phone draws, and 27 is
   what puts the pair's measured ink centre on the scene's centre line with the
   pet anchored where .hero-companion now anchors it.
   tests/hero-share-audit.mjs owns both halves: the pair reads centred, and
   neither drawing touches the frame.

   2026-10-02, SUPERSEDED BY TOM'S RULING, AND THE 0.0662 IS GONE. Tom, on the
   Bumbleseal options page: "tuck in front is best". The Bonehead no longer
   steps aside for a big pet: he holds the same position he has beside every
   other pet, and she is placed against HIM instead (the late block near the
   end of this file: `#bhStage.sharing > .hero-companion`). The shift was the
   cause of the weapon-on-the-edge reports (ink at x=0 at 390 and 430 with the
   worst outfit); with it removed his weapon sits 8px or more inside the frame.
   tests/hero-share-audit.mjs now asserts that he does NOT move. */
/* THE FIGURE STOPS GROWING WHERE CAM'S ART STOPS. The gear masters are 640px,
   and tests/art-resolution-audit.mjs caps any layer at 1.4x its source in
   PHYSICAL pixels: on a DPR-2 phone that is 448 CSS px, and past it the renderer
   is inventing detail nobody drew.
   THIS IS A WIDTH PROBLEM, NOT A HEIGHT ONE, and I checked the height first and
   was wrong. The layers are object-fit: contain inside .bh-anim, and the audit
   measures the ELEMENT box, so what matters is how wide .hero-char gets.
   Removing the playing-card frame made the scene full-bleed, which made the
   scene wider, which made this box wider: measured at 440x956, 490 CSS px and
   986 device px, 1.54x. Clean main sits at 1.39x, so this has always been one
   frame's worth of padding away from the ceiling.
   Capped, rather than shrinking the scene, so the layout the composition was
   centred against does not move. The freed width becomes air instead of blur.
   444 AND NOT 448, which is the arithmetic answer: bhIdle rotates the box, and a
   rotated rect measures wider than its own width. At the arithmetic cap the
   rendered box came back 451 and the row read 1.41x. Three px of the animation's
   own growth, given back.
   Raising this needs bigger masters, not a bigger number. */
/* AND margin-inline: auto IS HALF OF WHAT TOM REPORTED. An absolutely positioned
   box with left, right AND a constrained width is over-constrained, and CSS
   resolves that by DROPPING `right` (in a left-to-right document) unless the
   margins are auto. So the cap did not centre the figure at 444, it LEFT-ANCHORED
   him: measured, at 430x932 his ink sat at 8.5..281.5 with the scene's centre at
   215, i.e. 70px to the left, identical in absolute terms to where it sits at
   393 wide. That is exactly "bonehead weapon is off edge of screen", and it only
   happens on a phone wider than 394px, which is every Plus and Pro Max.
   `margin-inline: auto` is the documented resolution for the over-constraint and
   puts the capped box back on the centre line. */
.hero-char .bh-anim { --av-fit: contain; --av-pos: bottom center; max-width: 444px; margin-inline: auto; }
.hero-char .bh-anim img { object-fit: contain; object-position: bottom center; }
/* equipped pet trots along beside the bonehead on the home screen */
/* width auto so a long creature (lizard) can be scaled up for equal visual mass
   without being clipped; static pets are still pinned to 98 by the rule below. */
/* bottom tuned so the pet's INK baseline matches the Bonehead's, not so its box
   does: the art carries transparent padding and the stage contains it, so equal
   box offsets left the pet floating 15px high. tests/figure-audit.mjs measures it. */
/* RIGHT THIRD, outer edge on the safe margin, baseline shared with the feet.
   The exact bottom offset is tuned against the MEASURED ink baselines, not the
   boxes: tests/figure-audit.mjs reports both and fails if they drift. */
/* SHE IS ANCHORED TO THE CENTRE LINE, NOT TO THE PHONE'S EDGE, and that is the
   "too split" half of Tom's 2026-08-21 report. A gutter measured from the frame
   ties the pet to the viewport while the Bonehead is tied to the centre, so every
   extra pixel of screen width moves her a whole pixel away from him and moves him
   only half of one. Measured on v422: at 393 wide their drawings sit 113px apart
   centre to centre, at 430 wide 141px, off one declaration.
   So her OUTER edge is placed at 0.4473 of the figure's drawn side out from the
   centre line, which reproduces the approved 393x852 composition exactly
   (0.4473 x 408 = 182.5px out, i.e. the same 14px gutter) and keeps the pair
   rigid at every width. The 14px floor is the safe margin and it is a floor, not
   the anchor: on a narrow-and-tall phone the tension point would fall outside the
   frame, and a drawing off the edge is wrong on any screen (Tom, 2026-08-07, on
   the duck: "the duck and lizard are cut off that cant be happening"). */
.hero-companion { position: absolute; right: max(14px, calc(50% - var(--fig) * 0.4473)); bottom: 92px;
  /* WIDTH AUTO, anchored by its right edge. A fixed width made the box grow
     RIGHTWARD when mass scaling widened a species (the duck goes 108 -> 154px),
     so it ran 14px past the plate and got clipped: Tom, 2026-08-07, "the duck and
     lizard are cut off that cant be happening". Shrink-wrapped, a wide pet grows
     LEFT into the frame instead of off the edge. */
  width: auto; height: auto; z-index: 3; padding: 0; background: none; border: 0;
  display: flex; justify-content: flex-end;
  filter: drop-shadow(0 4px 6px rgba(0, 0, 0, .45));
  animation: bhIdle 4s ease-in-out infinite; }
.hero-companion.pop { animation: bhIdle 4s ease-in-out infinite, petPop .5s cubic-bezier(.34, 1.56, .64, 1); }
@keyframes petPop { 0% { scale: 1; rotate: 0deg; } 30% { scale: 1.09; rotate: -4deg; }
  60% { scale: 1.03; rotate: 3deg; } 100% { scale: 1; rotate: 0deg; } }
/* let the cropped/scaled content size itself; forcing 98 squashed flat pets */
.hero-companion .petcrop { margin-inline: auto; }
/* AND A BIG PET IS SIZED AGAINST THE BONEHEAD, NOT AGAINST THE DEVICE. Tom's
   ruling was a RATIO: Bumbleseal should stand "about 25% bigger" than the 135px
   he was shown, which is 169px beside the 408px figure a full-height phone draws.
   Shipped as 169 ABSOLUTE pixels it stopped being that ruling the moment the
   figure changed size: on a 375x667 phone the Bonehead is drawn at 258px and she
   was still 169, two thirds of his height, and on the 320x568 worst case she was
   169 against a 176px figure. tests/hero-share-audit.mjs said so itself and
   called the fix "a change to PET_HERO_PX"; this is that change.
   --pet-rel is her share of the figure, handed down from PET_HERO_REL in
   data/boneheadz.js (times the species' mass scale) so the catalogue still owns
   the number. croppedPetImg lays every layer out in PERCENTAGES of this box, so
   sizing the box is all it takes; nothing inside needs to know.
   !important, and it is earning it rather than papering over a specificity
   fight: croppedPetImg writes the box size as an INLINE style, because every
   other surface in the app asks for a pet in pixels, and an inline declaration
   outranks any selector. This is the one surface that measures the box in
   Boneheads instead, so it overrides that default here and nowhere else. */
.hero-companion.big .petcrop {
  width: calc(var(--fig) * var(--pet-rel)) !important;
  height: calc(var(--fig) * var(--pet-rel)) !important;
}
@keyframes petBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) { .hero-companion { animation: none; } }
/* The only ground cue. A floor band was tried in the v2 mockup and Tom killed it
   ("that floot mockup looks ugly it better not be in the final art"); two soft
   contact shadows carry the shared plane on their own. */
.hero-cast { position: absolute; border-radius: 50%; pointer-events: none; }
/* MEASURED, NOT EYEBALLED (figure contract rule 3: align on INK, not boxes).
   Tom, 2026-08-08: "I just said close the shoe to shadow gap to make it look
   more real." Only the POSITION changes here; size and opacity are untouched.
   Measured by diffing rendered frames (character-only vs shadow-only against an
   empty plate) with the drop-shadow filter disabled, so the blur could not be
   counted as ink:
     soles      105px from the plate bottom, centred at 45.0% of plate width
     was        bottom 88 + height 18 -> centre 97, i.e. 8px BELOW the soles, and
                left:50% put it 5% right of the shoes because it was centred on
                the SCENE while the figure is not (asymmetric box, and Cam's ink
                is off-centre inside its own 640 canvas)
     now        centre lands on 105, dead on the sole line
   Verified after the change: within 1px vertically, 0.5px horizontally. */
.hero-cast.c-bh { left: 45%; transform: translateX(-50%); bottom: 93px; width: 46%; height: 18px; z-index: 1;
  background: radial-gradient(ellipse at 50% 50%, rgba(30, 33, 26, .55) 0%, rgba(30, 33, 26, 0) 70%); }
/* No c-pet rule: Tom, 2026-08-08 "cloud pet already has its own shadow in the OG
   art". Cam drew one, so adding a second stacked two shadows under one pet.
   The element is no longer emitted at all (see renderToday). */
.hero-yard {
  position: absolute; left: 3%; bottom: 10%; width: clamp(64px, 21%, 104px);
  z-index: 1; pointer-events: none; filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.4));
}
.yard-decor { position: absolute; left: 4%; bottom: 6%; width: 24%; z-index: 3; pointer-events: none; }

/* ---------- bone road ---------- */
.road-strip {
  display: flex; align-items: center; gap: 12px; margin-bottom: 12px;
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 11px 13px;
}
.road-strip b { display: block; font-size: var(--fs-3); }
.road-strip small { color: var(--text-3); font-size: var(--fs-1); display: block; }
.road-cycle { font-family: var(--display); font-size: var(--fs-5); color: var(--carbs); letter-spacing: 1px; flex: none; }
.road-wrap { position: relative; border-radius: 18px; overflow: hidden; border: 1px solid var(--line-strong); background: #131118; }
.road-map { display: block; width: 100%; height: auto; opacity: 0.94; }
.road-stop {
  position: absolute; transform: translate(-50%, -50%); z-index: 2;
  width: 40px; height: 40px; border-radius: 13px; display: grid; place-items: center;
  background: rgba(13, 12, 18, 0.78); border: 2px solid var(--line-strong);
  color: var(--accent); font-weight: 800; font-size: var(--fs-3);
}
.road-stop.ready { border-color: rgba(165, 232, 71, 0.7); --crate-pulse-duration: 1.4s; }
.road-stop.claimed { color: var(--accent); opacity: 0.75; }
.road-stop.locked { opacity: 0.42; filter: grayscale(0.6); }
.road-you {
  position: absolute; transform: translate(-50%, -90%); z-index: 3;
  width: 46px; height: 46px; border-radius: 50%; overflow: hidden;
  border: 2px solid var(--accent); background: var(--surface-2);
  box-shadow: 0 0 12px rgba(165, 232, 71, 0.45); pointer-events: none;
}
.road-you .bh-anim { animation-duration: 2.4s; }

/* ---------- the pit ---------- */
.bar.pitstat i { background: var(--accent); }
.fight-stage { display: grid; gap: 10px; }
.foe-side, .you-side { display: flex; gap: 12px; align-items: center; }
.you-side { flex-direction: row; }
.bh-stage.fsmall { width: 86px; min-height: 86px; height: 86px; flex: none; border-radius: 14px; }
.bh-stage.fsmall .bh-anim { animation-duration: 2.6s; }
.fbars { flex: 1; min-width: 0; display: grid; gap: 4px; }
.fname { font-family: var(--display); font-size: var(--fs-body); letter-spacing: 0.5px; display: flex; gap: 8px; align-items: center; }
.fstate { font-family: -apple-system, system-ui, sans-serif; font-size: var(--fs-tiny); font-weight: 800; letter-spacing: 0.06em; color: var(--protein); border: 1px solid rgba(124, 196, 255, 0.4); padding: 2px 6px; border-radius: 6px; }
.fstate.stag { color: var(--amber); border-color: rgba(255, 180, 84, 0.4); }
.frow { display: flex; justify-content: space-between; font-size: var(--fs-0); color: var(--text-3); font-weight: 700; font-variant-numeric: tabular-nums; }
.bar.fhp { height: 9px; } .bar.fhp i { background: var(--accent); }
.bar.fwind { height: 5px; } .bar.fwind i { background: var(--protein); }
.bar.fhype { height: 5px; } .bar.fhype i { background: var(--carbs); }
.fight-log { min-height: 34px; text-align: center; font-size: var(--fs-2); font-weight: 600; color: var(--text-2); padding: 0 6px; }
/* COLUMN GAP 6, ROW GAP 8. The row gap is load-bearing for the three-row
   arithmetic below and does not move; the column gap is only separation between
   two bordered boxes and 2px of it is worth more as text width. Measured at
   393x852: 361px of tray, so 8px gaps give a 115px button and 6px gaps give
   116.33, which is 1.33px of hint width per button, and the hints below are
   losing by single digits. */
.fight-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 6px; margin-top: 12px; }
/* TIGHTER ROWS, the other half of Tom's 2026-08-13 call. 11/9 padding made a
   ten-button tray 317px on a 393x852 phone and left the boss 34% of the screen.
   8/7 plus a 44px floor keeps every button a legal tap target while giving the
   arena back the room the art actually needs (the Live Wire plate is 292px and
   its container had shrunk to 289px). Shrinking type was NOT the lever: this
   app already has a standing complaint about tiles too small to judge. */
.fight-act {
  /* iOS FIRES ITS OWN LONG-PRESS ON TOP OF OURS. v401 gave these buttons a
     750ms hold to open a move's detail; on a real iPhone that same hold also
     raises the text-selection magnifier and the callout menu, so Tom got a
     loupe and highlighted words over the popup he asked for.
     This is not a new idea: .map-den-mark / .map-mini-mark / .map-spawn already
     carry exactly these three lines, because the Boneyard map is the app's OTHER
     long-press and hit this first. The Pit inherited the gesture without
     inheriting the suppression. Any future long-press target needs this too.
     Reported by Tom 2026-08-18: "it's magnifying with force click and
     highlighting text on the iphone can we turn that iphone feature off?" */
  -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;
  min-height: 44px;
  /* 8/7 VERTICAL IS UNTOUCHED, 5 SIDE BECOMES 3. The v383 note above is about
     the vertical squeeze and about type size, and both stay exactly as they
     are. Side padding is the opposite lever: every pixel taken off it is a
     pixel the hint gets to use, and the hint running out of width is what put
     a second line under half these buttons. 101px of text box at 393x852
     becomes 106.3 with this and the column gap together. */
  display: grid; gap: 2px; justify-items: center; padding: 8px 3px 7px;
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm);
  position: relative;   /* containing block for small.cost below */
}
.fight-act b { font-family: var(--display); font-weight: 400; font-size: var(--fs-3); letter-spacing: 0.6px; }
.fight-act small { font-size: var(--fs-tiny); color: var(--text-3); font-weight: 700; }
/* QA round 28 P2, corrected 2026-09-04: a real <small class="cost"> element
   (fight-hint-audit selects `small.cost`, fight-press-audit relies on it being
   a SEPARATE element from the hint's own <small> so the hint's textContent
   never carries the cost). ea987dd7 first added it as a plain grid sibling,
   which .fight-act's `display:grid` auto-placed as a third implicit row and
   cost ~14px of extra button height, failing fight-layout-audit's ROWS check
   (2 of 3 rows fit a 188px tray instead of 3). 190b5eb1 "fixed" that by
   deleting the element and concatenating its text onto the hint's <small>
   instead, which wrapped the hint to two lines at 375/393 and made the
   Bone Guard hint (fight-press-audit) always carry "Stamina" even when the
   move's own facts had already dropped it to fit.
   The actual fix is to keep it a real, separate element but take it OUT of
   the grid's row flow: absolutely positioned in the corner, it costs the
   button zero extra height because grid never sees it as a row. */
.fight-act small.cost {
  /* top: -3px (2026-09-06, was 2px). Even a single-line cost's line box ran
     3.6px into the label's box below it (measured at 393x852 and 375x667):
     harmless there because a font's line-height leaves empty space the ink
     never reaches, but it is exactly what let a two-line cost (Bone Guard,
     the only move with a three-clause cost) paint straight over "BONE GUARD".
     Nudging the pill up clears the label's box on every button, not just the
     one that happened to wrap. Purely a repaint of an out-of-flow element:
     costs nothing in row height. */
  position: absolute; top: -3px; right: 3px; max-width: calc(100% - 8px);
  font-size: var(--fs-micro); line-height: 1.2; text-align: right; color: var(--text-3);
  font-weight: 700; pointer-events: none;
}
.fight-act:disabled { opacity: 0.35; }
.fight-act.endturn { background: var(--accent); border-color: var(--ink); }
.fight-act.endturn b, .fight-act.endturn small { color: var(--accent-ink); }
.shake { animation: fshake 0.4s ease; }
@keyframes fshake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-5px) rotate(-1deg); } 60% { transform: translateX(4px) rotate(1deg); } }
.fight-over { text-align: center; padding: 18px 0 8px; }

/* ---------- the pit: arena ---------- */
/* THE ARENA TAKES THE SLACK. Tom, 2026-08-09: "we currently arent using the
   veritcal aspect ratio of the game well in this instance."
   Measured before: on a 430x932 phone the arena was 258px and 388px under the
   buttons was dead, so 42% of the screen was empty while a boss got clipped.
   The fight body is a column and the arena claims whatever the HUD, the log line
   and the buttons do not. Same shape as .sheet-body.map-sheet, which already
   solves this for the map.
   The fighters DELIBERATELY do not grow with it. I tried that and two columns
   collided at 216px each in a 398px-wide arena. The space above them is not
   waste: it is where a boss stands and where every spell happens, which is the
   whole reason a Wraith at 212x190 was getting its hood cut off. */
/* THE FIGHT SCREEN RESPECTS THE HOME INDICATOR LIKE EVERY OTHER SHEET.
   Tom, 2026-08-18, on an iPhone 17 Pro Max: "you stil lcant ge three rows of
   buttons fully in the frame with your pit fix ? the fighting area is still the
   same height". Part of why he saw something the emulator did not is here.
   This padding used to live as an INLINE style="padding-bottom:10px" on
   #fightBody in js/app.js. An inline declaration beats the stylesheet, so the
   fight body was the only surface in the app that threw away
   `calc(var(--sab) + 20px)` from .sheet-body, and --sab is
   env(safe-area-inset-bottom). native/ios/App/App/capacitor.config.json sets
   "contentInset": "never", so env() is the ONLY thing holding content off the
   home indicator, and this screen had opted out of it. Measured with --sab
   forced to the 34px a Pro Max reports: End Turn's row ran 24px into the
   indicator band, on the one screen where the primary action is pressed every
   turn. Puppeteer models no safe area at all, which is exactly why three
   previous measurements of this screen said the tray was fine.
   It is 10px + the inset rather than the sheet's own 20px + inset ON PURPOSE:
   the 183px arena floor below was derived arithmetically from a 10px bottom
   pad (see the derivation in the arena note), and 375x667 and 320x568 report no
   inset at all, so writing it this way keeps both short phones byte-for-byte
   identical to what shipped while giving inset phones the 34px they were owed.
   Measured after: 120px of tray at 375x667 and 96px at 320x568, the same two
   numbers as before the change. */
.fight-body { flex: 1; min-height: 0; display: flex; flex-direction: column; padding-bottom: calc(var(--sab) + 10px); }
/* THE ARENA IS THE FIXED PART NOW, THE TRAY IS THE ELASTIC PART.
   Tom, 2026-08-15: "I don't like how the actual fighting section moved up and
   down based on how many buttons there are ... the fighters portion should be
   static in size and the buttons below can change as needed to fit on screen."
   It was the exact opposite before: the arena was flex:1 1 0 and the tray took
   its natural height, so every row the tray gained came straight out of the
   boss. Measured on origin/main: opening the ITEMS door shrank the arena by
   228px at 430x932 and 180px at 390x844 (it only held still at 375x667 because
   it was already pinned at its floor).
   Height is derived from the viewport, never from the tray, so it is the same
   number on every turn of the fight. The 400px subtrahend is the rest of the
   fight (sheet header 104 + meta 64 + End Turn row 64 + a ~170 tray). 440 was
   tried first and left the mage plate only 2px clear of the HUD at 390x844;
   400 restores 42px there and still leaves the tray more room than it needs
   for its tallest state, the open ITEMS door.
   258 WAS A NUMBER THE ART OUTGREW: the mage plate renders 292px tall, so that
   is the floor, not a nicety. */
/* The subtrahend grew by the HUD's 92.4px because the HUD is a sibling row now
   rather than a lid, so it costs its own height instead of eating the arena's.
   400 + 92 = 492, and then 504, because 492 was not quite right and the reason
   is worth keeping. At 492 a 390x844 phone kept 352 of scene against the 349.6
   it had before, a wash as intended, but the tray lost 11px and 11px crossed a
   GRID ROW: the resting move buttons went from 6 to 3, with 5 of 8 below the
   fold, on the commonest phone in the set. A row of controls is worth more than
   12px of empty arena above a boss who already fits, so the arena gives it
   back. 340 at 390x844 and 428 at 430x932, and the boss can afford both: he is
   262 tall plus a 16 to 30px lift. At 375x667 this changes nothing whatsoever,
   because the arena is pinned to its floor there and the subtrahend never
   binds. The short phone is where the move pays. The 292 floor was never 292 of scene, it was 292 minus the HUD,
   so 197.6. The floor is 183 of REAL scene now, and it is derived rather than
   picked: a 375x667 phone gives the fight body 564.5px, and the tray's tallest
   honest state needs 152.3 of it, so
   564.5 - 94.4 hud - 8 - 64 meta - 152.3 tray - 8 - 54.8 endrow = 183.
   That is 14.6px less box than the 197.6 of scene the old arena left, and the
   boss still gains, because 183 of arena the figure can stand in beats 197.6
   with 110.4 of the figure behind the bars. */
/* 504, not 492: that is #28's short-phone value, and it wins. The vh line is
   the dvh branch's point, kept: a browser that does not parse svh drops the
   declaration entirely and the arena falls back to its intrinsic height. */
/* 552, not 504. Tom, 2026-08-18: "it is too much scrolling right now we need to
   decrease the height of the fighting section and give the buttons room to
   breathe". Measured on a LOADED tray (a caster with three move talents plus
   brewed potions: 9 buttons, the same case the ITEMS-door note above measured),
   because that is the state the complaint comes from. A bare four-move tray does
   not overflow at any viewport and would have graded this screen healthy.
     504: 430x932 shows 6 of 9 moves, 83px hidden; 393x852 shows 6 of 9, 110px.
     552: 430x932 shows 8 of 9, 35px hidden; 393x852 shows 8 of 9, 62px.
   The tray's height is exactly this subtrahend minus 343.6 whatever the phone
   is, because the arena eats the rest of the viewport: 160.4px at 504, 208.4px
   at 552. So the subtrahend IS the tray, and nothing else in this rule set is.
   The ceiling is fight-layout-audit's "the fight picture uses at least half the
   screen": arena + hud >= 50% of 932, which puts the arena above 367 and the
   subtrahend at or below 564. 552 sits 13px inside that rather than 5, and 564
   buys 12px of tray with no extra button visible, so it is not worth the margin.
   WHAT THIS DOES NOT FIX: at 375x667 and 320x568 the 183px floor already binds
   (667 - 552 = 115, well under 183), so those phones are byte-for-byte unchanged
   and still show 3 of 9. The short phone needs a different lever, not this one. */
/* 230, AND THE SUBTRAHEND STOPS BEING THE LEVER. Tom, 2026-08-18, third report,
   on an iPhone 17 Pro Max: "you stil lcant ge three rows of buttons fully in
   the frame with your pit fix ? the fighting area is still the same height".
   Both halves of that sentence were literally true, and the note directly above
   states the mechanism without noticing it is the bug: "the tray's height is
   exactly this subtrahend minus 343.6 whatever the phone is, because the arena
   eats the rest of the viewport." A subtrahend rule means the tray is a CONSTANT
   and every pixel a bigger phone brings goes to the boss. Measured on v400 with
   a loaded 9-button tray: the tray is the same 149px at 393x852, at 402x874 AND
   at 440x956 while the arena grows 300 -> 322 -> 404. Buying a bigger phone
   bought Tom nothing, which is precisely what he is describing.
   So the relationship is inverted: the arena gets a CAP and the tray takes the
   slack. Same shape Tom asked for on 2026-08-15 (a static fighting area, an
   elastic tray), just with the arena no longer defined as "everything left
   over".

   AND --sat, WHICH WAS DOUBLE-CHARGED TO THE TRAY. The arena was derived from
   the raw viewport while it lives inside `.sheet.full`, which is
   `calc(100dvh - var(--sat) - 24px)`. So on any phone with a Dynamic Island the
   arena claimed 59px the sheet had already given away, and the tray, being the
   leftover, paid for it twice. Subtracting --sat here puts the arena on the same
   ruler as the box it stands in: the expression now reads "the sheet's height,
   less 528". That alone is worth 59px of tray on every notched iPhone and is a
   correctness fix independent of the cap.

   WHY 230 AND NOT A ROUNDER NUMBER. It is derived from the SMALLEST Dynamic
   Island iPhone, 393x852, because that is where three rows is hardest. With
   both insets honoured that phone has 415px to divide between arena and tray,
   and three rows of moves need 3 * 54.6 + 2 * 8 = 179.8px. 415 - 230 = 185px of
   tray, so the third row lands 5px inside the box with the scroll fade grazing
   the bottom of its subtitle only. Verified by rendering, not by arithmetic:
   scratchpad shots at 230 and at 210 were compared side by side and 210 bought
   a sliver of the ITEMS door at the cost of 20px of boss, which is not a trade
   worth making. Measured with --sat 59 / --sab 34 forced, an EMULATION of a
   real Pro Max, since puppeteer reports no insets:
     440x956  arena 404 -> 230, tray 149 -> 289, and the whole 243px tray fits
              with no scrolling at all
     402x874  arena 322 -> 230, tray 149 -> 207, three full rows
     393x852  arena 300 -> 230, tray 149 -> 185, three full rows
     375x667  arena 183, tray 120, UNCHANGED, the floor binds
     320x568  arena 183, tray  96, UNCHANGED, the floor binds
   The tray is now monotonic in phone height (289 > 207 > 185 > 120 > 96), which
   is the property that was missing.

   THIS NUMBER ASSUMES A ONE-LINE HINT. A move whose <small> wraps takes the row
   from 54.6px to 68.3px, and three of those need 220.9px, which no cap this side
   of the 183 floor can pay for at 393x852. That is not this rule's job to
   absorb: tests/fight-hint-audit.mjs is the guard that keeps hints on one line,
   and if it goes red this cap is not the thing to change.

   THE FLOOR AND THE MIDDLE TERM ARE BOTH STILL LOAD-BEARING. 183 is the derived
   floor from the note above and is what 375x667 and 320x568 get, unchanged. The
   middle term now only binds between roughly 757 and 805 CSS px of usable
   height, a narrow band, but it is what hands a short phone back to the floor
   smoothly instead of stepping, and it keeps the arena honest on a tablet or in
   landscape where a flat 230 would be arbitrary.
   The vh line stays ahead of the svh line for the same reason it always has: a
   browser that cannot parse svh drops that declaration and falls back. */
/* +100px, RECLAIMED, NOT INVENTED: the HUD row measured 92.4px tall plus its
   8px bottom margin, and it is out of flow now, so the reserve drops 552 -> 452
   and both clamp ends rise by the same 100. The floor stays the floor Tom's
   three-rows-of-buttons fix bought (183 -> 283 is the same flow height). */
.fight-body > .arena { flex: none; height: clamp(283px, calc(100vh - var(--sat) - 452px), 330px); }
/* @SUPPORTS, NOT A SECOND DECLARATION, AND THIS RULE ONLY NEEDED IT TODAY.
   The old arena height was `calc(100svh - 552px)` with no var() in it, so the
   conventional two-declaration fallback worked: an old browser cannot parse svh,
   drops that declaration at PARSE time, and keeps the vh one above it.
   Subtracting var(--sat) to stop the arena double-counting the top inset changed
   the shape into the blind spot documented at the .sheet rules above: a value
   containing var() is assumed valid at parse time and only fails at
   COMPUTED-VALUE time, which per CSS Variables makes the declaration UNSET
   rather than ignored, and the earlier declaration is discarded with it. So the
   two-line form would have left the arena with no height at all on any browser
   without svh, which is worse than the layout it replaced.
   Caught by tests/dvh-fallback-audit.mjs on the first gate run after that guard
   was unblocked. It is the reason this pattern is a condition and not a pair. */
@supports (height: 100svh) {
  .fight-body > .arena { height: clamp(283px, calc(100svh - var(--sat) - 452px), 330px); }
}
.fight-body > .fight-meta,
.fight-body > .fight-endrow { flex: none; }
/* The tray is what absorbs the variation: it always gets the same leftover box
   and scrolls inside it, so a four-move turn, a ten-row spell list and the open
   ITEMS door all leave End Turn in one place and the boss at one size.
   Tom, 2026-08-09: "the buttons were moving up and down based on the menu". */
.fight-body > .fight-actions { flex: 1 1 0; min-height: 96px; }
/* ROWS SIZE TO THEIR CONTENT, ALWAYS. This is the v383 regression Tom hit:
   "the buttons now have text falling off and are hard to see and press".
   Mechanism, measured against the commit before the arena change (cc3a9d6 vs
   b5dd3af): a grid auto row takes its BASE size from min-content and only grows
   toward max-content if the container has room. A <button> carrying
   min-height:44px contributes 44px of min-content. The old build put an inline
   height:284px on this tray, above its 202px of content, so every row grew to
   68.3px and nothing clipped. Making the tray flex:1 1 0 lands it at 159.2px,
   BELOW the 202px it needs, so no row grew past 44 and every two-line subtitle
   spilled 17px, 13.8px of it painted ink outside the button's own border box.
   Measured on 4 of 9 buttons at 390x844 and 430x932, 6 of 9 at 375x667.
   max-content sets both the base AND the growth limit from the content, so the
   row fits the text whatever height the tray gets, and the overflow lands where
   it belongs: on the tray, which scrolls. */
.fight-actions { overflow-y: auto; align-content: start; grid-auto-rows: max-content; }

/* AND THE SCROLL HAS TO BE VISIBLE. The tray hid 43px of content at 390x844 and
   68px at 375x667 with NOTHING to say so: offsetWidth - clientWidth measured 0
   (macOS overlay scrollbars), no mask, no shadow, no gutter. The ITEMS door sat
   in that hidden band, and all nine hit probes on it answered #endTurn or the
   sheet body, at every viewport. A control you cannot see, cannot reach, and are
   never told about is not a control.
   The fade is driven by a class the render sets, not by :hover or a media query,
   so it is honest on a touch screen. */
.fight-actions.scrolls { mask-image: linear-gradient(180deg, #000 calc(100% - 26px), transparent); -webkit-mask-image: linear-gradient(180deg, #000 calc(100% - 26px), transparent); }
.fight-actions.scrolls.at-end { mask-image: none; -webkit-mask-image: none; }
.fight-endrow { margin-top: 8px; }
.fight-endrow .fight-act { width: 100%; }

/* ---- SHORT PHONES: PIN THE PRIMARY ACTION, DO NOT SHRINK THE FIGHT ----
   Measured at 320x568 before this rule, on a real den fight:
     .fight-body is 466px tall and holds 520px of content
     End Turn's row renders at 557.9 to 612.6, so 44.6px below a 568 fold
   The reported bug was "End Turn renders off screen". It is worth being precise
   about what that turned out to be, because it changes the fix: .fight-body is
   already overflow-y:auto with 54px of scroll range, and setting scrollTop to
   its maximum brings End Turn to 503.9-558.6, fully on screen. So the button was
   never unreachable. It was BELOW THE FOLD ON FIRST PAINT with nothing saying
   so, which on the one screen where a player is taking turns is worse than it
   sounds: the primary action of the whole screen requires a hunt.
   The alternative was to shrink the arena floor from 183 to 129 on these phones,
   which fixes the symptom by making the fighters small on exactly the devices
   with the least room to lose. Tom's call, 2026-08-16: pin instead.
   So the row sticks to the bottom of the scroller. Nothing shrinks, the boss
   keeps his 183, and the arena and tray scroll behind a control that is always
   there. 600px is the same breakpoint the discarded shrink used. */
@media (max-height: 600px) {
  .fight-body { position: relative; overflow-y: auto; }
  /* R43-6: THE FOURTH MOVE HAS TO BE PRESSABLE WHERE IT SITS.
     Measured at 320x568 on a real den fight, before this, every number off the
     render: .fight-body is 465.5px and its parts are arena 283 + meta 52 +
     tray(margin 12) + endrow(margin 8, 58.8) + 10px pad, which leaves the tray
     41.7px and its own floor then forced it to 96 and overflowed the column.
     The four moves are two 68.3px rows, 145px of content, so Bone Guard
     rendered at 527.8 to 596 and the tray clipped at 547.5: its centre hit
     div#fightBody, a real click there did nothing, and the log still read
     "Round one. Your turn." The sticky End Turn row (499.3 to 558) was over the
     rest of it. Both larger viewports hand the tray its full 143px and all four
     moves answer at rest.
     TWO FULL ROWS ARE NOT AFFORDABLE HERE and this is the one place the
     2026-08-16 "pin, do not shrink" call has to bend: 145px of tray needs 105px
     from somewhere and the only line item that size is the arena. Everything
     else is squeezed first (meta margins 20 -> 8, the two row margins 20 -> 14)
     and the arena gives the remaining 63, 283 -> 220, at this breakpoint only.
     That buys a 122px tray: row one whole, row two exposed 46px, above the 40px
     tap floor, with its centre inside the tray, so ALL FOUR MOVES ARE HIT AT
     REST at 320x568. The tray still scrolls for the last 23px of row two and
     still draws its fade, and the type, the hints and the button sizes are
     untouched. The figures scale with the arena by design (container-type:size
     above), so nothing needs re-derivation. */
  .fight-body > .arena { height: 220px; }
  .fight-body > .fight-meta { margin: 6px 2px 4px; /* 6 top clears the arena's 5px --sh shadow */ }
  .fight-body > .fight-actions { margin-top: 8px; min-height: 0; }
  .fight-body > .fight-endrow { margin-top: 6px; }
  /* Snap so a rest position is always a whole row: the tray can still only show
     one and a half, and stopping between them is what put a button's centre in
     dead space in the first place. */
  .fight-actions { scroll-snap-type: y proximity; }
  .fight-act { scroll-snap-align: start; }
  .fight-body > .fight-endrow {
    position: sticky; bottom: 0; z-index: 4;
    /* opaque, or the tray scrolls through the button it is supposed to sit under */
    background: var(--bg);
    padding-bottom: max(4px, env(safe-area-inset-bottom));
    box-shadow: 0 -8px 10px -6px rgba(0, 0, 0, .55);
  }
}
/* container-type:size is what makes the cqh units below real. The figure rules
   have said "scales with the arena" since 2026-08-09, but nothing in this sheet
   ever declared a query container, so cqh silently resolved against the small
   viewport instead. Measured proof it was a no-op: .fstage came out 150px at
   390x844, 375x667 AND 430x932, three different arena heights, because 24cqh of
   the viewport clears the 150px cap at every one of them. The arena is the
   container now, so the fighters answer to the box they stand in.
   One precision note, because the numbers in the rules below are derived from
   this and someone will re-derive them later: container-type:size resolves cqh
   against the CONTENT box, and .arena carries a 2px border, so a 352px arena
   offers 348px of cqh. The figures therefore land a fraction under their caps,
   149.625px rather than 150 and 261px rather than 262, not exactly on them.
   Measured, not assumed. */
.arena {
  container-type: size;
  position: relative; height: 258px; border-radius: var(--radius); overflow: hidden;
  background: radial-gradient(ellipse at 50% 120%, #1d1a14 0%, #14120e 45%, #0e0d12 100%);
  border: 2px solid var(--ink); box-shadow: var(--sh);
}
.arena-floor {
  position: absolute; left: -10%; right: -10%; bottom: 26px; height: 3px;
  background: rgba(242, 233, 215, 0.12); border-radius: 50%;
  box-shadow: 0 10px 30px 14px rgba(0, 0, 0, 0.5);
}
.arena.quake { animation: quake 0.4s ease; }
@keyframes quake { 0%,100% { transform: translate(0,0); } 25% { transform: translate(-5px,2px); } 50% { transform: translate(4px,-2px); } 75% { transform: translate(-3px,1px); } }
.fighterG { z-index: 2; 
  position: absolute; bottom: 14px; width: 128px;
  transition: left 0.5s cubic-bezier(0.22, 1, 0.36, 1), right 0.5s cubic-bezier(0.22, 1, 0.36, 1);
  display: grid; gap: 6px; justify-items: center;
}
/* fighting-game HUD: compact bars in the arena's top corners, hard center gap */
/* A LID ON THE ARENA, AND NOT A CARD. It sat here before with an opaque
   background, which meant it did not sit beside the scene, it sat ON it:
   measured at 375x667 against a mage den, 92.4px of HUD over a 292px arena left
   197.6px of scene and rendered 110.4px of the 262px boss behind the bars. That
   was the PLATE's fault, not the position's. Tom, 2026-08-20, wants the scene
   back: "the health bars go on top of the background? then we get more room in
   the space for the immersive fighting sequences". So the surface, the border
   and the card radius are gone and the bars float on the backdrop the way a
   fighting game puts them there; the arena keeps the ~100px the row used to
   spend. Legibility comes from a drop shadow, not from a fill, because a fill is
   what stole the picture last time. */
.fight-hud {
  position: absolute; top: 0; left: 0; right: 0; z-index: 5;
  display: flex; align-items: flex-start; justify-content: space-between; gap: 10px;
  padding: 8px 8px 0; pointer-events: none;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .85));
}
/* min-width:0 is load-bearing: without it a long foe name sets the flex floor,
   both sides sum past 100% and the arena's overflow:hidden eats the right HUD */
/* EQUAL SIDES. Widening the foe's whole column to 58% fixed the truncated name
   and broke something more important: Tom, 2026-08-07, "why is the health bar of
   the enemy so much bigger when fighting in the pit". Two health bars of
   different lengths read as two different amounts of health, which is a lie the
   HUD should never tell. So the columns are level again and only the NAME plate
   is allowed extra room: it is right-aligned, so a wider max-width lets it reach
   LEFT into the centre gap without moving a single bar. */
.hud-side { flex: 1; min-width: 0; max-width: 46%; display: grid; gap: 4px; }
/* fixed-height single line so BOTH HP bars sit on the same guide regardless of
   name length (a long foe name like "Loose Bones" used to wrap taller and drop
   its bar below yours) */
.hud-side .fname {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-2); letter-spacing: .04em;
  color: var(--text); background: var(--surface-2);
  border: 2px solid var(--ink); border-radius: 8px; box-shadow: var(--sh-sm);
  padding: 1px 8px; display: inline-block; justify-self: start;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* the plate may overhang its column into the dead centre of the bar; the bars
   underneath it do not move, so the two sides still read as equal */
.hud-side.foe .fname { max-width: 148%; }
.hud-side.foe .fname { justify-self: end; }
.hud-side.foe .fname { text-align: right; }
.hud-side .bar.fhp { height: 11px; border: 2px solid var(--ink); border-radius: 999px; }
.hud-pet { display: flex; align-items: center; gap: 5px; width: 72%; margin-top: 2px; transition: opacity .4s, filter .4s; }
.hud-side.foe .hud-pet { margin-left: auto; flex-direction: row-reverse; }
.hud-pet .petname {
  max-width: 46%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: var(--fs-0); font-weight: 800; letter-spacing: .06em; text-transform: uppercase;
  background: rgba(42, 45, 40, .8); border-radius: 6px; padding: 1px 6px;
}
/* THE PET'S HEALTH BAR RENDERED AT ZERO HEIGHT. Tom, 2026-08-09: "why is my pets
   health always looking empty". Measured in a real fight: track 66x4, fill 62x0.
   `.bar i` is `height: 100%` of the CONTENT box, and a 4px track with 1.5px
   borders top and bottom leaves ~1px to draw in, which rounds away to nothing.
   The colour was never the problem: the fill was lime the whole time and zero
   pixels tall.
   This is the SAME defect I fixed for the second enemy in v341 and I only fixed
   it there. Both bars now state their height explicitly and the fill carries its
   own min-height so it can never be squeezed out by a border again. */
.hud-pet .bar { flex: 1; }
.hud-pet.down { opacity: 0.35; filter: grayscale(1); }
.microbars { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.microbars .bar { height: 8px; border: 2px solid var(--ink); border-radius: 999px; }
.hud-pet .bar { border: 2px solid var(--ink); border-radius: 999px; height: 10px; }
.hud-pet .bar > i { height: 100%; min-height: 4px; display: block; }
.fstage {
  /* Tom, 2026-08-09: "when fighting a smaller same sized boss the bonehead should
     seem bigger otherwise it feels like a lot of empty space."
     Scales with the arena now that the arena scales with the phone. The 150 cap
     is measured, not chosen: the two columns sit 43px apart at 112px stages, and
     a stage overflows its 128px column by (stage-128)/2 each side, so 150 leaves
     21px between them. Anything past ~160 and they touch. */
  /* 24 -> 43 because the percentage is measured against the arena now, not the
     viewport, and the arena is the smaller box. 43cqh holds the cap exactly
     where it already was on the phones that had room (150px at a 352px arena on
     390x844, 150px at a 440px arena on 430x932, to within the border) and lets
     the figure step down on
     a 375x667 phone, where the HUD's own row has to come out of the column.
     The floor moved 112 -> 130 deliberately: 112 is a WIDTH limit from the old
     comment (two columns 43px apart), not a height one, and at 130 the figure
     gives up 20px rather than 38 on the one phone that pays. 130 + the 14px
     bottom offset is 144 inside a 183px arena. */
  width: clamp(130px, 43cqh, 150px); height: clamp(130px, 43cqh, 150px); min-height: 130px; border-radius: 16px;
  background: transparent; border: none; overflow: visible;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
  filter: drop-shadow(0 10px 12px rgba(0, 0, 0, 0.5));
}
.fstage .bh-anim { --av-fit: contain; }
.fstage img { object-fit: contain !important; }
.mirror-wrap { position: absolute; inset: 0; transform: scaleX(-1); }
.fstage.lunge-r { transform: translateX(52px) rotate(4deg); }
.fstage.lunge-l { transform: translateX(-52px) rotate(-4deg); }
.fstage.lunge-r.big { transform: translateX(74px) rotate(9deg) scale(1.06); }
.fstage.lunge-l.big { transform: translateX(-74px) rotate(-9deg) scale(1.06); }
.fstage.lunge-r.whiff { transform: translateX(96px) rotate(16deg); transition-duration: 0.34s; }
.fstage.lunge-l.whiff { transform: translateX(-96px) rotate(-16deg); transition-duration: 0.34s; }
.fstage.hurt { animation: hurtflash 0.34s ease; }
@keyframes hurtflash {
  0% { filter: brightness(2.4) drop-shadow(0 10px 12px rgba(0,0,0,0.5)); transform: translateX(0); }
  35% { transform: translateX(10px) rotate(2deg); }
  70% { transform: translateX(-6px) rotate(-1deg); }
  100% { filter: brightness(1) drop-shadow(0 10px 12px rgba(0,0,0,0.5)); transform: translateX(0); }
}
.you-side .fstage.hurt { animation-name: hurtflashL; }
@keyframes hurtflashL {
  0% { filter: brightness(2.4); transform: translateX(0); }
  35% { transform: translateX(-10px) rotate(-2deg); }
  70% { transform: translateX(6px) rotate(1deg); }
  100% { filter: brightness(1); transform: translateX(0); }
}
.fstage.guard { animation: guardup 0.4s ease; }
@keyframes guardup { 0% { transform: scale(1); } 40% { transform: scale(0.94) translateY(4px); filter: brightness(1.25) drop-shadow(0 0 14px rgba(124,196,255,0.6)); } 100% { transform: scale(1); } }
.fstage.slip { animation: slipfx 0.4s ease; }
@keyframes slipfx { 0% { opacity: 1; } 45% { opacity: 0.35; transform: translateX(-14px) skewX(-6deg); } 100% { opacity: 1; } }
.you-side .fstage.slip { animation-name: slipfxR; }
@keyframes slipfxR { 0% { opacity: 1; } 45% { opacity: 0.35; transform: translateX(14px) skewX(6deg); } 100% { opacity: 1; } }
.fstage.ko { transform: rotate(78deg) translateY(34px); opacity: 0.55; transition: all 0.7s cubic-bezier(0.34, 1.2, 0.64, 1); }

#floats { position: absolute; inset: 0; pointer-events: none; }
.float {
  position: absolute; top: 46%; transform: translateX(-50%);
  animation: floatup 1s ease forwards; z-index: 5; text-align: center;
}
.float.dmg { font-family: var(--display); font-size: var(--fs-display); color: var(--text); text-shadow: 0 2px 8px rgba(0,0,0,0.8); letter-spacing: 1px; }
.float.dmg.crit { font-size: var(--fs-7); color: var(--carbs); }
.float.dmg.sig { font-size: var(--fs-hero); color: var(--accent); }
.float.stamp { font-size: var(--fs-2); font-weight: 800; letter-spacing: 0.1em; color: var(--text-2); top: 34%; }
.float.stamp.hot { color: var(--carbs); }
.float.stamp.dim { color: var(--text-3); }
.float.stamp.cool { color: var(--protein); }
.float.stamp.warm { color: var(--carbs); }
@keyframes floatup { 0% { opacity: 0; margin-top: 8px; } 15% { opacity: 1; } 100% { opacity: 0; margin-top: -44px; } }
.fight-meta { display: flex; align-items: center; gap: 10px; margin: 10px 2px 10px; }
.fight-meta .fight-log { flex: 1; text-align: left; min-height: 32px; font-size: var(--fs-2); }
.fight-act.glow { border-color: rgba(165, 232, 71, 0.65); --crate-pulse-duration: 1.2s; }
.fight-act.glow b { color: var(--accent); }
.fight-act.weak { opacity: 0.55; }
.fight-act.sig {
  background: linear-gradient(180deg, rgba(255, 201, 97, 0.2), rgba(255, 201, 97, 0.06));
  border-color: rgba(255, 201, 97, 0.7); --crate-pulse-duration: 1s;
}
.fight-act.sig b { color: var(--carbs); letter-spacing: 2px; }

/* ---------- talents ---------- */
.tal-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 4px; }
.tal-tree { background: var(--surface-2); border: 1px solid var(--line); border-radius: 16px; padding: 13px; margin-bottom: 12px; }
/* collapsible: the head is a tappable summary (8 trees would be a huge scroll open) */
.tal-tree > summary.tal-tree-head { cursor: pointer; list-style: none; }
.tal-tree > summary.tal-tree-head::-webkit-details-marker { display: none; }
.tal-tree:not([open]) { padding-bottom: 13px; }
.tal-tree > summary.tal-tree-head::after { content: '▾'; margin-left: 6px; color: var(--text-3); font-size: var(--fs-1); transition: transform 0.2s ease; }
.tal-tree:not([open]) > summary.tal-tree-head::after { transform: rotate(-90deg); }
.tal-tree-head { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
.tal-tree-head b { font-family: var(--display); font-size: var(--fs-5); letter-spacing: 1px; }
.tal-tag { font-size: var(--fs-tiny); font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; }
.tal-node {
  width: 100%; display: flex; gap: 11px; align-items: center; text-align: left;
  padding: 10px 11px; border-radius: 13px; margin-bottom: 6px;
  background: var(--surface); border: 1px solid var(--line);
}
.tal-node .tal-pip {
  width: 30px; height: 30px; border-radius: 10px; flex: none;
  display: grid; place-items: center; font-weight: 800; font-size: var(--fs-1);
  background: var(--surface-3); border: 2px solid var(--line-strong); color: var(--accent-ink);
}
.tal-node.locked .tal-pip { color: var(--text-3); }
.tal-node.can .tal-pip { border-color: var(--accent); color: var(--accent); background: var(--surface-3); }
.tal-node .tal-body b { display: block; font-size: var(--fs-3); }
.tal-node .tal-body small { color: var(--text-3); font-size: var(--fs-1); line-height: 1.35; display: block; }
.tal-node.taken { border-color: rgba(165, 232, 71, 0.35); }
.tal-node.partial { border-color: rgba(165, 232, 71, 0.45); }
.tal-node.can { border-color: rgba(165, 232, 71, 0.6); --crate-pulse-duration: 1.6s; }
.tal-node.locked { opacity: 0.5; }
.tal-node .tal-pip { font-size: var(--fs-0); letter-spacing: -0.02em; }
.tal-move { font-size: var(--fs-micro); font-weight: 800; letter-spacing: 0.08em; color: var(--carbs); border: 1px solid rgba(255, 180, 84, 0.4); padding: 1.5px 5px; border-radius: 5px; vertical-align: 2px; }
.tal-move.rank { color: var(--accent); border-color: rgba(165, 232, 71, 0.45); }
.tal-ranks { display: flex; gap: 4px; margin-top: 5px; }
.tal-ranks i { width: 14px; height: 6px; border-radius: 3px; background: var(--surface-3); border: 1px solid var(--line-strong); }
.tal-ranks i.on { border-color: transparent; }
.float.dmg.heal { color: var(--accent); font-size: var(--fs-6); }

/* ---------- talents v2 + spellcraft ---------- */
.tal-tier { display: grid; gap: 6px; margin-bottom: 6px; }
.tal-tier.pair { grid-template-columns: 1fr 1fr; }
.tal-tier.pair .tal-node { flex-direction: column; text-align: center; gap: 6px; padding: 11px 8px; margin: 0; }
.tal-tier.pair .tal-node .tal-body b { font-size: var(--fs-2); }
.tal-tier.pair .tal-node .tal-body small { font-size: var(--fs-0); }
.tal-gate { font-size: var(--fs-tiny); font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); text-align: center; margin: 8px 0 4px; }
.fstage.castfx { animation: castglow 0.5s ease; }
@keyframes castglow {
  0% { filter: brightness(1) drop-shadow(0 10px 12px rgba(0,0,0,0.5)); }
  45% { filter: brightness(1.5) drop-shadow(0 0 22px rgba(192, 132, 252, 0.9)); transform: translateY(-6px); }
  100% { filter: brightness(1) drop-shadow(0 10px 12px rgba(0,0,0,0.5)); }
}
.fstage.hexfx { animation: hexglow 0.5s ease; }
@keyframes hexglow { 45% { filter: brightness(0.7) drop-shadow(0 0 18px rgba(192, 132, 252, 0.8)); } }
.fstage.hurt.hexhit { animation-name: hurtflash; }
.fstage.mendfx { animation: mendglow 0.55s ease; }
@keyframes mendglow { 45% { filter: brightness(1.4) drop-shadow(0 0 20px rgba(165, 232, 71, 0.9)); } }
.float.dmg.magic { color: #c084fc; }
.float.dmg.bleed { color: var(--danger); font-size: var(--fs-5); }
.float.stamp.hex { color: #c084fc; }

/* ===== v15: combat impact pass ===== */
.proj {
  position: absolute; top: 52%; width: 16px; height: 16px; border-radius: 50%;
  z-index: 4; pointer-events: none;
  animation: projLTR .34s linear forwards;
}
.proj.rtl { animation-name: projRTL; }
.proj.shadow { background: #c084fc; box-shadow: 0 0 14px 5px rgba(192, 132, 252, .75), 0 0 30px 10px rgba(192, 132, 252, .3); }
.proj.holy   { background: #ffe08a; box-shadow: 0 0 14px 5px rgba(255, 224, 138, .8), 0 0 30px 10px rgba(255, 224, 138, .35); }
.proj.fire   { background: #ff7a45; box-shadow: 0 0 14px 5px rgba(255, 122, 69, .8), 0 0 30px 10px rgba(255, 60, 0, .35); }
.proj.frost  { background: #7cc4ff; box-shadow: 0 0 14px 5px rgba(124, 196, 255, .8), 0 0 30px 10px rgba(124, 196, 255, .35); }
.proj.nature { background: #a5e847; box-shadow: 0 0 14px 5px rgba(165, 232, 71, .8); }
.proj.alchemy { background: #8bd450; box-shadow: 0 0 14px 5px rgba(139, 212, 80, .85), 0 0 30px 10px rgba(139, 212, 80, .35); }
.proj.phys   { background: #f2e9d7; box-shadow: 0 0 10px 4px rgba(242, 233, 215, .6); }
@keyframes projLTR { from { left: 24%; transform: scale(.6) } 60% { transform: scale(1.1) } to { left: 70%; transform: scale(1) } }
@keyframes projRTL { from { left: 70%; transform: scale(.6) } 60% { transform: scale(1.1) } to { left: 24%; transform: scale(1) } }

.burst {
  position: absolute; top: 50%; width: 14px; height: 14px; border-radius: 50%;
  transform: translate(-50%, -50%); z-index: 5; pointer-events: none;
  border: 3px solid #f2e9d7; opacity: .9;
  animation: burstPop .45s ease-out forwards;
}
.burst.bigburst { border-width: 5px; animation-duration: .55s; }
.burst.shadow { border-color: #c084fc; box-shadow: 0 0 18px rgba(192, 132, 252, .6); }
.burst.holy   { border-color: #ffe08a; box-shadow: 0 0 18px rgba(255, 224, 138, .6); }
.burst.fire   { border-color: #ff7a45; box-shadow: 0 0 18px rgba(255, 122, 69, .6); }
.burst.frost  { border-color: #7cc4ff; box-shadow: 0 0 18px rgba(124, 196, 255, .6); }
.burst.phys   { border-color: #f2e9d7; }
@keyframes burstPop {
  from { width: 10px; height: 10px; opacity: .95 }
  to { width: 74px; height: 74px; opacity: 0 }
}
.bigburst { animation-name: burstPopBig !important; }
@keyframes burstPopBig {
  from { width: 12px; height: 12px; opacity: 1 }
  to { width: 110px; height: 110px; opacity: 0 }
}

.fstage.windup { animation: windup .5s ease-in both; }
@keyframes windup {
  0% { transform: none }
  100% { transform: scale(1.12) rotate(-6deg) translateX(-4%) }
}
.foe-side .fstage.windup { animation-name: windupR; }
@keyframes windupR {
  0% { transform: none }
  100% { transform: scale(1.12) rotate(6deg) translateX(4%) }
}

.fstage.castfx.cast-fire  { animation-name: castglowFire; }
.fstage.castfx.cast-frost { animation-name: castglowFrost; }
.fstage.castfx.cast-holy  { animation-name: castglowHoly; }
@keyframes castglowFire {
  0%, 100% { filter: none } 50% { filter: drop-shadow(0 0 16px rgba(255, 122, 69, .95)) brightness(1.25) }
}
@keyframes castglowFrost {
  0%, 100% { filter: none } 50% { filter: drop-shadow(0 0 16px rgba(124, 196, 255, .95)) brightness(1.25) }
}
@keyframes castglowHoly {
  0%, 100% { filter: none } 50% { filter: drop-shadow(0 0 16px rgba(255, 224, 138, .95)) brightness(1.3) }
}
.fstage.wardfx { animation: wardglow 0.55s ease; }
@keyframes wardglow {
  0%, 100% { filter: none } 50% { filter: drop-shadow(0 0 16px rgba(255, 224, 138, .95)) }
}

.float.dmg.fire  { color: #ff7a45; }
.float.dmg.frost { color: #7cc4ff; }
.float.dmg.holy  { color: #ffe08a; }
.float.dmg.shadow { color: #c084fc; }
.float.dmg.nature { color: #a5e847; }
.float.dmg.rage { color: #ff4d4d; }
.float.stamp.fire { color: #ff7a45; }
.float.stamp.holy { color: #ffe08a; }
.float.stamp.rage { color: #ff3b3b; text-shadow: 0 0 14px rgba(255, 59, 59, .9), 2px 2px 0 rgba(0,0,0,.6); font-size: var(--fs-5); }

/* ===== v70 class-identity FX ===== */
.burst.rage   { border-color: #ff4d4d; box-shadow: 0 0 22px rgba(255, 60, 60, .7); }
.burst.nature { border-color: #a5e847; box-shadow: 0 0 18px rgba(165, 232, 71, .6); }
.burst.alchemy { border-color: #8bd450; box-shadow: 0 0 18px rgba(139, 212, 80, .65); }
.float.dmg.alchemy { color: #8bd450; }
/* persistent red aura on a raging fighter */
.fstage.raging { animation: rageaura 1.1s ease-in-out infinite; }
@keyframes rageaura {
  0%, 100% { filter: drop-shadow(0 0 6px rgba(255, 40, 40, .55)); }
  50%      { filter: drop-shadow(0 0 16px rgba(255, 40, 40, .95)) saturate(1.3); }
}
.fstage.ragefx { animation: ragehit 0.4s ease; }
@keyframes ragehit { 0%,100% { filter: none } 40% { filter: drop-shadow(0 0 20px rgba(255, 40, 40, 1)) brightness(1.3); transform: scale(1.06); } }
.fstage.summonfx { animation: summonrise 0.5s ease; }
@keyframes summonrise { 0% { filter: none } 45% { filter: drop-shadow(0 0 18px rgba(192, 132, 252, .95)); } 100% { filter: none } }
.fstage.totemfx { animation: totemspark 0.5s ease; }
@keyframes totemspark { 0% { filter: none } 45% { filter: drop-shadow(0 0 16px rgba(165, 232, 71, .95)); } 100% { filter: none } }
/* a summon up tints the caster's stage so the buff reads at a glance */
.fstage.has-minion { animation: minionaura 1.5s ease-in-out infinite; }
@keyframes minionaura { 0%,100% { filter: drop-shadow(0 8px 10px rgba(0,0,0,.5)); } 50% { filter: drop-shadow(0 0 12px rgba(192,132,252,.7)); } }
.fstage.has-totem { animation: totemaura 1.5s ease-in-out infinite; }
@keyframes totemaura { 0%,100% { filter: drop-shadow(0 8px 10px rgba(0,0,0,.5)); } 50% { filter: drop-shadow(0 0 12px rgba(165,232,71,.7)); } }
.fstate.raging { color: #ff5b5b; border-color: rgba(255, 60, 60, .5); }
.float.stamp.gold {
  color: #ffd700; font-size: var(--fs-title);
  text-shadow: 0 0 18px rgba(255, 215, 0, .9), 2px 2px 0 rgba(0, 0, 0, .6);
}

.arena.holyflash::after {
  content: ''; position: absolute; inset: 0; z-index: 6; pointer-events: none;
  background: radial-gradient(circle at 50% 55%, rgba(255, 224, 138, .5), transparent 65%);
  animation: holyfade .7s ease-out forwards;
}
@keyframes holyfade { from { opacity: 1 } to { opacity: 0 } }

@media (prefers-reduced-motion: reduce) {
  .proj, .burst { display: none; }
  .fstage { transition: none; }
  .fstage.hurt, .fstage.guard, .fstage.slip, .fstage.castfx, .fstage.hexfx, .fstage.mendfx,
  .fstage.windup, .fstage.wardfx, .fstage.raging, .fstage.ragefx, .fstage.summonfx,
  .fstage.totemfx, .fstage.has-minion, .fstage.has-totem { animation: none; }
  .arena.quake, .float { animation: none; }
  .fstage.raging { filter: drop-shadow(0 0 10px rgba(255, 40, 40, .8)); }
}

/* ===== design pass: boneyard legend ===== */
/* THE INTRO CARD DRAWS THE SAME ART THE MAP DOES. It used to draw .blip-dot,
   plain coloured circles, while the map key beside it drew pixel art, so the
   card a player reads BEFORE opening the map disagreed with the map. Its coin
   row also still said "spend in the crate shop" two builds after the crate shop
   closed. boneyard-icon-audit reads NAMES off this card and never looked at its
   icons, which is how both survived. */
.legend-pix { width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; flex: none; }
.legend-pix img { image-rendering: pixelated; }
.legend-row { display: flex; align-items: center; gap: 11px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: var(--fs-3); }
.legend-row:last-child { border-bottom: 0; }
/* THE RARE DOT KEEPS ITS PULSE AND STOPS COSTING ANYTHING FOR IT.
   MEASURED on this tree at 440x956, sitting on the settled Boneyard doing
   nothing: 120 style recalcs per second, forever, for one 13px dot. Every other
   idle surface in the app scored 0.0. `box-shadow` cannot be composited, so an
   infinite box-shadow animation drags the main thread through a style recalc
   twice a frame for as long as the screen is open, and on a phone that is
   battery, heat, and then throttling that reads as choppiness.

   THE COST IS PER-SCREEN, NOT PER-ELEMENT, which is why one dot matters as much
   as sixteen would: measured 1 element = 120/s and 16 elements = 120/s, both on
   a bare page and in the app. One un-compositable animation in view puts the
   whole document on the slow path, and the same measurement says an element
   scrolled entirely out of view costs nothing, so what matters is what is ON
   SCREEN while the player sits still.

   SAME LOOK, DIFFERENT MECHANISM: the two shadows the keyframe used to
   interpolate between are now two pseudo-elements that CROSS-FADE on `opacity`,
   which the compositor owns. Both endpoints are exact, and that is the point of
   using two rather than one: a single overlay added on top of a kept 10px base
   makes the peak brighter at the core instead of wider, which is a different
   pulse. MEASURED, not asserted. The whole "OUT THERE TODAY" card, captured at
   3x off the real Boneyard with every animation paused on the pulse's brightest
   frame, is pixel-equivalent before and after: ZERO of 763,776 pixels differ by
   more than 24 (sum of RGB deltas), max delta 19, mean 0.05. Per-frame, against
   the shipped animation seeked to the same three times, mean luminance in
   7-10 / 10-13 / 13-16 / 16-20 / 20-26px rings around the dot came out +0.28 of
   a channel at the trough, +0.53 at the peak and -0.35 at the midpoint, which is
   where a cross-fade of 10px and 18px and an interpolated 14px blur are least
   alike and is still under half a channel out of 255. A single overlay added on
   top of a kept base measured +16.5 channels in the inner ring at peak, which is
   what "brighter at the core instead of wider" means. */
@keyframes rareRest { 50% { opacity: 0; } }
@keyframes rareBloom { 50% { opacity: 1; } }
/* Switched OFF BY NAME and parked on the peak frame, never shortened: a
   duration of .001s on an infinite loop runs it a thousand times a second,
   which this repo has already shipped once. Parked bright rather than dark
   because this dot's whole job in the map key is to read as the rare one. The
   animation it replaces had no reduced-motion rule at all and pulsed anyway. */
@media (prefers-reduced-motion: reduce) {
}

/* ===== character sheet: game tabs + stat pills ===== */
/* Five tabs in a 2-column grid meant three rows and ~430px of chrome before any
   content. One compact row instead, so the Wardrobe doll is visible on arrival. */
/* 4, not 5: the LEVEL chip came off this row on 2026-08-17 and a stale 5-column
   grid left the four survivors stranded in 4/5 of the width with a visible hole
   on the right. Count the chips in renderCharacter if this ever changes again. */
.ch-tabs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 14px 0 10px; }
.chip.ch-tab {
  position: relative; display: grid; justify-items: center; gap: 3px;
  padding: 9px 2px 7px; border-radius: 14px;
  background: var(--surface); border: 1px solid var(--line);
  font-family: var(--display); font-size: var(--fs-0); letter-spacing: 0.01em; line-height: 1.1;
  color: var(--text-2);
}
.chip.ch-tab svg, .chip.ch-tab img { opacity: 0.75; }
/* A SOLID ACCENT FILL MADE NAVIGATION THE LOUDEST THING ON A SHOPPING SCREEN.
   Measured on Shop at 440x956: the selected chip was 89.8% saturated pixels by
   its own area, against 3.7% for its siblings and 14.2% for the most colourful
   PRODUCT on the same screen. A full-bleed fill is ~88% saturated by
   construction, so nothing on a merchandising screen can out-shout it without
   putting a solid fill behind the goods, which is worse. Its label was also the
   least readable text in the row: 2.39:1 cream-on-coral, a WCAG failure, while
   the UNSELECTED siblings sat at 8.12:1.
   The tint is not invented here: `.chip.on` (line ~360) has always been a
   low-alpha accent wash + accent border + brighter label, so this is the row
   joining the idiom it was the only exception to. Selection is carried by FOUR
   signals, only one of which is hue: the coral ring (2.02:1 greyscale against
   the siblings' ring), the label brightness step (13.46:1 vs 8.12:1), the icon
   at full opacity vs 0.75, and the hard sticker shadow, which no sibling has.
   Guarded by tests/tab-chip-audit.mjs, which pins the fill CEILING so nobody
   can reinstate the block. Do not raise the fill without moving that ceiling
   and saying why. */
.chip.ch-tab.on {
  color: var(--text); border-color: var(--coral);
  background: var(--coral-soft);
  box-shadow: var(--sh-sm);
}
.chip.ch-tab.on svg, .chip.ch-tab.on img { opacity: 1; }
.ch-badge {
  position: absolute; top: 5px; right: 12%;
  background: var(--amber); color: #201500; font-style: normal;
  font-family: var(--body, system-ui); font-size: var(--fs-0); font-weight: 800;
  padding: 1px 6px; border-radius: 999px;
}
.bh-title { font-family: var(--display); font-size: var(--fs-5); letter-spacing: 0.02em; }
.bh-pills { display: flex; gap: 7px; flex-wrap: wrap; justify-content: center; }
.bh-pill {
  display: inline-flex; align-items: center; gap: 5px;
  background: var(--surface); border: 1px solid var(--line); border-radius: 999px;
  padding: 4px 11px; font-size: var(--fs-2); font-weight: 700; color: var(--text-2);
}
.bh-hero.mini .bh-pills { justify-content: start; }

/* ===== wardrobe: changing-room curtains (flat, inky, Cam-style) ===== */
.bh-stage.dressing { overflow: hidden; }
.curt {
  position: absolute; top: -2px; bottom: -2px; width: 53%; z-index: 6; pointer-events: none;
  /* hard-stop cel shading: ink line, deep red, highlight band, repeat */
  background: repeating-linear-gradient(90deg,
    #17151d 0 3px,
    #6b1f2a 3px 16px,
    #96374a 16px 24px,
    #7c2836 24px 34px);
  border-top: 3px solid #17151d;
  /* scalloped hem, cut with a mask so the fabric ends in drawn arcs */
  -webkit-mask: linear-gradient(#000 0 0) top / 100% calc(100% - 12px) no-repeat,
    radial-gradient(14px 14px at 17px 0, #000 98%, transparent) bottom / 34px 12px repeat-x;
  mask: linear-gradient(#000 0 0) top / 100% calc(100% - 12px) no-repeat,
    radial-gradient(14px 14px at 17px 0, #000 98%, transparent) bottom / 34px 12px repeat-x;
  transition: transform 0.55s cubic-bezier(0.66, 0, 0.24, 1);
}
.curt.l { left: 0; border-right: 3px solid #17151d; transform: translateX(0); }
.curt.r { right: 0; border-left: 3px solid #17151d; transform: translateX(0); }
.curt.l.open { transform: translateX(-106%); }
.curt.r.open { transform: translateX(106%); }
@media (prefers-reduced-motion: reduce) { .curt { display: none; } }

/* ===== radar v2: facing cone, ring labels, nearest target ===== */
.radar-cone {
  position: absolute; inset: 0; border-radius: 50%; z-index: 1; pointer-events: none;
  background: conic-gradient(from -30deg at 50% 50%,
    rgba(165, 232, 71, 0.28) 0deg, rgba(165, 232, 71, 0.10) 30deg,
    rgba(165, 232, 71, 0.28) 60deg, transparent 60.5deg);
  -webkit-mask: radial-gradient(circle, transparent 12%, #000 12.5%);
  mask: radial-gradient(circle, transparent 12%, #000 12.5%);
  transition: transform 0.35s ease-out;
}
.radar-ring i { position: absolute; top: -2px; left: 50%; transform: translate(-50%, -55%); font-style: normal; font-size: var(--fs-micro); color: rgba(165, 232, 71, 0.45); background: #0c100c; padding: 0 5px; border-radius: 6px; }
.radar-blip.nearest { filter: drop-shadow(0 0 7px rgba(165, 232, 71, 0.9)); }
.radar-target { font-size: var(--fs-3); }

/* ===== the pit: VS smash card ===== */
.vs-card {
  position: fixed; inset: 0; z-index: 200; display: grid; place-items: center;
  background: radial-gradient(circle at 50% 45%, #241019 0%, #12090e 60%, #0a0508 100%);
  animation: vsIn 0.2s ease-out;
}
.vs-inner { display: grid; justify-items: center; gap: 10px; text-align: center; padding: 0 26px; }
.vs-name {
  font-family: var(--display); color: #f2e9d7; font-size: var(--fs-display); letter-spacing: 0.05em;
  text-shadow: 3px 3px 0 rgba(0, 0, 0, 0.6);
}
.vs-name.foe { color: #ff7a45; }
.vs-vs {
  font-family: var(--display); font-size: var(--fs-hero); color: var(--accent); line-height: 1;
  text-shadow: 4px 4px 0 rgba(0, 0, 0, 0.65); animation: vsStamp 0.45s cubic-bezier(0.34, 1.8, 0.64, 1) 0.25s both;
}
.vs-venue { font-size: var(--fs-2); font-weight: 700; letter-spacing: 0.14em; color: var(--text-3); text-transform: uppercase; }
.vs-bones { position: relative; width: 150px; height: 66px; }
.vs-bone { position: absolute; top: 8px; font-size: 44px; filter: drop-shadow(2px 3px 0 rgba(0,0,0,.5)); }
.vs-bone.l { left: -6px; animation: boneSlamL 0.5s cubic-bezier(0.6, 0, 0.4, 1) both; }
.vs-bone.r { right: -6px; animation: boneSlamR 0.5s cubic-bezier(0.6, 0, 0.4, 1) both; transform: scaleX(-1); }
.vs-impact {
  position: absolute; left: 50%; top: 50%; width: 12px; height: 12px; border-radius: 50%;
  transform: translate(-50%, -50%); border: 4px solid #f2e9d7; opacity: 0;
  animation: burstPopBig 0.5s ease-out 0.42s;
}
@keyframes vsIn { from { opacity: 0 } }
@keyframes vsStamp { from { transform: scale(2.4); opacity: 0 } to { transform: scale(1); opacity: 1 } }
@keyframes boneSlamL { from { transform: translateX(-70px) rotate(-70deg); } to { transform: translateX(0) rotate(-24deg); } }
@keyframes boneSlamR { from { transform: scaleX(-1) translateX(-70px) rotate(-70deg); } to { transform: scaleX(-1) translateX(0) rotate(-24deg); } }
.vs-card.quake { animation: vsIn 0.2s ease-out, quake 0.3s linear 0.42s; }

/* ===== haunted-gladiator combat zones (shared: fight arena + Pit banner) ===== */
/* Theme: cold ink stone, bone accents, sickly slime-green ghost torchlight,
   tattered oxblood banners, low graveyard fog. No festive bunting. */
.arena { position: relative; }
.arena::before { /* ghost torchlight + heavy vignette */
  content: ''; position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background:
    radial-gradient(70% 45% at 8% 42%, rgba(150, 235, 150, 0.14), transparent 62%),
    radial-gradient(70% 45% at 92% 42%, rgba(150, 235, 150, 0.14), transparent 62%),
    radial-gradient(120% 95% at 50% 100%, transparent 34%, rgba(0, 0, 0, 0.62) 100%);
  animation: torchBreathe 3.4s ease-in-out infinite alternate;
}
.pit-crowd { /* ghostly skull rows in the dark stands, faint slime eyes */
  position: absolute; left: 0; right: 0; top: 0; height: 28%; z-index: 0; pointer-events: none;
  background:
    radial-gradient(2px 2px at 12px 60%, rgba(165, 232, 71, 0.25) 98%, transparent) repeat-x,
    radial-gradient(9px 8px at 14px 86%, #201a26 98%, transparent) repeat-x,
    radial-gradient(12px 10px at 30px 98%, #17121d 98%, transparent) repeat-x;
  background-size: 42px 100%, 42px 100%, 58px 100%;
  opacity: 0.85;
  animation: crowdSway 4.2s ease-in-out infinite alternate;
  -webkit-mask: linear-gradient(#000 76%, transparent);
  mask: linear-gradient(#000 76%, transparent);
}
/* tattered oxblood banners hung from above, torn jagged hems */
.pit-banner {
  position: absolute; top: -1px; width: 30px; height: 40%; z-index: 1; pointer-events: none;
  background: linear-gradient(#3a1418 0%, #2a1016 70%, #1c0d12 100%);
  border-left: 1px solid rgba(0, 0, 0, 0.45); border-right: 1px solid rgba(0, 0, 0, 0.45);
  box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.5);
  clip-path: polygon(0 0, 100% 0, 100% 84%, 82% 100%, 66% 86%, 50% 100%, 34% 86%, 18% 100%, 0 84%);
}
.pit-banner::after { /* faint slime sigil */
  content: ''; position: absolute; top: 22%; left: 50%; width: 11px; height: 11px; transform: translateX(-50%);
  border-radius: 50%; background: radial-gradient(circle, rgba(165, 232, 71, 0.55) 42%, transparent 46%);
}
.pit-banner.l { left: 12%; transform: rotate(-1.5deg); }
.pit-banner.r { right: 12%; transform: rotate(1.5deg); }
/* eerie green torch columns down each side */
.pit-torch {
  position: absolute; bottom: 10%; width: 34%; height: 66%; z-index: 0; pointer-events: none;
  background: radial-gradient(52% 48% at 50% 44%, rgba(150, 235, 150, 0.20), rgba(120, 220, 150, 0.06) 55%, transparent 72%);
  filter: blur(1px); animation: torchBreathe 2.6s ease-in-out infinite alternate;
}
.pit-torch.l { left: -4%; } .pit-torch.r { right: -4%; animation-delay: 0.9s; }
/* low graveyard fog crawling along the floor */
.pit-fog {
  position: absolute; left: 0; right: 0; bottom: 0; height: 34%; z-index: 1; pointer-events: none;
  background: linear-gradient(transparent, rgba(150, 220, 165, 0.05) 55%, rgba(120, 200, 150, 0.11));
  -webkit-mask: radial-gradient(130% 85% at 50% 128%, #000 42%, transparent 76%);
  mask: radial-gradient(130% 85% at 50% 128%, #000 42%, transparent 76%);
}
.pit-floor { /* packed-earth fighting circle, cold-lit */
  position: absolute; left: 4%; right: 4%; bottom: 4%; height: 17%; z-index: 0; pointer-events: none;
  background: radial-gradient(120% 140% at 50% 0%, #2a2320, #1b1518);
  border: 3px solid #100e14; border-radius: 50%;
  box-shadow: inset 0 6px 10px rgba(0, 0, 0, 0.5), inset 0 3px 0 rgba(165, 232, 71, 0.05);
}
/* venue now rides the fight sheet header as a subtitle under the foe name */
.fight-title { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.fight-venue { font-size: var(--fs-0); font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-3); }
@keyframes torchBreathe { from { opacity: 0.7 } to { opacity: 1 } }
@keyframes crowdSway { from { background-position: 0 0, 0 0, 0 0 } to { background-position: 6px 0, 9px 0, -7px 0 } }
@media (prefers-reduced-motion: reduce) { .arena::before, .pit-crowd, .pit-torch, .pit-emblem { animation: none; } }

/* ===== the Pit sheet: collapsible fight sections ===== */
.pit-sect { border-bottom: 1px solid var(--line, #23202b); }
.pit-sect > summary {
  list-style: none; cursor: pointer; user-select: none;
  display: flex; align-items: center; gap: 8px;
  font-size: var(--fs-1); font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--accent); padding: 12px 2px 10px;
}
.pit-sect > summary::-webkit-details-marker { display: none; }
.pit-sect > summary::after { content: '▸'; margin-left: auto; color: var(--text-3); transition: transform 0.2s; }
.pit-sect[open] > summary::after { transform: rotate(90deg); }
.pit-sect[open] > summary { color: var(--accent); }
.pit-sect > :not(summary) { margin-bottom: 4px; }

/* ===== the Pit sheet: haunted-gladiator banner header ===== */
.pit-hero {
  position: relative; overflow: hidden; border-radius: 16px;
  padding: 18px 12px 16px; text-align: center;
  background:
    radial-gradient(90% 60% at 50% 118%, rgba(140, 220, 160, 0.10), transparent 62%),
    radial-gradient(120% 120% at 50% 6%, #211a26 0%, #140f18 55%, #0a0810 100%);
  border: 2px solid #100d15;
  box-shadow: inset 0 0 0 1px rgba(203, 184, 146, 0.06), inset 0 -24px 38px rgba(0, 0, 0, 0.6);
}
.pit-hero-atmos { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
/* stone colosseum columns framing the sides */
.pit-arch::before, .pit-arch::after {
  content: ''; position: absolute; top: 0; bottom: 0; width: 22px; z-index: 0;
  background:
    repeating-linear-gradient(0deg, #2c2632 0 13px, #221c29 13px 15px),
    linear-gradient(90deg, #37303f, #221d2b);
  box-shadow: inset 0 0 12px rgba(0, 0, 0, 0.6);
}
.pit-arch::before { left: 0; border-right: 2px solid #0c0a11; }
.pit-arch::after { right: 0; border-left: 2px solid #0c0a11; }
.pit-hero .pit-crowd { top: auto; bottom: 26%; height: 34%; opacity: 0.5; }
.pit-hero .pit-banner { height: 44%; }
.pit-hero .pit-torch { bottom: 6%; height: 74%; }
.pit-emblem {
  position: relative; z-index: 2; width: 60px; height: 60px; object-fit: contain;
  filter: drop-shadow(0 0 10px rgba(150, 235, 150, 0.35)) drop-shadow(0 3px 0 rgba(0, 0, 0, 0.5));
  animation: emblemBob 3.2s ease-in-out infinite alternate;
}
@keyframes emblemBob { from { transform: translateY(0) rotate(-3deg) } to { transform: translateY(-4px) rotate(3deg) } }
.pit-hero-title {
  position: relative; z-index: 2; font-family: var(--display); font-size: var(--fs-7); line-height: 0.9;
  color: var(--bone, #f2e9d7); letter-spacing: 2px; margin-top: 2px;
  -webkit-text-stroke: 3px #0a0810; paint-order: stroke fill;
  text-shadow: 0 0 14px rgba(150, 235, 150, 0.22);
}
.pit-quote {
  position: relative; z-index: 2; margin-top: 3px; font-family: var(--display);
  font-size: var(--fs-3); letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--slime, #a5e847); opacity: 0.9; text-shadow: 0 0 9px rgba(165, 232, 71, 0.4);
}
.pit-hero-sub {
  position: relative; z-index: 2; margin-top: 5px; font-size: var(--fs-1); font-weight: 800;
  letter-spacing: 0.08em; color: rgba(242, 233, 215, 0.66);
}

/* ===== Pit energy (hybrid free-floor + Vigor) ===== */
.pit-energy {
  display: flex; align-items: center; gap: 11px; margin: 2px 2px 4px;
  padding: 10px 12px; border-radius: 13px; background: var(--surface-2);
  border: 1px solid rgba(165, 232, 71, 0.28);
}
.pit-energy.empty { border-color: rgba(255, 180, 84, 0.4); }
.pit-energy .pe-ico { color: var(--accent); flex: none; display: grid; place-items: center; }
.pit-energy.empty .pe-ico { color: var(--amber); }
.pit-energy b { font-size: var(--fs-3); }
.pit-energy small { display: block; margin-top: 2px; color: var(--text-3); font-size: var(--fs-1); line-height: 1.3; }
.pe-bar { height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; margin: 5px 0 4px; }
.pe-bar i { display: block; height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
.pit-energy.empty .pe-bar i { background: var(--amber); }

/* ===== Build page: weapon rack ===== */
.weap-rack { display: grid; gap: 10px; }
.weap-card {
  padding: 10px 12px; border-radius: 12px; background: var(--surface-2);
  border: 1px solid var(--border, #23202b); border-left-width: 4px;
}
.weap-card.r-common { border-left-color: #9fac9f; }
.weap-card.r-rare { border-left-color: #60a5fa; }
.weap-card.r-epic { border-left-color: var(--violet); }
.weap-card.r-legendary { border-left-color: #ffc961; }
.weap-card.r-prestige { border-left-color: #ff6b8b; background: linear-gradient(105deg, rgba(255,107,139,0.10), var(--surface-2) 55%); box-shadow: 0 0 12px rgba(255,107,139,0.18); }
.weap-card.on { box-shadow: 0 0 0 2px var(--accent) inset; }
.weap-top { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.weap-top b { font-size: var(--fs-3); }
.weap-spec { font-size: var(--fs-0); font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-3); }
.weap-desc { display: block; margin: 5px 0 8px; font-size: var(--fs-1); line-height: 1.35; color: var(--text-2, #cfc6d6); }
.weap-cta { display: flex; justify-content: flex-end; }
.weap-cta .q-frac { align-self: center; }
.weap-card.owned { opacity: 0.92; }
.weap-tier { font-size: var(--fs-tiny); letter-spacing: -1px; }
.weap-tier.t1 { color: #60a5fa; } .weap-tier.t2 { color: var(--violet); } .weap-tier.t3 { color: #ffc961; }
.weap-tier.t4 { color: #ff6b8b; }
.cta-dust { opacity: 0.9; font-size: 0.92em; }
.wallet-dust { font-size: var(--fs-1); color: var(--text-3); font-family: system-ui, sans-serif; letter-spacing: 0; }

/* ===== The Bone Merchant (v71) ===== */
.wallet-line { display: flex; align-items: center; justify-content: space-between; margin: 0 2px 12px; padding: 8px 12px; border-radius: 11px; background: var(--surface-2); }
.wallet-line b { font-family: var(--display); font-size: var(--fs-4); letter-spacing: 0.5px; }
/* v150: pointer card (Build -> Shop) + Shop's future-IAP vault placeholder */
.bsect-link { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; text-align: left; cursor: pointer; }
.bsect-link b { font-size: var(--fs-3); }
.shop-vault { opacity: 0.9; border-style: dashed; }
.vault-soon { margin-top: 10px; display: inline-block; font-size: var(--fs-0); font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 10px; }
.merch-group { margin-bottom: 16px; }
.merch-group.rec { border: 1px solid rgba(165, 232, 71, 0.35); border-radius: 14px; padding: 10px; background: rgba(165, 232, 71, 0.04); }
.merch-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
.merch-head b { font-family: var(--display); font-size: var(--fs-body); letter-spacing: 0.5px; }
.merch-head small { color: var(--text-3); font-size: var(--fs-1); }
.merch-ico { font-size: var(--fs-3); align-self: center; }
.merch-rec { margin-left: auto; font-size: var(--fs-tiny); font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); border: 1px solid rgba(165, 232, 71, 0.5); padding: 2px 7px; border-radius: 999px; }

/* ===== The Crew: online identity card ===== */
.crew-id { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.crew-handle { font-family: var(--display); font-size: var(--fs-5); letter-spacing: 0.5px; color: var(--accent); }
.crew-code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--fs-2); font-weight: 700;
  color: var(--text, #f2e9d7); background: var(--surface-2); border: 1px solid var(--line-strong, #2c2936);
  border-radius: 999px; padding: 7px 12px; cursor: pointer; letter-spacing: 0.06em;
}
.crew-name-wrap { display: flex; flex-direction: column; gap: 1px; }
.crew-editname { align-self: flex-start; font-size: var(--fs-1); font-weight: 700; padding: 2px 0; }
.crew-friends {
  display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%;
  margin-top: 12px; padding: 12px 14px; border-radius: 14px;
  background: var(--surface-2); border: 1px solid var(--line); color: var(--text);
  font-size: var(--fs-3); font-weight: 700; cursor: pointer;
}
.crew-friends:active { transform: scale(0.99); }
.crew-friends-r { display: flex; align-items: center; gap: 8px; color: var(--text-3); }
.crew-chev { color: var(--text-3); font-size: var(--fs-5); line-height: 1; font-weight: 400; }
.req-badge {
  background: var(--accent); color: #12140b; font-size: var(--fs-0); font-weight: 800;
  border-radius: 999px; padding: 2px 8px; letter-spacing: 0.02em;
}

/* ---- name builder sheet ---- */
.nb-preview {
  font-family: var(--display); font-size: var(--fs-display); letter-spacing: 0.5px; text-align: center;
  color: var(--accent); background: var(--surface-2); border: 1px solid var(--line);
  border-radius: 16px; padding: 20px 12px; margin-bottom: 12px; min-height: 34px;
  overflow-wrap: anywhere;
}
.nb-shuffle { width: 100%; margin-bottom: 14px; }
.nb-group { margin-bottom: 12px; }
.nb-lab { font-size: var(--fs-0); letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); margin: 0 2px 6px; font-weight: 700; }
.nb-chips {
  display: flex; gap: 7px; overflow-x: auto; padding: 2px 2px 8px; scroll-snap-type: x proximity;
  -webkit-overflow-scrolling: touch; scrollbar-width: none;
}
.nb-chips::-webkit-scrollbar { display: none; }
.nb-chip { flex: none; scroll-snap-align: center; white-space: nowrap; }
.nb-numrow { display: flex; align-items: center; gap: 12px; margin: 4px 2px 16px; }
.nb-numtog { display: flex; align-items: center; gap: 8px; font-size: var(--fs-3); font-weight: 600; color: var(--text); cursor: pointer; }
.nb-numtog input { width: 18px; height: 18px; accent-color: var(--accent); }
.nb-numinput {
  width: 92px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 10px;
  padding: 8px 10px; color: var(--accent); font-family: var(--display); font-size: var(--fs-4);
  text-align: center; letter-spacing: 0.08em;
}
.nb-numinput::placeholder { font-family: inherit; font-size: var(--fs-1); color: var(--text-3); letter-spacing: 0; }
.nb-save { width: 100%; }

/* ---- Crew tab: friend-code card ---- */
.crew-code-big {
  font-family: var(--display); font-size: var(--fs-display); letter-spacing: 0.18em; text-align: center;
  color: var(--accent); background: var(--surface-2); border: 1px dashed var(--line-strong, #2c2936);
  border-radius: 14px; padding: 16px 10px; margin-bottom: 10px; overflow-wrap: anywhere;
}
.crew-code-btns { display: flex; gap: 8px; }
.crew-code-btns .btn { flex: 1; }

/* ---- phone-landscape blocker (browsers ignore the manifest's portrait lock) ---- */
#rotateLock { display: none; }
@media (orientation: landscape) and (pointer: coarse) and (max-height: 540px) {
  #rotateLock {
    display: grid; place-items: center; position: fixed; inset: 0; z-index: 9999;
    background: var(--bg, #0d0c12); text-align: center;
  }
  .rl-inner { padding: 20px; }
  .rl-skull { font-size: 54px; animation: rlWiggle 1.6s ease-in-out infinite; }
  .rl-title { font-family: var(--display); font-size: var(--fs-display); color: var(--accent); letter-spacing: 1px; margin-top: 8px; }
  #rotateLock p { color: var(--text-3); margin: 6px 0 0; font-size: var(--fs-3); }
}
@keyframes rlWiggle { 0%, 100% { transform: rotate(-8deg); } 50% { transform: rotate(8deg); } }
@media (prefers-reduced-motion: reduce) { .rl-skull { animation: none; } }

/* ---- all-players leaderboard ---- */
.lb-open { position: relative; width: 100%; text-align: left; cursor: pointer; }
.lb-open .ul-chev { position: absolute; right: 16px; top: 14px; color: var(--text-3); font-size: var(--fs-5); }
/* top-3 podium tile on the Crew tab: show off the Boneheadz, gold in the middle.
   Restored 2026-08-09 (v344). It renders on the CARD; the sheet behind it is
   still one flat list of everybody, which is the thing Tom actually asked for on
   the 8th. Plinth heights and figure sizes carry the ranking on their own, so
   the numerals are confirmation rather than the only signal. */
.lb-podium { display: flex; align-items: flex-end; justify-content: center; gap: 8px; margin-top: 12px; }
.lb-pod { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; }
.pod-av {
  width: 76px; height: 76px; border-radius: 14px; overflow: hidden; position: relative;
  background: var(--surface-2); border: 2px solid var(--ink);
}
.pod-av .bh-anim { position: absolute; inset: 0; }
.pod-av img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; object-position: center bottom; }
.lb-pod.p1 .pod-av { width: 96px; height: 96px; border-color: var(--gold); box-shadow: 0 0 0 3px rgba(255, 201, 97, .28); }
.pod-name {
  margin-top: 6px; font-family: var(--display); font-size: var(--fs-3); letter-spacing: .02em;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.lb-pod.p1 .pod-name { font-size: var(--fs-4); }
.pod-lvl { font-size: var(--fs-0); color: var(--text-3); font-weight: 700; letter-spacing: .06em; }
.pod-plinth {
  margin-top: 6px; width: 100%; height: 30px; border-radius: 8px 8px 0 0;
  border: 2px solid var(--ink); border-bottom: none; background: var(--surface-2);
  display: flex; align-items: center; justify-content: center;
  font-family: var(--display); font-size: var(--fs-5); color: var(--ink);
}
.lb-pod.p1 .pod-plinth { height: 46px; background: var(--gold); font-size: var(--fs-6); }
.lb-pod.p2 .pod-plinth { height: 34px; background: #cfd6dd; }
.lb-pod.p3 .pod-plinth { height: 24px; background: #d09a63; }
/* where YOU stand, under the podium: the reason the standings matter to you */
.lb-youare {
  display: flex; align-items: baseline; gap: 8px; margin-top: 12px;
  padding: 8px 10px; border: 2px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface-2);
}
/* THE STEP RACE, from market-quality-mockups/race.html + race-open.html.
   A <details> banner in the Dark Spires / Bone Garden family, so the art has a
   permanent home instead of appearing once in the announcement. The board is a
   TRACK: the fill is distance relative to the leader and the marker is the
   racer's own Bonehead, because "people want to see and show off their guy"
   (Tom, 2026-08-08) and a rank number tells you nothing about who is ahead. */
.race-banner > summary { position: relative; overflow: hidden; }
.race-banner .race-ico { background: var(--coral); display: flex; align-items: center; justify-content: center; }
/* the player's own Bonehead, clipped into the corner of the summary */
.race-art {
  position: absolute; right: -10px; bottom: -34px; width: 96px; height: 110px;
  opacity: .42; pointer-events: none; z-index: 0;
}
.race-art .bh-anim { position: absolute; inset: 0; }
.race-art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.race-banner .gbn-txt, .race-banner .gbn-ico, .race-banner .gbn-chev { position: relative; z-index: 1; }

/* THE TITLE IS THE TITLE. v1 of this banner demoted it to a 10px uppercase
   eyebrow with the countdown in grey body text, and Tom called it: "your old
   step race banner looked cooler whyd you change it?" Collapsing the card was
   the ask; shrinking its identity was not. */
.race-h { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
.race-h b {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-5);
  letter-spacing: .03em; color: var(--text);
}
.race-clock {
  flex: none; display: inline-flex; align-items: center;
  background: var(--coral); color: #2a0d08; border: 2px solid var(--ink);
  border-radius: 999px; box-shadow: var(--sh-sm);
  padding: 1px 9px; font-size: var(--fs-0); font-weight: 800; letter-spacing: .06em;
  white-space: nowrap;
}
.race-banner .gbn-txt small { display: block; font-size: var(--fs-2); color: var(--text); }
.race-banner .gbn-txt small b { color: var(--accent); font-weight: 800; }

.race-champ {
  display: flex; align-items: center; gap: 9px; margin-bottom: 12px;
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 8px 11px;
  font-size: var(--fs-1); color: var(--text-2); line-height: 1.35;
}
.race-champ b { color: var(--gold); font-weight: 800; }

.race-lanes { display: grid; gap: 7px; }
.race-lane { display: grid; min-width: 0; min-height: 44px; }
.race-lane .rk {
  width: 26px; height: 26px; flex: 0 0 26px; display: grid; place-items: center;
  font-family: var(--display); font-size: var(--fs-2); color: var(--ink);
  background: var(--surface-3); border: 2px solid var(--ink); border-radius: 8px;
  box-shadow: var(--sh-sm);
  /* Tom 2026-09-17: "i dont like how the 1, 2, 3 are not centred in the boxes". Measured on
     the rendered card: every digit's ink sat 3.25px low and 0.5px right. Cause: the Shop
     rack's card class is also `.rk` (padding: 6px 6px 8px) and leaked in here, on top of a
     display face that rides low in its line box. These values measure +0.25px / within
     0.5px on ranks 1 to 5. */
  line-height: 1; padding: 1px 1px 0 0;
}
/* Native lane buttons share the same layout and minimum touch target. */
.race-lane.tap {
  appearance: none; -webkit-appearance: none;
  background: none; border: 0; padding: 0; margin: 0;
  font: inherit; color: inherit; text-align: left; width: 100%;
  min-height: 44px; cursor: pointer;
}
.race-lane.r1 .rk { background: var(--gold); }
.race-lane.r2 .rk { background: #cdd3da; }
.race-lane.r3 .rk { background: #d69a6a; }
.race-lane.r4 .rk, .race-lane.r5 .rk { color: var(--text-2); }
/* the leader's marker is centred on 100% and would be sliced by the card edge */
.race-lane .bd { min-width: 0; padding-right: 13px; }
/* The marker overhangs by 11px. Keep a 12px gap below the unwrapped row. */
.race-lane .nm { display: flex; flex-wrap: nowrap; align-items: baseline; gap: 6px; margin-bottom: 12px; }
.race-lane .nm b {
  min-width: 0; flex: 0 1 auto;
  font-size: var(--fs-2); font-weight: 700; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.race-lane .nm .st { flex: 0 0 auto; white-space: nowrap; font-size: var(--fs-1); color: var(--text-2); font-variant-numeric: tabular-nums; }
.race-lane .track {
  position: relative; height: 20px; background: var(--surface-3);
  border: 2px solid var(--ink); border-radius: 999px;
}
.race-lane .track > i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 999px; background: var(--text-3); transition: width .6s ease; } /* l-live-crew: the 30s refetch moves the bar in place */
.race-lane.r1 .track > i { background: var(--gold); }
/* TOM, 2026-08-14: "2nd place's silver bar reads LOUDER than the winner's gold.
   The hero must win that contest outright."
   He was right and the number is worse than the note. The mockup's silver was
   #cdd3da at 0.646 relative luminance against this app's --gold #ffc961 at
   0.639: the runner-up's bar was literally brighter than the winner's. These
   are picked so brightness falls monotonically with place and gold wins by a
   clear margin, not by a hair:
       gold #ffc961 0.639   silver #9aa2ab 0.357 (1.8x)   bronze #a87c58 0.234 (2.7x)
   Both sit ABOVE the .you rule below, so your own lane still reads as yours. */
.race-lane.r2 .track > i { background: #9aa2ab; }
.race-lane.r3 .track > i { background: #a87c58; }
.race-lane.you .track > i { background: var(--accent); }
.race-lane .run {
  position: absolute; top: -11px; transform: translateX(-50%); transition: left .6s ease;
  width: 32px; height: 32px; overflow: hidden;
  background: var(--surface-3); border: 2px solid var(--ink); border-radius: 999px;
  box-shadow: var(--sh-sm);
}
/* zoom to the HEAD: the source art is full-body, so a plain contain fit makes
   every racer read as the same grey smudge at 32px */
.race-lane .run .bh-anim { position: absolute; inset: 0; }
.race-lane .run img { position: absolute; left: 50%; top: -24%; width: 250%; height: auto; transform: translateX(-50%); }
.race-lane.r1 .run { border-color: var(--gold); }
.race-lane.you .run { border-color: var(--accent); }
.race-lane.you .nm b { color: var(--accent); }

/* ==================== THE SETTLED RESULT ====================
   Built to market-quality-mockups/race-results-banner.html, which Tom approved
   on 2026-08-14. Gold-led rather than coral: the app's coral is the "do this
   now" colour and a settled result is a podium, not an alarm. Lanes, rank chips
   and markers are the SAME .race-lane component the Crew board uses, so the two
   surfaces cannot drift.
   THE POSTER'S RULES LEFT WITH THE POSTER on 2026-08-25 (.rr-veil, .rr-scroll,
   .rr-hero, .rr-fig, .rr-who, .rr-crown, .rr-shout, .rr-haul, .rr-sect,
   .rr-foot). It was a full-screen veil opened only by a launch takeover, so it
   went with the takeover, and a rule scoped to a class nothing renders is a rule
   nobody can tell is dead. The banner below is untouched. */

/* the purse, as the thing a person won rather than a column in a table */
.rr-prize { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
.rr-prize .pz { display: inline-flex; align-items: center; gap: 4px;
  background: var(--surface-3); border: 2px solid var(--ink); border-radius: 999px;
  padding: 2px 9px 2px 7px; font-size: var(--fs-0); font-weight: 800; color: var(--text);
  white-space: nowrap; }
.pz svg { flex: none; }

/* THE HYPE BANNER'S SHELL IS GONE, ITS FIGURE BOX IS NOT, 2026-09-03. The banner
   that stood on Today from 2026-08-21 was two halves in one frame: a coral wash,
   an eyebrow spanning both columns, a caption under each and a hairline between
   them. It moved into the news pill that day as ONE featured banner with one
   subject (Tom: "we need to have one stand out banner that is showing more than a
   list of homework in the new section"), so the two-column furniture had nothing
   left to hold and is deleted rather than left as rules no selector can reach.
   The wash and the --hf knob were not deleted, they were RE-HOMED on .nb-hero
   further down this file, comments and measured background-size fix and all.
   .hype-fig stays exactly where it was, because it is not banner furniture: it is
   the box hypePlateHtml's measured percentages are computed against, and the hero
   is its caller now. Its size comes from --hf on whatever contains it. */
.hype-fig { position: relative; display: block; flex: 0 0 auto; width: var(--hf); height: var(--hf);
  overflow: visible; filter: drop-shadow(2px 3px 0 rgba(0, 0, 0, .45)); }
.hype-fig img { position: absolute; left: 0; top: 0; max-width: none; }

/* THE TODAY BANNER: the permanent home for the result, and the surface that
   carries EVERY place's purse (the poster drops them to keep 4th and 5th above
   the fold on a 375px phone). Same <details> shape as the Spires and Garden
   banners, so it is a pattern players already know. */
.rr-banner { background: linear-gradient(160deg, rgba(var(--gold-rgb), .14), rgba(22, 21, 29, .92) 58%), var(--surface);
  border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--sh);
  margin-bottom: 12px; overflow: hidden; }
.rr-banner > summary { list-style: none; display: flex; align-items: center; gap: 11px;
  padding: 11px 13px; cursor: pointer; }
.rr-banner > summary::-webkit-details-marker { display: none; }
.rr-banner .rr-ico { width: 38px; height: 38px; flex: none; display: grid; place-items: center;
  border-radius: 11px; background: var(--gold); border: 2px solid var(--ink);
  box-shadow: var(--sh-sm); color: var(--ink); }
.rr-banner .gbn-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rr-banner .gbn-txt i { color: var(--gold); }
.rr-banner .race-h { margin: 1px 0 2px; min-width: 0; }
.rr-banner .race-h b { font-family: var(--display); font-weight: 400; font-size: var(--fs-5);
  letter-spacing: .03em; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rr-banner .race-h .pill { flex: none; display: inline-flex; align-items: center;
  background: var(--coral); color: #2a0d08; border: 2px solid var(--ink);
  border-radius: 999px; padding: 1px 8px; font-size: var(--fs-0); font-weight: 800;
  letter-spacing: .06em; white-space: nowrap; }
.rr-banner .gbn-txt small { display: block; font-size: var(--fs-2); color: var(--text-2); }
.rr-banner .gbn-txt small b { color: var(--gold); font-weight: 800; }
.rr-banner .gbn-chev { flex: none; color: var(--text-2); font-size: var(--fs-5); line-height: 1; }
.rr-banner[open] .gbn-chev { transform: rotate(90deg); }
.rr-banner .gbn-body { padding: 2px 13px 13px; }
.rr-banner .race-lanes { margin-bottom: 10px; }
.rr-banner .note { margin: 0; font-size: var(--fs-1); line-height: 1.4; color: var(--text-3); }

/* AND IT SITS INSIDE THE NEWS PILL SINCE 2026-09-03 (Tom: "things will live in
   the collapsed news pill"), where it is a ROW of a list rather than a card on a
   page. The gold gradient stays, because that is what makes a settled result read
   as a result; the card furniture goes, or a rounded, shadowed, outlined box
   floats inside a flush list of .nb-row siblings. Same border-bottom the rows
   carry, so the seam between it and the first announcement is one hairline. */
#newsBanner .rr-banner { border: none; border-bottom: 1px solid var(--line);
  border-radius: 0; box-shadow: none; margin-bottom: 0; }

.race-gap { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--line); font-size: var(--fs-1); color: var(--text-2); }
.race-gap b { color: var(--accent); font-weight: 800; }
/* l-live-crew: "<Name> just passed you, N to take it back", shown 8s by hydrateRace's live tick */
.race-passed { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--line); font-size: var(--fs-1); color: var(--accent); font-weight: 800; }

.race-purse { margin-top: 12px; padding-top: 11px; border-top: 2px solid var(--line); }
.race-purse .lab {
  display: block; font-size: var(--fs-0); letter-spacing: .09em; font-weight: 800;
  text-transform: uppercase; color: var(--accent); margin-bottom: 8px;
}
.race-purse .rows { display: grid; gap: 6px; }
.race-purse .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.race-purse .row .pl { width: 30px; font-family: var(--display); font-size: var(--fs-2); color: var(--text-2); }
.race-purse .row.p1 .pl { color: var(--gold); }
.race-purse .row.p2 .pl { color: #cdd3da; }
.race-purse .row.p3 .pl { color: #d69a6a; }
.lb-wait { margin-top: 10px; font-size: var(--fs-1); color: var(--text-3); }
.lb-youare b { font-family: var(--display); font-size: var(--fs-4); color: var(--accent); }
.lb-youare span { font-size: var(--fs-1); color: var(--text-2); min-width: 0; }
.lb-pod {
  flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px;
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 9px 6px 8px;
}
.lb-pod b { font-size: var(--fs-1); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lb-pod small { color: var(--text-3); font-size: var(--fs-0); }
.lb-pod-av { width: 54px; height: 54px; position: relative; border-radius: 12px; overflow: hidden; background: var(--surface); }
.lb-pod-av .bh-anim { position: absolute; inset: 0; }
.lb-pod-av img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.lb-pod.p1 { padding: 13px 6px 12px; border-color: rgba(255, 215, 94, 0.5); box-shadow: 0 0 16px rgba(255, 215, 94, 0.12); }
.lb-pod.p1 .lb-pod-av { width: 68px; height: 68px; }
.lb-medal { font-family: var(--display); font-size: var(--fs-1); padding: 1px 8px; border-radius: 999px; background: var(--surface); color: var(--text-3); white-space: nowrap; }
.lb-pod.p1 .lb-medal { color: #ffd75e; }
.lb-pod.p2 .lb-medal { color: #cfd6e4; }
.lb-pod.p3 .lb-medal { color: #d9a05b; }
/* sheet rows: the player's actual Bonehead next to their rank */
.lb-av { width: 42px; height: 42px; flex: none; border-radius: 10px; overflow: hidden; background: var(--surface-2); position: relative; }
.lb-av .bh-anim { position: absolute; inset: 0; }
.lb-av img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.lb-row { display: flex; align-items: center; gap: 12px; padding: 11px 4px; border-bottom: 1px solid var(--line); }
.lb-row:last-child { border-bottom: 0; }
/* a row you can open has to look like one */
.lb-row[data-lbview] { cursor: pointer; }
.lb-row[data-lbview]:active { background: rgba(255, 255, 255, 0.05); }
.lb-row[data-lbview] .lb-who b::after { content: ' ›'; color: var(--text-2); font-weight: 400; }
.lb-row.me { background: rgba(165, 232, 71, 0.07); border-radius: 10px; padding-left: 8px; padding-right: 8px; }
.lb-rank { flex: 0 0 30px; text-align: center; font-family: var(--display); font-size: var(--fs-4); color: var(--text-3); }
.lb-rank.r1 { color: #ffd75e; font-size: var(--fs-5); }
.lb-rank.r2 { color: #cfd6e4; font-size: var(--fs-5); }
.lb-rank.r3 { color: #d9a05b; font-size: var(--fs-4); }
.lb-who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
.lb-who b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.social-name { display: flex; align-items: center; gap: 5px; min-width: 0; max-width: 100%; }
.social-name > b, .social-name-text { min-width: 0; overflow-wrap: anywhere; }
.social-name .gang-crest { flex: none; }
.cfan-plate .social-name > b { flex: 1; }
.lb-who .social-name > b {
  flex: 0 1 auto; white-space: normal; overflow-wrap: anywhere;
  overflow: visible; text-overflow: clip; line-height: 1.15;
}
#lbBody .lb-row { display: grid; grid-template-columns: 30px 52px minmax(0, 1fr) 40px 70px; padding-left: 4px; padding-right: 4px; }
#lbBody .lb-who .social-name { display: block; }
#lbBody .lb-who .social-name > b { display: inline; }
#lbBody .lb-who .gang-crest { display: inline-block; vertical-align: middle; margin-left: 5px; }
#lbBody .lb-row > .lb-medal { box-sizing: border-box; width: 40px; text-align: center; }
.pod-name.social-name { justify-content: center; }
.pod-name .social-name-text { min-width: 0; }
.sheet-head .fp-heading { flex: 1; min-width: 0; }
.fp-heading .social-name-text { flex: 1; min-width: 0; padding-right: .14em; }
.gang-explainer { display: grid; gap: 14px; }
.gang-explainer-gw { display: flex; align-items: flex-end; gap: 12px; }
.gang-explainer-gw > img { width: 84px; height: auto; flex: none; image-rendering: pixelated; }
.gang-explainer-gw .talkbox { flex: 1; margin: 0; min-width: 0; }
.gang-explainer-beats { display: grid; gap: 10px; }
.gang-explainer-beat { display: grid; grid-template-columns: 56px minmax(0, 1fr); align-items: center; gap: 12px; padding: 12px; }
.gang-explainer-icon { width: 56px; height: 56px; display: grid; place-items: center; border-radius: 12px; background: var(--surface-3); }
.gang-explainer-icon img { width: 40px; height: 40px; object-fit: contain; image-rendering: pixelated; }
.gang-explainer-icon svg { width: 34px; height: 34px; }
.gang-explainer-beat b { display: block; font: 400 var(--fs-4)/1 var(--display); letter-spacing: .6px; margin-bottom: 4px; }
.gang-explainer-beat div > span { color: var(--text-2); line-height: 1.3; }
.gang-explainer-go { width: 100%; font: 400 var(--fs-5) var(--display); }
.gang-explainer-link { display: block; margin: 12px auto 0; border: 0; background: none; color: var(--accent); text-decoration: underline; cursor: pointer; }
.today-gang { display: flex; align-items: center; gap: 14px; width: 100%; text-align: left; padding: 14px; border: 2px solid var(--line-strong); border-radius: 18px; background: var(--surface-2); color: var(--text); }
.today-gang.picked { border-color: var(--gang); }
.today-gang-stack { display: flex; flex: none; }
.today-gang-stack .gang-crest + .gang-crest { margin-left: -12px; }
.today-gang > .gang-crest { flex: none; }
.today-gang-copy { flex: 1; min-width: 0; }
.today-gang-copy b { display: block; min-width: 0; padding-right: .14em; font: 400 var(--fs-5)/1 var(--display); letter-spacing: .6px; white-space: normal; overflow-wrap: anywhere; }
.today-gang-copy small { display: block; color: var(--text-2); font-size: var(--fs-2); line-height: 1.3; }
.today-gang-go { flex: none; color: var(--accent); font: 400 var(--fs-5) var(--display); }
.today-gang-bar { display: block; height: 6px; margin-top: 6px; border-radius: 9px; background: var(--surface-3); overflow: hidden; }
.today-gang-bar i { display: block; height: 100%; background: var(--gang); }
.lb-who small { color: var(--text-3); font-size: var(--fs-1); }
.lb-tag { flex: 0 0 auto; font-size: var(--fs-1); font-weight: 800; padding: 4px 10px; border-radius: 999px; }
.lb-tag.you { background: var(--surface-2); color: var(--text-3); }
.lb-tag.crew { background: rgba(165, 232, 71, 0.16); color: var(--accent); }
.lb-tag.sent { background: var(--surface-2); color: var(--text-2); }
/* online / last-seen cues (Crew cards + leaderboard) */
.lb-seen { color: var(--text-3); }
.lb-seen.on { color: var(--accent); font-weight: 700; }

/* ---- friends sheet ---- */
.friends-add { display: flex; gap: 8px; margin-bottom: 8px; }
.friends-add input {
  flex: 1; background: var(--surface-2); border: 1px solid var(--line); border-radius: 12px;
  padding: 11px 13px; color: var(--text); font-size: var(--fs-3); text-transform: uppercase; letter-spacing: 0.04em;
}
.friends-add input::placeholder { text-transform: none; letter-spacing: 0; color: var(--text-3); }
.friends-mynote { margin: 0 2px 14px; }
.friends-loading { text-align: center; color: var(--text-3); padding: 24px 0; }
.friends-empty { text-align: center; padding: 18px 8px 8px; }
.fe-title { font-family: var(--display); font-size: var(--fs-5); letter-spacing: 0.5px; color: var(--text); margin-bottom: 6px; }
.fl-sect { margin-bottom: 16px; }
.fl-h { font-size: var(--fs-0); letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); font-weight: 700; margin: 0 2px 8px; }
.fl-row {
  display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px;
  padding: 10px 12px; margin-bottom: 8px; color: var(--text);
}
.fl-row.tap { cursor: pointer; }
.fl-row.tap:active { transform: scale(0.99); }
.fl-av { width: 46px; height: 46px; flex: none; border-radius: 11px; overflow: hidden; background: var(--surface); position: relative; }
.fl-av .bh-anim { position: absolute; inset: 0; }
.fl-av img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.fl-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.fl-main b { font-size: var(--fs-3); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fl-main span { font-size: var(--fs-1); color: var(--text-3); }
.fl-actions { display: flex; gap: 6px; flex: none; }

/* ---- friend profile sheet ---- */
.fp-hero { display: flex; justify-content: center; margin: 4px 0 6px; }
.fp-title { text-align: center; margin-bottom: 10px; }
.fp-lvl { font-family: var(--display); font-size: var(--fs-title); letter-spacing: 0.5px; color: var(--accent); }
.fp-class { font-size: var(--fs-2); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; }
.fp-facts { display: flex; gap: 10px; margin: 14px 0 6px; }
.fp-fact {
  flex: 1; text-align: center; background: var(--surface-2); border: 1px solid var(--line);
  border-radius: 14px; padding: 12px 6px;
}
.fp-fact b { display: block; font-size: var(--fs-4); font-weight: 800; }
.fp-fact span { font-size: var(--fs-0); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.05em; }
/* Their paddock total and the way into their field, on a friend's profile. */
.fp-yard { margin: 14px 0 6px; }
.fp-yard-h {
  display: flex; align-items: baseline; justify-content: space-between;
  font-size: var(--fs-0); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.05em;
  margin-bottom: 8px;
}
.fp-yard-h b { font-family: var(--display); font-weight: 400; font-size: var(--fs-3); color: var(--gold); letter-spacing: .06em; }
.fp-yard-go { width: 100%; margin-top: 10px; }
/* A friend's paddock has no collection panel to mount (their bonds and levels
   never leave their phone), so the panel below the scene is just the count. */
.pdk-panel.fpdk-note { min-height: 0; padding: 18px 14px 26px; text-align: center; }
.fpdk-note b { font-family: var(--display); font-weight: 400; font-size: var(--fs-5);
  color: var(--gold); letter-spacing: .06em; }
.fpdk-note .note { margin: 4px 0 0; }
.fp-real { font-size: var(--fs-1); color: var(--text-3); margin-top: 4px; }
.fp-alias { margin-top: 16px; }
.fp-alias .nb-lab { display: flex; align-items: baseline; gap: 6px; }
.fp-alias-hint { text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--text-3); }
.fp-alias-row { display: flex; gap: 8px; }
.fp-alias-row input {
  flex: 1; background: var(--surface-2); border: 1px solid var(--line); border-radius: 12px;
  padding: 11px 13px; color: var(--text); font-size: var(--fs-3);
}
.fp-remove { width: 100%; margin-top: 14px; }

/* ===== v323: THE CREW FAN. Friends as a fanned hand of trading cards.
   Approved mockup: market-quality-mockups/crew-fan.html. Geometry (rotation,
   scale, arc) is set inline by applyFan(); this block owns the card anatomy,
   the deck chrome (ink border, sticker shadow, coral only on the featured
   card + active dot) and the glide. ===== */
.cfan-block { margin-bottom: 14px; }
.cfan-head { display: flex; align-items: center; gap: 8px; margin: 2px 0 6px; }
.cfan-head > span {
  font-size: var(--fs-0); font-weight: 800; letter-spacing: 0.09em;
  text-transform: uppercase; color: var(--accent);
}
#cfanCount { color: var(--coral); }
/* Tom, 2026-09-18: "pull that dumb ass line divider ui from all over". The 2px
   rule between a section title and its label read as an AI-template flourish
   (he first flagged it 2026-09-17 for the Backpack). Hidden everywhere now; the
   right-side chip is held by .r/.spin margin-left:auto, so the header keeps its
   title-left, label-right shape without the line. */
.cfan-head i { display: none; }
.cfan-head small { font-size: var(--fs-tiny); font-weight: 800; letter-spacing: 0.07em; color: var(--text-3); }

.cfan-faves { display: flex; align-items: center; gap: 7px; min-height: 34px; margin-bottom: 2px; }
.cfan-fv {
  position: relative; width: 44px; height: 44px; flex-shrink: 0; padding: 0; cursor: pointer;
  border: 0; background: transparent; box-shadow: none;
}
.cfan-fv::before {
  content: ""; position: absolute; inset: 5px; border-radius: 50%; pointer-events: none;
  border: 2px solid var(--gold); background: var(--surface-2);
  box-shadow: 0 0 0 2px var(--ink), 2px 3px 0 rgba(0, 0, 0, 0.45);
}
/* Preserve the 34px circle and its 30px art inside the larger hit target. */
.cfan-fv canvas { position: absolute; inset: 7px; width: 30px; height: 30px; border-radius: 50%; pointer-events: none; }
.cfan-faves small { font-size: var(--fs-tiny); font-weight: 800; letter-spacing: 0.06em; color: var(--text-3); }

/* the fan: full-bleed stage; a drag must move the DECK, never lift a gear layer
   off a Bonehead (see the pointer handlers in renderFriends) */
/* Tom, 2026-08-08: "im able to scroll the whole app left to right on that tab
   it's not constrained to the width of the phone."
   Cards are absolutely positioned and flung sideways out of the wrap, and nothing
   clipped them: measured, a card sat at right: 476 in a 393px viewport. WKWebView
   turns that overhang into a real sideways scroll of the whole app.
   `clip`, not `hidden`: hidden makes this a scroll container and would swallow the
   fan's own gestures, and clip is the one overflow value that lets the other axis
   stay visible, which the fan's upward arc needs. */
/* Tom, 2026-09-18: "the arrow is behind the cards so it doesnt feel right to hit."
   348px was the fan's UN-ROTATED extent (10 top + 270 card + 70 arc). The outer
   seats rotate 21deg about the bottom centre, which drops a 75px half-width's
   corner another 27px: measured at 375x812 the outer cards' boxes reached 23px
   into the pager row and elementFromPoint at the arrows' corners returned a
   card. 378 is the rotated extent, so the controls below own their own row. */
.cfan-wrap {
  position: relative; height: 378px; margin: 0 calc(-1 * var(--pad));
  overflow-x: clip; overflow-y: visible;
  touch-action: pan-y; user-select: none; -webkit-user-select: none;
}
.cfan-deck { position: absolute; inset: 0; transition: transform 0.35s cubic-bezier(0.3, 1.25, 0.55, 1); }
.cfan-card {
  position: absolute; left: 50%; top: 10px; width: 194px; height: 270px; margin-left: -97px;
  border-radius: 14px; overflow: hidden; padding: 0; cursor: pointer;
  background: var(--surface); border: 2px solid var(--ink); box-shadow: var(--sh-sm);
  transform-origin: 50% 100%;
  transition: transform 0.45s cubic-bezier(0.3, 1.25, 0.55, 1), opacity 0.3s, border-color 0.25s, box-shadow 0.25s;
}
/* 2026-09-20: keep sealed gifts below the fan and on the first short screen.
   Remove 100px from both card and stage, preserving the rotated-card clearance. */
@media (max-width: 400px) and (max-height: 860px) {
  .cfan-wrap { height: 278px; }
  .cfan-card { height: 170px; }
}
.cfan-card.feat { border: 3px solid var(--coral); box-shadow: 0 0 0 2px var(--ink), 5px 6px 0 rgba(0, 0, 0, 0.55); }
/* CREW-9, 2026-09-05: the featured card's own rectangle is 194px wide, centred,
   and the immediate neighbour's seat is only ~64-67px off that same centre
   (measured via getBoundingClientRect + elementFromPoint), so a tap aimed at
   the neighbour always hit the featured card on top of it instead. Shrinking
   ONLY the featured card's hit region to a centred strip (still 97px wide,
   well over the 40px minimum tap target) frees its outer edges so whatever is
   genuinely on top there -- the neighbour -- receives the tap. pointer-events
   is inherited, so the featured card goes fully inert and .cfan-hit opts back
   in for the strip that stays its own. */
.cfan-card.feat { pointer-events: none; }
.cfan-hit { position: absolute; inset: 0 25% 0 25%; pointer-events: auto; }
.cfan-card img { pointer-events: none; -webkit-user-drag: none; }
.cfan-stage { position: absolute; inset: 0; }
.cfan-bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* figure contract: ~5% safe gutter, the Bonehead holds the centre. Tom on the
   first cut: "the boneheadz seem a touch small inside the card", so the figure
   gets the whole width above the plate rather than a square letterbox. */
.cfan-stage .bh-anim { position: absolute; inset: 5% 5% 19% 5%; --av-fit: contain; }
.cfan-stage .bh-anim img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
/* pet on the Bonehead's ground line, right tension line (figure-audit: cfan-pet).
   2026-09-26 Tom: the pet floated above the Bonehead. The pet is grounded now (ink
   on its box floor for every species, crewCardArtHtml), and its floor is his
   measured ground: 76px on the 270px card, where he is letterboxed above the
   plate; 56px on the 170px card, where the plate hides his shoes and the plate
   edge is the ground line. */
.cfan-pet { position: absolute; right: 4%; bottom: 76px; z-index: 1; line-height: 0; filter: drop-shadow(1px 2px 0 rgba(0, 0, 0, 0.35)); }
@media (max-width: 400px) and (max-height: 860px) { .cfan-pet { bottom: 56px; } }
.cfan-stage::after { /* ink scrim so the plate reads over any backdrop */
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 72px;
  background: linear-gradient(transparent, rgba(8, 7, 14, 0.82) 62%); pointer-events: none;
}
.cfan-plate {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; padding: 5px 10px 8px;
  border-top: 2px solid var(--ink); background: var(--surface);
  display: flex; flex-direction: column; gap: 1px; text-align: left;
}
/* ELLIPSIS, NOT CLIP. Tom, 2026-08-22: "the crew tab: some people's pets get cut
   off in the crew card album layout" on long titles and nicknames. Measured on the
   worst real case ("Bone Grand Master 113" plus a long alias) at 393x852: the
   plate ran to 74px over three lines and ate the pet; the name overflowed with
   text-overflow CLIP, so it was sliced mid-glyph with no signal it continued.
   With the title in its own ellipsising span the plate measures 58px, 16px of
   which goes back to the art, and both lines end in an ellipsis instead of a cut
   letter. Measured 2026-08-23, before 74px / after 58px. */
.cfan-plate b { font-family: var(--display); font-weight: 400; font-size: var(--fs-body); letter-spacing: 0.04em; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cfan-plate .cfan-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cfan-plate small {
  font-size: var(--fs-tiny); font-weight: 800; letter-spacing: 0.07em; text-transform: uppercase;
  color: var(--text-3); display: flex; align-items: center; gap: 5px;
}
.cfan-plate small.cfan-snapshot { text-transform: none; letter-spacing: 0; }
.cfan-plate .lv {
  margin-left: auto; background: var(--gold); color: var(--ink); border: 2px solid var(--ink);
  border-radius: 7px; padding: 0 6px; font-family: var(--display); font-size: var(--fs-1); letter-spacing: 0.03em;
  /* nowrap and no-shrink, and this is the half that actually saves the pet.
     The title ellipsising is not enough on its own: measured on the worst real
     case, .cfan-title was 13.5px tall while THIS badge was 36.4px, because a long
     title squeezed the flex row until "LV 113" broke across lines inside its own
     pill. The plate grew to 74px over three lines and ate the art below it.
     Measured 2026-08-23: 74px -> 57.8px, the pill 36.4px -> 20.2px.

     It is `flex` and not `white-space` that does this, which is worth writing down
     because the obvious guess is wrong. The first version set BOTH, and removing
     nowrap while keeping this line left the guard GREEN: the pill was never
     breaking on its own whitespace, it was being SQUEEZED, so refusing to shrink
     is the whole fix. Removing this line takes the plate straight back to 74px. */
  flex: 0 0 auto;
}
.cfan-live { position: absolute; top: 8px; right: 8px; z-index: 2; width: 11px; height: 11px; border-radius: 50%; background: var(--accent); border: 2px solid var(--ink); }
/* CREW-4: the one-line "since last time" signal, its own row under the title
   line so it never fights the LV pill for space; accent because it is the
   one thing on the plate that just became true. Selector beats the plain
   `.cfan-plate small` rule above on specificity, no !important needed. */
.cfan-plate small.cfan-since { color: var(--accent); text-transform: none; letter-spacing: 0; }
/* CREW-13: same voice as .lb-spires (the leaderboard's own spire tag) so a
   spire reads the same wherever it shows up. */
.cfan-plate small.cfan-spires { color: var(--gold); text-transform: none; letter-spacing: 0; display: flex; align-items: center; gap: 4px; }
.cfan-fstar { position: absolute; top: 5px; left: 8px; z-index: 2; display: grid; place-items: center;
  /* was a 15px ★ with an ink text-shadow faking a keyline; ICONS.star draws its own
     stroke, so the shadow becomes a drop-shadow on the svg instead of on glyph edges */
  filter: drop-shadow(1px 1px 0 var(--ink)) drop-shadow(-1px -1px 0 var(--ink)); }

.cfan-pager { display: flex; align-items: center; justify-content: center; gap: 14px; margin: 6px 0 0; }
/* Same report: one dot per friend, so a 24-friend crew's dots ran 329px wide in a
   343px row and the flex row shrank each arrow to 20px and pushed it half off
   screen (measured at 375: prev at x -11..9, next at 366..386). The arrows keep
   their 40px and the dot strip is the thing that yields, clipped, with the
   active dot scrolled to its centre in applyFan. */
.cfan-arrow {
  flex: none;
  width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; cursor: pointer;
  background: var(--surface); border: 2px solid var(--ink); box-shadow: var(--sh-sm); color: var(--text);
}
.cfan-arrow:active { transform: translate(1px, 1px); box-shadow: 1px 2px 0 rgba(0, 0, 0, 0.45); }
.cfan-arrow.prev svg { transform: scaleX(-1); }
/* min-width 0 lets the strip shrink below its content; the 3px pad/negative
   margin pair keeps the rotated (and 1.25x active) dots' corners inside the clip
   without changing the strip's footprint for a small crew. */
.cfan-dots { display: flex; gap: 7px; min-width: 0; overflow: hidden; padding: 3px; margin: -3px; }
.cfan-dots i { width: 7px; height: 7px; border-radius: 2px; background: var(--surface-3); border: 1.5px solid var(--ink); transform: rotate(45deg); transition: background 0.2s; }
.cfan-dots i.on { background: var(--coral); transform: rotate(45deg) scale(1.25); }

.cfan-sel {
  display: flex; align-items: center; gap: 10px; margin-top: 8px; padding: 10px 12px;
  background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius-sm); box-shadow: var(--sh);
}
.cfan-star {
  flex: none; width: 40px; height: 40px; border-radius: 12px; padding: 0; line-height: 1; cursor: pointer;
  display: grid; place-items: center; font-size: var(--fs-5); color: var(--text-3);
  background: var(--surface-2); border: 2px solid var(--ink); box-shadow: var(--sh-sm);
}
.cfan-star.on { color: var(--ink); background: var(--gold); }
.cfan-sel-tx { flex: 1; min-width: 0; }
.cfan-sel-nm { display: flex; align-items: baseline; gap: 7px; padding: 0; text-align: left; background: none; border: none; cursor: pointer; font-family: var(--display); font-size: var(--fs-5); letter-spacing: 0.03em; color: var(--text); }
.cfan-sel-nm em { font-style: normal; font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; font-size: var(--fs-0); color: var(--text-3); white-space: nowrap; }
.cfan-chips { display: flex; gap: 5px; margin-top: 6px; flex-wrap: wrap; }
.cfan-chip {
  display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px;
  border: 2px solid var(--ink); border-radius: 8px; background: var(--surface-2);
  font-size: var(--fs-0); font-weight: 800; color: var(--text);
}
.cfan-chip.lvl { background: var(--gold); color: var(--ink); }
.cfan-chip.pet { background: var(--violet); color: var(--ink); }
.cfan-acts { display: flex; flex-direction: column; gap: 7px; }
.cfan-acts .btn.gift { background: var(--coral); color: #2a0d08; }
@media (prefers-reduced-motion: reduce) {
  .cfan-card, .cfan-deck { transition: none; }
}

/* rich profile hero */
.fp-hero { position: relative; display: flex; justify-content: center; align-items: flex-end; margin: 4px 0 8px; min-height: 216px; }
/* only draw the frame/box when there's a real background to fill it; otherwise the
   bonehead + pet sit cleanly on the sheet with no floating outline (Tom: "everything
   fits in the box, or there's no outline at all") */
.fp-hero.framed { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
.fp-hero-backdrop { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
.fp-hero .bh-stage.lg { position: relative; z-index: 1; background: transparent; border: none; border-radius: 0; } /* kill the base bh-stage frame so there's no faint inner box */
/* pet tucked in beside the bonehead's feet (was stranded at the far edge) */
/* width:auto so a long pet is not squashed by a forced square. The pet reported as
   "half cut off" was NOT clipped: both lizard colourways have a large cream belly
   (identical 2.8% near-white pixels, measured), and at 70px that belly dominated the
   sprite and read as a blank cut edge. The fix is size, not overflow, so the friend
   profile now opts into mass scaling (70 -> 93px). */
.fp-pet { position: absolute; right: 20%; bottom: 6px; width: auto; max-width: 45%; text-align: center; z-index: 1; filter: drop-shadow(0 3px 4px rgba(0,0,0,.4)); }
.fp-pet .petanim, .fp-pet .petcrop, .fp-pet .pet-shiny-wrap { margin-inline: auto; }
/* size comes from croppedPetImg now; forcing 70 square clipped long pets */
/* out of flow, so the caption does not lift the pet off the Bonehead's baseline.
   It hangs BELOW the pet's box, and the box's bottom is only 6px above the hero's
   inner edge, so at -14px the badge's box ended 7px past a `.framed` hero (which
   is the one with overflow: hidden), 48% of "Lv 6" was inside it and the rest was
   sliced off. Measured 2026-08-23. It rides inside now: bottom -4px puts
   the badge 2px clear of the frame, and line-height 1 trims the 13.5px line box
   to the 10px glyphs so it fits the air under the pet. Measured on all 7 species:
   the drawing's ink (drop shadow included) bottoms out 10px (Catfish) to 17.5px
   above that edge, so the badge clears every one of them. Do NOT restore the
   overhang: the fix is the badge's position, never the pet's. */
.fp-pet-lvl { position: absolute; left: 0; right: 0; bottom: -4px; display: block; font-size: var(--fs-tiny); line-height: 1; font-weight: 800; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.05em; }
.fp-lvlbadge {
  position: absolute; left: 8%; top: 10px; z-index: 2; font-family: var(--display); font-size: var(--fs-4); letter-spacing: 0.5px;
  color: #12140b; background: var(--accent); border-radius: 999px; padding: 3px 12px;
}
.fp-battle { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 10px 0 8px; }
.fp-actions { display: flex; gap: 10px; margin: 6px 0 4px; }
.fp-actions .btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
.fp-gift svg { vertical-align: middle; }

/* stat bars */
.fp-stats-h { font-size: var(--fs-0); letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); font-weight: 700; margin: 18px 2px 8px; }
.fp-statbars { display: flex; flex-direction: column; gap: 8px; }
.fps-row { display: flex; align-items: center; gap: 10px; }
.fps-lab { width: 74px; flex: none; font-size: var(--fs-1); font-weight: 600; color: var(--text-3); }
.fps-bar { flex: 1; height: 9px; border-radius: 999px; background: rgba(255,255,255,0.07); overflow: hidden; }
.fps-bar i { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #7fb833, var(--accent)); }
.fps-val { width: 30px; flex: none; text-align: right; font-size: var(--fs-2); font-weight: 800; color: var(--text); }

/* gift sheet */
.gift-free {
  display: flex; align-items: center; gap: 12px; background: var(--surface-2); border: 1px solid var(--line);
  border-radius: 16px; padding: 14px; margin-bottom: 18px;
}
.gift-free.done { opacity: 0.6; }
.gift-free-l { flex: 1; min-width: 0; }
.gift-free-t { font-weight: 800; display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
.gift-free .btn { flex: none; }
.gift-amts { display: flex; flex-wrap: wrap; gap: 8px; }
.gift-amt { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; }
.gift-amt:disabled { opacity: 0.4; }

/* cheer sheet */
.cheer-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.cheer-chip {
  display: flex; align-items: center; gap: 10px; background: var(--surface-2); border: 1px solid var(--line);
  border-radius: 14px; padding: 12px 12px; color: var(--text); text-align: left;
}
.cheer-chip:active { transform: scale(0.97); }
.cheer-chip:disabled { opacity: 0.5; }
.cheer-emo { font-size: var(--fs-title); line-height: 1; flex: none; }
.cheer-txt { font-size: var(--fs-2); font-weight: 700; }

/* ===== icon pack (game-icons.net, flat no-rim + soft shadow) ===== */
.bhi-wrap { display: inline-flex; align-items: center; justify-content: center; line-height: 0; }
.bhi { display: block; filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45)); }

/* ===== Today: collapsible quests card ===== */
.q-collapse { background: var(--surface); border: 1px solid var(--line, #23202b); border-radius: 16px; margin-bottom: 12px; overflow: hidden; }
.q-collapse > summary {
  list-style: none; cursor: pointer; user-select: none;
  display: flex; align-items: center; gap: 8px; padding: 14px 16px;
  /* the display face, matching the feature dropdowns (.gbn-txt b) rather than the
     body-uppercase treatment that read as a different app's component */
  font-family: var(--display); font-weight: 400; font-size: var(--fs-4); letter-spacing: 0.02em;
  color: var(--text-2, #cfc6d6);
}
.q-collapse > summary::-webkit-details-marker { display: none; }
.q-collapse > summary::after { content: '▸'; margin-left: auto; color: var(--text-3); transition: transform 0.2s; }
.q-collapse[open] > summary::after { transform: rotate(90deg); }
.q-collapse[open] > summary { color: var(--accent); }
.q-sum-ico { display: inline-flex; width: 24px; height: 24px; align-items: center; justify-content: center; font-size: var(--fs-body); opacity: .9; }
.q-sum-ico svg { width: 18px; height: 18px; fill: currentColor; }
/* when a quest is claimable: warm the header + a soft accent edge so the eye
   catches it, WITHOUT a loud always-on treatment that unbalances the home feed */
.q-collapse.has-claim { border-color: rgba(165, 232, 71, 0.5); box-shadow: 0 0 0 1px rgba(165, 232, 71, 0.25), 0 0 14px rgba(165, 232, 71, 0.1); }
.q-collapse.has-claim > summary { color: var(--accent); }
.q-badge { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 8px; border-radius: 9px; background: var(--accent); color: #0d0c12; font-size: var(--fs-0); font-weight: 900; font-style: normal; letter-spacing: 0; white-space: nowrap; }
.q-card-body { padding: 0 16px 14px; }

/* ===== Pit victory reward pills ===== */
.reward-row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin: 0 auto 4px; max-width: 320px; }
.reward-pill {
  display: inline-flex; align-items: center; gap: 5px; padding: 7px 12px; border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 16%, var(--surface-2)); border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
  font-weight: 800; font-size: var(--fs-2); color: var(--text, #f2e9d7);
}
.reward-pill svg { flex: none; }

/* ===== egg hatch cinematic (bone egg -> shards -> pet) ===== */
/* THE STAGE MUST NOT BE SQUEEZED. Tom, 2026-08-10: "the end of the hatch egg
   animation has the egg shoot down to the bottom of the screen randomly at the
   end of its animation... it should be staying in the same spot during the hatch
   it doesn't need to be moving up or down on the screen at all just cracking in
   place."
   It was never an animation. .reveal-body is a centred flex column, and a flex
   item with a plain  is still SHRINKABLE: the instant the reveal panel
   became visible the stage was squeezed from 200px to 18px and pushed to the
   bottom of the column, dragging the egg 475px down the screen with it. Measured
   frame by frame: stage y182 h200 -> y677 h18 in one step.
   flex-shrink:0 with a matching min-height is what actually holds it still. */
.hatch-stage { position: relative; height: 200px; min-height: 200px; flex: 0 0 auto; display: grid; place-items: center; margin: 0 auto 4px; }
.hatch-glow {
  position: absolute; width: 220px; height: 220px; border-radius: 50%; opacity: 0;
  background: radial-gradient(circle, rgba(165, 232, 71, 0.28), transparent 62%);
}
/* NAMESPACED, AND THIS IS WHY. Tom, 2026-08-10: "the end of the hatch egg
   animation has the egg shoot down to the bottom of the screen randomly."
   The hatch stage used to add a bare `burst` class to itself, which collided with
   the COMBAT FX particle at line ~1466: `.burst { position:absolute; top:50%;
   width:14px; height:14px; transform:translate(-50%,-50%); animation:burstPop }`.
   So at the exact moment the egg cracked, the stage was yanked out of the flow,
   shrunk to a 14px dot and translated away. Measured: the stage went from
   y182 h200 to y495 h18 with transform matrix(1,0,0,1,-30.98,-100).
   Nothing was animating the egg anywhere. It was being turned into a combat
   spark by a name it happened to share. */
/* The stage retires 550ms after the burst. Keep the glow inside that window so
   it reaches its authored final scale instead of being cut off mid-pulse. */
.hatch-stage.egg-burst .hatch-glow { animation: hatchGlow 0.5s ease-out forwards; }
@keyframes hatchGlow { 0% { opacity: 0; transform: scale(0.6) } 40% { opacity: 1 } 100% { opacity: 0.55; transform: scale(1) } }
.hatch-flash {
  position: absolute; width: 60px; height: 60px; border-radius: 50%; opacity: 0;
  border: 4px solid var(--slime, #a5e847); box-shadow: 0 0 18px rgba(165, 232, 71, 0.7);
}
.hatch-stage.egg-burst .hatch-flash { animation: hatchFlash 0.5s ease-out forwards; }
@keyframes hatchFlash { 0% { opacity: 0.9; transform: scale(0.3) } 100% { opacity: 0; transform: scale(3.4) } }
.hatch-pet { position: absolute; transform: scale(0.1); opacity: 0; z-index: 3; }
.hatch-pet img { width: 150px; height: 150px; object-fit: contain; filter: drop-shadow(0 6px 6px rgba(0, 0, 0, 0.45)); }
.hatch-stage.egg-burst .hatch-pet { animation: hatchRise 0.7s cubic-bezier(0.2, 1.4, 0.4, 1) 0.12s forwards; }
@keyframes hatchRise { 0% { transform: scale(0.1) translateY(18px); opacity: 0 } 60% { opacity: 1 } 100% { transform: scale(1) translateY(0); opacity: 1 } }
.bone-egg {
  position: relative; width: 108px; height: 138px; z-index: 2;
  border-radius: 50% 50% 50% 50% / 62% 62% 40% 40%;
  background: radial-gradient(60% 55% at 42% 34%, #fbf5e6, #e9dcc0 55%, #cdbb95 100%);
  border: 3px solid #17140f; box-shadow: inset -8px -10px 16px rgba(120, 100, 66, 0.35), inset 6px 8px 12px rgba(255, 255, 255, 0.45), 0 8px 10px rgba(0, 0, 0, 0.35);
  /* IT DOES NOT MOVE. Tom, 2026-08-10: "the hatch should be staying in the same
     spot during the hatch it doesn't need to be moving up or down on the screen
     at all just cracking in place." eggDrop used to translateY(-40px) in, which
     is what threw it around at the end of the sequence. It fades and settles its
     scale now; nothing translates. */
  animation: eggIn 0.42s cubic-bezier(0.2, 1.3, 0.4, 1) both;
}
/* The "faint bone suture seam" that used to live here was a repeating-linear-
   gradient down the middle of the egg: a permanent dashed line, present before
   anything hatched and unrelated to the cracks. Tom, 2026-08-08: "i dont like
   the weird dotted line on the egg during the hatching animation..it should only
   be the cracks." Removed. The cracks are the only marks on the egg now. */
@keyframes eggIn { 0% { transform: scale(0.86); opacity: 0 } 70% { opacity: 1 } 100% { transform: scale(1); opacity: 1 } }
.bone-egg.wob { animation: eggWob 0.36s ease-in-out; }
/* ---- THE STEP EGG'S AUTHORED FRAMES ----
   THIS CSS DID NOT EXIST. js/app.js emits `<span class="egg-seq">` with fifteen
   <img> children and puts `.on` on the current one, and nothing anywhere
   positioned them or hid the rest. So every frame rendered, in flow, as a column
   of fifteen eggs running down the screen, and it has been shipping that way.
   Caught by recording the cinematic and watching it. I had "verified" this
   sequence by diffing the PNGs on disk against Tom's authored states and
   declaring it correct, which checks the art and not the screen. Tom watched the
   recording and said "the egg reveal you sent me is just the ghost crate", and
   fixing the recorder is what finally put the real thing in front of either of
   us. The hatch is reachable in the app only by walking an egg to term, which is
   why nobody had seen it.
   Same construction as .co-seq, which works: one box, absolutely positioned
   frames, only .on visible. 96px is 48px art at 2x, the scale the rest of the
   pixel art uses.
   .bone-egg.pix drops the vector egg underneath. The container paints a
   gradient-and-border egg of its own, which is the pre-pixel drawing, and it was
   sitting behind the sprites the whole time. */
/* 192px, AND DOWN WHERE THE PET LANDS. Tom, 2026-08-16: "it's too small on the
   screen and too high up too it should be down by where the actual pet you get
   shows."
   Measured on the running hatch: the egg drew 98x98 with its centre at y242,
   and the pet art that replaces it is 200x200 with its centre at y387. So the
   egg was HALF the size of the thing it turns into, and 145px above it, and the
   reveal jumped down and doubled at the same moment.
   192 is 4x the 48px art, which is the integer scale nearest the pet's 200 (4%
   under) and keeps the sprite on the pixel grid. `top` rather than a margin
   because .bone-egg is position:relative, so the offset moves the paint without
   changing the layout the shards and the glow are positioned against. */
.bone-egg.pix {
  width: 192px; height: 192px; border: 0; border-radius: 0;
  background: none; box-shadow: none;
  top: 145px;   /* measured: at 98 the egg centred on y340 against the pet's y387 */
}
.egg-seq { position: relative; display: block; width: 192px; height: 192px; }
.egg-seq img {
  position: absolute; inset: 0; width: 192px; height: 192px;
  image-rendering: pixelated; opacity: 0;
}
.egg-seq img.on { opacity: 1; }
/* Reduced motion gets the LAST pose, the smoke and the shells, not frame 1.
   Freezing on a whole egg would tell a player who cannot have animation that
   nothing hatched. Same call the crate sequence makes. */
@media (prefers-reduced-motion: reduce) {
  .egg-seq img { opacity: 0 !important; }
  .egg-seq img:last-child { opacity: 1 !important; }
}

@keyframes eggWob { 0%,100% { transform: rotate(0) } 25% { transform: rotate(-7deg) } 50% { transform: rotate(6deg) } 75% { transform: rotate(-3deg) } }
.egg-cracks { position: absolute; inset: 8px 12px; width: calc(100% - 24px); height: calc(100% - 16px); overflow: visible; }
/* pathLength=1 on each path (set in the markup) normalises the dash to the
   path's OWN length, so a crack always draws as one continuous stroke however
   the egg is scaled. The old fixed 140 happened to exceed all three paths
   (78/50/61) and so happened to work; a longer crack would have dashed. */
.egg-cracks .ec { fill: none; stroke: #17140f; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; }
.egg-cracks .ec.draw { transition: stroke-dashoffset 0.3s ease-out; stroke-dashoffset: 0; }
.egg-shard {
  position: absolute; top: 50%; left: 50%; width: 12px; height: 16px; margin: -8px 0 0 -6px; opacity: 0; z-index: 3;
  background: linear-gradient(#f6efdd, #cdbb95); border: 2px solid #17140f; clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.hatch-stage.egg-burst .bone-egg { animation: eggBurst 0.28s ease-out forwards; }
@keyframes eggBurst { 0% { transform: scale(1) } 40% { transform: scale(1.12) } 100% { transform: scale(0.6); opacity: 0 } }
.hatch-stage.egg-burst .egg-shard { animation: shardFly 0.55s ease-out forwards; }
@keyframes shardFly { 0% { opacity: 1; transform: rotate(var(--a)) translateY(0) scale(1) } 100% { opacity: 0; transform: rotate(var(--a)) translateY(-92px) scale(0.4) } }
.hatch-reveal { opacity: 0; transform: translateY(8px); transition: opacity 0.35s ease, transform 0.35s ease; }
.hatch-reveal.show { opacity: 1; transform: none; }
/* The hatched pet: big portrait, name and earned shiny below. */
.hatch-prize { display: grid; justify-items: center; gap: 5px; margin: 6px auto 0; }
.hatch-prize img { width: 148px; height: 148px; object-fit: contain; filter: drop-shadow(0 7px 8px rgba(0, 0, 0, 0.5)); }
.hatch-prize b { font-size: var(--fs-5); letter-spacing: -0.01em; }
.hatch-prize small { color: var(--text-3); font-size: var(--fs-2); }
.hatch-reveal.show .hatch-prize img { animation: hatchRise 0.7s cubic-bezier(0.2, 1.4, 0.4, 1) both; }
@media (prefers-reduced-motion: reduce) { .bone-egg, .hatch-stage.egg-burst .hatch-pet, .hatch-stage.egg-burst .egg-shard, .hatch-reveal.show .hatch-prize img { animation: none; } }

/* ===== Build page: armor / defense readout ===== */
.def-readout { display: flex; gap: 10px; margin: 0 2px 6px; }
.def-chip { flex: 1; display: grid; gap: 1px; padding: 8px 10px; border-radius: 12px; background: var(--surface-2); border: 1px solid color-mix(in srgb, var(--protein, #60a5fa) 40%, transparent); text-align: center; }
.def-chip small { font-size: var(--fs-0); letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-3); }
.def-chip b { font-family: var(--display); font-size: var(--fs-5); line-height: 1; color: var(--protein, #60a5fa); }
.def-chip i { font-size: var(--fs-tiny); color: var(--text-3); font-style: normal; }

/* ===== Build page: stat clarity ===== */
.stat-build { display: grid; gap: 12px; }
.statx { padding: 10px 12px; border: 1px solid var(--border, #23202b); border-radius: 12px; background: var(--surface-2); }
.statx-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.statx-name { font-weight: 800; font-size: var(--fs-3); }
.statx-val { font-variant-numeric: tabular-nums; font-weight: 700; }
.stat-gear { color: var(--carbs); font-size: var(--fs-0); font-weight: 700; }
.statx-do { margin-top: 8px; font-size: var(--fs-2); line-height: 1.35; color: var(--text-2, #cfc6d6); }
.statx-spec { margin-top: 4px; font-size: var(--fs-1); color: var(--text-3); }
.statx-spec b { color: var(--text-2, #cfc6d6); }
.statx-from { color: var(--text-3); }

/* bubble docks on whichever top corner the outfit leaves empty */
/* text-align stays LEFT on this side too: see the note on .hero-bubble. Docking
   right moves the box and the tail, never the typing direction. */
.hero-bubble.side-r { left: auto; right: 10px; --sway: rotate(2deg); }
.hero-bubble.side-r::before { right: auto; left: -14px; border-left-color: transparent; border-right-color: #3b393d; border-right-width: 10px; border-left-width: 0; transform: rotate(-12deg); }
.hero-bubble.side-r::after { right: auto; left: -8px; border-left-color: transparent; border-right-color: var(--surface); border-right-width: 7px; border-left-width: 0; transform: rotate(-12deg); }

/* ===== level-up spectacle ===== */
.lvlup-stage { position: relative; width: 224px; height: 224px; margin: 0 auto; }
.lvl-rays {
  position: absolute; inset: -34px; border-radius: 50%; z-index: 0;
  background: repeating-conic-gradient(from 0deg,
    rgba(165, 232, 71, 0.22) 0deg 11deg, transparent 11deg 30deg);
  -webkit-mask: radial-gradient(circle, #000 30%, transparent 72%);
  mask: radial-gradient(circle, #000 30%, transparent 72%);
  animation: raysSpin 14s linear infinite;
}
.lvlup-avatar { position: relative; z-index: 1; width: 100%; height: 100%; animation: lvlHero 0.6s cubic-bezier(0.34, 1.6, 0.64, 1) both; }
.lvl-stamp {
  font-family: var(--display); font-size: var(--fs-hero); line-height: 1; color: var(--accent);
  letter-spacing: 0.04em; text-shadow: 4px 4px 0 rgba(0, 0, 0, 0.55);
  animation: vsStamp 0.5s cubic-bezier(0.34, 1.8, 0.64, 1) 0.28s both;
  margin-top: 6px;
}
.cele-bubble {
  display: inline-block; margin-top: 14px;
  background: #f2e9d7; color: #17151d; border: 2.5px solid #17151d; border-radius: 15px;
  padding: 8px 13px; font-family: var(--display); font-size: var(--fs-3); letter-spacing: 0.04em;
  transform: rotate(-1.6deg); box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.45);
  position: relative; max-width: 82%;
}
/* NO TAIL, BECAUSE NOTHING HERE IS SPEAKING. Tom, 2026-08-20: "typed dialogue
   instead of chat bubbles APP-WIDE". This was the last chat bubble left in the
   app, at two sites: the pet level-up sheet and the breeding reveal. Both sit
   directly under a pet portrait, and the tail pointed UP at it, so a system
   message ("New talent unlocked. Choose it in the Stable.") read as the PET
   saying it. Pets have no voice anywhere in this app: grep finds no pet line,
   no petSay, nothing. The tail was claiming a speaker that does not exist.

   NOT converted to a typed talk box, which is what the instruction says
   literally, because that is the same mistake with better typography: it would
   give the pet the voice it does not have, and spend ~1.5s typing a UI label
   into the middle of a reward moment. The talk box stays for the characters who
   DO speak (Gwart, the Wanderer, the Mimic), where .hero-bubble's tail correctly
   drops to a jaw per the figure contract.

   Only the two pseudo-elements are gone. The copy, the sticker, its rotation and
   the 14px gap above it are untouched, so it now reads as the caption it always
   was. Verified on a 2x render of both sites, not estimated. */
.lvl-rewards { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
.lvl-rewards .bh-pill { font-size: var(--fs-2); padding: 6px 13px; }

/* ===== level-up MOMENT (added 2026-08-08, replaces the .lvlup-stage hero for
   the Bonehead's own level-up; the pet level-up still uses .lvl-rays/.lvlHero).

   A breathing lime glow bursts behind the figure, the character pops in, the
   level chips step 12 -> 13, and the XP bar speed-ramps to full, builds a glow,
   releases it in a flash and is revealed at the carry-over for the new level.
   The bar is NEVER shown draining: the snap happens under the flash at 72%.

   Beats: title .15s / figure .05s (lands ~1.1s) / rows .3s-.56s /
   XP 1.45s from .3s, flash peaks ~1.35s.
   ===== */
.reveal-take.lu-take { padding: calc(var(--sat) + 14px) 0 calc(var(--sab) + 18px); }
.reveal-take.lu-take::before { background: var(--bg); }
/* the rAF layer. Sits UNDER everything: .reveal-take > * would otherwise lift it. */
.reveal-take .lu-glow { position: absolute; inset: -40px; z-index: 0; pointer-events: none; filter: blur(26px); }
/* Centres when it fits, scrolls cleanly from the top when it does not.
   justify-content:center on an overflowing scroller clips BOTH ends — on a 667pt
   phone that put the headline 17px above the top and the CTA below the fold. */
.lu-body {
  flex: 1; min-height: 0; width: 100%; overflow-y: auto;
  display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
  text-align: center; padding: 0 30px;
}
.lu-body > :first-child { margin-top: auto; }
.lu-body > :last-child { margin-bottom: auto; }
.lu-head { animation: luTitle .8s cubic-bezier(0.34, 1.56, 0.64, 1) .15s both; }
.lu-title {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-jumbo); letter-spacing: 2px;
  line-height: 1; color: var(--accent); text-shadow: 0 3px 0 rgba(0, 0, 0, .5);
}
.lu-quip { margin-top: 10px; font-size: var(--fs-3); color: var(--text-2); line-height: 1.45; max-width: 280px; }

/* the figure is the point of the screen: everything else is sized off it.
   350/290 = 1.207, the design's stage ratio, with the drawing square inside it. */
.lu-stage { --lu-fig: min(290px, 76vw, 40vh); position: relative; flex: none; margin-top: 18px;
  width: var(--lu-fig); height: calc(var(--lu-fig) * 1.207); display: grid; place-items: center; }
.lu-figure { animation: luPop 1.05s cubic-bezier(0.22, 1, 0.36, 1) .05s both; will-change: transform; }
/* the figure floats free here: .bh-stage's panel surface, keyline and radius are
   for inline avatars, and a card edge around the character kills the burst */
.lu-stage .lu-avatar { width: var(--lu-fig); height: var(--lu-fig); margin: 0;
  background: none; border: 0; border-radius: 0; overflow: visible;
  filter: drop-shadow(0 14px 16px rgba(0, 0, 0, .5)); animation: luBob 3.4s ease-in-out 1.15s infinite; }
/* contain, not cover: the drawing is square inside a 640 canvas and cover would
   crop it against the stage's taller box (the figure contract, rule 3) */
.lu-stage .lu-avatar img { object-fit: contain; }
/* short phones: the figure yields first, because it is the only element with
   slack. Measured, not guessed — at 32vh a 375x667 fits with room to spare. */
@media (max-height: 760px) {
  /* the tallest this screen gets is a marquee milestone reached by a multi-level
     jump: stamp + four reward pills. Measured against that case, not the base one. */
  .lu-stage { --lu-fig: min(290px, 76vw, 30vh); margin-top: 6px; }
  /* a milestone adds a stamp and up to four reward pills. Only that case needs
     the figure cut this hard -- tuning every short phone to the worst case left
     the ordinary level-up with a 167px character and 61px of unused room. */
  .lu-take.has-ms .lu-stage { --lu-fig: min(290px, 76vw, 25vh); }
  .lu-title { font-size: var(--fs-hero); }
  .lu-quip { margin-top: 7px; font-size: var(--fs-2); }
  .lu-name { margin-top: 6px; font-size: var(--fs-3); }
  .lu-body .lvl-milestone { margin-top: 6px; }
  .lu-xp { margin-top: 14px; }
  .lu-rewards { margin-top: 12px; gap: 6px; }
  .lu-rewards .bh-pill { font-size: var(--fs-1); padding: 5px 10px; }
  .lu-cta { margin-top: 16px; padding: 10px 28px; font-size: var(--fs-5); }
}
.lu-spark { position: absolute; line-height: 0; pointer-events: none; z-index: 3; opacity: 0; }
.lu-spark.k1 { top: 4%; left: 8%; animation: luTwinkle 1.5s ease .5s 3 both; }
.lu-spark.k2 { top: 12%; left: 84%; animation: luTwinkle 1.7s ease .9s 3 both; }
.lu-spark.k3 { top: 58%; left: 2%; animation: luTwinkle 1.4s ease 1.3s 3 both; }
.lu-spark.k4 { top: 72%; left: 90%; animation: luTwinkle 1.6s ease .7s 3 both; }
.lu-spark.k5 { top: 30%; left: 94%; animation: luTwinkle 1.3s ease 1.7s 2 both; }

.lu-chips { display: flex; align-items: center; gap: 12px; margin-top: 6px;
  animation: luRow .8s cubic-bezier(0.22, 1, 0.36, 1) .3s both; }
.lu-chip { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px;
  box-shadow: 0 3px 0 rgba(0, 0, 0, .32); }
.lu-chip.was { padding: 6px 14px; background: #1b1a22; border: 2px solid var(--ink);
  font-size: var(--fs-2); font-weight: 800; color: var(--text-3); letter-spacing: .04em; }
.lu-chip.now { padding: 8px 18px; background: rgba(165, 232, 71, .14); border: 2px solid var(--accent);
  box-shadow: 0 3px 0 rgba(0, 0, 0, .32), 0 0 18px rgba(165, 232, 71, .28);
  font-family: var(--display); font-weight: 400; font-size: var(--fs-5); letter-spacing: 1px; color: var(--accent); }
.lu-arrow { color: var(--accent); font-family: var(--display); font-size: var(--fs-5); }
.lu-name { margin-top: 9px; font-family: var(--display); font-weight: 400; font-size: var(--fs-4);
  letter-spacing: .06em; color: var(--text); animation: luRow .8s cubic-bezier(0.22, 1, 0.36, 1) .36s both; }
.lu-body .lvl-milestone { margin-top: 10px; animation: luRow .8s cubic-bezier(0.22, 1, 0.36, 1) .4s both; }

.lu-xp { width: 100%; max-width: 280px; margin-top: 22px;
  animation: luRow .8s cubic-bezier(0.22, 1, 0.36, 1) .42s both; }
.lu-xp-lab { display: flex; justify-content: space-between; font-size: var(--fs-0); font-weight: 800;
  letter-spacing: .08em; color: var(--text-3); margin-bottom: 6px; font-variant-numeric: tabular-nums; }
.lu-track { height: 12px; border-radius: 999px; background: #1b1a22; border: 2px solid var(--ink);
  position: relative; animation: luXpBurst 1.45s ease-in-out .3s both; }
.lu-clip { position: absolute; inset: 0; border-radius: 999px; overflow: hidden; }
/* leading-edge smear runs ~35ms AHEAD of the fill, which is what sells the speed */
.lu-smear { position: absolute; top: 0; bottom: 0; left: 0; width: 2%; border-radius: 999px; opacity: 0;
  background: linear-gradient(90deg, rgba(194, 240, 125, 0) 30%, rgba(220, 255, 170, .95)); filter: blur(4px);
  animation: luXp 1.45s linear .265s both, luSmear 1.45s linear .3s both; }
.lu-fill { height: 100%; width: 2%; border-radius: 999px; position: relative;
  background: linear-gradient(180deg, #c2f07d, var(--accent));
  animation: luXp 1.45s linear .3s both, luXpGlowFill 1.45s linear .3s both; }
.lu-flash { position: absolute; inset: -4px; border-radius: 999px; pointer-events: none; opacity: 0;
  background: radial-gradient(120% 200% at 50% 50%, #e9ffc0, var(--accent) 72%, rgba(165, 232, 71, 0) 100%);
  filter: blur(1px); animation: luXpFlash 1.45s ease-in-out .3s both; }

.lu-rewards { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 18px;
  animation: luRow .8s cubic-bezier(0.22, 1, 0.36, 1) .5s both; }
.lu-rewards .bh-pill { font-size: var(--fs-2); padding: 6px 13px; }
.lu-extras { margin-top: 16px; animation: luRow .8s cubic-bezier(0.22, 1, 0.36, 1) .54s both; }
.lu-cta { width: auto; margin-top: 24px; padding: 12px 34px; border-radius: 14px;
  font-size: var(--fs-5); letter-spacing: 1px; color: #141a08;
  background: linear-gradient(180deg, #c2f07d, var(--accent)); border: 2.5px solid #10130a;
  box-shadow: 0 4px 0 #10130a, inset 0 2px 0 rgba(255, 255, 255, .35);
  animation: luRow .8s cubic-bezier(0.22, 1, 0.36, 1) .56s both; }
.lu-cta:active { transform: translateY(2px); box-shadow: 0 2px 0 #10130a, inset 0 2px 0 rgba(255, 255, 255, .35); }

@keyframes luPop { 0% { transform: translateY(56px) scale(.6); opacity: 0; } 26% { opacity: 1; }
  66% { transform: translateY(-4px) scale(1.012); } 100% { transform: translateY(0) scale(1); opacity: 1; } }
@keyframes luTitle { 0% { transform: scale(.3) rotate(-5deg); opacity: 0; }
  52% { transform: scale(1.14) rotate(1.6deg); opacity: 1; } 74% { transform: scale(.96) rotate(-.6deg); }
  100% { transform: scale(1) rotate(0); opacity: 1; } }
@keyframes luRow { 0% { transform: translateY(16px); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } }
@keyframes luBob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-7px); } }
@keyframes luTwinkle { 0%, 100% { opacity: 0; transform: scale(.4) rotate(0); }
  45% { opacity: .95; transform: scale(1.1) rotate(24deg); } }
/* --lu-end is the REAL carry-over for the new level, set per-open in JS. The bar
   never drains: 71.9% -> 72% is a hard cut that happens under the full flash. */
@keyframes luXp { 0% { width: 2%; } 10% { width: 6%; } 18% { width: 14%; } 26% { width: 30%; }
  32% { width: 55%; } 38% { width: 100%; } 71.9% { width: 100%; }
  72% { width: var(--lu-end, 0%); } 100% { width: var(--lu-end, 0%); } }
@keyframes luSmear { 0%, 4% { opacity: 0; } 8% { opacity: .85; } 36% { opacity: .85; } 44% { opacity: 0; } 100% { opacity: 0; } }
@keyframes luXpBurst {
  0%, 40% { box-shadow: none; transform: scale(1); border-color: var(--ink); }
  58% { box-shadow: 0 0 10px rgba(165, 232, 71, .3); transform: scale(1.01, 1.08); border-color: #3d4a2a; }
  70% { box-shadow: 0 0 22px rgba(165, 232, 71, .65), 0 0 3px rgba(165, 232, 71, .9); transform: scale(1.03, 1.3); border-color: rgba(194, 240, 125, .85); }
  72% { box-shadow: 0 0 46px rgba(190, 245, 120, 1), 0 0 8px rgba(220, 255, 170, 1); transform: scale(1.05, 1.55); border-color: #e9ffc0; }
  86% { box-shadow: 0 0 14px rgba(165, 232, 71, .3); transform: scale(1.01, 1.1); border-color: #33402a; }
  100% { box-shadow: none; transform: scale(1); border-color: var(--ink); } }
@keyframes luXpGlowFill { 0%, 40% { filter: brightness(1); } 72% { filter: brightness(2) saturate(1.4); } 73%, 100% { filter: brightness(1); } }
@keyframes luXpFlash { 0%, 62% { opacity: 0; } 70% { opacity: .55; } 72% { opacity: 1; } 88% { opacity: 0; } 100% { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  /* removed, not frozen: every element sits at its settled value */
  .lu-head, .lu-figure, .lu-stage .lu-avatar, .lu-chips, .lu-name, .lu-body .lvl-milestone,
  .lu-xp, .lu-track, .lu-fill, .lu-smear, .lu-flash, .lu-rewards, .lu-cta, .lu-spark { animation: none; }
  .lu-fill { width: var(--lu-end, 0%); }
  .lu-smear, .lu-flash, .lu-spark { opacity: 0; }
}
/* pet level-up: the avatar reuses the hero rays/pop; stat gains spell out the reward */
.petlvl-avatar { position: relative; z-index: 1; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; animation: lvlHero 0.6s cubic-bezier(0.34, 1.6, 0.64, 1) both; }
.pet-gains { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 14px; }
.pet-gain { background: rgba(255, 255, 255, 0.06); border: 1.5px solid rgba(255, 255, 255, 0.12); border-radius: 11px; padding: 6px 11px; font-size: var(--fs-1); letter-spacing: 0.04em; color: var(--muted); }
.pet-gain b { font-size: var(--fs-3); color: var(--ink); margin-left: 3px; }
.pet-gain i { font-style: normal; font-weight: 800; color: var(--accent); margin-left: 4px; }
@keyframes raysSpin { to { transform: rotate(360deg); } }
@keyframes lvlHero { from { transform: scale(0.55); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .lvl-rays { animation: none; } }

/* ===== pit: training-point allocation ===== */
.statline { display: flex; align-items: center; gap: 8px; }
.statline .bar.pitstat { position: relative; }
.statbase { position: absolute; top: -2px; bottom: -2px; width: 2px; background: rgba(242, 233, 215, 0.55); border-radius: 2px; }
.stat-bonus { color: var(--accent); font-size: var(--fs-1); }
.tp-btn {
  width: 30px; height: 30px; flex: none; border-radius: 9px;
  background: var(--surface); border: 1px solid var(--line-strong); color: var(--text);
  font-size: var(--fs-5); font-weight: 700; line-height: 1; display: grid; place-items: center;
}
.tp-btn:active { background: var(--surface-2); }
.tp-btn:disabled { opacity: 0.3; }
.tp-bar {
  display: flex; justify-content: space-between; align-items: baseline; gap: 10px;
  margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line);
  font-size: var(--fs-1); color: var(--text-2);
}
.tp-count { color: var(--accent); font-weight: 700; white-space: nowrap; }

/* ===== the boneyard: real-world map ===== */
/* The Boneyard is a route now, so #screen has to behave like the sheet the map
   used to live in: a flex column with no padding of its own. */
/* no --sat here: the topbar (absolute inside .map-stage) already pads by --sat,
   and padding both counted the notch twice, ~60px of dead band on iPhone */
/* Keep #screen as the scroll owner for the intro and location/load errors, so
   refresh() preserves its reading position. The wrapper must retain its content
   height instead of shrinking and clipping it. An active map still fills the
   available space; .map-stage owns clipping of the camera and its overlays. */
.screen.screen--map { display: flex; flex-direction: column; padding: 0; overflow-y: auto; }
.sheet-body.map-sheet { flex: 1 0 auto; padding: 0; overflow: visible; display: flex; flex-direction: column; }
#mapBody { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.map-stage { position: relative; flex: 1; min-height: 0; border-radius: 16px 16px 0 0; overflow: hidden; }
.map-canvas, .map-stage > .map-canvas.maplibregl-map { position: absolute; inset: 0; width: 100%; height: 100%; background: #0d0c12; }
.map-canvas .maplibregl-canvas { outline: none; }
/* long-press map feedback: suppress the iOS text-selection callout + image
   save menu so a press-and-hold reaches our nominate/report handler instead. */
.map-canvas, .map-den-mark, .map-mini-mark, .map-spawn { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
.map-den-mark img, .map-den-mark, .map-mini-mark, .map-spawn { -webkit-user-drag: none; }

.map-attrib {
  position: absolute; left: 8px; bottom: calc(var(--sab, 0px) + 8px); z-index: 5;
  font-size: var(--fs-tiny); color: rgba(242, 233, 215, 0.55);
  background: rgba(13, 12, 18, 0.65); padding: 2px 7px; border-radius: 999px; pointer-events: none;
}
.map-recenter {
  position: absolute; right: 12px; bottom: calc(var(--sab, 0px) + 74px); z-index: 6;
  width: 44px; height: 44px; border-radius: 50%;
  background: var(--surface); border: 1.5px solid var(--line-strong); color: var(--accent);
  font-size: var(--fs-title); line-height: 1; display: grid; place-items: center;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
}
.map-readout {
  position: absolute; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--sab, 0px) + 66px); z-index: 5;
  /* was nowrap + ellipsis, which cut "Slow down to a walk to coll..." and threw
     away the half of the sentence that says what to do. Wrap instead, and set
     width: max-content or it shrink-wraps to ~50% like the toast did. */
  width: max-content; max-width: 86%; text-wrap: balance;
  font-size: var(--fs-2); color: var(--text-2);
  background: rgba(13, 12, 18, 0.82); border: 1px solid var(--line-strong);
  padding: 7px 14px; border-radius: 999px;
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
.btn.map-collect {
  position: absolute; left: 16px; right: 16px; bottom: calc(var(--sab, 0px) + 14px); z-index: 6;
  width: auto; animation: bubblePop 0.3s cubic-bezier(0.34, 1.6, 0.64, 1);
}

.map-you { position: relative; width: 46px; height: 46px; }
.map-you-av {
  position: absolute; inset: 0; border-radius: 50%; overflow: hidden;
  border: 2.5px solid var(--accent); background: var(--surface-2);
  box-shadow: 0 0 16px rgba(165, 232, 71, 0.45), 0 3px 8px rgba(0, 0, 0, 0.5);
}
.map-you-av .bh-anim { position: absolute; left: -55%; top: -12%; width: 210%; height: 210%; animation: none; }
.map-you-av .bh-anim { --av-fit: contain; }
.map-you-av .bh-anim img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.map-cone {
  position: absolute; inset: -26px; border-radius: 50%; pointer-events: none;
  background: conic-gradient(from -26deg at 50% 50%,
    rgba(165, 232, 71, 0.30) 0deg, rgba(165, 232, 71, 0.10) 26deg,
    rgba(165, 232, 71, 0.30) 52deg, transparent 52.5deg);
  -webkit-mask: radial-gradient(circle, transparent 26%, #000 27%);
  mask: radial-gradient(circle, transparent 26%, #000 27%);
  transition: transform 0.35s ease-out;
}

.map-spawn {
  width: 38px; height: 38px; border-radius: 50%;
  background: rgba(13, 12, 18, 0.88); border: 2.5px solid #f2e9d7;
  display: grid; place-items: center;
  box-shadow: 0 3px 9px rgba(0, 0, 0, 0.55);
}
/* 2026-09-19, v688 animation census row 1: "`.map-spawn.rare { ...; animation:
   rarePulse 1.6s ease-in-out infinite; }` ... animates the box-shadow VALUE
   itself, not just its opacity: a paint property, not a compositor one" on up
   to 130 mounted markers. Read forward before touching it: the sticker-disc
   marker pass below ("MARKERS. Die-cut sticker discs") re-declares
   `.map-spawn.rare { ...; animation: none; }` at the same specificity later in
   this file, so the pulse never painted for any player. The dead declaration
   and its keyframes are removed so the next static census cannot trip on them;
   nothing a player sees changes. tests/unit.test.js "v688 census 1" pins it. */
.map-spawn.rare { border-color: var(--amber); box-shadow: 0 0 14px rgba(255, 180, 84, 0.75); }
.map-spawn.inrange { border-color: var(--accent); animation: mapMarkGlow 0.9s ease-in-out infinite; }
/* distant route-planning beacons: smaller + dimmer so they read as "over there",
   not "collect now". Rares stay bright even when far (worth the walk). */
.map-spawn.far { width: 26px; height: 26px; opacity: 0.62; border-width: 2px; }
.map-spawn.far .spawn-ing { display: none; }
.map-spawn.far.rare { opacity: 0.95; }

@media (prefers-reduced-motion: reduce) { .map-spawn.inrange { animation: none; } }

/* ===== boss dens on the boneyard map ===== */
/* MapLibre positions a DOM marker via an inline transform on its ROOT element.
   So the marker root (.map-den-mark) carries ONLY size/position; every visual +
   every transform-based animation lives on the inner .den-fx. Animating the
   root's transform (as the old roam/shudder rules did) overrides MapLibre's
   positioning transform and strands the marker at the map's top-left corner. */
.map-den-mark { position: relative; width: 46px; height: 52px; }
.den-fx { position: relative; width: 100%; height: 100%; display: block; filter: drop-shadow(0 3px 7px rgba(0, 0, 0, 0.6)); }
.map-den-mark img { width: 100%; height: 100%; object-fit: contain; }
.map-den-mark .den-skulls {
  position: absolute; left: 50%; transform: translateX(-50%) rotate(-4deg); top: -10px;
  font-size: var(--fs-1); color: #f2e9d7; text-shadow: 0 1px 0 #17151d, 0 0 6px rgba(0, 0, 0, 0.8);
  letter-spacing: 1px; white-space: nowrap;
}
.map-den-mark.big { width: 56px; height: 64px; }
.map-den-mark.big .den-skulls { color: var(--amber); }
/* roaming dens (v159): daily, temporary — tinted + a faint drift so they read as
   "here today, gone tomorrow" versus the permanent landmark dens. */
.map-den-mark.roaming img { filter: hue-rotate(150deg) saturate(1.25); }
.map-den-mark.roaming .den-skulls { color: #7cc4ff; }
.map-den-mark.roaming .den-fx { animation: roamDrift 3.2s ease-in-out infinite; }
/* easter-egg secret dens: materialize out of nowhere — spectral red, eyes lit */
.map-den-mark.secret img { filter: hue-rotate(210deg) saturate(1.6) brightness(0.85); }
.map-den-mark.secret .den-skulls { font-size: var(--fs-3); }
.map-den-mark.secret .den-eyes { opacity: 1; }
.map-den-mark.secret .den-eyes i { background: #ff5c5c; box-shadow: 0 0 7px rgba(255, 92, 92, 0.95); }
.map-den-mark.secret .den-fx { animation: secretRise 0.9s ease-out; }
@keyframes secretRise { 0% { opacity: 0; transform: translateY(14px); } 100% { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { .map-den-mark.secret .den-fx { animation: none; } }
@keyframes roamDrift { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
@media (prefers-reduced-motion: reduce) { .map-den-mark.roaming .den-fx { animation: none; } }
/* in-range emphasis: a filter GLOW on the root (filter never affects position),
   not a transform scale — a transform would fight MapLibre and teleport it. */
.map-den-mark.inrange { animation: mapMarkGlow 0.9s ease-in-out infinite; }
@keyframes mapMarkGlow { 0%,100% { filter: drop-shadow(0 0 0 transparent); } 50% { filter: drop-shadow(0 0 9px var(--accent)); } }
/* a den STIRS: boss eyes flare green + a shudder (ambient life, not a loop) */
.map-den-mark .den-eyes { position: absolute; top: 42%; left: 50%; transform: translateX(-50%); display: flex; gap: 6px; z-index: 2; pointer-events: none; opacity: 0; }
.map-den-mark .den-eyes i { width: 5px; height: 5px; border-radius: 50%; background: var(--slime, #a5e847); box-shadow: 0 0 6px rgba(165, 232, 71, 0.9); }
.map-den-mark.awaken .den-fx { animation: denShudder 0.5s ease-in-out 2; }
.map-den-mark.awaken .den-eyes { animation: denEyes 2.6s ease-in-out; }
@keyframes denShudder { 0%,100% { transform: translateX(0) } 20% { transform: translate(-1.5px, 0) rotate(-1.5deg) } 60% { transform: translate(1.5px, 0) rotate(1.5deg) } }
@keyframes denEyes { 0%,100% { opacity: 0 } 15% { opacity: 1 } 30% { opacity: 0.3 } 45% { opacity: 1 } 85% { opacity: 0.9 } }
.map-den-mark.claimed { opacity: 0.45; }
.map-den-mark.claimed .den-fx { filter: grayscale(0.7) drop-shadow(0 3px 7px rgba(0, 0, 0, 0.6)); }

/* Boneyard mini-bosses (v75): smaller roaming creatures, distinct from dens */
.map-mini-mark {
  width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center;
  background: radial-gradient(circle at 50% 38%, #5a3a7a, #2a1836);
  border: 2px solid var(--violet); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
}
.map-mini-mark .mini-glyph { font-size: var(--fs-3); color: #f2e9d7; line-height: 1; text-shadow: 0 1px 2px rgba(0,0,0,.8); }
.map-mini-mark.t2 { border-color: var(--amber); background: radial-gradient(circle at 50% 38%, #7a5a2a, #3a2810); filter: drop-shadow(0 0 4px rgba(255, 180, 84, 0.6)); }
.map-mini-mark.inrange { animation: mapMarkGlow 0.9s ease-in-out infinite; }
.map-mini-mark.claimed { opacity: 0.4; filter: grayscale(0.8); }
.map-den-mark.claimed::after {
  content: '✓'; position: absolute; right: -4px; top: -6px;
  width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center;
  background: var(--accent); color: #17151d; font-size: var(--fs-1); font-weight: 800;
}
.btn.map-den {
  position: absolute; left: 16px; right: 16px; bottom: calc(var(--sab, 0px) + 66px); z-index: 6;
  width: auto; background: var(--amber); color: #201500;
  animation: bubblePop 0.3s cubic-bezier(0.34, 1.6, 0.64, 1);
}
.btn.map-den ~ .btn.map-collect { bottom: calc(var(--sab, 0px) + 14px); }
.btn.map-mini {
  position: absolute; left: 16px; right: 16px; bottom: calc(var(--sab, 0px) + 66px); z-index: 6;
  width: auto; background: var(--violet); color: #1a1024;
  animation: bubblePop 0.3s cubic-bezier(0.34, 1.6, 0.64, 1);
}
/* Map key (legend): a ? button toggles a card whose rows reuse the exact marker
   markup the map draws, so the two never drift. */
.map-key-btn {
  position: absolute; top: calc(var(--sat, 0px) + 12px); left: 12px; z-index: 7;
  width: 36px; height: 36px; border-radius: 50%; border: 2.5px solid #2a0f3e;
  background: linear-gradient(180deg, #c98cf5, #b96cf0); color: #1a0f26;
  font-size: var(--fs-5); font-weight: 900; line-height: 1;
  box-shadow: 0 3px 0 #7a3fb0, 0 2px 8px rgba(0, 0, 0, 0.45);
}
.map-key-btn:active { transform: translateY(2px); box-shadow: 0 1px 0 #7a3fb0; }
.map-legend {
  position: absolute; top: calc(var(--sat, 0px) + 69px); left: 12px; right: 12px; z-index: 7;
  width: auto; max-height: calc(100% - var(--sat, 0px) - 69px - 104px); overflow-y: auto;
  background: var(--surface); border: 2px solid #050408; border-radius: 16px;
  padding: 0 0 4px; box-shadow: 0 3px 0 rgba(0,0,0,.32);
}
.map-legend .leg-h { font-size: var(--fs-1); font-weight: 700; letter-spacing: .06em; color: var(--text-3); margin: 0; padding: 14px 16px 6px; }
.leg-row { display: flex; align-items: center; gap: 11px; margin: 7px 0; }
.leg-ico { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; }
/* NO SCALE ON THE SPAWN OR MINI SWATCHES. Both discs are 42px and the cell is
   44, so they fit as drawn. The old `transform: scale(0.82)` was the only real
   RESAMPLE anywhere on this path: it rendered a 16px pixel sprite at 13.1 actual
   px, a fractional downscale of art that only survives whole steps, in the one
   panel whose whole job is to show you what a marker looks like. The dens keep
   their 0.66 because .map-den-mark is 46x52 and genuinely does not fit, and
   Cam's tombstone is a smooth drawing that scales without stepping. */
.leg-ico .map-den-mark { transform: scale(0.66); }
/* THE WANDERER AND THE SPIRE IN THE KEY. Both are plain art rather than the
   composed markers (the Wanderer's own marker carries a cone, which is a map
   affordance and means nothing in a list). Sized to the same 44px cell as every
   other row so the column stays flush. */
.leg-wnd, .leg-spire { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; }
.leg-wnd img, .leg-spire img { max-width: 100%; max-height: 100%; object-fit: contain; }
.leg-ico .map-den-mark .den-fx { animation: none; }
.leg-txt { display: flex; flex-direction: column; line-height: 1.2; }
.leg-txt b { font-weight: 800; font-size: var(--fs-2); }
.leg-txt small { color: var(--text-3); font-size: var(--fs-0); }
/* tap-a-marker inspect tooltip */
.map-poi-tip {
  position: absolute; z-index: 8; width: 208px; pointer-events: none;
  background: #f2e9d7; color: #241c10; border: 2.5px solid #14100b; border-radius: 12px;
  padding: 8px 11px; box-shadow: 0 5px 0 rgba(0, 0, 0, 0.45); display: flex; flex-direction: column; gap: 2px;
  animation: bubblePop 0.24s cubic-bezier(0.34, 1.6, 0.64, 1);
}
.map-poi-tip b { font-weight: 900; font-size: var(--fs-2); }
.map-poi-tip .pt-b { font-size: var(--fs-1); font-weight: 600; }
.map-poi-tip .pt-f { font-size: var(--fs-0); color: #6a5f4a; font-weight: 700; }
.btn.map-mini ~ .btn.map-collect { bottom: calc(var(--sab, 0px) + 14px); }

/* ===== gear: statted equipment ===== */
.ward-cell.gear { position: relative; }
.ward-cell .gear-stat {
  position: absolute; left: 3px; right: 3px; bottom: 3px;
  font-size: var(--fs-micro); font-weight: 800; letter-spacing: 0.02em; line-height: 1.15;
  color: var(--accent); background: rgba(13, 12, 18, 0.82);
  border-radius: 7px; padding: 2px 3px; pointer-events: none;
}
.ward-cell.gear.locked img, .ward-cell.gear.locked canvas { filter: grayscale(0.85) brightness(0.55); }
/* previewing a piece for inspect: blue highlight, distinct from green "equipped" */
.ward-cell.selected { outline: 2px solid var(--protein); outline-offset: 1px; box-shadow: 0 0 12px rgba(124, 196, 255, 0.5); }
/* KEYBOARD FOCUS MUST SURVIVE SELECTION (QA round 22 W12). .ward-cell.selected
   (0,2,0) beat the global :focus-visible (0,1,0), so a focused selected tile
   computed the identical 2px blue ring as an unfocused one. Three simple
   selectors, honestly, rather than !important. */
.ward-cell.selected:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
/* the armed state of a fit tile that would take statted gear off (QA round 22
   W5): armToConfirm swaps the art for the consequence, in the app's arm gold */
.ward-cell.arming { color: var(--gold); border-color: var(--gold); text-align: center; padding: 4px; }
/* The two-tap contract is visible before touching a garment or colourway, as a
   dashed edge. The printed "2 taps" label that shipped beside it in v548 is gone;
   see js/app.js. */
/* .pw-item (the colourway rail, later in this file) sets the border shorthand,
   so the rail tile needs the compound selector or the cue never paints (M11). */
.ward-two-tap, .pw-item.ward-two-tap { border-style: dashed; border-color: var(--gold); }
/* the inspect panel: full stats + what the ⚡ ability actually does */
.gear-inspect {
  display: grid; grid-template-columns: 62px 1fr; gap: 10px 12px; margin: 12px 2px 4px;
  padding: 12px; border-radius: 14px; background: var(--surface-2);
  border: 1px solid var(--line); border-left-width: 4px; align-items: center;
}
.gear-inspect.r-common { border-left-color: #9fac9f; }
.gear-inspect.r-uncommon { border-left-color: #9fac9f; }
.gear-inspect.r-rare { border-left-color: #60a5fa; }
.gear-inspect.r-epic { border-left-color: var(--violet); }
.gear-inspect.r-legendary { border-left-color: #ffc961; box-shadow: 0 0 14px rgba(255, 201, 97, 0.28); }
.gear-inspect img { width: 62px; height: 62px; object-fit: contain; filter: drop-shadow(0 4px 5px rgba(0,0,0,.5)); }
.gear-inspect .gi-body { min-width: 0; display: grid; gap: 3px; }
.gear-inspect .gi-body b { font-size: var(--fs-3); line-height: 1.2; }
.gi-stats { font-size: var(--fs-1); font-weight: 800; color: var(--accent); }
.gi-talent { font-size: var(--fs-1); font-weight: 800; color: var(--carbs); }
.gi-desc { font-size: var(--fs-1); line-height: 1.35; color: var(--text-2); }
.gi-actions { grid-column: 1 / -1; display: flex; gap: 8px; margin-top: 2px; }
.gi-actions .btn { flex: 1 1 0; min-width: 0; font-size: var(--fs-2); padding-left: 8px; padding-right: 8px; white-space: nowrap; }
.dust-ico { color: #b8a6d8; }
.ward-cell .gear-lock {
  position: absolute; top: 4px; right: 4px;
  font-size: var(--fs-micro); font-weight: 800; color: #201500;
  background: var(--amber); border-radius: 999px; padding: 1.5px 6px; pointer-events: none;
}
.gear-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 6px; }
.gear-chip {
  background: var(--surface); border: 1px solid var(--line); border-radius: 13px;
  padding: 8px 9px; display: grid; gap: 2px; min-width: 0;
}
.gear-chip small { font-size: var(--fs-tiny); letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-3); }
.gear-chip b { font-size: var(--fs-1); line-height: 1.15; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.gear-chip span { font-size: var(--fs-tiny); color: var(--text-3); }
.gear-chip.on span { color: var(--accent); font-weight: 700; }
.gear-chip.on.r-rare { border-color: #7cc4ff66; }
.gear-chip.on.r-epic { border-color: rgba(155,146,232,.4); }
.gear-chip.on.r-legendary { border-color: #ffb45466; }
.reveal-card.gear b::before { content: '⚔ '; }

/* ===== eggs: step incubation ===== */
.egg-row .q-bar.egg-bar { height: 7px; margin: 5px 0 3px; }
.egg-row .q-bar.egg-bar i { background: var(--accent); }

/* ===== wardrobe: paperdoll (WoW-style equip screen) ===== */
.paperdoll { display: grid; grid-template-columns: 66px 1fr 66px; gap: 7px; align-items: stretch; margin-top: 0; }
.pd-col { display: grid; gap: 7px; }
.pd-center {
  display: grid; position: relative; overflow: hidden;
  background: var(--coral); border: 2px solid var(--ink); border-radius: var(--radius);
  box-shadow: var(--sh);
}
.pd-center::after {
  content: ''; position: absolute; inset: 0; z-index: 3; pointer-events: none;
  background-image: var(--grain); background-size: 150px 150px; opacity: .07;
}
/* The stage FILLS the coral panel: no square aspect-ratio, and none of its own
   background or border, which were painting over the coral so only a small square
   of it ever showed. Tom, 2026-08-07: "the colour doesn't take up the whole
   centre part". Anchored low.
   2026-09-27: Tom picked A, no overscan so wide offhands stay inside the panel. */
.pd-center .bh-stage.lg {
  width: 100%; height: 100%; aspect-ratio: auto; min-height: 312px; margin: 0;
  background: transparent; border: 0; border-radius: 0;
}
.pd-center .bh-stage.lg .bh-anim { inset: 0; }
.pd-center .bh-stage.lg .bh-anim { --av-fit: contain; --av-pos: 50% 72%; }
.pd-center .bh-stage.lg .bh-anim img { object-fit: contain; object-position: 50% 72%; }
.pd-center .bh-backdrop { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* slot = art on top (as big as fits), label as its own strip below — never overlap */
.pd-slot {
  position: relative; width: 66px; height: 78px; border-radius: var(--radius-sm);
  background: var(--surface); border: 2px solid var(--ink); box-shadow: var(--sh-sm);
  display: flex; flex-direction: column; align-items: stretch; overflow: hidden; padding: 0;
}
.pd-slot img { position: static; flex: 1 1 auto; min-height: 0; width: 100%; height: auto; object-fit: contain; padding: 4px 4px 0; pointer-events: none; }
/* flex-shrink 1 + min-height 0: a 62px square cannot also fit the 18px label in the 74px inner box,
   so the art gives up its last 6px (object-fit: contain letterboxes it) instead of pushing the label out. */
.pd-slot .pd-art {
  flex: 0 1 auto; min-height: 0; width: 100%; height: auto; aspect-ratio: 1;
  object-fit: contain; padding: 1px 1px 0; pointer-events: none;
}
.pd-slot .pd-empty { flex: 1; display: grid; place-items: center; color: var(--text-3); font-size: var(--fs-5); opacity: 0.55; }
.pd-slot .pd-tag {
  position: static; flex: none; margin-top: auto; font-size: var(--fs-micro); font-weight: 800; line-height: 1.4;
  letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-2, #cfc6d6);
  background: rgba(13, 12, 18, 0.85); padding: 2px 0; pointer-events: none;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pd-slot .pd-gear {
  position: absolute; left: 0; right: 0; top: 0; font-size: var(--fs-micro); font-weight: 800;
  color: var(--accent); background: rgba(13, 12, 18, 0.82); padding: 1.5px 1px; pointer-events: none;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; z-index: 2;
}
.pd-slot.sel { border-color: var(--coral); box-shadow: var(--sh-sm); }
.pd-slot.sel .pd-tag { color: var(--coral); }
.pd-slot.gear-on.r-rare { border-color: #7cc4ff; }
.pd-slot.gear-on.r-epic { border-color: var(--violet); }
.pd-slot.gear-on.r-legendary { border-color: #ffb454; }
.pd-bottom {
  display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
  gap: 7px; justify-items: stretch; margin-top: 7px;
}
/* wider tiles down here, so keep the tile proportion and let the art fill it */
.pd-bottom .pd-slot { width: 100%; height: auto; aspect-ratio: 88 / 76; }
/* these tiles are wider than tall, so a square art box overflows the tile and
   gets clipped; let flex size the box and object-fit letterbox the content */
.pd-bottom .pd-slot .pd-art { aspect-ratio: auto; flex: 1 1 auto; min-height: 0; height: auto; }
/* Offhand ink must fit between its stat and label strips, including tall tools. */
.pd-slot[data-pd="IL"] .pd-art { aspect-ratio: auto; flex: 1 1 auto; min-height: 0; height: auto; }
.pd-slot[data-pd="IL"] .pd-gear { position: static; order: -1; flex: none; }
.pd-stats { display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; margin: 12px 0 2px; }
.pd-stat {
  display: inline-flex; align-items: baseline; gap: 4px;
  background: var(--surface); border: 1px solid var(--line); border-radius: 999px;
  padding: 4px 10px;
}
.pd-stat small { font-size: var(--fs-micro); letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-3); }
.pd-stat b { font-size: var(--fs-2); }
.pd-stat i { font-style: normal; font-size: var(--fs-0); font-weight: 800; color: var(--accent); }

/* ===== boss loot: pick-one-of-two ===== */
.loot-choice { margin: 4px 0 14px; }
.loot-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
.loot-card {
  display: grid; justify-items: center; gap: 4px; padding: 10px 8px 9px;
  background: var(--surface); border: 2px solid var(--line-strong); border-radius: 15px;
  text-align: center;
}
.loot-card img { width: 74px; height: 74px; object-fit: contain; }
.loot-card b { font-size: var(--fs-1); line-height: 1.2; }
.loot-card .loot-stats { font-size: var(--fs-0); font-weight: 800; color: var(--accent); }
.loot-card .loot-talent { font-size: var(--fs-tiny); font-weight: 800; color: var(--carbs); letter-spacing: .01em; }
.loot-card .loot-talent-desc { font-size: var(--fs-tiny); line-height: 1.25; color: var(--text-2); }
.loot-card .rar-chip { font-size: var(--fs-micro); }
.loot-card.r-rare { border-color: #7cc4ff88; }
.loot-card.r-legendary { border-color: #ffb454; box-shadow: 0 0 14px rgba(255, 180, 84, 0.35); }
/* previewing a piece (not yet kept): blue highlight, distinct from the green "taken" */
.loot-card.selected { border-color: var(--protein); box-shadow: 0 0 13px rgba(124, 196, 255, 0.45); }
.loot-card.taken { border-color: var(--accent); box-shadow: 0 0 14px rgba(165, 232, 71, 0.4); }
.loot-card:disabled:not(.taken) { opacity: 0.35; filter: grayscale(0.6); }
.loot-keep { width: 100%; margin-top: 10px; }
.loot-keep:disabled { opacity: 0.5; }
.loot-pending .loot-keep { margin-top: 8px; }
.loot-pending { margin-bottom: 12px; }
.loot-pending small { color: var(--text-3); font-weight: 700; }

/* ===== #13: rarity glow (no new art) ===== */
/* 2026-09-19 v691, animation census row 2 (Tom: "Wrapper element"). The pulse
   used to be `animation: legpulse` on the tile's own box-shadow, and a box-shadow
   animation forces a whole-document style recalc every frame: MEASURED 117-119
   RecalcStyleCount/s with ONE legendary tile on the Wardrobe H grid (0/s with
   none). The tile is overflow: hidden and both its pseudos are taken
   (.equipped::after, .slimed::before), so the glow lives on a wrapper: every
   rendered .ward-cell.r-legendary sits inside <span class="ward-halo"> (haloWrap
   in js/app.js) whose two pseudos carry the trough and the peak glow and
   CROSS-FADE ON OPACITY (rareRest / rareBloom, the compositor-owned pattern
   measured at the legend dot). Same 2.2s ease-in-out clock, same two glow
   endpoints as the old keyframes; the tile keeps its static 1.5px ring. The
   ring rule is `.ward-halo > .ward-cell.r-legendary` (0,3,0) because the old
   animation used to beat `.ward-cell.slimed` / `.selected` box-shadows on a
   legendary tile, and a plain `.ward-cell.r-legendary` rule would not.
   The peak layer (::after) also carries the old 50% frame's #ffd27a ring and
   paints ABOVE the tile (a box-shadow only draws outside its box, so it covers
   nothing on the tile): over the opaque static ring, its opacity fade IS the
   old ring-colour interpolation. MEASURED at 3x against the old keyframes
   seeked to the same frames: trough identical (0 of 342,225 px over a 24
   RGB-sum delta, max 14); peak differs on 1,548 px (0.45%), every one of them
   1.1 to 1.7 CSS px from the tile edge, which is the ring's antialiased outer
   edge drawn twice (static ring plus peak ring). A static ring with no ::after
   ring differed on 7,775 px at peak instead, the whole ring, so the tint stays. */
.ward-halo { display: grid; position: relative; isolation: isolate; min-width: 0; }
.ward-halo > .ward-cell.r-legendary { box-shadow: 0 0 0 1.5px #ffb454; }
.ward-halo::before, .ward-halo::after { content: ''; position: absolute; inset: 0; border-radius: 13px; pointer-events: none; }
.ward-halo::before { z-index: -1; box-shadow: 0 0 12px rgba(255,180,84,.45); animation: rareRest 2.2s ease-in-out infinite; }
.ward-halo::after { z-index: 1; box-shadow: 0 0 0 1.5px #ffd27a, 0 0 20px rgba(255,180,84,.75); opacity: 0; animation: rareBloom 2.2s ease-in-out infinite; }
.ward-cell.r-legendary img, .ward-cell.r-legendary canvas { filter: drop-shadow(0 0 6px rgba(255,180,84,.7)); }
.reveal-card.r-legendary, .loot-card.r-legendary { box-shadow: 0 0 16px rgba(255,180,84,.5); }
.map-spawn.rare, .map-den-mark.big { filter: drop-shadow(0 0 8px rgba(255,180,84,.7)); }
@media (prefers-reduced-motion: reduce) { .ward-halo::before, .ward-halo::after { animation: none; } }

/* ===== pets in battle: card + mini-tree + fx ===== */
.pet-card { display: flex; gap: 12px; align-items: center; padding: 10px; margin: 8px 0 4px;
  background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: 15px; }
.pet-card img { width: 60px; height: 60px; object-fit: contain; flex: none; }
.pet-card-meta { display: grid; gap: 2px; min-width: 0; }
.pet-card-meta b { font-size: var(--fs-3); }
.pet-role { font-size: var(--fs-tiny); font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.pet-count { position: absolute; right: -4px; bottom: -3px; background: var(--accent); color: #10210a; font-size: var(--fs-tiny); font-weight: 800; line-height: 1; padding: 2px 4px; border-radius: 7px; border: 1.5px solid var(--bg, #0c0f0a); }
/* Quiet Today app nudge, using the existing card and button treatments. */
.tf-banner { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; }
.tf-banner > span { flex: 1 1 200px; font-size: var(--fs-2); color: var(--muted); }
.tf-banner .btn { text-decoration: none; }
/* Health-sync watchdog banner: steps stopped flowing while connected */
.hk-stale { display: grid; gap: 3px; width: 100%; text-align: left; cursor: pointer;
  border: 2px solid rgba(255, 180, 84, .55); background: rgba(255, 180, 84, .10); }
.hk-stale b { font-size: var(--fs-3); color: #ffb454; }
.hk-stale span { font-size: var(--fs-2); color: var(--muted); }
/* Return card: the quiet welcome after 2+ days away. Same skeleton as .hk-stale,
   none of its alarm colour: this card is not a warning. */
.wb-back { display: grid; gap: 6px; justify-items: start; text-align: left; }
.wb-back b { font-size: var(--fs-3); }
.wb-back span { font-size: var(--fs-2); color: var(--muted); }
.wb-back .btn { margin-top: 3px; }
/* boss AoE sweep: a brief red wash over the arena so "it hit BOTH of you" reads */
.aoe-flash { position: absolute; inset: 0; z-index: 30; pointer-events: none; border-radius: inherit;
  background: radial-gradient(circle at 50% 45%, rgba(220,60,40,.42), rgba(220,60,40,0) 70%);
  animation: aoeFlash .45s ease-out forwards; }
@keyframes aoeFlash { 0% { opacity: 0; } 22% { opacity: 1; } 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .aoe-flash { animation: none; opacity: 0; } }
/* the boss's beast (its 2nd body) reads as a lesser summoned creature, not a rival */
.petmini.beast { filter: saturate(.55) brightness(.82) drop-shadow(0 0 6px rgba(150,40,30,.5)); }
.tree-next { margin: 4px 2px 8px; font-size: var(--fs-2); color: var(--muted); }
.tree-next b { color: var(--accent); }
/* species signature capstone — the maxed-pet payoff */
.pet-sig { display: grid; gap: 3px; margin: 10px 2px 4px; padding: 11px 13px; border-radius: 14px;
  border: 2px solid rgba(255,201,97,.5); background: rgba(255,201,97,.08); }
.pet-sig.locked { opacity: .62; border-style: dashed; }
.pet-sig-h { font-size: var(--fs-0); font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--accent); display: flex; align-items: center; gap: 5px; }
.pet-sig b { font-size: var(--fs-3); }
.pet-sig small { color: var(--muted); font-size: var(--fs-2); }
.pet-sig-tag { font-size: var(--fs-0); font-weight: 700; letter-spacing: .04em; color: var(--muted); margin-top: 2px; }
.pet-sig.on .pet-sig-tag { color: #ffc961; }
/* v128 breeding: lineage tag + a glow that brightens with each bred tier */
.lin-tag { font-size: var(--fs-tiny); font-weight: 800; color: #ffd34d; letter-spacing: .03em; vertical-align: middle; }
.lin-1 { filter: drop-shadow(0 0 3px rgba(255, 211, 77, .45)); }
.lin-2 { filter: drop-shadow(0 0 5px rgba(255, 211, 77, .55)); }
.lin-3 { filter: drop-shadow(0 0 7px rgba(255, 200, 90, .65)); }
.lin-4 { filter: drop-shadow(0 0 9px rgba(255, 180, 110, .72)); }
.lin-5 { filter: drop-shadow(0 0 11px rgba(255, 150, 140, .8)); }
.lin-6 { filter: drop-shadow(0 0 14px rgba(255, 120, 170, .88)); }
.pet-card.lin-1, .pet-card.lin-2, .pet-card.lin-3, .pet-card.lin-4, .pet-card.lin-5, .pet-card.lin-6 { filter: none; } /* card itself stays flat; glow rides the sprite */
.breed-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); gap: 8px; margin: 8px 2px; }
.breed-tile { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 4px 6px; background: rgba(255, 255, 255, .05); border: 2px solid transparent; border-radius: 12px; cursor: pointer; }
.breed-tile.sel { border-color: var(--accent); background: rgba(165, 232, 71, .14); }
.breed-lin { font-size: var(--fs-tiny); font-weight: 800; color: #ffd34d; letter-spacing: .03em; }
.breed-out { margin: 6px 2px; }
.breed-sp { display: flex; gap: 8px; flex-wrap: wrap; }
.breed-sp .chip { padding: 6px 12px; border-radius: 999px; background: rgba(255, 255, 255, .06); border: 2px solid transparent; font-weight: 700; font-size: var(--fs-2); cursor: pointer; }
.breed-sp .chip.on { border-color: var(--accent); background: rgba(165, 232, 71, .14); }
/* Stable roster */
.stable-card { display: flex; gap: 10px; align-items: center; padding: 8px; margin: 6px 0; background: rgba(255,255,255,.04); border: 2px solid rgba(255,255,255,.08); border-radius: 14px; }
.stable-card.equipped { border-color: var(--accent); background: rgba(165,232,71,.10); }
.stable-card.breedsel { border-color: #ffd34d; }
.stable-portrait { width: 60px; height: 60px; flex: none; display: flex; align-items: center; justify-content: center; }
.stable-info { flex: 1; min-width: 0; display: grid; gap: 3px; }
.stable-info b { font-size: var(--fs-2); }
.stable-info small { color: var(--muted); font-size: var(--fs-0); }
.stable-eqbadge { font-size: var(--fs-micro); font-weight: 800; letter-spacing: .06em; color: #10210a; background: var(--accent); padding: 1px 5px; border-radius: 6px; vertical-align: middle; }
.stable-active-lbl { font-size: var(--fs-0); color: var(--accent); font-weight: 700; align-self: center; }
.stable-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; align-items: center; }
.btn.tiny { width: auto; padding: 5px 10px; font-size: var(--fs-1); border-radius: 9px; letter-spacing: .02em; }
.btn.tiny.ghost { background: rgba(255,255,255,.06); }
.btn.tiny.on { background: #ffd34d; color: #2a1c00; }
.breed-bar { background: rgba(255,211,77,.08); border: 2px solid rgba(255,211,77,.35); border-radius: 14px; padding: 10px 12px; margin: 4px 0 10px; }
.crate-ico { position: relative; }
.pet-stats { font-size: var(--fs-1); letter-spacing: .02em; color: var(--muted); }
.pet-stats b { font-size: var(--fs-2); font-weight: 800; color: var(--ink); }
.pet-tree { display: grid; gap: 8px; margin: 4px 0 6px; }
.pet-tier { display: grid; gap: 4px; }
.pet-tier.locked { opacity: .5; }
.pet-tier-lbl { font-size: var(--fs-tiny); font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--text-3); }
.pet-opts { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
.pet-opt { text-align: left; padding: 8px 9px; border-radius: 12px; background: var(--surface);
  border: 1.5px solid var(--line); display: grid; gap: 2px; }
.pet-opt b { font-size: var(--fs-1); }
/* Tom, 2026-09-18: "the stable pet talent ui looks fucked the buttons are way
   too tall". The description is a rem token (--fs-0) and the root font is
   -apple-system-body (app.css html rule), so a player on enlarged iOS Dynamic
   Type had the desc wrap to 3+ lines and the button balloon (measured 63.7px at
   16px root, 128px at 30px). Clamp the desc to 2 lines so a big system font
   grows the text without unbounding the button. */
.pet-opt small { font-size: var(--fs-0); color: var(--text-3); line-height: 1.25;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.pet-opt.on { border-color: var(--accent); background: rgba(165,232,71,.08); box-shadow: 0 0 10px rgba(165,232,71,.15); }
.pet-opt:disabled { opacity: .55; }
.float.dmg.poison { color: #7fd463; }
.fstage.petpounce { animation: petpounce .4s ease; }
@keyframes petpounce { 40% { transform: translateY(-8px) scale(1.04); filter: drop-shadow(0 0 10px rgba(165,232,71,.6)); } }

/* pet/add as a battle body: a small companion that stands on the ground BESIDE
   the fighter (absolute so it never pushes the avatar up off the floor) */
/* Pet stands BEHIND its owner at the lower-outer side, so the owner occludes any
   overlap (natural depth, not "stacked on top") and the cloud's rain falls behind
   the bonehead rather than over it. Floor-aligned; mostly clear to the side. */
.pet-fighter { position: absolute; bottom: 0; display: grid; justify-items: center; gap: 1px; transform-origin: bottom center; transition: opacity .4s, filter .4s; z-index: 1; }
.fighterG > .fstage { position: relative; z-index: 2; }  /* the FIGHTER is closer to the screen; the pet tucks behind */
/* pet at the fighter's FRONT foot, toward the middle of the arena, BEHIND its
   owner — the fighter's leading arm/leg overlaps the pet (Tom's approved
   reference: the foe's lizard). Never on the body, never off the outer edge. */
.you-side .pet-fighter { left: 74px; }
.foe-side .pet-fighter.add { right: 96px; }                            /* the add: left of the boss, toward center */
.pet-fighter.fainted { opacity: 0.4; filter: grayscale(1); }
.pet-fighter.fainted .fstage.petmini { transform: rotate(68deg) translateY(8px); }
/* Bottom-align the pet in its box so GROUNDED creatures (lizard, dog, most pets)
   stand on the floor line instead of floating. The cloud is the exception and is
   meant to hover — it carries its own baked ground shadow. */
.fstage.petmini { width: 76px; height: 76px; min-height: 76px; display: flex; align-items: flex-end; justify-content: center; }
.fstage.petmini .petanim { margin: 0; }                 /* kill the auto-centering so flex-end grounds it */
/* static pets render through croppedPetImg (.petcrop) — a content-cropped, scaled
   img whose own transform seats/centers it; the wrapper just clips to the slot. */
.petcrop { position: relative; display: inline-block; overflow: hidden; }
/* PRESS AND HOLD SHOWS WHAT SHE IS WEARING. Tom, 2026-08-22: "Press and hold on
   bumble seal highlights her sunglasses." One accent halo on the worn layers and
   nothing else: the pet is not dimmed, nothing pulses, nothing moves. The
   callout/selection suppression is the whole reason this reads as a gesture on a
   phone rather than as a long-press that pops the browser's image menu. */
.petcrop.dressed { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
.petcrop .pw { transition: filter .16s ease; }
.petcrop.pw-lit .pw { filter: drop-shadow(0 0 1.5px var(--accent)) brightness(1.08); }
/* SHE FIGHTS FACING THE ENEMY. Tom, 2026-08-22: "we need to mirror bumbleseal in
   the fights so she faces the enemy". The player's pet stands on the LEFT of the
   arena with the foe on the right, and Cam drew Bumbleseal facing left, so she
   fought with her back to him. Which species this is true of is an art fact and
   is stated once, in js/pets.js beside HOVER_PETS; the class is emitted from that
   predicate, so no other pet is touched (measured: the bulldog, the duck and the
   beardie already face right).
   MIRRORED ON AN INNER ELEMENT, NEVER ON A ROOT THAT ALREADY OWNS A TRANSFORM.
   croppedPetImg gives every layer its own translate() and .pa-stage carries the
   animated pets' scale(); .petcrop and .petanim carry neither, so a scaleX here
   composes with the art's registration instead of overwriting it. That mistake
   has been paid for once already on the map markers. */
.fstage.petmini.faces-away .petcrop,
.fstage.petmini.faces-away .petanim { transform: scaleX(-1); }
/* pet name/HP now lives in the fight HUD (.hud-pet), not floating over the sprite */
.petname { font-size: var(--fs-micro); font-weight: 800; color: var(--text-3); text-transform: uppercase; letter-spacing: .04em; }
/* 4px was the whole bug. `.bar i` is height:100% of the CONTENT box, and 4px
   minus a 2px border top and bottom leaves nothing to draw, so the pet's health
   rendered 62px wide and ZERO pixels tall. Measured, not guessed: this rule
   (0,3,0) was quietly outranking the .hud-pet .bar fix (0,2,0) I tried first. */
.bar.fhp.mini { width: 48px; height: 10px; }
.pet-turn-h { text-align: center; font-family: var(--display); font-size: var(--fs-3); color: var(--accent); padding: 2px 0 6px; letter-spacing: .02em; }
.fight-act.petmove { border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
/* tap-to-target: pointer + a focus ring on the current target */
.foe-side[data-target], .pet-fighter.add[data-target] { cursor: pointer; }
/* TARGET RETICLE. Marks which enemy your attacks land on, so it only appears
   while there are genuinely two live enemies to choose between (see updateBars).
   Corner brackets in the hostile colour rather than a full box in --accent: a
   hard green outline around the whole stage read as a UI selection artifact, and
   green is the colour the app uses for the PLAYER's own actions. */
.fighterG.foe-side.targeted > .fstage::after, .pet-fighter.add.targeted > .fstage::after {
  content: ''; position: absolute; inset: -5px; pointer-events: none; z-index: 3;
  --tc: var(--danger); --tl: 15px; --tw: 2px;
  background:
    linear-gradient(var(--tc), var(--tc)) 0 0 / var(--tl) var(--tw) no-repeat,
    linear-gradient(var(--tc), var(--tc)) 0 0 / var(--tw) var(--tl) no-repeat,
    linear-gradient(var(--tc), var(--tc)) 100% 0 / var(--tl) var(--tw) no-repeat,
    linear-gradient(var(--tc), var(--tc)) 100% 0 / var(--tw) var(--tl) no-repeat,
    linear-gradient(var(--tc), var(--tc)) 0 100% / var(--tl) var(--tw) no-repeat,
    linear-gradient(var(--tc), var(--tc)) 0 100% / var(--tw) var(--tl) no-repeat,
    linear-gradient(var(--tc), var(--tc)) 100% 100% / var(--tl) var(--tw) no-repeat,
    linear-gradient(var(--tc), var(--tc)) 100% 100% / var(--tw) var(--tl) no-repeat;
}
/* "NAME TARGETED" above the bracketed enemy, so it is never ambiguous which of
   the two you are hitting. data-tgt is set on the wrapper in updateBars. */
.fighterG.foe-side.targeted::before, .pet-fighter.add.targeted::before {
  content: attr(data-tgt) " TARGETED";
  /* -19px, not -13px: at -13px the label's descender band overlapped the top
     bracket arm by a measured 2px and the bracket cut through the text. */
  position: absolute; left: 50%; transform: translateX(-50%); top: -19px;
  font-size: var(--fs-micro); font-weight: 900; letter-spacing: 0.08em; white-space: nowrap;
  color: var(--danger); z-index: 4; pointer-events: none;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
@media (prefers-reduced-motion: reduce) { .fstage.petpounce { animation: none; } }

/* ===== Animated pets (C1 cloud, C3 catfish, C4 lizard) — see js/petanim.js ===== */
/* .petanim reserves the scaled box; .pa-stage is the native-px stage, scaled by JS. */
.petanim { position: relative; overflow: hidden; flex: none; margin: auto; }
.pa-stage { position: absolute; top: 0; left: 0; transform-origin: top left; }
/* Override the generic ".bh-stage img" rule (width/height 100%, object-fit cover, inset 0)
   with !important — it out-orders our rules inside the fight petmini. Full-canvas layers
   ARE 100% of their .pa-art box (correct); small sprites set their own px below. */
.pa-stage img { position: absolute !important; inset: auto !important; object-fit: fill !important;
                image-rendering: auto; filter: none !important; pointer-events: none; }
/* Full-canvas layers fill .pa-art and sit at its origin. The left/top MUST be
   !important: the `.pa-stage img { inset: auto !important }` rule above outranks
   the plain `.pa-base { left: 0 }` declaration, which left these layers at an
   arbitrary STATIC position. Scaled up, that pushed the art past .petanim's
   overflow:hidden edge and sliced the creature down a straight vertical line,
   which is the "pet cut off on the crew screen" Tom reported three times. */
.pa-cloud .pa-body, .pa-cloud .pa-eyes, .pa-cloud .pa-closed,
.pa-lizard .pa-base, .pa-lizard .pa-mouthline, .pa-lizard .pa-drool,
.pa-lizard .pa-lid, .pa-lizard .pa-tongue, .pa-lizard .pa-fly,
.pa-catfish .pa-fbody {
  width: 100% !important; height: 100% !important; left: 0 !important; top: 0 !important; }
.pa-cloud { width: 222px; height: 219px; }
.pa-lizard { width: 273px; height: 218px; }
.pa-catfish { width: 261px; height: 206px; }

/* --- cloud --- */
.pa-cloud .pa-art { position: absolute; left: 18px; top: 18px; width: 186px; height: 183px; }
.pa-cloud .pa-body, .pa-cloud .pa-eyes, .pa-cloud .pa-closed { left: 0; top: 0; }
.pa-cloud .pa-shadow { left: 65px !important; top: 166px !important; width: 57px !important; height: auto !important;
             transform-origin: 50% 50%; animation: paShadow 3s ease-in-out infinite; }
@keyframes paShadow { 0%{transform:scale(1);opacity:.85} 50%{transform:scale(.9);opacity:.6} 100%{transform:scale(1);opacity:.85} }
.pa-bob { position: absolute; inset: 0; animation: paBob 3s ease-in-out infinite; }
@keyframes paBob { 0%{transform:translateY(0)} 50%{transform:translateY(-5px)} 100%{transform:translateY(0)} }
.pa-eyes { animation: paEyesOpen 3s infinite; transform-origin: 50% 33%; }
@keyframes paEyesOpen { 0%,84%{transform:scaleY(1);opacity:1} 88%,90%{transform:scaleY(.02);opacity:0} 94%,100%{transform:scaleY(1);opacity:1} }
.pa-closed { opacity: 0; animation: paEyesClosed 3s infinite; }
@keyframes paEyesClosed { 0%,85.5%{opacity:0} 88%,90%{opacity:1} 92.5%,100%{opacity:0} }
.pa-cloud .pa-drop { width: 11px !important; height: auto !important; animation: paFall 1.5s cubic-bezier(.45,.05,.85,.6) infinite; }
.pa-cloud .pa-d1 { left: 60px !important; top: 131px !important; animation-delay: 0s; }
.pa-cloud .pa-d2 { left: 80px !important; top: 129px !important; width: 9px !important; animation-delay: -.42s; }
.pa-cloud .pa-d3 { left: 94px !important; top: 133px !important; animation-delay: -.8s; }
.pa-cloud .pa-d4 { left: 116px !important; top: 129px !important; width: 10px !important; animation-delay: -1.14s; }
@keyframes paFall { 0%{transform:translateY(0) scaleY(.7);opacity:0} 14%{opacity:1} 70%{opacity:1} 100%{transform:translateY(30px) scaleY(1.1);opacity:0} }

/* --- lizard --- */
.pa-lizard .pa-art { position: absolute; left: 24px; top: 24px; width: 225px; height: 170px; }
.pa-lizard .pa-creature { position: absolute; inset: 0; }
.pa-base, .pa-mouthline, .pa-drool, .pa-lid, .pa-tongue { left: 0; top: 0; }
.pa-lid { opacity: 0; animation: paEyelid 3s ease-in-out infinite; animation-delay: -0.3s; transform-origin: 60% 13.8%; }
@keyframes paEyelid { 0%,89%{transform:scaleY(0);opacity:0} 91%,92.5%{transform:scaleY(1);opacity:1} 95%,100%{transform:scaleY(0);opacity:0} }
.pa-tongueclip { position: absolute; left: 0; top: 0; width: 225px; height: 170px;
  clip-path: polygon(0px 70px, 120px 70.8px, 128px 64.8px, 132px 61.8px, 136px 59.8px, 140px 56.8px, 144px 53.8px, 148px 51.8px, 152px 48.8px, 156px 45.8px, 164px 42.8px, 225px 42px, 225px 170px, 0px 170px); }
.pa-tongue { animation: paSlurp 6s infinite; }
@keyframes paSlurp { 0%,46%{transform:translateY(0)} 50.5%{transform:translateY(-46px)} 72.2%{transform:translateY(-46px)} 76.2%,100%{transform:translateY(0)} }
.pa-mouthline { animation: paMouth 6s infinite; }
@keyframes paMouth { 0%,49.7%{opacity:1} 49.9%,72.5%{opacity:0} 72.9%,100%{opacity:1} }
.pa-drool { animation: paDrool 6s infinite; }
@keyframes paDrool { 0%,46%{opacity:1} 49.5%,78%{opacity:0} 87%,100%{opacity:1} }
.pa-fly { animation: paBuzz 6s ease-in-out infinite; }
@keyframes paBuzz { 0%{transform:translate(0,0)} 20%{transform:translate(-38px,-6px)} 40%{transform:translate(-73px,0)} 55%{transform:translate(-58px,-9px)} 72%{transform:translate(-23px,-4px)} 100%{transform:translate(0,0)} }

/* --- catfish --- */
/* Cam drew it mid-flop: body airborne over its own ground shadow, one sweat bead
   still on its back and two flying. The loop completes the story: it lies beached
   most of the cycle (+18px seats the visible BELLY on the shadow: the belly's wide
   silhouette bottoms at y132 and the shadow spans y144-170, measured in
   scripts/build-catfish.py's frame; the first +5px attempt keyed off the whisker
   -tip bbox and left it levitating), then one flop per loop up through the drawn
   pose while the two loose drops fly off. Sweat fires WITH the hop so one thing
   happens at a time. The X eye is a drawn dizzy eye, so there is nothing to blink. */
.pa-catfish .pa-art { position: absolute; left: 18px; top: 18px; width: 225px; height: 170px; }
.pa-catfish .pa-flop { position: absolute; inset: 0; animation: paFlop 4.8s ease-in-out infinite; transform-origin: 50% 62%; }
@keyframes paFlop { 0%,54%{transform:translateY(18px) rotate(0deg)} 57%{transform:translateY(-1px) rotate(-1.6deg)}
  60%{transform:translateY(-4px) rotate(-2.2deg)} 63%{transform:translateY(0) rotate(-1.2deg)}
  66.5%{transform:translateY(18px) rotate(.4deg)} 69%{transform:translateY(15px) rotate(.2deg)} 72%,100%{transform:translateY(18px) rotate(0deg)} }
.pa-catfish .pa-fshadow { left: 68px !important; top: 144px !important; width: 81px !important; height: auto !important;
             transform-origin: 50% 50%; animation: paFlopShadow 4.8s ease-in-out infinite; }
@keyframes paFlopShadow { 0%,54%{transform:scale(1);opacity:.85} 60%{transform:scale(.8);opacity:.5}
  66.5%{transform:scale(1);opacity:.85} 69%{transform:scale(.95);opacity:.75} 72%,100%{transform:scale(1);opacity:.85} }
.pa-catfish .pa-sweat { height: auto !important; opacity: 0; animation: paSweat 4.8s ease-out infinite; }
.pa-catfish .pa-s1 { left: 73px !important; top: 0 !important; width: 10px !important; }
.pa-catfish .pa-s2 { left: 133px !important; top: 5px !important; width: 16px !important; animation-delay: -.08s; }
@keyframes paSweat { 0%,55%{transform:translateY(6px) scale(.4);opacity:0} 58%{transform:translateY(0) scale(1);opacity:1}
  64%{transform:translateY(-9px) scale(1);opacity:1} 67.5%{transform:translateY(-13px) scale(1);opacity:0} 68%,100%{transform:translateY(6px) scale(.4);opacity:0} }
/* The attached bead rides .pa-flop and belongs to the same water cycle as the
   free drops: gone through the dwell, it beads up as the wind-up RIGHT before
   the flop (42-52%), rides the launch, and is flicked off with the rest of the
   sweat. No mid-dwell regrowth (Tom: it came back too early and grew on its
   own, which read as separate water). */
.pa-catfish .pa-bead { left: 61px !important; top: 14px !important; width: 15px !important; height: auto !important;
             transform-origin: 50% 100%; opacity: 0; animation: paBead 4.8s ease-out infinite; }
@keyframes paBead { 0%,42%{transform:translate(0,0) scale(.3);opacity:0} 52%,54%{transform:translate(0,0) scale(1);opacity:1}
  58%{transform:translate(-2px,-10px) scale(1);opacity:1} 63%{transform:translate(-4px,-18px) scale(1);opacity:0}
  63.5%,100%{transform:translate(0,0) scale(.3);opacity:0} }

/* ---- ultra-rare SHINY pet variant ---- */
.pet-shiny-wrap { position: relative; display: inline-flex; }
.pet-shiny { object-fit: contain; animation: shinyPulse 2.4s ease-in-out infinite; }
@keyframes shinyPulse {
  0%,100% { filter: drop-shadow(0 0 5px rgba(var(--gold-rgb),.7)) drop-shadow(0 0 12px rgba(var(--gold-rgb),.4)); }
  50%     { filter: drop-shadow(0 0 9px rgba(var(--gold-rgb),1)) drop-shadow(0 0 20px rgba(var(--gold-rgb),.65)); }
}
.shiny-spark { position: absolute; top: -2px; right: -2px; line-height: 0; animation: shinySpark 1.8s ease-in-out infinite; pointer-events: none; }
@keyframes shinySpark { 0%,100% { opacity: .5; transform: scale(.85) rotate(-8deg); } 50% { opacity: 1; transform: scale(1.15) rotate(8deg); } }
.shiny-tag { font-size: var(--fs-tiny); font-weight: 800; color: var(--gold); letter-spacing: .04em; vertical-align: middle; }
/* On a CARD the gold edge reads as a rare frame. On the arena stage it read as a
   yellow BOX drawn around the creature, because .petmini is a square stage with
   no card around it. Tom, 2026-08-07: "My shiny pet also had a yellow box around
   it in every fight?" The arena gets the same prestige as a glow on the DRAWING
   instead of an outline on its container. */
.pet-card.is-shiny { box-shadow: 0 0 0 2px rgba(var(--gold-rgb),.6), 0 0 16px rgba(var(--gold-rgb),.35); }
/* The glow goes on the STAGE, not the img. It was written that way because
   `.fstage.petmini.r-epic img` owned the img's filter for rarity and a second
   filter rule there simply replaced it (measured: the shiny lizard came out
   epic-violet with no gold at all). That rarity rule is gone as of v425 (see the
   Pit glow removal), so the img's filter is free again, but this stays on
   the container: a filter there composes over whatever the child draws, which is
   what keeps a shiny animated pet's own layer filters intact. */
.petmini.is-shiny { box-shadow: none; filter: drop-shadow(0 0 7px rgba(var(--gold-rgb),.9)); }
.hatch-prize.is-shiny img { filter: drop-shadow(0 0 10px rgba(var(--gold-rgb),.9)); }
@media (prefers-reduced-motion: reduce) {
  .pet-shiny, .shiny-spark { animation: none; }
  .pet-shiny { filter: drop-shadow(0 0 7px rgba(var(--gold-rgb),.8)); }
}

/* ---- Trends: week recap ---- */
.recap-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 4px 0 6px; }
.recap-pill { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; background: var(--surface-2); border-radius: 12px; padding: 12px 4px; text-align: center; }
.recap-pill .rp-v { font-family: var(--display); font-weight: 400; letter-spacing: .5px; font-size: var(--fs-title); line-height: 1; }
.recap-pill .rp-v small { font-size: var(--fs-1); font-weight: 700; opacity: .7; }
.recap-pill .rp-s { font-size: var(--fs-0); color: var(--text-3); letter-spacing: .02em; }
.recap-lvl { margin-top: 4px; }
.recap-lvl .rl-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 5px; font-size: var(--fs-2); }
@media (max-width: 380px) { .recap-pill .rp-v { font-size: var(--fs-5); } }

/* ---- Trends: consistency heatmap ---- */
.heatmap { display: flex; gap: 4px; justify-content: space-between; }
.hm-col { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.hm-cell { aspect-ratio: 1; width: 100%; border-radius: 3px; background: var(--surface-3); }
.hm-0 { background: var(--surface-3); opacity: .5; }
.hm-1 { background: var(--accent); opacity: .35; }
.hm-2 { background: var(--accent); opacity: .65; }
.hm-3 { background: var(--amber); opacity: .95; }
.hm-legend { display: flex; align-items: center; gap: 4px; justify-content: flex-end; margin-top: 8px; }
.hm-legend .hm-cell { width: 12px; height: 12px; flex: none; }
.hm-legend .note { font-size: var(--fs-0); }

/* ---- pack-crack loot reveal ---- */
/* A reveal sheet goes edge to edge: the normal 24px + safe-area gap at the top
   let a hub panel show above the moment. */
/* CENTRED BY LAYOUT, NOT BY HALF A PIXEL.
   .sheet centres with translateX(-50%), and on a 393px viewport that is
   translateX(-196.5px): half a CSS pixel, inherited by every descendant, and at
   dpr 3 it is one and a half DEVICE pixels. Vector UI never notices. Pixel art
   does: proven on the crate by forcing that one translate to a whole -196px in
   a throwaway tree, which moved tests/crate-palette-audit.mjs's PALETTE row from
   84.05% to 97.86% with nothing else changed.
   A takeover is full bleed, so layout centring is exactly equivalent to the
   transform on every phone (width:100% under a 600px cap) and lands on whole
   pixels. Scoped to .takeover deliberately: dropping the transform on the base
   .sheet would change the containing block for any position:fixed descendant
   anywhere in the app, which is a much wider blast radius than this is worth.
   The slide-in needs its own keyframes because the shared one carries the
   -50% inside the transform it animates. */
.sheet.takeover {
  /* vh first, dvh second: an old parser keeps the vh line and drops the dvh one,
     a modern one takes both and the dvh wins. See tests/dvh-fallback-audit.mjs. */
  height: 100vh; height: 100dvh; max-height: 100vh; max-height: 100dvh;
  border-radius: 0; border-top: 0; z-index: 310;
  left: 0; right: 0; margin-inline: auto; transform: none;
  animation: slideupFlat .28s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes slideupFlat { from { transform: translateY(60%); } to { transform: translateY(0); } }
.sheet.takeover ~ .sheet-backdrop, .sheet.takeover + .sheet-backdrop { z-index: 309; }
.sheet.takeover .sheet-grab { display: none; }
/* TAKEOVER. A reveal fills the sheet and paints its own ground, so nothing of
   the screen underneath shows through behind the moment. */
.reveal-take {
  position: relative; display: flex; flex-direction: column; align-items: center;
  height: 100%; flex: 1; min-height: 0;
  padding: calc(var(--sat) + 14px) 16px calc(var(--sab) + 18px);
  overflow: hidden;
}
.reveal-take::before {
  content: ''; position: absolute; inset: 0; z-index: 0;
  background:
    radial-gradient(120% 70% at 50% 92%, rgba(253,104,87,.18), rgba(253,104,87,0) 60%),
    radial-gradient(90% 55% at 50% 6%, rgba(155,146,232,.15), rgba(155,146,232,0) 62%),
    linear-gradient(180deg, #0a0910, #100e18 55%, #0b0a11);
}
.reveal-take.warm::before {
  background:
    radial-gradient(120% 70% at 50% 88%, rgba(var(--gold-rgb),.22), rgba(255,201,97,0) 58%),
    linear-gradient(180deg, #0a0910, #14110d 60%, #0b0a11);
}
.reveal-take.cool::before {
  background:
    radial-gradient(120% 72% at 50% 90%, rgba(162,224,166,.18), rgba(162,224,166,0) 58%),
    linear-gradient(180deg, #0a0910, #0e1410 60%, #0b0a11);
}
.reveal-take > * { position: relative; z-index: 1; }
.reveal-take .grainy { position: absolute; inset: 0; z-index: 0; background-image: var(--grain); background-size: 150px 150px; opacity: .06; pointer-events: none; }

.pack-reveal { display: flex; flex-direction: column; align-items: center; flex: 1; min-height: 0; width: 100%; }

/* the eyebrow above a payoff */
.reveal-eyebrow { font-size: var(--fs-0); font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--coral); text-align: center; }
/* the big stamp */
.reveal-stamp {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-7); line-height: .96;
  letter-spacing: .03em; text-transform: uppercase; text-align: center;
  color: var(--accent); text-shadow: 3px 4px 0 var(--ink); margin-top: 4px;
}
.reveal-stamp.gold { color: var(--gold); }
.reveal-sub { font-size: var(--fs-2); color: var(--text-3); text-align: center; margin-top: 5px; }
/* TWO BUTTONS, AND THE SECOND ONE ARRIVES LATE. Tom, 2026-09-10: "when you
   hatch a pet the buttons for take home and open lab are way too close
   together". The markup ships one button; js/app.js appends "Open Laboratory"
   after the Laboratory snapshot resolves, so the pair only ever exists at
   runtime and nothing here spaced them. Both are full-width .btn, so they
   stacked flush. A grid with a gap spaces whatever ends up in it, one button
   or two, rather than a margin on a button that is created in JS. */
.reveal-foot { margin-top: auto; width: 100%; display: grid; gap: 9px; }
.reveal-hint { text-align: center; font-size: var(--fs-1); color: var(--text-3); margin-top: 9px; }
/* ---------------------------------------------------------------------------
   CRATE CRACK. The crate lands, strains, the lid blows off on an arc, light
   climbs out of the mouth, and the card RISES OUT of the box as it sinks away.

   The beat table lives here as custom properties so the whole sequence can be
   read (and retimed) in one place instead of being scattered across setTimeouts
   in js/app.js. Every distance is expressed against --pc-w, the card width, so
   the choreography holds its proportions from an SE to a Pro Max.

     land 0.85s · settle 0.95s · lid tumbles 2.3s · bloom 2.32s ·
     light rises 2.4s · card 2.6s over 1.05s · counter 2.9s · headline 3.7s

   No confetti and no seam: both were tried and both fought the art.
   --------------------------------------------------------------------------- */
.pack-reveal {
  /* the card is the unit of the whole scene. The third term keeps a tall card
     off the headline on a short phone. */
  /* vh here, dvh in the @supports below. A custom property accepts any token
     sequence at parse time, so an old browser stores this line happily and only
     fails when it is substituted, at which point the property using it is unset
     rather than falling back: `width: var(--pc-w)` computed to `auto` in the
     measurement, not to the previous declaration. A second --pc-w line would be
     inert for the same reason. See tests/dvh-fallback-audit.mjs. */
  --pc-w: min(61vw, 264px, calc((100vh - 300px) * .61));
  --rar: var(--text-2);
  --rar-rgb: 185, 172, 151;
  /* FASTER OFF THE MARK. Tom, 2026-08-08: "the initial open needs to happen a bit
     faster." Measured before: the crate sat still for 2.3s before the lid moved
     at all and the card did not arrive until 2.6s, so most of the sequence was
     spent looking at a closed box. Time-to-card is roughly halved and the landing
     and lid motions are themselves quicker, so it reads as eager rather than
     merely earlier. Everything after the card keeps its spacing. */
  --b-land: .85s;   /* the drop animation's end, so the thud has something to read */
  --b-settle: .28s; --b-lid: 1.12s; --b-bloom: 1.14s; --b-sink: 1.32s;
  --b-card: 1.38s; --b-count: 1.68s; --b-title: 2.3s; --b-foot: 2.5s;
  --b-sway: 2.5s; --b-spark: 2.6s;
}
/* THE PIXEL CRATE NEEDS A WIDER WINDOW THAN A LID ARC DOES.
   The shared beats give the sequence --b-card minus --b-lid, which is 260ms, and
   nine authored frames do not fit in 260ms: at 32.5ms each the lid crack and the
   ghost's settle got identical time and the card started rising while the ghost
   was still coming out. That is both halves of Tom's note, "the chest is landing
   too fast and opening before it lands" and "it's also fading too slowly as the
   card pops out and takes it over".
   The table in js/app.js sums to 411ms, so the window opens to 540 and every
   downstream beat moves with it. Time to card goes 1.38s to 1.72s on THIS crate
   only. The bloom moves to 1.22s so the flash lands on the lid cracking (frame
   f2) instead of 80ms before anything has moved. */
.pack-reveal.pix-crate {
  --b-land: 1.02s;   /* crDropPix runs 1.02s, not the vector crate's .85s */
  --b-bloom: 1.22s;
  /* THE CRATE MUST BE GONE BEFORE THE CARD ARRIVES, NOT FADING BEHIND IT.
     Tom, 2026-08-16, watching the recording: "you have the ghost crate fading
     out weird once the card is already in frame and in front of it." Measured
     off that capture: the sink ran 1.66s to 2.21s and the card rose at 1.72s, so
     for half a second the finished card sat in front of a chest still visibly
     dissolving around its edges. Two things competing for the same moment.
     The frame table sums to 400ms from --b-lid 1.12s, so the last pose lands at
     1.52s. Hold it 80ms, sink hard over 200ms, and the crate is fully gone at
     1.80 before the card starts at 1.82. Costs 0.10s of time-to-card. */
  /* 1.64, not 1.60. The frame table ends at 1.52 and the last frame was getting
     80ms on paper and 25-50ms in practice. Tom, 2026-08-17: the end of the
     animation clips. Moving the sink 40ms later and making it 40ms quicker buys
     the final frame 120ms and still has the crate GONE at 1.80, before the card
     at 1.82, which is the ordering row in tests/crate-reveal-audit.mjs. Tom's
     frame timings are untouched: the hold came out of the sink, not his art. */
  --b-sink: 1.64s; --b-card: 1.82s;
  --b-count: 2.12s; --b-title: 2.74s; --b-foot: 2.94s;
  --b-sway: 2.94s; --b-spark: 3.04s;
}
/* The .co-sink.pix animation-duration longhand that used to sit here is gone on
   purpose: it was cancelled by the shorthand further down and made the crate
   look like it had been retimed when it had not. Do not put it back. */
@supports (height: 100dvh) {
  .pack-reveal { --pc-w: min(61vw, 264px, calc((100dvh - 300px) * .61)); }
}
.pack-reveal.r-uncommon { --rar: var(--mint); --rar-rgb: 162, 224, 166; }
.pack-reveal.r-rare { --rar: var(--protein); --rar-rgb: 124, 196, 255; }
.pack-reveal.r-epic { --rar: var(--violet); --rar-rgb: 155, 146, 232; }
.pack-reveal.r-legendary { --rar: var(--gold); --rar-rgb: var(--gold-rgb); }
/* flicking to the next card is not a re-open: everything collapses to one beat */
.pack-reveal.browsing {
  --b-card: .02s; --b-count: .1s; --b-title: .1s; --b-foot: .3s;
  --b-sway: .6s; --b-spark: 1s;
}

.pack-head { width: 100%; text-align: center; }
.pack-count { font-size: var(--fs-1); font-weight: 700; color: var(--text-3); letter-spacing: .08em; min-height: 15px; }
.pack-count:not(:empty) { animation: crLabel .6s ease-out var(--b-count) both; }
.pack-title { margin-top: 6px; font-family: var(--display); font-weight: 400; font-size: var(--fs-5); letter-spacing: .8px; color: var(--text); animation: crLabel .7s ease-out var(--b-title) both; }

/* touch-action: none, not pan-y. pan-y told the browser that vertical drags
   belong to a scroller, so a tap that drifted downward got claimed as a scroll
   and cancelled mid-gesture. The reveal is a full takeover with nothing behind
   it to scroll, so the card owns every gesture on it. */
.pack-stage { flex: 1; display: flex; align-items: center; justify-content: center; width: 100%; position: relative; touch-action: none; }
/* the fixed-proportion box the whole sequence is laid out inside */
.pack-scene { position: relative; width: var(--pc-w); height: calc(var(--pc-w) * 1.986); }

/* ---- the light ---- */
/* Far larger than the screen on purpose: the rays have no max reach, they fall
   off in the shader. .reveal-take clips it. */
.pack-burst {
  position: absolute; left: 50%; bottom: calc(var(--pc-w) * .9555); z-index: 0;
  width: calc(var(--pc-w) * 4.16); height: calc(var(--pc-w) * 4.697);
  transform: translate(-50%, 50%); pointer-events: none;
}
.pack-burst .burst-gl { display: block; width: 100%; height: 100%; }
/* no WebGL (or reduced motion): a still haze, never a spinning conic */
.pack-burst:empty {
  background: radial-gradient(circle at 50% 50%, rgba(var(--rar-rgb), .10) 0%, rgba(var(--rar-rgb), .05) 34%, transparent 70%);
}

/* ---- the crate ---- */
.pack-crate {
  position: absolute; left: 50%; bottom: calc(var(--pc-w) * .2185); z-index: 2;
  width: calc(var(--pc-w) * .6218); margin-left: calc(var(--pc-w) * -.3109);
}
.co-sink { animation: crSink .9s ease-in var(--b-sink) both; will-change: opacity; }
.co-drop { animation: crDrop .85s linear both; will-change: transform; }
.co-settle { position: relative; animation: crSettle .78s cubic-bezier(.2,.9,.3,1) var(--b-settle) both; }
/* ---- THE PIXEL CRATE'S OWN MOTION ----
   Everything below exists because the vector crate's motion is transform-based
   and transforms are fatal to pixel art. The drop is animated on TOP, which goes
   through layout rather than the compositor, so every frame is re-rasterised on
   the pixel grid instead of being resampled once. There is no settle wobble at
   all: it was a scale, and Tom's nine frames already carry the motion. */
.co-drop.pix { position: relative; animation: crDropPix 1.02s cubic-bezier(.34,.02,.32,1) both; will-change: auto; }
.co-settle.pix { animation: none; }
/* .2s, not .9s. The retime went into an `animation-duration: .2s` rule that sat
   ABOVE this one, and the `animation` shorthand here reset it straight back to
   .9s, so the fix Tom asked for never ran: the sink still started at 1.60 and
   was not finished until 2.50, half a second after the card. Caught by the ORDER
   row in tests/crate-reveal-audit.mjs reading the animation's real duration off
   the element instead of trusting the stylesheet. The duration now lives in the
   shorthand, where nothing can quietly win against it. */
.co-sink.pix { position: relative; will-change: auto; animation: crSinkPix .16s ease-in var(--b-sink) both; }
/* THE EXIT IS NOT A SCALE EITHER. crSink ends on translateY() scale(.9), and a
   scale on an ancestor resamples the sprite exactly like the drop did: caught by
   tests/crate-palette-audit.mjs measuring the sprite at 143.508px wide mid-sink
   with 0.57% of its pixels still the artist's. Same motion, carried on `top`. */
@keyframes crSinkPix {
  0%   { top: 0; opacity: 1 }
  60%  { opacity: 1 }
  99%  { opacity: 0 }
  100% { top: calc(var(--pc-w) * .1092); opacity: 0; visibility: hidden }
}
@keyframes crDropPix {
  0%   { top: calc(var(--pc-w) * -1.5966); opacity: 0 }
  26%  { opacity: 1 }
  70%  { top: 0 }
  80%  { top: calc(var(--pc-w) * -.0588) }
  100% { top: 0 }
}
/* Behind the crate, not over it. See js/app.js where the class is emitted. */
.pack-bloom.pix { z-index: 1; }
.co-box, .co-lid { display: block; }
.co-box svg, .co-lid svg, .co-box .bhi-wrap, .co-lid .bhi-wrap { display: block; width: 100%; height: auto; }
.co-box { filter: drop-shadow(0 6px 8px rgba(0,0,0,.5)); }
/* The lid and the box are two clipped copies of the SAME icon. The box clip
   starts ABOVE the cut so the two halves overlap — abutting edges left a
   hairline seam on the closed crate. */
.co-lid { position: absolute; left: 0; top: 0; width: 100%; filter: drop-shadow(0 4px 6px rgba(0,0,0,.5)); animation: crLid .92s linear var(--b-lid) both; }
.co-shadow {
  position: absolute; left: 50%; bottom: calc(var(--pc-w) * -.042); transform: translateX(-50%);
  width: calc(var(--pc-w) * .63); height: calc(var(--pc-w) * .0924); border-radius: 50%;
  background: radial-gradient(ellipse at 50% 50%, rgba(0,0,0,.45), transparent 70%);
}
/* the flash as the lid goes: a burst at the crate MOUTH, white-hot core falling
   off into the tier colour — not a page-wide white wash anchored to nothing */
.pack-bloom {
  position: absolute; left: 50%; bottom: calc(var(--pc-w) * .3277); z-index: 3;
  width: calc(var(--pc-w) * 1.0924); height: calc(var(--pc-w) * 1.0924);
  margin-left: calc(var(--pc-w) * -.5462); pointer-events: none; border-radius: 50%;
  mix-blend-mode: screen; filter: blur(10px);
  background: radial-gradient(circle, rgba(255,252,244,.98) 0%, rgba(var(--rar-rgb), .85) 30%, rgba(var(--rar-rgb), .35) 52%, transparent 72%);
  animation: crBloom .95s cubic-bezier(.12,.72,.3,1) var(--b-bloom) both;
}

/* ---- the hand ---- */
.pack-deck { position: absolute; left: 50%; bottom: calc(var(--pc-w) * .2269); z-index: 1; width: var(--pc-w); transform: translateX(-50%); }
/* The departing card keeps the deck's geometry above the incoming card. */
.pack-deck.pack-outgoing { z-index: 2; pointer-events: none; }
/* the rest of the pack, stacked behind the card you are looking at */
.pc-ghost {
  position: absolute; left: 0; bottom: 0; width: 100%; aspect-ratio: 3 / 4.9; border-radius: 20px;
  background-color: #1b1a22;
  background-image: radial-gradient(rgba(242,233,215,.05) 1px, transparent 1.4px);
  background-size: 13px 13px;
  /* A DECK EDGE, NOT A BLANK CARD. Tom, 2026-08-10: "some cards leave like an
     empty silhouette as you move to the next one." That is exactly what these
     were: a full card-sized block with a bright bone border at 0.9 opacity,
     sitting directly behind the live one. While a card flew off you were left
     looking at an empty card, which reads as art that failed to load.
     They still say "there is more in here", but as the EDGE of a stack: dim,
     inked rather than bone-bordered, and pushed further up so only the top of
     each one is showing. Nothing reads as a card that forgot its contents. */
  border: 2px solid rgba(42,45,40,.9); box-shadow: 0 0 0 1px rgba(242,233,215,.10), 5px 6px 0 rgba(0,0,0,.4);
  opacity: 0; transition: opacity .4s ease .1s;
}
.pc-ghost.g1 { transform: translateY(calc(var(--pc-w) * -.115)) scale(.945); }
.pc-ghost.g2 { transform: translateY(calc(var(--pc-w) * -.075)) scale(.89); }
.pack-reveal.browsing .pc-ghost.g1 { opacity: .34; }
.pack-reveal.browsing .pc-ghost.g2 { opacity: .17; }

.pack-tilt { position: relative; z-index: 1; width: 100%; cursor: grab; touch-action: none; }
/* the rise out of the crate. Origin is the card's own bottom edge so scaling
   reads as "coming up out of the box", and the perspective for the sway lives
   here so the sway actually gets one. */
.pc-rise { transform-origin: 50% 100%; perspective: 1100px; visibility: hidden; }
.pack-deck.go .pc-rise { visibility: visible; animation: crCard 1.05s cubic-bezier(.18,.86,.26,1) var(--b-card) both; }
.pack-reveal.browsing .pack-deck.go .pc-rise { animation: crNext .38s cubic-bezier(.2,.9,.3,1) var(--b-card) both; }
.pack-deck.go .pc-sway { animation: crSway 4.8s ease-in-out var(--b-sway) infinite; }
@keyframes crDrop {
  0% { transform: translateY(calc(var(--pc-w) * -1.5966)) scale(.86); opacity: 0; }
  34% { opacity: 1; }
  44% { transform: translateY(0) scale(1.06, .9); }
  60% { transform: translateY(calc(var(--pc-w) * -.0588)) scale(.99, 1.03); }
  76% { transform: translateY(0) scale(1.01, .99); }
  100% { transform: translateY(0) scale(1); }
}
@keyframes crSettle {
  0%, 100% { transform: rotate(0) scale(1); }
  20% { transform: rotate(-1.4deg) scale(1.01); }
  44% { transform: rotate(1.8deg) scale(1.03); }
  66% { transform: rotate(-2.4deg) scale(1.05); }
  86% { transform: rotate(1.6deg) scale(1.08); }
}
@keyframes crLid {
  0% { transform: translate(0, 0) rotate(0); opacity: 1; }
  18% { transform: translate(calc(var(--pc-w) * -.0336), calc(var(--pc-w) * -.2437)) rotate(-8deg); opacity: 1; }
  52% { transform: translate(calc(var(--pc-w) * -.2437), calc(var(--pc-w) * -.7815)) rotate(-31deg); opacity: .9; }
  78% { transform: translate(calc(var(--pc-w) * -.4034), calc(var(--pc-w) * -1.1513)) rotate(-47deg); opacity: .5; }
  100% { transform: translate(calc(var(--pc-w) * -.4958), calc(var(--pc-w) * -1.3866)) rotate(-58deg); opacity: 0; }
}
@keyframes crBloom {
  0% { opacity: 0; transform: scale(.18); }
  14% { opacity: 1; }
  45% { opacity: .55; }
  100% { opacity: 0; transform: scale(2.4); }
}
@keyframes crSink {
  0% { transform: translateY(0) scale(1); opacity: 1; }
  55% { opacity: .5; }
  99% { opacity: 0; }
  100% { transform: translateY(calc(var(--pc-w) * .1092)) scale(.9); opacity: 0; visibility: hidden; }
}
/* ONE confident rise, no overshoot stops and no rotateX: foreshortening the card
   vertically on the way up read as a stutter */
@keyframes crCard {
  0% { transform: translateY(calc(var(--pc-w) * .2437)) scale(.16); opacity: 0; }
  16% { opacity: 1; }
  100% { transform: translateY(calc(var(--pc-w) * -.126)) scale(1); opacity: 1; }
}
@keyframes crNext {
  0% { transform: translateY(calc(var(--pc-w) * -.0168)) scale(.9); opacity: .25; }
  100% { transform: translateY(calc(var(--pc-w) * -.126)) scale(1); opacity: 1; }
}
@keyframes crSway {
  0%, 100% { transform: rotateY(-3.5deg) rotateX(1.6deg); }
  50% { transform: rotateY(3.5deg) rotateX(-1.6deg); }
}
@keyframes crLabel {
  0% { transform: translateY(14px); opacity: 0; }
  55% { transform: translateY(2px); opacity: 1; }
  100% { transform: translateY(0); opacity: 1; }
}

/* ---- the card ---- */
.pack-card {
  position: relative; width: min(78vw, 300px); aspect-ratio: 3 / 4.2; border-radius: 20px;
  /* FLAT surface + the app's bone-dot texture: matches the art (no gradients) */
  background-color: var(--surface-2);
  background-image: radial-gradient(rgba(242, 233, 215, 0.05) 1px, transparent 1.4px);
  background-size: 13px 13px;
  border: 3px solid var(--text); box-shadow: 0 0 0 2px var(--ink), 5px 6px 0 rgba(0,0,0,.55);
  display: flex; flex-direction: column; align-items: stretch; padding: 8px 8px 0; overflow: hidden;
  /* entrance via TRANSITION (transform/opacity) so the rarity glow can own `animation` */
  opacity: 0; transform: scale(.8) rotateY(-30deg); transition: opacity .45s ease, transform .55s cubic-bezier(.2,1.1,.35,1); user-select: none;
}
.pack-card.in { opacity: 1; transform: none; }
/* in the reveal the entrance belongs to .pc-rise, and the card is taller: it has
   a stats band to carry now */
.pack-reveal .pack-card { width: 100%; aspect-ratio: 3 / 4.9; opacity: 1; transform: none; transition: none; }
/* glare hotspot that tracks the pointer (set via --mx/--my in JS) */
.pc-glare { position: absolute; inset: 0; pointer-events: none; opacity: 0; transition: opacity .2s; mix-blend-mode: overlay;
  background: radial-gradient(circle at var(--mx,50%) var(--my,50%), rgba(255,255,255,.5), rgba(255,255,255,.12) 26%, transparent 46%); }
.pack-card.r-uncommon { border-color: var(--mint); }
/* rarity fanfare: a pulsing glow that intensifies by tier. common/uncommon: none. */
.pack-card.r-rare { border-color: var(--protein); animation: glowRare 2.3s ease-in-out infinite; }
.pack-card.r-epic { border-color: var(--violet); animation: glowEpic 1.9s ease-in-out infinite; }
.pack-card.r-legendary { border-color: var(--gold); animation: glowLegend 1.5s ease-in-out infinite; }
@keyframes glowRare { 0%,100% { box-shadow: 0 0 0 2px var(--ink), 5px 6px 0 rgba(0,0,0,.55), 0 0 12px rgba(124,196,255,.25); } 50% { box-shadow: 0 0 0 2px var(--ink), 5px 6px 0 rgba(0,0,0,.55), 0 0 26px rgba(124,196,255,.5); } }
@keyframes glowEpic { 0%,100% { box-shadow: 0 0 0 2px var(--ink), 5px 6px 0 rgba(0,0,0,.55), 0 0 16px rgba(155,146,232,.32); } 50% { box-shadow: 0 0 0 2px var(--ink), 5px 6px 0 rgba(0,0,0,.55), 0 0 34px rgba(155,146,232,.62); } }
@keyframes glowLegend { 0%,100% { box-shadow: 0 0 0 2px var(--ink), 5px 6px 0 rgba(0,0,0,.55), 0 0 20px rgba(var(--gold-rgb),.42); } 50% { box-shadow: 0 0 0 2px var(--ink), 5px 6px 0 rgba(0,0,0,.55), 0 0 44px rgba(var(--gold-rgb),.85); } }
/* floating sparkles on epic+ */
/* sparkles are drawn SVGs (flat gold + dark outline) in the game's art style */
.pc-spark { position: absolute; line-height: 0; opacity: 0; pointer-events: none; z-index: 3; }
.pack-card.r-epic .pc-spark, .pack-card.r-legendary .pc-spark { animation: sparkTwinkle 2.2s ease-in-out infinite; }
.pc-spark.k1 { top: 12%; left: 12%; animation-delay: var(--b-spark, 0s); }
.pc-spark.k2 { top: 20%; right: 14%; animation-delay: calc(var(--b-spark, 0s) + .5s); font-size: var(--fs-1); }
.pc-spark.k3 { bottom: 26%; left: 16%; animation-delay: calc(var(--b-spark, 0s) + 1.1s); font-size: var(--fs-2); }
.pc-spark.k4 { bottom: 30%; right: 12%; animation-delay: calc(var(--b-spark, 0s) + 1.6s); }
@keyframes sparkTwinkle { 0%,100% { opacity: 0; transform: scale(.5) rotate(0deg); } 45% { opacity: .95; transform: scale(1.1) rotate(20deg); } }
/* foil is RARE+ only (holo): commons/uncommons get no sheen (low tiers = no fanfare) */
.pc-foil { position: absolute; inset: 0; pointer-events: none; opacity: 0; z-index: 4; }
/* a NARROW moving rainbow band (not a full-face wash) so the flat face stays flat */
.pack-card.holo .pc-foil { opacity: 1; background: linear-gradient(115deg, transparent 34%, rgba(120,200,255,.10) 43%, rgba(200,140,255,.17) 50%, rgba(255,210,120,.10) 57%, transparent 66%); background-size: 260% 100%; animation: holoShift 7s linear infinite; }
@keyframes holoShift { 0% { background-position: 120% 0; } 100% { background-position: -60% 0; } }
/* what it is, and what level it wants — one line, opposite ends */
.pc-head { display: flex; justify-content: space-between; gap: 8px; padding: 2px 2px 6px; }
.pc-kind { font-size: var(--fs-0); font-weight: 800; letter-spacing: .1em; color: var(--text-3); text-align: left; text-transform: uppercase; }
.pc-lvl { font-size: var(--fs-0); font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--rar, var(--text-3)); }
.pc-art {
  flex: 1; width: 100%; display: grid; place-items: center; min-height: 0; position: relative;
  border: 2px solid var(--ink); border-radius: 12px; overflow: hidden;
  background: radial-gradient(120% 90% at 50% 40%, rgba(242,233,215,.07), rgba(242,233,215,0) 62%), var(--surface);
}
/* the tier reads on the art panel too, so a common and a legendary can never be
   confused at a glance */
.pack-card.r-epic .pc-art { box-shadow: inset 0 0 0 1px rgba(155,146,232,.45); }
.pack-card.r-legendary .pc-art { box-shadow: inset 0 0 0 2px rgba(var(--gold-rgb),.75); }
.pc-plate { margin: 8px -8px 0; padding: 7px 10px 8px; border-top: 2px solid var(--ink); text-align: center; }
/* legendary gets a struck gold nameplate with ink lettering */
.pack-card.r-legendary .pc-plate { background: linear-gradient(#ffd97a, #ecb14a); }
.pack-card.r-legendary .pc-name { color: var(--ink); }
.pack-card.r-legendary .pc-rar { background: var(--ink); color: var(--gold); }
.pc-canvas { width: 86%; height: auto; max-height: 86%; filter: drop-shadow(0 6px 8px rgba(0,0,0,.45)); }
/* THE PIECE, WORN. Tom, 2026-09-03: a reveal used to show the loose PNG, which
   made a grill the size of a coat and put every "how the sausage is made" cut-out
   on display. This is the shop rack's mannequin, unchanged.
   THE 1/0.86 BOX IS LOAD-BEARING. Every .fit-* transform-origin is a measured
   alpha-bbox centre taken against the rack's stage: a 1/0.86 window with the 640
   square laid in under object-fit: cover. .pc-art is a different shape, so the
   crop is given its own box of the rack's proportions and centred in the panel
   rather than re-deriving thirteen origins for a second aspect ratio.
   No drop-shadow: a figure standing on a stage does not float, and the halo on
   the tiny slots was measured at 6.6x the art's own area (see TINY_SLOTS).
   IT FILLS THE PANEL ON HEIGHT, NOT ON WIDTH. Measured on the rendered card at
   390x844: the art panel is 211x282, so a width-fitted 1/0.86 box left 100px of
   empty plate under the figure with a hard horizontal seam across it where the
   crop ended. Fitting the height overflows the box sideways instead, and
   .pc-art already clips it. Every .fit-* crop is centred on its target ink, so
   what leaves the frame is empty canvas at the edges. */
.pc-worn { position: absolute; top: 0; bottom: 0; left: 50%; transform: translateX(-50%);
  aspect-ratio: 1 / 0.86; overflow: hidden; }
.pc-worn .bh-anim { position: absolute; inset: 0; }
.pc-worn .bh-anim img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.pc-icon { font-size: 96px; display: grid; place-items: center; width: 100%; height: 100%; }
.pc-icon .bhi-wrap svg, .pc-icon svg { width: 62%; height: 62%; max-width: 168px; max-height: 168px; }
.pc-name { font-family: var(--display); font-weight: 400; letter-spacing: .02em; font-size: var(--fs-5); text-align: center; line-height: 1.08; }
/* 19px, not --fs-5: at 21px a two-word cosmetic name wraps inside a 238px card */
.pack-reveal .pc-name { font-size: var(--fs-5); }
.pc-rar {
  display: inline-block; margin-top: 4px;
  font-size: var(--fs-0); font-weight: 800; letter-spacing: .1em; text-transform: uppercase;
  border: 2px solid var(--ink); border-radius: 7px; padding: 1px 8px;
  background: var(--surface-2);
}
.pack-card.r-uncommon .pc-rar { background: var(--mint); color: var(--ink); }
.pack-card.r-rare .pc-rar { background: var(--protein); color: var(--ink); }
.pack-card.r-epic .pc-rar { background: var(--violet); color: var(--ink); }
/* the stats band: what the piece actually DOES. Cosmetics say so out loud
   rather than leaving an empty shelf where a legendary's numbers would be. */
.pc-band { flex: none; margin: 0 -8px; padding: 8px 9px 10px; border-top: 2px solid var(--ink); background: #191821; display: grid; gap: 6px; justify-items: center; }
.pc-chips { display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; }
.pcs {
  display: inline-flex; align-items: baseline; gap: 4px; padding: 3px 8px; border-radius: 8px;
  background: rgba(242,233,215,.06); border: 1.5px solid var(--ink);
  font-size: var(--fs-1); font-weight: 800; letter-spacing: .04em;
}
.pcs b { color: var(--stat, var(--text)); }
.pcs small { font-size: inherit; font-weight: inherit; color: var(--text-2); }
.pcs[data-k="POW"] { --stat: var(--gold); }
.pcs[data-k="MAR"] { --stat: var(--protein); }
.pcs[data-k="STA"] { --stat: var(--accent); }
.pcs[data-k="RFX"] { --stat: var(--violet); }
.pcs[data-k="HYP"] { --stat: #f08ab4; }
.pc-talent { display: flex; align-items: center; gap: 5px; font-size: var(--fs-0); font-weight: 700; letter-spacing: .02em; color: var(--text); }
.pc-talent i { width: 7px; height: 7px; transform: rotate(45deg); background: var(--rar, var(--gold)); }
.pc-plain { font-size: var(--fs-1); color: var(--text-3); }
.pc-stats { font-size: var(--fs-1); color: var(--text-3); text-align: center; line-height: 1.35; }
.pc-perk { font-size: var(--fs-2); font-weight: 800; color: var(--gold); }
.pc-perk-desc { font-size: var(--fs-0); color: var(--text-3); line-height: 1.3; }
/* ---- the foot ---- */
/* R37-6: margin-bottom, not just top, so the close hint clears #fab's box
   underneath (measured: full overlap at 393x852 and 375x667 without this). The
   FAB itself is also held disabled while a reveal is mounted (js/app.js), this
   is the belt beside that brace. */
.pack-foot { display: grid; justify-items: center; gap: 9px; margin-top: 14px; margin-bottom: 80px; min-height: 22px; animation: crLabel .7s ease-out var(--b-foot) both; }
.pack-hint { position: relative; font-size: var(--fs-1); color: var(--text-3); font-weight: 600; letter-spacing: .04em; }
.pack-hint::before { content: ''; position: absolute; left: 50%; top: 50%; min-width: 44px; min-height: 44px; width: 100%; transform: translate(-50%, -50%); }
.pack-dots { display: flex; gap: 6px; justify-content: center; }
.pack-dots i { width: 7px; height: 7px; border-radius: 50%; background: rgba(242,233,215,.22); }
.pack-dots i.on { background: var(--rar); }
.pack-coins {
  display: inline-flex; align-items: center; gap: 6px;
  background: var(--surface); border: 2px solid var(--ink); border-radius: 999px;
  box-shadow: var(--sh-sm); padding: 6px 13px;
  font-family: var(--display); font-weight: 400; font-size: var(--fs-4); letter-spacing: .02em;
  color: var(--gold);
}
@media (prefers-reduced-motion: reduce) {
  .pack-card { transition: opacity .2s; transform: none; }
  .pack-card.in { opacity: 1; transform: none; }
  .pack-card.in .pc-foil, .pack-card.holo .pc-foil, .pack-card.r-rare, .pack-card.r-epic, .pack-card.r-legendary,
  .pc-spark, .pack-deck.go .pc-rise, .pack-deck.go .pc-sway, .pack-count, .pack-title, .pack-foot { animation: none; }
  /* the sequence is removed, not frozen mid-frame: no crate, no bloom, no light */
  .pack-crate, .pack-bloom { display: none; }
  .pc-rise { visibility: visible; }
}

/* bigger, centered hatched pet (source PNG parks art in a corner) */
.hatch-prize .hatch-art { width: 200px; height: 200px; filter: drop-shadow(0 8px 9px rgba(0,0,0,.5)); }
.hatch-prize.is-shiny .hatch-art { filter: drop-shadow(0 0 14px rgba(var(--gold-rgb),.9)) drop-shadow(0 8px 9px rgba(0,0,0,.45)); }

/* boss loot: two pack cards side-by-side, pick one */
.loot-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 8px 0 12px; align-items: stretch; }
.loot-cards .pack-card { width: 100%; aspect-ratio: auto; height: auto; min-height: 340px; padding: 12px 9px 12px; opacity: 1; transform: none; box-shadow: 0 6px 18px rgba(0,0,0,.4); }
.loot-cards .pc-art { min-height: 120px; }
.loot-cards .pc-canvas { width: 84%; }
.loot-cards .pc-name { font-size: var(--fs-3); }
.loot-cards .pc-rar { font-size: var(--fs-0); }
.loot-cards .pc-stats { font-size: var(--fs-0); margin-top: 4px; }
.pack-card.selectable { cursor: pointer; -webkit-tap-highlight-color: transparent; }
/* outline (not box-shadow) so the selection ring survives the rarity glow animation */
.pack-card.selectable.selected { transform: translateY(-4px) scale(1.02); outline: 3px solid var(--accent); outline-offset: 1px; }
.pack-card.selectable.taken { opacity: .4; filter: grayscale(.65); }
/* the piece you KEPT: bright winner ring + badge (never the greyed one) */
.pack-card.selectable.kept { position: relative; transform: translateY(-4px) scale(1.02); outline: 3px solid var(--accent); outline-offset: 1px; box-shadow: 0 0 22px rgba(165, 232, 71, 0.55); }
.pack-card.selectable.kept::after { content: '✓ KEPT'; position: absolute; top: 8px; right: 8px; z-index: 4; background: var(--accent); color: #0d0c12; font-size: var(--fs-tiny); font-weight: 900; letter-spacing: .5px; padding: 2px 7px; border-radius: 999px; box-shadow: 0 2px 6px rgba(0,0,0,.4); }
.loot-cards .pack-card.in .pc-foil { animation: none; }

/* ---- Build tab: collapsible sections (same pattern as talent trees/quests) ---- */
.bsect { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; margin-bottom: 10px; overflow: hidden; }
.bsect > summary.bsect-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; cursor: pointer; list-style: none; }
.bsect > summary.bsect-head::-webkit-details-marker { display: none; }
.bsect-head b { font-family: var(--display); font-weight: 400; letter-spacing: .6px; font-size: var(--fs-4); }
.bsect-head .note { margin-left: auto; text-align: right; font-size: var(--fs-1); line-height: 1.25; }
.bsect-head::after { content: '▸'; margin-left: 6px; color: var(--text-3); transition: transform 0.2s; }
.bsect[open] > .bsect-head::after { transform: rotate(90deg); }
.bsect-body { padding: 2px 12px 12px; }

/* settle: item rewards as mini pack cards */
.settle-cards { margin: 10px auto 4px; max-width: 340px; }
.settle-cards.one { grid-template-columns: minmax(0, 62%); justify-content: center; }

/* notification preset buttons */
.notif-presets { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.notif-presets .btn { flex: 1; min-width: 130px; }

/* ---- wellness: sleep hour chips ---- */
.sleep-picks { display: flex; gap: 6px; margin-top: 7px; flex-wrap: wrap; }
.hchip { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-2); border-radius: 999px; padding: 5px 11px; font-size: var(--fs-2); font-weight: 700; cursor: pointer; }
.hchip.on { background: var(--protein); border-color: var(--protein); color: var(--bg); }

/* ---------- activity & recovery trends (v192) ---------- */
.trend-hero .recovery-cue {
  display: flex; align-items: flex-start; gap: 11px;
  padding: 11px 13px; border-radius: 14px; margin-bottom: 12px;
  border: 1px solid var(--line); background: var(--surface-2);
}
.recovery-cue .rc-dot { width: 11px; height: 11px; border-radius: 50%; margin-top: 3px; flex: none; background: var(--text-3); }
.recovery-cue .rc-txt { display: flex; flex-direction: column; gap: 2px; }
.recovery-cue .rc-txt b { font-size: var(--fs-3); }
.recovery-cue .rc-txt span { font-size: var(--fs-1); color: var(--text-2); line-height: 1.45; }
.recovery-cue.rc-good { background: var(--accent-soft); border-color: rgba(165, 232, 71, 0.34); }
.recovery-cue.rc-good .rc-dot { background: var(--accent); }
.recovery-cue.rc-easy { background: rgba(255, 201, 97, 0.12); border-color: rgba(var(--gold-rgb), 0.4); }
.recovery-cue.rc-easy .rc-dot { background: var(--gold); }
.recovery-cue.rc-steady .rc-dot { background: var(--protein); }

.trend-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.trend-card {
  text-align: left; background: var(--surface-2); border: 1px solid var(--line);
  border-radius: 13px; padding: 10px 11px 8px; display: flex; flex-direction: column; gap: 3px;
}
.trend-card:active { background: var(--surface-3); }
.trend-card .tc-top { display: flex; justify-content: space-between; align-items: center; }
.trend-card .tc-lab { font-size: var(--fs-0); color: var(--accent); text-transform: uppercase; letter-spacing: 0.03em; font-weight: 700; }
.trend-card .tc-chev { color: var(--text-3); font-size: var(--fs-4); line-height: 1; }
.trend-card .tc-val { font-size: var(--fs-5); font-weight: 800; font-variant-numeric: tabular-nums; }
.trend-card .tc-val small { font-size: var(--fs-0); color: var(--text-3); font-weight: 600; margin-left: 3px; }
.trend-card .tc-spark { height: 26px; margin-top: 2px; }

.mix-row { display: flex; align-items: center; gap: 10px; margin-bottom: 7px; }
.mix-row .mix-lab { font-size: var(--fs-2); font-weight: 600; width: 74px; flex: none; }
.mix-row .mix-bar { flex: 1; height: 8px; border-radius: 5px; background: var(--surface-3); overflow: hidden; }
.mix-row .mix-bar i { display: block; height: 100%; background: var(--accent); border-radius: 5px; }
.mix-row .mix-n { font-size: var(--fs-1); color: var(--text-3); font-variant-numeric: tabular-nums; width: 20px; text-align: right; flex: none; }

/* drill-in detail sheet */
.sheet-trend { position: relative; }
.trend-now { display: flex; align-items: baseline; gap: 7px; margin: 4px 2px 14px; }
.trend-now .n { font-size: var(--fs-7); font-weight: 900; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.trend-now .u { font-size: var(--fs-3); color: var(--text-2); font-weight: 700; }
.trend-now .now-d { font-size: var(--fs-1); font-weight: 800; color: var(--accent); margin-left: 3px; }
.trend-now .now-d.bad { color: var(--gold); }
.rtabs { display: flex; gap: 6px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 12px; padding: 4px; margin-bottom: 14px; }
.rtab { flex: 1; background: transparent; color: var(--text-3); font-weight: 800; font-size: var(--fs-2); padding: 7px 0; border-radius: 9px; }
.rtab.on { background: var(--surface-3); color: var(--text-1, #f2e9d7); }
.trend-panel { background: var(--surface-2); border: 1px solid var(--line); border-radius: 15px; padding: 12px; }
.trend-stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; margin-top: 12px; }
.trend-stats .st { background: var(--surface-2); border: 1px solid var(--line); border-radius: 12px; padding: 9px 10px; }
.trend-stats .st .l { font-size: var(--fs-tiny); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.trend-stats .st .v { font-size: var(--fs-4); font-weight: 850; margin-top: 1px; font-variant-numeric: tabular-nums; }
.trend-stats .st .v small { font-size: var(--fs-tiny); color: var(--text-3); font-weight: 600; }
.trend-insight { margin-top: 13px; padding: 11px 13px; border-radius: 13px; background: var(--surface-2); border: 1px solid var(--line); font-size: var(--fs-2); line-height: 1.5; color: var(--text-2); }
.trend-insight b { color: var(--text-1, #f2e9d7); }
.trend-insight.ti-good { background: var(--accent-soft); border-color: rgba(165, 232, 71, 0.32); }
.trend-insight.ti-warn { background: rgba(255, 201, 97, 0.11); border-color: rgba(var(--gold-rgb), 0.36); }

/* Home top-left "Trends" entry (repurposed streak chip → clear graph affordance, v194) */
.streak-chip.trend-chip { color: var(--text-2); border-color: var(--line-strong); }
.streak-chip.trend-chip b { color: var(--text); font-weight: 800; letter-spacing: 0.02em; }
.streak-chip.trend-chip .tico { display: inline-flex; align-items: center; }

/* Trends update banner (v196): self-hides when the running build is current */
.upd-banner { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px;
  text-align: left; background: linear-gradient(180deg, rgba(255,201,97,.17), rgba(255,201,97,.06));
  border: 1.5px solid rgba(var(--gold-rgb), .5); border-radius: var(--radius-sm); padding: 12px 14px; margin-bottom: 12px; }
.upd-banner .ub-txt { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.upd-banner .ub-txt b { font-size: var(--fs-3); color: var(--text); }
.upd-banner .ub-txt span { font-size: var(--fs-1); color: var(--text-3); }
.upd-banner .ub-cta { flex: none; background: var(--gold); color: #2a1c05; font-weight: 800; font-size: var(--fs-2);
  padding: 8px 14px; border-radius: 10px; }
.upd-banner:active { filter: brightness(.95); }

/* Trends "turn on heart tracking" CTA (v199) */
.heart-cta { display: flex; align-items: center; gap: 12px; margin-top: 12px; padding: 12px 13px;
  border-radius: var(--radius-sm); background: rgba(255,157,199,.10); border: 1px solid rgba(255,157,199,.34); }
.heart-cta .hc-txt { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.heart-cta .hc-txt b { font-size: var(--fs-3); color: var(--text); }
.heart-cta .hc-txt span { font-size: var(--fs-1); color: var(--text-3); line-height: 1.45; }
.heart-cta .btn { flex: none; }

/* Counterstep jab flurry FX (v199): Cam's 3 fists stacked as an anime 1-2-3 */
.jabfx { position: absolute; inset: 0; pointer-events: none; z-index: 40; overflow: hidden; }
.jabfx img { position: absolute; top: 50%; left: 50%; width: 82%; height: 82%; object-fit: contain; opacity: 0;
  transform: translate(-50%, -50%); filter: drop-shadow(0 2px 7px rgba(0,0,0,.55)); }
.jabfx.mir { transform: scaleX(-1); }
@keyframes jabpop {
  0% { transform: translate(-50%, -50%) scale(1.22); }
  60% { transform: translate(-50%, -50%) scale(.97); }
  100% { transform: translate(-50%, -50%) scale(1); }
}
@media (prefers-reduced-motion: reduce) { .jabfx img { animation-duration: .01s !important; } }

/* Basic-move strike FX (v245): Cam's jab + swing frames. The three frames per move
   are CUMULATIVE and share one cropped canvas, so they are stacked in the same spot
   and revealed one at a time (JS toggles opacity); aspect-ratio comes from the
   asset crop so the art is never squashed:
     jab   928x357  (2.599)  a flat horizontal punch
     swing 985x581  (1.695)  an upward arc
   Sizes are % of the arena so this scales with the stage on any phone. */
.strikefx { position: absolute; inset: 0; pointer-events: none; z-index: 40; overflow: hidden; }
.strikefx img {
  position: absolute; opacity: 0;      /* left/top/transform set by strikeFx() */
  transition: opacity .05s linear;
  filter: drop-shadow(0 2px 7px rgba(0, 0, 0, .55));
}
/* Size is measured against a FIGHTER, not the arena, because that is the only
   scale-invariant anchor: in Cam's comps each fighter is 583px and the jab FX is
   928px, i.e. 1.59x a fighter (swing 1.69x). .fstage is a fixed 112px, so:
     jab   1.59 x 112 = 178px wide
     swing 1.69 x 112 = 189px wide, which lands 112px tall, exactly one fighter high
   Anchoring to the arena instead was the mistake: Cam draws his fighters at 35% of
   the comp where the app draws them at 20% of the arena, so "56% of the scene"
   rendered the jab at 2.84x a fighter, nearly double Cam's intent. The max-widths
   only matter on a very narrow arena. */
/* Position is set inline by strikeFx() from the VICTIM's box (centre 35% down them,
   leading edge 75% into them, both measured off Cam's comps). Only the SIZE lives
   here, as a multiple of the fixed 112px fighter: jab 1.59x = 178px, swing 1.69x =
   189px, which lands exactly one fighter tall. Arena-relative percentages were the
   bug twice over: too big horizontally, and floating above the head vertically. */
.strikefx.sfx-jab img { width: 178px; max-width: 62%; aspect-ratio: 928 / 357; height: auto; }
.strikefx.sfx-haymaker img { width: 240px; max-width: 80%; aspect-ratio: 480 / 204; height: auto; transition: none; filter: none; }
.strikefx.sfx-swing img { width: 189px; max-width: 66%; aspect-ratio: 985 / 581; height: auto; }
.strikefx.sfx-signature img { width: 180px; height: auto; aspect-ratio: 360 / 236; transition: none; filter: none; }
@media (prefers-reduced-motion: reduce) {
  /* no crossfade, just land the impact frame */
  .strikefx img { transition: none; }
}

/* Heckle taunt FX (v205): two skulls jeer over the rattled fighter's shoulders,
   floating in alternate rhythm. Skull position/size are set inline by heckleTaunt. */
.heckfx { position: absolute; inset: 0; pointer-events: none; z-index: 41; }
.heckfx .hsk { position: absolute; background-size: contain; background-repeat: no-repeat; background-position: center; filter: drop-shadow(0 2px 6px rgba(0,0,0,.5)); animation: heckbob 1.6s ease-in-out infinite; }
.heckfx .hbone { animation-delay: -.8s; }
@keyframes heckbob { 0%, 100% { transform: translateY(-4px); } 50% { transform: translateY(4px); } }
@media (prefers-reduced-motion: reduce) { .heckfx .hsk { animation: none; } }

/* Backpack: collapsed Melt-gear fold (v203) so the Shop isn't buried below it */
.melt-fold > summary { list-style: none; cursor: pointer; font-size: var(--fs-2); font-weight: 700; color: var(--text-2);
  letter-spacing: 0.02em; padding: 9px 12px; background: var(--surface-2); border: 1px solid var(--line);
  border-radius: 12px; display: flex; align-items: center; gap: 6px; }
.melt-fold > summary::-webkit-details-marker { display: none; }
.melt-fold > summary::after { content: '▾'; margin-left: auto; color: var(--text-3); transition: transform .18s; }
.melt-fold[open] > summary::after { transform: rotate(180deg); }
.melt-fold > summary:active { background: var(--surface-3); }
.melt-fold[open] > summary { margin-bottom: 6px; }

/* Collapsible shop section. The weapon list is long, and most trips to the Shop
   are for crates, so the merchant folds away under the two crate shops. */
.shop-fold > summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px; }
.shop-fold > summary::-webkit-details-marker { display: none; }
.shop-fold > summary::after {
  content: ''; margin-left: auto; width: 8px; height: 8px;
  border-right: 2px solid var(--text-3); border-bottom: 2px solid var(--text-3);
  transform: rotate(45deg); transition: transform .18s ease;
}
.shop-fold[open] > summary::after { transform: rotate(-135deg); }
.shop-fold > summary:active { opacity: 0.7; }

/* Combat status chips (v208): compact ICON pills on their own line, tap for detail */
.fstate { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.fstate[hidden] { display: none; }
.hud-side.foe .fstate { justify-content: flex-end; }
.fchip { line-height: 1; padding: 2px 5px; border-radius: 7px; background: rgba(255,255,255,.05);
  border: 1px solid var(--line); display: inline-flex; align-items: center; gap: 2px; }
.fchip .fi { font-size: var(--fs-1); }
.fchip i { font-style: normal; font-size: var(--fs-tiny); font-weight: 800; color: var(--text-1, #f2e9d7); }
.fchip:active { filter: brightness(1.35); }
/* The tap target, not the look. These chips were 43x18px against Apple's 44pt
   minimum, so tapping one on a phone was a coin flip and it read as "the debuff
   icons do nothing" even though the handler and the tooltip both worked fine. The
   pseudo-element grows the hit area without making the chip itself any bigger, so
   the row still fits across the plate. */
.fchip { position: relative; }
.fchip::after {
  content: ''; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: max(100%, 44px); height: 44px;
}
/* the rows sit above the plates, so the grown targets must not swallow taps meant
   for the fighter art underneath: only the chips themselves are interactive */
.fstate { pointer-events: none; }
.fstate .fchip { pointer-events: auto; }
.fchip.s-burn { border-color: rgba(255,180,84,.6); background: rgba(255,180,84,.1); }
.fchip.s-bleed, .fchip.s-rage { border-color: rgba(255,109,94,.6); background: rgba(255,109,94,.1); }
.fchip.s-poison { border-color: rgba(165,232,71,.5); background: rgba(165,232,71,.08); }
.fchip.s-blind { border-color: rgba(185,172,151,.55); }
.fchip.s-weaken, .fchip.s-sunder { border-color: rgba(201,160,255,.6); background: rgba(201,160,255,.1); }
.fchip.s-mark { border-color: rgba(255,157,199,.6); background: rgba(255,157,199,.1); }
.fchip.s-ward { border-color: rgba(124,196,255,.6); background: rgba(124,196,255,.1); }
.fchip.s-summon { border-color: rgba(185,172,151,.45); }

/* Stable: per-pet inline talent tree (v207) — tap a pet to expand its talents */
.stable-card { cursor: pointer; }
.stable-card .stable-chev { font-size: var(--fs-tiny); font-weight: 700; color: var(--accent); margin-left: 8px; letter-spacing: .02em; white-space: nowrap; }
.stable-card.talk-open { border-color: rgba(165,232,71,.4); }
.pet-tree-inline { margin: 0 0 14px; padding: 10px 12px 12px; background: var(--surface-2); border: 1px solid var(--line);
  border-top: none; border-radius: 0 0 14px 14px; }
.pet-tree-inline .tree-head { font-size: var(--fs-1); font-weight: 800; color: var(--accent); letter-spacing: .03em; margin: 0 0 6px; text-transform: uppercase; }

/* Daily readiness dashboard (v212): futuristic gauge + feeder tiles */
.rd-card { position: relative; overflow: hidden; }
.rd-eyebrow { font-size: var(--fs-0); letter-spacing: .32em; color: #5fe6d0; text-transform: uppercase; text-align: center; opacity: .85; margin-bottom: 2px; }
/* The gauge is a 270deg arc in a square SVG, so its bottom third is empty. Pull
   the status text up into that open mouth so there's no dead gap below the dial. */
.rd-gauge-wrap { position: relative; display: grid; place-items: center; margin: 2px 0 -42px; }
.rd-gauge { width: 250px; height: 250px; max-width: 84%; }
.rd-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -54%); text-align: center; pointer-events: none; }
.rd-center .rd-score { font-family: var(--display); font-weight: 400; font-size: var(--fs-jumbo); line-height: .8; color: var(--text-1, #f2e9d7); text-shadow: 0 0 24px rgba(165,232,71,.35); }
.rd-center .rd-of { display: block; font-size: var(--fs-1); color: var(--text-3); font-weight: 700; margin-top: 4px; }
.rd-status { font-family: var(--display); font-weight: 400; font-size: var(--fs-5); letter-spacing: .05em; text-align: center; margin-top: 2px; }
.rd-sub { font-size: var(--fs-2); color: var(--text-3); text-align: center; margin: 4px auto 0; max-width: 30ch; line-height: 1.45; }
.rd-tiles { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; margin-top: 14px; }
.rd-tile { background: linear-gradient(180deg, var(--surface-2), #0d0c15); border: 1px solid var(--line); border-radius: 13px; padding: 10px 8px 9px; text-align: center; display: flex; flex-direction: column; gap: 2px; position: relative; overflow: hidden; }
.rd-tile::after { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 2px; background: #5fe6d0; opacity: .55; }
.rd-tile:not(.static):active { background: var(--surface-3); }
.rd-tile .rl { font-size: var(--fs-tiny); letter-spacing: .03em; color: var(--text-3); text-transform: uppercase; }
.rd-tile .rv { font-size: var(--fs-5); font-weight: 850; font-variant-numeric: tabular-nums; }
.rd-tile .rv small { font-size: var(--fs-tiny); color: var(--text-3); font-weight: 700; margin-left: 2px; }
.rd-tile i { font-style: normal; font-size: var(--fs-0); font-weight: 800; }
.rd-tile i.up { color: var(--accent); } .rd-tile i.warn { color: var(--gold); }

/* drill-in sheet now scrolls (content was clipped) with the Close pinned */
.sheet-trend { position: relative; }
.sheet-trend .trend-scroll { overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; flex: 1; min-height: 0; padding: 2px var(--pad) calc(var(--sab) + 20px); }

/* Sleep detail sheet (v213): stage breakdown + score */
.sleep-top { display: flex; align-items: center; gap: 14px; margin: 6px 0 12px; }
.sleep-score { font-family: var(--display); font-weight: 400; font-size: var(--fs-hero); line-height: .85; }
.sleep-score small { font-size: var(--fs-3); color: var(--text-3); margin-left: 3px; }
.sleep-meta { display: flex; flex-direction: column; gap: 2px; }
.sleep-meta b { font-size: var(--fs-4); }
.sleep-meta span { font-size: var(--fs-2); color: var(--text-3); }
.sleep-bar { display: flex; height: 16px; border-radius: 8px; overflow: hidden; margin: 4px 0 12px; background: var(--surface-2); }
.sleep-bar i { display: block; }
.sleep-legend { display: flex; flex-direction: column; gap: 7px; }
.sl-row { display: flex; align-items: center; gap: 9px; font-size: var(--fs-2); }
.sl-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
.sl-k { flex: 1; font-weight: 700; }
.sl-m { color: var(--text-2); font-variant-numeric: tabular-nums; }
.sl-p { width: 42px; text-align: right; color: var(--text-3); font-variant-numeric: tabular-nums; }

/* Combat status tooltip (v214): tap a stacked status chip for what it does,
   same bone-bubble language as the Boneyard marker tip. Fixed so it floats
   above the fight sheet regardless of arena transforms. */
.fchip-tip {
  position: fixed; z-index: 60; width: 210px; pointer-events: none;
  background: #f2e9d7; color: #241c10; border: 2.5px solid #14100b; border-radius: 12px;
  padding: 8px 11px; box-shadow: 0 5px 0 rgba(0, 0, 0, 0.45); display: flex; flex-direction: column; gap: 3px;
  animation: bubblePop 0.22s cubic-bezier(0.34, 1.6, 0.64, 1);
}
.fchip-tip b { font-weight: 900; font-size: var(--fs-2); text-transform: capitalize; }
.fchip-tip span { font-size: var(--fs-1); font-weight: 600; line-height: 1.35; }
/* Press-and-hold a move in the Pit: the same bone bubble, wider, because it
   carries a whole sentence rather than a status name. It reuses .fchip-tip
   wholesale so there is one tooltip look in the fight, not two.
   NO REDUCED-MOTION BLOCK OF ITS OWN, deliberately: bubblePop is inherited from
   .fchip-tip and the global @media (prefers-reduced-motion: reduce) rule at the
   top of this file already collapses it to one 0.001s cycle. A second rule here
   would be a second thing to keep in step for no gain. */
.fmove-tip { width: min(264px, calc(100vw - 24px)); }
/* .fchip-tip capitalises because a status name arrives lower-case ("sunder").
   A move LABEL is already cased by hand and the transform breaks it: measured on
   the render, "Call the Murder" came out "Call The Murder". */
.fmove-tip b { text-transform: none; }
.fmove-tip .mt-now { font-size: var(--fs-0); font-weight: 800; color: #6a5f4a; text-transform: uppercase; letter-spacing: 0.02em; }

/* The Glutton (v215): announcement card + reveal, matching the direction Tom
   already approved (scratchpad/glutton.src.html) — near-monochrome, ONE muted
   sickly-green accent, spooky not poppy, bone-white Bangers title, single
   outlined CTA. Do not reach for the app's usual bright lime accent here. */
:root { --glutton-sick: #9fb04e; --glutton-sick-dk: #586327; --glutton-bone: #EAE3D2; --glutton-dim: #8f887a; }
.glutton-card {
  text-align: center; background: radial-gradient(78% 60% at 50% 0%, rgba(159, 176, 78, .08), transparent 62%), var(--surface);
  border: 1px solid rgba(234, 227, 210, .08);
}
.glutton-tag { font-size: var(--fs-0); letter-spacing: .28em; color: var(--glutton-dim); text-transform: uppercase; margin-bottom: 4px; }
/* The tagline row is the sheet's top bar: sticky, full-bleed, painted in the sheet surface, so scrolled content never slides under the absolute Done button and Done stays tappable. */
.glutton-card .glutton-tag { position: sticky; top: 0; z-index: 1; box-sizing: border-box; min-height: 48px; margin: 0 calc(-1 * var(--pad)) 4px; padding: 8px calc(var(--pad) + 56px); display: flex; align-items: center; justify-content: center; background: var(--surface); }
.glutton-card { padding-bottom: 0; padding-top: 0 !important; }   /* top bar sticks flush: no strip above it for scrolled text to show in (overrides the inline 8px) */   /* the dock owns the bottom pad, so it sits flush with the scrollport edge */
.glutton-h1 { margin: 0 0 8px; }
.glutton-h1 .sm { display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-3); letter-spacing: .12em; color: var(--glutton-dim); }
.glutton-h1 .big { display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-7); letter-spacing: .02em; line-height: .92; color: var(--glutton-bone); text-shadow: 0 2px 0 #000, 0 0 30px rgba(159, 176, 78, .16); margin-top: 1px; }
.glutton-card .glutton-h1 .big { font-size: var(--fs-display); }

.glutton-hero { position: relative; width: 66%; max-width: 230px; margin: 6px auto 4px; }
.gh-glow { position: absolute; inset: 8% 4% auto 4%; height: 62%; border-radius: 50%; background: radial-gradient(closest-side, rgba(159, 176, 78, .22), transparent); filter: blur(8px); }
.gh-img { position: relative; width: 100%; display: block; animation: gluttonWob 4.6s ease-in-out infinite; transform-origin: 50% 100%; filter: drop-shadow(0 10px 14px rgba(0, 0, 0, .5)); }
@keyframes gluttonWob { 0%, 100% { transform: translateY(0) scale(1, 1); } 50% { transform: translateY(-3px) scale(1.015, .985); } }
.glutton-hero .glutton-bub { position: absolute; opacity: 0; animation: gluttonBubRise 5.5s ease-in infinite; }
.glutton-hero .gb0 { left: 8%; top: 4%; width: 9%; aspect-ratio: 1; animation-delay: 0s; }
.glutton-hero .gb1 { left: 18%; top: -2%; width: 5%; aspect-ratio: 1; animation-delay: 1.8s; }
.glutton-hero .gb2 { right: 10%; top: 10%; width: 6%; aspect-ratio: 1; animation-delay: 3.2s; }

.glutton-lore { margin-top: 12px; text-align: left; background: linear-gradient(180deg, rgba(22, 20, 32, .55), rgba(12, 10, 20, .55)); border: 1px solid rgba(234, 227, 210, .1); border-radius: 14px; padding: 15px 16px; }
.glutton-lore p { margin: 0 0 9px; font-size: var(--fs-2); color: #d7cfbc; line-height: 1.5; }
.glutton-lore p:last-child { margin-bottom: 0; }
.glutton-lore p.lead { color: var(--glutton-bone); font-weight: 600; }
.glutton-lore .accent { color: var(--glutton-sick); font-weight: 600; }
.glutton-lore p.sign { color: var(--glutton-dim); font-style: italic; }
.glutton-quote { margin-top: 11px; padding-top: 10px; border-top: 1px solid rgba(234, 227, 210, .1); font-style: italic; font-size: var(--fs-1); color: var(--glutton-dim); line-height: 1.5; }
.glutton-quote b { color: #b7ad97; font-style: normal; font-weight: 600; }
.glutton-mech { margin-top: 12px; font-size: var(--fs-1); color: var(--glutton-dim); line-height: 1.5; text-align: center; }
.glutton-mech b { color: var(--glutton-bone); font-weight: 600; }

/* The CTA docks to the bottom of the sheet so the lore scrolls under it; the dock paints the sheet's own surface and clears the home indicator. */
.glutton-cta-dock { position: sticky; bottom: 0; z-index: 1; margin: 0 calc(-1 * var(--pad)); padding: 10px var(--pad) calc(var(--sab) + 12px); background: var(--surface); }
.glutton-cta-dock .glutton-cta { margin-top: 0; }
.glutton-cta {
  margin-top: 16px; width: 100%; font-family: var(--display); font-weight: 400; letter-spacing: .05em; font-size: var(--fs-5);
  color: var(--glutton-bone); background: transparent; border: 2px solid var(--glutton-sick); border-radius: 13px; padding: 11px;
  box-shadow: 0 0 20px rgba(159, 176, 78, .12);
}
.glutton-cta[disabled] { opacity: .5; border-color: var(--glutton-dim); box-shadow: none; }
.glutton-beaten { margin-top: 12px; padding-bottom: calc(var(--sab) + 20px); font-size: var(--fs-2); color: var(--glutton-dim); text-align: center; }

/* Combat-only: the fuller idle/tongue-lash/drool loop as the foe's own art in
   the arena (replaces the generic skeleton costume for this one boss). */
.glutton-stage { position: relative; width: 100%; height: 100%; }
.glutton-plate {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain;
  opacity: 0; transition: opacity .5s ease;
}
.glutton-plate.on { opacity: 1; animation: gluttonBreathe 4.8s ease-in-out infinite; }
@keyframes gluttonBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.018); } }
.glutton-bub { position: absolute; display: block; opacity: 0; animation: gluttonBubRise 6s ease-in infinite; }
.glutton-bub img { width: 100%; height: 100%; display: block; }
.glutton-stage .gb0 { left: 60.4%; top: 13.1%; width: 5.5%; aspect-ratio: 1; animation-delay: 0s; }
.glutton-stage .gb1 { left: 59.0%; top: 19.0%; width: 2.8%; aspect-ratio: 1; animation-delay: 1.6s; }
.glutton-stage .gb2 { left: 64.0%; top: 20.4%; width: 3.5%; aspect-ratio: 1; animation-delay: 3.0s; }
.glutton-stage .gb3 { left: 55.4%; top: 17.8%; width: 2.5%; aspect-ratio: 1; animation-delay: 4.3s; }
@keyframes gluttonBubRise {
  0% { opacity: 0; transform: translateY(0) scale(.55); }
  12% { opacity: 1; transform: translateY(-6px) scale(1); }
  75% { opacity: .9; }
  100% { opacity: 0; transform: translateY(-46px) scale(1.3); }
}

/* The Glutton in combat: a large boss creature, not skeleton-sized. The combat
   plates (assets/bh/glutton/combat/*) are pre-cropped tight to the actual
   drawing (the raw canvases are ~35% empty transparent sky, which made the
   first pass read as floating and forced a too-wide box). Box matches the
   cropped art's real aspect ratio (431:327) so there's no letterboxing/gap. */
#foeStage.glutton-foe { width: 176px; height: 134px; align-self: end; }
.fighterG.foe-side.glutton-boss { width: 176px; }
.glutton-plate { object-position: center bottom; }

/* Glutton teaser banner on Today (v217): collapsible, native <details>, no
   fight button — just wayfinding to the real map encounter. */
.glutton-banner { background: linear-gradient(160deg, rgba(159, 176, 78, .14), rgba(22, 20, 32, .9) 55%), var(--surface); border: 1px solid rgba(159, 176, 78, .28); border-radius: 16px; margin-bottom: 12px; overflow: hidden; }
.glutton-banner > summary { list-style: none; display: flex; align-items: center; gap: 11px; padding: 11px 13px; cursor: pointer; }
.glutton-banner > summary::-webkit-details-marker { display: none; }
/* THE GLYPH IS CENTRED IN THE SLOT, AND IT IS THE SHARED SKIN THAT DOES IT.
   Tom, 2026-08-22: "the icon on the crew banner 'thanks for bieng early' is up in
   the top left, it should be centred like the first step foot". Measured on the
   real banner (.gbn-ico.bt-bnr-ico) at 393x852: the glyph sat -8.0, -9.0 px from
   the disc's centre, because the slot was `display: block` and the glyph simply
   started at its top-left corner. `grid` + `place-items: center` fixes it for
   every banner at once, current and future, rather than nudging this one.
   Measured 2026-08-23: before dx -8.0 dy -9.0, after dx 0.0 dy 0.0. */
.gbn-ico { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; background: #0d0f08; border: 1px solid rgba(159, 176, 78, .35); overflow: hidden; position: relative; display: grid; place-items: center; }
.gbn-ico .glutton-hero { position: absolute; width: 150%; left: 50%; top: 58%; transform: translate(-50%, -50%); margin: 0; }
.gbn-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.gbn-txt i { font-style: normal; font-size: var(--fs-tiny); letter-spacing: .2em; color: var(--glutton-sick); text-transform: uppercase; font-weight: 800; }
.gbn-txt b { font-family: var(--display); font-weight: 400; font-size: var(--fs-body); letter-spacing: .02em; color: var(--glutton-bone); }
.gbn-chev { color: var(--text-3); transition: transform .2s; }
.glutton-banner[open] .gbn-chev { transform: rotate(90deg); }
.gbn-body { padding: 2px 14px 15px; }

/* The Glutton's map marker (v217): real art + a pulsing sickly blight halo,
   matching the approved den-marker language but for a one-of-a-kind boss. */
/* Bigger, more formidable boss token than a den/spawn pin. */
.map-glutton-mark { position: relative; width: 92px; height: 92px; }
.map-glutton-mark img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; filter: drop-shadow(0 5px 8px rgba(0,0,0,.7)); z-index: 2; animation: gluttonMarkWob 4.4s ease-in-out infinite; transform-origin: 50% 92%; }
@keyframes gluttonMarkWob { 0%, 100% { transform: translateY(0) scale(1,1); } 50% { transform: translateY(-2px) scale(1.02,.98); } }
/* The BLIGHT: a feathered fractal-fog patch (matches the approved mock), sized
   in px by JS to cover the real ~140m suppression radius so the strangled loot
   reads visually. Static noise (no per-frame turbulence = no flicker), just a
   slow opacity breathe. */
.glutton-blight-halo {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: 220px; height: 220px; z-index: 1; pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cdefs%3E%3CradialGradient id='m' cx='50%25' cy='50%25' r='50%25'%3E%3Cstop offset='0%25' stop-color='%23fff' stop-opacity='1'/%3E%3Cstop offset='52%25' stop-color='%23fff' stop-opacity='.8'/%3E%3Cstop offset='100%25' stop-color='%23fff' stop-opacity='0'/%3E%3C/radialGradient%3E%3Cmask id='fade'%3E%3Crect width='220' height='220' fill='url(%23m)'/%3E%3C/mask%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.05' numOctaves='3' seed='7' result='t'/%3E%3CfeColorMatrix in='t' type='matrix' values='0 0 0 0 0.34 0 0 0 0 0.42 0 0 0 0 0.14 0 0 0 1.1 -0.3'/%3E%3C/filter%3E%3C/defs%3E%3Cg mask='url(%23fade)'%3E%3Crect width='220' height='220' fill='%230a0d07'/%3E%3Crect width='220' height='220' filter='url(%23f)'/%3E%3C/g%3E%3C/svg%3E");
  background-size: 100% 100%; animation: gluttonHaloPulse 4s ease-in-out infinite;
}
@keyframes gluttonHaloPulse { 0%, 100% { opacity: .82; } 50% { opacity: 1; } }
.map-glutton-mark.inrange img { filter: drop-shadow(0 0 12px rgba(165,232,71,.85)) drop-shadow(0 5px 8px rgba(0,0,0,.7)); }

/* SLIMED gear: the rare green-glowing variant only the Glutton drops. Sits on
   top of the normal rarity styling so you can still read the piece's tier. */
.ward-cell.slimed { border-color: rgba(159, 176, 78, .95); box-shadow: 0 0 14px rgba(120, 200, 60, .5), inset 0 0 12px rgba(120, 200, 60, .18); }
.ward-cell.slimed img, .ward-cell.slimed canvas { filter: drop-shadow(0 0 7px rgba(150, 230, 80, .85)); }
.ward-cell.slimed::before {
  content: 'SLIMED'; position: absolute; top: 3px; left: 3px; z-index: 3;
  font-size: var(--fs-micro); font-weight: 900; letter-spacing: .06em; line-height: 1;
  padding: 2px 3px; border-radius: 4px; color: #16210b; background: #9fb04e;
}
/* TRANSMOG (v221): the "pick your look" grid under the fit grid. Deliberately
   quieter than the gear grid above it, so it reads as a second, optional step
   rather than competing for the same attention. */
.look-grid .ward-cell.look { background: var(--surface); border-color: var(--line); }
.ward-cell.look .look-tag {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 2;
  font-size: var(--fs-micro); font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
  color: var(--text-2); background: rgba(13, 12, 18, .84); padding: 2.5px 2px; line-height: 1.3;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none;
}
.ward-cell.look .look-cost, .pw-item.famr .look-cost {
  position: absolute; right: 3px; bottom: 3px; z-index: 2;
  font-size: var(--fs-micro); font-weight: 900; line-height: 1; padding: 3px 4px; border-radius: 5px;
  color: var(--text-1, #efe9f5); background: rgba(13, 12, 18, .86); pointer-events: none;
}
/* 2026-09-25: match the wallet art while preserving the inline price slot. */
.ward-cell.look .look-cost::after, .pw-item.famr .look-cost::after { content: ''; display: inline-block;
  width: 1em; height: 1em; margin-left: .25em; vertical-align: middle;
  background: url(assets/icons-pix/bone-dust.png) center / contain no-repeat;
  image-rendering: pixelated; opacity: .75; }
.ward-cell.look .look-hide { font-size: var(--fs-5); opacity: .6; }
.look-bar {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  margin-top: 9px; padding: 9px 11px; border-radius: 12px;
  background: var(--surface-2); border: 1.5px solid var(--line); min-height: 42px;
}
.look-bar.armed { border-color: var(--accent); box-shadow: 0 0 12px rgba(255, 201, 97, .18); }
.look-bar .lb-txt { font-size: var(--fs-2); color: var(--text-2); min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.look-bar .lb-txt b { color: var(--text-1, #efe9f5); }
.look-bar .btn { flex: none; padding: 7px 12px; font-size: var(--fs-2); }

/* ---- ?mogv2: the reworked look panel (new-player grill, 2026-08-23) --------
   Behind a query param until Tom picks it. Everything here is presentation:
   nothing below changes a price, a gate or what a control does. */
.mog-panel {
  margin-top: 14px; padding: 11px 11px 10px; border-radius: 14px;
  background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--accent);
}
.mog-panel .sect-h.mog-h {
  display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 0;
}
.mog-lead { margin: 5px 0 9px; font-size: var(--fs-2); line-height: 1.4; color: var(--text-2); }
.mog-lead b { color: var(--text-1, #efe9f5); }
.mog-empty { margin: 6px 2px 2px; }
/* THE PREVIEW, AT THE DECISION. Two stages of the same figure, so the answer to
   "what will I look like" is in the same frame as the tiles that change it. */
.mog-figs { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 10px; }
.mog-figs figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.mog-figs .mog-cap {
  font-size: var(--fs-micro); font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--text-3);
}
.mog-figs figure.after .mog-cap { color: var(--accent); }
.mog-figs .mog-arrow { color: var(--text-3); display: flex; align-items: center; padding-top: 13px; }
.bh-stage.mog-fig { width: 130px; height: 130px; border-radius: 13px; }
/* same fit as the big paper doll, so the small pair is the SAME picture, not a
   differently-cropped one (.bh-stage's base rule is object-fit: cover) */
.bh-stage.mog-fig .bh-anim { inset: 0; --av-fit: contain; --av-pos: 50% 72%; }
.bh-stage.mog-fig img { object-fit: contain; object-position: 50% 72%; }
/* the price tile carries the REAL dust art, so kill the Unicode stand-in */
/* The pill carries the REAL dust art, so the Unicode stand-in goes.
   TWO measured traps here, both found by reading the render, not the source:
   pixCur() writes an INLINE style="width:16px;height:16px" that beats any
   stylesheet rule (and it returns null below 16, so 16 is the floor: the app's
   other dust marks call ICONS.dust(12) and silently get a vector diamond
   instead). And `.ward-cell img` is position:absolute;inset:0, which pinned the
   crystal to the tile's corner ON TOP of the digits. So the pill is sized to fit
   a real 16px icon and the icon is put back in flow. */
.ward-cell.look .look-cost.dust, .pw-item.famr .look-cost.dust {
  display: flex; align-items: center; gap: 3px; padding: 2px 5px; font-size: var(--fs-tiny); line-height: 16px;
}
.ward-cell.look .look-cost.dust::after, .pw-item.famr .look-cost.dust::after { content: none; }
/* "owned" is a receipt, not a price: same chip, a different ground (QA round 22
   W12 measured the two backgrounds at 1.00:1). Mint is the app's paid/done tint. */
.ward-cell.look .look-cost.paid, .pw-item.famr .look-cost.paid { background: var(--mint); color: var(--accent-ink); }
.ward-cell.look .look-cost.dust img, .ward-cell.look .look-cost.dust svg, .pw-item.famr .look-cost.dust img, .pw-item.famr .look-cost.dust svg {
  position: static; inset: auto; flex: none; object-fit: contain;
}
.mog-panel .look-hide svg { opacity: .75; }
/* the bar states what you keep, get and pay before the button, not after it */
.look-bar.mog-bar { align-items: center; }
.mog-lines { display: grid; gap: 3px; min-width: 0; }
.mog-lines span { display: flex; align-items: baseline; gap: 6px; font-size: var(--fs-1); min-width: 0; }
.mog-lines i {
  flex: none; width: 56px; font-style: normal; font-size: var(--fs-tiny); font-weight: 800;
  letter-spacing: .04em; text-transform: uppercase; color: var(--text-3);
}
.mog-lines b { color: var(--text-1, #efe9f5); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* THE PRICE NEVER TRUNCATES. Measured in the running app: "You pay: 12 Bone ..."
   clipped at 66px against the 79px it needed, because <b> was the flexible item
   on a row that also carries the wallet in an <em>. The other two lines SHOULD
   truncate, since gear names are long and a clipped name still reads; the price
   is the one row on this panel a player must be able to finish reading before
   they spend. So the number holds its width and the wallet gives way instead. */
.mog-lines .pay b { flex: none; overflow: visible; text-overflow: clip; }
.mog-lines .pay em { min-width: 0; overflow: hidden; }
.mog-lines em {
  flex: none; font-style: normal; font-size: var(--fs-0); color: var(--text-3);
  display: inline-flex; align-items: center; gap: 3px;
}
/* pixCur writes an inline 16px, so do not fight it: give the line the room */
.mog-lines em img, .mog-lines em svg { flex: none; }
/* .btn is width:100%, and .look-bar's flex:none does NOT undo that: the
   shipped v1 bar has the button lying across its own status text. */
/* min-height (QA round 28 B2): 10px padding on 13.5px text measured 79x42, two
   under the 44px floor. The padding and glyph are untouched; the box meets the floor. */
.look-bar.mog-bar .btn.mog-go { width: auto; flex: none; padding: 10px 15px; font-size: var(--fs-2); min-height: 44px; }
.mog-safe { text-align: center; margin-top: 8px; }
/* THE COMMIT COMES TO THE THUMB (QA round 23 F3). 23 commits in the app spend a
   currency; 21 arm the control under the thumb, and this one, the cheapest of
   them at 6 dust, sat on its own bar at document offset 1810, 78% of the scroll
   range at 375x667. Same fix as .breed-bar.sticky: the bar is a direct child of
   .mog-dock (which opens at the paper doll, js/app.js renderCharacter), so its
   containing block runs from the doll to the panel and the bar rides the bottom
   of the viewport from scrollTop 0 until the panel scrolls up to meet it. It is
   NOT inside .mog-panel on purpose: sticky is clamped by its containing block,
   and inside the panel it could only float while the panel was already on
   screen, which is the bug. Opaque surface and a top shadow because it is drawn
   over the grids while it floats. */
/* IT ONLY FLOATS ONCE THERE IS SOMETHING TO CONFIRM. Tom, 2026-09-10: "when you
   go to the floating wear it is back it's really annoying". v550 made the bar
   render at rest so the second step was visible at all (it used to appear only
   after a choice, which is why nobody found it); rendering it at rest and ALSO
   keeping it sticky meant an inert, disabled bar hovered over the paper doll
   grid for the whole visit, covering the PANTS and SOCKS tiles while doing
   nothing. Both notes are satisfied by splitting presence from floating: at
   rest the bar sits in the flow at the end of the dock, below the look grid,
   where it explains the next step without occluding anything; the moment a look
   is chosen it becomes sticky and comes to the thumb, which is the whole reason
   the sticky behaviour exists. Guarded both ways in tests/wardrobe-noise-audit.mjs. */
.mog-dock > .look-bar.mog-bar {
  position: static; z-index: 4;
  background: var(--surface);
}
.mog-dock > .look-bar.mog-bar.armed {
  position: sticky; bottom: 0;
  box-shadow: 0 -6px 18px rgba(0, 0, 0, .45);
}
/* a disguised slot on the paper doll: the doll shows the LOOK, this says so.
   Sits bottom-left, clear of the .pd-gear stat strip pinned to the slot's top
   (they collided there and the badge clipped the stat text). */
.pd-slot .pd-mog {
  position: absolute; left: 3px; bottom: 18px; z-index: 3; font-size: var(--fs-micro); line-height: 1;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .7)); pointer-events: none;
}

/* Optional hero image on a What's New entry (v230). Used once, for the account
   that was lost to the missing recovery system. It stays in the list forever;
   later releases simply stack above it. */
.wn-hero {
  position: relative; margin: 2px 0 12px; border-radius: 16px; overflow: hidden;
  background: radial-gradient(120% 100% at 50% 0%, #2a1d22 0%, #16131a 60%, #100e14 100%);
  border: 1.5px solid rgba(255, 109, 94, .35);
  display: grid; place-items: center; padding: 14px 10px 10px;
}
.wn-hero img { width: 62%; max-width: 210px; height: auto; display: block; filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .65)); }
.wn-hero .rip {
  position: absolute; top: 10px; left: 12px; font-size: var(--fs-tiny); font-weight: 900;
  letter-spacing: .14em; color: #ff6d5e; text-transform: uppercase; line-height: 1;
}
.wn-hero .meta { margin-top: 10px; text-align: center; display: grid; gap: 3px; }
.wn-hero .m-name { font-size: var(--fs-4); font-weight: 900; letter-spacing: .04em; line-height: 1; color: var(--text-1, #efe9f5); }
.wn-hero .m-rank { font-size: var(--fs-2); font-weight: 800; color: #ffc961; letter-spacing: .02em; }
.wn-hero .m-tally { font-size: var(--fs-tiny); font-weight: 700; color: var(--text-3); letter-spacing: .06em; }

/* SAVED FITS (v222): a look you can put back on in one tap, at the top of the
   Wardrobe. Scrolls sideways so six of them never crowd the paper doll. */
.fit-rail { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 6px; scrollbar-width: none; }
.fit-rail::-webkit-scrollbar { display: none; }
.fit-chip {
  position: relative; flex: none; display: flex; align-items: center; gap: 7px;
  padding: 8px 12px; border-radius: 12px; min-height: 44px;
  background: var(--surface-2); border: 1.5px solid var(--line);
  font-size: var(--fs-2); font-weight: 700; color: var(--text-2); white-space: nowrap;
}
.fit-chip.editing { border-color: var(--danger, #ff6b6b); }
.fit-chip .fc-pip { width: 9px; height: 9px; border-radius: 99px; flex: none; background: #9fac9f; }
.fit-chip .fc-pip.r-uncommon { background: var(--mint); }
.fit-chip .fc-pip.r-rare { background: var(--protein); }
.fit-chip .fc-pip.r-epic { background: var(--violet); }
.fit-chip .fc-pip.r-legendary { background: #ffc961; }
.fit-chip .fc-cost { font-style: normal; font-size: var(--fs-0); font-weight: 800; color: var(--accent); margin-left: 2px; }
.fit-chip .fc-x {
  font-style: normal; display: grid; place-items: center; width: 18px; height: 18px; margin-left: 2px;
  border-radius: 6px; background: var(--danger, #ff6b6b); color: #1b0d0d; font-size: var(--fs-0); font-weight: 900;
}
/* "Take it all off" wears the same quiet dashed chip as "+ Save this fit": it is
   a tidy-up, not a destruction, so it does not get the danger red the melt
   button carries. Nothing is spent and nothing is lost. The armed state borrows
   the gold every other arm-then-confirm control in the app uses. */
.fit-chip.add, .fit-chip.reset { color: var(--text-3); border-style: dashed; padding: 7px 13px; }
/* at the fits cap the save chip stays as a ghost and its tap toasts the rule (QA round 23 F8) */
.fit-chip.add[aria-disabled="true"] { opacity: .45; }
.fit-chip.arming { border-color: var(--gold); border-style: solid; color: var(--gold); }
.fit-note { text-align: center; margin: 2px 0 10px; }

.col-head {
  color: var(--accent);
  display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
  margin: 16px 0 7px; font-size: var(--fs-1); font-weight: 800; letter-spacing: .07em; text-transform: uppercase;
}
.col-head em {
  font-style: normal; color: var(--text-3); font-weight: 700; letter-spacing: 0;
  text-transform: none; font-size: var(--fs-1); text-align: right;
}
.col-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.col-cell {
  position: relative; aspect-ratio: 1; border-radius: 11px; overflow: hidden; padding: 0;
  background: var(--surface-2); border: 2px solid var(--line);
}
.col-cell img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.col-cell.r-common { border-color: rgba(159, 172, 159, .4); }
.col-cell.r-uncommon { border-color: rgba(162, 224, 166, .55); }
.col-cell.r-rare { border-color: rgba(124, 196, 255, .6); }
.col-cell.r-epic { border-color: rgba(155,146,232,.65); box-shadow: 0 0 9px rgba(155,146,232,.2); }
.col-cell.r-legendary { border-color: rgba(255, 201, 97, .8); box-shadow: 0 0 10px rgba(255, 201, 97, .22); }
.col-cell.selected { outline: 2px solid var(--protein); outline-offset: 1px; }
/* LOCKED: identical for every piece. No art, no silhouette of the real shape,
   no rarity, no name — finding it should still be a surprise. */
.col-cell.locked { border-color: var(--line); background: var(--surface); display: grid; place-items: center; }
.col-cell.locked .lock-q { font-size: var(--fs-6); font-weight: 900; color: var(--text-3); opacity: .3; line-height: 1; }
.col-cell.worn::after {
  content: "✓"; position: absolute; top: 3px; right: 3px; width: 17px; height: 17px;
  background: var(--accent); color: var(--accent-ink); border-radius: 6px;
  display: grid; place-items: center; font-size: var(--fs-tiny); font-weight: 900;
}
.col-inspect {
  display: flex; align-items: center; gap: 11px; margin-top: 9px; padding: 9px 11px;
  border-radius: 12px; background: var(--surface-2); border: 1.5px solid var(--line);
}
.col-inspect.r-epic { border-color: rgba(155,146,232,.5); }
.col-inspect.r-legendary { border-color: rgba(255, 201, 97, .6); }
.col-inspect img { width: 42px; height: 42px; object-fit: contain; flex: none; }
.col-inspect .ci-body { flex: 1; min-width: 0; display: grid; gap: 2px; }
.col-inspect .ci-body b { font-size: var(--fs-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-inspect .ci-note { font-size: var(--fs-1); color: var(--text-3); flex: none; }

/* Sleep read diagnostics (v227): plain rows, meant to be screenshotted and sent. */
/* Recovery sheet. The inputs reuse .field/.input; only these three are new.
   (rc-lab / rc-in from v230 never had styles at all, so those inputs rendered
   with the browser default chrome. They now use .field instead.) */
.rc-hint { font-weight: 400; color: var(--text-3); }
.rc-note { font-size: var(--fs-2); color: var(--text-3); margin: 2px 0 0; min-height: 16px; }
.rc-note.good { color: var(--accent, #b9ef4a); }
.rc-note.bad, .rc-err { color: var(--danger); }
.rc-err { font-size: var(--fs-2); margin: 4px 0 0; }

/* Device-vault state in Settings. Warn state is a left rule, not a red banner:
   it is an honest limitation, not an emergency. */
.vault-row.warn .lab b { color: var(--danger); }
.vault-row.warn { border-left: 2px solid var(--danger); padding-left: 9px; }

.hk-diag { display: grid; gap: 1px; border-radius: 11px; overflow: hidden; background: var(--line); }
.hk-diag > div { display: flex; justify-content: space-between; gap: 10px; padding: 8px 11px; background: var(--surface-2); font-size: var(--fs-2); }
.hk-diag > div span { color: var(--text-3); }
.hk-diag > div b { color: var(--text-1, #efe9f5); text-align: right; font-variant-numeric: tabular-nums; }

.slime-ico { position: relative; display: inline-block; }
.slime-ico.slimed { filter: drop-shadow(0 0 10px rgba(150, 230, 80, .9)); }

/* ================= the drop (Puffer Pack release) ================= */
/* announcement veil, shown on the first 5 launches while the drop is live */
.drop-veil { position: fixed; inset: 0; z-index: 300; background: rgba(6, 5, 9, 0.72);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  display: flex; align-items: center; justify-content: center; padding: 18px;
  padding-top: max(18px, var(--sat)); }
.drop-card { position: relative; width: 100%; max-width: 380px;
  background: radial-gradient(120% 90% at 50% 0%, #1c2413 0%, var(--surface) 55%);
  border: 1px solid var(--line-strong); border-radius: 24px; padding: 26px 20px 20px;
  text-align: center; overflow: hidden; }
.drop-card::after { content: ''; position: absolute; inset: 0; background: var(--grain);
  opacity: 0.05; pointer-events: none; }
.drop-count { position: absolute; top: 14px; right: 14px; background: var(--surface-3);
  border: 1px solid var(--line-strong); color: var(--gold); font-size: var(--fs-0); font-weight: 700;
  letter-spacing: 0.08em; border-radius: 99px; padding: 6px 10px; }
.drop-eyebrow { font-size: var(--fs-1); font-weight: 700; letter-spacing: 0.22em; color: var(--accent); margin: 0 0 8px; }
/* The Discord mark rides the eyebrow's own colour, sized to its cap height. A
   row, not an inline glyph: the eyebrow carries 0.22em of tracking, so an
   inline mark pushed the line over 390px and wrapped onto its own row. */
.dc-eyebrow { display: flex; align-items: center; justify-content: center; gap: 7px; }
.dc-eyebrow .dc-mark { flex: 0 0 auto; }
.drop-title { font-family: var(--display); font-size: var(--fs-hero); line-height: 0.95; color: var(--text);
  margin: 0; text-transform: uppercase; font-weight: 400; }
.drop-title em { font-style: normal; color: var(--accent); }
.drop-sub { color: var(--text-2); font-size: var(--fs-3); margin: 10px auto 2px; max-width: 26em; }
.drop-how { color: var(--text-3); font-size: var(--fs-2); margin: 8px auto 14px; max-width: 28em; }
.drop-row { display: flex; justify-content: center; align-items: flex-end; margin: 14px 0 0; height: 168px; }
/* one layered outfit: slot imgs stacked exactly like a .bh-anim avatar */
.drop-fit { position: relative; width: 150px; height: 150px; flex: 0 0 auto;
  filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.5)); }
.drop-fit img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.drop-row .drop-fit:nth-child(1) { transform: rotate(-8deg) translate(26px, -4px) scale(0.85); }
.drop-row .drop-fit:nth-child(2) { transform: rotate(6deg) translate(-2px, 2px) scale(1.05); z-index: 2; }
.drop-row .drop-fit:nth-child(3) { transform: rotate(10deg) translate(-30px, -8px) scale(0.82); }
.drop-cta { display: block; width: 100%; border: 0; border-radius: 16px; padding: 15px;
  font-family: var(--display); font-size: var(--fs-5); letter-spacing: 0.05em;
  background: var(--accent); color: var(--accent-ink); }
.drop-later { display: block; width: 100%; background: none; border: 0; color: var(--text-3);
  font-size: var(--fs-3); padding: 12px 0 2px; }


/* shop section: a COLLAPSED disclosure. Featured stock should not shove the coin
   shop down the page every time someone opens the tab, so it starts closed and
   reads as one row until you want it. */
.drop-sect { border: 1px solid rgba(255, 201, 97, 0.35); border-radius: 16px; margin: 0 0 16px;
  overflow: hidden;
  background: radial-gradient(130% 100% at 50% 0%, rgba(165, 232, 71, 0.07) 0%, var(--surface) 60%); }
.drop-sect > summary { list-style: none; display: flex; align-items: center; gap: 11px;
  padding: 11px 13px; cursor: pointer; }
.drop-sect > summary::-webkit-details-marker { display: none; }
.drop-sect[open] .gbn-chev { transform: rotate(90deg); }
.drop-sect-ico { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; background: #14100a;
  border: 1px solid rgba(255, 201, 97, 0.4); overflow: hidden; position: relative; }
.drop-sect-ico img { position: absolute; width: 242%; left: 50%; top: 50%;
  transform: translate(-40.7%, -23.4%); }
.drop-sect-sum { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.drop-sect-sum i { font-style: normal; font-size: var(--fs-tiny); letter-spacing: 0.2em; color: var(--gold);
  text-transform: uppercase; font-weight: 800; }
.drop-sect-sum b { font-family: var(--display); font-weight: 400; font-size: var(--fs-body);
  letter-spacing: 0.02em; color: var(--text); }
.drop-sect .new { background: var(--accent); color: var(--accent-ink); font-size: var(--fs-tiny);
  font-weight: 800; letter-spacing: 0.1em; padding: 4px 8px; border-radius: 99px; }
.drop-sect-body { padding: 0 13px 13px; }
.drop-sub2 { color: var(--text-2); font-size: var(--fs-2); margin: 0 2px 12px; }
.drop-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.drop-item { background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 14px;
  padding: 10px; text-align: center; position: relative; }
.drop-item::before { content: 'LEGENDARY'; position: absolute; top: 8px; left: 8px;
  font-size: var(--fs-micro); font-weight: 800; letter-spacing: 0.12em; color: var(--gold); }
.drop-item img { width: 88px; height: 88px; object-fit: contain;
  filter: drop-shadow(0 0 12px rgba(255, 201, 97, 0.25)); }
.drop-item b { display: block; font-size: var(--fs-2); color: var(--text); margin: 4px 0 8px; }
.drop-buy { width: 100%; min-height: 40px; border: 0; border-radius: 10px; padding: 9px; font-weight: 800;
  font-size: var(--fs-2); background: var(--accent); color: var(--accent-ink); }
.drop-buy:disabled { opacity: 0.9; }
.drop-item.owned .drop-buy, .drop-buy[disabled]:not([data-buydrop]) { background: var(--surface-3); color: var(--text-2); }
.drop-buy[data-buydrop]:disabled { background: var(--surface-3); color: var(--text-3); opacity: 1; }
/* OUT OF REACH IS STILL A PRICE, the rack's own rule (10178 .rk-buy .t3-price.cant):
   pressable, same size, digits still there, only the FILL drops. The football
   buy and bundle buttons use it instead of [disabled] when the price is real
   but this wallet is short. */
.drop-buy.cant { background: transparent; box-shadow: none; color: var(--accent);
  border: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); }

/* ================= Dark Spires (territory) ================= */
/* Marker root carries position ONLY: MapLibre owns its transform, so every
   filter/animation lives on .spire-fx. Same rule as .map-den-mark. */
.map-spire { position: relative; width: 58px; height: 74px; margin-top: 24px; }
.spire-fx { position: relative; width: 100%; height: 100%; display: block;
  filter: drop-shadow(0 4px 9px rgba(0, 0, 0, 0.7)); }
.map-spire img { width: 100%; height: 100%; object-fit: contain; }
/* EVERY `.map-spire ... img` FILTER ON THIS PAGE MEANS THE TOMB, and until
   2026-08-20 the tomb was the only <img> in the marker. The tribute coin is one
   now, so `.mine`'s hue-rotate(62deg) landed on it and a held tower's gold coin
   rendered LIME: measured off the render at 440x956, the 16px coin came back
   peak-channel green, not gold. Reset it here rather than rewriting eight tomb
   rules. !important on purpose: the [data-age] tomb tints further down outrank
   any scoped selector, and no tomb tint should ever reach the coin. */
.map-spire .spire-tribute img { filter: none !important; }
.map-spire.free img { filter: grayscale(0.85) brightness(0.62); }
.map-spire.mine img { filter: hue-rotate(62deg) saturate(1.5) brightness(1.05); }
.map-spire.mine .spire-fx { filter: drop-shadow(0 4px 9px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 10px rgba(165, 232, 71, 0.55)); }
.map-spire.dormant img { filter: grayscale(1) brightness(0.5); }
/* a RIVAL's tower: coral, the danger colour used everywhere else in the game */
.map-spire.rival img { filter: hue-rotate(-38deg) saturate(1.7) brightness(0.95); }
.map-spire.rival .spire-fx { filter: drop-shadow(0 4px 9px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 10px rgba(253, 104, 87, 0.5)); }
.map-spire.rival .spire-flag { background: var(--danger); border-color: var(--danger); color: var(--accent-ink); }
.map-spire.inrange .spire-fx { animation: spirePulse 1s ease-in-out infinite; }
@keyframes spirePulse {
  0%, 100% { filter: drop-shadow(0 4px 9px rgba(0, 0, 0, 0.7)); }
  50% { filter: drop-shadow(0 4px 9px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 12px var(--accent)); }
}
@media (prefers-reduced-motion: reduce) { .map-spire.inrange .spire-fx { animation: none; } }
/* Owner plate: the point of a claimed spire, so it sits ABOVE the arch on its own
   opaque ground rather than washing out against the gargoyle behind it. */
.spire-flag {
  position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); margin-bottom: 4px;
  display: flex; align-items: center; justify-content: center;
  box-sizing: border-box; width: fit-content; min-width: 64px; max-width: 60vw; text-align: center;
  font-family: var(--display); font-weight: 400; font-size: var(--fs-2); letter-spacing: 0.03em;
  line-height: 1; white-space: nowrap; padding: 4px 9px; border-radius: 7px;
  border: 1.5px solid var(--surface); background: var(--surface); color: var(--text);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.7);
}
.spire-flag-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spire-flag::after { content: ''; position: absolute; top: 100%; left: 50%;
  transform: translateX(-50%); border: 4px solid transparent; border-top-color: inherit; }
.map-spire.mine .spire-flag { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.map-spire.free .spire-flag, .map-spire.dormant .spire-flag { font-size: var(--fs-0); color: var(--text-2); }
.spire-tribute:empty { display: none; }
/* Tom, 2026-08-20: "i noticed the coin icon on the boneyard is too small as
   well". The coin went 11 -> 16 (the pixel floor), so the NUMBER beside it has
   to come up too or the label reads as one big coin and one tiny figure. The
   label is absolutely positioned, centred and nowrap, so growing it pushes
   nothing around. */
.spire-tribute { position: absolute; bottom: -7px; left: 50%; transform: translateX(-50%);
  font-size: var(--fs-2); font-weight: 800; color: var(--gold); white-space: nowrap;
  text-shadow: 0 1px 0 #17151d, 0 0 5px rgba(0, 0, 0, 0.9); }
/* the in-range action button, sitting above the other map CTAs */
/* NOTE the distinct class: the marker is .map-spire and this is .map-spire-btn.
   They collided at first, so the marker's 58x74 sizing was landing on the button
   and querySelectorAll('.map-spire') counted the button as a tower. */
.btn.map-spire-btn { position: absolute; left: 14px; right: 14px; bottom: 78px; z-index: 4; }
/* claim sheet */
.spire-hero { text-align: center; }
.spire-hero img { width: 150px; height: 150px; object-fit: contain;
  filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.6)); }
.spire-terms { margin: 6px 2px 16px; padding-left: 18px; }
.spire-terms li { color: var(--text-2); font-size: var(--fs-3); margin-bottom: 7px; }
.spire-terms b { color: var(--text); }
/* spire intro + Today card */
.spire-intro-art { text-align: center; margin: 6px 0 2px; }
.spire-intro-art img { width: 132px; height: 132px; object-fit: contain;
  filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.6)); }
.spire-veil { overflow-y: auto; align-items: flex-start; }   /* a short screen scrolls the poster instead of cropping its top */
.spire-veil .drop-card { margin: auto 0; }
.spire-veil .spire-terms { text-align: left; margin: 4px 4px 14px; }
/* The step-race poster: YOUR bonehead on a start line. One drawn line under the
   feet reads as a race; a field of dashes behind the art just reads as noise. */
.race-intro-art { position: relative; display: grid; place-items: center; margin: 6px 0 4px; height: 168px; }
.race-intro-art .bh-stage { width: 150px; height: 160px; position: relative; }
.race-intro-art .startline {
  position: absolute; left: 8%; right: 8%; bottom: 8px; height: 4px;
  background: var(--text); opacity: .22; border-radius: 2px;
}
.race-veil .spire-terms { text-align: left; margin: 4px 4px 14px; }
.spire-banner summary .gbn-txt i { color: var(--accent); }
.spire-ico { background: #0d0f08; }
.spire-ico img { position: absolute; width: 118%; left: 50%; top: 52%; transform: translate(-50%, -50%); }
.spire-list { display: grid; gap: 10px; margin: 4px 0 12px; }
.spire-row { background: var(--surface-2); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; }
.spire-row b { font-size: var(--fs-3); color: var(--text); }
.spire-row-r { float: right; color: var(--gold); font-weight: 700; font-size: var(--fs-2); }
.spire-row small { display: block; color: var(--text-3); font-size: var(--fs-1); margin-top: 5px; }
.spire-bar { height: 5px; border-radius: 99px; background: var(--surface-3); overflow: hidden; margin-top: 8px; }
.spire-bar i { display: block; height: 100%; background: var(--accent); }

/* tappable bar charts: tap a bar, read that day's exact number */
.bc { touch-action: manipulation; }
.bc .bc-hit { cursor: pointer; }
.bc .bc-bar { transition: opacity .12s; }
.bc .bc-bar.on { opacity: 1; stroke: var(--text); stroke-width: 2; paint-order: stroke; }
.bc-readout { margin: 6px 2px 0; min-height: 1.2em; font-variant-numeric: tabular-nums; }

/* SLIMED gear on the Bonehead itself. The wardrobe cell has glowed since the
   Glutton shipped (.ward-cell.slimed) but the avatar layer never did, so a
   slimed piece looked ordinary the moment you actually wore it. Same green as
   the cell, tuned down: this sits over art, not over a dark tile. */
.bh-anim img.bh-slimed, .bh-stage img.bh-slimed {
  filter: drop-shadow(0 0 5px rgba(150, 230, 80, 0.95)) drop-shadow(0 0 12px rgba(120, 200, 60, 0.55));
}

/* Salvage Bench bulk melt */
.melt-tools { display: flex; gap: 14px; padding: 4px 2px 8px; }
.melt-tools .link, .pw-empty .link { background: none; border: 0; color: var(--accent); font-size: var(--fs-2); font-weight: 700; padding: 0; }
.melt-row { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.melt-row .melt-pick { width: 20px; height: 20px; flex: none; accent-color: var(--danger); }
.melt-row.worn { opacity: 0.6; }
.melt-val { color: var(--text-2); font-size: var(--fs-2); font-weight: 800; }
/* An unworn piece's value used to be muted grey text beside a row whose only
   button belonged to the WORN piece, so the pieces you most want gone read as
   inert labels. The row is a <label> around the checkbox and has always been
   tappable; this makes it LOOK like the live control it is. */
.melt-val.on { color: #b8a6d8; background: var(--surface-3); border-radius: 999px;
  padding: 4px 8px; flex: none; display: inline-flex; align-items: center; gap: 4px; }
.melt-lede { font-size: var(--fs-2); color: var(--text-2); line-height: 1.4; padding: 2px 2px 0; }
.melt-lede b { color: var(--text-1); }
/* which pieces carry stats, so a junk sweep cannot eat a statted piece by mistake */
.melt-stat { color: var(--accent); font-weight: 700; }
.melt-nostat { color: var(--text-3); }
/* The confirm bar sits ABOVE the list, with the select-all tools.
   It was `position: sticky; bottom: 6px` as the LAST child of the fold, which
   cannot work: a sticky element is confined to its containing block, and sitting
   at the bottom of that block already it had nowhere to travel. What it did
   instead was paint its label, with .btn.danger's TRANSPARENT background, across
   whichever gear row happened to be behind it. Above the list it is visible the
   moment the fold opens, cannot overlap a row, and needs no z-index at all. */
.melt-go.btn.danger {
  width: 100%; margin: 0 0 10px;
  background: var(--surface-2);
  border: 1px solid rgba(255, 122, 110, 0.55);
}
.melt-tools { padding-bottom: 6px; }
/* THE BENCH ICONS WERE 27px OF PADDED CANVAS, so the ink read at 11x15px next to a
   Wardrobe that paints the same pieces at 62-84px. Trimmed art (bhTrim, the
   Wardrobe's own source) in a 48px tile, the Backpack's list-icon size, bordered
   by rarity exactly like .ward-cell. */
.melt-tile { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 11px;
  background: var(--surface-3); border: 2px solid var(--line); }
.melt-row { gap: 8px; }
.melt-row b { text-wrap: pretty; }
.melt-talent { white-space: nowrap; }
/* dim what is worn, never the instruction or the control that melts it */
.melt-row.worn { opacity: 1; }
.melt-row.worn .melt-tile, .melt-row.worn > div > b { opacity: 0.6; }
.melt-row.worn .melt-pick { visibility: hidden; }
/* the worn melt control is a pill like its neighbours, red-edged because it destroys */
.melt-row .btn.small.danger { border-radius: 999px; font-family: inherit; font-weight: 800; letter-spacing: 0; font-size: var(--fs-2);
  padding: 4px 10px; color: var(--text-2); background: var(--surface-3); border: 1px solid rgba(255, 122, 110, 0.55); box-shadow: none; }
.bp-salvage .melt-stable { margin: 12px 0 0; }
.melt-tile img { width: 40px; height: 40px; object-fit: contain; }
.melt-row .melt-pick { width: 24px; height: 24px; }
/* the worn note is a <b> inside <small>; .crate-row b made it a display-size block */
.melt-row small b { display: inline; font-size: inherit; }
/* 44px taps: the tools read 17.5px tall, the fold summary 37.5px */
.melt-tools { flex-wrap: wrap; gap: 0 10px; padding: 0 2px 4px; }
.melt-tools .link { min-height: 44px; margin-block: -6px; white-space: nowrap; }
.melt-tools #meltNone { margin-left: auto; padding: 0 5px; }
/* the Stable button only: a bare .btn.small here also caught the worn row's melt
   button, making worn rows taller and pushing that button 5px off-centre */
.melt-fold > summary, .bp-salvage #openStableFromBp { min-height: 44px; }
.bp-salvage #openStableFromBp { margin-bottom: 10px; }
/* Bone Dust is 48px pixel art: 24px is its only exact size on a 2x phone, so it
   rides at 24 and gives back the extra height instead of shrinking off-grid. */
.bp-salvage .ico.pix-cur { margin-block: -5px; }
.melt-val.on .ico.pix-cur, .melt-row .btn.small.danger .ico.pix-cur { margin-right: 1px; }
.melt-row .melt-worn { display: inline-block; margin-left: 4px; padding: 1px 7px; border-radius: 999px;
  background: var(--surface-3); color: var(--text-2); font-size: var(--fs-0); font-weight: 800;
  letter-spacing: .04em; text-transform: uppercase; vertical-align: 1px; }

/* ===== The Bone Garden (v259) ===== */
/* Beds share the cauldron card language on purpose: both are things on a timer in
   the same room, so they should read as one status block, not two systems. */
.plot-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 4px 0 6px; }
.plot-card {
  position: relative; display: grid; gap: 3px; justify-items: center; text-align: center;
  padding: 11px 6px 10px; border-radius: 14px; background: var(--surface-2);
  border: 2px solid var(--line); min-height: 116px; align-content: center;
  color: var(--text); font: inherit;
}
.plot-card.ready { border-color: rgba(165, 232, 71, .55); box-shadow: 0 0 12px rgba(165, 232, 71, .18); }
.plot-card.thirsty { border-color: rgba(124, 196, 255, .45); }
.plot-card.locked { border-style: dashed; cursor: pointer; }
.plot-card.locked { opacity: .8; }
.plot-card .plot-ico { line-height: 1; font-size: var(--fs-title); }
.plot-card .plot-ico .bhi { filter: drop-shadow(0 2px 3px rgba(0, 0, 0, .45)); }
.plot-card b { font-size: var(--fs-2); }
.plot-card small { font-size: var(--fs-0); color: var(--text-3); }
.plot-card .plot-done { color: rgba(124, 196, 255, .8); font-weight: 700; }
.plot-card .cook-bar { width: 100%; }
/* the flag pokes outside the card, so the row needs the headroom or it clips */
.plot-row { padding-top: 6px; }
.plot-flag {
  position: absolute; top: -8px; right: -4px; font-size: var(--fs-micro); font-weight: 800; letter-spacing: .04em;
  padding: 2px 6px; border-radius: 8px; background: #7cc4ff; color: #0b1622;
}
.plot-flag.crop { background: var(--accent); color: var(--accent-ink); }
.plot-btn { width: auto; padding: 4px 11px; font-size: var(--fs-1); margin-top: 3px; }
/* seeds live in the ingredient grid but are tappable, so they need button reset */
.ing-cell.seed-cell { color: var(--text); font: inherit; cursor: pointer; text-align: center; }

.compost-card { border: 1px solid rgba(165, 232, 71, .3); background: rgba(165, 232, 71, .06); border-radius: 12px; padding: 11px 10px; }
.compost-top { display: flex; align-items: center; gap: 10px; }
.compost-top b { font-size: var(--fs-3); }
.compost-top small { display: block; color: var(--text-3); font-size: var(--fs-1); }
.odds { display: flex; gap: 6px; margin-top: 8px; }
.odds span {
  flex: 1; text-align: center; font-size: var(--fs-tiny); font-weight: 800; padding: 4px 0; border-radius: 8px;
  background: var(--surface-3); color: var(--text-2);
}
.odds span b { display: block; font-size: var(--fs-2); color: var(--text); }

/* The .dust-cell rules stood here until 2026-08-25. They styled the Bone Dust
   shop's three product cells; the shop is closed and nothing renders them. */

/* ===== Build tab FAQ (v262); Gwart's Guide entry 'build' since 2026-09-26 ===== */
/* Two reading levels in one card: the playstyles are always legible at a glance for
   someone who does not think of themselves as a gamer; the mechanics are folded so
   they are available without being homework. */
.faq-style { display: flex; gap: 11px; align-items: flex-start; padding: 10px 2px; border-bottom: 1px solid var(--line); }
.faq-style:last-of-type { border-bottom: none; }
.faq-ico { flex: none; width: 30px; height: 30px; display: grid; place-items: center; font-size: var(--fs-5);
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 10px; }
.faq-style b { display: block; font-size: var(--fs-3); }
.faq-style small, .faq-stat small, .faq-qa small { display: block; color: var(--text-2); font-size: var(--fs-1); line-height: 1.4; margin-top: 2px; }
.faq-put { color: var(--text-3) !important; }
.faq-put b { display: inline !important; color: var(--accent); font-size: inherit !important; }
.faq-deep { margin-top: 10px; border-top: 1px solid var(--line); }
.faq-deep > summary { list-style: none; cursor: pointer; padding: 10px 2px 8px; font-size: var(--fs-2); font-weight: 800; color: var(--protein); }
.faq-deep > summary::-webkit-details-marker { display: none; }
.faq-deep > summary::after { content: ' ›'; display: inline-block; transition: transform .18s; }
.faq-deep[open] > summary::after { transform: rotate(90deg); }
.faq-stat, .faq-qa { padding: 7px 2px; border-bottom: 1px solid var(--line); }
.faq-stat:last-child, .faq-qa:last-child { border-bottom: none; }
.faq-stat b, .faq-qa b { font-size: var(--fs-2); }

/* The chosen background is a BACKDROP, not part of the character.
   It used to sit inside .bh-anim, which carries the idle bob (translateY + rotate),
   so the whole scene swayed and the backdrop's own edges slid into view against the
   stage. Same lesson as the map markers: never animate the root of a layer stack,
   move an inner child. Every other avatar surface already did this via skip:['BG'];
   the wardrobe was the one that did not. */
.bh-stage .bh-backdrop {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; z-index: 0; pointer-events: none;
}
.bh-stage .bh-anim { z-index: 1; }

/* ===== The Gauntlet's ceiling (v264) =====
   This was a sentence inside a note paragraph and a text link, which Tom read as
   fine print, so hitting the cap felt like the game had quietly stopped. It is a
   real state now: named in the section summary, and stated as a card with the
   reason, the arithmetic, and a way out. */
.pit-gate {
  border: 1px solid rgba(255, 201, 97, .45); background: rgba(255, 201, 97, .07);
  border-radius: 14px; padding: 12px; margin: 2px 0 10px;
}
.pit-gate .pg-head { display: flex; align-items: center; gap: 9px; margin-bottom: 6px; }
.pit-gate .pg-head b { font-family: var(--display); font-weight: 400; font-size: var(--fs-4); color: var(--gold); letter-spacing: .02em; }
.pit-gate .pg-ico { display: grid; place-items: center; }
.pit-gate .pg-why { font-size: var(--fs-2); color: var(--text-2); line-height: 1.45; margin: 0 0 9px; }
.pit-gate .pg-meter {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  background: var(--surface-2); border-radius: 10px; padding: 7px 10px; margin-bottom: 10px;
  font-size: var(--fs-0); color: var(--text-3); font-weight: 700;
}
.pit-gate .pg-meter b { font-size: var(--fs-2); color: var(--gold); }
.pit-gate .pg-foot { font-size: var(--fs-1); color: var(--text-3); margin: 8px 2px 0; }
.crate-row.capped { opacity: .8; }

/* A spire's level: +1 on every takeover and every repelled siege, and it pays
   more tribute. Sits opposite the owner pennant so a contested tower reads as
   contested from across the map. Hidden at level 1 via :empty. */
.map-spire .spire-lv {
  position: absolute; top: -6px; left: -4px; z-index: 3;
  font-family: var(--display); font-size: var(--fs-0); letter-spacing: .03em;
  padding: 1px 5px; border-radius: 7px;
  background: var(--gold); color: #201500;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .55);
}
.map-spire .spire-lv:empty { display: none; }

/* Territory on the leaderboard: how many towers someone is holding right now.
   Gold, because it is the only stat on that row you have to walk to earn. */
.lb-spires { color: var(--gold); font-weight: 800; white-space: nowrap; }
.lb-spires .bhi { vertical-align: -1px; }

/* ===== Weapon charge: the Wardrobe idle sheen (v266) =====
   A single charge travels a top-tier weapon hilt-to-tip, then rests. One authored
   moment on a still screen, not a sparkle loop.

   The element wears the weapon's OWN art as its mask, so the light physically
   cannot fall outside Cam's silhouette. mask-size is `cover` to match
   .bh-stage img { object-fit: cover }: any other value and the mask slides out of
   register with the layer it is lighting.

   TUNED AGAINST THE MEASURED RENDER, not by intuition. The sword occupies about
   149px of a 248px stage. The first build swept 800px on an exponential ease-out,
   which put the light past the blade within 4% of the cycle: captured frames at
   4/7/11/18% were indistinguishable from the resting frame, i.e. an effect nobody
   would ever see. Travel is now proportional to the art and the ease is gentle, so
   the charge takes ~1.4s to cross the blade and is legible the whole way. */
/* THE MASK MUST MATCH THE SURFACE'S OWN object-fit OR IT SLIDES OFF THE ART.
   The avatar stack is drawn `cover` on most surfaces but `contain` on the Today
   hero, both fight plates, the crew card and the map marker. This shipped
   Wardrobe-only with `mask-size: cover` hard-coded, which was correct for exactly
   the one screen it ran on; going app-wide with that constant would have lit a
   band of empty canvas beside the sword on four surfaces. --av-fit is declared
   next to each surface's object-fit rule and read by both, and
   tests/weapon-charge-audit.mjs asserts the computed mask-size equals the
   computed object-fit on every surface, so the two cannot drift apart unnoticed. */
.wpn-sheen {
  position: absolute; inset: 0; z-index: 2; pointer-events: none; overflow: hidden;
  -webkit-mask-image: var(--wpn); mask-image: var(--wpn);
  -webkit-mask-size: var(--av-fit, cover); mask-size: var(--av-fit, cover);
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  /* POSITION TOO, not just size. --av-fit kept the mask's SCALE in register and
     nothing kept its PLACEMENT there, so any surface that moved the art off
     centre with object-position left the charge hanging in mid-air beside the
     sword. That is what Tom photographed on 2026-08-07 in the Wardrobe, where
     `object-position: 50% 72%` has been shipping since v276 with a mask still
     pinned to centre. Declare --av-pos beside every object-position. */
  -webkit-mask-position: var(--av-pos, center); mask-position: var(--av-pos, center);
}
/* The charge. Colours are sampled from the artwork: cyan #92F5FF from the hilt
   wrap and the charm's iris, cream #FFF5D6 from the blade's lit edge. The band is
   raked slightly so it reads as light crossing a bevel, not a bar sliding past,
   and it is wide enough (18% of travel) to be visible on a 149px sword. */
.wpn-sheen::after {
  content: ''; position: absolute; inset: -25% 0 -25% 0; width: 34%;
  background: linear-gradient(102deg,
    transparent 0%,
    rgba(146, 245, 255, 0.34) 26%,
    rgba(255, 245, 214, 0.62) 50%,
    rgba(146, 245, 255, 0.34) 74%,
    transparent 100%);
  /* OVERLAY, not normal. Painting light on top blew the hanging charm out to a
     white blob at peak: the eyeball lost its cyan iris and its red veins, which is
     degrading Cam's art, the one thing this effect must never do. Overlay
     modulates the pixels underneath instead, so the gold blade brightens while the
     charm keeps its own colour and linework. */
  mix-blend-mode: overlay;
  transform: translate3d(-120%, 0, 0);
  animation: wpnCharge 6s ease-in-out infinite;
  will-change: transform;
}
/* Legendary and prestige run a touch hotter and slower, so the tiers stay
   distinguishable at a glance. */
.wpn-sheen.r-legendary::after,
.wpn-sheen.r-prestige::after { animation-duration: 7.2s; filter: brightness(1.18); }

/* Travel occupies the first ~23% (about 1.4s of a 6s cycle); the rest is dwell,
   parked off the art. A charge that ran continuously would be a shimmer, and this
   is meant to read as one event you occasionally catch. */
@keyframes wpnCharge {
  0%   { transform: translate3d(-120%, 0, 0); }
  23%  { transform: translate3d(330%, 0, 0); }
  100% { transform: translate3d(330%, 0, 0); }
}
/* Reduced motion removes the effect entirely rather than freezing a bright band
   across the blade, which is what a paused keyframe would leave behind. */
@media (prefers-reduced-motion: reduce) {
  .wpn-sheen { display: none; }
}

/* ===== Football kit, 2026-09-04: the team tint =====
   One master per garment, coloured per team by two multiply layers, each a span
   masked by an alpha PNG (mask-a the primary region, mask-b the secondary). The
   same registration contract as .wpn-sheen above: mask-size and mask-position
   read the surface's --av-fit/--av-pos so the colour cannot slide off the art.
   Multiply against a master whose team regions are normalised to white lands the
   team hex exactly (scripts/football-masks.py prints the numbers); the facemask,
   glass and outlines are outside both masks and keep Cam's colour. Pet and
   paddock layers pass their geometry inline (.pw / inkFitStyle) and their mask
   is stretched to that box, since the <img> beside them is too. */
.fb-tint {
  position: absolute; inset: 0; pointer-events: none;
  -webkit-mask-image: var(--fbm); mask-image: var(--fbm);
  -webkit-mask-size: var(--av-fit, cover); mask-size: var(--av-fit, cover);
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: var(--av-pos, center); mask-position: var(--av-pos, center);
  mix-blend-mode: multiply;
}
.petcrop .fb-tint, .pdk-thumb .fb-tint, .pdk-tile .fb-tint { -webkit-mask-size: 100% 100%; mask-size: 100% 100%; }
/* THE LASERS STAY INSIDE THE HELMET (VISOR_EYES_POLICY 'clip'). The eye LAYER is
   kept and masked by the worn visor helmet's own art alpha, so the three items
   that used to poke through the glass are bounded by the silhouette instead.
   Same registration as .fb-tint and for the same reason: this mask is a 640
   square on the same canvas as the eye master, and .bh-anim img is object-fit
   cover or contain per surface, so the mask must take the surface's own
   --av-fit/--av-pos or the clip slides off the art. It goes ON the <img>, which
   already IS the layer, so there is no second box to keep in register. */
.eye-clip {
  -webkit-mask-image: var(--fbm); mask-image: var(--fbm);
  -webkit-mask-size: var(--av-fit, cover); mask-size: var(--av-fit, cover);
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: var(--av-pos, center); mask-position: var(--av-pos, center);
}
/* the kit room */
.fb-pick { display: flex; align-items: center; gap: 10px; margin: 0 2px 12px; font-size: var(--fs-2); color: var(--text-2); }
.fb-pick select { flex: 1; min-width: 0; min-height: 44px; padding: 8px 10px; border-radius: 10px; background: var(--surface-2);
  color: var(--text); border: 1px solid var(--line-strong); font: inherit; }
/* A TEAM IS TWO COLOURS ON A DIAGONAL, NOT A HEX CODE (Tom, 2026-09-04: "theyre not
   robots"). One disc, split top-left to bottom-right, primary above. --fa/--fb are
   set inline per team; the same disc serves the picker, the rail bar, the bundle
   tile and the 32-team strip on the banner. */
.fb-swatch { width: 26px; height: 26px; border-radius: 50%; border: 2px solid var(--ink); flex: 0 0 auto;
  background: linear-gradient(135deg, var(--fa) 0 50%, var(--fb) 50% 100%); box-shadow: var(--sh-sm); }
/* 10px, A REAL SIZE, NOT `1fr` OF WHATEVER IS LEFT (R40-21). Sixteen auto-width
   columns divided the strip by the container: MEASURED on the rendered kit room,
   the tile strips are 138.5px at 393 and 102px at 320, so a disc came out 5.84px
   and 3.56px -- a row of dots, not a colourway proof. The count is capped
   instead (fbTeamsStripHtml) and the disc gets a fixed size that clears the 8px
   floor at every width. */
.fb-swatch.xs { width: 10px; height: 10px; border-width: 1px; box-shadow: none; }
/* the "+26" that carries the teams the strip does not draw */
.fb-more { font-size: var(--fs-tiny); font-weight: 800; line-height: 1; color: var(--text-2); letter-spacing: -0.2px; }
/* THE KIT ROOM POSTER (Tom, 2026-09-05): the art takes the whole left side and the
   full height; the title sits over the copy; no coral, it sits on the surface like
   the cards around it. The .t3-drop shell (border, radius, grain, open-state
   radius) is kept so the section still behaves like the drop poster below it.
   THE SUMMARY ITSELF STAYS display:block (2026-09-05): a <summary> with its own
   display overridden to flex does not reliably fire the native disclosure
   toggle on tap in iOS Safari -- measured on-device: a real tap on this poster
   never opened #fbSect, while setting .open=true from script did and rendered
   the kit grid correctly, so the failure is the click-to-toggle path, not the
   layout or the art. Every other <summary class="t3-drop"> in this file (the
   Bumbleseal drop, below) already keeps the summary at this base display:block
   and puts its flex row on an inner div instead (see .row above); .fb-drop-row
   is that same fix for the football poster, not a new pattern. */
.t3-drop.fb-drop { padding: 0 14px 0 0;
  background: var(--surface-2); border-color: var(--line-strong); }
.fb-drop-row { display: flex; align-items: stretch; gap: 12px; }
.fb-drop .tx { padding: 12px 0 13px; }
.fb-drop h2 { margin: 2px 0 6px; }
.fb-hero { position: relative; flex: 0 0 44%; min-width: 150px; align-self: stretch; overflow: hidden;
  background: rgba(255, 255, 255, .04); border-right: 1px solid var(--line-strong); }
.fb-hero .pc-worn { transform: translateX(-50%); }
/* the whole Bonehead, centred; the lizard sits IN FRONT of his feet (Tom, 2026-09-05) */
.fb-hero .pc-worn.fit-body .bh-anim { transform-origin: 50% 50%; transform: translate(-5%, 1%) scale(0.96); }
/* the two prices WRAP inside the card (R40-20: nowrap ran 7px past the card at 393,
   24px at 375 and 79px at 320, where it read "4,200 A PIECE, 16" and stopped) */
.fb-drop .t3-price { font-size: var(--fs-3); flex-wrap: wrap; max-width: 100%; box-sizing: border-box; row-gap: 0; line-height: 1.25; }
.fb-hero-pet { position: absolute; right: -2px; bottom: -4px; z-index: 2; filter: drop-shadow(0 2px 3px rgba(0, 0, 0, .45)); }
.fb-hero-pet .petcrop { display: block; }
.fb-teams { display: flex; align-items: center; gap: 3px; margin: 0 0 8px; }
.drop-item.fb::before { content: 'KIT'; }
.fb-worn { position: relative; width: 88px; height: 88px; margin: 0 auto; overflow: hidden; border-radius: 10px; }
.drop-item.fb .petcrop { display: block; margin: 0 auto; }
.drop-buy[data-buyfb]:disabled, .drop-buy[data-buyfbkit]:disabled { background: var(--surface-3); color: var(--text-3); opacity: 1; }
/* The bundle cell: the same tile, spanning the grid. Used to stand in for the
   product shot with one two-tone disc in the previewed team's colours; Tom,
   2026-09-05: "the way that you presented the full kit is confusing. It looks
   like you're just buying the blue and gold colorway." Replaced with the five
   garments themselves, each in a different team (.fb-allteams below), which is
   the actual product shot the tile needs. */
.drop-item.fb-bundle { grid-column: 1 / -1; }
.drop-item.fb-bundle::before { content: 'FULL KIT'; }
.fb-allteams { display: flex; justify-content: center; gap: 4px; margin: 4px 0 8px; flex-wrap: wrap; }
.fb-kitline { display: block; font-size: var(--fs-0); color: var(--text-2); margin: -4px 0 8px; }
.fb-save { display: block; font-size: var(--fs-0); color: var(--text-2); margin-top: 6px; }

/* ===== THE COLOURWAY RAIL (Wardrobe, Tom 2026-09-04) =====================
   "slide from east to west on the different tints". 32 teams of one garment,
   one horizontal snap rail, the tile under the centre is the one being tried.

   THE PRECEDENT IS .pw-row ABOVE, the Stable's pet-accessory row, and this
   REUSES it rather than copying it: .fb-rail carries .pw-row and each tile
   carries .pw-item, so the flex, the gap, the mandatory x-snap, the 96px tile
   and the `on` state (fill AND border AND label, never colour alone) are that
   row's, already shipped and already read by a returning player. Only what is
   genuinely different is written here: a centre snap instead of a start snap,
   because the selected tile is the one in the MIDDLE of a 32-long rail rather
   than the one you scrolled to the edge; the padding that lets the first and
   last tiles reach that centre; and the locked state.
   NO MOMENTUM CODE. scroll-snap-type does the landing and the browser does the
   fling; there is no scrolling library in this app and this does not add one. */
.fb-rail-wrap { margin-top: 14px; }
.fbr-h { margin-bottom: 4px; }
.fbr-lead { text-align: center; margin: 0 2px 8px; }
.fbr-fig { display: flex; justify-content: center; margin-bottom: 8px; }
.fb-rail {
  /* NO scroll-snap-type of its own: .pw-row above already declares
     `x mandatory`, which is the point of reusing that row. A second copy here
     was written first and is exactly the duplication that lets the two drift.
     What IS different is the ALIGNMENT (centre, not start) and it lives on the
     tile below. */
  scroll-behavior: smooth;
  scrollbar-width: none; margin: 0 calc(var(--pad) * -1); padding: 2px var(--pad) 6px;
}
/* No scroll-padding: the end margins below let the first and last tiles reach
   the centre with no snapport arithmetic at all. `scroll-padding-inline: 50%`
   is the other way to do it and it collapses the snapport to zero width, which
   is a strange enough state that it is not worth relying on. (It was blamed for
   a snap landing six teams away on 2026-09-04; that was mis-measured -- the real
   cause was reading scrollLeft back in the middle of a smooth scroll -- and it
   does NOT reproduce on its own, so this is a preference, not a fix.) */
.fb-rail::-webkit-scrollbar { display: none; }
.pw-item.fbr { width: 88px; scroll-snap-align: center; padding: 7px 5px 8px; }
.pw-item.fbr:first-child { margin-left: calc(50% - 44px - var(--pad)); }
.pw-item.fbr:last-child { margin-right: calc(50% - 44px - var(--pad)); }
.pw-item.fbr b { font-size: var(--fs-tiny); line-height: 1.15; }
.pw-item.fbr small { display: flex; align-items: center; justify-content: center; gap: 3px; }
/* The Stable's pet rail (2026-09-05) puts a team DISC on the .fbr tile instead of
   a mannequin: the garment tiles above it already show the team on the animal. */
.pw-item.fbr .fb-swatch { width: 34px; height: 34px; margin: 3px 0 1px; }
/* LOCKED IS SHOWN, NOT HIDDEN. A player owning one kit would otherwise get a
   one-tile rail, and seeing their OWN Bonehead in the other 31 is the argument
   for a second one. The tile still previews on the doll; only the bar can
   commit, and on a locked colourway the bar routes to the Kit room. Same
   grayscale+dim as .ward-cell.gear.locked, which is this screen's own idiom for
   "real, yours later". */
.pw-item.fbr.locked .fb-worn, .pw-item.fbr.locked .fb-swatch { filter: grayscale(0.85) brightness(0.6); }
.pw-item.fbr.locked b { color: var(--text-3); }
.pw-item.fbr.locked small { color: var(--text-3); }
.pw-item.fbr.on.locked { border-color: var(--text-3); background: rgba(159, 172, 159, .1); }
.fb-worn.sm { width: 64px; height: 64px; border-radius: 9px; }
.fb-bar .mog-lines em { font-style: normal; }
.fb-swatch.sm { width: 18px; height: 18px; display: inline-block; vertical-align: -4px; }
@media (prefers-reduced-motion: reduce) { .fb-rail { scroll-behavior: auto; } }

/* ===== A spire under siege (v266) =====
   The one state on the map with a deadline attached, so it gets the loudest
   treatment: coral, a pulse, and the countdown where the tribute number usually
   sits. Filters and animation go on .spire-fx, never the marker root, because
   MapLibre owns the root transform. */
.map-spire.besieged .spire-fx img {
  filter: hue-rotate(-25deg) saturate(1.9) brightness(1.05)
          drop-shadow(0 0 7px rgba(255, 109, 94, .95));
}
.map-spire.besieged .spire-flag { background: var(--danger); color: #2a0b08; }
.map-spire.besieged .spire-tribute { color: var(--danger); font-weight: 800; }
.map-spire.besieged .spire-fx { animation: siegeThrob 1.15s ease-in-out infinite; }
@keyframes siegeThrob { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.07); } }
@media (prefers-reduced-motion: reduce) { .map-spire.besieged .spire-fx { animation: none; } }

.spire-hero.besieged { border-color: rgba(255, 109, 94, .5); }
.siege-clock {
  display: grid; justify-items: center; gap: 2px; padding: 12px;
  margin: 2px 0 12px; border-radius: 14px;
  border: 1px solid rgba(255, 109, 94, .45); background: rgba(255, 109, 94, .08);
}
.siege-clock .sc-name { font-family: var(--display); font-size: var(--fs-4); color: var(--danger); letter-spacing: .02em; }
.siege-clock .sc-left { font-size: var(--fs-6); font-weight: 900; font-variant-numeric: tabular-nums; }
.siege-clock .sc-lab { font-size: var(--fs-0); color: var(--text-3); text-transform: uppercase; letter-spacing: .08em; }
.spire-banner.under-siege { border-color: rgba(255, 109, 94, .5); }
.spire-banner.under-siege summary .gbn-txt i { color: var(--danger); }
.spire-siege-tag { color: var(--danger); font-weight: 800; font-variant-numeric: tabular-nums; }
.spire-lvtag { font-family: var(--display); font-weight: 400; font-size: var(--fs-1); color: var(--gold); letter-spacing: .02em; }

/* ===== A tower wears its age (v267) =====
   Days-held milestones at 7 / 30 / 100. CSS only, derived from claimed_at, so it
   works for RIVAL towers too: an old tower should look like a prize. Filters go on
   .spire-fx, never the marker root (MapLibre owns that transform). */
.map-spire[data-age="1"] .spire-fx img { filter: saturate(1.2) brightness(1.05) drop-shadow(0 0 5px rgba(165, 232, 71, .5)); }
.map-spire[data-age="2"] .spire-fx img { filter: saturate(1.35) brightness(1.1) drop-shadow(0 0 8px rgba(124, 196, 255, .75)); }
.map-spire[data-age="3"] .spire-fx img { filter: saturate(1.5) brightness(1.15) drop-shadow(0 0 11px rgba(255, 201, 97, .95)); }
/* a crown of embers on a century tower */
.map-spire[data-age="3"] .spire-fx::after {
  content: ''; position: absolute; left: 50%; top: -3px; transform: translateX(-50%);
  width: 26px; height: 8px; border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 201, 97, .95), transparent 70%);
  animation: wardenEmber 2.6s ease-in-out infinite;
}
@keyframes wardenEmber { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .map-spire[data-age="3"] .spire-fx::after { animation: none; } }
/* a siege overrides age: the deadline is what matters right now */
.map-spire.besieged[data-age] .spire-fx img {
  filter: hue-rotate(-25deg) saturate(1.9) brightness(1.05) drop-shadow(0 0 7px rgba(255, 109, 94, .95));
}

/* ===== "How a spire works" (v267) =====
   Replaces four lines of prose nobody read. Numbered steps with a drawn icon each,
   so the loop is scannable, and a three-chip rule strip for the numbers people ask
   about. Every figure comes from the constants in js/spires.js, so this can never
   describe a game we are not running. */
.sp-how { margin: 4px 0 12px; }
.sp-how-h {
  font-family: var(--display); font-size: var(--fs-4); letter-spacing: .02em;
  color: var(--text-2); margin: 0 2px 8px;
}
.sp-step { display: flex; align-items: flex-start; gap: 10px; padding: 9px 2px; border-bottom: 1px solid var(--line); }
.sp-step:last-of-type { border-bottom: none; }
/* Numbers only. A plain numbered marker in the accent, sized to sit on the same
   optical line as the heading beside it. No icon column: every icon tried here was
   borrowed from another feature and read as decoration. */
.sp-n {
  flex: none; width: 22px; height: 22px; margin-top: 1px;
  display: grid; place-items: center; border-radius: 99px;
  background: var(--accent-soft); border: 1px solid rgba(165, 232, 71, .45);
  color: var(--accent);
  font-size: var(--fs-1); font-weight: 900; font-variant-numeric: tabular-nums;
}
.sp-txt { flex: 1; min-width: 0; }
/* ONLY the step heading is a block. `.sp-txt b` also matched every inline <b>
   inside the body copy, so each bold fragment broke onto its own line and read like
   a ransom note ("You can hold" / "3" / ", so pick ones"). */
.sp-txt > b { display: block; font-size: var(--fs-2); line-height: 1.3; }
.sp-txt small { display: block; font-size: var(--fs-1); color: var(--text-2); line-height: 1.45; margin-top: 3px; }
.sp-txt small b { display: inline; font-size: inherit; color: var(--text); font-weight: 800; }
.sp-rules { display: flex; gap: 6px; margin-top: 10px; }
.sp-rule {
  flex: 1; display: grid; justify-items: center; gap: 1px; text-align: center;
  padding: 7px 4px; border-radius: 11px;
  background: var(--surface-2); border: 1px solid var(--line);
  font-size: var(--fs-tiny); color: var(--text-3); line-height: 1.3;
}
.sp-rule b { font-size: var(--fs-2); color: var(--accent); font-weight: 900; letter-spacing: -.01em; }
.sp-rule i { font-style: normal; color: var(--text-3); opacity: .85; }

/* ===== Ember eyes (v268) =====
   E4 is drawn as two coals with hot cream centres, so this makes the drawing
   behave the way it looks: a slow breath in the glow, never fully out. Colour is
   the eye's own #FF532B, sampled from the art.

   GLOBAL BY DEFAULT, and that is the point. A cosmetic has to look like itself
   everywhere the character appears, or the item stops being a thing the player
   owns and becomes a thing that happens on certain screens. `avatarLayersHtml`
   feeds 17 surfaces (wardrobe, Today hero, both fighters in the arena, the map
   marker, Crew rows, friend cards, the leaderboard podium, level-up, splash);
   an allowlist of three was quietly incoherent. Filter, never transform, because
   a transform on a layer fights the stack's own idle bob. */
.bh-anim img.eye-ember {
  filter: drop-shadow(0 0 2px rgba(255, 83, 43, 0.45))
          drop-shadow(0 0 5px rgba(255, 60, 30, 0.25));
}
/* Tom, 2026-09-27 ("Fade between two glows"): the breath used to animate
   `filter`, which no compositor runs, so every frame restyled the page (~120/s).
   Now the dim glow above is still and this copy, stacked right on it, holds the
   bright glow and fades its opacity on the same 3.5s ease-in-out breath. */
.bh-anim img.eye-ember-hi {
  /* 0.7x the old peak alphas: the dim glow still shows underneath, and at full
     strength the stack breathed 47% wider than the filter loop. Measured on
     Today at 440x956: swing 136.7 vs 138.7 before (-1.4%), peak -0.0%. */
  filter: drop-shadow(0 0 5px rgba(255, 83, 43, 0.63))
          drop-shadow(0 0 13px rgba(255, 60, 30, 0.42))
          brightness(1.1);
  opacity: 0;
  animation: eyeEmberFade 3.5s ease-in-out infinite;
}
@keyframes eyeEmberFade {
  0%, 100% { opacity: 0; }
  50% { opacity: 1; }
}
/* Same rhythm, smaller radius on the 42-46px avatars (leaderboard rows, Crew
   list, the map "you" marker). At that size the full halo is a red smudge the eye
   reads as a rendering fault rather than a lit coal. Cohesive means the same
   BEHAVIOUR, not the same pixel count. */
.lb-av .bh-anim img.eye-ember,
.fl-av .bh-anim img.eye-ember,
.map-you-av .bh-anim img.eye-ember {
  filter: drop-shadow(0 0 1px rgba(255, 83, 43, 0.5));
}
.lb-av .bh-anim img.eye-ember-hi,
.fl-av .bh-anim img.eye-ember-hi,
.map-you-av .bh-anim img.eye-ember-hi {
  filter: drop-shadow(0 0 3px rgba(255, 83, 43, 0.665)) brightness(1.08); /* same 0.7x as the large halo */
}
/* Reduced motion keeps the eyes LIT but stops the breathing: the glow is part of
   what the item is, so removing it would misrepresent the artwork. */
@media (prefers-reduced-motion: reduce) {
  .bh-anim img.eye-ember {
    filter: drop-shadow(0 0 3px rgba(255, 83, 43, 0.6));
  }
  .bh-anim img.eye-ember-hi { animation: none; opacity: 0; }
}   /* :first-child never matched: the card title is the first child */

/* ===== "The Bonehead's ledger": his voice carries into the log (v269) =====
   Today read as two design languages bolted at the day strip: an authored game
   above it, a generic dark tracker below. This closes the seam with LETTERING and
   VOICE rather than layout.

   The display face goes on the things that are spoken NOUNS (the meal names, the
   ring's own label) and deliberately NOT on section headers, which stay the one
   unified chrome idiom. Content gets his lettering; furniture does not. */
.meal-head h2 {
  font-family: var(--display);
  font-weight: 400;
  font-size: var(--fs-5);
  letter-spacing: .02em;
}
.ring-center .lbl {
  font-family: var(--display);
  font-weight: 400;
  font-size: var(--fs-3);
  letter-spacing: .04em;
  color: var(--text-2);
}
/* An empty meal used to be a header and a dashed button, the only voiceless empty
   state in the app. */
.meal-empty {
  font-family: var(--display);
  font-weight: 400;
  font-size: var(--fs-3);
  letter-spacing: .02em;
  color: var(--text-3);
  margin: 2px 2px 9px;
}
/* The close. The page ended on a micronutrient string in the faintest text on the
   screen; peak-end says the last thing carries more weight than that deserves. */
.day-signoff {
  font-family: var(--display);
  font-weight: 400;
  font-size: var(--fs-3);
  letter-spacing: .02em;
  color: var(--text-2);
  text-align: center;
  margin: 18px 16px 4px;
  line-height: 1.35;
}
/* The boundary line. Quieter than the sign-off on purpose: it is a standing fact,
   not the close, so it reads as fine print you can find rather than a notice. */
.log-only {
  font-size: var(--fs-1);
  color: var(--text-3);
  text-align: center;
  margin: 14px 20px 2px;
  line-height: 1.4;
}

/* An armed purchase button: gold while it waits for the second tap, so "are you
   sure" reads as a state rather than as a new button appearing. */
.btn.arming, .shop-cell.arming { border-color: var(--gold); color: var(--gold); }
/* the filled .btn keeps its lime accent fill by default, and gold-on-lime is
   ~1.05:1 (invisible). Armed = ink fill so the gold question actually reads. */
.btn.arming { background: var(--ink); box-shadow: 0 0 0 1px rgba(var(--gold-rgb), .4); }

/* ============================================================================
   TIER 1: THE DAILY LOOP. The add-food flow, rebuilt on the brand-deck tokens
   from the mockups Tom approved 2026-08-06 (market-quality-mockups/t1-*.html).

   Everything here is a Phase 2 component recipe, not a one-off screen style:
   .t1-seg, .t1-search, .t1-route, .t1-frow, .t1-field, .t1-step, .t1-tag.
   They are prefixed because the legacy .chip/.action-tile/.food-row/.field
   families are still in use on ~35 other surfaces; those get migrated onto
   these recipes screen by screen, NOT by redefining them underneath.
   ============================================================================ */

/* ---- sheet chrome ---- */
.sheet.t1 { border: 2px solid var(--ink); box-shadow: 0 -6px 0 rgba(0, 0, 0, 0.5); }
.sheet.t1 .sheet-head {
  border-bottom: 2px solid var(--ink);
  padding: 10px var(--pad) 12px;
  align-items: flex-start;
}
.sheet.t1 .sheet-head h2 { font-size: var(--fs-6); letter-spacing: .02em; line-height: 1; }
.sheet.t1 .sheet-head .hd { min-width: 0; flex: 1; }
.sheet.t1 .sheet-head .hd .sub { font-size: var(--fs-1); color: var(--text-3); margin-top: 4px; }
.sheet.t1 .sheet-head .hd .sub.xp { color: var(--gold); font-weight: 800; letter-spacing: .08em; }
.t1-tools { display: flex; align-items: center; gap: 6px; flex: none; }
.t1-icon-btn {
  width: 40px; height: 40px; min-height: 40px; border-radius: 11px; flex: none;
  background: var(--surface-2); border: 2px solid var(--ink); box-shadow: var(--sh-sm);
  display: grid; place-items: center; color: var(--text-3);
}
/* THE SHEET-HEAD ICON BUTTONS ARE THE 44px ONES (QA round 25 M20). `.sheet-close`
   declares min-height: 44px at line ~445 and `.t1-icon-btn` above declares
   min-height: 40px: same specificity (one class each), so the rule written
   6,283 lines later wins and `<button class="sheet-close t1-icon-btn">` shipped
   at 44x41 with the a11y audit green (its registry never held a sheet control,
   see M24 in tests/a11y-audit.mjs). `#favBtn` beside it is a bare .t1-icon-btn
   and measured 40x41. Two classes beat one regardless of order, so this cannot
   be re-shadowed by a later one-class rule; scoped to .t1-tools so the 40px
   "Change portion" chevron inside a result row keeps its size. The glyph stays
   17px; only the box grows to Apple's floor. Guarded statically by
   tests/unit.test.js "R25-M20 sheet-head icon buttons resolve to >= 44px". */
.t1-tools .t1-icon-btn { width: 44px; height: 44px; min-height: 44px; }
/* QA round 28 B2: the M20 rule above was scoped to .t1-tools on purpose and left
   the "Change portion" chevron in a recents row (.t1-frow-split, recentRowHtml)
   at 40x40, two pixels under the floor the same release declared. Same recipe,
   second scope; the 18px glyph stays where it is, only the box grows. Pinned by
   "R25-M20" in tests/unit.test.js and the `add` rows of tests/a11y-audit.mjs. */
.t1-frow-split .t1-icon-btn { width: 44px; height: 44px; min-height: 44px; }
.t1-icon-btn.gold { color: var(--gold); }
.t1-icon-btn svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
/* the body has to be the shrinking child or a pinned foot pushes off-screen */
.sheet.t1 .sheet-body { flex: 1 1 auto; min-height: 0; }
.t1-foot {
  flex: none; padding: 12px var(--pad) calc(var(--sab) + 18px);
  background: var(--surface); border-top: 2px solid var(--ink);
}
.t1-foot .t1-g2 .btn { width: 100%; }
.t1-foot .why { text-align: center; font-size: var(--fs-1); color: var(--text-3); margin-top: 9px; }

/* ---- segmented control: replaces four loose meal pills ---- */
.t1-seg {
  display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); overflow: hidden;
}
.t1-seg button {
  padding: 10px 4px; min-height: 44px;
  font-size: var(--fs-2); font-weight: 700; color: var(--text-3);
  border-right: 2px solid var(--ink);
}
.t1-seg button:last-child { border-right: 0; }
.t1-seg button.on { background: var(--accent); color: var(--accent-ink); font-weight: 800; }
/* serving lists are any length: three options fill the width, six scroll. One
   recipe rather than a chip row for long lists and a segment for short ones. */
.t1-seg.scroll { grid-auto-columns: minmax(96px, 1fr); overflow-x: auto; scrollbar-width: none; }
.t1-seg.scroll::-webkit-scrollbar { display: none; }
.t1-macros .tr i { min-width: 3px; }   /* a real 2% share should still be visible */

/* ---- search ---- */
.t1-search { position: relative; }
.t1-search input {
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 12px 13px 12px 42px;
  font-size: var(--fs-3); width: 100%; outline: none; min-height: 48px;
}
.t1-search input:focus { border-color: var(--accent); }
.t1-search > svg {
  position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
  width: 18px; height: 18px; stroke: var(--coral); fill: none; stroke-width: 2.1;
  stroke-linecap: round; stroke-linejoin: round; pointer-events: none;
}

/* ---- the four capture routes, one row ---- */
.t1-routes { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.t1-route {
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 9px 3px 7px; min-height: 62px;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
}
.t1-route:active { background: var(--surface-3); }
.t1-route svg { width: 19px; height: 19px; stroke: var(--text); fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
.t1-route b { font-size: var(--fs-0); font-weight: 700; color: var(--text-3); }
.t1-route .xp { font-size: var(--fs-micro); font-weight: 800; letter-spacing: .04em; color: var(--gold); }

/* ---- the day's remaining budget, the number you decide against ---- */
.t1-budget {
  display: flex; align-items: center; gap: 11px; flex: none;
  padding: 10px var(--pad) 11px;
  background: var(--surface-2); border-bottom: 2px solid var(--ink);
}
.t1-budget .n { font-family: var(--display); font-size: var(--fs-5); line-height: 1; letter-spacing: .02em; color: var(--accent); }
.t1-budget .n.over { color: var(--danger); }
.t1-budget .n small { font-family: inherit; font-size: var(--fs-1); font-weight: 700; color: var(--text-3); letter-spacing: .04em; margin-left: 4px; }
.t1-budget .tr { flex: 1; height: 10px; background: var(--surface-3); border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; }
.t1-budget .tr i { display: block; height: 100%; background: var(--accent); border-radius: 999px; }
.t1-budget .p { font-size: var(--fs-1); font-weight: 700; color: var(--text-3); font-variant-numeric: tabular-nums; }

/* ---- section rule ---- */
.t1-sect { display: flex; align-items: center; gap: 8px; margin: 14px 0 6px; }
.t1-sect b { font-size: var(--fs-0); font-weight: 800; letter-spacing: .09em; text-transform: uppercase; color: var(--accent); }
.t1-sect i { display: none; }
.t1-sect .spin { margin-left: auto; flex: none; }

/* ---- food row: the kcal medallion anchors it, protein earns its column
        because protein is the macro the game actually pays for (+40 XP) ---- */
.t1-frow {
  display: flex; align-items: center; gap: 11px; width: 100%; text-align: left;
  padding: 9px 0; border-top: 1px solid var(--line); min-height: 64px;
}
.t1-frow:first-of-type { border-top: 0; }
.t1-frow:active { background: var(--surface-2); }
/* L9: a recents row is a div holding the relog button and a change-portion chevron */
.t1-sect + .t1-frow { border-top: 0; }
.t1-frow-main { display: flex; align-items: center; gap: 11px; flex: 1; min-width: 0; text-align: left; }
.t1-frow-split { padding-right: 2px; }
.t1-med {
  flex: none; width: 46px; height: 46px; border-radius: 13px;
  background: var(--surface-2); border: 2px solid var(--ink); box-shadow: var(--sh-sm);
  display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1;
}
.t1-med b { font-family: var(--display); font-size: var(--fs-4); font-weight: 400; letter-spacing: .02em; }
.t1-med small { font-size: var(--fs-micro); font-weight: 800; letter-spacing: .08em; color: var(--text-3); margin-top: 2px; }
.t1-frow .nm { flex: 1; min-width: 0; }
.t1-frow .nm b { display: block; font-size: var(--fs-3); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.t1-frow .nm small { display: block; font-size: var(--fs-1); color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.t1-frow .pg { flex: none; text-align: right; }
.t1-frow .pg b { font-size: var(--fs-2); font-weight: 800; color: var(--protein); font-variant-numeric: tabular-nums; }
.t1-frow .pg small { display: block; font-size: var(--fs-micro); font-weight: 800; letter-spacing: .08em; color: var(--text-3); }

.t1-tag {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: var(--fs-0); font-weight: 800; letter-spacing: .05em; text-transform: uppercase;
  border: 2px solid var(--ink); border-radius: 8px; padding: 1px 6px;
  background: var(--surface-2); color: var(--text-3);
}
.t1-tag.warn { background: var(--coral-soft); color: var(--coral); }

/* ---- portion hero + the payoff block ---- */
.t1-hero {
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius);
  box-shadow: var(--sh); padding: 14px 15px 13px; margin-bottom: 14px;
}
.t1-hero .k { display: flex; align-items: baseline; gap: 7px; }
.t1-hero .k b { font-family: var(--display); font-size: var(--fs-7); font-weight: 400; line-height: .9; letter-spacing: .02em; color: var(--gold); font-variant-numeric: tabular-nums; }
.t1-hero .k small { font-size: var(--fs-1); font-weight: 800; letter-spacing: .1em; color: var(--text-3); }
.t1-hero .k .of { margin-left: auto; font-size: var(--fs-1); font-weight: 700; color: var(--text-3); text-align: right; }
.t1-macros { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
.t1-macros small { display: block; font-size: var(--fs-micro); font-weight: 800; letter-spacing: .09em; color: var(--text-3); margin-bottom: 4px; }
.t1-macros b { display: block; font-size: var(--fs-3); font-weight: 800; margin-bottom: 5px; font-variant-numeric: tabular-nums; }
.t1-macros .tr { height: 8px; background: var(--surface-3); border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; }
.t1-macros .tr i { display: block; height: 100%; border-radius: 999px; }
.t1-macros .mp b { color: var(--protein); } .t1-macros .mp .tr i { background: var(--protein); }
.t1-macros .mc b { color: var(--carbs); }   .t1-macros .mc .tr i { background: var(--carbs); }
.t1-macros .mf b { color: var(--fat); }     .t1-macros .mf .tr i { background: var(--fat); }

/* Every number in here is one onFoodLogged already awards. None of it was
   visible until AFTER the tap, which made the whole economy invisible. */
.t1-payoff {
  margin-top: 14px; background: var(--surface-2);
  border: 2px solid var(--ink); border-radius: var(--radius); box-shadow: var(--sh);
  overflow: hidden;
}
.t1-payoff h3 {
  font-size: var(--fs-0); font-weight: 800; letter-spacing: .09em; text-transform: uppercase;
  color: var(--accent); padding: 10px 14px 8px;
}
.t1-pr { display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-top: 2px solid var(--ink); }
.t1-pr .xp { flex: none; min-width: 44px; font-family: var(--display); font-size: var(--fs-4); letter-spacing: .02em; color: var(--gold); }
.t1-pr .t { font-size: var(--fs-2); }
.t1-pr.hit { background: rgba(var(--gold-rgb), .1); }
.t1-pr.hit .t { font-weight: 700; }
.t1-pr.rest .t { color: var(--text-3); }
.t1-pr .left { margin-left: auto; font-size: var(--fs-2); font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; }
.t1-pr .left.over { color: var(--danger); }

/* ---- stepper ---- */
.t1-step { display: grid; grid-template-columns: 54px 1fr 54px; gap: 8px; align-items: stretch; }
.t1-step button, .t1-step .val {
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); min-height: 56px;
}
.t1-step button { position: relative; }
/* drawn +/-: a Bangers hyphen sits tiny and off-centre at this size */
.t1-step button::before, .t1-step button.plus::after {
  content: ''; position: absolute; left: 50%; top: 50%;
  width: 20px; height: 4px; border-radius: 2px; background: var(--text);
  transform: translate(-50%, -50%);
}
.t1-step button.plus::after { transform: translate(-50%, -50%) rotate(90deg); }
/* THE AMOUNT INPUT IS ITS OWN TAP TARGET (QA round 25 M20): #qtyIn measured
   181x31, because the 28px display face at line-height 1 is all the height an
   <input> gets and the 56px .val box around it is a div, not the control. The
   input takes the 44px floor itself; the .val padding gives back 10 of the 13px
   so the stepper row grows by ~3px instead of 13. Same for #gramsIn. */
.t1-step .val { text-align: center; padding: 3px 2px 4px; }
.t1-step .val input {
  width: 100%; min-height: 44px; background: none; border: none; text-align: center; outline: none;
  font-family: var(--display); font-size: var(--fs-6); letter-spacing: .02em; line-height: 1;
  font-variant-numeric: tabular-nums;
}
.t1-step .val small { display: block; font-size: var(--fs-0); font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--text-3); margin-top: 3px; }

/* ---- form field ---- */
.t1-field { margin-bottom: 10px; }
.t1-field label { display: block; font-size: var(--fs-0); font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-3); margin-bottom: 5px; }
.t1-field label .u { text-transform: none; margin-left: 3px; font-weight: 700; opacity: .72; }
.t1-field input {
  width: 100%; background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 11px 12px; font-size: var(--fs-3); outline: none; min-height: 46px;
}
.t1-field input:focus { border-color: var(--accent); }
.t1-field.hot input { border-color: var(--accent); font-family: var(--display); font-size: var(--fs-6); letter-spacing: .02em; padding: 6px 12px; }
.t1-field.check input { border-color: var(--coral); }
.t1-field .lbl { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; }
.t1-field .lbl label { margin: 0; }
.t1-field .lbl .t1-tag { padding: 0 5px; font-size: var(--fs-micro); }
.t1-g2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.t1-g3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }

/* ---- label-scan stage + rules ---- */
.t1-stage {
  position: relative; height: 232px; margin-bottom: 14px; overflow: hidden;
  background:
    radial-gradient(110% 80% at 50% 108%, rgba(162, 224, 166, .14), rgba(162, 224, 166, 0) 58%),
    linear-gradient(180deg, #0e0d13, #14131b);
  border: 2px solid var(--ink); border-radius: var(--radius); box-shadow: var(--sh);
}
.t1-stage img { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) rotate(-3deg); width: 148px; }
.t1-stage .brk i { position: absolute; width: 26px; height: 26px; border: 4px solid var(--accent); border-radius: 5px; }
.t1-stage .brk i:nth-child(1) { left: 52px; top: 22px; border-right: 0; border-bottom: 0; }
.t1-stage .brk i:nth-child(2) { right: 52px; top: 22px; border-left: 0; border-bottom: 0; }
.t1-stage .brk i:nth-child(3) { left: 52px; bottom: 22px; border-right: 0; border-top: 0; }
.t1-stage .brk i:nth-child(4) { right: 52px; bottom: 22px; border-left: 0; border-top: 0; }
.t1-rules { display: grid; gap: 8px; }
.t1-rules div { display: flex; align-items: center; gap: 10px; font-size: var(--fs-2); }
.t1-rules span {
  flex: none; width: 24px; height: 24px; border-radius: 8px;
  background: var(--surface-2); border: 2px solid var(--ink); box-shadow: var(--sh-sm);
  display: grid; place-items: center;
  font-family: var(--display); font-size: var(--fs-3); color: var(--coral);
}
.t1-priv {
  display: flex; align-items: center; gap: 9px; margin-top: 13px;
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 9px 12px; font-size: var(--fs-1); color: var(--text-3);
}
.t1-priv b { color: var(--text); font-weight: 700; }
.t1-priv svg { width: 15px; height: 15px; flex: none; stroke: var(--mint); fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }

/* ---- label-confirm banner ---- */
.t1-read {
  display: flex; align-items: center; gap: 11px; margin-bottom: 11px;
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius);
  box-shadow: var(--sh); padding: 9px 11px;
}
.t1-read .th { flex: none; width: 44px; height: 52px; border-radius: 9px; border: 2px solid var(--ink); overflow: hidden; background: var(--surface-3); }
.t1-read .th img { width: 100%; height: 100%; object-fit: cover; }
.t1-read .tx { flex: 1; min-width: 0; }
.t1-read .tx b { display: block; font-size: var(--fs-3); font-weight: 700; }
.t1-read .tx small { font-size: var(--fs-1); color: var(--text-3); }
.t1-read .again { flex: none; align-self: center; color: var(--coral); font-size: var(--fs-1); font-weight: 800; letter-spacing: .04em; }

/* ---- scanner: brackets and a plated manual route, not a rect in a void ---- */
.sheet.scanner.t1 { border: 0; box-shadow: none; }
.sheet.scanner .scan-head { position: absolute; left: 0; right: 0; top: 74px; text-align: center; z-index: 3; pointer-events: none; }
.sheet.scanner .scan-head b { font-family: var(--display); font-size: var(--fs-5); letter-spacing: .04em; color: var(--text); }
.sheet.scanner .scan-head span { display: block; font-size: var(--fs-1); font-weight: 800; letter-spacing: .09em; color: var(--gold); margin-top: 3px; }
.reticle.t1 {
  width: min(80%, 340px); height: 220px; top: 46%;
  outline: 0; box-shadow: none; border-radius: 0;
}
.reticle.t1 i {
  position: absolute; width: 34px; height: 34px;
  border: 4px solid var(--text); border-radius: 6px; box-shadow: 2px 3px 0 rgba(0, 0, 0, .6);
}
.reticle.t1 i:nth-child(1) { left: 0; top: 0; border-right: 0; border-bottom: 0; }
.reticle.t1 i:nth-child(2) { right: 0; top: 0; border-left: 0; border-bottom: 0; }
.reticle.t1 i:nth-child(3) { left: 0; bottom: 0; border-right: 0; border-top: 0; }
.reticle.t1 i:nth-child(4) { right: 0; bottom: 0; border-left: 0; border-top: 0; }
.reticle.t1::after { left: 8px; right: 8px; height: 3px; box-shadow: 0 0 0 2px rgba(42, 45, 40, .55); animation-duration: 2.4s; }
.sheet.scanner .scan-hint { top: auto; bottom: 172px; padding: 0 20px; }
/* the status used to sit ON the reticle: at 44% - 130px it lands exactly where
   the bracket tops are, and a two-line "camera denied" message crossed them.
   It shares the hint's slot instead, and the hint hides while it is speaking. */
/* exactly the hint's slot, because the two are mutually exclusive. bottom:226px
   was tried first and put a two-line message on the BOTTOM brackets instead of
   the top ones; there is no gap between the reticle and the hint big enough. */
.sheet.scanner .scan-status { top: auto; bottom: 172px; padding: 0 20px; }
.sheet.scanner .scan-status:empty { display: none; }
.sheet.scanner .scan-status span.plate {
  display: inline-block; max-width: 320px;
  background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 9px 13px;
  font-size: var(--fs-2); font-weight: 700; text-align: center; text-shadow: none;
}
.sheet.scanner .scan-status span.plate small { display: block; font-weight: 500; font-size: var(--fs-1); color: var(--text-2); margin-top: 3px; }
.sheet.scanner .scan-hint span {
  display: inline-flex; align-items: center; gap: 7px;
  background: var(--surface); border: 2px solid var(--ink); border-radius: 999px;
  box-shadow: var(--sh-sm); padding: 7px 14px;
  font-size: var(--fs-2); font-weight: 700; color: var(--text); text-shadow: none;
}
.sheet.scanner .scan-tools { left: 14px; right: 14px; top: 16px; justify-content: space-between; }
.sheet.scanner .scan-tools .icon-btn {
  width: 42px; height: 42px; border-radius: 12px;
  background: var(--surface); border: 2px solid var(--ink); box-shadow: var(--sh-sm); color: var(--text);
}
.sheet.scanner .scan-tools .icon-btn.on { background: #fcf35e; color: var(--ink); }
.sheet.scanner .scan-foot { background: var(--surface); border-top: 2px solid var(--ink); display: block; }
.sheet.scanner .scan-foot .row { display: flex; gap: 10px; align-items: stretch; }
.sheet.scanner .scan-foot .row .t1-search { flex: 1; }
.sheet.scanner .scan-foot .row .t1-search > svg { stroke: var(--text-3); }
.sheet.scanner .scan-foot .row .btn { width: auto; flex: none; padding: 11px 16px; }
.sheet.scanner .scan-alt {
  display: flex; align-items: center; justify-content: center; gap: 6px;
  margin-top: 12px; font-size: var(--fs-2); color: var(--text-3);
}
.sheet.scanner .scan-alt b { color: var(--coral); font-weight: 800; }

/* ============================================================================
   TIER 1: THE BONEYARD. Chrome only; the basemap style and Cam's den art are
   unchanged. From the mockups Tom approved 2026-08-06 (t1-boneyard*.html).

   The old chrome was a purple glass "?" bubble top-left, a differently-styled
   recentre circle bottom-right, a blurred pill readout, a separate floating
   Collect button, and a wrapped tip paragraph over the map. Five treatments,
   no two alike. This is two: a plated top bar and one action card.
   ============================================================================ */

.map-topbar {
  position: absolute; left: 0; right: 0; top: 0; z-index: 7;
  display: flex; align-items: center; gap: 10px;
  padding: calc(var(--sat, 0px) + 11px) 14px 11px;
  background: var(--surface); border-bottom: 2px solid #050408;
}
.map-topbar .mt-tx { flex: 1; min-width: 0; }
.map-topbar h1 {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-5);
  letter-spacing: .03em; line-height: 1; text-transform: uppercase;
}
.map-topbar small { display: block; font-size: var(--fs-2); color: var(--text-3); margin-top: 3px; }
.map-topbar small b { color: var(--text); font-weight: 700; }
/* 44px, not 38: these are the only two controls on the Boneyard and both were
   under the comfortable touch target. */
.map-ctl {
  flex: none; width: 44px; height: 44px; border-radius: 12px;
  background: var(--surface-2); border: 2px solid #050408; box-shadow: 0 3px 0 rgba(0,0,0,.32);
  display: grid; place-items: center; color: var(--text);
}
.map-ctl svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
.map-ctl .bhi svg, .map-ctl svg.bhi { stroke: none; }
/* THE MAP KEY WORE ITS PURPOSE AS AN 18px PICTURE (Tom, device pass: "too small
   and not intuitive for a player to click and see what it is"). A map badge on a
   map screen says nothing about opening anything, so the control says the word.
   Same plate, same ink, same 44px height as its neighbour: only wider, and the
   word takes the type the other small buttons on the app use. */
.map-ctl.map-key { width: auto; padding: 0 12px; display: flex; gap: 7px; border-radius: 16px; background: var(--surface); }
.map-key .mk-lbl { font-family: var(--display); font-weight: 400; font-size: var(--fs-2); letter-spacing: .06em; text-transform: uppercase; }
/* one action card: what is nearest, how far, and the verb. Replaces the pill
   readout AND the separately floating Collect button. */
/* `display: flex` here BEATS the hidden attribute's UA `display: none`, so when
   v296 started hiding this card unless something was in reach, it never actually
   hid: it just sat there forever showing its initial "Reading the bones"
   placeholder, which reads as a map that never loads. Tom, 2026-08-08: "the
   boneyard now says reading the bones with a loading tab for sooooo long...
   unplayable". */
.map-act[hidden] { display: none; }
.map-act {
  position: absolute; left: 12px; right: 12px; bottom: calc(var(--sab, 0px) + 14px); z-index: 6;
  display: flex; align-items: center; gap: 11px;
  background: var(--surface); border: 2px solid #050408; border-radius: 16px;
  box-shadow: 0 3px 0 rgba(0,0,0,.32); padding: 10px 12px 10px 14px;
}
.map-act .ma-body { flex: 1; min-width: 0; display: flex; align-items: center; gap: 11px; }
.map-act .ic {
  flex: none; width: 40px; height: 40px; border-radius: 50%;
  background: var(--surface-2); border: 3px solid var(--line-strong);
  display: grid; place-items: center;
}
.map-act.live .ic, .map-act .ic.near { border-color: var(--line-strong); }
/* `.fast`, not `.warn`: the global `.warn` banner utility at the top of this
   file pads its box, and on a fixed-size disc that padding squeezed the bolt out
   of the middle. See the note at the readout in js/app.js. */
.map-act .ic.fast { border-color: var(--gold); }
.map-act .tx { min-width: 0; }
.map-act .tx b { display: block; font-size: var(--fs-3); font-weight: 600; }
.map-act .tx small { display: block; font-size: var(--fs-2); color: var(--text-3); line-height: 1.3; }
.map-act .btn.map-collect {
  position: static; width: auto; flex: none; padding: 10px 16px; font-size: var(--fs-4);
  box-shadow: 0 3px 0 rgba(0,0,0,.32); animation: bubblePop 0.3s cubic-bezier(0.34, 1.6, 0.64, 1);
  border-color: #050408;
}
/* the boss/mini/spire buttons stack ABOVE the card instead of fighting it */
.btn.map-den, .btn.map-mini { bottom: calc(var(--sab, 0px) + 84px); }
.btn.map-den ~ .btn.map-collect, .btn.map-mini ~ .btn.map-collect { bottom: auto; }
.btn.map-spire-btn { bottom: calc(var(--sab, 0px) + 84px); }
.map-attrib { bottom: calc(var(--sab, 0px) + 82px); }

/* MARKERS. Die-cut sticker discs: bone edge, ink keyline, hard offset shadow,
   plus a drawn ground shadow so loot sits in the world instead of hovering.
   Rarity is the edge colour. The soft glows are gone (the deck bans them on
   chrome); the in-range cue is a thicker lime edge and a scale pop. */
.map-spawn {
  position: relative;
  width: 42px; height: 42px; border-radius: 50%;
  background: var(--surface); border: 3px solid var(--text);
  display: grid; place-items: center;
  box-shadow: 0 0 0 2px var(--ink), 3px 4px 0 rgba(0, 0, 0, 0.5);
}
.map-spawn::after {
  content: ''; position: absolute; left: 50%; bottom: -9px; transform: translateX(-50%);
  width: 26px; height: 6px; border-radius: 50%; background: rgba(0, 0, 0, 0.45);
}
.map-spawn.rare { border-color: var(--violet); box-shadow: 0 0 0 2px var(--ink), 3px 4px 0 rgba(0, 0, 0, 0.5); animation: none; }

/* IN-RANGE PULSE, TRANSFORM-FREE.
   Tom, 2026-08-08: "the mini boss icon disappears and swaps for the bottom
   button ... the icon should pulse or glow when you're near it".
   The icon was not hidden, it was THROWN OFF THE MAP. `spawnReady` animates
   `transform: scale()` and was applied to the marker ROOT, and a MapLibre DOM
   marker is positioned by a transform on that same element, so the keyframe
   overwrote the translate and parked every in-range marker at the map's
   top-left corner, behind the topbar. Measured 2026-08-08: player marker
   matrix(1,0,0,1,173.5,370.5), in-range mini matrix(1.066,0,0,1.066,0,0).
   This is the same trap that bit the spire markers in v161, which is why
   .map-den-mark documents "the marker root carries ONLY size/position".
   So the pulse is filter-only: it reads as a glow, and it physically cannot
   move a marker. tests/unit.test.js pins the rule so it cannot come back. */
@keyframes poiReady {
  0%, 100% { filter: drop-shadow(0 0 1px rgba(165, 232, 71, .35)); }
  50% { filter: drop-shadow(0 0 11px var(--accent)) drop-shadow(0 0 3px var(--accent)); }
}
.map-spawn.inrange { border-color: var(--accent); border-width: 4px; animation: poiReady 1.4s ease-in-out infinite; }
@keyframes spawnReady { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.07); } }
.map-spawn.far { width: 30px; height: 30px; border-width: 2.5px; opacity: 0.66; }
/* Keep the distant sticker small while giving its inspect action a 44px target. */
.map-spawn.far::before {
  content: ''; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: 44px; height: 44px;
}
.map-spawn.far::after { width: 18px; height: 5px; bottom: -7px; }
.map-spawn.far.rare { opacity: 0.95; }

/* Cam's illustrated grave, used as drawn. It gets a ground shadow and loses the
   glow; the art is not replaced with a chip. */
.map-den-mark { position: relative; }
.map-den-mark::after {
  content: ''; position: absolute; left: 50%; bottom: 1px; transform: translateX(-50%);
  width: 34px; height: 9px; border-radius: 50%; background: rgba(0, 0, 0, 0.5); z-index: -1;
}
.map-den-mark.inrange { animation: poiReady 1.4s ease-in-out infinite; }

/* the player: same die-cut treatment, no glow. 2026-09-25: the shadow was a hard
   `3px 4px 0` disc offset down-right, which read as a second marker stacked under
   the portrait (Tom: "two stacked on top of each other"). Now a soft shadow
   centred on the same circle, so portrait, ink ring and shadow share one centre.
   Blur 6, the deck's soft-glow ceiling (tests/t1-audit.mjs BONEYARD no soft-glow chrome). */
.map-you-av {
  border: 3px solid var(--text); background: var(--bone, #64605a);
  box-shadow: 0 0 0 2px var(--ink), 0 0 6px 2px rgba(0, 0, 0, 0.6);
}
.map-radius {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  border-radius: 50%; pointer-events: none; z-index: -2;
  border: 2px dashed rgba(165, 232, 71, 0.5); background: rgba(165, 232, 71, 0.07);
}

/* Mini-boss marker: same die-cut treatment as the loot discs, and the ☠ dingbat
   swapped for a real drawn skull (JS side). The purple was Tailwind's #c084fc;
   the deck's violet replaces it. Glow filters dropped. */
/* 42, UP FROM 34, and the skull inside it 24 up from 16 (MINI_SKULL_PX in
   js/app.js). Tom: "mini boss den scale up skill it's too small". Measured off
   the rendered map at 440x956 before the change: a 16px skull in a 34px disc,
   which is 16 of 28px of inner room, while a plain coin pile next to it was 24
   of 36. So the marker that starts a FIGHT was both a smaller disc and a
   slacker fill than a marker that pays 40 coins. 42/24 is not a new number: it
   is exactly .map-spawn, so the mini joins the sticker family at the same
   proportion instead of getting an invented size, and it still sits under the
   dens (46x52, 56x64) which are the bigger event.
   SIZE, NOT TRANSFORM. This element is the MapLibre marker root and maplibre
   owns its transform on every frame, so growing it goes through width/height,
   the same way .map-den-mark.big does. A scale() here would be overwritten (or
   would fight) the moment the map moved. */
.map-mini-mark {
  position: relative;
  width: 42px; height: 42px; border-radius: 50%;
  background: var(--surface); border: 3px solid var(--violet);
  box-shadow: 0 0 0 2px var(--ink), 3px 4px 0 rgba(0, 0, 0, 0.5);
  filter: none;
}
.map-mini-mark::after {
  content: ''; position: absolute; left: 50%; bottom: -9px; transform: translateX(-50%);
  width: 26px; height: 6px; border-radius: 50%; background: rgba(0, 0, 0, 0.45);
}
.map-mini-mark.t2 { border-color: var(--gold); background: var(--surface); filter: none; }
.map-mini-mark.inrange { border-width: 4px; animation: poiReady 1.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .map-spawn.inrange { animation: none; }
  .map-den-mark.inrange { animation: none; }
  .map-mini-mark.inrange { animation: none; }
  .map-den-mark.awaken .den-fx { animation: none; }
  .map-den-mark.awaken .den-eyes { animation: none; }
  .map-glutton-mark img { animation: none; }
  .glutton-blight-halo { animation: none; }
}
.map-mini-mark svg { width: 17px; height: 17px; }

/* the in-range action buttons join the sticker family: same offset shadow as the
   card they sit above, and the deck's violet instead of Tailwind's */
.btn.map-den, .btn.map-mini, .btn.map-glutton, .btn.map-spire-btn { box-shadow: var(--sh); }
.btn.map-mini { background: var(--violet); color: #1a1024; }

/* OSM attribution is a licence requirement. The bottom of the map stacks up to
   three things (action card, den/mini button, spire button), and at bottom:88px
   the den button covered it completely. It lives under the top bar instead,
   where only the legend shares the row and the legend is right-aligned. */
.map-attrib {
  left: 14px; right: auto; bottom: auto; top: calc(var(--sat, 0px) + 68px);
  background: rgba(13, 12, 18, 0.8); border: 1px solid var(--line-strong);
  color: rgba(242, 233, 215, 0.72);
}

/* the den tier pips are drawn skulls now, not ☠ text, so lay them out as icons */
.map-den-mark .den-skulls { display: flex; gap: 1px; align-items: center; justify-content: center; }
/* the .big / .roaming / .secret tier tints still drive these, because the icons
   are emitted with color:currentColor rather than a baked inline tint. */
.map-den-mark .den-skulls svg { width: 13px; height: 13px; filter: drop-shadow(0 1px 1px rgba(0,0,0,.7)); }

/* A tower that has already fought you off today: reads as deliberately closed
   rather than a washed-out lime button. Same treatment as the out-of-range
   action in the Boneyard tap sheet. */
.btn.map-spire-btn.spent {
  background: var(--surface-2); color: var(--text-3); box-shadow: var(--sh-sm); opacity: 1;
}

/* Tom, 2026-09-24: approved Boneyard cards and disclosure rows.
   Type uses the shared scalable ramp at the mock's default pixel sizes. */
.boneyard-heading, .boneyard-den .t1-sect { margin: 20px 4px 8px; }
.boneyard-heading, .boneyard-den .t1-sect b { font-family: system-ui, -apple-system, sans-serif; font-size: var(--fs-1); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-3); }
.boneyard-card, #mapIntro .card { border-radius: 16px; border: 2px solid #050408; box-shadow: 0 3px 0 rgba(0,0,0,.32); background: var(--surface); margin: 0 0 12px; overflow: hidden; }
.den-pays.boneyard-card { display: block; }
.boneyard-row { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 6px 16px; border-top: 1px solid var(--line); }
.boneyard-card > .boneyard-row:first-child { border-top: 0; }
button.boneyard-row { width: 100%; text-align: left; color: var(--text); }
.boneyard-card.boneyard-row { border-top: 2px solid #050408; min-height: 56px; } /* the mock's 52px row inside a 2px-bordered card */
.boneyard-about .note + .note { margin-top: 12px; }
.boneyard-row > .ic { width: 24px; height: 24px; flex: none; display: grid; place-items: center; }
.boneyard-row .l { flex: 1; min-width: 0; }
.boneyard-row .l b { display: block; font-size: var(--fs-3); font-weight: 600; color: var(--text); }
.boneyard-row .l span { display: block; font-size: var(--fs-2); color: var(--text-3); }
.boneyard-row .v { font-family: var(--display); font-size: calc(var(--fs-body) * 1.375); line-height: 1; letter-spacing: .02em; color: var(--text); flex: none; }
.boneyard-chevron { width: 8px; height: 8px; border-right: 2px solid var(--text-3); border-top: 2px solid var(--text-3); transform: rotate(45deg); margin-right: 4px; flex: none; }
#mapIntro { padding: 16px 16px 24px; }
#mapIntro .note { font-size: var(--fs-3); line-height: 1.45; color: var(--text-2); margin: 4px 4px 16px; }
#mapIntro #mapStart { margin-bottom: 12px; box-shadow: 0 3px 0 rgba(0,0,0,.32); border-color: #050408; }
#mapIntro .card { padding: 0; }
#mapIntro .leg-row, .map-legend .leg-row { min-height: 52px; margin: 0; padding: 4px 16px 4px 12px; gap: 12px; border-top: 1px solid var(--line); }
#mapIntro .leg-row:first-child, .map-legend .leg-h + .leg-row { border-top: 0; }
#mapIntro .leg-txt b, .map-legend .leg-txt b { font-size: var(--fs-3); font-weight: 600; color: var(--text); }
#mapIntro .leg-txt small, .map-legend .leg-txt small { font-size: var(--fs-2); color: var(--text-3); margin-top: 1px; }
.sheet.boneyard-den .sheet-head { border-bottom-color: #050408; }
.sheet.boneyard-den .sheet-head .hd .sub, .boneyard-den .t1-foot .why { font-size: var(--fs-2); }
.boneyard-den .t1-icon-btn, .sheet.boneyard-den .t1-foot .btn { border-color: #050408; box-shadow: 0 3px 0 rgba(0,0,0,.32); }
.boneyard-den .t1-foot { border-top-color: #050408; }

/* ---- boss-den tap sheet (Tier 1 Boneyard). Cam's grave art used as drawn. ---- */
.den-hero {
  display: flex; align-items: center; gap: 13px; margin: 12px 0 0;
  background: var(--surface); border: 2px solid #050408; border-radius: 16px;
  box-shadow: 0 3px 0 rgba(0,0,0,.32); padding: 12px 16px;
}
.den-hero .art { flex: none; width: 62px; height: 74px; }
.den-hero .art img { width: 100%; height: 100%; object-fit: contain; }
.den-hero .who { min-width: 0; }
.den-hero .who b { display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-5); letter-spacing: .02em; line-height: 1.05; text-transform: uppercase; }
.den-hero .who small { display: block; font-size: var(--fs-2); color: var(--text-3); margin-top: 3px; }
.den-hero .who .tier {
  display: inline-block; margin-top: 7px;
  font-size: var(--fs-0); font-weight: 800; letter-spacing: .07em;
  background: var(--gold); color: #201500; border: 2px solid var(--ink); border-radius: 7px; padding: 1px 7px;
}
.den-hero .who .tier.warden { background: var(--violet); color: #17131f; }
.den-hero .who .tier.warden.t3 { background: var(--coral); color: #2a0d08; }

/* ---- THE SPIRE'S KEEPER: a poster, not a database row ----------------------
   Tom, 2026-08-08: "i told you the bonehead controlling it should be loud and
   proud... make it feel like someone is excited and proud to be defending their
   neighbourhood spire." v1 dropped their avatar into the same 84px thumbnail
   slot the boss dens use, which is filing-cabinet treatment for the one screen
   in the game that is about a PERSON. Built to market-quality-mockups/spire.html:
   full-bleed, spotlit, standing on their tower, name on a plate, and the title
   they only get by holding it. The building is scenery behind them. */
.spp {
  position: relative; margin-bottom: 0;
  border: 2px solid var(--ink); border-radius: var(--radius); box-shadow: var(--sh);
  overflow: hidden; background: linear-gradient(180deg, #3b2f52 0%, #241d33 62%, #191426 100%);
}
.spp::after { content: ''; position: absolute; inset: 0; background-image: var(--grain); background-size: 150px 150px; opacity: .09; pointer-events: none; }
.spp.mine { border-color: var(--accent); }
.spp.rival { border-color: var(--coral); }
.spp .spot {
  position: absolute; left: 50%; top: -6%; width: 250px; height: 250px; transform: translateX(-50%);
  background: radial-gradient(circle at 50% 35%, rgba(255, 201, 97, .34), rgba(255, 201, 97, 0) 66%);
}
.spp .tower { position: absolute; left: 50%; bottom: 0; width: 150px; transform: translateX(-50%); opacity: .36; }
.spp .tower img { width: 100%; display: block; filter: grayscale(.3) brightness(.8); }
.spp .keeper { position: relative; height: 250px; display: grid; place-items: end center; }
.spp .bh { position: relative; width: 170px; height: 215px; margin-bottom: 6px; }
/* BOTTOM-aligned, not centred: contain letterboxes the art and centring it left
   the keeper's feet 33px above the pet's (measured by tests/figure-audit.mjs).
   See THE FIGURE CONTRACT in that file. */
.spp .bh .bh-anim { --av-fit: contain; --av-pos: bottom center; }
.spp .bh img, .spp .bh .bh-anim { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; object-position: bottom center; }
.spp .bh img { filter: drop-shadow(0 10px 14px rgba(0, 0, 0, .55)); }
/* nobody holds it: the tombstone is the figure, and there is nobody to spotlight */
.spp.empty .spot { display: none; }
.spp.empty .tower { position: static; width: auto; opacity: 1; transform: none; }
.spp.empty .keeper { height: 200px; }
.spp.empty .bh img { filter: drop-shadow(0 8px 12px rgba(0, 0, 0, .5)); }
/* BESIDE THE KEEPER, NOT IN THE CORNER. The figure is centred and 170 wide, so
   the pet is anchored off the same centre line and overlaps its right edge a
   little: it reads as standing next to them on the tower rather than as a sticker
   in the corner of the poster. Bottom matches the figure's own 6px margin so both
   sets of feet land on one line. */
.spp .pet { position: absolute; left: 50%; margin-left: 52px; bottom: 6px; width: 74px; height: 74px; }
.spp .pet .petcrop, .spp .pet .petanim { filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .5)); }
/* the title you only get by HOLDING it, which is the whole brag */
.spp .ribbon {
  position: absolute; left: 0; top: 16px; display: inline-flex; align-items: center; gap: 6px;
  background: var(--violet); color: #17131f; border: 2px solid var(--ink); border-left: 0;
  border-radius: 0 999px 999px 0; box-shadow: var(--sh-sm);
  padding: 3px 12px 3px 10px; font-family: var(--display); font-weight: 400; font-size: var(--fs-2); letter-spacing: .04em;
}
.spp .ribbon.t3 { background: var(--coral); color: #2a0d08; }
.spp .lvchip {
  position: absolute; right: 12px; top: 16px; background: var(--surface-2); color: var(--text);
  border: 2px solid var(--ink); border-radius: 10px; box-shadow: var(--sh-sm);
  padding: 2px 9px; font-family: var(--display); font-weight: 400; font-size: var(--fs-2); letter-spacing: .04em;
}
/* the nameplate sits ON the poster's bottom edge, half over the art */
.spp-plate {
  position: relative; z-index: 2; margin: -20px 14px 0; text-align: center;
  background: var(--coral); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh); padding: 8px 12px 9px;
}
.spp-plate.mine { background: var(--accent); }
.spp-plate b {
  display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-6);
  color: #2a0d08; letter-spacing: .03em; line-height: 1;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.spp-plate small { display: block; font-size: var(--fs-1); color: rgba(42, 45, 40, .85); font-weight: 700; margin-top: 3px; }
.spp-wrap { margin-bottom: 14px; }
.sp-siege {
  margin: 0 0 13px; padding: 9px 11px; font-size: var(--fs-1);
  background: var(--surface-2); color: var(--text-2);
  border: 2px solid var(--coral); border-radius: var(--radius-sm);
}
.den-pays { display: grid; grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); gap: 8px; }
.den-pays .p {
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 9px 6px 7px; text-align: center;
}
.den-pays .p > span { display: flex; justify-content: center; margin-bottom: 4px; }
.den-pays .p b { display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-4); letter-spacing: .02em; }
.den-pays .p small { font-size: var(--fs-micro); font-weight: 800; letter-spacing: .08em; color: var(--text-3); }
.den-odds { display: flex; gap: 7px; }
.den-odds span {
  flex: 1; text-align: center; padding: 5px 4px;
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: 8px;
  font-size: var(--fs-0); font-weight: 800; letter-spacing: .04em;
}
.den-odds i { display: block; font-style: normal; font-family: var(--display); font-weight: 400; font-size: var(--fs-3); letter-spacing: .02em; color: var(--text); }
.den-odds .uncommon { color: var(--mint); }
.den-odds .rare { color: var(--protein); }
.den-odds .legendary { color: var(--gold); }
.den-walk {
  display: flex; align-items: center; gap: 11px; margin-top: 13px;
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 10px 12px;
}
.den-walk .ic { flex: none; display: flex; color: var(--text-3); }
.den-walk .d { font-family: var(--display); font-weight: 400; font-size: var(--fs-5); letter-spacing: .02em; line-height: 1; color: var(--gold); }
.den-walk small { font-size: var(--fs-2); color: var(--text-3); }
.t1-foot .btn.spent { background: var(--surface-2); color: var(--text-3); box-shadow: var(--sh-sm); opacity: 1; }

/* What a breed COST you. Both parents are consumed and the screen celebrating it
   never said so, which is a large part of why players are confused about what
   breeding does. The two parents show struck through, under the offspring. */
.fused { margin-top: 16px; }
.fused-row { display: flex; align-items: center; justify-content: center; gap: 10px; }
.gone-pet {
  position: relative; width: 60px; height: 60px; border-radius: 50%;
  background: var(--surface); border: 2px solid var(--ink); box-shadow: var(--sh-sm);
  display: grid; place-items: center; opacity: .5; overflow: hidden;
}
.gone-pet .bh-stage, .gone-pet canvas, .gone-pet img { filter: grayscale(.75); }
.gone-pet::after {
  content: ''; position: absolute; left: 50%; top: 50%; width: 84%; height: 2.5px;
  background: var(--danger); border-radius: 2px; transform: translate(-50%, -50%) rotate(-32deg);
}
.fused-plus { font-family: var(--display); font-weight: 400; font-size: var(--fs-5); color: var(--text-3); }
.fused-note {
  text-align: center; margin-top: 7px;
  font-size: var(--fs-1); font-weight: 800; letter-spacing: .06em; text-transform: uppercase;
  color: var(--danger);
}

/* the middle of a takeover scrolls if it has to; the stamp and the button do not */
.reveal-body {
  flex: 1; min-height: 0; width: 100%; overflow-y: auto;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  text-align: center; gap: 2px;
}

/* ---- the pre-breed panel. Players kept being surprised that breeding destroys
   both pets: with nothing selected the copy said "fuse them into a stronger one",
   and once paired, "both parents consumed" was the last clause of a run-on line
   in the dimmest text on the screen. Now the trade is the headline. ---- */
.breed-h {
  font-size: var(--fs-0); font-weight: 800; letter-spacing: .09em; text-transform: uppercase;
  color: var(--accent); margin-bottom: 8px;
}
.breed-trade {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 10px 8px; margin-bottom: 10px;
}
.breed-trade .bt-in, .breed-trade .bt-out { display: flex; flex-direction: column; align-items: center; gap: 5px; }
.breed-trade .bt-row { display: inline-flex; align-items: center; gap: 6px; }
.breed-trade .bt-in small, .breed-trade .bt-out small {
  font-size: var(--fs-micro); font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-3);
}
.breed-trade .bt-in small { color: var(--danger); }
.breed-trade .bt-out small { color: var(--accent); }
.breed-trade .bt-pet {
  position: relative; width: 46px; height: 46px; border-radius: 50%; overflow: hidden;
  background: var(--surface); border: 2px solid var(--ink); display: grid; place-items: center;
  opacity: .55;
}
.breed-trade .bt-pet::after {
  content: ''; position: absolute; left: 50%; top: 50%; width: 84%; height: 2px;
  background: var(--danger); border-radius: 2px; transform: translate(-50%, -50%) rotate(-32deg);
}
.breed-trade .bt-pet.keep { width: 54px; height: 54px; opacity: 1; border-color: var(--accent); border-width: 3px; }
.breed-trade .bt-pet.keep::after { display: none; }
.breed-trade .bt-plus { font-family: var(--display); font-weight: 400; font-size: var(--fs-4); color: var(--text-3); }
.breed-trade .bt-arrow { color: var(--text-3); display: flex; }
.breed-facts { margin: 0 0 10px; padding-left: 18px; }
.breed-facts li { font-size: var(--fs-2); color: var(--text-2); line-height: 1.45; margin-bottom: 4px; }
.breed-facts b { color: var(--text); }
.breed-pick { margin-bottom: 8px; }
.breed-pick .note { display: block; margin-bottom: 5px; }
.btn.danger-ish { background: var(--danger); color: #2a0d09; }

/* victory: loot already banked is a ROW, so it cannot out-shout the gear choice.
   As full cards it stood as tall as the two pieces you actually have to pick
   between, and the pick was the thing pushed off-screen. */
.got-rows { display: grid; gap: 7px; margin: 10px 0 2px; }
.got-row {
  display: flex; align-items: center; gap: 10px;
  background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 7px 10px;
}
.got-row .got-ic { flex: none; width: 36px; height: 36px; border-radius: 9px; background: var(--surface-2); border: 2px solid var(--ink); display: grid; place-items: center; }
.got-row .got-ic svg, .got-row .got-ic .bhi-wrap svg { width: 22px; height: 22px; }
.got-row b { flex: 1; min-width: 0; font-size: var(--fs-3); font-weight: 700; }
.got-rar {
  flex: none; font-size: var(--fs-micro); font-weight: 800; letter-spacing: .09em; text-transform: uppercase;
  border: 2px solid var(--ink); border-radius: 6px; padding: 1px 6px; background: var(--surface-2);
}
.got-rar.r-uncommon { background: var(--mint); color: var(--ink); }
.got-rar.r-rare { background: var(--protein); color: var(--ink); }
.got-rar.r-epic { background: var(--violet); color: var(--ink); }
.got-rar.r-legendary { background: var(--gold); color: var(--ink); }
/* the ⚔ dingbat rendered as a stray ✕ on the live screen */
.choice-h { display: flex; align-items: center; gap: 8px; margin: 12px 0 2px; }
.choice-h b { font-size: var(--fs-0); font-weight: 800; letter-spacing: .09em; text-transform: uppercase; color: var(--accent); }
.choice-h i { flex: 1; height: 2px; background: var(--line); }

/* Status and cost dots, drawn instead of ● text glyphs sitting beside hand-inked
   art. A real element also means the online dot can carry its own colour rather
   than inheriting the label's. */
.live-dot {
  display: inline-block; width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent); border: 1.5px solid var(--ink); vertical-align: 0;
}
.ap-pips { display: inline-flex; gap: 3px; align-items: center; vertical-align: -1px; }
.ap-pips i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }

/* Today hero: the level plate from the approved mockup. A gold Bangers badge with
   the rank name beside it, both readable on the coral poster. */
.hero-lvrow { display: flex; align-items: center; gap: 9px; min-width: 0; }
.hero-meta .hero-title {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-4); letter-spacing: .05em;
  color: var(--text); text-shadow: 2px 2px 0 rgba(42, 45, 40, .95); text-transform: uppercase;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  /* The base .hero-title carries a 3.5px text-stroke, which paints ~2px OUTSIDE
     the glyph on each side. `overflow: hidden` (needed for the ellipsis on a long
     rank name) was cutting that stroke off the first and last letters: Tom,
     2026-08-08, "the text on bonegrandmaster 21 is cutting off on the edges".
     Pad the box and pull it back with the same negative margin, so the stroke has
     room and nothing moves. */
  padding: 0 3px; margin: 0 -3px;
}

/* "Maybe you shouldn't." Feeding a shiny, a bred bloodline or a levelled pet into
   another is a real loss, so it gets a panel rather than another line of body
   copy. Tom's call 2026-08-07: make it clear BEFORE the sacrifice. */
.breed-warn {
  display: flex; gap: 10px; align-items: flex-start;
  background: rgba(255, 109, 94, .12);
  border: 2px solid var(--danger); border-radius: var(--radius-sm);
  padding: 10px 12px; margin: 4px 0 12px;
  font-size: var(--fs-2); line-height: 1.45; color: var(--text-2);
}
.breed-warn svg { flex: none; margin-top: 1px; }
.breed-warn b { color: var(--text); display: block; margin-bottom: 2px; }
/* a pairing carrying something precious frames the whole panel */
.breed-bar.careful { border-color: var(--danger); }

/* the collect ring's own label, read from COLLECT_RADIUS_M so it can never
   disagree with the radius actually being enforced */
.map-radius b {
  position: absolute; left: 50%; bottom: -11px; transform: translateX(-50%);
  font-size: var(--fs-micro); font-weight: 800; letter-spacing: .08em; color: var(--accent);
  background: var(--surface); border: 2px solid var(--ink); border-radius: 999px;
  padding: 1px 7px; white-space: nowrap;
}

/* Wardrobe header, as approved: one row, gold level badge, rank beside it, then
   the wallet chips. The app had a two-row stack that I had only repainted. */
.ward-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin: 0 0 10px; }
.ward-lv {
  background: var(--gold); color: var(--ink);
  border: 2px solid var(--ink); border-radius: 10px; box-shadow: var(--sh-sm);
  padding: 1px 10px 2px;
  font-family: var(--display); font-weight: 400; font-size: var(--fs-5); letter-spacing: .04em;
}
.ward-rank {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-4);
  letter-spacing: .05em; color: var(--text-2); text-transform: uppercase;
}
.ward-head .bh-pill:first-of-type { margin-left: auto; }
/* the Wardrobe's door to the Looks collection: the same pill as the wallet chips
   it sits with, given an accent edge so it reads as a control and not a tally. */
.ward-head .ward-looks { color: var(--text); border-color: var(--accent); }

/* A backdrop has no silhouette: show a swatch, not art stretched across the tile. */
.pd-slot .pd-swatch {
  flex: 0 1 auto; width: 50px; height: 50px; margin: 8px auto 0;
  border-radius: 9px; border: 2px solid var(--ink);
  background-size: cover; background-position: center;
}
/* long slot names have to fit their tile */
.pd-slot .pd-tag {
  font-size: var(--fs-micro); letter-spacing: 0;
  /* the longest slot name has to fit the tile, not ellipse away */
  padding-inline: 1px; text-overflow: clip;
}
.pd-slot[data-pd="BG"] .pd-tag { font-size: var(--fs-micro); }

/* The hub has to fit the paper doll AND its body row on one screen, so this
   screen runs tighter than the others. The title is a SIBLING of #chBody, which
   is why an earlier `#chBody .page-h1` rule matched nothing. */
.hub-title { font-size: var(--fs-6); margin-bottom: 4px; }
#chBody .fit-rail { margin-bottom: 4px; }

/* ============================================================================
   THE HAUNTED KITCHEN scene (market-quality-mockups/kitchen.html rev 3).
   Tom rejected rev 1 as "soulless and lack lustre": the Kitchen is the most
   flavour-heavy room in the game and it opened on clean chrome. This is the
   approved answer, an illustrated night scene with slow ambient motion.
   NOTE: the scene art is a PLACEHOLDER I drew to prove the concept. It is on the
   list to go to Cam with the crate-reveal stage and the onboarding hero.
   ============================================================================ */
/* the marquee is a SCENE, not a text card: the kitchen at night */

/* REBALANCED 2026-08-07. Tom: "i think we can make the banner for the haunted
   kitchen much smaller now because it's banner heavy." It was: 178px of marquee
   sitting directly on top of two 130px doors, so the first screen was three
   posters stacked and none of them led. The identity stays (the cauldron is
   good art) but it becomes a HEADER: half the height, title on the left, the
   scene pushed to the right where it decorates rather than competes. */
.marquee { position: relative; text-align: left; padding: 13px 15px 0; margin-bottom: 12px;
    background:
      radial-gradient(130% 90% at 50% 108%, rgba(162,224,166,.28), rgba(162,224,166,0) 55%),
      radial-gradient(80% 60% at 82% -12%, rgba(114,168,241,.16), rgba(114,168,241,0) 60%),
      linear-gradient(180deg, #0e1410, #131a14 60%, #16201a);
    border: 2px solid var(--ink); border-radius: var(--radius); box-shadow: var(--sh); overflow: hidden; height: 92px; }

.marquee::before { content:''; position:absolute; inset:0; background: var(--grain); opacity:.07; }

.marquee h2 { position: relative; z-index: 3; font-family: var(--display); font-size: var(--fs-5); font-weight: 400;
    letter-spacing: .03em; color: var(--mint); text-shadow: 2px 2px 0 rgba(10,14,10,.9); max-width: 68%; line-height: 1.05; }

.marquee p { position: relative; z-index: 3; font-family: var(--display); font-size: var(--fs-0); letter-spacing: .07em;
    color: var(--text-3); margin-top: 2px; max-width: 68%; }

.scene { position: absolute; inset: auto 0 -8px; height: 92px; z-index: 2; pointer-events: none; }
/* to the RIGHT and scaled down: the cauldron used to sit dead centre under the
   title, which is what made the band need 178px in the first place */
.scene svg { position: absolute; left: auto; right: 2px; bottom: 0; transform: scale(.66); transform-origin: bottom right; }

.spore { position: absolute; border-radius: 50%; background: var(--mint); opacity: .55; }

.spore.g { background: var(--gold); opacity: .45; }

/* high enough to clear the title at the new band height, low enough that the
   bulbs are not sliced by the top edge (measured at both -4 and -13 first) */
.garland { position: absolute; top: -7px; left: -6px; right: -6px; z-index: 2; }

@keyframes wisp { 0% { transform: translateY(4px); opacity: 0; } 25% { opacity: var(--wo, .3); }
    75% { opacity: var(--wo, .3); } 100% { transform: translateY(-10px); opacity: 0; } }

.wisp { animation: wisp 3.2s linear infinite; }

@keyframes bub { 0%, 55% { transform: scale(.4); opacity: 0; } 70% { transform: scale(1); opacity: 1; }
    92% { transform: scale(1.15); opacity: 1; } 100% { transform: scale(1.3); opacity: 0; } }

.bub { transform-origin: center; transform-box: fill-box; animation: bub 2.6s ease-in-out infinite; }

.flame { transform-origin: 50% 100%; transform-box: fill-box; animation: flick 1.4s ease-in-out infinite; }

.spore { animation: drift var(--dur, 6s) linear infinite; animation-delay: var(--del, 0s); }

@keyframes stirr { 0%, 100% { transform: rotate(22deg); } 50% { transform: rotate(27deg); } }

.stir { transform-origin: 128px 46px; transform-box: view-box; animation: stirr 2.8s ease-in-out infinite; }

@media (prefers-reduced-motion: reduce) { .wisp, .bub, .flame, .spore, .stir { animation: none; } }

/* ============================================================================
   ONBOARDING (market-quality-mockups/onb-*.html, approved 2026-08-07).
   The first 90 seconds: sell the product, hand over the character, capture the
   plan honestly. Launch funnel: every step tracks. ============================================================================ */
/* THE STEP OWNS THE WHOLE SCREEN BOX, like the Boneyard route above.
   #screen pads `calc(var(--sat) + 14px) var(--pad) 24px` and .onb pads
   `calc(var(--sat) + 22px) 22px` on top of that, so the onboarding counted the
   notch TWICE (the same double-count the .screen--map note calls out) and the
   footer's full-bleed negative margins stopped 16px short of the screen at both
   edges. Measured at 320x568 before this: .onb runs 14 to 582 against a 568
   viewport, so a 100dvh step hung 14px past the fold and clipped the last line
   of the footer, and the footer's backing painted 38 to 282 of 320.
   A :has() rule rather than a class, because renderOnboarding does not go
   through route() and route() is what clears the screen-- classes: a class set
   here would leak onto Today. .survey-feat and body:has(.map-act) above already
   rely on :has. */
.screen:has(> .onb) { padding: 0; }
.onb {
  position: relative; height: 100vh; height: 100dvh; display: flex; flex-direction: column;
  align-items: center; padding: calc(var(--sat) + 22px) 22px 0;
  max-width: 600px; margin: 0 auto;
}
/* R43-4 / R43-5: THE STEP OWNS TWO BANDS, IT DOES NOT OVERLAY ONE ON THE OTHER.
   The sticky footer below fixed R39-15 (the CTA under the fold) by floating an
   OPAQUE 160px bar up to the scrollport edge, which is an overlay: at 320x568 it
   painted over the bottom 159.8px of every step at every scroll offset. Measured
   on the shipped tip: the nameplate on THIS ONE'S YOURS was 0% visible, a real
   click at the reroll's coordinates left the name unchanged, and recovering the
   plate took 144px of scroll. On THE PLAN a selected chip sat behind the footer
   at all three viewports, 393x852 included.
   Padding on the scroller cannot fix an overlay: reserved space at the END of
   the document does nothing at scrollTop 0, which is where the defect lives.
   So the content gets its OWN scrollport and the footer sits beside it, static,
   as the second child of a viewport-height column. That is why neither defect
   can come back: the button is a flex item of a box that is exactly one
   viewport tall, so it is on screen at first paint by construction and does not
   depend on the content fitting; and the content is clipped by a scrollport
   that ends where the footer begins, so nothing can be underneath it. */
.onb-scroll {
  flex: 1; min-height: 0; width: 100%;
  display: flex; flex-direction: column; align-items: center;
  overflow-y: auto; overscroll-behavior: contain;
  /* bleed to the screen edges: the parent's 22px gutter is re-applied inside so
     scrolled content keeps its margin without the scrollport stopping short */
  margin: 0 -22px; padding: 0 22px 10px;
}
.onb-in { animation: onbIn .26s ease-out; }
@keyframes onbIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .onb-in { animation: none; } }

.onb-dots { display: flex; gap: 7px; margin-bottom: 16px; }
.onb-dots i { width: 8px; height: 8px; border-radius: 50%; background: var(--surface-2); border: 2px solid var(--ink); }
.onb-dots i.on { background: var(--coral); transform: scale(1.3); }
.onb-dots i.done { background: var(--text-3); }
/* A STEP WITH A BACK ARROW GIVES ITS TITLE ROOM. The arrow is absolute at the
   top-left and the step headline starts at the same margin, so on any phone
   narrower than ~400px the circle sat ON the first letters of THE PLAN
   (playtest report at 390px; the salvaged sweep captures show it at 375 too,
   pixels, not rects). Steps without a back arrow (step 0) keep their tighter
   top. The selector leans on DOM order: back, dots, then h1. */
.onb-back ~ h1 { margin-top: 44px; }

.onb-back {
  position: absolute; left: 16px; top: calc(var(--sat) + 16px);
  width: 38px; height: 38px; border-radius: 11px; color: var(--text-2);
  background: var(--surface-2); border: 2px solid var(--ink); box-shadow: var(--sh-sm);
  display: grid; place-items: center; transform: scaleX(-1);
}
.onb h1 {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-7); line-height: .98;
  letter-spacing: .03em; text-align: center; color: var(--text);
  text-shadow: 3px 4px 0 var(--ink);
}
.onb-sub { font-size: var(--fs-3); color: var(--text-2); text-align: center; margin-top: 9px; max-width: 300px; line-height: 1.5; }
.onb-sub b { color: var(--text); }
.onb-sub.left { text-align: left; align-self: flex-start; max-width: none; margin-bottom: 10px; }

.onb-poster {
  position: relative; width: 100%; flex: 1; min-height: 240px; margin: 16px 0;
  background: var(--coral); border: 2px solid var(--ink); border-radius: var(--radius);
  box-shadow: var(--sh); overflow: hidden;
}
.onb-poster::before { content: ''; position: absolute; inset: 0; background-image: var(--grain); background-size: 150px 150px; opacity: .07; }
.onb-poster img.ly { position: absolute; inset: 4% -6% 0; width: 112%; height: 96%; object-fit: contain; object-position: 50% 80%; }
.onb-poster.bare img.ly { inset: 6% 0 4%; width: 100%; height: 90%; }
.onb-pet { position: absolute; right: 12px; bottom: 6px; width: 88px; height: 76px; }
.onb-pet img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }

/* R39-15: PIN THE PRIMARY CTA TO THE FOLD, NOT JUST TO THE END OF THE CONTENT.
   margin-top:auto only pushes the foot down when #screen has leftover space; on
   a short phone the step-2 reveal and step-3 plan both overflow #screen's own
   height, so there is no leftover space to push into and the button just sits
   wherever the content stack ends: measured 22px under the fold at 375x667 and
   293px under it on the plan step, with no scroll cue either time.
   The first fix for it was `position: sticky; bottom: 0`, and that overshot:
   see R43-4 below. The footer is a static flex item of a viewport-height column
   now, which pins it to the fold without overlaying anything.
   The negative margin + padding bleeds the backing colour to the screen edges
   (the parent's 22px gutter would otherwise show scrolled content beside it). */
/* R43-4: static, not sticky. See the .onb-scroll note above for why the sticky
   form of this rule was an overlay and what replaced it. The footer keeps the
   full-bleed backing (a hairline separates it from the scrolled content) and it
   owns the bottom safe-area inset, which is why .onb's padding-bottom is 0. */
/* align-self + width:auto, NOT width:100%: .onb is a centred flex column, and a
   100% width is 100% of its CONTENT box, so the negative margins this rule has
   carried since it was written moved nothing and the "full bleed" backing
   measured 22 to 298 of a 320px screen. Stretching to the cross axis first is
   what lets them reach the edges. */
.onb-foot { width: auto; align-self: stretch; flex: none; z-index: 2; margin-left: -22px; margin-right: -22px; padding: 14px 22px calc(var(--sab) + 16px); background: var(--bg); border-top: 1px solid var(--line); }
.onb-foot .btn { width: 100%; }
.onb-quiet { display: block; width: 100%; text-align: center; font-size: var(--fs-2); font-weight: 700; color: var(--text-3); margin-top: 13px; line-height: 1.5; }
.onb-quiet b { color: var(--coral); }

.onb-nameplate { display: flex; align-items: center; justify-content: center; gap: 9px; margin-top: 14px; }
.onb-nameplate .nm {
  background: var(--text); color: var(--ink);
  border: 2px solid var(--ink); border-radius: 12px; box-shadow: var(--sh-sm);
  padding: 6px 16px; font-family: var(--display); font-size: var(--fs-5); letter-spacing: .03em;
}
.onb-reroll {
  width: 42px; height: 42px; border-radius: 11px; background: var(--surface);
  border: 2px solid var(--ink); box-shadow: var(--sh-sm);
  display: grid; place-items: center; color: var(--text-2);
}
.onb-earns { display: grid; gap: 5px; width: 100%; margin: 12px 0; }
.onb-earn { display: flex; align-items: baseline; justify-content: center; gap: 9px; text-align: left; }
.onb-earn b { min-width: 76px; font-family: var(--display); font-weight: 400; font-size: var(--fs-2); letter-spacing: .04em; color: var(--text); }
.onb-earn small { font-size: var(--fs-1); color: var(--text-3); line-height: 1.3; }
.onb-gear, .onb-name-line { text-align: center; font-size: var(--fs-2); color: var(--text-2); line-height: 1.4; }
.onb-gear { margin: 8px 0 0; }

/* The Raising uses Cam's four unchanged 700x780 planes in one register.
   Stage classes own the poses; CSS owns settling and reveal timing. */
.onb-raising {
  --raise-travel: .5s; --raise-ease: cubic-bezier(.2,.9,.3,1);
}
.onb-raising .onb-back ~ h1 { margin-top: 16px; }
.onb-raising .onb-scroll { flex-shrink: 1; }
.onb-raising .onb-scroll > * { flex-shrink: 0; }
.onb-raise-copy { min-height: 3em; }
.onb-raise-wrap { flex: 1 0 auto; width: 100%; display: flex; align-items: center; justify-content: center; transition: flex-grow .45s var(--raise-ease); }
.onb-raise {
  position: relative;
  width: min(86vw, 340px, calc((100vh - 240px) * 252 / 281));
  width: min(86vw, 340px, calc((100dvh - 240px) * 252 / 281));
  height: auto; aspect-ratio: 252 / 281; flex: none;
  border: 0; background: none; padding: 0; box-shadow: none; touch-action: manipulation;
  transition: transform var(--raise-travel) var(--raise-ease), width .45s var(--raise-ease), margin-top .45s var(--raise-ease);
}
.onb-raise:focus-visible { outline: 3px solid var(--coral); outline-offset: 6px; border-radius: 12px; }
.onb-raise:disabled { opacity: 1; cursor: default; }
.onb-raise-shadow{position:absolute;left:50%;top:72%;translate:-50% -50%;width:52%;height:7%;border-radius:50%;
  background:rgba(42,45,40,.22);filter:blur(3px);transform:scaleX(.5);transition:transform .4s,opacity .4s}
/* Cam's Deathpile, layered, all four planes cropped to ONE register so they stack
   without a single hand-placed offset. The skull is SHARED: the same element is
   the one in the pile and the one on the standing Bonehead, so the last tap is a
   travel, not a swap. */
/* 2026-09-24: 800x1000 crop in the original 700x780 display register.
   Keeping the virtual stage preserves scale, floor and particle coordinates. */
.onb-raise-reg{position:absolute;left:-7.142857%;top:-25.641026%;width:114.285714%;height:128.205128%}
.onb-raise .ly{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
.onb-raise .ly.bg{z-index:1;transform-origin:50% 91.76%;transition:transform .45s cubic-bezier(.2,.9,.3,1)}
.onb-raise .ly.body{z-index:2;opacity:0;transform:translateY(7.02%) scale(.94);transform-origin:50% 88.64%}
.onb-raise .ly.skull{z-index:3;transform:translate(var(--sx,0%),var(--sy,0%)) rotate(var(--sr,0deg));
  transform-origin:50% 59%;transition:transform .5s cubic-bezier(.2,.9,.3,1)}
.onb-raise .ly.fg{z-index:4;transform-origin:50% 91.76%;transition:transform .45s cubic-bezier(.2,.9,.3,1),opacity .45s}
.onb-raise.up .ly.body{opacity:1;transform:none;transition:opacity .5s ease .12s,transform .6s cubic-bezier(.2,.9,.3,1) .12s}
.onb-raise.up .ly.fg{opacity:0}
.onb-raise.up .ly.bg{opacity:0;transition:opacity .4s}
/* THE JOSTLE. Tom: the bones should agitate and realign with each tap. Every
   plane shakes on its own axis and settles, so the heap reads as loose bones
   knocking together rather than one picture wobbling.
   2026-09-25: individual translate/rotate so the shake composes with the settle
   transform pose() sets inline; `transform` here replaced it and popped the pile
   back to full size every tap. Nothing in the jostle scales. */
.onb-raise.rattle .ly.fg{animation:onbRaiseFg var(--rt,.34s) cubic-bezier(.3,.9,.4,1)}
.onb-raise.rattle .ly.skull{animation:onbRaiseSk var(--rt,.34s) cubic-bezier(.3,.9,.4,1)}
.onb-raise.rattle .ly.bg{animation:onbRaiseBg var(--rt,.34s) ease-out}
/* 2026-09-25 review: "only the skull seems to move". The heap planes now shake
   at 1.6x the prototype's travel and 1.5x its tilt, and the back plane tilts too,
   so every plane visibly knocks. Still translate/rotate only: nothing scales. */
@keyframes onbRaiseFg{
  0%,100%{translate:0 0;rotate:0deg}
  18%{translate:calc(var(--k,1) * -1.96%) -2%;rotate:calc(var(--k,1) * -2.4deg)}
  38%{translate:calc(var(--k,1) * 1.68%) .87%;rotate:calc(var(--k,1) * 1.95deg)}
  58%{translate:calc(var(--k,1) * -.98%) -.62%;rotate:calc(var(--k,1) * -1.05deg)}
  78%{translate:calc(var(--k,1) * .49%) .31%;rotate:calc(var(--k,1) * .5deg)}}
@keyframes onbRaiseSk{
  0%,100%{translate:0 0}
  22%{translate:calc(var(--k,1) * .7%) -1.716%}
  46%{translate:calc(var(--k,1) * -.525%) .78%}
  72%{translate:calc(var(--k,1) * .2625%) -.312%}}
@keyframes onbRaiseBg{0%,100%{translate:0 0;rotate:0deg}30%{translate:calc(var(--k,1) * 1.1%) -1.2%;rotate:calc(var(--k,1) * 1.6deg)}65%{translate:calc(var(--k,1) * -.66%) .6%;rotate:calc(var(--k,1) * -.8deg)}}
/* THE IDLE CUE (playtest v761): the dim pile's only affordance was the words "Tap the pile." So the pile
   itself does the asking: after 2.2s of nothing it gives the skull and the heap one small knock (the tap
   jostle's own keyframe poses, spread over a 4s loop so it is a nudge, not a shake) and goes still again.
   An actual tap puts .rattle on the element, which this :not() steps aside for, so the tap jostle is
   byte-for-byte what it was; the cue restarts 2.2s after the shake settles. Reduced motion: no animation. */
.onb-raise:not(.up):not(.rattle):not(:disabled) .ly.skull{animation:onbRaiseIdleSk 4s ease-in-out 2.2s infinite}
.onb-raise:not(.up):not(.rattle):not(:disabled) .ly.fg{animation:onbRaiseIdleFg 4s ease-in-out 2.2s infinite}
@keyframes onbRaiseIdleSk{0%,16%,100%{translate:0 0}4%{translate:.7% -1.716%}9%{translate:-.525% .78%}13%{translate:.2625% -.312%}}
@keyframes onbRaiseIdleFg{0%,16%,100%{translate:0 0;rotate:0deg}4%{translate:-1.96% -2%;rotate:-2.4deg}9%{translate:1.68% .87%;rotate:1.95deg}13%{translate:-.98% -.62%;rotate:-1.05deg}}
.onb-raise-chip{position:absolute;width:16px;height:16px;pointer-events:none;z-index:5}
.onb-raise-chip svg{width:100%;height:100%}
/* Prototype --dirt (tally-refs/the-raising prototype line 7). */
.onb-raise-dust{position:absolute;width:9px;height:9px;border-radius:50%;background:#C9BFA8;pointer-events:none;opacity:.9}
.onb-raise-ring{position:absolute;left:50%;bottom:8px;translate:-50% 0;width:40px;height:14px;border:3px solid var(--gold);
  border-radius:50%;opacity:0;pointer-events:none}
.onb-raise.pop .onb-raise-ring{animation:onbRaiseRing .7s ease-out}
@keyframes onbRaiseRing{0%{opacity:.95;transform:scale(1)}100%{opacity:0;transform:scale(6.2)}}

.onb-raise-reg { pointer-events: none; }
/* 2026-09-23: size the layout box too, so the name cannot sit under the art. */
.onb-raising.raised .onb-raise-wrap { flex: none; }
.onb-raising.raised .onb-raise { width: min(60vw, 180px); }
/* 2026-09-25 review: the risen skull sat 0-4px under the sub line; 20px+ now. */
.onb-raising.raised .onb-raise { margin-top: 26px; }
.onb-raise.up .onb-raise-shadow { top: 93%; }
.onb-meet { width: 100%; }
.onb-meet[hidden], .onb-raise-foot[hidden] { display: none; }
#onbRaiseTitle, .onb-raise-copy { transition: opacity .15s; }
.onb-raising.raised:not(.restored) .onb-meet, .onb-raising.raised:not(.restored) .onb-raise-foot { animation: onbRaiseMeet .4s; }
.onb-raising .onb-nameplate { margin-top: 6px; flex-wrap: wrap; }
.onb-raising .onb-nameplate .nm { min-width: 0; overflow-wrap: anywhere; text-align: center; }
.onb-raising .onb-reroll { flex: none; }
/* 2026-09-24: 44px hit areas on the Raising's small controls without moving layout
   (measured 38, 42 and 16px tall in the 393x852 recording). */
.onb-reroll { position: relative; }
.onb-back::after, .onb-reroll::after { content: ''; position: absolute; inset: -4px; }
@keyframes onbRaiseMeet { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
  .onb-raise, .onb-raise .ly, .onb-raise-wrap, #onbRaiseTitle, .onb-raise-copy { transition-duration: .01ms !important; }
  .onb-raise .ly.skull, .onb-raise .ly.fg { animation: none !important; }
  .onb-raise.rattle .ly, .onb-raise.pop .onb-raise-ring, .onb-raising.raised:not(.restored) .onb-meet, .onb-raising.raised:not(.restored) .onb-raise-foot { animation: none; }
}
.onb-plan h1 { align-self: flex-start; text-align: left; font-size: var(--fs-display); }
.onb-plan #pfHost { width: 100%; }
.onb-plan .btn { width: 100%; margin-top: 4px; }

/* SHORT PHONES GET A COMPACT FOOTER, NOT A SMALLER BUTTON. At 320x568 the step-3
   footer measured 183.3px, a third of the screen, and all of it above the fold
   now that it no longer overlaps. The button keeps its size and its 40px floor;
   only the quiet secondary line tightens. */
@media (max-height: 600px) {
  .onb-foot { padding-top: 10px; padding-bottom: calc(var(--sab) + 10px); }
  .onb-quiet { font-size: var(--fs-1); margin-top: 8px; line-height: 1.35; }
  /* R43-4, THE OTHER HALF. Un-overlaying the footer stops the nameplate being
     PAINTED OVER; it does not by itself put it on screen. Measured at 320x568
     with the footer static: the step-2 scroller is 395px and its content 646px,
     and the nameplate starts at 432px, so the screen titled THIS ONE'S YOURS
     still opened with no name on it, one scroll away instead of one overlay
     away. The overflow is 251px and the poster is 240 of it, so the poster is
     where it comes from: the character shrinks on a 568px phone, the NAME does
     not, because the name is what this step is for. Type stays on the design
     scale (34px display, above the 30px THE PLAN already ships) and nothing
     here is a tap target. The earns row is deliberately left to peek below the
     fold: it is supporting copy, and a half-row is the scroll cue. */
  .onb-dots { margin-bottom: 10px; }
  .onb h1 { font-size: var(--fs-display); }
  .onb-back ~ h1 { margin-top: 40px; }
  .onb-poster { min-height: 140px; margin: 10px 0; }
  .onb-raising.raised .onb-raise { width: 112px; }
  .onb-nameplate { margin-top: 10px; }
  .onb-earns { margin: 10px 0 10px; }
  .onb-earn { padding: 8px 4px 6px; }
}

/* ---- Phase 4: FEEL. Exits for things that only knew how to enter. ---- */
.sheet.closing { animation: sheetOut .2s ease-in forwards; }
@keyframes sheetOut { to { transform: translate(-50%, 105%); } }
/* A TAKEOVER LEAVES STRAIGHT DOWN, and it needs its own keyframes for exactly
   the reason the entrance already has slideupFlat: sheetOut carries the -50%
   the base .sheet centres with, and .sheet.takeover sets transform: none. So
   the shared exit interpolated from translate(0,0) and yanked the takeover half
   its own width to the LEFT while it dropped. The entrance was fixed and the
   exit was not. Measured on a Common Crate reveal at 440x956 (tests/
   crate-exit-flicker-audit.mjs): the takeover's right edge marched from x=436
   to x=338 in 100ms, a vertical seam-front sweeping across the Backpack you are
   returning to, and at its worst that seam ran the FULL height of the screen.
   Every takeover in the app left this way, not just the crate. */
.sheet.takeover.closing { animation: sheetOutFlat .2s ease-in forwards; }
@keyframes sheetOutFlat { to { transform: translateY(105%); } }
.sheet-backdrop.closing { animation: fadeout .2s ease-in forwards; }
@keyframes fadeout { to { opacity: 0; } }
/* A CRATE LEAVES LIKE A CURTAIN, NOT LIKE A DROP. Tom, 2026-09-27: "the chest
   animation still closes abruptly". The slide above is ease-in: it leaves at
   full speed and stops dead, 200ms for a full-screen reveal. A 350ms ease-out
   then read as "urgently slamming closed" (2026-09-28), so the pack reveal
   slides on an ease-in-out, a gentle start that builds and then settles, over
   650ms (Tom's pick of 350 / 500 / 650), its backdrop on the same clock.
   js/app.js openPackReveal's CRATE_EXIT_MS must match the duration.
   tests/crate-close-audit.mjs grades it frame by frame. */
.sheet.takeover.pack-take.closing { animation: packSlideOut .65s cubic-bezier(.45, 0, .2, 1) forwards; }
@keyframes packSlideOut { to { transform: translateY(100%); } }
.sheet-backdrop.closing:has(+ .pack-take) { animation: fadeout .65s cubic-bezier(.45, 0, .2, 1) forwards; }
.toast.out { animation: toastOut .18s ease-in forwards; }
@keyframes toastOut { to { opacity: 0; transform: translate(-50%, 8px); } }
@media (prefers-reduced-motion: reduce) {
  /* .sheet.takeover.closing is listed by name: it out-specifies .sheet.closing,
     so the shorter selector alone would leave the takeover animating here. */
  .sheet.closing, .sheet.takeover.closing, .sheet-backdrop.closing, .toast.out { animation: none; }
  /* ...except a pack reveal, which gets a short plain slide rather than a cut.
     !important beats the global 0.001s collapse above. */
  .sheet.takeover.pack-take.closing { animation: packSlideOut .2s linear forwards !important; }
  .sheet-backdrop.closing:has(+ .pack-take) { animation: fadeout .2s linear forwards !important; }
}

/* the tab you press acknowledges the press; the active state slides in instead
   of teleporting */
#tabbar .tab { transition: color .18s ease, transform .12s ease; }
/* POSTER STICKERS. Tom picked this direction 2026-08-21 out of three, over a
   quieter "one lamp lit" option. The complaint it answers: "right now it feels
   all one note."
   EVERY TAB CARRIES ITS HUE ALL THE TIME, held back into bone while inactive,
   and the active one becomes a filled sticker in the same language the cards
   already use (--sh-sm, the hard offset shadow that is never blurred).
   THE HUES ARE THE DECK'S, and three of the four are tokens this file already
   defines. Sky is the one the app had no token for, so it is written out with
   its deck value rather than invented.
   NO color-mix, ON PURPOSE. The direction was authored with
   `color-mix(in srgb, var(--nav) 55%, #8f8578)`, which needs iOS 16.4. These
   four hexes are that mix pre-computed and were verified identical against the
   rendered output, so the floor stays where it is.
   MEASURED off the render, not off the stylesheet: worst pair is the active
   Today label at 4.85:1, and the inactive tinted labels land 5.62 to 7.06,
   which is BETTER than the 5.17 the untinted bar ships today.
   THE FAB STAYS LOUDEST. A bar of coloured blocks puts real pressure on the
   primary action, so it keeps its ring and its own shadow; measured 1.11x the
   active plate's area and 8px of rise. If a future tab grows, re-measure that
   ratio rather than trusting it. */
#tabbar .tab[data-tab="today"]    { --nav: var(--coral);  --nav-dim: #cc7566; }
#tabbar .tab[data-tab="boneyard"] { --nav: var(--violet); --nav-dim: #968cb6; }
#tabbar .tab[data-tab="friends"]  { --nav: #72a8f1;       --nav-dim: #7f98bb; }
#tabbar .tab[data-tab="bonehead"] { --nav: var(--gold);   --nav-dim: #bd9a54; }
#tabbar { border-top: 2px solid rgba(242, 233, 215, .2); }
#tabbar .tab { position: relative; border-radius: 12px; color: var(--nav-dim); }
#tabbar .tab::before {
  content: ''; position: absolute; inset: 0; z-index: -1;
  border-radius: 12px; background: var(--nav); box-shadow: var(--sh-sm);
  opacity: 0; transform: translate(-1px, -1px);
  transition: opacity .16s ease, transform .16s ease;
}
#tabbar .tab.active::before { opacity: 1; transform: none; }
#tabbar .tab.active { color: var(--ink); font-weight: 800; }
/* 2026-09-24 Tom picked C on the review page (A/B/C mock): the active plate goes quiet
   (surface-2 with a hairline), the tab's own hue moves to the icon, the label goes bright. */
#tabbar .tab.active::before { background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); }
#tabbar .tab.active { color: var(--text); }
#tabbar .tab.active svg { color: var(--nav); }
#tabbar .fab { box-shadow: var(--sh), 0 0 0 4px var(--bg); }
@media (prefers-reduced-motion: reduce) {
  /* the plate still appears, it just stops sliding. Never a 0.001s duration. */
  #tabbar .tab::before { transition: opacity .16s ease; transform: none; }
}
#tabbar .tab:active { transform: scale(.92); }
.fab { transition: transform .12s ease; }
.fab:active { transform: scale(.92); }

/* each route change fades its screen in rather than hard-cutting */
/* EVERY SCREEN ARRIVES WHOLE. Tom, 2026-08-08: "make this an across app thing,
   all new and existing pages should have this rule ... I want these tabs fully
   loaded before anyone is interacting so the UX is smoooooth and polished."
   `route-in` used to be added the instant the DOM existed, so it faded in markup
   whose art had not decoded and the screen assembled itself in front of you.
   The rendered child is now HIDDEN until revealWhenReady() has waited for every
   image to decode, and that function ALWAYS applies the class, cap included, so a
   broken or slow image degrades to ugly and never to a blank tab.
   This sits on the router, so it covers every screen that exists and every screen
   anyone adds later without them having to know about it. */
/* The CONTAINER carries the hide, not the first child: screens render many
   sibling nodes (Crew nine, Today fifteen) and the first one routinely holds none
   of the art, so hiding it waited on nothing. */
/* AND THIS GATE IS A SWITCH, NOT AN ANIMATION. #screen declares no transition,
   but `transition-property` defaults to `all`, and the reduced-motion block near
   the top of this file sets `transition-duration: 0.001s !important` on `*`. So
   for a player who asks for less motion, every property change on #screen becomes
   a one-millisecond transition, and a transition's first computed value is the
   OLD one: the frame in which `screen-in` lands still reads opacity 0, and the
   real value arrives on the next frame. That one frame is a hole, and the moment
   the held copy stopped dissolving over it, it became visible. Caught by
   tests/route-flash-audit.mjs REDUCED-MOTION, one bare frame at 214ms, which is
   exactly what that row is for. Declaring the property away is the fix, because
   there is nothing on #screen that is meant to transition; the alternative,
   holding the copy an extra frame, would have paid a frame on every navigation
   for every player to work around an accident that only exists for some.
   This is the 0.001s trap the reduced-motion block itself warns about, one
   element over. */
.screen { transition-property: none; }
.screen:not(.screen-in) { opacity: 0; }
.screen.screen-in { opacity: 1; }
.screen > .route-in { animation: routeIn .2s ease-out; }
@keyframes routeIn { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .screen > .route-in { animation: none; } }

/* AND THE OLD SCREEN COVERS THE GAP WHILE IT WAITS. Tom, 2026-08-21: "swapping
   between boneyard and today briefly shows the empty tray that sits behind the
   app. feels cheap." The rule above hides #screen from the moment a navigation
   starts until every image on the new screen has decoded, and for that window
   there was nothing in front of the body gradient but the tab bar. js/app.js
   (holdOutgoing) MOVES the outgoing nodes into one of these and parks it over
   #screen; the new screen builds underneath and the copy fades off it.
   IT SITS BETWEEN #screen AND #tabbar, by DOM order and nothing else: both are
   positioned with z-index auto, so paint order is source order, and inserting it
   right after #screen is what keeps the bar and the gear on top of it and
   tappable. pointer-events: none for the same reason.
   It carries #screen's own class list (holdOutgoing copies className), so it
   keeps that screen's padding, including the Boneyard's full-bleed
   `.screen--map`. overflow is clamped here so a frozen copy never grows a
   scrollbar of its own; scrollTop is still settable on it, which is how the copy
   freezes at the offset the player left.
   IT LEAVES ON ONE FRAME AND HAS NO EXIT ANIMATION AT ALL. It used to fade out
   over .18s and be removed on a 260ms timer, which put both screens on the glass
   together for a quarter of a second: Tom, 2026-08-21, "switching between tabs is
   not smooth it's showing a staggered preview of the the existing page as you
   swap". js/app.js removes the node in the same task that applies `screen-in`,
   so the swap is one composited frame with no in-between state for CSS to
   describe. Nothing to collapse to 0.001s, nothing to wait on transitionend for,
   and reduced motion needs no special case because there is no motion. */
.screen-held {
  position: absolute; left: 0; right: 0;
  overflow: hidden; pointer-events: none;
}
/* A held swap already has a finished picture on top, so the arriving child must
   NOT also fade up from transparent: two crossed opacity fades leave a quarter of
   the ground visible at the midpoint, which is the tray again. The class stays on
   the child regardless (it is the router's "arrived by a navigation" marker and
   two audits read it); only the animation is suppressed. */
.screen.screen-swap > .route-in { animation: none; }
/* Sheets are the app's other surface and get the same treatment. SCOPED TO
   #sheets deliberately: the Boneyard reuses `.sheet-body` as a screen class, so an
   unscoped rule matched it, tied on specificity with `.screen > .route-in` and won
   on source order, leaving the map blank. Caught by the ARRIVAL guard in
   tests/screen-sweep.mjs, which is exactly what that guard is for. */
#sheets .sheet-body:not(.sheet-in) { opacity: 0; }
#sheets .sheet-body.sheet-in { opacity: 1; transition: opacity .16s ease-out; }
@media (prefers-reduced-motion: reduce) { #sheets .sheet-body.sheet-in { transition: none; } }

/* AND THE FURNITURE ARRIVES WITH THEM. Tom, 2026-08-19: "when the app boots up
   before it shows the loading screen it always flashes an empty today tab with
   the bottom bar visible, this looks amateurish as a first impression."
   THE ORDERING, MEASURED, NOT GUESSED. `.screen` above is hidden until a screen
   is in it, but #tabbar and #gearBtn are STATIC MARKUP in index.html with no
   gate at all, so they paint the moment app.css parses: first paint, long before
   the module graph has executed and long before showSplash() can create #splash.
   The splash is a JS-built element and the furniture is HTML, so the furniture
   always wins. Captured at 440x956 and 393x852, cold AND warm, x1 and x4 CPU:
   the first painted state was the bare tab bar and gear over an empty screen in
   every one of the six runs (cold x1: first paint 48ms, splash covers it at
   109ms). See tests/boot-flash-audit.mjs.
   THE GATE IS A ONE-SHOT LATCH, and it has to be. Two simpler conditions were
   tried against the frames first and both are wrong:
     `#screen:not(.screen-in)` blinks the tab bar out on EVERY tab change,
     because route() strips screen-in on every navigation.
     `#screen:empty` releases too early. Measured by this audit's own WARM pass
     at x6 on both viewports: #screen gets its children at 373ms but
     revealWhenReady holds screen-in until its images decode at ~495ms, so the
     furniture came back over a screen that was still at opacity 0, and a bare
     frame turned up at 495ms (lime=720, edge=0.03%). The splash hides that
     window in an ordinary boot, but not for a reduced-motion player and not on
     a second load in the same session, where showSplash bails.
   So `booted` lands with the FIRST REVEAL, never before it, and is never
   removed (js/app.js: route's reveal, and renderOnboarding, which is the one
   screen that does not route).
   THE FAILSAFE IS THE POINT, NOT A GARNISH (anti-regression rule 8: whatever
   hides something must own un-hiding it). A boot that throws leaves #screen
   empty forever, and hiding the shell behind an event that never comes is how
   you ship a permanently black screen. So the hide is a 0s animation with an
   8s delay and fill `both`: before the delay it holds the `from` keyframe
   (hidden), after it holds `to` (visible), with no JS involved at all. Content
   arriving removes the selector and the furniture returns instantly.
   8s is measured. Time until #screen has content, cold cache, by CPU throttle,
   on this tree: x1 127ms, x4 317ms, x6 481ms, x10 860ms, x20 2165ms, x30
   3847ms, x40 6060ms; index.html's own table, taken against a heavier one-year
   save, reaches 9968ms at x40. So 8s never fires on a healthy boot on anything
   short of a 40x device, and it lands 4s BEFORE index.html's dead-shell reload,
   so a genuinely dead shell shows exactly the picture it shows today rather
   than nothing at all. */
/* 2026-09-25: the static #gearBtn is gone (each page's gear is in its title
   row, inside #screen), so the latch holds the tab bar alone. */
html:not(.booted) #tabbar { animation: shellFurniture 0s linear 8s both; }
@keyframes shellFurniture {
  from { opacity: 0; visibility: hidden; }
  to { opacity: 1; visibility: visible; }
}

/* ============================================================================
   TIER 3: THE DEPTH SCREENS. Shop, Backpack, Build, Pit entry, Garden, Stable,
   built from the mockups Tom approved 2026-08-07 (market-quality-mockups/
   t3-*.html + t3.css). One language: everything interactive is a sticker (2px
   ink border + hard offset shadow), prices are gold chips (violet for dust),
   section heads are the coral rule, heroes are framed posters.

   EVERY class is t3-prefixed. The mockup's own names (.hero, .drop, .sect,
   .egg, .faq, .wallet, .cell, .chip, .bar, .btn) collide with nine existing
   rules in this file, and a cascade fight over padding is exactly the bug
   class the project keeps paying for. Same convention as the .t1-* block.

   Token translation from the mockups' tokens.css:
     --panel -> --surface     --panel-2 -> --surface-2   --bone-hi -> --text
     --text-dim -> --text-2   --hair -> --line           --lime -> --accent
     --r -> --radius          --r-sm -> --radius-sm      --sky -> --protein
   ========================================================================== */

/* --- shared: section rule, price chips, cells, rows, stat allocators ------ */
.t3-sect { display: flex; align-items: center; gap: 8px; margin: 16px 0 9px; }
.t3-sect b { font-size: var(--fs-0); font-weight: 800; letter-spacing: .09em; text-transform: uppercase; color: var(--accent); }
.t3-sect i { display: none; }
.t3-sect .r { margin-left: auto; flex: none; }

.t3-price {
  display: inline-flex; align-items: center; gap: 5px;
  background: var(--gold); color: var(--ink); border: 2px solid var(--ink); border-radius: 9px;
  box-shadow: var(--sh-sm); padding: 1px 8px;
  font-family: var(--display); font-weight: 400; font-size: var(--fs-3); letter-spacing: .03em;
}
.t3-price.dust { background: var(--violet); }
.t3-price.free { background: var(--mint); }
.t3-price.crate { background: var(--mint); }   /* step-race podium: a crate is not a price, it is a payout */
.t3-price svg { display: block; }

.t3-cells { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.t3-cell {
  background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 12px 8px 10px; text-align: center; position: relative;
  color: var(--text); font: inherit; width: 100%; display: block;
}
.t3-cell:disabled { opacity: .5; }
.t3-cell:not(:disabled):active { transform: translate(2px, 2px); box-shadow: 1px 2px 0 rgba(0, 0, 0, .55); }
.t3-cell .art { height: 64px; display: grid; place-items: center; margin-bottom: 6px; }
.t3-cell .art img { max-width: 100%; max-height: 100%; object-fit: contain; }
.t3-cell b {
  display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-body);
  letter-spacing: .04em; color: var(--text); margin-bottom: 6px;
}
.t3-cell small { display: block; font-size: var(--fs-1); color: var(--text-2); margin-top: 5px; line-height: 1.35; }

.t3-row { display: flex; align-items: center; gap: 11px; padding: 10px 0; border-top: 1px solid var(--line); }
.t3-row:first-of-type { border-top: 0; }
.t3-med {
  flex: none; width: 40px; height: 40px; border-radius: 11px;
  background: var(--surface-2); border: 2px solid var(--ink); box-shadow: var(--sh-sm);
  display: grid; place-items: center; font-family: var(--display); font-size: var(--fs-4); color: var(--gold);
}
.t3-tx { flex: 1; min-width: 0; text-align: left; }
.t3-tx b { display: block; font-size: var(--fs-3); font-weight: 700; color: var(--text); }
.t3-tx small { font-size: var(--fs-1); color: var(--text-2); display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.t3-row .btn { width: auto; padding: 9px 16px; font-size: var(--fs-body); box-shadow: var(--sh-sm); flex: none; }
.t3-lock {
  flex: none; font-size: var(--fs-0); font-weight: 800; letter-spacing: .07em;
  color: var(--text-2); border: 2px solid var(--ink); border-radius: 8px; padding: 3px 9px; background: var(--surface-2);
}

.t3-stat {
  background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 10px 12px; margin-bottom: 9px;
}
.t3-stat .top { display: flex; align-items: baseline; gap: 9px; }
.t3-stat .top b { font-family: var(--display); font-weight: 400; font-size: var(--fs-4); letter-spacing: .04em; color: var(--text); }
.t3-stat .top .v { margin-left: auto; font-family: var(--display); font-size: var(--fs-5); color: var(--gold); }
.t3-stat .bar { height: 10px; margin-top: 7px; }
.t3-stat .foot { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.t3-stat .foot small { flex: 1; font-size: var(--fs-1); color: var(--text-2); line-height: 1.35; }
/* drawn +/- steppers: two bars, the plus rotates one of them */
/* 44, NOT 38. This is the one control in the tap-target pass that grows for
   real rather than under a transparent ::before: both of its pseudo-elements
   are already spent drawing the +/- bars. The bars are absolutely centred, so
   they do not move. */
.t3-pm {
  flex: none; width: 44px; height: 44px; border-radius: 10px; position: relative;
  background: var(--surface-2); border: 2px solid var(--ink); box-shadow: var(--sh-sm);
}
.t3-pm::before, .t3-pm.plus::after {
  content: ''; position: absolute; left: 50%; top: 50%;
  width: 14px; height: 3px; border-radius: 2px; background: var(--text); transform: translate(-50%, -50%);
}
.t3-pm.plus::after { transform: translate(-50%, -50%) rotate(90deg); }
.t3-pm:disabled { opacity: .4; }
.t3-pm:not(:disabled):active { transform: translate(2px, 2px); box-shadow: 1px 2px 0 rgba(0, 0, 0, .55); }

/* --- shop ---------------------------------------------------------------- */
.t3-wallet { display: flex; gap: 7px; margin: 0 0 12px; }
.t3-drop {
  position: relative; display: block; width: 100%; text-align: left;
  background: var(--coral); border: 2px solid var(--ink);
  border-radius: var(--radius); box-shadow: var(--sh); overflow: hidden; padding: 13px 14px;
}
.t3-drop::before { content: ''; position: absolute; inset: 0; background: var(--grain); opacity: .07; pointer-events: none; }
.t3-drop > * { position: relative; }
.t3-drop .new {
  position: absolute; right: 12px; top: 12px; background: var(--gold); color: var(--ink);
  border: 2px solid var(--ink); border-radius: 8px; box-shadow: var(--sh-sm);
  padding: 1px 8px; font-size: var(--fs-0); font-weight: 800; letter-spacing: .08em; z-index: 2;
}
.t3-drop .eyebrow { color: var(--text); opacity: .85; font-size: var(--fs-0); font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.t3-drop h2 { font-family: var(--display); font-weight: 400; font-size: var(--fs-title); letter-spacing: .03em; color: var(--text); margin: 2px 0 8px; }
.t3-drop .row { display: flex; align-items: center; gap: 12px; }
.t3-drop .art {
  flex: none; width: 84px; height: 84px; border-radius: 12px; background: rgba(42, 45, 40, .35);
  border: 2px solid var(--ink); display: grid; place-items: center; overflow: hidden;
}
.t3-drop .art img { width: 86%; height: 86%; object-fit: contain; }
.t3-drop .tx { flex: 1; min-width: 0; }
.t3-drop .tx small { display: block; color: var(--text); opacity: .9; font-size: var(--fs-2); line-height: 1.4; margin-bottom: 8px; }
.t3-forage {
  display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
  background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 11px 13px; margin-top: 12px; color: var(--text);
}
.t3-forage b { flex: 1; font-size: var(--fs-3); }
.t3-forage small { color: var(--text-2); }

/* --- backpack ------------------------------------------------------------ */
.t3-qty {
  position: absolute; top: -8px; right: -8px; min-width: 24px; height: 24px;
  background: var(--coral); color: #2a0d08; border: 2px solid var(--ink); border-radius: 999px;
  font-size: var(--fs-0); font-weight: 800; display: grid; place-items: center; padding: 0 5px; z-index: 2;
}
.t3-cell .btn { width: 100%; margin-top: 4px; padding: 9px 4px; font-size: var(--fs-body); box-shadow: var(--sh-sm); }
.t3-egg {
  display: flex; align-items: center; gap: 12px; background: var(--surface);
  border: 2px solid var(--ink); border-radius: var(--radius); box-shadow: var(--sh); padding: 12px 13px;
}
.t3-egg .art { flex: none; display: grid; place-items: center; position: relative; isolation: isolate; }
/* R39-21: the shell tease is a FLAT palette colour multiplied onto the shell
   through a mask of the shell itself (--shell from js/app.js eggTint), so an
   Ember egg reads orange whatever the shell's own hue. Not a hue-rotate. */
.t3-egg .art.tinted::after { content: ''; position: absolute; inset: 0; background: var(--shell); mix-blend-mode: multiply; pointer-events: none;
  -webkit-mask: url(assets/icons-pix/egg.png) center / 48px 48px no-repeat; mask: url(assets/icons-pix/egg.png) center / 48px 48px no-repeat; }
.t3-egg .tx { flex: 1; min-width: 0; }
.t3-egg .tx b { display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-4); letter-spacing: .04em; color: var(--text); }
.t3-egg .bar { height: 10px; margin-top: 6px; }
.t3-egg .bar > i { background: var(--protein); }
.t3-egg small { font-size: var(--fs-1); color: var(--text-2); }

/* --- build --------------------------------------------------------------- */
.t3-fighter {
  display: flex; align-items: center; gap: 12px; background: var(--surface-2);
  border: 2px solid var(--ink); border-radius: var(--radius); box-shadow: var(--sh); padding: 12px 14px; margin-bottom: 10px;
}
.t3-fighter .n { flex: 1; min-width: 0; }
.t3-fighter .n b { display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-5); letter-spacing: .04em; color: var(--text); }
.t3-fighter .n small { font-size: var(--fs-1); color: var(--text-2); }
.t3-fighter .tp {
  flex: none; text-align: center; background: var(--coral); border: 2px solid var(--ink); border-radius: 11px;
  box-shadow: var(--sh-sm); padding: 4px 12px;
}
.t3-fighter .tp b { display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-5); color: #2a0d08; line-height: 1; }
.t3-fighter .tp small { font-size: var(--fs-micro); font-weight: 800; letter-spacing: .08em; color: #2a0d08; }
.t3-armor { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px; }
.t3-armor .t3-cell b { color: var(--protein); font-size: var(--fs-title); margin-bottom: 2px; }
.t3-armor .t3-cell .lab { font-family: var(--display); font-size: var(--fs-2); letter-spacing: .05em; color: var(--text); }
.t3-armor .t3-cell small { margin-top: 2px; }
/* BUILD TAB POLISH. One card (the t3 sticker plate: surface, 2px ink, sh-sm) for
   every block on the tab; section labels drop to the DESIGN.md header idiom so
   lime is left for the one thing to do next (a live + stepper, a pickable talent). */
.t3-fighter .tp { font: inherit; color: inherit; min-height: 44px; }
.t3-fighter .tp + .tp { margin-left: -4px; }
button.tp { cursor: pointer; }
button.tp:active { transform: translate(2px, 2px); box-shadow: 1px 2px 0 rgba(0, 0, 0, .55); }
.t3-fighter .tp b, #talBody .t3-sect .r, #talBody .tal-pip, #talBody .tal-tree-head .note,
#talBody .t3-stat .v, #talBody .t3-cell b { font-variant-numeric: tabular-nums; }
#talBody .t3-sect { margin: 22px 2px 10px; }
#talBody .t3-sect b { font-size: var(--fs-1); font-weight: 700; letter-spacing: .06em; color: var(--text-3); }
#talBody .t3-stat .top .v { display: flex; align-items: baseline; gap: 6px; }
#talBody .t3-stat .stat-bonus, #talBody .t3-stat .stat-gear {
  font-family: system-ui, -apple-system, sans-serif; font-size: var(--fs-0); font-weight: 700; letter-spacing: 0;
}
#talBody .t3-stat .stat-bonus { color: var(--text-2); order: -1; text-transform: uppercase; letter-spacing: .04em; }
#talBody .t3-stat .stat-gear { color: var(--carbs); order: -1; text-transform: uppercase; letter-spacing: .04em; margin-right: 4px; }
#talBody .tal-tree, #talBody .bsect:not(.faq-card) {
  background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); margin-bottom: 9px;
}
#talBody .tal-tree { padding: 12px; }
#talBody .tal-tree:not([open]) { padding-bottom: 12px; }
#talBody .tal-tree > summary.tal-tree-head::after { font-size: var(--fs-3); color: var(--text-2); }
#talBody .tal-tree > .note { margin: 4px 2px 10px !important; }
#talBody .tal-node { background: var(--surface-2); }
#talBody .tal-node.taken, #talBody .tal-node.partial { border-color: color-mix(in srgb, var(--tc) 45%, transparent); }
#talBody .tal-node.locked { opacity: .6; }
#talBody .tal-gate { margin: 12px 0 6px; }
#talBody .tal-tier.pair .tal-node:last-child:nth-child(odd) { grid-column: 1 / -1; }
#talBody .t3-fighter { flex-wrap: wrap; row-gap: 4px; }
#talBody .t3-fighter > .sub { flex-basis: 100%; font-size: var(--fs-1); color: var(--text-2); line-height: 1.4; }
#talBody .tal-pip svg { stroke-width: 3.2; }
#talBody .tal-move { white-space: nowrap; }
#talBody .t3-fighter .tp { min-width: 0; padding: 5px 9px; }
#talBody .bsect-head b { white-space: nowrap; }
#talBody .tal-tree > summary.tal-tree-head { min-height: 44px; margin: -6px 0; }
#talBody .t3-fighter .tp small { font-size: var(--fs-0); }
#talBody .tal-id > b, #talBody .tal-tree-head > .note { white-space: nowrap; flex: none; }
#talBody .tal-tag { letter-spacing: .06em; white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
#talBody .tal-tree > summary.tal-tree-head::after { flex: none; }
#talBody .tal-move { font-size: var(--fs-tiny); }
#talBody #tpReset { min-height: 44px; }
/* ROUND 2: SHORTER PAGE. Stats are one row each (name, bar, value, - +); the
   line of meaning opens under the row on tap. The panel eases open on
   grid-template-rows 0fr -> 1fr, which animates in WebKit as well as Chrome. */
#talBody .t3-fighter .sub .ar b { color: var(--protein); font-weight: 800; }
#talBody .t3-stat.fold { padding: 6px 8px 6px 12px; margin-bottom: 8px; }
#talBody .t3-stat .row { display: flex; align-items: center; gap: 6px; }
#talBody .stat-tog {
  flex: 1; min-width: 0; min-height: 44px; display: flex; align-items: center; gap: 10px;
  background: none; border: 0; padding: 0; color: inherit; font: inherit; text-align: left; cursor: pointer;
}
#talBody .stat-tog b { flex: none; font-family: var(--display); font-weight: 400; font-size: var(--fs-4); letter-spacing: .04em; color: var(--text); }
#talBody .stat-tog .bar { flex: 1; min-width: 40px; height: 8px; margin: 0; }
#talBody .stat-tog .v { flex: none; min-width: 30px; margin-right: 4px; text-align: right; font-family: var(--display); font-size: var(--fs-5); color: var(--gold); }
#talBody .stat-more { display: grid; grid-template-rows: 0fr; opacity: 0; visibility: hidden; transition: grid-template-rows .3s cubic-bezier(.2, .8, .2, 1), opacity .22s ease, visibility 0s linear .3s; }
#talBody .t3-stat.open .stat-more { grid-template-rows: 1fr; opacity: 1; visibility: visible; transition-delay: 0s; }
#talBody .stat-more > div { overflow: hidden; min-height: 0; }
#talBody .stat-more small { display: block; padding: 4px 12px 6px 0; font-size: var(--fs-1); color: var(--text-2); line-height: 1.35; }
#talBody .stat-parts { display: flex; gap: 8px; padding-bottom: 6px; }
#talBody .t3-stat.open .stat-tog b { color: var(--gold); }
/* Grid, not flex-wrap on the whole head: wrapping there let a long subtitle push
   the points onto a second line at 360px. Only name + tag wrap (.tal-id below);
   points and arrow hold row 1, and the taken-talent pips get a row to themselves. */
#talBody .tal-tree > summary.tal-tree-head { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; column-gap: 8px; row-gap: 0; }
#talBody .tal-tree-head > .tal-can { grid-column: 2; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px rgba(165, 232, 71, .18); }
#talBody .tal-tree-head > .note { grid-column: 3; }
#talBody .tal-tree > summary.tal-tree-head::after { grid-column: 4; grid-row: 1; }
#talBody .tal-tree-head > .tal-id, #talBody .tal-tree-head > .tal-can, #talBody .tal-tree-head > .note { grid-row: 1; }
#talBody .tal-tree[open] .tal-can { visibility: hidden; }
/* Name and tag share one cell and wrap: a tag that does not fit beside its name
   drops under it (Bone Shaman, The Alchemist at 375; most trees at 320) instead of
   ellipsizing. At 393 every tag fits, so nothing moves there. */
#talBody .tal-id { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; min-width: 0; }
#talBody .tal-id > .tal-tag { max-width: 100%; box-sizing: border-box; }
#talBody .tal-tree-head > .note { margin-left: 0 !important; }
#talBody .tal-dots { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 4px; padding: 0 0 6px; }
#talBody .tal-dots i { width: 10px; height: 10px; border-radius: 3px; background: var(--tc); }
#talBody .tal-dots i.part { background: transparent; box-shadow: inset 0 0 0 2px var(--tc); }
#talBody .stat-tog b { order: -2; width: auto; min-width: 62px; }
#talBody .stat-tog::after { content: '▸'; order: -1; flex: none; margin-left: -4px; color: var(--text-2); font-size: var(--fs-3); line-height: 1; transition: transform .25s ease; }
#talBody .stat-gear-mini { flex: none; font-size: var(--fs-0); font-weight: 700; color: var(--carbs); font-variant-numeric: tabular-nums; }
#talBody .tal-tier.pair .tal-move { display: inline-block; margin-top: 3px; }
#talBody .t3-stat.open .stat-tog::after { transform: rotate(90deg); }
/* Talent tiles: pip beside the name instead of stacked above it, which is the
   single biggest height cost once a tree is open. Same pip, same colours. */
#talBody .tal-tier.pair .tal-node {
  display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 8px; row-gap: 4px;
  text-align: left; padding: 9px 10px; align-content: start;
}
#talBody .tal-tier.pair .tal-node .tal-body { display: contents; }
#talBody .tal-tier.pair .tal-node .tal-pip { width: 26px; height: 26px; border-radius: 8px; }
#talBody .tal-tier.pair .tal-node .tal-body > small, #talBody .tal-tier.pair .tal-node .tal-ranks { grid-column: 1 / -1; }
#talBody .tal-tier.pair .tal-node .tal-ranks { margin-top: 2px; }
#talBody .tal-tier.pair .tal-node .tal-body b { line-height: 1.2; }
#talBody .tal-deep > summary { list-style: none; cursor: pointer; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: 6px;
  font-size: var(--fs-tiny); font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--text-3); }
#talBody .tal-deep > summary::-webkit-details-marker { display: none; }
/* square box: the arrow turns in place, so the open state (rotate 90deg) does not
   widen the line past its summary (it overflowed 3px at 393). */
#talBody .tal-deep > summary::after { content: '▸'; display: inline-block; width: 1em; height: 1em; line-height: 1; text-align: center; transition: transform .2s ease; }
#talBody .tal-deep[open] > summary::after { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) { #talBody .stat-more { transition: none; } }
/* Tab badges anchor at the icon's right edge (icon is 24px, centred) and grow
   rightward. Anchored at right:12% a two-digit count ("28") grew left over the
   icon art. One-digit badges barely move: "2" by 0.3px, "!" 3.5px left. */
.ch-badge { right: auto; left: calc(50% + 12px); }

/* --- pit entry ----------------------------------------------------------- */
.t3-hero {
  position: relative; border: 2px solid var(--ink); border-radius: var(--radius); box-shadow: var(--sh);
  overflow: hidden; background: #121016; padding: 22px 14px 16px; text-align: center; margin-bottom: 12px;
}
.t3-hero > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 70%; opacity: .5; }
.t3-hero > *:not(img) { position: relative; }
.t3-hero h2 { font-family: var(--display); font-weight: 400; font-size: var(--fs-display); letter-spacing: .04em; color: var(--text); text-shadow: 3px 4px 0 var(--ink); }
.t3-hero p { font-family: var(--display); font-weight: 400; font-size: var(--fs-2); letter-spacing: .14em; color: var(--accent); margin-top: 2px; }
.t3-hero .stats { display: inline-flex; gap: 7px; margin-top: 9px; flex-wrap: wrap; justify-content: center; }
.t3-hero .chip { background: rgba(42, 45, 40, .85); color: var(--text); }
.t3-rung {
  flex: none; width: 34px; height: 34px; border-radius: 10px; background: var(--surface-2);
  border: 2px solid var(--ink); box-shadow: var(--sh-sm); display: grid; place-items: center;
  font-family: var(--display); font-weight: 400; font-size: var(--fs-4); color: var(--accent);
}
.t3-row.done .t3-rung { color: var(--text-2); }
.t3-row.done .t3-tx b { color: var(--text-2); text-decoration: line-through; }

/* --- stable -------------------------------------------------------------- */
/* Pet trading card layout. */
.t3-petcard {
  display: flex; gap: 12px; background: var(--surface); border: 3px solid var(--text-3);
  border-radius: 15px; box-shadow: 0 0 0 2px var(--ink), 4px 5px 0 rgba(0, 0, 0, .55);
  padding: 11px 12px; margin-bottom: 11px;
}
.t3-petcard.active { border-color: var(--accent); }
.t3-petcard .portrait {
  flex: none; width: 74px; height: 74px; border-radius: 12px;
  background: var(--surface-2); border: 2px solid var(--ink); display: grid; place-items: center; overflow: hidden;
}
.t3-petcard .tx { flex: 1; min-width: 0; }
.t3-petcard .nm { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.t3-petcard .nm b { font-family: var(--display); font-weight: 400; font-size: var(--fs-4); letter-spacing: .03em; color: var(--text); }
.t3-petcard .nm .lv {
  background: var(--gold); color: var(--ink); border: 2px solid var(--ink); border-radius: 8px;
  padding: 0 7px; font-family: var(--display); font-weight: 400; font-size: var(--fs-2);
}
.t3-petcard .nm .on {
  background: var(--accent); color: var(--ink); border: 2px solid var(--ink); border-radius: 8px;
  padding: 0 7px; font-size: var(--fs-micro); font-weight: 800; letter-spacing: .06em; align-self: center;
}
.t3-petcard .st { font-size: var(--fs-1); color: var(--text-2); margin-top: 3px; }
.t3-petcard .acts { display: flex; gap: 7px; margin-top: 8px; flex-wrap: wrap; }
.t3-petcard .acts .btn { width: auto; padding: 7px 13px; font-size: var(--fs-3); box-shadow: var(--sh-sm); }
.t3-ghosty {
  background: var(--surface-2); color: var(--text); border: 2px solid var(--ink);
  border-radius: var(--radius-sm); padding: 7px 13px; font-family: var(--display); font-weight: 400;
  font-size: var(--fs-3); letter-spacing: .04em; box-shadow: var(--sh-sm);
}
.t3-ghosty.danger { color: #ff8a7d; }
.t3-ghosty:active { transform: translate(2px, 2px); box-shadow: 1px 2px 0 rgba(0, 0, 0, .55); }
.t3-steps { height: 8px; margin-top: 6px; border-width: 1.5px; }
.t3-steps > i { background: var(--gold); }
/* the drop poster IS the disclosure control: tapping it opens the real
   per-item grid below (the existing .drop-grid), so the promo and the
   purchase live in one place instead of two competing cards */
.t3-dropsect { margin: 0; }
.t3-dropsect > summary.t3-drop { list-style: none; cursor: pointer; }
.t3-dropsect > summary.t3-drop::-webkit-details-marker { display: none; }
.t3-dropsect[open] > summary.t3-drop { border-radius: var(--radius) var(--radius) 0 0; }
.t3-dropbody {
  background: var(--surface); border: 2px solid var(--ink); border-top: 0;
  border-radius: 0 0 var(--radius) var(--radius); box-shadow: var(--sh); padding: 12px 13px 13px;
}
.t3-dropbody .drop-sub2 { margin: 0 0 12px; }
/* armed Tier 3 cell: the helper swaps innerHTML for the confirm question, so the
   cell needs to read as a question rather than a half-empty tile */
.t3-cell.arming {
  border-color: var(--gold); color: var(--gold); background: var(--ink);
  font-family: var(--display); font-weight: 400; font-size: var(--fs-4); letter-spacing: .03em;
  display: grid; place-items: center; min-height: 132px;
}
.t3-art { width: 100%; height: 100%; object-fit: contain; }
/* the Pit poster reuses the drawn arena atmosphere as its backdrop */
.t3-hero .pit-hero-atmos { position: absolute; inset: 0; opacity: .85; }
.t3-hero.empty { border-color: var(--coral); }
.t3-ghosty.on { background: var(--accent); color: var(--ink); }
.t3-petcard.breedsel { box-shadow: 0 0 0 2px var(--ink), 0 0 0 5px var(--accent), 4px 5px 0 rgba(0, 0, 0, .55); }
/* an unread delivery, until you open the tab that lists it */
.t3-row.unread { background: rgba(253, 104, 87, .07); }

/* THE CHEERS INBOX (v425). Deliberately built out of the row idiom DELIVERIES
   already uses (.t3-row: a medallion, a two-line text column, an action on the
   right) rather than a new shape, because they are siblings a few hundred
   pixels apart and two different list grammars on one tab would read as two
   different apps. What separates them is what is IN the row, not how it is
   drawn: a cheer leads with the emoji the sender actually chose, quotes their
   phrase, and carries the one control that answers it. */
.cheers-card .card-title { display: flex; align-items: center; gap: 8px; }
/* The count rides the card's own title, not the tab badge: the tab badge is one
   number for the whole of Crew, and by the time you are reading this card you
   need to know how many of THESE are new. */
.cheers-new {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-1); letter-spacing: .1em;
  padding: 2px 8px; border-radius: 999px;
  color: var(--ink); background: var(--coral); border: 2px solid var(--ink);
}
.cheer-rows { display: block; }
/* Capped and scrollable when expanded, for the same reason DELIVERIES is: a long
   history must not push the way out of it off the bottom of the phone. */
.cheer-rows.all { max-height: 44vh; overflow-y: auto; -webkit-overflow-scrolling: touch; }
.cheer-row {
  display: flex; align-items: center; gap: 11px;
  padding: 10px 0; border-top: 1px solid var(--line);
}
.cheer-row:first-child { border-top: 0; }
.cheer-row.unread { background: rgba(253, 104, 87, .07); }
/* The medallion is the cheer itself. Sized so the emoji is legible rather than
   decorative: this is the content of the message, not an icon for it. */
.cheer-face {
  flex: none; width: 40px; height: 40px; display: grid; place-items: center;
  font-size: var(--fs-5); line-height: 1;
  background: var(--card-2, rgba(242, 233, 215, .06));
  border: 2px solid var(--ink); border-radius: 12px; box-shadow: var(--sh-sm);
}
.cheer-tx { flex: 1; min-width: 0; text-align: left; }
.cheer-tx b { display: block; font-size: var(--fs-3); font-weight: 700; color: var(--text); }
/* Their words, set apart from the name above and the timestamp below so the row
   reads as a quotation rather than as one run-on sentence. */
.cheer-said { display: block; font-size: var(--fs-2); color: var(--text); opacity: .92; }
.cheer-tx small { font-size: var(--fs-1); color: var(--text-2); }
.cheer-row .btn.cheer-back { width: auto; flex: none; padding: 8px 13px; font-size: var(--fs-3); }
/* a milestone level wears a stamp, so 25 does not read like 24 */
.lvl-milestone {
  display: inline-block; margin-top: 8px; padding: 3px 12px;
  font-family: var(--display); font-weight: 400; font-size: var(--fs-3); letter-spacing: .12em;
  color: var(--ink); background: var(--gold);
  border: 2px solid var(--ink); border-radius: 9px; box-shadow: var(--sh-sm);
  transform: rotate(-2deg);
}
.lvl-milestone.t-big { background: var(--violet); color: var(--ink); }
.lvl-milestone.t-marquee { background: var(--coral); color: #2a0d08; font-size: var(--fs-4); letter-spacing: .16em; }
/* a routine you wrote yourself carries its own remove control */
.routine-x { flex: none; font-size: var(--fs-0); color: var(--text-3); padding: 4px 2px 4px 8px; }
.well-row.done .routine-x { opacity: .6; }

/* The Boneyard arrives as ONE picture. Spawns, dens, minis and the Glutton paint
   from local data; spires alone need a network round trip, so without this they
   pop in a beat later and the map assembles itself in front of you. Hidden until
   the first full pass completes, then everything fades up together. */
/* !important is not decoration here, it is the whole fix. MapLibre writes
   `opacity: 1` as an INLINE style on every marker element it owns (measured
   2026-08-08: `transform: translate(...); opacity: 1;`). An inline style beats
   any selector, so this hide rule silently did nothing and markers were visible
   the instant they were placed. That is why the Boneyard kept trickling after it
   was reported fixed three times: the guard counted DOM nodes, which appear at
   the same moment either way, instead of computed opacity, which was always 1.
   Same family as the standing rule that MapLibre owns a marker root's transform.
   It owns opacity too. */
/* AND THE GLUTTON IS ONE OF THEM. He was missing from all three rules here
   while the comment above named him and js/map.js POI_CLASSES listed him, so his
   marker was born VISIBLE and painted alone on an empty map for a second and a
   half before anything else faded up. Measured 2026-08-20, the first time this
   suite counted real markers rather than the map key: glutton visible at 290ms,
   reveal at 1846ms, every other marker at 3279ms. That is precisely the "things
   trickle in" complaint this block exists to answer, for one marker type, and it
   shipped because no row here asserted that NOTHING paints before the reveal.
   That row exists now, in tests/boneyard-audit.mjs. */
#mapStage .map-spawn, #mapStage .map-den-mark, #mapStage .map-mini-mark, #mapStage .map-spire,
#mapStage .map-glutton-mark {
  opacity: 0 !important; transition: opacity .22s ease;
}
#mapStage.markers-in .map-spawn, #mapStage.markers-in .map-den-mark,
#mapStage.markers-in .map-mini-mark, #mapStage.markers-in .map-spire,
#mapStage.markers-in .map-glutton-mark { opacity: 1 !important; }
/* A marker that turns up after the map is already on screen waits for the rest of
   its beat (js/map.js holdArrival) so late finds fade in together instead of
   trickling. Specificity has to MATCH the .markers-in rule above or it loses. */
#mapStage.markers-in .poi-arriving { opacity: 0 !important; }
/* The entrance. On .poi-in the marker is already visible, so this only scales the
   INNER art: MapLibre owns the root's transform for positioning and animating it
   strands the marker at 0,0 (the v161 spire bug, same family). */
#mapStage .poi-in > * { animation: poiPop .38s cubic-bezier(.22,1.2,.36,1) both; }
@keyframes poiPop { from { transform: scale(.72); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { #mapStage .poi-in > * { animation: none; } }
@media (prefers-reduced-motion: reduce) {
  #mapStage .map-spawn, #mapStage .map-den-mark, #mapStage .map-mini-mark, #mapStage .map-spire,
  #mapStage .map-glutton-mark { transition: none; }
}

/* A SEALED GIFT. Tom, 2026-08-08: "otherwise deliveries reads like a receipt
   you'd get at a store." So an unopened gift is not a row: it is a closed thing
   with the sender's name on it and one obvious verb. */
.gift-sealed {
  display: flex; align-items: center; gap: 11px; width: 100%; text-align: left;
  background: var(--surface-2); border: 2px solid var(--gold); border-radius: var(--radius);
  box-shadow: var(--sh-sm); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;
  transition: transform .16s ease, opacity .16s ease;
}
.gift-sealed .wrap { flex: none; display: grid; place-items: center; animation: giftnudge 2.4s ease-in-out infinite; }
.gift-sealed .tx { flex: 1; min-width: 0; }
.gift-sealed .tx b { display: block; font-size: var(--fs-3); color: var(--text); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gift-sealed .tx small { display: block; font-size: var(--fs-1); color: var(--text-2); }
.gift-sealed .open {
  flex: none; font-family: var(--display); font-size: var(--fs-2); letter-spacing: .06em;
  background: var(--gold); color: var(--ink); border: 2px solid var(--ink);
  border-radius: 999px; box-shadow: var(--sh-sm); padding: 3px 12px;
}
.gift-sealed.popping { transform: scale(1.06); opacity: 0; }
@keyframes giftnudge {
  0%, 88%, 100% { transform: rotate(0) scale(1); }
  92% { transform: rotate(-7deg) scale(1.06); }
  96% { transform: rotate(7deg) scale(1.06); }
}
@media (prefers-reduced-motion: reduce) {
  .gift-sealed .wrap { animation: none; }
  .gift-sealed.popping { transform: none; }
}

/* The breed panel is a CHECKOUT BAR: it follows you down the pet list instead of
   sitting at the top where selecting your second pet pushed it off-screen. */
/* A STICKY FOOTER MUST NOT BE ABLE TO RISE OVER THE ROW IT IS TALKING ABOUT.
   Tom, 2026-08-10: "the breeding popup is good but it covers the breed button
   when you swipe to another pet." Measured then: the bar sat over .cf-acts by
   15px, and BREED is in that row, so the control you were being TOLD to press
   was under the thing telling you to press it.

   TWO FIXES FOR THIS ALREADY SHIPPED AND NEITHER ONE HELD, so the mechanism is
   worth stating exactly. A `position: sticky; bottom: 0` element is clamped by
   its CONTAINING BLOCK, and this bar's containing block was #stableBody, i.e.
   the whole sheet. That let it rise all the way to the top of the sheet, which
   is why it kept ending up over the carousel:
     v317  gave the sheet a padding-bottom the size of the bar. Padding is
           OUTSIDE the sticky containing block, so the bar un-stuck at the end of
           the content and that padding became Brock's grey gutter (2026-08-14).
     v379  moved the same room to the sibling ABOVE the bar as a margin. That
           kept the bar stuck, but it only bought an ESCAPE: you could scroll the
           action row out from under the bar, you were not stopped from putting
           it back. Measured on v380 at 375x667 with a precious pair flagged:
           the bar is 387px of a 578px sheet, [data-destroy] hit-tested to
           li / span.bt-in / div.breed-trade at 42 of the 76 scroll offsets where
           it is on screen at all, including the one an ordinary scroll toward it
           lands on. That button MELTS A PET PERMANENTLY.

   So the room is gone and the clamp is real: .breed-dock (js/app.js, in
   openStable's render) wraps everything that FOLLOWS the carousel, and the bar
   sticks inside THAT. It can still float down the talent tree, which is what
   Tom asked for; it can no longer reach .cf-acts, because .cf is outside its
   containing block. Measured after: [data-destroy] tappable at every on-screen
   offset at 375x667, 430x932 AND 320x568, #doBreed unchanged at all three, the
   bar showing exactly the same content (the shiny warning still 100% visible),
   and the sheet 387px shorter because the 401px of room is not needed at all.
   DO NOT re-add scroll room here. Room is what you need when the bar can reach
   the row; it cannot. */
.breed-bar.sticky {
  position: sticky; bottom: 0; z-index: 4; margin-top: 14px;
  background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius);
  box-shadow: 0 -6px 18px rgba(0, 0, 0, .45), var(--sh);
  /* capped so the panel cannot swallow the whole screen: the pets you are
     choosing between have to stay visible above it */
  max-height: 58vh; overflow-y: auto; overscroll-behavior: contain;
}

/* THE ACTION STAYS INSIDE ITS OWN CLIP. Brock, 2026-08-14: "when you select a
   non-shiny in the breeding pair, the warning that pops up makes the breed button
   dip out of the window so you have to scroll to get to it." The panel above is
   capped at 58vh and scrolls, so the warning growing pushes #doBreed past the
   bottom of that cap: measured at 375x667 it was drawn at y598 while the panel's
   clip ended at 492, i.e. a tap at the button's centre hit #stableBody. Pinning
   it to the bottom of its own scroller keeps it on screen at any content height,
   which matters more here than anywhere else in the app: the button destroys a
   pet permanently, so it must never be reached by a blind scroll. */
.breed-bar.sticky #doBreed { position: sticky; bottom: 0; z-index: 1; }

/* The delivery archive scrolls inside its own box so the control that collapses
   it never leaves the screen. See paintDeliveries in js/app.js. */
.dlv-rows.all { max-height: 44vh; overflow-y: auto; -webkit-overflow-scrolling: touch; }

/* HP readouts in the fight HUD. A proportional bar says what fraction is left and
   never how much that is, which is why the second enemy's health was unreadable
   (Tom, 2026-08-07). The number sits on the outside edge of each side so the bars
   still line up across the arena. */
/* A mini bar is as long as its fighter's POOL is big, relative to the fighter
   above it (--pool, set per fight). A pet or an add with half the health gets
   half the bar, so "how much has the second enemy got" is answered by the shape
   rather than by a number Tom did not want on screen. */
.hud-pet .bar.fhp.mini { flex: none; width: var(--pool, 100%); min-width: 18px; }

/* THE SECOND ENEMY GETS A REAL HEALTH BAR.
   Tom, 2026-08-08: "fighting a boss den with two enemy targets doesn't have a
   clear health bar for the second target at all. I've brought this up like 3
   times. You think you've fixed it but you haven't."
   Measured before this: the captain's bar was 155x11 with a 151x7 fill, and the
   second target's was a 67x4 track whose fill computed to height ZERO. Not small,
   not unclear: absent. `.bar i` is height:100%, and 100% of a 4px track with
   1.5px borders leaves nothing to see.
   It also wore `.hud-pet`, the same styling as YOUR pet on the other side, so
   even at the right size it read as an ally rather than a second thing trying to
   kill you.
   It is now its own bar in the foe column: full width, its own name above it,
   sized just under the captain's so the hierarchy is still obvious. Explicit
   heights on BOTH track and fill, because inheriting a percentage is exactly how
   this vanished. */
.hud-add { display: grid; gap: 2px; margin-top: 5px; width: 100%; }
.hud-add .aname {
  justify-self: end; font-size: var(--fs-0); font-weight: 800; letter-spacing: .06em;
  text-transform: uppercase; color: var(--text-2); max-width: 100%;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.hud-add .bar.fhp.add { height: 9px; border: 2px solid var(--ink); border-radius: 999px; width: 100%; }
.hud-add .bar.fhp.add > i { height: 100%; min-height: 5px; display: block; }
.hud-add.down { opacity: .38; filter: grayscale(1); }

/* A nickname is your private note about someone, not their name: it sits BESIDE
   the Bonehead name rather than replacing it (Tom, 2026-08-07). Quieter and
   smaller, so the name still reads as the name. */
.alias-tag {
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; font-size: .72em; font-weight: 700; letter-spacing: .02em;
  color: var(--text-3); background: var(--surface-2);
  border-radius: 999px; padding: 1px 7px; margin-left: 5px;
  white-space: nowrap; vertical-align: middle;
}
.fc-name .alias-tag, .fl-main b .alias-tag { font-size: var(--fs-0); }

/* The pet you just levelled, when the Stable is opened straight from its
   celebration: a brief gold pulse so the eye lands on it after the scroll. */
.t3-petcard.just-levelled { animation: petFocus 1.5s ease-out 1; }
@keyframes petFocus {
  0%, 100% { box-shadow: none; }
  25%, 60% { box-shadow: 0 0 0 2px rgba(var(--gold-rgb), .85), 0 0 18px rgba(var(--gold-rgb), .4); }
}
@media (prefers-reduced-motion: reduce) { .t3-petcard.just-levelled { animation: none; } }

/* ---- THE KITCHEN'S TWO DOORS (market-quality-mockups/garden-c-kitchen.html) ----
   The Garden used to be one row buried in the recipe list, which read as an
   accessory to cooking rather than the system that feeds it (Tom, 2026-08-07:
   "the garden feels like an after thought that you wouldn't think to click
   into"). COOK and GROW are peers now, each stating its own live state. */
.kd-doors { display: grid; gap: 12px; margin-bottom: 16px; }
.kd-door {
  position: relative; display: block; width: 100%; text-align: left; overflow: hidden;
  border: 2px solid var(--ink); border-radius: var(--radius); box-shadow: var(--sh);
  padding: 12px 14px 12px; color: var(--text);
}
.kd-door::after { content: ''; position: absolute; inset: 0; background-image: var(--grain); background-size: 150px 150px; opacity: .08; pointer-events: none; }
.kd-door.cook { background: linear-gradient(150deg, #40261f 0%, #2a1a17 70%); }
.kd-door.grow { background: linear-gradient(150deg, #24331f 0%, #182417 70%); }
.kd-art { position: absolute; right: 10px; bottom: -6px; opacity: .3; pointer-events: none; }
.kd-eye { display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-1);
  letter-spacing: .1em; text-transform: uppercase; color: var(--text-3); }
.kd-door b { display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-6);
  line-height: 1; color: var(--text); letter-spacing: .03em; margin-top: 1px; }
.kd-door.grow b { color: var(--mint, #a2e0a6); }
.kd-door p { position: relative; z-index: 1; font-size: var(--fs-2); color: var(--text-3);
  margin-top: 5px; max-width: 66%; line-height: 1.35; }
.kd-pills { position: relative; z-index: 1; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }
.kd-pill {
  display: inline-flex; align-items: center; gap: 5px;
  background: rgba(42, 45, 40, .7); color: var(--text);
  border: 2px solid var(--ink); border-radius: 999px; box-shadow: var(--sh-sm);
  padding: 3px 9px; font-size: var(--fs-1); font-weight: 800; letter-spacing: .02em;
}
.kd-pill.go { background: var(--accent); color: var(--ink); }
.kd-pill.wait { background: var(--surface-2); color: var(--text-3); }
/* the chevron is the app's forward chevron, turned around: this is a way BACK */
.kd-back { margin-bottom: 10px; }
.kd-back .ico { transform: rotate(180deg); }

/* ---- Today hero, option 1d chrome (design_handoff_today_home) ---------------
   Four chips competing over the art became one wallet pill. The icon-only Trends
   dot that used to lead this row is gone (2026-08-26): it is a labelled door in
   .hero-actions now. */
.wallet-pill {
  /* GAP 20, NOT 12. The four chips' 44px hit areas meet in the middle of the
     gap, and at 12 they overlapped: measured, #dustBtn and #vigorBtn answered
     over 38px of width because their neighbours took the rest. 20 puts every
     pair of centres at least 46px apart, which is the only reason the numbers
     in tests/a11y-audit.mjs come out at 44 and not 38. */
  display: inline-flex; align-items: center; gap: 20px;
  padding: 6px 12px; border-radius: 999px;
  background: rgba(13, 12, 18, .42);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  font-size: var(--fs-1); font-weight: 700; color: var(--text); white-space: nowrap;
}
.wallet-pill .wp {
  display: inline-flex; align-items: center; gap: 4px;
  background: none; border: 0; padding: 0; color: inherit;
  font-size: inherit; font-weight: inherit;
}
.wallet-pill .wp.dust { color: var(--violet); }
.wallet-pill .wp.gold { color: var(--gold); }
.wallet-pill .wp b { font-weight: 700; }

/* the XP row: 20 pips instead of a 13px bar, ceil(pct/5) lit */
.hero-xprow { display: flex; align-items: center; gap: 3px; width: 62%; }
.hero-xpn {
  font-size: var(--fs-tiny); font-weight: 700; color: var(--text-2);
  margin-right: 6px; white-space: nowrap; font-variant-numeric: tabular-nums;
}
.hero-xprow i { display: block; height: 6px; border-radius: 2px; flex: 1; background: rgba(242, 233, 215, .16); }
.hero-xprow i.on { background: var(--gold); }
/* the level plate keeps its sticker recipe, one size down for the caption */
.hero-meta .hero-lv { font-size: var(--fs-5); padding: 3px 9px; border-radius: 9px; box-shadow: 2px 3px 0 rgba(0, 0, 0, .5); }
.hero-meta .hero-title { font-size: var(--fs-body); letter-spacing: .8px; -webkit-text-stroke: 0; }
/* the bubble replays when you poke the pet */
.hero-bubble.again { animation: bubblePop .45s cubic-bezier(0.34, 1.6, 0.64, 1) both; }
@media (prefers-reduced-motion: reduce) {
  .hero-char, .hero-companion, .hero-companion.pop { animation: none; }
}

/* the one-player rename apology (see openRenameNotice in js/app.js) */
.sheet-rename .rn-gift {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  background: var(--surface-2); border: 2px solid #17151d; border-radius: 13px;
  padding: 10px 13px; box-shadow: var(--sh-sm);
}
.sheet-rename .rn-gift span { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.sheet-rename .rn-gift small { flex: 1 1 100%; color: var(--text-3); font-size: var(--fs-1); }

/* ---------- The Stable coverflow (v317) --------------------------------------
   Spec: market-quality-mockups/stable-coverflow.html. A flat ring of pets with
   one in front, replacing the species-grouped list you had to scroll past.
   The art inside a card comes from petPortraitHtml(..., {mass:true}), so it is
   content-cropped by PET_CROP and mass-normalised: a flat lizard and the round
   cloud read as the same size animal in the same square tile. */
.cf { --card: 176px; width: 100%; }
/* THE FRAME HAS TO BE TALL ENOUGH TO HOLD THE CARD IT FRAMES.
   Tom, 2026-08-19: "fix that the UI comes up too much over top the album cover
   style frame for the pet."
   Measured before the fix, at 440x956 AND 393x852, identical both times: the
   frame is 176px tall, box-sizing is border-box (app.css line 54), and the
   padding is 22 + 14 = 36, so the content box is 140px. The track inside is
   `height: var(--card)` = 176px and starts 22px down, so it runs to 198px in a
   box that clips at 176. The bottom 22px of the focused card was cut off: no
   bottom border, no bottom corners, and the "OUT WITH YOU" ribbon (.cf-eq,
   bottom: 0) sliced in half. The caption's top edge measured MINUS 22px from
   the card's real bottom, which is precisely "the UI comes up over the frame".
   The other reading was measured too and cleared: the rarity chip and the LV
   pill cover 7.03% of the card between them and overlap the drawn animal by
   397px of 15,167, i.e. 2.6% of the sprite box, in the two corners where the
   art is transparent anyway. The chrome was never the problem.
   --cf-pad carries the padding into the height so BOTH states stay exact
   rather than the panelled one inheriting 36px of slack; height is what
   transitions, so an unregistered property inside the calc is fine here. */
.cf-frame {
  position: relative; overflow: hidden; padding: 22px 0 14px; --cf-pad: 36px;
  height: calc(var(--card) * var(--cf-scale) + var(--cf-pad));
  /* the ring is a 3D subtree; contain it so scrolling the sheet past it does not
     force the whole thing to re-rasterise on every scroll frame */
  contain: layout paint;
  cursor: grab; touch-action: pan-y; outline: none;
  /* a shorter viewer distance = a wider lens = a stronger rake. At 3x the
     neighbours read as merely offset rather than turned. */
  perspective: calc(var(--card) * 2.1);
}
.cf-frame:active { cursor: grabbing; }
.cf-track {
  position: relative; height: var(--card); transform-style: preserve-3d; user-select: none;
  transform: scale(var(--cf-scale)); transform-origin: 50% 0;
  transition: transform .52s cubic-bezier(.76, 0, .24, 1);
}
/* MOTION BLUR. Tom, 2026-08-08: "there should be a little bit of motion blur
   to make it feel real and classy." Driven from JS, not a keyframe, because the
   amount has to follow ACTUAL velocity: a fixed blur on a slow drag looks like a
   rendering fault, and none on a fast flick looks cheap. Set on the CARD rather
   than the track: a filter on the track would flatten `transform-style:
   preserve-3d` and collapse the whole rake. Cleared to '' when still, so a parked
   ring costs nothing. */
.cf-card {
  position: absolute; left: 50%; top: 0; width: var(--card); aspect-ratio: 1;
  border: 2.5px solid #17151d; border-radius: 18px; overflow: hidden;
  background: var(--surface-2);
  will-change: transform; box-shadow: 0 10px 18px rgba(0, 0, 0, .45);
}
.cf-card.active { border-color: var(--accent); }
.cf-card.picked { border-color: var(--violet); }
.cf-art { position: absolute; inset: 0; display: grid; place-items: center; }
.cf-chip {
  position: absolute; left: 8px; top: 8px; z-index: 2;
  font-size: var(--fs-tiny); font-weight: 800; letter-spacing: .08em; text-transform: uppercase;
  background: var(--ink); color: var(--text); border: 2px solid rgba(0, 0, 0, .35);
  border-radius: 999px; padding: 2px 8px; display: inline-flex; align-items: center; gap: 3px;
}
.cf-lv {
  position: absolute; right: 8px; top: 8px; z-index: 2;
  font-family: var(--display); font-size: var(--fs-3); letter-spacing: .04em; color: var(--ink);
  background: var(--gold); border: 2px solid #17151d; border-radius: 8px; padding: 0 7px 1px;
}
.cf-eq {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; text-align: center;
  font-size: var(--fs-tiny); font-weight: 800; letter-spacing: .1em; text-transform: uppercase;
  background: var(--accent); color: var(--ink); border-top: 2px solid #17151d; padding: 3px 0 2px;
}
.cf-eq.sel { background: var(--violet); color: var(--ink); }
/* ONE CARD PER SPECIES (2026-09-05): how many copies the card stands for.
   Bottom-right, above the .cf-eq strip, because .cf-chip owns top-left and
   .cf-lv owns top-right; same ink plate as .cf-chip so it reads over Cam's art. */
.cf-n {
  position: absolute; right: 8px; bottom: 26px; z-index: 2;
  font-family: var(--display); font-size: var(--fs-2); letter-spacing: .04em; color: var(--text);
  background: var(--ink); border: 2px solid rgba(0, 0, 0, .35); border-radius: 999px; padding: 1px 7px;
}
/* Copy tiles reuse the wardrobe row and item rules. */
.cf-kin:empty { display: none; }
.cf-cap { padding: 4px 4px 0; text-align: center; }
.cf-cap b {
  display: block; font-family: var(--display); font-weight: 400; font-size: var(--fs-6);
  letter-spacing: .03em; color: var(--text); line-height: 1.05;
}
.cf-cap .role {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 5px;
  font-size: var(--fs-1); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-2);
}
.cf-cap .dot { width: 9px; height: 9px; border-radius: 50%; border: 2px solid #17151d; background: var(--text-3); }
/* Tom, 2026-08-08: "the highlighted one looks kinda weird and cut off like a
   rectangle oval hybrid instead of a dot."
   It was being CLIPPED, not mis-shaped. The collapse animation needs
   `overflow: hidden` on this row, and the row is exactly one dot tall, so the
   active dot's scale(1.25) grew straight into the clip on both axes and came out
   a squared-off lozenge. Padding gives the scaled dot room to exist. `flex: none`
   stops a dot being squeezed narrower than it is tall, which would have produced
   the same shape by a different route once a crew gets large. */
.cf-dots {
  display: flex; justify-content: center; align-items: center; gap: 7px;
  margin-top: 14px; flex-wrap: wrap; padding: 4px 0;
}
.cf-dots i {
  flex: none; width: 8px; height: 8px; border-radius: 50%;
  background: var(--text-2); opacity: .3;
  transition: opacity .2s, transform .2s; cursor: pointer;
}
.cf-dots i.on { opacity: 1; transform: scale(1.25); background: var(--accent); }
.cf-acts { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; padding: 16px 0 0; }
.cf-acts .btn { width: 100%; }
/* Stable redesign: a featured album portrait, quiet controls, separate permanent changes.
   Scoped to this sheet so the shared pet wardrobe and other rooms keep their skin. */
.stable-head { border-bottom: 1px solid var(--line); padding-bottom: 12px; }
.pet-a11y .sheet-head.stable-head h2 { font: 400 var(--fs-5)/1.1 var(--display); letter-spacing: .03em; }
#stableBody button { font-family: inherit; }
#stableBody { --fs-stable-dust: 1.1875rem; padding-top: 12px; padding-bottom: calc(var(--pad) + var(--sab)); }
#stableBody .cf { --card: min(280px, calc((100vw - 2 * var(--pad)) * .68)); display: flex; flex-direction: column; }
#stableBody .cf-frame {
  /* Leave both edges open to the shipped ring, including its compact state. */
  order: 2; width: 100%; max-width: calc(var(--card) * 1.55 * var(--cf-scale));
  margin: 0 auto;
}
#stableBody .cf-card { border: 2px solid #050408; border-radius: 16px; box-shadow: 0 3px 0 rgba(0,0,0,.32); }
@media (max-width: 343px) {
  #stableBody .cf-art { transform: scale(.88); }
}
#stableBody .cf-card.active { border-color: #050408; }
#stableBody .cf-card.picked { border-color: var(--violet); }
#stableBody .cf-eq { background: var(--surface-3); color: var(--text); border-color: var(--ink); }
#stableBody .cf-eq.sel { background: var(--violet); color: var(--bg); }
#stableBody .cf-lv { font: 400 var(--fs-4)/1 var(--display); letter-spacing: .03em; background: var(--surface); color: var(--text); border-color: var(--ink); }
#stableBody .cf-n { font-family: inherit; font-weight: 700; letter-spacing: 0; background: var(--surface); color: var(--text); border-color: var(--ink); }
#stableBody .cf-cap { display: contents; }
#stableBody .cf-cap > b { order: 0; font: 400 var(--fs-7)/1.05 var(--display); text-align: center; overflow-wrap: anywhere; transition: none; }
#stableBody .cf-cap .alias-tag { font: 500 var(--fs-3)/1.4 system-ui, sans-serif; white-space: normal; }
#stableBody .cf-cap .role { order: 1; justify-content: center; font: 700 var(--fs-1)/1.3 system-ui, sans-serif; letter-spacing: .06em; text-transform: uppercase; color: var(--text-3); }
#stableBody .cf-cap .role .dot { display: none; }
/* 2026-09-25: Tom chose cues for 2+ owned pets. */
#stableBody .cf-pet-hint[hidden], #stableBody .cf-dots[hidden] { display: none; }
#stableBody .pet-wear { order: 3; }
#stableBody .cf-kin { order: 4; margin: 4px 0 0; }
#stableBody .cf-pet-hint { font: 500 var(--fs-1)/1.4 system-ui, sans-serif; color: var(--text-2); }
#stableBody .cf-pet-hint { order: 2; text-align: center; margin: 10px 0 0; }
#stableBody .cf-colour-label { order: 4; }
#stableBody .cf-dots { order: 2; gap: 0; margin: 0; padding: 0; max-height: none; opacity: 1; overflow: visible; }
#stableBody .cf-dots button { display: grid; place-items: center; width: 44px; min-height: 44px; padding: 0; border: 0; background: none; }
#stableBody .cf-dots i { pointer-events: none; background: var(--text-2); opacity: .5; transform: none; }
#stableBody .cf-dots .on i { background: var(--text); opacity: 1; outline: 1px solid var(--text); outline-offset: 3px; }
/* 2026-09-24: approved mock, four quiet tiles below the primary action. */
#stableBody .cf-acts { order: 8; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; padding-top: 24px; overflow-x: auto; align-items: start; }
/* Tom, 2026-09-18: "the stable pet talent ui looks fucked the buttons are way
   too tall and one is just random text". The "random text" is the "... Needed
   for Toxic + Rose. Melting or breeding ..." ingredient note, which js/app.js
   appends INTO this action grid (repaintLabIngredients). As a plain grid cell it
   took one ~80px column, wrapped to nine lines, and (with the grid's default
   align-items: stretch) pulled every action button up to that height. It gets
   its own full-width row now, and align-items: start above keeps the buttons at
   their natural height regardless. */
#stableBody .cf-acts .lab-ingredient { grid-column: 1 / -1; margin: 2px 2px 0; text-align: left; }
#stableBody .cf-acts:has(.stable-primary) { grid-template-columns: repeat(4, minmax(0, 1fr)); }
#stableBody .cf-acts > .hero-act { min-height: 64px; border: 2px solid #050408; border-radius: 16px; box-shadow: 0 3px 0 rgba(0,0,0,.32); background: var(--surface); }
#stableBody .cf-acts .hero-act span { font: 700 var(--fs-2)/1.2 system-ui, sans-serif; color: var(--text); white-space: normal; overflow-wrap: anywhere; }
/* 2026-09-25: measured at 320, a tile's content box is 55px and bold "Nickname" is 64.3 at 13px
   and 55.5 at 11px, so it broke mid-word ("Nicknam/e"). 10px is 50.4. At 13px the label needs a
   358px viewport; 359 keeps a pixel of margin. */
/* 2026-09-26 int/v732: a11y-sweep raised --fs-tiny to 11px (Tom's call), where "Nickname" is 55.5
   against the 55px box and breaks mid-word again. Keep the 11px label and take the room from the
   gaps instead: 9px gaps give each of the five tiles 2.4px more (cf-acts-fit-audit LABEL 320). */
@media (max-width: 359px) { #stableBody .cf-acts { gap: 9px; } #stableBody .cf-acts .hero-act span { font-size: var(--fs-tiny); } }
#stableBody .cf-acts .stable-primary { order: -1; grid-column: 1 / -1; width: auto; min-height: 52px; padding: 14px; font: 400 var(--fs-5)/1 var(--display); letter-spacing: .04em; border: 2px solid #050408; border-radius: 16px; box-shadow: 0 3px 0 rgba(0,0,0,.32); background: var(--accent); color: var(--accent-ink); }
#stableBody [data-pettree].stable-talents-unspent { position: relative; }
#stableBody [data-pettree].stable-talents-unspent::after {
  content: ''; position: absolute; inset: -2px; border-radius: inherit;
  pointer-events: none;
  box-shadow: 0 0 14px 3px var(--accent);
  animation: stable-talents-pulse 2.4s ease-in-out infinite;
}
@keyframes stable-talents-pulse { 0%, 100% { opacity: .2; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  #stableBody [data-pettree].stable-talents-unspent::after { animation: none; opacity: 1; }
}

#stableBody .cf-acts .stable-bring { grid-column: 1 / -1; }
#stableBody .cf-acts .stable-bring[hidden] { display: none; }
#stableBody .stable-danger { grid-column: 1 / -1; flex-basis: 100%; margin-top: 12px; padding-top: 14px; border-top: 2px dashed var(--line-strong); display: flex; flex-wrap: wrap; gap: 4px 16px; }
#stableBody .stable-danger .danger-ish { min-height: 44px; }
#stableBody .stable-danger .on { color: var(--violet); text-decoration: underline; text-underline-offset: 4px; }
#stableBody .stable-danger small { flex-basis: 100%; font-size: var(--fs-1); line-height: 1.5; color: var(--text-2); }
#stableBody .stable-wallet { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; margin: 0; padding: 0; background: none; border: 0; border-radius: 0; color: var(--text); font: 600 var(--fs-3)/1.2 system-ui, sans-serif; }
#stableBody .stable-wallet b { display: inline-flex; align-items: center; gap: 6px; font: 400 var(--fs-stable-dust)/1 var(--display); letter-spacing: .03em; font-variant-numeric: tabular-nums; }
/* 2026-09-24: approved standard cards and contained 48px room art. */
#stableBody .stable-rooms { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 0 0 24px; }
#stableBody .stable-room { position: relative; display: grid; grid-template-rows: auto auto auto; justify-items: center; align-content: center; gap: 2px; min-width: 0; min-height: 44px; margin: 0; padding: 12px 6px; border: 2px solid #050408; border-radius: 16px; background: var(--surface); color: var(--text); box-shadow: 0 3px 0 rgba(0,0,0,.32); }
#stableBody .stable-room:active { transform: translate(2px, 3px); box-shadow: 1px 1px 0 #05040a; }
#stableBody .stable-room-picture { display: grid; place-items: center; width: 100%; height: 48px; background: none; border: 0; }
#stableBody .stable-room-picture > * { display: block; max-height: 100%; width: auto; object-fit: contain; margin: 0 auto; }
#stableBody .stable-room-picture img { image-rendering: pixelated; }
#stableBody .stable-room b { font: 400 var(--fs-body)/1.15 var(--display); letter-spacing: .02em; }
#stableBody .stable-room small { color: var(--text-2); font: 400 var(--fs-2)/1.35 system-ui, sans-serif; overflow-wrap: anywhere; }
#stableBody .stable-room .new-dot { top: 7px; right: 7px; width: 13px; height: 13px; background: var(--coral); box-shadow: 0 0 0 2px var(--surface-2); }
#chContent .stable-help { margin-top: 8px; border-block: 1px solid var(--line); font-size: var(--fs-2); line-height: 1.5; }
#stableBody .stable-help .link, #chContent .stable-help summary { min-height: 44px; padding: 12px 0; }
#stableBody .stable-footer { margin: 24px 0 0; border: 2px solid #050408; border-radius: 16px; box-shadow: 0 3px 0 rgba(0,0,0,.32); background: var(--surface); padding: 0 16px; }
#stableBody .stable-help { margin: 0; font-size: var(--fs-2); line-height: 1.5; }
#stableBody .stable-help summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; padding: 0; border-top: 1px solid var(--line); list-style: none; font: 600 var(--fs-3)/1.2 system-ui, sans-serif; color: var(--text); cursor: pointer; }
#stableBody .stable-help summary::-webkit-details-marker { display: none; }
#stableBody .stable-help summary::after { content: ''; box-sizing: border-box; width: 8px; height: 8px; border-right: 2px solid var(--text-3); border-top: 2px solid var(--text-3); transform: rotate(45deg); margin-right: 4px; }
#stableBody .stable-help[open] summary::after { transform: rotate(135deg); }
#stableBody .cf-acts .btn:disabled, #stableBody .cf-acts .hero-act:disabled, #stableBody .cf-acts .hero-act:disabled span { opacity: 1; color: var(--text-3); } /* the span: its label colour rule otherwise hid the disabled state */
#stableBody .stable-help p, #chContent .stable-help p { margin: 0 0 10px; }
#stableBody button:focus-visible, #stableBody [tabindex]:focus-visible, #stableBody summary:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
  #stableBody *, #stableBody *::before, #stableBody *::after { animation: none !important; transition: none !important; }
}
/* The nickname button spans the two-column grid: a fifth button would otherwise
   sit alone in the bottom row beside a gap. */
.cf-acts .btn.cf-wide { grid-column: 1 / -1; }
.cf-acts .btn.on { background: var(--violet); color: var(--ink); }
/* A coverflow shows ONE thing, which is right for talents (they belong to the pet
   in front) and wrong for breeding (it needs two). So neither becomes a separate
   screen: the ring SHRINKS and stays on top, and the panel takes the space the
   stat table had, so you never lose track of which pet you are editing. */
/* THE IN-BETWEEN MOMENT. Tom, 2026-08-08: "this transition should be smooth
   scaling with elegance not just a jarring 2 keyframe scale. we need to start
   thinking about the flow of things and the in between moments."
   --card drives every card's width, so it has to be REGISTERED to be animatable:
   an unregistered custom property is a string to the engine and flips between
   values with nothing in between, which is exactly what he saw. @property gives
   it a type, so it interpolates. The ring's spacing is computed from the measured
   card width, so js re-paints on rAF for the length of this transition, or the
   cards would keep their old pitch while the cards themselves shrank. */
/* SCALE, DO NOT RESIZE. This was fixed in v332 and the catfish merge silently
   reverted it, which is why the lag came back: the branch was cut before v332 and
   git took its older version of this block wholesale.
   Animating --card animates every card's WIDTH: a LAYOUT property on six
   absolutely-positioned elements, plus a 520ms rAF loop repainting the ring
   because its spacing is derived from the measured width. Layout sixty times a
   second. A transform on the track does the same job for free: composited, and it
   scales the spacing along with the cards because the pitch lives inside the
   scaled space, so no repaint loop is needed at all. */
.cf { --cf-scale: 1; }
.cf-frame { transition: height .52s cubic-bezier(.76, 0, .24, 1), padding .52s cubic-bezier(.76, 0, .24, 1); }
.cf-cap b { transition: font-size .52s cubic-bezier(.76, 0, .24, 1); }
/* Height and opacity, not display:none. `display` cannot be transitioned, so the
   stat table and dots used to vanish on the first frame and take the eye with
   them; now they collapse with the ring rather than disappearing ahead of it. */
.cf-dots {
  overflow: hidden;
  transition: opacity .3s ease-in-out, max-height .52s cubic-bezier(.76, 0, .24, 1),
    margin-top .52s cubic-bezier(.76, 0, .24, 1), padding .52s cubic-bezier(.76, 0, .24, 1);
  max-height: 260px; opacity: 1;
}
.cf.panelled { --cf-scale: .62; }
.cf.panelled .cf-frame { padding: 10px 0 6px; --cf-pad: 16px; }
.cf.panelled .cf-dots { max-height: 0; opacity: 0; margin-top: 0; padding-top: 0; padding-bottom: 0; }
.cf.panelled .cf-cap b { font-size: var(--fs-5); }
/* the panel arrives rather than appearing */
.pet-tree-inline, .breed-bar.sticky { animation: cfPanelIn .46s cubic-bezier(.76, 0, .24, 1) both; }
@keyframes cfPanelIn {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .cf, .cf-frame, .cf-cap b, .cf-dots { transition: none; }
  .pet-tree-inline, .breed-bar.sticky { animation: none; }
}

/* ONE PET MOVES: the one you are looking at. Tom's call, 2026-08-08. It stops two
   of the same species running the identical loop in lockstep beside each other,
   and it is one animated sprite stack on screen instead of six, which is most of
   the frame cost the ring was paying during a spin.
   And while the ring is actually in motion, even that one holds still: an idle bob
   is invisible on a card flying past. */
/* !important on BOTH sides deliberately: the pet sprites declare their loops with
   the `animation` SHORTHAND, which resets animation-play-state to `running`, and
   those selectors are more specific than `.cf-card *`. Without it the pause
   silently did nothing on three of six cards (measured). Specificity still orders
   these three correctly: moving > focus > default. */
.cf-card * { animation-play-state: paused !important; }
.cf-card.focus * { animation-play-state: running !important; }
.cf.moving .cf-card * { animation-play-state: paused !important; }
/* Crew search: filters the fan, so it sits with the fan rather than reading as
   part of the leaderboard below it. */
.cfan-search { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; position: relative; }
.cfan-search input {
  flex: 1; min-width: 0; font: inherit; font-size: var(--fs-3); color: var(--text);
  background: var(--surface-2); border: 2px solid #17151d; border-radius: 999px;
  padding: 9px 14px; box-shadow: var(--sh-sm); -webkit-appearance: none; appearance: none;
}
.cfan-search input::placeholder { color: var(--text-3); }
/* When the crew is too small for search to be worth typing, the input steps out
   and the Online chip keeps the row. */
.cfan-search.no-search input, .cfan-search.no-search .cfan-clear { display: none; }
.cfan-search.no-search { justify-content: flex-start; }
.cfan-online {
  flex: none; display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--display); font-weight: 400; font-size: var(--fs-2); letter-spacing: .04em;
  color: var(--text-2); background: var(--surface-2); border: 2px solid #17151d;
  border-radius: 999px; padding: 8px 13px; box-shadow: var(--sh-sm); cursor: pointer;
  transition: background .16s ease, color .16s ease;
}
.cfan-online .live-dot { opacity: .45; transition: opacity .16s ease; }
.cfan-online.on { background: var(--accent); color: var(--ink); }
.cfan-online.on .live-dot { opacity: 1; }
.cfan-search input:focus { outline: none; border-color: var(--accent); }
.cfan-clear {
  position: absolute; right: 8px; width: 26px; height: 26px; display: grid; place-items: center;
  border: 0; border-radius: 50%; background: rgba(242, 233, 215, .12); color: var(--text-2); cursor: pointer;
}
.cfan-nohit { text-align: center; margin: 10px 2px 4px; }

/* THE FAN LEARNS FROM THE STABLE (2026-08-08).
   Seven layered Bonehead stacks is more compositing than six pet cards, so the
   same two rules apply, for the same reasons:
   1. Cards rotated off the fan are skipped entirely rather than drawn at opacity
      0. Invisible is not the same as free.
   2. Only the FEATURED crew member's pet animates. Off-centre idle loops are
      motion nobody is looking at, and two of the same species side by side would
      run in lockstep exactly as the Stable's lizards did.
   !important on both sides deliberately: the pet sprites declare their loops with
   the `animation` shorthand, which resets animation-play-state to running, and
   those selectors outrank a bare descendant selector. That silently defeated the
   pause in the Stable until it was measured. */
.cfan-card.off { visibility: hidden; }
.cfan-card * { animation-play-state: paused !important; }
.cfan-card.feat * { animation-play-state: running !important; }

/* ONE ARRIVAL for the Stable ring and the Crew fan (see revealWhenReady).
   They are held until their art has decoded, then fade in over one short beat,
   so neither screen assembles itself in front of you. The `.ready` class is
   applied by JS and ALWAYS applied, cap included, so a broken image degrades to
   ugly rather than to a blank tab. */
/* (the Stable ring and Crew fan are covered by the router/sheet rule above) */

/* A shiny of an animated species reuses the animation and recolours it (see
   SHINY_TINT in js/app.js, measured off the art). display:contents so the tint
   wrapper adds no box of its own and the existing sizing is untouched. */
.pa-shiny { display: contents; }
.pet-shiny-wrap > .pa-shiny { display: block; }
.tz-row .bh-stage { width: 74px; height: 74px; }
.tz-fit { display: inline-flex; align-items: flex-end; justify-content: center; }
.tz-tally { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin: 10px 0 4px; }
.tz-tally span {
  font-size: var(--fs-1); color: var(--text-2); background: var(--surface-2);
  border: 2px solid #17151d; border-radius: 999px; padding: 3px 10px;
}
.tz-tally b { color: var(--gold); font-family: var(--display); font-size: var(--fs-3); margin-right: 3px; }
.tz-more { color: var(--accent); font-weight: 700; }

.sheet-teaser .tz-pop { text-align: center; }
.tz-kicker {
  display: inline-block; font-size: var(--fs-0); font-weight: 800; letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink); background: var(--gold);
  border: 2px solid #17151d; border-radius: 999px; padding: 3px 11px; box-shadow: var(--sh-sm);
}
.tz-h { font-family: var(--display); font-weight: 400; font-size: var(--fs-display); letter-spacing: .04em; margin: 12px 0 2px; }
.tz-sub { color: var(--text-2); margin: 0 0 14px; }
.tz-grid { display: none; /* replaced by .tz-wall */
  grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px;
  background: var(--surface-2); border: 2px solid #17151d; border-radius: 14px;
  padding: 10px; box-shadow: var(--sh-sm);
}
.tz-grid .bh-stage { width: 100%; aspect-ratio: 1; height: auto; }
.tz-tally.big { margin: 14px 0 10px; }
.tz-body { color: var(--text-2); font-size: var(--fs-2); line-height: 1.5; margin: 0 0 8px; }
.tz-strong { color: var(--accent); font-weight: 700; }

/* the head crop: a window onto the skull, so a head-only cosmetic fills the tile */
.tz-head {
  position: relative; overflow: hidden; display: inline-block; flex: none;
  border-radius: 12px; background: var(--surface-2); border: 2px solid #17151d;
}
.tz-head-in { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
/* display:block is load-bearing. These stages are <span>s, and a span is
   display:inline, where width and height are simply ignored: measured, the stage
   came out 0x5px and every tile rendered as an empty box while its images
   reported as decoded. Same trap as the Stable's thumbnails. */
.tz-head-in .bh-stage { display: block; width: 640px; height: 640px; }
.tz-head-in .bh-anim, .tz-head-in .bh-anim img { display: block; width: 640px; height: 640px; }
.tz-head-in .bh-anim { position: relative; }
.tz-head-in .bh-anim img { position: absolute; left: 0; top: 0; }
.tz-wall {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
  gap: 6px; justify-items: center;
  background: var(--surface-2); border: 2px solid #17151d; border-radius: 14px;
  padding: 8px; box-shadow: var(--sh-sm); max-height: 46vh; overflow-y: auto;
}
.tz-h em { font-style: normal; color: var(--gold); }

/* ---------- the drop reel: grid -> solo -> quad, looping -------------------- */
.tz-reel { position: relative; }
.tz-reel .tz-wall, .tz-reel .tz-spot { transition: opacity .9s ease, transform .9s cubic-bezier(.76, 0, .24, 1); }
.tz-spot {
  position: absolute; inset: 0; display: flex; flex-wrap: wrap; gap: 10px;
  align-items: center; justify-content: center; align-content: center;
  opacity: 0; pointer-events: none;
}
.tz-spot .tz-head { border-width: 3px; box-shadow: var(--sh); }
.tz-spot-name {
  position: absolute; left: 0; right: 0; bottom: 4px; text-align: center;
  font-family: var(--display); font-size: var(--fs-4); letter-spacing: .04em; color: var(--gold);
}
/* grid beat: the wall is the hero */
.tz-reel.tz-s-grid .tz-wall { opacity: 1; transform: scale(1); }
.tz-reel.tz-s-grid .tz-spot { opacity: 0; }
/* solo + quad beats: the wall recedes and the spotlight takes over */
.tz-reel.tz-s-solo .tz-wall, .tz-reel.tz-s-quad .tz-wall { opacity: .12; transform: scale(.96); }
.tz-reel.tz-s-solo .tz-spot, .tz-reel.tz-s-quad .tz-spot { opacity: 1; }
.tz-kicker.live { background: var(--accent); }
@media (prefers-reduced-motion: reduce) {
  .tz-reel .tz-wall, .tz-reel .tz-spot { transition: none; }
}

/* ---------- the leaderboard: rank first, Bonehead proud ---------------------
   The numeral is deliberately LARGER than the head it sits behind, and the head
   overlaps it, so the eye reads the rank and then lands on the character. The old
   row drew a full standing skeleton into 42px, which is why the art read as a
   smudge (Tom: "the art is soooo small in the list"). */
.lb-head {
  position: relative; overflow: hidden; display: inline-block; flex: none;
  border-radius: 12px; background: var(--surface-2); border: 2px solid #17151d;
  margin-left: -22px;                 /* the overlap: art sits ON the numeral */
  box-shadow: var(--sh-sm);
}
.lb-head .tz-head-in { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.lb-head .bh-stage { display: block; width: 640px; height: 640px; }
.lb-head .bh-anim, .lb-head .bh-anim img { display: block; width: 640px; height: 640px; }
.lb-head .bh-anim { position: relative; }
.lb-head .bh-anim img { position: absolute; left: 0; top: 0; }
/* THE LINE AND THE LEAN, 2026-09-24. Tom saw a hairline across the top of every
   board head: the 640 stage's own 1px .bh-stage border sits ~5px inside this
   window at this crop, and in WebKit the team tint also bleeds one row at its top
   edge. border-color, not border:0, which shifts the art by one canvas px.
   And every head sat one border-width right and down: .tz-head-in is placed at
   the PADDING box's origin while lbHeadInner computes its translate from half the
   full 52px box (px is always 52, js/app.js openLeaderboard), so centre the
   52px frame in the box instead. Pinned by tests/head-line-centre-audit.mjs. */
.lb-head .bh-stage { border-color: transparent; }
.lb-head .fb-tint { clip-path: inset(4px 0 0 0); }
.lb-head .tz-head-in { left: calc(50% - 26px); top: calc(50% - 26px); }
.lb-num {
  font-family: var(--display); font-weight: 400; line-height: .9;
  font-size: var(--fs-7); color: var(--text-3); min-width: 42px; text-align: left;
  letter-spacing: .02em; flex: none;
}
/* every row is the same size: rank is communicated by COLOUR and a medal, not by
   an unexplained size jump (Tom: "it's not clear enough why they're bigger") */
.lb-row.t1 .lb-num { color: var(--gold); }
.lb-row.t2 .lb-num { color: #cfd6dd; }
.lb-row.t3 .lb-num { color: #d09a63; }
.lb-row.top .lb-head { border-width: 3px; border-color: var(--gold); }
.lb-medal {
  margin-left: 7px; font-family: var(--display); font-weight: 400; font-size: var(--fs-1);
  letter-spacing: .06em; padding: 1px 7px; border-radius: 999px; color: var(--ink);
  border: 2px solid #17151d; vertical-align: 2px;
}
.lb-medal.m1 { background: var(--gold); }
.lb-medal.m2 { background: #cfd6dd; }
.lb-medal.m3 { background: #d09a63; }
.lb-row.t2 .lb-head { border-color: #cfd6dd; }
.lb-row.t3 .lb-head { border-color: #d09a63; }
.lb-row.me .lb-head { border-color: var(--accent); }

/* ---------- THE BESTIARY (2026-08-09) ----------
   Tom: "create a cool hype popup and out there today banner for the new bosses
   you can fight on the map make it seem like a fun new evolution to the game."

   Both surfaces draw a REAL boss through the real avatar stack, so the only new
   work here is fitting a 640-square figure into two very different boxes. */

/* The Today row's monster. `.bh-stage img` is `object-fit: cover` everywhere
   else, which is right for a portrait tile and wrong at 32px: it crops the head
   off. Contain plus a nudge up keeps the skull, which is the part that says
   which monster this is. */
/* Both rows now hold a PORTRAIT (headshotHtml), not a shrunken whole body. A
   32px full figure cropped its own legs off and read as a smudge; the skull box
   is the part that says which monster, or which hat, this is. The inner tile
   already carries the crop, so the icon shell only has to get out of its way. */
.gbn-ico .tz-head { position: absolute; inset: 0; width: 100% !important; height: 100% !important;
  border: 0; border-radius: 0; background: none; }

/* The announcement is a WALL of monsters, not a paragraph about monsters. Four
   across so a phone still shows a whole face per cell, and each cell pops in on
   a stagger so the grid assembles itself rather than appearing as a slab. */
.boss-wall {
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px;
  margin: 12px 0 10px;
}
.boss-cell {
  width: 100%; aspect-ratio: 1; height: auto; min-height: 0; margin: 0;
  border-radius: 11px; background: rgba(0, 0, 0, .3);
  border: 1px solid rgba(242, 236, 220, .12);
  animation: bossPop .42s cubic-bezier(.34, 1.4, .64, 1) both; animation-delay: var(--d, 0ms);
}
.boss-cell img { object-fit: contain; object-position: 50% 44%; transform: scale(1.18); }
@keyframes bossPop { from { opacity: 0; transform: scale(.82); } to { opacity: 1; transform: scale(1); } }
@media (prefers-reduced-motion: reduce) { .boss-cell { animation: none; } }
.boss-veil .drop-sub { margin-bottom: 4px; }

/* THE DAY'S MONSTER HAS TO BE VISIBLE. Tom, 2026-08-09: "the bestiary banner
   doesn't have any of the art it's just text". The art was there, correctly
   clipped, at 30px next to a 16px title on a full-width row: technically present
   and effectively invisible. The whole point of the row is the monster, so the
   portrait gets real size, and opening it shows the thing at 132px. */
/* GROW THE BOX, NOT THE HEADSHOT. `.gbn-ico .tz-head` (added with the Bestiary)
   forces any portrait to fill its icon box with !important, and
   `.out-there .gbn-ico` pins that box at 32px. So passing a bigger px to
   headshotHtml() only moved the crop transform while the window stayed 32, and
   the portrait came out zoomed into a jaw. Measured, after two wrong guesses:
   head box 32x32 with an inline width of 52px and an inner transform built for
   52. The box and the px have to agree, so the box moves. */
/* News rows: the garden bed art needs a box to sit in, same as the other thumbs */
.nw-thumb .nw-garden { transform: scale(.30); transform-origin: center; display: block; }
.nw-thumb .tz-head { border: none; border-radius: 11px; }


/* THE DAY'S MONSTER IS THE ROW. Tom, 2026-08-09, with the teaser on screen:
   "you have a drop down banner that says some shit about the bouncer below. it
   should have this type of art in there". So the row borrows the teaser's own
   tile: a whole figure on a dark square, at a size you can actually read. */
.bestiary-banner {
  display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 7px 2px; text-align: left; cursor: pointer;
  border-color: rgba(253, 104, 87, .3);
  background: linear-gradient(160deg, rgba(253, 104, 87, .13), rgba(22, 20, 32, .9) 55%), var(--surface);
}
.bestiary-banner:active { transform: translateY(1px); }
/* display:block is load-bearing: .bh-stage is a <span>, and width/height on an
   inline box are simply ignored. Same trap the News thumbs hit. */
/* matched to .out-there .gbn-ico above. A whole monster needs a little more room
   than a head-and-shoulders icon, so it keeps the same BOX and simply fills more
   of it, rather than being a different size. */
.bst-fig { display: block; width: 48px; height: 48px; flex: 0 0 auto; animation: none; position: relative; }
.bestiary-banner .gbn-txt i { color: var(--coral); }
.bestiary-banner .gbn-txt b { font-size: var(--fs-body); line-height: 1.15; }
.bestiary-banner .gbn-txt em { font-style: normal; font-size: var(--fs-1); color: var(--text-3); margin-top: 3px; }

/* THE LIVE WIRE. Drawn art, not a cosmetic stack, so he gets a plate like The
   Glutton: contained, never mirrored, never stretched. */
.mage-plate { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; object-position: 50% 50%; }
/* HE IS A BOSS AND HAS TO READ AS ONE. The rough-in measured it before the art
   existed (ROADMAP, 2026-08-09): 1.84x the player with headroom, because 1.41x
   does not read as a boss next to the Glutton. I then shipped him at 1.00x, the
   exact size of the player, which is the thing that spec existed to prevent.
   Height is free now the arena is 558 tall; width is not, so the foe column
   widens for him and the player's stage steps back to 0.92, both from that spec.
   He FLOATS: no legs in the drawing, so he is lifted clear of the floor and
   keeps his own shadow well below the robe. */
.arena.boss-mage .fighterG.foe-side { width: 208px; }
#foeStage.mage-foe {
  /* 75 -> 54cqh. NOT a shrink: he is bigger on every phone than he was before
     (152.8 against 148 at 375x667, 178.2 against 172.5 at 430x932), because the
     arena grew 100px underneath him. It is a RE-BASING. cqh is the arena, so
     handing the arena the HUD's old row handed HIM 75% of it, and he grew right
     back up behind the bars the HUD had just stopped covering: measured 47.4px
     of his ink over the band at 375x667 and 33.9px at 430x932, which is a
     smaller replay of the 110.4px that got the HUD evicted in the first place.
     The band is 81.4px, his lift is 8cqh, so the ceiling is (A - 81.4 - lift)/A,
     worst at the short phone: 63%. 54 leaves 13.6px of measured clearance there
     after the ink slop, rather than sitting on the ceiling. */
  width: 100%; height: clamp(148px, 54cqh, 262px); align-self: end;   /* the 262 cap and the 148 floor are unchanged: /* 46 -> 75: same re-basing onto the arena, and it keeps his full 262px on the
     352px arena a 390x844 phone gives him. The 196px floor came down to 148
     because a floor is only a floor if the box can hold it: 196 + his 30px lift
     is 226, which does not fit the 183px arena a 375x667 phone can afford. At
     148 + 30 = 178 he clears it by 5px and renders whole. He is smaller than the
     151.6px of him that used to be visible there, by 3.6px, and that is the
     trade: all of a slightly smaller boss instead of 58% of a bigger one. */
  /* The lift scales with the arena for the same reason his height does. At a
     fixed 30 he cleared the top of a 183px arena by 2.1px of measured ink, and a
     2.1px margin is not headroom, it is a coin flip for any guard watching it.
     Taking it out of the LIFT rather than his height keeps him the same size on
     the phone that can least afford to lose any of him: 16px at a 183px arena,
     the full 30 back by the time the arena is 375px. */
  margin-bottom: clamp(16px, 8cqh, 30px);    /* the lift: he hangs, he does not stand */
  filter: drop-shadow(0 26px 16px rgba(0, 0, 0, 0.55));
}
/* THE TWO FIGURES WERE TOUCHING. Tom, 2026-08-13: "the live wire looks too
   close to the bonehead". Measured off the render rather than eyeballed, and
   he was right in the strongest sense: the ink was CONTIGUOUS, no clear column
   between them anywhere.
     player ink  css x88..204   (116 wide)
     boss ink    css x124..330  (206 wide)
     arena       css x16..377   (361 wide)
   116 + 206 = 322 of ink in a 361 arena, so there are only 39px for two
   gutters AND a gap. The figure contract's 6% safe margin alone wants 44, so
   the arithmetic does not close at these sizes: something had to give. Tom
   chose to split it, so the player steps back a little further (.92 -> .87,
   about 6%) AND both groups move outward, instead of taking all of it out of
   either one. The boss keeps every pixel he has: he is the subject.
   Target, all in css px: player ink 28..138, gap 22, boss ink 159..365,
   margins 12 each side. Verified against the render, not assumed. */
.arena.boss-mage .fighterG:not(.foe-side) { transform: translateX(-53px); }
.arena.boss-mage .fighterG.foe-side { transform: translateX(35px); }
.arena.boss-mage .fighterG:not(.foe-side) > .fstage { transform: scale(.87); transform-origin: bottom center; }
/* AND THE PET, which was bridging the gap the two moves above had just opened.
   Measured: after separating the fighters the pet still sat at css x82..158
   with the boss's ink starting at 159, so the ink read as contiguous exactly as
   before and the fix looked like it had done nothing.
   Three figures need 110 + 76 + 206 = 392px of ink in a 361px arena, so one
   overlap is unavoidable. It belongs between the player and HIS OWN pet, who
   are a pair, not between the pet and the boss they are both facing. So the pet
   tucks left into the player's shoulder (it already sits behind him on
   z-index) and the daylight goes where the drama is. */
/* Tom, 2026-08-13: "the pet needs to be further in front than that the overlap
   is extreme and bring the pets art forward on top of the bonehead."
   Two things, and they pull against each other on width. The tuck comes back
   from -27px to -13px, which halves how much of him it covers and still leaves
   daylight to the boss (pet ink ends ~145, the Live Wire starts at 159). And it
   comes FORWARD: `.fighterG > .fstage` sets z-index 2 with the comment "the pet
   tucks behind", which is the right default beside a Bonehead on a card but the
   wrong one here, where a smaller pet behind a standing figure reads as
   swallowed by it. In front, the same overlap reads as the pet standing at his
   feet, which is what it is. Scoped to this arena only, so the paperdoll and
   card surfaces keep the tuck-behind they were designed with. */
.arena.boss-mage .pet-fighter { transform: translateX(25px); z-index: 4; }
.arena.boss-mage .pet-fighter > .fstage { z-index: 4; }
.bst-fig.bst-drawn { background: #0d0f08; }
.mage-poster { display: block; width: 100%; max-width: 320px; margin: 10px auto 8px; }
/* the NEW pill sits top-right over the card, so the eyebrow keeps out of its lane */
.mage-veil .drop-eyebrow { padding-right: 74px; }
.den-hero .art.art-mage { background: none; border: 0; }
.den-hero .art.art-mage img { width: 100%; height: 100%; object-fit: contain; }

/* ===== two-enemy clarity (2026-08-10) ===================================== */
/* THE ADD IS A CREATURE, NOT A GHOST. Tom: "fighting the live wire that has a
   second minion shows the minion art as super transparent and kind of faded."
   Measured: .petmini.beast was knocking it to saturate(.55) brightness(.82),
   which is a big desaturation next to a full-colour hand-inked boss, and it read
   as a rendering fault rather than as a lesser enemy. It keeps a slight cool cast
   so it still reads as the boss's creature and not a second boss, but it is
   SOLID now. */
.petmini.beast { filter: saturate(.86) brightness(.95) drop-shadow(0 0 6px rgba(150,40,30,.5)); }

/* NARROW SCREENS: TUCK THE CREATURE IN, TOWARDS ITS OWNER. On a 320px phone the
   arena is 288px and his stage alone is 208px, so the default add position put
   the creature 100% on top of the PLAYER (measured: 76px of a 76px figure at
   320, 47px at 390). Overlapping the boss reads as depth; overlapping you reads
   as a broken layout. `right` counts from the right edge of the foe side, so a
   SMALLER value pulls the add back towards him and away from you.
   This does not touch how big he is, which is a separate question.
   18px was too far out: it left the creature hanging 5.9px off the RIGHT edge of
   the arena at 320 and 1.1px at 360, and only 0.7px inside at 375, the width the
   suite calls the smallest supported phone. Swept 18/22/24/26/30 at four widths:
   24 is the threshold with no margin at all, 26 is the first value with real
   clearance (320 +2.1, 360 +6.9, 375 +8.7, 390 +10.5). Overlap with the PLAYER,
   the thing this rule exists to prevent, stays 0px of a 76px figure at every
   width and every value swept, so pulling it in costs nothing it was buying. */
@media (max-width: 400px) {
  .arena.boss-mage .foe-side .pet-fighter.add { right: 26px; }
}

/* NO BESPOKE POSITION. I moved the add out from under his robe and made it worse
   at every real phone width: measured at right:212px it hung 36px off the left
   edge of the arena at 320px and landed 39/52/76px ON THE PLAYER at 375/390/430.
   Tom's complaint was that the creature was WASHED OUT, not that it overlapped;
   sitting in front of the boss's hem reads as depth and is what every other den
   add does. The de-saturation fix above is the whole fix. */

/* THE ONE STILL STANDING. Tom: "the other remaining enemy should glow in rage or
   briefly scale up to show that there's still a second creature to fight to win."
   A single flare on the transition, not a permanent glow: it fires when its
   partner drops, which is the moment the information is needed. */
/* NOT `both`. animation-fill-mode: both keeps the 100% frame (transform:scale(1);
   filter:none) applying from the animation origin FOREVER, which outranks any
   normal declaration: once an enemy had flared, .fstage.ko's rotate(78deg) and
   .petmini.beast's tint could never take effect again. So the add flared when the
   boss died, and then when you killed the ADD it faded to 55% opacity and stayed
   standing upright, which is verbatim the bug this was written to fix. The class
   is also removed on animationend (js/app.js) so nothing lingers. */
.fstage.last-standing { animation: lastStanding 1.15s cubic-bezier(.2,.9,.3,1) 1; }
@keyframes lastStanding {
  0%   { transform: scale(1);    filter: none; }
  22%  { transform: scale(1.14); filter: drop-shadow(0 0 14px rgba(253,104,87,.95)) drop-shadow(0 0 26px rgba(253,104,87,.5)); }
  55%  { transform: scale(1.03); filter: drop-shadow(0 0 10px rgba(253,104,87,.6)); }
  100% { transform: scale(1);    filter: none; }
}
/* the KO pose already exists (.fstage.ko); it just never ran until a fight was
   over. Nothing to add but the note that it is now driven off HP. */
@media (prefers-reduced-motion: reduce) {
  /* no filter override here either: it would sit on the element for the rest of
     the fight and defeat the KO pose the same way the fill-mode did */
  .fstage.last-standing { animation: none; outline: 2px solid rgba(253,104,87,.85); outline-offset: 3px; }
}

/* the arena closes once the knockdown has played, so the result is not preceded
   by a screenful of empty floor (measured at ~450px before this) */
.fight-body > .arena { transition: min-height .35s ease, height .35s ease, opacity .3s ease; }
.fight-body.fight-settled > .arena { flex: 0 0 auto; min-height: 0; height: 0; opacity: 0; overflow: hidden; margin: 0; border: 0; }
/* THE HUD COLLAPSES WITH THE ARENA, FOR FREE AGAIN. It is back inside .arena,
   so the arena's own height:0 and overflow:hidden take it out on the same class
   flip and nothing here has to. The hand-rolled collapse that stood here while
   the HUD was a sibling is deleted with it, and the reason it existed is worth
   keeping: without it, measured at 375x667 after a win, 94.4px of live HP bars
   stayed on screen and pushed .fight-over from top 157.8 down to 260.2. Two dead
   bars over a VICTORY banner is a smaller version of Tom's 2026-08-10 "huge
   blank gap at the top of the screen". If the HUD ever leaves the arena again,
   that collapse has to come back with it. */
.fight-body.fight-settled > .fight-actions,
.fight-body.fight-settled > #fendrow { display: none; }
@media (prefers-reduced-motion: reduce) { .fight-body > .arena { transition: none; } }
/* The result rides the SAME class flip. It used to pop in fully lit at the
   bottom of the void, hold there while the knockdown played, then lurch up
   ~250px with the closing arena: three motions where the player should see
   one (filmed on a real den win, 2026-08-11). Hidden while the KO pose
   plays, faded and risen into place as the arena closes. The un-hide owner
   is the fight-settled add in settle(); a missed flip leaves the arena open
   too, so the failure degrades to the old screen, never to a blank one. */
.fight-body > .fight-over { opacity: 0; transform: translateY(14px); transition: opacity .3s ease .1s, transform .3s ease .1s; }
.fight-body.fight-settled > .fight-over { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) { .fight-body > .fight-over { transition: none; transform: none; } }

/* "incl. today" under a Trends stat label: today now counts toward the averages
   at its honest weight, so the label has to say so rather than silently changing
   what the number means */
.trend-stats .st .l .st-note { display: block; font-style: normal; font-size: var(--fs-tiny); color: var(--text-3); letter-spacing: .02em; margin-top: 1px; }

/* THE SMEAR IS GONE, AND SO IS THE BLUR. First attempt at Tom's "find a good
   compromise here" was two composited copies of the card offset along the axis of
   travel. It cannot work: .cf-card is overflow:hidden, so the copies were clipped
   to the card's own box and never extended past its silhouette, and ::after
   painted on top of .cf-art, veiling the pet instead of trailing it. Two extra
   compositor layers per card for nothing.
   The measurement that started this said the filter cost 5% of frames over 32ms
   against 0.4% with no filter at all. So: no filter, no smear. The carousel keeps
   its depth, rotation and fade, which is where its motion actually reads, and it
   stops dropping frames. If a directional cue is wanted later it needs a wrapper
   OUTSIDE the clipped card, not a pseudo-element inside it. */
@media (prefers-reduced-motion: reduce) {
  }

/* the breed bar while only one pet is picked: same slot, same weight, so the
   answer to "what now?" is where the real bar is about to appear */
/* THE SECOND-PICK PROMPT. A compact banner pinned to the top of the Stable, so
   it stays on screen while you swipe and can never sit over the BREED button it
   is telling you to press. Fixed widths on the portrait and the button, and the
   text is the only flexible column: the first attempt let three flex children
   negotiate and the label wrapped one word per line. */
.breed-waiting {
  position: sticky; top: 0; z-index: 5;
  display: flex; align-items: center; gap: 10px;
  padding: 8px 10px; margin: 0 0 12px;
  background: var(--surface); border: 2px solid rgba(255,211,77,.45); border-radius: 14px;
  box-shadow: 0 6px 16px rgba(0,0,0,.42);
}
.breed-waiting .bw-pet { flex: 0 0 38px; width: 38px; height: 38px; border-radius: 11px;
  display: grid; place-items: center; overflow: hidden;
  background: rgba(242,233,215,.06); border: 2px solid var(--coral); }
.breed-waiting .bw-pet img { width: 100%; height: 100%; object-fit: contain; }
.breed-waiting .bw-say { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.breed-waiting .bw-say b { font-size: var(--fs-2); line-height: 1.15; }
.breed-waiting .bw-say small { font-size: var(--fs-0); color: var(--text-3); line-height: 1.2; }
.breed-waiting .bw-cancel { flex: 0 0 auto; width: auto; padding: 6px 11px; font-size: var(--fs-0); }
.chip-btn { cursor: pointer; border: 1px solid rgba(242,233,215,.22); background: rgba(242,233,215,.05); display: inline-flex; align-items: center; gap: 5px; }
/* THE PICKED-PET RULES ARE GONE ON PURPOSE, DO NOT PUT THEM BACK. Two orphaned
   declaration bodies sat here from 61dd854, which replaced the .breed-waitrow row
   (picked pet, arrow, empty .bw-slot) with the sticky .breed-waiting banner above
   and deleted only the selector lines of `.breed-waitrow .bt-pet` and
   `.breed-waitrow .bw-slot`. Their markup went with them: nothing in js/ emits
   .breed-waitrow, .bw-slot or .bw-arrow any more, so restoring the selectors would
   only add CSS that matches nothing. The picked pet is .breed-waiting .bw-pet
   directly above, and the real bar's slots keep their own .breed-trade rules
   (~line 5604). The bodies were not harmless while they sat here: a declaration at
   depth zero makes the parser consume everything up to the next opening brace as a selector,
   so `.pets-help section { margin: 0 0 14px; }` below was swallowed with them and
   the pets explainer ran with no gap between its five sections. */

/* the pets explainer. Four questions, four answers: readable at a glance, not a
   wall. Section headings carry the question so the body can stay short. */
.pets-help section { margin: 0 0 14px; }
.pets-help h3 { font-family: var(--display); font-size: var(--fs-4); letter-spacing: .02em; margin: 0 0 4px; color: var(--accent); }
.pets-help p { margin: 0 0 6px; font-size: var(--fs-2); line-height: 1.45; }
.pets-help p.note { font-size: var(--fs-1); }

/* ===== THE PADDOCK (pet playland; design_handoff_the_paddock 2026-08-10) =====
   Namespaced .pdk-*: .pd-* is the Wardrobe PAPERDOLL (line ~2942), collision
   caught by Walt before a rule shipped. Scene is Lane R; cards/panel (Lane W)
   mount inside .pdk-panel. All ambient motion is CSS keyframes, no rAF. */
.pdk-scene { position: relative; height: 520px; overflow: hidden; border-radius: 0;
  background: linear-gradient(#171f12 0 196px, #3a482e 212px, #36432c 360px, #303c27 520px); }

.pdk-scene svg.pdk-ground { position: absolute; inset: 0; width: 100%; height: 100%; }
/* the herd */
.pdk-pet { position: absolute; z-index: 5; }
/* Instance states sit outside the facing/bob wrappers so their words stay upright.
   LEFT/RIGHT 0, NOT left:50% + translateX(-50%). An absolutely positioned box with
   only `left: 50%` shrinks to fit HALF its sprite (76px sprite -> 38px of available
   width), which was invisible while the text-scaling rule below only had to beat
   `white-space: nowrap`. Once that rule turned wrapping on with `overflow-wrap:
   anywhere`, BREEDING broke mid-word into "BREE"/"DING" and the second line landed
   under the sprite stack. Spanning the sprite gives the label the width the
   text-scaling comment already claimed it had ("wrap within their owning sprite");
   align-items: center keeps each badge centred, so nothing moves for labels that
   already fitted. */
.pdk-state { position: absolute; z-index: 1; bottom: 0; left: 0; right: 0;
  display: flex; flex-direction: column; align-items: center; pointer-events: none;
  font: 700 var(--fs-micro)/1.3 var(--font-body, sans-serif); white-space: nowrap; }
.pdk-state span { width: max-content; max-width: calc(100vw - 32px); padding: 1px 4px; border-radius: 3px; background: #14131a; color: var(--accent); }
.pdk-state .pdk-breeding { color: #ffd34d; }
.pdk-pet .pdk-flip { display: block; width: 100%; height: 100%; }
.pdk-pet .pdk-shadow { position: absolute; left: 14%; right: 14%; bottom: 2px; height: 9px;
  border-radius: 50%; background: rgba(10, 12, 8, .5); filter: blur(2px); }
/* walkers: wander on the wrapper, synced flip on the sprite (48%/96%), trot bob */
.pdk-walk { animation: pdkWander var(--pdk-dur, 11s) ease-in-out infinite; }
@keyframes pdkWander { 0%, 100% { transform: translateX(0); } 48% { transform: translateX(var(--pdk-range, 60px)); } }
.pdk-walk .pdk-flip { animation: pdkFace var(--pdk-dur, 11s) step-end infinite; }
@keyframes pdkFace { 0%, 47.9% { transform: scaleX(1); } 48%, 95.9% { transform: scaleX(-1); } 96%, 100% { transform: scaleX(1); } }
.pdk-walk .pdk-bob { display: block; animation: pdkTrot .48s ease-in-out infinite alternate; }
@keyframes pdkTrot { from { transform: translateY(0) rotate(-1deg); } to { transform: translateY(-4px) rotate(1deg); } }
/* flyers: full crossing, wing bob on the sprite */
.pdk-fly { animation: pdkCross var(--pdk-dur, 15s) linear infinite; animation-delay: var(--pdk-phase, 0s); z-index: 4; }
@keyframes pdkCross { from { transform: translateX(-170px); } to { transform: translateX(440px); } }
.pdk-fly .pdk-bob { display: block; animation: pdkWing 1.9s ease-in-out infinite alternate; }
@keyframes pdkWing { from { transform: translateY(-9px) rotate(-2deg); } to { transform: translateY(9px) rotate(2deg); } }
/* hoverers: drift + bob, epic glow */
/* the delay is the per-pet phase js/app.js derives from the iid: without it a
   row of clouds drifts and bobs as one animal. Drift and bob run on different
   clocks (10s and 3s), so one offset desynchronises both. */
.pdk-hover { animation: pdkDrift 10s ease-in-out infinite alternate; animation-delay: var(--pdk-phase, 0s); z-index: 4; }
@keyframes pdkDrift { from { transform: translateX(0); } to { transform: translateX(var(--pdk-range, 0px)); } }
.pdk-hover .pdk-bob { display: block; animation: pdkHoverBob 3s ease-in-out infinite alternate; animation-delay: var(--pdk-phase, 0s); }
@keyframes pdkHoverBob { from { transform: translateY(-6px); } to { transform: translateY(6px); } }
/* floppers: one flop per cycle, origin low so it pivots off the ground */
.pdk-flop .pdk-bob { display: block; transform-origin: 50% 90%; animation: pdkFlop 4.8s ease-in-out infinite; animation-delay: var(--pdk-phase, 0s); }
@keyframes pdkFlop { 0%, 64%, 84%, 100% { transform: none; } 72% { transform: translateY(-4px) rotate(-2deg); } }
/* keeper: idle bob + moonlit rim (charcoal bones vanish on the dark scene without it) */
/* THE KEEPER SWALLOWS ITS OWN TAPS. It was pointer-events: none because it is
   not a control, and the consequence was worse than a dead control: it is drawn
   at z-index 6 OVER the herd, so every tap that landed on your own bonehead fell
   straight through to whichever pet was hidden behind him and opened that pet's
   card. The new player reported it as "tapping my character opens a pet I cannot
   see". You cannot tap what you cannot see, so the figure absorbs the press and
   nothing opens. It is not given a handler: it has nothing to say, and inventing
   a response is not what the report asked for. The rule covers .pdk-visitor too,
   which carries this class and had the identical hole in a friend's field. */
.pdk-keeper { position: absolute; z-index: 6; pointer-events: auto;
  filter: drop-shadow(0 0 10px rgba(232, 228, 210, .35)) drop-shadow(0 6px 8px rgba(0, 0, 0, .45));
  animation: pdkKeeper 4s ease-in-out infinite alternate; }
@keyframes pdkKeeper { from { transform: translateY(0); } to { transform: translateY(-7px); } }
.pdk-keeper img { position: absolute; inset: 0; width: 100%; height: 100%; }
/* The visitor faces the keeper. Flipping the CONTAINER rather than each layer
   keeps the avatar stack composited as one, which is what the Pit learned when
   mirroring a pet turned its accessories inside out. */
/* MIRROR THE GUEST, WHO STANDS ON THE RIGHT. THE ART FACES RIGHT.
   Tom, twice. First: "the friend bonehead should be mirrored so it looks like
   they're hanging out not just both facing away." I flipped the HOST and got:
   "you flipped the wrong bonehead in the paddock so theyre back to back."
   The art faces RIGHT, so the host on the LEFT is already looking inward and must
   not be touched; it is the guest on the right who has to turn around. Confirmed
   in the codebase, not just from the reports: js/pets.js's FACES_LEFT set holds
   exactly one id, which is the house convention written down.

   `scale`, NOT `transform`, AND ON THE CONTAINER. Two separate traps, both hit:
   1. v435 put `transform: scaleX(-1)` on .pdk-visitor. It did nothing, because
      .pdk-keeper runs `animation: pdkKeeper` on TRANSFORM and an animation beats
      a normal declaration. `scale` is its own property and composites with an
      animated `transform` instead of being eaten by it, so it survives. Both
      candidate containers animate transform (.bh-anim runs bh-idle), so there was
      no un-animated container to use.
   2. The next version moved it onto `img` to dodge trap 1. That splits the
      figure: avatarLayersHtml emits the weapon's charge as a `<span
      class="wpn-sheen">`, NOT an img, masked by and registered to the unflipped
      weapon art. Flipping only the imgs sweeps the glint across the empty side of
      the body while the blade stays dark, for anyone carrying an epic or better.
      Flipping the CONTAINER carries the whole stack, sheen included. */
.pdk-scene.pdk-shared .pdk-visitor { scale: -1 1; }
/* lurker tease: hidden most of a 13s cycle, rises briefly; silhouette treatment */
.pdk-lurker { position: absolute; z-index: 3; overflow: hidden; }
.pdk-lurker img { filter: brightness(0); opacity: .72; display: block; width: 100%;
  animation: pdkPeek 13s ease-in-out infinite; transform: translateY(60px); }
@keyframes pdkPeek { 0%, 74%, 92%, 100% { transform: translateY(60px); } 78%, 88% { transform: translateY(4px); } }
.pdk-lurker .pdk-eyes { position: absolute; top: 26px; left: 50%; width: 26px; margin-left: -13px;
  display: flex; justify-content: space-between; opacity: 0; animation: pdkPeekEyes 13s ease-in-out infinite; }
@keyframes pdkPeekEyes { 0%, 74%, 92%, 100% { opacity: 0; } 78%, 88% { opacity: 1; } }
.pdk-lurker .pdk-eyes i { width: 6px; height: 6px; border-radius: 50%; background: #a5e847;
  box-shadow: 0 0 7px 2.5px rgba(165, 232, 71, .95); }
/* a SHINY hides here: gold eyes + drifting sparks, timed to the same peek */
.pdk-lure-shiny .pdk-eyes i { background: #ffc961; box-shadow: 0 0 7px 2.5px rgba(255, 201, 97, .95); }
.pdk-lure-spark { position: absolute; top: 18px; left: 22%; color: #ffc961; font-size: var(--fs-2);
  text-shadow: 0 0 6px rgba(255, 201, 97, .9); opacity: 0; animation: pdkLureSpark 13s ease-in-out infinite; }
.pdk-lure-spark.s2 { left: 68%; top: 8px; font-size: var(--fs-tiny); animation-delay: .6s; }
@keyframes pdkLureSpark { 0%, 74%, 92%, 100% { opacity: 0; transform: translateY(6px); } 78%, 88% { opacity: 1; transform: translateY(-4px); } }
/* scene furniture animations */
.pdk-sign { position: absolute; top: 0; left: 50%; z-index: 7; transform-origin: 50% 0;
  animation: pdkSway 5s ease-in-out infinite alternate; }
@keyframes pdkSway { from { transform: translateX(-50%) rotate(-1.6deg); } to { transform: translateX(-50%) rotate(1.6deg); } }
.pdk-grass { transform-origin: 50% 100%; animation: pdkGrass 3.4s ease-in-out infinite alternate; }
@keyframes pdkGrass { from { transform: rotate(-2.5deg); } to { transform: rotate(2.5deg); } }
.pdk-fog { position: absolute; width: 300px; height: 90px; border-radius: 50%; z-index: 6; pointer-events: none;
  background: radial-gradient(closest-side, rgba(214, 226, 210, .13), transparent);
  animation: pdkFog var(--pdk-dur, 26s) ease-in-out infinite alternate; }
@keyframes pdkFog { from { transform: translateX(-70px); } to { transform: translateX(70px); } }
.pdk-blink { animation: pdkBlink var(--pdk-dur, 7s) step-end infinite; }
@keyframes pdkBlink { 0%, 87.9%, 94% { opacity: 1; } 88%, 93.9% { opacity: 0; } }
.pdk-nestglow { animation: pdkNest 3.2s ease-in-out infinite alternate; }
@keyframes pdkNest { from { opacity: .3; } to { opacity: .75; } }
.pdk-vignette { position: absolute; inset: 0; z-index: 8; pointer-events: none;
  background: radial-gradient(ellipse at 50% 45%, transparent 52%, rgba(10, 12, 8, .32)),
    linear-gradient(rgba(20, 28, 40, .16), transparent 30%); }
.pdk-back { position: absolute; top: 12px; left: 14px; z-index: 10; width: 44px; height: 44px;
  border-radius: 50%; background: rgba(13, 12, 18, .42); backdrop-filter: blur(10px);
  border: 0; color: var(--text); display: grid; place-items: center; }
/* pointer-events: none because the pill is a SIGN, not a control, and it sits at
   z-index 9 over the card host's 6: on a tall card (BEST FRIEND) the Pet/Feed row lands
   exactly under it, so every press hit the pill, and being outside the card the
   dismisser then closed the card. The pill is also removed the moment any card opens
   (paddock-cards.js openPaddockCards); this is the half that cannot be forgotten. */
.pdk-coach { position: absolute; left: 50%; bottom: 30px; transform: translateX(-50%); z-index: 9; pointer-events: none;
  font-family: var(--display); font-size: var(--fs-3); color: #17151d; background: #f2e9d7;
  border-radius: 999px; padding: 7px 18px 6px; box-shadow: 0 3px 0 rgba(0, 0, 0, .38); }
/* panel shell: Lane W mounts inside */
.pdk-panel { position: relative; z-index: 9; margin-top: -22px; background: #14131a;
  border-radius: 26px 26px 0 0; border-top: 2.5px solid #17151d; min-height: 340px; padding: 16px 12px 24px; }
@media (prefers-reduced-motion: reduce) {
  .pdk-walk, .pdk-walk .pdk-flip, .pdk-walk .pdk-bob, .pdk-fly, .pdk-fly .pdk-bob,
  .pdk-hover, .pdk-hover .pdk-bob, .pdk-flop .pdk-bob, .pdk-keeper, .pdk-lurker img,
  .pdk-sign, .pdk-grass, .pdk-fog, .pdk-blink { animation: none; }
}
/* ===== THE PADDOCK: cards + collection panel (Lane W) =====================
   `.pdk-`, NOT `.pd-`: `.pd-*` is the wardrobe paperdoll above. Same reason the
   reveal rule is scoped to `#sheets .sheet-body`. */
/* z-index 10, ABOVE the panel's 9 and the coach mark's 9. Measured 2026-08-31 at
   430x932: the dots row lands at y605 and the panel's top edge is at y601, so at 6 the
   one control that says "there are more of these" was painted UNDER the panel, and
   elementFromPoint at each dot returned .pdk-panel. An open card is the front-most
   thing on this screen, so it says so. */
/* AND IT SITS WHERE THE HAND IS. Measured 2026-08-31 at 430x932: every door
   into this card except a field tap is BELOW the scene (the collection grid
   starts at y601), and top:284px put the card at y387-565, 278px above the tile
   the finger had just pressed and on top of the keeper. Anchored to the scene's
   bottom edge instead, it ends where the panel begins, roughly a tile's height
   from the tap. `.pdk-hi` is the one exception and the field asks for it: a pet
   standing in the LOWER half of the scene would otherwise be covered by its own
   card (paddock-cards.js openPaddockCards). */
.pdk-host { position: absolute; left: 50%; transform: translateX(-50%); bottom: 26px; width: 274px; z-index: 10; }
.pdk-host.pdk-hi { bottom: auto; top: 96px; }
.pdk-rail { display: flex; gap: 10px; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; }
.pdk-rail::-webkit-scrollbar { display: none; }
.pdk-card {
  /* 92%, so the copy NEXT to this one peeks in at the edge. At 100% a four-deep stack
     looked like a single card and the second playtester never found the slider at all;
     the dots are the other half of the same message. */
  flex: 0 0 92%; scroll-snap-align: center; box-sizing: border-box;
  background: #1d1b22; border: 2.5px solid #17151d; border-radius: 16px;
  box-shadow: 4px 5px 0 rgba(0,0,0,.45); padding: 12px 14px; position: relative;
  animation: pdkPop 320ms cubic-bezier(.34,1.56,.64,1) both;
}
@keyframes pdkPop { 0% { transform: scale(.84); opacity: 0 } 60% { transform: scale(1.04); opacity: 1 } 100% { transform: scale(1) } }
.pdk-head { display: flex; gap: 10px; align-items: center; position: relative; }
/* position: relative so an ink-fitted pet (inkFitStyle, absolute at the box's top-left)
   anchors HERE and not on some ancestor. The thumb is bigger than the art's ink, so the
   art is deliberately larger than this box and clipped by it. */
.pdk-thumb { position: relative; width: 54px; height: 54px; flex: 0 0 54px; background: #14131a; border: 2px solid #17151d; border-radius: 12px; display: grid; place-items: center; overflow: hidden; }
.pdk-thumb img { width: 100%; height: 100%; object-fit: contain; }
.pdk-sil img { filter: brightness(0); opacity: .72; }
.pdk-name { font-family: Bangers, system-ui; font-size: var(--fs-5); color: #f2e9d7; letter-spacing: .5px; }
.pdk-chips { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 2px; }
.pdk-chip { font-size: var(--fs-micro); font-weight: 800; text-transform: uppercase; letter-spacing: .04em; padding: 2px 6px; border-radius: 999px; color: #17151d; }
.pdk-shiny { background: #ffc961; }
.pdk-lv { background: #14131a; color: #8f8578; border: 1px solid #26232e; }
.pdk-flavor { font-size: var(--fs-1); font-weight: 600; color: #8f8578; margin: 8px 0 6px; }
.pdk-bond { display: flex; gap: 5px; }
/* the pips hold a real heart icon now (game-icons.net Skoll, CC-BY 3.0). Flat fill, NO
   rim: the icon system's rule. The soft shadow belongs here, not on the art. An EMPTY
   pip is the same heart dimmed, so five always read as five. */
.pdk-heart { width: 17px; height: 17px; display: grid; place-items: center; }
.pdk-heart .bhi { filter: drop-shadow(0 1px 1px rgba(10,12,8,.55)); }
.pdk-heart { color: #26232e; }
.pdk-heart.on { color: #fd6857; }
.pdk-bff { display: inline-block; margin-top: 7px; background: #ffc961; color: #17151d; border: 2px solid #17151d; border-radius: 999px; padding: 2px 9px; font-family: Bangers, system-ui; font-size: var(--fs-2); }
.pdk-bff.pdk-pop { animation: pdkBadge 450ms cubic-bezier(.34,1.56,.64,1) both; }
@keyframes pdkBadge { 0% { transform: scale(0) } 62% { transform: scale(1.2) } 100% { transform: scale(1) } }
.pdk-acts { display: flex; gap: 8px; margin-top: 10px; }
.pdk-btn { flex: 1; font-family: Bangers, system-ui; font-size: var(--fs-body); color: #17151d; border: 2.5px solid #17151d; border-radius: 12px; padding: 7px 0; box-shadow: 0 4px 0 #17151d; }
.pdk-btn-pet { background: #a5e847; } .pdk-btn-feed { background: #f2e9d7; }
.pdk-btn:active { transform: translateY(2px); box-shadow: 0 2px 0 #17151d; }
.pdk-x-btn {
  position: absolute; top: -10px; right: -10px; z-index: 3; width: 30px; height: 30px;
  border-radius: 50%; background: #1d1b22; border: 2.5px solid #17151d; color: #f2e9d7;
  font-family: Bangers, system-ui; font-size: var(--fs-5); line-height: 1; padding: 0;
  display: grid; place-items: center; box-shadow: 0 3px 0 #17151d;
}
.pdk-x-btn:active { transform: translateY(2px); box-shadow: 0 1px 0 #17151d; }
/* THE DOT IS A CONTROL WITH A 24px TAP AREA and an 8px face. It was an 8px box with no
   handler: too small to hit even once it was visible, and nothing happened if you did.
   The face is drawn by ::after so the box can grow without the dot growing with it, and
   the empty face is #3a3542 rather than the panel's own #14131a: these land ON the
   panel's top edge, where a dot the colour of the panel is a dot nobody can see. */
.pdk-dots { display: flex; gap: 2px; justify-content: center; margin-top: 2px; }
.pdk-dot { width: 24px; height: 24px; display: grid; place-items: center; }
.pdk-dot::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #3a3542; border: 1.5px solid #17151d; }
.pdk-dot.on::after { background: #a5e847; }
/* the burst floats out of the header; pointer-events none so it never eats a tap */
.pdk-burst { position: absolute; right: 4px; top: 0; pointer-events: none; }
.pdk-glyph { position: absolute; right: 0; top: 0; width: 13px; height: 13px; opacity: 0; animation: pdkFloat 850ms ease-out both; }
/* the burst glyph carries the same heart, so what floats up matches what fills */
.pdk-heartg { color: #fd6857; }
.pdk-heartg .bhi { filter: drop-shadow(0 1px 1px rgba(10,12,8,.5)); }
.pdk-boneg { background: #f2e9d7; border-radius: 3px; }
@keyframes pdkFloat { 0% { opacity: 0; transform: translate(0,0) scale(.7) } 22% { opacity: 1 } 100% { opacity: 0; transform: translate(var(--dx,0), -46px) scale(1) } }
/* collection panel: rendered INTO his #pdkPanel container, so no wrapper rule here */
.pdk-teaser { display: flex; gap: 10px; align-items: center; width: 100%; text-align: left; background: #1d1b22; border: 2px solid #17151d; border-radius: 13px; box-shadow: 0 3px 0 #17151d; padding: 9px; }
.pdk-teaser-tx small { display: block; color: #a5e847; font-size: var(--fs-tiny); font-weight: 800; letter-spacing: .12em; }
.pdk-teaser-tx b { display: block; color: #f2e9d7; font-size: var(--fs-2); font-weight: 700; margin-top: 2px; }
.pdk-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
.pdk-tile { position: relative; aspect-ratio: 1; background: #201d28; border: 2px solid #17151d; border-radius: 13px; box-shadow: 0 3px 0 #17151d; display: grid; place-items: center; overflow: hidden; }
.pdk-tile img { width: 84%; height: 84%; object-fit: contain; }
.pdk-lockt { background: #17161d; border-style: dashed; }
/* .4, not .55: ink-fitting the art made these silhouettes fill their tile, and at .55
   the blob and the centred "?" fought each other. Dimming the hint keeps the glyph in
   front without shrinking the art back down or moving the glyph off centre. */
.pdk-lockt img { filter: brightness(0); opacity: .4; }
.pdk-q { position: absolute; font-family: Bangers, system-ui; font-size: var(--fs-5); color: #8f8578; }
.pdk-x { position: absolute; top: 3px; right: 3px; background: #f2e9d7; color: #17151d; border: 1.5px solid #17151d; border-radius: 999px; font-size: var(--fs-micro); font-weight: 800; padding: 0 4px; }
.pdk-star { position: absolute; top: 3px; left: 3px; width: 11px; height: 11px; background: #ffc961; clip-path: polygon(50% 0,61% 39%,100% 50%,61% 61%,50% 100%,39% 61%,0 50%,39% 39%); }
.pdk-eggico { width: 26px; height: 32px; background: #f2e9d7; border: 2px solid #17151d; border-radius: 50% 50% 46% 46% / 60% 60% 40% 40%; }
/* an empty nest still shows the door, but not a full egg it does not have */
.pdk-eggico.pdk-empty { opacity: .3; }
.pdk-eggbar { display: block; width: 76%; height: 6px; background: #14131a; border: 1.5px solid #17151d; border-radius: 999px; margin-top: 5px; overflow: hidden; }
.pdk-eggbar i { display: block; height: 100%; background: #a5e847; }
.pdk-foot { display: flex; gap: 8px; margin-top: 14px; }
.pdk-seg { flex: 1; font-family: Bangers, system-ui; font-size: var(--fs-3); border: 2px solid #17151d; border-radius: 13px; padding: 7px 0; background: #1d1b22; color: #8f8578; }
.pdk-seg.on { background: #a5e847; color: #17151d; }
/* a door that does not open yet must LOOK shut: `disabled` alone left it at full
   opacity with a pointer cursor beside a lit segment, which reads as a working tab */
.pdk-seg[disabled] { opacity: .45; cursor: default; }
/* and the count beside it is a LABEL, not the selected half of a toggle */
.pdk-count { text-align: center; color: #f2e9d7; }
/* the field cannot hold everybody, so the panel says so out loud rather than
   letting a 16-pet footer sit over 14 animals with no explanation */
.pdk-bench { margin: 8px 0 0; text-align: center; font-size: var(--fs-1); font-weight: 600; color: #8f8578; }
/* THE TAPPED TILE LOOKS TAPPED. A grid press had neither a pressed nor a
   selected state, so nothing on the tile said the card belonged to it. */
.pdk-tile:active, .pdk-teaser:active { transform: translateY(2px); box-shadow: 0 1px 0 #17151d; }
.pdk-tile.on, .pdk-teaser.on { border-color: #a5e847; box-shadow: 0 3px 0 #17151d, 0 0 0 2px rgba(165, 232, 71, .35); }


/* ---------- The Kennel, Kennel Phase A (2026-09-05) ----------
   A display screen, not a shop: no glow anywhere on a pet here, deliberately
   (Tom's ruling). Roster ownership uses colour strength and size; grid cells keep locked silhouettes. */
.k-gwart { background: var(--surface-2); border-radius: var(--radius-sm); padding: 10px 12px; font-size: var(--fs-2); color: var(--text-2); margin: 4px 0 14px; }
.k-gwart b { color: var(--text); }
/* SIX ROWS, ONE SCREEN, EVEN AT 320x568 (the narrowest phone this app
   supports): a 6-owned-species roster (all six ordinary hatch species,
   Bumbleseal included per Tom's ruling) must fit with no scroll, which is why
   the row is 60px, not the plan's original 72px -- measured over budget at
   72px on the narrow phone with all six owned. */
.k-roster { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 14px; }
.k-row { display: flex; align-items: center; gap: 12px; min-height: 60px; background: var(--surface-2); border-radius: var(--radius-sm); padding: 6px 12px; }
.k-thumb { flex: 0 0 48px; width: 48px; height: 48px; display: grid; place-items: center; }
.k-id { min-width: 0; flex: 1; }
.k-id b { display: block; font-size: var(--fs-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.k-dots { display: flex; gap: 6px; margin-top: 4px; }
/* 2026-09-20 Tom: the roster says owned with saturation, not a caption.
   Keep the neutral edge visible even for Midnight. Size is a second state cue;
   only the colour fill is translucent, so missing slots remain countable. */
.k-dot { --k-strength: 35%; width: 14px; height: 14px; border-radius: 50%; background: color-mix(in srgb, var(--kc, var(--text)) var(--k-strength), transparent); outline: 1px solid var(--text-3); display: inline-block; transform: scale(.75); }
.k-swatch { display: block; width: 10px; height: 10px; margin: 0 auto 4px; border-radius: 50%; background: var(--kc, var(--text)); outline: 1px solid var(--text-3); }
/* Every colourway dot is round. Ember was square as a second, non-colour cue
   until Tom, 2026-09-17: "it's a small tracker of pets, not an interface you
   work with all the time; the random square looks like a mistake". */
/* THE DOT IS THE COLOURWAY'S OWN COLOUR, not the accent (v500: "How to use the
   kennel not clear at all"). Five identical lime dots said "you own five of
   something" and made the player read the caption to learn WHICH; the shell
   hues say it at a glance, and DESIGN.md keeps the accent scarce and meaning
   "go", never "collected". --kc is set inline from morphSwatch (js/app.js). */
.k-dot.on { --k-strength: 100%; transform: scale(1); }
.k-empty { color: var(--text-3); font-size: var(--fs-2); padding: 12px 0; }
.k-lead { font-size: var(--fs-1); color: var(--text-3); margin: -2px 0 10px; line-height: 1.45; }
/* LABEL ABOVE, NOT BESIDE: a side label would eat into the same six-column
   budget the plan derives from sheet width alone (viewport - outer pad - five
   4px gutters, over six columns), so the cell size computed in JS would no
   longer match what CSS actually gives the row. Stacking keeps one formula. */
/* COLUMN HEADERS, one row, above every species block -- same flex/gap as
   .k-grid-cells so its six labels land over the six cells beneath them in every
   row. DESIGN.md's one section-header idiom (.sect-h: 12px/700/.06em/upper-
   case/--text-3), sized down for a column as narrow as 40px: "Midnight" does
   not fit at 12px, so this shrinks the size rather than the tracking or the
   case, keeping the same voice as every other section header. */
/* R39-30 (2026-09-06): head and cells share ONE grid track definition.
   As flex rows, "MIDNIGHT" at 9px could not shrink below its own text and the
   headers drifted up to 4.52px off the cells at 320px; minmax(0, 1fr) lets
   both rows split the width identically whatever their content measures. */
.k-grid-head, .k-grid-cells { display: grid; grid-template-columns: repeat(var(--kennel-columns, 6), minmax(0, 1fr)); gap: 4px; }
.k-grid-head { margin-bottom: 4px; }
.k-grid-head-cell { text-align: center; font-size: var(--fs-micro); font-weight: 700; letter-spacing: .02em; text-transform: uppercase; color: var(--text-3); line-height: 1.2; }
.k-grid-row { margin-bottom: 10px; }
.k-grid-label { display: block; font-size: var(--fs-0); color: var(--text-3); margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* R39-11: the cell is a real <button> (44px floor: 48px at 320 wide) and it
   shows its pressed state; R39-8/9: the art fills the cell from CSS, so the
   sheet's 600px cap and a rotation both resize it (croppedPetImg's layers are
   percentages of .petcrop, which is why !important over its inline px works). */
.k-cell { position: relative; aspect-ratio: 1; padding: 0; background: var(--surface-2); border-radius: var(--radius-sm); display: grid; place-items: center; overflow: hidden; }
.k-cell .petcrop { width: 100% !important; height: 100% !important; }
.k-cell[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: -2px; }
/* AN EMPTY SLOT, NOT A DIM PET. Tom, on v500: a player who has never opened
   this should tell an owned cell from an unowned one in one look. At
   brightness(.4)/opacity(.55) an unowned cell still read as a picture of an
   animal, so the grid looked like 30 pets of varying exposure. Flattened to a
   silhouette it reads as a hole in the set -- still the species' own shape (so
   the row is legible) and still a decoded image, never a 404. */
.k-cell.locked .petcrop img { filter: grayscale(1) brightness(.18) contrast(.6); opacity: .5; }
.k-cell.locked { background: var(--bg); }
.k-cell svg { position: absolute; bottom: 4px; right: 4px; color: var(--text-3); }

/* ---------- Discord card hero (Tom, 2026-08-12) ----------
   "the discord icon with a bonehead chillin next to it or something to really
   grab the attention". Two figures on one baseline, so it reads as a pair
   rather than two things that happen to be near each other. */
.dc-hero { display: flex; align-items: flex-end; justify-content: center; gap: 16px; margin: 4px 0 14px; }
/* The home-screen shape. iOS/Android round it to about 22% of the tile, which
   is what makes it read as "the app" and not "a logo". */
.dc-app {
  width: 78px; height: 78px; border-radius: 22px; background: #5865F2;
  display: grid; place-items: center; flex: none;
  box-shadow: 0 6px 18px rgba(88, 101, 242, 0.38);
}
/* contain, never cover: Cam's art sits in a 640 square with a lot of
   transparent air, and cover would crop the skull to fill the box. Aligned on
   the drawing's own baseline via flex-end above, not by nudging margins. */
.dc-bh { position: relative; width: 92px; height: 92px; flex: none; }
.dc-bh img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
/* THE LEAN, AND THE INK CORRECTION. Measured off the actual render, not
   guessed: the icon is 77x77 with its bottom at y=237, and his ink is 55x78
   with its bottom at y=231, sitting 30px to the right of the icon's edge even
   though the CSS gap is 16. The drawing carries ~22px of transparent air down
   its left side, so the BOXES were 16 apart while the DRAWINGS were 30 apart,
   and he read as a separate thing floating nearby instead of leaning on it.
   margin-left closes the ink gap to ~10; translateY drops him the 6px onto the
   icon's baseline. Both numbers come from measuring the render (figure
   contract rule 3: align on ink, never on boxes). */
.dc-bh { margin-left: -20px; transform: rotate(-5deg) translateY(6px); transform-origin: 60% 100%; }
@media (prefers-reduced-motion: reduce) { .dc-bh { transform: translateY(6px); } }

/* The NEW pill is absolutely positioned top-right and this eyebrow is the
   longest one any card has, so they collided and ate the last letters.
   Symmetric padding keeps the line centred while clearing the badge. */
/* Shortened from "THE CLUBHOUSE IS OPEN": measured at 360 and 375px, the long
   version ran under the absolutely-positioned NEW pill (text right 288 vs
   badge left 276 on a 360). Padding could not save it without wrapping the
   line, and the title underneath already says what is open. */
.dc-veil .dc-eyebrow { padding: 0 50px; white-space: nowrap; }

/* The way out has to look like a control, not like small print. Scoped to this
   card so the drop/race/spire popups keep the quiet text version they were
   designed with. */
.dc-veil .dc-close, .bt-veil .dc-close {
  border: 1px solid var(--line); border-radius: 14px; padding: 13px;
  color: var(--text-2); font-weight: 700; margin-top: 8px;
}

/* ---------- Beta thank-you card (Tom, 2026-08-15) ----------
   One figure, so it centres rather than leaning: .dc-bh carries a -20px
   margin and a 5deg lean that exist only to close the ink gap against the
   Discord app icon, and with nothing to lean on both read as a wonky crop. */
/* THE ONE CARD THAT CAN OUTGROW THE PHONE. .drop-veil centres its card and
   does not scroll, which is fine for every popup that fits; this one is 684px
   of thank-you, link and Android instruction against an iPhone SE's 667, so
   centring alone clipped the eyebrow off the top and put the card out of
   reach. align-items:center also CLIPS an overflowing flex item rather than
   letting it scroll into view, so the item takes auto margins instead: it
   still centres wherever there is room, and it scrolls where there is not.
   Scoped to this veil so every other popup keeps the geometry it was drawn
   with. The audit measures reachability at 375x667, not just height. */
.bt-veil { overflow-y: auto; align-items: flex-start; }
.bt-veil .drop-card { margin: auto 0; }
.bt-hero { display: flex; justify-content: center; margin: 6px 0 12px; }
.bt-hero .dc-bh { width: 92px; height: 92px; margin-left: 0; transform: none; }
/* The strip inherits .dc-banner's row (same family, same 72px height); only
   the tint differs, because a blurple heart would read as Discord's. */
.bt-bnr-ico { color: var(--accent); }
.bt-bnr-ico svg { width: 20px; height: 18px; }
/* The link under the CTA is a fallback, not a second button: it stays note
   weight, but it has to survive a 375px screen without wrapping mid-URL. */
.bt-url a { color: var(--accent); word-break: break-word; }

/* The Crew strip. Same family as the Glutton/Spires/Garden banners so it reads
   as furniture rather than an ad, but a <button> because there is nothing to
   expand: it opens the same card the popup opens. Not dismissible on purpose. */
/* A <button> gets none of `.glutton-banner > summary`'s box, so it inherited
   only the outer card and measured 96px against the family's 72. Carry the
   summary's own flex row and padding here instead of guessing a height: the
   strip should match its siblings because it IS one, and 72 is their number,
   not a value I liked the look of. */
.dc-banner {
  width: 100%; text-align: left; font: inherit; cursor: pointer;
  display: flex; align-items: center; gap: 11px; padding: 11px 13px;
}
.dc-bnr-ico { color: #5865F2; display: grid; place-items: center; }
.dc-bnr-ico svg { width: 22px; height: 17px; }
/* The one door the potions live behind, and the way back out of it. */
.fight-act.items { border-color: rgba(139, 212, 80, .5); }
.fight-act.items.back { border-color: var(--line); color: var(--text-2); }

/* The one-line explainer under the hero: what the bolt is and how you get more.
   Caption weight on purpose. Today already competes for attention above the
   food, so this states the loop without becoming another card shouting on a
   screen that has plenty. */
.hero-why {
  display: flex; align-items: baseline; justify-content: center; gap: 6px;
  margin: 8px 2px 2px; font-size: var(--fs-2); color: var(--text-2);
}
.hero-why button { display: inline-flex; align-items: center; gap: 6px; padding: 0; }
/* PLAYTEST v761, item 3: #todayFightsReady answered a tap over 18px, and an invisible pad
   cannot fix it because the dock sits 32px away. So the button gets a real 44px box. Owner's call
   (review page 2026-10-03): the "12px growth, no overlap" variant. Today grows about 12px and the box
   ends exactly where the Trends and Backpack tiles begin, so it overlaps neither (a bottom margin
   of 0, never negative). It still tucks a few px under the bar above, which takes no taps here.
   z-index keeps that top overlap the button's own; the paragraph itself takes no taps.
   Guard: tests/proposal-fights-ready-hit.mjs. */
.hero-why:has(#todayFightsReady) { position: relative; z-index: 2; align-items: center; margin: -12px 2px 0; pointer-events: none; }
#todayFightsReady { min-height: 44px; pointer-events: auto; }
.hero-why b { color: var(--text); font-weight: 800; }
.hero-why svg { flex: 0 0 auto; color: var(--gold); }

/* The first-meal nudge and its "Not right now" way out (`.ul-wrap`, `.ul-skip`)
   were spaced here, after Tom's 2026-08-21 grouping bug. Both came out on
   2026-08-22 with the nudge itself. */

/* The takeover celebrates a badge; this line says what the player actually did,
   because the toast that confirms it is covered 380ms later by this screen. */
.cele-note { margin: 14px 0 0; text-align: center; font-size: var(--fs-2); color: var(--text-2); }

/* ---- The Hollow (v1 WIP): garden diorama. Namespaced .hlw-* ---- */
@keyframes hlwIdle{0%,100%{transform:translateY(0)}50%{transform:translateY(-7px)}}
@keyframes hlwWalk{0%{transform:translateY(0) rotate(-1.2deg)}100%{transform:translateY(-5px) rotate(1.2deg)}}
@keyframes hlwSway{0%,100%{transform:rotate(-4deg)}50%{transform:rotate(5deg)}}
@keyframes hlwReady{0%,100%{transform:scale(1)}50%{transform:scale(1.09)}}
@keyframes hlwGlow{0%,100%{opacity:.5}50%{opacity:.9}}
@keyframes hlwFirefly{0%,100%{transform:translate(0,0);opacity:.15}25%{opacity:.9}50%{transform:translate(14px,-18px);opacity:.3}75%{opacity:.85}}
@keyframes hlwPour{0%,100%{transform:rotate(-2deg)}25%,75%{transform:rotate(-14deg)}}
@keyframes hlwDrop{0%{transform:translateY(0);opacity:0}15%{opacity:.9}100%{transform:translateY(30px);opacity:0}}
@keyframes hlwPluck{0%{transform:translate(0,0) scale(1);opacity:1}60%{transform:translate(-14px,-44px) scale(1.15)}100%{transform:translate(-22px,-64px) scale(.9);opacity:0}}
@keyframes hlwToast{0%{transform:translateY(6px);opacity:0}15%,80%{transform:translateY(0);opacity:1}100%{transform:translateY(-8px);opacity:0}}
@keyframes hlwSprout{0%{transform:scale(0);opacity:0}70%{transform:scale(1.25)}100%{transform:scale(1);opacity:1}}
@keyframes hlwSteam{0%{transform:translateY(0);opacity:.55}100%{transform:translateY(-14px);opacity:0}}
@keyframes hlwCrowBob{0%,86%,100%{transform:translateY(0) rotate(0deg)}90%{transform:translateY(-3px) rotate(-6deg)}94%{transform:translateY(0) rotate(3deg)}}
.hlw-vp{width:100%;overflow:hidden;border-radius:18px;position:relative}
.hlw-stage{width:390px;height:740px;position:relative;transform-origin:0 0;background:linear-gradient(180deg,#55673a 0%,#617442 38%,#5d7040 66%,#4f6138 100%);overflow:hidden}
/* 60px, NOT the 84px the soil mound is drawn at. The spots are 63px apart, so an
   84px box overlapped its row-mate by 21px, and on the lower row the overlapping
   winner was #hlwBuy (appended last, same z-index): a thumb aiming at a bed could
   arm a 1,500-coin spend. 60 leaves a 3px gutter and still clears the 40px floor. */
.hlw-bed{position:absolute;width:60px;height:60px;background:none;border:none;cursor:pointer;z-index:6;padding:0}
.hlw-bed.arming{width:104px;margin-left:-22px;height:auto;padding:7px 4px;background:#1d1b22;border:2.5px solid var(--accent);border-radius:12px;box-shadow:4px 5px 0 rgba(0,0,0,.45);font-family:var(--display),Bangers,sans-serif;font-size:var(--fs-2);letter-spacing:.05em;color:var(--accent);z-index:14}
.hlw-chip{position:absolute;display:inline-flex;align-items:center;gap:4px;background:rgba(13,12,18,.6);border-radius:999px;padding:3px 9px;font-family:var(--display),Bangers,sans-serif;font-size:var(--fs-1);letter-spacing:.06em;color:#f2e9d7;pointer-events:none;z-index:5;transform:translateX(-50%)}
/* The screen's ONE accent moment, spent on the only bed that wants something.
   Every growing bed used to wear the same droplet, so the droplet meant nothing. */
/* -7px off the bed's centre line: at 3 beds owned the price sign hangs in the
   right half of this bed's row, and a centred chip clipped its board by 0.4px.
   Measured in the render; the audit pins the intersection at zero. */
/* The chip module owns the markup, the scene owns the position. A zero-size
   absolutely positioned anchor at the bed's centre x, so .hlw-chip's own
   translateX(-50%) centres it regardless of how long the label is. */
.hlw-chipwrap{position:absolute;width:0;height:0;z-index:5}
.hlw-signwrap{position:absolute;width:66px;height:70px;pointer-events:none;z-index:5}
/* FIRST VISIT. pointer-events none on the whole layer: it points at the shed,
   so it must never be the thing that catches the tap it is asking for. */
.hlw-first{position:absolute;inset:0;pointer-events:none;z-index:11}
.hlw-first>*{position:absolute}
.hlw-arrow{filter:drop-shadow(0 2px 6px rgba(0,0,0,.5))}
.hlw-arrow svg circle{fill:var(--accent);fill-opacity:1}
.hlw-arrow svg path{stroke:#17151d}
.hlw-freelabel{background:var(--accent);color:#17151d;border-radius:999px;padding:3px 9px;font-family:var(--display),Bangers,sans-serif;font-size:var(--fs-0);letter-spacing:.06em;white-space:nowrap;box-shadow:3px 3px 0 rgba(0,0,0,.35)}
.hlw-bar{margin:0 2px 8px;padding:9px 12px;border-radius:12px;background:#1d1b22;border:2px solid var(--accent);color:var(--accent);font-family:var(--display),Bangers,sans-serif;font-size:var(--fs-3);letter-spacing:.05em;text-align:center}
/* READY carries the lime now. It is the payoff, and it was the least visible
   thing on the screen: 175 css px of fruit at 1.81:1 while the optional watering
   action wore this chip. The accent MOVED, it was not duplicated. */
/* A BRAND FOCUS RING. Every control had one, but it was the unstyled Chromium
   default (outline: auto 1px rgb(0,95,204)) and app.css carried zero
   :focus-visible rules. On this screen every control is a transparent box over
   artwork, so a 1px hairline browser ring is the only thing a keyboard user has
   to go on. Accent, offset, and a dark halo so it survives on the lawn too. */
/* the panel takes focus on open as a tabindex=-1 container, so it must not
   draw a ring: only real controls get one. */
.sheet:focus,.sheet:focus-visible{outline:none}
:focus-visible{outline:3px solid var(--accent);outline-offset:2px;border-radius:6px}
.hlw-bed:focus-visible{outline:3px solid var(--accent);outline-offset:0;box-shadow:0 0 0 6px rgba(13,12,18,.55)}
.hlw-chip.ready{background:var(--accent);color:#17151d;margin-left:-7px}
/* Thirst keeps the droplet and drops to the muted ground. Still the only chip
   with a droplet, so it is still unambiguous, just no longer the loudest thing. */
.hlw-chip.thirst{background:rgba(13,12,18,.72);color:#9fd0e8;margin-left:-7px}
.hlw-toast{position:absolute;white-space:nowrap;background:rgba(13,12,18,.72);border-radius:999px;padding:4px 10px;font-family:var(--display),Bangers,sans-serif;font-size:var(--fs-2);letter-spacing:.06em;color:var(--accent);animation:hlwToast 2.4s ease-out forwards;z-index:12}

/* THE KEEPER'S SPEECH BUBBLE. Sits above his head, never over a bed, and never
   takes a tap: pointer-events stay off so it can never sit between a thumb and
   a crop. Same bone-card look as the Boneyard's .map-poi-tip. */
.hlw-say {
  /* announced: this is the screen's primary status text */

  position: absolute; left: 50%; bottom: 168px; transform: translateX(-50%);
  max-width: 210px; width: max-content; z-index: 6; pointer-events: none;
  background: var(--bone-hi, #f2e9d7); color: var(--ink, #17151d);
  border: 2px solid var(--ink, #17151d); border-radius: 13px;
  box-shadow: 3px 4px 0 rgba(0, 0, 0, .45);
  /* BANGERS, because this pill IS the designer's coach mark. Tom, 2026-08-16:
     "the font for the bonehead speech bubble isnt the right one?" It was not.
     The comp's pill is background #f2e9d7, border 2.5px #17151d, radius 16,
     font-family Bangers, and this rule had copied every one of those except the
     face. The result looked like the designer's component wearing a system font.
     Sized up to 15px with a little tracking because Bangers at 12.5px on a
     two-line sentence is genuinely hard, which is the real reason a reviewer
     defended the system stack here. */
  padding: 8px 12px; font-family: var(--display), Bangers, sans-serif;
  font-size: var(--fs-3); font-weight: 400; letter-spacing: .02em; line-height: 1.22;
  text-align: center; animation: hlwSayIn .34s cubic-bezier(.34, 1.56, .64, 1) both;
}
.hlw-say::after {
  content: ''; position: absolute; left: 50%; bottom: -11px; margin-left: -6px;
  border: 6px solid transparent; border-top-color: var(--ink, #17151d);
  border-bottom-width: 5px;
}
@keyframes hlwSayIn { from { opacity: 0; transform: translateX(-50%) translateY(6px) scale(.94); }
                        to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } }

/* THE COMMON CRATE'S AUTHORED FRAMES.
   Every frame is stacked in the same box and only `.on` is shown, so stepping
   the sequence is a class swap with no layout and no reflow. They are all in
   the DOM from the start, which is also what lets openPackReveal decode them
   before the sequence is allowed to run.
   144px is 48 x 3 EXACTLY. The vector crates use 148, which is what the icon
   path hands out, but 148 is not a multiple of 48 and pixel art resampled off
   its grid turns to mush. image-rendering: pixelated keeps the edges hard at
   whatever the device pixel ratio does on top. */
/* the backpack / shop / reveal-card chest icons: hard pixels, never smoothed */
.crate-ico-pix { display: block; image-rendering: pixelated; }
.co-seq { position: relative; display: block; width: 144px; height: 144px; margin: 0 auto; }
.co-seq img {
  position: absolute; inset: 0; width: 144px; height: 144px;
  image-rendering: pixelated; opacity: 0;
  /* A HARD SHADOW, NOT A BLUR. drop-shadow(0 4px 6px) puts a soft grey ramp
     around art that has no soft anything in it, and the ramp is off the pixel
     grid by construction. 3px is exactly ONE art pixel at 3x. */
  filter: drop-shadow(3px 3px 0 rgba(0, 0, 0, .55));
}
.co-seq img.on { opacity: 1; }
/* Reduced motion gets the OPEN state, not the closed one. The point of the beat
   is that the crate opened; freezing on frame 0 would tell a player who cannot
   have animation that nothing happened. */
@media (prefers-reduced-motion: reduce) {
  .co-seq img { opacity: 0 !important; }
  .co-seq img:last-child { opacity: 1 !important; }
}

/* ---- THE HOLLOW'S MUSIC CONTROL ----
   Muted is the default state and the button is the only way out of it, so on a
   first visit it has to read as "press me" rather than as a status light in a
   corner. Tom asked for exactly that, twice. The invite is a slow breathing
   ring, not a flash: it should catch the eye on a scene the player is looking
   at anyway, and it stops the moment they answer either way.
   Placed left so it never collides with the coin pill on the right. */
.hlw-mute {
  position: absolute; left: 14px; top: 14px; z-index: 20;
  display: inline-flex; align-items: center; justify-content: center;
  width: 46px; height: 46px; padding: 0;
  border: 0; border-radius: 999px; cursor: pointer;
  background: rgba(13, 12, 18, .42); backdrop-filter: blur(10px);
  color: #f2e9d7; -webkit-tap-highlight-color: transparent;
}
.hlw-mute:active { transform: scale(.94); }
.hlw-mute svg { display: block; }
.hlw-mute.invite { animation: hlwMuteInvite 2.2s ease-in-out infinite; }
@keyframes hlwMuteInvite {
  0%, 100% { box-shadow: 0 0 0 0 rgba(165, 232, 71, .55); background: rgba(13, 12, 18, .42); }
  50%      { box-shadow: 0 0 0 9px rgba(165, 232, 71, 0);  background: rgba(40, 58, 20, .62); }
}
/* An invitation that pulses forever is nagging, and a player who cannot see
   motion gets no invitation at all, so it becomes a steady ring instead. */
@media (prefers-reduced-motion: reduce) {
  .hlw-mute.invite { animation: none; box-shadow: 0 0 0 3px rgba(165, 232, 71, .5); }
}
/* Tom's 48px currency art. Only ever drawn at 48, 24 or 16, so it is always a
   whole-number reduction and never resampled. */
.ico.pix-cur { image-rendering: pixelated; vertical-align: middle; display: inline-block; }

/* A CRATE CHIP, WITH RARITY CARRIED BY LIGHT INSTEAD OF DETAIL (js/app.js
   crateChip). At 16px there is only one chest drawing, so gold and common would
   read identically; the gold one gets a halo instead.
   THE HALO IS A PSEUDO-ELEMENT BEHIND THE SPRITE, and it must stay one. A
   filter: drop-shadow() or blur() on the <img> resamples the sprite, which is
   the exact defect the 48/24/16 steps exist to avoid, and a bloom painted OVER
   the pixel crate once erased its outline (see .pack-bloom.pix above). */
.crate-chip { position: relative; display: inline-flex; align-items: center; line-height: 0; }
.crate-chip > * { position: relative; z-index: 1; }
.crate-chip.rare::before {
  content: ''; position: absolute; inset: -5px; border-radius: 50%; z-index: 0; pointer-events: none;
  background: radial-gradient(circle, rgba(232, 194, 77, .55), rgba(232, 194, 77, 0) 70%);
}

/* ============ THE RACK: the cosmetic shop ============ */

/* An aura is a BOUGHT halo on the weapon. It uses the same one property the
   rarity halo uses (a single drop-shadow on the same <img>) and avatarLayersHtml
   emits one class or the other, never both, so a weapon can never wear two
   glows. Rarity has not gone anywhere: it is the tile border, below. */
/* ONE drop-shadow, not two: a bought aura is stronger than a rarity halo (6px)
   because it is the thing you paid for, but it is still a single glow, so
   "exactly one glow on this object" stays a checkable number. */
.bh-anim img.wpn-aura.a-tide { filter: drop-shadow(0 0 11px rgba(125, 200, 255, 1)); }
.bh-anim img.wpn-aura.a-ash { filter: drop-shadow(0 0 11px rgba(240, 236, 225, 1)); }

.rk-theme { display: flex; align-items: center; gap: 8px; margin: 2px 2px 10px; }
.rk-theme b { font-size: var(--fs-0); font-weight: 800; letter-spacing: .12em; color: var(--text-2); }
.rk-theme i { flex: 1; height: 1px; background: var(--line); }
.rk-theme span { font-size: var(--fs-0); color: var(--text-3); }

/* THREE WIDE, NINE TILES (Tom, after the Finch shop). Measured on the render:
   a tile is 115px at 393x852 and 129px at 440x956, against 176/198 two-wide.
   Everything below is what that width forced, and each of it was measured, not
   guessed. */
/* THE TRANSMUTE PROGRESS METER. A count buried at the end of a paragraph is not
   an answer to "can I do this yet", so the row carries a bar. Deliberately the
   same ink as the rest of the crate row rather than a new accent: this is a
   readout, not a call to action, and the button beside it is the action. */
/* ---- transmute slot strip (option B with option C's glow, Tom 2026-08-29).
   The sockets reuse the shop/socket border language; the out-socket's violet is
   the ritual glow from the C mockup, lit only when the six are on the table.
   The old .tm-meter rules stay for one release below in case a cached shell
   paints before its module updates. */
.tmx { display: flex; align-items: center; gap: 5px; margin-top: 9px; }
.tmx-sock { width: 30px; height: 30px; border-radius: 8px; flex: none;
  border: 2px solid var(--line-strong); background: var(--surface-2);
  display: grid; place-items: center; }
.tmx-sock.emp { opacity: .32; }
.tmx-arrow { color: var(--text-3); font-weight: 800; margin: 0 1px; }
.tmx-out { border-radius: 50%; }
.tmx-out:not(.on) { opacity: .45; }
.tmx-out.on { border-color: #b48aff; box-shadow: 0 0 9px rgba(180, 138, 255, .5); }
@keyframes tmxBloom {
  0% { box-shadow: 0 0 9px rgba(180, 138, 255, .5); transform: scale(1); }
  45% { box-shadow: 0 0 26px rgba(180, 138, 255, .95); transform: scale(1.22); }
  100% { box-shadow: 0 0 9px rgba(180, 138, 255, .5); transform: scale(1); }
}
.tmx-out.bloom { animation: tmxBloom 620ms ease-out 1; }
/* the fly-in: clones only, one transform animation per clone, per the
   two-animations-one-property lesson. Reduced motion never mints a clone. */
.tmx-fly { position: fixed; z-index: 300; pointer-events: none; margin: 0;
  transition: transform 360ms cubic-bezier(.5, .05, .75, .4), opacity 360ms linear; }

.tm-meter { display: flex; align-items: center; gap: 6px; position: relative;
  width: 140px; max-width: 100%; margin-top: 9px; font-size: var(--fs-0); font-weight: 700;
  letter-spacing: .04em; color: var(--text-3); text-transform: uppercase; }
/* THE EMPTY REMAINDER IS THE POINT. A fill with no track behind it is just a
   line: 3 of 6 and 5 of 6 look the same at a glance, which is the confusion this
   row is being fixed for. The track is the full width, the fill sits on it. */
.tm-meter::before { content: ''; position: absolute; left: 0; right: 0; top: -4px;
  height: 3px; border-radius: 2px; background: var(--line-strong); opacity: .7; }
.tm-meter i { display: block; position: absolute; left: 0; top: -4px; height: 3px;
  border-radius: 2px; background: var(--text-3); opacity: .55; max-width: 100%; }
.tm-meter b { color: var(--text-2); font-variant-numeric: tabular-nums; }
.tm-meter.full i { background: var(--lime, #a5e847); opacity: .9; }
.tm-meter.full b { color: var(--lime, #a5e847); }

.rk-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
/* THE BROWSE SHELF IS DENSER THAN THE HERO SHELF, AND THAT IS A RESOLUTION FIX
   AS MUCH AS A VISUAL ONE. The themed nine draw from 640 masters; the rotating
   twelve draw from the 384 tier so twelve more mannequins fit under the 90 MB
   ceiling (measured: masters put the Shop at 115.6 MB, and even six of them did
   not fit). But .rk-stage crops to the body part with a scale, so at three
   across on a 440 phone a tile's layer is 292 CSS px, which is 583 device px
   against a 384 source: 1.52x, the exact upscale art-resolution-audit's own
   header records as the reason the rack moved to masters in the first place. I
   reintroduced it for this shelf and shipped it in v462.
   Four across brings the same layer to about 216 CSS px (432 device, 1.13x)
   with the crop and the memory tier both intact. minmax(0, 1fr), never
   repeat(N, 1fr), which grid-min-width-audit bans outright. */
.rk-grid.rot { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.rk-grid.rot .rk { padding: 5px 4px 6px; border-radius: 11px; }
/* One tile language, four times. Rarity is the border and nothing else, so the
   tile carries a name, two prices and no third line of text. */
.rk { background: var(--surface-2); border: 1.5px solid var(--line-strong); border-radius: 13px;
  padding: 6px 6px 8px; text-align: center; }
.rk.r-uncommon { border-color: color-mix(in srgb, #9fac9f 60%, var(--line-strong)); }
.rk.r-rare { border-color: color-mix(in srgb, #60a5fa 60%, var(--line-strong)); }
.rk.r-epic { border-color: color-mix(in srgb, var(--violet) 62%, var(--line-strong)); }
.rk.r-legendary { border-color: color-mix(in srgb, #ffc961 62%, var(--line-strong)); }
/* Two lines, reserved whether the name uses them or not, so a one-line name and
   a two-line name do not produce two different tile heights in the same row.
   Measured at 393: the longest name on the rack wraps to 2 lines and 0 of 9 are
   truncated. line-clamp is the backstop, not the plan. */
.rk > b { display: -webkit-box; margin-top: 2px; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
  font-size: var(--fs-1); line-height: 1.25; height: 28px; margin: 5px 0 6px; }
/* PRESENCE. Measured off the eight-tile render, clipped to each stage: the
   selected Shop chip is a solid coral fill at 89% saturated area, while White
   Hi-Tops measured 0.0% and Yellow X Tee 2.0%. The nav chip is shipped global
   chrome and out of scope from a mockup branch, so the rack answers on its own
   side: every stage sits on its own rarity-tinted ground instead of eight
   copies of the same flat --surface-3. A bone-white sneaker now has a colour to
   be seen against, and the grid reads as eight tiles rather than one texture.
   Same --rar-rgb ladder the pack reveal already uses; nothing new invented. */
.rk, .ton-stage { --rar-rgb: 185, 172, 151; }
.rk.r-uncommon, .ton-stage.r-uncommon { --rar-rgb: 162, 224, 166; }
.rk.r-rare, .ton-stage.r-rare { --rar-rgb: 124, 196, 255; }
.rk.r-epic, .ton-stage.r-epic { --rar-rgb: 155, 146, 232; }
.rk.r-legendary, .ton-stage.r-legendary { --rar-rgb: var(--gold-rgb); }
/* THE STAGE IS THE TRY-ON CONTROL. Not a 44px icon parked in a corner of a
   101px tile, which would have eaten 43% of its width: the art itself is the
   button, so the target is the whole 101x87 stage and the tile gains no chrome
   for it. The magnifier is a hint sitting on top, not the hit area. */
.rk-stage { display: block; border: 0; padding: 0; font: inherit; color: inherit;
  position: relative; width: 100%; aspect-ratio: 1 / 0.86; border-radius: 12px; overflow: hidden;
  background: radial-gradient(ellipse at 50% 62%, rgba(var(--rar-rgb), .30) 0%, rgba(var(--rar-rgb), .12) 55%, rgba(var(--rar-rgb), .04) 100%), var(--surface-3); }
.rk-stage .bh-anim { position: absolute; inset: 0; }
.rk-stage .bh-anim img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* THE ART ARRIVES ALREADY FRAMED, so the frame is not applied twice. Tom,
   2026-09-18: "the shop tiles are not big on a phone, the original art assembled
   can look good in the shop make it work." The browse shelf's tile is
   assets/bh/thumb/rack/<id>.webp, the mannequin and the piece assembled and cut
   to this very .fit-* window from Cam's originals (the three numbers each
   transform is built from are data/boneheadz.js BH_FIT_CROP now, and
   scripts/build-rack-cuts.py cuts from them), so the image IS the window and
   transforming it again would crop the crop. The .fit-* class stays on the stage
   for the `animation: none` it carries and so js/app.js renderShop can put the
   transform back by removing this one class if the tile ever 404s.
   Specificity, not order: two classes on .rk-stage beat the one on .fit-*. */
.rk-stage.rk-cut .bh-anim { transform: none; }
/* STACKED, not side by side. Two pills across a 115px tile leaves 46px each,
   and a pill is an icon plus up to five digits: measured, the coin pill needs
   72px to render "3,000" without wrapping. So the prices stack. They keep the
   full tile width each, which makes them BIGGER tap targets than the two-wide
   build had (115x44 against 76x44 at 393). */
.rk-try { position: absolute; right: 3px; bottom: 3px; z-index: 2; width: 17px; height: 17px;
  display: grid; place-items: center; border-radius: 50%; pointer-events: none;
  background: rgba(22, 21, 29, .72); color: var(--text-2); }
.rk-try svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 2.4; }

/* min-height is load-bearing: an owned card carries ONE inert marker where the
   others carry two pills and the word between them, and without a floor the
   owned card would be 70px shorter than its neighbours and the row would go
   ragged. The runner asserts every tile in a viewport is the same height, so
   drift here goes red rather than silent. */
.rk-buy { display: flex; flex-direction: column; justify-content: center; gap: 4px; min-height: 106px; }
/* The word that says these are ALTERNATIVES. Two identical full-width pills
   stacked read as "3,000 AND 200", a bill in two parts, and the whole mechanic
   is that they are two ways to pay for one thing. */
/* 44px minimum, measured: the first build shipped 31px-tall buy buttons. On the
   owned tile the marker is the thing that has to clear 44, so the rule is on
   every child of the row, not on the price pill alone. */
/* The 44px floor is for CONTROLS, not for the word between them. Scoping this
   to `> *` made the "or" a 44px-tall element, which added 49px to every tile
   and made the runner report it as a tap target. */
.rk-buy > button, .rk-buy > .rk-aura-toggle {
  width: 100%; min-height: 44px; border-radius: 11px; font-weight: 800;
  font-size: var(--fs-2); letter-spacing: .02em; display: inline-flex; align-items: center;
  justify-content: center; gap: 6px; }
/* The aura's wear/take-off control shares the price footprint. Its hollow
   state colors belong here, so affordable coin and dust prices keep their
   currency fill and ink digits from .t3-price. */
.rk-aura-toggle { background: none; border: 1.5px solid var(--line-strong); color: var(--text-2); }
.rk-aura-toggle.on { border-color: rgba(125, 200, 255, .7); color: #7dc8ff; }
.rk-owned { justify-content: center; padding: 4px 6px; font-size: var(--fs-3); min-height: 44px; }

/* OUT OF REACH IS STILL A PRICE. Keep the target size, icon and digits readable
   and tappable so a refusal can explain the shortfall. Affordable prices retain
   their currency fill and ink digits. Short wallets get a hollow pill with
   currency-colored digits, a subdued border and no shadow; pet prices use
   data-short for the same state. Rendered contrast and emphasis are guarded by
   r8-shop-css-browser-audit.mjs. The former numeric claims here did not describe
   the shipped cascade. */
.rk-buy .t3-price.cant, .t3-price[data-short="1"] { background: transparent; box-shadow: none;
  color: var(--gold); border-color: color-mix(in srgb, var(--gold) 45%, transparent); }
.rk-buy .t3-price.dust.cant { color: var(--violet); border-color: color-mix(in srgb, var(--violet) 45%, transparent); }

/* OWNED IS INERT, and it used to be the loudest thing on a shop rack. It was a
   solid --accent pill in the same shape as the buy prices, sitting on the one
   card a player cannot act on, and it collided with the reroll's --mint FREE
   pill besides: two different greens both meaning "good" on one screen. It is a
   check and a dimmed card now. No fill, no border, no green, and the card's art
   drops to half opacity so the eye skips it on the way to the eight for sale. */
.rk.owned .rk-stage { opacity: .45; }
.rk.owned > b { color: var(--text-3); }
/* --text-2, not --text-3: measured off the render at --text-3 this was 4.38:1
   against its own card, an actual WCAG AA failure at 11px. Inert is a job for
   shape and colour (no fill, no border, no green, dimmed art), never for
   contrast. At --text-2 it reads 7.5:1 and is still plainly not a button. */
.rk-owned { display: inline-flex; align-items: center; justify-content: center; gap: 5px;
  color: var(--text-2); font-size: var(--fs-0); font-weight: 700; letter-spacing: .06em; }
.rk-owned svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 2.6; }

/* Crop each preview to the part of the figure the piece occupies. Centres and
   scales are MEASURED alpha bounding boxes (median over every asset in the
   slot): H (.425,.209) w.322, T (.455,.505) w.486, P (.509,.657) h.270,
   FW (.495,.832) w.278, IR (.388,.670) w.508.
   animation:none is load-bearing: .bh-anim carries the bh-idle keyframes, which
   animate `transform`, and a running animation outranks a plain declaration in
   the cascade. Without it the crop simply does not happen. */
.fit-head .bh-anim, .fit-torso .bh-anim, .fit-hips .bh-anim, .fit-feet .bh-anim, .fit-hand .bh-anim,
.fit-hand-l .bh-anim, .fit-body .bh-anim, .fit-shin .bh-anim, .fit-waist .bh-anim, .fit-fbhead .bh-anim { animation: none; }
.fit-head .bh-anim { transform-origin: 43% 21%; transform: translate(4%, 26%) scale(2.3); }
/* A FOOTBALL HELMET IS NOT A HAT (Tom, 2026-09-04: the Kit room's helmet tile is
   "too zoomed in"). .fit-head above frames a skull for HEADWEAR; a football
   helmet is shell plus a facemask hanging down over the whole face, and at 2.3
   it blew off all four edges of its square.

   MEASURED on the rendered 88px tile, the garment's own silhouette read as an
   ALPHA -- the layer alone over a black ground and again over a white one --
   because the obvious background diff measures the CONTRAST rather than the art
   and reported this same helmet at two different sizes on two differently
   coloured surfaces:

     garment         ink % of tile      does the art run off the tile?
     helmet        94.2  ->  43.4     ALL FOUR (L83 R45 T88 B46)  ->  none
     jersey        42.6      42.6     none                        (unchanged)
     cleats        33.4      33.4     none                        (unchanged)
     lizard helm   23.6      23.6     none                        (unchanged)
     lizard jersey 20.7      20.7     none                        (unchanged)

   1.40 was picked from a seven-scale sweep of the real tile (2.30, 2.00, 1.80,
   1.60, 1.40, 1.20, 1.00, then three translate variants around the winner). It
   lands the helmet level with the jersey Tom said reads correctly and inside its
   frame on every side, which is what a discrete object wants: the jersey's
   sleeve and the cleats' soles are allowed to leave their frames because those
   are crops of a body, not of a thing. The translate moves with the scale so the
   art stays centred -- measured centre 48%/49% of the tile, against 46%/45% if
   the old translate is kept. Same 1/0.86 window as every other .fit-*, so this
   one declaration serves the Kit-room tile, the rack stage, a reveal card and
   the wardrobe's colourway rail. Re-measured, with its own negative control,
   by tests/football-tile-crop-audit.mjs on every run. */
.fit-fbhead .bh-anim { transform-origin: 43% 21%; transform: translate(7%, 31%) scale(1.4); }
.fit-torso .bh-anim { transform-origin: 46% 51%; transform: translate(4%, -1%) scale(1.7); }
.fit-hips .bh-anim { transform-origin: 51% 66%; transform: translate(-1%, -16%) scale(2.0); }
.fit-feet .bh-anim { transform-origin: 49% 83%; transform: translate(1%, -33%) scale(2.2); }
/* The aura tile frames the weapon hand, not the whole body: the halo is on the
   held item, so that is what the preview has to show. */
/* The aura tile frames the weapon hand of the BASE SKELETON, carrying the plain
   common katana IR7-3 (measured bbox centre .402/.727, width .600 of canvas). The
   first carrier, IR7-1, dangles a bright yellow smiley that out-shouted the
   thing for sale. */
.fit-hand .bh-anim { transform-origin: 40% 73%; transform: translate(10%, -23%) scale(1.6); }
/* The four crops the nine-tile rack added. Same recipe as the five above:
   transform-origin IS the slot's measured alpha-bbox centre, the translate is
   (50 - centre)%, and the scale is chosen so the piece fills the stage rather
   than the canvas. fit-hand-l is not a mirror of fit-hand: the left hand's ink
   sits at .698,.341, high and to the right, where the right hand's sits at
   .388,.670, low and to the left, so the two hand tiles do not show the same
   corner of the same skeleton. */
.fit-hand-l .bh-anim { transform-origin: 70% 34%; transform: translate(-20%, 16%) scale(1.6); }
.fit-body .bh-anim { transform-origin: 50% 63%; transform: translate(0%, -13%) scale(1.35); }
.fit-shin .bh-anim { transform-origin: 49% 82%; transform: translate(1%, -32%) scale(2.5); }
.fit-waist .bh-anim { transform-origin: 51% 60%; transform: translate(-1%, -10%) scale(2.8); }
/* The one tile that is not selling an object says so, in a FULL WIDTH strip no
   other tile has. The first pass was an 8px corner chip: the smallest type on
   the screen carrying the only thing that stops a player reading the katana as
   the product. A structural difference beats a smaller label. */
/* The strip was --violet, which is the exact colour of the dust pill sitting
   two rows below it and of the epic rarity border around it, so a CATEGORY
   badge was wearing a CURRENCY badge's colour. It is bone on ink now: the only
   near-achromatic signal on the screen (saturation .11) against gold .62,
   violet .37, coral .66, mint .71, blue .56 and accent .69, so it cannot be
   read as a rarity or a price. The strip is what changed; the aura did not. */
/* Measured, not guessed, and the first guess was wrong. The two-wide build's
   10px/.1em needs 128.9px of text width; a three-wide stage is 101px at 393, so
   it wrapped onto THREE lines and buried a third of the art. At 8px/.04em the
   text measures 96.9px against 101px available. nowrap makes an overflow a
   visible defect rather than a silent reflow.
   (scrollWidth cannot measure this: with overflow visible it reports the
   padding box, so every font size came back "fits" and the check proved
   nothing. The number above is a Range over the text node.)
   8.5px with the tracking dropped measures 96.6px, so it buys a larger glyph
   for the same width as 8px/.04em at 96.9px. It is still the smallest type on
   the rack, which is a real cost and is called out in the report. */
/* EVERY CARD CARRIES ONE, which is why this stopped being the AURA strip. As
   the only tag on the rack it made the other eight cards look untagged by
   mistake, and rarity was separately illegible: a faint tinted border and a
   tinted ground, with nothing naming the code and no legend to look it up in.
   The strip spells the rarity in the rarity's OWN colour, so the card teaches
   the code itself, and the aura's keeps ANY WEAPON and stays the only INVERTED
   one, bone fill and ink text against dark-on-tint everywhere else, so it is
   still structurally the odd card out rather than one more label.
   Shrinking the type was the wrong lever and the phrase was the right one. This
   label had been cut to 8.5px, smaller than the tile names at 11.5px, which
   made the most load-bearing words on the rack the hardest to read. Largest
   size fitting the 101px stage at 393, measured with a Range over the text
   node: "AURA . ANY WEAPON" 8.5px, "FITS ANY WEAPON" 9px, "ON ANY WEAPON"
   9.5px, "ANY WEAPON" 12px. The word AURA moved to the name directly beneath,
   which was always going to carry it. LEGENDARY, the longest rarity, is shorter
   than ANY WEAPON, so nothing else has to shrink to keep it company. */
.rk-tag { position: absolute; top: 0; left: 0; right: 0; z-index: 2; text-align: center;
  font-size: var(--fs-1); font-weight: 800; letter-spacing: .04em; white-space: nowrap; padding: 3px 0;
  color: var(--ink); background: var(--text); }
/* Rarity, on every card, under the art rather than over it, in the rarity's own
   colour so the tinted ground and border stop being a code with no key. */
.rk-rar { display: block; font-size: var(--fs-tiny); font-weight: 800; letter-spacing: .1em;
  margin: 5px 0 0; color: rgb(var(--rar-rgb)); }

/* One control. No printed ladder: the price on the button IS the price. */
/* WHERE THE LAST ROW LANDS. Two pieces of shipped chrome float over this
   screen and neither may be restyled from a mockup branch: the settings gear
   (fixed, 38px, top right, z20) and the fab, whose lime glow (0 8px 24px)
   reaches 16px above its own top edge into the bottom of the scroller. On a
   shop screen app chrome on top of goods is wrong, and the one lever the rack
   owns is where its own scroll ENDS. This tail is tuned by measurement so that
   at scroll-bottom, the rest position, the gear band and the fab-glow band both
   land on tile chrome and empty tail rather than on product art. Mid-scroll
   transit under a fixed gear is app-wide behaviour and no rack padding removes
   it; see the report.
   PARTIAL, and knowingly so: three columns widened the price pills to the full
   tile, so the top-right PILL now reaches under the gear where the two-wide
   76px pill did not (measured 31x36px at 393, 31x18px at 440, at scroll
   bottom). There is no tail height that clears both the art and the pills at
   both viewports: 393 has exactly one clean value, 0, and 440 has exactly one,
   78. The art is what this value protects.
   Re-solved again after the buy row grew the "or" line and the head grew a
   wallet row, both of which move every row's pitch: the window clean at BOTH
   393 and 440 is now 6 to 66px, and 36 is its middle. It was 96, which is
   outside that window and puts the gear back on a stage. The
   A smaller tail is also less dead space under the rack, which was a separate
   note. */
.rk-tail { height: 16px; }

/* 2026-09-05: moved from after the rotating grid to beside its own header, so
   the free first reroll is visible before the 12 tiles rather than after them
   (margin-bottom added for the same gap it used to get from margin-top alone,
   now that the grid follows it instead of the other way round). */
.rk-reroll { display: flex; align-items: center; gap: 10px; width: 100%; margin: 10px 0;
  background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius-sm);
  box-shadow: var(--sh-sm); padding: 9px 12px; color: var(--text); }
.rk-reroll b { text-align: left; font-size: var(--fs-3); }
.rk-reroll:disabled { opacity: 0.45; }

/* ============ the try-on sheet ============
   One piece, on the player's own bonehead, over their real outfit. The grid is
   neutral on purpose and this is personal on purpose. Nothing here is wired to
   a purchase and closing the sheet writes nothing. */
/* THE ITEM IS THE HERO AND IT IS SHOWN AT SIZE. Measured on the old render at
   440x956: the stage was 300px, the whole sheet 499px tall on a 956px screen,
   and the player's opaque BG plate covered 61.5% of the stage. So the piece a
   player is deciding to buy was a third of a third of the screen, on the same
   olive swatch whatever its rarity.
   86vw with a 380px ceiling, which is the largest square that still leaves the
   name, both prices and the reach line above the fold at 393x852 (measured,
   see the report). */
.ton-stage { position: relative; width: 86vw; max-width: 380px; margin: 2px auto 0; aspect-ratio: 1 / 1;
  border-radius: 18px; overflow: hidden;
  background: radial-gradient(ellipse at 50% 62%, rgba(var(--rar-rgb), .30) 0%, rgba(var(--rar-rgb), .12) 55%, rgba(var(--rar-rgb), .04) 100%), var(--surface-3); }
/* THE LIGHT IS BEHIND THE SPRITE, never on it. A separate stacking context at
   z-index 0 with the figure at 1: no filter, no blur, nothing that resamples a
   single pixel of Cam's art. Same rule the weapon halos already follow. */
.ton-burst { position: absolute; inset: -14%; z-index: 0; pointer-events: none; }
.ton-burst .burst-gl { display: block; width: 100%; height: 100%; }
.ton-fig { position: absolute; inset: 0; z-index: 1; }
.ton-stage .bh-anim { position: absolute; inset: 0; }
.ton-stage .bh-anim img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }

/* DIRECTION A, SPOTLIGHT: the pack reveal's own vocabulary. The volumetric ray
   fan from js/crate-fx.js, tinted by rarity from the same BURST table, behind
   the figure. Reused rather than reinvented on purpose: a second language for
   "this is exciting" in one app is one too many.
   DIRECTION B, PLINTH: no burst. A floor the figure stands on, a contact
   shadow under it and a vignette, so the stage is a place rather than a
   swatch. Kept on the branch under ?ton=b so the comparison can be re-rendered.
   Both share the ground, the size and the landing; only the light differs. */
.ton-stage.d-b .ton-burst { display: none; }
.ton-stage.d-b::before { content: ''; position: absolute; z-index: 0; left: 6%; right: 6%; bottom: 8%; height: 26%;
  border-radius: 50%; background: radial-gradient(ellipse at 50% 50%, rgba(var(--rar-rgb), .38) 0%, rgba(var(--rar-rgb), .10) 58%, transparent 78%); }
/* THE VIGNETTE IS SHARED, AND IT SITS BEHIND THE FIGURE. Measured on the first
   pair of renders: direction A's burst lifted the stage corners from 32.7 to
   47.0 mean luminance, so the light stopped being a light and became a wash,
   and the figure's centre-to-corner separation fell from 2.61 to 1.78. B's
   vignette is what fixed that, so it belongs to both.
   z-index 0 is load-bearing and it is the reason this is legal: ::after is the
   last child in paint order at that level, so it darkens the burst and the
   ground and is then painted OVER by .ton-fig at z-index 1. Not one pixel of
   Cam's art is darkened, tinted, filtered or resampled by it. */
.ton-stage::after { content: ''; position: absolute; z-index: 0; inset: 0; pointer-events: none;
  background: radial-gradient(ellipse at 50% 55%, transparent 40%, rgba(9, 8, 14, .62) 100%); }

/* THE FIGURE LANDS. It does not cut in. `.sheet-in` is applied by
   revealWhenReady once every layer has DECODED, so the landing can never start
   on an undecoded frame, and the animation is finite with no fill beyond its
   own end so nothing is left stuck if the sheet closes mid-beat. */
#sheets .sheet-body.sheet-in .ton-fig { animation: tonLand .42s cubic-bezier(.18,.86,.26,1) both; }
@keyframes tonLand {
  0% { opacity: 0; transform: translateY(16px) scale(.93); }
  100% { opacity: 1; transform: none; }
}
/* THE PIECE ARRIVES ON YOU. One layer of the stack, the piece being tried, on
   its own opacity and transform. Delayed past the landing so it reads as
   arriving rather than as having always been there. */
#sheets .sheet-body.sheet-in .ton-new { animation: tonArrive .34s cubic-bezier(.2,.9,.3,1) .22s both; }

/* THE PRODUCT IS THE ONLY LIT THING ON THE STAGE, and before this it was not
   lit at all. Measured on the render: eight layers are drawn on the try-on
   figure, the piece being sold (H13-5.png) computed `filter: none`, and the ONE
   glowing layer was IR10-3.png, the player's OWN EQUIPPED WEAPON wearing its
   rarity halo at drop-shadow(rgba(255,201,97,.95) 0 0 6px).
   So a stage built to sell a hat lit a weapon that is not for sale. That is the
   same confusion Tom raised about the rack ("its not clear youre buying an aura
   and not the sword") reappearing inside the sheet whose entire job is to show
   you the product.
   `.ton-new` was only an ARRIVAL animation, .34s and then nothing, which is why
   the piece read as unmarked once it had landed. It now keeps a halo in its own
   rarity colour, reusing the wpn-glow palette so the light means the same thing
   here as everywhere else in the game.
   drop-shadow, not blur or a filter on the art: it casts from the layer's alpha
   and does not resample a pixel of Cam's art, which is the same reason
   .bh-anim img.wpn-glow is allowed to use it. */
.ton-stage .ton-fig .ton-new { filter: drop-shadow(0 0 3px rgba(242, 233, 215, 0.75)); }
/* ONE COLOUR PER RARITY, READ FROM THE DECK TOKEN. These four were hand-written
   RGB, and two of them had drifted: uncommon glowed 74,222,128 (Tailwind's
   green-400) against a deck token of 162,224,166, and rare glowed 111,208,255
   against 124,196,255. Epic and legendary had been migrated to their tokens and
   the other two were missed, so two of the four rarities lit up a colour that
   appears nowhere else in their card. t2-audit's NO-TAILWIND row has been red on
   exactly this.
   Reading --rar-rgb, which the .r-* rules above already set, makes the drift
   impossible rather than merely fixed: there is now one place to change a rarity
   colour. The radius and alpha stay per-rarity because those were deliberate,
   epic and legendary being the louder pair. */
.ton-stage.r-uncommon  .ton-fig .ton-new { filter: drop-shadow(0 0 3px rgba(var(--rar-rgb), 0.75)); }
.ton-stage.r-rare      .ton-fig .ton-new { filter: drop-shadow(0 0 3px rgba(var(--rar-rgb), 0.80)); }
.ton-stage.r-epic      .ton-fig .ton-new { filter: drop-shadow(0 0 4px rgba(var(--rar-rgb), 0.80)); }
.ton-stage.r-legendary .ton-fig .ton-new { filter: drop-shadow(0 0 4px rgba(var(--rar-rgb), 0.85)); }

/* AND THE PLAYER'S OWN GEAR STANDS DOWN WHILE THEY ARE TRYING SOMETHING ON.
   Only for a garment: on an AURA try-on the weapon's halo IS the product, which
   is why this is scoped :not(.aura). Without it the two lights compete and the
   brighter one belongs to the item that is not for sale. */
/* :not(.ton-new) IS LOAD-BEARING AND I SHIPPED THIS WRONG FIRST TIME. When the
   piece for sale IS a weapon, its <img> carries BOTH wpn-glow and ton-new, and
   this selector (3 classes + an element + :not) out-specifies the product halo
   (3 classes), so the one item on the stage that SHOULD be lit went dark.
   Measured on the real rack: IL8-1, a weapon rung, computed `filter: none` while
   every garment rung lit correctly. Excluding .ton-new fixes it without
   weakening the stand-down for gear that is merely being worn. */
.ton-stage:not(.aura) .ton-fig img.wpn-glow:not(.ton-new) { filter: none; }

/* A HALO CANNOT MARK A SMALL ITEM, AND MEASURING IT SETTLED THE ARGUMENT.
   Tom: "im pretty sure youre making the grillz and other small items look worse
   than the original art". He was right, and it is not a matter of degree.

   Measured at the 384px stage, glow area against the art's OWN area, with the
   9px drop-shadow above:

     G3 grillz   12x11 on screen     31 art px    205 glow px    6.61x
     G4 grillz   33x20              120           555            4.62x
     G1 grillz   34x26              279           756            2.71x
     E1 earring  56x31              498          1104            2.22x
     H13-5 hat  145x152            9770          4347            0.44x
     T9-5 jacket 188x132          12954          4506            0.35x

   So on grillz a player was looking at SIX TIMES more of my glow than of Cam's
   art. A fixed blur is simply the wrong instrument when the items vary by 300x
   in area.

   AND NO SMALLER HALO FIXES IT. Sweeping the radius, G3 stays above 1.0x even at
   2px (1.03), where the glow is already too faint to notice. G4 is 0.95 at 2px.
   There is no radius that both marks the item and stays smaller than it, so the
   answer is not a tuned blur, it is a different instrument.

   MOTION ADDS NO PIXELS. For small slots the item's own layer breathes instead:
   the eye finds it by movement, nothing is painted around it, and not one pixel
   of the art is altered or covered. This is the same reason the arrival beat was
   already CSS on the layer itself rather than an effect drawn over it.
   PRODUCT.md: Cam's art is never redrawn. Adding 6x its own area in glow around
   it is a way of redrawing it. */
.ton-stage.tiny .ton-fig .ton-new { filter: none; animation: tonBreathe 1.9s ease-in-out 3; }
@keyframes tonBreathe {
  0%, 100% { transform: none; }
  50% { transform: scale(1.045); }
}
/* THE ITERATION COUNT IS CAPPED, NOT THE DURATION. This repo has been bitten by
   `animation-duration: .001s` under prefers-reduced-motion, which runs a loop a
   thousand times a second. Three breaths and it rests. */
@media (prefers-reduced-motion: reduce) {
  .ton-stage.tiny .ton-fig .ton-new { animation: none; }
}
@keyframes tonArrive {
  0% { opacity: 0; transform: translateY(-9px) scale(1.05); }
  60% { opacity: 1; }
  100% { opacity: 1; transform: none; }
}
/* The name, the badge and the price land last, so the buy is the conclusion of
   the moment rather than a footer that was always sitting there. */
#sheets .sheet-body.sheet-in .ton-name { animation: tonRise .34s ease-out .30s both; }
#sheets .sheet-body.sheet-in .ton-buy { animation: tonRise .34s ease-out .40s both; }
#sheets .sheet-body.sheet-in .ton-reach { animation: tonRise .34s ease-out .48s both; }
@keyframes tonRise { 0% { opacity: 0; transform: translateY(9px); } 100% { opacity: 1; transform: none; } }

/* THE FIGURE IS ALIVE HERE. bh-idle was already running on this stage and was
   measured at a 4px translate, which on a 300px stage is 1.3% and read as
   frozen. The rack tiles kill it outright so their crops sit still; the try-on
   stage is not a crop, so it keeps it and gets a stage large enough for it to
   read. Nothing about the keyframes changed. */

/* REDUCED MOTION IS ALREADY HANDLED, AND ADDING A SECOND HANDLER WAS THE BUG.
   The first version of this sheet shipped its own @media block capping bh-idle's
   iteration count. The audit went red on it and was right: the app's global
   reduce block (top of this file) already sets iteration-count 1, delay 0 and
   the 0.001s collapse with !important, on `*`, and that combination is the
   documented fix for the loop trap rather than an instance of it. A per-surface
   copy could only ever disagree with it.
   So there is nothing here. Every entrance above declares `both`, so the global
   collapse lands them on their end state instantly: measured under reduce, the
   figure, the piece and the price row all read opacity 1 with the art decoded.
   The two things CSS cannot do are done in JS, both gated on `reducedMotion`:
   the burst never mounts, and the aura's halo is on from the first frame
   instead of switching on after a beat. */
.ton-name { display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; margin: 12px 0 10px; }
.ton-name b { font-size: var(--fs-3); }
/* The status line sits against the name, where the eye already is, and it is
   the plainest sentence on the screen because it is the one a player must not
   get wrong. Coral, which is neither a currency (gold, violet) nor a rarity. */
.ton-state { font-size: var(--fs-0); font-weight: 800; letter-spacing: .08em; border-radius: 9px; padding: 4px 8px;
  color: var(--coral); background: var(--coral-soft); border: 1.5px solid color-mix(in srgb, var(--coral) 45%, transparent); }
.ton-state.own { color: var(--accent); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 55%, transparent); }
/* Side by side here: a sheet is full width, so the reason the rack stacks them
   (46px of tile) does not apply. */
/* Side by side here: a sheet is full width, so the reason the rack stacks them
   (46px of tile) does not apply. align-items is load-bearing: the OR is a 14px
   line between two 44px pills and without it the row stretches everything to
   the tallest item and the word lands against the top edge. */
/* THE BUY IS THE CONCLUSION, so it is the width of the stage above it and it is
   the biggest control on the sheet. It is still the SAME pill in the SAME three
   states as the rack, because that is what stops the sheet reading as a
   receipt: a price tag, larger, not a checkout button. */
.ton-buy { flex-direction: row; align-items: center; gap: 8px; width: 86vw; max-width: 380px; margin: 0 auto; min-height: 0; }
.ton-buy > button { flex: 1; min-height: 52px; font-size: var(--fs-body); }
/* WHERE A PLAYER WHO CANNOT AFFORD IT LANDS. Only drawn when NEITHER price is
   reachable, states the gap in both currencies, and offers the one route the
   rack already offers. No urgency, no second ask, nothing that reads as a
   pitch, and nothing here is derived from health data of any kind. */
/* 12.5px, NOT 11.5. Measured: this was the SMALLEST type on the sheet while
   carrying the most commercially important line on it (how far short you are,
   and the one route that closes the gap). It sits below the prices on purpose,
   so a player reads what it costs before what they lack, but it should not be
   the hardest thing on the screen to read. Contrast measured at 5.00:1 on the
   sheet ground, which clears AA; the problem was size, not colour. */
.ton-reach { display: block; width: 86vw; max-width: 380px; margin: 10px auto 0; background: none; border: 0;
  font-size: var(--fs-2); line-height: 1.35; color: var(--text-3); text-align: center; padding: 7px 4px; }

/* ============ what this wallet reaches ============
   A player at 340 coins could reach nothing on the old rack and the screen
   never said so. The out-of-reach pills were real and their contrast was
   measured, but a state with nothing to compare against is not perceived as a
   state, so the count is now printed and the rack always stocks one piece a
   starting wallet can buy. The dust half carries its own route, because a
   currency shown as 0 with no way to earn any is decoration. It reuses the
   shipped #shopSalvage handler, which still routes to the Salvage Bench. */
.rk-wallet { display: flex; gap: 8px; margin: 0 2px 10px; }
.rk-w { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; text-align: left;
  background: var(--surface-2); border: 1.5px solid var(--line-strong); border-radius: 10px;
  padding: 6px 8px; font-size: var(--fs-0); color: var(--text-2); }
.rk-w b { font-family: var(--display); font-weight: 400; font-size: var(--fs-3); color: var(--text); }
.rk-w i { font-style: normal; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rk-w svg { flex: none; }

/* The reroll's mechanic since 2026-08-31 is a rising PRICE, not a count, so
   the button carries the price pill and nothing else: the price IS the limit,
   and a wallet that cannot cover it gets the disabled state below rather than
   a countdown ("N left this week" left with the count cap). */
.rk-rr { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
.rk-rr b { font-size: var(--fs-3); }
.rk-rr small { font-size: var(--fs-0); color: var(--text-3); }

/* GWART'S MENAGERIE. The pet shelf sits above the rack: Tom, 2026-08-21, wanted
   "a hero banner above the normal shop stuff for bumbleseal ... below on one
   segment you can have a scrolling list of just her accessories and then the
   normal shop below that." The rack's nine cells are untouched; this is its own
   block, so the theme rules and the adjacency rule the rack lives by still hold. */
.pet-shelf { margin: 0 0 18px; }
.pet-hero { position: relative; margin: 0 0 4px; border: 2px solid rgba(242, 233, 215, .10);
  border-radius: 16px; overflow: hidden; display: flex; align-items: flex-end; gap: 6px;
  padding: 8px 12px 12px;
  background: radial-gradient(ellipse 90% 70% at 50% 46%, #4a3a10 0%, #2a2109 46%, rgba(13, 12, 18, 0) 100%); }
.pet-new { position: absolute; left: 12px; top: 11px; font-size: var(--fs-tiny); font-weight: 800;
  letter-spacing: .14em; padding: 3px 8px; border-radius: 999px; background: var(--text); color: var(--ink); }
.pet-hero-art { flex: none; margin-top: 6px; }
.pet-hero-copy { flex: 1; min-width: 0; padding-bottom: 10px; }
.pet-name { font-family: var(--display); font-size: var(--fs-6); line-height: 1.02; margin: 2px 0 5px; }
.pet-hero-copy p { font-size: var(--fs-1); opacity: .74; line-height: 1.38; margin: 0 0 9px; }
.pet-buy { font-size: var(--fs-3); }
/* scroll-snap so a flick lands on a tile rather than between two */
.pet-row { display: flex; gap: 8px; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 6px; }
.pet-row .rk { width: 112px; flex: none; scroll-snap-align: start; }
/* LOCKED, NOT HIDDEN. Every accessory is drawn for HER body and would hang in
   empty air on any other pet, so it cannot be sold before she is. Showing it
   locked says why, and is also the reason to want her. */
.pet-locked .rk-stage { opacity: .45; }
.pet-lock { font-size: var(--fs-0); font-weight: 700; color: var(--text-3); text-align: center; padding: 6px 4px; }
.rk-owned { font-size: var(--fs-0); font-weight: 700; color: var(--text-2); text-align: center; padding: 6px 4px; }

/* ===================================================================
   HER WARDROBE, in the Stable under the carousel's action row.
   v422 sold five accessories and shipped nowhere to put them on.

   A ROW OF TILES, NOT A GRID. Five pieces is one horizontal scroll and no
   more, so it reads as "what she owns" rather than as another screen; the
   shop's own .pet-row above uses the same scroll-snap for the same reason,
   and a returning player recognises the shape.
   ONE TAP TOGGLES, so the worn state has to be unmistakable at 96px: the
   fill changes, the border changes AND the label changes. Colour alone
   would leave the state invisible to anyone who cannot separate gold from
   the panel it sits on.
   44px is the floor for a touch target everywhere in this app; the tile is
   96 wide and taller than that with the art in it. */
.pet-wear { margin: 14px 0 0; }
.pet-wear[hidden] { display: none; }
.pw-empty { margin: 0 2px; }
.pw-hint { margin: 2px 2px 0; }
.pw-row, .col-fam-rail { display: flex; gap: 8px; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 6px; }
.pw-item { flex: none; width: 96px; scroll-snap-align: start; display: flex; flex-direction: column;
  align-items: center; gap: 3px; padding: 8px 6px 9px; border-radius: 14px; cursor: pointer;
  background: var(--surface); border: 1px solid var(--line); color: var(--text); }
.pw-item .pw-art { display: block; height: 62px; }
.pw-item b { font-size: var(--fs-0); line-height: 1.2; text-align: center; }
.pw-item .pw-nick { display: block; max-width: 82px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.pw-item small { font-size: var(--fs-tiny); font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
  color: var(--text-3); text-align: center; }
.pw-item.on { background: rgba(255, 201, 97, .12); border-color: var(--gold); }
.pw-item.on small { color: var(--gold); }
/* THE FAMILY TILE'S COUNT BADGE, same corner and same plate as the wardrobe's
   .ward-fam-n (extended selector at that rule) -- .pw-art has no positioning
   context of its own above. */
.pw-item.fam .pw-art { position: relative; }
/* =====================================================================   GWART'S EMPORIUM — the Shop tab's header (option A, approved 2026-08-20)
   ============================================================================
   The Shop tab drops the hub's character header entirely (no name heading, no
   portrait card, no LV/streak row, no stat pills) and puts the shopkeeper there
   instead: a full-bleed panel, Gwart large and centred, the wordmark in the
   headroom above him, the Settings gear in his bottom corner. Wardrobe,
   Backpack and Build share this hub and are untouched: the swap is one ternary
   arm in renderCharacter(), see js/app.js.

   WHY OPTION A AND NOT THE WHOLE FIGURE. Six builds were rendered and measured
   (scratchpad emporium/, sheet4.png). A is the CROP FIX, not a reframe: Gwart
   stays at the size Tom approved, face 77px, and what changes is where he is cut
   off. The complaint it fixes, in source rows off the 2048px art: the shipped
   fade started at row 1020 while both hands run 746..1105, so 23.7% of his hands
   were dissolving into the floor. He now cuts at row 1240, below the hands and
   below the belly, at the row where his feet start, so he reads as standing
   behind a counter rather than as melting into one. His feet are still absent
   and Tom accepted that: the alternatives that keep them (B/C/D) cost the face
   77 -> 60, 52 or the grid a whole extra tile row.

   EVERY NUMBER BELOW IS DERIVED FROM THE ART, NOT TUNED. The art's own alpha
   bbox is [183, 202, 1821, 1689] in the 2048 square, so:
     --gw-k     384 / 1638   the scale that makes his ink 384 CSS px wide
     --gw-side  2048 * k     the square the animation needs (Cam: aspect-ratio 1)
     (--gw-off is GONE. It was 1002 * k, the x-centre of the UNION of both
      layers' ink, and centring on that leaned the wizard 3px right because the
      stars layer's ink sits 28.5px left of centre in its own square while his
      sits half a pixel off dead centre. .gw-art centres the square instead.)
     --gw-top   70 - 202*k   drops the art so the top of the sparkle arc lands
                             exactly on the wordmark band's floor
     --gw-panel top + 1240*k + 14.5   the cut row plus the opaque floor
   Panel 327.84px, and with the chips' own 14px margin the grid pays 150.7px
   against today's 191.1px header. Product tiles: 1.08 rows at --sat 0, 0.89 at
   --sat 59.

   THE FLOOR IS var(--bg) AND THE BLOCK IS PAINTED, so the seam under the panel
   is zero BY CONSTRUCTION rather than by a matched hex. The mockup could get
   away with #111122 because it sat on a flat plate; the app's ground is a
   four-stop body gradient plus three radials, and no constant matches that at
   every y. So .gw-hero paints var(--bg) across the panel AND the 14px below it,
   the floor fades to the same var(--bg), and the two sides of the seam are the
   same declared colour.

   NO GRAIN LAYER HERE, AND THAT IS A BUG FIX, NOT TASTE. body::before is the
   app's grain and it is a BACKDROP: position fixed, z-index 0, behind #app at
   z-index 1, so an opaque panel is supposed to occlude it. An earlier build of
   this panel copied that backdrop's recipe (opacity .5, mix-blend-mode
   soft-light) as a LOCAL layer, and soft-light over near-black is a net LIFT:
   the headroom fogged to (59,59,71) at sigma 8.90 against the app's own 1.68.
   That is what Tom saw "spilling everywhere". If grain is ever wanted here the
   local idiom is .hero-scene's ::after: opacity .07 and NO blend mode.

   THE GEAR MOVES INTO THE PANEL rather than floating over Gwart's hat. The
   floating #gearBtn is gone app-wide since 2026-09-25 (every page's gear sits in
   its title row); the one in here is the same .gear-btn with the same target.
   It is the one gear still positioned, see .gw-gear below. Measured on the render: 0%
   wordmark ink and 0% art ink inside its 38px box, disc against its own ground
   1.16:1 where the floating gear sits at 1.11:1 today.

   Guard: tests/emporium-audit.mjs. */
.gw-hero {
  --gw-side: 480.117px;
  --gw-top: 22.645px;
  --gw-panel: 327.841px;
  --gw-band: 70px;
  --gw-fl: 30px;
  --gw-air: 14.5px;
  position: relative;
  /* full-bleed: out through .screen's own calc(var(--sat) + 14px) / var(--pad) */
  margin: calc(-1 * (var(--sat) + 14px)) calc(-1 * var(--pad)) 0;
  padding-bottom: 14px;
  background: var(--bg);
}
/* the chips' own 14px margin is the gap, and .gw-hero already paid it above */
.gw-hero + .ch-tabs { margin-top: 0; }
.gw-panel {
  position: relative; overflow: hidden; height: calc(var(--sat) + var(--gw-panel));
  /* isolation, because the palm glows are mix-blend-mode: screen and a screen
     blend with no stacking context of its own reaches every pixel below it. */
  isolation: isolate;
  /* K_DIM, the approved dim level (r2, Tom 2026-08-20). The four stops are the
     deck's crimson ramp at full strength — 194,21,46 / 160,17,38 / 109,14,30 /
     43,10,19 — and --gw-dim is the ONE number that sets how far down they are
     pulled, so a different level is a one-line change and not a recomputed ramp.
     At 0.45 that lands on rgb(87,9,21) at the core, which is what every contrast
     and ink number on this panel was measured against. The last stop is var(--bg)
     at zero alpha, so the glow dies into the ground rather than into black. */
  --gw-dim: 0.45;
  background: radial-gradient(ellipse 84% 66% at 50% 54%,
    rgb(calc(194 * var(--gw-dim)) calc(21 * var(--gw-dim)) calc(46 * var(--gw-dim))) 0%,
    rgb(calc(160 * var(--gw-dim)) calc(17 * var(--gw-dim)) calc(38 * var(--gw-dim))) 22%,
    rgb(calc(109 * var(--gw-dim)) calc(14 * var(--gw-dim)) calc(30 * var(--gw-dim))) 46%,
    rgba(calc(43 * var(--gw-dim)), calc(10 * var(--gw-dim)), calc(19 * var(--gw-dim)), 0.7) 72%,
    rgba(13, 12, 18, 0) 100%);
}
/* CENTRE THE SQUARE, because Cam already centred Gwart inside it. This used to
   be calc(50% - var(--gw-off)) with --gw-off derived from 1002*k, the x-centre
   of the UNION of both layers' ink. That union is dragged left by the stars
   layer, whose ink centre sits at 995.5 in its own 2048 square while the
   WIZARD's sits at 1023.5, half a pixel off dead centre. So centring the union
   leaned the subject right, measured 3.0px off centre on the rendered pixels at
   393 and spotted by Tom on sight. Expressed as half of --gw-side rather than as
   a second constant so it cannot drift if the scale is ever retuned. */
.gw-art { position: absolute; left: calc(50% - var(--gw-side) / 2); top: calc(var(--sat) + var(--gw-top)); width: var(--gw-side); }
/* rgba(13,12,18,0) is var(--bg) at zero alpha: naming `transparent` here would
   still be correct in every engine that ships today, but spelling the colour out
   is what makes the top of the fade provably the same hue as its bottom. */
.gw-floor {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; pointer-events: none;
  height: var(--gw-fl);
  background: linear-gradient(to top, var(--bg) 0, var(--bg) var(--gw-air), rgba(13, 12, 18, 0) 100%);
}
/* The app's own sticker shadow (3px 4px 0 var(--ink)), not a straight-down drop:
   every other display heading in here throws to the lower right and one that
   throws straight down reads as a different app. -3px is an optical nudge, not a
   centring fix: Bangers' apostrophe and the terminal M do not weigh the same. */
.gw-wm {
  position: absolute; left: 0; right: 0; top: calc(var(--sat) + 14px); z-index: 4;
  font-family: var(--display); font-weight: 400; font-size: var(--fs-hero); line-height: 1;
  letter-spacing: 0.004em; text-align: center; color: var(--text);
  transform: translateX(-3px); text-shadow: 3px 4px 0 var(--ink);
}
/* The Shop's gear is absolute in the panel. Its 44px hit area reaches 3px past
   the 38px disc, and the wallet band rides --gw-air up over the panel at z 3,
   so at bottom: 14px the band took the lowest 3.5px of the gear's taps (the
   pre-existing HIT failure the 2026-09-25 gear mock found). 0.5px of air on top. */
.gw-gear { position: absolute; z-index: 20; bottom: calc(var(--gw-air) + 3.5px); right: 16px; }

/* Clock base treatment. 2026-09-25 Tom approved Shop clean-up places the
   Shop clock inside the weekly rack heading; .shop-g2 below owns its sizing. */
.rk-clock { display: flex; align-items: baseline; justify-content: center; gap: 8px; margin: 10px 2px 14px; text-align: center; }
.rk-clock b { font-family: var(--display); font-weight: 400; font-size: var(--fs-5); letter-spacing: .02em; color: var(--text); }
.rk-clock span { font-size: var(--fs-1); color: var(--text-3); }

/* ---- Cam's spell-cast animation, inlined VERBATIM from tally-refs/wizard/
   animation/wizard-cast.css. Not one keyframe, duration, easing or percentage is
   altered; the only additions are the reduced-motion block Cam's README asks for
   and the .gw- prefix on nothing at all (his class names are kept so the file can
   be diffed against the handoff). ---- */
@keyframes zBreathe { 0%,100%{transform:scaleY(1)} 50%{transform:scaleY(1.012) scaleX(1.003)} }
@keyframes zEnterCine { 0%{transform:scale(.88) translateY(2.5%);opacity:0;filter:blur(8px)} 25%{filter:blur(1.5px)} 45%{opacity:.7;filter:blur(0)} 100%{transform:scale(1) translateY(0);opacity:1;filter:blur(0)} }
/* starts and ends at 0deg so the delayed loop begins without a jolt */
@keyframes zOrbitSoft { 0%,100%{transform:rotate(0deg)} 25%{transform:rotate(1.1deg)} 75%{transform:rotate(-1.1deg)} }
@keyframes zTwA { 0%,100%{opacity:1} 22%{opacity:.35} 48%{opacity:.9} 71%{opacity:.5} }
@keyframes zTwB { 0%,100%{opacity:.5} 30%{opacity:1} 55%{opacity:.4} 80%{opacity:.95} }
@keyframes zTwC { 0%,100%{opacity:.8} 18%{opacity:.45} 42%{opacity:1} 76%{opacity:.4} }
@keyframes zGlowIn { 0%{opacity:0;transform:scale(.6)} 100%{opacity:.55;transform:scale(1)} }
@keyframes zGlowIdle { 0%,100%{opacity:.35;transform:scale(1)} 50%{opacity:.75;transform:scale(1.12)} }

.wz-scene { position:relative; aspect-ratio:1; overflow:hidden; }
.wz-scene img { position:absolute; inset:0; width:100%; height:100%; }
.wz-body { position:absolute; inset:0; transform-origin:50% 95%; animation:zBreathe 5.4s ease-in-out infinite; }
.wz-enter { position:absolute; inset:0; transform-origin:50% 62%; animation:zEnterCine 2.4s cubic-bezier(0.22,1,0.36,1) both; }
/* HE ALREADY ARRIVED THIS SESSION. Not a change to the cinematic: zEnterCine
   ends at scale(1) translateY(0) opacity:1 blur(0) and is filled `both`, so an
   element that never ran it and an element that finished it are the same
   pixels. Only Today sets it (js/app.js, gwEntranceSeen); the Emporium's own
   .gw-panel entrance is deliberately left replaying, because walking into the
   shop is a place you arrive at, not the screen you keep coming back to. */
.wz-enter.seen { animation: none; }
/* The sway and the twinkle carry Cam's 2.4s delay so they begin as the entrance
   lands. With no entrance to wait for, that delay is 2.4s of a frozen wizard, so
   it is dropped HERE and nowhere else. Their durations are untouched. */
.wz-enter.seen .wz-sway, .wz-enter.seen .wz-stars-l,
.wz-enter.seen .wz-stars-m, .wz-enter.seen .wz-stars-r { animation-delay: 0s; }
.wz-sway { position:absolute; inset:0; transform-origin:50% 85%; animation:zOrbitSoft 9s ease-in-out 2.4s infinite; }
.wz-stars-l { clip-path:inset(0 62% 0 0);   animation:zTwA 3.6s ease-in-out 2.4s infinite; }
.wz-stars-m { clip-path:inset(0 38% 0 38%); animation:zTwB 4.4s ease-in-out 2.4s infinite; }
.wz-stars-r { clip-path:inset(0 0 0 62%);   animation:zTwC 3s   ease-in-out 2.4s infinite; }
.wz-glow { position:absolute; width:12%; height:9%; border-radius:50%;
  background:radial-gradient(circle,rgba(211,255,60,.55),transparent 70%);
  filter:blur(6px); mix-blend-mode:screen; opacity:.55;
  animation:zGlowIn 1.6s cubic-bezier(0.22,1,0.36,1) both, zGlowIdle 4.5s ease-in-out 2.4s infinite; }
.wz-glow.left  { left:16%; top:40%; }
.wz-glow.right { left:72%; top:40%; animation-delay:0s,4.65s; }
/* ONE ANIMATION AT A TIME, and not one keyframe, duration or easing above is
   touched. Cam's glow carries a filled one-shot AND an infinite loop, both on
   opacity and transform, and Chrome will not composite a property that two
   animations claim. The one-shot is `both`, so it never leaves the list: the
   Emporium sat at 119.9 style recalcs/second for as long as it was open while
   every other settled screen in the app read 0.0 (tests/idle-perf-audit.mjs).
   Measured on a bare page at 440x956: the pair 119.9/s, zGlowIdle alone 0.0/s.

   AND HERE IS THE HALF THAT IS EASY TO MISS, SO IT IS WRITTEN DOWN RATHER THAN
   LEFT TO BE REDISCOVERED: compositability is decided WHEN AN ANIMATION STARTS,
   and it is never re-evaluated for an animation that goes on running. Taking the
   second animation away therefore does NOT promote the element back. Nothing you
   can do to a live animation gets it onto the compositor; only an animation that
   starts fresh is decided fresh, and the one reliable way to start one fresh is
   to replace the ELEMENT, because a new node has no running animation to inherit.

   So at 2.4s renderCharacter REPLACES each glow with a bare clone carrying
   `.idle`. 2.4s is the instant zGlowIdle takes over anyway. Three cheaper
   spellings were tried on this tree and every one of them still measured 119.9/s,
   for exactly the reason above: `animation-name: none, zGlowIdle`, and restating
   the same shorthand under a class, both leave the SAME animation object in place
   so Chrome never re-decides it, and both LOOK correct in getAnimations(), which
   is what makes them expensive; an @starting-style transition in place of zGlowIn
   does the same from the other side. (animation-fill-mode backwards and will-change were
   measured at 119.9/s before that.) The one spelling that DOES re-decide it,
   changing the delay under a class, slides the running timeline instead of
   restarting it: rewriting the left glow's 2.4s to 0s put it at .740 opacity
   100ms after the swap, near the top of the pulse, where Cam's has it at .351
   near the bottom. A new element has no timeline to slide.

   So the delays below are measured FROM THE SWAP, and the look is unchanged.
   Before 2.4s only zGlowIn is drawing and it is untouched. The LEFT glow's idle
   began at 2.4s and still does, which is delay 0 from the swap. The RIGHT glow's
   began at 4.65s and still does, which is 2.25s from the swap, and until then it
   rests on the `opacity:.55` / `scale(1)` declared above, which is the pixel
   zGlowIn's fill was holding. Verified against the unpatched tree at thirteen
   offsets across two pulse cycles: both glows agree to four decimals on opacity
   and on transform at every one. */
.wz-glow.idle       { animation: zGlowIdle 4.5s ease-in-out 0s infinite; }
.wz-glow.idle.right { animation-delay: 2.25s; }

/* Cam's README: "keep the entrance as a plain fast fade, disable the sway; the
   twinkle is low-amplitude and can stay". THE DURATIONS ARE NOT COLLAPSED. The
   sway, the breathe, the twinkle and the glow idle are all INFINITE loops, and
   this repo has already been bitten by `animation-duration: .001s` under reduced
   motion, which runs a loop a thousand times a second. An infinite loop is
   switched OFF with animation-name: none. The twinkle keeps its authored
   3.6 / 4.4 / 3s. */
@media (prefers-reduced-motion: reduce) {
  .gw-panel .wz-enter, .gw-today .wz-enter { animation: zGwFade .28s linear both; }
  .gw-panel .wz-sway, .gw-panel .wz-body,
  .gw-today .wz-sway, .gw-today .wz-body { animation-name: none; }
  .gw-panel .wz-glow { animation: zGwFade .28s linear both; opacity: .45; }
}
@keyframes zGwFade { from { opacity: 0 } to { opacity: 1 } }
/* ------------------------------------------------------------ THE WANDERER
   IN THE PIT. Tom, with a mockup: "make this work better than my crude mock up
   but he should be about this big in the pit". His mockup has the Wanderer
   filling the stage, head near the top and feet on the floor, with the player's
   bonehead small at the bottom left. The point is not size, it is the GAP: it
   should read as fighting something far bigger than you.

   This is per-boss on purpose and touches nothing else. The Glutton keeps
   #foeStage.glutton-foe, the Live Wire keeps #foeStage.mage-foe, and the Mimic
   keeps the default .fstage, which is a decision rather than an oversight: the
   Mimic is a chest that bit you and is deliberately NOT a wall.

   AND THE FIRST CUT OF IT WAS TOO BIG. Tom, 2026-08-22, having fought him: "i
   just fought the wanderer and he is WAY too big in the pit and overlapping with
   my bonehead", then "im guessing what happened was you were afraid to cut off
   the tail of the wanderer in the pit, you can that's ok". He is right about the
   cause, and it is arithmetic rather than nerve. His plate is a 640-square whose
   ink occupies 562x417, and 77px of that width is the black tail wisp hanging
   off his right. Painting the WHOLE plate means the tail is part of the width
   budget, and the budget does not close: measured at 393x852 in painted pixels,
   his ink ran x8.5..319.5 of a 361px arena while the player's ran x28.5..87, so
   the two figures OVERLAPPED BY 78.5px. There is no size at which a 562-wide
   drawing and a 58-wide bonehead both fit a 361 arena with a gap, so the tail
   comes off, exactly as Tom says it can.

   WHY THE NUMBERS ARE WHAT THEY ARE, all measured off the render in PAINTED
   pixels (an element is hidden and the frame diffed), never off a stage box:
     - THE STAGE IS SQUARE. object-fit:contain paints the square at min(w, h), so
       a square box makes the painted plate and the element box the same
       rectangle, which is the only thing that lets a percentage clip below land
       on a known column of Cam's drawing.
     - THE CLIP IS 12.5%, i.e. plate x=560 of 640. Measured on the art: his body,
       his lantern and the beast's head all end left of 560 and the only ink
       right of it is the tail. So the crop takes the tail and nothing else.
     - 85cqw. Width is the binding dimension in a landscape arena, so he is sized
       off the arena's WIDTH and not its height. At 361px of arena that is 307px
       of plate, which paints 240x200 of ink: 66% of the arena's width, 61% of
       its height, and 2.65x the player's own painted ink height. The Live Wire,
       the boss this composition is measured against, is 1.76x on the same
       instrument, so the Wanderer still looms over the biggest thing that shares
       this stage.
     - 22% and -0.21. The first is the push right (the foe column is anchored
       12% in from the arena's right by positionFighters, which is not far enough
       right to leave the player a gutter); the second is the empty strip under
       his boots (21% of the plate), pulled off so he stands on the floor rather
       than a fifth of his own height above it. Measured result: his ink
       x107.6..347.3, the player's x28.5..87, so a 20px gap where there was a
       78px overlap, and his boots land within 6px of the player's. */
.arena.boss-wanderer { --wnd: clamp(220px, 85cqw, 340px); }
.arena.boss-wanderer .fighterG.foe-side { width: var(--wnd); transform: translateX(22%); }
#foeStage.wanderer-foe {
  width: var(--wnd); height: var(--wnd); align-self: end;
  /* the empty strip under his boots, pulled off so he stands on the floor */
  margin-bottom: calc(var(--wnd) * -0.21);
}
/* THE TAIL COMES OFF. clip-path rather than a smaller plate: Cam's art is the
   fixed input and is not re-exported, cropped or redrawn for this, only
   presented differently. The stage is square above, so 12.5% of the element box
   IS plate x=560. */
#foeStage.wanderer-foe .mage-plate { clip-path: inset(0 12.5% 0 0); }
/* THE PLAYER STEPS BACK AND TO THE LEFT, exactly as they do for the Live Wire,
   and further: the gap IS the design. Scaled rather than re-sized so nothing in
   the fighter's own layout (the pet, the KO pose, the lunge transforms) has to
   know about it, and anchored bottom-centre so their feet stay on the same
   floor line as his. */
.arena.boss-wanderer .fighterG:not(.foe-side) { transform: translateX(-52px); z-index: 3; }
.arena.boss-wanderer .fighterG:not(.foe-side) > .fstage { transform: scale(.58); transform-origin: bottom center; }
/* The pet came +14px toward the middle to escape a boss who filled the whole
   arena. He no longer does, and the middle is now HIS, so the pet tucks back
   beside the player instead of walking into his coat. */
.arena.boss-wanderer .pet-fighter { transform: translateX(-8px); z-index: 4; }

/* ================================================================ *
 * THE DAY IS A CONTAINER (v425, approved 2026-08-22).
 *
 * Pass one unified the card SKIN and Tom's verdict was that Today still had no
 * ANCHOR: a floating `< TODAY Aug 22 > [gear]` row reading as navigation, over a
 * pile of cards that did not visibly belong to it. So the hierarchy moved, not
 * the surfaces. The day is ONE --surface-2 well; its titlebar IS that row,
 * promoted to the header it was always governing; and calories, wellness,
 * kitchen, activity and meals are sections INSIDE it under kicker labels.
 * Containment, not indentation, so the day has a visible end as well as a start.
 * Nothing pins: this variant deliberately carries no sticky header.
 *
 * No new colours, no gradients, no glows: --surface-2, --line, --line-strong and
 * the incumbent .card / .icon-btn idioms only.
 * ================================================================ */

.dayblk {
  background: var(--surface-2); border: 1px solid var(--line-strong);
  border-radius: var(--radius); padding: 12px 10px; margin-top: 8px;
}

/* LEFT-ALIGNED ON PURPOSE: a centred title flanked by arrows is exactly the
   floating-nav read Tom objected to. Title left, the three controls grouped
   right, and everything day-scoped hangs off it. */
.dayhdr {
  display: flex; align-items: center; gap: 6px;
  padding: 0 4px 10px; border-bottom: 1px solid var(--line); margin-bottom: 12px;
}
.dayhdr .day-title { flex: 1; text-align: left; min-width: 0; }
.dayhdr .day-title h1 { font-size: var(--fs-title); line-height: 1.08; }
.dayhdr .day-title .sub { font-size: var(--fs-2); }
.dayhdr .icon-btn { width: 38px; height: 38px; }

/* the nested sections: one skin, one header idiom, one rhythm. */
.tsec { margin-bottom: 16px; }
.tsec:last-of-type { margin-bottom: 4px; }
.tsec-h {
  font-size: var(--fs-1); font-weight: 700; letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--accent); margin: 0 2px 6px;
}
.tsec .card { margin-bottom: 0; }
/* The kicker replaces the card's own NAME, so the label is not said twice. Only
   the name: the Activity card's Sync link and the Kitchen card's Collect live in
   that same title row, and hiding the whole row (as the mockup did) took a real
   control off the screen with it. The row itself only goes when nothing but the
   name was in it, which is the Activity card on a past day. */
.tsec .wellness-card > .sect-h:first-child,
.tsec > .card > .card-title:first-child > .ct-name { display: none; }
.tsec > .card > .card-title:first-child { justify-content: flex-end; }
.tsec > .card > .card-title:first-child:not(:has(> :not(.ct-name))) { display: none; }
/* meals join the sibling set: the same hand-inked panel treatment as .card
   (app.css:972), so a meal reads as a section like every other one. */
.tsec-meals .meal {
  background-color: var(--surface); background-image: var(--grain);
  background-size: 150px 150px; background-blend-mode: soft-light;
  border: 2px solid var(--ink); border-radius: var(--radius);
  box-shadow: 0 3px 0 rgba(0, 0, 0, 0.32), inset 0 0 0 1.5px rgba(242, 233, 215, 0.10);
  padding: 4px var(--pad) 12px; margin-bottom: 10px;
}
.tsec-meals .meal:last-child { margin-bottom: 0; }

/* ================================================================ *
 * AND THE DAY STOPS BEING A BOX OF BOXES (v427).
 *
 * Tom: the screen "reads as floating widgets rather than one screen". The
 * container shipped in v425 answered that at SCREEN level, and the measurement
 * agrees: 18 painted surfaces on the pre-rework screen, 4 once the day owned
 * everything (390x844, ?demo, surfaces counted as elements painting their own
 * background or keyline and not already inside one).
 *
 * INSIDE the day it was still 8. Calories, Wellness, Activity and all four
 * meals each kept the full hand-inked panel (app.css:975: 2px --ink keyline,
 * --radius corners, grain, drop edge) laid ON TOP of the day's own --surface-2
 * well, with a kicker above each one naming it a second time. That is a box
 * holding seven more boxes, which is the same complaint one level in, and it is
 * the half the container could not reach.
 *
 * So inside .dayblk a section gives up its panel and becomes a ROW of the day:
 * the well is the only surface, the kickers are the only section headers, and
 * one hairline marks each seam. No new colours and no new idioms, only --line
 * and the .tsec rhythm that is already here.
 *
 * EVERY RULE IS SCOPED TO .dayblk ON PURPOSE. `.card` has call sites all over
 * the app and this is a claim about the day, not about cards: the Kitchen, the
 * Stable and the shop keep the hand-inked skin exactly as they are. Measured
 * after: 0 boxes drawn inside the day.
 * ================================================================ */
.dayblk .tsec > .card,
.dayblk .tsec-meals .meal {
  background: none; background-image: none; background-blend-mode: normal;
  border: 0; border-radius: 0; box-shadow: none;
  padding-left: 2px; padding-right: 2px; margin-bottom: 0;
}
/* ONE HAIRLINE PER SEAM, AND NEVER A LEADING ONE. A rule above the first
   section would draw a second line immediately under .dayhdr's own bottom
   border, which reads as a double rule rather than as a seam. */
.dayblk .tsec + .tsec { border-top: 1px solid var(--line); padding-top: 14px; }
.dayblk .tsec { margin-bottom: 14px; }
/* meals are rows of the same ledger, so they take the same seam */
.dayblk .tsec-meals .meal + .meal { border-top: 1px solid var(--line); padding-top: 8px; }

/* AND THE SAME ARGUMENT ONE LEVEL IN. A meal that stopped being a panel cannot
   keep serving its foods as pills: measured, flattening the sections alone took
   the day from 8 boxes to 10, because the nine `.entry` rows that used to sit
   INSIDE a meal card became the outermost surfaces drawn on the well. A logged
   food is a row of the meal. :active keeps the press feedback, which is the
   part of the pill that was doing real work, and it swaps to --surface because
   the ground under it is now the day's --surface-2. */
.dayblk .tsec-meals .entry {
  background: none; border: 0; border-radius: 0; margin-bottom: 0;
  padding-left: 2px; padding-right: 2px;
}
.dayblk .tsec-meals .entry + .entry { border-top: 1px solid var(--line); }
.dayblk .tsec-meals .entry:active { background: var(--surface); }

/* announcements are not part of the day, so they queue after it */
.promo-slot { margin-top: 14px; }

/* ---------------- Gwart's Guide (2026-08-23) ----------------
   Tom, 2026-08-22: "clicking on gwart should take you to an explainer FAQ page".
   Three selectors, and two of them exist because a <button> brings defaults with
   it that the surrounding art never asked for. */

/* HE IS A BUTTON IN HIS OWN SHOP NOW, and the reset is the whole point: the box
   .gw-art declares above (absolute, left, top, width) is what
   tests/emporium-audit.mjs pins his geometry off, and a button's UA border,
   padding and inline-block display would move every one of those numbers. This
   rule is later in the file than .gw-art on purpose, same specificity, so source
   order settles it. */
.gw-art { display: block; padding: 0; border: 0; background: none; -webkit-appearance: none; appearance: none; color: inherit; font: inherit; text-align: inherit; cursor: pointer; }

/* The inline "What is this?". It sits in a .sect-h, which is 12px uppercase and
   already a flex row, so this is deliberately NOT a .btn: the app's button
   carries a 2px ink border and a sticker shadow that reads as the primary action
   of the section it is labelling, which is exactly what this must not be. It is
   a link with a hit area. margin-left:auto matches .sect-h .spin above. */
.gd-what {
  margin-left: auto; flex: none; min-height: 44px;
  padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; background: none;
  font: inherit; font-size: var(--fs-0); letter-spacing: 0.04em; text-transform: none;
  color: var(--text-3); cursor: pointer;
}
.gd-what:active { transform: translateY(1px); }

/* NATIVE DISCLOSURE, so open/close, the keyboard and the screen-reader role all
   come for free. Only the marker and the spacing are ours. */
/* GWART'S GUIDE. The hero card is the discoverability half: the only thing that
   told a player the wizard on Today was a button was an aria-label, which a
   sighted player never reads. Tom, 2026-08-26: "make sure that players know they
   should click on gwart if they get confused." */
.gd-hero {
  display: flex; gap: 12px; align-items: center;
  background: var(--surface-2); border: 2px solid var(--ink); border-radius: 16px;
  padding: 12px 14px; margin: 2px 2px 16px; box-shadow: 0 3px 0 rgba(0, 0, 0, .32);
}
.gd-hero img { width: 58px; height: 58px; object-fit: contain; flex: none; }
.gd-hero .t {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-5);
  letter-spacing: .02em; line-height: 1.05; color: var(--text-1);
}
.gd-hero .s { font-size: var(--fs-2); color: var(--text-3); margin-top: 3px; line-height: 1.35; }
.gd-grp { margin: 0 2px 14px; }
.gd-grp > h4 {
  font-family: var(--display); font-weight: 400; font-size: var(--fs-3);
  letter-spacing: .06em; text-transform: uppercase; color: var(--accent); margin: 0 0 1px;
}
.gd-grp > .gs { font-size: var(--fs-1); color: var(--text-3); margin: 0 0 6px; }
.gd-e { border-top: 1px solid var(--line); padding: 2px 2px 6px; }
.gd-e:last-child { border-bottom: 1px solid var(--line); }
/* inside a group the rules are between rows, not around the block */
.gd-grp .gd-e { border-top: none; border-bottom: 1px solid var(--line); }
.gd-grp .gd-e:last-child { border-bottom: none; }
/* the open one is marked, so a deep link from "What is this?" lands somewhere
   the eye finds without scrolling for it */
.gd-grp .gd-e[open] {
  background: rgba(165, 232, 71, .05); border-radius: 10px;
  box-shadow: inset 3px 0 0 var(--accent); padding-left: 9px;
}
.gd-e > summary {
  list-style: none; cursor: pointer; padding: 12px 2px;
  font-size: var(--fs-body); font-weight: 800; color: var(--text);
  display: flex; align-items: center; gap: 8px;
}
.gd-e > summary::-webkit-details-marker { display: none; }
.gd-e > summary::after {
  content: ''; margin-left: auto; width: 8px; height: 8px;
  border-right: 2px solid var(--text-3); border-bottom: 2px solid var(--text-3);
  transform: rotate(45deg) translate(-2px, -2px);
}
.gd-e[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.gd-e > p { font-size: var(--fs-3); line-height: 1.55; color: var(--text-2); margin: 0 2px 10px; }
@media (prefers-reduced-motion: reduce) { .gd-what:active { transform: none; } }

/* THE BOX IS THE NEXT LINE, NOW THAT THE PLAQUE IS THE GUIDE. Tom asked for both
   things and they are one week apart: 2026-08-21 "when you tap him he should say
   things", 2026-08-22 "clicking on gwart should take you to an explainer FAQ
   page". Giving the tap to the Guide and leaving it there would have deleted the
   older ask, so the two controls split: the plaque opens the Guide, the box he is
   speaking out of advances the line. It is also what the box's own markup always
   meant (renderToday: "the chevron means tap the box for the next line").
   It opts back IN to pointer events over .talkbox.tb-done:not(.tb-hold) above,
   and ONLY inside .gw-row, so no other talk box in the app changes: the rule
   above exists because a finished box must not eat the tap target it floats
   over, and on Today the thing underneath the box is the Bonehead stage, which
   this row's own `pointer-events: none` already handles. */
.gw-row .gw-box.tb-done { pointer-events: auto; cursor: pointer; }

/* .gw-row on Today is position:absolute against the hero island; onboarding
   has no island, so the inherited pin parked the box over the headline and
   left the wizard collapsed off-flow (seen on the rendered step, not guessed;
   and the first fix landed EARLIER in this file than .gw-row, so the pin won
   the cascade at equal specificity, measured again: box y=50 over h1 y=60).
   Doubled class beats it wherever it sits; in flow, portrait left, words right. */
.gw-row.onb-gwrow { position: static; margin: 14px 0 4px; pointer-events: none; }
.gw-row.onb-gwrow .gw-today { pointer-events: none; background: none; border: 0; padding: 0; width: auto; height: auto; position: static; float: none; }
.gw-row.onb-gwrow .wz-scene { position: relative; display: block; width: 74px; height: 74px; flex: none; }
.gw-row.onb-gwrow .wz-body { position: absolute; inset: 0; }
.gw-row.onb-gwrow .wz-body img { width: 100%; height: 100%; object-fit: contain; object-position: top; }
.gw-row.onb-gwrow .gw-box { position: static; flex: 1; }


/* A SHUT DRAWER STILL TICKS, AND THE WHOLE DOCUMENT PAYS FOR IT.
   Chrome keeps running the animations inside a closed <details>, but that
   content is skipped for paint, so nothing in it can get a compositor layer and
   every one of those animations falls onto the main thread. Per
   idle-perf-audit's own note, ONE un-compositable animation in the document
   drops the entire document onto the slow path, however cheap it is and whether
   or not you can see it.

   MEASURED. v457 put four headshots into the shut news banner on Today, each
   with the bh-idle sway and one with a weapon sheen. Settled Today went 0.0
   style recalcs/s at 482b6fff to 119.9/s at 2bee5858, the commit that added the
   banner, and stayed there. In the live page, cancelling ONLY the five
   animations inside #newsBanner took Today from 119.0/s back to 0.0/s while
   every other animation on the screen kept running, so the eighteen elements
   that were being restyled every frame were victims, not the cause.

   AND IT IS NOT ONE ANIMATION, WHICH IS WHY THIS RULE IS NOT A NAME LIST. The
   first fix killed bh-idle inside a shut disclosure and measured 119.6/s: the
   single remaining wpnCharge in there held the document on the slow path by
   itself. `will-change: transform` on `.wpn-sheen::after` does not rescue it,
   because skipped content gets no layer to be promoted to. So the rule is the
   thing that is actually true: content behind a shut disclosure is invisible,
   and invisible content does not move. The summary is excluded because it is
   the half that IS on screen. */
details:not([open]) > :not(summary),
details:not([open]) > :not(summary) *,
details:not([open]) > :not(summary) *::before,
details:not([open]) > :not(summary) *::after { animation: none !important; }
/* Same idea for loops that are merely scrolled out of view: pauseOffscreenLoops
   in js/app.js sets this only while the element is outside the viewport, so
   nothing a player can see changes. */
.loop-off { animation-play-state: paused !important; }

/* THE NEWS BANNER, collapsed by default. One line at rest so it can never become
   wallpaper the way a permanently open banner does, and a lime count when there
   is something unread. Tom, 2026-08-27: "when there is new news there can be an
   icon letting them know otherwise it stays collapsed and avoids being
   annoying?" */
.nb { margin: 0 0 10px; border: 2px solid var(--ink); border-radius: var(--radius-sm);
  background: var(--surface); box-shadow: 0 3px 0 rgba(0, 0, 0, .32); overflow: hidden; }
.nb > summary { list-style: none; cursor: pointer; display: flex; align-items: center;
  gap: 8px; padding: 10px 12px; font-size: var(--fs-2); font-weight: 800; }
.nb > summary::-webkit-details-marker { display: none; }
.nb-ico { display: flex; flex: none; }
.nb-t { letter-spacing: .07em; text-transform: uppercase; font-size: var(--fs-1); color: var(--text-2); }
.nb-dot { background: var(--accent); color: #14210a; font-size: var(--fs-0); font-weight: 900;
  min-width: 17px; height: 17px; border-radius: 9px; display: grid; place-items: center; padding: 0 5px; }
.nb-sub { margin-left: 2px; flex: 1; min-width: 0; font-weight: 600; font-size: var(--fs-2);
  color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nb-chev { display: flex; flex: none; color: var(--text-3); transform: rotate(90deg); transition: transform .18s linear; }
.nb[open] .nb-chev { transform: rotate(-90deg); }
.nb-list { border-top: 1px solid var(--line); }
.nb-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 12px;
  background: none; border: none; border-bottom: 1px solid var(--line); text-align: left; cursor: pointer; }
.nb-row:last-child { border-bottom: none; }
.nb-row:active { background: var(--surface-2); }
.nb-thumb { width: 40px; height: 40px; border-radius: 10px; overflow: hidden; flex: none;
  display: grid; place-items: center; background: var(--surface-2); }
.nb-thumb img, .nb-thumb svg { max-width: 100%; max-height: 100%; }
.nb-txt { flex: 1; min-width: 0; display: grid; gap: 1px; }
.nb-txt b { font-size: var(--fs-2); }
.nb-txt i { font-style: normal; font-size: var(--fs-1); color: var(--text-3);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nb-date { font-size: var(--fs-0); color: var(--text-3); flex: none; }

/* THE HERO SLOT, first thing behind the tap. Tom, 2026-09-03: "we need to have
   one stand out banner that is showing more than a list of homework in the new
   section that gets people excited." A list of forty-pixel tiles is the homework;
   this is the one place in the pill where the art is the message and the words
   are the caption.
   IT IS THE OLD TODAY HYPE BANNER'S TREATMENT, not a new one: the same coral
   radial over the card grain, the same measured plate boxes, the same figure
   drop-shadow. The banner moved, its design language did not, and inventing a
   second loud surface for the same job is how an app ends up with two of
   everything.
   WHY THE WASH IS THREE LAYERS WITH THREE SIZES. background-size applies to EVERY
   layer, so tiling the grain at 150px tiled both gradients at 150px too and
   painted the first render of the original banner in vertical bands. Caught in a
   screenshot, not in the source; carried over here intact. */
.nb-hero { display: block; width: 100%; padding: 12px 12px 11px; border: none;
  border-bottom: 1px solid var(--line); text-align: center; cursor: pointer;
  /* THE ONE KNOB FOR THE ART. hypePlateHtml emits percentages, so the creature
     boxes are sized here and nowhere else. 320 is the narrowest phone this app
     supports and it is what caps the small value: two plates and both lines of
     copy have to sit on the pill's content width there. */
  --hf: 88px;
  /* AND THE COLOUR LAYER IS EXPLICIT. The global `button { background: none }`
     leaves this element's background-COLOUR transparent, and background-blend-mode
     blends only within one element's own layers: soft-light against nothing is
     nothing, so the grain would simply not paint here even though the pill behind
     it is the very --surface the blend is written for. .card carries this
     declaration for exactly the same reason. */
  background-color: var(--surface);
  background-image: radial-gradient(115% 95% at 18% 0%, rgba(253, 104, 87, .34), rgba(253, 104, 87, 0) 64%),
    linear-gradient(158deg, rgba(253, 104, 87, .16), rgba(253, 104, 87, 0) 58%), var(--grain);
  background-size: cover, cover, 150px 150px;
  background-repeat: no-repeat, no-repeat, repeat;
  background-blend-mode: normal, normal, soft-light; }
@media (min-width: 380px) { .nb-hero { --hf: 104px; } }
.nb-hero:active { transform: translateY(1px); }
/* The figure boxes themselves are .hype-fig, defined once further up: fixed
   squares of --hf with the plate inside absolutely placed by hypePlateHtml's
   measured percentages, so two creatures stand on one floor. */
.nb-hero-figs { display: flex; align-items: flex-end; justify-content: center; gap: 0; min-width: 0; }
.nb-hero-txt { display: grid; gap: 2px; margin-top: 6px; }
/* CREAM ON BOTH LINES, AND THE SECOND ONE IS NOT A JUDGEMENT CALL. The accent is
   the wash, not the word: coral-on-coral measured 2.59:1 on the original banner.
   The blurb wanted --text-2 for hierarchy and cannot have it. Computed over the
   worst pixel this wash can paint (both coral layers at full strength stacked on
   --surface, rgb(130,62,60)): --text-2 is 3.48:1 against the 4.5 that 11.5px body
   text needs, --text is 6.44:1. So the two lines are separated by SIZE and FACE,
   which cost nothing, rather than by a tint that fails AA.
   AND NOT BY OPACITY EITHER. a11y-audit reads the COMPUTED colour and samples the
   background off the render, so an element faded with `opacity` reports the full
   cream it is not painting: the measurement would come back 6.44 while the player
   reads something dimmer. A colour that has to lie to a guard is the wrong fix. */
.nb-hero-txt b { font-family: var(--display); font-weight: 400; font-size: var(--fs-3);
  line-height: 1.1; letter-spacing: .02em; color: var(--text); }
.nb-hero-txt i { font-style: normal; font-size: var(--fs-1); color: var(--text); }

/* THE DAY, COLLAPSED. The wheel and the macros are the summary; meals, wellness,
   the Kitchen and activity live behind the tap. Tom, 2026-08-27: "below the fold
   should be fully collapsed and only showing a banner with the wheel and macros". */
.dayrest > summary { list-style: none; cursor: pointer; display: block; }
.dayrest > summary::-webkit-details-marker { display: none; }
.dr-more {
  display: flex; align-items: center; justify-content: center; gap: 6px;
  margin: 9px 2px 2px; font-size: var(--fs-1); font-weight: 800;
  letter-spacing: .07em; text-transform: uppercase; color: var(--accent);
}
.dr-more svg { transform: rotate(90deg); transition: transform .18s linear; }
.dayrest[open] .dr-more svg { transform: rotate(-90deg); }
.dayrest[open] .dr-more { margin-bottom: 10px; }

/* a news row that LEAVES Today says where it is going */
.nb-goes { display: flex; align-items: center; gap: 3px; flex: none;
  font-size: var(--fs-0); font-weight: 800; letter-spacing: .05em;
  text-transform: uppercase; color: var(--accent); }

/* ============================================================================
   TAP TARGETS: Apple's 44x44 floor, without moving a pixel
   Round-5 accessibility pass, 2026-08-31, and it is App Store review surface.

   Measured with document.elementFromPoint at 375x667 and 390x844, before:
     #vigorBtn  26x16      #dustBtn 29x16   #cratesBtn 28x16   #coinBtn 44x16
     #prevDay / #nextDay / #todaySettings   38x39
     .seg button  43x37 (and 32x38 on the Gear glow row)
     .q-claim     56x28   button.t3-price (pet shop)  59x26
   Every one is under the floor a reviewer taps against, and the wallet chips
   are the smallest interactive elements in the app while opening real sheets.

   THE BOXES DO NOT CHANGE. Cam's art and the chip sizes are the fixed input, so
   the hit area is carried by a transparent ::before, centred on the control and
   never smaller than 44x44. Two consequences worth knowing:
     - getBoundingClientRect still reads the OLD number. Only a hit test grades
       this, which is exactly what tests/a11y-audit.mjs does, and why a box-size
       assertion would have failed a correct fix.
     - Two of these controls sit close enough that their hit areas would fight
       over the same pixels. That is why .wallet-pill's gap went 12 -> 20 and
       .settings-row .seg stopped shrinking: the spacing is part of the fix, and
       the audit measures what a neighbour actually leaves behind.
   .t3-pm is not in this list: both of its pseudo-elements already draw the +/-
   bars, so it grows for real (see its own rule).

   Round-9 pass, 2026-09-01, same instrument, same two viewports. The PRIMARY
   segmented toggles already passed at 44 effective; the SECONDARY buttons under
   them never got the treatment and measured, before:
     .btn.small  42x43   (#exportBtn #importBtn #eraseBtn #redeemBtn
                          #saveTargets #notifTest #copyDiag #whatsNewBtn)
     #recalc     74x17.5 (a .link inside the DAILY TARGETS card title)
   Same fix for the same reason: the boxes stay exactly where they are.
   ========================================================================== */
.wallet-pill .wp,
.dayhdr .icon-btn,
.seg button,
.q-claim,
.btn.small,
#recalc,
button.t3-price { position: relative; }
/* .gear-btn is deliberately NOT in the list above: it is already position:
   relative in its own rule, and a `position: relative` for it here, at the end
   of the file, would beat .gw-gear on source order and unpin the Shop's gear
   from Gwart's panel. Its ::before anchors to it either way. */

.wallet-pill .wp::before,
.dayhdr .icon-btn::before,
.gear-btn::before,
.seg button::before,
.q-claim::before,
.btn.small::before,
#recalc::before,
button.t3-price::before {
  content: ''; position: absolute; left: 50%; top: 50%;
  width: 100%; height: 100%; min-width: 44px; min-height: 44px;
  transform: translate(-50%, -50%);
}

/* =====================================================================
   COLOURWAY FAMILIES — one tile per drawing, the variants on a rail
   =====================================================================
   Tom, 2026-09-04: "collapse similar items into a different interface ...
   those that have collected like 1000 head slots over the years will have a
   messy af wardrobe." The family RULE is data/boneheadz.js; this is the
   interface it earns.

   THE RAIL REUSES .pw-row RATHER THAN COPYING IT, which is the same call
   .fb-rail (the football colourway rail) made and for the same reason: the
   flex, the 8px gap, the mandatory x-snap, the 96px tile and the `on`/ring
   idiom are already shipped and already read by a returning player. Only what
   is genuinely different is written here -- the tile SIZE (the art has to be
   readable, so 78px of picture over a name), the centre snap so the selected
   colourway sits in the middle of a long rail rather than at an edge, the end
   margins that let the first and last tiles reach that centre, and living
   inside a CSS grid it has to span. No momentum code: scroll-snap-type lands
   it and the browser does the fling. */
/* TOP-LEFT, NOT TOP-RIGHT, AND THAT IS MEASURED. The first version sat at
   `right: 3px; top: 3px`, which is where .ward-cell.equipped::after paints its
   green tick (4px/4px, 20x20): on the tile you are actually wearing -- the one
   most worth reading -- the count sat under the tick. Cosmetic tiles keep their
   tier letter at bottom-left and only GEAR tiles use the top-left corner, so
   this corner is free here. wardrobe-family-grid-audit's BADGE row measures the
   two boxes against each other so the pair cannot drift back together. */
.ward-cell.fam .ward-fam-n, .col-cell.fam .ward-fam-n, .pw-item.fam .ward-fam-n {
  position: absolute; left: 3px; top: 3px; z-index: 2; pointer-events: none;
  min-width: 15px; padding: 2px 4px; border-radius: 999px;
  font-size: var(--fs-micro); font-weight: 900; line-height: 1; text-align: center;
  /* The .88 plate preserves contrast because what is
     behind it is Cam's art and the worst case is a white pixel. */
  background: rgba(13, 12, 18, .88); color: var(--text);
}
/* THE OPEN TILE SAYS SO. Without this the rail below is the only evidence of
   which of nine badged tiles it belongs to, and on a 4-wide grid it can be any
   of the four above it. The badge fills rather than changing shape, so it is
   the same target and the same box: fill AND text colour, never colour alone
   (a11y-audit), and it reads at a glance beside the unfilled ones. */
.ward-cell.fam[aria-expanded="true"] .ward-fam-n,
.col-cell.fam[aria-expanded="true"] .ward-fam-n,
.pw-item.fam[aria-expanded="true"] .ward-fam-n { background: var(--gold); color: #17140b; }
/* THE COLLECTION'S OWN RAIL, same collapse as the Wardrobe's (.fam-rail) but
   its tiles are .col-cell (an <img>, not a canvas): reuse .pw-row for the
   flex/gap/snap and give the tiles a fixed width so aspect-ratio:1 has
   something to work from inside a flex row. */
.col-fam-rail {
  grid-column: 1 / -1;          /* .col-grid is a 4-column grid; the rail spans it */
  scroll-behavior: smooth; scrollbar-width: none;
  margin: 1px calc(var(--pad) * -1) 3px; padding: 4px var(--pad) 8px;
}
.col-fam-rail::-webkit-scrollbar { display: none; }
.col-fam-rail .col-cell.famr { flex: 0 0 72px; width: 72px; scroll-snap-align: center; }
.col-fam-rail .col-cell.famr:first-child { margin-left: calc(50% - 36px - var(--pad)); }
.col-fam-rail .col-cell.famr:last-child { margin-right: calc(50% - 36px - var(--pad)); }
@media (prefers-reduced-motion: reduce) { .col-fam-rail { scroll-behavior: auto; } }
.fam-rail {
  grid-column: 1 / -1;          /* .ward-grid is a 4-column grid; the rail spans it */
  /* NO scroll-snap-type of its own: .pw-row already declares `x mandatory`,
     which is the point of reusing that row. */
  scroll-behavior: smooth; scrollbar-width: none;
  margin: 1px calc(var(--pad) * -1) 3px; padding: 4px var(--pad) 8px;
}
.fam-rail::-webkit-scrollbar { display: none; }
.pw-item.famr {
  position: relative;           /* Anchor overlays to the art. */
  width: 92px; scroll-snap-align: center; padding: 7px 5px 8px; gap: 5px;
}
.pw-item.famr:first-child { margin-left: calc(50% - 46px - var(--pad)); }
.pw-item.famr:last-child { margin-right: calc(50% - 46px - var(--pad)); }
.pw-item.famr .famr-art { position: relative; display: block; width: 78px; height: 78px; }
.pw-item.famr .famr-art canvas.ward-art { display: block; width: 100%; height: 100%; }
.pw-item.famr b { font-size: var(--fs-tiny); line-height: 1.15; }
/* The ring is the grid's own `equipped`, not .pw-item's `on`: the rail's tiles
   carry data-equip like every other tile and restageWardrobe rings them by that
   attribute, so giving them a second selected-state class would be one more
   thing to keep in sync for no visual gain. Fill AND border, never colour
   alone (a11y-audit). */
.pw-item.famr.equipped { background: rgba(255, 201, 97, .12); border-color: var(--gold); }
/* The Dressing Room's rail (data-look tiles, 2026-09-05): `selected` is the colourway being
   tried, the same class the look grid's own tiles use for it. */
.pw-item.famr.selected { outline: 2px solid var(--protein); outline-offset: 1px; }
@media (prefers-reduced-motion: reduce) { .fam-rail { scroll-behavior: auto; } }


/* R44-24: pet sheets follow the root text size. Keep the 16px-root design;
   allow scrolling and wrapping at larger sizes instead of shrinking the text. */
.pet-a11y {
  font-size: var(--fs-body);
  --fs-0: .6875rem; --fs-1: .75rem; --fs-2: .8125rem; --fs-3: .9375rem;
  --fs-4: 1.0625rem; --fs-5: 1.3125rem; --fs-6: 1.75rem; --fs-7: 2.5rem;
  --fs-micro: .625rem; --fs-tiny: .6875rem; --fs-body: 1rem;
  --fs-title: 1.5rem; --fs-display: 2rem; --fs-hero: 3rem; --fs-jumbo: 4rem;
}
.pet-a11y .sheet-head h2 { font-size: var(--fs-4); white-space: normal; }
.pet-a11y .sheet-close { font-size: var(--fs-3); }
.pet-a11y .sect-h { font-size: var(--fs-1); }
.pet-a11y .note { font-size: var(--fs-2); }
.pet-a11y .btn { font-size: var(--fs-body); overflow-wrap: anywhere; }
.pet-a11y .btn.small { font-size: var(--fs-2); }
.pet-a11y .k-grid-head-cell { overflow-wrap: anywhere; }
.pet-a11y .k-id b, .pet-a11y .k-grid-label { white-space: normal; overflow-wrap: anywhere; }
.pet-a11y .pw-item.famr .look-cost { font-size: var(--fs-micro); }
.pet-a11y .pw-item.famr .look-cost.dust { font-size: var(--fs-tiny); line-height: 1rem; }

/* Laboratory, 2026-09-08. Uses the existing sheets and destructive controls. */
.lab-sheet { isolation: isolate; }
.lab-sheet::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--grain) repeat; background-size: 150px 150px; opacity: .05; pointer-events: none; z-index: -1; }
.lab-sheet .sheet-head h2 { font: 400 var(--fs-6)/1.1 var(--display); letter-spacing: .02em; }
.lab-room { display: flex; flex-direction: column; gap: var(--pad); padding-bottom: calc(24px + var(--sab, 0px)); }
.lab-room p, .lab-review p { margin: 0 0 10px; line-height: 1.5; }
.lab-room > p { margin-bottom: 0; }
.lab-room h3, .lab-species legend, .lab-species-tile > b { font: 400 var(--fs-5)/1.1 var(--display); letter-spacing: .02em; }
.lab-room h3 { margin: 0 0 10px; }
.lab-room select { width: 100%; min-height: 44px; color: var(--text); background: var(--surface-2); border: 2px solid var(--ink); border-radius: 16px; padding: 10px; }
.lab-species { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--pad); }
.lab-species { min-width: 0; margin: 0; padding: 0; border: 0; }
.lab-species { gap: 8px; }
.lab-species legend { margin-bottom: var(--pad); }
.lab-species-choice { position: relative; min-width: 0; cursor: pointer; }
/* Keep the native radio focus and arrow keys; the tile carries its visible state. */
.lab-species-choice input { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.lab-species-tile { display: flex; flex-direction: column; align-items: center; gap: 2px; height: 100%; min-height: 44px; padding: 4px; border: 2px solid var(--ink); border-radius: 20px; background: var(--surface-2); box-shadow: var(--sh); text-align: center; overflow-wrap: anywhere; }
.lab-species-choice input:checked + .lab-species-tile { border-color: var(--text-2); }
.lab-species-choice input:focus-visible + .lab-species-tile { outline: 2px solid var(--accent); outline-offset: 3px; }
.lab-selected, .lab-species-tile small, .lab-species-tile > span:not(.lab-art), .lab-specimen small { color: var(--text-2); font-size: var(--fs-3); line-height: 1.5; }
.lab-art { display: grid; place-items: center; min-height: 64px; }
.lab-specimen { margin: 0; min-width: 0; text-align: center; }
.lab-specimen figcaption { display: flex; flex-direction: column; gap: 4px; overflow-wrap: anywhere; line-height: 1.5; }
.lab-swatch { display: inline-block; width: 10px; height: 10px; margin-right: 6px; border-radius: 50%; background: var(--lab-colour); }
.lab-branches, .lab-working { display: grid; gap: var(--pad); }
.lab-branch, .lab-loss { border-left: 2px solid var(--line-strong); padding: 0 0 0 12px; overflow-wrap: anywhere; }
.lab-branches h4 { margin: 0; font: 600 var(--fs-3)/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; color: var(--text-2); }
.lab-branch p, .lab-branch small { font-size: var(--fs-3); line-height: 1.5; }
.lab-branch > b { display: block; margin-bottom: 6px; font-size: var(--fs-3); }
.lab-path small, .lab-pet small, .lab-odds { color: var(--text-2); font-size: var(--fs-3); line-height: 1.5; }
.lab-identity { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.lab-identity-copy { flex: 1; display: flex; flex-direction: column; }
.lab-identity-copy small { color: var(--text-2); font-size: var(--fs-3); line-height: 1.5; }
.lab-path { display: grid; gap: 0; }
.lab-path header { text-align: center; }
.lab-path h3, .lab-path p { margin: 0; }
.lab-room .lab-path h3 { font: 600 var(--fs-3)/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; color: var(--text-2); }
.lab-odds-line { font-size: var(--fs-1); color: var(--text-3); line-height: 1.45; }
.lab-working { border-top: 2px solid var(--ink); padding-top: 10px; gap: 10px; }
.lab-working h3, .lab-working > p, .lab-stock p { margin: 0; }
.lab-stock small { color: var(--text-2); font-size: var(--fs-3); line-height: 1.5; }
.lab-slots { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 8px; }
.lab-slots > section { min-width: 0; }
.lab-pet { display: flex; flex-direction: column; gap: 6px; text-align: center; align-items: center; justify-content: center; width: 100%; min-height: 88px; padding: 12px; border: 2px solid var(--ink); border-radius: 16px; background: var(--surface-2); color: var(--text); box-shadow: var(--sh); overflow-wrap: anywhere; font: 400 var(--fs-5)/1.15 var(--display); }
.lab-pet b { font: inherit; }
.lab-pet > span:not(.lab-instance-art) { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; color: var(--text-2); }
.lab-pick-row { margin-bottom: 16px; }
.lab-pick-info { display: grid; gap: 4px; padding: 8px 12px 0; color: var(--text-2); font-size: var(--fs-3); line-height: 1.5; }
.lab-pick-row:has(.lab-pet:disabled) { opacity: .65; }
.lab-equation { margin-bottom: 20px; }
.lab-inputs, .lab-alternatives { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: start; gap: 8px; }
.lab-input, .lab-outcome { display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; margin: 0; text-align: center; overflow-wrap: anywhere; }
.lab-input b, .lab-outcome b { display: block; font: 400 var(--fs-5)/1.15 var(--display); }
.lab-input > span:not(.lab-instance-art), .lab-outcome figcaption span { display: block; font: 400 var(--fs-3)/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; color: var(--text-2); }
.lab-plus, .lab-or { align-self: center; color: var(--text-2); font: 400 var(--fs-3)/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; }
.lab-plus { font-size: var(--fs-5); }
.lab-outcomes { border-top: 2px solid var(--text-2); padding: 12px; margin-top: 12px; border-radius: 0 0 16px 16px; background: var(--surface-2); text-align: center; }
.lab-outcomes h4 { margin: 0 0 12px; font: 400 var(--fs-5)/1.2 var(--display); }
.lab-outcomes > p { margin: 10px 0 0; color: var(--text-2); font-size: var(--fs-3); }
.lab-outcome:only-child { grid-column: 1 / -1; }
.lab-review > h3 { margin: 0 0 10px; font: 600 var(--fs-3)/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; color: var(--danger); }
.lab-pet span, .lab-loss span { display: block; font-size: var(--fs-3); line-height: 1.5; }

/* Text scaling: flow and scroll text without resizing the art coordinate systems.
   These are source mitigations. Full large-text visual review is still owed. */
.btn, .gi-actions .btn, .pdk-btn { white-space: normal; overflow-wrap: anywhere; flex-wrap: wrap; min-width: 0; }
.sheet-head h2 { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
.tab { min-width: 0; overflow-wrap: anywhere; text-align: center; }
.tab-badge { height: auto; min-height: 16px; }
.q-badge { height: auto; min-height: 18px; }
.stepper button { width: auto; height: auto; min-width: 44px; min-height: 42px; padding: 4px; }
.rk > b { display: block; height: auto; min-height: 2.5em; -webkit-line-clamp: unset; overflow: visible; overflow-wrap: anywhere; }
.ward-cell.look .look-cost.dust, .pw-item.famr .look-cost.dust,
.pet-a11y .pw-item.famr .look-cost.dust { line-height: 1.6; }

/* Stable: the carousel and its cropped pictures retain their measured geometry.
   Bound opposite badges to separate halves; let captions and room text reflow. */
.cf-chip, .cf-lv { max-width: calc(50% - 16px); white-space: normal; overflow-wrap: anywhere; }
.cf-chip { flex-wrap: wrap; }
.cf-eq { white-space: normal; overflow-wrap: anywhere; }
.cf-n { bottom: calc(var(--fs-tiny) * 1.35 + 12px); max-width: calc(100% - 16px); overflow-wrap: anywhere; }
#stableBody .cf-cap .role, #stableBody .stable-wallet { flex-wrap: wrap; }
#stableBody .cf-acts { flex-wrap: wrap; }
#stableBody .stable-rooms { grid-template-columns: repeat(auto-fit, minmax(min(100%, 5.5rem), 1fr)); }
#stableBody .stable-room b, #stableBody .stable-room small { max-width: 100%; overflow-wrap: anywhere; }
.t3-cells { grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr)); }
.t3-cell { min-width: 0; overflow-wrap: anywhere; }

/* Paddock: card content scrolls inside the existing 520px scene. Keep the high
   card's 96px anchor and the bottom card's 26px anchor, plus room for controls.
   Sprite status backgrounds fit their words; enlarged text wraps at viewport gutters. */
.pdk-host { max-width: calc(100% - 28px); max-height: 398px; overflow-y: auto; padding: 12px; }
.pdk-state { max-width: 100%; white-space: normal; text-align: center; overflow-wrap: anywhere; }
.pdk-head > :not(.pdk-thumb) { min-width: 0; overflow-wrap: anywhere; }
.pdk-acts { flex-wrap: wrap; }
.pdk-btn { flex-basis: 5rem; }
.pdk-coach { max-width: calc(100% - 28px); text-align: center; overflow-wrap: anywhere; }
.pdk-x-btn { width: auto; height: auto; min-width: 30px; min-height: 30px; padding: 2px; }
.lab-pet:disabled { cursor: default; }
.lab-pet:disabled b, .lab-pet:disabled small { color: var(--text-2); }
.lab-instance-art { align-self: center; }
#labPickRows { display: grid; gap: var(--pad); }
.lab-room .btn { border: 2px solid var(--ink); border-radius: 16px; box-shadow: var(--sh); min-height: 44px; }
.lab-room .lab-next { background: var(--accent); color: var(--accent-ink); border: 2px solid var(--ink); border-radius: 16px; box-shadow: var(--sh); }
.lab-room .lab-next small { color: inherit; }
.lab-room .btn:active:not(:disabled), .lab-room .lab-next:active { transform: translate(2px, 2px); box-shadow: 2px 3px 0 rgba(0,0,0,.55); }
.lab-links { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
.lab-links .btn { flex: 1 1 110px; min-height: 44px; }
.lab-collection, .lab-odds, .lab-species-tile small { font-variant-numeric: tabular-nums; }
.lab-collection { color: var(--text-2); }
.lab-door { width: 100%; margin-bottom: 12px; }
.lab-sinks { color: var(--text); line-height: 1.5; }
.lab-room summary, .lab-room .link { min-height: 44px; padding: 10px 0; }
.lab-room details { border-bottom: 1px solid var(--line-strong); }
.lab-loss { margin: 12px 0; border-color: var(--danger); }
.lab-reveal { text-align: center; }
.lab-reveal [data-lab-result-art] { display: grid; place-items: center; min-height: 160px; }
.lab-surprise.lab-play [data-lab-result-art] { animation: lab-result-arrive .24s ease-out both; }
/* Colour is the reward here. No rarity glow or opacity fade over Cam's art. */
@keyframes lab-result-arrive { from { transform: scale(.96); } to { transform: scale(1); } }
.lab-today, .lab-egg-help { padding: 14px 0; border-top: 1px solid var(--line-strong); margin-top: 16px; }
.lab-today p, .lab-egg-help p { color: var(--text-2); line-height: 1.5; margin: 8px 0; }
.lab-today .link { min-height: 44px; padding: 10px 14px; }
.lab-room :focus-visible, .lab-review :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* Approved Lab mock, Tom 2026-09-25. Kitchen is deliberately outside this scope. */
.lab-sheet { --lab-border: 2px solid #050408; --lab-shadow: 0 3px 0 rgba(0,0,0,.32); --lab-system: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; }
.lab-sheet .sheet-head h2 { display: none; }
.lab-sheet .sheet-head { min-height: 44px; padding-top: 0; padding-bottom: 0; justify-content: flex-end; }
.lab-sheet .sheet-close { min-height: 44px; }
.lab-sheet .bh-room-header { border: var(--lab-border); box-shadow: var(--lab-shadow); }
.lab-sheet .lab-working h3, .lab-sheet .lab-path h3 { font: 700 calc(var(--fs-body) * 0.75)/1.3 var(--lab-system); letter-spacing: .06em; text-transform: uppercase; color: var(--text-3); margin: 24px 2px 12px; text-align: left; display: flex; align-items: center; gap: 8px; min-height: 24px; }
.lab-sheet .lab-info-toggle { flex: none; width: 44px; height: 44px; margin: -10px; border: 0; border-radius: 50%; background: none; color: var(--text-3);
  font: italic 700 calc(var(--fs-body) * 0.8125)/1 Georgia, serif; text-transform: none; letter-spacing: 0; display: inline-grid; place-items: center; padding: 0; position: relative; cursor: pointer; isolation: isolate; }
.lab-sheet .lab-info-toggle::after { content: ''; position: absolute; inset: 10px; border-radius: 50%; border: 1.5px solid var(--text-3); z-index: -1; }
.lab-sheet .lab-info-toggle[aria-expanded="true"] { color: var(--surface); }
.lab-sheet .lab-info-toggle[aria-expanded="true"]::after { background: var(--text-3); }
.lab-sheet .lab-info { font: 400 calc(var(--fs-body) * 0.8125)/1.45 var(--lab-system) !important; color: var(--text-2) !important; margin: -4px 2px 12px !important; }
.lab-sheet .lab-info[hidden] { display: none !important; }

/* ================= LAB ================= */
.lab-sheet .sheet-body.lab-room, .lab-sheet #labBody { gap: 24px !important; }
.lab-sheet .bh-room-header { margin-bottom: 0 !important; }
.lab-sheet #labBody > section:empty, .lab-sheet #labBody > .lab-availability:not(:has(*)) { display: none !important; }

.lab-sheet .lab-species { gap: 12px !important; }
.lab-sheet .lab-species legend { margin-bottom: 4px !important; }
.lab-sheet .lab-collect-note { grid-column: 1 / -1; margin: 0 2px !important; font: 400 calc(var(--fs-body) * 0.8125)/1.4 var(--lab-system); color: var(--text-3); }
.lab-sheet .lab-species-tile { border: var(--lab-border) !important; border-radius: 16px !important; box-shadow: var(--lab-shadow) !important; background: var(--surface) !important; padding: 12px 8px !important; }
.lab-sheet .lab-species-tile b { font: 400 calc(var(--fs-body) * 1.125)/1.1 var(--display) !important; letter-spacing: .03em !important; }
.lab-sheet .lab-species-tile small, .lab-sheet #labSpeciesProgress { font: 400 calc(var(--fs-body) * 0.9375)/1.1 var(--display) !important; letter-spacing: .05em; color: var(--text-3) !important; }

.lab-sheet .lab-identity { border: var(--lab-border); border-radius: 16px; box-shadow: var(--lab-shadow); background: var(--surface); padding: 8px 12px; min-height: 64px; }
.lab-sheet .lab-identity-copy b { font: 400 calc(var(--fs-body) * 1.25)/1.1 var(--display) !important; letter-spacing: .03em; }
.lab-sheet .lab-identity .link { font: 600 calc(var(--fs-body) * 0.8125)/1.2 var(--lab-system) !important; color: var(--text-2) !important; }

.lab-sheet .lab-availability, .lab-sheet .lab-working { border-top: 0 !important; }
.lab-sheet .lab-working { gap: 12px !important; }
.lab-sheet .lab-working h3 { margin: 0 2px !important; }
.lab-sheet .lab-slots { gap: 12px !important; }
.lab-sheet .lab-pet { border: var(--lab-border) !important; border-radius: 16px !important; box-shadow: var(--lab-shadow) !important; background: var(--surface) !important;
  font: 400 calc(var(--fs-body) * 1.0625)/1.15 var(--display) !important; letter-spacing: .03em !important; min-height: 96px !important; }
.lab-sheet .lab-pet b { font: 400 calc(var(--fs-body) * 1.125)/1.1 var(--display) !important; letter-spacing: .03em !important; }
.lab-sheet .lab-pet > span:not(.lab-instance-art) { letter-spacing: .05em !important; font: 400 calc(var(--fs-body) * 0.875)/1.2 var(--display) !important; color: var(--text-3) !important; }
.lab-sheet .lab-slots [data-lab-clear] { display: block; text-align: center; width: 100%; min-height: 44px; font: 600 calc(var(--fs-body) * 0.8125)/1.2 var(--lab-system) !important; color: var(--text-3) !important; }
.lab-sheet .lab-plus { color: var(--text-3) !important; }
.lab-sheet .lab-odds-line, .lab-sheet #labPairHint { font: 400 calc(var(--fs-body) * 0.875)/1.4 var(--lab-system) !important; color: var(--text-2) !important; margin: 0 2px !important; letter-spacing: 0 !important; }
.lab-sheet .lab-odds-line { display: flex; align-items: center; gap: 8px; }
.lab-sheet .lab-working > .lab-info { margin: 0 2px !important; }
.lab-sheet [data-lab-review] { min-height: 52px; border-radius: 16px !important; box-shadow: var(--lab-shadow) !important; }
.lab-sheet [data-lab-review]:disabled { opacity: 1 !important; color: #6f6d7a !important; background: transparent !important; border: 2px dashed #3a3845 !important; box-shadow: none !important; }
/* "Choose a species" focuses the first tile; keep the heading look and a full target. */
.lab-sheet [data-lab-choose-species] { all: unset; display: flex; align-items: center; font: 700 calc(var(--fs-body) * 0.75)/1.3 var(--lab-system) !important; letter-spacing: .06em !important; text-transform: uppercase !important; color: var(--text-3) !important; margin: 0 2px; min-height: 44px; }
/* before a species is chosen, the pair slots are placeholders */
.lab-sheet #labBody.lab-no-species .lab-slots .lab-pet { border: 2px dashed #3a3845 !important; background: transparent !important; box-shadow: none !important; color: #8a8896 !important; }

/* colour path: drawn, not told. Owned colours in full colour, still-to-collect ones faded */
.lab-sheet .lab-path { border: var(--lab-border); border-radius: 16px; box-shadow: var(--lab-shadow); background: var(--surface); padding: 12px; margin: 0 !important; gap: 12px !important; }
.lab-sheet .lab-path header { text-align: left !important; padding: 0; }
.lab-sheet .lab-path header h3 { margin: 0 !important; }
.lab-sheet .lab-path > .lab-info { margin: 0 !important; }
.lab-sheet .lab-flow { display: grid; grid-template-columns: minmax(0,1fr) 28px minmax(0,1fr) 28px minmax(0,1fr) 28px minmax(0,1fr); align-items: center; }
.lab-sheet .lab-step { display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; }
.lab-sheet .lab-step .lab-specimen { width: 100%; }
.lab-sheet .lab-step .lab-art { min-height: 0 !important; zoom: .56; display: grid; place-items: center; }
.lab-sheet .lab-step figcaption { display: block !important; margin-top: 2px; }
.lab-sheet .lab-step figcaption b, .lab-sheet .lab-step > b { display: block; font: 400 calc(var(--fs-body) * 0.875)/1.1 var(--display) !important; letter-spacing: .04em; color: var(--text); text-align: center; overflow-wrap: normal; white-space: nowrap; }
.lab-sheet .lab-step .lab-missing .lab-art { filter: grayscale(1); opacity: .6; }
.lab-sheet .lab-step .lab-missing b { color: var(--text-3) !important; }
.lab-sheet .lab-arrow { display: flex; flex-direction: column; align-items: center; justify-content: center; align-self: center; text-align: center; color: var(--text-3); font: 700 calc(var(--fs-body) * 1)/1 var(--lab-system); }
.lab-sheet .lab-arrow > span { display: block; width: 100%; text-align: center; }
.lab-sheet .lab-arrow small { font: 400 calc(var(--fs-body) * 0.75)/1.2 var(--display) !important; letter-spacing: .04em; color: var(--text-3) !important; margin-top: 2px; white-space: nowrap; }
.lab-sheet .lab-example { margin: 0 !important; font: 400 calc(var(--fs-body) * 0.8125)/1.4 var(--lab-system); color: var(--text-3); }

/* footer: collection + more actions become two chevron rows in one card */
.lab-sheet .lab-footer { border: var(--lab-border); border-radius: 16px; box-shadow: var(--lab-shadow); background: var(--surface); padding: 0 12px; margin: 0; }
.lab-sheet .lab-footer .lab-collection, .lab-sheet .lab-footer .lab-more > summary { display: flex !important; align-items: center; justify-content: space-between; width: 100%; min-height: 52px; padding: 0 !important; margin: 0 !important;
  font: 600 calc(var(--fs-body) * 0.9375)/1.2 var(--lab-system) !important; color: var(--text) !important; text-align: left !important; text-decoration: none !important; list-style: none; background: none !important; border: 0 !important; box-shadow: none !important; }
.lab-sheet .lab-footer .lab-more { border-top: 1px solid var(--line) !important; border-bottom: 0 !important; margin: 0 !important; }
.lab-sheet .lab-footer .lab-more > summary::-webkit-details-marker { display: none; }
.lab-sheet .lab-footer .lab-collection::after, .lab-sheet .lab-footer .lab-more > summary::after { content: ''; width: 8px; height: 8px; border-right: 2px solid var(--text-3); border-top: 2px solid var(--text-3); transform: rotate(45deg); margin-right: 4px; flex: none; }
.lab-sheet .lab-gwrow { margin: 0 !important; }

/* 2026-09-25: the supplied 393px mock has 54px art. At 320px the path
   tracks are narrower; scale only its portraits and arrow tracks to fit. */
@media (max-width: 360px) {
  .lab-sheet .lab-flow { grid-template-columns: minmax(0,1fr) 24px minmax(0,1fr) 24px minmax(0,1fr) 24px minmax(0,1fr); }
  .lab-sheet .lab-step .lab-art { zoom: .44; }
  .lab-sheet .lab-step figcaption b { letter-spacing: 0; }
}
@media (max-width: 380px) { .lab-pet { padding: 8px; } }
@media (prefers-reduced-motion: reduce) { .lab-surprise.lab-play [data-lab-result-art] { animation: none; } }

/* Early Studio. Preview is the actual export Blob, with no extra overlay. */
.studio-screen { max-width: 600px; margin: 0 auto; padding-bottom: 140px; }
.studio-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.studio-header .page-h1 { margin: 0; }
/* .studio-link is now ONLY the Studio's own "Back to Wardrobe" control. The
   Wardrobe entry used to use it too, on its own right-aligned line; it is a
   .fit-chip in the fit rail now (see below) so it stops being a separate
   alignment to get wrong. Tom, 2026-09-10: "for now move the studio button
   somewhere". */
.studio-link { width: auto; min-height: 44px; padding: 8px; font: inherit; font-size: var(--fs-2); color: var(--text-2); background: transparent; border: 0; box-shadow: none; text-transform: none; letter-spacing: normal; text-decoration: underline; text-underline-offset: 3px; }
.studio-link:hover { color: var(--text); }
.studio-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* THE CHIP ADDS NOTHING BUT THE ICON'S SIZE. Everything that positions it, its
   height, radius, border, font and gap, comes from .fit-chip, which is the point:
   it lines up with "+ Save this fit" and "Take it all off" because it IS one of
   them, not because two rules happen to agree. */
.fit-chip.studio img, .fit-chip.studio svg { width: 22px; height: 22px; flex: none; }
/* Studio uses the shipped chip, button and sheet vocabulary. */
.studio-preview { position: relative; width: min(100%, 360px, calc((100vh - 300px) * 9 / 16));
  width: min(100%, 360px, calc((100dvh - 300px) * 9 / 16)); aspect-ratio: 9 / 16; margin: 12px auto; background: #F3EFE7; overflow: hidden; touch-action: pan-y; }
/* Only while a sticker is selected: see updateSelection in js/studio-screen.js. */
.studio-preview.grabbing { touch-action: none; }
.studio-preview img { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: auto; }
.studio-screen [hidden] { display: none !important; }
.studio-controls, .studio-sticker-grid { display: flex; flex-wrap: wrap; gap: 10px; margin: 14px 0; }
.studio-screen .chip, .studio-screen .btn, .studio-screen summary { min-height: 44px; }
.studio-screen .chip { white-space: normal; }
.studio-screen .chip[aria-pressed="true"] { background: var(--coral); color: var(--ink); border-color: var(--ink); }
.studio-screen > .btn { margin: 8px 0; }
.studio-screen > .primary, .studio-text-sticker { background: var(--coral); color: var(--ink); border: 2px solid var(--ink); box-shadow: var(--sh); font-family: var(--display); }
.studio-text-sticker { font-size: var(--fs-5); }
.studio-tray { position: fixed; left: 50%; transform: translateX(-50%); width: min(600px, calc(100% - 32px)); bottom: calc(80px + var(--sab)); z-index: 5; background: var(--surface); border: 2px solid var(--ink); border-radius: var(--radius); padding: 12px; box-shadow: var(--sh); margin-top: 16px; }
/* THE HANDLE IS ALWAYS ABOVE THE TAB BAR. It used to sit in normal flow while
   the tray was closed, which put it at y 821..877 on a 430x932 phone against a
   tab bar starting at 852: overlapping the nav by 25px, below the fold, and the
   only door into the stickers. Fixed like the open tray, so the one control the
   screen exists for is never underneath the navigation. */
#studioTrayToggle { width: 100%; touch-action: manipulation; }
.studio-tray:not([data-open="true"]) {
  position: fixed; left: 50%; transform: translateX(-50%);
  bottom: calc(80px + var(--sab)); width: min(600px, calc(100% - 32px)); z-index: 5;
}
#studioTrayBody { max-height: 48vh;
  max-height: 48dvh; overflow-y: auto; overscroll-behavior: contain; padding: 0 4px 12px; }
.studio-screen summary { cursor: pointer; padding: 12px 0; font-family: var(--display); font-size: var(--fs-5); }
#studioSelection { position: absolute; border: 1px solid var(--ink); outline: 1px solid #F3EFE7; pointer-events: none; }
.studio-clean { position: fixed; inset: 0; width: 100vw; height: 100vh;
  height: 100dvh; max-width: none; max-height: none; padding: 0; margin: 0; border: 0; background: #F3EFE7; overflow: hidden; }
.studio-clean::backdrop { background: #F3EFE7; }
.studio-clean img { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: auto; }
.studio-screen button:focus-visible, .studio-screen summary:focus-visible { outline: 3px solid var(--coral); outline-offset: 3px; }

.studio-sticker-grid .chip img { width: 64px; height: 64px; object-fit: contain; image-rendering: auto; }

/* Studio v3: the picture is the editing surface. */
.studio-preview { width: min(100%, 430px, calc((100vh - 220px) * 9 / 16));
  width: min(100%, 430px, calc((100dvh - 220px) * 9 / 16)); }
#studioLive { position: absolute; inset: 0; width: 100%; height: 100%; image-rendering: auto; pointer-events: none; }
#studioSelection { border-color: #2A2D28; outline-color: #F3EFE7; }
.studio-selection-tools { display: flex; justify-content: center; gap: 8px; margin: 8px auto; }
.studio-selection-tools button { min-height: 44px; padding: 8px 12px; background: #F3EFE7; color: #2A2D28; border: 2px solid #2A2D28; border-radius: 8px; font: inherit; }
.studio-selection-tools button:disabled { opacity: .45; }
.studio-pick:active { transform: scale(.96); }
@media (prefers-reduced-motion: reduce) { .studio-pick:active { transform: none; } }
#studioBin { position: absolute; left: 30.5556%; top: 86.4583%; width: 38.8889%; height: 12.5%; display: grid; place-items: center; text-align: center; background: #F3EFE7; color: #2A2D28; border: 2px solid #2A2D28; border-radius: 12px; pointer-events: none; }
#studioBin[data-active="true"] { background: var(--coral); }
.studio-art-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.studio-pick { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 0; min-height: 100px; padding: 6px; background: transparent; border: 0; color: var(--text); font: inherit; cursor: pointer; }
.studio-pick img, .studio-pick canvas { display: block; width: 100%; height: 96px; object-fit: contain; image-rendering: auto; }
.studio-pick span { font-size: var(--fs-2); }
.studio-pick:hover, .studio-pick:focus-visible { background: var(--surface-2); }
#studioStage:focus-visible { outline: 3px solid var(--coral); outline-offset: 3px; }
#studioAccessible .studio-sticker-grid { display: flex; }

.studio-tray { position: relative; left: auto; bottom: auto; transform: none; width: min(100%, 430px); margin: 0 auto; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; }
.studio-tray[data-open="true"] { position: fixed; left: 50%; bottom: calc(80px + var(--sab)); transform: translateX(-50%); width: min(600px, calc(100% - 32px)); padding: 12px; border: 2px solid #2A2D28; border-radius: var(--radius); background: var(--surface); box-shadow: 4px 5px 0 #2A2D28; }

/* Approved 1A chrome. Keep header metrics, arena and figure rules intact. */
.sheet-head.fight-header > .fight-title { flex: 1; }
.sheet-head.fight-header > .sheet-close { flex-shrink: 0; }
#fightBody > .fight-actions > .fight-act,
#fightBody > .fight-endrow > .fight-act {
  border-radius: 11px;
  box-shadow: none;
}
#fightBody > .fight-actions > .fight-act:not(.glow):not(.sig):not(.petmove):not(.potion) {
  background: var(--surface);
  border-color: var(--line);
}
#fightBody > .fight-endrow > .fight-act.endturn { border-color: var(--ink); }
#fightBody .fight-hud .fname { box-shadow: none; border-color: var(--line); }

/* Approved screen 1B. Every rule is rooted in the Pit body, never the arena. */
#pitBody > .t3-hero { background: linear-gradient(180deg, #182013, #10140f); border: 1px solid #32362a; border-radius: 18px; box-shadow: none; padding: 22px 12px; }
#pitBody > .t3-hero h2 { font-size: calc(var(--fs-body) * 2.1875); line-height: 1.02; text-shadow: none; }
#pitBody > .t3-hero p { margin-top: 10px; font-size: calc(var(--fs-body) * .875); letter-spacing: .11em; }
#pitBody > #buildBtn { border: 1px solid var(--line); border-radius: 13px; background: var(--surface); box-shadow: none; padding: 10px 12px; gap: 10px; }
#pitBody > #buildBtn b { min-width: 0; text-align: left; }
#pitBody > .t3-sect { margin: 24px 0 10px; flex-wrap: wrap; }
#pitBody > .t3-sect::before { content: ''; width: 4px; height: 15px; border-radius: 2px; background: var(--accent); }
#pitBody > .t3-sect.pit-gauntlet-heading::before { background: var(--accent); }
#pitBody > .t3-sect.pit-sparring-heading::before { background: var(--surface-3); }
#pitBody > .t3-sect b { font-family: var(--display); font-size: calc(var(--fs-body) * 1.125); font-weight: 400; letter-spacing: .03em; color: var(--text); }
#pitBody > .t3-sect i { height: 1px; }
#pitBody > .t3-sect .r { background: transparent; border: 0; box-shadow: none; white-space: normal; }
#pitBody .t3-row { display: grid; grid-template-columns: 52px minmax(0, 1fr) auto; gap: 10px; padding: 12px; margin-bottom: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; }
#pitBody .t3-row > .t3-tx b { font-size: var(--fs-2); line-height: 1.3; overflow-wrap: anywhere; }
#pitBody .t3-row > .t3-tx small { display: block; margin-top: 4px; font-size: var(--fs-tiny); line-height: 1.4; }
#pitBody .t3-row > .btn { min-height: 40px; padding: 7px 12px; font-family: var(--display); font-size: var(--fs-body); border-radius: 10px; box-shadow: none; }
#pitBody .t3-row > .t3-lock { max-width: 92px; white-space: normal; text-align: center; }
#pitBody .t3-row > .pit-opponent-portrait { position: relative; display: block; width: 52px; height: 52px; overflow: hidden; border-radius: 12px; background: var(--surface-2); }
#pitBody .t3-row > .pit-opponent-portrait > .tz-head { border: 0; }
#pitBody .t3-row > .pit-opponent-portrait > img { display: block; width: 100%; height: 100%; object-fit: contain; }
/* FREEZE THE PIT OPPONENT PORTRAITS, 2026-09-18 (v681). Every headshot avatar
   runs `bh-idle` (the 3.4s breathing) via the base `.bh-anim` rule. The Pit
   board mounts ~13 of them at once, layered, on top of an already-animating
   Today, inside the sheet's own slide-up. On iOS WKWebView that many
   GPU-backed animating layers crashed the WebKit content process the instant
   the board rendered, and Capacitor reloaded the web view: Tom's "tap the Pit,
   it pops up then closes", reproduced on his iPhone 17 Pro Max via safaridriver
   (bisected 2026-09-18: animation:none here SURVIVES, baseline CRASHES; not
   memory, not filters). The breathing is imperceptible on a 52px headshot, so
   the opponents sit still. Scoped to the Pit, NOT global: the cosmetics drop
   wall's heads must keep bobbing (tests/motion-truth-audit.mjs drop-wall row).
   Pinned by tests/pit-portrait-still-audit.mjs. */
.pit-opponent-portrait .bh-anim { animation: none; }

/* THE SAME DENSITY, THREE MORE PLACES, 2026-09-18. Tom asked whether the Pit's
   ~13-portrait crash drags anywhere else; a census of every avatarLayersHtml
   call site says yes, three surfaces mount more animating `.bh-anim` headshots
   than the Pit ever did and none of them were touched by v681:
     .fl-av    Crew "Wants to be friends" + "Pending" rows (requestRowsHtml).
               No client-side cap; the server already allows up to 100 rows per
               bucket, so a busy account can mount 200 breathing avatars in one
               scroll of the Crew tab.
     .lb-head  The full Leaderboard sheet's per-row heads (lbHeadInner). Already
               deferred by an IntersectionObserver with a 600px rootMargin so a
               scroll never holds every row at once (that fix predates this
               one, see the "RECYCLE, do not just defer" comment in
               openLeaderboard), but the observed window alone still seats
               ~26 rows at a typical phone height, each a full 640px stack.
     .boss-cell  The one-time "Meet the Bestiary" veil (openBossIntro), stacked
               over Today like the Pit's own sheet: 16 boss headshots born in
               one innerHTML write, no observer, no defer.
     .race-lane .run  The Crew tab's live Step Race board, once its <details>
               is opened (closed by default, and app.css's own `details:not(
               [open])` rule already stops everything inside a shut one -- see
               "A SHUT DRAWER STILL TICKS" above). Opened, it seats up to 25
               racers (server-capped, CREW-2) with nothing pausing them.
   None of these are on the motion-truth-audit register (that file polices only
   #tzWall, #tzReel and .nb-hero) and none are the Today hero. Same reasoning as
   the Pit: every one of these is a headshot at 52px or smaller, so bh-idle's
   sway is imperceptible, and freezing it removes real GPU-layer count instead
   of a cosmetic. Pinned by tests/dense-heads-still-audit.mjs. */
.fl-av .bh-anim,
.lb-head .bh-anim,
.boss-cell .bh-anim,
.race-lane .run .bh-anim { animation: none; }

/* Approved Crew 1C. IDs belong only to renderFriends, never the fight arena.
   Fan geometry, solid plates and bounded art mounting remain unchanged. */
#cfanSel { display: block; margin-top: 4px; padding: 2px 0; background: none; border: 0; box-shadow: none; }
#cfanSel .cfan-identity { display: flex; align-items: center; gap: 12px; }
#cfanSel .cfan-star { width: 46px; height: 46px; place-content: center; border: 1px solid var(--line); box-shadow: none; }
#cfanSel .cfan-star img { width: 48px; height: 48px; max-width: none; grid-area: 1 / 1; pointer-events: none; }
#cfanSel .cfan-star:not(.on) img { filter: grayscale(1); opacity: .65; }
#cfanSel .cfan-star.on { background: var(--surface-2); border-color: var(--gold); }
#cfanSel .cfan-sel-nm { display: block; width: 100%; overflow-wrap: anywhere; }
#cfanSel .cfan-status { display: block; margin-top: 4px; color: var(--text-3); font-size: var(--fs-0); }
#cfanSel .cfan-acts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 6px; }
#cfanSel .cfan-acts .btn { min-height: 44px; font-size: var(--fs-4); }
#cfanSel .cfan-acts .btn.gift { background: var(--gold); color: var(--ink); }
#cfanSearchRow { flex-wrap: wrap; }
#cfanSearchRow input { width: 0; min-height: 44px; border-radius: 11px; box-shadow: none; font-size: var(--fs-2); background: var(--surface); }
#cfanOnline { min-height: 44px; border-radius: 10px; box-shadow: none; background: var(--surface); }
#cfanOnline.on { color: var(--accent); border-color: var(--accent); }
#cfanClear { position: static; flex: none; width: 44px; height: 44px; }
#cfanSnapshot { font-size: var(--fs-0); color: var(--text-3); }
#cfanDeck .cfan-stage::after { content: none; background: none; }

/* Approved screen 1D: readiness metrics only. No figure or arena consumers. */
.rd-card .rd-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; align-items: stretch; }
.rd-card .rd-tile { min-width: 0; min-height: 148px; padding: 12px 9px; background: var(--surface); text-align: left; align-items: flex-start; gap: 0; }
.rd-card .rd-tile::after { content: none; }
.rd-card .rd-icon { height: 30px; display: flex; align-items: center; margin-bottom: 9px; flex: none; }
.rd-card .rd-icon svg { width: 22px; height: 22px; display: block; }
.rd-card .rd-icon img { object-fit: contain; }
.rd-card .rd-tile .rl { font-size: var(--fs-micro); font-weight: 750; letter-spacing: .05em; }
.rd-card .rd-tile .rv { font-size: calc(var(--fs-body) * 1.4375); font-weight: 750; line-height: 1.1; margin-top: 6px; }
.rd-card .rd-tile .rv small { font-size: var(--fs-tiny); font-weight: 500; }
.rd-card .rd-delta { font-size: var(--fs-tiny); margin-top: 7px; color: var(--text-2); line-height: 1.4; overflow-wrap: anywhere; }
.rd-card .rd-delta i { font-size: inherit; }
.rd-activities .rd-activity-icon { object-fit: contain; flex: none; }

/* Screen 1E: move only Today's three figures. Paired top/bottom changes keep
   the character box and --fig unchanged, including the large-pet composition. */
.screen--today #bhStage > .hero-char {
  top: calc(var(--gw-top, 50px) + var(--gw-h, 90px) + var(--bleed, 0px) - 16px);
  bottom: 95px;
}
.screen--today #bhStage > .hero-companion { bottom: 108px; }
.screen--today #bhStage > .gw-row { top: calc(var(--gw-top) + var(--bleed, 0px) - 16px); }
/* Paint the existing page backdrop through a feather, over any equipped hero.
   Fixed attachment matches .today-plate without choosing a cosmetic colour. */
.screen--today #bhStage > .hero-fade {
  background-color: var(--bg);
  background-image: var(--app-backdrop);
  background-attachment: fixed;
  /* Tom 2026-09-17: "the dark gradient is reaching too far up, it shouldn't
     be darkening the shoes and pet so much". (Comment words matter here: the
     dressing-room 1F census greps a four-letter substring; keep "up", not the longer word.) Measured on the served page: the
     name block's top is 96px from the stage bottom at 393x852, 375x667 and
     430x932, the shoes end 110-119px up and the pet's shadow 108-118px up, so a
     percentage stop cannot clear the feet on one phone without uncovering the
     name on another. Pixels from the bottom fit every stage: solid under the
     level row, 60% at the name block's top, clear by the shoes. (v659 moved the
     BASE .hero-fade to 78% and this override kept painting 70%; that is the
     mistake this replaces.) */
  /* Round 2, Tom: "make the colour fade go a bit further so that it's just fading
     out softly as it hits the top of the button row; the dark shouldn't reach the
     pet and the feet". So the plate stays behind the name block and only the last
     72px (level row and the seam to the buttons) darken, softly. */
  /* Round 3, Tom on live v667: "the coloured backdrop needs to come down a bit
     lower and softly fade out; you've removed too much of the dark, the username
     and level are hard to read; keep the shadow, stop it bleeding into the pet
     and shoes". Tom picked "D2" from seven rendered options: one full-width
     shade with a near-hard edge (8px feather) just above the name block, so the
     plate runs down to the name and the text sits on solid dark. Solid to 40px,
     85% at 85px, clear at 93px; the shoes end 110px up, the pet's shadow 108px.
     Measured (fade3/probe.mjs): name contrast 12.3 on a mint plate, 14.3 on the
     demo plate; shoes within 2% of the no-fade brightness, pet unchanged. */
  /* v671 widened the feather to 14px on Tom's "needs to feather a little bit
     more"; seeing it live he said "push the 8px feather", so it is back to the
     edge he picked as D2: 85% at 85px, clear at 93px. */
  /* 2026-09-24, Tom: "let's get the background colour feather to go back to
     like 3 pixels". Same edge (clear at 93px, the shoes still 110px up), the 85%
     stop moves to 90px: a 3px feather instead of 8px. */
  -webkit-mask-image: linear-gradient(to top, #000 0, #000 40px, rgba(0, 0, 0, .85) 90px, transparent 93px);
  mask-image: linear-gradient(to top, #000 0, #000 40px, rgba(0, 0, 0, .85) 90px, transparent 93px);
}
.screen--today #bhStage > .hero-meta { background: none; }
.screen--today .hero-meta .hero-name {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-family: var(--display); font-size: var(--fs-5); color: var(--text);
}
.screen--today .hero-meta .hero-lv { flex: none; font-size: var(--fs-body); }
/* 2026-09-24 Today design scrub, Tom approved. */
.screen--today .hero-meta .hero-title {
  font-family: system-ui, -apple-system, sans-serif; font-size: var(--fs-2); font-weight: 600;
  text-transform: none; letter-spacing: 0; font-style: normal; transform: none; color: var(--text-2);
}
.screen--today .hero-actions .hero-act:active {
  background: var(--surface-2); border-color: transparent;
  box-shadow: inset 0 -3px 0 var(--accent); color: var(--accent);
}
/* v664 slimmed the rows to 44px and 6px apart; Tom on live: "the padding does
   seem cramped, default back to a bit more room between the pills". Rows stay
   44px. 2026-09-24 Today design scrub, Tom approved: 12px card gaps. */
.screen--today .hero-actions { margin-bottom: 12px; gap: 8px; }
.screen--today .hero-act span { font-size: var(--fs-1); letter-spacing: 0; }
.screen--today #newsBanner,
.screen--today .q-collapse,
.screen--today .dayblk,
.screen--today #habitGrantCard {
  border: 2px solid #050408; border-radius: 16px; margin: 0 0 12px;
  box-shadow: 0 3px 0 rgba(0,0,0,.32); background-color: var(--surface);
}
.screen--today #newsBanner > summary,
.screen--today .q-collapse > summary {
  min-height: 44px; padding: 7px 13px; gap: 10px;
  font-family: inherit; font-size: var(--fs-1); font-weight: 800; letter-spacing: .06em;
}
.screen--today .q-collapse > summary { text-transform: uppercase; }
.screen--today #newsBanner .nb-t { letter-spacing: inherit; }
.screen--today #newsBanner .nb-chev { transform: none; transition: transform .2s; }
.screen--today #newsBanner[open] .nb-chev { transform: rotate(90deg); }
.screen--today .ring-side .kv { font-size: var(--fs-2); font-weight: 600; color: var(--text); }
.screen--today #newsBanner .nb-ico,
.screen--today .q-collapse .q-sum-ico { width: 24px; height: 24px; flex: none; align-items: center; justify-content: center; }
.screen--today .q-collapse.has-claim {
  border-color: #050408; box-shadow: inset 0 -3px 0 var(--accent), 0 3px 0 rgba(0,0,0,.32);
}
.screen--today .q-collapse.has-claim > summary,
.screen--today .q-collapse.has-claim summary * { color: var(--text); }
.screen--today .tsec-h, .screen--today .card-title, .screen--today .sect-h,
.screen--today .q-tier-h, .screen--today .q-tier.week .q-tier-h,
.screen--today .q-tier.month .q-tier-h { color: var(--text-3); font-size: var(--fs-1); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
.screen--today .dr-more, .screen--today .dr-more span { color: var(--text-3); }
.screen--today .dr-more svg { color: var(--text-3); stroke: var(--text-3); }
.screen--today .nb-sub { letter-spacing: 0; text-transform: none; font-weight: 400; color: var(--text-2); font-size: var(--fs-2); }
.screen--today .nb-dot { background: var(--surface-3); color: var(--text); }
.screen--today .ring-center .lbl {
  font-family: system-ui, -apple-system, sans-serif; font-style: normal; transform: none;
  text-transform: none; font-size: var(--fs-2); font-weight: 400; letter-spacing: 0; color: var(--text-2);
}
.screen--today .meal-empty, .screen--today .day-signoff {
  font-family: system-ui, -apple-system, sans-serif; font-size: var(--fs-3); font-weight: 500;
  font-style: normal; transform: none; text-transform: none; letter-spacing: 0; color: var(--text-2);
}
.screen--today .well-bar i { background: var(--text-2); }
.screen--today .q-bar.gold i { background: var(--gold); }

/* Wardrobe 1F: only navigation paint and the toolbar before .mog-dock. */
:is(#chTabs.ward-navigation, .ward-mode-tabs) > .ch-tab.on {
  background: var(--surface-2); border-color: transparent;
  box-shadow: inset 0 -3px 0 var(--accent); color: var(--accent);
}
#chContent > .ward-toolbar {
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 5px; overflow: visible; align-items: stretch;
}
#chContent > .ward-toolbar > .fit-chip {
  min-width: 0; min-height: 44px; padding: 4px 2px; gap: 3px;
  flex-direction: column; align-items: center; justify-content: center; text-align: center;
  border-radius: 9px; font-size: var(--fs-0); line-height: 1.2;
}
#chContent > .ward-toolbar > .ward-fit-switcher {
  flex-direction: column; align-items: center; justify-content: center;
}
#chContent > .ward-toolbar > .ward-fit-switcher > span {
  max-width: 100%; white-space: nowrap;
}
#chContent > .ward-toolbar > .ward-fit-switcher > small { font-size: var(--fs-micro); }
#chContent > .ward-toolbar > .fit-chip.arming {
  white-space: normal; max-width: none;
}
#chContent > .ward-toolbar > .ward-fit-list { grid-column: 1 / -1; min-width: 0; }
#wardFitList > .ward-fit-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 5px; margin-top: 7px; }
#wardFitList > .ward-fit-row > .fit-chip { min-width: 0; padding: 7px 8px; font-size: var(--fs-0); }
#wardFitList .ward-fit-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#wardFitList > .ward-fit-row > [data-fit-del] { color: var(--danger); }

/* Wardrobe 1F: name and one level chip, then earned title and live tallies. */
.hub-title.ward-identity { display: flex; align-items: center; gap: 9px; }
.hub-title.ward-identity > .hub-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hub-title.ward-identity > .ward-lv { flex: none; }
.ward-head > .ward-rank { flex-basis: 100%; }
/* Share the tally row: one fewer 27.5px row, with three 13.15px row gaps
   instead of four 9px gaps, predicts 24.1px recovered at 375px. */
.ward-head { row-gap: 13.15px; }
.ward-head > .ward-wallet,
.ward-head > .ward-collection { display: contents; }
.ward-head .ward-looks { min-height: 44px; }
.ward-head .bh-pill:first-of-type { margin-left: 0; }

/* 1G: Backpack inventory only. The shared hub and ingredient grid stay intact. */
#chContent .bp-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
#chContent .bp-card { min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; overflow-wrap: anywhere; }
#chContent .bp-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
#chContent .bp-qty { color: var(--accent); font-family: var(--display); font-size: var(--fs-5); font-variant-numeric: tabular-nums; }
#chContent .bp-card > b { font-family: var(--display); font-weight: 400; font-size: var(--fs-4); }
#chContent .bp-card p, #chContent .bp-card small { color: var(--text-2); font-size: var(--fs-1); line-height: 1.45; margin: 0; }
#chContent .bp-card > .btn { width: 100%; min-height: 44px; margin-top: auto; padding: 8px 4px; }
#chContent .bp-crate-details summary { min-height: 44px; padding: 12px 0; font-size: var(--fs-1); cursor: pointer; }
#chContent .bp-crate-details .t3-sect { flex-wrap: wrap; }
#chContent .bp-eggs { grid-column: 1 / -1; min-width: 0; padding: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; }
#chContent .bp-eggs > .t3-sect { margin-top: 0; }
#chContent .bp-eggs .t3-egg { padding: 10px 0; border: 0; border-radius: 0; box-shadow: none; flex-wrap: wrap; }
#chContent .bp-eggs .t3-egg .tx { flex-basis: 150px; }
#chContent .bp-salvage { margin-top: 20px; padding: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; }
#chContent .bp-salvage > .t3-sect { margin-top: 0; }

/* v569: recover 16.4px across the measured header's three row gaps.
   Keep every row, tap target and paperdoll dimension intact. */
.ward-head { row-gap: 7.683333px; }

/* The two synchronized axes traverse the original 16 by 15 sheet at 50fps.
   Repeated frames in the source already encode the authored exposure timing. */
.lab-banner { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 80px; padding: 12px; margin: 0 0 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--text); text-align: left; font: inherit; cursor: pointer; }
.lab-banner-copy { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 4px; }
.lab-banner-copy b { font-family: var(--display); font-size: var(--fs-4); font-weight: 400; }
.lab-banner-copy small { color: var(--text-2); font-size: var(--fs-1); }
.lab-banner-recipes { color: var(--accent); font-size: var(--fs-1); }
.lab-banner:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* One compositor clock walks the whole sheet: 15 row segments, each stepping 16 columns.
   v658 measured on the Backpack: two nested transform animations (the element and its ::before)
   cost 120 style recalcs per second while idle; either one alone is 0/s. A single animation is
   0/s and byte-identical at six sampled times. */
.lab-slime { display: block; flex: none; width: 48px; height: 48px; overflow: hidden; image-rendering: pixelated; }
.lab-slime-rows { display: block; width: 768px; height: 720px; }
.lab-slime-rows::before { content: ''; display: block; width: 768px; height: 720px; background-image: url('assets/slime/slime-spritesheet.png'); background-repeat: no-repeat; background-size: 768px 720px; animation: slime-walk 4.8s infinite; }
@keyframes slime-walk {
  0.0000% { transform: translate(0, 0px); animation-timing-function: steps(16); }
  6.6667% { transform: translate(-768px, 0px); }
  6.6667% { transform: translate(0, -48px); animation-timing-function: steps(16); }
  13.3333% { transform: translate(-768px, -48px); }
  13.3333% { transform: translate(0, -96px); animation-timing-function: steps(16); }
  20.0000% { transform: translate(-768px, -96px); }
  20.0000% { transform: translate(0, -144px); animation-timing-function: steps(16); }
  26.6667% { transform: translate(-768px, -144px); }
  26.6667% { transform: translate(0, -192px); animation-timing-function: steps(16); }
  33.3333% { transform: translate(-768px, -192px); }
  33.3333% { transform: translate(0, -240px); animation-timing-function: steps(16); }
  40.0000% { transform: translate(-768px, -240px); }
  40.0000% { transform: translate(0, -288px); animation-timing-function: steps(16); }
  46.6667% { transform: translate(-768px, -288px); }
  46.6667% { transform: translate(0, -336px); animation-timing-function: steps(16); }
  53.3333% { transform: translate(-768px, -336px); }
  53.3333% { transform: translate(0, -384px); animation-timing-function: steps(16); }
  60.0000% { transform: translate(-768px, -384px); }
  60.0000% { transform: translate(0, -432px); animation-timing-function: steps(16); }
  66.6667% { transform: translate(-768px, -432px); }
  66.6667% { transform: translate(0, -480px); animation-timing-function: steps(16); }
  73.3333% { transform: translate(-768px, -480px); }
  73.3333% { transform: translate(0, -528px); animation-timing-function: steps(16); }
  80.0000% { transform: translate(-768px, -528px); }
  80.0000% { transform: translate(0, -576px); animation-timing-function: steps(16); }
  86.6667% { transform: translate(-768px, -576px); }
  86.6667% { transform: translate(0, -624px); animation-timing-function: steps(16); }
  93.3333% { transform: translate(-768px, -624px); }
  93.3333% { transform: translate(0, -672px); animation-timing-function: steps(16); }
  100.0000% { transform: translate(-768px, -672px); }
}
.lab-banner:hover .lab-slime-rows::before, .lab-banner:focus-within .lab-slime-rows::before { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
  .lab-slime-rows::before { animation: none; transform: none; }
}
/* THE KITCHEN'S CAULDRON, Tom 2026-09-17: "use this in the backpack for the kitchen
   so that it matches the lab pill that has its own animated gif". Tom's 23-frame
   48px GIF, laid out as one 1104x48 strip and driven the way the slime is: a
   single transform clock on one ::before (nested clocks cost 120 style
   recalcs/s idle, see slime-walk), step-end at the GIF's own frame times. */
.kitchen-pot-rows { display: block; width: 1104px; height: 48px; }
.kitchen-pot-rows::before { content: ''; display: block; width: 1104px; height: 48px; background-image: url('assets/kitchen/cauldron-strip.png'); background-repeat: no-repeat; background-size: 1104px 48px; animation: cauldron-bubble 4.8s infinite; will-change: transform; }
@keyframes cauldron-bubble {
  0.0000% { transform: translateX(-0px); animation-timing-function: step-end; }
  5.0000% { transform: translateX(-48px); animation-timing-function: step-end; }
  9.1667% { transform: translateX(-96px); animation-timing-function: step-end; }
  12.5000% { transform: translateX(-144px); animation-timing-function: step-end; }
  16.2500% { transform: translateX(-192px); animation-timing-function: step-end; }
  20.0000% { transform: translateX(-240px); animation-timing-function: step-end; }
  22.9167% { transform: translateX(-288px); animation-timing-function: step-end; }
  25.4167% { transform: translateX(-336px); animation-timing-function: step-end; }
  27.9167% { transform: translateX(-384px); animation-timing-function: step-end; }
  31.6667% { transform: translateX(-432px); animation-timing-function: step-end; }
  36.6667% { transform: translateX(-480px); animation-timing-function: step-end; }
  39.5833% { transform: translateX(-528px); animation-timing-function: step-end; }
  42.5000% { transform: translateX(-576px); animation-timing-function: step-end; }
  49.1667% { transform: translateX(-624px); animation-timing-function: step-end; }
  50.8333% { transform: translateX(-672px); animation-timing-function: step-end; }
  54.1667% { transform: translateX(-720px); animation-timing-function: step-end; }
  57.5000% { transform: translateX(-768px); animation-timing-function: step-end; }
  63.7500% { transform: translateX(-816px); animation-timing-function: step-end; }
  71.2500% { transform: translateX(-864px); animation-timing-function: step-end; }
  77.9167% { transform: translateX(-912px); animation-timing-function: step-end; }
  82.0833% { transform: translateX(-960px); animation-timing-function: step-end; }
  85.8333% { transform: translateX(-1008px); animation-timing-function: step-end; }
  91.6667% { transform: translateX(-1056px); animation-timing-function: step-end; }
  100% { transform: translateX(-1056px); }
}
.lab-banner:hover .kitchen-pot-rows::before, .lab-banner:focus-within .kitchen-pot-rows::before { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
  .kitchen-pot-rows::before { animation: none; transform: none; }
}
/* Tom 2026-09-17 (card v664-confirm): "The Locker Room is open" and "1 ready to claim"
   shared a type treatment. 2026-09-24 Today design scrub, Tom approved:
   the badge is now a neutral chip and the subtitle is regular weight. */
.screen--today .q-badge { background: var(--surface-3); color: var(--text); font-size: var(--fs-2); font-weight: 700; letter-spacing: 0; text-transform: none; }

/* Board 1A sets every move name in caps (JAB, SWING, HAYMAKER, BONE GUARD), and
   ITEMS and END TURN with them. Bangers carries a lowercase, so without this the
   names render title case and do not match the approved board. Appended rather
   than edited in place: wardrobe-ui-1f-audit hash-freezes the inherited CSS
   prefix, and that guard is correct. Same selectors, later in the cascade. */
#fightBody > .fight-actions > .fight-act[data-act] > b,
#fightBody > .fight-actions > .fight-act.items > b,
#fightBody > .fight-endrow > .fight-act.endturn > b { text-transform: uppercase; }


/* Approved 1A move anatomy: cost, name, then effect. Append to preserve the
   inherited Wardrobe CSS prefix. */
#fightBody > .fight-actions {
  grid-template-columns: repeat(3, minmax(0, 1fr));
  grid-auto-rows: 1fr; gap: 7px;
}
#fightBody > .fight-actions > .fight-act[data-act] {
  min-width: 0; min-height: 64px; padding: 7px 4px;
  grid-template-rows: auto auto 1fr; gap: 3px;
  background: var(--surface); border: 1px solid var(--line);
  text-align: center;
}
#fightBody > .fight-actions > .fight-act[data-act] > b {
  font-size: var(--fs-body); line-height: 1.1; color: var(--text);
  overflow-wrap: anywhere;
}
/* 2026-09-20: The size ladder keeps tray labels consistent on narrow screens.
   Unleash the Murder may still wrap: its label is longer than any button at
   any width. Resolving that is a separate question nobody has ruled on. */
@media (max-width: 380px) {
  #fightBody > .fight-actions > .fight-act[data-act] > b {
    font-size: var(--fs-3);
  }
}
@media (max-width: 360px) {
  #fightBody > .fight-actions > .fight-act[data-act] > b {
    font-size: var(--fs-2);
  }
}
@media (max-width: 340px) {
  #fightBody > .fight-actions > .fight-act[data-act] > b {
    font-size: var(--fs-1);
  }
}

#fightBody > .fight-actions > .fight-act[data-act="titan"] > b { color: var(--gold); }
#fightBody > .fight-actions > .fight-act[data-act="rage"] > b { color: var(--coral); }
#fightBody > .fight-actions > .fight-act[data-act] > small.cost {
  position: static; max-width: 100%;
  font-size: calc(var(--fs-body) * .5); line-height: 1.2;
  color: var(--text-3); text-align: center; overflow-wrap: anywhere;
}
#fightBody > .fight-actions > .fight-act[data-act] > small.effect {
  font-size: var(--fs-micro); line-height: 1.2;
  color: var(--text-2); font-weight: 400; overflow-wrap: anywhere;
}
#fightBody > .fight-actions > .fight-act[data-act] .move-unavailable {
  display: block; margin-top: 3px;
}
#fightBody > .fight-actions > .fight-act.items {
  min-height: 40px; grid-column: 1 / -1;
}
#fightBody > .fight-actions > .fight-act.items > b {
  font-size: calc(var(--fs-body) * 1.0625); line-height: 1.1;
}
#fightBody > .fight-endrow > .fight-act.endturn {
  width: 100%; min-height: 40px; padding: 8px 16px; gap: 2px;
}
#fightBody > .fight-endrow > .fight-act.endturn > b {
  font-size: calc(var(--fs-body) * 1.0625); line-height: 1.1;
}
#fightBody > .fight-endrow > .fight-act.endturn > small {
  font-size: var(--fs-tiny); font-weight: 400; line-height: 1.2;
  color: var(--accent-ink);
}

/* v574: retain the original anchors and horizontal sharing offset. Only the
   ambient vertical excursion changes, from 5px to a negligible 0.001px. */
@keyframes todayStillIdle {
  0%, 100% { transform: translate(var(--bh-shift, 0px), 0); }
  50% { transform: translate(var(--bh-shift, 0px), -0.001px); }
}
.screen--today #bhStage > .hero-char,
.screen--today #bhStage > .hero-companion { animation-name: todayStillIdle; }
.screen--today #bhStage > .hero-companion.pop { animation-name: todayStillIdle, petPop; }
@media (prefers-reduced-motion: reduce) {
  .screen--today #bhStage > .hero-char,
  .screen--today #bhStage > .hero-companion,
  .screen--today #bhStage > .hero-companion.pop {
    animation: none;
    transform: translateX(var(--bh-shift, 0px));
  }
}
/* Absolute footer contents grow without changing scene or figure geometry.
   The inherited feather and radial backdrop remain intact. */
.screen--today .hero-meta .hero-name { font-size: calc(var(--fs-5) * 1.12); }
.screen--today .hero-meta .hero-lv { font-size: calc(var(--fs-body) * 1.125); }
.screen--today .hero-meta .hero-xprow { width: 72%; }
.screen--today .hero-meta .hero-xpn { font-size: var(--fs-1); }
.screen--today .hero-meta .hero-xprow i { height: 8px; }

/* Pit list only. The arena uses a different body. */
#pitBody > .t3-hero {
  background: linear-gradient(135deg, color-mix(in srgb, var(--violet) 35%, var(--surface)), var(--surface));
  border-color: var(--violet);
}
#pitBody > .pit-daily-den {
  min-height: 210px; padding: 20px; gap: 16px;
  grid-template-columns: 80px minmax(0, 1fr);
  border-color: var(--violet);
  background: linear-gradient(135deg, var(--surface-3), var(--surface));
}
#pitBody > .pit-daily-den > .pit-opponent-portrait { width: 80px; height: 80px; }
#pitBody > .pit-daily-den > .t3-tx b { font-family: var(--display); font-size: var(--fs-5); }
#pitBody > .pit-daily-den > .t3-tx small { font-size: var(--fs-1); }
#pitBody > .pit-daily-den > .btn,
#pitBody > .pit-daily-den > .t3-lock { grid-column: 1 / -1; max-width: none; width: 100%; min-height: 48px; font-size: var(--fs-3); }
/* Still the nested idle shuffle too; the existing reward bounce remains live. */
.screen--today #bhStage:not(.bounce) > .hero-char .bh-anim { animation: none; }
@media (prefers-reduced-motion: reduce) {
  .screen--today #bhStage > .hero-char .bh-anim { animation: none; }
}
#pitBody > .pit-daily-den > .pit-opponent-portrait > .tz-head {
  transform: scale(1.5384615385); transform-origin: top left;
}

/* Recovered after a chain-merge: my conflict resolver took only the incoming
   side for app.css, so screen v573's Backpack rules were silently dropped while
   its markup shipped. Tom saw a Backpack with no tab highlight, unstyled item
   dropdowns and the old pet box. Appended, because wardrobe-ui-1f-audit
   hash-freezes the inherited prefix. */
#chTabs.bp-navigation .ch-tab.on {
  background: var(--surface-2); border-color: transparent;
  box-shadow: inset 0 -3px 0 var(--accent); color: var(--accent);
}
#chContent .bp-item-details summary { min-height: 44px; padding: 12px 0; font-size: var(--fs-1); cursor: pointer; }
/* Wardrobe slot navigation: leave room for a heading and honour reduced motion. */
[data-slot-heading], #chContent .paperdoll { scroll-margin-top: 12px; }
/* .ward-other-slots retired in v586: the slot picker shows ONE slot. */
@media (prefers-reduced-motion: reduce) {
  #chBody *, #chBody *::before, #chBody *::after { animation: none !important; transition: none !important; }
}

/* v578: the contact shadow reads, instead of washing out. Tom, 2026-09-11:
   "he is still clearly not down on the ground with his own shadow."
   .hero-cast.c-bh's GEOMETRY is the measured 2026-08 sole line and is untouched
   here; its .55 alpha was measured against the ground as it stood then, and
   .hero-fade and the scene vignette have darkened that plate since, so the same
   alpha now lands as a smudge. Measured 2026-09-11 at 375x812, the element's own
   contribution at the sole line (the same frame with it hidden, then shown):
     .55 (shipped)   29.0%   <- what Tom was looking at
     .78             45.0%
   The first cut of this fix also raised the element above .hero-fade on the
   theory that the fade was erasing it. Measured, that is FALSE and it is the
   wrong direction: at z-index 5 the same alpha reads WEAKER (.55 -> 24.5%,
   .78 -> 41.6%), because the fade's warm wash over the shadow is part of what
   separates it from the plate. Stacking is left exactly as it shipped.
   Guard: tests/toast-today-audit.mjs, row SHADOW. */
#bhStage > .hero-cast.c-bh {
  background: radial-gradient(ellipse at 50% 50%, rgba(24, 27, 20, .78) 0%, rgba(24, 27, 20, 0) 70%); }

/* AND IT MUST NOT LAND ON TODAY'S DOOR ROW. Same class of collision as the map
   card and the sheet tray above, found the same way: measured 2026-09-11 at
   375x812 on live v575, toast 672.0->716.3 sitting on the Trends / Backpack /
   Stable / Kitchen / The Pit tiles at 653.0->719.0, so a message covered the
   five doors that are the whole point of the screen. Tom saw it in his own
   screenshot.
   Anchored to the TOP here rather than nudged up, because nudging only moves
   the collision: below the art every band on Today is a control (name+level
   557->627, doors 653->719, nav 738->804) and the widest is 24px, which a 44px
   pill cannot sit in. The art band 165->425 is the only clear full-width run on
   the screen. 175px clears the Gwart card's measured bottom (163.0) by 12px and
   a two-line pill still ends at ~245, well above the pet at 426.
   Keyed with :has() on the body, like the map-card rule above, because the
   toast is a SIBLING of #app while screen--today is stamped on #screen inside
   it, so a plain descendant selector never matches. No hidden state to test
   for: the doors are always present on Today.
   AND IT YIELDS TO THE SHEET RULE. This rule is appended below
   `body:has(#sheets .sheet)` and both are one :has() on body, so without the
   :not() it wins on order and re-seats the toast at 175 while a fight sheet is
   open over Today, covering Flee. Caught by toast-sheet-audit rows CLEAR and
   SEAT, not by reading the file.
   Guard: tests/toast-today-audit.mjs. */
body:has(.screen--today):not(:has(#sheets .sheet)) .toast { top: calc(var(--sat) + 175px); bottom: auto; }

/* Settings backup actions share one column; enlarged text wraps the row. */
.settings-data-action { flex-wrap: wrap; }
.settings-data-action > .lab { flex: 1 1 10rem; min-width: 0; }
.settings-data-action > .btn { flex: 0 0 7rem; width: 7rem; white-space: nowrap; }
/* Wardrobe slot navigation: leave room for a heading and honour reduced motion. */
[data-slot-heading], #chContent .paperdoll { scroll-margin-top: 12px; }

@media (prefers-reduced-motion: reduce) {
  #chBody *, #chBody *::before, #chBody *::after { animation: none !important; transition: none !important; }
}
/* 2026-09-11 grounding correction. Supersedes the historical 2026-08-08
   105px/45% claim and v578 geometry claim above (prefix is hash-frozen).
   Screen 1E raised the character and pet 16px but left the cast behind.
   Equipped art padding varies; hero-ground.js now derives the cast centre
   from the rendered alpha, including the shared shift and sharing offset.
   Keep --bh-shift on each figure, never inherited from the scene. */
#bhStage > .hero-char { --bh-shift: var(--hero-left-shift, 0px); transform: translateX(var(--bh-shift)); }
#bhStage > .hero-companion { --bh-shift: var(--hero-left-shift, 0px); transform: translateX(var(--bh-shift)); }
/* 2026-10-02 (Tom: "tuck in front is best"). A big pet (Bumbleseal) stays full
   size and stands against the Bonehead, who no longer moves. Her box starts
   50px right of the scene centre, minus 3.52% of the figure so her ink tucks
   5 to 15% of her width over his; z-index 3 keeps her in front of .hero-char
   (z2). 50px is a measured constant, not a --fig ratio: his ink does not scale
   with --fig at 320 (scratchpad/bumble/options.md, option C). */
#bhStage.sharing > .hero-companion { left: calc(50% + 50px - var(--fig) * 0.0352); right: auto; }
/* Measured toast seat supersedes the historical map, sheet and Today offsets.
   Specificity intentionally exceeds those rules; inherited CSS is frozen. */
body #toast.toast[data-severity][data-seat-clear] { top: var(--toast-seat, calc(var(--sat) + 175px)); bottom: auto; z-index: 320; pointer-events: none; }
/* Selected Laboratory/Kitchen headers. Art does not intercept room controls. */
.bh-room-header { position: relative; isolation: isolate; flex: none; min-height: 144px; padding-top: 12px; margin-bottom: 12px; overflow: hidden; border-radius: 16px; background: linear-gradient(180deg, #202715, #0d0c12); --header-accent: #a5e847; --fs-room-title: 2.25rem; }
.bh-room-header.bh-room-kitchen { background: linear-gradient(180deg, #302319, #0d0c12); --header-accent: #ffc961; }
.bh-room-header .bh-room-title { position: relative; z-index: 2; margin: 0; padding: 0 8px; font: 400 var(--fs-room-title)/1 var(--display); letter-spacing: .015em; color: #f2e9d7; text-align: center; overflow-wrap: anywhere; }
.bh-room-cast { position: relative; height: 96px; max-width: 375px; margin: auto; transform: scale(.875); transform-origin: center bottom; pointer-events: none; }
.bh-room-art { position: absolute; display: block; width: var(--art-size); height: var(--art-size); object-fit: contain; image-rendering: pixelated; pointer-events: none; z-index: 1; }
.bh-room-inward { transform: scaleX(-1); }
.bh-room-stage { position: absolute; left: 18px; right: 18px; bottom: 15px; height: 1px; background: color-mix(in srgb, var(--header-accent) 22%, transparent); pointer-events: none; }
.lab-sheet .bh-room-header { margin-bottom: 0; }
.lab-sheet #labBody { padding-bottom: 0; }

/* Room headers: keep the approved default composition. When the available
   inline width falls below 10 root ems, use a compact 1.125rem title with
   breathing room for wrapped glyphs. At root 53px: 59.625px type, 71.55px
   line boxes, above the 1rem body size. All four illustrations stay intact.
   The audit budget is 340px at 375x812, leaving over 58% for room content.
   No height cap: longer future copy grows instead of being truncated. */
.bh-room-header {
  container-type: inline-size;
}
@container (width < 10em) {
  .bh-room-header .bh-room-title {
    --fs-room-title: 1.125rem;
    line-height: 1.2;
  }
}

/* NAME-FIT, SCOPED TO THE TWO SURFACES TOM REPORTED.
   He said: "my username is getting clipped. This has happened in a few places a
   few times", and the measured cases were .hub-name on the Bonehead screen
   (scrollWidth 293 in a 227px box, truncated with an ellipsis) and .hero-name
   on Today (230/230, one character from the same fate).
   The first version of this fix generalised that to roughly thirty selectors
   across the app. That reached the Crew deck and took its name plate from
   57.8px to 101px, where it covers the card art: Tom, 2026-09-12, "crew cards
   dont show a photo anymore... it's just a bar with a name". Bisected line by
   line against crew-fan-audit to this block.
   Wrapping is right on a surface that can grow and wrong on one sized around
   art, so it is applied per surface now rather than globally. The remaining
   clipped sites found by tests/name-fit-audit.mjs (#newcomersList .t3-tx b at
   3758px of text in a 143px box, #lbBody .lb-who b at 86 in 77) stay OPEN in
   ROADMAP.md rather than being swept up by a broad rule. */
.hub-title.ward-identity > .hub-name,
.screen--today .hero-meta .hero-name {
  min-width: 0; max-width: 100%;
  white-space: normal; overflow-wrap: anywhere;
  overflow: visible; text-overflow: clip;
  -webkit-line-clamp: unset;
}
.hub-title.ward-identity, .hero-lvrow { align-items: baseline; }
.hub-title.ward-identity > .hub-name,
.screen--today .hero-meta .hero-name { flex: 1; }
/* .pname-iso dropped from this rule. It also sits inside the Crew card's name
   plate, and `display: block` stacks it there: bisected against crew-fan-audit,
   this one line takes the plate from 57.8px to 79.4px and the name from 21.6 to
   43.2, pushing it over the card art. The greeting surface can grow and keeps
   the block display; the card cannot. */
.crew-greeting { display: block; }
.cfan-sel-nm, .fl-main, .lb-pod, .race-lane .bd,
.fight-title, .fight-hud .hud-side { min-width: 0; }
/* Tom 2026-09-17: the step count belongs NEXT TO its name ("the way the steps are laid
   out makes it seem like the person below you's steps are yours"). v664 removed the
   count's margin-left:auto and nothing moved on screen, because this rule made the name
   fill the row (flex: 1 1 50%) and pushed the count to the far edge anyway. The name now
   takes its own width and still shrinks and wraps when long. */
.race-lane .nm b { flex: 0 1 auto; }
.fight-hud .fname { flex-wrap: wrap; gap: 4px; }
.spire-flag { text-transform: uppercase; }
#raceResultCard .race-h b { text-transform: uppercase; }

/* The caption participates in layout, so extra lines extend the scene below
   the art instead of disappearing above a fixed 96px bottom band. */
.screen--today #bhStage {
  height: auto;
  padding-top: calc(var(--scene-h) + var(--bleed, 0px) - 96px);
}
.screen--today #bhStage > .hero-meta {
  position: relative; height: auto; min-height: 96px;
}

/* A variable-height nameplate must not cover the pet or escape a fixed card.
   Grid stacks the existing fanned cards while reserving the tallest card's
   height. Their existing transforms still position each card in the fan. */
/* REMOVED IN v585. The name-fit work rebuilt the Crew fan deck so a wrapping
   name could push the card taller: #cfanWrap height auto, #cfanDeck as a grid,
   and every .cfan-card position relative with height auto. On a deck of fixed
   trading cards that let the name plate grow OVER the art. Measured by
   crew-fan-audit: plate 72.3px before, 101px after, against its own 62px limit,
   and Tom saw it as "crew cards dont show a photo anymore you fucked that up
   too it's just a bar with a name".
   A Crew card is sized around its art, so a long name ellipsises there; the
   wrap treatment belongs on surfaces that have room to grow. The deck keeps its
   original absolute layout. */

.vs-inner { min-width: 0; max-width: 100%; }
.vs-name { text-transform: uppercase; }
.vs-card { overflow: auto; }

/* Inline identity fragments need a measurable, wrapping box too. */
.crew-greeting b, .race-champ b, .sheet-rename .note b {
  display: inline-block; max-width: 100%; overflow-wrap: anywhere;
}

/* A fixed Cheer back button must yield a line before it squeezes the sender
   below one glyph at large root text sizes. Keep the name's wrapping policy. */
.cheer-row { flex-wrap: wrap; }
.cheer-row .cheer-tx { flex: 1 1 8rem; }
.cheer-row .btn.cheer-back {
  max-width: 100%; white-space: normal; overflow-wrap: anywhere;
}

/* F1: match the Backpack ingredient tiles' centred content convention.
   Keep the existing full-width two-column grid and quantity at the corner. */
#chContent .bp-card { text-align: center; }
#chContent .bp-card-top { position: relative; justify-content: center; }
#chContent .bp-card-top .bp-qty { position: absolute; right: 0; top: 0; }

/* F1: the native inside disclosure marker shifts the summary text centre.
   Keep the marker outside the text line, with symmetric space inside the tile. */
#chContent .bp-crate-details summary,
#chContent .bp-item-details summary {
  list-style-position: outside;
  padding-inline: 1em;
}
/* SETTINGS TIDY, 2026-09-11. Every short action in a settings row (Import,
   Review, Guide, Join, Read, Claim, Copy, Open) wrapped into a vertical stack of
   letters at 393px, because the row is a flex box, the label wants its whole
   sentence, and a button's automatic minimum is one glyph. The seg got its
   `flex: none` in the tap-target pass; the buttons never did. The label is the
   thing that yields, never the control. */
.settings-row .lab { flex: 1 1 auto; min-width: 0; }
.settings-row > .btn, .settings-row > a.btn { flex: none; white-space: nowrap; }
.settings-row.stack { flex-direction: column; align-items: stretch; gap: 10px; }
.settings-row .notif-presets { display: flex; gap: 8px; margin: 0; flex-wrap: nowrap; }
.settings-row .notif-presets .btn { flex: 1; min-width: 0; white-space: nowrap; }
#redeemInput::placeholder { text-transform: none; }
.settings-note { margin: 8px 2px 0; }
.settings-foot { text-align: center; margin-top: 18px; }
.redeem-row { display: flex; gap: 8px; }
/* A fold is a settings row that opens: same type, same padding, chevron where
   the control would be. Rows inside keep their own hairlines. */
.settings-fold > summary {
  list-style: none; cursor: pointer; position: relative;
  display: flex; flex-direction: column; gap: 0; padding: 13px 30px 13px 2px;
}
.settings-fold > summary::-webkit-details-marker { display: none; }
.settings-fold > summary::after {
  content: '▸'; position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
  color: var(--text-3); font-size: var(--fs-4); transition: transform 0.2s;
}
.settings-fold[open] > summary::after { transform: translateY(-50%) rotate(90deg); }
.settings-fold > summary > b { font-size: var(--fs-3); font-weight: 600; }
.settings-fold > summary > span { font-size: var(--fs-2); color: var(--text-3); }
.settings-fold-body { padding: 2px 2px 12px; }
.settings-fold-actions { display: flex; align-items: center; gap: 14px; margin-top: 10px; }
.settings-sheet .settings-fold-actions .link { display: inline-flex; align-items: center; min-height: 44px; }
/* F1: match the Backpack ingredient tiles' centred content convention.
   Keep the existing full-width two-column grid and quantity at the corner. */
#chContent .bp-card { text-align: center; }
#chContent .bp-card-top { position: relative; justify-content: center; }
#chContent .bp-card-top .bp-qty { position: absolute; right: 0; top: 0; }

/* F1: the native inside disclosure marker shifts the summary text centre.
   Keep the marker outside the text line, with symmetric space inside the tile. */
#chContent .bp-crate-details summary,
#chContent .bp-item-details summary {
  list-style-position: outside;
  padding-inline: 1em;
}

/* Laboratory: certain and uncertain pets share the existing arrival. */
.lab-reveal.lab-play [data-lab-result-art] { animation: lab-result-arrive .24s ease-out both; }
@media (prefers-reduced-motion: reduce) {
  .lab-reveal.lab-play [data-lab-result-art] { animation: none; animation-iteration-count: 1; }
}



/* THE WARDROBE'S TWO NAVIGATION CONTROLS, GIVEN DIFFERENT JOBS AND DIFFERENT
   WEIGHT. They shipped as two full-width ghost bars stacked flush: measured at
   393x852, "Back to slots" y-77.2..-22.3 and "Dressing Room" y-22.3..32.6, both
   361px wide, zero gap. Tom, 2026-09-12: "your dressing room and back to slots
   buttons look like shit and are overlapping".
   Neither is the primary action on this screen; the grid of pieces is. So both
   become quiet, self-sized controls rather than bars competing with it, and
   they no longer sit next to each other at all: the mode toggle belongs to the
   paperdoll (it switches the whole wardrobe) and the return belongs to the slot
   picker.
   Sizing follows the system: .sect-h's 12/700/.06em uppercase idiom for the
   label, the 13px small radius, and the 44px tap floor kept via padding rather
   than a fixed height so large text still fits. */
.ward-mode-row { display: flex; justify-content: center; margin: 14px 0 2px; }
.ward-slot-nav { display: flex; margin: 18px 0 6px; }

/* .btn.ghost is (0,2,0) and out-specifies a single class, so these carry .btn
   too: measured without it, the return painted --surface-2 with full --text,
   i.e. a second filled button rather than quiet navigation. */
.btn.ward-mode,
.btn.ward-back {
  width: auto; min-height: 44px; padding: 11px 16px;
  border-radius: var(--radius-sm, 13px);
  font-size: var(--fs-1); font-weight: 700; letter-spacing: .06em;
  text-transform: uppercase; white-space: nowrap;
}
/* The return reads as navigation, not as an action: chevron first, the quietest
   text colour in the system, and no filled ground. */
.btn.ward-back {
  display: inline-flex; align-items: center; gap: 8px;
  color: var(--text-3); background: none; border: 0; padding-left: 4px;
  /* .btn carries an elevation shadow. Without clearing it the return still drew
     a rounded-rect outline under the label, which is the button box it is
     meant to stop being. */
  box-shadow: none;
}
.btn.ward-back > span { font-size: 1.25em; line-height: 1; }
.btn.ward-back:hover, .btn.ward-back:focus-visible { color: var(--text); }
/* The mode toggle is a real, if secondary, control: it changes what the whole
   wardrobe is showing you, so it keeps a surface and a hairline. */
.btn.ward-mode {
  background: var(--surface-2); border: 1px solid var(--line);
  color: var(--text-2);
}
.btn.ward-mode:hover, .btn.ward-mode:focus-visible { color: var(--text); border-color: var(--line-strong); }

/* The wardrobe's rules collapse, matching the Backpack's item details. The same
   prose used to sit open under every slot's grid as two centred paragraphs.
   Left-aligned inside, because centred body copy is harder to read and this is
   a paragraph, not a caption. */
.ward-help { margin-top: 14px; }
.ward-help > div { padding-bottom: 4px; }
.ward-help .note { text-align: left; margin: 6px 0 0; }

/* THE VARIANT RAIL IS SIZED AROUND ITS ART, not around the longest item name.
   Tom, 2026-09-12, on tapping a statted piece: "this menu pops down below the
   fold and not near the gear youre trying to click it feels like a glitch.
   also youve fully miscaled the items".
   Measured at 393x852, and PRE-EXISTING rather than new: each .pw-item.famr was
   92px wide and 291px TALL, because .pw-item sets a width and lets height come
   from content, and a name like "Necromancer's Bone Headband" wraps to five or
   six lines in 92px. Four of those made the rail 303px, so it opened correctly
   under the tapped tile at y578 and then ran to y880 on an 852px screen: the
   piece you just tapped scrolls its own controls off the bottom, which is
   exactly why it reads as nothing happening.
   The name is now bounded to two lines and the card to the art plus its labels,
   so the rail lands near the tile AND fits. The name still ellipsises rather
   than being cut mid-glyph, and the tap target keeps its 44px floor. */
/* Cards stretch to one height again, which is fine now that the tallest is 145px
   rather than 267px: uneven card tops in a horizontal rail read as broken. */
/* THE ART CANVAS IS SIZED. `.pw-item.famr .famr-art canvas.ward-art` sizes it to
   100%, but that rule wants a `.famr-art` WRAPPER and the cosmetic cards in this
   rail put the canvas as a DIRECT child of the button. So the rule never
   matched and the canvas fell back to its intrinsic 200x200 attribute size
   inside a 104px card: measured at 393x852, those cards were 267px tall against
   110px for the gear cards beside them, which is the mismatch Tom saw as "youve
   fully miscaled the items". 78px matches the `.famr-art` box the wrapped case
   uses, so both paths now render the same size. */
.fam-rail .pw-item.famr > canvas.ward-art {
  display: block; width: 78px; height: 78px; flex: none;
}
.fam-rail .pw-item.famr { width: 104px; padding: 8px 6px 9px; }
.fam-rail .pw-item.famr b {
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; text-overflow: ellipsis;
  min-height: calc(2 * 1.2em); line-height: 1.2;
}
.fam-rail .pw-item.famr small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }

/* v588: the bench belongs below the Wardrobe slot list. */
#chContent .bp-salvage { margin-top: 16px; scroll-margin-top: 12px; }

/* F6 / v588: complete builder names, including Thunderous Vertebrae #999.
   Rows grow with wrapped text. No line clamp cuts off large-text names. */
.pod-name,
#friendsList .fl-main b,
#crewRequests .fl-main b,
#lbBody .lb-who b,
#raceResultCard .race-h b,
#raceResultCard .race-lane .nm b,
.gift-sealed .tx b,
.spp-plate b,
.cfan-card.feat .cfan-plate b {
  min-width: 0; max-width: 100%;
  white-space: normal; overflow-wrap: anywhere;
  overflow: visible; text-overflow: clip;
  line-height: 1.15;
}
.pod-name { width: 100%; text-align: center; }
/* The crest leaves less name space in the fixed Crew art card. Keep the
   wrapped name within two lines so the plate stays inside the card. */
.cfan-card.feat .cfan-plate .social-name > b {
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden; text-overflow: ellipsis;
}
#raceResultCard .race-h { flex-wrap: wrap; }
#raceResultCard .race-h b { flex: 1 1 100%; }

/* F6 round 2: a single word must also fit at 200% text. */
#newcomersList .t3-tx b,
.fight-venue { overflow-wrap: anywhere; }
/* The HUD is absolutely positioned over the arena, so wrapping cannot move
   the fight stage. Reserve equal two-line name rows to align both HP bars. */
.fight-hud .fname {
  font-size: var(--fs-tiny);
  min-height: calc(2.3em + 6px); line-height: 1.15;
}
.fight-hud .foe .fname {
  white-space: normal; overflow-wrap: anywhere;
  overflow: visible; text-overflow: clip;
}
/* Restored race hydration also exposes the active lanes to the same guard. */
#raceCard .race-lane .nm b {
  min-width: 0; max-width: 100%; white-space: normal;
  overflow-wrap: anywhere; overflow: visible; text-overflow: clip;
}
#raceCard .race-champ > span { min-width: 0; }
#raceCard .gbn-txt small,
#raceCard .race-gap { overflow-wrap: anywhere; }

/* F6 round 3: incoming request controls get their own line so large text
   cannot consume the name column. Since Crew v3 (2026-09-26) they sit inside
   .fl-main under the name (#crewRequests rules at the end of this file). */

/* F7: keep the Pit stage around its uniformly grown companion. */
#petStage.fstage.petmini {
  width: calc(76px * var(--pet-growth, 1));
  height: calc(76px * var(--pet-growth, 1));
  min-height: calc(76px * var(--pet-growth, 1));
}

/* F25: the gear variants rail lives inside the 4-column grid, at the end of the
   tapped tile's row, so it spans the row like .fam-rail does. */
.ward-grid > .ward-variants { grid-column: 1 / -1; }
.ward-grid > .ward-variants:empty { display: none; }

/* F27: the gear rail uses the grid's stat typography, in normal card flow. */
.pw-item.famr .famr-stat {
  font-size: var(--fs-micro); font-weight: 800; letter-spacing: 0.02em; line-height: 1.15;
  color: var(--accent);
}
.fam-rail .pw-item.famr[data-equipgear] b { font-weight: 800; }

/* Backpack round two: inventory counts sit on the card corner, with nearby disclosure. */
#chContent .bp-card,
#chContent .lab-banner { position: relative; overflow: visible; }
#chContent .bp-card-top { position: static; }
#chContent .bp-card-top .bp-qty,
#chContent .lab-banner .bp-qty {
  position: absolute;
  top: -10px; right: -8px; width: 28px; height: 28px; padding: 0;
  box-sizing: border-box; display: grid; place-items: center;
  border: 2px solid var(--ink); border-radius: 999px;
  box-shadow: 2px 2px 0 var(--ink); background: var(--accent); color: var(--ink);
  font-family: var(--display); font-weight: inherit; font-style: normal; font-size: var(--fs-2); line-height: 1;
}
#chContent .bp-qty[data-empty="true"] { background: var(--text-3); }
#chContent .bp-crate-stage {
  display: grid; place-items: center; width: 100%; min-height: 96px;
}
#chContent .bp-crate-details > summary,
#chContent .bp-item-details > summary {
  display: flex; align-items: center; justify-content: center; gap: 6px;
  width: 100%; max-width: 100%; box-sizing: border-box; min-height: 44px;
  padding: 8px 0; list-style: none; text-align: center;
  border: 0; border-radius: 0; background: transparent;
  font-size: var(--fs-1); cursor: pointer;
}
#chContent .bp-card summary::-webkit-details-marker { display: none; }
#chContent .bp-card summary::marker { content: ''; }
#chContent .bp-chevron { display: inline-block; flex: none; transform: rotate(0deg); }
#chContent details[open] > summary .bp-chevron { transform: rotate(90deg); }
#chContent .bp-eggs-empty { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: var(--fs-1); }
#chContent .bp-eggs-empty img { object-fit: contain; image-rendering: pixelated; }

/* Approved Shop G2, 2026-09-14. Shared chip paint and Wardrobe room selection. */
#chContent.shop-g2 { margin: 0; padding: 0; }
#chContent.shop-g2 [hidden] { display: none !important; }
#chContent.shop-g2 .shop-tabs { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; margin: 12px 0; }
#chContent.shop-g2 .shop-tabs .chip { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; min-height: 44px; min-width: 0; padding: 5px 0; font-family: var(--display); font-weight: 400; font-size: var(--fs-2); box-shadow: none; }
#chContent.shop-g2 .shop-tabs .chip.on { box-shadow: var(--sh-sm); background: var(--surface-3); border-color: var(--text-3); color: var(--text); }
#chContent.shop-g2 .shop-tabs .chip.on span { color: var(--text); }
#chContent.shop-g2 .rk-grid, #chContent.shop-g2 .rk-grid.rot { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
#chContent.shop-g2 .rk, #chContent.shop-g2 .rk-grid.rot .rk { display: flex; flex-direction: column; min-width: 0; padding: 6px; border: 2px solid #050408; border-radius: 16px; box-shadow: 0 3px 0 rgba(0,0,0,.32); background: var(--surface); }
#chContent.shop-g2 .rk-stage { aspect-ratio: 1 / 1; border-radius: 7px; overflow: hidden; isolation: isolate; }
#chContent.shop-g2 .rk > b { font: 400 var(--fs-4)/1.15 var(--display); letter-spacing: .03em; min-height: 0; margin: 0 0 12px; }
#chContent.shop-g2 .rk-rar, #chContent.shop-g2 .eyebrow, #chContent.shop-g2 .shop-clock-label { font: 800 var(--fs-0)/1.25 system-ui, sans-serif; letter-spacing: .08em; text-transform: uppercase; }
#chContent.shop-g2 .rk-rar { margin: 12px 0; font: 700 var(--fs-0)/1.2 system-ui, sans-serif; letter-spacing: .06em; }
#chContent.shop-g2 .rk-buy { flex-direction: row; align-items: center; gap: 8px; min-height: 32px; margin-top: auto; }
#chContent.shop-g2 .t3-price { font-family: var(--display); font-weight: 400; font-size: var(--fs-3); letter-spacing: .03em; font-synthesis: none; white-space: nowrap; }
#chContent.shop-g2 .rk-buy > button { flex: 1; width: auto; min-width: 0; min-height: 44px; padding: 0 8px; border: 2px solid var(--ink); border-radius: 9px; gap: 6px; box-shadow: var(--sh-sm); }
#chContent.shop-g2 .rk-owned { min-height: 32px; padding: 0 8px; border: 1.5px solid var(--line-strong); border-radius: 9px; background: var(--surface); letter-spacing: 0; box-shadow: none; }
#chContent.shop-g2 .rk-theme { margin: 24px 4px 12px; gap: 12px; flex-wrap: wrap; }
#chContent.shop-g2 [role="tabpanel"] > .rk-theme:first-child { margin-top: 0; }
#chContent.shop-g2 .shop-rack-heading > b, #chContent.shop-g2 .rk-theme b { font: 700 var(--fs-1)/1.3 system-ui, sans-serif; letter-spacing: .06em; text-transform: uppercase; color: var(--text-3); }
#chContent.shop-g2 .rk-theme i { display: none; }
#chContent.shop-g2 .rk-theme span { font: 400 var(--fs-2)/1.3 system-ui, sans-serif; color: var(--text-3); }
#chContent.shop-g2 .shop-restock { display: grid; gap: 12px; margin: 24px 0 12px; }
#chContent.shop-g2 .shop-rack-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 0 4px 8px; padding-top: 17px; }
#chContent.shop-g2 .rk-clock { position: relative; flex: none; margin: 0; }
#chContent.shop-g2 .shop-clock-label { position: absolute; bottom: calc(100% + 2px); right: 0; font: 700 var(--fs-1)/1.2 system-ui, sans-serif; letter-spacing: .06em; color: var(--text-3); white-space: nowrap; }
#chContent.shop-g2 .rk-clock time { font: 400 var(--fs-6)/1 var(--display); letter-spacing: .03em; font-variant-numeric: tabular-nums; color: var(--text); white-space: nowrap; }
#chContent.shop-g2 .rk-reroll { justify-content: center; width: 100%; min-height: 52px; margin: 12px 0 0; padding: 0 12px; gap: 12px; background: var(--accent); color: var(--accent-ink); border: 2px solid #050408; border-radius: 16px; box-shadow: 0 3px 0 rgba(0,0,0,.32); }
#chContent.shop-g2 .rk-rr { flex: none; align-items: center; }
#chContent.shop-g2 .rk-rr b { font: 400 calc(var(--fs-body) * 1.1875)/1 var(--display); letter-spacing: .04em; color: var(--accent-ink); text-align: center; }
#chContent.shop-g2 .rk-reroll .t3-price { background: var(--surface); color: var(--mint); border: 0; box-shadow: none; padding: 4px 8px; }
#chContent.shop-g2 .fb-drop { box-shadow: none; }
#chContent.shop-g2 .fb-drop h2 { font-size: var(--fs-title); font-weight: 400; }
#chContent.shop-g2 .fb-drop .tx small { white-space: normal; font-size: var(--fs-0); }
#chContent.shop-g2 .fb-drop .t3-price { background: none; border: 0; box-shadow: none; padding: 0; color: var(--gold); flex-wrap: wrap; white-space: normal; }
#chContent.shop-g2 .fb-tint { background: var(--fbc); }
#chContent.shop-g2 .t3-dropsect { margin: 24px 0 0; }
#chContent.shop-g2 #fbSect { margin-top: 0; }
#chContent.shop-g2 summary.chip { min-height: 44px; background: transparent; border: 0; border-bottom: 2px solid var(--line-strong); border-radius: 0; box-shadow: none; }
#chContent.shop-g2 [aria-disabled="true"] { cursor: default; }
#chContent.shop-g2 button:focus-visible, #chContent.shop-g2 summary:focus-visible, #chContent.shop-g2 a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
#chContent.shop-g2 .fb-hero .pc-worn { top: 1.9px; bottom: 4.8px; transform: translateX(calc(-50% + 3.5px)); }
#chContent.shop-g2 .fb-hero { flex: 0 0 113px; min-width: 113px; }
/* 2026-09-25 Tom: "Match Today's sizes". Share the Bonehead's H - 6.7px
   height basis. The measured pair implies a 133.36px reference box at 393:
   37.1 / 133.36 = .2782. Scale its offsets with that box as well. */
#chContent.shop-g2 .fb-hero-pet { inset: 1.9px 0 4.8px; }
#chContent.shop-g2 .fb-hero-pet .shop-kitpet {
  position: absolute; height: 27.82%; width: auto; aspect-ratio: 1;
  left: 50%; bottom: -2.775%; transform: translateX(19.407%);
}
#chContent.shop-g2 [hidden] { display: none !important; }
#chContent.shop-g2 .fb-drop .tx { flex: 1; min-width: 0; }
#chContent.shop-g2 .shop-kitpet { display: block; position: relative; width: 37.1px; height: 37.1px; overflow: visible; isolation: isolate; }
#chContent.shop-g2 .shop-kitpet .bh-anim { position: absolute; width: 100%; height: 100%; transform-origin: 68.75% 77.92%; transform: translate(-18.75%, -27.92%) scale(2.7); animation: none; }
#chContent.shop-g2 .shop-kitpet img { position: absolute; inset: 0; width: 100%; height: 100%; }
#chContent.shop-g2 .rk-clock time { display: block; min-width: 0; text-align: right; }

#chContent.shop-g2 .shop-supply { display: grid; place-items: center; }
#chContent.shop-g2 .rk-stage > .petcrop { width: 100% !important; height: 100% !important; }
#chContent.shop-g2 .rk-stage > .petcrop img { width: 100%; height: 100%; }
#chContent.shop-g2 .rk > small { font: 400 var(--fs-0)/1.4 system-ui, sans-serif; margin-bottom: 12px; }
#chContent.shop-g2 .rk .pet-lock { font-size: var(--fs-0); }

/* Keep the live short-wallet treatment when the approved pill geometry wins. */
#chContent.shop-g2 .rk-buy .t3-price.cant,
#chContent.shop-g2 .rk-buy .t3-price[data-short="1"] { box-shadow: none; border-color: color-mix(in srgb, var(--gold) 45%, transparent); }
#chContent.shop-g2 .rk-buy .t3-price.dust.cant { border-color: color-mix(in srgb, var(--violet) 45%, transparent); }
#chContent.shop-g2 .rk-buy > .rk-aura-toggle { min-height: 44px; border-radius: 9px; }

/* Match G2's 12px content gutter inside the app's 16px screen padding. */
#chContent.shop-g2,
#chTabs.ward-navigation:has(> [data-tab="shop"].on) { margin-inline: calc(12px - var(--pad)); }
#chContent.shop-g2 .drop-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
#chContent.shop-g2 .drop-item { padding: 6px; border: 1.5px solid var(--line-strong); border-radius: 13px; }
#chContent.shop-g2 .drop-item > b { font: 700 var(--fs-2)/1.25 system-ui, sans-serif; }
#chContent.shop-g2 .drop-buy { min-height: 44px; border-radius: 9px; font: 400 var(--fs-3)/1.25 var(--display); box-shadow: var(--sh-sm); }

/* Round 3: the approved filled dust pill. A short wallet keeps the hollow live
   treatment above, so a price you cannot pay never looks payable. */
#chContent.shop-g2 .rk-buy .t3-price.dust:not(.cant) {
  background: var(--violet); color: var(--ink); border-color: var(--ink); box-shadow: var(--sh-sm);
}
/* Reserve the rack's rarity-row space without adding labels to supplies or pets. */
#chContent.shop-g2 .rk-stage + b { margin-top: calc(24px + var(--fs-0) * 1.25); }

/* Backpack's static BG shares the original stage; only its figure wrapper idles. */
.character-portrait .bh-stage > img {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  pointer-events: none;
}
/* Today's 13px baseline offset and right anchor, mapped from its 408px figure.
   Percentages follow the tile; the crop's inline dimensions need overriding. */
.character-portrait .bp-companion {
  position: absolute; right: 5.27%; bottom: 3.18627%;
  width: calc(100% * var(--pet-rel)); aspect-ratio: 1; z-index: 3;
  display: flex; pointer-events: none;
}
.character-portrait .bp-companion .petcrop { width: 100% !important; height: 100% !important; }
/* A shiny companion's crop sits inside .pet-shiny-wrap, which has no size of its
   own, so the crop's 100% resolved against nothing and the pet floated 21px off
   the Bonehead's ground (figure-audit PLANE, shiny C6, 2026-09-26). */
.character-portrait .bp-companion .pet-shiny-wrap { width: 100%; height: 100%; }
.character-portrait .bp-companion img { object-fit: contain; }
.character-portrait .bh-anim { animation: bp-gentle-idle 4s ease-in-out infinite; }
@keyframes bp-gentle-idle {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-2px); }
}
@media (prefers-reduced-motion: reduce) {
  .character-portrait .bh-anim { animation: none; }
}

/* A2: four separate corners. Dot top-left, tick top-right, rarity bottom-left.
   Retain the existing count plate and 3px corner inset. */
.ward-cell.fam .ward-fam-n { left: auto; top: auto; right: 3px; bottom: 3px; }

/* 2026-09-15 Wardrobe A2 round 3: text-only button labels, five stat columns,
   and the existing Wardrobe 12px scroll clearance. Room tabs stay unchanged. */
.ward-mode-tabs > .chip.ch-tab {
  display: flex; flex-direction: row; align-items: center; justify-content: center;
  padding: 8px; font-family: var(--display); font-size: var(--fs-4); font-weight: 400;
}
#chContent .pd-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); }
#chContent .pd-stat { min-width: 0; flex-direction: column; align-items: center; padding: 4px; }
#chContent .pd-stat small { letter-spacing: 0; }
/* Keep colour names whole without changing the shared type scale or columns. */
.pet-a11y .k-grid-head-cell { white-space: nowrap; overflow-wrap: normal; }

/* Kennel Laboratory entry uses the Stable help row's 44px navigation rhythm. */
#kennelBody > [data-lab-open] {
  min-height: 44px; padding: 0; margin: 0;
  border: 0; border-block: 1px solid var(--line); border-radius: 0;
  background: none; box-shadow: none; justify-content: flex-start;
  font-family: inherit; font-size: var(--fs-2); font-weight: 400;
  letter-spacing: normal; line-height: 1.5;
}


/* Normal phones keep the arena cap; chrome spacing pays for three move rows.
   Max-content rows keep the full-width items door from stretching every move.
   Two-line names need 71.6px with 4px padding: three rows plus two 4px gaps
   need 222.8px, with the existing 64px minimum and all label sizes retained. */
@media (min-height: 701px) {
  .sheet-head.fight-header { padding-top: 0; padding-bottom: 0; }
  /* 10px, not 0: the arena's --sh sticker shadow paints 5px below its box, and at 0
     the log row sat inside it, which read as the text "stuck" to the arena edge
     (Tom, 2026-09-27, same report that removed the turn pill). */
  .fight-body > .fight-meta { margin: 10px 2px 0; }
  .fight-meta .fight-log { min-height: 24px; }
  #fightBody > .fight-actions { grid-auto-rows: max-content; gap: 4px 7px; margin-top: 4px; }
  #fightBody > .fight-actions > .fight-act[data-act] { padding-block: 4px; }
  .fight-body > .fight-endrow { margin-top: 4px; }
  #fightBody > .fight-endrow > .fight-act.endturn { min-height: 44px; padding-block: 3px; }
}

/* fight-short: only short phones trade arena height for complete move rows. */
@media (max-height: 700px) {
  /* A fixed height, not flex shrink: the fighters must not move when the log or tray
     changes. 384px is the measured non-arena budget at 375x667 (header 62, meta 40,
     four moves 142, End Turn 59, margins); 183 is the fight-layout floor. */
  .fight-body:not(.fight-settled) > .arena {
    flex: none; min-height: 0;
    height: clamp(183px, calc(100vh - var(--sat) - 384px), 283px);
  }
  #fightBody > .fight-actions {
    flex: 0 0 auto; min-height: 0; grid-auto-rows: max-content;
  }
  .fight-body > .fight-meta { margin: 6px 2px 4px; /* 6 top clears the arena's 5px --sh shadow */ }
  .fight-body > .fight-actions { margin-top: 8px; }
  .fight-body > .fight-endrow { position: static; margin-top: 6px; }
  /* 2026-09-19: at 375x667 the seat-clear toast pinned at sat + 60px sat on the fight sheet's head, covering
     40x33 of the 50x44 Flee control (tests/toast-sheet-audit.mjs CLEAR). The head is 62px and the meta row 40px
     (the fight-short budget above), so sat + 108px puts the toast over the arena art, which has no controls.
     Short phones only: this block is inside max-height 700px, so nothing moves on a normal screen. */
  /* 2026-09-20: measured at 375x667 with a live toast: sat + 108px sat on the HUD lid (names and HP bars, 107 to 194px),
     covering both fighters' HP numbers. The lid ends at 194px, so sat + 200px puts the toast over the arena backdrop
     above the fighters' heads: art only, no numbers, no controls. Still inside the max-height 700px block. */
  body:has(.fight-body:not(.fight-settled)) #toast.toast[data-severity][data-seat-clear] {
    top: calc(var(--sat) + 200px); bottom: auto;
  }
}
/* svh for browsers that have it; the calc holds var(), so an unknown unit would discard
   the whole declaration at computed-value time (dvh-fallback-audit). */
@supports (height: 100svh) {
  @media (max-height: 700px) {
    .fight-body:not(.fight-settled) > .arena { height: clamp(183px, calc(100svh - var(--sat) - 384px), 283px); }
  }
}

/* Today: these two cards use the neighbouring disclosure cards' quiet shell. */
.screen--today #habitGrantCard { background-image: none; }
.screen--today #dayRest > summary > .ring-card {
  background: var(--surface); background-image: none;
  border: 1px solid var(--line); border-radius: 12px; box-shadow: none;
}

/* Kennel keeps Today's portrait and talk box in the sheet's normal flow.
   Explicit display keeps the row visible on short phones too. */
.gw-row.kennel-gwrow, .gw-row.lab-gwrow, .gw-row.gw-say, .gw-row.salvage-gwrow {
  --gw-h: 58px;
  position: static; display: flex; margin: 4px 0 14px;
}
/* gwartSaysHtml: the finished box stays inert (the Today opt-in at
   .gw-row .gw-box.tb-done has no handler here); the plaque is the one tap
   target and opens the Guide. */
.gw-row.gw-say .gw-box.tb-done { pointer-events: none; cursor: auto; }
/* ROUND 2 (Tom, 2026-09-25): the row folds away ([hidden] loses to the
   display:flex above without this) and Gwart waits as a small head in the
   screen's header. The button is the 44px tap target; negative block margins
   trim its layout box so the header it sits in does not grow. The plate is the
   Today plaque's cream and ink border, cropped to his hat and face from the same
   1024 square (x 312, y 205, 400 wide): no new art. */
.gw-row[hidden] { display: none; }
.gw-head {
  --gh: 26px;
  flex: none; width: 44px; height: 44px; margin: -14px -7px -14px auto; padding: 0;
  display: grid; place-items: center; background: none; border: 0; cursor: pointer;
  -webkit-appearance: none; appearance: none;
}
.gw-head-plate {
  position: relative; display: block; overflow: hidden;
  width: calc(var(--gh) + 4px); height: calc(var(--gh) + 4px);
  background: #d5c8b0; border: 2px solid var(--ink); border-radius: 8px; box-shadow: var(--sh-sm);
}
.gw-head-plate img {
  position: absolute; max-width: none;
  width: calc(var(--gh) * 1024 / 400); height: calc(var(--gh) * 1024 / 400);
  left: calc(var(--gh) * -312 / 400); top: calc(var(--gh) * -205 / 400);
}
/* In a 16px .sect-h there are 38px between the tabs above and the grid below,
   so the 44px target costs the header 6px: it reaches up to the tabs' edge, the
   line grows to 22px, the words stay pinned at the top and the plate is nudged
   up to sit on them. */
/* gwart-head-transition */
/* ONE GWART ON SCREEN (Tom, 2026-09-25): while his row is open the head is
   gone, keeping its box so the header does not reflow; visibility also makes it
   neither tappable nor focusable. It goes and comes back under a puff (below):
   poof B, Tom's pick from origin/mock/gwart-r2, replacing the 180ms fade.
   position: relative is the puff's containing block. */
.gw-head { position: relative; }
.gw-head.is-out { visibility: hidden; pointer-events: none; }
/* THE POOF (js/app.js gwartSwap / gwPoof). One node per puff, removed when its
   one clock ends: a 3px-cell pixel strip of four frames, stepped across by the
   svg's transform (steps(4) over .3s = GW_FRAME 75ms x 4; `both` holds the empty
   slot past frame 4 until the node goes). The svg is the only thing animated:
   neither the head nor the row transitions. visibility: visible so the head's
   own hidden state does not take its puff with it. .gw-gone empties a closing
   row while its puff clears, keeping the box. No gradient, no blur; the fills
   are the palette's ink, cream and its shade, and Gwart's own star lime
   (assets/gwart/gwart-stars.png, #dbff00) for the sparkles. Reduced motion
   makes no puff at all (gwartSwap swaps instantly); the media rule is a net. */
.gw-row.gw-gone > :not(.gw-poof) { visibility: hidden; }
.gw-poof { position: absolute; z-index: 2; overflow: hidden; pointer-events: none; visibility: visible; }
.gw-poof svg { display: block; max-width: none; animation: gwPoof .3s steps(4) both; }
@keyframes gwPoof { to { transform: translateX(-100%); } }
.gw-poof .o { fill: var(--ink); }
.gw-poof .b { fill: var(--text); }
.gw-poof .s { fill: var(--text-2); }
.gw-poof .k { fill: #dbff00; }
@media (prefers-reduced-motion: reduce) { .gw-poof { display: none; } }
/* The row's way shut, the head being gone: the sheet heads' X glyph at 12px,
   drawn on the talk box's name line (right edge on the text's right padding) in
   the box's quiet ink. The button is the 44px target and reaches 7px above and
   3px right of the row; the box beneath takes no taps of its own here. inset
   auto undoes .gw-row's Today pin now that the row is positioned. */
.gw-row[data-gw-tip] { position: relative; inset: auto; }
.gw-x {
  position: absolute; top: -7px; right: -3px; z-index: 1;
  width: 44px; height: 44px; min-height: 44px; padding: 0;
  display: grid; place-items: center; pointer-events: auto; color: var(--text-3);
}
.gw-x svg { width: 12px; height: 12px; stroke: currentColor; fill: none; stroke-width: 2.4; stroke-linecap: round; }
/* THE FIRST LINE STOPS SHORT OF THE X (Tom, 2026-09-26: the Build tip read
   "sta[x]rts"). The 44px target reaches 41px in from the box's right edge and
   down into the first text line, so that line alone gives up the width the X
   takes past the box's border and padding: a 1px-tall right float at the top of
   the line shortens it and no other. The ghost (it lays the box out) and the
   reveal (it paints the text) must wrap alike or the box and its text disagree,
   so both get the float, and in tip rows the ghost moves from ::before to
   ::after so it flows after the float. Graded by tests/gwart-close-audit.mjs. */
.gw-row[data-gw-tip] .tb-line::before,
.gw-row[data-gw-tip] .tb-reveal::before {
  content: ''; float: right; height: 1px; visibility: hidden;
  width: calc(41px - 2px - var(--tb-size) * 0.94);
}
.gw-row[data-gw-tip] .tb-line::after {
  content: attr(data-tb);
  display: block; visibility: hidden; white-space: pre-line;
}
.sect-h:has(> .gw-head) { align-items: flex-start; }
.sect-h > .gw-head { margin: -10px -7px -12px auto; }
.sect-h > .gw-head .gw-head-plate { translate: 0 -4px; }
/* Build: beside the YOUR FIGHTER title, not beside the points badge, where it
   took 49px from the stat line and wrapped it to four lines at 320. */
.t3-fighter .n b:has(> .gw-head) { display: flex; align-items: center; gap: 8px; }
.t3-fighter .n b > .gw-head { margin: -12px 0; }

/* THE INCIDENT BAR (2026-09-18). ABOVE THE KEEP-LAST SUFFIX ON PURPOSE:
   tests/wallet-chip-hit.test.mjs fails closed on any later z-index or position.
   WHY A BAR AND NOT A CARD, AND WHY NOTHING HERE IS STICKY. v677 dropped a card
   before .hero-card; the hero's island bleed (a negative margin-top of
   --sat + 14px on .hero-scene, see the block above the second .hero-scene rule)
   collapsed through and painted the art over the card's lower half. v678 answered
   with position: sticky; top: 0; z-index: 4, and Tom, 2026-09-18, with two phone
   screenshots: "it currently scrolls weird behind the UI and looks bad": the
   card pinned while the wallet pill (z-index 6) slid over it, and a band of the
   page backdrop showed between the status bar and the card's top edge.
   So: an in-flow, edge-to-edge bar that owns the top of the screen. Its negative
   top margin cancels .screen's padding exactly as the hero's bleed does, and its
   own padding puts the type 10px under the inset, so nothing of the page shows
   above it. It scrolls away with the page like any other content. Amber edge
   kept: this is "we know", not "something is wrong with your save". */
.incident-bar {
  margin: calc(-1 * (var(--sat) + 14px)) calc(-1 * var(--pad)) 0;
  padding: calc(var(--sat) + 10px) var(--pad) 10px;
  background: var(--surface-2);
  border-left: 3px solid var(--amber);
  display: grid; gap: 3px; text-align: left;
}
/* Type stays on the scale tokens: a player who has turned text size up is
   exactly the player who must be able to read an incident notice, and
   fontscale-audit holds the whole app to 100% scaling type. Fixed px here read
   as 401/402 in unit.test.js. */
.incident-bar b { font-size: var(--fs-2); }
.incident-bar span { font-size: var(--fs-1); line-height: 1.4; color: var(--text-2); }
/* WHILE THE BAR IS PRESENT THE HERO STARTS DIRECTLY UNDER IT, and the
   composition under the bar is the shipped sat-0 composition to the pixel:
   --bleed drops to the 14px of .screen padding the hero has always painted
   into, the negative margin goes, and .hero-top (the one child pinned to --sat
   rather than to --bleed) sits 10px into the scene the way it sits 10px under
   the island today. --fig never read --bleed, so the art's size and centring
   do not move. General sibling, not adjacent: the welcome-back card can sit
   between the bar and the hero. Graded by tests/incident-bar-audit.mjs (SEAM,
   COMPOSE, and an OFF control that pins the bleed intact without the bar). */
#incidentCard ~ .hero-card .hero-scene { --bleed: 14px; margin-top: 0; }
#incidentCard ~ .hero-card .hero-top { top: 10px; }

/* ================= THE GANGS (openGangPick / openGangSheet, js/app.js) =================
 * Behind GANGS_LIVE (data/gangs.js), and every selector here is namespaced
 * under .gang-*, so none of it can paint while the flag is false. `--gang` is
 * each gang's own hex, set inline where the markup already carries it (see
 * gangCrestHtml and the two sheets); colour lives in the data, not twice. */
.gang-standing { display: flex; align-items: center; gap: 13px; margin-bottom: 12px; padding: 13px; background: color-mix(in srgb, var(--gang) 10%, var(--surface-2)); border: 1px solid color-mix(in srgb, var(--gang) 35%, var(--line)); border-radius: 16px; }
.gang-crest { flex: none; }
.gang-standing-txt { flex: 1; min-width: 0; }
.gang-standing-txt b { display: block; font-size: var(--fs-4); font-weight: 700; }
.gang-standing-txt span { display: block; font-size: var(--fs-2); color: var(--text-2); }
.gang-standing-txt small { display: block; font-size: var(--fs-1); color: var(--text-3); margin-top: 4px; }
.gang-standings { display: grid; gap: 8px; margin-bottom: 14px; }
.gs-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.gs-head small { color: var(--text-3); font-size: var(--fs-1); }
.gs-row { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto auto; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 13px; }
.gs-row .gang-crest { width: 42px; height: 42px; object-fit: contain; }
.gs-row > b { overflow: hidden; text-overflow: ellipsis; }
.gs-row > span { color: var(--text-2); font-size: var(--fs-2); }
.gs-row > strong { min-width: 38px; padding: 4px 7px 3px; border-radius: 99px; background: var(--surface-3); text-align: center; font: 400 var(--fs-2)/1 var(--display); }
.gs-row.mine { border-color: var(--gang); background: color-mix(in srgb, var(--gang) 12%, var(--surface-2)); box-shadow: 0 0 16px color-mix(in srgb, var(--gang) 48%, transparent); }
.gang-locked { opacity: .5; }
/* Gang pick and switch (Tom, 2026-09-27; mock mocks-0927/gang-pick). Live only; see openGangPick / openGangSwitch. */
.gk-veil { position: fixed; inset: 0; z-index: 300; display: flex; flex-direction: column; padding: calc(var(--sat) + 22px) 16px calc(var(--sab) + 18px); box-sizing: border-box;
  background: radial-gradient(90% 45% at 50% 38%, color-mix(in srgb, var(--gang) 22%, transparent), transparent 70%), var(--app-backdrop); transition: background .4s; overflow: hidden; }
.gk-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
.gk-top .drop-title { margin: 0; font-size: var(--fs-hero); }
.gk-icobtn { width: 46px; height: 46px; flex: none; border-radius: 14px; background: var(--surface-2); border: 1px solid var(--line-strong); color: var(--text-2); display: grid; place-items: center; box-shadow: var(--sh-sm); }
.gk-icobtn svg { width: 22px; height: 22px; }
.gk-icobtn[aria-pressed="true"] { color: var(--accent-ink); background: var(--accent); border-color: transparent; }
.gk-rail { flex: 1; min-height: 0; display: flex; gap: 12px; margin: 0 -16px; padding: 4px 10vw; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; overscroll-behavior-x: contain; }
.gk-rail::-webkit-scrollbar { display: none; }
.gk-rail[hidden], .gk-grid[hidden] { display: none; }
.gk-card { overflow-y: auto; flex: none; width: 80vw; scroll-snap-align: center; box-sizing: border-box; display: flex; flex-direction: column; border-radius: var(--radius); padding: 14px 14px 12px;
  border: 2px solid var(--gang); box-shadow: var(--sh); background: radial-gradient(80% 55% at 50% 42%, color-mix(in srgb, var(--gang) 34%, var(--surface)), var(--surface) 78%);
  opacity: .45; transform: scale(.94); transition: opacity .35s, transform .35s; }
.gk-card.on { opacity: 1; transform: none; }
.gk-card header { display: flex; align-items: center; gap: 14px; padding-bottom: 8px; }
.gk-card header b { font-family: var(--display); font-weight: 400; font-size: var(--fs-6); line-height: .95; letter-spacing: 1px; color: var(--gang); }
.gk-stage { flex: 1; min-height: 0; display: grid; place-items: center; overflow: hidden; }
/* 60px at default text, 120px at 200%: leave room for rank words and reflow. */
.gk-track { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 3.75rem), 1fr)); gap: 6px; }
.gk-thumb { position: relative; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.gk-thumb .gp-fig.pet:has(> small) { width: max(var(--gp-size), 3.75rem) !important; height: var(--gp-size) !important; display: flex; flex-direction: column; }
.gk-thumb .gp-fig.pet > img { flex: 1; min-height: 0; height: 0; aspect-ratio: auto; }
.gk-thumb .gp-fig.pet > small { flex: none; white-space: nowrap; }
.gk-thumb .gp-fig { border-radius: 12px; background: rgba(0,0,0,.28); border: 2px solid var(--line); }
.gk-thumb.free .gp-fig { border-color: var(--gang); }
.gk-thumb .gk-rk { position: absolute; right: -2px; top: -6px; z-index: 3; }
.gk-thumb small { font-size: var(--fs-2); color: var(--text); font-weight: 600; }   /* Tom 2026-10-01: rep under the pill was hard to read */
.gk-thumb.free small { color: var(--gang); }
.gp-badge.gk-iconly { padding: 3px; width: 24px; height: 24px; justify-content: center; box-sizing: border-box; }
.gk-line { margin: 10px 0 0; text-align: center; font-family: var(--dialogue); font-size: var(--fs-3); color: var(--text); }
.gk-dots { display: flex; justify-content: center; gap: 8px; margin: 12px 0; }
.gk-dots i { width: 9px; height: 9px; border-radius: 50%; background: var(--surface-3); transition: width .3s, background .3s; }
.gk-dots i.on { width: 26px; border-radius: 9px; background: var(--gang); }
.gk-veil.compare .gk-dots { visibility: hidden; }
.btn.gk-cta, .btn.gk-go { background: var(--gang); color: #111; min-height: 54px; width: 100%; font-size: var(--fs-5); }
.btn.gk-go:disabled { opacity: .5; }
.gk-foot { margin: 12px 0 0; display: flex; justify-content: center; align-items: center; gap: 8px; color: var(--text-3); font-size: var(--fs-2); font-weight: 600; }
.gk-clock { display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px 3px 6px; border-radius: 99px; background: var(--surface-3); color: var(--text); font-family: var(--display); letter-spacing: 1px; font-size: var(--fs-3); line-height: 1; }
.gk-clock svg { width: 16px; height: 16px; }
.gk-coin { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px 3px 5px; border-radius: 99px; background: rgba(255,180,84,.16); color: var(--gold); font-family: var(--display); letter-spacing: 1px; font-size: var(--fs-4); line-height: 1; border: 1px solid rgba(255,180,84,.35); }
.gk-coin img { width: 20px; height: 20px; image-rendering: pixelated; }
.gk-grid { flex: 1; min-height: 0; overflow-y: auto; display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, minmax(0, 1fr)); gap: 8px 6px; align-items: center; justify-items: center; align-content: start; }
.gk-ch { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.gk-ch b { font-family: var(--display); font-weight: 400; letter-spacing: 1px; color: var(--gang); font-size: var(--fs-4); }
.gk-rh { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.gk-rh small { font-size: var(--fs-2); color: var(--text); font-weight: 600; }
.gk-cc { width: 100%; display: grid; place-items: center; border-radius: 14px; padding: 4px 0; background: color-mix(in srgb, var(--gang) 12%, var(--surface)); border: 2px solid color-mix(in srgb, var(--gang) 45%, transparent); }
.btn.gk-pickbtn { width: 100%; min-height: 46px; background: var(--gang); color: #111; }
.gk-panel { position: absolute; inset: 0; z-index: 5; }
.gk-shade { position: absolute; inset: 0; background: rgba(6,5,9,.72); animation: gk-fade .3s; }
.gk-sheet { position: absolute; left: 0; right: 0; bottom: 0; max-height: 100%; overflow-y: auto; background: var(--surface); border-radius: 26px 26px 0 0; border-top: 2px solid var(--gang); padding: 8px 16px calc(var(--sab) + 22px); box-shadow: 0 -10px 40px rgba(0,0,0,.5); animation: gk-up .4s cubic-bezier(.2,.8,.2,1); }
@keyframes gk-up { from { transform: translateY(100%); } }
@keyframes gk-fade { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .gk-veil, .gk-card, .gk-dots i { transition: none; } .gk-sheet, .gk-shade { animation: none; } }
.gk-conf-hero { display: flex; align-items: center; justify-content: center; gap: 4px; }
.gk-h { font-family: var(--display); font-weight: 400; letter-spacing: 1px; text-align: center; font-size: var(--fs-6); margin: 8px 0 12px; line-height: 1; }
.gk-h span { color: var(--gang); }
.gk-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 16px; }
.gk-facts > div { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px; border-radius: 14px; background: var(--surface-2); border: 1px solid var(--line); }
.gk-facts small { font-size: var(--fs-2); font-weight: 700; color: var(--text); text-align: center; }
.gk-tower { width: 52px; height: 52px; display: grid; place-items: center; border-radius: 50%; box-shadow: inset 0 0 0 3px var(--gang); background: color-mix(in srgb, var(--gang) 18%, transparent); }
.gk-tower img { height: 38px; }
.gk-bigclock { width: 52px; height: 52px; display: grid; place-items: center; color: var(--gang); }
.gk-bigclock svg { width: 40px; height: 40px; }
.gk-row2 { display: grid; grid-template-columns: 1fr 1.6fr; gap: 10px; }
.gk-row2 .btn { min-height: 52px; }
.gk-change-btn { width: 100%; min-height: 64px; display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-radius: 14px; background: var(--surface-2); border: 1px solid var(--line-strong); color: var(--text); }
.gk-change-crests { display: flex; gap: 4px; }
.gk-change-lab { flex: 1; text-align: left; font-family: var(--display); letter-spacing: 1px; font-size: var(--fs-4); }
.gk-switch { display: flex; flex-direction: column; gap: 12px; }
.gk-sw-head { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 4px 0 8px; }
.gk-arrow { width: 28px; height: 28px; color: var(--text-3); display: grid; place-items: center; flex: none; }
.gk-arrow svg { width: 26px; height: 26px; }
.gk-arrow.sm { width: 18px; height: 18px; }
.gk-arrow.sm svg { width: 18px; height: 18px; }
.gk-targets { display: flex; gap: 8px; }
.gk-tgt { padding: 4px; background: none; border: 0; opacity: .5; min-width: 44px; min-height: 44px; }
/* No circle around a crest (Tom 2026-09-29): the pick reads as full opacity plus the Solid crest glow. */
.gk-tgt.on { opacity: 1; filter: drop-shadow(0 0 3px var(--gang)) drop-shadow(0 0 6px color-mix(in srgb, var(--gang) 60%, transparent)); }
.gk-door { border-radius: var(--radius); padding: 10px 12px 12px; border: 2px solid var(--gang); background: color-mix(in srgb, var(--gang) 9%, var(--surface-2)); display: flex; flex-direction: column; gap: 10px; }
.gk-door header { display: flex; align-items: center; justify-content: space-between; min-height: 30px; }
.gk-freechip { font-family: var(--display); letter-spacing: 1.2px; font-size: var(--fs-5); line-height: 1; }
.gk-lane { display: flex; align-items: center; gap: 6px; }
.gk-lane.swag { display: grid; grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr); align-items: center; }
.gk-swag-side { min-width: 0; display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
.gk-lane .gp-st, .gk-pc.got .gp-st { position: static; width: 22px; height: 22px; }
.gk-lane .gp-st svg { width: 12px; height: 12px; }
.gk-push { flex: 1; }
.gk-pc { position: relative; flex: none; border-radius: 10px; background: rgba(0,0,0,.3); border: 2px solid var(--line); display: grid; place-items: center; }
.gk-pc.got { border-color: var(--gang); }
.gk-pc.got .gp-st { position: absolute; right: -6px; top: -6px; width: 20px; height: 20px; }
.gk-pc.lost { border-color: color-mix(in srgb, var(--danger) 55%, transparent); }
.gk-pc.lost .gp-fig { filter: grayscale(.25) brightness(.6); }
.gk-pc.lost > i { position: absolute; inset: 0; display: grid; place-items: center; color: var(--danger); }
.gk-pc.lost > i svg { width: 100%; height: 100%; filter: drop-shadow(0 0 2px #000); }
.gk-pc.tower { width: 54px; height: 54px; box-sizing: content-box; }
.gk-pc.tower img { width: 70%; height: 70%; object-fit: contain; filter: grayscale(.25) brightness(1); }
.gk-pc.tower b { position: absolute; z-index: 1; right: -6px; bottom: -6px; background: var(--surface-3); border-radius: 99px; padding: 2px 6px; font-size: var(--fs-2); color: var(--text); }
.gk-cool { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 8px 0 6px; }
.gk-ring { position: relative; width: 150px; height: 150px; }
.gk-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.gk-ring circle { fill: none; stroke-width: 9; }
.gk-ring .bg { stroke: var(--surface-3); }
.gk-ring .fg { stroke: var(--gang); stroke-linecap: round; }
.gk-ring > div { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
.gk-ring .gk-bigclock { width: 36px; height: 36px; }
.gk-ring .gk-bigclock svg { width: 30px; height: 30px; }
.gk-ring b { font-size: var(--fs-7); color: var(--text); }
.gk-confirm { overflow-y: auto; justify-content: flex-start; }
.gk-modal { width: 100%; max-width: 420px; margin-block: auto; background: var(--surface); border-radius: var(--radius); border: 2px solid var(--gang); padding: 18px 16px 16px; box-shadow: var(--sh); }
.gk-lost-big { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; margin: 6px 0 16px; }
.gk-loss-label { text-align: center; color: var(--text); font-size: var(--fs-3); line-height: 1.3; margin: 12px 0 24px; }
.gk-thumb .gp-badge, .gk-rh .gp-badge { max-width: 100%; box-sizing: border-box; white-space: nowrap; overflow-wrap: normal; justify-content: center; text-align: center; line-height: 1.3; letter-spacing: 0; font-size: var(--fs-1); padding: 3px 4px 2px; }
.gk-thumb .gp-badge img, .gk-rh .gp-badge img { display: none; }
.gk-thumb > small, .gk-rh > small { line-height: 1.3; text-align: center; }
/* Gang progress, direction A (spotlight), 2026-09-27. Live only; see gangProgressHtml. */
.gp { --soft: color-mix(in srgb, var(--gang) 14%, var(--surface-2)); margin-bottom: 14px; }
.gp-crest { position: relative; flex: none; display: inline-grid; place-items: center; }
.gp-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px 2px 6px; border-radius: 99px; background: var(--gang); color: #111;
  white-space: nowrap; font-family: var(--display); font-size: var(--fs-3); letter-spacing: 1px; line-height: 1.1; }
.gp-badge img { width: 16px; height: 16px; image-rendering: pixelated; }
/* Rank reads on the word too (Tom 2026-09-29): Brittle is an outline, Solid is filled, Unbreakable is filled and glowing. */
.gp-badge.r-brittle, .gp-crest .gp-badge.r-brittle { background: var(--surface); color: var(--gang); box-shadow: inset 0 0 0 2px var(--gang), 0 0 0 3px var(--surface); }
.gp-badge.r-unbreakable, .gp-crest .gp-badge.r-unbreakable { box-shadow: 0 0 0 3px var(--surface), 0 0 14px 2px var(--gang); }
.gk-thumb .gp-badge.r-brittle, .gk-rh .gp-badge.r-brittle, .gk-card .gp-crest .gp-badge.r-brittle { background: var(--gang); color: #111; box-shadow: 0 0 0 3px var(--surface); }
.gp-crest .gp-badge { position: absolute; left: 50%; bottom: -9px; transform: translateX(-50%); box-shadow: 0 0 0 3px var(--surface); }
/* Your Gang: crest and pill share one grid cell, so a pill wider than the crest (Unbreakable) widens the cell and nudges the crest right instead of poking past the left gutter. Same 9px drop as above. */
.gp-id .gp-crest > * { grid-area: 1 / 1; }
.gp-id .gp-crest .gp-badge { position: relative; z-index: 1; left: auto; transform: none; align-self: end; margin-bottom: -9px; }   /* relative + z: the crest's filter paints it in the positioned layer, above a static pill */
.gp-fig { position: relative; display: block; overflow: hidden; flex: none; }
.gp-fig .bh-anim { inset: 0; animation: none; }
.gp-fig:not(.gp-upper) .bh-anim { transform-origin: 50% 50%; transform: scale(.88); }
.gp-fig.pet .petcrop { display: block; }
.gp-fig.pet:has(> small) { height: auto !important; max-width: 100%; overflow: visible; }
.gp-fig.pet > img { display: block; width: 100%; height: auto; aspect-ratio: 1.5; object-fit: contain; }
.gp-fig.pet > small { display: block; text-align: center; font-size: var(--fs-1); line-height: 1.3; overflow-wrap: anywhere; padding-bottom: 4px; }
.gp-num { font-family: var(--display); letter-spacing: 1px; line-height: 1; font-variant-numeric: tabular-nums; }
.gp-st { position: absolute; z-index: 3; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; }
.gp-st svg { width: 15px; height: 15px; }
.gp-st.ok { background: var(--accent); color: var(--accent-ink); }
.gp-st.lk { background: var(--surface-3); color: var(--text-2); border: 1px solid var(--line-strong); }
.gp-id { display: flex; align-items: center; gap: 16px; padding: 32px 2px 18px; } /* 32px top: room for the Unbreakable glow, which the scroll area would clip */
.gp-id b { font-family: var(--display); font-size: var(--fs-6); letter-spacing: 1px; line-height: 1; font-weight: 400; }
/* Track B (Tom 2026-09-30): the next reward's garment big in a gang-tinted hero, a linked chip per stage, one caption. */
.gp-hero { border-radius: 22px; padding: 18px 16px 16px; border: 2px solid var(--gang); text-align: center;
  background: radial-gradient(120% 90% at 50% 35%, color-mix(in srgb, var(--gang) 18%, var(--surface-2)), var(--surface-2) 62%); }
.gp-show { display: grid; justify-items: center; gap: 10px; }
.gp-kick { font-size: var(--fs-1); font-weight: 700; line-height: 1; letter-spacing: .14em; text-transform: uppercase; color: var(--gang); }
.gp-art { display: block; filter: drop-shadow(0 10px 16px rgba(0,0,0,.5)); }
.gp-name { font-family: var(--display); font-weight: 400; font-size: var(--fs-6); line-height: 1; letter-spacing: .5px; text-transform: uppercase; color: var(--text); }
.gp-q { display: grid; place-items: center; font-family: var(--display); font-size: var(--fs-6); line-height: 1; color: var(--text-2); }
.gp-hero .gp-bar { margin-top: 12px; }
.gp-chips { display: flex; align-items: center; justify-content: center; margin: 16px 0 8px; }
.gp-chipb { position: relative; flex: none; width: 58px; height: 58px; padding: 0; border-radius: 50%; display: grid; place-items: center;
  background: var(--surface-2); border: 2px solid var(--surface-3); }
.gp-chipb.done, .gp-chipb.next { border-color: var(--gang); }
.gp-chipb.next { box-shadow: 0 0 14px color-mix(in srgb, var(--gang) 45%, transparent); }
.gp-chipb.lock .gp-ca { filter: grayscale(1) brightness(.45); }
.gp-chipb .gp-st { width: 20px; height: 20px; right: -5px; top: -5px; }
.gp-chipb .gp-st svg { width: 11px; height: 11px; }
.gp-link { flex: 1 1 34px; max-width: 34px; height: 4px; background: linear-gradient(90deg, var(--gang) calc(var(--f) * 100%), var(--surface-3) 0); }
.gp-steps { margin: 0 0 16px; text-align: center; font-size: var(--fs-2); color: var(--text-2); }
.gp-bar { position: relative; height: 16px; margin-top: 6px; border-radius: 99px; background: var(--surface-3); overflow: hidden; box-shadow: inset 0 1px 2px rgba(0,0,0,.4); }
.gp-bar i { position: absolute; inset: 0; border-radius: 99px; background: var(--gang); overflow: hidden; transform-origin: left center; transition: transform 1s cubic-bezier(.2,.8,.2,1); }
.gp-bar i::after { content: ''; position: absolute; top: 0; bottom: 0; width: 40%; left: -45%; background: linear-gradient(90deg, transparent, rgba(255,255,255,.45), transparent); }
.gp-bar.landing i::after { animation: gp-sheen 1s ease-out 1; }
@keyframes gp-sheen { to { left: 110%; } }
.gp-nums { display: flex; align-items: baseline; justify-content: space-between; margin-top: 8px; }
.gp-nums .now { font-family: inherit; font-size: var(--fs-2); font-weight: 700; letter-spacing: 0; color: var(--text); }
.gp-nums .of { font-size: var(--fs-2); color: var(--text-2); }
.gp-nums .togo { font-size: var(--fs-5); color: var(--gang); }
.gp-src { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.gp-src button { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 6px; min-height: 108px;
  padding: 10px 4px; border-radius: var(--radius-sm); background: var(--surface-2); border: 1px solid var(--line); color: var(--text); font: inherit; }
.gp-src button[aria-expanded="true"] { border-color: var(--gang); background: var(--soft); }
.gp-src .art { height: 52px; display: grid; place-items: end center; }
.gp-src .art img { max-height: 52px; width: auto; }
.gp-src .art img.px { height: 44px; image-rendering: pixelated; }
.gp-src .gp-src-caption { max-width: 100%; min-width: 0; font-size: var(--fs-2); line-height: 1.3; font-weight: 700; color: var(--gang); text-align: center; overflow-wrap: anywhere; }
.gp-src .x2 { position: absolute; top: 7px; right: 7px; display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 99px;
  background: var(--surface-3); color: var(--text-2); font-size: var(--fs-0); font-weight: 700; }
.gp-src .x2 svg { width: 12px; height: 12px; }
.gp-hint { margin: 8px 0 0; padding: 9px 12px; border-radius: 12px; background: var(--surface-3); color: var(--text); font-size: var(--fs-2); font-weight: 600; text-align: center; position: relative; }
.gp-hint::before { content: ''; position: absolute; top: -5px; width: 10px; height: 10px; background: var(--surface-3); transform: rotate(45deg); left: var(--hx, 50%); margin-left: -5px; }
.gp-cap { display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 10px; color: var(--text-3); font-size: var(--fs-1); font-weight: 700; font-variant-numeric: tabular-nums; }
.gp-cap .mini { width: 64px; height: 5px; border-radius: 99px; background: var(--surface-3); overflow: hidden; }
.gp-cap .mini i { display: block; height: 100%; background: var(--text-3); border-radius: 99px; }
.gp-cap img { width: 16px; height: 16px; image-rendering: pixelated; opacity: .8; }
.gp-unlock { position: fixed; inset: 0; z-index: 300; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; padding: 24px;
  background: radial-gradient(70% 45% at 50% 42%, color-mix(in srgb, var(--gang) 34%, transparent), transparent 70%), rgba(13, 12, 18, .94); }
.gp-unlock .halo { position: absolute; left: 50%; top: 42%; width: 300px; height: 300px; margin: -150px 0 0 -150px; border-radius: 50%; border: 3px solid var(--gang); opacity: 0; pointer-events: none; }
.gp-unlock .hero { position: relative; border-radius: 28px; border: 2px solid var(--gang); padding: 10px;
  background: radial-gradient(80% 70% at 50% 40%, color-mix(in srgb, var(--gang) 30%, var(--surface-2)), var(--surface-2) 72%); }
.gp-unlock .shackle { position: absolute; right: 14px; top: 14px; z-index: 4; }
.gp-unlock .shackle .gp-st { position: static; width: 40px; height: 40px; }
.gp-unlock .shackle .gp-st svg { width: 20px; height: 20px; }
.gp-unlock .gp-crest .gp-badge { font-size: var(--fs-4); padding: 4px 11px 3px 8px; bottom: -12px; }
.gp-unlock .gp-crest .gp-badge img { width: 18px; height: 18px; }
.gp-unlock .btn { min-width: 200px; margin-top: 8px; }
@media (prefers-reduced-motion: reduce) { .gp-bar i { transition: none; } .gp-bar.landing i::after { animation: none; } }

/* Brock's crows circle above the Bonehead, clipped below the controls. */
.crowfx { position: absolute; overflow: hidden; pointer-events: none; z-index: 3; }
.crowfx-bird { position: absolute; width: 51.2px; height: 39.2px; pointer-events: none; }
.crowfx-bird img { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.crowfx[data-side="f"] .crowfx-bird { transform: scaleX(-1); }
/* 1px cream outline so dark ink reads on the dark arena: haymaker wind-up frames 1-6 by default (Tom picked V1), crows only with ?fxopt=crowrim. */
.strikefx.sfx-haymaker img.fxopt-rim, .crowfx-bird.fxopt-rim img { filter: drop-shadow(1px 0 0 #f3ead7) drop-shadow(-1px 0 0 #f3ead7) drop-shadow(0 1px 0 #f3ead7) drop-shadow(0 -1px 0 #f3ead7); }

/* Tom 2026-09-17: "remove these lines next to CRATES and STEP EGGS, that design
   feature feels like an AI choice". The shared heading decoration is .t3-sect i;
   only the Backpack loses it (the identity header carries .bp-identity). */
#chBody:has(#chTabs [data-tab="crates"].on) #chContent .t3-sect > i { display: none; }
#chBody:has(#chTabs [data-tab="crates"].on) #chContent .sect-h::after { content: none; display: none; }

/* 2026-09-24 Settings reorganise, Tom approved. */
.settings-home { display:grid; grid-template-columns:minmax(0, 1fr); gap:12px; } /* minmax(0,1fr): at 200% text a returned sheet panel's min-content widened every card to 428px in a 375 screen */
.settings-home > .card { margin:0; padding:4px 16px; border:2px solid #050408; border-radius:16px; box-shadow:0 5px 14px rgba(0,0,0,.25); }
.settings-section-title { font:700 var(--fs-1)/1.4 var(--body, sans-serif); letter-spacing:.06em; text-transform:uppercase; color:var(--text-3); margin:12px 4px -4px; }
.settings-home .settings-row, .settings-sheet .settings-row { min-height:52px; gap:12px; padding:8px 0; box-sizing:border-box; }
.settings-home .settings-row[hidden], .settings-switch-legacy[hidden] { display:none; }
.settings-door { display:flex; align-items:center; width:100%; background:none; border:0; border-bottom:1px solid var(--line); color:var(--text); text-align:left; font:inherit; cursor:pointer; text-decoration:none; }
.settings-door:last-child { border-bottom:0; }
.settings-door .lab { flex:1; min-width:0; }
.settings-door .lab b { display:block; font-size:var(--fs-3); font-weight:600; }
.settings-door .lab > span { display:block; margin-top:2px; color:var(--text-3); font-size:var(--fs-2); }
.settings-bug-tools > summary::after,
.settings-chevron { flex:none; width:8px; height:8px; border-right:2px solid var(--text-3); border-top:2px solid var(--text-3); transform:rotate(45deg); margin-right:4px; }
.settings-value { color:var(--text-3); font-size:var(--fs-2); text-align:right; }
.settings-gold { color:var(--gold); }
.settings-switch { position:relative; flex:0 0 48px; width:48px; height:44px; padding:0; border:0; background:none; cursor:pointer; }
.settings-switch::before { content:''; position:absolute; inset:8px 0; border:2px solid #050408; border-radius:16px; background:var(--surface-3); }
.settings-switch::after { content:''; position:absolute; left:4px; top:12px; width:20px; height:20px; border-radius:50%; background:var(--text-2); }
.settings-switch[aria-checked="true"]::before { background:var(--text); }
.settings-switch[aria-checked="true"]::after { left:24px; background:var(--bg); }
.settings-switch:disabled { opacity:.45; cursor:default; }
.settings-home :focus-visible, .settings-sheet :focus-visible { outline:2px solid var(--text); outline-offset:3px; }
.settings-home .seg .on { background:var(--surface-3); color:var(--text); }
.settings-home .btn:not(#feedbackBtn), .settings-sheet .btn { background:var(--surface-2); color:var(--text); font-family:var(--display); }
.settings-home .link, .settings-sheet .link { color:var(--text-2); }
.settings-home .q-badge { background:var(--surface-3); color:var(--text); }
.settings-home > .settings-feedback { display:grid; grid-template-columns:64px 1fr; gap:14px; align-items:center; padding:16px; }
.settings-feedback img { width:64px; height:64px; object-fit:cover; border:2px solid #050408; border-radius:12px; background:var(--text); }
.settings-feedback b { font:400 calc(var(--fs-body) * 1.375)/1.1 var(--display); letter-spacing:.02em; }
.settings-feedback p { margin:4px 0 0; font-size:var(--fs-2); color:var(--text-2); }
.settings-feedback > button, .settings-feedback > a { grid-column:1 / -1; }
.settings-feedback > a { justify-content:space-between; font-size:var(--fs-2); color:var(--text-2); border-top:1px solid var(--line); }
.settings-danger .lab b { color:var(--danger); }
.settings-foot { font-size:var(--fs-1); line-height:1.6; text-align:center; color:var(--text-3); }
.settings-foot a { display:inline-flex; align-items:center; min-height:44px; color:var(--text-2); text-decoration:underline; }
.settings-panel > .settings-fold-body { padding:12px 0; }
.settings-sheet .sheet-body { overflow-x:hidden; }
.settings-home .crew-handle { color:var(--text); }
.settings-home .crew-id { padding:12px 0; }
.settings-home .settings-door:hover, .settings-sheet .btn:hover { background:var(--surface-2); }

.settings-bug-tools > summary::after { content:""; transform:translateY(-50%) rotate(45deg); }
/* 2026-09-24 grill pass: one row rhythm. Inline subtitle spans took the 21.6px
   strut of their .lab, so two-line rows read looser than the doors beside them;
   the bug-tools summary kept the old fold's 2px/30px insets; the notes kept 2px;
   the Discord link left a 40px floor under the feedback card; the sheet's data
   rows let the label's max-content push the button onto its own line. */
.settings-home .settings-row .lab > span, .settings-sheet .settings-row .lab > span { display:block; margin-top:2px; }
.settings-home .settings-bug-tools > summary { padding:8px 16px 8px 0; min-height:52px; justify-content:center; box-sizing:border-box; }
.settings-home .settings-bug-tools > summary::after { right:0; }
.settings-home .settings-note { margin-left:0; margin-right:0; }
.settings-home > .settings-feedback { padding-bottom:4px; }
.settings-feedback > a { min-height:44px; }
.settings-row.settings-data-action > .lab { flex:1 1 10rem; }
.settings-sheet .settings-row:has(#filePointsBtn) { border-bottom:0; }
.settings-home .card > .note:has(+ .settings-row, + .settings-door) { padding-bottom:12px; border-bottom:1px solid var(--line); } /* the cloud-backup note ran into Recovery code with no divider */
.settings-home .lab > span { overflow-wrap:anywhere; } /* diagLine carries unbroken tokens that ran 85px past its column at 200% text */
.settings-bug-tools[open] > summary::after { transform:translateY(-50%) rotate(135deg); }

.iphone-transfer-steps { padding-left: 24px; margin: 8px 0; }
.iphone-transfer-steps .lab { flex: 1; }
.iphone-transfer-steps .lab b, .iphone-transfer-steps .lab > span { display: block; }
.iphone-transfer-steps .lab > span { color: var(--text-3); font-size: var(--fs-2); }
.iphone-transfer-steps .btn { min-height: 44px; }
.onb-web-note .card-title { color: var(--text-3); }
#goOnlineBtn + .note { text-align: center; margin: 10px 0 0; }
.iphone-transfer-steps li + li { border-top: 1px solid var(--line); }

/* 2026-09-25: the approved reorg controls retain a 44px touch floor. */
.settings-sheet #restoreAcctBtn { min-height: 44px; }


/* 2026-09-25: approved mockup icons and diagnostic sheet wrapping. */
.settings-row-icon { width:26px; height:26px; flex:none; image-rendering:pixelated; }
.settings-sheet .lab > span { overflow-wrap:anywhere; }

/* KEEP LAST: tests/wallet-chip-hit.test.mjs fails closed on any later rule touching these properties. */
/* Wallet hit areas stay above the adjacent Gwart row without changing layout. */
.hero-top { z-index: 6; }
#coinBtn, #dustBtn, #vigorBtn { position: relative; }
#coinBtn::before, #dustBtn::before, #vigorBtn::before {
  content: ''; position: absolute; left: 50%; top: 50%;
  width: 100%; height: 100%; min-width: 44px; min-height: 44px;
  transform: translate(-50%, -50%); pointer-events: auto;
}

/* 2026-09-25: the ground follows the art's bottom-centred contain square.
   This sibling shares the hero layout/shift but never its filter or bounce. */
#bhStage > .hero-ground-anchor { filter: none; animation: none; z-index: 1; pointer-events: none; }
#bhStage > .hero-ground-anchor .bh-anim { container-type: size; animation: none; transition: none; }
#bhStage .hero-ground-anchor .c-bh {
  left: calc(50% + var(--foot-x) * min(100cqw, 100cqh));
  top: auto;
  bottom: calc(var(--foot-gap) * min(100cqw, 100cqh) - 9px);
  width: var(--hero-cast-width); height: 18px;
  background: radial-gradient(ellipse at 50% 50%, rgba(24, 27, 20, .78) 0%, rgba(24, 27, 20, 0) 70%);
}

/* 2026-09-25 Tom approved Shop clean-up, variant A. Existing art is untouched. */
.gw-hero:has(.shop-header-band) { padding-bottom: 8px; }
.shop-header-band { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin: 0 16px; position: relative; z-index: 3; }
.gw-panel + .shop-header-band { margin-top: calc(-1 * var(--gw-air)); }
.shop-wallet { display: flex; gap: 6px; flex: none; }
.shop-wallet-chip { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); font: 400 var(--fs-3)/1 var(--display); letter-spacing: .03em; color: var(--text); font-variant-numeric: tabular-nums; }
.shop-wallet-chip img { image-rendering: pixelated; flex: none; }
#chBody:has(.gw-hero) #chTabs.ward-navigation > .ch-tab.on { color: var(--text); box-shadow: inset 0 -3px 0 var(--text); }
#chBody:has(.gw-hero) #chTabs.ward-navigation > .ch-tab.on span { color: var(--text); }
#chContent.shop-g2 .rk-buy .t3-price { min-height: 44px; line-height: 1; }
#fbSect .fb-drop-row { position: relative; padding-right: 26px; }
.shop-chevron { position: absolute; right: 12px; top: 50%; width: 9px; height: 9px; margin-top: -7px; border-right: 2px solid var(--text-2); border-top: 2px solid var(--text-2); transform: rotate(135deg); transition: transform .2s; }
#fbSect[open] .shop-chevron { transform: rotate(-45deg); margin-top: -2px; }
@media (max-width: 340px) {
  #chContent.shop-g2 .shop-tabs .chip .ico { width: 16px; height: 16px; }
  #chContent.shop-g2 .rk-buy { gap: 6px; }
  #chContent.shop-g2 .rk-buy > button { padding: 0 5px; gap: 4px; font-size: var(--fs-2); }
  #fbSect .shop-dot { display: none; }
  #fbSect .fb-drop-row { padding-right: 0; }
}
/* Food logging, approved pass 2. Tom, 2026-09-25. */
/* F1 placeholders measured 3.65:1 (UA grey #757575). Tokened tertiary text instead. */
.sheet.t1 input::placeholder { color: var(--text-3); opacity: 1; }

/* F2 9px micro labels (KCAL, PROTEIN, +XP, macro heads) are under the 11px floor. */
.sheet.t1 .t1-med small, .sheet.t1 .t1-frow .pg small,
.sheet.t1 .t1-macros small, .sheet.t1 .t1-route .xp { font-size: var(--fs-0); letter-spacing: .04em; }

/* F3 budget row: "94 / 185 P" sat 3.6px off the "LEFT" baseline. */
.sheet.t1 .t1-budget .n, .sheet.t1 .t1-budget .p { align-self: baseline; }

/* F4 capture tiles: icons and labels did not line up across the four tiles
   (the two with an XP line centred higher). Top-anchor the stack. */
.sheet.t1 .t1-route { justify-content: flex-start; padding-top: 10px; }
/* Quick and My foods draw a 16px <img>, the other two a 19px <svg>: give the img the
   same 19px slot (centred, not scaled) so all four labels share one baseline. */
.sheet.t1 .t1-route > img:first-child { margin-block: 1.5px; }

/* F5 the "8 recent foods" line repeated the LOG IT AGAIN header on the default
   list; on a search it is the only header, so it stays, with room under the field. */
.sheet.t1 .sheet-body:has(#results > .t1-sect:first-child) #visibleResultsCount { display: none; }
.sheet.t1 #visibleResultsCount { margin-top: 8px; }

/* F6 (reviewer): names stay one line with ellipsis, so rows keep live height at 320.
   Wrap vs ellipsis is Tom's call: wrapping cost one visible row at 320x568. */

/* F6b the 11px labels (F2) widen the protein column by ~10px; on a 320 phone give that
   back from the row gaps so the name column does not shrink below live's. */
@media (max-width: 359px) { .sheet.t1 .t1-frow, .sheet.t1 .t1-frow-main { gap: 7px; } }

/* F7 at 320 the portion title cut to "Chicken breast, c..." under the star. */
.sheet.t1 .sheet-head .hd h2 { white-space: normal !important; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.02; }

/* F7b the 2-line title pushed the meal chips under the footer at 320x568. Below 360px
   wide win the room back: drop the typing hint and tighten the stepper and spacers. */
@media (max-width: 359px) {
  .sheet.t1 #qtyArea .note { display: none; }
  .sheet.t1 #visibleResultsCount { margin-top: 0; }
  .sheet.t1 .t1-route { padding-top: 7px; padding-bottom: 5px; gap: 3px; }   /* F2's 11px XP line made the tiles 4px taller than live */   /* F5's gap cost the third search row at 568 tall */
  .sheet.t1 .t1-step button, .sheet.t1 .t1-step .val { min-height: 48px; }
  .sheet.t1 .t1-step .val small { margin-top: 0; }
  .sheet.t1 .t1-hero { margin-bottom: 8px; }
  .sheet.t1 #qtyArea + div, .sheet.t1 #servChips + div { height: 6px !important; }
}

/* F9 the star is disabled on a diary portion but painted exactly like an active one. */
.sheet.t1 .t1-icon-btn:disabled { opacity: .45; }

/* Keep the single-choice strip hidden despite the segment display rule. */
.sheet.t1 #servChips[hidden] { display: none; }

/* 2026-09-25: stranger controls keep their visual boxes and gain 44px hit areas.
   Onboarding controls are left to the onboarding rejig (Gwart). */
#hkSync, .meal-add, .hchip, #meltAll, #meltNone, .wn-tab { position: relative; }
#hkSync::before,
.meal-add::before, .hchip::before, #meltAll::before, #meltNone::before, .wn-tab::before {
  content: ''; position: absolute; left: 50%; top: 50%;
  width: 100%; height: 100%; min-width: 44px; min-height: 44px;
  transform: translate(-50%, -50%);
}
@media (max-width: 340px) {
  .screen--today #charBtn > span { font-size: var(--fs-micro); letter-spacing: -.02em; }
  /* 2026-09-26 --fs-micro went 9px to 10px (a11y sweep): "Backpack" measured 48px
     in a 45px box at 320. Room comes from the row, never from the text. */
  .screen--today .hero-actions { gap: 4px; }
  .screen--today .hero-actions .hero-act { padding-left: 0; padding-right: 0; }
  #charBtn .hero-badge { position: static; display: block; line-height: 1; }
}

/* ===== CREW V3 (Tom, "Yes, build it", 2026-09-26) =====
   The approved crew3 mock, built for real: structure in renderFriends, these
   rules scoped to .screen--crew (route() sets it on the Crew tab only). Keeps
   the original's colour, art and voice; tidy only. Guard: tests/crew-v3.test.mjs. */

/* Section headings: display font, each in the accent its own section already
   uses (gift gold, race coral, lime crew), with an existing
   pixel icon. No divider line (Tom, 2026-09-18). */
#screen.screen--crew .crew-sect {
  display: flex; align-items: center; gap: 9px;
  font-family: var(--display); font-weight: 400; font-size: var(--fs-5);
  letter-spacing: .03em; line-height: 1; text-transform: uppercase;
  margin: 26px 2px 12px;
}
#screen.screen--crew .crew-sect img { width: 28px; height: 28px; image-rendering: pixelated; flex: none; }
#screen.screen--crew .crew-sect-wait { color: var(--gold); }
#screen.screen--crew .crew-sect-compete { color: var(--coral); }
#screen.screen--crew .crew-sect-grow { color: var(--accent); }
/* A heading with nothing visible under it is an orphan. Derived from the three
   cards' own hidden state, so no script has to remember to un-hide it. */
#screen.screen--crew .crew-sect-wait:not(:has(~ #crewRequests:not([hidden]), ~ #giftTopCard:not([hidden]), ~ #cheersCard:not([hidden]))) { display: none; }

/* One rhythm between cards and banners (the original mixed 12px margins,
   inline 12px and banner margins). */
#screen.screen--crew > .card, #screen.screen--crew > .glutton-banner { margin-bottom: 12px; }
#screen.screen--crew > .card + .crew-sect, #screen.screen--crew > .glutton-banner + .crew-sect, #screen.screen--crew > .cfan-block + .crew-sect { margin-top: 28px; }

/* Greeting under the title was cramped (the h1's -0.02em tracking leaked into it). */
#screen.screen--crew .page-h1 .crew-greeting { letter-spacing: .03em; margin-top: 6px; }
/* 44px hit area on "change name" without moving the line */
#screen.screen--crew #crewEditName { padding: 13px 4px; margin: -13px -4px; }

/* Waiting for you: every item is one framed row inside its card, the same
   shape as the original gift row (2px frame, radius, shadow). The gift keeps its
   gold frame; the request and cheer rows take the neutral frame. Accept/Ignore
   under the name. */
#screen.screen--crew .crew-req .fl-row, #screen.screen--crew #cheersList .cheer-row {
  background: var(--surface-2); border: 2px solid var(--line); border-radius: var(--radius);
  box-shadow: var(--sh-sm); padding: 10px 12px; margin: 0 0 8px; align-items: flex-start;
}
#screen.screen--crew #cheersList .cheer-row { align-items: center; }
#screen.screen--crew .crew-req .fl-sect { margin: 0; }
#screen.screen--crew .crew-req .fl-h { font-size: var(--fs-1); letter-spacing: .06em; margin: 0 0 10px; }
#screen.screen--crew .crew-req .fl-row:last-child, #screen.screen--crew #cheersList .cheer-row:last-child, #screen.screen--crew #giftTopList .gift-sealed:last-child { margin-bottom: 0; }
#screen.screen--crew .crew-req .fl-av { width: 56px; height: 56px; border-radius: 12px; }
#screen.screen--crew .crew-req .fl-main .fl-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }

/* Tap targets: the Crew's small buttons measured 40-42px tall. */
#screen.screen--crew .btn.small, #screen.screen--crew .btn.ghost, #screen.screen--crew .cheer-back { min-height: 44px; }
#screen.screen--crew .cfan-arrow { width: 44px; height: 44px; }
#screen.screen--crew #cfanView { position: relative; }
#screen.screen--crew #cfanView::after { content: ''; position: absolute; inset: -8px 0; }

/* Step race: the watermark Bonehead stays; the text stops running over it. */
#screen.screen--crew .race-banner > summary .race-art { right: 30px; }
#screen.screen--crew .race-banner > summary .gbn-txt small { padding-right: 72px; }

/* Leaderboard podium. Carries the approved one-card-structure fix from
   fe99731b (rel/v727): the stale pre-v344 .lb-pod block no longer decides the
   look; the gold frame and its glow live on the #1 CARD only (the glow follows
   the card's own border box, so it matches the frame exactly); the portrait has
   no gold ring; level text centred on two lines; plinth flush on the card floor.
   Every box keeps live's size at 393: portraits stay 76/96px, card padding stays,
   and the bottom padding the stale block added moves into the plinth so each
   card's outer height is unchanged. Names keep live's size and wrap the same way,
   so every card keeps live's height per place (Tom, 2026-09-26: the shrunken
   2nd/3rd names made those cards shorter than live). */
#screen.screen--crew #lbPodium .lb-pod { text-align: center; padding-bottom: 0; overflow: hidden; }
#screen.screen--crew #lbPodium .lb-pod.p1 { padding-bottom: 0; border-color: var(--gold); box-shadow: 0 0 16px rgba(255, 201, 97, .18); }
#screen.screen--crew #lbPodium .lb-pod.p1 .pod-av { border-color: var(--ink); box-shadow: none; }
#screen.screen--crew #lbPodium .pod-lvl { text-align: center; }
#screen.screen--crew #lbPodium .pod-plinth { align-self: stretch; width: auto; margin: 6px -6px 0; border: 0; border-top: 2px solid var(--ink); border-radius: 0; }
#screen.screen--crew #lbPodium .lb-pod.p1 .pod-plinth { height: 58px; } /* 46 + the 12px bottom padding it replaces */
#screen.screen--crew #lbPodium .lb-pod.p2 .pod-plinth { height: 42px; } /* 34 + 8 */
#screen.screen--crew #lbPodium .lb-pod.p3 .pod-plinth { height: 32px; } /* 24 + 8 */

/* Add a friend: the lime goes to "Share my code" beside it, Add stays a solid
   secondary so one section does not carry two lime buttons. */
#screen.screen--crew #friendAddBtn { background: var(--surface-2); color: var(--text); white-space: nowrap; flex: none; }
/* (it also broke to "AD / D" at 320 in the original) */
/* The requester's name rendered dim (text-3 grey) while every other name on the page is cream. */
#screen.screen--crew .crew-req .fl-main b, #screen.screen--crew .crew-req .fl-main b .pname-iso { color: var(--text); }

/* 320: keep labels on one line and balance two-line titles */
#screen.screen--crew .crew-code-btns .btn { white-space: nowrap; padding-inline: 8px; }
#screen.screen--crew #crewWhatsNew > span { white-space: nowrap; min-width: 0; }
#screen.screen--crew #crewWhatsNew { gap: 6px; }
#screen.screen--crew .glutton-banner .gbn-txt b, #screen.screen--crew .glutton-banner .gbn-txt small, #screen.screen--crew #newcomersList .t3-tx small { text-wrap: balance; }
@media (max-width: 360px) {
  /* at 320 the text keeps the original's run over the faded watermark rather than wrapping to 4 lines */
  #screen.screen--crew .race-banner > summary .gbn-txt small { padding-right: 0; }
  /* 320 only: live's 96px #1 portrait overflowed its 79px card. Scale every
     portrait with the podium (the 393 podium is 325px wide), same proportions. */
  #screen.screen--crew #lbPodium { container-type: inline-size; }
  #screen.screen--crew #lbPodium .lb-pod { padding-inline: 3px; }
  #screen.screen--crew #lbPodium .pod-plinth { margin-inline: -3px; }
  #screen.screen--crew #lbPodium .pod-fig { width: 100%; display: flex; justify-content: center; }
  #screen.screen--crew #lbPodium .lb-pod .pod-av { flex: none; width: calc(100cqw * 76 / 325); height: auto; aspect-ratio: 1; }
  #screen.screen--crew #lbPodium .lb-pod.p1 .pod-av { width: calc(100cqw * 96 / 325); }
  #screen.screen--crew #lbPodium .pod-lvl { letter-spacing: 0; }
  #screen.screen--crew #crewWhatsNew { padding-inline: 10px; gap: 4px; }
  #screen.screen--crew #crewWhatsNew > span:first-child { flex: none; }
  #screen.screen--crew #crewWhatsNew .crew-friends-r { gap: 4px; }
  #screen.screen--crew #crewWhatsNew .crew-friends-r > span:first-child { font-size: var(--fs-1) !important; }
}

/* 2026-09-25 Tom (6C): "why is there a second box around the Bonehead": the
   portrait's own dark tile and grey outline sat inside the card. Remove the tile
   on every podium card; the Bonehead stands straight on the card. Size unchanged. */
#screen.screen--crew #lbPodium .lb-pod .pod-av,
#screen.screen--crew #lbPodium .lb-pod.p1 .pod-av { background: transparent; border-color: transparent; box-shadow: none; border-radius: 0; overflow: visible; }
/* 2026-09-25: approved Pit round 3, plain words without portrait frames. */
#pitBody .t3-sect { margin: 22px 4px 8px !important; }
#pitBody .t3-sect::before { display: none !important; }
#pitBody .t3-sect b { font-family: system-ui, -apple-system, sans-serif !important; font-size: calc(var(--fs-body) * 0.75) !important; font-weight: 700 !important; letter-spacing: .06em !important; text-transform: uppercase !important; color: var(--text-3) !important; }
#pitBody .t3-sect .r { font-family: system-ui, -apple-system, sans-serif !important; font-size: calc(var(--fs-body) * 0.75) !important; color: var(--text-3) !important; padding: 0 !important; }
#pitBody .mk-card { border-radius: 16px; border: 2px solid #050408; box-shadow: 0 3px 0 rgba(0,0,0,.32); background: var(--surface); margin: 0 0 12px; }
/* the poster: live violet surface + display voice, one card geometry */
#pitBody > .t3-hero { border-radius: 16px !important; border: 2px solid var(--violet) !important; box-shadow: 0 3px 0 rgba(0,0,0,.32) !important; padding: 20px 16px 16px !important; margin: 0 0 12px !important; }
#pitBody > .t3-hero h2 { font-size: calc(var(--fs-body) * 2.1875) !important; line-height: 1.02 !important; }
#pitBody > .t3-hero p { margin-top: 10px !important; font-size: calc(var(--fs-body) * 0.875) !important; letter-spacing: .11em !important; color: color-mix(in srgb, var(--violet) 70%, var(--text)) !important; }
#pitBody .mk-band { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 16px 0 12px; padding-top: 14px; border-top: 1px solid color-mix(in srgb, var(--violet) 35%, transparent); text-align: center; }
#pitBody .mk-band div { display: flex; flex-direction: column; gap: 3px; }
#pitBody .mk-band b { font-family: var(--display); font-weight: 400; font-size: calc(var(--fs-body) * 1.625); line-height: 1; color: var(--text); letter-spacing: .02em; }
#pitBody .mk-band span { font-size: calc(var(--fs-body) * 0.6875); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-2); }
#pitBody > .t3-hero .bar { height: 6px; border-radius: 3px; background: rgba(0,0,0,.35); overflow: hidden; }
#pitBody > .t3-hero .bar > i { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
#pitBody > .t3-hero.empty .mk-band > div:last-child b { color: var(--coral); } /* empty tank: coral, as the old energy card was */
#pitBody > .t3-hero .mk-line { font-size: calc(var(--fs-body) * 0.8125); color: var(--text-2); margin-top: 8px; }
/* build row */
#pitBody .mk-row { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 6px 16px; border-top: 1px solid var(--line); }
#pitBody .mk-row:first-child { border-top: 0; }
#pitBody .mk-row .l { flex: 1; min-width: 0; }
#pitBody .mk-row .l b { display: block; font-size: calc(var(--fs-body) * 0.9375); font-weight: 600; color: var(--text); }
#pitBody .mk-row .l span { display: block; font-size: calc(var(--fs-body) * 0.8125); color: var(--text-3); }
#pitBody .mk-row > img, #pitBody .mk-row > svg { width: 24px; height: 24px; flex: none; }
#pitBody .mk-chev { width: 8px; height: 8px; border-right: 2px solid var(--text-3); border-top: 2px solid var(--text-3); transform: rotate(45deg); margin-right: 4px; flex: none; }
#pitBody .mk-item .btn { width: auto; min-height: 40px; padding: 7px 14px; font-family: var(--display); font-size: var(--fs-body); border-radius: 10px; box-shadow: none; flex: none; }
/* today's den keeps its violet poster family */
#pitBody > .pit-daily-den { border-radius: 16px !important; border: 2px solid var(--violet) !important; box-shadow: 0 3px 0 rgba(0,0,0,.32) !important; min-height: 0 !important; padding: 16px !important; margin: 0 0 12px !important; }
/* opponent rows joined into cards */
#pitBody .t3-row:not(.pit-daily-den) { margin: 0 !important; border-radius: 0 !important; border: 2px solid #050408 !important; border-top: 1px solid var(--line) !important; border-bottom: 0 !important; background: var(--surface) !important; box-shadow: none !important; }
#pitBody .t3-row.mk-first { border-top: 2px solid #050408 !important; border-radius: 16px 16px 0 0 !important; }
#pitBody .t3-row.mk-last { border-bottom: 2px solid #050408 !important; border-bottom-left-radius: 16px !important; border-bottom-right-radius: 16px !important; box-shadow: 0 3px 0 rgba(0,0,0,.32) !important; margin-bottom: 12px !important; }
#pitBody .t3-row > .t3-tx b { font-size: calc(var(--fs-body) * 0.9375) !important; font-weight: 600; }
#pitBody .t3-row:not(.pit-daily-den) > .btn { background: var(--surface-2); color: var(--text); border: 2px solid #050408; box-shadow: none; }
#pitBody > .pit-daily-den > .t3-tx b { font-size: var(--fs-5) !important; font-weight: 400; }
/* locked rungs: art stays full colour, the live "beat rung" copy stays as quiet text */
#pitBody .t3-row > .t3-lock { background: transparent !important; border: 0 !important; box-shadow: none !important; color: var(--text-3) !important; font-size: calc(var(--fs-body) * 0.6875) !important; padding: 0 !important; min-height: 0 !important; max-width: 92px !important; }
/* Battle items and practice. */
/* battle items: the SAME row as an opponent (52px tile | text | action), icon centred in its tile */
#pitBody .t3-row.mk-item > .mk-ic { display: grid !important; place-items: center; }
#pitBody .t3-row.mk-item > .mk-ic .bhi-wrap { display: contents; }
/* native 48px: the pixel art is drawn on a 48 grid, so 48 stays crisp (40 resampled it at 0.83x) */
#pitBody .t3-row.mk-item > .mk-ic img, #pitBody .t3-row.mk-item > .mk-ic svg { width: 48px !important; height: 48px !important; display: block; image-rendering: pixelated; }
/* optical centring: each sprite's ink sits off its own 48px canvas (measured alpha bbox:
   vigor-draught ink centre +1,+2.5; battle-charm +1.5,+0.5), so nudge the art, never edit it */
#pitBody .t3-row.mk-item > .mk-ic img[src*="vigor-draught"] { transform: translate(-1px, -2px); }
#pitBody .t3-row.mk-item > .mk-ic img[src*="battle-charm"] { transform: translate(-1px, 0); }
#pitBody .t3-row.mk-item > .t3-tx small { font-size: var(--fs-tiny); }
/* one action column: every button in the board ends on the same right edge and shares a width floor */
#pitBody .t3-row > .btn { min-width: 88px; justify-self: end; }
#pitBody .t3-row > .t3-lock { justify-self: end; }
/* practice fold for veterans: one quiet row, a button, no chrome of its own */
#pitBody .t3-row.mk-practice { width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer; }
#pitBody .t3-row.mk-practice .mk-chev { justify-self: end; margin-right: 6px; }
/* boss label on coming-up rows */
#pitBody .mk-boss { font-style: normal; font-weight: 800; font-size: calc(var(--fs-body) * 0.6875); letter-spacing: .06em; text-transform: uppercase; color: var(--violet); margin-right: 6px; }
/* Warning words are relative to the rank curve, never portrait frames. */
#pitBody .mk-danger { font-style: normal; font-weight: 800; font-size: calc(var(--fs-body) * 0.6875); letter-spacing: .08em; text-transform: uppercase; margin-right: 6px; }
#pitBody .mk-d1 { color: var(--amber); }
#pitBody .mk-d2 { color: var(--coral); }
#pitBody .mk-d3 { color: var(--bg); background: var(--coral); border-radius: 4px; padding: 1px 5px; }
#pitBody .t3-row > .btn { min-height: 44px; }
#pitBody .t3-row > .t3-med { width: 52px; height: 52px; border-radius: 12px; box-shadow: none; }
#pitBody > #buildBtn.mk-build { display: block; width: 100%; color: inherit; text-align: left; font: inherit; padding: 0; cursor: pointer; border: 2px solid #050408; border-radius: 16px; box-shadow: 0 3px 0 rgba(0,0,0,.32); }
#pitBody .mk-practice-body[hidden] { display: none; }
#pitBody .mk-practice[aria-expanded="true"] .mk-chev { transform: rotate(135deg); }
/* 2026-09-25: approved Kitchen round 3, retaining the frozen mock geometry. */
.sheet:has(#kitchenBody) { --fs-kitchen-14: .875rem; --fs-kitchen-18: 1.125rem; --fs-kitchen-19: 1.1875rem; --mk-b: 2px solid #050408; --mk-sh: 0 3px 0 rgba(0,0,0,.32); --mk-sys: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; }

/* ---- one title: the art banner keeps "THE KITCHEN" / "THE LABORATORY"; the sheet-head name is visually hidden but still read by screen readers (Done stays) ---- */
#sheets > div:has(#kitchenBody) .sheet-head h2{ position: absolute !important; width: 1px !important; height: 1px !important; margin: -1px !important; padding: 0 !important; border: 0 !important; overflow: hidden !important; clip: rect(0 0 0 0) !important; white-space: nowrap !important; }
/* the bar shrinks to the 44px Done/Back row instead of leaving an empty band */
#sheets > div:has(#kitchenBody) .sheet-head{ min-height: 44px; padding-top: 0 !important; padding-bottom: 0 !important; justify-content: flex-end !important; }
#sheets > div:has(#kitchenBody) .sheet-close{ min-height: 44px; }
/* Kitchen page = the Lab's page colour, so --surface cards read as cards */
.sheet:has(#kitchenBody) { background: #1a1921 !important; }
.sheet-body:has(#kitchenBody) .bh-room-header{ border: var(--mk-b) !important; box-shadow: var(--mk-sh) !important; margin-bottom: 12px !important; }

/* ---- quiet grey section headers, never lime ---- */
#kitchenBody .sect-h{
  font: 700 var(--fs-1)/1.3 var(--mk-sys) !important; letter-spacing: .06em !important; text-transform: uppercase !important; color: var(--text-3) !important;
  margin: 24px 2px 12px !important; text-align: left !important; display: flex !important; align-items: center; gap: 8px; min-height: 24px; }
#kitchenBody .sect-h .gw-link, #kitchenBody .sect-h #forageBtn { margin-left: auto; }

/* ---- the line a row opens ---- */
#kitchenBody .mk-info { font: 400 var(--fs-2)/1.45 var(--mk-sys); color: var(--text-2); margin: -4px 2px 12px; }
#kitchenBody .mk-info[hidden] { display: none !important; }

/* ================= KITCHEN ================= */
#kitchenBody > .sect-h:first-of-type { margin-top: 16px !important; }
#kitchenBody .note { font: 400 var(--fs-2)/1.45 var(--mk-sys) !important; color: var(--text-2) !important; margin: 0 2px 12px !important; }

#kitchenBody .pot-row { gap: 12px !important; margin: 0 0 12px !important; grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
#kitchenBody .pot-card { border: var(--mk-b) !important; border-radius: 16px !important; box-shadow: var(--mk-sh) !important; background: var(--surface) !important; min-height: 112px !important; padding: 12px 8px !important; }
#kitchenBody .pot-card.buy { border-style: dashed !important; box-shadow: none !important; background: transparent !important; border-color: var(--line) !important; }
#kitchenBody .pot-card b { font: 400 var(--fs-body)/1.1 var(--display) !important; letter-spacing: .03em; color: var(--text) !important; }
#kitchenBody .pot-card small { font: 400 var(--fs-1)/1.35 var(--mk-sys) !important; color: var(--text-3) !important; }
#kitchenBody .pot-card .cook-bar { height: 6px !important; }

#kitchenBody .mk-card { border: var(--mk-b); border-radius: 16px; box-shadow: var(--mk-sh); background: var(--surface); padding: 0 12px; margin-bottom: 12px; }
#kitchenBody .mk-card > .crate-row { border-bottom: 1px solid var(--line) !important; padding: 12px 0 !important; min-height: 64px; gap: 12px !important; align-items: center !important; }
#kitchenBody .mk-card > .crate-row:last-child { border-bottom: 0 !important; }
#kitchenBody .crate-row .crate-ico { width: 36px; height: 36px; flex: 0 0 36px; display: grid; place-items: center; border-radius: 12px; background: var(--surface-2); }
/* 2026-09-25 v731: stays 30 (off-grid for the 48px art), Tom's call on Deck 5: he likes the bigger food icons. */
#kitchenBody .crate-row .crate-ico img, #kitchenBody .crate-row .crate-ico svg { width: 30px !important; height: 30px !important; }
#kitchenBody .crate-row > div { min-width: 0; }
#kitchenBody .mk-name { display: flex; align-items: center; gap: 8px; }
#kitchenBody .crate-row b { font: 400 var(--fs-kitchen-18)/1.1 var(--display) !important; letter-spacing: .03em; color: var(--text) !important; }
#kitchenBody .crate-row small { font: 400 var(--fs-2)/1.35 var(--mk-sys) !important; color: var(--text-2) !important; margin-top: 2px; }
/* PROPOSAL 2026-10-02: the stat line is the effect itself ("+25% dmg, +35 Hype · 4 fights"), so it wraps instead of ellipsing, as the pantry rows already did. */
#kitchenBody .crate-row small.mk-stat { white-space: normal; font-weight: 600 !important; font-size: var(--fs-1) !important; color: var(--text) !important; }
#kitchenBody .crate-row .mk-info { margin: 4px 0 0 !important; font-size: var(--fs-1) !important; color: var(--text-3) !important; }
#kitchenBody .mk-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
#kitchenBody .mk-chip { display: inline-flex; align-items: center; gap: 3px; height: 22px; padding: 0 6px 0 3px; border-radius: 11px; background: var(--surface-2); border: 1px solid rgba(255,255,255,.06);
  font: 400 var(--fs-2)/1 var(--display); letter-spacing: .04em; color: var(--text); font-variant-numeric: tabular-nums; }
#kitchenBody .mk-chip .bhi-wrap, #kitchenBody .mk-chip img { width: 16px !important; height: 16px !important; display: inline-grid; }
#kitchenBody .mk-chip.lack { color: var(--text-3); opacity: .6; }
#kitchenBody .mk-act { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: none; }
#kitchenBody .mk-time { display: inline-flex; align-items: center; gap: 4px; font: 400 var(--fs-2)/1 var(--display); letter-spacing: .04em; color: var(--text-3); }
#kitchenBody .crate-row.lack .crate-ico { filter: grayscale(.7); opacity: .7; }

/* transmute: same card; the violet stays on the output socket (it IS the ready signal) */
#kitchenBody .crate-row.transmute { border: 2px solid rgba(180,140,255,.55) !important; border-radius: 16px !important; box-shadow: var(--mk-sh) !important; background: rgba(180,140,255,.08) !important; padding: 12px !important; align-items: center !important; margin-bottom: 12px; gap: 12px !important; flex-wrap: wrap; }
#kitchenBody .crate-row.transmute > div { flex: 1 1 200px !important; }
#kitchenBody .tmx { margin-top: 8px !important; }

/* ingredients: one card, no box per cell */
#kitchenBody .ingredient-grid { border: var(--mk-b); border-radius: 16px; box-shadow: var(--mk-sh); background: var(--surface);
  grid-template-columns: repeat(4, minmax(0, 1fr)) !important; gap: 0 !important; padding: 8px 4px !important; margin: 0 0 12px !important; }
#kitchenBody .ing-cell { border: 0 !important; background: none !important; padding: 8px 4px !important; }
#kitchenBody .ing-cell .ing-n { font: 400 var(--fs-kitchen-19)/1 var(--display) !important; letter-spacing: .03em; color: var(--text) !important; margin-top: 4px; }
#kitchenBody .ing-cell .ing-name { white-space: normal !important; overflow-wrap: normal; min-height: 30px; font: 600 var(--fs-1)/1.25 var(--mk-sys) !important; text-transform: none !important; letter-spacing: 0 !important; color: var(--text-3) !important; text-align: center; }

/* buttons: lime ONLY on the next action; everything else quiet, all in the display voice */
#kitchenBody .btn.small { min-height: 44px; min-width: 64px; padding-left: 12px !important; padding-right: 12px !important; border-radius: 12px !important; font-family: var(--display) !important; }
#kitchenBody .btn.small:not(.mk-next) { background: var(--surface-2) !important; color: var(--text) !important; border: var(--mk-b) !important; box-shadow: var(--mk-sh) !important; }
#kitchenBody .btn.small:not(.mk-next):disabled { opacity: .45; }
#kitchenBody .btn.small.mk-next { background: var(--accent) !important; color: var(--accent-ink) !important; border: var(--mk-b) !important; box-shadow: var(--mk-sh) !important; }
#kitchenBody #forageBtn { min-height: 44px !important; }
#kitchenBody #kitchenToMap { min-height: 52px !important; border: var(--mk-b) !important; border-radius: 16px !important; box-shadow: var(--mk-sh) !important; background: var(--surface) !important; margin: 0 0 12px !important; }

/* ================= ROUND 3 ================= */
/* THE HEARTH: the pots sit on the room header's own warm floor (its #302319 to #0d0c12), so the kitchen continues under the banner */
#kitchenBody .mk-hearth { margin-top: 0 !important; background: linear-gradient(180deg, #1d1618, #17131a); border: var(--mk-b); border-top: 0; border-radius: 0 0 16px 16px; box-shadow: var(--mk-sh); padding: 12px; margin: 0 0 12px; }
#kitchenBody .mk-hearth .pot-row { margin: 0 !important; }
#kitchenBody .mk-hearth .pot-card { background: rgba(13,12,18,.55) !important; min-height: 132px !important; gap: 6px !important; padding: 10px 6px !important; align-content: start !important; }
#kitchenBody .mk-hearth .pot-card.ready { border-color: rgba(165,232,71,.55) !important; }
#kitchenBody .mk-hearth .pot-card.buy { background: transparent !important; border-color: rgba(255,201,97,.22) !important; }
#kitchenBody .mk-hearth .pot-card b { font-size: var(--fs-3) !important; }
/* one pot = Tom's cauldron at 1.5x (72px), pixel-crisp. Lit pots bubble on the existing strip with the fire's glow under them; empty pots are the still drawing, cold */
#kitchenBody .mk-pot { position: relative; width: 72px; height: 72px; display: block; margin: 0 auto; }
#kitchenBody .mk-pot img { width: 72px !important; height: 72px !important; image-rendering: pixelated; display: block; }
#kitchenBody .mk-pot.lit::before { content: ''; position: absolute; left: -10px; right: -10px; bottom: -6px; height: 30px; background: radial-gradient(ellipse at 50% 60%, rgba(255,201,97,.30), rgba(255,201,97,0) 70%); pointer-events: none; }
#kitchenBody .mk-pot-anim { position: absolute; inset: 0; overflow: hidden; }
#kitchenBody .mk-pot-anim .kitchen-pot-rows { transform: scale(1.5); transform-origin: 0 0; image-rendering: pixelated; }
#kitchenBody .mk-pot-anim .kitchen-pot-rows::before { image-rendering: pixelated; }
#kitchenBody .mk-pot.cold img { opacity: .7; }
/* Tom's Kennel rule: not yours yet = the same art at low opacity */
#kitchenBody .mk-pot.ghost img { opacity: .2; }
/* what is in the pot: the dish's own icon riding on the rim */
#kitchenBody .mk-pot .mk-dish { position: absolute; right: -8px; top: -4px; width: 30px; height: 30px; border-radius: 10px; background: var(--surface-2); border: var(--mk-b); display: grid; place-items: center; }
#kitchenBody .mk-pot .mk-dish img { width: 24px !important; height: 24px !important; opacity: 1 !important; }
#kitchenBody .pot-card .pot-serve { margin-top: 2px; }

/* GWART carries the one line of explanation (Kennel's row, in flow) */
#kitchenBody .mk-gwrow { margin: 0 0 4px !important; }

/* THE EMPTY SHELF: ghosts of the dishes that go here (same low-opacity rule) */
#kitchenBody .mk-shelf { border: 2px dashed rgba(255,255,255,.12); border-radius: 16px; padding: 10px 12px 10px; margin: 0 0 12px; text-align: center; }
#kitchenBody .mk-ghosts { display: flex; justify-content: center; gap: 14px; padding-bottom: 8px; border-bottom: 2px solid rgba(255,201,97,.22); margin: 0 8px; }
#kitchenBody .mk-ghost .crate-ico { background: none !important; width: 40px !important; height: 40px !important; flex: none !important; }
#kitchenBody .mk-ghost img { width: 40px !important; height: 40px !important; opacity: .28; image-rendering: pixelated; }
#kitchenBody .mk-shelf-cap { margin: 8px 0 0; font: 400 var(--fs-2)/1.4 var(--mk-sys); color: var(--text-3); }

/* INGREDIENTS: an empty slot is the ingredient's ghost, no zero */
#kitchenBody .ing-cell.mk-ghostcell { opacity: 1 !important; }
#kitchenBody .ing-cell.mk-ghostcell img { opacity: .28; }
#kitchenBody .ing-cell.mk-ghostcell .ing-n { visibility: hidden; }
#kitchenBody .ing-cell.mk-ghostcell .ing-name { opacity: .6; }

/* TAPPABLE ROWS: the chevron says "there is more", the row presses, the chevron turns when open */
#kitchenBody .mk-tap { cursor: pointer; -webkit-tap-highlight-color: transparent; }
#kitchenBody .mk-tap:active { background: rgba(255,255,255,.035) !important; }
#kitchenBody .mk-tap:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 12px; }
#kitchenBody .mk-chev { flex: none; display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 50%; background: var(--surface-2); color: var(--text-3); transition: transform .18s ease; }
#kitchenBody .mk-name[aria-expanded="true"] .mk-chev { transform: rotate(180deg); color: var(--text); }
#kitchenBody .crate-row.transmute .mk-name b { font: 400 var(--fs-kitchen-18)/1.1 var(--display) !important; letter-spacing: .03em; }
#kitchenBody .crate-row.transmute .mk-info { margin: 8px 0 0 !important; }

/* the banner and the hearth are one room: no gap, one outline */
.sheet-body:has(#kitchenBody .mk-hearth) .bh-room-header { margin-bottom: 0 !important; border-radius: 16px 16px 0 0 !important; box-shadow: none !important; border-bottom: 0 !important; }
/* the owned empty pot is at full ink (the ghost rule is only for pots you do not own); a finished pot is the still drawing */
#kitchenBody .mk-pot.cold img, #kitchenBody .mk-pot.done img { opacity: 1; }
#kitchenBody .pot-card.idle .mk-pot img { opacity: 1; }
#kitchenBody .pot-card.idle b { color: var(--text-3) !important; }
/* the finished dish sits big on the rim of a done pot */
#kitchenBody .mk-pot.done .mk-dish { right: 50%; transform: translateX(50%); top: -14px; width: 38px; height: 38px; }
#kitchenBody .mk-pot.done .mk-dish img { width: 30px !important; height: 30px !important; }
#kitchenBody .mk-pot.done::before { content: ''; position: absolute; left: -10px; right: -10px; bottom: -6px; height: 30px; background: radial-gradient(ellipse at 50% 60%, rgba(165,232,71,.22), rgba(165,232,71,0) 70%); pointer-events: none; }
#kitchenBody .mk-pot.lit .mk-dish { top: auto; bottom: -2px; right: -10px; }
/* the empty shelf is a warm plank, not a grey drop zone */
#kitchenBody .mk-shelf { border: var(--mk-b) !important; box-shadow: var(--mk-sh); background: linear-gradient(180deg, #2a2019, #17131a); }
#kitchenBody .mk-ghosts { border-bottom-color: rgba(255,201,97,.35); }
#kitchenBody .mk-shelf-cap { color: #cdb99a; }
/* locked rows: the name stays at full ink, only the art, chips and button step back */
#kitchenBody .crate-row.recipe.lack { opacity: 1 !important; }
#kitchenBody .crate-row.recipe.lack b { color: var(--text) !important; }
#kitchenBody .crate-row.recipe.lack .crate-ico { filter: none !important; opacity: .45 !important; }
#kitchenBody .crate-row.recipe.lack .mk-stat { color: var(--text-2) !important; }
/* an opened row: the line is readable, the row top-aligns */
#kitchenBody .mk-tap:has(.mk-name[aria-expanded="true"]) { align-items: flex-start !important; }
#kitchenBody .crate-row .mk-info { font-size: var(--fs-2) !important; color: var(--text-2) !important; margin: 8px 0 0 !important; }
/* tap affordance: a clearer chevron and a visible press */
#kitchenBody .mk-chev { width: 20px !important; height: 20px !important; color: var(--text-2) !important; background: rgba(255,255,255,.08) !important; }
#kitchenBody .mk-tap:active { background: rgba(255,255,255,.08) !important; }
#kitchenBody .mk-name { outline-offset: 2px; }
/* transmute: the button takes its own line under the sockets, never on top of them */
#kitchenBody .crate-row.transmute > div { flex: 1 1 calc(100% - 60px) !important; min-width: 0; }
#kitchenBody .crate-row.transmute > #transmuteBtn { margin-left: auto; }
/* pantry rows share their width with Eat and toss, so the stat may take two lines there */
#kitchenBody .crate-row:has([data-eat]) small.mk-stat { white-space: normal !important; overflow: visible !important; }
#kitchenBody .mk-hearth .pot-card.ready { padding-top: 18px !important; }
#kitchenBody .mk-pot.done .mk-dish { top: -10px; }

/* Native disclosure buttons retain keyboard behavior and a 44px target. */
#kitchenBody button.mk-name { border: 0; background: none; color: inherit; padding: 0; min-height: 44px; width: 100%; text-align: left; }
#kitchenBody button.mk-name:focus-visible { outline: 2px solid var(--accent); border-radius: 4px; }
#kitchenBody .mk-transmute-guide[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) { #kitchenBody .mk-chev { transition: none; } }
/* 2026-09-25: frozen Backpack and Wardrobe redo, mock2 layout with variant B. */
:root { --inventory-ink: #050408; --inventory-shadow: 0 3px 0 rgba(0,0,0,.32); }

/* ---------- one identical header on both tabs ---------- */
/* live's Backpack arrangement at every width: name + gear across the top, the 120px tile left, meta beside it */
.character-header { display: grid; grid-template-columns: 120px minmax(0, 1fr); grid-template-rows: auto 1fr auto auto auto 1fr; column-gap: 12px; row-gap: 5px; align-items: start; margin: 4px 0 12px; }
.character-identity { display: contents; }
.character-name-row { grid-column: 1 / -1; grid-row: 1; }
.character-portrait { grid-column: 1; grid-row: 2 / 7; }
.character-rank-row, .character-xp, .character-pills { grid-column: 2; }
.character-rank-row { grid-row: 3; } .character-xp { grid-row: 4; } .character-pills { grid-row: 5; }   /* meta block centred beside the tile */
.character-portrait { width: 120px; height: 120px; border-radius: 16px; border: 2px solid var(--inventory-ink); box-shadow: var(--inventory-shadow); overflow: hidden; position: relative; }
/* outer box 120px like live's Backpack tile (so its colour and box match live); the 120px stage scaled whole into the 116px inside the 2px frame, never re-cropped */
.character-portrait > .bh-stage { width: 120px !important; height: 120px !important; border-radius: 0 !important; margin: 0 !important; transform: scale(.9667); transform-origin: 0 0; }
.character-name-row { display: flex; align-items: center; gap: 8px; min-width: 0; height: 32px; }
.character-name { flex: 1; min-width: 0; margin: 0; font-family: var(--display); font-weight: 400; font-size: var(--fs-6); line-height: 1.05; letter-spacing: .02em; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.character-name-row .character-gear { position: relative !important; top: auto !important; right: auto !important; flex: none; width: 44px; height: 44px; margin: -6px -4px -6px 0; }
.character-rank-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.character-rank-row .ward-lv { flex: none; font-size: var(--fs-3); padding: 0 7px 1px; line-height: 1.2; }
.character-rank { font-family: var(--display); font-size: var(--fs-3); letter-spacing: .05em; text-transform: uppercase; color: var(--text-2); line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.character-xp { height: 6px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
.character-xp > i { display: block; height: 100%; background: var(--gold); border-radius: 3px; }   /* gold like the level plate it measures */
.character-pills { display: flex; gap: 6px; min-width: 0; flex-wrap: wrap; }
.character-share-card { grid-column: 2; grid-row: 6; justify-self: start; margin-top: 2px; min-height: 38px; }
.share-card-btn { width: 100%; margin: 0 0 12px; }
.character-pills .bh-pill { flex: none; padding: 3px 9px; font-size: var(--fs-1); font-variant-numeric: tabular-nums; white-space: nowrap; }
@media (max-width: 360px) {
  .character-rank { white-space: normal; }   /* rank wraps in the 156px column instead of an ellipsis */
  .character-pills { gap: 4px; }   /* retain the compact gap when pills wrap in the 156px column */
  .character-pills .bh-pill { padding: 3px 5px; }
  .character-found { display: none; }   /* live drops the word at this width too; the aria-label keeps it */
  #chBody:has(> .character-header) .pd-slot[data-pd="BG"] .pd-tag { font-size: calc(var(--fs-micro) * .8); letter-spacing: -.01em; padding-inline: 0; }   /* "BACKGROUND" is 67px in a 63px tile at 8.8px (still clipped 6px, measured 2026-10-02 at 320x568); 8px with a hair of tracking is 60px */
}

/* ---------- tabs: labels and pixel icons as live; selection stays lime (Tom 2026-09-25, deck v3 answer 1A) ---------- */
:is(#chBody:has(> .character-header) #chTabs.ward-navigation, #chBody:has(> .character-header) #chTabs.bp-navigation, #chBody:has(> .character-header) .ward-mode-tabs) > .ch-tab.on { color: var(--text) !important; box-shadow: inset 0 -4px 0 var(--accent) !important; background: var(--surface-2) !important; border-color: transparent !important; }
#chBody:has(> .character-header) #chTabs { gap: 8px !important; margin-bottom: 12px !important; }
#chBody:has(> .character-header) #chTabs > .ch-tab { border-radius: 16px; overflow: visible; position: relative; }
/* badge sits on the tab's corner, not on the pixel icon */
#chBody:has(> .character-header) #chTabs .ch-badge { top: -6px !important; right: -4px !important; border: 2px solid var(--inventory-ink); z-index: 2; }

/* ---------- one card style everywhere ---------- */
.inventory-card, .character-looks, #chBody:has(> .character-header) #chContent > .ward-toolbar > .fit-chip, #chBody:has(> .character-header) .ward-mode-tabs > .ch-tab, #chBody:has(> .character-header) #chContent .bp-card, #chBody:has(> .character-header) #chContent .pd-stats > *, #chBody:has(> .character-header) #chContent .lab-banner, #chBody:has(> .character-header) #chContent section.bp-salvage {
  border-radius: 16px !important; border: 2px solid var(--inventory-ink) !important; box-shadow: var(--inventory-shadow) !important; background-color: var(--surface) !important;
}
#chBody:has(> .character-header) #chContent .lab-banner { margin-bottom: 12px !important; }
/* section headers stay as live (accent, live size): the redo is layout, not a recolour */

/* ---------- WARDROBE ---------- */
/* toolbar, piece grid, mode tabs and crate/item grids keep live's gaps (box-diff vs live within 2px) */
.character-looks { display: flex !important; align-items: center; gap: 10px; width: 100%; min-height: 48px; margin: 0 0 12px; padding: 6px 14px !important; color: var(--text) !important; font-size: var(--fs-2) !important; font-weight: 700; text-align: left; }
.character-looks img { flex: none; image-rendering: pixelated; }
#chBody:has(> .character-header) #chContent > .ward-toolbar > .fit-chip { border-style: solid !important; color: var(--text-2); }
/* equipped tick: lime like the selected tab (Tom 2026-09-25, 1A); tier plate and tick sit in the padding, not on the art */
#chBody:has(> .character-header) .ward-cell.equipped::after { background: var(--accent) !important; color: var(--inventory-ink) !important; border: 2px solid var(--inventory-ink); width: 18px !important; height: 18px !important; top: 3px !important; right: 3px !important; }
#chBody:has(> .character-header) .ward-cell canvas.ward-art { inset: 14px 12px 14px 12px !important; width: calc(100% - 24px) !important; height: calc(100% - 28px) !important; }
#chBody:has(> .character-header) .ward-cell { border-radius: 16px !important; }
/* Open the Stable was 42px tall on live: 44px tap target */
#chBody:has(> .character-header) #chContent #openStableFromBp { min-height: 44px; }
/* "Tap a piece..." reads left like every other note on the screen */
#chBody:has(> .character-header) #chContent .mog-dock p.note { text-align: left !important; }

/* ---------- BACKPACK ---------- */
/* VARIANT B: crates all lime (the gold rule is gone, so both OPEN fall back to .btn lime); USE takes the app's secondary .btn.ghost (values mirrored from live computed style) */
/* quantity: amber sticker like the tab badges, not lime */
#chBody:has(> .character-header) #chContent .bp-qty { background: var(--amber) !important; color: #201500 !important; border: 2px solid var(--inventory-ink) !important; }
#chBody:has(> .character-header) #chContent .bp-qty[data-empty="true"] { background: var(--surface-3) !important; color: var(--text-2) !important; }
.inventory-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; min-height: 64px; margin: 0 0 12px; }
.inventory-row > .inventory-art { flex: none; width: 48px; height: 48px; display: grid; place-items: center; }
.inventory-row > .inventory-art img, .inventory-row > .inventory-art svg { max-width: 48px; max-height: 48px; image-rendering: pixelated; }
.inventory-row .inventory-copy { flex: 1; min-width: 0; display: grid; gap: 2px; text-align: left; }
.inventory-row .inventory-copy b { font-family: var(--display); font-weight: 400; font-size: var(--fs-4); letter-spacing: .03em; color: var(--text); line-height: 1.05; }
.inventory-row .inventory-copy small { font-size: var(--fs-1); color: var(--text-2); }
#chBody:has(> .character-header) #chContent .inventory-row .inventory-copy .bp-item-details { text-align: left; justify-self: start; }
#chBody:has(> .character-header) #chContent .inventory-row .inventory-copy .bp-item-details summary { justify-content: flex-start !important; min-height: 44px; padding: 0; min-width: 88px; text-align: left; }
.inventory-row > .btn { flex: none; width: auto !important; min-width: 88px; min-height: 44px; padding: 0 16px; }
.inventory-more { width: 100%; color: inherit; font: inherit; cursor: pointer; }
.inventory-chevron { flex: none; width: 9px; height: 9px; border-right: 2px solid var(--text-3); border-top: 2px solid var(--text-3); transform: rotate(45deg); margin-right: 6px; }
.inventory-strip { display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap; }
.inventory-strip > span { width: 28px; height: 28px; flex: none; display: grid; place-items: center; }
.inventory-strip img, .inventory-strip svg { max-width: 28px; max-height: 28px; image-rendering: pixelated; }
.inventory-quantity { font-family: var(--display); color: var(--text-2); font-size: var(--fs-2); letter-spacing: .03em; }

/* The empty egg row owns its card; its section stays a plain heading and wrapper. */
#chBody:has(> .character-header) #chContent .bp-eggs:has(.bp-eggs-empty) { background: none; border: 0; padding: 0; box-shadow: none; }
#chBody:has(> .character-header) #chContent .bp-eggs-empty { justify-content: flex-start; gap: 12px; }

#chBody:has(> .character-header) #chContent .bp-eggs:has(.bp-eggs-empty) > .t3-sect { margin-top: 16px; }

/* 2026-09-25 stranger-fixes3: THE PET'S HUD ROW. Measured at 390x844: the name was capped at 46%
   of a 72%-wide row (51px) while "Bumbleseal" needs 96px, so it clipped with 13px of the row left
   empty beside it (41px at 320x568). The pool bar, "as long as its fighter's POOL is big, relative
   to the fighter above it", was drawn 28% short too: 42.5px for a 38% pool under a 155.5px bar.
   The row now spans the column like the bar above it, and the name takes what the pool bar
   leaves, so it clips only when a long name has no room at all. */
.hud-pet { width: 100%; }
.hud-pet .petname { max-width: none; min-width: 0; flex: 0 1 auto; }

/* 2026-09-25 stranger-fixes3: END TURN STAYS PINNED ON SHORT PHONES. The fight-short block above
   (max-height 700px, v658) sets this row static, which also overrode the 2026-08-16 pin under
   600px (Tom: pin, do not shrink). Its 384px budget was measured on round one at 375x667. At
   320x568 a two-line log already leaves End Turn 5px off the fold (562.8 of 568, measured) and a
   three-line log with Stamina lines on the moves put it below. Short phones only: the row sticks to
   the bottom of the fight scroller when the fight outgrows the screen, and sits where it always
   sat when it fits. */
@media (max-height: 600px) {
  .fight-body > .fight-endrow { position: sticky; }
}

/* Gear guidance reuses the Today grant notice and its Wardrobe action. */
.toast.toast-gear { background: transparent; border: 0; box-shadow: none; padding: 0; }
/* PLAYTEST v761 item 5: Gwart's box as a toast (the Welcome kit). The .talkbox inside draws everything; this strips the
   bone chip around it. Never interactive, like every toast. Guard: tests/proposal-welcome-kit-gwart.mjs. */
.toast.toast-gwart { background: transparent; border: 0; box-shadow: none; padding: 0; width: min(86vw, 340px); text-align: left; }
.toast-gear .wb-back { margin: 0; pointer-events: auto; cursor: pointer; }

/* Today sound control (mock A1): a 32px plate at the end of the coin row, so the row keeps its 32px
   height and Gwart stays touching the currencies; the 44px touch target is a centred ::before. */
#todaySounds { margin-left: auto; position: relative; display: grid; place-items: center; width: 32px; height: 32px; padding: 0;
  border: 0; border-radius: 50%; color: var(--text); background: rgba(13, 12, 18, .42);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); }
#todaySounds svg { width: 18px; height: 18px; }
#todaySounds::before { content: ''; position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); }

/* Readable acquisition tags for the Wardrobe, including variant tiles. */
.new-dot.new-tag {
  width: auto; height: auto; border-radius: 4px; padding: 3px 6px 2px;
  color: var(--accent-ink); font-family: var(--display); font-size: var(--fs-2);
  line-height: 1; letter-spacing: .04em;
}
.ward-variants .pw-item { position: relative; }
.ward-variants .pw-item .new-tag { top: 4px; right: 4px; z-index: 4; }
.gear-wear-now { display: grid; gap: 6px; margin-top: 12px; }
.gear-wear-now .note { text-align: center; margin: 0; }

/* THE SALVAGE BENCH GLINT, Tom 2026-09-27: "a simple looping animation like the Lab and Kitchen banners".
   The Kitchen's idiom exactly: one 12-frame 48px strip (the Bone Dust icon with a pixel glint that
   travels across three crystals, assets/salvage/bench-strip.png), one transform clock on one ::before,
   steps() at 300ms a frame. Reduced motion shows frame 1, the plain icon. Swap the PNG for Tom's art
   (same 48px frames; change the width and step count here) when it arrives. */
.salvage-bench-rows { display: block; width: 576px; height: 48px; }
.salvage-bench-rows::before { content: ''; display: block; width: 576px; height: 48px; background-image: url('assets/salvage/bench-strip.png'); background-repeat: no-repeat; background-size: 576px 48px; animation: salvage-glint 3.6s steps(12) infinite; will-change: transform; }
@keyframes salvage-glint { to { transform: translateX(-576px); } }
.lab-banner:hover .salvage-bench-rows::before, .lab-banner:focus-within .salvage-bench-rows::before { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
  .salvage-bench-rows::before { animation: none; transform: none; }
}
.salvage-confirm { border: 2px solid var(--danger, #e65757); }

/* Incubators: cards use 48px art at 2x; the purchase card dims its empty sprite. */
.inc-ring { display: grid; place-items: center; width: 96px; height: 96px; margin: 0 auto; border-radius: 50%;
  background: radial-gradient(circle at 50% 60%, rgba(165, 232, 71, .18), transparent 70%), var(--surface-2); border: 2px dashed var(--accent); }
.inc-ring.empty { background: var(--surface-2); border-color: var(--line-strong); color: var(--text-3); font-size: var(--fs-6); }
.inc-sprite-wrap { display: block; width: 96px; height: 96px; margin: 0 auto; }
.inc-sprite { display: block; width: 96px; height: 96px; image-rendering: pixelated; }
.inc-dim { opacity: 0.45; }
.inc-ready { filter: drop-shadow(0 0 6px #ffcf7a); animation: inc-ready-pulse 1.6s ease-in-out infinite alternate; }
@keyframes inc-ready-pulse { to { filter: drop-shadow(0 0 10px #ffcf7a); } }
@media (prefers-reduced-motion: reduce) { .inc-ready { animation: none; } }
.inc-card { text-align: center; }
.inc-card .bar { height: 10px; margin-top: 6px; }
.inc-card .bar i { background: var(--accent); }
.melt-confirm-foot { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.inc-card .note { display: block; margin-top: 6px; }
.t3-egg .inc-act { width: auto; padding: 9px 16px; font-size: var(--fs-body); }
.inc-buy { text-align: center; }
.inc-buy .inc-ring { margin-bottom: 12px; }
.got-row .got-ic img { width: 28px; height: 28px; object-fit: contain; }
.got-row .got-note { display: block; font-size: var(--fs-tiny); font-weight: 500; color: var(--text-2); }

/* Salvage confirm (mock A3): the pieces, the total, and the two choices side by side. */
.salvage-confirm .melt-confirm-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
.salvage-confirm .melt-confirm-row b { flex: 1; min-width: 0; }
.salvage-confirm .melt-confirm-row .crate-ico img { width: 40px; height: 40px; }

/* Match the Lab and Kitchen banner paint; the counts use their subtitle typography. */
#chContent .salvage-banner .bp-chevron { display: none; }

/* hunt-0927b: a past day's log pays no XP, so its log doors hide the XP tag. */
.xp-past .xp, .scan-head.xp-past span, .hd.xp-past .sub.xp { display: none !important; }

/* Gang map presentation is opt-in; legacy markers keep their original rules. */
.map-spire.gang-marked .spire-fx {
  filter: drop-shadow(0 4px 9px rgba(0, 0, 0, .7)) drop-shadow(0 0 10px var(--spire-gang));
}
.map-spire.gang-marked .spire-flag {
  background: var(--spire-gang); border-color: var(--spire-gang); color: #171717;
}
.map-spire.gang-marked.friendly .spire-flag { border-style: double; border-width: 3px; }
.map-spire.gang-marked .spire-fx > img:not(.spire-crest) { filter: grayscale(1); }
.map-spire.gang-marked .spire-fx > img.spire-crest {
  position: absolute; width: 32px; height: 32px; left: 13px; top: 21px;
  object-fit: contain; filter: none; pointer-events: none;
}

/* THE GANG SPIRE SHEET (GANGS_LIVE only; js/spire-sheet.js + openGangSpireSheet).
   Tom, 2026-09-27, approving the NEW board: poster in the holder's gang colour,
   3 faces (crown lead, coral ring next fight, dashed + open, grey dormant or
   knocked out, YOU), icon chips, one button, one short line at most. --g is the
   gang hex; absent means neutral. */
.ssl .spp.ganged { border-color: var(--g); }
.ssl .spp-plate.ssl-plate { background: var(--g, var(--surface-2)); }
.ssl .spp-plate.ssl-plate b { color: #17131f; }
.ssl .spp-plate.ssl-plate small { color: rgba(23, 19, 31, .8); }
.ssl:not([style*="--g"]) .spp-plate.ssl-plate b { color: var(--text); }
.ssl:not([style*="--g"]) .spp-plate.ssl-plate small { color: var(--text-2); }
.ssl .spp .lvchip { display: none; }
.ssl .spp.dim .keeper { filter: grayscale(1) brightness(.6); }
.ssl .spp.empty .tower { display: none; }
.ssl .spp.empty .keeper { height: 230px; }
.ssl .spp.empty .bh { width: 150px; height: 190px; }
.ssl-crest { position: absolute; right: 10px; top: 10px; width: 56px; height: 56px; object-fit: contain; z-index: 3; filter: drop-shadow(0 3px 0 rgba(0, 0, 0, .6)); }
.ssl .spp.sieged .ssl-crest, .ssl .spp.sieged .ribbon { top: 44px; }
.ssl-siege {
  position: absolute; left: 0; right: 0; top: 0; z-index: 4; background: var(--coral); color: #2a0d08; text-align: center;
  font-family: var(--display); font-size: var(--fs-4); letter-spacing: .05em; padding: 5px 0; border-bottom: 2px solid var(--ink);
}
.ssl-row { display: flex; justify-content: center; gap: 14px; margin: 16px 0 4px; }
.ssl-face { position: relative; width: 92px; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.ssl-ring {
  position: relative; width: 84px; height: 84px; box-sizing: border-box; border-radius: 50%; border: 3px solid var(--g, var(--line-strong));
  background: radial-gradient(circle at 50% 40%, #3b2f52, #191426 75%); box-shadow: 0 4px 0 rgba(0, 0, 0, .42);
  overflow: hidden; display: grid; place-items: center;
}
.ssl-ring .tz-head { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.ssl-face.empty .ssl-ring { border: 3px dashed var(--line-strong); background: transparent; box-shadow: none; }
.ssl-face.empty.open .ssl-ring { border-color: var(--g, var(--accent)); }
.ssl-face.empty.open .ssl-ring b { font-family: var(--display); font-size: var(--fs-7); font-weight: 400; color: var(--g, var(--accent)); line-height: 1; }
.ssl-face.you .ssl-ring { border-color: var(--accent); }
.ssl-face.target .ssl-ring { border-color: var(--coral); box-shadow: 0 0 0 4px rgba(253, 104, 87, .28), 0 4px 0 rgba(0, 0, 0, .42); }
.ssl-face.dormant .ssl-ring, .ssl-face.out .ssl-ring { filter: grayscale(1) brightness(.55); border: 3px solid #8a8490; }
.ssl-crown { position: absolute; top: -12px; left: 50%; transform: translateX(-50%); z-index: 2; filter: drop-shadow(0 2px 0 #000); }
.ssl-x { position: absolute; top: 6px; left: 50%; transform: translateX(-50%) rotate(-8deg); font-family: var(--display); font-size: var(--fs-jumbo); line-height: 1; color: var(--coral); text-shadow: 0 3px 0 #000; pointer-events: none; }
.ssl-zz { position: absolute; top: 2px; right: 6px; font-family: var(--display); font-size: var(--fs-5); color: var(--text-2); text-shadow: 0 2px 0 #000; }
.ssl-you {
  position: absolute; top: 62px; left: 50%; transform: translateX(-50%); z-index: 2; background: var(--accent); color: var(--accent-ink);
  border: 2px solid var(--ink); border-radius: 8px; font-family: var(--display); font-size: var(--fs-2); letter-spacing: .06em; padding: 0 6px;
}
.ssl-name { font-size: var(--fs-0); font-weight: 700; color: var(--text); max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; min-height: 1.2em; }
.ssl-face.empty .ssl-name, .ssl-face.dormant .ssl-name, .ssl-face.out .ssl-name { color: var(--text-2); }
.ssl-chips { display: flex; justify-content: center; gap: 8px; margin: 12px 0 0; flex-wrap: wrap; }
.ssl-chip {
  display: inline-flex; align-items: center; gap: 6px; background: var(--surface-2); border: 2px solid var(--ink); border-radius: 10px;
  box-shadow: var(--sh-sm); padding: 4px 10px; font-family: var(--display); font-size: var(--fs-3); letter-spacing: .04em; color: var(--text);
}
.ssl-chip.gold { color: var(--gold); }
.ssl-line { text-align: center; margin: 12px 4px 0; font-size: var(--fs-1); color: var(--text-2); font-weight: 700; }
.ssl-foot .btn[disabled] { background: var(--surface-2); color: var(--text-2); box-shadow: none; }
.ssl-foot .btn.coral { background: var(--coral); color: #2a0d08; }
.ssl-foot .btn svg, .ssl-foot .btn img { vertical-align: -4px; margin-right: 6px; }
.ssl-foot .btn .n, #spireFaceNext .n { opacity: .75; font-size: .8em; margin-left: 6px; }
/* Victory, spire chain or takeover: every element on the centre line, the block
   centred in the sheet (min-height set after the arena closes). */
.fight-over.ssl-over { display: flex; flex-direction: column; align-items: center; justify-content: center; box-sizing: border-box; padding: 13px 0; }
.fight-over.ssl-over > * { margin-left: auto; margin-right: auto; }
.fight-over.ssl-over > .btn { width: 100%; }
.fight-over.ssl-over .cele-big { transform: translateX(-3px); }   /* Bangers leans right: ink measured 2.8px right of its box */
.ssl-strip { width: 100%; text-align: center; margin: 14px 0 6px; }
.ssl-big { font-family: var(--display); font-size: var(--fs-7); letter-spacing: .04em; line-height: 1; color: var(--coral); text-shadow: 0 3px 0 #000; transform: translateX(-2px); }
.ssl-big.yours { color: var(--g, var(--accent)); transform: none; }
.ssl-strip .ssl-row { margin-top: 14px; }
/* Map: 3 slot pips on a gang tower's flag, crossed swords on one you can attack. */
.spire-flag .ssl-pips { display: inline-flex; flex: none; gap: 3px; margin-left: 7px; vertical-align: 1px; }
.spire-flag .ssl-pips i { width: 8px; height: 8px; border-radius: 2px; background: currentColor; box-shadow: 0 0 0 1px rgba(0, 0, 0, .5); }
.spire-flag .ssl-pips i.off { background: transparent; box-shadow: inset 0 0 0 1.5px currentColor; opacity: .7; }
.spire-flag .ssl-sw { margin-right: 5px; }

/* Round chips clip the figure stage, leaving earned badges outside. */
.gp-chipb .gp-fig, .gp-chipb .gp-ca { border-radius: 50%; overflow: hidden; }
.gp-chips { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.gp-chipb { justify-self: center; }
.gp-link { grid-row: 1; width: 100%; max-width: none; align-self: center; pointer-events: none; }
.gp-chipb { grid-row: 1; z-index: 1; }
.gp-chipb:nth-child(1) { grid-column: 1; }
.gp-chipb:nth-child(3), .gp-link:nth-child(2) { grid-column: 2; }
.gp-chipb:nth-child(5), .gp-link:nth-child(4) { grid-column: 3; }
.gp-chipb:nth-child(7), .gp-link:nth-child(6) { grid-column: 4; }
.gp-link { transform: translateX(-50%); }
.gp-labels { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; margin-bottom: 16px; text-align: center; font-size: var(--fs-2); }
.gp-labels > div { min-width: 0; overflow-wrap: anywhere; }
.gp-labels span, .gp-labels small { display: block; }
#lbBody .lb-who .social-name > b { display: inline-block; box-sizing: border-box; padding-right: 25px; }
#lbBody .lb-who .gang-crest { margin-right: -25px; }

/* Gang notes, iOS build 21 (2026-10-02). */
/* Picker thumbs open a big preview of the piece. */
.gk-thumb, .gk-cc { -webkit-appearance: none; appearance: none; }
.gk-thumb:active .gp-fig, .gk-cc:active { transform: scale(.96); }
.gk-peek-hero { display: flex; justify-content: center; padding-top: 6px; }
.gk-peek-hero .gp-fig { border-radius: 20px; background: color-mix(in srgb, var(--gang) 14%, rgba(0,0,0,.28)); border: 2px solid var(--gang); }
.gk-peek-when { text-align: center; margin: 4px 0 0; color: var(--gang); font-weight: 800; font-size: var(--fs-1); }
.gk-peek .gk-row2 { grid-template-columns: 1fr; margin-top: 18px; }
/* How gangs work: the Today strip colour must not paint around the row inside a sheet. */
.sheet-body > .screen--today { background-color: transparent; }
/* The moon bar is the daily rep cap: labelled, and a tap explains it. */
button.gp-cap { width: 100%; padding: 6px 0; border-radius: 10px; }
.gp-cap-lab { text-transform: uppercase; letter-spacing: .06em; }
.gp-cap[aria-expanded="true"] { color: var(--text); }
.gp-cap-say { margin-top: 8px; }
/* Share card preview: the rendered card, then explicit buttons. */
.share-preview { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.share-preview-img { width: min(100%, 320px); height: auto; aspect-ratio: 1080 / 1350; border-radius: 16px; border: 2px solid var(--line); box-shadow: 0 8px 30px rgba(0,0,0,.45); }
.share-preview-acts { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 10px; width: 100%; }

/* PROPOSAL 2026-10-02 (playtest v761): TODAY HEALTH CARD ON SMALL PHONES.
   Below 360 the cause was the 158px ring, not the text: the macro row needs
   ~117px (label + value) and at 320 the ring left 83. nowrap is now held at every
   width (the old min-width:360 exemption is gone) and the ring gives the room
   back: 124px at 361-380 (375 was 1.8px over), 102px at 320, with the centre
   readout scaled to sit inside the stroke. 390 and up is untouched. */
@media (max-width: 380px) {
  .ring-card { gap: 10px; }
  .ring-wrap { width: 124px; height: 124px; }
  .ring-center { transform: scale(.8); }   /* the readout's size is inline (ringFitPx), so scale the whole readout with the ring */
}
@media (max-width: 360px) {
  .ring-wrap { width: 102px; height: 102px; }
  .ring-center { transform: scale(.76); }
}

/* PROPOSAL 2026-10-02 (playtest v761): SHORT-PHONE FIGHT HUD. At 320x568 the
   arena is 220px and the HUD (name plate, HP, stamina, pet) stacked ~85px of it,
   so both HP blocks sat on the fighters' heads. The 2026-08-16 call stands (the
   figures are not shrunk); instead every row of the HUD gets thinner at this
   height only: the name plate loses its bulk, the bars lose 2 to 3px each, the
   gaps close. Same rows, same order, same colours, same numbers. */
@media (max-height: 600px) {
  .fight-hud { padding: 2px 6px 0; }
  .hud-side { gap: 2px; }
  .hud-side .fname, .fight-hud .fname { font-size: var(--fs-0); padding: 0 6px; border-width: 1px; line-height: 1.15; min-height: 0; box-shadow: none; }
  .hud-side .bar.fhp { height: 9px; border-width: 1.5px; }
  .microbars { gap: 3px; }
  .microbars .bar { height: 5px; border-width: 1px; }
  .hud-pet { margin-top: 0; gap: 4px; }
  .hud-pet .bar { height: 8px; border-width: 1.5px; }
  .hud-pet .petname { font-size: var(--fs-micro); line-height: 1; padding: 0 5px; }
}
