@import url('assets/JetBrainsMono-VariableFont_wght.ttf');

* { box-sizing: border-box; }

html, body {
    margin: 0;
    padding: 0;
    min-height: 100%;
}

/* ====== STAGE BACKGROUND ======
   One shared stage photo behind every screen (Bank 1 / Bank 2 / Madballz).
   Drop a replacement at assets/bg-img/stage.jpg and the whole game retints.
   When body.react-mode-active is set, the BG is darkened + desaturated to
   sell the "something is wrong" mood, and the horror-munki corner sprites
   defined further down slowly creep into view. */
body {
    background-color: #000;
    background-image: url('assets/bg-img/stage.jpg');
    background-attachment: fixed;
    background-size: cover;
    /* Pin the BOTTOM edge of the stage photo to the bottom of the
       viewport on every device. `cover` scales the image to fill the
       screen; `center bottom` then crops the overflow off the TOP so
       the floor of the photo always sits exactly at the screen bottom
       — that's the surface the Munkis stand on, so it must never drift
       up and leave a gap below them on tall/short screens alike. */
    background-position: center bottom;
    color: #2dd4bf;
    font-family: 'JetBrains Mono', monospace;
    overflow-x: hidden;
    user-select: none;
    -webkit-user-select: none;
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
    padding-left: env(safe-area-inset-left, 0);
    padding-right: env(safe-area-inset-right, 0);
    position: relative;
    /* Vertical flex column so main can fill the viewport height between
       the header and the (fixed) tray — used by the stage's flex-end
       positioning that drops the Munkis to the BG floor. */
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    min-height: 100dvh;
    /* Slow filter transition gives the horror buildup time to settle in
       rather than snapping; the 4 s ease matches the corner-Munki creep. */
    transition: filter 4s ease-in-out;
}

/* ====== PHONE-PORTRAIT STAGE ART ======
   The shared stage.jpg is a LANDSCAPE plate. On a narrow portrait PHONE,
   `background-size: cover` zooms it so hard that the rainbow floor (the
   surface the Munkis stand on) and the side boxes get cropped away — the
   scene reads as a flat smear. So on phone-portrait only, swap to an
   art-directed 9:16 portrait crop (assets/bg-img/stage-portrait.jpg).
   Gate is deliberately `max-width: 600px` AND `orientation: portrait`:
   - phones in portrait are ~360–430 CSS px wide  → MATCH (get portrait art)
   - tablets in portrait are ≥768 CSS px wide     → NO match (keep stage.jpg)
   - any landscape (phone or tablet or desktop)    → NO match (keep stage.jpg)
   Tablets and desktop are intentionally untouched per the art-direction
   decision. If stage-portrait.jpg is ever missing the rule simply has no
   image to load and the existing #000 background-color shows — no break. */
@media (orientation: portrait) and (max-width: 600px) {
    body { background-image: url('assets/bg-img/stage-portrait.jpg'); }
}

/* React mode intentionally leaves the page layout + colors alone — the
   only horror cue is the corner Ice/Moon Munkis creeping into view (see
   the .horror-munki block below). Per design: "the layout of the screen
   doesn't change in horror mode, just Ice and Moon Munki slowly fading
   in and getting bigger/more defined/scarier." */

/* Soft dark vignette over the BG photo so the UI chrome reads against any
   stage art the user drops in. Always present, not tied to react mode. */
body::before {
    content: '';
    position: fixed;
    inset: 0;
    pointer-events: none;
    background:
        radial-gradient(ellipse at 50% 60%, transparent 30%, rgba(0, 0, 0, 0.55) 100%);
    z-index: 0;
}

/* Keep content above the fixed BG ::before vignette. */
body > * { position: relative; z-index: 1; }

/* ====== HORROR-MODE CORNER MUNKIS ======
   Moon Munki creeps in from the lower-left corner, Ice Munki from the
   lower-right. Anchored to the viewport bottom so their feet always touch
   the bottom of the screen (responsive: height is capped by viewport
   width so the sprites don't overwhelm narrow phones).
   The reveal is intentionally slow — ~12 s of fade-in while the sprites
   scale up and a soft blur lifts. The user should not notice them
   arriving; they should look up and find someone already there. */
.horror-munki {
    position: fixed;
    /* Hard-anchor to the viewport bottom on EVERY screen size. The sprite
       art is intentionally cropped at the back/legs on the assumption it
       always bleeds off its bottom-side corner — if it ever pulls away
       from BOTH the bottom edge AND its side edge, the crop shows as an
       ugly cutoff. !important defeats any inherited/utility rule (e.g.
       `body > * { position: relative }`) that could re-anchor it. */
    bottom: 0 !important;
    height: min(78vh, 70vw);
    width: auto;
    aspect-ratio: 432 / 988;
    pointer-events: none;
    z-index: 40;          /* over the stage, under modals/jumpscare/tray */
    opacity: 0;
    filter: blur(8px) drop-shadow(0 12px 32px rgba(0, 0, 0, 0.7));
    transition:
        opacity   12s ease-in,
        transform 12s ease-out,
        filter    12s ease-in;
}
/* transform-origin is the sprite's OWN bottom-side corner so the 0.55→1
   scale-up grows OUT of the screen corner and the anchored corner never
   moves for the entire 12 s entrance. No translateX — any horizontal
   offset would pull the cropped edge off its corner. */
.horror-munki--moon {
    left: 0 !important;
    right: auto !important;
    transform-origin: 0 100%;       /* bottom-LEFT corner */
    transform: scale(0.55);
}
.horror-munki--ice {
    right: 0 !important;
    left: auto !important;
    transform-origin: 100% 100%;    /* bottom-RIGHT corner */
    transform: scale(0.55);
}

body.react-mode-active .horror-munki {
    opacity: 0.96;
    /* Final pose: sharp focus + a dim blood-red glow — Moon's signature
       "they're definitely here now". Ice's glow is overridden below to
       cyan since Ice's horror palette is cold blue, not blood. */
    filter: blur(0) drop-shadow(0 12px 32px rgba(180, 0, 0, 0.55))
                    drop-shadow(0 0 40px rgba(255, 40, 40, 0.28));
}
body.react-mode-active .horror-munki--moon { transform: scale(1); }
body.react-mode-active .horror-munki--ice {
    transform: scale(1);
    /* Cyan glow for Ice — matches the ice-wall, frost overlay, snow,
       and the cyan jumpscare flash. Same shape as the red shadow above
       (long soft drop + wider spread), just shifted to a cold palette. */
    filter: blur(0) drop-shadow(0 12px 32px rgba(8, 145, 178, 0.55))
                    drop-shadow(0 0 40px rgba(103, 232, 249, 0.45));
}

/* Slow ominous breathing — barely-perceptible scale pulse while visible.
   Kicks in AFTER the entry transform finishes (~12 s) so it doesn't fight
   the creep-in. */
@keyframes horror-munki-breathe {
    0%, 100% { transform: translate(0, 0) scale(1); }
    50%      { transform: translate(0, -0.6%) scale(1.015); }
}
body.react-mode-active .horror-munki--moon { animation: horror-munki-breathe 5.2s ease-in-out 12s infinite; }
body.react-mode-active .horror-munki--ice  { animation: horror-munki-breathe 5.6s ease-in-out 12s infinite; }

/* Reduced-motion: no slow creep, no breathing — a short opacity fade so
   the kid is still aware something's happening without the motion. */
@media (prefers-reduced-motion: reduce) {
    .horror-munki { transition: opacity 1.2s ease-in; transform: none !important; filter: none; }
    .horror-munki--moon, .horror-munki--ice { animation: none !important; }
    body.react-mode-active .horror-munki { opacity: 0.7; }
}

/* ====== HEADER ====== */
header {
    padding: 18px 22px 14px;
    border-bottom: 1px solid rgba(45, 212, 191, 0.2);
    position: relative;
}

.header-inner {
    max-width: 1200px;
    margin: 0 auto;
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    align-items: flex-end;
    gap: 14px;
}

.status {
    font-size: 10px;
    color: #ec4899;
    font-weight: 700;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    margin-bottom: 6px;
    text-decoration: underline;
    text-decoration-thickness: 2px;
}

h1 {
    margin: 0;
    font-size: clamp(26px, 5.5vw, 60px);
    font-weight: 800;
    font-style: italic;
    letter-spacing: -0.03em;
    text-transform: uppercase;
    color: #fff;
    line-height: 1;
}

.neon-pink {
    color: #db2777;
    text-shadow: 0 0 10px #db2777, 0 0 20px #db2777;
}

.subtitle {
    margin: 8px 0 0;
    font-size: 11px;
    color: rgba(45, 212, 191, 0.65);
    text-transform: uppercase;
    letter-spacing: 0.2em;
}

.header-buttons {
    display: flex;
    gap: 8px;
    align-items: center;
}

.btn {
    font-family: 'Fredoka', sans-serif;
    font-weight: 700;
    font-size: 16px;
    letter-spacing: 0.12em;
    padding: 10px 20px;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.15s;
    background: transparent;
    min-height: 44px;
}

.btn-remix {
    border: 2px solid #db2777;
    color: #db2777;
    text-shadow: 0 0 8px rgba(219, 39, 119, 0.7);
}
.btn-remix:hover, .btn-remix:active {
    background: #db2777;
    color: #000;
    text-shadow: none;
    transform: scale(1.05);
}

.btn-clear {
    border: 2px solid rgba(45, 212, 191, 0.55);
    color: #2dd4bf;
}
.btn-clear:hover, .btn-clear:active {
    background: rgba(45, 212, 191, 0.15);
    color: #fff;
    border-color: #2dd4bf;
}

/* SONG toggle — glows amber when the base song is on, dims when off. */
.btn-song {
    border: 2px solid #fbbf24;
    color: #fbbf24;
    text-shadow: 0 0 6px rgba(251, 191, 36, 0.55);
}
.btn-song:hover, .btn-song:active {
    background: rgba(251, 191, 36, 0.15);
    color: #fff;
}
.btn-song.off {
    border-color: rgba(251, 191, 36, 0.35);
    color: rgba(251, 191, 36, 0.5);
    text-shadow: none;
}

/* BASS toggle — orange neon, sibling to SONG. Same on/off pattern;
   off state is intentionally less dim than SONG's so the user can still
   read "BASS" clearly at a glance (the bass overlay defaults to off).
   Madballz-mode-only: the bass overlay is a feature of MADBALLZ_SONG,
   so the button is hidden during og rainbow mode and revealed when the
   user enters Madballz mode (same pattern as MEET THE MADBALLZ ↔ BACK). */
.btn-bass { display: none; }
body.madballz-mode .btn-bass { display: inline-block; }
.btn-bass {
    border: 2px solid #fb923c;
    color: #fb923c;
    text-shadow: 0 0 6px rgba(251, 146, 60, 0.55);
}
.btn-bass:hover, .btn-bass:active {
    background: rgba(251, 146, 60, 0.15);
    color: #fff;
}
.btn-bass.off {
    border-color: rgba(251, 146, 60, 0.55);
    color: rgba(251, 146, 60, 0.75);
    text-shadow: none;
}

/* BOO! — big ominous red button that triggers the jumpscare. */
.btn-boo {
    border: 2px solid #ef4444;
    color: #ef4444;
    text-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
    font-weight: 800;
}
.btn-boo:hover, .btn-boo:active {
    background: #ef4444;
    color: #000;
    text-shadow: none;
    transform: scale(1.06);
    box-shadow: 0 0 24px rgba(239, 68, 68, 0.6);
}

.icon-btn {
    width: 44px;
    height: 44px;
    border: 2px solid rgba(45, 212, 191, 0.6);
    border-radius: 8px;
    background: transparent;
    color: #2dd4bf;
    font-size: 20px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.15s;
}

.icon-btn:hover { background: rgba(45, 212, 191, 0.1); }
.icon-btn.muted { color: #db2777; border-color: #db2777; }

/* ====== MAIN / STAGE ======
   main is a vertical flex container that fills the viewport between the
   header and the fixed tray. justify-content: flex-end pins the stage
   to the bottom of main's content area — which lands the Munkis directly
   above the tray, looking like they're standing on the rainbow floor of
   the stage.jpg background photo. */
main {
    /* flex:1 fills the viewport height between the header and the (fixed)
       tray, since body is now a flex column. padding-bottom reserves the
       tray's actual height (set on --tray-h by JS) so the Munkis on stage
       come to rest just above the tray. */
    flex: 1 1 auto;
    width: 100%;
    max-width: 1200px;
    margin: 0 auto;
    padding: 22px 18px calc(var(--tray-h, 240px) + 12px);
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    gap: 20px;
}

/* The frame is gone — no border, no backdrop blur, no dot pattern. The
   Munkis sit directly on the BG photo's rainbow floor. .stage-wrap stays
   in markup so the existing JS hooks (and any descendant selectors) keep
   working, but it's now a transparent positioning shell. */
.stage-wrap {
    position: relative;
}

.stage {
    display: grid;
    grid-template-columns: repeat(6, 1fr);
    gap: 4px;
    position: relative;
    z-index: 1;
    max-width: 960px;
    margin: 0 auto;
}

/* ====== DUAL BAND MODE (v1.1) ======
   Stage splits into two rows of 3 — Row A = slots 0-2, Row B = 3-5.
   The 6 grid items reflow into 2 rows when columns drop to 3. The
   default single-row mode is untouched (these rules are all gated on
   body.dual-band-mode or elements that only exist in the mode). */
body.dual-band-mode .stage {
    grid-template-columns: repeat(3, 1fr);
    row-gap: 22px;
    max-width: 640px;
}
body.dual-band-mode .stage::before {
    content: '';
    position: absolute;
    left: 4%; right: 4%; top: 50%;
    height: 1px;
    transform: translateY(-0.5px);
    background: linear-gradient(90deg, transparent, rgba(45,212,191,0.30), transparent);
    pointer-events: none;
    z-index: 0;
}
/* DUAL BAND toggle — mirrors .btn-song's amber pattern but in the
   project teal so it reads as the v1.1 mode switch sibling. The base
   rule was missing → the button looked invisible. */
.btn-dualband {
    border: 2px solid #2dd4bf;
    color: #2dd4bf;
    text-shadow: 0 0 6px rgba(45, 212, 191, 0.55);
}
.btn-dualband:hover, .btn-dualband:active {
    background: rgba(45, 212, 191, 0.15);
    color: #fff;
}
.btn-dualband.on {
    background: rgba(45, 212, 191, 0.22);
    border-color: #2dd4bf;
    box-shadow: 0 0 16px rgba(45, 212, 191, 0.5);
}
.band-footswitches {
    display: flex;
    justify-content: center;
    gap: 22px;
    padding: 10px 12px 4px;
    flex-wrap: wrap;
}
.band-footswitches[hidden] { display: none; }
.band-foot {
    -webkit-appearance: none; appearance: none;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 4px;
    width: 132px;
    height: 84px;
    border-radius: 16px;
    border: 2px solid rgba(148, 163, 184, 0.55);
    background: linear-gradient(180deg, rgba(30,41,59,0.92), rgba(8,12,20,0.95));
    color: #94a3b8;
    font-family: 'Fredoka', sans-serif;
    font-weight: 800;
    letter-spacing: 0.14em;
    cursor: pointer;
    box-shadow: 0 6px 0 rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.06);
    transition: transform 0.06s, box-shadow 0.12s, border-color 0.12s, color 0.12s, background 0.12s;
    touch-action: manipulation;
}
.band-foot:active { transform: translateY(4px); box-shadow: 0 2px 0 rgba(0,0,0,0.45); }
.band-foot-label { font-size: 13px; }
.band-foot-state { font-size: 16px; letter-spacing: 0.2em; }
.band-foot.lit {
    border-color: #2dd4bf;
    color: #5eead4;
    background: linear-gradient(180deg, rgba(13,148,136,0.55), rgba(6,30,28,0.95));
    box-shadow: 0 6px 0 rgba(0,0,0,0.45), 0 0 22px rgba(45,212,191,0.55),
                inset 0 0 16px rgba(45,212,191,0.35);
}
@media (max-width: 480px) {
    .band-foot { width: 116px; height: 74px; }
}
/* While DUAL BAND is on, any band that's still OFF gently pulses so
   the player knows "tap me to make sound" (the locked design starts
   both bands silent; the pulse advertises the interaction). */
body.dual-band-mode .band-foot:not(.lit) {
    animation: band-foot-attn 1.6s ease-in-out infinite;
}
@keyframes band-foot-attn {
    0%, 100% { box-shadow: 0 6px 0 rgba(0,0,0,0.45), 0 0 0 rgba(45,212,191,0); }
    50%      { box-shadow: 0 6px 0 rgba(0,0,0,0.45), 0 0 18px rgba(45,212,191,0.45); }
}
@media (prefers-reduced-motion: reduce) {
    body.dual-band-mode .band-foot:not(.lit) { animation: none; }
}

/* Slots are invisible grid placeholders — no border, no background, no
   padding. The Munki standing inside is the entire visible element. Empty
   slots reserve the grid column but render nothing so the dance row stays
   balanced as Munkis fill in. */
.stage-slot {
    aspect-ratio: 5 / 6;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-end;
    transition: transform 0.15s, filter 0.2s;
    cursor: pointer;
    position: relative;
    background: transparent;
    border: none;
    padding: 0;
    overflow: visible;
}

/* Filled slots claim the whole touch gesture so a finger drag becomes a
   drag-to-clear instead of a page scroll. Without this, on mobile the
   browser steals the gesture for page-scroll before pointermove ever
   reaches the JS that would have marked the gesture as a drag. Empty
   slots are left at default so they don't interfere with anything. */
.stage-slot.active {
    touch-action: none;
}

/* Active slot gets a soft footlight halo under the Munki to suggest a
   spotlight on the dance floor — no border or filled box. */
.stage-slot.active::before {
    content: '';
    position: absolute;
    bottom: 4%;
    left: 10%;
    right: 10%;
    height: 18%;
    background: radial-gradient(ellipse at 50% 100%, rgba(45, 212, 191, 0.35), transparent 70%);
    pointer-events: none;
    z-index: 0;
}

/* Empty slots stay in the grid (so positions don't reshuffle) but render
   nothing — no "+" placeholder, no label. */
.stage-slot.empty .slot-icon,
.stage-slot.empty .slot-label {
    visibility: hidden;
}

.slot-icon {
    flex: 1;
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    /* visible (not hidden) — the head-bob animation translates the head
       upward by ~20% on each beat. If this box clipped at its edge, the
       top of the head sprite would get cut off at the apex of the bounce.
       .stage-slot is overflow:visible so the head can extend above the
       slot's top edge cleanly. */
    overflow: visible;
    min-height: 0;
    pointer-events: none;
}

.slot-icon.slot-empty {
    align-items: center;
}

.empty-plus {
    font-size: 30px;
    color: rgba(45, 212, 191, 0.32);
    font-weight: 300;
    line-height: 1;
}

.slot-label {
    margin-top: 4px;
    font-size: 10px;
    font-family: 'JetBrains Mono', monospace;
    color: rgba(45, 212, 191, 0.75);
    text-transform: uppercase;
    letter-spacing: 0.1em;
    font-weight: 700;
    text-align: center;
    pointer-events: none;
    /* Single-line lock with ellipsis fallback so the .slot-icon area above
       stays a fixed height across every Munki — the sprite never gets
       squeezed by a wrapping label. Full label is still available via the
       slot's `title` attribute on hover. */
    line-height: 1.1;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    width: 100%;
}

.stage-slot.empty .slot-label { color: rgba(45, 212, 191, 0.32); }

/* ====== CHARACTER ART (body + head) ====== */
/* Container is sized by the parent (.slot-icon / .chip-icon). The body and
   head are absolutely positioned siblings so their bounce animations stay
   independent — head bobs while body squashes/stretches. */
.char-art {
    position: relative;
    height: 100%;
    aspect-ratio: 5 / 7;
    pointer-events: none;
    transform-origin: 50% 100%;
}

.char-body {
    position: absolute;
    bottom: 0;
    left: 0;
    width: 100%;
    height: 65%;
    transform-origin: 50% 100%;
}

.char-head {
    position: absolute;
    top: 0;
    left: 7%;
    width: 86%;
    height: 56%;
    transform-origin: 50% 100%;
    z-index: 2;
}

.char-body svg {
    width: 100%;
    height: 100%;
    display: block;
    filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.45));
}

/* The head is layered: colored circle (shape) → head sprite → headphones
   overlay. All three sibling layers share the same 100×100 footprint so the
   headphones never shift when the sprite frame changes (e.g. when an
   expression flip swaps the Munki's face). */
.char-head .head-shape,
.char-head .head-mod,
.char-head .head-phones {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    display: block;
    pointer-events: none;
}

.char-head .head-shape {
    filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.45));
    z-index: 1;
}

.char-head .head-mod {
    z-index: 2;
}

/* Hair sits between the head sprite and the headphones — peeks out around
   the band/earcups for that "Munki had a wild hairdo BEFORE the headphones
   went on" look. overflow: visible lets spikes / antennae / mohawks extend
   past the SVG box without getting clipped. */
.char-head .char-hair {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    display: block;
    pointer-events: none;
    z-index: 2;
    overflow: visible;
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}

/* Pixel-art rendering hint so the spritesheet keeps its crisp edges when
   scaled down to chip / slot size. Cascade order matters: each browser
   uses the LAST keyword it recognises, so list specific/legacy values
   first and `pixelated` LAST (Chrome/Edge — kills the bilinear bleed
   that was pulling neighbour-frame pixels through the viewBox edge).
   Apply to BOTH the SVG and the inner <image> — the SVG's own raster
   step can smooth too, even when the inner image is pixelated. */
.char-head .head-mod,
.char-head .head-mod image {
    image-rendering: -webkit-optimize-contrast;
    image-rendering: crisp-edges;
    image-rendering: pixelated;
}

/* ===== MADBALLZ-MODE HORROR GATE (v1.1) ===========================
   User asked to strip the ice/moon visual horror entirely from Madballz
   mode while LEAVING the flying creeps + per-Munki react ladder intact
   (those still build dread; only the screen-wide horror overlays + the
   cold-muffle audio are gated). Pure CSS so the underlying horror state
   keeps running for the creeps/reacts to feed off, but the chrome the
   ice/moon trigger normally paints simply isn't rendered. The audio
   gates (setIceMuffle + setReactDrone bypass in Madballz mode) live in
   game.js. */
body.madballz-mode #horror-overlay .bg-dim,
body.madballz-mode #horror-overlay .red-vignette,
body.madballz-mode #horror-overlay .eyes-container,
body.madballz-mode #horror-overlay .ice-wall,
body.madballz-mode #ice-freeze-warp,
body.madballz-mode #ice-freeze-warp::before,
body.madballz-mode #moon-warp,
body.madballz-mode .horror-munki,
body.madballz-mode .snow-fall,
body.madballz-mode .moon-fall {
    display: none !important;
}

.char-head .head-phones {
    z-index: 3;
    filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.55));
    overflow: visible;
}

@keyframes char-body-bounce {
    0%   { transform: scale(1, 1); }
    18%  { transform: scale(1.14, 0.82); }  /* deeper squash on impact */
    52%  { transform: scale(0.92, 1.12); }  /* taller stretch upward */
    78%  { transform: scale(1.04, 0.96); }  /* small overshoot on the way down */
    100% { transform: scale(1, 1); }
}

/* Sillier head bob: deeper dip, much taller lift, plus an overshoot wiggle
   so the head whips a bit before settling. Range was -12%/2°, now -20%/4°
   with a -4%/-2.5° rebound. */
@keyframes char-head-bob {
    0%   { transform: translateY(0) rotate(0); }
    18%  { transform: translateY(3%)  rotate(-3deg); }
    52%  { transform: translateY(-20%) rotate(4deg); }
    78%  { transform: translateY(-4%) rotate(-2.5deg); }
    100% { transform: translateY(0) rotate(0); }
}

.char-art.beat .char-body { animation: char-body-bounce 0.36s ease-out; }
.char-art.beat .char-head { animation: char-head-bob 0.36s ease-out; }

/* ====== TRAY (fixed at bottom) ====== */
.tray-wrap {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    padding: 8px 0 calc(12px + env(safe-area-inset-bottom, 0) + var(--mv-slim-h, 22px));
    /* Tray sits directly on the stage — no translucent panel, no blur.
       A thin teal hairline + a faint top highlight keep enough edge
       definition to read as a separate strip without the frosted slab. */
    background: transparent;
    border-top: 1px solid rgba(45, 212, 191, 0.26);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
    z-index: 10;
}

.tray-hint {
    text-align: center;
    font-size: 10px;
    color: rgba(45, 212, 191, 0.55);
    letter-spacing: 0.18em;
    text-transform: uppercase;
    margin-bottom: 6px;
    padding: 0 12px;
}

/* Two-row wrap layout. Drag-to-place needs touch-action:none on each chip,
   which kills browser scrolling — so the tray must fit every chip without
   scrolling. 7 chips wrap to ~4+3 on phones, 7-in-a-row on tablets+. */
.tray {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 8px;
    padding: 4px 10px 8px;
    max-width: 1100px;
    margin: 0 auto;
}

.tray-chip {
    position: relative;          /* containing block for the .chip-swap badge */
    flex: 0 0 auto;
    width: 92px;
    height: 116px;
    border: 2px solid rgba(45, 212, 191, 0.5);
    border-radius: 14px;
    background: rgba(255, 255, 255, 0.04);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-end;
    cursor: grab;
    transition: transform 0.12s, box-shadow 0.2s, border-color 0.2s, opacity 0.15s;
    touch-action: none;
    user-select: none;
    padding: 5px;
}

.tray-chip:hover {
    transform: translateY(-4px);
    border-color: #2dd4bf;
    box-shadow: 0 0 22px rgba(45, 212, 191, 0.45);
}

.tray-chip:active,
.tray-chip.grabbing {
    transform: scale(0.94);
    transition: transform 0.05s;
    cursor: grabbing;
    opacity: 0.55;
}

/* Floating chip clone that follows the cursor/finger during a drag from
   the tray to the stage. Created in startTrayGhost() and removed on drop
   or cancel. transform handles both translation and the scale-up cue. */
.tray-chip.drag-ghost {
    opacity: 0.92;
    transition: none;
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6),
                0 0 24px rgba(45, 212, 191, 0.55);
    border-color: #2dd4bf;
    pointer-events: none;
}

/* Drop-target glow on the stage slot under the dragging pointer. */
.stage-slot.drop-target {
    outline: 2px dashed #2dd4bf;
    outline-offset: -4px;
    background: rgba(45, 212, 191, 0.18);
    box-shadow: 0 0 24px rgba(45, 212, 191, 0.55) inset;
}

/* Lift the Munki visually while it's being dragged off the stage. If the
   kid releases inside the stage, the class clears and it snaps back. */
.stage-slot.dragging-off .char-art {
    opacity: 0.55;
    transform: translateY(-10%) scale(0.92);
    transition: transform 0.1s, opacity 0.1s;
}

.chip-icon {
    flex: 1;
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    /* visible so a chip's head-bob (if it ever animates) doesn't clip.
       Matches .slot-icon — the head-bob lifts the sprite ~20%. */
    overflow: visible;
    min-height: 0;
    pointer-events: none;
}

.chip-label {
    font-family: 'Fredoka', sans-serif;
    font-size: 11px;
    font-weight: 700;
    color: #fbbf24;
    margin-top: 4px;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    pointer-events: none;
    /* Single-line lock with ellipsis fallback — keeps the .chip-icon
       (sprite area) at a fixed height for every Munki. Full label
       remains available via the chip's `title` attribute on hover. */
    text-align: center;
    line-height: 1.05;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    width: 100%;
}

/* (Desktop tray padding is now driven by --tray-h on main — see the main
   rule above. No fixed override needed.) */

/* ====== MOBILE TUNING ====== */
@media (max-width: 720px) {
    header { padding: 14px 14px 10px; }
    .title-block { flex: 1 1 auto; }
    .header-inner { gap: 10px; align-items: center; }
    .header-buttons { gap: 6px; }
    .btn { font-size: 13px; padding: 8px 12px; letter-spacing: 0.08em; min-height: 40px; }
    .icon-btn { width: 40px; height: 40px; font-size: 18px; }
    .subtitle { font-size: 10px; letter-spacing: 0.14em; }

    main { padding: 14px 10px calc(var(--tray-h, 240px) + 10px); gap: 14px; }
    .stage { gap: 4px; }
    .slot-label { font-size: 9px; letter-spacing: 0.08em; margin-top: 2px; }

    /* Smaller chips on phones so the 7-chip 4+3 wrap doesn't eat the stage.
       Was 76×100 → tray rendered ~371px tall on 360-wide phones (overlapped
       the stage). 64×84 brings tray down to ~230px and keeps tap targets
       comfortable for kids. */
    .tray-chip { width: 64px; height: 84px; padding: 3px; }
    .chip-label { font-size: 9px; }
    .tray-hint { font-size: 9px; letter-spacing: 0.12em; }
}

@media (max-width: 420px) {
    h1 { font-size: 26px; }
    .status { font-size: 9px; letter-spacing: 0.2em; }
    .subtitle { font-size: 9px; }
    main { padding: 12px 6px calc(var(--tray-h, 240px) + 8px); }
    .stage { gap: 3px; }
    .slot-label { font-size: 8px; }
    /* Tiny phones (≤420 — vast majority of Android devices in market):
       chips shrink further so 4+3 wraps in a 220-ish px tray. */
    .tray-chip { width: 58px; height: 78px; padding: 3px; }
    .chip-label { font-size: 8px; }
}

/* ====== MOBILE STAGE + BANK REFLOW (phones only — max-width: 600px) ======
   Phones get a 2×3 stage + a single-row bank. Desktop and tablets
   (≥601px) are deliberately UNTOUCHED: they keep the 1×6 stage strip,
   the larger bank chips, and the existing dual-band rules verbatim. The
   600px cutoff matches the phone-portrait stage-art query at the top of
   this file (and excludes ≥768px tablets-in-portrait by the same logic).
   Every rule here is scoped inside this query — nothing leaks wider. */
@media (max-width: 600px) {
    /* Stage is ALWAYS two rows of three on phones, in BOTH modes (visual
       symmetry preserved regardless of mode). Single-band and dual-band
       share this geometry; dual-band only layers on the centre divider
       (.stage::before, already defined) + the footswitches + the per-row
       band semantics (slots 0-2 = Row A / Band 1 top, slots 3-5 = Row B
       / Band 2 bottom — that mapping already exists in game.js; the data
       model is unchanged at 6 slots, so every achievement that reads the
       slots[] array — Solid Squad, Pattern Maker, Solid Sequence — keeps
       working with zero logic change; this is a pure CSS reflow). The
       row-gap + max-width EASE over 250ms so toggling into dual-band
       visibly "splits" the two rows apart — the player sees the change. */
    .stage {
        grid-template-columns: repeat(3, 1fr);
        column-gap: 6px;
        row-gap: 10px;
        max-width: 440px;
        transition: row-gap 0.25s ease, max-width 0.25s ease;
    }
    body.dual-band-mode .stage {
        grid-template-columns: repeat(3, 1fr);
        row-gap: 30px;          /* rows part further — the visible cue */
        max-width: 440px;
    }

    /* Single-row bank. flex:1 evenly distributes the per-bank chips
       (6 rainbow + the evil 7th-wheel swap chip; 8 only in the rare
       both-evils case) across the width with NO wrap, replacing the old
       4+3 wrap that ate the stage. min-width:0 lets them shrink to fit.
       Touch-target math (worst cases):
         360px / 7 chips → ~46px wide   (≥ the 44px mobile guideline)
         360px / 8 chips → ~41px wide   (~3px under 44 — the only
                                          sub-guideline case; the chip is
                                          still ~84px TALL so the actual
                                          tap area stays large for a kid)
         390–414px       → ~48–55px wide (comfortable)
       Reclaiming the wrapped second row also frees ~110px of vertical
       height — that's the room the bigger 2×3 Munkis now use. */
    .tray { flex-wrap: nowrap; gap: 4px; padding: 4px 6px 6px; }
    .tray-chip {
        flex: 1 1 0;
        min-width: 0;
        width: auto;
        height: 84px;
        padding: 4px 2px;
    }
    .chip-icon { min-width: 0; }
    .chip-label { font-size: 9px; letter-spacing: 0; }
    .tray-hint  { font-size: 9px; letter-spacing: 0.1em; }

    /* Footswitches sit comfortably on a 360px row (2 × 104 + 22 gap =
       230px). They're display:none via [hidden] until JS enters
       dual-band, so their appearance is itself a clear mode cue
       alongside the 250ms row-split ease above. */
    .band-foot { width: 104px; height: 64px; }
    .band-foot-label { font-size: 11px; }
    .band-foot-state { font-size: 14px; }
}

/* (Was: explicit padding-bottom on short phones. Now main reads --tray-h
   on every breakpoint so the value auto-tracks the actual tray height.) */

/* ====== JUMP SCARE ======
   When body.jumpscare is set, four things happen at once:
     1. A red radial vignette flashes across the viewport (::after on body).
     2. The stage section shakes hard.
     3. Active Munki characters glitch — color shift + jitter.
     4. A giant "BOO!" overlay punches in then fades.
   Everything is keyframe-driven so it cleanly resets when the class is
   removed after 1.5s. The fixed tray and header are intentionally NOT
   shaken so the kid still has something stable to look at. */
.boo-overlay {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: 'Fredoka', sans-serif;
    font-weight: 800;
    font-style: italic;
    font-size: clamp(80px, 25vw, 280px);
    color: #fff;
    letter-spacing: -0.04em;
    text-shadow:
        0 0 30px #ff0000,
        0 0 60px #800000,
        4px 4px 0 #000,
        -4px -4px 0 #000;
    pointer-events: none;
    z-index: 9999;
    opacity: 0;
    transform: scale(0.4);
    -webkit-text-stroke: 3px #000;
}

body.jumpscare .boo-overlay {
    animation: boo-show 1.4s cubic-bezier(0.2, 0.9, 0.3, 1) forwards;
}

body.jumpscare::after {
    content: '';
    position: fixed;
    inset: 0;
    background: radial-gradient(circle at 50% 50%, rgba(255, 0, 0, 0.5), rgba(120, 0, 0, 0.85));
    pointer-events: none;
    z-index: 9998;
    animation: jumpscare-flash 1.5s ease-out forwards;
}

/* Ice-trigger jumpscare uses a CYAN radial flash + cyan BOO! glow
   instead of the default red. Body has both `.jumpscare` and
   `.ice-on-stage` set during ice-triggered jumpscares, so this 2-class
   selector wins on specificity over the default red ::after above.
   Mixed Moon+Ice scenario: ice wins (consistent with how the ice-wall
   overrides the red-vignette in mixed-trigger horror). */
body.jumpscare.ice-on-stage::after {
    background: radial-gradient(circle at 50% 50%, rgba(34, 211, 238, 0.55), rgba(8, 47, 73, 0.85));
}
body.jumpscare.ice-on-stage .boo-overlay {
    text-shadow:
        0 0 30px #06b6d4,
        0 0 60px #155e75,
        4px 4px 0 #000,
        -4px -4px 0 #000;
}

body.jumpscare main {
    animation: jumpscare-shake 0.55s linear;
}

body.jumpscare .stage-slot.active .char-art {
    animation: char-glitch 0.18s steps(2) infinite;
}

@keyframes jumpscare-flash {
    0%   { opacity: 0; }
    8%   { opacity: 1; }
    20%  { opacity: 0.55; }
    32%  { opacity: 0.95; }
    50%  { opacity: 0.4; }
    100% { opacity: 0; }
}

@keyframes jumpscare-shake {
    0%, 100% { transform: translate(0, 0) rotate(0); }
    10% { transform: translate(-9px, 5px) rotate(-0.6deg); }
    20% { transform: translate(8px, -4px) rotate(0.6deg); }
    30% { transform: translate(-6px, 6px) rotate(-0.4deg); }
    40% { transform: translate(7px, -5px) rotate(0.5deg); }
    50% { transform: translate(-8px, 3px) rotate(-0.3deg); }
    60% { transform: translate(5px, -6px) rotate(0.4deg); }
    70% { transform: translate(-4px, 5px) rotate(-0.2deg); }
    80% { transform: translate(6px, -3px) rotate(0.3deg); }
    90% { transform: translate(-3px, 4px) rotate(-0.1deg); }
}

@keyframes char-glitch {
    0%   { filter: hue-rotate(0deg) saturate(1) brightness(1); transform: translate(0, 0); }
    33%  { filter: hue-rotate(180deg) saturate(3) contrast(2); transform: translate(-2px, 1px) scale(1.05); }
    66%  { filter: invert(1) saturate(2.5) brightness(1.1); transform: translate(2px, -1px) scale(0.97); }
    100% { filter: hue-rotate(0deg) saturate(1) brightness(1); transform: translate(0, 0); }
}

@keyframes boo-show {
    0%  { opacity: 0; transform: scale(0.4) rotate(-8deg); }
    12% { opacity: 1; transform: scale(1.25) rotate(3deg); }
    20% { opacity: 1; transform: scale(1.0)  rotate(-2deg); }
    35% { opacity: 1; transform: scale(1.08) rotate(1.5deg); }
    55% { opacity: 1; transform: scale(1.0)  rotate(-1deg); }
    100%{ opacity: 0; transform: scale(0.85) rotate(0); }
}

/* Honour reduced-motion preferences — keep the visual punch subtle. */
@media (prefers-reduced-motion: reduce) {
    body.jumpscare main { animation: none; }
    body.jumpscare .stage-slot.active .char-art { animation: none; filter: hue-rotate(180deg); }
    body.jumpscare .boo-overlay { animation: boo-show-reduced 1.2s ease-out forwards; }
    @keyframes boo-show-reduced {
        0%   { opacity: 0; transform: scale(0.9); }
        20%  { opacity: 1; transform: scale(1); }
        80%  { opacity: 1; transform: scale(1); }
        100% { opacity: 0; transform: scale(1); }
    }
}

/* ====== STORY / MADBALLZ HEADER BUTTONS ====== */
/* STORY — opens the lore modal. Soft lavender so it sits between the cool
   cyan UI chrome and the warm button colors without competing. */
.btn-story {
    border: 2px solid #a78bfa;
    color: #a78bfa;
    text-shadow: 0 0 6px rgba(167, 139, 250, 0.55);
}
.btn-story:hover, .btn-story:active {
    background: rgba(167, 139, 250, 0.18);
    color: #fff;
    border-color: #c4b5fd;
}

/* MEET THE MADBALLZ — only revealed once the kid has tripped horror mode
   the threshold number of times. The .reveal modifier plays a one-shot
   "look at this!" pulse the first time it appears in a session. */
.btn-madballz {
    border: 2px solid #ef4444;
    color: #fca5a5;
    background: linear-gradient(135deg, rgba(239, 68, 68, 0.06), rgba(168, 85, 247, 0.06));
    text-shadow: 0 0 8px rgba(239, 68, 68, 0.7);
    font-weight: 800;
    box-shadow: 0 0 12px rgba(239, 68, 68, 0.25);
}
.btn-madballz:hover, .btn-madballz:active {
    background: linear-gradient(135deg, rgba(239, 68, 68, 0.28), rgba(168, 85, 247, 0.28));
    color: #fff;
    transform: scale(1.05);
    box-shadow: 0 0 24px rgba(239, 68, 68, 0.6);
}
.btn-madballz.reveal {
    animation: madballz-reveal 1.6s cubic-bezier(0.2, 0.85, 0.3, 1);
}
@keyframes madballz-reveal {
    0%   { transform: scale(0.4) rotate(-8deg); opacity: 0; box-shadow: 0 0 0 rgba(239, 68, 68, 0); }
    25%  { transform: scale(1.18) rotate(2deg);  opacity: 1; box-shadow: 0 0 32px rgba(239, 68, 68, 0.85); }
    50%  { transform: scale(1.0)  rotate(-1deg); box-shadow: 0 0 16px rgba(168, 85, 247, 0.7); }
    75%  { transform: scale(1.06) rotate(0.5deg); box-shadow: 0 0 24px rgba(239, 68, 68, 0.55); }
    100% { transform: scale(1) rotate(0); box-shadow: 0 0 12px rgba(239, 68, 68, 0.25); }
}

/* BACK — only visible inside Madballz mode. Calm cyan to contrast the
   red/purple Madballz palette and read as "exit door". */
.btn-back {
    border: 2px solid #2dd4bf;
    color: #2dd4bf;
    text-shadow: 0 0 6px rgba(45, 212, 191, 0.55);
}
.btn-back:hover, .btn-back:active {
    background: rgba(45, 212, 191, 0.18);
    color: #fff;
}

/* ====== ANTAGONIST + MADBALLZ CHIP/SLOT ACCENTS ====== */
/* Ice Munki + Moon Munki tray chips wear a faint red border so the kid
   can spot the antagonists at a glance even before reading the label. */
.tray-chip.chip-bad {
    border-color: rgba(239, 68, 68, 0.65);
}
.tray-chip.chip-bad:hover {
    border-color: #ef4444;
    box-shadow: 0 0 22px rgba(239, 68, 68, 0.5);
}
.tray-chip.chip-bad .chip-label { color: #fca5a5; }

/* Once placed on stage, antagonists keep the red accent — but on the
   frame-less stage we express it as a red footlight under the Munki
   (overriding the default cyan halo) plus the label color. */
.stage-slot.active.slot-bad::before {
    background: radial-gradient(ellipse at 50% 100%, rgba(239, 68, 68, 0.5), transparent 70%);
}
.stage-slot.active.slot-bad .slot-label { color: rgba(252, 165, 165, 0.85); }

/* ====== MADBALLZ MODE (whole-screen palette swap) ====== */
/* Activated by adding `body.madballz-mode` when the player taps "MEET THE
   MADBALLZ". Tints the whole screen red/violet to signal "you are in the
   bad neighborhood now". The stage + tray + chips all retint together.
   The body background-image is owned by --bg-madballz-normal (see the
   per-mode BG block at the top of this file); this rule only tweaks
   chrome colors. */
body.madballz-mode {
    color: #f0abfc;
}
body.madballz-mode header {
    border-bottom-color: rgba(239, 68, 68, 0.32);
}
body.madballz-mode .status { color: #ef4444; }
body.madballz-mode .subtitle { color: rgba(239, 68, 68, 0.7); }
/* (Dormant: previously re-themed the .stage-wrap frame in Madballz mode.
   The frame is gone in the redesign — Munkis stand directly on the BG.) */
/* Madballz dance floor — recolour the active slot footlight halo to the
   purple palette so the spotlight reads against the red/violet BG. */
body.madballz-mode .stage-slot.active::before {
    background: radial-gradient(ellipse at 50% 100%, rgba(168, 85, 247, 0.45), transparent 70%);
}
body.madballz-mode .stage-slot .slot-label { color: rgba(240, 171, 252, 0.85); }
body.madballz-mode .tray-wrap {
    border-top-color: rgba(168, 85, 247, 0.4);
    background: linear-gradient(to top, rgba(0, 0, 0, 0.96) 60%, rgba(40, 0, 40, 0.6));
}
body.madballz-mode .tray-hint { color: rgba(240, 171, 252, 0.65); }
body.madballz-mode .tray-chip {
    border-color: rgba(168, 85, 247, 0.55);
}
body.madballz-mode .tray-chip:hover {
    border-color: #c084fc;
    box-shadow: 0 0 22px rgba(168, 85, 247, 0.55);
}
body.madballz-mode .chip-label { color: #f0abfc; }

/* ====== STORY MODAL ====== */
.story-modal {
    position: fixed;
    inset: 0;
    z-index: 9000;
    display: none;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.78);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    padding: 22px;
}
.story-modal.open {
    display: flex;
    animation: story-fade-in 0.28s ease-out;
}
.story-card {
    position: relative;
    max-width: 540px;
    width: 100%;
    max-height: 86vh;
    overflow-y: auto;
    background: linear-gradient(180deg, #0b0b18, #14081a);
    border: 1px solid rgba(167, 139, 250, 0.55);
    box-shadow: 0 0 40px rgba(167, 139, 250, 0.35);
    border-radius: 18px;
    padding: 32px 26px 26px;
    color: #e2e8f0;
    font-family: 'JetBrains Mono', monospace;
    line-height: 1.5;
    animation: story-card-rise 0.32s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.story-close {
    position: absolute;
    top: 10px;
    right: 12px;
    width: 36px;
    height: 36px;
    border-radius: 10px;
    border: 1px solid rgba(167, 139, 250, 0.5);
    background: transparent;
    color: #a78bfa;
    font-size: 24px;
    line-height: 1;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
}
.story-close:hover { background: rgba(167, 139, 250, 0.2); color: #fff; }
.story-read {
    position: absolute;
    top: 10px;
    left: 12px;
    height: 36px;
    padding: 0 12px;
    border-radius: 10px;
    border: 1px solid rgba(167, 139, 250, 0.5);
    background: transparent;
    color: #a78bfa;
    font-family: 'Fredoka', sans-serif;
    font-size: 14px;
    font-weight: 700;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    gap: 6px;
    line-height: 1;
}
.story-read:hover { background: rgba(167, 139, 250, 0.2); color: #fff; }
.story-read.speaking {
    background: rgba(239, 68, 68, 0.22);
    border-color: #fca5a5;
    color: #fff;
}
.story-title {
    margin: 0 0 16px;
    padding: 0 44px 0 96px;
    font-family: 'Fredoka', sans-serif;
    font-size: 24px;
    font-weight: 800;
    font-style: italic;
    text-transform: uppercase;
    color: #fff;
    text-shadow: 0 0 10px rgba(167, 139, 250, 0.45);
    letter-spacing: -0.01em;
}
.story-body p {
    margin: 0 0 12px;
    font-size: 14px;
    color: rgba(226, 232, 240, 0.92);
}
.story-body strong { color: #fff; }
.story-body em { color: #fbbf24; font-style: italic; }
.bank-switcher {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 12px;
    padding: 6px 0 4px;
    font-family: 'Fredoka', sans-serif;
}
/* The .bank-switcher rule above forces display:flex, beating the UA's
   [hidden] { display:none } default, so we restore it explicitly. */
.bank-switcher[hidden] { display: none; }
.bank-arrow {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    border: 1px solid rgba(255, 255, 255, 0.28);
    background: rgba(255, 255, 255, 0.06);
    color: #fff;
    font-size: 22px;
    line-height: 1;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}
.bank-arrow:hover { background: rgba(255, 255, 255, 0.16); }
.bank-arrow:disabled { opacity: 0.3; cursor: not-allowed; }
.bank-label {
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 0.08em;
    color: #00ffcc;
    text-shadow: 0 0 8px rgba(0, 255, 204, 0.45);
    min-width: 88px;
    text-align: center;
}
.bank-label.bank-locked { color: #94a3b8; text-shadow: none; }

.story-body .lore-bad  { color: #fca5a5; text-shadow: 0 0 8px rgba(239, 68, 68, 0.5); }
.story-body .lore-good { color: #fde68a; text-shadow: 0 0 8px rgba(0, 0, 0, 0.6), 0 0 8px rgba(167, 139, 250, 0.4); }
.story-body .lore-mb   { color: #d8b4fe; text-shadow: 0 0 8px rgba(168, 85, 247, 0.55); }
.story-body .story-hint {
    margin-top: 14px !important;
    padding-top: 12px;
    border-top: 1px dashed rgba(167, 139, 250, 0.35);
    color: rgba(167, 139, 250, 0.85);
    font-style: italic;
}
@keyframes story-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes story-card-rise {
    from { transform: translateY(24px) scale(0.96); opacity: 0; }
    to   { transform: translateY(0) scale(1); opacity: 1; }
}

/* Mobile tweak — story title wraps cleanly on narrow screens. */
@media (max-width: 480px) {
    .story-card { padding: 26px 18px 20px; }
    .story-title { font-size: 20px; }
    .story-body p { font-size: 13px; }
}

/* Reduced-motion: skip the reveal pulse + modal slide. */
@media (prefers-reduced-motion: reduce) {
    .btn-madballz.reveal { animation: none; }
    .story-modal.open { animation: none; }
    .story-card { animation: none; }
}

/* ====== ICE FREEZE (cold / FNAF — no cartoon RIP) ======
   When Ice Munki lands on stage, every other Munki freezes. The vibe
   is cold dread, NOT a morbidly-cute gag: the Munki goes rigid +
   drained (desaturated, darker), trembles, and frost cracks silently
   encase the slot. No "RIP" text, no spinning emoji, no comic bounce.
   Class is toggled in updateIceFreeze() in game.js. */
.stage-slot.frozen-by-ice {
    /* Slot itself has no frame on the frame-less stage; instead, replace
       the active spotlight under the Munki with an icy cyan halo so the
       footing reads cold. */
    filter: drop-shadow(0 0 12px rgba(103, 232, 249, 0.55));
}
.stage-slot.frozen-by-ice::before {
    background: radial-gradient(ellipse at 50% 100%, rgba(103, 232, 249, 0.55), transparent 70%) !important;
}

.stage-slot.frozen-by-ice .char-art {
    filter: hue-rotate(178deg) saturate(0.22) brightness(0.82) contrast(1.18);
    animation: ice-shiver 0.6s ease-in-out infinite !important;
}

/* Bouncing continues WHILE the ice is climbing from feet → head; the
   bounce only stops once the freeze is fully encased (JS adds the
   .frozen-encased class after ICE_ENCASE_MS — see updateIceFreeze).
   That reads as "the cold is creeping up; once it reaches the head,
   you're frozen solid". */
.stage-slot.frozen-encased .char-art.beat .char-body,
.stage-slot.frozen-encased .char-art.beat .char-head {
    animation: none !important;
}

@keyframes ice-shiver {
    0%, 100% { transform: translateX(0) rotate(0); }
    25%      { transform: translateX(-0.6px) rotate(-0.6deg); }
    75%      { transform: translateX(0.6px)  rotate(0.6deg); }
}

/* Static frost shard at the corner — a sharp drawn ice crystal, NOT a
   spinning cartoon snowflake glyph. No emoji, no animation. */
.stage-slot.frozen-by-ice .slot-icon::before {
    content: '';
    position: absolute;
    top: 5px;
    right: 6px;
    width: 13px;
    height: 17px;
    background: linear-gradient(150deg, #fff 0%, #a5f3fc 45%, rgba(103, 232, 249, 0.25) 100%);
    clip-path: polygon(50% 0, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0 50%, 39% 39%);
    filter: drop-shadow(0 0 6px rgba(103, 232, 249, 0.85));
    opacity: 0.9;
    z-index: 5;
    pointer-events: none;
}

/* Ice CLIMBS the slot from feet → head: a bottom-anchored frost
   layer whose HEIGHT animates 0% → 105% over ICE_ENCASE_MS (~3.5 s).
   The top edge of the layer fades to transparent so it reads as a
   rising ice level rather than a slab dropping in. JS adds
   .frozen-encased the moment it tops out (matches the animation
   duration) — at that point the beat-bounce is suppressed (see
   .frozen-encased rule above) and the Munki is locked in ice. */
.stage-slot.frozen-by-ice::after {
    content: '';
    position: absolute;
    left: 0; right: 0; bottom: 0;
    height: 0;
    /* Real ice-chunk encasement (rawpixel). The image stretches with
       the animated height so the Munki appears to be entombed as the
       ice rises feet → head; texture/cracks come from the photo. */
    background-image: url('assets/sprites/ice-encase.png');
    background-size: 100% 100%;
    background-repeat: no-repeat;
    background-position: bottom center;
    opacity: 0.92;
    animation: ice-climb 3.5s ease-in-out forwards;
    z-index: 7;
    pointer-events: none;
}
@keyframes ice-climb {
    from { height: 0%;   }
    to   { height: 105%; }
}
/* Once fully encased: a faint cold vignette settles over the whole
   slot (the world has gone dark behind the ice) and the climbing
   layer holds at full. */
.stage-slot.frozen-encased {
    box-shadow: inset 0 0 28px rgba(6, 18, 28, 0.45);
}

.stage-slot.frozen-by-ice .slot-label {
    color: #67e8f9 !important;
    text-shadow: 0 0 6px rgba(103, 232, 249, 0.6);
}

/* Ice Munki itself wears the cold but isn't frozen — give it a frost
   halo footlight + bluish drop-shadow so the kid sees who's doing the
   freezing on the frame-less stage. */
body.ice-on-stage .stage-slot.active.slot-bad[data-char="ice"]::before {
    background: radial-gradient(ellipse at 50% 100%, rgba(103, 232, 249, 0.6), transparent 70%);
}
body.ice-on-stage .stage-slot.active.slot-bad[data-char="ice"] {
    filter: drop-shadow(0 0 14px rgba(103, 232, 249, 0.55));
    box-shadow:
        inset 0 0 30px rgba(103, 232, 249, 0.35),
        0 0 28px rgba(103, 232, 249, 0.45);
    border-color: #67e8f9;
}

@media (prefers-reduced-motion: reduce) {
    .stage-slot.frozen-by-ice .char-art { animation: none !important; }
    .stage-slot.frozen-by-ice .slot-icon::before { animation: none; }
    /* No climb — snap straight to fully encased. */
    .stage-slot.frozen-by-ice::after { animation: none; height: 105%; }
}

/* ====== MOON RULES (chaos events fired by clicks while Moon is on stage) ====== */
body.moon-hue {
    animation: moon-hue-shift 0.85s ease-in-out;
}
@keyframes moon-hue-shift {
    0%, 100% { filter: hue-rotate(0); }
    50%      { filter: hue-rotate(220deg) brightness(1.08); }
}

body.moon-invert {
    animation: moon-invert-flash 0.28s steps(2);
}
@keyframes moon-invert-flash {
    0%, 100% { filter: invert(0); }
    50%      { filter: invert(1) hue-rotate(180deg); }
}

/* ====== JEALOUSY FLAVOR ======
   The 7th-wheel chip (in the bank) and the altar chip both wear the .sulk
   class. Idle: a slow sigh-droop. .sulk-deep (rainbow on stage is complete):
   the lonely chip dims and droops further as if to say "they finished
   without me". Speech bubbles pop on tap with kid-friendly jealous lines. */
.tray-chip.sulk {
    animation: sulk-sigh 5s ease-in-out infinite;
}
@keyframes sulk-sigh {
    0%, 100% { transform: translateY(0)   rotate(0); }
    50%      { transform: translateY(2px) rotate(-1.2deg); }
}
.tray-chip.sulk.sulk-deep {
    animation: sulk-sigh-deep 4.2s ease-in-out infinite;
    filter: saturate(0.7) brightness(0.85);
}
@keyframes sulk-sigh-deep {
    0%, 100% { transform: translateY(0)   rotate(0); }
    50%      { transform: translateY(5px) rotate(-2.4deg); }
}
/* Don't sulk while the kid is actively grabbing — looks broken otherwise. */
.tray-chip.sulk.grabbing { animation: none; }

/* Speech bubble — small floating chat tooltip above the chip. */
.speech-bubble {
    position: fixed;
    z-index: 60;
    transform: translate(-50%, -100%) scale(0.85);
    background: rgba(15, 23, 42, 0.95);
    color: #fef9c3;
    font-family: 'Fredoka', sans-serif;
    font-size: 12px;
    font-weight: 600;
    letter-spacing: 0.02em;
    padding: 7px 12px;
    border-radius: 12px;
    border: 1px solid rgba(253, 230, 138, 0.4);
    box-shadow: 0 4px 18px rgba(0, 0, 0, 0.55);
    pointer-events: none;
    white-space: nowrap;
    max-width: 90vw;
    text-overflow: ellipsis;
    opacity: 0;
    transition: opacity 0.32s ease, transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.speech-bubble.shown {
    opacity: 1;
    transform: translate(-50%, -100%) scale(1);
}
.speech-bubble::after {
    content: '';
    position: absolute;
    bottom: -6px;
    left: 50%;
    transform: translateX(-50%);
    border: 6px solid transparent;
    border-top-color: rgba(15, 23, 42, 0.95);
    border-bottom: 0;
}

/* ====== MUNKI ALTAR — RETIRED ======
   The Ice <-> Moon swap is now a tap-to-swap badge on the 7th-wheel
   bank chip (.chip-swap, below). The old #munkiAltar element is kept
   in the DOM but always hidden, so the bank is a single clean 7-chip
   row with no dangling extra chip. (.swap-target / .altar-* rules
   further down are now dead CSS, left in place — zero risk.) */
.munki-altar { display: none !important; }

/* Tap-to-swap badge on the 7th-wheel chip — added by renderTray only
   after Moon unlocks. Compact modern pill in a cold FNAF purple, not a
   childish emoji. Tapping it swaps which evil rides the bank slot. */
.chip-swap {
    position: absolute;
    top: 4px;
    right: 4px;
    display: inline-flex;
    align-items: center;
    gap: 3px;
    padding: 2px 7px;
    font-family: 'Fredoka', sans-serif;
    font-size: 8px;
    font-weight: 700;
    letter-spacing: 0.1em;
    color: #ddd6fe;
    background: rgba(76, 29, 149, 0.5);
    border: 1px solid rgba(167, 139, 250, 0.65);
    border-radius: 999px;
    cursor: pointer;
    z-index: 6;
    line-height: 1;
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    box-shadow: 0 0 10px rgba(167, 139, 250, 0.4);
    touch-action: manipulation;
    transition: background 0.15s, border-color 0.15s, transform 0.1s;
}
.chip-swap:hover  { background: rgba(109, 40, 217, 0.72); border-color: #c4b5fd; }
.chip-swap:active { transform: scale(0.92); }
.chip-swap-arrow  { font-size: 11px; font-weight: 400; }
/* Small phone chips: drop the word, the arrow alone reads. */
@media (max-width: 480px) {
    .chip-swap     { padding: 3px 5px; top: 3px; right: 3px; gap: 0; }
    .chip-swap-txt { display: none; }
}

/* Highlight the bank chip that's a valid swap drop target during an
   altar drag — mirrors the .drop-target glow on stage slots. */
.tray-chip.swap-target {
    border-color: #c4b5fd;
    box-shadow: 0 0 22px rgba(167, 139, 250, 0.7);
    animation: swap-target-pulse 0.9s ease-in-out infinite;
}
@keyframes swap-target-pulse {
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.06); }
}

/* ====== EASTER-EGG COUNTER + MOON REVEAL ======
   The counter chip lives top-right of the viewport. It stays hidden until
   the kid finds their first egg, then fades in and animates each bump.
   The hidden corner hotspots catch the 4-corners egg without leaving any
   visible mark. The moon-reveal overlay celebrates the 5th find. */
.egg-counter {
    position: fixed;
    /* Sits BELOW the .madder-controls cluster (fixed top-right, wraps to
       up to ~2 rows on phones). 92px clears a wrapped row so the moon-
       points chip never hides behind BOO/SONG/mute. */
    top: calc(env(safe-area-inset-top, 0) + 92px);
    right: calc(env(safe-area-inset-right, 0) + 10px);
    z-index: 50;
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 6px 10px;
    border-radius: 999px;
    background: rgba(15, 23, 42, 0.78);
    border: 1px solid rgba(167, 139, 250, 0.5);
    color: #e9d5ff;
    font-family: 'Fredoka', sans-serif;
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.08em;
    box-shadow: 0 0 14px rgba(167, 139, 250, 0.3);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    opacity: 0;
    transform: translateY(-6px);
    transition: opacity 0.45s ease, transform 0.45s ease;
    pointer-events: none;
}
.egg-counter.shown {
    opacity: 1;
    transform: translateY(0);
    /* The chip becomes a button once visible — opens the achievements panel
       on tap. Default visibility uses pointer-events:none to ignore stray
       taps before any unlock; .shown lifts that. */
    pointer-events: auto;
    cursor: pointer;
}
.egg-counter.shown:hover { background: rgba(15, 23, 42, 0.92); }
.egg-counter.shown:active { transform: translateY(0) scale(0.95); }
.egg-counter.bump {
    animation: egg-counter-bump 0.55s ease-out;
}
.egg-counter.found-all {
    color: #fde68a;
    border-color: rgba(251, 191, 36, 0.7);
    box-shadow: 0 0 18px rgba(251, 191, 36, 0.45);
}
.egg-counter[hidden] { display: none; }
@keyframes egg-counter-bump {
    0%   { transform: translateY(0)    scale(1); }
    35%  { transform: translateY(-3px) scale(1.18); }
    100% { transform: translateY(0)    scale(1); }
}

/* Corner hotspots — invisible 60×60 squares anchored to each viewport
   corner. Sit above the page chrome so taps register even on top of the
   header and footer. */
.corner-egg {
    position: fixed;
    width: 60px;
    height: 60px;
    z-index: 40;
    pointer-events: auto;
    background: transparent;
}
.corner-tl { top: 0;    left: 0; }
.corner-tr { top: 0;    right: 0; }
.corner-br { bottom: 0; right: 0; }
.corner-bl { bottom: 0; left: 0; }

/* Moon reveal — full-screen dimmer + centered card with a big floating
   moon glyph. open class flips opacity + scale; tapping anywhere on the
   overlay also dismisses early (handled in JS). */
.moon-reveal {
    position: fixed;
    inset: 0;
    z-index: 200;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(8, 8, 24, 0);
    opacity: 0;
    pointer-events: none;
    transition: background 0.5s ease, opacity 0.5s ease;
}
.moon-reveal.open {
    background: rgba(8, 8, 24, 0.82);
    opacity: 1;
    pointer-events: auto;
}
.moon-reveal-card {
    text-align: center;
    color: #fbeffb;
    font-family: 'Fredoka', sans-serif;
    transform: scale(0.7);
    transition: transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.moon-reveal.open .moon-reveal-card { transform: scale(1); }
.moon-reveal-glyph {
    font-size: 96px;
    line-height: 1;
    filter: drop-shadow(0 0 24px rgba(167, 139, 250, 0.8));
    animation: moon-float 3.2s ease-in-out infinite;
    animation-play-state: paused;
}
.moon-reveal.open .moon-reveal-glyph { animation-play-state: running; }
@keyframes moon-float {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-10px); }
}
.moon-reveal-title {
    margin-top: 18px;
    font-size: 22px;
    font-weight: 700;
    letter-spacing: 0.14em;
    color: #e9d5ff;
    text-shadow: 0 0 14px rgba(167, 139, 250, 0.7);
}
.moon-reveal-sub {
    margin-top: 8px;
    font-size: 14px;
    letter-spacing: 0.08em;
    color: rgba(233, 213, 255, 0.78);
}

/* Tilt only the playfield — header/tray stay anchored so the kid still
   has something stable to read. */
body.moon-tilt main {
    animation: moon-tilt-shake 0.7s ease-in-out;
    transform-origin: 50% 50%;
}
@keyframes moon-tilt-shake {
    0%, 100% { transform: rotate(0); }
    25%      { transform: rotate(2.2deg); }
    55%      { transform: rotate(-1.6deg); }
    80%      { transform: rotate(0.8deg); }
}

.moon-rain {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 9500;
    overflow: hidden;
}
.moon-rain span {
    position: absolute;
    top: -10vh;
    animation: moon-fall 2.8s ease-in forwards;
    text-shadow: 0 0 16px #93c5fd;
}
@keyframes moon-fall {
    0%   { transform: translateY(0) rotate(0); opacity: 0; }
    10%  { opacity: 1; }
    100% { transform: translateY(115vh) rotate(360deg); opacity: 0.65; }
}

/* ====== FALLING MOON SPRITES (v1.1 atmospheric horror) ======
   Distinct from .moon-rain above (the brief Moon-chaos splash at
   z 9500). This is sustained background precipitation while Moon-
   horror is fully engaged: ABOVE the stage/Munkis (z 1) but BELOW
   the tray/UI (tray-wrap z 10). Container is built/torn down in JS
   (startMoonFall/stopMoonFall); each sprite's left / --amp /
   animation-duration are set inline per-sprite. */
.moon-fall {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 9;
    overflow: hidden;
    transition: opacity 2.5s ease;
}
.moon-fall.fading { opacity: 0; }
.moon-fall span {
    position: absolute;
    top: -12vh;
    will-change: transform, opacity;
    animation-name: moon-drift;
    animation-timing-function: linear;
    animation-fill-mode: forwards;
}
@keyframes moon-drift {
    0%   { transform: translate(0, -12vh);                          opacity: 0; }
    8%   {                                                          opacity: 0.85; }
    25%  { transform: translate(var(--amp, 8px), 22vh); }
    50%  { transform: translate(calc(var(--amp, 8px) * -1), 52vh); }
    75%  { transform: translate(var(--amp, 8px), 80vh);             opacity: 0.8; }
    100% { transform: translate(0, 114vh);                          opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
    .moon-fall span { animation-duration: 6s !important; }
    @keyframes moon-drift {
        0%   { transform: translateY(-12vh); opacity: 0; }
        10%  { opacity: 0.8; }
        100% { transform: translateY(114vh); opacity: 0; }
    }
}

/* Comets — bigger, SLOW, shaky, glitchy DIAGONAL falls woven into the
   same Moon-horror rain. A haunted shooting star that doesn't know
   where it's going. Three stacked animations on the one span, on
   non-conflicting properties:
     • comet-streak  (transform + opacity) — the slow LINEAR diagonal
       path over the per-comet --dur (4–7 s); no ease-out whip; just a
       quick fade-in and a fade-out at the floor. --dx = horizontal
       travel, --rot = sprite tilt along the diagonal.
     • comet-jitter  (the independent `translate:` property) — fast
       jerky ±2-4px wobble so it looks unstable.
     • comet-flicker (`filter` only — drop-shadow glow + filter-opacity
       + hue/brightness) — glitchy opacity/colour wobble. filter-opacity
       MULTIPLIES the streak's opacity envelope, so no property fight. */
.moon-fall span.comet {
    animation: comet-streak  var(--dur, 5s) linear forwards,
               comet-jitter  0.09s steps(2, jump-none) infinite,
               comet-flicker 0.16s steps(1) infinite;
}
@keyframes comet-streak {
    /* --flip-x is +1 by default; spawnFallingComet sets it to -1 when the
       comet enters from the top-LEFT so the sprite is mirrored and its
       tail trails BEHIND the diagonal travel instead of leading it.
       scaleX comes AFTER rotate so the flip is applied in the rotated
       reference frame — the tail still trails along the comet's path. */
    0%   { transform: translate(0, -16vh)
                       rotate(var(--rot, 28deg))
                       scaleX(var(--flip-x, 1));
           opacity: 0; }
    9%   { opacity: 0.92; }
    88%  { opacity: 0.9; }
    100% { transform: translate(var(--dx, 40vw), 118vh)
                       rotate(var(--rot, 28deg))
                       scaleX(var(--flip-x, 1));
           opacity: 0; }
}
@keyframes comet-jitter {
    0%   { translate: 0 0; }
    20%  { translate: 3px -2px; }
    40%  { translate: -2px 3px; }
    60%  { translate: 4px 1px; }
    80%  { translate: -3px -3px; }
    100% { translate: 2px -1px; }
}
@keyframes comet-flicker {
    0%   { filter: drop-shadow(0 0 9px rgba(186,230,253,0.7))
                    opacity(0.78) hue-rotate(0deg) brightness(1); }
    50%  { filter: drop-shadow(0 0 14px rgba(196,210,255,0.85))
                    opacity(1) hue-rotate(-16deg) brightness(1.18); }
    100% { filter: drop-shadow(0 0 7px rgba(180,235,255,0.6))
                    opacity(0.84) hue-rotate(10deg) brightness(0.95); }
}
@media (prefers-reduced-motion: reduce) {
    /* No shake / no glitch — a calm slow diagonal drift only. */
    .moon-fall span.comet {
        animation: comet-streak var(--dur, 5s) linear forwards;
        filter: drop-shadow(0 0 9px rgba(186, 230, 253, 0.7));
    }
}

/* ====== HORROR-MODE OVERLAY (v1.1) ======
   Keyed off the EXISTING body.react-mode-active (same class that drives
   the 12 s corner-Munki creep — these ramp in lockstep). #horror-overlay
   is a non-positioned group that MUST NOT form a stacking context, so
   its fixed children resolve z-index against the root. Built by
   buildHorrorOverlay() in game.js. */
#horror-overlay { position: static; z-index: auto; }

/* (1) BG dimming — z 0: above the BG/body::before, BELOW the stage
   Munkis (z 1) and the tray (z 10), so the scene darkens but the
   Munkis + UI stay bright in front. Ramp IN 12 s, OUT 2 s. */
#horror-overlay .bg-dim {
    position: fixed; inset: 0;
    background: #000;
    opacity: 0;
    pointer-events: none;
    z-index: 0;
    transition: opacity 2s ease;
}
body.react-mode-active #horror-overlay .bg-dim {
    opacity: 0.5;
    transition: opacity 12s ease;
}

/* (2) Watching eye-pairs — same z 0, painted ABOVE .bg-dim (DOM order),
   still below the Munkis. Each pair fades in with a per-pair inline
   transition-delay staggered across the creep; each eye blinks
   occasionally (long random period set inline). */
#horror-overlay .eyes-container {
    position: fixed; inset: 0;
    pointer-events: none;
    z-index: 0;
}
#horror-overlay .eye-pair {
    position: absolute;
    transform: translate(-50%, -50%);
    display: flex;
    gap: 7px;
    opacity: 0;
    transition: opacity 3.5s ease;
}
/* Watching eyes are Moon's territory ("perception lies — you're being
   watched"). Ice horror is about isolation/stillness, not surveillance,
   so no eyes during ice-only phase. Mixed Moon+Ice: Moon's eyes show. */
body.react-mode-active.moon-present #horror-overlay .eye-pair { opacity: 1; }
#horror-overlay .eye {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: radial-gradient(circle at 50% 50%,
        #fff 0%, #ffd2d2 28%, rgba(200, 30, 40, 0.9) 55%, transparent 75%);
    filter: drop-shadow(0 0 5px rgba(255, 40, 40, 0.85))
            drop-shadow(0 0 9px rgba(150, 0, 0, 0.55));
    animation-name: eye-blink;
    animation-timing-function: ease-in-out;
    animation-iteration-count: infinite;
}
@keyframes eye-blink {
    0%, 95%, 100% { opacity: 1;    transform: scaleY(1); }
    97.5%         { opacity: 0.05; transform: scaleY(0.08); }
}

/* (3) Red vignette — z 8: ABOVE eyes/dim/Munkis, BELOW moon-fall (z 9)
   + tray (z 10) so it never obscures the tools. Ramp IN 12 s, then a
   slow "still alive" pulse; ramp OUT 2 s. pointer-events:none. */
#horror-overlay .red-vignette {
    position: fixed; inset: 0;
    pointer-events: none;
    z-index: 8;
    background: radial-gradient(circle at center,
        transparent 38%, rgba(180, 0, 30, 0.5) 100%);
    opacity: 0;
    transition: opacity 2s ease;
}
body.react-mode-active #horror-overlay .red-vignette {
    opacity: 0.7;
    transition: opacity 12s ease;
    animation: red-vig-pulse 4s ease-in-out 12s infinite;
}
@keyframes red-vig-pulse {
    0%, 100% { opacity: 0.55; }
    50%      { opacity: 0.8; }
}

@media (prefers-reduced-motion: reduce) {
    #horror-overlay .eye { animation: none; }
    body.react-mode-active #horror-overlay .red-vignette { animation: none; }
}

/* ===== Dread stage tiers (Chunk 3) =====
   applyDreadStageClass() sets body.react-mode-active for the `dread`
   AND `terror` stages, so every horror rule above IS the `dread` tier
   (full look, unchanged → no regression). `unease` is a faint
   pre-horror layer that sits BELOW it (deliberately NO watching eyes
   and NO corner Munkis — those stay react-mode-active only). `terror`
   AMPS beyond `dread`; these rules follow the react-mode-active ones
   so they win at equal specificity. The per-Munki flinch/freak-out is
   independent (the unified fear ladder), so Munkis still react at
   `unease` even though the screen barely shifts. */
body.dread-unease #horror-overlay .bg-dim {
    opacity: 0.16;
    transition: opacity 6s ease;
}
body.dread-unease #horror-overlay .red-vignette {
    opacity: 0.16;
    transition: opacity 6s ease;
}
body.dread-terror #horror-overlay .bg-dim { opacity: 0.74; }
body.dread-terror #horror-overlay .red-vignette {
    opacity: 0.95;
    animation-duration: 2.4s;   /* faster, harder "still alive" pulse */
}
@media (prefers-reduced-motion: reduce) {
    body.dread-terror #horror-overlay .red-vignette { animation: none; }
}

/* (3b) Ice WALL — z 8 sibling of .red-vignette. Replaces the red
   vignette during the ICE phase (body.ice-on-stage); the Moon phase
   retains the red. ice-wall.png is a horizontal bar of jagged crystals
   anchored to the bottom edge so the ice closes in upward from the
   floor. Ramps with dread like the red vignette does. */
#horror-overlay .ice-wall {
    position: fixed; inset: 0;
    pointer-events: none;
    z-index: 8;
    opacity: 0;
    transition: opacity 2s ease;
    overflow: hidden;
    /* Wall thickness (post-rotation). Tuned so the wall stops at roughly
       the outer edge of the corner Ice/Moon Munki at its largest
       "breathing" scale — never crowds the play area. Single source of
       truth: retune both sides by editing this one value. */
    --ice-wall-thickness: 18vw;
}
/* Two vertical ice columns — one on each side of the viewport, peaks
   pointing inward. ice-wall.png is a wide horizontal bar with peaks
   along its top; we rotate the art 90° per side (CW for left, CCW for
   right) so the jagged edge always faces the play area. Mirrors how
   Moon's red vignette darkens the edges, but with literal ice. */
#horror-overlay .ice-wall::before,
#horror-overlay .ice-wall::after {
    content: '';
    position: absolute;
    width: 100vh;                       /* becomes the wall's HEIGHT after rotation */
    height: var(--ice-wall-thickness);  /* becomes the wall's THICKNESS after rotation */
    background-image: url('assets/bg-img/ice-wall.png');
    background-repeat: no-repeat;
    background-size: 100% 100%;
}
#horror-overlay .ice-wall::before {
    top: 0; left: 0;
    transform-origin: top left;
    transform: translateX(var(--ice-wall-thickness)) rotate(90deg);            /* peaks → right (inward) */
}
#horror-overlay .ice-wall::after {
    top: 0; right: 0;
    transform-origin: top right;
    transform: translateX(calc(-1 * var(--ice-wall-thickness))) rotate(-90deg); /* peaks → left (inward) */
}
body.ice-on-stage.react-mode-active #horror-overlay .ice-wall {
    opacity: 0.92;
    transition: opacity 12s ease;
}
body.ice-on-stage.dread-unease #horror-overlay .ice-wall {
    opacity: 0.22;
    transition: opacity 6s ease;
}
body.ice-on-stage.dread-terror #horror-overlay .ice-wall { opacity: 1; }
/* During ICE phase, suppress the red vignette — Ice takes its slot.
   Moon phase (no Ice) keeps the red exactly as before. */
body.ice-on-stage #horror-overlay .red-vignette {
    opacity: 0 !important;
    animation: none !important;
}

/* ===== Moon personality — "perception lies" (Chunk 4) =====
   #moon-warp is a fixed, click-through backdrop layer over the stage
   (z 7: above the stage/Munkis at z 1, BELOW the vignette z8 /
   moon-fall z9 / tray z10 — so the world the kid is looking at warps
   while the controls stay readable). Inert by default; only when Moon
   is on stage (body.moon-present) AND a dread stage is reached does
   it slowly hue-rotate/desaturate the scene behind it, scaled by
   stage. backdrop-filter is already used elsewhere (.tray-wrap), so
   this is safe; it never competes with the transient click-chaos
   body/main animations (different element + property). */
#moon-warp {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 7;
    backdrop-filter: hue-rotate(0deg);
    -webkit-backdrop-filter: hue-rotate(0deg);
}
body.moon-present.dread-unease #moon-warp {
    animation: moon-warp-soft 17s ease-in-out infinite;
}
body.moon-present.dread-dread #moon-warp {
    animation: moon-warp-med 11s ease-in-out infinite;
}
body.moon-present.dread-terror #moon-warp {
    animation: moon-warp-hard 6.5s ease-in-out infinite;
}
@keyframes moon-warp-soft {
    0%, 100% { backdrop-filter: hue-rotate(-10deg);
               -webkit-backdrop-filter: hue-rotate(-10deg); }
    50%      { backdrop-filter: hue-rotate(12deg);
               -webkit-backdrop-filter: hue-rotate(12deg); }
}
@keyframes moon-warp-med {
    0%, 100% { backdrop-filter: hue-rotate(-26deg) saturate(1.05);
               -webkit-backdrop-filter: hue-rotate(-26deg) saturate(1.05); }
    50%      { backdrop-filter: hue-rotate(34deg) saturate(0.9);
               -webkit-backdrop-filter: hue-rotate(34deg) saturate(0.9); }
}
@keyframes moon-warp-hard {
    0%, 100% { backdrop-filter: hue-rotate(-52deg) saturate(1.15) contrast(1.05);
               -webkit-backdrop-filter: hue-rotate(-52deg) saturate(1.15) contrast(1.05); }
    50%      { backdrop-filter: hue-rotate(60deg) saturate(0.82) contrast(1.1);
               -webkit-backdrop-filter: hue-rotate(60deg) saturate(0.82) contrast(1.1); }
}
@media (prefers-reduced-motion: reduce) {
    /* No swirling hue cycle — hold a faint static shift instead. */
    body.moon-present.dread-unease #moon-warp,
    body.moon-present.dread-dread  #moon-warp,
    body.moon-present.dread-terror #moon-warp {
        animation: none;
        backdrop-filter: hue-rotate(18deg) saturate(0.95);
        -webkit-backdrop-filter: hue-rotate(18deg) saturate(0.95);
    }
}

/* ===== Ice personality — "the world seizes" (Chunk 5) =====
   Sibling of #moon-warp. Where Moon drifts the hue, Ice STUTTERS — the
   filter snaps between desaturate/brightness/blur states via steps()
   timing (no smooth easing, on purpose: ice locks things, it doesn't
   flow). Scales by stage; terror adds a faint cyan frost vignette.
   Same z (7) so both can layer when Ice+Moon are both on stage. */
#ice-freeze-warp {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 7;
    backdrop-filter: saturate(1) brightness(1);
    -webkit-backdrop-filter: saturate(1) brightness(1);
}
/* Real frost overlay (rawpixel art) — sits as a ::before so the parent's
   stutter animation + backdrop-filter keep working on top of it. Opacity
   ramps with dread stage: the frost slowly creeps in across the glass. */
#ice-freeze-warp::before {
    content: '';
    position: absolute; inset: 0;
    background-image: url('assets/bg-img/ice-frost-overlay.png');
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    opacity: 0;
    transition: opacity 1.5s ease;
    pointer-events: none;
}
body.ice-on-stage.dread-unease #ice-freeze-warp::before { opacity: 0.15; }
body.ice-on-stage.dread-dread  #ice-freeze-warp::before { opacity: 0.35; }
body.ice-on-stage.dread-terror #ice-freeze-warp::before { opacity: 0.55; }
body.ice-on-stage.dread-unease #ice-freeze-warp {
    animation: ice-stutter-soft 5.4s steps(6, jump-none) infinite;
}
body.ice-on-stage.dread-dread #ice-freeze-warp {
    animation: ice-stutter-med 4.2s steps(8, jump-none) infinite;
}
body.ice-on-stage.dread-terror #ice-freeze-warp {
    animation: ice-stutter-hard 3.0s steps(10, jump-none) infinite;
    background: radial-gradient(circle at center,
        transparent 55%, rgba(165, 243, 252, 0.14) 100%);
}
@keyframes ice-stutter-soft {
    0%   { backdrop-filter: saturate(0.92) brightness(1);
           -webkit-backdrop-filter: saturate(0.92) brightness(1); }
    100% { backdrop-filter: saturate(0.80) brightness(0.96);
           -webkit-backdrop-filter: saturate(0.80) brightness(0.96); }
}
@keyframes ice-stutter-med {
    0%   { backdrop-filter: saturate(0.74) brightness(0.96) blur(0px);
           -webkit-backdrop-filter: saturate(0.74) brightness(0.96) blur(0px); }
    100% { backdrop-filter: saturate(0.58) brightness(0.88) blur(0.7px);
           -webkit-backdrop-filter: saturate(0.58) brightness(0.88) blur(0.7px); }
}
@keyframes ice-stutter-hard {
    0%   { backdrop-filter: saturate(0.50) brightness(0.85) blur(0.6px) hue-rotate(170deg);
           -webkit-backdrop-filter: saturate(0.50) brightness(0.85) blur(0.6px) hue-rotate(170deg); }
    100% { backdrop-filter: saturate(0.36) brightness(0.74) blur(1.2px) hue-rotate(182deg);
           -webkit-backdrop-filter: saturate(0.36) brightness(0.74) blur(1.2px) hue-rotate(182deg); }
}
@media (prefers-reduced-motion: reduce) {
    /* No stutter cycle — hold a static cold tint instead. */
    body.ice-on-stage.dread-unease #ice-freeze-warp,
    body.ice-on-stage.dread-dread  #ice-freeze-warp,
    body.ice-on-stage.dread-terror #ice-freeze-warp {
        animation: none;
        backdrop-filter: saturate(0.68) brightness(0.92);
        -webkit-backdrop-filter: saturate(0.68) brightness(0.92);
    }
}

/* ===== Snow atmosphere — Ice's signature precipitation (Chunk 5) =====
   Mirrors .moon-fall (cap / fade / teardown / z 9) but each particle
   is a tiny cyan/white speck (drawn fallback — no sprite needed; drop
   real snowflake art later by swapping the inline background). Falls
   gentler than moons (slower duration, slightly larger amp sway). */
.snow-fall {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 9;
    overflow: hidden;
    transition: opacity 2.5s ease;
}
.snow-fall.fading { opacity: 0; }
.snow-fall span {
    position: absolute;
    top: -12vh;
    will-change: transform, opacity;
    animation-name: snow-drift;
    animation-timing-function: linear;
    animation-fill-mode: forwards;
}
@keyframes snow-drift {
    0%   { transform: translate(0, -12vh);                          opacity: 0; }
    8%   {                                                          opacity: 0.85; }
    25%  { transform: translate(var(--amp, 12px), 22vh); }
    50%  { transform: translate(calc(var(--amp, 12px) * -1), 52vh); }
    75%  { transform: translate(var(--amp, 12px), 80vh);            opacity: 0.8; }
    100% { transform: translate(0, 114vh);                          opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
    .snow-fall span { animation-duration: 9s !important; }
    @keyframes snow-drift {
        0%   { transform: translateY(-12vh); opacity: 0; }
        10%  { opacity: 0.8; }
        100% { transform: translateY(114vh); opacity: 0; }
    }
}

/* Subtitle goes glitchy + moon-colored when the chaos message lands. */
.moon-glitch-text {
    color: #93c5fd !important;
    text-shadow: 0 0 14px #fff, 0 0 26px #60a5fa;
    animation: moon-text-flicker 0.18s steps(2) infinite;
}
@keyframes moon-text-flicker {
    0%   { transform: translateX(-1px); opacity: 0.85; }
    50%  { transform: translateX(1px);  opacity: 1; }
    100% { transform: translateX(0);    opacity: 0.9; }
}

/* Phantom Munki briefly haunting an empty slot. brightness was 1.45
   but that aggressive boost was amplifying sub-pixel edge sampling on
   the head-mod sprite (the "ghost bleed" the user kept seeing); 1.15
   keeps the moon-blue glow without exaggerating the alpha fringe.
   will-change hints the compositor to keep a stable GPU layer through
   the scale/rotate keyframes so the sprite isn't re-rasterised every
   frame at slightly different resolutions. */
.moon-phantom {
    position: absolute;
    inset: 6px;
    z-index: 4;
    pointer-events: none;
    filter: hue-rotate(220deg) brightness(1.15) drop-shadow(0 0 14px #93c5fd);
    animation: moon-phantom-pop 0.95s ease-out forwards;
    display: flex;
    align-items: center;
    justify-content: center;
    will-change: transform, opacity;
}
.moon-phantom .char-art {
    width: 100%;
    height: 100%;
}
@keyframes moon-phantom-pop {
    0%   { opacity: 0; transform: scale(0.4) rotate(-12deg); }
    30%  { opacity: 0.85; transform: scale(1.05) rotate(4deg); }
    70%  { opacity: 0.7; transform: scale(1) rotate(-2deg); }
    100% { opacity: 0; transform: scale(0.95) rotate(0); }
}

@media (prefers-reduced-motion: reduce) {
    body.moon-hue, body.moon-invert, body.moon-tilt main { animation: none; }
    .moon-rain span { animation-duration: 1.4s; }
    .moon-phantom { animation: moon-phantom-pop 0.6s ease-out forwards; }
}

/* ====== ACHIEVEMENT TOAST + PANEL ======
   Toasts pop under the egg counter when a new achievement unlocks. Stack
   when multiple unlocks fire in quick succession via --toast-stack. The
   panel is a small dialog that opens when the counter is tapped. */
.achievement-toast {
    position: fixed;
    top: calc(env(safe-area-inset-top, 0) + 56px + (var(--toast-stack, 0) * 64px));
    right: calc(env(safe-area-inset-right, 0) + 10px);
    z-index: 55;
    min-width: 180px;
    max-width: 260px;
    padding: 8px 14px;
    border-radius: 14px;
    background: linear-gradient(135deg, rgba(167, 139, 250, 0.95), rgba(99, 102, 241, 0.95));
    color: #fff;
    font-family: 'Fredoka', sans-serif;
    text-align: right;
    border: 1px solid rgba(255, 255, 255, 0.25);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55), 0 0 24px rgba(167, 139, 250, 0.5);
    opacity: 0;
    transform: translateX(20px);
    transition: opacity 0.4s ease, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.3s ease;
    pointer-events: none;
}
.achievement-toast.shown {
    opacity: 1;
    transform: translateX(0);
}
.achievement-toast-title {
    font-weight: 700;
    font-size: 13px;
    letter-spacing: 0.04em;
}
.achievement-toast-points {
    font-size: 11px;
    color: rgba(255, 255, 255, 0.85);
    letter-spacing: 0.06em;
    text-transform: uppercase;
    margin-top: 1px;
}

.achievements-panel {
    position: fixed;
    top: calc(env(safe-area-inset-top, 0) + 50px);
    right: calc(env(safe-area-inset-right, 0) + 10px);
    z-index: 60;
    width: min(280px, calc(100vw - 20px));
    max-height: min(360px, calc(100vh - 80px));
    overflow-y: auto;
    padding: 12px 14px;
    border-radius: 16px;
    background: rgba(15, 23, 42, 0.95);
    border: 1px solid rgba(167, 139, 250, 0.5);
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.7);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    color: #e9d5ff;
    font-family: 'Fredoka', sans-serif;
    opacity: 0;
    transform: translateY(-8px) scale(0.96);
    transition: opacity 0.25s ease, transform 0.25s ease;
    pointer-events: none;
}
.achievements-panel.open {
    opacity: 1;
    transform: translateY(0) scale(1);
    pointer-events: auto;
}
.achievements-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
    padding-bottom: 8px;
    border-bottom: 1px solid rgba(167, 139, 250, 0.25);
}
.achievements-title {
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: #fde68a;
}
.achievements-close {
    background: transparent;
    border: none;
    color: rgba(233, 213, 255, 0.7);
    font-size: 22px;
    line-height: 1;
    cursor: pointer;
    padding: 0 4px;
}
.achievements-close:hover { color: #fff; }
.achievements-list {
    list-style: none;
    margin: 0;
    padding: 0;
}
.achievement-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 6px 4px;
    border-bottom: 1px solid rgba(167, 139, 250, 0.12);
    font-size: 13px;
}
.achievement-row:last-child { border-bottom: none; }
.achievement-name {
    color: #e9d5ff;
    letter-spacing: 0.02em;
}
.achievement-points {
    color: #fde68a;
    font-weight: 700;
    font-size: 12px;
    letter-spacing: 0.06em;
    padding: 2px 8px;
    border-radius: 999px;
    background: rgba(251, 191, 36, 0.12);
    border: 1px solid rgba(251, 191, 36, 0.3);
}
.achievement-empty {
    list-style: none;
    padding: 12px 4px;
    text-align: center;
    color: rgba(233, 213, 255, 0.6);
    font-style: italic;
    font-size: 12px;
}

/* Horror-mode corner sprites become tap-targets so 'Touch the Outsider'
   can fire. They stay non-interactive in normal play. */
body.react-mode-active .horror-munki { pointer-events: auto; cursor: pointer; }

/* ====== FLYING CREEPS ======
   Ambient creature that drifts across the stage on a timer (one at a
   time, random variant per appearance). Positioned by JS via transform:
   translate(); fixed to the viewport so its proximity math
   (getBoundingClientRect centers) matches what the kid sees. Sits above
   the stage + Munkis, below the tray/controls (z from CREEP.Z_INDEX, set
   inline). Not interactive in v1. */
.flying-creep {
    position: fixed;
    top: 0;
    left: 0;
    pointer-events: none;
    will-change: transform;
    filter: drop-shadow(0 6px 18px rgba(120, 160, 220, 0.35));
    opacity: 0;
    transition: opacity 0.6s ease-in;
}
.flying-creep.flying-creep-in { opacity: 0.9; }
.flying-creep[hidden] { display: none; }

/* Animation frame layer. paintCreepFrame() sizes this element to EXACTLY
   the rendered sprite (its own measured content box, scaled) and it is
   absolutely centred inside the outer .flying-creep via inset:0 +
   margin:auto. Because the element is the sprite's own size with
   overflow:hidden, the windowed sheet can only ever show THIS frame —
   an adjacent strip frame can never bleed into a margin (the ghost
   double-creep). transform is left free for the death-shrink animation
   (the centring uses margin:auto, not transform). Until the sheet loads
   .flying-creep holds the inline placeholder SVG and this rule is
   unused. */
.flying-creep .flying-creep-frame {
    position: absolute;
    inset: 0;
    margin: auto;
    overflow: hidden;
    background-repeat: no-repeat;
    image-rendering: -webkit-optimize-contrast;
}

/* PLACEHOLDER tag riding the corner of the placeholder ghost so it's
   obvious the final art hasn't landed yet. Gone automatically once the
   real sheet loads (placeholder markup isn't rendered then). */
.flying-creep .flying-creep-ph {
    position: absolute;
    left: 50%;
    bottom: -14px;
    transform: translateX(-50%);
    font-family: 'JetBrains Mono', monospace;
    font-size: 9px;
    letter-spacing: 1.5px;
    color: #cfe0f5;
    background: rgba(20, 28, 44, 0.7);
    padding: 1px 6px;
    border-radius: 6px;
    white-space: nowrap;
}

/* Munki flinch while a Flying Creep is too close. Targets the inner
   .char-art so it STACKS on top of the slot's other states (sulk,
   react-mode expression cycling) instead of replacing them — the shake
   is a transform on .char-art, the wide-eyed pop is a quick filter
   flash on .char-head. */
.stage-slot.creep-scared .char-art {
    animation: creep-flinch 0.1s steps(2) infinite;
}
.stage-slot.creep-scared .char-head {
    filter: brightness(1.35) saturate(1.2) contrast(1.15);
    transition: filter 0.12s ease-out;
}
.stage-slot:not(.creep-scared) .char-head {
    transition: filter 0.2s ease-in;   /* fade the startled look back out */
}
@keyframes creep-flinch {
    0%   { transform: translateX(-3px) rotate(-1deg); }
    100% { transform: translateX( 3px) rotate( 1deg); }
}

/* ----- Creep DIE (Float → swoop → die) -----
   The swoop is terminal: JS adds .flying-creep-dying on the last swoop
   frame and despawns after CREEP.DIE_MS (520ms — keep these in sync).
   The parent only fades (it owns the positioning `transform` inline, so
   we must NOT animate transform here or it'd snap to origin mid-air).
   The shrink/spin rides the inner .flying-creep-frame, whose transform
   is unused by JS — safe to animate. Placeholder (no frame child) just
   fades, which is fine. */
.flying-creep.flying-creep-dying {
    animation: creep-die-fade 0.52s ease-in forwards;
}
.flying-creep.flying-creep-dying .flying-creep-frame {
    animation: creep-die-shrink 0.52s ease-in forwards;
    transform-origin: 50% 60%;
}
@keyframes creep-die-fade {
    0%   { opacity: 0.9; filter: drop-shadow(0 6px 18px rgba(120,160,220,0.35)); }
    60%  { opacity: 0.55; filter: drop-shadow(0 4px 14px rgba(180,120,220,0.45)) blur(0.5px); }
    100% { opacity: 0; filter: blur(3px); }
}
@keyframes creep-die-shrink {
    0%   { transform: scale(1)    rotate(0deg); }
    100% { transform: scale(0.45) rotate(26deg); }
}

/* ----- Creep STRIKE knock -----
   The hit Munki gets a one-shot recoil. JS adds .creep-struck and clears
   it on animationend (600ms fallback). Ambient .creep-scared is cleared
   the instant the creep starts its dive, so this never fights the
   flinch shake; it's a transform on .char-art, independent of the
   per-beat .beat bounce (which lives on the inner body/head). */
.stage-slot.creep-struck .char-art {
    animation: creep-knock 0.38s cubic-bezier(0.22, 1, 0.36, 1) 1;
}
@keyframes creep-knock {
    0%   { transform: translateX(0)    rotate(0deg); }
    18%  { transform: translateX(10px) rotate(7deg) scale(0.96); }
    45%  { transform: translateX(-6px) rotate(-4deg); }
    70%  { transform: translateX(3px)  rotate(2deg); }
    100% { transform: translateX(0)    rotate(0deg); }
}

@media (prefers-reduced-motion: reduce) {
    .flying-creep { transition: opacity 0.6s ease-in; }
    .stage-slot.creep-scared .char-art { animation: none; }
    /* Keep the brightness flash — it's a non-motion "startled" cue. */
    /* Death: keep only the fade (no spin/shrink). Knock: no recoil. */
    .flying-creep.flying-creep-dying .flying-creep-frame { animation: none; }
    .stage-slot.creep-struck .char-art { animation: none; }
}

/* iOS: kill double-tap-to-zoom (Safari ignores user-scalable=no). Keeps taps/scroll/pinch; canvas touch-action:none wins on specificity. */
* { touch-action: manipulation; }
