/* Placeholder-fidelity chrome. All user-facing strings are DRAFT until the copy
   pass (rulebook A22 + Q3 gate); style here is utilitarian scaffolding, not design. */

/* Safe areas go through variables, never raw env() (interface session
   2026-08-12). The dev phone frame is a TRANSFORMED fixed box, and inside one
   env(safe-area-inset-*) resolves to 0 — so every bottom-padding judgement made
   in the lab was 34px optimistic against a real notched phone. The frame now
   substitutes the device's real insets, and the lab tells the truth. */
:root { --safe-b: env(safe-area-inset-bottom); --safe-t: env(safe-area-inset-top); }
/* No iOS floor any more (walk row 3.6, 2026-08-25). The phone round of
   2026-08-19 believed the page ran edge-to-edge under the status bar while
   env(safe-area-inset-top) said 0, and clamped --safe-t to a 59px floor
   (`@supports (-webkit-touch-callout: none)`). It doesn't: in Safari the
   page's y=0 already sits BELOW the bar (D2-5 on the device; 3.2 for the
   camera; Oleh's own eye on the support dot + "saving…" pill, 2026-08-25 —
   "a significant gap from the top edge"). So the floor put every top-edge
   consumer — support dot, status pill, intro back chevron, intro slide
   padding, sources page — 59px lower than designed, on every iPhone. The
   browser's own env() is the only source now: 0 where the page sits below the
   bar, the real inset wherever it runs under it (home-screen / fullscreen).
   ?top=floor (main.ts) re-applies the old floor for the phone A/B; the dev
   frame's 47px substitution (body.dev-phone below) is the lab's inset. */

/* The global box model — guarded by tests/client/style-parses.test.ts (walk 5.11,
   2026-08-25): a stray `}` left above this line by the 3.6 edit made every browser drop
   this rule silently, and every card grew by its padding. public/ is served verbatim. */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: #141b16;
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #e8ede6;
  -webkit-tap-highlight-color: transparent; }
#game, #game canvas { position: fixed; inset: 0; }
/* No double-tap zoom on the chrome (walk row 5.24, 2026-08-27 — Oleh: a double-tap
   in Safari zoomed the page in and only a reload brought it back). Safari ignores the
   viewport's user-scalable=no in the browser (iOS 10+, accessibility): a double-tap on
   any DOM surface — the boot veil, a card's scrim, the sheet, a token — zoomed the page,
   and the world canvas (full-screen, touch-action none for its own scroll) refused every
   browser gesture, so nothing on the world could reverse it. `manipulation` = pan and
   pinch stay the browser's, double-tap zoom is off. Set on EVERY chrome element, not
   only the root: WebKit restarts the touch-action chain at each scrollable box (a card
   body with overflow: auto would fall back to auto). The canvas itself is `pinch-zoom`
   (camera.ts) — a pinch on the world is the browser's and undoes any zoom. Guarded by
   tests/client/style-parses.test.ts; html.zoom-old = the A of the phone A/B (?zoom=old). */
html, body, #ui, #ui * { touch-action: manipulation; }
html.zoom-old, html.zoom-old body, html.zoom-old #ui, html.zoom-old #ui * { touch-action: auto; }

#ui { position: fixed; inset: 0; pointer-events: none; }
#ui > * { pointer-events: auto; }

/* ─────────────────────────────────────────────────────────────────────────
   THE ACTION CHROME — SETTLED (Oleh 2026-08-15): direction b, "the world is
   the interface", painted parchment tokens. The losing skins (a "field
   station", c "one moment") and label materials (tag/halo/sign) are deleted;
   git holds them. The body carries `chrome-b mat-icon` permanently — the
   selectors below keep those names because their specificity depends on them.

   Standing law (rulebook 4.7 / spec §6): no meters, no numbers, no streak.
   A finished day is shown as SETTLED, never as a disabled slab — greying a
   button out reads as refusal, and this game never refuses anyone.
   ───────────────────────────────────────────────────────────────────────── */

.action-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 10;
  padding: 10px 16px calc(14px + var(--safe-b));
  display: flex; gap: 10px; justify-content: center; align-items: stretch; }
.action { flex: 1; border: none; cursor: pointer; color: #171d15;
  display: flex; align-items: center; justify-content: center; gap: 8px;
  font-size: 14.5px; font-weight: 600; text-align: left; letter-spacing: .005em;
  transition: opacity .22s ease, background .22s ease, transform .18s ease; }
.action .face { display: flex; flex-direction: column; line-height: 1.12; }
.action .sub { font-size: 10.5px; font-weight: 400; opacity: .66; margin-top: 1px;
  letter-spacing: .01em; }
.action .glyph { flex: none; width: 19px; height: 19px; opacity: .7; }
.action .glyph svg { width: 100%; height: 100%; fill: none;
  stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }

/* The "problems?" affordance — the one place the app admits it is software.
   Sits opposite the status pill, quiet enough to disappear until wanted. */
.support-dot { position: fixed; top: calc(6px + var(--safe-t)); left: 10px; z-index: 11;
  width: 30px; height: 30px; border-radius: 50%; border: none; cursor: pointer;
  background: rgba(20, 27, 22, .42); color: rgba(232, 237, 230, .62);
  font-size: 16px; font-weight: 600; line-height: 1; padding: 0; }

/* ── B · THE WORLD IS THE INTERFACE ────────────────────────────────────────
   No dock. The two daily actions ride world anchors — veg at the stump (the
   fibre store made diegetic), reveal on the road at the fog boundary — the
   same per-frame idiom the spring-bud button already ships. main.ts supplies
   the coordinates; the buttons are small enough to read as world labels. */
/* Redesign round 2 (2026-08-13, after Oleh confirmed b): the labels are FIELD
   LABELS — the naturalist's own material (the copy canon's voice made
   physical), pinned BESIDE their places, never over them. A breathing ground
   light on the spot does the pointing; the label only names. Off-screen —
   the NORMAL case once the frontier moves — the label becomes a small tab
   clipped to the frame edge; the edge is the direction, no arrow. */
body.chrome-b .action-bar { display: contents; }
/* iOS Safari smear guard (phone finding 4, 2026-08-19): these elements are
   re-anchored via left/top every ticker frame while the camera moves. Left
   unpromoted, iOS Safari fails to repaint the rects they vacate over the
   WebGL canvas — stale copies of the reveal face laddered down the fog on a
   real iPhone scroll (headless never showed it). will-change promotes each
   onto its own compositor layer, so a move is composition, never a repaint
   of what it left behind. Boring, well-supported; no cascade effect on the
   transforms the breathe/fidget animations own. */
body.chrome-b .action, .action-light, .action-mist, .bud-token {
  will-change: transform; }
body.chrome-b .action { position: absolute; flex: none; z-index: 10;
  transform: translate(-50%, -50%); white-space: nowrap;
  padding: 6px 11px; font-size: 12.5px; }
body.chrome-b .action .sub { display: none; }
body.chrome-b .action .glyph { width: 15px; height: 15px; }
body.chrome-b .action[data-state="done"],
body.chrome-b .action[data-state="asleep"] { opacity: .5; }
body.chrome-b .action:disabled { cursor: default; }
/* Fermented has no world anchor of its own until Area 2 is built (C4); its
   final form is an open discussion (teaser vs B6 arrival) — parked. */
body.chrome-b .action.ferm { position: fixed; left: 50%; top: auto;
  bottom: calc(12px + var(--safe-b)); transform: translateX(-50%); }

/* The ground light: soft ellipse breathing ON the action spot. Deliberately
   dim and greenish-neutral — warm cream luminance stays reserved for creature
   state (colour system §3), so this must never read as a creature's glow. */
.action-light { position: absolute; z-index: 9; display: none;
  width: 118px; height: 56px; transform: translate(-50%, -50%);
  pointer-events: none; border-radius: 50%;
  background: radial-gradient(closest-side, rgba(232, 240, 212, .44), rgba(232, 240, 212, 0) 72%); }
body.chrome-b .action-light.on { display: block;
  animation: light-breathe 3.6s ease-in-out infinite; }
@keyframes light-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
/* The bud tab's invitation (Oleh 2026-08-14): the same ground-light grammar
   the other travel tabs carry, in the WARM hue of the available bud's own
   halo — the pocket glows with the thing it points to. Warm-on-warm needs
   more push than the green lights to register at all (Oleh: invisible at the
   shared intensity), so it runs bigger and brighter. */
/* Seated on the mini-stump (Oleh 2026-08-14, composition round): the veg
   token RESTS on its cut face — shallow contact shadow instead of the hover
   drop, and its ground light tightens to hug the pedestal so the stump face
   itself seems to glow. Reveal keeps the deeper shadow (it stands on road
   and fog, not on furniture); ferm inherits the seated look with its stump. */
body.mat-icon.chrome-b .action.veg.painted .tokimg,
body.mat-icon.chrome-b .action.ferm.painted .tokimg {
  filter: drop-shadow(0 1px 2px rgba(15, 22, 12, .3)); }
body.mat-icon.chrome-b .action.veg.painted[data-state="done"] .tokimg,
body.mat-icon.chrome-b .action.ferm.painted[data-state="done"] .tokimg {
  filter: saturate(.8) drop-shadow(0 1px 2px rgba(15, 22, 12, .26)); }
.action-light.veg { width: 86px; height: 50px; }
body.chrome-b .action-light.veg.on .inhale { width: 80px; height: 80px;
  margin: -40px 0 0 -40px; }

.action-light.bud { width: 150px; height: 72px; filter: blur(4px); background:
  radial-gradient(closest-side, rgba(255, 196, 88, .5) 28%,
    rgba(255, 186, 70, .72) 60%, rgba(255, 176, 56, 0) 96%); }

/* ── b materials · ICON — the action is a small pictured object at its spot
   (Oleh, 2026-08-13). Placeholder marks; the painted roll replaces them if
   this form wins. Reveal = eye at the point the road dives under the fog;
   done = the object rests, faded, wearing a small settled mark. */
body.mat-icon.chrome-b .action { width: 42px; height: 42px; padding: 0;
  border-radius: 50%; justify-content: center;
  background: rgba(242, 236, 215, .92); color: #33402a;
  border: 1px solid rgba(90, 96, 70, .5);
  box-shadow: 0 2px 7px rgba(15, 22, 12, .35); }
body.mat-icon.chrome-b .action .face { display: none; }
body.mat-icon.chrome-b .action .glyph { width: 22px; height: 22px; opacity: .95; }
body.mat-icon.chrome-b .action[data-state="done"],
body.mat-icon.chrome-b .action[data-state="asleep"] { opacity: 1;
  background: rgba(242, 236, 215, .55); color: rgba(51, 64, 42, .55); }
body.mat-icon.chrome-b .action[data-state="done"]::after { content: '✓';
  position: absolute; top: -4px; right: -4px; width: 16px; height: 16px;
  border-radius: 50%; background: #6f9552; color: #f6f3e4; font-size: 11px;
  line-height: 16px; text-align: center; font-weight: 700;
  box-shadow: 0 1px 3px rgba(15, 22, 12, .4); }
/* Edge tab, icon form (Oleh fix round, 2026-08-14): a small parchment POCKET
   emerging flush from the frame edge, with the FULL token seated in it at a
   smaller size — the earlier half-tucked clip cut the painted eye and read as
   a rendering bug, not a deliberate tuck. Higher specificity than both the
   global tab transform (l.~340) and .painted's bare form, so it wins without
   ordering games. The pocket is a signpost: its tap travels, never acts. */
body.mat-icon.chrome-b .action[data-offscreen],
body.mat-icon.chrome-b .action.painted[data-offscreen] { width: 56px; height: 40px;
  background: rgba(242, 236, 215, .92); border: 1px solid rgba(90, 96, 70, .5);
  box-shadow: 0 2px 7px rgba(15, 22, 12, .35); }
body.mat-icon.chrome-b .action[data-offscreen="up"] {
  border-radius: 0 0 20px 20px; border-top: none; transform: translate(-50%, 0); }
body.mat-icon.chrome-b .action[data-offscreen="down"] {
  border-radius: 20px 20px 0 0; border-bottom: none; transform: translate(-50%, -100%); }
body.mat-icon.chrome-b .action[data-offscreen] .glyph { width: 18px; height: 18px; }
body.mat-icon.chrome-b .action.painted[data-offscreen] .tokimg { width: 30px; height: 30px; }
/* Tap answer: the token dips — pressed, acknowledged — while the travel glide
   does the real responding. */
body.mat-icon.chrome-b .action[data-offscreen]:active .tokimg,
body.mat-icon.chrome-b .action[data-offscreen]:active .glyph { transform: scale(.88); }

/* Painted tokens (batch 3): the rolled PNG IS the whole button — the CSS disc,
   border and stroke glyph all retire for that mark. Done fades the token
   itself; the rolled settled-mark badge replaces the CSS ✓ when it lands. */
body.mat-icon.chrome-b .action .tokimg { display: none; }
body.mat-icon.chrome-b .action.painted { background: none; border: none; box-shadow: none; }
body.mat-icon.chrome-b .action.painted .tokimg { display: block; width: 42px; height: 42px;
  filter: drop-shadow(0 2px 4px rgba(15, 22, 12, .35)); }
body.mat-icon.chrome-b .action.painted .glyph { display: none; }
body.mat-icon.chrome-b .action.painted[data-state="done"] { background: none;
  opacity: .74; }
/* Asleep is SEMI-active, not spent (Oleh 2026-08-14): fuller presence than
   done plus a slow sleep-breath, so "resting until the bud wakes" never reads
   as "already used today". Its tap plays the walled answer. */
body.mat-icon.chrome-b .action.painted[data-state="asleep"] { background: none;
  opacity: .92; }
body.mat-icon.chrome-b .action.painted[data-state="asleep"] .tokimg {
  animation: asleep-breath 4.6s ease-in-out infinite; }
/* The held DOOR token is DISABLED, not resting (Oleh 2026-09-06, on the phone:
   "less inviting, more disabled" — the asleep grammar read as an invitation):
   still, dimmer and greyer than the spent token, no breath, no glow. The tap
   still answers (tag + the wall's breath) so the world never feels bugged.
   `door_tok=a|c` on the body (main.ts, a judging switch) shows the two other
   strengths; the default is b. */
body.mat-icon.chrome-b .action.painted[data-state="door"] { background: none;
  opacity: .58; }
body.mat-icon.chrome-b .action.painted[data-state="door"] .tokimg {
  filter: saturate(.45) drop-shadow(0 1px 3px rgba(15, 22, 12, .22)); }
body[data-door-tok="a"].mat-icon.chrome-b .action.painted[data-state="door"] { opacity: .7; }
body[data-door-tok="a"].mat-icon.chrome-b .action.painted[data-state="door"] .tokimg {
  filter: saturate(.7) drop-shadow(0 1px 3px rgba(15, 22, 12, .26)); }
body[data-door-tok="c"].mat-icon.chrome-b .action.painted[data-state="door"] { opacity: .46; }
body[data-door-tok="c"].mat-icon.chrome-b .action.painted[data-state="door"] .tokimg {
  filter: saturate(.2) drop-shadow(0 1px 2px rgba(15, 22, 12, .18)); }
/* Strengthened 2026-08-15 (Oleh: "barely noticeable" — the 1.04 swell moved
   under 2px at token size, and a dead-still asleep token risks reading as
   "no more map / bugged"): a clear calm breath, swell + brighten together. */
@keyframes asleep-breath {
  0%, 100% { transform: scale(.97); opacity: .8; }
  50% { transform: scale(1.07); opacity: 1; } }
body.mat-icon.chrome-b .action.painted[data-state="done"] .tokimg { filter: saturate(.8)
  drop-shadow(0 2px 4px rgba(15, 22, 12, .3)); }
body.mat-icon.chrome-b .action .donemark { position: absolute; top: -5px; right: -5px;
  width: 18px; height: 18px; }
body.mat-icon.chrome-b .action.markimg::after { content: none; }

/* The bud button in token form (buttons #6/#7, 2026-08-14): main.ts owns the
   per-frame placement at the bud's base; this styles the material. Waiting
   shows the closed-bud painting at FULL presence — a different true state of
   the same object, never a dimmed control (its tap answers with the flower's
   refusal shake + one tooltip line). Ready breathes like every available
   token. The .draftface text form is the probe-gated fallback only. */
.bud-token { position: absolute; transform: translate(-50%, 0); z-index: 30;
  background: none; border: none; padding: 0; cursor: pointer; }
.bud-token:not([data-offscreen]):active { transform: translate(-50%, 0) scale(.9); }
.bud-token .tokimg { display: block; width: 42px; height: 42px;
  filter: drop-shadow(0 2px 4px rgba(15, 22, 12, .35)); }
/* Ready breath is JS-driven (main.ts) so the token scales in step with the
   flower's own pulse — one shared clock, never two free-running animations. */
/* Waiting: not a stale disabled control — the token stirs in its sleep every
   few seconds (a soft rock, then stillness), enough to catch the eye and
   invite the tap that teaches the wake rule (Oleh 2026-08-14). */
.bud-token[data-state="waiting"] .tokimg { animation: bud-stir 5.6s ease-in-out infinite;
  transform-origin: 50% 85%; }
@keyframes bud-stir {
  0%, 55%, 100% { transform: rotate(0deg) scale(1); }
  63% { transform: rotate(-4deg) scale(1.04); }
  71% { transform: rotate(3deg) scale(1.03); }
  79% { transform: rotate(-1.5deg) scale(1.01); }
  87% { transform: rotate(0deg) scale(1); }
}
.bud-token .draftface { display: block; padding: 8px 14px; border-radius: 14px;
  font: 600 13px system-ui; background: #cde8a6; color: #1c2415;
  box-shadow: 0 2px 10px rgba(0, 0, 0, .35); }
.bud-token[data-state="waiting"] .draftface { background: rgba(60, 72, 60, .85);
  color: #c7cfc2; }
/* Available-and-off-screen: the bud's tab form wears the REAL action-pocket
   classes ('bud-token action painted' + data-offscreen, set by main.ts), so
   its sticker is the other travel tabs' sticker by construction — no
   duplicated styling to drift. While the bud is available it is the ONLY tab
   on the frame (ui.budFocus hides the daily ones): a once-a-run moment must
   not compete with chores (Oleh 2026-08-14). */

/* Available = the token BREATHES (the world's aliveness verb), a slow scale
   with a soft halo swelling in step with the ground light (same 3.6s). Halo
   hue matches the action light's greenish-neutral — never the warm cream
   reserved for creature state (colour system §3). Done/asleep tokens hold
   still: stillness is the rest read. Animates the inner mark, not the button,
   so it never fights the tab-tuck and tilt transforms. */
body.mat-icon.chrome-b .action[data-state="ready"] .tokimg {
  animation: token-invite 3.6s ease-in-out infinite; }
body.mat-icon.chrome-b .action[data-state="ready"] .glyph {
  animation: glyph-invite 3.6s ease-in-out infinite; }
@keyframes token-invite {
  0%, 100% { transform: scale(1);
    filter: drop-shadow(0 2px 4px rgba(15, 22, 12, .35))
      drop-shadow(0 0 2px rgba(232, 240, 212, .12)); }
  50% { transform: scale(1.07);
    filter: drop-shadow(0 2px 4px rgba(15, 22, 12, .35))
      drop-shadow(0 0 9px rgba(232, 240, 212, .6)); } }
@keyframes glyph-invite {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.1); } }

/* ── Veg-ready: the in-breath (Oleh's pick, 2026-08-14, from the quiet/
   motes/inbreath round) — a soft green ring condenses INTO the spot and
   dies at its heart: the manger breathing in. Deliberate mirror of the
   reveal's outward radiate — give and receive read as opposite motions.
   Greenish-neutral only: warm cream is creatures-only (§3), amber is the
   reveal's mark. Real child div of the veg ground light (inherits anchor
   and ready-only visibility; animated pseudos don't paint in the frame). */
.action-light .inhale { display: none; }
.action-light.veg.on .inhale { display: block;
  position: absolute; left: 50%; top: 50%; width: 96px; height: 96px;
  margin: -48px 0 0 -48px; border-radius: 50%; pointer-events: none;
  background: radial-gradient(closest-side, rgba(214, 236, 178, 0) 46%,
    rgba(214, 236, 178, .7) 68%, rgba(206, 232, 168, 0) 96%);
  filter: blur(4px);
  animation: veg-inhale 3.6s ease-in infinite; }
@keyframes veg-inhale {
  0% { transform: scale(1.45); opacity: 0; }
  30% { opacity: .85; }
  78% { transform: scale(.62); opacity: .25; }
  100% { transform: scale(.5); opacity: 0; } }

/* Logging confirmation (Oleh's ask: the world must never feel bugged after a
   log): on chip confirm the token SETTLES — a glad dip as the manger takes
   the food, easing into the done rest; the settled mark pops in on the tail.
   Transform-only on the token (done state owns the opacity), and the class
   lands on freshly rebuilt done-face children, so nothing fights it. */
body.mat-icon.chrome-b .action.settle .tokimg,
body.mat-icon.chrome-b .action.settle .glyph {
  animation: veg-settle .9s cubic-bezier(.3, 0, .3, 1); }
body.mat-icon.chrome-b .action.settle::after,
body.mat-icon.chrome-b .action.settle .donemark {
  animation: mark-pop .45s ease-out .5s backwards; }
@keyframes veg-settle {
  0% { transform: scale(1.12); }
  38% { transform: scale(.9); }
  100% { transform: scale(1); } }
@keyframes mark-pop {
  0% { transform: scale(0); opacity: 0; }
  60% { transform: scale(1.25); }
  100% { transform: scale(1); opacity: 1; } }

/* The in-breath press (fix 5.23, 2026-08-27 — Lina: "it registers very fast,
   not sure I hit the right icon"): on the chip tap the token takes ONE slow
   breath in — dips as the manger draws the food, swells — and only on the
   release (ui.ts, FEED_PRESS_MS) wears its done face; settle + mark ride
   that landing. Animates the independent `scale` property so it composes
   with token-invite (which owns transform), the tok-fidget/veg-arrive
   precedent; `forwards` holds the swell until the release swaps the face.
   The reveal's ritual is the sibling: pressed → working → spent. */
body.mat-icon.chrome-b .action.inbreath { pointer-events: none; }
body.mat-icon.chrome-b .action.inbreath .tokimg {
  animation: token-invite 3.6s ease-in-out infinite,
    token-inbreath var(--feed-press, .7s) cubic-bezier(.3, 0, .3, 1) forwards; }
body.mat-icon.chrome-b .action.inbreath .glyph {
  animation: glyph-invite 3.6s ease-in-out infinite,
    token-inbreath var(--feed-press, .7s) cubic-bezier(.3, 0, .3, 1) forwards; }
@keyframes token-inbreath {
  0% { scale: 1; }
  42% { scale: .86; }
  78% { scale: 1.1; }
  100% { scale: 1.08; } }
/* The ground light's ring condenses ONCE, fast, with the dip — the in-breath
   the ready state loops slowly, taken in one go. Same green, never cream. */
body.chrome-b .action-light.veg.on.inbreath .inhale {
  animation: veg-inhale-once var(--feed-press, .7s) ease-in forwards; }
@keyframes veg-inhale-once {
  0% { transform: scale(1.55); opacity: 0; }
  22% { opacity: .95; }
  62% { transform: scale(.55); opacity: .5; }
  100% { transform: scale(.3); opacity: 0; } }

/* Reveal-ready: golden mist radiating off the token's edges — the sun that
   burns fog, physically ready to clear it (Oleh's ask, 2026-08-13). COMP,
   colour system §3 collision on record: warm luminance is creatures-only, so
   this is amber-GOLD (not creature-cream) and reveal-only; baking it needs an
   explicit §3 carve-out. Own layer UNDER the button (the action-light
   pattern) — a negative-z pseudo inside the button proved unpaintable in this
   stacking arrangement, and an ANIMATED pseudo inside the transformed dev
   frame does not paint at all — so the two layers are real child divs. */
.action-mist { position: absolute; z-index: 9; width: 42px; height: 42px;
  transform: translate(-50%, -50%); pointer-events: none; display: none; }
body.mat-icon.chrome-b .action-mist.on { display: block; }
.action-mist .haze { position: absolute; inset: -20px;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba(255, 190, 80, 0) 40%,
    rgba(255, 190, 80, .55) 62%, rgba(255, 180, 60, 0) 95%);
  filter: blur(6px);
  animation: mist-haze 3.6s ease-in-out infinite; }
.action-mist .ring { position: absolute; inset: -14px;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba(255, 194, 84, 0) 52%,
    rgba(255, 194, 84, .8) 74%, rgba(255, 180, 62, 0) 100%);
  filter: blur(3px);
  animation: mist-radiate 3.6s ease-out infinite; }
@keyframes mist-haze {
  0%, 100% { opacity: .55; transform: scale(1); }
  50% { opacity: 1; transform: scale(1.14); } }
@keyframes mist-radiate {
  0% { transform: scale(.8); opacity: 0; }
  22% { opacity: .95; }
  100% { transform: scale(1.75); opacity: 0; } }

/* Reveal choreography (Oleh, 2026-08-13): no fog chrome during the tear.
   EXIT — 240ms fade, complete inside the camera-glide overture (the veil's
   dissolve only ramps from t=0.35s), so the stage is clean before the fog
   moves. ENTRANCE — after the veil settles, the done token condenses in:
   slightly large and transparent, settling to rest, delayed a beat so the
   tomorrow-glow's closing breath isn't fought. Opacity-only on the shells
   (the invite animation owns the token's transform while ready). */
.action.fog-exit, .action-mist.fog-exit, .action-light.fog-exit {
  opacity: 0 !important; pointer-events: none;
  transition: opacity .24s ease-out !important; }
/* The never-tapped veg token asks for attention once in a while (Oleh
   2026-08-17): a small wiggle every ~8.5s, ready-state only, retired for good
   at the first sheet open. Animates the independent `rotate` property so it
   COMPOSES with token-invite (which owns transform/scale) instead of
   fighting it. */
body.mat-icon.chrome-b .action.veg.fidget[data-state="ready"] .tokimg {
  animation: token-invite 3.6s ease-in-out infinite,
    tok-fidget 8.5s ease-in-out 3.5s infinite; }
@keyframes tok-fidget {
  0%, 7%, 100% { rotate: 0deg; }
  1.2% { rotate: -8deg; }
  2.6% { rotate: 7deg; }
  4% { rotate: -5deg; }
  5.4% { rotate: 3deg; } }
/* The D1 materialize, loud (Oleh 2026-08-17: the quiet condense read as
   nothing happening): the token blurs in bright, overshoots, settles; the
   ground light flares wide and calms. `scale`/`filter` properties only —
   transform stays owned by the chrome placement and the invite breathe. */
.action.veg-arrive { animation: fog-chrome-enter .6s ease-out .1s backwards; }
.action.veg-arrive .tokimg {
  animation: token-invite 3.6s ease-in-out infinite,
    tok-materialize 1.5s cubic-bezier(.22, .8, .3, 1) .1s backwards; }
@keyframes tok-materialize {
  0% { opacity: 0; scale: .2; filter: blur(9px) brightness(1.7); }
  45% { opacity: 1; scale: 1.22; filter: blur(1.5px) brightness(1.4); }
  70% { scale: .93; filter: blur(0) brightness(1.18); }
  100% { scale: 1; filter: none; } }
.action-light.veg-arrive { display: block;
  animation: light-flare 1.9s ease-out .1s backwards; }
@keyframes light-flare {
  0% { opacity: 0; scale: .3; }
  40% { opacity: 1; scale: 1.9; }
  100% { opacity: .9; scale: 1; } }
.action.fog-enter { animation: fog-chrome-enter .55s ease-out .25s backwards; }
.action.fog-enter .tokimg, .action.fog-enter .glyph {
  animation: token-condense .55s ease-out .25s backwards; }
.action-mist.fog-enter, .action-light.fog-enter {
  animation: fog-chrome-enter .55s ease-out .4s backwards; }
/* No `to` frame: it animates to each element's OWN resting opacity (a done
   token rests at .74 — a hardcoded `to 1` would snap at class removal). */
@keyframes fog-chrome-enter {
  from { opacity: 0; } }
@keyframes token-condense {
  from { transform: scale(1.28); }
  to { transform: scale(1); } }

/* The tap's ritual (Oleh, 2026-08-13 — "you tapped, it really got pressed,
   starts working, does its thing"): press-down, spring back charged, a small
   working shiver, then the token spends itself. ~0.72s, matched by the map's
   REVEAL_PRELUDE_S so the dissolve enters only after the stage is clear.
   `forwards` holds the spent state until the choreography's entrance clears
   the class. The mist flares outward and dies with it. */
.action.ritual { pointer-events: none;
  animation: ritual-fade .72s ease-in forwards !important; }
.action.ritual .tokimg, .action.ritual .glyph {
  animation: token-ritual .72s cubic-bezier(.3, 0, .4, 1) forwards !important; }
@keyframes ritual-fade {
  0%, 80% { opacity: 1; }
  100% { opacity: 0; } }
@keyframes token-ritual {
  0% { transform: scale(1); }
  14% { transform: scale(.84); }
  34% { transform: scale(1.14); }
  52% { transform: scale(1.05) rotate(3deg); }
  70% { transform: scale(1.1) rotate(-2.5deg); }
  100% { transform: scale(.35) rotate(0deg); } }
.action-mist.flare { animation: mist-flare .72s ease-in forwards !important; }
.action-light.flare { animation: mist-flare .72s ease-in forwards !important; }
@keyframes mist-flare {
  0% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
  55% { transform: translate(-50%, -50%) scale(1.4); opacity: 1; }
  100% { transform: translate(-50%, -50%) scale(1.7); opacity: 0; } }

/* Tooltip primitive (placeholder fidelity) — tag material, self-dismissing.
   Born of the closed-eye ruling: a done action answers a tap with words
   instead of a countdown (timers rejected — a countdown is a number in
   costume, 7.3-adjacent, and quietly promises a notification, 7.2). */
/* Tooltip in the family material (chrome pass, 2026-08-15): the same paper
   and rim as the cards, a soft rise on arrival. */
.ui-tip { position: absolute; z-index: 30;
  transform: translate(-50%, calc(-100% + 4px));
  background: linear-gradient(#f6f1de, #ece3c6); color: #33402a;
  border: 1px solid rgba(90, 96, 70, .5);
  border-radius: 9px; padding: 6px 11px; font-size: 11.5px; font-weight: 600;
  box-shadow: 0 3px 9px rgba(15, 22, 12, .35), inset 0 0 0 2px rgba(163, 122, 74, .14);
  pointer-events: none; white-space: nowrap; opacity: 0;
  transition: opacity .25s ease, transform .25s ease; }
.ui-tip.show { opacity: 1; transform: translate(-50%, -100%); }

/* Edge-tab geometry, all materials: half-tucked into the frame (66% showing),
   riding the anchor's clamped x. Last so its transform wins the tilt rules. */
body.chrome-b .action[data-offscreen="up"] { transform: translate(-50%, -34%); }
body.chrome-b .action[data-offscreen="down"] { transform: translate(-50%, -66%); }

.status-pill { position: fixed; top: calc(8px + var(--safe-t)); right: 10px;
  font-size: 11px; font-weight: 600; color: #4a4230;
  background: rgba(242, 236, 215, .92); border: 1px solid rgba(90, 96, 70, .45);
  padding: 4px 10px; border-radius: 10px; display: none;
  box-shadow: 0 2px 6px rgba(15, 22, 12, .3); }
.status-pill.show { display: block; }

/* Modals sit ABOVE the action chrome: the dock is z-index 10, and a sheet that
   renders under it leaves the buttons floating over the chips. */
.sheet-scrim { position: fixed; inset: 0; background: rgba(10, 14, 10, .38); z-index: 20; }
/* The logging sheet as field-journal parchment (2026-08-14, chrome pass): the
   same paper the tokens are cut from — cream ground, warm painted rim, ink
   text — so tapping a parchment token opens parchment, not an app tray.
   Behavior untouched: one tap picks, scrim/Esc/back dismiss, <5s law holds. */
/* max-width + auto margins (2026-08-16): on a bare desktop window the sheet
   spanned the whole screen (Oleh's "became full-screen" report) — it now caps
   at phone width and centers; inside the phone frame nothing changes. */
.sheet { position: fixed; left: 0; right: 0; bottom: 0; z-index: 21;
  max-width: 430px; margin: 0 auto;
  background:
    radial-gradient(120% 90% at 50% 0%, rgba(255, 252, 240, .9), rgba(242, 236, 215, 0) 60%),
    linear-gradient(#f2ecd7, #e6dcbd);
  color: #33402a;
  border: 1px solid rgba(90, 96, 70, .5); border-bottom: none;
  border-radius: 22px 22px 0 0;
  box-shadow: 0 -6px 24px rgba(15, 22, 12, .38), inset 0 0 0 4px rgba(163, 122, 74, .16);
  padding: 16px 14px calc(18px + var(--safe-b));
  animation: sheet-rise .22s ease-out; }
@keyframes sheet-rise { from { transform: translateY(26px); opacity: .35; }
  to { transform: none; opacity: 1; } }
/* The sheet's exit after a pick (fix 5.23): it stopped being a modal on the
   tap (ui.ts release()); the DOM lingers on the lit chip, then sinks. */
.sheet.sink { animation: sheet-sink .22s ease-in forwards; pointer-events: none; }
@keyframes sheet-sink { to { transform: translateY(22px); opacity: 0; } }
.sheet-scrim.sink { transition: opacity .22s ease-in; opacity: 0; pointer-events: none; }
.sheet h3 { margin: 2px 4px 12px; font-size: 15px; font-weight: 700; color: #4a4230; }
/* Onboarding info at the point of input (2026-08-16): the veg sheet is
   self-sufficient — what/how under the title, why + the practice link below
   the chips. */
.sheet-note { margin: -8px 4px 12px; font-size: 12.5px; line-height: 1.45; color: #6d6248; }
.sheet-why { margin: 12px 4px 0; font-size: 12.5px; line-height: 1.45; color: #6d6248; }
.sheet-demo { margin: 8px 4px 0; text-align: center; }
.sheet-demo a { color: #8a7c58; font-size: 13px; }

/* ============ The full-screen intro (v0.13, 2026-08-16) ============
   Not a popup over an unmet world: an opaque, gently animated experience of
   its own. Parchment ground, one composed visual per slide, progress dots. */
.intro { position: fixed; inset: 0; z-index: 40;
  /* Sun-covered mist (Oleh 2026-08-16): the world's own weather, not a
     design-system gradient — warm light from above, soft haze below. */
  background:
    radial-gradient(85% 55% at 50% -8%, rgba(255, 236, 178, .85), rgba(255, 236, 178, 0) 70%),
    radial-gradient(120% 80% at 50% 115%, rgba(252, 250, 244, .9), rgba(252, 250, 244, 0) 60%),
    linear-gradient(#f8f2e1 0%, #f1e9d0 55%, #ece2c4 100%);
  color: #33402a;
  display: flex; align-items: center; justify-content: center; overflow: hidden; }
/* Drifting mist bodies — the painted fog's own tufts, huge and soft. Held
   <img> nodes on the intro host since walk 5.3 (ui.ts INTRO_MIST; they were
   ::before/::after backgrounds — requested at mount, behind the world flood,
   fading in late). The slides mount into `.intro-stage` (display: contents —
   the slide stays the host's flex item) so a slide change never re-creates
   the mist. tuft-02 = mist-a, tuft-04 = mist-b. */
.intro .mist { position: absolute; z-index: 0; width: 150%; height: 46%;
  pointer-events: none; opacity: .5; object-fit: contain; filter: blur(7px); }
.intro .mist-a { bottom: -10%; left: -30%; animation: mist-drift-a 26s ease-in-out infinite; }
.intro .mist-b { top: 4%; right: -40%; width: 120%; height: 36%; opacity: .32;
  animation: mist-drift-b 34s ease-in-out infinite; }
.intro-stage { display: contents; }
@keyframes mist-drift-a { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(6%); } }
@keyframes mist-drift-b { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(-7%); } }
.intro.intro-out { animation: intro-fade-out .6s ease-in forwards; }
@keyframes intro-fade-out { to { opacity: 0; } }
/* Composition grid (Oleh 2026-08-17: "elements could be located and sized
   better relative to the screen edges and to each other"): the slide is a
   FULL-HEIGHT column with a CONSTANT art band, so the title, body, button and
   dots land at the same place on every slide — before this, per-slide art
   heights (360/158/225/172) made the text block wander. The art band centers
   its scene; the button anchors to the bottom via the flexible gap; the body's
   line-count variance is absorbed by that gap, never by the art or the CTA. */
.intro-slide { width: min(84%, 340px); height: 100%; box-sizing: border-box;
  padding: calc(46px + var(--safe-t, 0px)) 0 calc(26px + var(--safe-b, 0px));
  display: flex; flex-direction: column; align-items: center;
  text-align: center; position: relative; z-index: 1;
  animation: intro-slide-in .5s ease-out; }
@keyframes intro-slide-in {
  from { transform: translateY(16px); opacity: 0; }
  to { transform: none; opacity: 1; } }
.intro-slide h2 { flex: none; margin: 10px 0 0; font-size: 21px; color: #4a4230;
  text-wrap: balance; }
.intro-slide p { flex: none; align-self: stretch; margin: 12px 0 0;
  font-size: 14.5px; line-height: 1.55; color: #5a5138; text-align: left; }
/* Breathing floor between the last paragraph and the CTA (Oleh, phone round
   2026-08-19: slide 4's two paragraphs sat flush against Begin). The flexible
   gap still absorbs tall screens; this margin is the guaranteed minimum. */
.intro-slide p:last-of-type { margin-bottom: 22px; }
.intro-slide .intro-next { flex: none; margin-top: auto; width: 100%; padding: 13px;
  border: none; border-radius: 14px;
  background: #6d8f56; color: #fdfbf2; font-size: 16px; font-weight: 700;
  box-shadow: 0 2px 10px rgba(38, 52, 30, .25); cursor: pointer; }
.intro-dots { flex: none; margin-top: 14px; display: flex; gap: 7px; justify-content: center; }
.intro-dots span { width: 7px; height: 7px; border-radius: 50%; background: #c9bb92; }
.intro-dots span.on { background: #6d8f56; }
/* The art band: constant across slides; shrinks first (before text or CTA)
   on short screens. Scenes with engine-tied pixel geometry (the feed scene)
   keep their fixed size and simply center in the band. */
.intro-art { flex: 0 1 350px; min-height: 235px; align-self: stretch;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 10px; }
/* Walk 5.3 (2026-08-24): the glade oval and both scapes were CSS backgrounds —
   requested by the browser only when the slide mounted, i.e. behind the world
   flood, so they arrived seconds after their slide. They are now `.ground`
   images HELD decoded by loadIntroArt and mounted as nodes (ui.ts
   placeHeldIntroArt), stretched to the box exactly as `background-size:
   100% 100%` did (object-fit: fill) and clipped by the parent's mask as before. */
.intro-glade .ground, .intro-feedscene .scape .ground, .intro-fogscape .scape .ground {
  position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* Back to the previous slide (Oleh 2026-08-16): quiet chevron, top-left.
   Rendered OUTSIDE the animating slide (walk 1.1) so fixed anchors to the
   real viewport; it keeps the slide's entrance feel via its own animation
   (a transform on the element itself never re-anchors it). */
.intro-back { position: fixed; top: calc(26px + var(--safe-t, 0px)); left: 14px; z-index: 2;
  animation: intro-slide-in .5s ease-out;
  width: 40px; height: 40px; border: none; border-radius: 50%;
  background: rgba(163, 122, 74, .14); color: #6d6248;
  font-size: 24px; line-height: 1; cursor: pointer; }
/* The replay's × (walk 5.9, Oleh 2026-08-25): the help card's re-run of the
   cards may be left early; the first-open intro never renders it (ui.ts
   introHostMarkup). The chevron's mirror — top-right, the same box, fixed to
   the real viewport (on the host, outside the animating slide — walk 1.1).
   z 2 on both: the full-height .intro-slide (z 1) overlaps their inner edge
   on a phone width. Separate rules — the floor test pins `.intro-back {`.
   The disc itself (tint, no rim, ink) is the shared corner-× rule below the
   evidence page (walk 5.27) — this block is geometry only. */
.intro-close { position: fixed; top: calc(26px + var(--safe-t, 0px)); right: 14px; z-index: 2;
  animation: intro-slide-in .5s ease-out;
  width: 40px; height: 40px; font-size: 24px; }
@keyframes intro-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
/* Slide 1 — the rolled gut-world painting (the FIGURE final, Oleh's ruling
   2026-08-17). The melt is TWO layers: the feather is BAKED into the asset's
   alpha (high-core ellipse at 50%,38% — the head stays whole, the edges reach
   true zero before the bounds; a CSS mask alone left the corners whispering a
   square), and a halo of the painting's own edge tone (sampled:
   rgb(230,211,170)) breathes its atmosphere outward into the sun-mist. */
.intro-gutworld { height: 100%; display: flex; align-items: center; justify-content: center; }
/* The asset is the ORIGINAL roll centered on a larger canvas with mist-tone
   margins and the feather baked into the alpha (per-side ramps + high-core
   global feather), reaching true zero well inside every bound. The figure is
   the central 74%, so the box reads smaller than it measures. Scales with
   the art band (square, height-driven). */
.intro-gutworld img { display: block; height: 122%; max-height: 430px;
  aspect-ratio: 1; width: auto; }
/* Slide 2 — the mirror, unframed (Oleh: no buttons, no peepholes): pressed
   veg chips on the left, and on the right a soft-edged glade that slowly
   covers with Mossling's white star flowers, then breathes clear and grows
   again. The glade is a real map crop under a feathered mask — it melts into
   the mist instead of sitting in a frame. */
.intro-mirrorrow { display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: 16px; }
.intro-chiprow { display: flex; gap: 14px; }
.intro-chiprow img { width: 76px; height: 76px; object-fit: cover; border-radius: 18px;
  border: 2px solid rgba(255, 252, 240, .9); box-shadow: 0 3px 10px rgba(38, 52, 30, .2); }
.intro-chiprow .p1 { animation: chip-press 7.5s ease-in-out .8s infinite; }
.intro-chiprow .p2 { animation: chip-press 7.5s ease-in-out 3.2s infinite; }
.intro-chiprow .p3 { animation: chip-press 7.5s ease-in-out 5.6s infinite; }
@keyframes chip-press {
  0%, 6%, 100% { transform: scale(1); box-shadow: 0 3px 10px rgba(38, 52, 30, .2); }
  3% { transform: scale(.9); box-shadow: 0 1px 4px rgba(38, 52, 30, .35); } }
.intro-glade { position: relative; width: 300px; height: 170px;
  /* The green-heart window (scored programmatically), widened to 380 master
     px for the full-width oval (composition pass 2026-08-17) — the heart
     sits near the master's west edge, which caps the zoom-out. Round 4
     (Oleh's catch, 2026-08-19): this used to be a CSS window into the FULL
     master.png — the intro silently waited on the whole 12 MB map for one
     300×170 oval, and the glade popped in under the flowers. The exact same
     window now ships as its own 34 KB crop (ops/convert-art-webp.py cuts it
     from the canonical master; same framing by construction) — mounted as
     the held `.ground` image (walk 5.3), no longer a background. */
  -webkit-mask-image: radial-gradient(58% 55% at 50% 50%, #000 55%, transparent 88%);
  mask-image: radial-gradient(58% 55% at 50% 50%, #000 55%, transparent 88%); }
.intro-glade .st { position: absolute; width: 36px; opacity: 0; }
.intro-glade .e1 { left: 24%; top: 30%; animation: star-early 13s ease-in-out infinite; }
.intro-glade .e2 { left: 58%; top: 52%; animation: star-early 13s ease-in-out .3s infinite; }
.intro-glade .m1 { left: 40%; top: 66%; animation: star-mid 13s ease-in-out infinite; }
.intro-glade .m2 { left: 66%; top: 24%; animation: star-mid 13s ease-in-out .4s infinite; }
.intro-glade .l1 { left: 16%; top: 56%; animation: star-late 13s ease-in-out infinite; }
.intro-glade .l2 { left: 46%; top: 14%; animation: star-late 13s ease-in-out .5s infinite; }
@keyframes star-early { 0%, 10% { opacity: 0; transform: scale(.5); }
  18%, 88% { opacity: 1; transform: scale(1); } 96%, 100% { opacity: 0; } }
@keyframes star-mid { 0%, 34% { opacity: 0; transform: scale(.5); }
  42%, 88% { opacity: 1; transform: scale(1); } 96%, 100% { opacity: 0; } }
@keyframes star-late { 0%, 58% { opacity: 0; transform: scale(.5); }
  66%, 88% { opacity: 1; transform: scale(1); } 96%, 100% { opacity: 0; } }
/* Slide 3 — the REAL feeding run (Oleh 2026-08-17): a feather-masked window
   on the actual stump→pool stretch of the master (crop 100,130 560×420 at
   k≈.464 — a whisker over the game's own screen scale), the real painted
   token seated on its real stump, and the game's own FeedConnective flying
   the caravan on a transparent Pixi stage (introFeedScene.ts). Same melting-
   into-mist idiom as the glade and the fogscape — no frame, no peephole. */
.intro-feedscene { position: relative; width: 340px; height: 235px;
  /* The feather must COMPLETE inside the box (outer stop × radius < half-size)
     — an oval whose fade crosses the element edge gets chopped flat at the
     sides (Oleh's callout, 2026-08-17). */
  -webkit-mask-image: radial-gradient(50% 50% at 50% 50%, #000 50%, transparent 90%);
  mask-image: radial-gradient(50% 50% at 50% 50%, #000 50%, transparent 90%); }
.intro-feedscene .scape { position: absolute; inset: 0; z-index: 0;
  /* The DORMANT stump→pool window (baked master + dry-bed overlay) — the pool
     a D1 player really sees; the awake blue was a B4 spoiler. */
  /* q95 WebP (Oleh's size ruling 2026-08-19): 878→156 KB, canonical PNG kept;
     mounted as the held `.ground` image since walk 5.3 (feedscene-dormant.webp). */ }
.intro-feedscene .feedstage { position: absolute; inset: 0; z-index: 1; }
/* The token rides its seat exactly as in play — the button over the painted
   cut face — and dips when the loop presses it (the in-game tap answer). */
.intro-feedscene .tok { position: absolute; left: 111px; top: 71px; z-index: 2;
  width: 62px; transform: translate(-50%, -50%);
  filter: drop-shadow(0 3px 8px rgba(38, 52, 30, .3)); }
/* The SAME in-breath the real log plays (fix 5.23; rulebook 3.8 — the demo
   never teaches a motion the real log fails to repeat): the `scale` keyframe
   composes with the seat's translate; the release eases back over .35s. */
.intro-feedscene .tok { transition: scale .35s ease-out; }
.intro-feedscene .tok.press { animation: token-inbreath .7s cubic-bezier(.3, 0, .3, 1) forwards; }
/* Slide 4 — the eye over the fogscape, one composition (no split, no
   peephole): a wide soft-masked stretch of the real map, its lower half
   asleep under breathing mist, the eye resting on it. */
.intro-fogscape { position: relative; width: 324px; height: 200px; }
.intro-fogscape .scape { position: absolute; inset: 0;
  /* The DORMANT stretch (baked master + dry-bed overlay — the awake stream
     was a B4 spoiler); same completed-feather rule as the feed scene. */
  /* q95 WebP (Oleh's size ruling 2026-08-19): 460→85 KB, canonical PNG kept;
     mounted as the held `.ground` image since walk 5.3 (fogscape-dormant.webp). */
  -webkit-mask-image: radial-gradient(50% 50% at 50% 50%, #000 50%, transparent 90%);
  mask-image: radial-gradient(50% 50% at 50% 50%, #000 50%, transparent 90%);
  overflow: hidden; }
.intro-fogscape .veil { position: absolute; inset: 0; background:
  linear-gradient(to top, rgba(252, 250, 244, .98) 12%, rgba(252, 250, 244, .8) 32%, rgba(252, 250, 244, 0) 62%);
  animation: veil-breathe 5.5s ease-in-out infinite; }
@keyframes veil-breathe { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(16px); } }
.intro-fogscape .eye { position: absolute; left: 50%; top: 58%; width: 62px;
  transform: translateX(-50%);
  filter: drop-shadow(0 0 16px rgba(255, 233, 160, .85));
  animation: intro-float 5s ease-in-out infinite; }
/* Slides 3/4 — the verbs: the painted token in a breathing halo. */
.intro-halo { position: relative; width: 120px; height: 120px;
  display: flex; align-items: center; justify-content: center; }
.intro-halo::before { content: ''; position: absolute; inset: -14px; border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 233, 160, .55), rgba(255, 233, 160, 0) 70%);
  animation: intro-breathe 3.4s ease-in-out infinite; }
@keyframes intro-breathe { 0%, 100% { transform: scale(.92); opacity: .7; }
  50% { transform: scale(1.08); opacity: 1; } }
.intro-halo img { width: 96px; position: relative; }
.chips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; }
/* 8 fibre-source chips (2026-08-24) = two rows of four on a 360-px phone (~80 px a
   chip). The <5 s law (rulebook 3.3) is checked at the phone frame, not assumed.
   The 6-chip fermented sheet keeps the three-wide grid. */
.chips--four { grid-template-columns: repeat(4, 1fr); gap: 8px; }
.chips--four .chip { padding: 5px 4px 7px; font-size: 11px; }
/* Photoreal example image + label (rulebook 3.5). The image decorates the chip;
   the label is ALWAYS present under it, never replaced by it — hue is never the
   sole carrier of a read (art §6 colourblind guard), and a missing image must
   still leave a usable chip. Framed like a specimen plate: thin warm rim
   around the photo, ink label beneath on the paper. */
.chip { display: flex; flex-direction: column; align-items: stretch; gap: 7px;
  background: rgba(255, 253, 244, .55); border: 1px solid rgba(122, 101, 62, .35);
  border-radius: 14px; padding: 6px 6px 8px;
  color: #3d3524; font-size: 11.5px; line-height: 1.15; font-weight: 600; cursor: pointer;
  -webkit-tap-highlight-color: transparent; }
.chip:active { background: rgba(122, 101, 62, .18); }
/* The chip answers (fix 5.23): the pick lifts and brightens, the rest dim,
   the grid locks (no second pick) — the choice is the last thing seen before
   the sheet sinks. Paper-bright ground, the paper's own warm rim, the veg
   in-breath green as a soft ring — never creature-cream, never reveal amber. */
.chip { transition: opacity .18s ease-out, transform .18s ease-out, box-shadow .18s ease-out,
  background-color .18s ease-out, border-color .18s ease-out; }
.chips.locked { pointer-events: none; }
.chips.locked .chip:not(.picked) { opacity: .42; }
.chip.picked { transform: translateY(-3px) scale(1.04);
  background: rgba(255, 253, 244, .96); border-color: rgba(163, 122, 74, .75);
  box-shadow: 0 0 0 2px rgba(214, 236, 178, .6), 0 5px 12px rgba(80, 62, 34, .22); }
.chip .thumb { display: block; position: relative; width: 100%; aspect-ratio: 1;
  border-radius: 10px; overflow: hidden;
  border: 1px solid rgba(122, 101, 62, .4);
  box-shadow: inset 0 0 6px rgba(80, 62, 34, .25); }
.chip .thumb img { position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; display: block; }
.chip .chip-label { display: block; text-align: center; }

.card-scrim { position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 30;
  display: flex; align-items: center; justify-content: center; padding: 24px; }
/* Story/beat cards sit in the lower third with a soft scrim, so the world moment
   they narrate stays visible above them (Oleh review 2026-08-01). */
.card-scrim--lower { align-items: flex-end; padding-bottom: 96px;
  background: linear-gradient(to bottom, rgba(0,0,0,.12), rgba(0,0,0,.55)); }
/* bottom: the door card after the day-13 reveal — the log sits mid-screen, the card sits
   under it on the mist (lab QA 2026-09-06). */
.card-scrim--bottom { padding-bottom: 28px; }
/* Cards share the sheet's field-journal parchment (2026-08-14, chrome pass):
   story beats, welcome, naming — one paper, ink text, moss-green confirm.
   The DRAFT ribbon stays until the copy pass strips it. */
.card { max-width: 320px; color: #33402a;
  background:
    radial-gradient(130% 100% at 50% 0%, rgba(255, 252, 240, .9), rgba(242, 236, 215, 0) 62%),
    linear-gradient(#f2ecd7, #e6dcbd);
  border: 1px solid rgba(90, 96, 70, .5);
  border-radius: 18px;
  box-shadow: 0 8px 26px rgba(15, 22, 12, .4), inset 0 0 0 4px rgba(163, 122, 74, .16);
  padding: 20px 20px 18px;
  animation: card-in .22s ease-out; }
@keyframes card-in { from { transform: translateY(14px); opacity: .35; }
  to { transform: none; opacity: 1; } }
.card h3 { margin: 0 0 8px; font-size: 16px; color: #4a4230; }
.card p { margin: 0 0 16px; font-size: 14px; line-height: 1.5; color: #5c5540; }
.card button { width: 100%; padding: 12px; border-radius: 12px;
  border: 1px solid rgba(60, 74, 40, .4);
  background: #66824c; color: #f7f3e4; font-size: 15px; font-weight: 600; cursor: pointer; }
.card button:active { background: #57713f; }
.card input { width: 100%; padding: 10px; border-radius: 10px; font-size: 15px;
  margin-bottom: 12px; border: 1px solid rgba(122, 101, 62, .45);
  background: rgba(255, 253, 244, .7); color: #33402a; }
/* Outside-tap answer on the naming card (Oleh 2026-08-20): the button is the
   only exit, and a scrim tap gets the refusal-shake grammar instead of a
   silent irreversible default. */
.card.nudge { animation: card-nudge .4s ease; }
@keyframes card-nudge {
  0%, 100% { transform: none; }
  25% { transform: translateX(-5px); }
  55% { transform: translateX(4px); }
  80% { transform: translateX(-2px); }
}

/* Naming: the creature stands on the card, centred above its name field —
   the player sees exactly who they are naming (Oleh 2026-08-15). */
.card .portrait { display: block; height: 88px; margin: 2px auto 10px; }
/* NO filter here — ever (walk 2.3, Oleh's ruling "C" 2026-08-21). A CSS
   drop-shadow on a card image races iOS Safari's compositor while the card-in
   animation promotes the card to its own layer: the shadowed image gets
   rasterized with an opaque backing rectangle and stays that way — the pale
   grey-rimmed box on the first-flower and naming cards. Three device rounds
   (qa-shadow-test*.html) showed it is timing-random on cold images and cannot
   be gated by decode or layer promotion; removing the filter is the only
   deterministic cure. The art is clean (measured) — never "fix" the PNGs.
   Guarded by tests/client/card-portrait-style.test.ts. */

.gate { position: fixed; inset: 0; display: flex; align-items: center;
  justify-content: center; padding: 32px; text-align: center; background: #141b16; }
/* The gate message rides the family parchment — even the locked door is part
   of the same world (chrome pass, 2026-08-14). */
.gate p { margin: 0; max-width: 300px; color: #33402a; line-height: 1.6;
  background:
    radial-gradient(130% 100% at 50% 0%, rgba(255, 252, 240, .9), rgba(242, 236, 215, 0) 62%),
    linear-gradient(#f2ecd7, #e6dcbd);
  border: 1px solid rgba(90, 96, 70, .5); border-radius: 18px;
  box-shadow: 0 8px 26px rgba(15, 22, 12, .4), inset 0 0 0 4px rgba(163, 122, 74, .16);
  padding: 22px 20px; }

/* The D1 closing banner (2026-08-16): transient, pointer-through, non-modal.
   Re-dressed in the FAMILY parchment (phone finding, Oleh 2026-08-20): the
   old dark mist-catcher material shared the screen with parchment tooltips
   and read as two unrelated systems — every popup surface wears one paper. */
.day-toast { position: fixed; left: 0; right: 0; bottom: 120px; z-index: 25;
  display: flex; justify-content: center; pointer-events: none;
  opacity: 0; transform: translateY(8px); transition: all .7s ease; }
.day-toast.show { opacity: 1; transform: none; }
/* The welcome-back tap catcher (return mist, rulebook 2.5) — the same parchment
   line, lower on the screen, catching the one tap. Walk row 4.1 (2026-08-22):
   it had kept an inline dark pill after the toast moved to parchment. */
.mist-catcher { position: fixed; inset: 0; display: flex; align-items: flex-end;
  justify-content: center; padding-bottom: 140px;
  /* Above the action chrome (z 10/11) and its lights: the veil's one tap lands
     here whatever stands beneath (walk row 4.7 — at z auto the reveal token
     took it). Below cards (30) so a stacked card stays closable (row 4.4). */
  z-index: 25; }
.day-toast > div, .mist-catcher > div { max-width: 300px; text-align: center;
  background: linear-gradient(#f6f1de, #ece3c6); color: #33402a;
  border: 1px solid rgba(90, 96, 70, .5);
  box-shadow: 0 3px 9px rgba(15, 22, 12, .35), inset 0 0 0 2px rgba(163, 122, 74, .14);
  padding: 11px 18px; border-radius: 14px; font-size: 13.5px; line-height: 1.45; }

/* DEV ONLY (?dev=1 or ?frame=1 adds body.dev-phone): schematic phone frame.
   390×844 logical px = iPhone 6.1" class, the dominant device of the target
   cohort (US health-curious women 25–35; assumption logged in recipes). The frame
   centers in the window while the state-lab panel stays pinned left;
   --dev-phone-scale (set from JS) shrinks the whole frame when the window is
   smaller than the device. The transform also makes position:fixed descendants
   (action bar, sheets, scrims) anchor to the FRAME, so the full UI clips to the
   phone like on a real device. */
/* …and because that transform zeroes env(safe-area-inset-*), the frame supplies
   the device's real insets by hand — otherwise the lab shows the action bar 34px
   lower than a real phone ever would. */
body.dev-phone { --safe-b: 34px; --safe-t: 47px; }
body.dev-phone #game, body.dev-phone #ui {
  position: fixed; inset: auto; left: 50%; top: 50%;
  width: 390px; height: 844px;
  transform: translate(-50%, -50%) scale(var(--dev-phone-scale, 1));
  border-radius: 28px; overflow: hidden;
}
body.dev-phone #game { box-shadow: 0 0 0 12px #2a3132, 0 0 0 14px #454f50,
  0 30px 80px rgba(0, 0, 0, .55); }
body.dev-phone #game canvas { position: absolute; inset: 0; }
body.dev-phone #ui::before { content: ''; position: absolute; top: 10px; left: 50%;
  transform: translateX(-50%); width: 120px; height: 22px; border-radius: 12px;
  background: #10140f; opacity: .85; pointer-events: none; z-index: 5; }

/* ------------------------------------------------------------------------
   Copy bake 2026-08-24: evidence footnotes, help-card restructure, the
   evidence page. Design settled from the copy session's judge shots
   (scratch/copy-handoff/review-shots-2026-08-23-intro/shots/). */

/* Intro evidence footnote: bottom of the slide, hairline rule, muted; the
   reference on its own line. The whole note is the sources-page tap target. */
.intro-slide p.intro-note { margin-top: auto; margin-bottom: 0; padding-top: 12px;
  border-top: 1px solid rgba(74, 66, 48, .16);
  font-size: 12.5px; line-height: 1.5; color: #8f8468; cursor: pointer; }
.intro-slide .intro-note .cite { display: block; margin-top: 2px;
  font-size: 12px; color: #9d9276; }
.intro-slide .intro-note + .intro-next { margin-top: 16px; }

/* Help card: corner × (tap-away closes too since walk 5.17), link-out button,
   the science line. .card-close must undo the full-width green .card button
   rules. Geometry (walk 5.17, Oleh 2026-08-26): the 30 px × sat 7 px above
   the "How this game works" row — a slightly low tap opened the four-page
   intro. Now 36 px, and the title line is its row: 30 px tall beside the ×
   with the ×'s width kept clear, 22 px of plain card below before the first
   menu row (× bottom → row top = 22 px; was 7). Only cards WITH a × get the
   title-row rule (`~`), so beat/naming/flower cards keep their 8 px title gap.
   The disc's look lives in the shared corner-× rule (walk 5.27) — geometry only here. */
.card { position: relative; }
.card .card-close { position: absolute; top: 14px; right: 14px; width: 36px; height: 36px;
  padding: 0; margin: 0; font-size: 22px; font-weight: 400; box-shadow: none; }
.card .card-close ~ h3 { min-height: 30px; line-height: 30px; margin: 0 0 22px; padding-right: 44px; }
.card .cardbtn { display: block; width: 100%; box-sizing: border-box; padding: 12px;
  border-radius: 12px; border: 1px solid rgba(60, 74, 40, .4); background: #66824c;
  color: #f7f3e4; font-size: 15px; font-weight: 600; text-align: center;
  text-decoration: none; }
.card .cardbtn:active { background: #57713f; }
/* Help menu rows (walk 5.9, Oleh's outline pick 2026-08-25): two quiet
   parchment rows above the Telegram body — self-serve first, the human last.
   They undo the green full-width .card button rule; the › is the row's own
   affordance. `aria-busy` = the replay row while the slides' art re-holds. */
.card .menu-row { display: flex; align-items: center; justify-content: space-between;
  width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 11px 12px;
  border-radius: 12px; border: 1px solid rgba(90, 96, 70, .35);
  background: rgba(255, 253, 244, .55); color: #4a4230;
  font-size: 14.5px; font-weight: 600; text-align: left; cursor: pointer; }
.card .menu-row span { color: #8a7c58; font-size: 18px; font-weight: 400; line-height: 1; }
.card .menu-row:active { background: rgba(255, 253, 244, .85); }
.card .menu-row[aria-busy="true"] { opacity: .5; pointer-events: none; }
.card .menu-row + p { margin-top: 16px; }

/* The evidence page: full-screen scrollable parchment over everything (the
   intro is z-40, cards z-30). Appended inside #ui or the intro host, so the
   dev phone frame contains it and both entry points share one surface. */
.sources-sheet { position: absolute; inset: 0; z-index: 50; overflow-y: auto;
  background: #f7f1dc; color: #5a5138; font-size: 13.5px; line-height: 1.55;
  -webkit-overflow-scrolling: touch; }
/* The × is the sheet's FIRST child, outside the text column (walk 5.28, Oleh
   2026-08-28: inside the centred 340 px column it sat 41 px from the screen
   edge — three times the 14 px every other corner × keeps; a full-screen
   page's × belongs to the screen corner, a card's to the card corner). Sticky
   under the safe-area floor (was 12 px — under the status bar on the phone);
   its flow height is taken back (-36 px) and the column's top padding grows
   by the same 36 px, so the title and every line below it stay where they were. */
.sources-sheet .sources-close { position: sticky; top: calc(14px + var(--safe-t, 0px)); z-index: 2;
  display: block; margin: 0 14px -36px auto; width: 36px; height: 36px; padding: 0; font-size: 22px; }
.sources-inner { max-width: 340px; margin: 0 auto;
  padding: calc(50px + var(--safe-t, 0px)) 16px calc(40px + var(--safe-b, 0px)); }
.sources-sheet h1 { font-size: 21px; color: #4a4230; margin: 6px 0 0; }
.sources-sheet h2 { font-size: 15.5px; color: #4a4230; margin: 26px 0 0;
  border-top: 1px solid rgba(74, 66, 48, .16); padding-top: 18px; }
.sources-sheet p { margin: 10px 0 0; }
.sources-sheet .entry { margin: 10px 0 0; }
.sources-sheet .entry b { color: #4a4230; }
.sources-sheet .src { font-size: 12px; color: #8f8468; font-style: italic;
  margin-top: 2px; word-break: break-all; }
.sources-sheet .note { font-style: italic; color: #8f8468; font-size: 12.5px;
  margin-top: 12px; }
.sources-sheet .footer { font-style: italic; color: #4a4230; font-size: 12.5px;
  margin-top: 26px; border-top: 1px solid rgba(74, 66, 48, .16); padding-top: 18px; }

/* The corner × — ONE look on every surface that has one (walk 5.27, Oleh
   2026-08-28: the intro replay's soft tan disc was the nicer ×; the help card
   and the two parchment pages wore a white disc with a dark rim, each written
   by its own session). Each site keeps its own geometry in its own block
   above — position, size, the glyph size that fits the disc (24 in 40, 22 in
   36; the pages' × grew 32 → 36 to match the card's tap target, walk 5.17).
   The disc itself is owned here and nowhere else — `close-x-look.test.ts`
   fails the moment a site re-declares it. */
.intro-close, .card .card-close, .sources-sheet .sources-close {
  border: none; border-radius: 50%; background: rgba(163, 122, 74, .14); color: #6d6248;
  line-height: 1; text-align: center; cursor: pointer; }
.intro-close:active, .card .card-close:active, .sources-sheet .sources-close:active {
  background: rgba(163, 122, 74, .26); }

/* "What comes next" (2026-08-28, brief §14): the sources sheet's parchment and
   corner ×, then one .next-row per habit — a 44 px painted token, title ·
   status, a one-line world tie. Status is a LAYER read from state
   (nextPage.ts): here now = solid, coming next = half, later = faint —
   transparent, never a promise (Oleh's ruling 2026-08-27). */
.next-sheet .next-lead { margin-top: 14px; }
.next-row { display: flex; align-items: flex-start; gap: 12px; margin: 14px 0 0; }
.next-row img { width: 44px; height: 44px; flex: none; object-fit: contain; display: block; }
.next-row b { color: #4a4230; }
.next-row .next-status { font-size: 12.5px; color: #8f8468; font-style: italic; }
.next-row .next-desc { margin-top: 1px; }
.next-row[data-state="next"] { opacity: .55; }
.next-row[data-state="later"] { opacity: .3; }
.next-sheet .next-note { margin-top: 18px; }

/* The day-1 ending card (2026-08-28): the two verb UNITS in the sentence —
   the real token face tight against its bold name, never glyphs (Oleh, brief
   §8; r2 2026-08-28: icon + name must read as one thing — the face hugs the
   word, the unit never wraps, the name is set apart from the running text by
   weight and the ink colour of the headings). Height in em so the token rides
   the text size; the negative baseline shift seats a round token on the
   x-height instead of hanging it off the baseline. */
.card .tok-unit { font-weight: 600; color: #4a4230; white-space: nowrap; }
.card .tok-unit .tok-inline { height: 1.2em; width: auto; vertical-align: -0.24em; margin: 0 .14em 0 .02em; }
