  /* ===== THE FONT RULE — rewritten 2026-07-28 on Michael's ruling. It is now ONE sentence: =====

       EVERY WORD IN THE APP IS var(--font-read). The ONLY exceptions are the AnimaLingo logo
       and the AnimaLingo wordmark at the top of the slide-out menu, which use --font-display.

     There is no judgement call left. The old rule split the app into "words a child reads"
     (Andika) and "the app's personality" (Fredoka One), and that seam is exactly where things
     went wrong: the landmark name pill, the big animal name, the country name, the Town caption
     and the postcard title were all reasoned into the decorative font — but they hold TRANSLATED
     words, and Fredoka One has no Russian, no Polish accents and no Chinese/Japanese/Hindi/Arabic.
     Those words were quietly being drawn by whatever the iPad substituted, and Polish came out
     mixed mid-word. Personality now comes from size, weight and colour instead.

       --font-read     ANDIKA + Noto Sans Devanagari (Hindi) + Noto Sans Arabic. Andika is a
                       reading font drawn by SIL for children learning to read, so no two of its
                       letters can be confused (capital-i vs lower-L vs one, a/g/y). It covers
                       Latin, Latin-Extended (Polish) and Cyrillic (Russian); Hindi and Arabic
                       fall through to the two Noto faces listed after it. Use this everywhere.

       --font-display  FREDOKA ONE — the logo and the slide-out wordmark. NOTHING ELSE, EVER.
                       Both are due to become images; when they do, this font is deleted.

       --font-map      THE MAP'S OWN LETTERING — ★ Michael's ruling 2026-07-28: the world map
                       KEEPS the look it always had. Continent names in Fredoka One; the oceans
                       and the equator keep their italic serif (written straight into the map's
                       shapes in AnimaLingo.html). A map should read like a map, not like the
                       rest of the app. This is the one deliberate exception to "one font", and
                       it applies ONLY to lettering that sits on the map itself.
                       ⚠️ The Animals and Landmarks tabs don't use this live text at all — they
                       show a PICTURE of the map with the names already painted in. Change the
                       map's lettering and you must re-run scripts/bake_world_map_labeled.py and
                       bump AL_MAP_V in src/state.js, or those two tabs keep the old picture.

     Chinese and Japanese have no app font on purpose — they use the iPad's own PingFang and
     Hiragino. Neither language has an alphabet, so a font must either carry thousands of
     characters (over a megabyte) or be cut to the exact words we ship today — and this app is
     not finished, so tomorrow's Chinese word would come out an empty box. Apple's are identical
     on every iPad and can never miss a character. Japanese takes Apple's for its alphabets AND
     its kanji, because one Japanese word mixes them and two designs inside a word looks broken.

     Andika ships Regular + Bold only. Weights 800/900 in this file therefore render as Bold —
     that is expected and was approved on the comparison page, not a bug to "fix" by adding a
     heavier face. Font files + the full explanation: assets/fonts/fonts.css. */
  :root {
    --font-read:    'Andika', 'Noto Sans Devanagari', 'Noto Sans Arabic', sans-serif;
    --font-display: 'Fredoka One', cursive;
    --font-map:     'Fredoka One', cursive;
  }

  * { margin:0; padding:0; box-sizing:border-box; }
  html { -webkit-text-size-adjust:100%; }
  body { font-family:var(--font-read); overflow:hidden; background:#3DA6D4;
    overscroll-behavior:none; touch-action:manipulation;
    -webkit-user-select:none; user-select:none;
    -webkit-tap-highlight-color:transparent; -webkit-touch-callout:none; }

  /* ===== ONE-CANVAS SCALING (unify units across tablets) — src/canvas-scale.js =====
     The whole visual scene lives inside #app-canvas, a fixed-size logical canvas at the
     iPad landscape ratio. A single transform:scale fits it to any tablet uniformly, so
     every tab scales as one. In CONTAIN mode the leftover strips on differently-shaped
     tablets become the letterbox where Michael's decorative border will mount (Phase 6).
     --canvas-w/--canvas-h are the ONE place the reference size is defined (mirror in
     canvas-scale.js). #app-canvas sits at a LOW z-index so all chrome (menu scrim/drawer 87-88,
     panels 84-90, postcard 86) paints above it; the scene's own layering is preserved
     WITHIN this stacking context. */
  :root { --canvas-w:1194; --canvas-h:834; --app-scale:1; }
  #app-canvas {
    position:fixed; left:50%; top:50%;
    width:calc(var(--canvas-w) * 1px);
    height:calc(var(--canvas-h) * 1px);
    transform:translate(-50%,-50%) scale(var(--app-scale,1));
    transform-origin:center center;
    overflow:hidden;   /* crisp letterbox edge; zoomed map clips to the canvas like a viewport */
    z-index:1;
  }

  /* MAP — cartoony */
  #map-wrap { position:fixed; inset:0; }
  /* EXPLORE STAGE (2026-07-18 flavor 1): world-svg + glow-svg + labels-svg move as ONE element
     under the shared camera (src/map-cam.js). pointer-events:none on the stage itself so the
     empty box never eats taps on other tabs (children re-enable their own). */
  #explore-stage { position:absolute; inset:0; transform-origin:0 0;
                   transform:translate3d(0,0,0); pointer-events:none;
                   /* UNIFORM TAB FADE (2026-07-18): the live map dissolves with the SAME curve
                      and timing the mode overlays use — no more display:none snap/flash on tab
                      switch. visibility rides the same transition: it flips to hidden only
                      AFTER the fade lands (and back to visible instantly), so the hidden map
                      costs nothing to paint on other tabs. */
                   transition:opacity 2.363s cubic-bezier(.65,0,.35,1), visibility 2.363s; }
  body:not([data-mode="explore"]) #explore-stage { opacity:0; visibility:hidden; }
  /* ★ FIRST-PAINT PRE-DRAW (2026-07-18, the Town hamburger-prepaint trick): shortly after
     launch the stage renders for REAL at 0.5% opacity — invisible to the eye, but the browser
     pays the one-time cost of drawing all 177 country paths + stars + labels THEN, not during
     the first Animals→Countries crossfade (Michael's "first time still kinda flashes").
     Class added/removed once by src/explore-cam.js; snap in/out (transition:none), like Town's. */
  body.explore-prepaint:not([data-mode="explore"]) #explore-stage {
    opacity:0.005; visibility:visible; transition:none; }
  #explore-stage #world-svg { pointer-events:auto; }
  #world-svg { width:100%; height:100%; display:block; }

  .cont { transition: filter .3s; }

  /* Continent labels */
  .ctlabel {
    font-family:var(--font-map);   /* the map keeps its own lettering — Michael 2026-07-28 */
    fill:white; paint-order:stroke;
    stroke-width:5; stroke-linejoin:round;
    pointer-events:none;
  }

  /* TITLE BANNER */
  #title-banner {
    position:fixed; top:10px; left:50%;
    transform:translateX(-50%);
    z-index:25; pointer-events:none;
  }
  #title-banner svg { width:min(340px,60vw); height:auto; display:block; }

  /* ROPE BORDER FRAME (Michael's design 2026-07-18): the rope lives IN THE MAP WORLD.
     One .map-rope ring sits inside each map stage (#anim-stage/#lean-stage/#explore-stage);
     src/rope-frame.js pins it onto exactly the slice of the map this tablet shows at rest
     (sets left/top/width/height + --rope-w), so at rest it hugs the screen edge on EVERY
     tablet size/aspect, and zooming carries it off-screen with the map. It rides the stage's
     one compositor transform — zero per-frame cost; shows/hides with its stage's own fade.
     Built from 8 background-image pieces, NOT border-image: iPad WebKit refuses to paint
     border-image in this app's layout (simulator bisect 2026-07-18) while backgrounds paint
     fine. Geometry matches a 9-slice: corners are var(--rope-w) squares, edges tile between
     them with background-repeat:round so only whole twists appear (seamless at any width). */
  .map-rope {
    position:absolute; z-index:30;   /* above the map art, below pins (60) / rings (59) */
    pointer-events:none;
  }
  /* Pre-stitch fallback = the ONE master rope stretched corner-to-corner (same file the
     stitcher uses, so it's already decoding). rope-frame.js replaces this with the exact
     aspect-correct stitched bitmap the instant the master loads. The empty .r-* piece divs
     left in the markup are unused now (hidden once .stitched) — the 8 cut pieces were deleted. */
  .map-rope { background:url('../assets/rope/rope-master.webp') 0 0 / 100% 100% no-repeat; }
  .map-rope div { display:none; }

  /* COMPASS ROSE (src/compass.js sizes/positions it from data/compass-layout.js):
     one per map stage, rides the zoom like the rope. Above the map art, below the rope (30). */
  .map-compass { position:absolute; z-index:28; pointer-events:none; }

  /* DIM */
  #map-dim {
    position:fixed; inset:0;
    background:rgba(0,0,0,0);
    transition:background .4s ease;
    pointer-events:none; z-index:40; cursor:pointer;
  }
  #map-dim.active { background:rgba(8,18,48,.6); pointer-events:auto; }

  /* PINS */
  /* The pin containers need a real full-screen box: when zoom.js transforms them, they become
     the pins' positioning ancestor, so the pins' left/top percentages must resolve against the
     full map area (a bare 0-height div would collapse every pin onto one line). pointer-events
     pass through the empty container; the pins themselves re-enable them. */
  /* position:ABSOLUTE, not fixed — #pins now lives inside the transformed #anim-stage (2026-07-16
     animals rebuild), and iOS mis-places fixed descendants of a transformed ancestor (the exact
     per-device pin-skew bug from the Landmarks pins saga). Absolute = anchored to the stage. */
  #pins { position:absolute; inset:0; pointer-events:none; }
  /* the ANIMAL pins too — the base .pin rule says fixed (Animals shared it with Landmarks before
     the split); fixed descendants of the ANIMATED #anim-stage would hit the iOS per-device
     mis-placement bug. Absolute resolves against #pins (= the stage box), same geometry.
     ALSO (zoom-smoothness pass, same day): the base .pin transform + pinFloat keyframes carry
     calc(var(--map-zoom)) — WebKit runs var()/calc() transform animations on the MAIN thread
     (Landmarks landmine #3), which is 12 main-thread animations ticking under every gesture.
     --map-zoom is dead on this tab (the stage zooms, pins ride), so the Animals pins get
     plain-number overrides: same look, compositor-run. */
  /* FIXED-SIZE BOX + PX MARGINS — the full Landmarks pin coordinate system, because it is the
     only centering that survives the --a-hi re-raster flip on iOS. translate(-50%,-50%)
     re-resolves against the pin's rendered size, so when the 3x flip grew the box every pin
     SHIFTED off its coordinate (Michael, TEST 107). A fixed box centered by margins that carry
     the same --a-hi factor cannot move: box 3x, margins 3x, scale(1/3) shows it at the same
     size — the CENTER is invariant by construction. */
  /* ★★ SIZE DIAL — DRY-FIT ONLY (2026-08-01, Michael cleared §1). --pin-grow defaults to 1, so
     with the dial untouched this is today's app exactly.
     ⚠️ THE PINS GROW BY SIZE, NEVER BY TRANSFORM — the first attempt did the tempting thing and
     multiplied the scale-back below, which looked like one clean edit instead of the fourteen
     underneath. Michael tried it at 2x on device: "the images look terrible." A transform stretches
     a picture that has ALREADY been drawn; it never sends the browser back to the file for more
     detail. And the detail was right there — most pin files hold MORE pixels than a 2x pin covers.
     So --pin-grow goes on the same SIZE numbers the deep-zoom flip uses (font-size, widths), which
     make the picture lay out bigger and therefore be re-drawn from the file, sharp. This is the
     identical reason --a-hi exists; the lesson is one line up in the comment above and I still
     walked past it. If a future session is tempted to "simplify" this back into the transform: no. */
  #pins .pin { position:absolute;
               width:calc(110px * var(--a-hi,1));
               height:calc(110px * var(--a-hi,1));
               margin-left:calc(110px * var(--a-hi,1) / -2);
               margin-top:calc(110px * var(--a-hi,1) / -2);
               transform:scale(calc(1 / var(--a-hi,1)));
               justify-content:center;              /* flex column from the base .pin rule */
               animation:apinFloat 3s ease-in-out infinite; }
  @keyframes apinFloat {
    /* translateY BEFORE scale = the bob stays a true 9px (same as the Landmarks lmFloat) */
    0%,100%{ transform:translateY(0) scale(calc(1 / var(--a-hi,1))) }
    50%{ transform:translateY(-9px) scale(calc(1 / var(--a-hi,1))) }
  }
  /* ★ 2026-07-30 — RINGS MOVED OUT OF THE PINS, matching Landmarks (#lm-rings, ~line 398).
     #pin-rings is a sibling of #pins inside #anim-stage at z 59 (< the pins' z 60), so every ring
     paints beneath every icon. Two reasons: a ring can no longer pulse OVER a neighbour's animal,
     and a pin's own GPU layer no longer contains something that grows to 2.4x inside it.
     position:ABSOLUTE, never fixed — same trap the landmark rings hit: a fixed layer inside the
     transformed #anim-stage puts the RINGS in screen coordinates while the PINS are in map
     coordinates, so they come apart as soon as you zoom. */
  #pin-rings{ position:absolute; inset:0; pointer-events:none; z-index:59; }
  /* The holder is the IDENTICAL fixed-size box as the pin (110px x --a-hi, margin-centered), at the
     identical map point, riding the identical bob — so the ring sits exactly where it did when it
     was a child of the pin. Same margin-centering as the pin, for the same reason: translate(-50%)
     re-resolves against the rendered size and would SHIFT everything at the 3x re-raster flip. */
  #pin-rings .apin-ring{
    position:absolute;
    width:calc(110px * var(--a-hi,1));
    height:calc(110px * var(--a-hi,1));
    margin-left:calc(110px * var(--a-hi,1) / -2);
    margin-top:calc(110px * var(--a-hi,1) / -2);
    display:flex; align-items:center; justify-content:center;
    transform:scale(calc(1 / var(--a-hi,1)));
    animation:apinFloat 3s ease-in-out infinite;
  }
  /* The circle inside the holder: flex-centered, so the ping is a PLAIN SCALE with no
     percent-of-own-size translate — the shared .pin-ring rule's translate(-50%,-50%) + pingRing
     must both be cancelled here, exactly as #lm-rings .pin-ring cancels them. */
  #pin-rings .pin-ring{
    position:static; top:auto; left:auto; margin:0;
    width:calc(39px * var(--a-hi,1) * var(--pin-grow,1)); height:calc(39px * var(--a-hi,1) * var(--pin-grow,1));
    border-width:calc(6px * var(--a-hi,1));
    transform:none;
    animation:apinPing 3s ease-out infinite;
  }
  /* Ring: centered with calc margins (static, allowed), sized in the pin's PRE-SCALE space
     (x --a-hi) so the visible circle stays the same size when deep; ping animates a plain
     scale, exactly like the Landmarks lmPing.
     ★ Kept as the FALLBACK path only (buildPins uses it when #pin-rings is absent). */
  #pins .pin-ring {
    left:50%; top:50%;
    /* 39px = 3.3% of the FIXED 1194px canvas. vw units sized the ring to the browser WINDOW
       while everything else follows the canvas, so rings ballooned in wide windows (Michael
       2026-07-17, "owl and dog rings touch"). Fixed px = identical in every window + iPad. */
    width:calc(39px * var(--a-hi,1) * var(--pin-grow,1)); height:calc(39px * var(--a-hi,1) * var(--pin-grow,1));
    margin-left:calc(39px * var(--a-hi,1) / -2);
    margin-top:calc(39px * var(--a-hi,1) / -2);
    border-width:calc(6px * var(--a-hi,1));
    transform:none;
    /* 3s period = LOCKED to the apinFloat bounce cycle (Michael 2026-07-17): every pulse is
       born at the bottom of its pin's bounce, forever in sync. The visible expansion is still
       the approved 2.4s (0→80% of the cycle); the last 0.6s the ring just stays invisible. */
    animation:apinPing 3s ease-out infinite;
  }
  @keyframes apinPing {
    /* End scale 3.12 = the approved 2.4 reach, 30% further out (Michael 2026-08-21).
       Border 6px (was 3px) = twice as thick; box-sizing:border-box keeps the thickness INSIDE
       the 39px circle, so the ring's starting size is unchanged and only the stroke reads heavier. */
    0%{ transform:scale(.5); opacity:.9 }
    80%,100%{ transform:scale(3.12); opacity:0 }
  }
  /* Each pin on its OWN GPU layer so the dive RE-RASTERS the small pin crisp instead of blurring
     a scaled base texture — the same pin-sharpness fix the Landmarks tab uses. */
  body[data-mode="animals"] #pins .pin { will-change:transform; }
  /* NO drop-shadow filter on the Animals pins AT ALL (2026-07-16, Michael: "zooming in is slow
     to respond"). The motion-only version made it WORSE at the endpoints: removing the filter at
     dive START re-rastered 12 pin layers, and re-adding it on LANDING re-drew 12 shadows at 3× —
     the same shadow-raster-at-deep-zoom that froze Mom's iPad 6s on Landmarks. Landmarks cured it
     by dropping the filter on that tab permanently; same medicine here.
     ★ 2026-07-24 UNSCOPED FROM THE TAB. This used to read body[data-mode="animals"] …, so the
     shadow came BACK on every other tab — where the animal pins are invisible but still painted
     (mode overlays fade, they don't switch off). The Landmarks tab was paying for 12 invisible
     shadowed pins, and Animals for 36 invisible landmark ones (see the twin rule ~line 837) —
     which is why ANIMALS felt stickier than Landmarks despite having a third as many pins.
     Scoped to the #pins CONTAINER instead of the mode, so it holds on every tab. The landmark
     emoji-FALLBACK pins (#lm-pins .pin-emoji, used when a landmark has no thumbnail) are
     deliberately untouched — they keep their shadow exactly as before. */
  #pins .pin-emoji,
  #pins .pin-img { filter:none; }
  /* (body.anim-zooming still gets set by src/anim-map.js while the camera moves, but nothing
     needs to hide anymore: the bob + ring ping above are compositor-run, so they keep playing
     through the dive — Landmarks parity. The earlier pause/display:none hid a main-thread cost
     that no longer exists, and the toggle itself caused a start/land hiccup.) */
  /* ===== ANIMALS STAGE (src/anim-map.js, 2026-07-16) — the Landmarks #lean-stage model exactly:
     one element (baked labeled map + animal pins), one compositor transform, pins ride the zoom. */
  #anim-stage { position:fixed; inset:0; transform-origin:0 0; will-change:transform; }
  #anim-map-canvas { position:absolute; inset:0; width:100%; height:100%;
                     display:block; pointer-events:none; will-change:transform; }
  /* (2026-07-18 uniform fade: the live-map layers no longer display:none-snap per mode —
     the whole #explore-stage fades out on every non-explore tab; rule lives with the stage.) */
  /* Landmark pins anchor to the MAP, not the screen (Michael 2026-07-16). #lm-pins/#lm-rings live
     inside the transformed #lean-stage; position:ABSOLUTE means their left/top % resolve against the
     map stage and ride its zoom cleanly. position:fixed would tie them to the viewport, which iOS
     WebKit mis-places once an ancestor is transformed — that was the per-device pin SKEW (fine on
     Mom's iPad, off on Daniel's). Absolute = locked to the map coordinate on every device. */
  #lm-pins, #lm-rings { position:absolute; inset:0; pointer-events:none; }
  #pins .pin, #lm-pins .pin { pointer-events:auto; }
  .pin {
    position:fixed;
    /* counter-scale: map zooms under the pin, icon keeps its screen size (maps-app standard).
       --pin-ctr overrides the formula per pin — the Landmarks camera (src/lean-map.js) sets it
       so pins PLUMP UP toward finger size while the camera dives; the fallback keeps every
       other pin (and the whole app without lean-in) exactly as before. */
    transform:translate(-50%,-50%) scale(var(--pin-ctr, calc(1/var(--map-zoom,1))));
    cursor:pointer; z-index:60;   /* well above glow (50) AND labels (51) — the glow's drop-shadow filter can otherwise paint over the pin on its own compositor layer */
    isolation:isolate;            /* force a new stacking context so the pin-emoji's own drop-shadow filter can't sneak under the glow's layer */
    display:flex; flex-direction:column; align-items:center; gap:3px;
    animation:pinFloat 3s ease-in-out infinite;
  }
  /* Pin float delays are set per-pin by buildPins() in src/animals.js — each pin gets a negative
     animation-delay based on its `left` position so the bounce rolls left→right across the map.
     Replaces the old 3-pin nth-child stagger (which only ever covered the first 3 pins). */
  @keyframes pinFloat {
    /* the counter-scale must live in these frames too — the animation owns `transform` while it runs */
    0%,100%{transform:translate(-50%,-50%) translateY(0) scale(var(--pin-ctr, calc(1/var(--map-zoom,1))))}
    50%{transform:translate(-50%,-50%) translateY(-9px) scale(var(--pin-ctr, calc(1/var(--map-zoom,1))))}
  }
  /* --a-hi = the Animals deep-zoom re-raster flip (2026-07-16, the Landmarks --lm-hi trick):
     at world view it's 1 (raster == display, nothing to shimmer); a dive sets body.anim-deep
     (src/anim-map.js glideTo, ONCE per glide) and the pin CONTENT re-rasters 3x — its layout
     font-size triples while a static scale(1/3) shows it at the same size, so the texture holds
     3x detail exactly where the zoom displays it ~1:1. The flip lives on .pin-emoji/.pin-img
     (children), NOT in the bob keyframes — apinFloat stays plain numbers = compositor-run. */
  /* The ring holders take the SAME flip as the pins (2026-07-30, when the rings moved to their own
     layer) — exactly as #lm-rings .lm-ring does on Landmarks. Without it the holder stayed 110px
     while its pin grew to 330px at the dive, so the ring's 3px border rendered from a world-view
     texture and went soft at deep zoom. */
  #pins .pin, #pin-rings .apin-ring { --a-hi:1; }
  body.anim-deep #pins .pin, body.anim-deep #pin-rings .apin-ring { --a-hi:3; }
  .pin-emoji {
    font-size:calc(clamp(2rem,3.6vw,3.2rem) * var(--a-hi,1) * var(--pin-grow,1));
    filter:drop-shadow(2px 5px 8px rgba(0,0,0,.55));
    line-height:1; transform:scale(1.28);   /* baked in: every pin now renders at what used to be the HOVER size; hover-grow removed (bee excluded below). The 1/--a-hi scale-back lives on the PIN BOX, not here — content just grows with the box via font-size. */
  }
  .pin-img {
    width:calc(clamp(3rem,5.4vw,4.6rem) * var(--a-hi,1) * var(--pin-grow,1)); height:auto;
    display:block; transform:scale(1.2);   /* baked-in hover size; hover-grow removed; scale-back lives on the pin box */
    filter:drop-shadow(2px 4px 6px rgba(0,0,0,.4));
  }
  /* TEST 34 (Michael): postcard pins were hard to hit — "tiny, I have to touch it a million
     times". An invisible halo widens each landmark pin's TOUCH target by 14 logical px per side
     without changing the icon's visual size (Michael's Pin Layout sizes untouched). Pseudo-
     elements hit-test as part of their pin, so taps on the halo behave exactly like pin taps.
     Dense areas (Europe) may have slivers of halo overlap — the topmost pin wins there, and the
     icons themselves are never ambiguous. */
  /* ===== LANDMARK PIN COORDINATE SYSTEM (Michael 2026-07-16 — keep it DEAD SIMPLE) =====
     One map point per landmark (left/top % of the 1194x834 stage). The pin is a FIXED-SIZE
     square box (--lm-box, plain px — the stage is always 1194 logical px wide, so px are
     map-proportional) whose CENTER is placed on that point via fixed px margins. The icon is
     flex-centered inside the box. NO percent-of-own-size transforms: translate(-50%,-50%)
     depends on the box's rendered size, and iOS resolves it against a 0-sized box when the
     bob animation starts before the icon image loads — baking the pin's TOP-LEFT corner onto
     the coordinate (the "ring at the pin's corner" bug). Fixed numbers can't do that.
     The ring (#lm-rings .lm-ring) is the IDENTICAL box at the identical point. */
  :root { --lm-box:76px; --lm-ring:39px; }   /* 6.4% / 3.3% of the 1194px map = Michael's approved sizes */
  /* CRISP AT DEEP ZOOM (restored on the fixed-box system): the box is built --lm-hi× oversized
     (still a fixed number — 3×76 = 228px) so the icon texture rasterizes at 3× resolution, then
     the whole box is scaled back by 1/--lm-hi. scale() is a PURE NUMBER on a fixed-size box —
     unlike translate(-50%,-50%) it never resolves against a not-yet-loaded image size, so the
     iOS corner-bake bug cannot return. Center stays put (scaling about the box's own center).
     Net on-screen size unchanged; texture is 3× = crisp when the dive scales it up. */
  /* --lm-hi is ZOOM-CONDITIONAL (flash fix v2, 2026-07-16): 1 at world view — texture drawn at
     display size, so the bob has nothing to shimmer (the always-3× version FLASHED while bouncing:
     each bounce redrew a big texture downscaled to a small pin; moving the bob to an inner element
     did NOT help — TEST 98, reverted — because the downscale itself was the shimmer). Diving in
     sets body.lean-deep (src/lean-map.js glideTo) and the pins re-raster ONCE at 3× — crisp at the
     dive scale where they display ~1:1. Box size/margins/scale all follow --lm-hi, so the pin's
     CENTER never moves and pin/ring stay colocated in both states. */
  #lm-pins .pin, #lm-rings .lm-ring { --lm-hi:1; }
  body.lean-deep #lm-pins .pin, body.lean-deep #lm-rings .lm-ring { --lm-hi:3; }
  #lm-pins .pin {
    position:absolute;
    width:calc(var(--lm-box) * var(--pin-size,1) * var(--lm-hi,1));
    height:calc(var(--lm-box) * var(--pin-size,1) * var(--lm-hi,1));
    margin-left:calc(var(--lm-box) * var(--pin-size,1) * var(--lm-hi,1) / -2);
    margin-top:calc(var(--lm-box) * var(--pin-size,1) * var(--lm-hi,1) / -2);
    transform:scale(calc(1 / var(--lm-hi,1)));
    align-items:center; justify-content:center;   /* (flex column from the base .pin rule) */
    animation:lmFloat 3s ease-in-out infinite;    /* bob WITHOUT any percent translate */
  }
  /* ★ TAP CIRCLE UNLINKED FROM VISUAL SIZE (Michael 2026-07-18): the pin element is visuals-only
     (pointer-events none, as are all its art children); the ONLY tappable thing is the .pin-tap
     child — a centred CIRCLE of diameter 76px × --tap-mul (logical px), set per pin by
     buildLandmarkPins from pin-layout.js `tap`. Default --tap-mul = visual size + 0.37 (the old
     14px halo folded in), so untouched pins feel identical — but Michael can now make a tap
     zone smaller than its icon. Taps on the circle BUBBLE to the .pin's own listeners.
     (This replaces the old ::after halo + world-view halo-shrink: the tap circle is one honest
     shape at every zoom — WYSIWYG with the Pin editor's TAP ZONES overlay.) */
  #lm-pins .pin { pointer-events:none; }
  #lm-pins .pin * { pointer-events:none; }
  #lm-pins .pin .pin-tap {
    pointer-events:auto;
    position:absolute; left:50%; top:50%;
    width:calc(var(--lm-box) * var(--tap-mul,1) * var(--lm-hi,1));
    height:calc(var(--lm-box) * var(--tap-mul,1) * var(--lm-hi,1));
    margin-left:calc(var(--lm-box) * var(--tap-mul,1) * var(--lm-hi,1) / -2);
    margin-top:calc(var(--lm-box) * var(--tap-mul,1) * var(--lm-hi,1) / -2);
    border-radius:50%;
  }
  #lm-pins .pin-icon { width:100%; height:100%; }
  #lm-pins .pin-thumb { max-width:100%; max-height:100%; }   /* icon fills the fixed box, aspect kept */
  @keyframes lmFloat {
    /* the animation owns `transform` while it runs, so the 1/--lm-hi scale must live in the
       frames too. translateY comes AFTER scale in the list = the bob stays a true 9px. */
    0%,100%{ transform:translateY(0) scale(calc(1 / var(--lm-hi,1))) }
    50%{ transform:translateY(-9px) scale(calc(1 / var(--lm-hi,1))) }
  }
  /* Europe dive-first (Michael 2026-07-17): at world view the Europe pile's pins are NOT
     tappable at all — the tap falls through to the map and lean-map dives into the continent.
     Leaned in (lean-world clears at scale 1.5) they become normal tappable pins. .pin-eu is
     tagged by buildLandmarkPins (src/landmarks.js) from the LEAN_HOTSPOTS eu box. */
  body.lean-world #lm-pins .pin.pin-eu { pointer-events:none; }
  /* the tap CIRCLE (2026-07-18 unlink) sets its own pointer-events:auto, which IGNORES the
     parent's none — without this line the Europe dive-first rule silently died the moment the
     tap zones were unlinked. The circle itself must go quiet at world view too. */
  body.lean-world #lm-pins .pin.pin-eu .pin-tap { pointer-events:none; }
  .pin-thumb {
    /* landmark thumbnail icons (assets/thumbnails/, 256px lossless WebP sources).
       Michael picked ~96px on-screen (2026-07-06 "middle" size) — clamp tops out there and the
       256px source keeps it crisp on 2x Retina. max-width guards the wide landscape icons so
       neither axis can exceed the picked size; the pin's translate(-50%,-50%) centers the icon
       on the landmark's map coordinate exactly like the emoji pins did. */
    /* --pin-size = per-landmark resting-size multiplier (Michael's Pin Layout editor →
       data/pin-layout.js, applied inline by buildLandmarkPins). The base is a PURE
       proportion of the map width (the map always fills the window), so the pin-to-map
       ratio is identical on every screen AND immune to browser zoom (Michael 2026-07-07 —
       the old rem clamp guardrails stretched with Cmd+plus while the map didn't).
       6.4vw ≈ the ~96px "middle" size Michael picked on his Mac. */
    /* --pin-hi (default 1) renders the icon this many times bigger; the Landmarks pin then scales
       itself DOWN by 1/--pin-hi (see the people-mode rule), so it LOOKS the same size but its texture
       is rasterized at --pin-hi× resolution — staying crisp when the deep-zoom scales it up. */
    max-width:calc(6.4vw * var(--pin-size,1) * var(--pin-hi,1));
    max-height:calc(6.4vw * var(--pin-size,1) * var(--pin-hi,1));
    width:auto; height:auto; display:block;
    filter:drop-shadow(2px 4px 6px rgba(0,0,0,.4));
    -webkit-user-drag:none; user-select:none;
  }
  .pin-label {
    background:white; color:#333;
    font-size:.7rem; font-weight:900;
    padding:3px 9px; border-radius:11px;
    box-shadow:0 2px 6px rgba(0,0,0,.3);
    letter-spacing:.5px;
  }
  /* ★ SIZE DIAL: the picture may now overflow its 110px pin box, and an overflowing child is
     still tappable — which is how a grown cow ended up stealing the snake's and the pig's taps
     so Michael couldn't open them at all. Taking the picture out of hit-testing leaves the pin's
     own fixed box as the only target, so every animal stays reachable at any size. */
  #pins .pin-icon{ pointer-events:none; }
  .pin-icon{ position:relative; display:flex; align-items:center; justify-content:center; }   /* tight wrapper around the static icon so the ring locks to the icon's centerpoint and bounces with it */
  /* Sonar rings live in a SEPARATE layer beneath the pin icons (Michael 2026-07-16: rings must sit
     BEHIND all pins, never over a neighbour's icon). #lm-rings is a sibling of #lm-pins inside
     #lean-stage at z 59 (< the pins' z 60), so every ring paints under every icon. Each .lm-ring is
     the SAME fixed-size box as its pin, at the same left/top, built in lockstep by
     buildLandmarkPins(); it rides the same lmFloat bob so it stays glued to the icon. */
  /* position:ABSOLUTE — identical anchoring to #lm-pins (the map, not the screen). A leftover
     position:fixed here silently overrode the map-anchoring rule above and put the RINGS in
     screen coordinates while the PINS were in map coordinates = the not-colocated bug on device. */
  #lm-rings{ position:absolute; inset:0; pointer-events:none; z-index:59; }
  /* The ring holder is the IDENTICAL fixed-size box as the pin, at the identical map point —
     same width/height, same px margins, same bob. The circle is flex-centered inside it.
     NO percent-of-own-size translates anywhere in this system (the iOS corner-bake bug). */
  #lm-rings .lm-ring{
    position:absolute;
    width:calc(var(--lm-box) * var(--pin-size,1) * var(--lm-hi,1));
    height:calc(var(--lm-box) * var(--pin-size,1) * var(--lm-hi,1));
    margin-left:calc(var(--lm-box) * var(--pin-size,1) * var(--lm-hi,1) / -2);
    margin-top:calc(var(--lm-box) * var(--pin-size,1) * var(--lm-hi,1) / -2);
    display:flex; align-items:center; justify-content:center;
    transform:scale(calc(1 / var(--lm-hi,1)));   /* identical oversize+scale-back as the pin */
    animation:lmFloat 3s ease-in-out infinite;
  }
  /* .pin-ring is SHARED: animal pins keep it INSIDE .pin-icon (original absolute-centered rule +
     translate ping, unchanged); the LANDMARK ring layer overrides it below with the fixed-box,
     no-percent-translate version. Editing the shared rule alone broke the ANIMAL rings (2026-07-16
     "pulses moved left") — keep the two scoped. */
  .pin-ring {
    position:absolute;
    width:calc(3.3vw * var(--pin-size,1)); height:calc(3.3vw * var(--pin-size,1));
    border-radius:50%;
    border:3px solid rgba(255,255,255,.8);
    top:50%; left:50%;
    transform:translate(-50%,-50%);
    animation:pingRing 2.4s ease-out infinite;
    pointer-events:none;
  }
  @keyframes pingRing {
    0%{transform:translate(-50%,-50%) scale(.5); opacity:.9}
    100%{transform:translate(-50%,-50%) scale(2.4); opacity:0}
  }
  /* LANDMARK ring circle: flex-centered in its fixed-size holder, ping only SCALES —
     no percent-of-own-size translate anywhere in the landmark system (iOS corner-bake bug). */
  #lm-rings .pin-ring {
    position:static; top:auto; left:auto;
    /* sized in the holder's PRE-SCALE space (×--lm-hi) so the visible circle stays 39px */
    width:calc(var(--lm-ring) * var(--pin-size,1) * var(--lm-hi,1));
    height:calc(var(--lm-ring) * var(--pin-size,1) * var(--lm-hi,1));
    border-width:calc(3px * var(--lm-hi,1));   /* pre-scale space: stays a visible 3px */
    transform:none;
    /* 3s period = LOCKED to the lmFloat bounce (Michael 2026-07-17, same as apinPing): every
       pulse is born at the bottom of its pin's dip. Visible expansion unchanged at 2.4s
       (0→80% of the cycle); the last 0.6s the ring stays invisible. SIZE untouched. */
    animation:lmPing 3s ease-out infinite;
  }
  @keyframes lmPing {
    0%{transform:scale(.5); opacity:.9}
    80%,100%{transform:scale(2.4); opacity:0}
  }

  /* FLYER */
  #flyer {
    position:fixed; display:none; z-index:82;
    line-height:1; transform:translate(-50%,-50%);
    cursor:pointer;
    filter:none;   /* GLOW/SHADOW PERF TEST 2026-07-19 (Michael): the flyer's drop-shadow was recomputed every frame as the animal moves/scales/swaps frames — a weak-iPad GPU cost. Removed to test first-play smoothness. Restore tag: anim-glow-shadow-pre-removal. Original: drop-shadow(4px 12px 18px rgba(0,0,0,.55)) */
    will-change:transform;   /* animal-perf Phase 1: keep the flyer on its own compositor layer (old-iPad jank) */
  }
  /* (2026-07-19 the gold language-glow was hidden here because its blurred halo was re-blurred
     every frame on the weak iPad and stuttered the animal's first play. 2026-07-28 it is BACK in
     a cheap form — see the #glow-svg rules further down: no blur at all, and one single fade
     instead of 30 separately-shimmering countries. The hide rule is gone; nothing to restore.) */
  /* On water, the foam ripples extend beyond the duck silhouette; drop-shadow would render shadow for
     the ripples too, painting a faint rectangular halo against the ocean. Suppress the shadow once the
     duck is on water (set by the engine entering swim phase). */
  #flyer.on-water { filter:none; }
  #flyer.wiggle { animation:wiggle .45s ease; }
  @keyframes wiggle {
    0%{transform:translate(-50%,-50%) rotate(0deg) scale(1)}
    30%{transform:translate(-50%,-50%) rotate(-4deg) scale(1.05)}
    60%{transform:translate(-50%,-50%) rotate(4deg) scale(1.03)}
    100%{transform:translate(-50%,-50%) rotate(0deg) scale(1)}
  }
  #flyer-hint {
    position:fixed; display:none; z-index:83;
    color:rgba(255,255,255,.78);
    font-size:.78rem; font-weight:700;
    text-align:center; pointer-events:none;
    text-shadow:1px 1px 3px rgba(0,0,0,.5);
    letter-spacing:.3px;
  }

  /* DOG RIG (reference style, alive) */
  .dogrig{display:block;overflow:visible}
  .dogrig .dpart{transform-box:fill-box}
  .dogrig .d-dog{transform-origin:50% 92%; animation:dogBreath 3s ease-in-out infinite}
  .dogrig .d-earL{transform-origin:70% 16%}
  .dogrig .d-earR{transform-origin:30% 16%}
  .dogrig .d-tail{transform-origin:8% 94%; animation:dogTail 1.9s ease-in-out infinite}
  .dogrig .d-eyeL,.dogrig .d-eyeR{transform-origin:50% 50%; animation:dogBlink 4.6s ease-in-out infinite}
  @keyframes dogBreath{0%,100%{transform:scale(1,1)}50%{transform:scale(1.02,0.985)}}
  @keyframes dogTail{0%,100%{transform:rotate(-5deg)}50%{transform:rotate(9deg)}}
  @keyframes dogBlink{0%,90%,100%{transform:scaleY(1)}94%{transform:scaleY(0.12)}}
  #pin-cow .pin-emoji{ font-size:calc(7.301rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* sized by Michael in the Animal Pin Placer, 2026-08-03 */
  #pin-frog .pin-emoji{ font-size:calc(5.528rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* size Michael set on the iPad, 2026-08-03 */
  #pin-lamb .pin-emoji{ font-size:calc(7.274rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* size Michael set on the iPad, 2026-08-03 */
  #pin-owl .pin-emoji{ font-size:calc(5.773rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* size Michael set on the iPad, 2026-08-03 */
  #pin-pig .pin-emoji{ font-size:calc(6.613rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* size Michael set on the iPad, 2026-08-03 */
  #pin-dog .pin-emoji{ font-size:calc(5.951rem * var(--a-hi,1) * var(--pin-grow,1)); }
  #pin-rooster .pin-emoji{ font-size:calc(5.456rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* rooster is tall+narrow; sized up so its pin's visual area matches the dog's */
  #pin-cat .pin-emoji{ font-size:calc(6.145rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* cat sized to ~match the other animals' map pins (its big em made the default rig read oversized) */
  #pin-lion .pin-emoji{ font-size:calc(7.141rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* lion sized up a touch so its map static reads larger (perch size is unaffected — that uses targetFs) */
  #pin-duck .pin-emoji{ font-size:calc(8.782rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* duck's em is small (small Atlantic perch + foam-margined frame), so its pin is enlarged here to read like the other animals' pins */
  #pin-snake .pin-emoji{ font-size:calc(6.426rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* snake's sprite canvas is wide (extended slither) so its coiled body only fills ~43% of it; pin enlarged so the visible body reads similar to the other pins */
  #pin-bee .pin-emoji{ font-size:calc(6.622rem * var(--a-hi,1) * var(--pin-grow,1)); }   /* bee map pin (static + hover) = bee's first-frame size; no hover scaling */
  #pin-bee .pin-emoji{ transform:scale(1); }   /* bee already sits at its hover/first-frame size (4.06rem) -- exclude it from the baked-in 1.28x scale. scale(1), NOT none: a transform (any transform) keeps the image in the same paint tier as the other pins, so the pulse ring stays BEHIND the bee like every other animal (transform:none put the ring on top). */

  /* CTRL PANEL */
  #ctrl-panel {
    position:fixed; left:0; right:0; bottom:0;
    padding:0 18px 26px;
    z-index:90;
    display:flex; flex-direction:column; align-items:center; gap:11px;
    opacity:0; transform:translateY(24px);
    /* ★ SHUT MEANS GONE, NOT JUST FADED (Michael 2026-08-01). Faded-to-nothing still costs the
       iPad a draw every frame — thirteen flags, the pill and the three keys, on every tab, for
       nobody. `visibility:hidden` stops that and puts the whole panel out of reach.
       The `0s linear .45s` is what keeps the closing animation: the panel stays on screen for
       the .45s it takes to slide away, then switches off in one step. (visibility:hidden, never
       display:none — a hidden element still has a size to measure, and the wheel's label widths
       and the pill's width are measured from these. display:none would measure zero.) */
    visibility:hidden;
    transition:opacity .4s ease, transform .45s cubic-bezier(.34,1.4,.5,1), visibility 0s linear .45s;
    pointer-events:none;
  }
  #ctrl-panel.open { opacity:1; transform:translateY(0); visibility:visible;
    transition:opacity .4s ease, transform .45s cubic-bezier(.34,1.4,.5,1), visibility 0s linear 0s; }
  /* only the interactive bits catch taps; empty space passes through to close */
  #ctrl-panel.open .panel-top, #ctrl-panel.open #subtitle-box,
  #ctrl-panel.open .speed-row, #ctrl-panel.open .action-row, #ctrl-panel.open .lang-slider { pointer-events:auto; }
  /* friendly pop-in when controls appear */
  /* ★ 2026-08-21 (Michael): THE HOUSEHOLD CARD WEARS THE SAME KEYS AS THE ANIMALS PANEL.
     Every rule from here to the end of the held-key block names #home-panel.open beside
     #ctrl-panel.open, so the two rows behave identically and can never drift apart. The Town
     card is a page of its own (town-options/town-live.html) and keeps a copy of these rules;
     change one, change both. */
  #ctrl-panel.open .act-btn, #home-panel.open .act-btn, #ctrl-panel.open .lang-btn { animation:popIn .42s cubic-bezier(.34,1.7,.5,1) backwards; }
  /* nth-of-type, not nth-child — the teal sound-it-out circle (a div) now sits first in the row */
  /* ★ THE BREATHING KEYS — Michael chose option 5 from the button-options page, 2026-07-30.
     The other half of the answer to a child not knowing these could be pressed. Once the animal
     has landed and the panel has settled, each key swells very slightly and settles again, over
     and over, one after another — the three are staggered so it reads as a small living ripple
     rather than three things twitching at once. Motion is the strongest attractor a small child
     has; a key that keeps quietly moving keeps saying "press me" long after a one-off gesture
     would have been missed.
     It LOOPS by design. Michael was told the trade — an attractor that never stops eventually
     gets filtered out by the eye, and it competes with the animal — and picked it anyway. Don't
     "fix" it into a one-shot without asking him.
     Cheap by construction: it moves ONLY scale, which the iPad hands to the graphics chip, so no
     part of the button is redrawn on any frame. That is exactly what the travelling shine he
     first asked about could not do, and why that one was turned down.
     It runs as a SECOND animation alongside popIn, so nothing is added at the landing frame (no
     JS, no timer — the browser already has the panel's `.open` and schedules it itself).
     ⚠️ breathe must NOT carry `backwards`: both animations drive transform, and a backwards fill
     would apply breathe's first frame from time zero and flatten popIn's scale-up. With no fill
     it contributes nothing until it starts, by which time popIn has long finished.
     Two delays per button: the first is popIn's stagger, the second starts the breathing. */
  #ctrl-panel.open .act-btn, #home-panel.open .act-btn { animation:popIn .42s cubic-bezier(.34,1.7,.5,1) backwards,
                                        breathe 10s ease-in-out infinite;
                              animation-delay:var(--d-pop), var(--d-breathe); }
  /* The two start times are kept in variables so the press rules below can repeat them EXACTLY.
     They must not be re-stated by hand there: a different delay would shift the breathing that is
     already running and make the key twitch under the finger. */
  #ctrl-panel.open .act-btn:nth-of-type(1), #home-panel.open .act-btn:nth-of-type(1) { --d-pop:.10s; --d-breathe:1.50s }
  #ctrl-panel.open .act-btn:nth-of-type(2), #home-panel.open .act-btn:nth-of-type(2) { --d-pop:.17s; --d-breathe:1.74s }
  #ctrl-panel.open .act-btn:nth-of-type(3), #home-panel.open .act-btn:nth-of-type(3) { --d-pop:.24s; --d-breathe:1.98s }
  /* Most of the cycle is perfectly still — the swell is a brief event inside a long pause, not a
     constant throb. A key that pulses without rest reads as an alarm.
     ★ Michael 2026-07-30: bigger swell, less often. Those two go together — a bigger movement
     seen as often as a small one reads as fidgeting, so the rest between had to grow with it. */
     ★ Michael 2026-07-31: the rest between swells grew again, from 4.2s to TEN seconds, when the
     held-key look below arrived. Two things now say "press me" — the glow on the one that is
     playing and the bounce on the two that are not — and a bounce arriving every four seconds
     alongside a lit key read as busy. The SWELL ITSELF IS UNCHANGED: still half a second up, six
     tenths back down. Only the pause grew, which is why the percentages moved with the duration
     (74/86 of 4.2s and 89/94 of 10s are the same number of milliseconds). If you ever change the
     10s, move those two numbers with it or the swell turns slow and floaty. */
  @keyframes breathe { 0%,89%,100%{transform:scale(1)} 94%{transform:scale(1.10)} }
  /* ★ THE PRESS — Michael 2026-07-30, second pass. Finger down: the key sinks onto its base and
     stays there. Finger up: it springs back THROUGH its normal size, overshoots, and settles.
     ⚠️ WHY THIS IS WRITTEN AS A THIRD ANIMATION AND NOT AS A PLAIN `transform`.
     The first version said `animation:none; transform:translateY(7px)` on the pressed key. That
     is what Michael saw as the key "disappearing entirely and then bouncing": switching the
     animations off and straight back on again the instant the finger lifted made the pop-in
     animation START OVER — and its first frame is invisible and shrunk to a third of size. The
     key really did vanish and re-inflate on every single press.
     A browser only restarts an animation when its POSITION in the list changes. So the press is
     APPENDED as a third entry, leaving pop-in and breathing untouched at positions one and two.
     Nothing restarts, nothing blinks, and the last entry in the list is the one that gets to move
     the key — which is exactly the override the press needs.
     The delays repeat the same two variables plus 0s for the press, so the breathing keeps its
     own place in its cycle rather than jumping when the finger lands. */
  #ctrl-panel.open .act-btn:active, #home-panel.open .act-btn:active {
    animation:popIn .42s cubic-bezier(.34,1.7,.5,1) backwards,
              breathe 10s ease-in-out infinite,
              pressDown .09s ease-out forwards;
    animation-delay:var(--d-pop), var(--d-breathe), 0s;
  }
  /* The release. `flash` is added by flashBtn() in ui.js the moment a button's job actually runs —
     i.e. on finger UP — and removed again when the animation ends, so no new JS was needed.
     (The plain `.act-btn.flash` brightness rule further down never applied inside this panel: it
     is weaker than the rule that starts the breathing, so it was already dead. This replaces it
     here rather than leaving two things claiming the same class.) */
  #ctrl-panel.open .act-btn.flash, #home-panel.open .act-btn.flash {
    animation:popIn .42s cubic-bezier(.34,1.7,.5,1) backwards,
              breathe 10s ease-in-out infinite,
              pressPop .32s cubic-bezier(.34,1.8,.5,1);
    animation-delay:var(--d-pop), var(--d-breathe), 0s;
  }
  /* ★ THE HELD KEY (Michael 2026-07-31, option 5 of the six on the button-stick page). Pressing
     Name, Sound or Sentence leaves that key PUSHED IN — sunk on its base, wearing a soft warm
     halo — until a different one is pressed. The child can always see which of the three they are
     hearing, which is the whole point; it is the app's first piece of "you are here".
     Three parts, and all three matter:
       • it stays SUNK — same landing the finger already produces, so nothing new has to be learnt
       • it GLOWS — a still, warm halo. Deliberately motionless: what pulls a small child's eye is
         movement, so a glow that sat there pulsing would compete with the animal for attention.
         Michael asked outright whether this costs anything; it does not. A shadow is not a
         picture — nothing is loaded, nothing is stored, and because it never changes the iPad
         draws it once when the key is pressed and once when it is released.
       • it STOPS BREATHING — a key being held must not also be waving to be pressed.
     ⚠️ WRITTEN AFTER `.flash` AND `:active` ON PURPOSE. Those three rules all weigh the same, so
     the LAST one in this file wins, and held has to be the winner: otherwise pressing the key
     that is already held would run the release spring and pop it back up out of its own hole.
     Re-pressing a held key gets its own movement instead — see `.held.flash` just below.
     ⚠️ The animation list is character-for-character the pressed list, for the reason spelled out
     above it: a browser restarts an animation whose POSITION in the list changes, and pop-in
     starts invisible. Keep pressDown third or the key blinks out on every press. */
  #ctrl-panel.open .act-btn.held, #home-panel.open .act-btn.held {
    animation:popIn .42s cubic-bezier(.34,1.7,.5,1) backwards,
              breathe 10s ease-in-out infinite,
              pressDown .09s ease-out forwards;
    animation-delay:var(--d-pop), var(--d-breathe), 0s;
    /* above its neighbours, so the halo spills over them instead of under them */
    z-index:2;
  }
  /* ★ THE RE-PRESS (Michael 2026-07-31). Pressing the key that is ALREADY held has to feel like
     something happened — it replays the line, so it must look like it replayed the line. His
     words: it should "animate down and then back up again, and stay in that depressed position."
     So the key dips deeper into its own hole, springs back up, and settles right back where it
     was. Nothing else in the row moves and the halo never blinks.
     ⚠️ THE RE-PRESS IS A FOURTH ENTRY, ADDED AFTER the sunk pose — it does NOT replace it. That
     is the whole trick, and it is the same restart trap the pressed key documents above: a
     browser restarts any animation whose POSITION in the list changes, and pop-in's first frame
     is invisible. Appending keeps positions 1-3 identical, so when `flash` is taken off again a
     moment later the sink is still running and still holding the key down — no flinch back up.
     Replacing entry 3 instead would make the key jump out of its hole for a tenth of a second
     every single time, which is exactly the flinch the pressed-key note fought before it.
     `repress` deliberately does NOT hold its last frame: it ends on the sunk pose, and the sink
     underneath it is already holding that same pose, so the hand-off is invisible. */
  #ctrl-panel.open .act-btn.held.flash, #home-panel.open .act-btn.held.flash {
    animation:popIn .42s cubic-bezier(.34,1.7,.5,1) backwards,
              breathe 10s ease-in-out infinite,
              pressDown .09s ease-out forwards,
              repress .40s cubic-bezier(.34,1.5,.5,1);
    animation-delay:var(--d-pop), var(--d-breathe), 0s, 0s;
  }
  /* Starts and ends on the held pose. The rise is smaller than a fresh press's spring on purpose:
     this key is not being released, it is being pressed again, so it must never look like it has
     let go and come back. Every step keeps the same down-and-left lean as the rest of the row. */
  @keyframes repress {
    0%  { transform:translate(-2px,5px) scale(.97) }
    20% { transform:translate(-3px,8px) scale(.93) }   /* deeper into its own hole */
    58% { transform:translate(0,-2px)   scale(1.06) }  /* back up, a little proud of level */
    82% { transform:translate(-2px,6px) scale(.96) }   /* drops back in, just past the seat */
    100%{ transform:translate(-2px,5px) scale(.97) }   /* settles exactly where it started */
  }
  /* The halo itself. Kept in one place for all three keys — only the solid edge underneath it is
     the key's own colour, which is what `--edge` is for (set beside each `--face` below). The
     white ring hugging the key separates it from the map; the wide gold bloom is the warmth. */
  #ctrl-panel.open .act-btn.held.btn-name, #home-panel.open .act-btn.held.btn-name,
  #ctrl-panel.open .act-btn.held.btn-sound, #home-panel.open .act-btn.held.btn-sound,
  #ctrl-panel.open .act-btn.held.btn-sentence, #home-panel.open .act-btn.held.btn-sentence {
    box-shadow:-.5px 1px 0 var(--edge),
               0 0 0 4px rgba(255,255,255,.35),
               0 0 22px 8px rgba(255,231,138,.55),
               inset 1px 2px 0 rgba(255,255,255,.55);
  }
  /* Sinks the full height of its own base, with a touch of squash so it reads as taking weight.
     ★ It travels DOWN-AND-LEFT (Michael 2026-07-30), because the base it lands on now sits down
     and to the left — see the angled-light note further down. A key that sank straight down while
     its shadow lay off to one side would look like it was missing its own base. */
  @keyframes pressDown { to { transform:translate(-2px,5px) scale(.97) } }
  /* Starts from where the finger left it, springs up past normal size, settles. Michael asked for
     the overshoot explicitly and said it can go deep. The spring goes back up the same diagonal. */
  @keyframes pressPop {
    0%  { transform:translate(-2px,5px) scale(.97) }
    42% { transform:translate(1px,-3px) scale(1.12) }
    72% { transform:translate(0,0) scale(.99) }
    100%{ transform:translate(0,0) scale(1) }
  }
  #ctrl-panel.open .lang-btn:nth-child(1){animation-delay:.30s}
  #ctrl-panel.open .lang-btn:nth-child(2){animation-delay:.36s}
  #ctrl-panel.open .lang-btn:nth-child(3){animation-delay:.42s}
  #ctrl-panel.open .lang-btn:nth-child(4){animation-delay:.48s}
  @keyframes popIn { 0%{opacity:0;transform:scale(.3) translateY(14px)} 100%{opacity:1;transform:scale(1) translateY(0)} }
  .panel-top { display:none; }
  #close-btn {
    background:#F3DCBB; border:none; color:#8A5A28;
    font-family:var(--font-read); font-size:.9rem; font-weight:400;
    padding:8px 16px; border-radius:22px; cursor:pointer;
    transition:transform .15s, background .2s; flex-shrink:0;
  }
  #close-btn:hover { background:#EBC999; transform:scale(1.05); }
  #close-btn:active { transform:scale(.95); }
  #panel-name {
    flex:1; text-align:center;
    font-family:var(--font-read);
    font-size:clamp(1.4rem,3.4vw,1.9rem);
    color:#fff; letter-spacing:1px;
    text-shadow:0 2px 8px rgba(0,0,0,.55);
  }

  /* SUBTITLE */
  #subtitle-box {
    background:#FFFFFF;
    border-radius:18px;
    padding:11px 18px;
    min-height:54px; text-align:center;
    box-shadow:0 8px 22px rgba(0,0,0,.28);
    max-width:min(560px,90vw);
    /* ★ THE PILL WAITS FOR THE LANGUAGE (Michael 2026-07-30). Since the flags make one full turn
       before the animal's name is spoken, the pill used to sit on screen for that whole turn —
       blank, and at its unlocked CSS size, snapping to the real size the moment words arrived.
       Now it starts tucked behind the Name/Sound/Sentence row and rises out of it as the words
       appear. It keeps its place in the layout the whole time (its size is locked at open), so
       nothing below it ever moves. z-index 0 puts it UNDER the buttons, which carry z-index 1. */
    position:relative; z-index:0;
    transition:opacity .30s ease, transform .45s cubic-bezier(0.65,0,0.35,1);
  }
  /* parked: invisible, slid down behind the button row, and deaf to taps (this selector has to
     out-rank "#ctrl-panel.open #subtitle-box{pointer-events:auto}" above, hence the #ctrl-panel) */
  #ctrl-panel #subtitle-box.pill-wait {
    opacity:0; transform:translateY(var(--pill-tuck,64px)); pointer-events:none;
  }
  #subtitle-main {
    font-size:clamp(.95rem,2.4vw,1.2rem);
    font-weight:800; color:#4A4036;
    line-height:1.45; min-height:1.45em;
    letter-spacing:.3px;
  }
  .syl {
    display:inline-block;
    transition:color .1s ease, transform .1s ease;
    white-space:nowrap;
  }
  .syl.lit {
    /* NO font-weight change here (was 900): bolder letters are WIDER letters, so the pill stretched
       and shrank on every syllable — the pulsing Michael flagged. The scale() grow is layout-free. */
    color:#FF3060;
    transform:scale(1.2);
    text-shadow:0 0 10px rgba(255,48,96,.4);
  }
  .ws { display:inline-block; width:.32em; }
  #subtitle-hint { font-size:.76rem; color:#B09877; margin-top:3px; }

  /* SPEED */
  .speed-row { display:flex; align-items:center; gap:8px; justify-content:center; }
  .speed-label { color:#fff; font-size:.82rem; font-weight:800; text-shadow:0 1px 4px rgba(0,0,0,.55); }
  .spd-btn {
    background:#FBEAD2;
    border:2.5px solid #F0D3A8;
    color:#8A5A2A;
    padding:5px 15px; border-radius:20px; cursor:pointer;
    font-family:var(--font-read); font-size:.82rem; font-weight:800;
    transition:transform .15s, background .2s, border-color .2s;
  }
  .spd-btn:hover { transform:scale(1.05); }
  .spd-btn:active { transform:scale(.95); }
  .spd-btn.active { background:#FFB84D; border-color:#F59500; color:#fff; }

  /* ACTIONS */
  .action-row { display:flex; gap:12px; flex-wrap:wrap; justify-content:center; position:relative; }
  /* keep the three buttons painting OVER the teal circle so it can hide behind Sentence */
  .action-row .act-btn { position:relative; z-index:1; }
  /* ★ THE TEAL SOUND-IT-OUT CIRCLE, ANIMALS EDITION (Michael 2026-07-30) — the postcard's button
     brought to the Animals panel. Same family look: teal fill, gold ring, the pill's brown
     letters, "abc" / wireframe-globe faces showing what a press GETS you. It parks hidden behind
     the Sentence button and steps sideways out from under it on the postcard's own slide curve;
     JS decides when each move happens and keeps it exactly the Sentence button's height. */
  /* ★ 2026-08-21 (Michael): the Household card wears the SAME circle, so #home-roman-btn is named
     beside #an-roman-btn on every rule below. The Town card is a page of its own and keeps a copy
     of these rules; change one, change both. */
  #an-roman-btn, #home-roman-btn{
    position:absolute; z-index:0;
    display:none;
    align-items:center; justify-content:center;
    box-sizing:border-box;
    background:#33B8AD;
    border:3px solid #e8b04a; border-radius:999px;
    /* leans down-and-left like every other button in this row (see the angled-light note below) —
       it stands beside the three keys, so a shadow pointing elsewhere would read as a second sun */
    box-shadow:-2px 5px 14px rgba(0,0,0,.22);
    font-family:var(--font-read); font-weight:700; font-size:23px;
    color:#4A3B2A; letter-spacing:.5px;
    cursor:pointer; -webkit-tap-highlight-color:transparent;
    transition:left .38s cubic-bezier(0.16,1,0.3,1), transform .18s ease-out;
  }
  #an-roman-btn.show, #home-roman-btn.show{ display:flex; }
  #an-roman-btn:active, #home-roman-btn:active{ transform:scale(.92); }
  #an-roman-btn > #an-roman-globe, #home-roman-btn > .roman-globe{ display:none; width:87%; height:87%; }
  /* "abc" hangs on the bowl of the b — the postcard's measured optical centring */
  #an-roman-btn > #an-roman-abc, #home-roman-btn > .roman-abc{ position:relative; top:-0.167em; }
  #an-roman-btn.globe > #an-roman-abc, #home-roman-btn.globe > .roman-abc{ display:none; }
  #an-roman-btn.globe > #an-roman-globe, #home-roman-btn.globe > .roman-globe{ display:block; }
  .act-btn {
    display:flex; flex-direction:column; align-items:center; gap:3px;
    border:none; border-radius:24px;
    padding:12px 16px; min-width:90px;
    font-family:var(--font-read);
    font-size:.84rem; font-weight:400;
    color:#fff; cursor:pointer;
    /* box-shadow rides the transition so the held key's halo FADES up and away instead of
       switching on like a light — the no-snap motion law applies to light as much as to movement */
    transition:transform .15s cubic-bezier(.34,1.6,.5,1), filter .15s, box-shadow .2s ease;
  }
  .act-btn .ico { font-size:1.95rem; line-height:1; filter:drop-shadow(0 2px 2px rgba(0,0,0,.18)); }
  .act-btn .ico img { width:32px; height:32px; display:block; }   /* icon image (replaces emoji) */
  .spd-ico { width:1.25em; height:1.25em; vertical-align:-0.28em; margin-right:3px; }
  .act-btn:hover { transform:translateY(-4px) scale(1.05) rotate(-1.5deg); filter:brightness(1.06); }
  /* Sinks by exactly the 7px the base loses, so the key bottoms out on its own edge instead of
     shrinking. No scale: a key that gets smaller reads as moving away, not as being pushed.
     Keep this number in step with the 8px edge above — if the edge changes and this does not,
     the key stops looking like it is landing on anything. */
  .act-btn:active { transform:translate(-2px,5px); }
  .act-btn.flash { animation:btnFlash .5s ease; }
  @keyframes btnFlash {
    0%,100%{filter:brightness(1)} 40%{filter:brightness(1.7)}
  }
  /* ★ CHUNKY KEYS (Michael 2026-07-30) — a child tested the app and did not read these as things
     you press. A soft coloured glow reads as decoration; THICKNESS reads as a physical button.
     So each one now stands on a solid darker edge (a hard, un-blurred shadow — no blur means no
     per-frame blur cost, and a shadow never affects layout, so the row does not move), with a
     light sheen inset along the top so the face reads as a rounded cap catching light.
     Pressing sinks the key onto its own base: the lift drops to 1px and the button moves down by
     the same amount, so the finger sees the key travel and bottom out. `filter` on :active would
     have been cheaper still, but it cannot move the key — and the movement IS the affordance. */
  /* ★ THE GLOSSY TOP (Michael 2026-07-30) — his words: it should look like it "has some kind of
     white on the top", a lit, domed surface. That was a single 2px white line before, which is
     just an edge; a real cap needs the light to FADE DOWN the face, brightest along the top and
     gone by about half way. That fade is what makes the eye read a curved plastic top instead of
     a flat coloured card — the same thing that makes a toy piano key or a board-game counter look
     touchable. It is drawn as an extra layer of the button's own colour fill, so it costs the
     same single paint the flat colour already cost.
     The key also stands taller now (8px of edge, deeper ground shadow) so it separates from the
     busy map behind it — Michael's "not quite prominent enough". */
  /* ★ THE LIGHT COMES IN ON AN ANGLE (Michael 2026-07-30) — his call, and he's right: a shadow
     dropped dead-straight below a thing is the one direction real light almost never gives you.
     Sunlight arrives from off to one side, so a real object's edge and its shadow both lean.
     Every chunky key throws its edge and its ground shadow DOWN AND SLIGHTLY LEFT, as if the light
     were coming over the child's right shoulder and a bit behind them.
     ★ THE ANGLE IS SOUTH-SOUTH-WEST, NOT SOUTH-WEST (Michael 2026-07-30). That is 22.5° off
     straight-down — a lean, not a diagonal. The first attempt used a true 45° and he rejected it
     by name: at 45° the shadow is as far sideways as it is down, which reads as a low evening sun
     and drags the eye off the button. So the sideways step is always a bit under HALF the drop
     (tan 22.5° ≈ 0.41):
       • the edge (the solid thickness it stands on)  2.5px left, 6px down
       • the ground shadow it casts                     4px left, 10px down
       • the gloss runs top-RIGHT to bottom-left (202°, i.e. the same 22° off straight down)
       • the white top-edge line shifts 1px right, the dark bottom-edge line 1px left
     Keep all of these leaning the SAME way and by the SAME amount — the moment one points
     somewhere else the eye reads two light sources and the row looks like flat stickers again.
     The press travels down this same lean (see @keyframes pressDown above); change one, change
     both. */
  .act-btn.btn-name, .act-btn.btn-sound, .act-btn.btn-sentence{
    background-image:linear-gradient(202deg, rgba(255,255,255,.62) 0%, rgba(255,255,255,.30) 30%,
                                             rgba(255,255,255,.05) 52%, rgba(255,255,255,0) 64%),
                     var(--face);
  }
  .btn-name     { --face:linear-gradient(202deg,#FF9EC4,#FF5C9A); --edge:#C43A6E;
                  box-shadow:-2.5px 6px 0 #C43A6E, -4px 10px 18px rgba(0,0,0,.30),
                             inset 1px 2px 0 rgba(255,255,255,.8), inset -1px -3px 0 rgba(0,0,0,.10); }
  .btn-sound    { --face:linear-gradient(202deg,#7BE8B0,#2FC98D); color:#06402B; --edge:#1E8F63;
                  box-shadow:-2.5px 6px 0 #1E8F63, -4px 10px 18px rgba(0,0,0,.30),
                             inset 1px 2px 0 rgba(255,255,255,.85), inset -1px -3px 0 rgba(0,0,0,.10); }
  .btn-sentence { --face:linear-gradient(202deg,#AAB7FF,#6E7CF0); --edge:#4A55B8;
                  box-shadow:-2.5px 6px 0 #4A55B8, -4px 10px 18px rgba(0,0,0,.30),
                             inset 1px 2px 0 rgba(255,255,255,.8), inset -1px -3px 0 rgba(0,0,0,.10); }
  /* Pressed: the edge all but disappears and the ground shadow tightens, so the key reads as
     bottomed out rather than hovering. Both keep the same lean. */
  .btn-name:active     { box-shadow:-.5px 1px 0 #C43A6E, -1px 2px 5px rgba(0,0,0,.30), inset 1px 2px 0 rgba(255,255,255,.55); }
  .btn-sound:active    { box-shadow:-.5px 1px 0 #1E8F63, -1px 2px 5px rgba(0,0,0,.30), inset 1px 2px 0 rgba(255,255,255,.6); }
  .btn-sentence:active { box-shadow:-.5px 1px 0 #4A55B8, -1px 2px 5px rgba(0,0,0,.30), inset 1px 2px 0 rgba(255,255,255,.55); }

  /* LANG */
  .lang-slider { position:relative; width:100%; max-width:680px; height:148px; margin:0 auto; overflow:hidden; touch-action:none; cursor:grab; }
  .lang-slider.grabbing { cursor:grabbing; }
  .lang-item { position:absolute; left:50%; top:50%; display:flex; flex-direction:column; align-items:center; gap:3px; width:60px; will-change:transform,opacity; }
  .lang-item .lf { font-size:7rem; line-height:1; filter:drop-shadow(0 2px 3px rgba(0,0,0,.3)); }
  .lang-item .lf .flag-img { width:96px; height:72px; border-radius:8px; }   /* real flag image (replaces 7rem emoji) */
  /* Every wheel label shares ONE fixed width — the widest language name, measured at boot
     (--wheel-ln-w, set in ui.js) — so "Portuguese" sits in the same box as "Thai" on every
     wheel (Animals, Town, Household). Measure-to-max rule, Michael 2026-07-28. */
  .lang-item .ln { font-size:.76rem; font-weight:800; color:#fff; text-shadow:0 1px 4px rgba(0,0,0,.6); white-space:nowrap; width:var(--wheel-ln-w,auto); text-align:center; }
  .lang-item.center .ln { color:#FFE08A; }

  /* ★ THE FLAG TURN (Michael 2026-07-30) — see the matching block in src/ui.js.
     The panel and the wheel appear exactly when they always did: at the landing, with `.open`.
     An earlier version brought the panel up early so the flags could spin through the whole
     fly-in; that made the arrival stutter on the iPads and Michael dropped it. Nothing here
     changes how an animal OPENS — the single turn happens afterwards.
     While the wheel is turning, a flag must not take a tap: a flag flying past is not a choice.
     ⚠️ That is enforced on each flag directly in renderSlider (ui.js), NOT here — the wheel writes
     each flag's tappability onto the element itself, and a value set on an element beats any rule
     in this file. A stylesheet rule alone did nothing. This line only covers the wheel's frame. */
  body.flag-spinning .lang-slider { pointer-events:none; }

  /* ===== MODE LAYERS (Animals / People / Explore) — smooth cross-fade ===== */
  /* Layers stay in the DOM and fade via opacity so tabs dissolve, not snap. */
  /* ★ 2026-07-24 HIDDEN TABS STOP PAINTING. Fading to opacity:0 does NOT stop a layer being
     rendered — the inactive tab kept drawing its pins, their bounce and their pulse rings behind
     whatever you were looking at, forever. `visibility` rides the SAME transition so it flips to
     hidden only AFTER the fade lands (and back to visible INSTANTLY on the way in) — the dissolve
     is untouched, no snap. This is the identical idiom #explore-stage already uses (~line 40),
     copied rather than reinvented.
     Deliberately visibility, NOT display:none: visibility keeps the layout built, so switching
     back costs nothing. display:none would throw the layout away and reintroduce the first-paint
     stall the Town/Explore pre-paint tricks exist to avoid. */
  .mode-overlay {
    opacity:0; pointer-events:none;
    visibility:hidden;
    transition:opacity 2.363s cubic-bezier(.65,0,.35,1), visibility 2.363s;
  }
  /* ★ Hamburger pre-paint (TEST 124): while the menu is open, the town renders for REAL at 0.5%
     opacity — invisible to the eye but the browser does its one-time paint work, so tapping Town
     opens with zero first-paint stall. Class managed by ui.js (endTownPrepaint). */
  /* ★ 2026-07-24 COMPANION to the visibility rule above: the pre-paint's whole job is to make the
     browser REALLY draw Town while it's invisible-to-the-eye, so visibility MUST be forced back on
     here or the new hidden-tab rule would cancel the pre-paint and hand back the first-tap stall.
     Same shape as the explore-prepaint rule at ~line 47. */
  body.town-prepaint:not([data-mode="town"]) #mode-town { opacity:0.005; pointer-events:none; visibility:visible; transition:none; }  /* snap, don't fade — 0.005 must be reached instantly for the pre-paint to work */

  body[data-mode="animals"] #mode-animals,
  body[data-mode="people"]  #mode-people,
  body[data-mode="explore"] #mode-explore,
  body[data-mode="home"]    #mode-home,
  body[data-mode="town"]    #mode-town {
    opacity:1; pointer-events:auto; visibility:visible;
  }

  /* ★ 2026-08-25 HIDDEN TABS STOP ANIMATING (Michael: "they need to be paused completely").
     The visibility rule above stops a hidden tab PAINTING, but visibility:hidden does NOT pause
     CSS animations — every hidden tab's infinite loops (pin bobs, sonar rings, the language glow's
     shimmer, per-animal pin rigs: ~145 of them) kept running on the main thread forever, ticking
     style recalc every frame behind whatever was on screen. Paused costs zero and resumes exactly
     where it left off the instant the tab returns.
     NOT during a tab change: body.tab-switching is on for the whole 2.363s cross-fade (switchPanes
     in ui.js owns the class and always clears it), so a pane the eye can still see mid-fade keeps
     moving — nothing freezes on screen (motion-feel LAW). The pre-paint windows (town-prepaint /
     explore-prepaint) are unaffected: a paused element still PAINTS, and paint is all they exist
     to warm. WAAPI animations (element.animate — sparkles, card flights) ignore this property, so
     none of those are touched. The Town's inner page is an iframe with its own sleep system. */
  body:not(.tab-switching):not([data-mode="animals"]) #mode-animals *,
  body:not(.tab-switching):not([data-mode="people"])  #mode-people *,
  body:not(.tab-switching):not([data-mode="home"])    #mode-home *,
  body:not(.tab-switching):not([data-mode="town"])    #mode-town *,
  body:not(.tab-switching):not([data-mode="explore"]) #explore-stage * {
    animation-play-state:paused !important;
  }

  .mode-svg {
    opacity:0; pointer-events:none;
    transition:opacity 2.363s cubic-bezier(.65,0,.35,1);
  }
  body[data-mode="people"]  #svg-people,
  body[data-mode="explore"] #svg-explore {
    opacity:1; pointer-events:auto;
  }

  /* ★ MUTUALLY-EXCLUSIVE TAB ASSETS (Michael 2026-07-09). The inactive `.mode-overlay` is
     pointer-events:none, but `#pins .pin` / `#lm-pins .pin` re-enable pointer-events on the pins
     THEMSELVES — and a child re-enabling pointer-events overrides the inactive parent's `none`.
     Result: invisible animal pins stayed CLICKABLE under Landmarks (and vice-versa), and a stray
     tap on a hidden pin fired the wrong tab AND landed a ghost click that closed open postcards
     ("card reaches center then instantly vanishes"). Gate every tab's interactive assets to its
     own mode so only the ACTIVE tab is ever clickable. Higher specificity than the `.pin` rule.
     ★ 2026-07-18 TAP-CIRCLE COMPANION: the tap-zone unlink (af58a2cb) moved the sole tappable
     element to an invisible `.pin-tap` circle CHILD that sets its own `pointer-events:auto` — which
     overrides the ancestor `none` above (exactly the trap the pin-eu rule at line ~304 already
     patches). Without a `.pin-tap`-scoped companion line, the built-once landmark tap circles kept
     stealing taps on the Animals tab after Landmarks was visited (invisible pins opened postcards).
     So gate `.pin-tap` per-mode too. (Animal pins have no `.pin-tap` child today; the line is a
     harmless no-op there and future-proofs them if they ever grow one.) */
  body:not([data-mode="animals"]) #pins .pin,
  body:not([data-mode="animals"]) #pins .pin .pin-tap    { pointer-events:none; }
  body:not([data-mode="people"])  #lm-pins .pin,
  body:not([data-mode="people"])  #lm-pins .pin .pin-tap { pointer-events:none; }
  /* The flyer (a traveling animal) must never appear over another tab. `!important` beats the inline
     `display:block` that closeAnimal sets during travel — closeAnimal only hides it when currentAnimal
     is set, and the travel-only duck can leave it flying over Landmarks (tap-through closed cards). */
  body:not([data-mode="animals"]) #flyer        { display:none !important; }
  body:not([data-mode="animals"]) #atlas-canvas { display:none !important; }   /* GPU sprite layer follows the flyer's mode rule (idle lives on it now — TEST 256) */

  /* "coming soon" card for People / Explore */
  .mode-stub {
    position:fixed; top:50%; left:50%;
    transform:translate(-50%,-50%);
    z-index:30; text-align:center;
    background:rgba(255,255,255,.92);
    border:3px solid #B8941E; border-radius:24px;
    padding:26px 34px; max-width:80vw;
    box-shadow:0 10px 30px rgba(0,0,0,.35);
  }
  .stub-emoji { font-size:3.4rem; line-height:1; }
  .stub-title { font-family:var(--font-read); font-size:1.7rem; color:#7A4A0A; margin-top:6px; }
  .stub-sub   { font-weight:700; color:#5a4a2a; margin-top:8px; line-height:1.4; }

  /* ===== HAMBURGER MENU ===== */
  /* ★ MAP-ANCHORED HAMBURGER (Michael 2026-07-24, placed with scripts/hamburger_placer.py).
     The button lives INSIDE #app-canvas and is positioned as a % of the 1194x834 logical
     canvas — the same coordinate system the animal pins use — so it lands on the same map
     spot on every tablet shape instead of drifting with the screen corner. Its % is the
     button's CENTER (hence the translate(-50%,-50%)), exactly like a pin. */
  /* ★ BIGGER + CHUNKY (Michael 2026-07-30) — the same treatment the Name/Sound/Sentence keys got:
     a child did not read a flat tile as a thing you press. The button is 76px instead of 68px
     (first tried at 84px, which Michael cut back), stands on a solid un-blurred edge, and has the
     light fading down its face so it reads as a rounded cap catching light. The BUTTON is now only
     a positioner + hit area; every visual lives on .mb-cap inside it, so the attract-spin (which
     turns the cap) can never fight the button's own position transform or the slide-off when the
     menu opens.
     ★ NUDGED 10px RIGHT + 7px DOWN (Michael 2026-07-30) — written as a share of the canvas, NOT as
     pixels, because pixels would land somewhere different on every iPad shape. The stage is always
     1194x834 logical units no matter the screen, so 10 of those across = 10/1194 = 0.84%, and 7
     down = 7/834 = 0.84%. Added to the old 10.2% / 9.4% that gives 11.04% / 10.24% — the same map
     spot on every tablet. (Same trick the placer tool uses; keep any future nudge in these units.) */
  #menu-btn {
    position:absolute; left:11.04%; top:10.24%; z-index:999;
    width:76px; height:76px; cursor:pointer;
    background:none; border:none; padding:0;
    display:flex; align-items:center; justify-content:center;
    overflow:visible;   /* let the ::before tap-target (below) extend the hit area past the button */
    /* same curve + duration as #menu-drawer so the two motions read as one gesture */
    transform:translate(-50%,-50%);
    transition:transform .3s ease;
  }
  .mb-cap {
    width:100%; height:100%; border-radius:22px;
    border:3px solid #8B5A12;
    display:flex; flex-direction:column; align-items:center; justify-content:center; gap:7px;
    /* the lit top: one extra layer of the button's own colour fill, brightest along the top edge
       and gone by about half way down — the thing that makes the eye read a domed plastic cap. */
    background-image:linear-gradient(202deg, rgba(255,255,255,.72) 0%, rgba(255,255,255,.34) 30%,
                                             rgba(255,255,255,.06) 52%, rgba(255,255,255,0) 64%),
                     linear-gradient(202deg,#FFF6D4,#FFDD8E);
    /* The solid edge it stands on, then the soft ground shadow — both leaning south-south-west
       like the action keys (see the angled-light note above them). No blur on the edge means no
       per-frame blur cost, and a shadow never moves layout.
       ★ SHALLOWER THAN THE ACTION KEYS ON PURPOSE (Michael 2026-07-30): 3.5px of edge against
       their 6px. It was as deep as they are and he wanted it "not so recessed" and "a little bit
       different from the other buttons" — it isn't one of the three teaching keys, it's the way
       out of the room, so it reads as its own kind of thing. The lean is identical, only the
       depth differs; the light must still come from the same place. */
    box-shadow:-1.5px 3.5px 0 #A9701A, -3px 7px 14px rgba(0,0,0,.26),
               inset 1px 2px 0 rgba(255,255,255,.85), inset -1px -2px 0 rgba(0,0,0,.08);
    /* lets the spin unwind smoothly if a finger interrupts it, instead of snapping back */
    transition:transform .25s ease, box-shadow .12s ease;
  }
  #menu-btn:hover { transform:translate(-50%,-50%) scale(1.08); }
  /* Pressed: bottoms out on its own base by travelling down the same lean the edge follows — keep
     these numbers in step with the shallower edge above (3.5px of edge → a 3px sink). */
  #menu-btn:active .mb-cap {
    transform:translate(-1px,3px);
    box-shadow:-.5px 1px 0 #A9701A, -1px 2px 5px rgba(0,0,0,.26), inset 1px 2px 0 rgba(255,255,255,.6);
  }
  /* Menu open → the button slides off the left edge as the drawer slides in; closing the
     drawer slides it right back to its spot (the same transition, played in reverse).
     -420px is well past the canvas's left edge, which overflow:hidden already clips. */
  body.menu-open #menu-btn { transform:translate(-50%,-50%) translateX(-420px); }
  .mb-cap span { display:block; width:40px; height:5.5px; background:#7A4A0A; border-radius:4px; }
  /* ★ THE ATTRACT SPIN (Michael 2026-07-30) — when the app has been left alone, the button gives a
     quick nudge anticlockwise and then rolls ONE full turn clockwise back to home.
     • The nudge is 27° and takes about a seventh of the move (Michael 2026-07-30, cut twice: from
       90° to 27°, then from a slow wind-up to this fast one). It is a flick of the wrist before
       the throw, not a turn of its own.
     • ONE turn, not two (Michael 2026-07-30) — two read as a performance rather than a hint.
     • 387° of clockwise travel from the nudge = the full turn PLUS the 27° it borrowed, which is
       why it lands square instead of leaning.
     • ONE UNBROKEN MOVE, no pause: it used to hold still at the top of the nudge, which read as
       go-stop-go. Every step hands its speed to the next — it flicks back and arrives at a
       standstill for an instant, sweeps away the other way gathering speed, and glides into home.
       That instant of stillness is a change of direction, not a stop, because the speed arrives at
       zero and leaves it smoothly instead of being switched off.
     It turns only `transform`, which the iPad hands straight to the graphics chip — nothing
     repaints for any of it. */
  /* ★ IT CROUCHES BEFORE IT GOES (Michael 2026-07-30). The flick back SHRINKS the button 10%, and
     it swells back out as the turn takes off — so the move reads as gathering itself up and then
     launching, the way a cat dips before it jumps.

     ★★ WHY THIS IS WRITTEN AS TWENTY-SIX STEPS AND NOT FOUR (Michael 2026-07-30: "can't you just
     make it less jerky"). It WAS four steps, each with its own hand-picked speed curve. That is
     what made it jerky, and no amount of re-picking those four curves could have fixed it: where
     two curves meet, one is finishing at one speed and the next is starting at another, and the
     eye sees the mismatch as a flinch. Four joins, four flinches.
     So the motion is no longer described in curve-pieces at all. It comes from ONE formula,
     measured out at twenty-six evenly spaced moments, with plain even movement in between — at
     4% apart the steps are far finer than the eye resolves, so what you see is the formula itself.
     The formula was chosen so the button never changes speed abruptly ANYWHERE:
       • the turn follows a smootherstep — it starts and finishes with no speed AND no sudden
         push, so it doesn't lurch off the line or slam into the end;
       • the flick back is a raised sine hump, which arrives and leaves with no speed and no
         sudden push either, so handing over from flick to turn is seamless rather than a
         changing-of-the-guard;
       • the size follows the SPEED — smallest at the crouch where it's barely moving, fullest
         where it's flying, back to normal as it settles. Tying it to the speed is why the squash
         and the spin read as one thing instead of two effects running at once.
     ⚠️ Do NOT "tidy" this back into a handful of keyframes with eased curves between them; that
     is precisely the version Michael rejected. To retune it, change the numbers at the top of
     `_spin_curve/attract_curve.py` and paste its output in — never hand-edit the steps.
     Total travel is 387°: the full turn plus the 27° the flick borrowed, which is why it lands
     square instead of leaning. It moves only `transform`, which the iPad hands straight to the
     graphics chip — nothing repaints for any of it. */
  @keyframes mbAttract {
    0%   { transform:rotate(0.0deg)    scale(1.000) }
    4%   { transform:rotate(-0.8deg)   scale(1.002) }
    8%   { transform:rotate(-9.3deg)   scale(0.990) }
    12%  { transform:rotate(-24.0deg)  scale(0.934) }
    16%  { transform:rotate(-23.5deg)  scale(0.923) }
    20%  { transform:rotate(0.8deg)    scale(0.988) }
    24%  { transform:rotate(29.3deg)   scale(1.024) }
    28%  { transform:rotate(49.5deg)   scale(1.026) }
    32%  { transform:rotate(68.6deg)   scale(1.030) }
    36%  { transform:rotate(90.3deg)   scale(1.034) }
    40%  { transform:rotate(114.3deg)  scale(1.036) }
    44%  { transform:rotate(139.9deg)  scale(1.038) }
    48%  { transform:rotate(166.5deg)  scale(1.039) }
    52%  { transform:rotate(193.5deg)  scale(1.039) }
    56%  { transform:rotate(220.1deg)  scale(1.038) }
    60%  { transform:rotate(245.7deg)  scale(1.036) }
    64%  { transform:rotate(269.7deg)  scale(1.034) }
    68%  { transform:rotate(291.4deg)  scale(1.030) }
    72%  { transform:rotate(310.4deg)  scale(1.026) }
    76%  { transform:rotate(326.4deg)  scale(1.021) }
    80%  { transform:rotate(339.1deg)  scale(1.016) }
    84%  { transform:rotate(348.6deg)  scale(1.011) }
    88%  { transform:rotate(354.8deg)  scale(1.007) }
    92%  { transform:rotate(358.4deg)  scale(1.003) }
    96%  { transform:rotate(359.8deg)  scale(1.001) }
    100% { transform:rotate(360.0deg)  scale(1.000) }
  }
  /* `linear` is REQUIRED, not a default left in place: without it the browser eases between every
     one of the twenty-six steps, which puts twenty-five little speed-ups and slow-downs back into
     a curve that was built to have none. The smoothness lives in the numbers, not in the easing. */
  #menu-btn.attract .mb-cap { animation:mbAttract 1.05s linear both; }
  /* ★ BIGGER TAP TARGET than the visual button (Michael-tuned 18px, 2026-07-25). A transparent
     ::before extends the hit area 18px past the visible button (76px button → 112x112 hotspot).
     It's part of #menu-btn (z999) so it always sits ABOVE #map-dim (z40) — a near-miss tap lands
     on the button, not the dim layer. Visual button size is unchanged. */
  #menu-btn::before { content:''; position:absolute; inset:-18px; border-radius:36px; }
  /* ★ MENU ABOVE THE POSTCARD (Michael 2026-07-26): scrim + drawer moved 55/58 → 87/88 so the
     menu slides in ABOVE an open postcard (z86) instead of hiding underneath it. Still below the
     z90 bottom panels, so nothing else re-stacks. Goes with the overlay's hamburger pass-through
     (postcardOverlayTap in landmarks.js). */
  #menu-scrim {
    position:fixed; inset:0; z-index:87; background:rgba(8,18,48,.45);
    opacity:0; pointer-events:none; transition:opacity .3s;
  }
  body.menu-open #menu-scrim { opacity:1; pointer-events:auto; }
  #menu-drawer {
    position:fixed; top:0; left:0; bottom:0; z-index:88;
    width:250px; max-width:78vw;
    /* ★ SCALLOPS (Michael's pick 2026-08-01, from the 11-option audition in _review/menu_patterns.html).
       The drawer used to be one flat beige fading down; it now wears overlapping arcs, like fish
       scales or a row of little clouds. Soft and sweet without competing with the coloured keys.
       ⚠️ THIS COSTS NOTHING AND MUST STAY THAT WAY. There is no picture file — the browser draws
       the arcs itself from the two lines below, so nothing is downloaded and nothing is held in
       memory. It is painted ONCE when the drawer opens and never repaints, because nothing here
       moves. If anyone ever wants this pattern to drift or shimmer, that is a different decision
       with a real cost: it would repaint the whole panel every frame while the menu is open.
       The two arc rows are the SAME arc offset by half a tile (14px across, 8px down) — that
       half-step is what makes them interlock instead of lining up in stripes. The 12px/14px pair
       is the arc's inner and outer edge, i.e. a 2px line. Change one, change the other. */
    background:
      radial-gradient(circle at 50% 100%, transparent 12px, rgba(255,255,255,.65) 12px 14px, transparent 14px) 0 0/28px 16px,
      radial-gradient(circle at 50% 100%, transparent 12px, rgba(255,255,255,.65) 12px 14px, transparent 14px) 14px 8px/28px 16px,
      linear-gradient(180deg,#FFF6DC,#FFE2A8);
    border-right:3px solid #8B5A12; box-shadow:4px 0 18px rgba(0,0,0,.35);
    transform:translateX(-105%); transition:transform .3s ease;
    padding:12px 12px 14px; display:flex; flex-direction:column; gap:8px;
  }
  /* ⚠️ THE HEIGHT BUDGET. The base under each key is now REAL SPACE (it used to be a shadow, which
     costs nothing), so every row is 8px taller than its face: 44 + 8 = 52. Ten of those, plus the
     gaps, the title, the two dividers and this padding, come to about 695px — and they have to fit
     the SHORTEST iPad we ship to (the 6th-gen, 768 tall) without the drawer ever scrolling. That
     sum is why the face is 44px and the gap is 8. ★ If a row is ever added to this menu, redo that
     sum FIRST — it is the constraint that decides every spacing number in here. */
  body.menu-open #menu-drawer { transform:translateX(0); }
  .menu-head { font-family:var(--font-display); /* ★ EXCEPTION 2 of 2: the AnimaLingo wordmark */ font-size:1.3rem; color:#7A4A0A; text-align:center; padding:6px 0 8px; }
  /* ★ THE MENU ROWS ARE CHUNKY KEYS TOO (Michael 2026-07-31). They were words on a see-through
     strip — the same thing a child failed to read as pressable on Name/Sound/Sentence. So they now
     borrow that already-approved look exactly, at menu scale: each row stands on a solid un-blurred
     edge, has white light fading down the top half of its face, and sinks onto its own edge when
     pressed, springing back a touch past size on release. Nothing repaints per frame — the light is
     one more layer of a colour fill that was already being painted, and the edge is a shadow with
     no blur (a blurred shadow is the expensive kind, and a shadow never moves layout).
     The lean is the SAME south-south-west as every other button in the app (edge 2.5px left / 6px
     down, gloss at 202°). One light source for the whole app; the moment a shadow points somewhere
     else the eye reads two suns and everything flattens back into stickers.
     ★ EACH ROW HAS ITS OWN COLOUR, on purpose: these are pre-readers, and colour is how they find
     Animals again without reading the word. This does NOT reopen the 2026-07-30 "every row looks
     identical" ruling below — that ruling is about HIERARCHY (nothing faded, shrunk or badged), and
     it still holds: every row here is the same size, same font, same full-strength brightness.
     ★ NO BREATHING. The action keys swell because there are three of them and they are the point of
     the screen; ten rows all pulsing is fidgeting, which Michael rejected by name ("constant jiggle
     on all three"). The drawer is already a deliberate destination — it does not need an attractor. */
  /* ★ THE BASE IS A REAL, SEPARATE PIECE (Michael 2026-07-31, third pass — and this is the one that
     was actually wrong the first two times). Both earlier goes drew the thickness as a SHADOW on the
     key itself. A shadow is stuck to the thing that casts it, so the only way to make the key look
     pressed was to move the key and shrink the shadow to nothing at the same time — which is exactly
     what he saw: "the whole button is moving, then the shadow disappears, which is ugly."
     You cannot fix that by picking better numbers. A key is TWO pieces of plastic: a base sitting on
     the surface that never moves, and a face on top of it that travels. So it is built that way now.
       • `.mi-slot` is the BASE — the coloured slab. It never moves, and it is what the drawer's
         layout measures, so nothing around it can ever shift.
       • `.menu-item` is the FACE, sitting on top, and it is the only thing that travels.
     At rest the face sits high and to the right, leaving 8px of base showing below it and 3px to its
     left — the same south-south-west lean as every button in the app. Pressed, the face slides down
     into exactly that gap and covers the base. The base is still fully drawn underneath the whole
     time; there is simply less of it in view. Nothing shrinks and nothing vanishes.
     ⚠️ The 8px/3px offset appears in three places that must agree: the slot's padding (which is what
     makes the base visible at all), and the press and you-are-here transforms further down. Change
     one and the face either stops covering the base or slides past it. */
  /* ★ THE DARK PIECE IS ONLY EVER UNDERNEATH — Michael 2026-07-31, and this is absolute: never
     above the key, never to the left of it, never to the right. Earlier rounds put a rim on those
     three sides (a "cradle"), and he ruled it out by name. The dark piece is a SHELF the key stands
     on, nothing more.
       • `.mi-slot` is the shelf. It never moves and never changes size, so the drawer's layout is
         fixed no matter what any key is doing.
       • `.menu-item` is the face. It is the only thing that travels, and it covers the shelf's full
         width — so the shelf is invisible except for the 10px of it standing below the key.
     Pressed, the face drops 8px down the shelf, leaving 2px of it still showing beneath: enough to
     read as a key sat down on something, never enough to look like it fell through.
     ⚠️ The travel must stay SHORTER than the shelf, and the face must stay full width. The moment
     the face is inset on any side, dark appears there — which is the thing he ruled out. */
  /* ★ NOTHING ABOVE A PRESSED KEY — Michael 2026-07-31, fourth pass, and this is the thing the
     previous three all still got wrong. The shelf used to be painted as ONE full pill behind the
     key, taller than it by 10px. At rest the face covered all of it bar the 10px sticking out
     below — which he likes. But the moment the face slid 8px down, it uncovered the top 8px of
     that same pill, and a dark strip appeared ABOVE the key. Nothing was created by the press; it
     was always there, hiding under the key. He read it as dark on the wrong side, which it is.
     So the shelf no longer starts at the top of the row. It starts exactly 8px down — the same 8px
     the face travels — so a pressed face lands with its top edge flush on the shelf's top edge and
     there is simply no shelf above it to reveal. Empty drawer above, key, then 2px of shelf below.
     ⚠️ The `top:8px` here and the 8px press travel further down are the SAME number and must move
     together. Bigger travel than that and dark reappears above the key — the one thing ruled out.
     The shelf is a pseudo-element so the slot itself can stay the untouched, never-moving,
     never-resizing box the drawer's layout measures. */
  .mi-slot{
    position:relative;
    display:block; border-radius:999px;
    padding:0 0 10px;
  }
  .mi-slot::before{
    content:''; position:absolute; left:0; right:0; top:8px; bottom:0;
    border-radius:999px;
    background:var(--edge,#C69218);
    box-shadow:-4px 11px 15px rgba(0,0,0,.26);
  }
  .menu-item {
    /* Positioned so the face paints ON TOP of the shelf pseudo-element behind it. */
    position:relative;
    display:flex; align-items:center; gap:10px; width:100%;
    font-family:var(--font-read); font-weight:800; font-size:1.05rem; color:#4A2E06;
    border:none; cursor:pointer; text-align:left;
    /* ★ ROUND ENDS (Michael's pick 2026-07-31, options C+D) — fully round reads as a sweet rather
       than a card, and the base showing underneath is what carries that across the room. */
    padding:9px 14px; border-radius:999px;
    background-image:linear-gradient(202deg, rgba(255,255,255,.62) 0%, rgba(255,255,255,.30) 30%,
                                             rgba(255,255,255,.05) 52%, rgba(255,255,255,0) 64%),
                     var(--face,linear-gradient(202deg,#FFE9AE,#FFD070));
    box-shadow:inset 1px 3px 0 rgba(255,255,255,.85), inset -1px -4px 0 rgba(0,0,0,.10);
    /* Springy on the way down AND on the way back, so the release overshoots slightly and settles —
       the same feel as the action keys, with no JS behind it. */
    transition:transform .16s cubic-bezier(.34,1.8,.5,1), box-shadow .16s ease;
    /* ★ EVERY ROW THE SAME HEIGHT (Michael 2026-07-30). Pinned here rather than left to each
       row's contents, because an icon that is a drawn shape produces a shorter box than an emoji
       character does — that is what made Passport the one row out of ten that didn't line up.
       Fixing it on the row means any future icon, of any kind, lands at the same height. */
    min-height:44px;
  }
  .menu-item:hover:not(:disabled) { filter:brightness(1.06); }
  /* THE PRESS — the face slides down into its base and covers it. Only the face moves; the base and
     the shadow it casts on the drawer are on the slot and stay exactly where they were. Nothing
     shrinks, nothing disappears, and the row below is never crowded because the slot's size never
     changes. `.mi-held` is the same position kept on by hand across the gap between the finger
     lifting and the tap completing — see the note in ui.js. It must match :active exactly, or the
     key twitches at the moment it is meant to look steady. */
  /* ★ A PRESSED KEY HAS NO SHADOW ANYWHERE — Michael 2026-07-31, and he means ANYWHERE, including
     the dark shading that was being drawn INSIDE the top of the face. It is down, it is flat, it is
     just the colour. Every dark line comes off. Never put shading back on a pressed key. */
  .menu-item:active, .menu-item.mi-held {
    transform:translateY(8px);
    box-shadow:none;
  }
  /* ★ THE PICTURE SITS ON ITS OWN WHITE CHIP (Michael's pick 2026-07-31, option C). A coloured key
     with a real little button on it — that is what makes it read as a toy rather than a label. The
     chip stands slightly proud on the same south-south-west lean as everything else. */
  .menu-item .mi-emoji {
    width:32px; height:32px; flex:0 0 32px; border-radius:50%;
    background:rgba(255,255,255,.92);
    box-shadow:-1px 2px 0 rgba(0,0,0,.16), inset 0 -2px 0 rgba(0,0,0,.06);
  }
  /* ★ ONE COLOUR PER ROW — a child who cannot read finds Animals by its colour, every time, in the
     same place. Each pair is a light face and the darker version of itself for the edge it stands
     on, so the thickness always looks like the same piece of plastic seen from the side. */
  .mi-animals   { --face:linear-gradient(202deg,#FFC69B,#FF9A54); --edge:#C96A24 }
  .mi-countries { --face:linear-gradient(202deg,#FFA9B8,#F76F87); --edge:#C2445C }
  .mi-landmarks { --face:linear-gradient(202deg,#A9C8FF,#6F9BF0); --edge:#4569B8 }
  .mi-household { --face:linear-gradient(202deg,#9CE9BE,#4FCE95); --edge:#2A9469 }
  .mi-town      { --face:linear-gradient(202deg,#CDB4FF,#9C7BF0); --edge:#6B4FBA }
  .mi-passport  { --face:linear-gradient(202deg,#FFE08A,#FFC53D); --edge:#C99514 }
  .mi-game      { --face:linear-gradient(202deg,#9FE6E4,#4FC9C6); --edge:#2A918F }
  .mi-settings  { --face:linear-gradient(202deg,#E2D9C6,#C3B79C); --edge:#8E8266 }
  .mi-about     { --face:linear-gradient(202deg,#DCE4F0,#B8C6DC); --edge:#8494AC }
  .mi-parents   { --face:linear-gradient(202deg,#E8CBA8,#CFA271); --edge:#966B3E }
  .menu-item .mi-emoji { font-size:1.3rem; display:inline-flex; align-items:center; justify-content:center; }
  /* Passport's icon is a drawn star rather than a character, so it made a shorter box than the
     emoji rows — 49px against 56px, the one row that never lined up. Sizing it to 1.3rem matches
     the emoji visually. */
  .menu-item .mi-emoji svg { width:1.3rem; height:1.3rem; display:block; }
  /* ★ THE TAB YOU ARE ON IS THE KEY THAT IS ALREADY PUSHED IN — it keeps its own colour and simply
     stays down, ringed in its own darker edge colour. That says "this one is on" in the physical
     language of the keys themselves, which a non-reader can read; the old flat yellow highlight
     said it in a language only the layout knew. */
  /* Same compressed geometry as a live press — the base stays put, the face is down. A tab you are
     already on has to sit at EXACTLY the depth your finger just pushed it to, or the key lifts a
     little the moment you let go and the menu looks like it changed its mind. */
  .menu-item.active {
    transform:translateY(8px);
    box-shadow:none;
  }
  /* ★ EVERY MENU ROW LOOKS IDENTICAL — Michael's ruling 2026-07-30. Same font, same size, same
     full-strength colour, top to bottom. Nothing in this menu is faded, half-lit or badged.
     Two things were removed to get here:
       • `.menu-soon` — Game used to sit at half opacity with a "soon" tag. It now reads like any
         other row and is simply INERT: it makes the normal tap sound and does nothing else.
       • `.menu-grown` — About and Parent's Corner used to be smaller and dimmed so a child would
         slide past them. He'd rather have one consistent menu than a quiet corner of it.
     Don't reintroduce a dimmed or shrunken row here; if something needs to be unavailable, it
     should be inert and look normal, not greyed. */
  .menu-divider { height:2px; background:rgba(184,148,30,.35); margin:2px 4px; border-radius:2px; }

  /* ===== EXPLORE: CLICKABLE COUNTRIES ===== */
  /* In Explore mode the merged continents + continent labels fade out while the
     individual countries fade in — a smooth dissolve, not a hard switch. */
  .cont, .ctlabel { transition:opacity 2.363s cubic-bezier(.65,0,.35,1), filter .3s ease; }
  /* ★ ONE MOVING LAYER PER TAB CHANGE (Michael 2026-08-25, after TEST 831 "turns blue for a
     second"). During a tab change setMode() puts `tab-switching` on the body. These three layers
     then take their new value at once instead of running their own 2.363s fades under the change.
     Half way through a change the continents used to be half gone while the country shapes were
     only a quarter in, so the screen showed mostly bare ocean — that was the blue. Nobody loses
     the dissolve: it is only switched off while a solid pane covers or reveals the whole map. */
  body.tab-switching .cont,
  body.tab-switching .ctlabel,
  body.tab-switching .mode-svg { transition:none; }
  body[data-mode="explore"] .cont { opacity:0; pointer-events:none; }
  /* continent NAMES stay visible in Explore (label parity across all map tabs, 2026-07-18) */

  /* each country's colour comes from its fill="" attribute (set in buildCountries) */
  .country {
    stroke:#ffffff; stroke-width:0.6;
    stroke-linejoin:round; cursor:pointer;
    transition:filter .15s;
  }
  /* Hover / selected: glow & shimmer gold, the same look used for language areas */
  .country:hover, .country.sel {
    fill:#FFE07A; stroke:#FFC53D; stroke-width:1.1;
    filter:drop-shadow(0 0 6px #FFD24D);
    animation:ctryGlow 2.4s ease-in-out infinite;
  }
  @keyframes ctryGlow{ 0%,100%{opacity:.62} 50%{opacity:.96} }

  /* Tappable marker for tiny countries (currently just Taiwan) so kids can find them */
  .ctry-marker {
    position:fixed; transform:translate(-50%,-50%);
    width:20px; height:20px; border-radius:50%;
    background:#E8784A;            /* set from the country's own colour in JS */
    border:2.5px solid #fff;
    box-shadow:0 2px 5px rgba(0,0,0,.4);
    cursor:pointer; z-index:33;
    transition:transform .15s;
  }
  .ctry-marker:hover { transform:translate(-50%,-50%) scale(1.22); }

  /* Country detail panel — slides up from the bottom */
  #country-panel {
    position:fixed; left:50%; bottom:0; transform:translate(-50%,115%);
    width:min(440px,94vw); z-index:84;
    background:linear-gradient(180deg,#FFF7E0,#FFE7B0);
    border:3px solid #8B5A12; border-bottom:none;
    border-radius:26px 26px 0 0;
    box-shadow:0 -8px 26px rgba(0,0,0,.35);
    padding:18px 18px 28px;
    transition:transform .35s cubic-bezier(.2,.8,.2,1);
  }
  #country-panel.open { transform:translate(-50%,0); }
  .country-hero { text-align:center; margin:2px 0 10px; }
  .country-flag-big { width:62px; height:46px; border-radius:7px; margin:0 auto;
    box-shadow:0 2px 5px rgba(0,0,0,.28), 0 0 0 1px rgba(0,0,0,.12); }
  /* real flag images (assets/flags/*.svg) — uniform rectangles, soft corners, subtle edge */
  .flag-img { display:block; object-fit:cover; border-radius:5px; box-shadow:0 0 0 1px rgba(0,0,0,.15); }

  .panel-close {
    position:absolute; top:9px; right:11px; width:34px; height:34px; z-index:3;
    border:none; border-radius:50%; cursor:pointer; line-height:1;
    background:rgba(139,90,18,.12); color:#7A4A0A; font-size:1.05rem;
  }
  .panel-close:hover { background:rgba(139,90,18,.22); transform:scale(1.08); }

  /* Two "learn rows": a country row and (premium) a capital row. Each is a self-
     contained tinted card, so the free, country-only card still looks complete.
     Globe badge = country, red star badge = capital (matches the map markers). */
  .learn-row {
    display:flex; align-items:center; gap:12px;
    padding:9px 13px; margin:8px 4px 0; border-radius:18px; border:2.5px solid;
  }
  .learn-row.country-row { background:#EAF4FF; border-color:#A7CBEA; }
  .learn-row.cap-row     { background:#FFF2D2; border-color:#E8C45F; }
  .lr-badge { flex:0 0 auto; width:30px; height:30px; display:block; }
  .lr-badge.star  { filter:drop-shadow(0 1px 1px rgba(0,0,0,.28)); }
  .lr-badge.globe { filter:drop-shadow(0 1px 1px rgba(0,0,0,.2)); }
  .lr-name { font-family:var(--font-read); font-size:1.15rem; line-height:1.08; color:#7A4A0A;
    flex:1 1 auto; min-width:0; text-align:left;
    /* wrap long country/capital names to at most 2 lines instead of truncating mid-word */
    display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; }
  .lr-btns { display:flex; gap:9px; flex:0 0 auto; }

  /* Country-card speaker buttons — smaller/softer than the shared .flag-btn used by
     the Landmarks & Household cards (those are intentionally left untouched). */
  #country-panel .flag-btn { width:66px; height:50px; border-width:2.5px; border-radius:13px;
    box-shadow:0 3px 7px rgba(0,0,0,.2); }
  #country-panel .flag-btn .flag-img { width:46px; height:34px; }

  .flag-btn {
    width:114px; height:80px; cursor:pointer;
    border:3px solid #8B5A12; border-radius:16px; background:#fff;
    box-shadow:0 4px 10px rgba(0,0,0,.25);
    display:flex; align-items:center; justify-content:center;
    transition:transform .15s;
  }
  .flag-btn:hover { transform:translateY(-3px) scale(1.04); }
  .flag-btn .flag-face { font-size:3rem; line-height:1; }
  /* household "say it again" speaker icon (square, no flag crop/edge) */
  #home-panel .btn-ico { width:48px; height:48px; display:block; }
  .flag-btn.wiggle { animation:btnWiggle .5s ease; }
  @keyframes btnWiggle {
    0%   { transform:rotate(0)     scale(1);    filter:brightness(1); }
    30%  { transform:rotate(-7deg) scale(1.10); filter:brightness(1.3); }
    60%  { transform:rotate(6deg)  scale(1.06); filter:brightness(1.15); }
    100% { transform:rotate(0)     scale(1);    filter:brightness(1); }
  }

  /* ===== Passport (rewards collection) — first-pass view, to be art-directed ===== */
  #passport-panel {
    position:fixed; left:50%; top:50%; transform:translate(-50%,-50%) scale(.9);
    width:min(560px,92vw); max-height:82vh; z-index:90;
    background:linear-gradient(180deg,#FFF7E0,#FFE7B0);
    border:3px solid #8B5A12; border-radius:24px;
    box-shadow:0 12px 40px rgba(0,0,0,.4);
    padding:16px 16px 20px; overflow:auto;
    opacity:0; pointer-events:none;
    transition:opacity .25s, transform .25s cubic-bezier(.2,.8,.2,1);
  }
  #passport-panel.open { opacity:1; pointer-events:auto; transform:translate(-50%,-50%) scale(1); }
  .pp-head { display:flex; align-items:center; justify-content:center; gap:12px; margin:2px 36px 14px; }
  .pp-title { font-family:var(--font-read); font-size:1.5rem; color:#7A4A0A; }
  .pp-count { font-family:var(--font-read); font-weight:800; color:#9A6A00;
    background:#FFD98A; border-radius:12px; padding:2px 10px; }
  .pp-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(92px,1fr)); gap:10px; }
  .pp-cell { position:relative; background:#fff; border:2.5px solid #E6C98A; border-radius:14px;
    padding:9px 4px; text-align:center; opacity:.5; filter:grayscale(.75); transition:transform .15s; }
  .pp-cell.got { opacity:1; filter:none; border-color:#F2A93B; box-shadow:0 3px 8px rgba(242,169,59,.35); }
  .pp-icon { font-size:2rem; line-height:1; }
  .pp-name { font-family:var(--font-read); font-weight:800; font-size:.7rem; color:#7A4A0A;
    margin-top:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .pp-stamp { position:absolute; top:-7px; right:-7px; color:#E23B2E; font-size:1.15rem;
    text-shadow:-1px 0 #FFD23D,1px 0 #FFD23D,0 -1px #FFD23D,0 1px #FFD23D; }

  /* --- tablet / touch tuning --- */
  .pin, .act-btn, .lang-btn, .spd-btn, #flyer, #map-dim, .pin-img, #menu-btn, .menu-item { touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
  img, svg { -webkit-user-drag:none; user-drag:none; }
  @media (hover: none) {
    .act-btn:hover, .lang-btn:hover, .spd-btn:hover { transform:none; filter:none; }
    /* the hamburger keeps its map-anchored centering transform; only cancel the hover GROW so it
       doesn't stick on a touch's first tap (the "bounce then nothing" symptom). */
    #menu-btn:hover { transform:translate(-50%,-50%); }
  }
  /* language glow — z 50 INSIDE #explore-stage, ABOVE the world map but BELOW the labels-svg
     (z 51 below). Same viewBox as world-svg keeps coordinates aligned.
     ⚠️ position:absolute, NOT fixed — a fixed child inside the transformed stage silently
     stops painting in Chrome (found 2026-07-18). */
  #glow-svg{ position:absolute; inset:0; width:100%; height:100%; z-index:50; pointer-events:none; }
  /* labels-svg holds the ocean/equator/continent text labels, sitting ABOVE the glow so the text
     stays readable when a language lights up large land areas. */
  #labels-svg{ position:absolute; inset:0; width:100%; height:100%; z-index:51; pointer-events:none; }
  /* ===== LANDMARKS LEAN-IN STAGE (src/lean-map.js, 2026-07-16 native-maps rebuild) =====
     ONE element holds the flat map image + the pins; the lean-in zooms just this element with a
     single compositor transform, so the whole scene scales as one GPU layer (pins ride along). This
     replaced the old multi-layer CSS-scale + per-frame JS that froze Mom's 120Hz iPad. On the
     Landmarks tab the shared vector world map + glow + labels are hidden (the image stands in). */
  #lean-stage { position:fixed; inset:0; transform-origin:0 0; will-change:transform; }
  #lean-map-canvas { position:absolute; inset:0; width:100%; height:100%;
                     display:block; pointer-events:none; will-change:transform; }
  /* MOM'S 6s-FREEZE FIX (2026-07-16, profiler-proven): the 36 pins' soft drop-shadow FILTER
     rasterized catastrophically at deep zoom — a 6.1s main-thread FREEZE on Mom's 120Hz iPad
     (Daniel's did it in 0.18s). Dropping that one filter on the Landmarks tab fixed BOTH iPads
     (Mom's now 0.16s, dive runs on the GPU). Keep the shadow off (bake depth into the art if wanted).
     The sonar RING is just a bordered circle (no blur) — safe, so it stays. And promote each pin to
     its OWN GPU layer so the zoom RE-RASTERS the small pin crisp (cheap without the filter) instead
     of blurring a scaled base texture — that's the pin-sharpness fix.
     ★ 2026-07-24 UNSCOPED FROM THE TAB (twin of the ~line 171 rule). This used to read
     body[data-mode="people"] …, so all 36 landmark shadows came back the moment you left the
     Landmarks tab — invisible, still painted, still bouncing. Scoped to the #lm-pins CONTAINER
     so the shadow is off on every tab. No visible change: on Landmarks it was already off. */
  #lm-pins .pin-thumb { filter:none; }
  /* PIN + RING SHARE ONE ANCHOR (Michael 2026-07-16): a landmark pin and its sonar ring MUST land on
     the exact same map point. Both are position:absolute at the same left/top; the pin must therefore
     use the SAME plain centering as the ring — translate(-50%,-50%) scale(1). The old "sharpen"
     oversized the icon 3× (--pin-hi:3) and scaled the pin back 1/3 (--pin-ctr:.3333); on iOS that big
     scaled box lands OFF its coordinate (the ring, being a tiny box, lands ON it) — so the pin drifted
     away from its ring. Dropped the oversample: the pin now centres exactly like the ring, same
     coordinate, colocated on every device. (Deep-zoom crispness = a later, placement-safe pass.)
     Keep will-change so the dive re-rasterises each pin on its own GPU layer (cheap, no filter). */
  body[data-mode="people"] #lm-pins .pin { will-change:transform; }
  /* (2026-07-18 uniform fade: live-map hide moved to the #explore-stage fade rule.) */
  /* CHEAP LANGUAGE GLOW (2026-07-28). Three deliberate economies vs. the original:
     (1) NO blur — the old soft halo was a filter the iPad re-drew every frame;
     (2) the gentle breathe now runs on the ONE group that holds every country, not on each
         country separately, so the tablet animates one thing instead of thirty;
     (3) it fades in when it first appears (see #glow-content.arrive) so it arrives softly
         with the flags rather than snapping on. */
  #glow-svg .glow-area{ fill:#FFE07A; stroke:#FFC53D; stroke-width:1.2; opacity:.55; }
  #glow-svg .glow-blob{ stroke:none; }
  #glow-svg #glow-content{ opacity:.8; animation:glowShim 2.85s ease-in-out infinite; }
  /* ★ 2026-08-25 will-change only while Countries is on screen (same mode-gating the pins use at
     the two rules above/below). Ungated, this one line held a permanent GPU compositor layer for
     the glow on EVERY tab, all session. The hidden-tab pause rule stops its shimmer off-mode. */
  body[data-mode="explore"] #glow-svg #glow-content{ will-change:opacity; }
  #glow-svg #glow-content.arrive{ animation:glowArrive .55s ease-out both, glowShim 2.85s ease-in-out .55s infinite; }
  @keyframes glowShim{ 0%,100%{opacity:.62} 50%{opacity:1} }
  @keyframes glowArrive{ from{opacity:0} to{opacity:.62} }
  /* ===== per-animal animation rigs ===== */
  .pin-rig{ font-size:calc(2.155rem * var(--a-hi,1)); }   /* LAST RESORT ONLY — every animal now has
     its own size rule below. This used to read clamp(1.6rem,3.1vw,2.5rem) x --pin-grow default 2:
     sized by the WIDTH OF THE SCREEN and doubled by a dial, so owl/pig/cow/lamb/frog were a
     different size on every device and disagreed with the seven animals that had their own rule.
     That is why Michael kept seeing sizes he had not chosen (2026-08-03). */   /* x --a-hi: the deep-zoom re-raster flip — without it the 5 pins with no per-pin size (owl/pig/cow/lamb/frog) stayed SMALL at the dive (Michael, TEST 108) */
  .snk2-body{ transform-box:fill-box; transform-origin:50% 86%; animation:snkSway 2.6s ease-in-out infinite; }
  @keyframes snkSway{ 0%,100%{transform:rotate(-3.2deg)} 50%{transform:rotate(3.2deg)} }
  .snk-tongue{ transform-box:fill-box; transform-origin:center top; animation:snkTongue 2.4s ease-in-out infinite; }
  @keyframes snkTongue{ 0%,70%,100%{transform:scaleY(.14)} 82%,92%{transform:scaleY(1)} }
  /* ===== LIVING POSTCARD overlay (coin-flip reveal) ===== */
  #postcard-overlay{
    position:fixed; inset:0;
    background:rgba(8,18,48,0);
    z-index:86;
    display:flex; align-items:center; justify-content:center;
    pointer-events:none;
    transition:background .4s ease;
  }
  #postcard-overlay.active{ background:rgba(0,0,0,.25); pointer-events:auto; }
  #postcard-overlay:not(.active):not(.warming) #postcard-wrapper{ display:none; }
  /* WARMING (2026-07-09): the tapped card's engine boots on finger-down, LAID OUT at its final
     size but invisible + non-interactive, so it measures correctly and is already rendering by the
     time the flip lands. Keeps layout + rAF alive (display:none would zero the size and throttle
     the boot — the two bugs the old cold-boot reveal machinery fought).
     2026-07-10: opacity:0.001 instead of visibility:hidden — hidden let the browser SKIP
     RASTERIZING the card's artwork during the warm, so the first paint of everything happened AT
     reveal: rendering choked to ~20-30fps for the first beat while the animation clock ran at
     wall speed, and every card visibly "fast-forwarded" for a split second on open (Michael's
     super-speed symptom, confirmed by the probe's post-reveal rAF collapse). At 0.001 the card is
     imperceptible but the compositor must paint it, so reveal costs nothing. */
  #postcard-overlay.warming{ pointer-events:none; }
  #postcard-overlay.warming #postcard-wrapper{ opacity:0.001; }
  #postcard-wrapper{
    position:relative;
    overflow:hidden;
    /* Rounded outer corners on the LANDED card — matches the flip proxy's own 14px
       (.flip-face) so the corner stays round through the spin AND the landing (no
       square-snap). overflow:hidden is already here, so this radius clips the iframe
       (rope border and all). One shared wrapper = all 36 cards, no per-card masks. */
    border-radius:14px;
    /* width/height set by JS = the card's final display size; overflow:hidden clips
       the iframe overhang (the player's dark stage around the card) */
  }
  /* NATIVE FILL (2026-07-07): the iframe fills the wrapper 1:1 — the wrapper IS booted at
     the card's true on-screen size, and injectCardFill() makes the player's #card fill the
     iframe. NO transform (transforms on iframes render inconsistently on real devices and
     fed the player wrong measurements → "boats teleport to the sky"). frame.style.opacity
     is toggled by JS (0 until revealed). */
  #postcard-frame{
    display:block;
    width:100%; height:100%;
    border:none; outline:none;
  }

  /* ===== PIN→POSTCARD COIN FLIP (proxy card) =====
     Two plain faces + transforms/opacity ONLY — everything stays on the GPU
     compositor thread so the spin is smooth on tablets. The live iframe is never
     rotated (WebKit backface bugs); it sits underneath and is revealed on landing. */
  #flip-proxy{
    position:absolute;               /* JS sets width/height; flex centering of the overlay
                                        doesn't apply (absolute), so JS centers it too */
    display:none;
    transform-style:preserve-3d;
    pointer-events:none;
    z-index:2;
    will-change:transform;
  }
  #postcard-overlay.flipping #flip-proxy{ display:block; }
  /* NOTE (2026-07-07): we deliberately do NOT set #postcard-wrapper{opacity:0} while
     flipping. Making the wrapper fully transparent made the player mis-MEASURE the card
     (it fell back to its landscape default → boats teleported to the sky, Great Wall flag
     to the middle — flip-only bug). Instead the live card is hidden by the IFRAME's own
     opacity (frame.style.opacity=0 in bootCard) — same as the no-flip path — and the
     opaque #flip-proxy covers the centre during the spin. The wrapper stays laid out at
     opacity 1 so the player always measures the true on-screen size. */
  /* ★ RELIABLE CULLING (2026-07-28, the tap-flip "both faces at once" glitch).
     Three WebKit realities this block answers:
     1. backface-visibility is judged PER COMPOSITED LAYER — the snap canvas and the plate
        image get their own layers and must carry the rule THEMSELVES, or they paint even
        when their face is turned away. Hence the descendant selector.
     2. Culling is unreliable on a member of a 3D context that has NO transform of its own —
        the front face must wear an explicit rotateY(0).
     3. Two faces in exactly the same plane z-fight. The translateZ (applied after each
        face's rotate, so it pushes along its own outward normal) gives the coin 1px of real
        thickness — even in a frame where culling is late, depth ordering hides the far face.
        0.5px at 1200px perspective scales the face by 0.04%: invisible. */
  .flip-face, .flip-face *{
    backface-visibility:hidden;
    -webkit-backface-visibility:hidden;
  }
  .flip-face{
    position:absolute; inset:0;
    border-radius:14px;
    overflow:hidden;
  }
  #flip-front{ transform:rotateY(0deg) translateZ(0.5px); }
  #flip-back{ transform:rotateY(180deg) translateZ(0.5px); }
  /* when the REAL written back is mounted on the coin, the CSS stand-in underneath must not
     also paint (two stacked opaque backs); it returns the moment the real one isn't ready */
  #flip-back.has-real .pc-back{ display:none; }
  /* Front face phase 1 = the pin's cut-out thumbnail on a transparent bg; the
     static card preview underneath is revealed at the first edge-on moment. */
  #flip-thumb{
    position:absolute; inset:0; margin:auto;
    max-width:88%; max-height:88%;
    object-fit:contain;
    z-index:2;
  }
  #flip-preview{
    position:absolute; inset:0;
    width:100%; height:100%;
    object-fit:cover;
    opacity:0;
    z-index:1;
  }
  /* the thumb→card swap is a WAAPI opacity animation synced to the spin (see
     runPinFlip) — same timeline as the rotation, so it can never drift, even if
     the tablet stutters. No CSS class swap, no timers. */
  /* The written side of the postcard — cream card, stamp (the landmark's own
     thumbnail!), a wobbly postmark, divider and address squiggles. Pure CSS,
     no text (pre-reader rule), no extra image assets. backface-visibility keeps
     it hidden during phase 1 (it's facing away). */
  .pc-back{
    position:absolute; inset:0;
    background:#fbf3e2;
    border:6px solid #fff;
    border-radius:14px;
    box-shadow:inset 0 0 0 2px #e8dcc2;
  }
  .pc-stamp{
    position:absolute; top:7%; right:5%;
    width:19%; aspect-ratio:1;
    background:#fff;
    border:3px dashed #d9884a;
    border-radius:6px;
    display:flex; align-items:center; justify-content:center;
    transform:rotate(4deg);
  }
  .pc-stamp img{ width:78%; height:78%; object-fit:contain; }
  .pc-postmark{
    position:absolute; top:9%; right:22%;
    width:13%; aspect-ratio:1;
    border:3px solid rgba(200,90,80,.5);
    border-radius:50%;
    transform:rotate(-12deg);
  }
  .pc-divider{
    position:absolute; top:12%; bottom:12%; left:50%;
    width:3px; border-radius:2px;
    background:#d9c9a3;
  }
  .pc-lines{ position:absolute; left:58%; right:8%; top:42%; }
  .pc-lines i{
    display:block; height:4px; border-radius:2px;
    background:#d9c9a3; margin-bottom:14%;
  }
  .pc-lines i:last-child{ width:62%; }

  /* The LANDMARK DETAIL panel is gone (Michael, 2026-08-25) — the postcard is the only landmark
     screen. Its emoji hero, title, flag row and fact line went with it. See openLandmark() in
     src/landmarks.js. The one element left, #lm-say, is hidden and holds no styling of its own. */

  /* ===== Sparkle burst module — used by teleportFlyerHome() (Approved Behavior #7, revised) ===== */
  /* Drop-shadows kept subtle on purpose — peak opacity is also capped in the JS keyframes so the sparkles read as a soft shimmer, not a flashbulb. */
  .al-spark { position: fixed; display: grid; place-items: center; pointer-events: none; z-index: 9999; transform: translate(-50%,-50%) scale(0); opacity: 0; }
  .al-spark .arms { width: 100%; height: 100%; background: linear-gradient(135deg, #ffe9a0, #f3c141); clip-path: polygon(50% 0%, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0% 50%, 40% 40%); filter: drop-shadow(0 0 3px rgba(255,233,160,.55)); }
  .al-spark .pip  { position: absolute; width: 30%; height: 30%; background: radial-gradient(circle, #fff 0%, #ffe9a0 60%, transparent 100%); border-radius: 50%; filter: drop-shadow(0 0 2px rgba(255,255,255,.7)); }
  .al-spark.t-cream .arms { background: linear-gradient(135deg, #fff, #faecc4); filter: drop-shadow(0 0 3px rgba(250,236,196,.55)); }
  .al-spark.t-warm  .arms { background: linear-gradient(135deg, #ffe9a0, #e89870); filter: drop-shadow(0 0 3px rgba(232,152,112,.55)); }
  /* Flyer fade-out at the teleport source (paired with the outward sparkle burst).
     Duration tuned by Michael — slower fades read as a more deliberate "poof" rather than a snap. */
  .al-teleport-out { transition: opacity 270ms ease-out !important; opacity: 0 !important; }
  /* ★ TELEPORT ARRIVAL (Michael 2026-08-20): DO NOT REINVENT THIS. The arrival is the SAME
     mechanic the pin already uses when an animal teleports home (.al-pin-fadein below) — 480ms
     opacity plus a scale-pop from .7 on the overshoot curve. An earlier 270ms opacity-only fade
     read as a blink, because the pop is what makes the return feel like an arrival, not the fade.
     The flyer is centered by transform:translate(-50%,-50%), so the keyframes carry that translate
     through; without it the animal jumps a half-body on arrival. */
  .al-teleport-in { animation: alPinOpacityIn 480ms ease-out both, alFlyerPopIn 480ms cubic-bezier(.34,1.4,.5,1) both; }
  @keyframes alFlyerPopIn { from { transform: translate(-50%,-50%) scale(.7); } to { transform: translate(-50%,-50%) scale(1); } }
  /* Pin fade-in at the home spot (paired with the inward sparkle converge).
     OPACITY ONLY on the pin itself — .pin uses transform:translate(-50%,-50%) for centering
     AND runs the pinFloat keyframes; touching transform here would un-center the pin
     (visible "down-right then snap" jump). The "pop" feel comes from a scale on the
     INNER .pin-icon, which has no centering transform of its own. */
  .al-pin-fadein { animation: alPinOpacityIn 480ms ease-out both; }
  .al-pin-fadein .pin-icon { animation: alPinPopIn 480ms cubic-bezier(.34,1.4,.5,1) both; }
  @keyframes alPinOpacityIn { from { opacity: 0; } to { opacity: 1; } }
  @keyframes alPinPopIn { from { transform: scale(.7); } to { transform: scale(1); } }

  /* ===== Parental gate (src/parental-gate.js) — deliberately plain/adult; the one screen with no kid pizzazz ===== */
  .pg-overlay { position: fixed; inset: 0; z-index: 99999; display: flex; align-items: center; justify-content: center;
    background: rgba(20,22,28,.72); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
    animation: pgFadeIn .18s ease-out both; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
  .pg-overlay.pg-closing { animation: pgFadeOut .2s ease-in both; }
  @keyframes pgFadeIn { from { opacity: 0; } to { opacity: 1; } }
  @keyframes pgFadeOut { from { opacity: 1; } to { opacity: 0; } }
  .pg-card { position: relative; width: min(86vw, 320px); background: #f4f4f6; color: #2a2d34;
    border-radius: 18px; padding: 22px 20px 20px; box-shadow: 0 16px 48px rgba(0,0,0,.4);
    font-family: var(--font-read); text-align: center; }   /* 2026-07-28: was the iPad's own font — the app has ONE font now */
  .pg-card.pg-shake { animation: pgShake .4s; }
  @keyframes pgShake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-9px)} 40%{transform:translateX(8px)} 60%{transform:translateX(-6px)} 80%{transform:translateX(4px)} }
  .pg-close { position: absolute; top: 8px; right: 10px; width: 32px; height: 32px; border: none; background: transparent;
    color: #9a9da6; font-size: 18px; line-height: 1; cursor: pointer; border-radius: 8px; }
  .pg-close:active { background: #e4e4e8; }
  .pg-title { font-size: 17px; font-weight: 700; letter-spacing: .2px; }
  .pg-sub { font-size: 12px; color: #767a84; margin: 3px 0 14px; }
  /* Sized to hold EITHER question style, because src/parental-gate.js can be flipped between
     "31 + 24 = ?" and the spelled-out "thirty-one plus twenty-four" with one line. The words
     version needs to wrap without shoving the number pad off-screen; min-height reserves the
     second line so the card is the same height either way and doesn't jump between questions. */
  .pg-question { font-size: 24px; font-weight: 700; line-height: 1.25; min-height: 2.2em;
    display: flex; align-items: center; justify-content: center;
    text-wrap: balance; font-variant-numeric: tabular-nums; }
  .pg-entry { min-height: 34px; margin: 8px auto 14px; font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums;
    color: #1a73c0; letter-spacing: 2px; }
  .pg-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; }
  .pg-key { height: 52px; border: none; border-radius: 12px; background: #fff; color: #2a2d34; font-size: 21px; font-weight: 600;
    cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.12); font-variant-numeric: tabular-nums; }
  .pg-key:active { background: #e8e9ee; transform: scale(.96); }
  .pg-key-action { background: #e9edf3; color: #4a5160; }

  /* ===== GROWN-UP SCREENS — About (ungated) + Parent's Corner (gated) =====
     src/grownups.js draws the shell; src/about.js and src/parents-corner.js fill it.
     Deliberately PLAIN, like the parental gate: these are the only screens in the app written for
     adults, and a child who lands on one should find it boring. No kid colours, no bounce, normal
     readable body text. Everything is var(--font-read) per CLAUDE.md §12 — one font, and these
     pages hold ordinary sentences, not decoration. */
  .gu-panel {
    position:fixed; inset:0; z-index:99998; display:flex; align-items:center; justify-content:center;
    background:rgba(18,20,26,.55); -webkit-backdrop-filter:blur(2px); backdrop-filter:blur(2px);
    opacity:0; pointer-events:none; transition:opacity .22s ease;
  }
  .gu-panel.open { opacity:1; pointer-events:auto; }
  .gu-sheet {
    width:min(620px,94vw); max-height:88vh; display:flex; flex-direction:column;
    background:#f7f7f9; color:#22252c; border-radius:18px; overflow:hidden;
    box-shadow:0 18px 54px rgba(0,0,0,.42);
    transform:translateY(10px) scale(.985); transition:transform .22s cubic-bezier(.2,.8,.2,1);
  }
  .gu-panel.open .gu-sheet { transform:none; }
  .gu-head {
    display:flex; align-items:center; gap:10px; padding:14px 14px 12px 18px;
    border-bottom:1px solid #e3e4e9; background:#fff; flex:0 0 auto;
  }
  .gu-title { font-family:var(--font-read); font-weight:800; font-size:1.05rem; flex:1; }
  .gu-close {
    width:34px; height:34px; flex:0 0 auto; border:none; border-radius:9px;
    background:#eef0f4; color:#59606e; font-size:16px; cursor:pointer;
  }
  .gu-close:active { background:#e0e3ea; transform:scale(.95); }
  /* The scrolling body. -webkit-overflow-scrolling keeps the flick feeling native on the iPad. */
  .gu-scroll {
    padding:16px 18px 22px; overflow-y:auto; -webkit-overflow-scrolling:touch;
    font-family:var(--font-read); font-size:.92rem; line-height:1.5;
  }
  .gu-scroll p { margin:0 0 .7em; }
  .gu-lead { color:#4a5160; }
  .gu-foot { color:#6b717e; font-size:.85rem; margin-top:12px; }
  .gu-dim  { color:#8b909b; }
  .gu-hr { border:none; border-top:1px solid #e3e4e9; margin:16px 0; }

  .gu-sec { margin:0 0 18px; }
  .gu-sec-title {
    font-family:var(--font-read); font-weight:800; font-size:.78rem; letter-spacing:.06em;
    text-transform:uppercase; color:#8b909b; margin:0 0 7px;
  }
  .gu-sec-body { }

  /* The scannable promise line at the top of About. */
  .gu-badges { display:flex; flex-wrap:wrap; gap:6px; margin:0 0 20px; }
  .gu-badge {
    background:#e7f1e8; color:#2f6b3a; border:1px solid #cfe3d2; border-radius:999px;
    padding:3px 10px; font-size:.76rem; font-weight:700;
  }

  /* A tappable row in Parent's Corner. */
  .gu-row {
    display:flex; align-items:center; gap:12px; width:100%; text-align:left;
    background:#fff; border:1px solid #e3e4e9; border-radius:12px;
    padding:12px 14px; margin:0 0 8px; cursor:pointer; font-family:var(--font-read); color:inherit;
  }
  .gu-row:active { background:#eef0f4; transform:scale(.995); }
  .gu-row-text { flex:1; display:flex; flex-direction:column; gap:2px; min-width:0; }
  .gu-row-title { font-weight:800; font-size:.95rem; }
  .gu-row-sub { font-size:.8rem; color:#767c88; }
  .gu-row-chev { color:#a8adb7; font-size:1.3rem; line-height:1; flex:0 0 auto; }
  .gu-row-off { opacity:.55; cursor:default; }
  .gu-back {
    border:none; background:transparent; color:#4a6fa5; font-family:var(--font-read);
    font-weight:700; font-size:.88rem; padding:0 0 12px; cursor:pointer;
  }

  /* ★ The honest "slot exists, nothing behind it yet" marker. Most of both screens is this today.
     Showing it greyed rather than hiding it is the point: the SHAPE of both screens is settled
     now, so filling one in later is a content change and not a layout redesign. */
  .gu-todo {
    display:flex; align-items:flex-start; gap:9px;
    background:#f0f1f4; border:1px dashed #c8ccd4; border-radius:10px; padding:11px 12px;
  }
  .gu-todo-tag {
    flex:0 0 auto; background:#dfe2e8; color:#5d636f; border-radius:5px;
    padding:2px 7px; font-size:.66rem; font-weight:800; text-transform:uppercase;
    letter-spacing:.04em; margin-top:1px; white-space:nowrap;
  }
  .gu-todo-what { color:#5d636f; font-size:.85rem; line-height:1.45; }

  /* The child's Settings screen (src/settings.js). Each setting is led by a BIG PICTURE, because
     that picture is what has to survive on its own: this is a pre-reader's screen and the words
     beside it today are placeholder scaffolding that must be gone before it ships (CLAUDE.md §4).
     Sizing the icon generously now keeps the shape honest while the words are still there. */
  .set-slot { display:flex; align-items:flex-start; gap:13px; margin:0 0 14px; }
  .set-icon { flex:0 0 auto; width:52px; height:52px; border-radius:14px; background:#fff;
    border:1px solid #e3e4e9; display:flex; align-items:center; justify-content:center;
    font-size:1.7rem; line-height:1; }
  .set-slot-body { flex:1; min-width:0; }

  /* ★ THE FIRST WORKING CONTROL ON THE SETTINGS SCREEN (Michael 2026-08-27).
     A plain adult switch, on purpose and for now — his call while the screen is still being
     shaped. The whole row is the button, so a fingertip cannot miss it. The knob slides; it never
     jumps (the motion-feel LAW). */
  .set-toggle { display:flex; align-items:flex-start; gap:11px; width:100%; text-align:left;
    background:#fff; border:1px solid #e3e4e9; border-radius:14px; padding:11px 13px;
    font-family:var(--font-read); cursor:pointer; -webkit-tap-highlight-color:transparent; }
  .set-toggle-track { flex:0 0 auto; width:46px; height:28px; border-radius:14px; background:#cdd0d8;
    position:relative; margin-top:1px; transition:background .22s ease; }
  .set-toggle-knob { position:absolute; top:3px; left:3px; width:22px; height:22px; border-radius:50%;
    background:#fff; box-shadow:0 1px 3px rgba(0,0,0,.28);
    transition:transform .22s cubic-bezier(0.65,0,0.35,1); }
  .set-toggle.on .set-toggle-track { background:#2fb5a3; }
  .set-toggle.on .set-toggle-knob { transform:translateX(18px); }
  .set-toggle-text { display:flex; flex-direction:column; gap:3px; min-width:0; }
  .set-toggle-title { color:#2b303a; font-size:.9rem; font-weight:800; }
  .set-toggle-sub { color:#5d636f; font-size:.82rem; line-height:1.45; }

  /* Rendered from the .md files by scripts/build_legal.py. */
  .gu-prose { color:#3a3f4a; }
  .gu-prose .gu-h { font-family:var(--font-read); font-weight:800; font-size:.9rem; margin:14px 0 5px; }
  .gu-prose code { background:#eceef2; border-radius:4px; padding:1px 4px; font-size:.86em; }
  .gu-ul { margin:0 0 .7em; padding-left:1.15em; }
  .gu-ul li { margin:0 0 .3em; }
  .gu-draft {
    background:#fff5e0; border:1px solid #f0dcae; color:#7a5c14; border-radius:9px;
    padding:8px 11px; margin:0 0 11px; font-size:.82rem; font-weight:700;
  }
  /* App / version / studio facts. */
  .gu-facts { display:grid; grid-template-columns:auto 1fr; gap:4px 14px; margin:0; }
  .gu-facts dt { color:#8b909b; font-size:.82rem; }
  .gu-facts dd { margin:0; font-size:.82rem; font-weight:700; }

  /* ===== HOUSEHOLD ITEMS (home mode) — cutaway dollhouse (src/household.js) ===== */
  /* In home mode the globe, its labels and the language glow hide, and the dollhouse
     scene shows alone. Hiding (not covering) the map keeps the z-index simple:
     #map-dim (z40) + #home-panel (z84) still layer on top. The title banner STAYS —
     Michael ruled 2026-07-27 that the logo belongs on every tab, Household and Town
     included (it used to be faded out here). */
  body[data-mode="home"] { background:#EAD7B6; }
  /* (map layers fade via the #explore-stage rule) */

  /* Locked base art (2026-06-26): the real Firefly-expanded dollhouse fills the screen.
     object-fit: cover keeps the house centered + crops only the filler sky/grass on any
     tablet (verified iPad 4:3 -> 16:9 safe). Item sprites get layered on top next. */
  #mode-home {
    position:fixed; inset:0; z-index:22;
    background:#cfeff7;   /* sky fill; the house art moved onto #home-stage so it zooms with the items */
  }
  /* ★ THE HOUSE STEPS BACK WHILE A WORD CARD IS OPEN (Michael chose this 2026-08-03).
     The house and the card were both bright and busy, so nothing marked where one stopped and the
     other started and the whole card read as washed out. Rather than draw a line round the card,
     the house goes SOFT and DARKER: the card becomes the only sharp, bright thing on the screen,
     which is what the eye actually locks onto. The card itself is untouched.
     Two dials, both here — soften how far out of focus, and dim how much darker: */
  #mode-home { --home-soften:7px; --home-dim:0.62;
    transition:filter .42s cubic-bezier(0.65,0,0.35,1), transform .42s cubic-bezier(0.65,0,0.35,1); }
  body.home-card-open #mode-home {
    filter:blur(var(--home-soften)) brightness(var(--home-dim));
    /* Softening samples past the screen edge, which would leave a pale faded rim all round. The
       whole tab is nudged 3% bigger so that rim sits off-screen. It is a scale, so the graphics
       chip does it and nothing is redrawn. NOT on #home-stage — the pinch-zoom camera owns that
       element's transform and the two would fight. */
    transform:scale(1.03); }
  /* Tappable item sprites layered over the house background (positioned in JS to
     match object-fit:cover, so they stay glued to the rooms on any tablet shape). */
  /* Layers (items + masked hotspots) over the house. Taps hit the TRUE shape via an
     alpha hit-grid (computed at build time); the tapped shape glows gold. */
  /* The house art lives HERE (not on #mode-home) so the shared camera (src/home-cam.js)
     zooms the house and every item sprite as ONE transformed element — the same recipe
     every other zooming tab uses. object-fit:cover geometry is identical to before. */
  #home-stage { position:absolute; inset:0; z-index:2;
    background:url("../assets/images/household/house-base.webp?v=clean5") center center / cover no-repeat; }
  .home-layer { position:absolute; pointer-events:none; transform-origin:center center; }
  .home-layer img { width:100%; height:100%; display:block; user-select:none; -webkit-user-drag:none; }
  .home-layer.glow img { filter:drop-shadow(0 0 4px #ffd24a) drop-shadow(0 0 4px #ffd24a) drop-shadow(0 0 9px #ffd24a); }
  @keyframes home-pop { 0%{transform:scale(1);} 35%{transform:scale(1.13);} 100%{transform:scale(1);} }
  .home-layer.pop { animation:home-pop .42s cubic-bezier(.34,1.56,.64,1); }

  /* old CSS placeholder dollhouse retired now that the real art is the background */
  #home-scene { display:none; }
  #home-scene-DISABLED { width:min(900px,100%); height:100%; max-height:780px; display:flex; flex-direction:column; }
  #home-roof {
    flex:0 0 auto; height:36px; background:#C85B33;
    clip-path:polygon(0 100%, 50% 0, 100% 100%);
  }
  #home-house {
    flex:1 1 auto; min-height:0;
    background:#9C6B2F; border:5px solid #7A4A0A;
    border-radius:4px 4px 20px 20px; padding:8px;
  }
  #home-rooms {
    height:100%; display:grid;
    grid-template-columns:repeat(2,1fr); grid-auto-rows:1fr; gap:8px; min-height:0;
  }
  .hi-room {
    border-radius:12px; padding:8px 8px 6px;
    display:flex; flex-direction:column; min-height:0;
    box-shadow:inset 0 0 0 2px rgba(255,255,255,.4);
  }
  .hi-living   { background:#FBEBCF; }
  .hi-kitchen  { background:#E6F0D8; }
  .hi-bathroom { background:#DCEBFA; }
  .hi-bedroom  { background:#EAE6FB; }
  .hi-room-name {
    flex:0 0 auto; font-family:var(--font-read);
    font-size:.95rem; color:#7A4A0A; margin:0 0 6px 2px;
  }
  .hi-tiles {
    flex:1 1 auto; min-height:0; overflow:auto;
    display:grid; grid-template-columns:repeat(auto-fit,minmax(72px,1fr));
    gap:7px; align-content:start;
  }
  .hi-item {
    cursor:pointer; border:none; background:rgba(255,255,255,.82);
    border-radius:13px; padding:8px 3px 6px;
    display:flex; flex-direction:column; align-items:center; gap:3px;
    transition:transform .12s ease, background .12s;
  }
  @media (hover:hover){ .hi-item:hover { transform:translateY(-3px) scale(1.06); background:#fff; } }
  .hi-emoji { font-size:1.9rem; line-height:1; filter:drop-shadow(1px 3px 4px rgba(0,0,0,.22)); }
  /* Real isometric Kenney sprites — sit directly on the room floor (lighter tile bg)
     with a soft ground shadow so they read as little 3D objects, not flat icons. */
  .hi-sprite { height:50px; width:auto; object-fit:contain; filter:drop-shadow(2px 5px 3px rgba(0,0,0,.28)); }
  .hi-item.has-sprite { background:rgba(255,255,255,.30); }
  @media (hover:hover){ .hi-item.has-sprite:hover { background:rgba(255,255,255,.6); } }
  .hi-hero-sprite { height:118px; width:auto; object-fit:contain; filter:drop-shadow(3px 8px 8px rgba(0,0,0,.34)); }
  .hi-word  { font-size:.7rem; font-weight:900; color:#5a3a0a; text-align:center; line-height:1.05; }
  .hi-item.hi-pop { animation:hiPop .45s cubic-bezier(.34,1.5,.5,1); }
  @keyframes hiPop { 0%{transform:scale(1)} 40%{transform:scale(1.22)} 100%{transform:scale(1)} }

  /* Item detail card — same look as the landmark card; reuses .lm-hero/.lm-title/.flag-btn. */
  /* Bottom language panel for Household items — mirrors the animals' #ctrl-panel. */
  #home-panel {
    position:fixed; left:0; right:0; bottom:0; z-index:84;
    padding:6px 18px 22px;
    display:flex; flex-direction:column; align-items:center; gap:6px;
    opacity:0; transform:translateY(24px); pointer-events:none;
    visibility:hidden;   /* shut means gone, not just faded — see #ctrl-panel for the full reason */
    transition:opacity .4s ease, transform .45s cubic-bezier(.34,1.4,.5,1), visibility 0s linear .45s;
  }
  #home-panel.open { opacity:1; pointer-events:auto; transform:translateY(0); visibility:visible;
    transition:opacity .4s ease, transform .45s cubic-bezier(.34,1.4,.5,1), visibility 0s linear 0s; }
  /* The big teaching picture. Most word cards are see-through cut-outs with no background of
     their own, so they sit on their ROOM'S SURFACE (wired 2026-08-01) — a wordless clue about
     where the thing lives. The six that are full scenes (the rooms, the sky) carry their own
     place and simply fill the card edge to edge. White stays as the base under everything. */
  #home-hero {
    width:min(340px,62vw); height:min(230px,30vh);
    display:flex; align-items:center; justify-content:center;
    background:#FFFFFF; border-radius:22px; overflow:hidden;
    background-size:cover; background-position:center; background-repeat:no-repeat;
    box-shadow:0 10px 26px rgba(0,0,0,.32);
    margin-bottom:2px;
  }
  #home-hero-img { max-width:92%; max-height:92%; object-fit:contain; display:block; }
  #home-hero.scene #home-hero-img { max-width:100%; max-height:100%; object-fit:cover; width:100%; height:100%; }
  /* Opened by tapping a thing in the house: the card FLIES in (src/household.js), so the panel
     must not also slide up 24px underneath it — two motions on one card read as a drift into
     place. The panel just fades; the flight is the entrance. */
  #home-panel.flying { transform:none; transition:opacity .28s ease; }
  #home-hero.pop { animation:homeHeroPop .42s cubic-bezier(.34,1.5,.5,1); }
  @keyframes homeHeroPop { 0%{transform:scale(.86)} 55%{transform:scale(1.04)} 100%{transform:scale(1)} }
  /* The word sits in the same white pill the Animals tab uses, so both tabs read alike.
     Locked to one size by the steady-pill rule (measured in src/household.js).
     ★ 2026-08-21 (Michael): the lettering now matches the Animals caption pill exactly — same
     size, same brown, same letter spacing, same padding. The Animals rule is #subtitle-main
     further up this file. Change one, change both. */
  #home-title {
    font-family:var(--font-read); font-weight:800;
    font-size:clamp(.95rem,2.4vw,1.2rem); color:#4A4036;
    letter-spacing:.3px; line-height:1.45;
    background:#FFFFFF; border-radius:18px; padding:11px 18px;
    text-align:center; box-shadow:0 8px 22px rgba(0,0,0,.28);
    max-width:min(560px,90vw); white-space:nowrap;
  }
  /* Pressing Sentence puts the whole line in the pill. A sentence needs more room than a name,
     so it wraps and drops the one width the steady-pill rule locked. */
  #home-title.sentence { white-space:normal; max-width:min(620px,92vw); }
  /* ★ 2026-08-21 (Michael): the always-on sound-it-out line under the pill is gone. The teal
     circle turns the pill itself over between the two spellings — see #home-roman-btn above. */
  #home-actions { margin-top:2px; }
  /* The ✕ button was removed 2026-08-01 (Michael): a finger anywhere that isn't the picture,
     the word pill, a button or a flag closes the panel, so there is nothing to aim at. */
  .hi-speak-row { display:flex; justify-content:center; margin:16px 0 2px; }

  /* Phone / portrait QA: rooms stack and the scene scrolls (the no-scroll rule is
     for the landscape tablet target; scrolling is fine for phone preview). */
  @media (max-width:560px){
    #mode-home { overflow:auto; align-items:flex-start; padding-top:84px; }
    #home-scene { height:auto; }
    #home-house { flex:0 0 auto; }
    #home-rooms { grid-template-columns:1fr; grid-auto-rows:auto; }
    .hi-tiles { overflow:visible; }
  }

  /* ===== TOWN (town mode) — top-down cartoon town map (src/town.js) ===== */
  /* Like home mode, the globe + labels + glow hide so the town shows alone. The title
     banner STAYS — see the home-mode note above (Michael's 2026-07-27 ruling). */
  body[data-mode="town"] { background:#3DA6D4; }
  /* (map layers fade via the #explore-stage rule) */

  #mode-town { position:fixed; inset:0; z-index:22; }
  /* Map art is a background-image set lazily in JS (first Town open) to keep boot light.
     object-fit:cover equivalent so the 4:3 map fills the screen, cropping only filler
     scenery on wider tablets (iPad 4:3 = perfect, per TOWN_PLAN safe-zone rule). */
  #town-stage { position:absolute; inset:0; background-position:center center;
    background-size:cover; background-repeat:no-repeat; }

  /* ★ 2026-08-21 — THE OLD TOWN WORD PANEL IS GONE (SHARED_PARTS_PLAN.md, Step 1).
     The Town tab shows town-options/town-live.html in a frame that fills #town-stage, and the
     word card lives inside that page. The tap zones, the ring shimmer, the bottom panel, its
     title and its close button belonged to an older Town that never shipped: src/town.js and
     src/town-data.js have not been loaded since 2026-06-28, so none of this could ever paint.
     Deleted here, with the markup in AnimaLingo.html and both script files. Git holds it. */

/* ===== START-UP HOLD (src/splash.js, ANIMAL_LOAD_PLAN.md build 2, 2026-08-21) =====
   A plain cover shown while all 12 animal openers pin to the GPU. Michael: this is a TOOL for
   now, not a design — the app's own wordmark, still, on linen. Expect the look to change. */
#al-splash{ position:fixed; inset:0; z-index:9999; background:#F3EADA;
  display:flex; align-items:center; justify-content:center; opacity:1; transition:opacity .4s ease; }
#al-splash.al-splash-out{ opacity:0; pointer-events:none; }
#al-splash .al-splash-mark{ font-family:var(--font-display); /* the AnimaLingo wordmark — HARD RULE §12 exception */
  font-size:3.2rem; color:#7A4A0A; letter-spacing:.5px; }

/* ─── Household drag-and-drop game (src/household-game.js, 2026-08-28) ──────────────────
   The overlay sits over the dollhouse and takes every pointer, so a tap during the game can
   never open a word panel. It is transparent — the child must see the rooms to aim at one. */
/* Moved 40px right and 15px up, Michael 2026-08-29. The same button ends the game early. */
#home-game-btn{
  position:absolute; left:58px; bottom:33px; z-index:60;
  font-family:var(--font-read); font-weight:700; font-size:20px; color:#0d3b46;
  padding:12px 20px; border:0; border-radius:18px; cursor:pointer;
  background:linear-gradient(#7fe3d8,#35b3a6);
  box-shadow:0 5px 0 #1d7f76, 0 8px 14px rgba(0,0,0,.25);
}
#home-game-btn:active{ transform:translateY(4px); box-shadow:0 1px 0 #1d7f76, 0 3px 8px rgba(0,0,0,.25); }
/* The button stays on screen while the game runs — it is the way out (Michael 2026-08-28). */
body[data-home-game="1"] #home-game-btn{ background:linear-gradient(#ffd08a,#e2952f); box-shadow:0 5px 0 #a96a17, 0 8px 14px rgba(0,0,0,.25); }
body[data-home-game="1"] #home-game-btn:active{ box-shadow:0 1px 0 #a96a17, 0 3px 8px rgba(0,0,0,.25); }

/* The overlay covers the whole screen, but a finger goes straight through its empty space. Only
   the five boxes and a picture in the air take a touch. Before this, the overlay took every tap,
   and the button on the lawn could not end the game (Michael reported it 2026-08-28). */
#hgame{ position:absolute; inset:0; z-index:70; touch-action:none; pointer-events:none; }
#hgame[hidden]{ display:none; }
#hgame-fx{ position:absolute; inset:0; pointer-events:none; z-index:2; }

/* The row sits on the lawn, under the house. It is FIVE boxes of one size, made once and never
   moved, so a picture leaving cannot shift the others (Michael 2026-08-28). */
#hgame-tray{
  position:absolute; left:0; right:0; bottom:88px; z-index:1; pointer-events:none;
  display:flex; justify-content:center; align-items:center; gap:30px;
}
/* A box is exactly as wide as the picture inside it is drawn (household-game.js sets the width),
   so the 30px above is the true space a child sees between two pictures. */
.hgame-slot{ display:flex; align-items:center; justify-content:center; flex:0 0 auto;
  position:relative; pointer-events:auto; }
/* A thin picture keeps a wide target. This invisible pad reaches 13px past both edges of the box
   and takes the finger for it. Two pads still leave 4px of clear space between them. */
.hgame-slot::after{ content:''; position:absolute; top:0; bottom:0; left:-13px; right:-13px; }
/* Every picture is drawn to the SAME box. A tall thing and a wide thing fill one box, so nothing
   looks bigger than it is, and a knife is as easy to hit as a fridge. */
.hgame-slot img{ max-width:100%; max-height:100%; width:auto; height:auto; display:block;
  touch-action:none; pointer-events:none; filter:drop-shadow(0 6px 10px rgba(0,0,0,.32)); }
/* The picture in the air. It sits at the top left corner and travels by TRANSFORM, so the whole
   move happens on the compositor and nothing repaints — a shadowed picture moved by left/top
   leaves old shadow edges behind on WebKit (Michael saw them 2026-08-28). `will-change` gives it
   its own layer for the seconds a finger is down. */
/* The arrival. Each picture drops in with a small bounce, one after the other from the left.
   `backwards` holds the first frame during the wait, so a picture cannot show before its turn. */
@keyframes hgame-pop{
  0%{   opacity:0; transform:translateY(22px) scale(.35); }
  60%{  opacity:1; transform:translateY(0)    scale(1.12); }
  100%{ opacity:1; transform:translateY(0)    scale(1); }
}
img.hgame-pop{ animation:hgame-pop 420ms cubic-bezier(.34,1.56,.64,1) backwards; }

img.hgame-drag{ position:absolute; left:0; top:0; z-index:3; pointer-events:auto;
  touch-action:none; object-fit:contain; will-change:transform;
  filter:drop-shadow(0 12px 18px rgba(0,0,0,.4)); }

/* The sad red X on a wrong answer (Michael's picture, 2026-08-28). It fades in over the house
   and fades out. It never blocks a finger, so a child can drag again straight away. */
/* The height is stated for the same reason as the check mark above. 300 wide on a 641x700
   picture is 328 tall. */
img.hgame-x{
  position:absolute; width:300px; height:328px; pointer-events:none;
  transform:translate(-50%,-50%);
  filter:drop-shadow(0 10px 22px rgba(0,0,0,.35));
}
/* The happy green check, drawn to the same 300px as the sad X so the two answers read the same
   size (Michael 2026-08-29). */
/* ★ The HEIGHT is stated, never left to the picture (Michael 2026-08-29, third report). The
   picture is centred by shifting it up by half its own height. Until the file loads, that height
   is 0, so the first one — the cold one — did not shift and landed 141px low, over the row of
   items. A stated height is known before the file arrives. 300 wide on a 700x657 picture is 282
   tall; change one number and you must change the other. */
img.hgame-check{
  position:absolute; width:300px; height:282px; pointer-events:none;
  transform:translate(-50%,-50%);
  filter:drop-shadow(0 10px 22px rgba(0,0,0,.35));
}
/* The points a child wins. Big and bright, so the number is the thing they see. It sits clear
   above the check mark and never covers it (Michael 2026-08-29). */
.hgame-points{
  position:absolute; font-family:var(--font-read); font-weight:800; font-size:192px;
  color:#ffe25c; -webkit-text-stroke:4px #7a4b00;
  text-shadow:0 0 26px rgba(255,214,70,.9), 0 8px 16px rgba(0,0,0,.5);
  transform:translate(-50%,-50%); white-space:nowrap;
}
/* The bonus block for a clean run: a big plus, the fifty, and the word BONUS. It arrives beside
   the banner and slides into it (Michael 2026-08-29, the pinball game's move). */
.hgame-bonus{
  position:absolute; font-family:var(--font-read); font-weight:800; font-size:88px;
  color:#ffe25c; -webkit-text-stroke:4px #7a4b00;
  text-shadow:0 0 26px rgba(255,214,70,.9), 0 8px 16px rgba(0,0,0,.5);
  transform:translate(-50%,-50%); white-space:nowrap; pointer-events:none;
}

.hgame-done{
  position:absolute; left:50%; top:50%; padding:22px 38px; border-radius:26px;
  font-family:var(--font-read); font-weight:800; font-size:46px; color:#0d3b46;
  background:rgba(255,255,255,.94); box-shadow:0 10px 26px rgba(0,0,0,.3);
  transform:translate(-50%,-50%); white-space:nowrap;
}
