/* Numberblocks Playground — v1 */

@font-face {
  font-family: 'Learninglings';
  src: url('Learninglings.ttf') format('truetype');
  font-display: swap;
}
/* One's numberling in the show is a plain straight stick, not the font's
   flagged "1" (six players over three months, with reference frames). This
   one-glyph face covers only U+0031 and goes first in the block numerals'
   stack, so every other character still comes from Learninglings. */
@font-face {
  font-family: 'NumberlingOne';
  src: url('NumberlingOne.ttf') format('truetype');
  font-display: swap;
  unicode-range: U+0031;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

html, body {
  height: 100%;
  overflow: hidden;
  font-family: 'Learninglings', 'Comic Sans MS', 'Chalkboard SE', system-ui, sans-serif;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}

/* ---------- Numberland ---------- */
#sky {
  position: fixed;
  inset: 0;
  background: linear-gradient(180deg, #8fd8f8 0%, #bfeafc 55%, #e6f8ff 100%);
  z-index: 0;
}

.cloud {
  position: absolute;
  background: rgba(255, 255, 255, 0.92);
  border-radius: 999px;
  filter: blur(1px);
  animation: drift linear infinite;
}
.cloud::before, .cloud::after {
  content: '';
  position: absolute;
  background: inherit;
  border-radius: 50%;
}
.cloud::before { width: 55%; height: 160%; left: 14%; top: -78%; }
.cloud::after  { width: 42%; height: 120%; right: 16%; top: -48%; }
.cloud-a { width: 160px; height: 46px; top: 12%; animation-duration: 95s; }
.cloud-b { width: 110px; height: 34px; top: 26%; animation-duration: 140s; animation-delay: -60s; opacity: .8; }
.cloud-c { width: 200px; height: 54px; top: 7%;  animation-duration: 180s; animation-delay: -120s; opacity: .65; }

@keyframes drift {
  from { transform: translateX(-260px); }
  to   { transform: translateX(110vw); }
}

/* ---------- night mode ----------
   A persisted day/night toggle: the sky deepens to indigo, a starfield and a
   moon fade in, clouds dim, the grass darkens. Purely cosmetic — the blocks
   stay bright (they're the show), and the default is day. */
#sky { transition: background 0.6s ease; }
/* starfield + moon, both faded out by default so day is byte-for-byte the same */
#sky::after, #sky::before {
  content: '';
  position: absolute;
  opacity: 0;
  transition: opacity 0.6s ease;
  pointer-events: none;
}
#sky::after { /* stars: sparse twinkling dots, tiled cheaply with radial-gradients */
  inset: 0;
  background-image:
    radial-gradient(1.5px 1.5px at 18% 22%, #fff, transparent),
    radial-gradient(1.5px 1.5px at 67% 14%, #fff, transparent),
    radial-gradient(2px 2px   at 41% 38%, #fff, transparent),
    radial-gradient(1.5px 1.5px at 83% 31%, #cfe3ff, transparent),
    radial-gradient(1px 1px   at 29% 9%,  #fff, transparent),
    radial-gradient(1.5px 1.5px at 92% 18%, #fff, transparent),
    radial-gradient(2px 2px   at 9%  44%, #fff, transparent),
    radial-gradient(1px 1px   at 54% 27%, #cfe3ff, transparent),
    radial-gradient(1.5px 1.5px at 75% 47%, #fff, transparent);
  animation: twinkle 4s ease-in-out infinite;
}
#sky::before { /* moon, tucked top-right */
  width: 54px; height: 54px;
  top: 9%; right: 11%;
  border-radius: 50%;
  background: radial-gradient(circle at 38% 35%, #fffdf0 0%, #f2ead0 60%, #d9cda0 100%);
  box-shadow: 0 0 22px 6px rgba(255, 250, 220, 0.45);
}
html.night #sky {
  background: linear-gradient(180deg, #0b1026 0%, #1a2148 55%, #2a3360 100%);
}
html.night #sky::after, html.night #sky::before { opacity: 1; }
/* Moon phases: each switch into night mode advances the moon one step through the
   real lunar cycle (full -> waning gibbous -> last quarter -> waning crescent ->
   new -> waxing crescent -> first quarter -> waxing gibbous -> full ...). Phase 0
   (full) is just the default #sky::before disc above, so it stays byte-identical;
   only phases 1-7 carve a shadow. Lit side sweeps NH-convention. Night-only. */
html.night.moon-p1 #sky::before { /* waning gibbous — thin dark on right */
  background: radial-gradient(circle 60px at 142% 50%, #39405c 0 42%, transparent 44%),
              radial-gradient(circle at 38% 35%, #fffdf0 0%, #f2ead0 60%, #d9cda0 100%);
}
html.night.moon-p2 #sky::before { /* last quarter — right half dark */
  background: linear-gradient(90deg, transparent 50%, #39405c 50%),
              radial-gradient(circle at 38% 35%, #fffdf0 0%, #f2ead0 60%, #d9cda0 100%);
}
html.night.moon-p3 #sky::before { /* waning crescent — thin lit on left */
  background: radial-gradient(circle 60px at -42% 50%, #fdf6dd 0 42%, transparent 44%), #39405c;
  box-shadow: 0 0 12px 3px rgba(255, 250, 220, 0.28);
}
html.night.moon-p4 #sky::before { /* new moon — dark disc */
  background: radial-gradient(circle at 42% 40%, #454d69 0%, #39405c 70%, #2f3550 100%);
  box-shadow: 0 0 10px 2px rgba(255, 250, 220, 0.16);
}
html.night.moon-p5 #sky::before { /* waxing crescent — thin lit on right */
  background: radial-gradient(circle 60px at 142% 50%, #fdf6dd 0 42%, transparent 44%), #39405c;
  box-shadow: 0 0 12px 3px rgba(255, 250, 220, 0.28);
}
html.night.moon-p6 #sky::before { /* first quarter — left half dark */
  background: linear-gradient(90deg, #39405c 50%, transparent 50%),
              radial-gradient(circle at 38% 35%, #fffdf0 0%, #f2ead0 60%, #d9cda0 100%);
}
html.night.moon-p7 #sky::before { /* waxing gibbous — thin dark on left */
  background: radial-gradient(circle 60px at -42% 50%, #39405c 0 42%, transparent 44%),
              radial-gradient(circle at 38% 35%, #fffdf0 0%, #f2ead0 60%, #d9cda0 100%);
}
/* On the Moon the moon is under your feet, so the sky shows EARTH instead — a
   blue-and-green marble hanging where our moon usually sits (Mateosty). Moon
   land forces the night sky, so this lands next to the moon→night rule; it
   overrides every lunar phase (a different world is up there now). Placed after
   the moon-pN rules so equal specificity resolves in Earth's favour. */
html.land-moon #sky::before,
html.night.land-moon #sky::before,
html.night.land-space #sky::before {
  background:
    radial-gradient(ellipse 10px 5px at 32% 30%, rgba(255,255,255,0.85) 0 60%, transparent 62%), /* cloud swirl */
    radial-gradient(ellipse 8px 6px at 60% 64%, #5bc47e 0 60%, transparent 64%),                 /* landmass */
    radial-gradient(circle 7px at 42% 60%, #6fd08a 0 60%, transparent 64%),                       /* landmass */
    radial-gradient(circle at 38% 35%, #bfe3ff 0%, #3f86d8 52%, #1f5aa8 100%);                    /* ocean */
  box-shadow: 0 0 22px 7px rgba(120, 190, 255, 0.5);
}
html.night .cloud { opacity: 0.1; filter: blur(2px); }
html.night #grass {
  background: linear-gradient(180deg, #2f5a2c 0%, #244a21 30%, #1a3a18 100%);
  border-top-color: #3a6e34;
}

/* ---------- sunset mode ----------
   The middle state of the day cycle: a warm dusk sky deepening to purple up
   top, a big low sun glowing over the horizon, dimmer warm-tinted scenery.
   Darker than day, brighter than night. Stars stay hidden (only night turns
   #sky::after on). Blocks stay bright — they're the show. */
html.sunset #sky {
  background: linear-gradient(180deg, #3b2f63 0%, #7b4a7e 32%, #c75f6b 58%, #ef9a5a 82%, #f7bd6c 100%);
}
/* Repurpose the #sky::before disc as a big warm sun sitting low on the horizon. */
html.sunset #sky::before {
  opacity: 1;
  width: 104px; height: 104px;
  top: 30%; right: 12%;
  background: radial-gradient(circle at 50% 50%, #fff4cf 0%, #ffd873 38%, #ff9f4d 74%, #ff7e3c 100%);
  box-shadow: 0 0 70px 30px rgba(255, 158, 70, 0.5);
}
html.sunset .cloud { opacity: 0.6; filter: blur(1px) brightness(0.9) sepia(0.3); }
html.sunset #grass {
  background: linear-gradient(180deg, #4f7a3a 0%, #406630 30%, #335326 100%);
  border-top-color: #5e8c46;
}
html.sunset .hill-far { background: #7a6480; opacity: 0.55; }
html.sunset .hill-near { background: #62506a; opacity: 0.7; }
html.sunset .tree::before { background: #3f7a3a; }
html.sunset .tree::after  { background: #4a8a42; }
html.sunset .bird, html.sunset .plane:not(.plane-night) { opacity: 0.45; }

/* ---------- sunrise mode ----------
   The dawn state closing the cycle (night→sunrise→day): a cool bluish sky up
   top warming to soft pink and pale gold at the horizon, a fresh sun climbing
   low on the LEFT (mirror of the sunset sun on the right). Brighter and cooler
   than sunset — morning, not evening. Stars stay hidden. */
html.sunrise #sky {
  background: linear-gradient(180deg, #4a6a9e 0%, #8f86b4 30%, #e89ba6 58%, #f7c290 82%, #fde7b6 100%);
}
/* Repurpose the #sky::before disc as a fresh sun rising low on the left. */
html.sunrise #sky::before {
  opacity: 1;
  width: 96px; height: 96px;
  top: 34%; left: 12%; right: auto;
  background: radial-gradient(circle at 50% 50%, #fffaf0 0%, #ffe9a3 40%, #ffc06e 76%, #ff9c66 100%);
  box-shadow: 0 0 64px 26px rgba(255, 190, 120, 0.5);
}
html.sunrise .cloud { opacity: 0.7; filter: blur(0.5px) brightness(1.05) sepia(0.18); }
html.sunrise #grass {
  background: linear-gradient(180deg, #6fae47 0%, #5d9a3b 30%, #4d8531 100%);
  border-top-color: #7bbf50;
}
html.sunrise .hill-far { background: #8a7fa6; opacity: 0.5; }
html.sunrise .hill-near { background: #6f6488; opacity: 0.65; }
html.sunrise .tree::before { background: #4a8a42; }
html.sunrise .tree::after  { background: #58a050; }
html.sunrise .bird, html.sunrise .plane:not(.plane-night) { opacity: 0.7; }

@keyframes twinkle {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.55; }
}
@media (prefers-reduced-motion: reduce) {
  #sky::after { animation: none; }
}

#grass {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  height: 110px;
  background: linear-gradient(180deg, #82ce4f 0%, #6ab93e 30%, #57a233 100%);
  border-top: 4px solid #8fdd5e;
  z-index: 1;
}

/* ---------- distant hills ----------
   Two soft rounded silhouettes peeking up behind the grass for depth. Hazy
   blue-greens read as faraway hills/mountains without competing with the
   blocks. Static (no animation), pointer-transparent, darken at night. */
#hills {
  position: fixed;
  left: 0; right: 0; bottom: 96px;
  height: 160px;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
  /* Distant hills track the zoom too, but gently (parallax): far things move
     less than the foreground trees, so only 40% of the world's scale change
     reaches them. Rooted at their base so the horizon stays put. */
  transform-origin: 50% 100%;
  transform: scale(calc(1 + (var(--scene-scale, 1) - 1) * 0.4));
  transition: transform 0.28s ease;
}
.hill {
  position: absolute;
  bottom: 0;
  height: 200%;
  border-radius: 50% 50% 0 0 / 100% 100% 0 0;
  transition: background 0.6s ease, opacity 0.6s ease;
}
.hill-far {
  left: -10%; width: 70%;
  background: #9fd6c4;
  opacity: 0.55;
}
.hill-near {
  right: -14%; width: 80%;
  background: #7cc6a8;
  opacity: 0.7;
}
html.night .hill-far { background: #2a3a4e; opacity: 0.5; }
html.night .hill-near { background: #20303f; opacity: 0.65; }

/* ---------- trees ----------
   Simple lollipop trees resting on the grass: a brown trunk with stacked
   green canopy puffs. z-index 2 keeps them behind the blocks (playground=10). */
#scenery {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  height: 110px;
  z-index: 2;
  pointer-events: none;
}
.tree {
  position: absolute;
  bottom: 64px;
  width: 12px; height: 30px;
  background: #8a5a36;
  border-radius: 3px;
}
.tree::before, .tree::after {
  content: '';
  position: absolute;
  border-radius: 50%;
  background: #4fae46;
  transition: background 0.6s ease;
}
.tree::before { /* lower, wider canopy */
  width: 44px; height: 40px;
  left: -16px; bottom: 18px;
  box-shadow: 0 0 0 0 #4fae46;
}
.tree::after { /* upper, smaller canopy puff */
  width: 30px; height: 28px;
  left: -9px; bottom: 36px;
  background: #5dbe53;
}
/* Trees are rooted at the grass line and grow from the bottom, so the world's
   zoom level scales them the same way it scales the blocks — a tree next to a
   zoomed-in block reads as one scene, not a giant block by a tiny tree
   (stone_log). --scene-scale is driven by applyZoom(); each tree keeps its own
   --tree-base for its distance/size. transform-origin bottom keeps roots planted
   on the grass through the zoom, and the transition matches #playground's. */
.tree {
  --tree-base: 1;
  transform-origin: 50% 100%;
  transform: scale(calc(var(--tree-base) * var(--scene-scale, 1)));
  transition: transform 0.28s ease;
}
.tree-1 { left: 5%; --tree-base: 0.85; }
.tree-2 { left: 22%; --tree-base: 1.05; }
.tree-3 { right: 7%; --tree-base: 0.95; }
/* A few more, smaller/further-back so the horizon feels fuller without crowding
   the central play area (all sit behind the blocks, pointer-events:none). */
.tree-4 { left: 35%; bottom: 70px; --tree-base: 0.62; }
.tree-5 { right: 21%; bottom: 68px; --tree-base: 0.72; }
.tree-6 { right: 37%; bottom: 72px; --tree-base: 0.55; }
html.night .tree::before { background: #2e6e34; }
html.night .tree::after  { background: #387c3e; }
/* Gentle breeze: the canopy puffs rock from where they meet the trunk. The
   upper puff swings a touch wider and lags the lower one, so each tree bends
   like it's catching the wind rather than rotating as a rigid blob. Trees are
   staggered so the grove never sways in unison. The trunk stays planted. */
.tree::before { animation: tree-sway 4.6s ease-in-out infinite; transform-origin: 50% 100%; }
.tree::after  { animation: tree-sway 4.6s ease-in-out infinite; transform-origin: 50% 0%; animation-delay: -0.4s; }
.tree-2::before, .tree-2::after { animation-duration: 5.4s; animation-delay: -1.9s; }
.tree-3::before, .tree-3::after { animation-duration: 4.1s; animation-delay: -3.1s; }
@keyframes tree-sway {
  0%, 100% { transform: rotate(-2.2deg); }
  50%      { transform: rotate(2.2deg); }
}
@media (prefers-reduced-motion: reduce) {
  .tree::before, .tree::after { animation: none; }
}

/* ---------- seasons ----------
   A cosmetic cycle (default summer) orthogonal to the day/sunset/night sky
   cycle: it retints the tree canopies and turns on a falling-weather overlay
   (#weather). Summer is the default green and shows no weather. Kept to trees +
   weather so it composes cleanly with any sky mode. */
html.autumn .tree::before { background: #d9762e; }
html.autumn .tree::after  { background: #e8a23c; }
html.winter .tree::before { background: #d4e7f0; }
html.winter .tree::after  { background: #eef6fb; }
html.spring .tree::before { background: #f2a3c6; }
html.spring .tree::after  { background: #ffc6dd; }

/* Falling weather: two parallax layers of repeating particles scrolled
   downward by an animated background-position. Seamless because the scroll
   distance equals the tile height. Off (opacity 0) until a season turns it on. */
#weather {
  position: fixed;
  inset: 0;
  z-index: 3;            /* in front of trees (2), behind the playground (10) */
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s ease;
}
html.autumn #weather, html.winter #weather, html.spring #weather, html.rainy #weather, html.cloudy #weather, html.fog #weather { opacity: 1; }
#weather::before, #weather::after {
  content: '';
  position: absolute;
  inset: 0;
  background-repeat: repeat;
}
#weather::before { animation: weather-fall 9s linear infinite; }
#weather::after  { animation: weather-fall 6s linear infinite; opacity: 0.75; }
@keyframes weather-fall {
  from { background-position: 0 0; }
  to   { background-position: 22px 160px; }
}
/* Autumn — amber leaves tumbling down. */
html.autumn #weather::before {
  background-size: 150px 160px;
  background-image:
    radial-gradient(circle at 18% 12%, #e08a2a 0 3px, transparent 3.6px),
    radial-gradient(circle at 64% 38%, #c96b1e 0 2.4px, transparent 3px),
    radial-gradient(circle at 41% 74%, #d99a3a 0 3px, transparent 3.6px),
    radial-gradient(circle at 88% 88%, #b85c1a 0 2.4px, transparent 3px);
}
html.autumn #weather::after {
  background-size: 110px 130px;
  background-image:
    radial-gradient(circle at 30% 24%, #e8a23c 0 2px, transparent 2.6px),
    radial-gradient(circle at 76% 62%, #cf7a26 0 2px, transparent 2.6px);
}
/* Winter — soft snowflakes. */
html.winter #weather::before {
  background-size: 130px 150px;
  background-image:
    radial-gradient(circle at 20% 14%, #ffffff 0 2.6px, transparent 3.2px),
    radial-gradient(circle at 62% 40%, #eaf4fb 0 2.2px, transparent 2.8px),
    radial-gradient(circle at 44% 78%, #ffffff 0 2.6px, transparent 3.2px),
    radial-gradient(circle at 86% 86%, #dcebf6 0 2.2px, transparent 2.8px);
}
html.winter #weather::after {
  background-size: 100px 120px;
  background-image:
    radial-gradient(circle at 34% 28%, #ffffff 0 1.8px, transparent 2.4px),
    radial-gradient(circle at 78% 66%, #eaf4fb 0 1.8px, transparent 2.4px);
}
/* Spring — drifting blossom petals. */
html.spring #weather::before {
  background-size: 150px 160px;
  background-image:
    radial-gradient(circle at 18% 12%, #f7b8d4 0 3px, transparent 3.6px),
    radial-gradient(circle at 64% 38%, #f49ac2 0 2.4px, transparent 3px),
    radial-gradient(circle at 41% 74%, #ffc9de 0 3px, transparent 3.6px),
    radial-gradient(circle at 88% 88%, #ef89b6 0 2.4px, transparent 3px);
}
html.spring #weather::after {
  background-size: 110px 130px;
  background-image:
    radial-gradient(circle at 30% 24%, #ffd0e3 0 2px, transparent 2.6px),
    radial-gradient(circle at 76% 62%, #f49ac2 0 2px, transparent 2.6px);
}
/* Rainy — fast, near-vertical pale-blue streaks. Its own quick keyframe (the
   gentle 6–9s weather-fall is too slow to read as rain); trees stay summer-green
   so it composes with any sky like the seasons do. Tiles are 220px tall and the
   scroll distance is 220px, so the loop is seamless. */
html.rainy #weather::before {
  background-size: 120px 220px;
  animation: rain-fall 0.7s linear infinite;
  background-image:
    radial-gradient(1.7px 11px at 16% 10%, rgba(190,216,244,0.85) 0 65%, transparent 72%),
    radial-gradient(1.5px 9px  at 52% 32%, rgba(170,202,236,0.8)  0 65%, transparent 72%),
    radial-gradient(1.7px 11px at 38% 64%, rgba(190,216,244,0.85) 0 65%, transparent 72%),
    radial-gradient(1.5px 9px  at 84% 86%, rgba(170,202,236,0.8)  0 65%, transparent 72%);
}
html.rainy #weather::after {
  background-size: 90px 220px;
  animation: rain-fall 0.5s linear infinite;
  opacity: 0.55;
  background-image:
    radial-gradient(1.4px 8px at 28% 22%, rgba(205,226,247,0.7) 0 65%, transparent 72%),
    radial-gradient(1.4px 8px at 72% 58%, rgba(205,226,247,0.7) 0 65%, transparent 72%);
}
@keyframes rain-fall {
  from { background-position: 0 0; }
  to   { background-position: 6px 220px; }
}
/* Cloudy — big soft white clouds drifting sideways across the sky. Its own
   horizontal keyframe (the vertical weather-fall would read as falling blobs);
   two parallax layers at different speeds so the sky feels alive. Both tiles are
   440px wide and the drift distance is 440px, so the loop is seamless. Trees stay
   summer-green so it composes with any sky mode like the other weather states. */
html.cloudy #weather::before {
  background-size: 440px 300px;
  animation: cloud-drift 70s linear infinite;
  background-image:
    radial-gradient(62px 34px at 20% 26%, rgba(255,255,255,0.92) 0 55%, transparent 74%),
    radial-gradient(46px 27px at 31% 33%, rgba(255,255,255,0.9)  0 55%, transparent 74%),
    radial-gradient(54px 30px at 72% 64%, rgba(255,255,255,0.9)  0 55%, transparent 74%),
    radial-gradient(40px 24px at 62% 70%, rgba(255,255,255,0.88) 0 55%, transparent 74%);
}
html.cloudy #weather::after {
  background-size: 440px 260px;
  animation: cloud-drift 105s linear infinite;
  opacity: 0.6;
  background-image:
    radial-gradient(52px 29px at 47% 18%, rgba(255,255,255,0.85) 0 55%, transparent 74%),
    radial-gradient(40px 23px at 88% 80%, rgba(255,255,255,0.82) 0 55%, transparent 74%);
}
@keyframes cloud-drift {
  from { background-position: 0 0; }
  to   { background-position: 440px 0; }
}
/* Foggy — a low, soft grey haze that veils the whole scene and drifts slowly
   sideways. Unlike cloudy (discrete white clouds up in the sky), fog is a broad
   translucent wash: a faint full-screen tint on #weather plus two banks of big
   soft blobs drifting at different speeds so the murk feels alive. Tiles are
   520px wide and its own fog-drift keyframe scrolls exactly 520px, so the loop
   is seamless. Trees stay summer-green so it composes with any sky mode like the
   other weather states (TheSecondaryBlob's ask). */
html.fog #weather { background: rgba(224,228,232,0.26); }
html.fog #weather::before {
  background-size: 520px 340px;
  animation: fog-drift 60s linear infinite;
  background-image:
    radial-gradient(150px 90px at 22% 40%, rgba(232,235,238,0.55) 0 60%, transparent 84%),
    radial-gradient(120px 74px at 68% 66%, rgba(224,228,232,0.5)  0 60%, transparent 84%);
}
html.fog #weather::after {
  background-size: 520px 300px;
  animation: fog-drift 92s linear infinite;
  opacity: 0.7;
  background-image:
    radial-gradient(160px 92px at 48% 22%, rgba(238,240,242,0.5)  0 60%, transparent 84%),
    radial-gradient(130px 80px at 88% 78%, rgba(224,228,232,0.48) 0 60%, transparent 84%);
}
@keyframes fog-drift {
  from { background-position: 0 0; }
  to   { background-position: 520px 0; }
}

@media (prefers-reduced-motion: reduce) {
  #weather::before, #weather::after { animation: none; }
}

/* ---------- land themes ----------
   A cosmetic ground reskin (nb.land), orthogonal to the sky and weather cycles:
   the default grass, a snowy ice field, or a dark volcanic lava field. Only the
   ground layers (grass, hills, trees) change — the sky keeps whatever time-of-day
   it's set to, so any land composes with any sky. Placed after the base + sky
   scene rules so it wins the equal-specificity ties for the ground colour.
   Requested repeatedly ("themes like volcano and ice instead of just grass"). */

/* Snow — a bright ice field: white ground, pale-blue snowy hills, frosted trees. */
html.land-snow #grass {
  background: linear-gradient(180deg, #eaf6ff 0%, #d4ebf8 40%, #bcdcf0 100%);
  border-top-color: #ffffff;
}
html.land-snow .hill-far { background: #cfe6f2; opacity: 0.7; }
html.land-snow .hill-near { background: #b3d6ea; opacity: 0.8; }
html.land-snow .tree { background: #7a6650; }
html.land-snow .tree::before { background: #dcecf5; }
html.land-snow .tree::after  { background: #eef7fc; }

/* Lava — a dark volcanic plain: charred rock ground veined with glowing lava,
   dark cinder-cone hills, scorched trees. A slow pulse makes the cracks flicker
   like molten rock (skipped for reduced-motion). */
html.land-lava #grass {
  background:
    radial-gradient(2px 10px at 18% 40%, rgba(255,140,40,0.9) 0 60%, transparent 70%),
    radial-gradient(2px 8px  at 46% 70%, rgba(255,90,30,0.85) 0 60%, transparent 70%),
    radial-gradient(2px 11px at 72% 32%, rgba(255,150,50,0.9) 0 60%, transparent 70%),
    radial-gradient(2px 9px  at 90% 66%, rgba(255,100,30,0.85) 0 60%, transparent 70%),
    linear-gradient(180deg, #3a2320 0%, #2a1614 40%, #1c0e0d 100%);
  border-top: 4px solid #ff7a2c;
  box-shadow: 0 -2px 14px rgba(255,110,40,0.5);
  animation: lava-glow 3.2s ease-in-out infinite;
}
html.land-lava .hill-far { background: #3a2a2e; opacity: 0.75; }
html.land-lava .hill-near { background: #2a1c20; opacity: 0.85; }
html.land-lava .tree { background: #2e2320; }
html.land-lava .tree::before { background: #3a2c26; }
html.land-lava .tree::after  { background: #46352d; }
@keyframes lava-glow {
  0%, 100% { box-shadow: 0 -2px 12px rgba(255,110,40,0.4); border-top-color: #f26a24; }
  50%      { box-shadow: 0 -3px 20px rgba(255,140,50,0.7); border-top-color: #ff8c3c; }
}
@media (prefers-reduced-motion: reduce) {
  html.land-lava #grass { animation: none; }
}

/* Beach — a sunny sandy shore: warm sand ground, a teal sea on the horizon
   (the distant hills read as the ocean), palm-ish trees with tan trunks. */
html.land-beach #grass {
  background: linear-gradient(180deg, #f6e4b8 0%, #eed49a 40%, #e3c37f 100%);
  border-top: 4px solid #fbf0cf;
}
html.land-beach .hill-far { background: #7fd4dd; opacity: 0.6; }
html.land-beach .hill-near { background: #4fb9c9; opacity: 0.78; }
html.land-beach .tree { background: #b07a44; }
html.land-beach .tree::before { background: #3fae62; }
html.land-beach .tree::after  { background: #52c274; }

/* Desert — sun-baked dunes: warm orange-tan sand ground, hazy sand-dune hills on
   the horizon, and green cactus-ish plants for "trees". Drier and warmer than the
   beach (no sea) — webaisimul's ask. */
html.land-desert #grass {
  background: linear-gradient(180deg, #f2cd82 0%, #e7b45f 45%, #d69a44 100%);
  border-top: 4px solid #f7e2ad;
}
html.land-desert .hill-far { background: #e9c891; opacity: 0.7; }
html.land-desert .hill-near { background: #dcb066; opacity: 0.85; }
html.land-desert .tree { background: #3f9a54; }
html.land-desert .tree::before { background: #49ad60; }
html.land-desert .tree::after  { background: #57c06f; }

/* Forest — a deep woodland floor: dark mossy-green ground, shadowy green hills,
   rich dark-trunked trees. Denser and cooler than the open meadow. */
html.land-forest #grass {
  background: linear-gradient(180deg, #3f7d34 0%, #316128 40%, #244a1e 100%);
  border-top: 4px solid #57a83f;
}
html.land-forest .hill-far { background: #4a7d5a; opacity: 0.6; }
html.land-forest .hill-near { background: #38614a; opacity: 0.8; }
html.land-forest .tree { background: #5a3a22; }
html.land-forest .tree::before { background: #2f7a34; }
html.land-forest .tree::after  { background: #3a8f3f; }

/* Candy Land — pink frosting ground scattered with sprinkles, pastel gumdrop
   hills, and lollipop "trees" (white stick, pink/mint swirl tops). The sky keeps
   whatever time-of-day the player picked, like the other Earth biomes. */
html.land-candy #grass {
  background:
    radial-gradient(2px 6px at 12% 30%, #ffffff 0 60%, transparent 70%),
    radial-gradient(2px 6px at 27% 62%, #7fe0c0 0 60%, transparent 70%),
    radial-gradient(2px 6px at 41% 24%, #ffd84d 0 60%, transparent 70%),
    radial-gradient(2px 6px at 56% 55%, #6fb8ff 0 60%, transparent 70%),
    radial-gradient(2px 6px at 70% 33%, #ffffff 0 60%, transparent 70%),
    radial-gradient(2px 6px at 84% 66%, #b98cff 0 60%, transparent 70%),
    radial-gradient(2px 6px at 94% 28%, #7fe0c0 0 60%, transparent 70%),
    linear-gradient(180deg, #ffb3d9 0%, #ff94c6 45%, #f777b3 100%);
  background-size: 120px 100%, 120px 100%, 120px 100%, 120px 100%, 120px 100%, 120px 100%, 120px 100%, 100% 100%;
  border-top: 4px solid #fff0f7;
}
html.land-candy .hill-far { background: #c9b3ff; opacity: 0.7; }
html.land-candy .hill-near { background: #9ee8d0; opacity: 0.85; }
html.land-candy .tree { background: #ffffff; }
html.land-candy .tree::before { background: #ff6fb5; }
html.land-candy .tree::after  { background: #7fe0c0; }

/* Spooky Land — Halloween: a dark purple graveyard ground dotted with little
   pumpkins and grey headstones, inky hills, bare black trees. Night is forced
   (renderNight), so the moon and stars are always out. */
html.land-spooky #grass {
  background:
    radial-gradient(7px 6px at 14% 38%, #ff8a1e 0 70%, transparent 78%),
    radial-gradient(2px 3px at 14% 26%, #3d7a2a 0 70%, transparent 80%),
    radial-gradient(5px 7px at 47% 30%, #8a8798 0 70%, transparent 78%),
    radial-gradient(6px 5px at 73% 58%, #ff7a0f 0 70%, transparent 78%),
    radial-gradient(2px 3px at 73% 48%, #3d7a2a 0 70%, transparent 80%),
    radial-gradient(5px 7px at 90% 34%, #7d7a8c 0 70%, transparent 78%),
    linear-gradient(180deg, #4a2f63 0%, #3a2450 45%, #2a1a3a 100%);
  background-size: 140px 100%, 140px 100%, 140px 100%, 140px 100%, 140px 100%, 140px 100%, 100% 100%;
  border-top: 4px solid #5f3f7d;
}
html.land-spooky .hill-far { background: #3b2a55; opacity: 0.75; }
html.land-spooky .hill-near { background: #2b1f3d; opacity: 0.9; }
html.land-spooky .tree { background: #1a1220; }
html.land-spooky .tree::before { background: #1f1726; }
html.land-spooky .tree::after  { background: #ff8a1e; opacity: 0.85; }

/* Moon — a grey lunar plain: dusty regolith ground pocked with darker craters
   (each a dark bowl with a pale raised rim), ashen grey hills, and pale rocky
   spires for "trees". TheSecondaryBlob's ask (pairs with the 🌙 Over the Moon
   discovery achievement). */
html.land-moon #grass {
  background:
    radial-gradient(circle 15px at 16% 58%, #6f747d 0 50%, #b6bcc5 56%, transparent 62%),
    radial-gradient(circle 23px at 43% 38%, #676c75 0 52%, #b0b6bf 58%, transparent 64%),
    radial-gradient(circle 11px at 65% 68%, #71767f 0 48%, #b9bfc8 54%, transparent 60%),
    radial-gradient(circle 18px at 85% 47%, #6b707a 0 51%, #b3b9c2 57%, transparent 63%),
    linear-gradient(180deg, #b9bec7 0%, #9ca1aa 45%, #83878f 100%);
  border-top: 4px solid #cfd4dc;
}
html.land-moon .hill-far { background: #9ea3ac; opacity: 0.6; }
html.land-moon .hill-near { background: #82868f; opacity: 0.8; }
html.land-moon .tree { background: #8a8f98; }
html.land-moon .tree::before { background: #b0b5be; }
html.land-moon .tree::after  { background: #c3c8d0; }

/* Mars — the rusty red planet: iron-oxide red ground pocked with darker craters
   and scattered rocks (each a dark bowl with a dusty lighter rim), red-brown dune
   hills on the horizon, and reddish rock spires for "trees". A third space world
   beside the moon and neptune (divinegorilla + terreearth's asks). Visual only,
   no new mechanic — the sky and weather stay whatever the player picked. */
html.land-mars #grass {
  background:
    radial-gradient(circle 14px at 18% 60%, #8a3312 0 50%, #c96a3a 56%, transparent 62%),
    radial-gradient(circle 22px at 44% 40%, #7e2e10 0 52%, #c4633a 58%, transparent 64%),
    radial-gradient(circle 10px at 66% 66%, #90361a 0 48%, #ce7040 54%, transparent 60%),
    radial-gradient(circle 17px at 86% 50%, #82300f 0 51%, #c86636 57%, transparent 63%),
    linear-gradient(180deg, #c15a2e 0%, #a5431d 45%, #833113 100%);
  border-top: 4px solid #d9784a;
}
html.land-mars .hill-far { background: #b1552e; opacity: 0.6; }
html.land-mars .hill-near { background: #8f3d1c; opacity: 0.8; }
html.land-mars .tree { background: #7a3418; }
html.land-mars .tree::before { background: #9c4a26; }
html.land-mars .tree::after  { background: #b25c33; }
/* Mars sky: a hazy butterscotch dome that warms toward the horizon, harmonising
   with the rusty ground. Forced by the mars-sky class (set whenever Land is
   Mars) so it overrides the chosen day/sky mode while on Mars and restores it on
   leaving — exactly as Moon forces night. The ambient white water clouds are
   hidden; Mars has none. */
html.mars-sky #sky {
  background: linear-gradient(180deg, #b97a4e 0%, #d29368 42%, #e8b98a 100%);
}
html.mars-sky .cloud { opacity: 0; }

/* Neptune sky: a deep methane-blue dome that lightens only slightly toward the
   horizon, harmonising with the vivid blue ground — never Earth's pale sky.
   Forced by the neptune-sky class (set whenever Land is Neptune) so it overrides
   the chosen day/sky mode while on Neptune and restores it on leaving, exactly
   as Mars does. The ambient white water clouds are hidden; the ice giant has
   none. (dampphoenix: "darker blue and remove the clouds.") */
html.neptune-sky #sky {
  background: linear-gradient(180deg, #0a1f57 0%, #123f8f 55%, #1d59b0 100%);
}
html.neptune-sky .cloud { opacity: 0; }

/* Neptune — a deep methane-blue ice-giant world: vivid blue ground with faint
   swirling cloud bands, dark-blue atmosphere hills, pale icy spires for "trees".
   TheSecondaryBlob's ask. Real Neptune is thought to rain diamonds deep in its
   mantle, so on 🌧️ rainy weather here the pale streaks become bright cyan gems
   (see the diamond-rain override below) — visual only, no new mechanic. */
html.land-neptune #grass {
  background:
    radial-gradient(circle 20px at 22% 52%, rgba(130,195,255,0.45) 0 54%, transparent 62%),
    radial-gradient(circle 26px at 72% 62%, rgba(90,150,235,0.4) 0 54%, transparent 62%),
    linear-gradient(180deg, #2f74d6 0%, #1f52ac 45%, #123a80 100%);
  border-top: 4px solid #8ab9ff;
}
html.land-neptune .hill-far { background: #3a6bc4; opacity: 0.6; }
html.land-neptune .hill-near { background: #274f9c; opacity: 0.8; }
html.land-neptune .tree { background: #1e3f7a; }
html.land-neptune .tree::before { background: #6fa8e6; }
html.land-neptune .tree::after  { background: #9cc6f2; }

/* Diamond rain — on Neptune the rainy streaks become bright cyan-white gems.
   Only the droplet shape changes; it inherits the rainy rule's background-size
   and rain-fall animation, so the diamonds still fall fast like rain. The two
   land+weather classes out-specify the single-class base rainy rule. */
html.rainy.land-neptune #weather::before {
  background-image:
    radial-gradient(3px 3px at 16% 10%, rgba(228,246,255,0.98) 0 52%, rgba(150,220,255,0.5) 60%, transparent 66%),
    radial-gradient(2.6px 2.6px at 52% 32%, rgba(190,235,255,0.9) 0 52%, transparent 62%),
    radial-gradient(3px 3px at 38% 64%, rgba(228,246,255,0.98) 0 52%, rgba(150,220,255,0.5) 60%, transparent 66%),
    radial-gradient(2.6px 2.6px at 84% 86%, rgba(190,235,255,0.9) 0 52%, transparent 62%);
}
html.rainy.land-neptune #weather::after {
  background-image:
    radial-gradient(2.4px 2.4px at 28% 22%, rgba(205,240,255,0.85) 0 52%, transparent 62%),
    radial-gradient(2.4px 2.4px at 72% 58%, rgba(205,240,255,0.85) 0 52%, transparent 62%);
}

/* Acid rain — on Venus the rainy streaks turn sickly sulfuric green (Lpn201898's
   ask). Only the droplet colour changes; it keeps the base rainy streak shape,
   size and rain-fall animation, so the acid still falls fast like rain. The two
   land+weather classes out-specify the single-class base rainy rule. */
html.rainy.land-venus #weather::before {
  background-image:
    radial-gradient(1.7px 11px at 16% 10%, rgba(168,224,72,0.9)  0 65%, transparent 72%),
    radial-gradient(1.5px 9px  at 52% 32%, rgba(140,200,56,0.85) 0 65%, transparent 72%),
    radial-gradient(1.7px 11px at 38% 64%, rgba(168,224,72,0.9)  0 65%, transparent 72%),
    radial-gradient(1.5px 9px  at 84% 86%, rgba(140,200,56,0.85) 0 65%, transparent 72%);
}
html.rainy.land-venus #weather::after {
  background-image:
    radial-gradient(1.4px 8px at 28% 22%, rgba(190,232,96,0.8) 0 65%, transparent 72%),
    radial-gradient(1.4px 8px at 72% 58%, rgba(190,232,96,0.8) 0 65%, transparent 72%);
}

/* ===== The rest of the solar system =====
   Six more worlds beside Moon/Mars/Neptune, completing the "visit every planet"
   set (kaneonwebsim2026 & repeated space-world asks). Each reskins the ground
   like the earlier planets; the ones with a coloured atmosphere also force their
   own sky (see the *-sky rules) and hide the white water clouds. Airless
   Mercury/Pluto ride the forced night sky (set in renderNight) and just hide
   clouds here. */

/* Mercury — a scorched, heavily-cratered grey-tan rock: like the Moon but warmer
   and browner (iron-rich regolith), pocked with dark craters. Airless → forced
   night sky, no clouds/birds/planes. */
html.land-mercury #grass {
  background:
    radial-gradient(circle 14px at 17% 58%, #6a655c 0 50%, #a49c8f 56%, transparent 62%),
    radial-gradient(circle 22px at 45% 40%, #625d54 0 52%, #9e968a 58%, transparent 64%),
    radial-gradient(circle 10px at 67% 66%, #726c62 0 48%, #a8a094 54%, transparent 60%),
    radial-gradient(circle 17px at 86% 50%, #676159 0 51%, #a29a8e 57%, transparent 63%),
    linear-gradient(180deg, #a39b8e 0%, #8b8479 45%, #726c62 100%);
  border-top: 4px solid #c1b9ab;
}
html.land-mercury .hill-far { background: #948c80; opacity: 0.6; }
html.land-mercury .hill-near { background: #7a746a; opacity: 0.8; }
html.land-mercury .tree { background: #7d7669; }
html.land-mercury .tree::before { background: #a39b8e; }
html.land-mercury .tree::after  { background: #b8b0a2; }
html.land-mercury .cloud { opacity: 0; }

/* Venus — a baked volcanic hellscape: dim orange-brown basalt ground under a
   permanent thick sulfur-yellow haze. Forced venus-sky, no clouds/birds. */
html.land-venus #grass {
  background:
    radial-gradient(circle 12px at 24% 56%, #7a4f22 0 55%, transparent 62%),
    radial-gradient(circle 16px at 70% 64%, #6e461d 0 55%, transparent 62%),
    linear-gradient(180deg, #b5793a 0%, #9c6129 45%, #7e4c1c 100%);
  border-top: 4px solid #d59a55;
}
html.land-venus .hill-far { background: #b98a4c; opacity: 0.6; }
html.land-venus .hill-near { background: #9c6b30; opacity: 0.8; }
html.land-venus .tree { background: #6e4a22; }
html.land-venus .tree::before { background: #8a5e2e; }
html.land-venus .tree::after  { background: #a0713a; }
html.venus-sky #sky {
  background: linear-gradient(180deg, #c99a3a 0%, #e0bd5f 45%, #f2d888 100%);
}
html.venus-sky .cloud { opacity: 0; }

/* Jupiter — the banded gas giant: its "ground" is the swirling cloud-tops, warm
   cream/tan/rust horizontal bands, with the Great Red Spot low-right. Forced
   jupiter-sky continues the bands overhead. */
html.land-jupiter #grass {
  background:
    radial-gradient(ellipse 60px 22px at 76% 62%, #c0492f 0 55%, #d67a52 68%, transparent 74%),
    repeating-linear-gradient(180deg,
      #e6cba0 0 7px, #cf9a63 7px 15px, #e0bd8e 15px 22px, #b5764a 22px 30px);
  border-top: 4px solid #f0dab6;
}
html.land-jupiter .hill-far { background: #d3a874; opacity: 0.6; }
html.land-jupiter .hill-near { background: #bd8850; opacity: 0.8; }
html.land-jupiter .tree { background: #9c6a3c; }
html.land-jupiter .tree::before { background: #c99a63; }
html.land-jupiter .tree::after  { background: #e0bd8e; }
html.jupiter-sky #sky {
  background: repeating-linear-gradient(180deg,
    #d8b98a 0 22px, #c59a68 22px 40px, #e2c79c 40px 60px, #b78a5c 60px 82px);
}
html.jupiter-sky .cloud { opacity: 0; }

/* Saturn — the pale butterscotch gas giant: softer, creamier bands than Jupiter,
   no red spot. Forced saturn-sky is a smooth pale-gold dome (the 🪐 label carries
   the rings). */
html.land-saturn #grass {
  background:
    repeating-linear-gradient(180deg,
      #f0e0bc 0 8px, #dcc492 8px 17px, #ecdcb4 17px 25px, #cdb27e 25px 34px);
  border-top: 4px solid #f7ecd0;
}
html.land-saturn .hill-far { background: #e0cc9e; opacity: 0.6; }
html.land-saturn .hill-near { background: #cbb078; opacity: 0.8; }
html.land-saturn .tree { background: #b39760; }
html.land-saturn .tree::before { background: #dcc492; }
html.land-saturn .tree::after  { background: #ecdcb4; }
html.saturn-sky #sky {
  background: linear-gradient(180deg, #d8c088 0%, #e8d5a2 45%, #f5e8c2 100%);
}
html.saturn-sky .cloud { opacity: 0; }

/* Uranus — the featureless pale-cyan ice giant: an even, calm aquamarine world,
   ground and sky nearly one colour. Forced uranus-sky. */
html.land-uranus #grass {
  background: linear-gradient(180deg, #9ee0dd 0%, #7fccca 45%, #5fb2b4 100%);
  border-top: 4px solid #c6f0ed;
}
html.land-uranus .hill-far { background: #8ad3d0; opacity: 0.6; }
html.land-uranus .hill-near { background: #66b8b8; opacity: 0.8; }
html.land-uranus .tree { background: #479a9a; }
html.land-uranus .tree::before { background: #7fccca; }
html.land-uranus .tree::after  { background: #a0e0dd; }
html.uranus-sky #sky {
  background: linear-gradient(180deg, #a7e2df 0%, #c2eeeb 50%, #d8f4f1 100%);
}
html.uranus-sky .cloud { opacity: 0; }

/* Pluto — the frozen dwarf: reddish-tan tholin ground streaked with pale
   nitrogen-ice patches (a nod to the famous heart). Airless → forced night sky,
   no clouds/birds. */
html.land-pluto #grass {
  background:
    radial-gradient(ellipse 40px 16px at 40% 46%, rgba(238,224,204,0.85) 0 55%, transparent 64%),
    radial-gradient(circle 12px at 76% 66%, rgba(232,218,198,0.7) 0 55%, transparent 62%),
    linear-gradient(180deg, #a68b72 0%, #8a6f58 45%, #6e5544 100%);
  border-top: 4px solid #d8c4ad;
}
html.land-pluto .hill-far { background: #9a8168; opacity: 0.6; }
html.land-pluto .hill-near { background: #7c6350; opacity: 0.8; }
html.land-pluto .tree { background: #6a5648; }
html.land-pluto .tree::before { background: #9a8168; }
html.land-pluto .tree::after  { background: #c2b096; }
html.land-pluto .cloud { opacity: 0; }

/* Eris — Pluto's twin out in the far dark: one of the brightest surfaces in the
   solar system, frozen methane frost, near-white with faint grey pits. Airless →
   forced night sky, no clouds/birds/trees. */
html.land-eris #grass {
  background:
    radial-gradient(circle 14px at 28% 58%, rgba(170,176,186,0.55) 0 55%, transparent 64%),
    radial-gradient(circle 9px at 70% 40%, rgba(170,176,186,0.5) 0 55%, transparent 62%),
    linear-gradient(180deg, #f2f3f5 0%, #d9dce2 50%, #b9bec8 100%);
  border-top: 4px solid #ffffff;
}
html.land-eris .hill-far { background: #c9ced6; opacity: 0.6; }
html.land-eris .hill-near { background: #aab0ba; opacity: 0.8; }
html.land-eris .cloud { opacity: 0; }

/* ---------- sky traffic: birds + a plane ---------- */
.bird, .plane {
  position: absolute;
  pointer-events: none;
  z-index: 1;
  transition: opacity 0.6s ease;
}
/* Birds: a small open "m" drawn with two borders, gently bobbing as they cross. */
.bird {
  width: 14px; height: 7px;
  border-top: 2.5px solid rgba(60, 80, 100, 0.55);
  border-radius: 50%;
  border-left-color: transparent; border-right-color: transparent;
  background: transparent;
}
.bird::before {
  content: '';
  position: absolute;
  left: 50%; top: -1px;
  width: 14px; height: 7px;
  border-top: 2.5px solid rgba(60, 80, 100, 0.55);
  border-radius: 50%;
}
.bird-a { top: 18%; animation: fly-across 54s linear infinite; }
.bird-b { top: 24%; transform: scale(0.75); animation: fly-across 78s linear infinite; animation-delay: -30s; opacity: 0.8; }
/* Plane: a small fuselage with a tail fin, crossing the upper sky now and then. */
.plane {
  top: 13%;
  width: 26px; height: 7px;
  background: #eef4f8;
  border-radius: 6px 3px 3px 6px;
  box-shadow: 0 1px 0 rgba(0,0,0,0.08);
  animation: plane-across 72s linear infinite;
}
.plane::before { /* tail fin */
  content: '';
  position: absolute;
  right: 0; top: -6px;
  border-left: 7px solid #dbe6ee;
  border-top: 6px solid transparent;
}
.plane::after { /* wing */
  content: '';
  position: absolute;
  left: 9px; top: 4px;
  width: 9px; height: 6px;
  background: #d3e0e9;
  border-radius: 0 0 4px 4px;
}
@keyframes fly-across {
  from { transform: translateX(-30px); }
  to   { transform: translateX(105vw); }
}
@keyframes plane-across {
  /* Crosses briskly so it clearly reads as *flying* (the slow 120s loop crept
     at ~5px/s and looked frozen), then waits off-screen before the next pass.
     Travels right->left so the rounded nose (left end, tail fin on the right)
     leads instead of flying backwards. */
  0%   { transform: translateX(112vw); }
  22%  { transform: translateX(-40px); }
  100% { transform: translateX(-40px); }
}
/* Night sky has no daytime traffic — fade birds and the day plane out. The
   night plane (below) flies in their place. */
html.night .bird, html.night .plane:not(.plane-night) { opacity: 0; }

/* ---------- night flight ----------
   A dark plane silhouette that only crosses at night, carrying real aircraft
   nav lights: a steady green light at the nose, a steady red at the tail, and
   a white anti-collision strobe that blinks on top (united_airlines). Reuses
   the daytime plane's position/size/animation via the shared .plane class, so
   it drifts across on the same loop. Hidden by default; shown only at night. */
.plane-night { opacity: 0; background: #1b2330; box-shadow: 0 1px 0 rgba(0,0,0,0.25); }
.plane-night::before { border-left-color: #26313f; } /* tail fin, darkened */
.plane-night::after  { background: #26313f; }         /* wing, darkened */
html.night .plane-night { opacity: 1; }
/* No Earth sky traffic crosses a deep-space world. The moon is airless; Neptune
   is an ice giant far out in the solar system — neither has birds or aeroplanes.
   Moon forces the night sky (which would otherwise fly the night plane, nav
   lights and all, across the starfield) and Neptune keeps the day sky (which
   flies the day plane + birds) — both looked wrong to the same player who found
   them (VerityTheUser: "a plane on the moon", "remove the planes and birds on
   neptune"). Ground every bit of sky traffic on both; specificity ties
   html.night .plane-night, and the !important + later source order keep it
   grounded. */
html.land-moon .bird,
html.land-moon .plane,
html.land-moon .plane-night,
html.land-neptune .bird,
html.land-neptune .plane,
html.land-neptune .plane-night,
html.land-mars .bird,
html.land-mars .plane,
html.land-mars .plane-night,
/* The Sun — a star, not a planet: a boiling sea of bright granules with a
   couple of dark sunspots, under a forced fiery corona sky. The granules drift
   slowly so the surface reads as alive. No clouds/birds/planes/trees. */
html.land-sun #grass {
  background:
    radial-gradient(ellipse 22px 14px at 30% 52%, #7a2a08 0 45%, #c2410c 60%, transparent 70%),
    radial-gradient(circle 9px at 72% 68%, #8a3408 0 45%, #d9550f 62%, transparent 72%),
    radial-gradient(circle 10px at 0 0, #fff3a0 0 40%, transparent 72%) 0 0 / 26px 22px,
    radial-gradient(circle 8px at 13px 11px, #ffe066 0 35%, transparent 70%) 0 0 / 26px 22px,
    linear-gradient(180deg, #ffd23f 0%, #ff9f1c 45%, #f26419 100%);
  border-top: 4px solid #fff6b0;
  box-shadow: 0 -6px 26px 6px rgba(255, 190, 60, 0.75);
  animation: sun-boil 9s linear infinite;
}
@keyframes sun-boil {
  from { background-position: 0 0, 0 0, 0 0, 0 0, 0 0; }
  to   { background-position: 0 0, 0 0, 26px 22px, -26px 22px, 0 0; }
}
@media (prefers-reduced-motion: reduce) { html.land-sun #grass { animation: none; } }
html.land-sun .hill-far { background: #ffb347; opacity: 0.7; }
html.land-sun .hill-near { background: #ff8c1a; opacity: 0.85; }
html.sun-sky #sky {
  background: radial-gradient(ellipse 140% 90% at 50% 100%, #ffe08a 0%, #ff9a3c 35%, #e2481c 70%, #7a1608 100%);
}
html.sun-sky .cloud { opacity: 0; }
/* Red dwarf — a small, cool star: the Sun's boiling granules in dim embers
   under a deep crimson sky. Same boil animation, slower. */
html.land-reddwarf #grass {
  background:
    radial-gradient(ellipse 20px 12px at 62% 48%, #3a0606 0 45%, #6e0f0a 60%, transparent 70%),
    radial-gradient(circle 10px at 0 0, #ff7a4a 0 40%, transparent 72%) 0 0 / 28px 24px,
    radial-gradient(circle 8px at 14px 12px, #e8452a 0 35%, transparent 70%) 0 0 / 28px 24px,
    linear-gradient(180deg, #d9381e 0%, #a3200f 50%, #5e0c06 100%);
  border-top: 4px solid #ff8a5c;
  box-shadow: 0 -6px 26px 6px rgba(230, 60, 30, 0.6);
  animation: reddwarf-boil 14s linear infinite;
}
@keyframes reddwarf-boil {
  from { background-position: 0 0, 0 0, 0 0, 0 0; }
  to   { background-position: 0 0, 28px 24px, -28px 24px, 0 0; }
}
@media (prefers-reduced-motion: reduce) { html.land-reddwarf #grass { animation: none; } }
html.land-reddwarf .hill-far { background: #8a1c10; opacity: 0.7; }
html.land-reddwarf .hill-near { background: #6a120a; opacity: 0.85; }
html.reddwarf-sky #sky {
  background: radial-gradient(ellipse 140% 90% at 50% 100%, #ff6a3d 0%, #b8231a 35%, #5a0b10 70%, #1c0308 100%);
}
html.reddwarf-sky .cloud { opacity: 0; }
/* Neutron star — a city-sized dead star: a smooth, blinding blue-white crust
   with a faint hex lattice, under the forced night sky. The glow pulses like a
   pulsar's beam sweeping past. */
html.land-neutron #grass {
  background:
    radial-gradient(circle 1.4px at 50% 50%, rgba(255, 255, 255, 0.9) 0 60%, transparent 75%) 0 0 / 14px 12px,
    linear-gradient(180deg, #f4fbff 0%, #b9dcff 35%, #6fa8ff 75%, #3d63c9 100%);
  border-top: 3px solid #ffffff;
  box-shadow: 0 -6px 30px 8px rgba(150, 200, 255, 0.8);
  animation: pulsar-beat 1.4s ease-in-out infinite;
}
@keyframes pulsar-beat {
  0%, 100% { box-shadow: 0 -6px 30px 8px rgba(150, 200, 255, 0.55); }
  50%      { box-shadow: 0 -10px 46px 14px rgba(190, 225, 255, 0.95); }
}
@media (prefers-reduced-motion: reduce) { html.land-neutron #grass { animation: none; } }
html.land-neutron .hill-far, html.land-neutron .hill-near { display: none; }
html.land-neutron .cloud { opacity: 0; }
/* Black hole — a pitch-dark floor at the event horizon, lit only by the
   orange accretion disc swirling just above it, under the forced night sky. */
html.land-blackhole #grass {
  background:
    radial-gradient(ellipse 60% 14px at 50% 0, rgba(255, 170, 60, 0.75), transparent 100%),
    linear-gradient(180deg, #1a0d05 0%, #050307 30%, #000 100%);
  border-top: 3px solid #ffb347;
  box-shadow: 0 -6px 34px 10px rgba(255, 140, 40, 0.6);
  animation: accretion-glow 2.2s ease-in-out infinite;
}
@keyframes accretion-glow {
  0%, 100% { box-shadow: 0 -6px 30px 8px rgba(255, 130, 40, 0.45); border-top-color: #ff9a3c; }
  50%      { box-shadow: 0 -10px 48px 14px rgba(255, 190, 90, 0.85); border-top-color: #ffd27a; }
}
@media (prefers-reduced-motion: reduce) { html.land-blackhole #grass { animation: none; } }
html.land-blackhole .hill-far, html.land-blackhole .hill-near { display: none; }
html.land-blackhole .cloud { opacity: 0; }

html.land-sun .bird,
html.land-sun .plane,
html.land-sun .plane-night,
html.land-mercury .bird,
html.land-mercury .plane,
html.land-mercury .plane-night,
html.land-venus .bird,
html.land-venus .plane,
html.land-venus .plane-night,
html.land-jupiter .bird,
html.land-jupiter .plane,
html.land-jupiter .plane-night,
html.land-saturn .bird,
html.land-saturn .plane,
html.land-saturn .plane-night,
html.land-uranus .bird,
html.land-uranus .plane,
html.land-uranus .plane-night,
html.land-pluto .bird,
html.land-pluto .plane,
html.land-pluto .plane-night,
html.land-eris .bird,
html.land-eris .plane,
html.land-eris .plane-night,
html.land-reddwarf .bird,
html.land-reddwarf .plane,
html.land-reddwarf .plane-night,
html.land-neutron .bird,
html.land-neutron .plane,
html.land-neutron .plane-night,
html.land-blackhole .bird,
html.land-blackhole .plane,
html.land-blackhole .plane-night,
html.land-space .bird,
html.land-space .plane,
html.land-space .plane-night,
html.land-space .cloud { opacity: 0 !important; }
/* No trees off-world. The space lands tinted the meadow trees as "rock spires",
   but a round canopy on a trunk still reads as a tree — divinegorilla (who asked
   for Mars in the first place) filed "Bug Mars Has Not Trees" with an arrow at
   one. Hide them on all ten planets/moons; the ground already carries craters. */
html.land-sun .tree, html.land-moon .tree, html.land-mars .tree, html.land-mercury .tree,
html.land-venus .tree, html.land-jupiter .tree, html.land-saturn .tree,
html.land-uranus .tree, html.land-neptune .tree, html.land-pluto .tree, html.land-eris .tree,
html.land-reddwarf .tree, html.land-neutron .tree, html.land-blackhole .tree,
html.land-space .tree, html.land-space .hill-far, html.land-space .hill-near { display: none; }
/* Deep space — no ground at all, so the floor is just a faint glowing edge
   over more starfield (the box still has a bottom; blocks bump it and float
   back up). Forced night sky; far-off Earth hangs where the moon would be. */
html.land-space #grass {
  background:
    radial-gradient(circle 1.5px at 20% 30%, #fff 0 60%, transparent 70%),
    radial-gradient(circle 1px at 63% 55%, #dfe8ff 0 60%, transparent 70%),
    radial-gradient(circle 1.2px at 85% 25%, #fff 0 60%, transparent 70%),
    radial-gradient(circle 1px at 42% 78%, #cfd8ff 0 60%, transparent 70%),
    linear-gradient(180deg, #120c2e 0%, #0a0820 60%, #05040f 100%);
  border-top: 2px solid rgba(150, 140, 255, 0.55);
  box-shadow: 0 -4px 18px 2px rgba(120, 100, 255, 0.35);
}
html.land-space #sky {
  background:
    radial-gradient(ellipse 60% 40% at 30% 40%, rgba(150, 80, 220, 0.28), transparent 70%),
    radial-gradient(ellipse 50% 35% at 75% 65%, rgba(60, 120, 230, 0.22), transparent 70%),
    linear-gradient(180deg, #05040f 0%, #0d0a26 60%, #140f36 100%);
}
.navlight { position: absolute; width: 3px; height: 3px; border-radius: 50%; top: 2px; }
.nav-green  { left: -1px;  background: #4dff6a; box-shadow: 0 0 4px 1px rgba(77,255,106,0.9); }
.nav-red    { right: -1px; background: #ff4d4d; box-shadow: 0 0 4px 1px rgba(255,77,77,0.9); }
.nav-strobe {
  left: 11px; top: -2px; width: 2.5px; height: 2.5px;
  background: #fff; box-shadow: 0 0 5px 2px rgba(255,255,255,0.95);
  animation: strobe-blink 1.6s steps(1, end) infinite;
}
/* Quick double-flash then dark, like a real anti-collision strobe. */
@keyframes strobe-blink { 0%, 88%, 94% { opacity: 0; } 90%, 96% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  .bird, .plane { animation: none; opacity: 0; }
  /* Beats html.night .plane-night's specificity so the frozen night plane
     doesn't sit parked in the corner. */
  html.night .plane-night, .nav-strobe { animation: none; opacity: 0; }
}

/* ---------- Twenty-Six's car ----------
   A friendly little red car that motors across the grass line every so often,
   a nod to canon Twenty-Six (the dapper detective who drives around
   Numberland). Rides in its own layer at z-index 1 so it passes *behind* the
   trees (z-index 2) for depth. GPU-cheap transform loop, hidden at night like
   the sky traffic. */
#road {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  height: 110px;
  z-index: 1;
  pointer-events: none;
  overflow: hidden;
}
.car26 {
  position: absolute;
  left: 0; bottom: 24px;              /* rolling along the grass */
  width: 62px; height: 24px;
  background: #e8503a;                /* cheery red body */
  border-radius: 9px 12px 5px 5px;
  box-shadow: inset 0 -4px 0 rgba(0,0,0,0.14), 0 2px 3px rgba(0,0,0,0.18);
  animation: car26-across 56s linear infinite;
  transition: opacity 0.6s ease, filter 0.15s ease;
}
/* Brief flash when tapped — 26 is the detective who drives around Numberland,
   so tapping his car pops a real 26 out of it (gentlegorilla1009428's idea).
   The tap is hit-tested in the playground pointer handler, since the play layer
   sits above this road layer. */
.car26-honk { filter: brightness(1.4); }
.car26::before {                      /* cabin roof + windscreen band */
  content: '';
  position: absolute;
  left: 11px; top: -13px;
  width: 30px; height: 15px;
  background: #e8503a;
  border-radius: 8px 8px 0 0;
  box-shadow: inset 0 5px 0 #bce4f5;  /* light-blue window */
}
.car26-plate {
  position: absolute;
  right: 6px; top: 5px;
  font: 700 11px/1 system-ui, -apple-system, sans-serif;
  color: #fff;
  letter-spacing: 0.5px;
}
.car26-wheel {
  position: absolute;
  bottom: -6px;
  width: 15px; height: 15px;
  background: #2a2a2a;
  border-radius: 50%;
  box-shadow: inset 0 0 0 4px #d0d0d0, inset 0 0 0 6px #2a2a2a;
  animation: car26-wheel-spin 0.65s linear infinite;
}
.car26-wheel-a { left: 8px; }
.car26-wheel-b { right: 8px; }
@keyframes car26-across {
  /* Drives briskly across, then waits off-screen-right before the next pass so
     it reads as an occasional visitor, not constant traffic. */
  0%   { transform: translateX(-90px); }
  30%  { transform: translateX(104vw); }
  100% { transform: translateX(104vw); }
}
@keyframes car26-wheel-spin {
  to { transform: rotate(360deg); }
}
/* No car at night. */
html.night #road .car26 { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .car26 { animation: none; opacity: 0; }
  .car26-wheel { animation: none; }
}

#wordmark {
  position: fixed;
  top: 12px;
  left: 16px;
  font-size: 19px;
  font-weight: normal;
  color: #ffffff;
  text-shadow: 0 2px 0 rgba(35, 110, 150, 0.45);
  letter-spacing: 0.5px;
  z-index: 30;
  pointer-events: none;
}

/* ---------- playground & blocks ---------- */
#playground {
  position: fixed;
  inset: 0;
  z-index: 10;
  /* Zoom-out scales the whole block layer from the top-left so screen = world ×
     scale — keeps the pointer→world math a simple divide. Scenery/grass live in
     separate layers and stay put, so only the play space pulls back. */
  transform-origin: 0 0;
  transition: transform 0.28s ease;
}

.block {
  position: absolute;
  left: 0; top: 0;
  will-change: transform;
  cursor: grab;
  touch-action: none;
}
/* Grab slop. A block is a DOM element sized exactly by its cubes, so the browser
   hit-tests it at its precise visual bounds with no tolerance at all — and one
   cube is 32px. The welcome row a first-time player is handed is 1,2,3,4,5,6,
   and the "1" is a single 32x32 cube. Measured (probe-grab-tolerance): a touch
   landing 16px off centre — exactly half a cube — grabs NOTHING. This project
   already adopted a 44px touch floor for its buttons (mobile-touch-targets);
   the game's primary interactive object was never held to it, and 74.3% of views
   are touch. A finger that prods a block twice and gets no response has learnt
   that the blocks are scenery, which is the one lesson the game cannot afford.

   8px is not a round number, it is the largest value that changes nothing else:
   the welcome row sits on a 48px pitch with 32px blocks, so 8px a side closes
   each 16px gap exactly and no two halos in the default layout overlap. It takes
   the smallest target 32 -> 48px, clear of the 44px floor.

   z-index:-1 keeps the halo BEHIND the cubes rather than over them, so a tap on
   a cube still targets the cube and any code reading e.target sees what it saw
   before; only taps in the previously-dead margin now resolve to the block. */
.block::before {
  content: '';
  position: absolute;
  inset: -8px;
  z-index: -1;
}
.block.held { cursor: grabbing; z-index: 20; }
.block.held .cube { box-shadow: 0 8px 18px rgba(20, 60, 90, 0.25); }
/* Press-and-hold squish: the frame loop appends scale(1, n) to the transform;
   origin at the base so the block squashes down onto the ground, and a springy
   transition so it pops flat (it's held still while squished, so easing the
   transform here doesn't fight the drag/physics position updates). */
.block.squished { transform-origin: 50% 100%; transition: transform 0.13s cubic-bezier(0.34, 1.2, 0.5, 1); }

/* × mode's press-and-hold "unmake" (see UNMAKE_HOLD_MS in main.js) gets a
   visible WIND-UP: the block trembles harder the longer you hold it, then comes
   apart into its factors. The telegraph is the safety feature, not decoration —
   × mode's primary verb is dragging blocks together to multiply, so a player
   who presses and then hesitates before dragging needs to SEE the split coming
   and be able to cancel it by moving (onMove clears the timer at 6px).
   ⚠️ Animates the INDEPENDENT `rotate`/`scale` properties, never `transform`:
   the main loop writes `transform` on every block every frame for position, so
   a transform animation here would fight the physics. `.wiggle` already does
   it this way. Duration comes from --unmake-ms, set by the same JS constant
   that arms the timer, so the wind-up can never drift out of sync with it. */
.block.unmaking { animation: unmake-charge var(--unmake-ms, 500ms) ease-in both; }
@keyframes unmake-charge {
  0%   { rotate: 0deg;    scale: 1; }
  30%  { rotate: -1.5deg; }
  55%  { rotate: 1.5deg;  scale: 0.99; }
  78%  { rotate: -3deg; }
  100% { rotate: 3deg;    scale: 0.95; }
}
@media (prefers-reduced-motion: reduce) {
  /* Keep the cue, drop the shake — the scale alone still reads as "charging". */
  @keyframes unmake-charge {
    0%   { scale: 1; }
    100% { scale: 0.95; }
  }
}

/* ---------- golden mutation (rare shiny spawn) ---------- */
/* A golden block keeps its number/shape (so it's still recognisable) but wears a
   warm gold glow and a diagonal light-sheen that sweeps across it — the classic
   "shiny" read. Purely cosmetic; the block behaves like any other. */
.block-golden { animation: golden-glow 1.9s ease-in-out infinite; }
@keyframes golden-glow {
  0%, 100% { filter: drop-shadow(0 0 3px rgba(255, 210, 70, 0.8)); }
  50%      { filter: drop-shadow(0 0 12px rgba(255, 232, 130, 1)); }
}
.block-golden::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  background: linear-gradient(115deg,
    transparent 32%,
    rgba(255, 240, 175, 0.85) 47%,
    rgba(255, 255, 255, 0.95) 51%,
    rgba(255, 224, 120, 0.85) 55%,
    transparent 70%);
  background-size: 260% 260%;
  animation: golden-sheen 2.6s linear infinite;
}
@keyframes golden-sheen {
  0%   { background-position: 130% 0; }
  100% { background-position: -50% 0; }
}
/* A super-cube (27, 64…) fills only a diamond-shaped isometric silhouette inside
   its square viewport box, so the rectangular ::after sheen sweeps a bare square
   AROUND the cube — it reads as a stray gold square, not a shine on the block
   (summerwinter's report). Drop the flat sheen for cube-shaped blocks; the
   drop-shadow glow already follows the cube's real silhouette and carries the
   shiny read on its own. */
.block-golden:has(.supercube-scene)::after,
.block-diamond:has(.supercube-scene)::after,
.block-rainbow:has(.supercube-scene)::after,
.block-aquatic:has(.supercube-scene)::after,
.block-oxygen:has(.supercube-scene)::after,
.block-radioactive:has(.supercube-scene)::after { display: none; }

/* ---------- diamond mutation (the tier above golden) ---------- */
/* Same "shiny" read as golden, but an icy crystalline blue-white — a rarer, cooler
   glint. Purely cosmetic; behaves like any other block. */
.block-diamond { animation: diamond-glow 1.9s ease-in-out infinite; }
@keyframes diamond-glow {
  0%, 100% { filter: drop-shadow(0 0 3px rgba(150, 225, 255, 0.85)); }
  50%      { filter: drop-shadow(0 0 13px rgba(210, 245, 255, 1)); }
}
.block-diamond::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  background: linear-gradient(115deg,
    transparent 32%,
    rgba(185, 235, 255, 0.85) 47%,
    rgba(255, 255, 255, 0.98) 51%,
    rgba(150, 220, 255, 0.85) 55%,
    transparent 70%);
  background-size: 260% 260%;
  animation: golden-sheen 2.4s linear infinite;
}

/* ---------- rainbow mutation (the apex tier — rarest of all) ---------- */
/* Same "shiny" read as golden/diamond, but the full spectrum: the block keeps its
   number/shape while a rainbow sheen sweeps across it and the glow cycles every
   hue. Purely cosmetic; behaves like any other block. */
.block-rainbow { animation: rainbow-glow 3s linear infinite; }
@keyframes rainbow-glow {
  0%   { filter: drop-shadow(0 0 5px rgba(255, 45, 85, 0.9)); }
  25%  { filter: drop-shadow(0 0 8px rgba(255, 200, 0, 0.95)); }
  50%  { filter: drop-shadow(0 0 8px rgba(52, 209, 122, 0.95)); }
  75%  { filter: drop-shadow(0 0 8px rgba(10, 163, 255, 0.95)); }
  100% { filter: drop-shadow(0 0 5px rgba(165, 77, 255, 0.9)); }
}
.block-rainbow::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  opacity: 0.6;
  background: linear-gradient(115deg,
    rgba(255, 45, 85, 0.9),
    rgba(255, 149, 0, 0.9),
    rgba(255, 224, 0, 0.9),
    rgba(52, 209, 122, 0.9),
    rgba(10, 163, 255, 0.9),
    rgba(165, 77, 255, 0.9),
    rgba(255, 45, 85, 0.9));
  background-size: 300% 100%;
  animation: rainbow-sheen 3s linear infinite;
}
@keyframes rainbow-sheen {
  0%   { background-position: 0% 0; }
  100% { background-position: 300% 0; }
}

/* ---------- radioactive mutation (the event-only tier) ---------- */
/* A toxic nuclear-green glow that pulses harder than the other tiers, plus a
   green sheen sweep. Only ever applied to blocks spawned during a Radioactive
   Rush. Purely cosmetic; behaves like any other block. */
.block-radioactive { animation: radioactive-glow 1.1s ease-in-out infinite; }
@keyframes radioactive-glow {
  0%, 100% { filter: drop-shadow(0 0 4px rgba(120, 255, 40, 0.85)); }
  50%      { filter: drop-shadow(0 0 15px rgba(170, 255, 60, 1)); }
}
.block-radioactive::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  background: linear-gradient(115deg,
    transparent 34%,
    rgba(150, 255, 60, 0.8) 48%,
    rgba(230, 255, 180, 0.95) 51%,
    rgba(120, 255, 40, 0.8) 54%,
    transparent 68%);
  background-size: 260% 260%;
  animation: golden-sheen 1.9s linear infinite;
}

/* ---------- lava mutation (the Rush's orange flavour) ---------- */
/* A molten orange/red glow with a warm sheen sweep. Only ever applied to blocks
   spawned during a Lava Rush. Purely cosmetic; behaves like any other block. */
.block-lava { animation: lava-glow 1.1s ease-in-out infinite; }
@keyframes lava-glow {
  0%, 100% { filter: drop-shadow(0 0 4px rgba(255, 90, 0, 0.85)); }
  50%      { filter: drop-shadow(0 0 15px rgba(255, 140, 20, 1)); }
}
.block-lava::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  background: linear-gradient(115deg,
    transparent 34%,
    rgba(255, 120, 20, 0.8) 48%,
    rgba(255, 225, 160, 0.95) 51%,
    rgba(255, 70, 0, 0.8) 54%,
    transparent 68%);
  background-size: 260% 260%;
  animation: golden-sheen 1.9s linear infinite;
}

/* ---------- charged mutation (the lightning tier) ---------- */
/* An electric cyan glow with a fast crackle, plus a cyan sheen sweep. Applied
   when a lightning bolt strikes a settled block. Purely cosmetic. */
.block-charged { animation: charged-glow 0.7s ease-in-out infinite; }
@keyframes charged-glow {
  0%, 100% { filter: drop-shadow(0 0 4px rgba(0, 229, 255, 0.85)); }
  45%      { filter: drop-shadow(0 0 16px rgba(125, 249, 255, 1)); }
  55%      { filter: drop-shadow(0 0 6px rgba(0, 229, 255, 0.9)); }
}
.block-charged::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  background: linear-gradient(115deg,
    transparent 34%,
    rgba(0, 229, 255, 0.8) 48%,
    rgba(224, 255, 255, 0.95) 51%,
    rgba(57, 214, 255, 0.8) 54%,
    transparent 68%);
  background-size: 260% 260%;
  animation: golden-sheen 1.5s linear infinite;
}

/* ---------- eclipse mutation (keyword tier) ---------- */
/* A dark block wearing a glowing golden corona — the moment the moon hides the
   sun. Summoned by typing "eclipse". Purely cosmetic. */
.block-eclipse { animation: eclipse-glow 2.6s ease-in-out infinite; }
@keyframes eclipse-glow {
  0%, 100% { filter: drop-shadow(0 0 6px rgba(255, 205, 95, 0.8)) brightness(0.5) saturate(0.7); }
  50%      { filter: drop-shadow(0 0 18px rgba(255, 224, 140, 1)) brightness(0.6) saturate(0.75); }
}
.block-eclipse::after {
  content: '';
  position: absolute;
  inset: -3px;
  pointer-events: none;
  border-radius: 6px;
  z-index: 6;
  box-shadow: 0 0 12px 3px rgba(255, 214, 106, 0.95) inset, 0 0 16px 4px rgba(255, 214, 106, 0.7);
}

/* ---------- aquatic mutation (keyword tier) ---------- */
/* A deep ocean-blue block with a slow watery sheen. Summoned by typing
   "aquatic". Purely cosmetic. */
.block-aquatic { animation: aquatic-glow 2.2s ease-in-out infinite; }
@keyframes aquatic-glow {
  0%, 100% { filter: drop-shadow(0 0 4px rgba(0, 119, 204, 0.85)); }
  50%      { filter: drop-shadow(0 0 15px rgba(63, 214, 224, 1)); }
}
.block-aquatic::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  background: linear-gradient(115deg,
    transparent 34%,
    rgba(0, 150, 220, 0.75) 47%,
    rgba(180, 245, 255, 0.95) 51%,
    rgba(0, 119, 204, 0.75) 55%,
    transparent 68%);
  background-size: 260% 260%;
  animation: golden-sheen 2.6s linear infinite;
}

/* ---------- oxygen mutation (keyword tier) ---------- */
/* A pale, airy mint-white block with a soft breathing pulse. Summoned by typing
   "oxygen". Purely cosmetic. */
.block-oxygen { animation: oxygen-glow 3s ease-in-out infinite; }
@keyframes oxygen-glow {
  0%, 100% { filter: drop-shadow(0 0 4px rgba(169, 245, 230, 0.75)) brightness(1.05); }
  50%      { filter: drop-shadow(0 0 16px rgba(255, 255, 255, 0.95)) brightness(1.14); }
}
.block-oxygen::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  background: linear-gradient(115deg,
    transparent 36%,
    rgba(198, 255, 233, 0.7) 48%,
    rgba(255, 255, 255, 0.95) 51%,
    rgba(216, 251, 255, 0.7) 54%,
    transparent 66%);
  background-size: 260% 260%;
  animation: golden-sheen 3.2s linear infinite;
}

/* ---------- obsidian mutation (keyword tier) ---------- */
/* A dark volcanic-glass block: dimmed almost to black with a cold violet edge
   glow and a hard glassy diagonal glint. Summoned by typing "obsidian". Fling one
   into another block and it bonks it (see the obsidian-bonk check in main.js). */
.block-obsidian { animation: obsidian-glow 2.8s ease-in-out infinite; }
@keyframes obsidian-glow {
  0%, 100% { filter: drop-shadow(0 0 6px rgba(107, 90, 158, 0.75)) brightness(0.32) saturate(0.8); }
  50%      { filter: drop-shadow(0 0 16px rgba(150, 130, 220, 0.95)) brightness(0.4) saturate(0.85); }
}
.block-obsidian::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  background: linear-gradient(120deg,
    transparent 38%,
    rgba(120, 100, 180, 0.55) 48%,
    rgba(201, 186, 240, 0.95) 51%,
    rgba(120, 100, 180, 0.55) 54%,
    transparent 64%);
  background-size: 260% 260%;
  animation: golden-sheen 3.4s linear infinite;
}

/* ---------- saturn mutation (keyword tier) ---------- */
/* A warm gas-giant block wearing a tilted golden ring that sticks out both
   sides. Summoned by typing "saturn". Purely cosmetic. The ::after draws the
   ring as a wide flat ellipse (border only), so it scales with the block. */
.block-saturn { animation: saturn-glow 3s ease-in-out infinite; }
@keyframes saturn-glow {
  0%, 100% { filter: drop-shadow(0 0 5px rgba(232, 200, 138, 0.8)) saturate(1.15); }
  50%      { filter: drop-shadow(0 0 15px rgba(255, 233, 170, 1)) saturate(1.2); }
}
.block-saturn::after {
  content: '';
  position: absolute;
  left: -42%;
  top: 26%;
  width: 184%;
  height: 48%;
  pointer-events: none;
  border-radius: 50%;
  z-index: 7;
  border: 4px solid rgba(233, 200, 140, 0.95);
  box-shadow: 0 0 8px 1px rgba(255, 236, 180, 0.7),
              0 0 0 2px rgba(201, 161, 90, 0.5);
  transform: rotate(-18deg);
}

/* ---------- candy mutation (keyword tier) ---------- */
/* Pink-and-white candy-cane stripes washed over the block's own colours, with a
   soft sugary glow. Summoned by typing "candy" (jupiterr's ask). Purely cosmetic. */
.block-candy { filter: drop-shadow(0 0 8px rgba(255, 111, 181, 0.85)) saturate(1.2); }
.block-candy::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  background: repeating-linear-gradient(135deg,
    rgba(255, 255, 255, 0.55) 0 8px,
    rgba(255, 70, 150, 0.45) 8px 16px);
  box-shadow: 0 0 0 2px #ff6fb5, 0 1px 8px rgba(255, 111, 181, 0.7);
}

/* ---------- bloodmoon mutation (keyword tier) ---------- */
/* A block bathed in deep crimson moonlight. Summoned by typing "bloodmoon".
   Purely cosmetic — a pulsing red glow plus a diagonal blood-red sheen sweeping
   across it (same sheen keyframe the golden/obsidian tiers reuse). */
.block-bloodmoon { animation: bloodmoon-glow 2.8s ease-in-out infinite; }
@keyframes bloodmoon-glow {
  0%, 100% { filter: drop-shadow(0 0 6px rgba(210, 31, 31, 0.8)) saturate(1.35) brightness(0.92); }
  50%      { filter: drop-shadow(0 0 16px rgba(255, 60, 60, 0.98)) saturate(1.5) brightness(1); }
}
.block-bloodmoon::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  background: linear-gradient(120deg,
    transparent 38%,
    rgba(180, 20, 20, 0.55) 48%,
    rgba(255, 150, 150, 0.95) 51%,
    rgba(180, 20, 20, 0.55) 54%,
    transparent 64%);
  background-size: 260% 260%;
  animation: golden-sheen 3.4s linear infinite;
}

/* ---------- lightning strike (brief thunderstorm flash + bolt) ---------- */
/* Full-screen white flash, fired for well under a second when a bolt strikes.
   Fixed, pointer-events off. Kept gentle (peaks well below full white) so it
   reads as a storm flicker, not a strobe. */
#storm-flash {
  position: fixed;
  inset: 0;
  z-index: 4;
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(130% 90% at 50% 0%, rgba(210, 245, 255, 0.55), rgba(190, 220, 240, 0.15) 55%, transparent 75%);
}
#storm-flash.flash { animation: storm-flash 0.6s ease-out; }
@keyframes storm-flash {
  0%   { opacity: 0; }
  8%   { opacity: 0.85; }
  20%  { opacity: 0.2; }
  32%  { opacity: 0.7; }
  100% { opacity: 0; }
}
/* The bolt itself: a thin jagged cyan streak from the top of the scene down to
   the struck block. Positioned by JS (left = block centre, height = block top). */
.lightning-bolt {
  position: absolute;
  top: 0;
  width: 6px;
  margin-left: -3px;
  z-index: 20;
  pointer-events: none;
  background: linear-gradient(180deg, rgba(224,255,255,0.2), #7df9ff 30%, #00e5ff 70%, #ffffff);
  filter: drop-shadow(0 0 6px rgba(0, 229, 255, 0.9));
  clip-path: polygon(45% 0, 100% 34%, 55% 40%, 100% 66%, 50% 72%, 90% 100%, 20% 60%, 55% 54%, 10% 30%, 45% 24%);
  animation: bolt-fade 0.5s ease-out forwards;
}
@keyframes bolt-fade {
  0%   { opacity: 0; transform: scaleY(0.4); transform-origin: top; }
  15%  { opacity: 1; transform: scaleY(1); }
  70%  { opacity: 1; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  #storm-flash.flash { animation-duration: 0.3s; }
  .lightning-bolt { animation: none; opacity: 0.9; }
}

/* ---------- radioactive rush event (screen wash + banner) ---------- */
/* Full-screen toxic-green wash, fading in only while a rush is live. Fixed and
   pointer-events off, above the scene but below the blocks/UI so play is
   unaffected. */
#rush-glow {
  position: fixed;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s ease;
  background:
    radial-gradient(120% 80% at 50% 0%, rgba(120, 255, 40, 0.28), transparent 60%),
    radial-gradient(120% 80% at 50% 100%, rgba(80, 220, 30, 0.22), transparent 55%);
}
html.radioactive-rush #rush-glow { opacity: 1; animation: rush-pulse 2.2s ease-in-out infinite; }
@keyframes rush-pulse { 0%, 100% { opacity: 0.75; } 50% { opacity: 1; } }

/* The countdown banner. Top-center pill, slides down when a rush starts. */
#rush-banner {
  position: fixed;
  top: -60px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 40;
  /* This bubble looks like the #hint/#toast dead-cap family (fixed + left:50%,
     no right, no width) but is NOT affected: the `white-space: nowrap` below
     makes min-content the whole string, which overflows the 50vw available
     width instead of wrapping into it. Measured 316px on a 360 phone. The
     nowrap is therefore load-bearing — dropping it reintroduces the bug. */
  max-width: 92vw;
  padding: 8px 16px;
  border-radius: 999px;
  background: linear-gradient(180deg, #4aa300, #2f7a00);
  color: #f4ffe0;
  font-weight: 800;
  font-size: 15px;
  letter-spacing: 0.02em;
  white-space: nowrap;
  box-shadow: 0 3px 14px rgba(60, 160, 20, 0.55), 0 0 0 2px rgba(180, 255, 90, 0.7);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
  opacity: 0;
  transition: top 0.4s cubic-bezier(0.2, 1.2, 0.4, 1), opacity 0.3s ease;
  pointer-events: none;
}
#rush-banner.show { top: 52px; opacity: 1; animation: rush-banner-buzz 0.9s ease-in-out infinite; }
@keyframes rush-banner-buzz {
  0%, 100% { box-shadow: 0 3px 14px rgba(60, 160, 20, 0.55), 0 0 0 2px rgba(180, 255, 90, 0.7); }
  50%      { box-shadow: 0 3px 20px rgba(90, 220, 30, 0.8), 0 0 0 3px rgba(210, 255, 120, 0.95); }
}
@media (prefers-reduced-motion: reduce) {
  html.radioactive-rush #rush-glow { animation: none; }
  #rush-banner.show { animation: none; }
}

/* ---------- lava-rush flavour override (molten orange) ---------- */
/* When a Rush is the LAVA flavour, recolour the same #rush-glow / #rush-banner
   from toxic green to molten orange. Only the colours change — layout, timing and
   the countdown mechanic are shared with the radioactive rush. */
html.radioactive-rush.rush-lava #rush-glow {
  background:
    radial-gradient(120% 80% at 50% 0%, rgba(255, 90, 0, 0.28), transparent 60%),
    radial-gradient(120% 80% at 50% 100%, rgba(255, 50, 0, 0.24), transparent 55%);
}
html.rush-lava #rush-banner {
  background: linear-gradient(180deg, #d64500, #a12c00);
  color: #fff2e0;
  box-shadow: 0 3px 14px rgba(200, 70, 10, 0.55), 0 0 0 2px rgba(255, 170, 90, 0.7);
}
html.rush-lava #rush-banner.show { animation: rush-banner-buzz-lava 0.9s ease-in-out infinite; }
@keyframes rush-banner-buzz-lava {
  0%, 100% { box-shadow: 0 3px 14px rgba(200, 70, 10, 0.55), 0 0 0 2px rgba(255, 170, 90, 0.7); }
  50%      { box-shadow: 0 3px 20px rgba(255, 100, 20, 0.8), 0 0 0 3px rgba(255, 200, 120, 0.95); }
}
@media (prefers-reduced-motion: reduce) {
  html.rush-lava #rush-banner.show { animation: none; }
}

/* ---------- thunderstorm event (dark sky wash + banner) ---------- */
/* Full-screen stormy indigo wash, fading in only while a Thunderstorm is live.
   Same layer as #rush-glow (above the scene, below blocks/UI, pointer-events
   off) so play is unaffected. Temporary — it lifts when the storm ends. */
#storm-glow {
  position: fixed;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s ease;
  background:
    radial-gradient(130% 90% at 50% 0%, rgba(40, 52, 92, 0.5), rgba(30, 40, 74, 0.28) 55%, transparent 78%),
    radial-gradient(120% 80% at 50% 100%, rgba(24, 30, 58, 0.34), transparent 60%);
}
html.thunderstorm #storm-glow { opacity: 1; animation: storm-pulse 3.4s ease-in-out infinite; }
@keyframes storm-pulse { 0%, 100% { opacity: 0.8; } 50% { opacity: 1; } }

/* The countdown banner. Top-center pill, slides down when a storm rolls in. */
#storm-banner {
  position: fixed;
  top: -60px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 40;
  /* This bubble looks like the #hint/#toast dead-cap family (fixed + left:50%,
     no right, no width) but is NOT affected: the `white-space: nowrap` below
     makes min-content the whole string, which overflows the 50vw available
     width instead of wrapping into it. Measured 316px on a 360 phone. The
     nowrap is therefore load-bearing — dropping it reintroduces the bug. */
  max-width: 92vw;
  padding: 8px 16px;
  border-radius: 999px;
  background: linear-gradient(180deg, #3a4d8f, #232f5e);
  color: #eaf2ff;
  font-weight: 800;
  font-size: 15px;
  letter-spacing: 0.02em;
  white-space: nowrap;
  box-shadow: 0 3px 14px rgba(40, 60, 140, 0.55), 0 0 0 2px rgba(125, 249, 255, 0.6);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
  opacity: 0;
  transition: top 0.4s cubic-bezier(0.2, 1.2, 0.4, 1), opacity 0.3s ease;
  pointer-events: none;
}
#storm-banner.show { top: 52px; opacity: 1; animation: storm-banner-buzz 1.1s ease-in-out infinite; }
@keyframes storm-banner-buzz {
  0%, 100% { box-shadow: 0 3px 14px rgba(40, 60, 140, 0.55), 0 0 0 2px rgba(125, 249, 255, 0.6); }
  50%      { box-shadow: 0 3px 20px rgba(80, 120, 220, 0.8), 0 0 0 3px rgba(160, 240, 255, 0.95); }
}
@media (prefers-reduced-motion: reduce) {
  html.thunderstorm #storm-glow { animation: none; }
  #storm-banner.show { animation: none; }
}

/* Drag-to-delete target: slides down from off-screen when a drag starts,
   pulses bigger when a block hovers it. Pointer-events off — it's hit-tested
   in JS against the captured pointer. */
#trash-zone {
  position: fixed;
  top: -84px;
  left: 16px;
  width: 64px; height: 64px;
  display: flex; align-items: center; justify-content: center;
  font-size: 30px;
  background: rgba(255, 255, 255, 0.92);
  border: 3px dashed #e2231a;
  border-radius: 16px;
  box-shadow: 0 6px 18px rgba(30, 80, 110, 0.25);
  z-index: 70;
  opacity: 0;
  pointer-events: none;
  transition: top 0.22s cubic-bezier(0.2, 0.8, 0.3, 1.2), opacity 0.18s ease,
              transform 0.12s ease, background 0.12s ease;
}
#trash-zone.show { top: 16px; opacity: 1; }
#trash-zone.armed { transform: scale(1.2); background: #ffe2de; box-shadow: 0 8px 24px rgba(226, 35, 26, 0.42); }

/* First-time teaching caption under the corner targets: reveals the delete/copy
   verb the moment a new player first drags a block, then hides forever once
   they've used the gesture (nb.taughtdel / nb.taughtcopy). Absolutely positioned
   so it never grows the 64×64 drop hitbox that JS rect-tests. */
#trash-zone .zone-label, #mirror-zone .zone-label {
  position: absolute;
  top: calc(100% + 5px);
  left: 50%;
  transform: translateX(-50%);
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.2px;
  white-space: nowrap;
  padding: 2px 9px;
  border-radius: 9px;
  color: #fff;
  display: none;
  pointer-events: none;
  box-shadow: 0 3px 10px rgba(30, 80, 110, 0.28);
}
#trash-zone .zone-label { background: #e2231a; }
#mirror-zone .zone-label { background: #8a5cd6; }
#trash-zone.show.teach .zone-label,
#mirror-zone.show.teach .zone-label { display: block; }

/* Drag-to-DUPLICATE target: the magic mirror. Same slide-in-on-drag gesture as
   the trash bin (players already know it), but the top-RIGHT corner and a violet
   "magic" theme so it reads as make-a-twin, not delete. Hit-tested in JS. */
#mirror-zone {
  position: fixed;
  top: -84px;
  right: 16px;
  width: 64px; height: 64px;
  display: flex; align-items: center; justify-content: center;
  font-size: 30px;
  background: rgba(255, 255, 255, 0.92);
  border: 3px dashed #8a5cd6;
  border-radius: 16px;
  box-shadow: 0 6px 18px rgba(30, 80, 110, 0.25);
  z-index: 70;
  opacity: 0;
  pointer-events: none;
  transition: top 0.22s cubic-bezier(0.2, 0.8, 0.3, 1.2), opacity 0.18s ease,
              transform 0.12s ease, background 0.12s ease;
}
#mirror-zone.show { top: 16px; opacity: 1; }
#mirror-zone.armed { transform: scale(1.2); background: #efe6ff; box-shadow: 0 8px 24px rgba(138, 92, 214, 0.45); }

/* Zoom control: a slim two-button pill floating on the right edge, above the
   play layer but below the toolbar. Map-style +/− so it reads instantly.

   Centred on the PLAY AREA (--play-mid, set by updateBounds from the toolbar's
   real top edge), not on the viewport. `top: 50%` put it in the middle of the
   SCREEN, which on a short phone is inside the button deck — zoom-out ended up
   painted behind #spawn-buttons and was invisible on 13 of 45 measured phone
   viewports. The 50% fallback preserves the old placement for the frames before
   updateBounds() first runs. */
#zoom-ctl {
  position: fixed;
  right: 10px;
  top: var(--play-mid, 50%);
  transform: translateY(-50%);
  z-index: 15;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
#zoom-ctl button {
  width: 40px;
  height: 40px;
  border: none;
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.82);
  color: #2b6a8f;
  font-size: 24px;
  font-weight: 800;
  line-height: 1;
  cursor: pointer;
  box-shadow: 0 3px 10px rgba(30, 80, 110, 0.22);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
  transition: transform 0.1s ease, background 0.12s ease, opacity 0.12s ease;
}
#zoom-ctl button:active { transform: scale(0.9); background: #fff; }
#zoom-ctl button:disabled { opacity: 0.32; cursor: default; }
.night #zoom-ctl button { background: rgba(40, 54, 78, 0.82); color: #cfe6f5; }
/* Full-screen toggle: the ⛶ glyph sits a touch smaller than the +/− weights, and
   flips to a filled state while full-screen is active so a re-tap reads as exit. */
#fs-btn { font-size: 20px; }
#fs-btn.fs-on { background: #2b6a8f; color: #fff; }
.night #fs-btn.fs-on { background: #cfe6f5; color: #26364a; }

.cube {
  position: absolute;
  box-shadow: inset 0 -3px 0 rgba(0, 0, 0, 0.08), inset 0 2px 0 rgba(255, 255, 255, 0.28);
}

/* ✨ The imaginary unit "i" (√−1). Not a normal number, so it doesn't wear a
   normal colour — a faintly see-through, iridescent lilac cube with a slow
   violet↔cyan aura and a shimmer sweep, like light through something that isn't
   quite there. BookwormKevin asked for imaginary numbers to look interesting.
   Pure costume keyed on the "i" label; the value stays 1. */
/* ARRAY FLAMES — soft glowing licks along each dimension of a factor-rectangle
   reshape (see buildArrayFlames). Sit BEHIND the cubes (z-index -1) so the glow
   bleeds out around the array edges, and flicker gently so they read as flames,
   not static bars. Purely decorative; pointer-events off so they never eat taps. */
.array-flame {
  position: absolute;
  z-index: -1;
  pointer-events: none;
  border-radius: 50%;
  filter: blur(1.5px);
  opacity: 0.82;
  animation: array-flame-flicker 1.15s ease-in-out infinite;
}
@keyframes array-flame-flicker {
  0%, 100% { opacity: 0.62; transform: scale(0.9); }
  50%      { opacity: 0.92; transform: scale(1.08); }
}
/* Opt-out via the 🔥 Settings toggle (adds html.no-rays), for players — mostly
   on phones — who find a board full of animating factor-flames laggy. display:none
   drops every flame from the render tree (no paint, no animation) at once. */
html.no-rays .array-flame { display: none !important; }

.block-i { animation: imaginary-glow 2.8s ease-in-out infinite; }
@keyframes imaginary-glow {
  0%, 100% { filter: drop-shadow(0 0 5px rgba(150, 130, 235, 0.75)); }
  50%      { filter: drop-shadow(0 0 16px rgba(120, 225, 245, 0.95)); }
}
.block-i .cube {
  background: linear-gradient(150deg, #d9c9f2 0%, #c4caed 46%, #bfe4ef 100%) !important;
  border-color: #a99fd6 !important;
  /* A dashed outline — the block isn't quite solid, the way an imaginary number
     isn't quite a real quantity (TheChomiks: "make ni have outlines cut together
     like - - - -"). The value/merges/collection are untouched; the dashes are
     pure costume that reinforces the "not-fully-there" read the lilac glow starts. */
  border-style: dashed !important;
  opacity: 0.88;
}
/* Iridescent sheen sweeping across the face, reusing the golden-sheen sweep. */
.block-i::after {
  content: '';
  position: absolute;
  inset: -2px;
  pointer-events: none;
  border-radius: 5px;
  z-index: 6;
  mix-blend-mode: screen;
  background: linear-gradient(120deg,
    transparent 34%,
    rgba(190, 150, 245, 0.7) 47%,
    rgba(212, 246, 255, 0.95) 51%,
    rgba(150, 210, 245, 0.7) 55%,
    transparent 68%);
  background-size: 260% 260%;
  animation: golden-sheen 3s linear infinite;
}
.block-i .i-dot {
  position: absolute;
  left: 50%;
  top: -15px;
  width: 12px;
  height: 12px;
  margin-left: -6px;
  border-radius: 50%;
  background: radial-gradient(circle at 40% 35%, #eaf3ff, #a98fe0);
  box-shadow: 0 0 6px 1px rgba(150, 130, 235, 0.7), inset 0 -2px 0 rgba(0, 0, 0, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.5);
  pointer-events: none;
  animation: imaginary-dot 2.8s ease-in-out infinite;
}
@keyframes imaginary-dot {
  0%, 100% { box-shadow: 0 0 6px 1px rgba(150, 130, 235, 0.7), inset 0 -2px 0 rgba(0, 0, 0, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.5); }
  50%      { box-shadow: 0 0 12px 3px rgba(120, 225, 245, 0.95), inset 0 -2px 0 rgba(0, 0, 0, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.5); }
}
/* The negative imaginary twin (−i, −2i…) is the "dark mirror" of the lilac
   imaginary block: a near-black cube kept legible by a glowing violet imaginary
   outline, so −i reads as the anti-i instead of looking identical to +i
   (blandshrine2822579). Same idiom as the anti-block dark mirror — the value,
   collection and merges are unchanged; only the imaginary costume goes dark.
   Overrides .block-i's lilac fill (both use !important; this rule is more
   specific, so the darker anti look wins for negatives). */
.block-negative.block-i .cube {
  background: linear-gradient(150deg, #262232 0%, #1a1a26 55%, #221d30 100%) !important;
  border-color: #7d5fd6 !important;
  box-shadow: 0 0 9px 1px rgba(150, 120, 235, 0.6), inset 0 0 6px rgba(150, 120, 235, 0.35) !important;
  opacity: 0.92;
}
/* The screen-blend sheen at full strength washed the dark −i out to lilac every
   sweep, so it flickered into +i (cutecliff8957658's scene video). A faint
   violet glint keeps the shimmer and the dark read. */
.block-negative.block-i::after { opacity: 0.3; }

/* Fractional imaginaries (½i, ¾i, 1⅜i): the Fraction Friend slice and the
   trailing fraction cube wear the same lilac, dashed, not-quite-solid skin as
   .block-i .cube above; anti ones take the dark mirror. */
.block-i .fraction-friend path {
  fill: #c4caed !important;
  stroke: #a99fd6 !important;
  stroke-dasharray: 5 3;
  opacity: 0.9;
}
.block-negative.block-i .fraction-friend path {
  fill: #1f1c2b !important;
  stroke: #7d5fd6 !important;
}
.block-i .cube-half { background: #e4e0f6 !important; border-color: #a99fd6 !important; border-style: dashed !important; }
.block-i .cube-half-fill { background: #c4caed !important; }
.block-negative.block-i .cube-half { background: #23232c !important; border-color: #7d5fd6 !important; }
.block-negative.block-i .cube-half-fill { background: #1f1c2b !important; }

/* Super-cube shape-shift form: a perfect cube number (8, 27, 64…) stacked into
   an actual 3-D cube (three ruled faces). The scene holds the perspective; the
   cube tilts into an isometric view; each face is a k×k grid of unit-cube faces. */
.supercube-scene {
  position: absolute;
  left: 0; top: 0;
  transform-style: preserve-3d;
}
/* Orthographic (no perspective) so the faces stay clean parallelograms — the
   crisp "super cube" look, not a foreshortened trapezoid. Tilt shows front, a
   lit top, and a shadowed right face. */
.supercube-cube {
  position: absolute;
  transform-style: preserve-3d;
  transform: rotateX(-24deg) rotateY(-45deg);
}
.sc-face {
  position: absolute;
  left: 0; top: 0;
  box-sizing: border-box;
  transform-origin: center center;
  border-radius: 2px;
}
/* The eyes live on the front face and inherit its 3-D tilt, so the cube looks
   right at you (canon Numberblocks cube). */
.sc-front .face { transform: none; }

/* A "half" block — a unit cube bisected on the diagonal (the show's fraction
   look). The full square footprint + face stays so it reads as a real block;
   the lower-left triangle is filled solid and a crisp diagonal line marks the
   cut, so a lone ½ is unmistakably "half of a One". */
.cube-half {
  position: absolute;
  overflow: hidden;
  box-shadow: inset 0 -3px 0 rgba(0, 0, 0, 0.08), inset 0 2px 0 rgba(255, 255, 255, 0.28);
}
/* A mixed number's Fraction Friend piece: the box is only a footprint. */
.block .cube-half.ff-piece, .block.block-i .cube-half.ff-piece {
  background: none !important; border: none !important; box-shadow: none; overflow: visible;
}
.cube-half-fill {
  position: absolute;
  inset: 0;
  clip-path: polygon(0 0, 100% 100%, 0 100%);
}
.cube-half-line {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 142%;
  height: 2.4px;
  background: rgba(0, 0, 0, 0.4);
  transform: translate(-50%, -50%) rotate(45deg);
  pointer-events: none;
}

/* Numberblock Six's dice pips — her 2×3 body matches a die's six-face, so each
   cube wears a small white dot (Simulation's idea, ties into the dice motif).
   Sits inside the cube and behind the face, so the eyes stay readable on top. */
.dice-pip {
  position: absolute;
  left: 50%;
  top: 50%;
  translate: -50% -50%;
  width: 48%; /* the show's spots fill about half the face (OneSeventhStudios 10-01: "make Six's dots size larger") */
  height: 48%;
  background: #c9bcf2; /* light lilac, like the show (OneSeventhStudios 10-01: "6's dots not white it's light purple") */
  border-radius: 50%;
  box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.14);
  pointer-events: none;
}

.block-abstract {
  border-radius: 14px;
  box-shadow: inset 0 -6px 0 rgba(0, 0, 0, 0.12), inset 0 3px 0 rgba(255, 255, 255, 0.3);
}

.spawn { animation: pop-in 0.38s cubic-bezier(0.34, 1.56, 0.64, 1); }
@keyframes pop-in {
  from { scale: 0.2; }
  to   { scale: 1; }
}

/* 67 — the "six seven" meme number this audience loves most. Every 67 you build
   gets a soft pulsing warm halo so the wink lands every time, not just on first
   discovery. Pairs with pop-in during the spawn beat so both animations play. */
.block-sixseven { animation: sixseven-glow 1.7s ease-in-out infinite; }
.block-sixseven.spawn {
  animation: pop-in 0.38s cubic-bezier(0.34, 1.56, 0.64, 1),
             sixseven-glow 1.7s ease-in-out infinite;
}
@keyframes sixseven-glow {
  0%, 100% { filter: drop-shadow(0 0 3px rgba(255, 198, 0, 0.5)); }
  50%      { filter: drop-shadow(0 0 13px rgba(255, 142, 0, 0.92)); }
}
/* 67 wears a big goofy open grin so it reads as the meme block at a glance. */
.block-sixseven .mouth {
  height: calc(var(--fs) * 0.34);
  width: calc(var(--fs) * 0.62);
  border-radius: 0 0 calc(var(--fs) * 0.4) calc(var(--fs) * 0.4);
  background: #4a1d00;
}
/* An excited little hop fired on every (re)build of a 67 — replayable delight.
   Scoped to .block-sixseven so it outranks .spawn / .block-sixseven.spawn (equal
   specificity, later in source), and re-declares the glow so it stays lit while
   the hop plays. translate/scale longhands compose with the physics transform. */
.block-sixseven.sixseven-bounce {
  animation: sixseven-hop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1),
             sixseven-glow 1.7s ease-in-out infinite;
}
@keyframes sixseven-hop {
  0%   { translate: 0 0;     scale: 1; }
  35%  { translate: 0 -16px; scale: 1.12 0.9; }
  60%  { translate: 0 0;     scale: 0.94 1.08; }
  100% { translate: 0 0;     scale: 1; }
}

/* 666 — "the number of the beast". A permanent pulsing demonic red glow so it
   reads as devilish every time (paired with the little infernal horns added in
   block.js). Mirrors the 67 warm-halo pattern, and re-declares the glow on the
   spawn beat so both animations play. */
.block-666 { animation: demonic-glow 1.6s ease-in-out infinite; }
.block-666.spawn {
  animation: pop-in 0.38s cubic-bezier(0.34, 1.56, 0.64, 1),
             demonic-glow 1.6s ease-in-out infinite;
}
@keyframes demonic-glow {
  0%, 100% { filter: drop-shadow(0 0 4px rgba(200, 0, 0, 0.55)); }
  50%      { filter: drop-shadow(0 0 15px rgba(255, 30, 0, 0.95)); }
}

/* 777 — the "angel number" (and jackpot). The radiant mirror of 666: a soft
   pulsing golden halo-glow, plus a floating angel's halo ring (built in
   block.js). Same 67/666 glow idiom, re-declared on the spawn beat. */
.block-777 { animation: angelic-glow 1.7s ease-in-out infinite; }
.block-777.spawn {
  animation: pop-in 0.38s cubic-bezier(0.34, 1.56, 0.64, 1),
             angelic-glow 1.7s ease-in-out infinite;
}
@keyframes angelic-glow {
  0%, 100% { filter: drop-shadow(0 0 4px rgba(255, 236, 150, 0.6)); }
  50%      { filter: drop-shadow(0 0 16px rgba(255, 210, 70, 0.95)); }
}
/* The little golden halo ring floating above 777's head, gently bobbing. The
   `translate` centres it over headX (set inline); `transform` does the bob, so
   the two never fight. */
.angel-halo {
  position: absolute;
  top: 0;
  /* Lifted clear of the floating numeral, which itself sits just above the head. */
  translate: -50% -270%;
  width: var(--halo, 30px);
  height: calc(var(--halo, 30px) * 0.4);
  border: calc(var(--halo, 30px) * 0.11) solid #ffd54a;
  border-radius: 50%;
  background: transparent;
  box-shadow: 0 0 10px rgba(255, 215, 90, 0.9), inset 0 0 4px rgba(255, 240, 190, 0.8);
  pointer-events: none;
  z-index: 2;
  animation: halo-bob 2.6s ease-in-out infinite;
}
@keyframes halo-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-3px); }
}

/* TREE(3)'s little tree, perched above the summit stand-in's head. Positioned
   with the translate longhand so the halo-bob transform composes cleanly. */
.tree3-topper {
  position: absolute;
  top: 0;
  translate: -50% -170%;
  line-height: 1;
  pointer-events: none;
  z-index: 3;
  filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
  animation: halo-bob 2.8s ease-in-out infinite;
}

/* Nine's hay-fever sneeze — she leans back through two intake breaths, then
   snaps forward on the "ACHOO!". translate/scale/rotate longhands so the wind-up
   composes with the physics transform set each frame (like sixseven-hop). */
.nine-sneeze { animation: nine-sneeze 0.9s ease; }
@keyframes nine-sneeze {
  0%   { translate: 0 0;    scale: 1;         rotate: 0deg; }
  18%  { translate: 0 -3px; scale: 0.97 1.05; rotate: -4deg; }
  33%  { translate: 0 -6px; scale: 0.93 1.1;  rotate: -9deg; }  /* ah… ah… */
  44%  { translate: 0 3px;  scale: 1.13 0.84; rotate: 9deg; }   /* ACHOO! */
  56%  { translate: 0 0;    scale: 0.96 1.05; rotate: -3deg; }
  72%  { translate: 0 0;    scale: 1.02 0.99; rotate: 1deg; }
  100% { translate: 0 0;    scale: 1;         rotate: 0deg; }
}
.achoo-bubble {
  position: absolute;
  top: -42px;
  left: 50%;
  transform: translateX(-50%);
  font-size: 15px;
  font-weight: 800;
  line-height: 1;
  padding: 4px 9px;
  white-space: nowrap;
  background: #fff;
  color: #2a2540;
  border-radius: 999px;
  box-shadow: 0 2px 6px rgba(40, 30, 60, 0.28);
  pointer-events: none;
  z-index: 7;
  animation: achoo-in 0.2s ease both;
}
.achoo-bubble.achoo-big {
  font-size: 20px;
  color: #1d6fb8;
  animation: achoo-snap 0.34s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes achoo-in {
  from { opacity: 0; transform: translateX(-50%) translateY(6px) scale(0.6); }
  to   { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
}
@keyframes achoo-snap {
  0%   { transform: translateX(-50%) scale(0.9); }
  50%  { transform: translateX(-50%) scale(1.28); }
  100% { transform: translateX(-50%) scale(1.1); }
}

/* Octoblock — Eight's eight magenta tentacles. They curl out from his sides and
   sway out of phase. Behind the cubes, ignored by physics/hit-testing. */
.octo-arm {
  position: absolute;
  z-index: -1;
  pointer-events: none;
  /* A deeper magenta than Eight's body (#ec008c) with a dark outline, so the
     limbs read as separate tentacles instead of blending into the block, plus a
     row of pale suction cups down the inside that say "octopus" at a glance. */
  background:
    radial-gradient(circle at 50% 22%, rgba(255,255,255,0.9) 0 2px, transparent 2.6px),
    radial-gradient(circle at 50% 47%, rgba(255,255,255,0.75) 0 1.8px, transparent 2.4px),
    radial-gradient(circle at 50% 72%, rgba(255,255,255,0.6) 0 1.5px, transparent 2px),
    linear-gradient(160deg, #ff4fb4, #91005a);
  border: 1.5px solid #5e003a;
  border-radius: 55% 55% 50% 50% / 22% 22% 95% 95%;
  box-shadow: inset -2px 0 0 rgba(0, 0, 0, 0.16), inset 0 2px 0 rgba(255, 255, 255, 0.28);
  transform-origin: top center;
}
.octo-left  { animation: octo-sway-l 2.2s ease-in-out infinite; }
.octo-right { animation: octo-sway-r 2.2s ease-in-out infinite; }
/* 🙌 Arms & legs (settings, opt-in) — block.js buildLimbs. Each piece is a
   0-size anchor at the shoulder / hip; the stick and its round mitten hand or
   oval foot hang off it. --limb is a darker shade of the cube it grows from. */
.nb-limb { position: absolute; width: 0; height: 0; z-index: -1; pointer-events: none; }
.nb-arm::before {
  content: ''; position: absolute; top: calc(var(--ls) * -0.07);
  width: calc(var(--ls) * 0.42); height: calc(var(--ls) * 0.14);
  background: var(--limb); border-radius: 999px;
}
.nb-arm::after {
  content: ''; position: absolute; top: calc(var(--ls) * -0.12);
  width: calc(var(--ls) * 0.24); height: calc(var(--ls) * 0.24);
  background: var(--limb); border-radius: 50%;
}
.nb-arm-left::before { right: 0; }
.nb-arm-left::after  { right: calc(var(--ls) * 0.34); }
.nb-arm-right::before { left: 0; }
.nb-arm-right::after  { left: calc(var(--ls) * 0.34); }
/* Five's star glove — a bigger dark-blue mitten with a light star on it. */
.nb-arm-right.nb-glove::after {
  top: calc(var(--ls) * -0.23); left: calc(var(--ls) * 0.3);
  width: calc(var(--ls) * 0.46); height: calc(var(--ls) * 0.46);
  background: #1d5fa8; border-radius: 45% 45% 50% 50%;
  content: "★"; color: #8fd8ff; font-size: calc(var(--ls) * 0.32); line-height: calc(var(--ls) * 0.46); text-align: center;
}
/* Ten's star gloves — white with a red rim and red star, on both hands. */
.nb-arm.nb-glove-ten::after {
  top: calc(var(--ls) * -0.23);
  width: calc(var(--ls) * 0.46); height: calc(var(--ls) * 0.46);
  background: #fff; border: calc(var(--ls) * 0.03) solid #e0262b; box-sizing: border-box; border-radius: 45% 45% 50% 50%;
  content: "★"; color: #e0262b; font-size: calc(var(--ls) * 0.3); line-height: calc(var(--ls) * 0.4); text-align: center;
}
.nb-arm-left.nb-glove-ten::after  { right: calc(var(--ls) * 0.3); }
.nb-arm-right.nb-glove-ten::after { left: calc(var(--ls) * 0.3); }
/* Super rectangles' array watch (block.js buildLimbs) — on the left wrist, just
   inside the hand: a white-bezelled face in the block's own colour. */
.nb-watch {
  position: absolute; right: calc(var(--ls) * 0.15); top: calc(var(--ls) * -0.19);
  width: calc(var(--ls) * 0.26); height: calc(var(--ls) * 0.38);
  background: var(--watch); border: calc(var(--ls) * 0.045) solid #fff; box-sizing: border-box;
  border-radius: calc(var(--ls) * 0.03); box-shadow: 0 0 0 calc(var(--ls) * 0.015) rgba(0, 0, 0, 0.35);
}
.nb-arm-left  { transform-origin: 0 0; animation: nb-arm-l 2.6s ease-in-out infinite; }
.nb-arm-right { transform-origin: 0 0; animation: nb-arm-r 2.6s ease-in-out infinite; }
@keyframes nb-arm-l { 0%, 100% { transform: rotate(-28deg); } 50% { transform: rotate(-16deg); } }
@keyframes nb-arm-r { 0%, 100% { transform: rotate(28deg); } 50% { transform: rotate(16deg); } }
/* Flung through the air — arms up, wheee. */
.block.airborne .nb-arm-left  { animation: none; transform: rotate(40deg); }
.block.airborne .nb-arm-right { animation: none; transform: rotate(-40deg); }
.nb-leg::before {
  content: ''; position: absolute; left: calc(var(--ls) * -0.07); top: calc(var(--ls) * -0.14);
  width: calc(var(--ls) * 0.14); height: calc(var(--ls) * 0.22);
  background: var(--limb); border-radius: 0 0 999px 999px;
}
.nb-leg::after {
  content: ''; position: absolute; top: calc(var(--ls) * 0.02);
  width: calc(var(--ls) * 0.3); height: calc(var(--ls) * 0.13);
  background: var(--limb); border-radius: 60% 60% 40% 40% / 70% 70% 30% 30%;
}
.nb-leg-left::after  { left: calc(var(--ls) * -0.2); }
.nb-leg-right::after { left: calc(var(--ls) * -0.1); }
@keyframes octo-sway-l {
  0%, 100% { transform: rotate(18deg); }
  50%      { transform: rotate(34deg); }
}
@keyframes octo-sway-r {
  0%, 100% { transform: rotate(-18deg); }
  50%      { transform: rotate(-34deg); }
}

/* Two's sparkly dancing shoes — "Two, two, dancing shoes!" A pair peeks out at
   her base and taps out of phase so she's always mid-dance. Deeper bronze than
   her orange body with white sparkle dots; above the body so the shine reads,
   ignored by physics/hit-testing. */
.dance-shoe {
  position: absolute;
  bottom: -3px;
  z-index: 4;
  pointer-events: none;
  background:
    radial-gradient(circle at 28% 32%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px),
    radial-gradient(circle at 66% 52%, rgba(255,255,255,0.8) 0 1.2px, transparent 1.7px),
    linear-gradient(160deg, #ffaa3a, #c2560a);
  border: 1.5px solid #7d3403;
  border-radius: 42% 58% 55% 50% / 45% 45% 100% 100%;
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.45), 0 2px 2px rgba(0, 0, 0, 0.22);
  transform-origin: 55% 100%;
}
.shoe-left  { left: 3px;  animation: shoe-tap-l 0.9s ease-in-out infinite; }
.shoe-right { right: 3px; animation: shoe-tap-r 0.9s ease-in-out infinite; }
/* At night Two slips off her dancing shoes to sleep — they stop tapping and
   drop flat to the ground, splayed slightly apart like kicked-off slippers
   (KittyTheKitty + Mateosty both noticed she was dancing in her sleep). */
html.night.sleeping #playground .dance-shoe { animation: none; bottom: -6px; transition: transform 0.6s ease, bottom 0.6s ease; }
html.night.sleeping #playground .shoe-left  { transform: rotate(-16deg) translateX(-2px); }
html.night.sleeping #playground .shoe-right { transform: rotate(16deg) translateX(2px); }
/* A dozing block should be still all over — its character toppers (Seven's
   rainbow, Twenty's top hat, Thirty-One's calendar, Thirty-Five's flowers,
   the octopus tentacles, Hundred's galaxy) keep bobbing/swaying/spinning on
   their own infinite animations, so pause them too while the scene sleeps
   (_.wwilloww. noticed the rainbow and hat kept moving as 6 and 7 napped).
   animation-play-state freezes them gently in place rather than snapping home. */
html.night.sleeping #playground :is(.rainbow-hair, .tophat, .bowler-26, .propcap-37, .propcap-37-prop, .tyrol-21, .beret-34, .skate-helmet-14, .calendar-topper, .flower, .octo-left, .octo-right, .galaxy-aura, .jester-3, .jester-point, .helmet-47, .siren-41, .siren-41-glow) {
  animation-play-state: paused;
}
/* You can't tap your feet on thin air — when Two is up off the ground (picked up,
   mid-backflip, or bouncing) the shoes stop tapping and just dangle until she
   lands (NumberblockBoy spotted her tapping mid-air). */
.block.airborne .dance-shoe { animation: none; }
/* Squarey — Four's shaggy brown pet square. Sits at Four's base to the side and
   bobs gently like a dozing puppy. Fur is a rounded brown blob with a vertical
   strand texture and a darker cowlick tuft on top; two big sleepy half-lidded
   eyes, no mouth, no numberling. Decorative sibling of the block, ignored by
   physics/hit-testing, so it rides along when Four is dragged. */
.squarey {
  position: absolute;
  bottom: -2px;
  z-index: 3;
  pointer-events: none;
  animation: squarey-bob 3.1s ease-in-out infinite;
  transform-origin: 50% 100%;
}
.squarey-body {
  position: absolute;
  inset: 0;
  border-radius: 34% 34% 30% 30% / 40% 40% 32% 32%;
  background:
    repeating-linear-gradient(92deg, rgba(60,34,15,0.55) 0 1px, transparent 1px 4px),
    linear-gradient(165deg, #9a6538 0%, #7a4a26 55%, #5c371c 100%);
  border: 1.5px solid #4a2c15;
  box-shadow: inset 0 3px 3px rgba(255,220,180,0.28), inset 0 -4px 5px rgba(0,0,0,0.3), 0 2px 3px rgba(0,0,0,0.25);
}
/* Shaggy fur silhouette: a fringe of little tufts around the outline. */
.squarey-body::before {
  content: '';
  position: absolute;
  inset: -3px;
  border-radius: inherit;
  background: radial-gradient(circle at 50% 0, #6e421f 0 3px, transparent 3.5px) 0 0/26% 22% repeat-x,
              radial-gradient(circle at 50% 100%, #5c371c 0 3px, transparent 3.5px) 0 100%/26% 22% repeat-x;
  z-index: -1;
}
/* Darker cowlick tuft poking up from the top. */
.squarey-body::after {
  content: '';
  position: absolute;
  top: -18%;
  left: 42%;
  width: 20%;
  height: 26%;
  background: #4a2c15;
  border-radius: 60% 40% 55% 45% / 80% 80% 20% 20%;
  transform: rotate(-10deg);
}
.squarey-eye {
  position: absolute;
  top: 40%;
  width: 26%;
  height: 30%;
  background: radial-gradient(circle at 60% 45%, #fff 0 24%, #3a2412 25%, #241408 70%);
  border-radius: 50%;
  box-shadow: inset 0 1px 1px rgba(0,0,0,0.4);
  overflow: hidden;
  animation: squarey-blink 4.4s ease-in-out infinite;
}
/* Heavy fur lid over the top of each eye for the sleepy half-lidded look. */
.squarey-eye::before {
  content: '';
  position: absolute;
  left: -10%;
  top: -6%;
  width: 120%;
  height: 46%;
  background: #5c371c;
  border-radius: 0 0 40% 40%;
}
.squarey-eye-left  { left: 16%; }
.squarey-eye-right { right: 16%; }
/* Oblongy = Squarey stretched into an oblong: a flatter, rounded-rectangle body
   (the width/height are set inline to a ~1.8:1 ratio) with the two sleepy eyes
   pulled toward the centre and a touch smaller, so a wide body still reads as one
   little face rather than eyes stuck at opposite ends. */
.oblongy .squarey-body {
  border-radius: 30% 30% 26% 26% / 46% 46% 40% 40%;
}
.oblongy .squarey-eye { width: 20%; height: 34%; top: 34%; }
.oblongy .squarey-eye-left  { left: 30%; }
.oblongy .squarey-eye-right { right: 30%; }
html.night.sleeping #playground .squarey { animation-play-state: paused; }
@keyframes squarey-bob {
  0%, 100% { transform: translateY(0) rotate(0deg); }
  50%      { transform: translateY(-2.5px) rotate(1.5deg); }
}
@keyframes squarey-blink {
  0%, 92%, 100% { transform: scaleY(1); }
  96%           { transform: scaleY(0.15); }
}
@keyframes shoe-tap-l {
  0%, 100% { transform: rotate(0deg) translateY(0); }
  50%      { transform: rotate(-15deg) translateY(-3px); }
}
@keyframes shoe-tap-r {
  0%, 45%, 100% { transform: rotate(0deg) translateY(0); }
  72%           { transform: rotate(15deg) translateY(-3px); }
}

/* Three the juggler — a trio of bright balls cascades above her head. The anchor
   is a 0-size point centred on the head (translate -50% keeps it centred without
   clobbering the per-ball transform); each ball runs the same arc 1/3 out of
   phase so together they read as a continuous juggle. Above the head, ignored by
   physics/hit-testing. --jx (spread) and --jh (peak height) are set inline. */
.juggle {
  position: absolute;
  top: -10px;
  width: 0;
  height: 0;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
}
.juggle-ball {
  position: absolute;
  left: 0;
  top: 0;
  border-radius: 50%;
  border: 1px solid rgba(0, 0, 0, 0.18);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
  animation: juggle 1.2s linear infinite;
}
@keyframes juggle {
  0%   { transform: translate(var(--jx), 0); }
  25%  { transform: translate(0, calc(var(--jh) * -1)); }
  50%  { transform: translate(calc(var(--jx) * -1), 0); }
  75%  { transform: translate(0, calc(var(--jh) * -0.5)); }
  100% { transform: translate(var(--jx), 0); }
}
/* At night Three sets the balls down to sleep — they stop and settle, dimmed,
   so she isn't juggling in her sleep (the lesson from Two's dancing shoes). */
html.night.sleeping #playground .juggle-ball {
  animation: none;
  transform: translate(0, 4px);
  opacity: 0.55;
  transition: transform 0.5s ease, opacity 0.5s ease;
}

/* Three's red jester/clown hat — three floppy red points fanning left/up/right,
   each tipped with a yellow jingle-bell, over a red headband. Drawn topper on the
   block (sizes in px, no --fs here), gently tips like the top hat; each point
   bobs a touch out of phase so the bells jingle. Sits under the juggling cascade
   so Three reads as a jester who juggles (BigDKidsFan's canon ref). */
.jester-3 {
  position: absolute;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  transform-origin: 50% 100%;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.32));
  animation: tophat-tip 2.6s ease-in-out infinite;
}
.jester-point {
  position: absolute;
  bottom: 15%;
  left: 50%;
  width: 42%;
  height: 86%;
  transform-origin: 50% 100%;
  animation: jester-bob 1.8s ease-in-out infinite;
}
.jester-point::before { /* the red cloth point */
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(#ff6363, #d42c2c);
  clip-path: polygon(50% 0, 90% 100%, 10% 100%);
}
.jester-point::after { /* the yellow jingle-bell at the tip */
  content: '';
  position: absolute;
  top: -6%;
  left: 50%;
  width: 34%;
  aspect-ratio: 1;
  translate: -50% 0;
  border-radius: 50%;
  background: radial-gradient(circle at 34% 30%, #fff3b0, #f6cf2a 60%, #e0a915);
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.25);
}
.jester-point:nth-child(1) { transform: translateX(-50%) rotate(-42deg); }
.jester-point:nth-child(2) { transform: translateX(-50%) rotate(0deg); }
.jester-point:nth-child(3) { transform: translateX(-50%) rotate(42deg); }
.jester-band { /* the red headband the points sit on */
  position: absolute;
  bottom: 0;
  left: 50%;
  width: 100%;
  height: 26%;
  translate: -50% 0;
  background: linear-gradient(#ff6363, #cf2a2a);
  border-radius: 32% 32% 46% 46% / 60% 60% 100% 100%;
  z-index: 1;
}
@keyframes jester-bob {
  0%, 100% { margin-top: 0; }
  50%      { margin-top: 2px; }
}
/* Numberblock Thirty's circus-tent hat (30 = 3×10) — Three's three-point hat
   scaled up: gold cloth points each capped with a round red pom (Three has it the
   other way: red cloth, yellow bells). Reuses .jester-3's DOM/bob; recolours the
   points, poms and headband. */
.crown-30 .jester-point::before { background: linear-gradient(#ffe14d, #f2b711); }
.crown-30 .jester-point::after  { background: radial-gradient(circle at 34% 30%, #ff9a9a, #e2231a 60%, #b3160f); }
.crown-30 .jester-band          { background: linear-gradient(#ffe14d, #eeae06); }

/* Three Hundred's royal crown (300 = 3×100) — the same three-point DOM as
   Three/Thirty but the points stand upright and the band is broad + gold-rimmed
   so it reads as a king's crown, not a floppy jester hat. Red cloth spikes each
   capped with a gold jewel (S4E29). */
.crown-300 .jester-point {
  width: 34%;
  height: 96%;
  bottom: 20%;
}
.crown-300 .jester-point::before { background: linear-gradient(#ff5a5a, #cc1f1f); }
.crown-300 .jester-point::after  { background: radial-gradient(circle at 34% 30%, #fff3b0, #f6cf2a 60%, #d99e0c); }
.crown-300 .jester-point:nth-child(1) { transform: translateX(-50%) rotate(-22deg); }
.crown-300 .jester-point:nth-child(2) { transform: translateX(-50%) rotate(0deg); }
.crown-300 .jester-point:nth-child(3) { transform: translateX(-50%) rotate(22deg); }
.crown-300 .jester-band {
  height: 34%;
  background: linear-gradient(#ff5a5a 60%, #f6cf2a 60%, #e0a915);
  border-radius: 20% 20% 40% 40% / 40% 40% 100% 100%;
}

/* The Terrible Twos — split a Four and, rarely, the two Twos arrive as the
   show's mischievous masked duo. A coloured mask band sits behind the eyes
   (so they peek through), with two slanted angry brows on top and a sly grin.
   In canon the two don't match: one wears a blue mask + blue shoes, the other
   pink (TheSecondaryBlob's reference). The --mask vars are set per variant
   below. Cosmetic; they're still ordinary Twos. The mask scales-in with a
   cheeky little pop on spawn. */
/* The two Elevens a Twenty-Two splits into — a football team gag: one wears the
   blue kit, the other red (canon; awskda's ask). They wear WHITE kits with a
   team-coloured trim, not a solid team-colour body: the white ten-rod stays
   white and only the single ones cube (.team-one) takes the kit colour, plus a
   team glow — matching the show (divinegorilla's reference). !important overrides
   the ones cube's inline place-value fill. Cosmetic only — an ordinary Eleven
   that merges straight back into a Twenty-Two. */
.team-blue .cube { border-color: #143f80 !important; }
.team-red  .cube { border-color: #8f130d !important; }
.team-blue .team-one { background: #1f6fd6 !important; }
.team-red  .team-one { background: #e2231a !important; }
.team-blue { filter: drop-shadow(0 0 6px rgba(31, 111, 214, 0.75)); }
.team-red  { filter: drop-shadow(0 0 6px rgba(226, 35, 26, 0.75)); }
/* 33 / 44's Elevens squad (main.js ELEVEN_SQUAD_KITS) — same white kit, new trims. */
.team-orange .cube { border-color: #9a4a0c !important; }
.team-yellow .cube { border-color: #8a7208 !important; }
.team-green  .cube { border-color: #1f6a2a !important; }
.team-orange .team-one { background: #f08a2e !important; }
.team-yellow .team-one { background: #f6d534 !important; }
.team-green  .team-one { background: #46b850 !important; }
.team-orange { filter: drop-shadow(0 0 6px rgba(240, 138, 46, 0.75)); }
.team-yellow { filter: drop-shadow(0 0 6px rgba(246, 213, 52, 0.75)); }
.team-green  { filter: drop-shadow(0 0 6px rgba(70, 184, 80, 0.75)); }
/* 55 … 99's rainbow squads add four more trims (blue is 22's). */
.team-purple .cube { border-color: #4b1f80 !important; }
.team-pink   .cube { border-color: #a0306e !important; }
.team-teal   .cube { border-color: #0f6660 !important; }
.team-brown  .cube { border-color: #5a3410 !important; }
.team-purple .team-one { background: #8a4fd6 !important; }
.team-pink   .team-one { background: #f27ab8 !important; }
.team-teal   .team-one { background: #2bb3a8 !important; }
.team-brown  .team-one { background: #9a6230 !important; }
.team-purple { filter: drop-shadow(0 0 6px rgba(138, 79, 214, 0.75)); }
.team-pink   { filter: drop-shadow(0 0 6px rgba(242, 122, 184, 0.75)); }
.team-teal   { filter: drop-shadow(0 0 6px rgba(43, 179, 168, 0.75)); }
.team-brown  { filter: drop-shadow(0 0 6px rgba(154, 98, 48, 0.75)); }
/* 110's tenth Eleven */
.team-black  .cube { border-color: #000 !important; }
.team-black  .team-one { background: #2a2a2e !important; }
.team-black  { filter: drop-shadow(0 0 6px rgba(20, 20, 24, 0.75)); }
/* The Rainbow Eights (56 → seven Eights) and the Grey Sevens (63 → nine Sevens
   in Nine's greys), main.js RAINBOW_GREY_CHANCE. Cosmetic; still plain 8s / 7s. */
.kit-r8red    .cube { background: #e2231a !important; border-color: #8f130d !important; }
.kit-r8orange .cube { background: #f08a2e !important; border-color: #9a4a0c !important; }
.kit-r8yellow .cube { background: #f6d534 !important; border-color: #8a7208 !important; }
.kit-r8green  .cube { background: #46b850 !important; border-color: #1f6a2a !important; }
.kit-r8blue   .cube { background: #1f6fd6 !important; border-color: #143f80 !important; }
.kit-r8indigo .cube { background: #4b3cc4 !important; border-color: #261c70 !important; }
.kit-r8violet .cube { background: #a64fd6 !important; border-color: #5a1f80 !important; }
.kit-g7 .cube { filter: grayscale(1); }
.kit-g7 { filter: drop-shadow(0 0 6px rgba(150, 150, 150, 0.8)) !important; }
/* The cyan half of the pair carries the base vars; .terrible-pink overrides them
   for the other twin. .terrible-twenty shares BOTH — the Terrible Twenties are
   canonically the pink-and-cyan pair (S4E24 screencaps), which is already the
   exact palette the Twos shipped with, so the sequel needed no new colours. */
.terrible-twos,
.terrible-twenty { --mask-a: #9fd6f0; --mask-b: #6db4dd; --mask-line: #3f7fa8; }
.terrible-pink { --mask-a: #f3b9e0; --mask-b: #e88fc9; --mask-line: #b65a99; }
.terrible-mask {
  position: absolute;
  top: -14%;
  left: 50%;
  width: 132%;
  height: 56%;
  transform: translateX(-50%);
  background: linear-gradient(var(--mask-a), var(--mask-b));
  border: 1.5px solid var(--mask-line);
  border-radius: 50% 50% 42% 42% / 78% 78% 46% 46%;
  z-index: -1; /* behind the eyes — the eyes read as peeking through the mask */
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  animation: terrible-on 0.5s ease-out;
}
/* Two slanted angry brows perched on the mask, tinted darker than the band. */
.terrible-mask::before,
.terrible-mask::after {
  content: '';
  position: absolute;
  top: 14%;
  width: 36%;
  height: 15%;
  background: var(--mask-line);
  border-radius: 2px;
}
.terrible-mask::before { left: 8%;  rotate: 22deg; }
.terrible-mask::after  { right: 8%; rotate: -22deg; }
/* Eyes stay white & beady, peeking through the coloured mask. */
.terrible-twos .eye,
.terrible-twenty .eye { background: #f4f4f8; }
.terrible-twos .eye::after,
.terrible-twenty .eye::after { background: #1c1c1e; }
/* Their dancing shoes match the mask (canon) instead of the usual bronze. */
.terrible-twos .dance-shoe {
  background:
    radial-gradient(circle at 28% 32%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px),
    radial-gradient(circle at 66% 52%, rgba(255,255,255,0.8) 0 1.2px, transparent 1.7px),
    linear-gradient(160deg, var(--mask-a), var(--mask-b));
  border-color: var(--mask-line);
}
/* A sly, slightly wider grin. */
.terrible-twos .mouth,
.terrible-twenty .mouth {
  width: calc(var(--fs) * 0.5);
  height: calc(var(--fs) * 0.18);
  rotate: -8deg;
}
/* ── The Terrific Twos (S8E25 "Octonaughty and the Fractionator") ────────────
   Octonaughty's Fractionator cuts Octoblock into "four equal pieces" (the
   Octo-Computer's own phrase) meaning to mint himself a second set of Terrible
   Twos. What he gets is the TERRIFIC Twos — four GOOD Twos, two in blue masks
   and two in red — who turn the machine back on him.

   They are masked Twos, so they deliberately keep the whole .terrible-twos
   apparatus above: the mask shape and its drop-in animation, the white beady
   eyes peeking through, the dancing shoes in the mask colour, and Two's
   spectacle bridge dropped. Only the three things canon actually changes are
   overridden here — a parallel .terrific-mask copy of those rules is precisely
   how the two looks would drift apart on the next edit to either.

   Both palettes are COMPOUND selectors (0-2-0) so they beat the .terrible-twos
   and .terrible-pink base vars (0-1-0) regardless of where this block sits in
   the file, and the hero pair reads as saturated primaries against the
   villains' pastel cyan/pink rather than a near-miss of them. */
.terrific-twos.terrible-blue { --mask-a: #6f9fe8; --mask-b: #2f62c8; --mask-line: #1d4491; }
.terrific-twos.terrible-red  { --mask-a: #f4796c; --mask-b: #de2b23; --mask-line: #8f130d; }
/* 1. No angry brows — the heroes' masks are plain in every canon frame. Higher
      specificity than the .terrible-mask::before/::after that draw them, so
      order is irrelevant.
      ⚠️ MEASURED, because it is tempting to call this the "up to no good" tell
      and size other things off that: at block size each brow renders 14.5 x
      1.8px in --mask-line, the SAME colour as the mask's own border, so it is a
      faint smudge rather than a read-at-a-glance cue. What actually separates
      the two crews on screen is the COUNT (four vs two) and the PALETTE
      (saturated primaries vs the villains' pastel cyan/pink); the brows and the
      grin angle are secondary. Do not "restore parity" by thickening these. */
.terrific-twos .terrible-mask::before,
.terrific-twos .terrible-mask::after { display: none; }
/* 2. An open, level grin instead of the villains' sly −8deg smirk. Keeps the
      wider mouth (they are still grinning) and only takes the slant out. */
.terrific-twos.terrible-twos .mouth { rotate: 0deg; }
/* ── The Terrible Twenties (S4E24 "Land of the Giants") ──────────────────────
   Split a Forty and, rarely, the two Twenties arrive as the masked pair. They
   reuse the Twos' mask/brows/eyes/grin above; what is specific to Twenty is
   the rest of his dapper kit, which the villain look repaints:

   1. The domino mask REPLACES his round purple specs. In the canon frames the
      mask sits exactly where the glasses were, and leaving both on stacked two
      eye pieces into an unreadable smear on a 2-cube-wide block. */
.terrible-twenty .specs-20 { display: none; }
/* 2. His white bowtie takes the twin's colour (canon: pink bow / cyan bow). */
.terrible-twenty .bowtie20-wing {
  background: linear-gradient(var(--mask-a), var(--mask-b));
  border-color: var(--mask-line);
}
.terrible-twenty .bowtie20-knot {
  background: var(--mask-a);
  border-color: var(--mask-line);
}
/* 3. His 🎩 becomes a matching CSS hat. The emoji is a black glyph and no
      filter recolours black, so block.js empties the .tophat element and drops
      in crown/band/brim spans instead — same element, so the tip-and-bob
      animation, z-index, headX centring and the night-mode pause rule all still
      apply. The anchor changes from the glyph box's `top: -52px` to
      `bottom: 100%`, which seats the brim ON the block's top edge instead of
      floating it above a glyph's internal whitespace. */
/* ⚠️ DO NOT "SEAT THIS ON THE BLOCK'S TOP EDGE". It keeps the base `.tophat`
   rule's `top: -52px`, and that offset is deliberate NUMERAL CLEARANCE, not
   slack: a block's "20" label is a fixed ~26x27 box ending ~3px above the block,
   i.e. it occupies −30px to −3px. This hat is 18px tall, so at −52px it spans
   −52 to −34 and clears the label by 4px.
   Moving it to `bottom: 100%` — which measures as "the brim touches the block",
   and sounds tidier — put it at −18 to 0, i.e. straight underneath the numeral.
   Measured: the numeral covered 382px² of a 414px² hat, 92% of it, and the
   render showed a coloured sliver and nothing else (`topper-hides-behind-numeral`
   for the third time). The suite now asserts the numeral/hat overlap is ~zero
   rather than asserting a position, so the requirement is the visible one. */
.tophat.tophat-terrible {
  font-size: 0 !important;      /* the element still carries the emoji's px size
                                   in style.fontSize; zero it so no glyph draws */
}
.tophat-terrible .tophat-crown,
.tophat-terrible .tophat-band,
.tophat-terrible .tophat-brim { position: absolute; }
.tophat-terrible .tophat-crown {
  left: 18%;
  right: 18%;
  top: 0;
  bottom: 22%;
  background: linear-gradient(100deg, var(--mask-a), var(--mask-b));
  border: 1px solid var(--mask-line);
  border-radius: 14% 14% 4% 4%;
}
.tophat-terrible .tophat-band {
  left: 16%;
  right: 16%;
  bottom: 26%;
  height: 20%;
  background: var(--mask-line);
  border-radius: 2px;
}
.tophat-terrible .tophat-brim {
  left: 0;
  right: 0;
  bottom: 0;
  height: 22%;
  background: linear-gradient(var(--mask-b), var(--mask-line));
  border: 1px solid var(--mask-line);
  border-radius: 46%;
  box-sizing: border-box;
}
@keyframes terrible-on {
  0%   { opacity: 0; transform: translateX(-50%) scale(0.3) rotate(-14deg); }
  60%  { opacity: 1; transform: translateX(-50%) scale(1.18) rotate(7deg); }
  100% { opacity: 1; transform: translateX(-50%) scale(1) rotate(0); }
}

/* The Three Threes' handlebar moustache — the cheerful hero counterpart to the
   Terrible Twos' masks. A droopy dark-brown body (flat top, rounded bottom) with
   two tips curling upward at the ends, sitting between the eyes and the mouth. */
.mustache {
  position: absolute;
  left: 50%;
  top: 56%;
  width: 74%;
  height: 16%;
  transform: translateX(-50%);
  background: linear-gradient(#4a2e1a, #2c1a0e);
  border-radius: 8% 8% 48% 48% / 12% 12% 100% 100%;
  pointer-events: none;
  z-index: 1;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3));
  animation: mustache-on 0.5s ease-out;
}
/* Two ends that curl up, so it reads as a waxed handlebar. */
.mustache::before,
.mustache::after {
  content: '';
  position: absolute;
  bottom: 8%;
  width: 40%;
  height: 150%;
  background: linear-gradient(#4a2e1a, #2c1a0e);
  border-radius: 60% 60% 0 0 / 100% 100% 0 0;
}
.mustache::before { left: -12%;  rotate: -22deg; }
.mustache::after  { right: -12%; rotate: 22deg; }
@keyframes mustache-on {
  0%   { opacity: 0; transform: translateX(-50%) scale(0.3) rotate(-14deg); }
  60%  { opacity: 1; transform: translateX(-50%) scale(1.15) rotate(6deg); }
  100% { opacity: 1; transform: translateX(-50%) scale(1) rotate(0); }
}

/* The Four Fours — a barbershop quartet. Each Four wears a straw boater hat with a
   green ribbon band (canon, S4E5). The .boater element is the flat straw crown,
   tipped at a jaunty angle atop the head; ::before is the wide flat brim (sitting
   behind the crown) and ::after is the green ribbon band across the crown base. */
/* The wide flat straw brim is the MAIN element (so it reads clearly even at a
   small block size); the short crown and green ribbon band are drawn on top with
   ::before/::after. The old design made the crown the main box, which left it a
   thin ~5px beige bar that didn't read as a hat at all (Lpn201898). */
.boater {
  position: absolute;
  left: 50%;
  top: -30%;
  width: 84%;
  height: 26%;
  transform: translateX(-50%) rotate(-5deg);
  background: linear-gradient(#ecdb9a, #cbad5e);
  border: 1px solid #c6a54a;
  border-radius: 50%;
  pointer-events: none;
  z-index: 3;
  filter: drop-shadow(0 1.5px 1.5px rgba(0, 0, 0, 0.28));
  animation: boater-on 0.5s ease-out;
}
/* Short cylindrical straw crown sitting on top of the brim. */
.boater::before {
  content: '';
  position: absolute;
  left: 50%;
  bottom: 30%;
  width: 56%;
  height: 168%;
  transform: translateX(-50%);
  background: linear-gradient(#f7ecb8, #ddc47c);
  border: 1px solid #c6a54a;
  border-radius: 34% 34% 6% 6%;
}
/* Green ribbon band wrapped across the base of the crown, above the brim line. */
.boater::after {
  content: '';
  position: absolute;
  left: 24%;
  right: 24%;
  bottom: 42%;
  height: 44%;
  background: linear-gradient(#37b24d, #2b8a3e);
  border-radius: 1px;
}
@keyframes boater-on {
  0%   { opacity: 0; transform: translateX(-50%) translateY(-55%) rotate(-22deg); }
  60%  { opacity: 1; transform: translateX(-50%) translateY(4%) rotate(-1deg); }
  100% { opacity: 1; transform: translateX(-50%) rotate(-5deg); }
}

/* The Five Fives — Numberland's rock band. Each Five wears star-shaped shades
   (Five is the show's star-loving rockstar, S4 "Fifty"). The .star-shades element
   sits across the eyes; its two .lens spans are dark star-shaped lenses with a
   gold star-glow rim (drop-shadow follows the clipped star), joined by a gold
   bridge drawn with ::before. */
.star-shades {
  position: absolute;
  left: 50%;
  top: 34%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 22%;
  width: 88%;
  justify-content: center;
  pointer-events: none;
  z-index: 4;
  animation: shades-on 0.5s ease-out;
}
.star-shades .lens {
  width: 42%;
  aspect-ratio: 1;
  background: radial-gradient(circle at 36% 30%, #4a5da0, #0b1230 68%);
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
  filter: drop-shadow(0 0 1px #ffd54a) drop-shadow(0 0 2px rgba(255, 213, 74, 0.85));
}
/* Gold bridge joining the two star lenses across the nose. */
.star-shades::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 42%;
  width: 20%;
  height: 8%;
  transform: translate(-50%, -50%);
  background: linear-gradient(#ffe08a, #ffc61a);
  border-radius: 2px;
}
@keyframes shades-on {
  0%   { opacity: 0; transform: translateX(-50%) translateY(-30%) scale(0.4); }
  60%  { opacity: 1; transform: translateX(-50%) translateY(3%) scale(1.12); }
  100% { opacity: 1; transform: translateX(-50%) scale(1); }
}

/* The Six Sixes — the six Sixes a Thirty-Six unmakes into, a living set of keys in
   "Puzzle Square". Each Six carries a small golden key charm tilted beside the
   face. The .key-charm box is the round bow (a gold ring, hole punched with a
   radial gradient); ::before is the shaft dropping from it and ::after the two
   teeth at its tip. */
.key-charm {
  position: absolute;
  left: 62%;
  top: 20%;
  width: 20%;
  aspect-ratio: 1;
  transform: rotate(-24deg);
  border-radius: 50%;
  background: radial-gradient(circle, transparent 26%, #f7cf46 30%, #e3a200 62%, #b47f00 74%, transparent 76%);
  pointer-events: none;
  z-index: 4;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3));
  animation: key-on 0.5s ease-out;
}
/* Shaft dropping straight down from the bow. */
.key-charm::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 78%;
  width: 20%;
  height: 130%;
  transform: translateX(-50%);
  background: linear-gradient(#f7cf46, #d59700);
  border-radius: 2px;
}
/* Two teeth jutting from the far end of the shaft, so it reads as a key. */
.key-charm::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 185%;
  width: 46%;
  height: 26%;
  transform: translateX(-30%);
  background: linear-gradient(#f7cf46, #d59700);
  clip-path: polygon(0 0, 34% 0, 34% 55%, 62% 55%, 62% 0, 100% 0, 100% 100%, 0 100%);
}
@keyframes key-on {
  0%   { opacity: 0; transform: rotate(-70deg) scale(0.3); }
  60%  { opacity: 1; transform: rotate(-14deg) scale(1.12); }
  100% { opacity: 1; transform: rotate(-24deg) scale(1); }
}

/* Octonaughty — the villain version of Octoblock (Eight). Canon (wiki +
   Mateosty's reference): a magenta body like Octoblock's, but with GREEN octopus
   limbs, a spiky GREEN mask, and sleepy heavy-lidded eyes with small pupils. The
   arms/mask/eyes are all recoloured green below; the mask's jagged edge and the
   droopy lids give the "up to no good" look. */
/* Octonaughty's spiky villain mask — the jagged domino shape (same silhouette as
   Fifty-Six's superhero mask) in her canon light-green scheme, sitting behind the
   eyes so they peek through. The jagged upper edge gives the "up to no good"
   scowl. */
.naughty-mask {
  position: absolute;
  top: -16%;
  left: 50%;
  width: 132%;
  height: 58%;
  transform: translateX(-50%);
  background: linear-gradient(160deg, #8fd96f, #4f9e3a);
  clip-path: polygon(
    0% 46%, 6% 34%, 12% 8%, 18% 34%, 25% 4%, 32% 32%, 40% 2%, 48% 30%,
    56% 2%, 64% 30%, 72% 4%, 80% 32%, 88% 8%, 94% 34%, 100% 46%,
    92% 96%, 8% 96%);
  z-index: -1; /* behind the eyes — they read as peeking through the mask */
  pointer-events: none;
  filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.35));
  animation: naughty-on 0.5s ease-out;
}
/* A sly, wider, tilted grin. */
.octonaughty .mouth {
  width: calc(var(--fs) * 0.52);
  height: calc(var(--fs) * 0.2);
  rotate: -9deg;
}
/* Canon GREEN octopus tentacles (not Octoblock's magenta ones) that whip a
   little faster, so he reads as the mischievous twin. */
.octonaughty .octo-arm {
  background:
    radial-gradient(circle at 50% 22%, rgba(255,255,255,0.7) 0 2px, transparent 2.6px),
    radial-gradient(circle at 50% 47%, rgba(255,255,255,0.55) 0 1.8px, transparent 2.4px),
    radial-gradient(circle at 50% 72%, rgba(255,255,255,0.4) 0 1.5px, transparent 2px),
    linear-gradient(160deg, #8fd96f, #3f7f2e);
  border-color: #2c5a1f;
}
.octonaughty .octo-left  { animation-duration: 1.4s; }
.octonaughty .octo-right { animation-duration: 1.4s; }
/* Canon sleepy eyes — small dark pupils on a pale sclera, half-covered by heavy
   green droopy lids (the ".eyes are not even ∞s" fix). The pupil is the eye's
   ::after, the sclera is the eye background (mirrors the terrible-twos scheme);
   ::before is the green lid drooping down from the top. */
.octonaughty .eye {
  background: #f2f4ee;
  overflow: visible;
}
.octonaughty .eye::after {
  width: 46%; height: 46%;
  left: 27%; top: 34%;
  background: #241d24;
}
.octonaughty .eye::before {
  content: '';
  position: absolute;
  left: -10%; right: -10%; top: -34%;
  height: 74%;
  background: linear-gradient(160deg, #8fd96f, #4f9e3a);
  border-radius: 50% 50% 42% 42%;
  box-shadow: inset 0 -1px 1px rgba(0, 0, 0, 0.2);
  z-index: 1;
}
@keyframes naughty-on {
  0%   { opacity: 0; transform: translateX(-50%) scale(0.3) rotate(-12deg); }
  60%  { opacity: 1; transform: translateX(-50%) scale(1.15) rotate(6deg); }
  100% { opacity: 1; transform: translateX(-50%) scale(1) rotate(0); }
}

/* ── Infinaughty — Octonaughty wearing the stolen Golden Infinity ──────────
   Series 9's main antagonist ("The Rise of Infinaughty", 2026-09-07 → 09-11).
   Canon appearance, from the Infinaughty section of the wiki's Octonaughty page:
   he is Octonaughty "except his Numberling is turned around to resemble an
   infinity, and he has a mask over his typical mask which resembles two large
   golden disks, connecting together to form the shape of an infinity. There are
   4 triangular prongs on each side of said golden mask."
   So `.infinaughty` is always ALSO `.octonaughty` — everything above (green
   tentacles, green spiky mask, sleepy eyes, sly grin) still applies, and this
   block only adds the two things canon says are different.
   ⚠️ This rule set sits AFTER the .octonaughty block on purpose. A one-class
   override placed before it would lose to equal specificity on source order —
   that exact bug shipped once already with .tophat-terrible. */

/* Canon's headline detail: the numberling is TURNED AROUND to read as an ∞.
   A rotated 8 genuinely is one, so this is a rotation and not a glyph swap —
   the block is still honestly an Eight, which matters in a counting game.
   `rotate` is its own property here, so .numeral's `translate: -50% 0`
   centring is untouched and no transform is being overwritten. */
.infinaughty .numeral {
  rotate: 90deg;
  /* Gold to match the mask, keeping the canon white halo for night-sky reads. */
  color: #b8860b;
}

/* The second mask, worn OVER the green one. Spans the eye band of the face;
   the eyes sit at 19%/81% across (.eye-left/right are 6% in and 26% wide) and
   their centres 15% of --fs down, which is what the disk placement below is
   derived from rather than eyeballed. */
.inf-mask {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(var(--fs) * -0.05);
  height: calc(var(--fs) * 0.40);
  pointer-events: none;
  /* Above the green mask (z-index -1) and the droopy lid (z-index 1). */
  z-index: 2;
  animation: inf-mask-on 0.55s ease-out 0.1s backwards;
}
/* The bridge that joins the two disks into a single ∞ silhouette. Sits in the
   0.22·--fs gap the two rings leave between them. */
.inf-mask::after {
  content: '';
  position: absolute;
  left: 50%;
  top: calc(var(--fs) * 0.15);
  width: calc(var(--fs) * 0.26);
  height: calc(var(--fs) * 0.10);
  translate: -50% 0;
  background: linear-gradient(180deg, #ffe89a, #c08d14);
  border-radius: calc(var(--fs) * 0.03);
  box-shadow: 0 1px 1px rgba(90, 60, 0, 0.35);
}
/* Each golden disk is a RING, not a solid disc, so the eye reads through it —
   the same rule the green mask follows (z-index -1, "eyes peek through"). A
   solid disc here would leave the block faceless at block size. */
.inf-disk {
  position: absolute;
  top: calc(var(--fs) * -0.03);
  width: calc(var(--fs) * 0.46);
  height: calc(var(--fs) * 0.46);
  border: calc(var(--fs) * 0.07) solid #e2ac25;
  border-radius: 50%;
  background: transparent;
  box-shadow:
    inset 0 0 0 calc(var(--fs) * 0.012) rgba(255, 245, 190, 0.9),
    0 0 calc(var(--fs) * 0.05) rgba(226, 172, 37, 0.55);
}
.inf-disk-l { left:  calc(var(--fs) * -0.04); }
.inf-disk-r { right: calc(var(--fs) * -0.04); }
/* The four triangular prongs per side. One clipped element per disk, sized 150%
   of the disk and centred on it, so the polygon's inner radius (33%) lands
   exactly on the ring's outer edge and only the tips show past it. */
.inf-disk::before {
  content: '';
  position: absolute;
  left: -25%;
  top: -25%;
  width: 150%;
  height: 150%;
  background: linear-gradient(160deg, #ffe89a, #c08d14);
  z-index: -1;
}
.inf-disk-l::before {
  clip-path: polygon(37.4% 78.3%, 19.2% 89.4%, 25.6% 69.1%, 22.1% 63.6%, 1.1% 60.4%,
    19.0% 48.9%, 19.0% 51.1%, 1.1% 39.6%, 22.1% 36.4%, 25.6% 30.9%, 19.2% 10.6%, 37.4% 21.7%);
}
.inf-disk-r::before {
  clip-path: polygon(62.6% 21.7%, 80.8% 10.6%, 74.4% 30.9%, 77.9% 36.4%, 98.9% 39.6%,
    81.0% 51.1%, 81.0% 48.9%, 98.9% 60.4%, 77.9% 63.6%, 74.4% 69.1%, 80.8% 89.4%, 62.6% 78.3%);
}
/* No translateX here — unlike .naughty-mask this element is pinned left:0/right:0,
   so borrowing naughty-on's keyframes would shove it half a face to the left. */
@keyframes inf-mask-on {
  0%   { opacity: 0; transform: scale(0.35) rotate(-10deg); }
  60%  { opacity: 1; transform: scale(1.12) rotate(4deg); }
  100% { opacity: 1; transform: scale(1) rotate(0); }
}

/* One Hundred's galaxy aura — a deep-space halo with a scatter of twinkling
   stars glowing behind the cubes, rewarding the hundred-chart milestone. Sits
   behind the block (z-index -1) so the cubes/face stay readable; the soft glow
   pulses and the starfield twinkles out of phase. */
.galaxy-aura {
  position: absolute;
  /* -2, one layer behind the array factor-flames (which sit at -1). Both draw
     behind the cubes, but the aura and the flames used to share z-index -1, so
     DOM order alone decided — and the aura is appended last, painting its purple
     halo on top of the rays that lick past the block edge. On a 100-block
     reshaped into a factor array (× mode) that hid the flames entirely under the
     "aurora" (commonvine8417084). Sending the aura one layer deeper keeps it
     behind the cubes for every normal Hundred (nothing else sits at -2) while
     letting the factor rays show through on top of it. */
  z-index: -2;
  pointer-events: none;
  border-radius: 50%;
  background:
    radial-gradient(circle at 12% 22%, #fff 0 2px, transparent 3px),
    radial-gradient(circle at 88% 16%, #cfe3ff 0 2.4px, transparent 3.4px),
    radial-gradient(circle at 92% 70%, #fff 0 1.8px, transparent 2.8px),
    radial-gradient(circle at 30% 88%, #ffe6a8 0 2.2px, transparent 3.2px),
    radial-gradient(circle at 8% 58%, #d8c4ff 0 1.8px, transparent 2.8px),
    radial-gradient(circle at 70% 90%, #fff 0 1.6px, transparent 2.6px),
    radial-gradient(circle at 50% 6%, #fff 0 1.6px, transparent 2.6px),
    radial-gradient(circle at 6% 84%, #cfe3ff 0 1.5px, transparent 2.4px),
    radial-gradient(circle at 96% 44%, #ffe6a8 0 1.5px, transparent 2.4px),
    radial-gradient(circle at 20% 46%, #fff 0 1.3px, transparent 2.2px),
    radial-gradient(circle at 80% 56%, #fff 0 1.3px, transparent 2.2px),
    radial-gradient(circle at 44% 72%, #d8c4ff 0 1.4px, transparent 2.3px),
    radial-gradient(circle at 60% 30%, #fff 0 1.2px, transparent 2px),
    radial-gradient(circle at 50% 50%, rgba(120, 78, 224, 0.7) 0%, rgba(58, 30, 124, 0.5) 38%, rgba(30, 16, 70, 0.22) 60%, transparent 74%);
  animation: galaxy-twinkle 2.6s ease-in-out infinite, galaxy-spin 26s linear infinite;
}
@keyframes galaxy-twinkle {
  0%, 100% { opacity: 0.85; filter: drop-shadow(0 0 8px rgba(140, 100, 240, 0.6)); }
  50%      { opacity: 1;    filter: drop-shadow(0 0 22px rgba(165, 135, 255, 0.95)); }
}
@keyframes galaxy-spin {
  to { transform: rotate(360deg); }
}

/* A centillion's divine aura — the summit of the whole climb rendered like a
   god: a radiant golden halo with sun-rays streaming outward, glowing behind
   the cubes (z-index -1) so the block stays readable. The rays slowly rotate
   and the glow pulses; a little crown floats above the head. */
.divine-aura {
  position: absolute;
  z-index: -1;
  pointer-events: none;
  border-radius: 50%;
  background:
    repeating-conic-gradient(from 0deg at 50% 50%,
      rgba(255, 236, 150, 0.55) 0deg 6deg,
      transparent 6deg 18deg),
    radial-gradient(circle at 50% 50%,
      rgba(255, 244, 180, 0.95) 0%,
      rgba(255, 215, 80, 0.7) 26%,
      rgba(255, 190, 40, 0.35) 46%,
      transparent 68%);
  /* fade the rays out toward the edge so it's a round radiant halo, not a
     square field of spokes (keeps it readable on narrow phone screens) */
  -webkit-mask: radial-gradient(circle at 50% 50%, #000 0%, #000 30%, transparent 70%);
  mask: radial-gradient(circle at 50% 50%, #000 0%, #000 30%, transparent 70%);
  animation: divine-glow 2.4s ease-in-out infinite, divine-spin 22s linear infinite;
}
@keyframes divine-glow {
  0%, 100% { opacity: 0.85; filter: drop-shadow(0 0 10px rgba(255, 210, 70, 0.7)); }
  50%      { opacity: 1;    filter: drop-shadow(0 0 26px rgba(255, 230, 120, 1)); }
}
@keyframes divine-spin {
  to { transform: rotate(360deg); }
}
.divine-crown {
  position: absolute;
  left: 50%;
  top: 0;
  translate: -50% -105%;
  pointer-events: none;
  line-height: 1;
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
  animation: divine-crown-float 2.4s ease-in-out infinite;
}
@keyframes divine-crown-float {
  0%, 100% { transform: translateY(0) rotate(-4deg); }
  50%      { transform: translateY(-14%) rotate(4deg); }
}

/* −Centillion's infernal aura — the dark mirror of the divine halo (autumn's
   idea). Same radiant-rays structure, but a sinister violet/black palette, and
   the crown is flipped upside-down and colour-inverted below the block. */
.infernal-aura {
  position: absolute;
  z-index: -1;
  pointer-events: none;
  border-radius: 50%;
  background:
    repeating-conic-gradient(from 0deg at 50% 50%,
      rgba(150, 70, 220, 0.55) 0deg 6deg,
      transparent 6deg 18deg),
    radial-gradient(circle at 50% 50%,
      rgba(40, 0, 60, 0.95) 0%,
      rgba(110, 30, 170, 0.7) 26%,
      rgba(150, 40, 200, 0.35) 46%,
      transparent 68%);
  -webkit-mask: radial-gradient(circle at 50% 50%, #000 0%, #000 30%, transparent 70%);
  mask: radial-gradient(circle at 50% 50%, #000 0%, #000 30%, transparent 70%);
  animation: infernal-glow 2.4s ease-in-out infinite, divine-spin 22s linear infinite;
}
@keyframes infernal-glow {
  0%, 100% { opacity: 0.85; filter: drop-shadow(0 0 10px rgba(150, 50, 220, 0.7)); }
  50%      { opacity: 1;    filter: drop-shadow(0 0 26px rgba(180, 80, 240, 1)); }
}
.infernal-crown {
  position: absolute;
  left: 50%;
  top: 0;
  translate: -50% -105%;
  pointer-events: none;
  line-height: 1;
  filter: invert(1) hue-rotate(180deg) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
  animation: infernal-crown-float 2.4s ease-in-out infinite;
}
@keyframes infernal-crown-float {
  0%, 100% { transform: translateY(0) rotate(176deg); }
  50%      { transform: translateY(-14%) rotate(184deg); }
}

/* Summit wings — great feathered wings flanking the very top/bottom blocks of the
   chase (autumn's idea). Inline-SVG feather fan behind the block (z-index -1),
   one mirrored so the pair points outward — drawn, not an emoji, so it renders
   the same on every device (the 🪽 glyph showed as a tofu box on older fonts).
   The divine pair glows gold; the infernal pair is darkened to bat-black and
   glows violet, matching its aura/crown. */
.summit-wing {
  position: absolute;
  z-index: -1;
  top: 50%;
  translate: 0 -50%;
  line-height: 1;
  pointer-events: none;
  animation: wing-flap 2.6s ease-in-out infinite;
}
.wing-left  { right: 88%; scale: -1 1; }
.wing-right { left: 88%; }
.wing-divine {
  filter: drop-shadow(0 0 10px rgba(255, 224, 120, 0.9));
}
.wing-infernal {
  filter: brightness(0.28) sepia(1) hue-rotate(235deg) saturate(6)
          drop-shadow(0 0 10px rgba(180, 70, 240, 0.85));
}
@keyframes wing-flap {
  0%, 100% { rotate: -6deg; }
  50%      { rotate: -18deg; }
}

/* Infernal horns — two dark curved horns rising from the head, under the floating
   inverted crown, completing the demon read. */
.infernal-horns {
  position: absolute;
  top: 0;
  left: 50%;
  translate: -50% -40%;
  width: 0;
  height: 0;
  pointer-events: none;
}
.infernal-horns::before,
.infernal-horns::after {
  content: '';
  position: absolute;
  bottom: 0;
  width: var(--horn, 16px);
  height: calc(var(--horn, 16px) * 1.7);
  background: linear-gradient(160deg, #8a0038, #240010);
  clip-path: polygon(50% 0, 78% 100%, 22% 100%);
  transform-origin: bottom center;
  filter: drop-shadow(0 0 4px rgba(190, 60, 130, 0.7));
}
.infernal-horns::before { right: 8%; rotate: -22deg; }
.infernal-horns::after  { left: 8%;  rotate: 22deg; }

/* 666's horns sit on the two top corners (cleanforest's note) rather than the
   centred cluster the summit uses — the wrapper spans the block's full width so
   the left horn rides the left corner and the right horn the right corner,
   clear of the face and numeral. */
.horns-666 { left: 0; right: 0; width: auto; translate: 0 6px; }
.horns-666::before { left: 4%;  right: auto; }
.horns-666::after  { right: 4%; left: auto; }

/* Anti-block "dark mirror": an anti-block wears its positive twin's head-topper,
   but flipped upside-down and colour-inverted — the same treatment the Evil
   Summit's infernal crown uses, generalised to every topper (autumn's idea).
   The toppers centre with the `translate` property and bob with `transform`, so
   overriding transform-origin + transform here flips them in place without
   knocking them off-centre. We keep them bobbing — a flipped mirror of the
   upright sway — so an evil twin moves like its positive counterpart (autumn:
   the negative crown "doesn't move like its positive counterpart"). */
.block-negative .crown,
.block-negative .tophat,
.block-negative .rainbow-hair,
.block-negative .calendar-topper,
.block-negative .flower-field,
/* Head-hats — they sit above the head and swayed with tophat-tip when positive,
   so the anti-block flips them upside-down + inverts colour just like the top
   hat, so −3/−26/−37/−60/−14 wear their evil-twin costume (autumn's ask). */
.block-negative .jester-3,
.block-negative .bowler-26,
.block-negative .propcap-37,
.block-negative .tyrol-21,
.block-negative .beret-34,
.block-negative .skate-helmet-14,
.block-negative .skateboard-14,
/* Series 9: Forty-Seven's aviator helmet flips like Fourteen's skate lid, and
   Forty-Six's head flower flips like Thirty-Five's flower field does. */
.block-negative .helmet-47,
.block-negative .flower-46,
.block-negative .soccer-11 {
  transform-origin: 50% 50%;
  animation: anti-topper-bob 2.6s ease-in-out infinite;
  filter: invert(1) hue-rotate(180deg) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
}
@keyframes anti-topper-bob {
  0%, 100% { transform: translateY(0) rotate(176deg); }
  50%      { transform: translateY(-12%) rotate(184deg); }
}
/* Octopus arms hang from the sides, so flipping would look wrong — just invert
   the colour so −8 / −80 wear an eerie shadow-octopus (autumn: "inverted hands").
   The same goes for face-anchored costumes (masks, sunglasses, brows, lashes)
   and base/waist pieces (shoes, belt, bowtie, juggle cascade): flipping them
   would knock them off the eyes/feet, so anti-blocks just wear the inverted
   colour, keeping the dark-mirror rule without breaking the anchor. */
.block-negative .octo-left,
.block-negative .octo-right,
.block-negative .dance-shoe,
.block-negative .juggle-ball,
.block-negative .brow,
.block-negative .lashes,
.block-negative .cube-mask,
.block-negative .rainbow-mask56,
.block-negative .octomask-72,
.block-negative .shades-26,
.block-negative .tie26,
.block-negative .specs-20,
.block-negative .bowtie20,
.block-negative .belt42,
.block-negative .bowtie54,
.block-negative .badge-80,
/* Series 9: the two eleven-multiple crests sit on the chest like Eighty's badge,
   and Forty-Seven's goggles are face-anchored like Twenty-Six's shades — so all
   three invert rather than flip. Forty-Three's brows are covered by `.brow`.
   Forty-One's siren is invert-ONLY on purpose even though it is a head topper:
   the flip list replaces `animation`, which would kill the sweep that makes it
   read as a siren at all, and an upside-down beacon is not a better mirror. */
.block-negative .badge-33,
.block-negative .badge-44,
.block-negative .goggles-47,
.block-negative .siren-41,
.block-negative .quiff-90,
.block-negative .pocket-90,
.block-negative .headband-63,
.block-negative .nose-39,
/* The repdigit halo (−111, −222, −333 …) was the last accessory still rendering
   un-mirrored on an anti-block: a gold ring identical to its positive twin's
   (autumn). Invert-ONLY, not flip — `.angel-halo` runs its own `halo-bob`
   animation and the flip list above replaces `animation` outright, which would
   kill the bob, the same reason `.siren-41` is invert-only. A ring is very
   nearly symmetric too, so flipping would not read as mirrored anyway. */
.block-negative .angel-halo,
.block-negative .paint-splash {
  filter: invert(1) hue-rotate(180deg);
}
/* …except −666, which opts back out. The anti-beast wearing a REAL golden halo
   is the punchline of the two-sided 666 easter egg (TH30) — it is the one
   negative whose accessory is SUPPOSED to look positive. Set in block.js, which
   is the only place that can distinguish it; both cases are a .block-negative
   carrying an .angel-halo, so the CSS alone cannot tell them apart. */
.block-negative.anti-beast .angel-halo {
  filter: none;
}

/* Numberblock Thirty-Nine's round grey clown nose (canon: "a grey nose, alike to
   that of a clown"). A soft round blob centred on the face between the eyes and
   mouth, with a light top-left highlight so it reads as a rounded clown nose.
   Lives in the face so it scales with --fs; inverts on anti-blocks via the rule
   above. Paired with 39's dark-navy brows (built in block.js). */
.nose-39 {
  position: absolute;
  top: calc(var(--fs) * 0.30);
  left: 50%;
  translate: -50% 0;
  width: calc(var(--fs) * 0.24);
  height: calc(var(--fs) * 0.22);
  background: radial-gradient(circle at 35% 30%, #bcc1c9 0 18%, #8a9099 55%, #6b717b 100%);
  border-radius: 50%;
  box-shadow: inset 0 calc(var(--fs) * -0.03) calc(var(--fs) * 0.045) rgba(0, 0, 0, 0.22);
}

/* Numberblock Seventeen the artist — her cubes are dappled with colourful paint
   splatters (webaisimul's canon wiki ref). A fixed scatter of small irregular
   blobs, each with a couple of satellite droplets (box-shadow) for a splat read.
   Built in block.js; sits above the cubes, behind the face. Positive 17 only;
   the anti-17 inverts via the .block-negative rule above. */
.paint-splashes17 {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 1;
}
.paint-splash {
  position: absolute;
  transform: translate(-50%, -50%);
  border-radius: 46% 62% 55% 40% / 58% 44% 60% 46%;
  opacity: 0.92;
  box-shadow:
    0.28em -0.42em 0 -0.16em currentColor,
    -0.4em 0.3em 0 -0.2em currentColor,
    0.34em 0.36em 0 -0.24em currentColor;
  color: inherit;
  animation: splash-on 0.4s ease-out both;
}
@keyframes splash-on {
  from { opacity: 0; scale: 0.2; }
  to { opacity: 0.92; scale: 1; }
}

/* 🥧 × on π: π turns into a disc 1 cube in radius (area = π cubes). The rim tick
   makes the roll visible; physics.js turns the whole block. */
.pi-disc {
  position: absolute; inset: 0; box-sizing: border-box; border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, var(--disc-hi, #e4f36a), var(--disc, #b8d82e) 60%, var(--disc-lo, #9cbd1c));
  border: 3px solid var(--disc-rim, #6f8a0e);
  box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.12);
}
.pi-disc::after {
  /* the roll tick sits on the RIGHT rim, horizontal — at the bottom, under the
     smile, it read as something rude (Peashooterfrompvzreal2763, 10-06) */
  content: ''; position: absolute; top: 50%; right: 0; height: 3px; width: 22%;
  margin-top: -1.5px; background: var(--disc-rim, #6f8a0e); border-radius: 2px; opacity: 0.55;
}
.block-negative .pi-disc { background: #1b1b22; border-color: var(--disc, #b8d82e); }
/* 🌗 π/2 = the top half of π's disc, π/4 = its quarter (flat edges down/left) */
.pi-disc.pi-part-2 { border-radius: 9999px 9999px 0 0; }
.pi-disc.pi-part-4 { border-radius: 0 100% 0 0; }
.pi-disc.pi-part::after { display: none; }
.block-negative .pi-disc::after { background: var(--disc, #b8d82e); }
/* 🔮 4π/3's ball: deeper shading + an equator arc so it reads as a sphere */
.pi-disc.pi-sphere {
  background: radial-gradient(circle at 34% 28%, #fff 0, var(--disc-hi) 12%, var(--disc) 48%, var(--disc-lo) 100%);
  box-shadow: inset -6px -8px 14px rgba(0, 0, 0, 0.18);
}
.pi-disc.pi-sphere::after {
  top: 52%; left: 3%; right: 3%; width: auto; height: 22%; margin-top: 0;
  background: none; border: 2px solid var(--disc-rim); border-color: transparent transparent var(--disc-rim);
  border-radius: 50%; opacity: 0.45;
}
.block-negative .pi-disc.pi-sphere { background: radial-gradient(circle at 34% 28%, #4a4a55 0, #1b1b22 55%); }
.block-negative .pi-disc.pi-sphere::after { background: none; }
/* 🍩 π²/2's donut: a lime ring 3 cubes across with a 1-cube hole, seen from
   a little above (the hole sits high, the face rides the front of the ring) */
.pi-torus {
  position: absolute; inset: 0; box-sizing: border-box; border-radius: 50%;
  background: radial-gradient(ellipse 50% 50% at 50% 40%, transparent 0 30%, var(--disc-rim) 30% 34%, var(--disc-lo) 36%, var(--disc) 50%, var(--disc-hi) 66%, var(--disc) 82%, var(--disc-lo) 100%);
  border: 3px solid var(--disc-rim);
  box-shadow: inset 0 -6px 10px rgba(0, 0, 0, 0.16);
}
.block-negative .pi-torus {
  background: radial-gradient(ellipse 50% 50% at 50% 40%, transparent 0 30%, var(--disc) 30% 34%, #1b1b22 36%, #2c2c36 66%, #1b1b22 100%);
  border-color: var(--disc);
}
/* π²/4: the same ring at double width, cut down the middle; the cut face shows
   the tube's two round slices */
.pi-torus-half { right: auto; width: 200%; clip-path: inset(-4px 50% -4px -4px); }
.pi-torus-half::before, .pi-torus-half::after {
  content: ''; position: absolute; left: calc(50% - 22%); width: 22%; border-radius: 50%;
  background: var(--disc-lo); border: 2px solid var(--disc-rim); box-sizing: border-box;
}
.pi-torus-half::before { top: 0; height: 28%; }
.pi-torus-half::after { bottom: 0; height: 42%; }
.block-negative .pi-torus-half::before, .block-negative .pi-torus-half::after { background: #2c2c36; border-color: var(--disc); }
.pi-square {
  position: absolute; inset: 0; box-sizing: border-box; border-radius: 4px;
  background:
    repeating-linear-gradient(90deg, transparent 0 calc(var(--cs) - 2px), rgba(0, 0, 0, 0.14) calc(var(--cs) - 2px) var(--cs)),
    repeating-linear-gradient(180deg, transparent 0 calc(var(--cs) - 2px), rgba(0, 0, 0, 0.14) calc(var(--cs) - 2px) var(--cs)),
    linear-gradient(160deg, #d6ec5a, var(--disc, #b8d82e) 55%, #9cbd1c);
  border: 3px solid #6f8a0e;
  box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.12);
}
.block-negative .pi-square { background: #1b1b22; border-color: var(--disc, #b8d82e); }
.const-square:not(.block-negative) .pi-square {
  background:
    repeating-linear-gradient(90deg, transparent 0 calc(var(--cs) - 2px), rgba(0, 0, 0, 0.14) calc(var(--cs) - 2px) var(--cs)),
    repeating-linear-gradient(180deg, transparent 0 calc(var(--cs) - 2px), rgba(0, 0, 0, 0.14) calc(var(--cs) - 2px) var(--cs)),
    linear-gradient(160deg, color-mix(in srgb, var(--disc) 70%, white), var(--disc) 55%, color-mix(in srgb, var(--disc) 82%, black));
  border-color: color-mix(in srgb, var(--disc) 55%, black);
}
.wiggle { animation: wiggle 0.4s ease; }
@keyframes wiggle {
  20% { rotate: -7deg; }
  50% { rotate: 7deg; }
  80% { rotate: -4deg; }
}

/* face */
.face {
  position: absolute;
  top: calc(var(--fs) * 0.18);
  left: 50%;
  translate: -50% 0;
  width: var(--fs);
  height: calc(var(--fs) * 0.8);
  pointer-events: none;
  /* Ink colour for hand-drawn expression mouths/brows; anti-blocks flip it light. */
  --mouthc: #1c1c1e;
}
.block-negative .face { --mouthc: #eef0f6; }
.eye {
  position: absolute;
  top: 0;
  width: calc(var(--fs) * 0.26);
  height: calc(var(--fs) * 0.30);
  background: #1c1c1e;
  border-radius: 50%;
  animation: blink 5.5s infinite;
  /* transform → smooth droop when dozing at night; translate → smooth eye-look
     toward whatever block you're dragging (the two properties compose, so the
     look never fights the blink's scaleY). */
  transition: transform 0.5s ease, translate 0.28s ease;
}
.eye::after {
  content: '';
  position: absolute;
  width: 38%; height: 38%;
  left: 16%; top: 12%;
  background: #fff;
  border-radius: 50%;
}
.eye-left  { left: 6%; }
.eye-right { right: 6%; }
.eye-square { border-radius: 18%; }
/* Rectangle eyes for "super rectangles" (12, 18, 24, 32, 40…) — the oblong
   cousin of the square club's square eyes. TALL (portrait) rectangles: narrower
   and taller than the round default so they read unmistakably as upright
   rectangles, matching the show's tall rectangle characters. The two requesters
   (TV10TheCool + commonvine) both corrected the earlier landscape version to
   vertical. Light rounding keeps them clearly rectangular, not oval. */
.eye-rect {
  width: calc(var(--fs) * 0.20);
  height: calc(var(--fs) * 0.33);
  border-radius: 14%;
  top: 0;
}
/* Canon mismatched eyes for the multiples of eleven (11,22,…,99 except 55): a
   big eye (the ten-block) and a small one (the one-block). Sizes bracket the
   default 0.26×0.30; the small eye is nudged down so both sit on the same eye
   line instead of hanging from the top edge. Set in buildFace (block.js). */
.eye-big   { width: calc(var(--fs) * 0.32); height: calc(var(--fs) * 0.37); }
.eye-small { width: calc(var(--fs) * 0.19); height: calc(var(--fs) * 0.22); top: calc(var(--fs) * 0.08); }
/* 👀 Forty-Six alone. Four askers; the sizing is measured off the wiki render the
   fourth of them posted — see the long note at `bigEyes46` in block.js for why
   this is 0.40 and not the reference's own (much larger) proportion, and why it
   is deliberately not `.eye-big`. Two eyes at 0.40 plus the 6% left/right insets
   come to ~92% of the face box, so the pair nearly touch, which is what the
   reference shows. Any wider and they would overlap, or push out of the box that
   the mouth and the .flower-46 head topper are positioned against. */
.eye-46    { width: calc(var(--fs) * 0.40); height: calc(var(--fs) * 0.46); }
/* The football-team eyes: a coloured iris (blue on the big eye, red on the small
   one) with a small dark pupil and the usual white glint. The two colours
   foreshadow the red/blue Elevens the block splits into. Set in buildFace on
   positive 22 AND 44 — the same Series 9 football gag, with a BBC frame of
   "4 × 11 = 44" as 44's reference (see the twoTone22 comment in block.js).
   The default .eye is a solid dark pupil, so here the eye body becomes the iris
   colour and ::before draws the pupil on top. */
.eye-iris-blue { background: #1f6fd6; }
.eye-iris-red  { background: #e2231a; }
.eye-iris-blue::before,
.eye-iris-red::before {
  content: '';
  position: absolute;
  width: 52%; height: 52%;
  left: 24%; top: 26%;
  background: #1c1c1e;
  border-radius: 50%;
}
/* the −22 / −44 evil twins use .block-negative's inverted eyes — drop the
   coloured pupil dot so they stay uniform with the other negatives */
.block-negative .eye-iris-blue::before,
.block-negative .eye-iris-red::before { display: none; }
/* Numberblock Two's canon eyes: a pair of purple-framed spectacles. Each eye is
   a WHITE oval lens ringed by a thick purple frame, with a black pupil inside —
   set close together and bridged so the pair reads unmistakably as glasses (the
   show describes "purple oval eyes that look like glasses"). This is the honest
   answer to the recurring "give 2 glasses" asks — the glasses are the eyes.
   Earlier we made the whole eye a solid purple blob; ComfyNoob's canon ref shows
   it should be a white lens in a purple frame, so we correct it. */
/* @tweakable Numberblock Two's spectacle-frame colour (canon purple) */
.eye-two {
  background: #fff;
  width: calc(var(--fs) * 0.30);
  height: calc(var(--fs) * 0.34);
  border-radius: 45%;
  /* the purple ring IS the glasses frame — box-shadow so it never shifts layout */
  box-shadow: 0 0 0 calc(var(--fs) * 0.055) #7b3ff2;
}
/* pull the two lenses inward so their frames nearly meet — the close-set spacing
   plus the bridge is what makes them read as a pair of spectacles */
.eye-two.eye-left  { left: 15%; }
.eye-two.eye-right { right: 15%; }
/* black pupil with a small white glint, replacing the default corner shine */
.eye-two::after {
  width: 50%; height: 50%;
  left: 25%; top: 26%;
  border-radius: 50%;
  background: radial-gradient(circle at 34% 30%, #fff 0 16%, #1c1c1e 26%);
}
/* the purple bridge joining the two frames across the nose */
.two-bridge {
  position: absolute;
  top: calc(var(--fs) * 0.14);
  left: 50%;
  width: calc(var(--fs) * 0.18);
  height: calc(var(--fs) * 0.07);
  margin-left: calc(var(--fs) * -0.09);
  background: #7b3ff2;
  border-radius: calc(var(--fs) * 0.04);
  z-index: -1;
}
/* the masked Terrible Twos wear their own eyes — drop the spectacle frame/bridge */
.terrible-twos .eye-two { box-shadow: none; }
.terrible-twos .two-bridge { display: none; }
/* Numberblock Four's dark-green eyebrows — two little squares each, perched
   above his square eyes (positions set per-side in block.js). */
.brow {
  position: absolute;
  /* Lifted clear of the eyes (was -0.05, brows sat pressed onto the pupils with
     no gap — Simulation's note) so the green squares float just above with a
     small breathing gap, matching Four's canon look. */
  top: calc(var(--fs) * -0.18);
  width: calc(var(--fs) * 0.38);
  height: calc(var(--fs) * 0.20);
}
.brow-left  { left: 0%; }
.brow-right { right: 0%; }
.brow-seg {
  position: absolute;
  width: calc(var(--fs) * 0.18);
  height: calc(var(--fs) * 0.14);
  background: #2f6e1e;
  border-radius: 16%;
}
/* Numberblock Nine's slate-blue eyebrows — one thick rounded bar each, perched
   above her grey eyes (tilt set per-side in block.js). */
.brow-nine {
  top: calc(var(--fs) * -0.30);
  width: calc(var(--fs) * 0.40);
}
.brow-nine.brow-left  { left: 0%; }
.brow-nine.brow-right { right: 0%; }
.brow-bar {
  width: 100%;
  height: calc(var(--fs) * 0.16);
  background: #58657f;
  border-radius: calc(var(--fs) * 0.09);
}
/* Numberblock Forty's canon green eyebrows — two thick rectangular brows in
   Four's dark green (40 = 4×10, Four's brows scaled up). Reuses Nine's brow slot;
   squared off rather than rounded, with a subtle two-tone to echo the show's
   small green rectangles. 40-specific. */
.brow-40 .brow-bar {
  background: repeating-linear-gradient(90deg, #2f6e1e 0 24%, #3f8a29 24% 50%);
  height: calc(var(--fs) * 0.20);
  border-radius: calc(var(--fs) * 0.03);
}
/* Forty-Two's canon blue eyebrows — two thick rounded blue brows (similar to
   Forty's rectangular brows but in 42's bright blue, matching his blue eyes/limbs).
   Reuses Nine's brow slot; 42-specific. */
.brow-42 .brow-bar {
  background: #2f6fd6;
  height: calc(var(--fs) * 0.19);
  border-radius: calc(var(--fs) * 0.05);
}
/* Thirty-Five's canon blue eyebrows — two thick rounded brows in Five's cyan-blue
   (35 = 5×7; his eyes/glasses/limbs are all blue). Reuses Nine's brow slot. */
.brow-35 .brow-bar {
  background: #00b6e9;
  height: calc(var(--fs) * 0.19);
  border-radius: calc(var(--fs) * 0.05);
}
/* Twelve's canon red brows (Nine's bar shape). */
.brow-12 .brow-bar {
  background: #e2231a;
  height: calc(var(--fs) * 0.17);
  border-radius: calc(var(--fs) * 0.08);
}
/* Forty-Eight's mismatched canon brows — indigo over the left eye, magenta over
   the right (reuses Nine's bar shape; built per-side in block.js). */
.brow-48-left  .brow-bar { background: #4338ca; }
.brow-48-right .brow-bar { background: #c0268f; }
/* Numberblock Nineteen / Thirty-Eight's canon zig-zag brows — the Nine brow slot
   (bar colour set inline per side in block.js), with the rounded bar recut into a
   lightning ribbon. The right brow mirrors so the two zig-zags read symmetric. */
.brow-zig .brow-bar {
  height: calc(var(--fs) * 0.22);
  border-radius: 0;
  clip-path: polygon(0% 60%, 22% 5%, 44% 50%, 66% 5%, 100% 45%, 100% 80%, 66% 40%, 44% 85%, 22% 40%, 0% 95%);
}
.brow-zig.brow-right .brow-bar { transform: scaleX(-1); }
/* Numberblock Twenty-Five's canon blue eyebrows — one spiky brow over each eye,
   each carved into five upward points (5² = the fifth square; wiki). Reuses Nine's
   brow slot; the bar is Five's blue and clipped to a five-spike comb. 25-specific. */
.brow-25 .brow-bar {
  background: #00b6e9;
  height: calc(var(--fs) * 0.24);
  border-radius: 0;
  clip-path: polygon(0% 100%, 0% 66%, 10% 0%, 20% 66%, 30% 0%, 40% 66%, 50% 0%, 60% 66%, 70% 0%, 80% 66%, 90% 0%, 100% 66%, 100% 100%);
}
/* Numberblock Fifty's single canon brow — one bold dark-navy bar swept up over
   her gold star eye (viewer's left only; reuses Nine's bar shape, tilt set in
   block.js). Sits a touch wider and lower so it hugs the larger star. */
.brow-fifty {
  top: calc(var(--fs) * -0.24);
  width: calc(var(--fs) * 0.44);
}
.brow-fifty.brow-left { left: 1%; }
.brow-fifty .brow-bar { background: #24407a; height: calc(var(--fs) * 0.14); }
/* Numberblock Six's red eyelashes — three per eye (six total), fanning up and
   out from the top-outer corner of each eye (angles set per-side in block.js). */
.lashes {
  position: absolute;
  top: calc(var(--fs) * -0.05);
  width: calc(var(--fs) * 0.26); /* spans one eye */
  height: 0;
}
.lashes-left  { left: 6%; }
.lashes-right { right: 6%; }
.lash {
  position: absolute;
  bottom: 0;
  width: calc(var(--fs) * 0.035);
  height: calc(var(--fs) * 0.26);
  background: #e23b3b;
  border-radius: calc(var(--fs) * 0.02);
  transform-origin: bottom center;
}
/* Numberblock Fifteen's spiky navy Step Squad mask — one jagged piece over each
   eye (built per-side in block.js). Fifteen-exclusive. The clip-path carves a
   row of upward spikes: the outer points reach the top, the centre dips, for
   her cool crowned-mask look. */
.brow-fifteen {
  top: calc(var(--fs) * -0.22);
  width: calc(var(--fs) * 0.32);
  height: calc(var(--fs) * 0.28);
  background: #2a338f;
  clip-path: polygon(0% 100%, 0% 48%, 17% 0%, 33% 42%, 50% 14%, 67% 42%, 83% 0%, 100% 48%, 100% 100%);
}
.brow-fifteen.brow-left  { left: 2%; }
.brow-fifteen.brow-right { right: 2%; }
/* …grown into a real mask band (kindfinch6702839's 10-15 "Bug" — the two little
   spikes read as hair, canon is a navy band over both eyes): the halves overlap
   in the middle and reach below the eyes, which sit on top in white eye-holes. */
.brow-fifteen {
  top: calc(var(--fs) * -0.26);
  width: calc(var(--fs) * 0.6);
  height: calc(var(--fs) * 0.7);
  z-index: 0;
  clip-path: polygon(0% 100%, 0% 32%, 17% 0%, 33% 28%, 50% 10%, 67% 28%, 83% 0%, 100% 32%, 100% 100%);
}
.brow-fifteen.brow-left  { left: -8%; }
.brow-fifteen.brow-right { right: -8%; }
.face:has(> .brow-fifteen) > .eye {
  z-index: 1;
  box-shadow: 0 0 0 calc(var(--fs) * 0.08) #fff;
}
/* The rest of the Step Squad wear Fifteen's exact spiky step-mask, recoloured to
   each member's step colour (built in block.js; positive members only, the −anti
   twin inherits the .brow negative inversion). 21 indigo, 28 rainbow, 36 pink,
   45 grey, 55 white with a red border — the wiki's shared Step-Squad uniform. */
.brow-step {
  top: calc(var(--fs) * -0.22);
  width: calc(var(--fs) * 0.32);
  height: calc(var(--fs) * 0.28);
  clip-path: polygon(0% 100%, 0% 48%, 17% 0%, 33% 42%, 50% 14%, 67% 42%, 83% 0%, 100% 48%, 100% 100%);
}
.brow-step.brow-left  { left: 2%; }
.brow-step.brow-right { right: 2%; }
/* …and grown into the same band as Fifteen's (divinegorilla: "21-19900 should
   have step mask like 15 not eyebrows") — the old 0.32fs spikes read as brows. */
.brow-step {
  top: calc(var(--fs) * -0.26);
  width: calc(var(--fs) * 0.6);
  height: calc(var(--fs) * 0.7);
  z-index: 0;
  clip-path: polygon(0% 100%, 0% 32%, 17% 0%, 33% 28%, 50% 10%, 67% 28%, 83% 0%, 100% 32%, 100% 100%);
}
.brow-step.brow-left  { left: -8%; }
.brow-step.brow-right { right: -8%; }
.face:has(> .brow-step) > .eye {
  z-index: 1;
  box-shadow: 0 0 0 calc(var(--fs) * 0.08) #fff;
}
/* …but square/rect eyes (28, 36, 45, 78…) framed in a white box read as odd
   little screens (Mateosty: "make the eyes look like a normal number's") — they
   keep their ordinary look and just sit on the band. */
.face:has(> .brow-step) > .eye.eye-rect,
.face:has(> .brow-step) > .eye.eye-square { box-shadow: none; }
.brow-step.step-21 { background: #4736b8; }            /* indigo */
.brow-step.step-36 { background: #ef5ba8; }            /* pink */
.brow-step.step-45 { background: #9aa3b2; }            /* grey */
.brow-step.step-28 {                                    /* rainbow */
  background: linear-gradient(90deg,
    #e2231a 0 15%, #ff8300 15% 29%, #ffd100 29% 43%, #7ac143 43% 57%,
    #00b6e9 57% 71%, #3a4ea0 71% 85%, #9b59b6 85% 100%);
}
.brow-step.step-55 {                                    /* white with red border */
  background: #ffffff;
  filter: drop-shadow(0 0 1px #e2231a) drop-shadow(0 0 0.6px #e2231a);
}
/* Step colours by digit for the two-piece masks past 55 (block.js
   STEP_SQUAD_WIDE): the same colours the canon masks wear for 5…10 steps,
   with One…Four's own block colours below that. d10 = the tens-digit-1 piece. */
.brow-step.step-d1 { background: #e2231a; }
.brow-step.step-d2 { background: #ff8300; }
.brow-step.step-d3 { background: #ffd100; }
.brow-step.step-d4 { background: #7ac143; }
.brow-step.step-d5 { background: #00b6e9; }  /* Five's blue, not navy — navy read as d6's indigo (faintwood) */
.brow-step.step-d6 { background: #4736b8; }
.brow-step.step-d7 {
  background: linear-gradient(90deg,
    #e2231a 0 15%, #ff8300 15% 29%, #ffd100 29% 43%, #7ac143 43% 57%,
    #00b6e9 57% 71%, #3a4ea0 71% 85%, #9b59b6 85% 100%);
}
.brow-step.step-d8 { background: #ef5ba8; }
.brow-step.step-d9 { background: #9aa3b2; }
.brow-step.step-d10 {
  background: #ffffff;
  filter: drop-shadow(0 0 1px #e2231a) drop-shadow(0 0 0.6px #e2231a);
}
.brow-step.step-d100 {                                  /* One Hundred's salmon (colors.js hundredsCell) */
  background: #ff8580;
  filter: drop-shadow(0 0 1px #a5262a) drop-shadow(0 0 0.6px #a5262a);
}
/* Numberblock Twenty-Seven's purple "super cube" mask (3×3×3 = 27). A purple
   domino band sits behind the eyes so they peek through, with a small yellow
   cube emblem riding above it — the show's badge for a cube number. Canon-
   distinct from Fifteen's step-mask; positive 27 only (built in block.js). */
.cube-mask {
  position: absolute;
  top: -8%;
  left: 50%;
  width: 130%;
  height: 50%;
  transform: translateX(-50%);
  background: linear-gradient(#8a44e0, #5e25ad);
  border: 1px solid #3f1878;
  border-radius: 50% 50% 44% 44% / 72% 72% 50% 50%;
  z-index: -1; /* behind the eyes — eyes read as peeking through the mask */
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
  animation: cube-mask-on 0.5s ease-out;
}
/* The yellow cube emblem above the mask — a small square badge with a hint of a
   bottom edge for a 3D-cube read. */
.cube-emblem {
  position: absolute;
  top: -46%;
  left: 50%;
  width: 26%;
  aspect-ratio: 1;
  transform: translateX(-50%);
  background: #ffd23b;
  border: 1px solid #d99e00;
  border-radius: 16%;
  box-shadow: inset 0 calc(var(--fs) * -0.045) 0 rgba(0, 0, 0, 0.14);
}
/* Eyes pop light against the purple mask, dark pupil — like the Terrible Twos. */
.block-cube27 .eye { background: #f6f4fb; }
.block-cube27 .eye::after { background: #1c1c1e; }

/* Numberblock Fifty-Six's spiky rainbow mask (7×8 — Seven's rainbow over her
   eyes). A wide band banded in the seven rainbow colours with jagged spikes along
   the top and pointed ends, sitting behind the eyes so they peek through — canon
   superhero look. Built in block.js; positive 56 only. */
.rainbow-mask56 {
  position: absolute;
  top: -14%;
  left: 50%;
  width: 138%;
  height: 60%;
  transform: translateX(-50%);
  background: linear-gradient(90deg,
    #e2231a 0 15%, #ff8300 15% 29%, #ffd100 29% 43%, #7ac143 43% 57%,
    #00b6e9 57% 71%, #3a4ea0 71% 85%, #9b59b6 85% 100%);
  clip-path: polygon(
    0% 46%, 6% 34%, 12% 8%, 18% 34%, 25% 4%, 32% 32%, 40% 2%, 48% 30%,
    56% 2%, 64% 30%, 72% 4%, 80% 32%, 88% 8%, 94% 34%, 100% 46%,
    92% 96%, 8% 96%);
  z-index: -1; /* behind the eyes — they read as peeking through the mask */
  filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.3));
  animation: cube-mask-on 0.5s ease-out;
}
/* Canon 56's eyes are ringed in bright pink against the rainbow band. */
.block-mask56 .eye { box-shadow: 0 0 0 3px #ff4fb4; }

/* Twenty-Six's black bowler hat — a rounded dome sitting on a curved brim, drawn
   in black so it reads as the detective's derby. Tips gently, reusing the top
   hat's bob. Sits above the head like the other hat toppers. */
.bowler-26 {
  position: absolute;
  /* width/height/top set inline in px (topper lives on el, which has no --fs) */
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  transform-origin: 50% 100%;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.35));
  animation: tophat-tip 2.6s ease-in-out infinite;
}
/* the curved brim */
.bowler-26::before {
  content: '';
  position: absolute;
  left: 50%; bottom: 6%;
  translate: -50% 0;
  width: 100%; height: 28%;
  background: #17171b;
  border-radius: 50%;
}
/* the rounded dome */
.bowler-26::after {
  content: '';
  position: absolute;
  left: 50%; bottom: 16%;
  translate: -50% 0;
  width: 62%; height: 82%;
  background: linear-gradient(#3a3a42, #1c1c22 78%);
  border-radius: 50% 50% 40% 40% / 74% 74% 42% 42%;
}
/* Thirty-Seven's light-yellow propeller cap: a pale dome + a stalk and two
   spinning blades on top (XDGameMaker's fan-render ref). Sized inline in px. */
.propcap-37 {
  position: absolute;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  transform-origin: 50% 100%;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.3));
  animation: tophat-tip 2.8s ease-in-out infinite;
}
/* the little brim */
.propcap-37::before {
  content: '';
  position: absolute;
  left: 50%; bottom: 4%;
  translate: -50% 0;
  width: 96%; height: 22%;
  background: #e7cf5f;
  border-radius: 50%;
}
/* the rounded pale-yellow dome */
.propcap-37::after {
  content: '';
  position: absolute;
  left: 50%; bottom: 12%;
  translate: -50% 0;
  width: 66%; height: 80%;
  background: linear-gradient(#fff6bf, #f4df70 80%);
  border-radius: 50% 50% 42% 42% / 74% 74% 44% 44%;
  border: 1px solid #e7cf5f;
}
/* propeller hub sits centred on top of the dome */
.propcap-37-prop {
  position: absolute;
  left: 50%; top: -14%;
  width: 16%; height: 16%;
  translate: -50% 0;
  background: #6b7280;
  border-radius: 50%;
  z-index: 1;
}
/* short silver stalk down into the dome */
.propcap-37-prop::after {
  content: '';
  position: absolute;
  left: 50%; top: 60%;
  translate: -50% 0;
  width: 30%; height: 90%;
  background: #9aa2ad;
  border-radius: 2px;
}
/* two spinning blades — a thin bar with rounded ends, rotating */
.propcap-37-prop::before {
  content: '';
  position: absolute;
  left: 50%; top: 50%;
  translate: -50% -50%;
  width: 320%; height: 34%;
  background: linear-gradient(90deg, #e2231a 0 46%, #1e9be9 54% 100%);
  border-radius: 999px;
  transform-origin: 50% 50%;
  animation: propcap-spin 0.5s linear infinite;
}
@keyframes propcap-spin { to { transform: rotate(360deg); } }
/* Numberblock Twenty-One's green Tyrolean explorer hat — an upturned green brim,
   a tapered green crown with a darker hatband near the base, and three rainbow
   feathers fanning up one side (one per seven; 21 = 3×7). Sized inline in px like
   the other el-toppers. She keeps her navy step-mask (built via STEP_SQUAD_MASKED). */
.tyrol-21 {
  position: absolute;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  transform-origin: 50% 100%;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.32));
  animation: tophat-tip 2.7s ease-in-out infinite;
}
/* upturned brim */
.tyrol-21::before {
  content: '';
  position: absolute;
  left: 50%; bottom: 2%;
  translate: -50% 0;
  width: 100%; height: 26%;
  background: #2f7d3a;
  border-radius: 50% 50% 46% 46% / 80% 80% 60% 60%;
}
/* tapered crown with a darker hatband stripe near the base */
.tyrol-21::after {
  content: '';
  position: absolute;
  left: 50%; bottom: 15%;
  translate: -50% 0;
  width: 60%; height: 84%;
  background: linear-gradient(#3c9a4a, #2c7638 58%, #1f5c2b 58%, #256a31 72%, #2c7638 72%);
  border-radius: 52% 52% 30% 30% / 70% 70% 40% 40%;
  border: 1px solid #1f5c2b;
}
/* Thirty-Four's artist beret — a soft light-yellow cap flopping to one side, a
   green rim band at the base and a little green stalk on top (the canon 31-40
   design; green accents echo his green splatters). Sized inline in px like the
   other el-toppers; tilted a touch so it reads as a beret, not a bowl. */
.beret-34 {
  position: absolute;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  transform: rotate(-11deg);
  transform-origin: 50% 100%;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.3));
  animation: tophat-tip 2.7s ease-in-out infinite;
}
/* the round yellow cap, slightly asymmetric so it droops to the right */
.beret-34::before {
  content: '';
  position: absolute;
  left: 50%; bottom: 12%;
  translate: -50% 0;
  width: 100%; height: 82%;
  background: radial-gradient(120% 120% at 36% 22%, #fff6c4, #f3df67 58%, #e7ce4c);
  border-radius: 56% 62% 50% 58% / 76% 80% 46% 42%;
  border: 1px solid #d9c04a;
}
/* green headband rim hugging the base */
.beret-34::after {
  content: '';
  position: absolute;
  left: 50%; bottom: 0;
  translate: -50% 0;
  width: 66%; height: 26%;
  background: #3c9a4a;
  border-radius: 44% 44% 50% 50% / 34% 34% 92% 92%;
  border: 1px solid #2c7638;
}
/* the little green stalk poking up from the crown */
.beret-34-nub {
  position: absolute;
  left: 46%; top: -6%;
  width: 12%; height: 20%;
  background: #3c9a4a;
  border-radius: 50%;
  box-shadow: 0 0 0 1px #2c7638;
}
/* three rainbow feathers fanning up from the left of the band (colour set inline) */
.tyrol-feather {
  position: absolute;
  left: 20%; bottom: 40%;
  width: 8%; height: 76%;
  border-radius: 60% 60% 50% 50% / 42% 42% 58% 58%;
  transform-origin: 50% 100%;
  z-index: 1;
}

/* Sixty's British custodian police helmet: a tall navy dome with a base band, a
   silver star badge on the front and a little boss on top. Sized inline in px
   (topper lives on el, no --fs). Reuses the tophat-tip bob like the bowler. */
/* Fourteen's dark-green skate helmet — a low rounded dome (::after) sitting on a
   brow band (::before), with two neon-green stripes over the top. Sized in px on
   el like the cop helmet; tips gently with the same idle animation. */
.skate-helmet-14 {
  position: absolute;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  transform-origin: 50% 100%;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.35));
  animation: tophat-tip 2.7s ease-in-out infinite;
}
/* brow band around the base */
.skate-helmet-14::before {
  content: '';
  position: absolute;
  left: 50%; bottom: 3%;
  translate: -50% 0;
  width: 94%; height: 32%;
  background: linear-gradient(#164a24, #0d2f16);
  border-radius: 46% 46% 46% 46% / 88% 88% 60% 60%;
}
/* the rounded dome */
.skate-helmet-14::after {
  content: '';
  position: absolute;
  left: 50%; bottom: 13%;
  translate: -50% 0;
  width: 100%; height: 94%;
  background: radial-gradient(125% 135% at 50% 92%, #2f9448 0 40%, #1d6631 72%, #144a20);
  border-radius: 50% 50% 42% 42% / 76% 76% 40% 40%;
}
/* two neon-green stripes running front-to-back over the dome */
.helmet-stripe-14 {
  position: absolute;
  bottom: 15%;
  width: 12%; height: 84%;
  background: linear-gradient(#8dffab, #33e463);
  border-radius: 45% 45% 40% 40%;
  box-shadow: 0 0 3px rgba(95, 255, 145, 0.6);
  z-index: 2;
}
.helmet-stripe-14.stripe-l { left: 36%; }
.helmet-stripe-14.stripe-r { left: 52%; }

/* Fourteen's canon rainbow skateboard, riding just under his feet — a rainbow
   deck with kicked-up nose/tail and two dark wheels. Decorative sibling of the
   block; sits below the base so the block reads as standing on it. */
.skateboard-14 {
  position: absolute;
  left: 50%;
  bottom: -12%;
  translate: -50% 0;
  pointer-events: none;
  z-index: 3;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.32));
  animation: tophat-tip 3.1s ease-in-out infinite;
}
.skate-deck-14 {
  position: absolute;
  left: 0; top: 0;
  width: 100%; height: 46%;
  background: linear-gradient(90deg,
    #e6352b 0 14%, #f5a03c 14% 30%, #f7e14a 30% 46%,
    #4fb95a 46% 62%, #3f8fd6 62% 78%, #7c4bc4 78% 100%);
  border-radius: 40% 40% 46% 46% / 70% 70% 100% 100%;
  box-shadow: inset 0 -2px 2px rgba(0, 0, 0, 0.18);
}
.skate-wheel-14 {
  position: absolute;
  bottom: 0;
  width: 20%; height: 46%;
  background: radial-gradient(circle at 40% 35%, #4a4a52 0 45%, #232327 80%);
  border-radius: 50%;
  z-index: -1;
}
.skate-wheel-14.wheel-l { left: 16%; }
.skate-wheel-14.wheel-r { right: 16%; }

/* Eleven's soccer ball, resting at her feet beside the block — a classic
   black-and-white ball: white sphere shaded from top-left, five dark edge
   patches around the rim plus one central pentagon, so it reads as a football.
   Decorative sibling of the block; sits low and to the side like a ball she's
   just standing next to. */
.soccer-11 {
  position: absolute;
  bottom: -8%;
  right: -16%;
  border-radius: 50%;
  pointer-events: none;
  z-index: 3;
  background:
    radial-gradient(circle at 50% 12%, #1c1c1c 0 9%, transparent 10%),
    radial-gradient(circle at 86% 42%, #1c1c1c 0 9%, transparent 10%),
    radial-gradient(circle at 72% 84%, #1c1c1c 0 9%, transparent 10%),
    radial-gradient(circle at 28% 84%, #1c1c1c 0 9%, transparent 10%),
    radial-gradient(circle at 14% 42%, #1c1c1c 0 9%, transparent 10%),
    radial-gradient(circle at 36% 30%, #ffffff 0 60%, #dcdce0 100%);
  border: 1px solid #b8b8bf;
  box-shadow: 0 2px 3px rgba(0, 0, 0, 0.32), inset -2px -3px 4px rgba(0, 0, 0, 0.14);
  animation: tophat-tip 3.1s ease-in-out infinite;
}
.soccer-11-patch {
  position: absolute;
  left: 50%; top: 50%;
  width: 34%; height: 34%;
  translate: -50% -50%;
  background: #1c1c1c;
  clip-path: polygon(50% 0, 100% 38%, 82% 100%, 18% 100%, 0 38%);
}

/* Twenty-Six's black sunglasses — two dark lenses joined by a bridge, riding
   over the eyes (z-index above them, so the shades cover the eyes). Scale with
   --fs since they live in the face. */
.shades-26 {
  position: absolute;
  top: -2%;
  left: 0;
  width: 100%;
  height: 44%;
  z-index: 4;
  pointer-events: none;
}
.shade-lens {
  position: absolute;
  top: 0;
  width: calc(var(--fs) * 0.33);
  height: calc(var(--fs) * 0.31);
  background: radial-gradient(circle at 32% 26%, #4a4a52 0 20%, #141418 62%);
  border: 1px solid #000;
  border-radius: 46% 46% 52% 52%;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
.shade-left  { left: 2%; }
.shade-right { right: 2%; }
.shade-bridge {
  position: absolute;
  top: 12%; left: 50%;
  translate: -50% 0;
  width: calc(var(--fs) * 0.18);
  height: calc(var(--fs) * 0.06);
  background: #141418;
  border-radius: 2px;
}

/* Numberblock Twenty's round purple glasses (2×10 — echoes Two's spectacles). Two
   see-through round frames joined by a bridge over the eyes, in Two's exact
   glasses purple. Built in block.js; positive 20 only. */
.specs-20 {
  position: absolute;
  top: -2%;
  left: 0;
  width: 100%;
  height: 44%;
  z-index: 4;
  pointer-events: none;
}
.spec-lens {
  position: absolute;
  top: 0;
  width: calc(var(--fs) * 0.33);
  height: calc(var(--fs) * 0.33);
  background: rgba(255, 255, 255, 0.14);
  border: calc(var(--fs) * 0.05) solid #7b3ff2;
  border-radius: 50%;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.32);
}
.spec-left  { left: 1%; }
.spec-right { right: 1%; }
.spec-bridge {
  position: absolute;
  top: 20%; left: 50%;
  translate: -50% 0;
  width: calc(var(--fs) * 0.2);
  height: calc(var(--fs) * 0.055);
  background: #7b3ff2;
  border-radius: 2px;
}
/* Thirty-Five reuses Twenty's specs shape/position but in blue (canon blue glasses,
   not Two's purple) — recolour the lens rims and bridge to Five's cyan-blue. */
.specs-35 .spec-lens { border-color: #00b6e9; }
.specs-35 .spec-bridge { background: #00b6e9; }

/* Twenty's white collar bowtie — sits just below the face (the chin/neckline),
   two rounded white wings around a small knot, sized off the face's --fs so it
   scales with the block. Anchored to the face so it stays put like the specs. */
.bowtie20 {
  position: absolute;
  left: 50%;
  bottom: calc(var(--fs) * -0.34);
  translate: -50% 0;
  display: flex;
  align-items: center;
  z-index: 4;
  pointer-events: none;
  filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.3));
}
.bowtie20-wing {
  width: calc(var(--fs) * 0.2);
  height: calc(var(--fs) * 0.26);
  background: #ffffff;
  border: calc(var(--fs) * 0.035) solid #c9d2e6;
  box-sizing: border-box;
}
.bowtie20-l { rotate: -14deg; border-radius: 45% 10% 10% 45% / 50%; }
.bowtie20-r { rotate: 14deg; border-radius: 10% 45% 45% 10% / 50%; }
.bowtie20-knot {
  width: calc(var(--fs) * 0.09);
  height: calc(var(--fs) * 0.16);
  margin: 0 calc(var(--fs) * -0.02);
  background: #eef2fb;
  border: calc(var(--fs) * 0.02) solid #c9d2e6;
  border-radius: 2px;
  z-index: 2;
}

/* Twenty-Six's black tie — completes the bowler + shades look. A small dark knot
   at the neckline with a downward-hanging blade, sized off the face's --fs so it
   scales with the block. Anchored to the face like the bowtie; positive 26 only. */
/* Sits clear BELOW the mouth with a white shirt collar around the knot —
   numberblocks26 (07-30 and 10-13): "there should be a white collar with the
   tie, and the tie shouldnt be that close to the mouth". */
.tie26 {
  position: absolute;
  left: 50%;
  bottom: calc(var(--fs) * -0.6);
  translate: -50% 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  z-index: 4;
  pointer-events: none;
  filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.3));
}
.tie26::before {
  content: '';
  position: absolute;
  top: calc(var(--fs) * -0.03);
  left: 50%;
  translate: -50% 0;
  width: calc(var(--fs) * 0.38);
  height: calc(var(--fs) * 0.13);
  background: #fff;
  clip-path: polygon(0 0, 100% 0, 60% 100%, 50% 70%, 40% 100%);
  z-index: 1;
}
.tie26-knot {
  position: relative;
  width: calc(var(--fs) * 0.12);
  height: calc(var(--fs) * 0.09);
  background: #141418;
  border-radius: 2px 2px 0 0;
  z-index: 2;
}
.tie26-blade {
  position: relative;
  z-index: 0;
  width: calc(var(--fs) * 0.16);
  height: calc(var(--fs) * 0.3);
  margin-top: calc(var(--fs) * -0.01);
  background: #141418;
  clip-path: polygon(50% 100%, 0 18%, 22% 0, 78% 0, 100% 18%);
}

/* Numberblock Seventy-Two's octo-mask (8×9): a grey domino band behind the eyes
   with eight magenta spikes fanning out — four per side — echoing 8×9. Built in
   block.js; positive 72 only. The band sits behind the eyes (z-index -1) so they
   peek through, like 27/56's masks. */
.octomask-72 {
  position: absolute;
  top: -4%;
  left: 50%;
  width: 112%;
  height: 46%;
  transform: translateX(-50%);
  background: linear-gradient(#c9cdd5, #9ba1ab 55%, #7d838e);
  border: 1px solid #6b7079;
  border-radius: 48% 48% 46% 46% / 62% 62% 52% 52%;
  z-index: -1;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  animation: cube-mask-on 0.5s ease-out;
}
/* Four magenta spikes per side, cut from a rectangle with a comb clip-path. The
   flat backs attach to the mask; the points fan outward. */
.octomask-spikes {
  position: absolute;
  top: -6%;
  width: 30%;
  height: 112%;
  background: #e0219a;
  z-index: -1;
}
.octomask-left {
  left: -24%;
  /* points fan left: apexes at x=0%, backs along the right edge (x=100%) */
  clip-path: polygon(
    100% 0%, 0% 12%, 100% 25%,
    100% 25%, 0% 37%, 100% 50%,
    100% 50%, 0% 62%, 100% 75%,
    100% 75%, 0% 88%, 100% 100%);
}
.octomask-right {
  right: -24%;
  /* mirror: points fan right, apexes at x=100% */
  clip-path: polygon(
    0% 0%, 100% 12%, 0% 25%,
    0% 25%, 100% 37%, 0% 50%,
    0% 50%, 100% 62%, 0% 75%,
    0% 75%, 100% 88%, 0% 100%);
}
@keyframes cube-mask-on {
  0%   { opacity: 0; transform: translateX(-50%) scale(0.4); }
  60%  { opacity: 1; transform: translateX(-50%) scale(1.12); }
  100% { opacity: 1; transform: translateX(-50%) scale(1); }
}
/* Eighty's octo-mask in Octoblock purple (72's band is grey) — same eight-spike
   shape, recoloured band. The magenta spikes are shared with 72. */
.octomask-80 {
  background: linear-gradient(#a877ff, #7b3ff2 55%, #5a24c8);
  border-color: #4a1ea8;
}
/* Octoblock's OWN mask (Eight) — no grey domino band, just the spiky tufts
   fanning out around the eyes on his magenta body. Drop 72's band (transparent,
   no border/shadow) and recolour the spikes to his deep tentacle magenta with a
   dark outline so they read as the same limbs' spiky cousins, not 72's bright
   pink comb on grey. The spike geometry is shared with 72/80. */
.octomask-8 {
  background: none;
  border: none;
  box-shadow: none;
}
.octomask-8 .octomask-spikes {
  background: linear-gradient(160deg, #ff4fb4, #91005a);
  filter: drop-shadow(0 0 0.5px #5e003a);
}
/* ":3" — the kitty costume (autumn). Triangular ears perched above the head and
   a curly tail hooking off the side, both tinted to the block's OWN colour via
   the inline --cat-color (read from a rendered cube, so it matches every number,
   anti-block and mutation). Pure cosmetic; rides the persistent body.cat flag so
   it survives reshape / merge / split / mirror. */
.cat-ears {
  position: absolute;
  top: 0;
  translate: -50% -72%;
  display: flex;
  gap: calc(var(--ear) * 0.7);
  pointer-events: none;
  z-index: 6;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.28));
}
.cat-ear {
  width: var(--ear);
  height: var(--ear);
  /* A soft top-lit → shaded sheen over the ear's own colour so it reads as a
     gradient of the block's colour rather than a flat wedge (autumn). */
  background: linear-gradient(155deg, rgba(255, 255, 255, 0.34), rgba(0, 0, 0, 0.16)), var(--cat-color);
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
  position: relative;
}
/* Seven is rainbow everywhere, so a cat Seven's ears + tail stack the rainbow
   (red at the base → violet at the tip), matching Seven's own seven bands. */
.block-cat-rainbow .cat-ear {
  background: linear-gradient(0deg, #e2231a, #ff8300, #ffd100, #7ac143, #00b6e9, #6e5ab0, #9b59b6);
}
.cat-ear-left { transform: rotate(-14deg); }
.cat-ear-right { transform: rotate(14deg); }
.cat-ear-inner {
  position: absolute;
  left: 50%;
  bottom: 8%;
  width: 44%;
  height: 52%;
  translate: -50% 0;
  background: #ff9ecf;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.cat-tail {
  position: absolute;
  right: calc(var(--tail-t) * -0.6);
  bottom: 6%;
  width: var(--tail-w);
  height: var(--tail-h);
  border: var(--tail-t) solid var(--cat-color);
  border-left: none;
  border-bottom: none;
  border-top-right-radius: 100%;
  border-bottom-right-radius: 90%;
  transform: rotate(14deg);
  transform-origin: 50% 100%;
  pointer-events: none;
  z-index: 4;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.22));
  animation: cat-tail-sway 3s ease-in-out infinite;
}
/* Rainbow curl for a cat Seven — the border-drawn tail can't carry a gradient
   stroke cleanly, so redraw it as a filled rainbow curl masked to a matching
   ring. The mask is an annulus (thickness = --tail-t) centred on the LEFT-MIDDLE
   of the box with radius = --tail-w, so it traces the same full C-curl the
   border version draws on the right side. (The old mask centred the circle at
   the top-left corner with farthest-corner sizing, so only a tiny sliver near
   the bottom-right corner showed through — autumn: "why is 7's tail so small".) */
.block-cat-rainbow .cat-tail {
  border: none;
  background: linear-gradient(180deg, #9b59b6, #6e5ab0, #00b6e9, #7ac143, #ffd100, #ff8300, #e2231a);
  -webkit-mask: radial-gradient(circle at 0% 50%, transparent calc(var(--tail-w) - var(--tail-t)), #000 calc(var(--tail-w) - var(--tail-t) + 0.5px), #000 var(--tail-w), transparent calc(var(--tail-w) + 0.5px));
          mask: radial-gradient(circle at 0% 50%, transparent calc(var(--tail-w) - var(--tail-t)), #000 calc(var(--tail-w) - var(--tail-t) + 0.5px), #000 var(--tail-w), transparent calc(var(--tail-w) + 0.5px));
}
.block-cat-multi .cat-tail {
  /* A multi-colour block's tail: the same masked curl as the rainbow Seven, but
     painted with the block's own colours (--cat-grad, set inline). */
  border: none;
  background: var(--cat-grad);
  -webkit-mask: radial-gradient(circle at 0% 50%, transparent calc(var(--tail-w) - var(--tail-t)), #000 calc(var(--tail-w) - var(--tail-t) + 0.5px), #000 var(--tail-w), transparent calc(var(--tail-w) + 0.5px));
          mask: radial-gradient(circle at 0% 50%, transparent calc(var(--tail-w) - var(--tail-t)), #000 calc(var(--tail-w) - var(--tail-t) + 0.5px), #000 var(--tail-w), transparent calc(var(--tail-w) + 0.5px));
}
@keyframes cat-tail-sway {
  0%, 100% { transform: rotate(10deg); }
  50% { transform: rotate(22deg); }
}

/* 🐶 DOG MODE — asked for by SEVEN distinct authors (notes/dog-mode-queued.md).
   It reskins the EXACT SAME ear/tail/face nodes the kitty costume builds, which
   is the whole trick: every rule below sets only geometry and animation and
   NEVER `background`, so a dog inherits all four of the cat's hard-won colour
   paths untouched — the block's own tint via --cat-color, lilac for imaginary
   blocks, the dark anti mirror, and Seven's rainbow ears (.block-cat-rainbow
   .cat-ear still applies, because these selectors don't fight it for the
   background property at all). A rainbow Seven dog works with no rule of its own.
   Floppy lobes hanging BESIDE the head instead of perked triangles above it, and
   a fast happy wag instead of the cat's slow sway — the two silhouette cues that
   read as "dog" at a 40px block size. */
.block-dog .cat-ears {
  /* Drop the pair down the side of the head rather than perching them on top,
     and push them apart so they frame the face like a spaniel's. */
  translate: -50% -34%;
  gap: calc(var(--ear) * 1.45);
}
.block-dog .cat-ear {
  width: calc(var(--ear) * 0.76);
  height: calc(var(--ear) * 1.4);
  clip-path: none; /* drop the cat's upward triangle */
  border-radius: 48% 48% 44% 44% / 58% 58% 42% 42%;
}
/* Rotated well past the cat's 14deg: at ~22deg the rounded lobes still read as a
   RABBIT (up-and-out), and it takes roughly 45deg before they hang beside the
   head like a beagle's. Rendered and compared side by side before picking. */
.block-dog .cat-ear-left { transform: rotate(-46deg); }
.block-dog .cat-ear-right { transform: rotate(46deg); }
.block-dog .cat-ear-inner {
  bottom: 14%;
  width: 46%;
  height: 58%;
  clip-path: none;
  border-radius: 48% 48% 44% 44% / 58% 58% 42% 42%;
}
/* Same curl the cat tail draws (so the rainbow-Seven mask, which is tuned to
   --tail-w/--tail-t, still traces it exactly) — only the wag differs. */
.block-dog .cat-tail {
  animation: dog-tail-wag 0.62s ease-in-out infinite;
}
@keyframes dog-tail-wag {
  0%, 100% { transform: rotate(-4deg); }
  50% { transform: rotate(32deg); }
}
/* The dog muzzle: a nose dot above the smile and a lolling tongue panting below
   it. Ink follows --mouthc like the cat's ω arcs do, so an anti-block's near-black
   body flips the nose light instead of drawing black-on-black (the divinegorilla
   report that fixed the cat mouth applies here for the same reason). The tongue
   stays pink in both — it's a tongue, not ink. Under the same :not([class*="expr-"])
   guard, so picking a mood in 😀 Emotions mode still takes over the mouth. */
.dog-face:not([class*="expr-"]) .mouth {
  width: calc(var(--fs) * 0.34);
  height: calc(var(--fs) * 0.17);
}
.dog-face:not([class*="expr-"]) .mouth::before {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 112%;
  width: calc(var(--fs) * 0.17);
  height: calc(var(--fs) * 0.13);
  translate: -50% 0;
  background: var(--mouthc);
  border-radius: 50%;
}
.dog-face:not([class*="expr-"]) .mouth::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 60%;
  width: calc(var(--fs) * 0.16);
  height: calc(var(--fs) * 0.19);
  translate: -50% 0;
  background: #ff7a9c;
  border-radius: 40% 40% 50% 50% / 20% 20% 80% 80%;
  transform-origin: 50% 0;
  animation: dog-tongue-pant 1.4s ease-in-out infinite;
}
@keyframes dog-tongue-pant {
  0%, 100% { transform: scaleY(1); }
  50% { transform: scaleY(0.72); }
}

/* ":3" cat mouth — two little humps (ω / ◡◡), the shape the emoticon actually
   draws. Reshapes the plain happy mouth into the twin-arc cat smile via two
   pseudo-arcs. Only on a bare cat face: if the player picks an emotion in 😀
   Emotions mode, that mood's own mouth takes over (:not([class*="expr-"])). */
.cat-face:not([class*="expr-"]) .mouth {
  border: none;
  width: calc(var(--fs) * 0.36);
  height: calc(var(--fs) * 0.15);
}
.cat-face:not([class*="expr-"]) .mouth::before,
.cat-face:not([class*="expr-"]) .mouth::after {
  content: "";
  position: absolute;
  top: 0;
  box-sizing: border-box;
  width: 52%;
  height: 100%;
  /* Ink follows --mouthc so anti-blocks (near-black body) flip the ω arcs light
     instead of drawing them near-black-on-near-black — divinegorilla report. */
  border: calc(var(--fs) * 0.06) solid var(--mouthc);
  border-top: none;
  border-radius: 0 0 999px 999px;
}
.cat-face:not([class*="expr-"]) .mouth::before { left: 0; }
.cat-face:not([class*="expr-"]) .mouth::after { right: 0; }
/* Eighty's canon octagon chest badge — a magenta stop-sign octagon rimmed in
   purple, stamped "80". Sits centred on the body below the face (like 42's belt
   buckle). Positive 80 only; the −80 anti inverts via .badge-80 in the negative
   list. */
.badge80-field {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 5;
}
.badge-80 {
  position: absolute;
  left: 50%;
  top: 55%;
  translate: -50% -50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  font-family: inherit;
  font-weight: 800;
  line-height: 1;
  background: #d81b8c;
  border: 2px solid #7b1fa2;
  clip-path: polygon(30% 0%, 70% 0%, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0% 70%, 0% 30%);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
  animation: cube-mask-on 0.5s ease-out;
}
/* Sixty-Three's canon rainbow headband with a grey rainbow arch. The band is a
   thin horizontal rainbow stripe hugging the top of the body (spanning most of
   the width, like a real headband); the arch is a colourless "grey rainbow"
   resting on it, offset to the right, drawn as concentric grey arcs with a small
   coloured heart at its base. Rides on |n| (63); the −63 anti inverts via
   .headband-63 in the negative list. Built in block.js. */
.headband-63 {
  position: absolute;
  left: 6%;
  right: 6%;
  top: 3%;
  height: 13px;
  border-radius: 99px;
  pointer-events: none;
  /* Above the cubes but BELOW the face — on 63's tall 7×9 body the band sits at
     eye level, so at z-index:4 it painted over the eyes ("he blind",
     blandshrine). buildRainbowHeadband63 bumps the face to z-index:2 so the eyes
     read through; the band can't go behind the cubes (top:3% is on the body, so
     z-index:-1 hid it, unlike 72's mask which pokes above the top edge). */
  z-index: 1;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22);
}
.grey-rainbow-63 {
  position: absolute;
  left: 66%;
  bottom: 60%;
  translate: -50% 0;
  border: 5px solid #8d8d95;
  border-bottom: none;
  border-top-left-radius: 999px;
  border-top-right-radius: 999px;
  box-sizing: border-box;
  /* two lighter grey inner rings, then a tiny warm heart at the base centre */
  box-shadow:
    inset 0 0 0 3px #c3c3c9,
    inset 0 0 0 8px #6b6b73;
}
.grey-rainbow-63::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -3px;
  translate: -50% 0;
  width: 5px;
  height: 5px;
  border-radius: 99px;
  background: #e23b3b;
}

/* When Four is woken grumpy (or otherwise cross), the black angry-brow overlay
   takes over — hide his normal brows so the two don't stack. */
.annoyed .brow { display: none; }
/* Same for Six's lashes — let the angry brows read cleanly on a grumpy wake. */
.annoyed .lashes { display: none; }
/* Five is the star Numberblock — give her two little star-shaped eyes. The
   sparkle ::after reads odd on a star silhouette, so suppress it and bump the
   size slightly so the points stay legible at small scales. */
.eye-star {
  border-radius: 0;
  width: calc(var(--fs) * 0.32);
  height: calc(var(--fs) * 0.32);
  top: calc(var(--fs) * -0.02);
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.eye-star::after { display: none; }
@keyframes blink {
  0%, 94%, 100% { transform: scaleY(1); }
  96.5% { transform: scaleY(0.12); }
}
.mouth {
  position: absolute;
  bottom: 0;
  left: 50%;
  translate: -50% 0;
  width: calc(var(--fs) * 0.42);
  height: calc(var(--fs) * 0.22);
  border: calc(var(--fs) * 0.07) solid #1c1c1e;
  border-top: none;
  border-radius: 0 0 999px 999px;
}

/* Hand-drawn block expressions (mrvoid). The default 😊 is the plain .mouth
   above; these swap the mouth shape for the other moods. Kept off the temper
   system — a static drawing wears whatever the artist picked. */
.expr-surprised .mouth {
  width: calc(var(--fs) * 0.28);
  height: calc(var(--fs) * 0.28);
  border: calc(var(--fs) * 0.07) solid var(--mouthc);
  border-radius: 999px;
}
.expr-sad .mouth {
  height: calc(var(--fs) * 0.16);
  border: calc(var(--fs) * 0.07) solid var(--mouthc);
  border-bottom: none;
  border-radius: 999px 999px 0 0;
}
.expr-angry .mouth {
  width: calc(var(--fs) * 0.32);
  height: calc(var(--fs) * 0.12);
  border: calc(var(--fs) * 0.07) solid var(--mouthc);
  border-bottom: none;
  border-radius: 999px 999px 0 0;
}
.expr-silly .mouth {
  background: var(--mouthc);
  overflow: visible;
}
/* Silly's lolling tongue pokes out below the smile. */
.expr-silly .mouth::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: calc(var(--fs) * -0.10);
  translate: -50% 0;
  width: calc(var(--fs) * 0.20);
  height: calc(var(--fs) * 0.16);
  background: #ff6f8f;
  border-radius: 0 0 999px 999px;
}
/* Angry eyebrows — slanted into a V. Real divs (block.js) so they never clash
   with the temper .annoyed brows, which are hidden here if both ever apply. */
.expr-brow {
  position: absolute;
  top: calc(var(--fs) * -0.02);
  width: calc(var(--fs) * 0.32);
  height: calc(var(--fs) * 0.09);
  background: var(--mouthc);
  border-radius: 999px;
}
.expr-brow-left  { left: 0;  rotate: 20deg; }
.expr-brow-right { right: 0; rotate: -20deg; }
/* One-eyed faces (One, 100, Fraction Friends): the pair joins into a single
   brow over the one eye — the moods below still tilt each half. */
.face-one-eye .expr-brow { width: calc(var(--fs) * 0.22); }
.face-one-eye .expr-brow-left  { left: calc(50% - var(--fs) * 0.19); }
.face-one-eye .expr-brow-right { left: calc(50% - var(--fs) * 0.03); right: auto; }
.annoyed .expr-brow { display: none; }
/* The expression brows replace a number's canon brows (Four's green pair) —
   otherwise angry Four shows four eyebrows. Flag set in applyCustomExpr. */
.expr-has-brow .brow { display: none; }
/* …but a number with canon brows (Four's green squares, Nine's slate bars, 39,
   40s…) keeps them and wears the mood on THEM (divinegorilla7160575: "Change
   Black eyebrow to 4's eyebrows and 9's eyebrows"). The black pair is hidden and
   each canon brow is tilted/lowered to match the mood's slant. */
.expr-canon-brow .expr-brow { display: none; }
.expr-canon-brow.expr-angry .brow-left,
.expr-canon-brow.expr-devious .brow-left  { rotate: 20deg;  translate: 0 calc(var(--fs) * 0.12); }
.expr-canon-brow.expr-angry .brow-right,
.expr-canon-brow.expr-devious .brow-right { rotate: -20deg; translate: 0 calc(var(--fs) * 0.12); }
.expr-canon-brow.expr-determined .brow-left  { rotate: 9deg;  translate: 0 calc(var(--fs) * 0.14); }
.expr-canon-brow.expr-determined .brow-right { rotate: -9deg; translate: 0 calc(var(--fs) * 0.14); }
.expr-canon-brow.expr-thinking .brow-left  { rotate: 0deg;  translate: 0 calc(var(--fs) * -0.06); }
.expr-canon-brow.expr-thinking .brow-right { rotate: -8deg; translate: 0 calc(var(--fs) * 0.10); }
.expr-canon-brow.expr-amazed .brow { translate: 0 calc(var(--fs) * -0.08); }
/* 🤨 dress-up brows: replace any canon pair and read as a dark, cocked brow on
   every colour (Four's green squares vanished on Four himself). */
.dress-brows .brow:not([data-dress]) { display: none; }
.dress-brows .brow[data-dress] .brow-seg { background: var(--mouthc, #2b2b2b); }
.dress-brows .brow-right[data-dress] { translate: 0 calc(var(--fs) * -0.12); rotate: -10deg; }
/* One-eyed faces (One, 100, the Fraction Friends…) wear ONE 🤨 brow, raised and
   centred over the eye — a pair out at the corners read as two brows on one eye
   (cutecliff8957658 10-15). */
.face-one-eye:not(.expr-canon-brow) .brow-left[data-dress] { display: none; }
.face-one-eye:not(.expr-canon-brow) .brow-right[data-dress] { right: auto; left: calc(50% - var(--fs) * 0.19); }

/* Determined (EveryGameIsCoolAlt) — a firm flat mouth under lowered, focused
   brows. Shallower slant than angry's steep V so the two read apart, and no
   open frown; it's the "I've got this" set jaw. */
.expr-determined .mouth {
  width: calc(var(--fs) * 0.30);
  height: 0;
  border: none;
  border-top: calc(var(--fs) * 0.08) solid var(--mouthc);
  border-radius: 999px;
}
.expr-determined .expr-brow-left  { rotate: 9deg;  top: calc(var(--fs) * 0.03); }
.expr-determined .expr-brow-right { rotate: -9deg; top: calc(var(--fs) * 0.03); }

/* Scared (EveryGameIsCoolAlt) — a small wobbly open mouth and one blue sweat
   bead beading off the top corner. */
.expr-scared .mouth {
  width: calc(var(--fs) * 0.20);
  height: calc(var(--fs) * 0.24);
  border: calc(var(--fs) * 0.06) solid var(--mouthc);
  border-radius: 45% 45% 50% 50%;
}
.expr-sweat {
  position: absolute;
  top: calc(var(--fs) * 0.04);
  right: calc(var(--fs) * -0.02);
  width: calc(var(--fs) * 0.11);
  height: calc(var(--fs) * 0.16);
  background: #7ec8ff;
  border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
  rotate: 10deg;
}

/* Crying (EveryGameIsCoolAlt) — a downturned wailing frown with two tears
   streaming down from under the eyes. NOTE: the frown curves DOWN, so it must
   drop the BOTTOM border and round the TOP corners (border-radius 999px 999px
   0 0) — same idiom as .expr-sad. The happy default .mouth does the opposite
   (border-top:none / 0 0 999px 999px); copying that by mistake made crying
   smile (NineTheUser, 2026-07-26). Wider/taller than sad so it reads as a wail. */
.expr-crying .mouth {
  width: calc(var(--fs) * 0.26);
  height: calc(var(--fs) * 0.20);
  border: calc(var(--fs) * 0.07) solid var(--mouthc);
  border-bottom: none;
  border-radius: 999px 999px 0 0;
}
.expr-tear {
  position: absolute;
  top: calc(var(--fs) * 0.28);
  width: calc(var(--fs) * 0.10);
  height: calc(var(--fs) * 0.16);
  background: #7ec8ff;
  border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
  animation: expr-tear-fall 1.6s ease-in-out infinite;
}
.expr-tear-left  { left: calc(var(--fs) * 0.15); }
.expr-tear-right { right: calc(var(--fs) * 0.15); animation-delay: 0.5s; }
@keyframes expr-tear-fall {
  0%   { transform: translateY(0); opacity: 0.35; }
  45%  { opacity: 1; }
  100% { transform: translateY(calc(var(--fs) * 0.20)); opacity: 0; }
}

/* Excited (VerityTheUser, 🤩) — star-struck eyes and a big open grin. The eyes
   become gold stars (reusing Five's star clip-path) and lose the round pupil
   sparkle; the mouth opens into a wide happy beam. Works on any eye count since
   it just restyles whatever .eye elements the face has. */
.expr-excited .eye {
  border-radius: 0;
  background: #ffd23c;
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.expr-excited .eye::after { display: none; }
.expr-excited .mouth {
  width: calc(var(--fs) * 0.34);
  height: calc(var(--fs) * 0.26);
  background: var(--mouthc);
  border: none;
  border-radius: 28% 28% 55% 55% / 22% 22% 92% 92%;
}

/* Loving (VerityTheUser, 😘) — a puckered kiss, a happy wink, and a little heart
   drifting up beside the head. The kiss is a small pink pucker; the right eye
   winks shut (only 2-eye faces have .eye-right, others just keep both open). */
.expr-loving .mouth {
  width: calc(var(--fs) * 0.16);
  height: calc(var(--fs) * 0.14);
  background: #ff6f8f;
  border: none;
  border-radius: 60% 60% 50% 50%;
}
.expr-loving .eye-right {
  animation: none;
  transform: scaleY(0.14);
}
.expr-heart {
  position: absolute;
  top: calc(var(--fs) * 0.02);
  right: calc(var(--fs) * -0.10);
  width: calc(var(--fs) * 0.20);
  height: calc(var(--fs) * 0.20);
  animation: expr-heart-float 1.8s ease-in-out infinite;
}
.expr-heart::before,
.expr-heart::after {
  content: '';
  position: absolute;
  top: 0;
  width: 50%;
  height: 80%;
  background: #ff4f7a;
  border-radius: 50% 50% 0 0;
}
.expr-heart::before { left: 50%; transform: rotate(-45deg); transform-origin: 0 100%; }
.expr-heart::after  { left: 0;   transform: rotate(45deg);  transform-origin: 100% 100%; }
@keyframes expr-heart-float {
  0%   { transform: translate(0, 0) scale(0.7); opacity: 0; }
  25%  { opacity: 1; }
  100% { transform: translate(calc(var(--fs) * 0.12), calc(var(--fs) * -0.30)) scale(1); opacity: 0; }
}

/* Neutral (TheSecondaryBlob, 😐) — the deadpan "meh": a plain flat line mouth,
   eyes left as they are. Same flat-bar idiom as determined's mouth but with no
   brows, so it reads as blank/unbothered rather than focused. */
.expr-neutral .mouth {
  width: calc(var(--fs) * 0.30);
  height: 0;
  border: none;
  border-top: calc(var(--fs) * 0.08) solid var(--mouthc);
  border-radius: 999px;
}

/* Devious (TheSecondaryBlob, 😈) — the classic "up to no good" villain grin:
   angry's exact steep-V scowling brows over a broad, open smile (laterainbow
   asked for "😠's eyes and 🙂/😊's mouth" — the literal 😈 recipe). The brow divs
   come from applyCustomExpr (shared with angry/determined); here they tilt to
   angry's full 20° inner-corner-DOWN scowl. The mouth falls through to the plain
   default .mouth (the happy outlined smile) — no override needed. This stays
   clearly distinct: from angry, which shares the brows but wears the OPPOSITE
   mouth (an upside-down frown), and from happy, which has no brows at all. A
   villain's furrowed brow over a gleeful open grin reads unmistakably scheming.
   (Earlier versions read as worried/confused (😟+😕), which laterainbow & oldsugar
   flagged; the interim used a small filled blob grin + shallower 15° brows.) */
.expr-devious .expr-brow-left  { rotate: 20deg; }
.expr-devious .expr-brow-right { rotate: -20deg; }

/* Squinting (Lpn201898, 😑) — the deadpan/unimpressed "expressionless" look:
   both eyes squeeze down to thin slits and the mouth flattens to a plain line.
   Like neutral's flat mouth, but with the eyes half-shut so it reads as skeptical
   rather than merely blank. Mirrors loving's wink idiom (animation:none + scaleY)
   but squints BOTH eyes; works on any eye count since it just scales whatever
   .eye elements the face has. */
.expr-squinting .eye {
  animation: none;
  transform: scaleY(0.3);
}
.expr-squinting .mouth {
  width: calc(var(--fs) * 0.26);
  height: 0;
  border: none;
  border-top: calc(var(--fs) * 0.08) solid var(--mouthc);
  border-radius: 999px;
}

/* Blush (Lpn201898, 😳) — a bashful look: two soft rosy cheeks glow on the sides
   of the face (real divs from applyCustomExpr, like tears/hearts) paired with a
   small shy smile. The cheeks are the signature; they sit on the eye line's
   lower edge, out toward the block's sides. Works on any eye count since they
   attach to the face, not the eyes. */
.expr-cheek {
  position: absolute;
  top: calc(var(--fs) * 0.30);
  width: calc(var(--fs) * 0.20);
  height: calc(var(--fs) * 0.13);
  background: radial-gradient(closest-side, rgba(255, 122, 148, 0.85), rgba(255, 122, 148, 0));
  border-radius: 50%;
  pointer-events: none;
}
.expr-cheek-left  { left: calc(var(--fs) * -0.02); }
.expr-cheek-right { right: calc(var(--fs) * -0.02); }
/* Sick (faintwood6935456, 🤢) — the blush cheeks go green, the eyes droop
   half-shut and the mouth turns into a queasy little frown. */
.expr-sick .expr-cheek {
  width: calc(var(--fs) * 0.30);
  height: calc(var(--fs) * 0.22);
  background: radial-gradient(closest-side, rgba(90, 200, 40, 1), rgba(90, 200, 40, 0));
}
.expr-sick .eye {
  animation: none;
  transform: scaleY(0.5);
}
.expr-sick .mouth {
  width: calc(var(--fs) * 0.32);
  height: calc(var(--fs) * 0.14);
  background: none;
  border: none;
  border-top: calc(var(--fs) * 0.07) solid var(--mouthc);
  border-radius: 50% 50% 0 0;
}
.expr-blush .mouth {
  width: calc(var(--fs) * 0.24);
  height: calc(var(--fs) * 0.13);
  border-width: calc(var(--fs) * 0.06);
}

/* Smirk (Lpn201898, 😏) — the cool, knowing half-smile: one mouth corner curls up
   while the other stays flat, and the eyes drop to a relaxed half-lid. Distinct
   from devious (no villain brows, subtler tilt) and squinting (eyes only half-shut,
   not slits), it reads as smug/confident rather than scheming or deadpan. The mouth
   keeps the base centering (only `rotate` + a small `margin-left` shift, both compose
   with the `translate` centering) so it curls off to one side. Works on any eye count
   since it just scales whatever .eye elements the face has. */
.expr-smirk .eye {
  animation: none;
  transform: scaleY(0.62);
}
.expr-smirk .mouth {
  width: calc(var(--fs) * 0.28);
  height: calc(var(--fs) * 0.14);
  background: none;
  border: none;
  border-bottom: calc(var(--fs) * 0.07) solid var(--mouthc);
  border-radius: 0 0 90% 12% / 0 0 100% 30%;
  rotate: -13deg;
  margin-left: calc(var(--fs) * 0.05);
}

/* Thinking (betterpalace2353155, 🤔) — the pondering "hmm…" look. The brows go
   asymmetric (shared expr-brow divs from applyCustomExpr): the left lifts into a
   flat quizzical arch while the right settles low with a small inward furrow, and
   the mouth pinches to a short line pressed off to one side. Distinct from neutral
   (level brows, centred flat line), smirk (upward curl + half-lid eyes) and
   squinting (eye slits): here the eyes stay normal and the raised-brow + pursed
   off-centre mouth carry the "still deciding" read. */
.expr-thinking .expr-brow-left  { rotate: 0deg;  top: calc(var(--fs) * -0.10); }
.expr-thinking .expr-brow-right { rotate: -8deg; top: calc(var(--fs) * 0.05); }
.expr-thinking .mouth {
  width: calc(var(--fs) * 0.18);
  height: 0;
  border: none;
  border-top: calc(var(--fs) * 0.08) solid var(--mouthc);
  border-radius: 999px;
  rotate: -8deg;
  margin-left: calc(var(--fs) * 0.09);
}

/* Laughing (shyleaf0928724 + Roameris9, 😂) — a big belly-laugh: both eyes scrunch
   shut into happy upward arcs (∩), the mouth flies open into a wide guffaw, and two
   "tears of joy" (the shared .expr-tear divs from applyCustomExpr, same as crying)
   squeeze out. Distinct from crying (downturned frown + round open eyes) and excited
   (star eyes, no tears): here the closed arc-eyes + open grin + tears read as howling
   with laughter. Eyes become arcs by dropping the fill for a top-only rounded border,
   so it works on any eye count; #1c1c1e matches the base eye ink. */
.expr-laughing .eye {
  animation: none;
  background: none;
  border: calc(var(--fs) * 0.055) solid #1c1c1e;
  border-bottom: none;
  border-radius: 999px 999px 0 0;
  height: calc(var(--fs) * 0.16);
}
.expr-laughing .eye::after { display: none; }
.expr-laughing .mouth {
  width: calc(var(--fs) * 0.36);
  height: calc(var(--fs) * 0.28);
  background: var(--mouthc);
  border: none;
  border-radius: 22% 22% 50% 50% / 16% 16% 94% 94%;
}

/* Amazed (numberblocks26, 😃) — the star-struck cousin of Excited *without* the
   star eyes: plain wide-open round eyes under high, flat "wow!" brows and the
   same big open beaming grin. Reads as thrilled/amazed and reads clearly apart
   from 🤩 Excited (star eyes) and 😮 Surprised (small round O mouth). Leaves the
   eye colour/shape alone so it works on any eye count; just widens them a touch
   for the wide-eyed look. */
.expr-amazed .eye {
  width: calc(var(--fs) * 0.30);
  height: calc(var(--fs) * 0.34);
}
.expr-amazed .expr-brow-left  { rotate: 0deg; top: calc(var(--fs) * -0.12); }
.expr-amazed .expr-brow-right { rotate: 0deg; top: calc(var(--fs) * -0.12); }
.expr-amazed .mouth {
  width: calc(var(--fs) * 0.34);
  height: calc(var(--fs) * 0.26);
  background: var(--mouthc);
  border: none;
  border-radius: 28% 28% 55% 55% / 22% 22% 92% 92%;
}

/* Talking (Lpn201898) — while a block has a say-mode speech bubble up, its mouth
   chatters open and shut so it looks like it's actually saying the line. Fills
   the smile arc dark and pumps it from the bottom lip; uses `transform` so it
   composes with the base `.mouth` `translate` centering (separate property).
   Sits after every `.expr- .mouth` rule so a custom-expr block talks too while a
   bubble is up (equal specificity → source order wins). Blocks with no .mouth
   (Zero, numberling-only faces) simply match nothing — graceful no-op. */
.saying .mouth {
  background: var(--mouthc);
  border: none;
  border-radius: 45% 45% 50% 50% / 32% 32% 90% 90%;
  transform-origin: center 88%;
  animation: nb-talk 0.34s ease-in-out infinite;
  /* The flat-line moods (😐 neutral, 😑 squinting, 🤔 thinking, determined) draw
     their mouth as height:0 + a border-top line. `border:none` above strips that
     line, so without a floor the mouth would vanish entirely while talking
     (Base25Playground: emotion + 💬Say hid the mouth). min-height gives those a
     fillable talking box; taller moods keep their own height (0-regress). */
  min-height: calc(var(--fs) * 0.16);
}
@keyframes nb-talk {
  0%, 100% { transform: scaleY(0.30); }
  50%      { transform: scaleY(1.12); }
}
@media (prefers-reduced-motion: reduce) {
  /* Hold the mouth slightly open instead of chattering — still reads as talking. */
  .saying .mouth { animation: none; transform: scaleY(0.7); }
}

/* Zero, the hero — canon design: a bold black "0" numberling and a big foxy-pink
   mouth, no eyes, gently floating. The ring is her whole body (see block.js). */
.cube-zero {
  position: absolute;
  left: 50%;
  top: 1px;
  translate: -50% 0;
  width: 27px;
  height: 33px;
  border: 8px solid #161616;
  border-radius: 999px;
  box-sizing: border-box;
  background: transparent;
  animation: zero-float 3.2s ease-in-out infinite;
}
.face-zero {
  top: auto;
  bottom: -3px;
  height: auto;
  animation: zero-float 3.2s ease-in-out infinite;
}
.face-zero .mouth {
  position: absolute;
  left: 50%;
  bottom: 0;
  translate: -50% 0;
  width: calc(var(--fs) * 0.72);
  height: calc(var(--fs) * 0.4);
  border: none;
  background: #ef7d86;
  border-radius: 34% 34% 50% 50% / 24% 24% 100% 100%;
  box-shadow: inset 0 calc(var(--fs) * -0.05) 0 rgba(0, 0, 0, 0.18);
}
@keyframes zero-float {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-3px); }
}
/* Zero drifts in a little translucent — the number that's there and not there,
   a gentle ghost to match her 👻 (Alpizza's idea, on-canon: Zero is "really
   something"). Kept high enough that her bold ring stays clearly readable. */
.block-zero { opacity: 0.72; }
/* No eyes to droop, so at night Zero just keeps her soft mouth and floats.
   Scoped to #playground so the night-nap only touches sandbox blocks, never a
   block preview shown in the ★ collection / My Blocks / fullscreen overlay
   (those live outside #playground). Otherwise a preview opened at night dozes
   off a few seconds later — jonasbadpony999's collection-sleep bug. */
html.night.sleeping #playground .face-zero .mouth {
  height: calc(var(--fs) * 0.18);
}
/* Zero has no eyes or eyebrows in canon — just a floating ring and a soft mouth.
   The .annoyed angry-brow overlay anchors its brows to the face TOP, but Zero's
   face is bottom-anchored (bottom: -3px), so a wall bonk dropped her "eyebrows"
   down onto the floor (redclover). She's the unflappable one anyway, so suppress
   the angry brows on Zero and keep her calm pink mouth instead of the frown line. */
.annoyed .face-zero::before,
.annoyed .face-zero::after { display: none; }
.annoyed .face-zero .mouth {
  border: none;
  border-radius: 34% 34% 50% 50% / 24% 24% 100% 100%;
  width: calc(var(--fs) * 0.72);
  height: calc(var(--fs) * 0.4);
  bottom: 0;
}

/* Sleepy night: when the playground sits untouched in night mode (html.night.
   sleeping, toggled from the frame loop after SLEEP_AFTER seconds idle), the
   blocks doze — eyes droop shut, mouths soften, and a soft 💤 drifts up. Any
   tap or spawn removes .sleeping and they wake. Gated on idle so it never
   interferes with active play. */
html.night.sleeping #playground .eye {
  animation: none;
  transform: scaleY(0.12);
}
html.night.sleeping #playground .mouth {
  height: calc(var(--fs) * 0.09);
  width: calc(var(--fs) * 0.24);
  transition: height 0.5s ease, width 0.5s ease;
}
html.night.sleeping #playground .face::after {
  content: '💤';
  position: absolute;
  top: calc(var(--fs) * -0.55);
  right: calc(var(--fs) * -0.55);
  font-size: calc(var(--fs) * 0.5);
  opacity: 0;
  pointer-events: none;
  animation: napz 3.6s ease-in-out infinite;
  animation-delay: var(--nap-delay, 0s);
}
@keyframes napz {
  0%   { opacity: 0; transform: translateY(0) scale(0.6); }
  35%  { opacity: 0.9; }
  70%  { opacity: 0.5; }
  100% { opacity: 0; transform: translateY(calc(var(--fs) * -1)) scale(1.05); }
}
/* A little sleep mask for a good night's rest (united_airlines' idea): while the
   blocks doze at night, a soft padded eye-mask fades in over their (already shut)
   eyes, with a thin strap running off past the face to the block sides. Two
   background layers on one pseudo-element — a wide rounded pad (radial) covering
   both wide-set eyes, and a full-width strap band (linear). Zero has no eyes, so
   she's excluded (her mouth still softens). Pure cosmetic, only ever visible in
   the idle night-nap state, and fades away the instant a tap/spawn wakes them.
   Opt-out via the 😴 Settings toggle (adds html.no-mask), for players who'd
   rather watch the faces doze bare (united_airlines + innerbubble asks). */
html.night.sleeping:not(.no-mask) #playground .face:not(.face-zero)::before {
  content: '';
  position: absolute;
  z-index: 6;
  left: 50%;
  top: calc(var(--fs) * -0.02);
  translate: -50% 0;
  width: 132%;
  height: calc(var(--fs) * 0.42);
  pointer-events: none;
  border-radius: calc(var(--fs) * 0.2);
  background:
    radial-gradient(ellipse 39% 150% at 50% 50%, #5a5280 0 60%, rgba(90, 82, 128, 0) 63%),
    linear-gradient(#403a5c 0 100%);
  background-size: 100% 100%, 100% 32%;
  background-position: center, center;
  background-repeat: no-repeat;
  opacity: 0;
  animation: mask-on 0.6s ease 0.2s forwards;
}
@keyframes mask-on {
  from { opacity: 0; transform: translateY(calc(var(--fs) * -0.15)); }
  to   { opacity: 1; transform: translateY(0); }
}

/* anti-blocks (negative numbers): dark body, so the face is drawn in light,
   and the mouth is a flat neutral line — the calm "opposite" of a number. */
.block-negative .eye { background: #eef0f6; }
.block-negative .eye::after { background: #1b1b22; }
.block-negative .mouth {
  border: none;
  border-top: calc(var(--fs) * 0.07) solid #eef0f6;
  border-radius: 0;
  height: 0;
  bottom: calc(var(--fs) * 0.07);
}
/* An anti-block dragged over the 🗑️ WANTS oblivion (r265 cheer). Its usual flat
   line curves up into a real light-drawn grin so the "Bin me!" glee shows on its
   face, not just in the bubble (webaisimul's ask). Mirrors the default smile in
   the anti palette; cleared the moment it leaves the bin. */
.block-negative.trash-glad .mouth {
  border: calc(var(--fs) * 0.07) solid #eef0f6;
  border-top: none;
  border-radius: 0 0 999px 999px;
  height: calc(var(--fs) * 0.22);
  bottom: 0;
}
/* A poked anti-prime GIGGLES instead of huffing (legobren2026's inversion of the
   proud-prime temper). The glee ladder's last rung literally promises "Poke all
   you want — −7 will just keep smiling!", but the anti mouth above is a flat
   deadpan line by design, so the face kept calling the toast a liar —
   Tropical_Studios filmed a −7 asking "do i look like i'm smiling?" over exactly
   that white bar. Same light-drawn curve as .trash-glad; the two-class scope
   outranks .block-negative .mouth regardless of source order. */
.block-negative.gleeful .mouth {
  border: calc(var(--fs) * 0.07) solid #eef0f6;
  border-top: none;
  border-radius: 0 0 999px 999px;
  height: calc(var(--fs) * 0.22);
  bottom: 0;
}
/* Glee 3+ — the giggle tips over into an open laugh, so the ladder's climb is
   something you can SEE and not only read. Filled, like the −67 grin below. */
.block-negative.gleeful[data-glee="3"] .mouth,
.block-negative.gleeful[data-glee="4"] .mouth,
.block-negative.gleeful[data-glee="5"] .mouth {
  border: none;
  background: #eef0f6;
  width: calc(var(--fs) * 0.5);
  height: calc(var(--fs) * 0.3);
  border-radius: 0 0 calc(var(--fs) * 0.4) calc(var(--fs) * 0.4);
}
/* −67 reads "six seven" too (it glows, hops and chants like +67), so it keeps
   the goofy open grin instead of the flat anti-line — drawn in the light face
   palette so it shows on the dark anti body (mirrors trash-glad). Two-class
   scope outranks .block-negative .mouth regardless of source order. */
.block-negative.block-sixseven .mouth {
  border: none;
  width: calc(var(--fs) * 0.62);
  height: calc(var(--fs) * 0.34);
  border-radius: 0 0 calc(var(--fs) * 0.4) calc(var(--fs) * 0.4);
  background: #eef0f6;
  bottom: 0;
}

/* One anger-tick from blowing up: a block pushed to ANGER_LIMIT-1 (Tris's
   temper meter) pulses an angry red so the coming explosion feels earned. */
.block.fuming { animation: fuming 0.5s ease-in-out infinite; }
@keyframes fuming {
  0%, 100% { filter: none; }
  50% { filter: drop-shadow(0 0 8px #ff3b3b) brightness(1.08); }
}

/* A prime poked over and over in × mode (you can't divide it) loses its temper:
   lowered brows, a frown, a cross little shake, an anger vein at the boil. */
.annoyed-shake { animation: annoyed-shake 0.34s ease; }
@keyframes annoyed-shake {
  0%, 100% { translate: 0 0; rotate: 0deg; }
  15% { translate: -3px 0; rotate: -4deg; }
  35% { translate:  3px 0; rotate:  4deg; }
  55% { translate: -3px 0; rotate: -3deg; }
  75% { translate:  2px 0; rotate:  2deg; }
}

/* Prime Titan finale (baremammoth's idea). A prime poked far past the end of its
   temper swells up (scale driven in JS so it rides the position transform) and
   turns menacing while the whole playground quakes. A tiny scale is baked into
   the quake keyframe so the shake never bares a gap at the screen edges. */
.block.titan-block {
  z-index: 40;
  filter: brightness(0.82) contrast(1.18) drop-shadow(0 0 16px rgba(150, 12, 12, 0.62));
}
body.quake { animation: quake 0.42s linear 0s 5; }
@keyframes quake {
  0%, 100% { transform: translate(0, 0) scale(1.015); }
  10% { transform: translate(-7px, 4px) scale(1.015); }
  20% { transform: translate( 8px, -5px) scale(1.015); }
  30% { transform: translate(-9px, 3px) scale(1.015); }
  40% { transform: translate( 7px, 5px) scale(1.015); }
  50% { transform: translate(-6px, -4px) scale(1.015); }
  60% { transform: translate( 7px, 4px) scale(1.015); }
  70% { transform: translate(-5px, -3px) scale(1.015); }
  80% { transform: translate( 5px, 3px) scale(1.015); }
  90% { transform: translate(-3px, 2px) scale(1.015); }
}
.annoyed .face::before,
.annoyed .face::after {
  content: '';
  position: absolute;
  top: calc(var(--fs) * -0.05);
  width: calc(var(--fs) * 0.30);
  height: calc(var(--fs) * 0.085);
  background: #1c1c1e;
  border-radius: 3px;
  z-index: 1;
}
.annoyed .face::before { left: 0;  rotate: 18deg; }
.annoyed .face::after  { right: 0; rotate: -18deg; }
/* One-eyed faces: the two halves meet over the single eye as ONE V brow. */
.annoyed .face-one-eye::before,
.annoyed .face-one-eye::after { width: calc(var(--fs) * 0.20); }
.annoyed .face-one-eye::before { left: calc(50% - var(--fs) * 0.19); }
.annoyed .face-one-eye::after  { left: calc(50% - var(--fs) * 0.01); right: auto; }
.annoyed[data-annoy="3"] .face::before,
.annoyed[data-annoy="4"] .face::before,
.annoyed[data-annoy="5"] .face::before,
.annoyed[data-annoy="6"] .face::before,
.annoyed[data-annoy="7"] .face::before,
.annoyed[data-annoy="8"] .face::before { rotate: 27deg; }
.annoyed[data-annoy="3"] .face::after,
.annoyed[data-annoy="4"] .face::after,
.annoyed[data-annoy="5"] .face::after,
.annoyed[data-annoy="6"] .face::after,
.annoyed[data-annoy="7"] .face::after,
.annoyed[data-annoy="8"] .face::after  { rotate: -27deg; }
.block-negative.annoyed .face::before,
.block-negative.annoyed .face::after { background: #eef0f6; }
.annoyed .mouth {
  border: none;
  border-top: calc(var(--fs) * 0.07) solid #1c1c1e;
  border-radius: 999px 999px 0 0;
  width: calc(var(--fs) * 0.34);
  height: calc(var(--fs) * 0.16);
  bottom: calc(var(--fs) * -0.02);
}
.block-negative.annoyed .mouth { border-top-color: #eef0f6; }
/* Worried face — a block pleading over the trash bin (summerwinter's ask). Same
   downturned frown as the angry face, but the brows tilt the OTHER way (inner
   ends raised) so it reads as scared/sad rather than cross. Only positives get
   it (anti-blocks cheer, Zero shrugs); cleared the instant it leaves the bin. */
.worried .brow { display: none; }
.worried .lashes { display: none; }
.worried .face::before,
.worried .face::after {
  content: '';
  position: absolute;
  top: calc(var(--fs) * -0.06);
  width: calc(var(--fs) * 0.30);
  height: calc(var(--fs) * 0.085);
  background: #1c1c1e;
  border-radius: 3px;
  z-index: 1;
}
.worried .face::before { left: 0;  rotate: -20deg; }
.worried .face::after  { right: 0; rotate:  20deg; }
.worried .mouth {
  border: none;
  border-top: calc(var(--fs) * 0.07) solid #1c1c1e;
  border-radius: 999px 999px 0 0;
  width: calc(var(--fs) * 0.30);
  height: calc(var(--fs) * 0.14);
  bottom: calc(var(--fs) * -0.02);
}
/* An anti-block refusing a twin over the mirror gets `.worried` too (mirror
   recoil, main.js) — but the worried mouth and brows are drawn in near-black,
   which vanishes on the dark anti body (blandshrine's "negative's mouth is
   black" bug). Redraw them in the light anti palette so the frown reads,
   mirroring the .block-negative.annoyed fix. Two-class scope outranks the
   plain .worried rules regardless of source order. */
.block-negative.worried .mouth { border-top-color: #eef0f6; }
.block-negative.worried .face::before,
.block-negative.worried .face::after { background: #eef0f6; }
.anger-vein {
  position: absolute;
  font-size: 22px;
  pointer-events: none;
  transform: translate(-50%, -50%);
  animation: vein-pop 0.8s ease forwards;
  z-index: 5;
}
@keyframes vein-pop {
  0%   { opacity: 0; scale: 0.3; }
  30%  { opacity: 1; scale: 1.25; }
  60%  { scale: 1; }
  100% { opacity: 0; translate: 0 -14px; }
}

/* tap-to-apologise bubble over a cross prime — pat it and it forgives you */
.sorry-tag {
  position: absolute;
  top: -54px;
  right: -14px;
  font-size: 20px;
  line-height: 1;
  padding: 3px 5px;
  background: #fff;
  border-radius: 999px;
  box-shadow: 0 2px 5px rgba(40, 30, 60, 0.3);
  cursor: pointer;
  pointer-events: auto;
  z-index: 6;
  animation: sorry-bob 1.1s ease-in-out infinite;
}
@keyframes sorry-bob {
  0%, 100% { translate: 0 0; }
  50%      { translate: 0 -4px; }
}
/* a quick friendly wave over a block you just deliberately made — Numberblocks
   greet each other all the time in the show. One-shot: pops in, then floats up
   and fades (the JS removes it after ~1.3s). */
.greet-bubble {
  position: absolute;
  top: -42px;
  left: 50%;
  font-size: 17px;
  font-weight: 800;
  line-height: 1;
  padding: 4px 9px;
  white-space: nowrap;
  background: #fff;
  color: #2a2540;
  border-radius: 999px;
  box-shadow: 0 2px 6px rgba(40, 30, 60, 0.28);
  pointer-events: none;
  z-index: 7;
  animation: greet-pop 1.3s ease forwards;
}
/* --greet-dx slides the bubble sideways so it stays ON SCREEN when its block is
   near an edge — the same job --say-dx does for .say-bubble, which was clamped
   after three players screenshotted a half-cut bubble on 2026-09-15 while this
   sibling element was left alone. Measured on a 390px phone: the longer
   costume-crew intro lines clipped 6 of 21 spawns, worst case 75px off the left.
   Set ONCE by clampGreetBubble (main.js), not per-frame — these bubbles live
   1.3s and then delete themselves.
   ⚠️ It must appear in EVERY keyframe, not on the base rule: greet-pop owns
   `transform` for the bubble's entire life, so a base-rule transform would be
   overridden the instant the animation starts and the clamp would silently do
   nothing. Same reason say-pop carries var(--say-dx) in all of its steps. */
@keyframes greet-pop {
  0%   { opacity: 0; transform: translateX(calc(-50% + var(--greet-dx, 0px))) translateY(6px) scale(0.5); }
  18%  { opacity: 1; transform: translateX(calc(-50% + var(--greet-dx, 0px))) translateY(0) scale(1.1); }
  30%  { opacity: 1; transform: translateX(calc(-50% + var(--greet-dx, 0px))) translateY(0) scale(1); }
  75%  { opacity: 1; transform: translateX(calc(-50% + var(--greet-dx, 0px))) translateY(-2px) scale(1); }
  100% { opacity: 0; transform: translateX(calc(-50% + var(--greet-dx, 0px))) translateY(-16px) scale(1); }
}
/* A player-authored speech bubble (💬 Say mode). Unlike greet/mad bubbles it
   PERSISTS — it's a child of the block, so it rides along as the block moves,
   until you clear it or type new text. Pops in once, then just sits there. Wraps
   to two short lines for a longer line; pointer-transparent so the block under
   it is still grabbable. */
.say-bubble {
  position: absolute;
  bottom: 100%;
  left: 50%;
  /* --say-dx slides the bubble sideways so it stays ON SCREEN when its block is
     near an edge; the frame loop sets it (see clampSayBubble). Centring alone
     put up to 81px of a 150px bubble off the viewport for a block in the outer
     ~75px of the board — 38% of a 390px phone's width — which is how two
     players came to screenshot a half-cut bubble on 2026-09-15. Kept as a
     custom property rather than an inline transform so the say-pop animation
     below still composes with it instead of fighting the cascade. */
  transform: translateX(calc(-50% + var(--say-dx, 0px)));
  /* 6px of air, PLUS however far this block's numeral reaches above its head.
     .numeral is `top:-30px` (-24px for -mid, -20px for -long and the two-line
     scientific form) and this bubble is `bottom:100%` — the same strip of sky,
     with the bubble at z-8 over the numeral's z-7. Measured 2026-09-20: the
     bubble buried 49-100% of the numeral in 10 of 10 cases, and a plain "4"
     saying "hi!" lost 89% of its own number, so this was never the renamed-block
     niche the report looked like. BFDIfandomlover screenshotted the result — the
     white-outlined tops of a name poking out from under a bubble, which reads as
     a rendering glitch rather than a layout bug, exactly like the half-cut
     bubble of 2026-09-15 did.
     --say-lift is set from the numeral's OWN offsetTop in renderSayBubble rather
     than hardcoded here: the three numeral classes sit at three different
     heights, so a literal 30px would have covered one of them and left the other
     two wrong. */
  margin-bottom: calc(6px + var(--say-lift, 0px));
  /* max-content so the bubble sizes to its text (up to max-width) instead of
     shrink-fitting to the narrow block it lives inside — otherwise a longer
     phrase collapses to ~2 chars per line into a tall column. Wraps at word
     boundaries (pre-wrap) once it hits max-width. */
  width: max-content;
  max-width: 150px;
  font-size: 15px;
  font-weight: 800;
  line-height: 1.15;
  padding: 5px 10px;
  text-align: center;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  background: #fff;
  color: #2a2540;
  border-radius: 13px;
  box-shadow: 0 2px 6px rgba(40, 30, 60, 0.28);
  pointer-events: none;
  z-index: 8;
  animation: say-pop 0.24s ease;
}
/* little pointer tail under the bubble, aimed at the block. When the bubble is
   slid sideways to stay on screen the tail counter-slides by the same amount,
   so it keeps pointing at the block that's talking rather than travelling with
   the box. --say-tail is --say-dx clamped to the bubble's own half-width (minus
   the corner radius) so the tail can never detach past a rounded corner. */
.say-bubble::after {
  content: '';
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(calc(-50% - var(--say-tail, 0px)));
  border: 6px solid transparent;
  border-top-color: #fff;
  margin-top: -1px;
}
@keyframes say-pop {
  0%   { opacity: 0; transform: translateX(calc(-50% + var(--say-dx, 0px))) translateY(4px) scale(0.6); }
  70%  { opacity: 1; transform: translateX(calc(-50% + var(--say-dx, 0px))) translateY(0) scale(1.08); }
  100% { opacity: 1; transform: translateX(calc(-50% + var(--say-dx, 0px))) translateY(0) scale(1); }
}
/* 🎬 Scene mode hides the bubbles that haven't spoken yet, then reveals them one
   at a time. OPACITY, not display:none — the bubble's width is measured once in
   renderSayBubble and cached on the body for the per-frame edge clamp, and a
   display:none node measures zero, which would slam every hushed bubble back to
   dead-centre the moment it reappeared. Kept visible-but-transparent so the
   cached geometry stays true. Transition-free: the reveal is the say-live pop
   below, and a fade fighting that animation reads as a flicker. */
.say-bubble.hushed { opacity: 0; }
/* Re-runs the same entrance the bubble had when it was first pinned, so each
   line in a scene lands with the identical pop rather than snapping on. The
   class is stripped and re-added (with a forced reflow between) for every line,
   which is what restarts the keyframes — an animation that merely sits in the
   cascade has already finished by the second line. */
.say-bubble.say-live { animation: say-pop 0.24s ease; }

/* The little "what should it say?" entry card for 💬 Say mode. Fixed near the
   top so the on-screen keyboard (which rises from the bottom on phones) never
   covers it. */
#say-box {
  position: fixed;
  top: 14%;
  left: 50%;
  transform: translateX(-50%);
  z-index: 60;
  width: min(90vw, 340px);
  background: #fff;
  border: 3px solid #cdd7e0;
  border-radius: 16px;
  box-shadow: 0 10px 30px rgba(40, 30, 60, 0.35);
  padding: 14px 16px;
}
#say-box.hidden { display: none; }

/* 😀 The picker card — moods on top, the 🎩 dress-up drawer below. One card, so
   one set of rules; the drawer is a section of it, not a second dialog. Reuses
   .expr-choice styling from the drawn-block editor. */
#emote-box {
  position: fixed;
  top: 14%;
  left: 50%;
  transform: translateX(-50%);
  z-index: 60;
  width: min(92vw, 300px);
  background: #fff;
  border: 3px solid #cdd7e0;
  border-radius: 16px;
  box-shadow: 0 10px 30px rgba(40, 30, 60, 0.35);
  padding: 14px 16px;
  text-align: center;
}
.emote-title { font-weight: 800; color: #2a2540; margin-bottom: 10px; font-size: 15px; }
.emote-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
}
#emote-box .expr-choice { width: 32px; height: 32px; font-size: 18px; }
/* Two sections in one card, so give it a little more width and cap the height —
   18 moods plus 19 accessories is more than a short phone can show at once. */
#emote-box { width: min(94vw, 316px); max-height: 74vh; overflow-y: auto; }
.dress-title { margin-top: 12px; }
#say-label { display: block; font-weight: 800; color: #2a2540; margin-bottom: 8px; font-size: 15px; }
#say-input {
  width: 100%;
  box-sizing: border-box;
  font-size: 16px;
  padding: 9px 11px;
  border: 2px solid #cdd7e0;
  border-radius: 10px;
  outline: none;
}
#say-input:focus { border-color: #8e6ad6; }
.say-actions { display: flex; gap: 8px; margin-top: 10px; justify-content: flex-end; }
.say-actions button {
  font-size: 15px;
  font-weight: 700;
  padding: 8px 14px;
  border-radius: 10px;
  border: 2px solid #cdd7e0;
  background: #f6f7fb;
  color: #2a2540;
  cursor: pointer;
}
#say-ok { border-color: #8e6ad6; background: #f1ebfc; color: #5a3ea8; }
/* a hard-flung block's cross "Hey! Don't throw me!" shout — same wave bubble,
   angry red tint (Movies' idea). Lives on the playground, so it doesn't tumble. */
.mad-bubble { background: #ffe2df; color: #c0392b; }
/* the ~10% happy flip-side: a flung block that thinks flying is FUN whoops with
   delight instead — same wave bubble, cheery green tint (NumberblockBoy97104). */
.fun-bubble { background: #e4f7d6; color: #2f8f2f; }
/* a block held over the trash pleads for its life — this bubble persists (no
   auto-fade) for as long as it hangs over the bin, and wobbles in panic. Sits
   BELOW the block: it's dragged up to the bin at the top edge, so an above-head
   bubble would clip off-screen. */
.plead-bubble { top: auto; bottom: -44px; animation: plead-pop 0.9s ease-out infinite alternate; }
@keyframes plead-pop {
  0%   { opacity: 1; transform: translateX(-50%) translateY(0) scale(1) rotate(-3deg); }
  100% { opacity: 1; transform: translateX(-50%) translateY(3px) scale(1.08) rotate(3deg); }
}
/* Zero is neither positive nor negative, so over the bin it's completely
   indifferent — no begging, no cheering, just a flat grey shrug (DynamicFly's
   idea, completing the trash-reaction trilogy). Calmer bob than the panicked
   plead wobble, and no rotate. */
.meh-bubble { background: #ececf0; color: #5a5568; animation: meh-bob 1.1s ease-in-out infinite alternate; }
@keyframes meh-bob {
  0%   { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
  100% { opacity: 1; transform: translateX(-50%) translateY(2px) scale(1.02); }
}
/* An imaginary block over the bin isn't sure it even exists, so its bubble is a
   woozy violet (matching the lilac i costume) that drifts side to side rather than
   panic-wobbling (laterainbow + baremammoth). */
.confused-bubble { background: #ece2fb; color: #6a3ec8; animation: confused-drift 1.3s ease-in-out infinite alternate; }
@keyframes confused-drift {
  0%   { opacity: 1; transform: translateX(-58%) translateY(1px) rotate(-4deg); }
  100% { opacity: 1; transform: translateX(-42%) translateY(3px) rotate(4deg); }
}
/* The 💨 toot cloud — drifts up and off to the side like a little gas puff. */
.fart-puff {
  position: absolute;
  top: -6px;
  left: 68%;
  font-size: 22px;
  line-height: 1;
  pointer-events: none;
  z-index: 7;
  animation: fart-puff 0.95s ease-out forwards;
}
@keyframes fart-puff {
  0%   { opacity: 0; transform: translate(-50%, 6px) scale(0.4) rotate(0deg); }
  22%  { opacity: 0.95; transform: translate(-50%, -6px) scale(1) rotate(-8deg); }
  100% { opacity: 0; transform: translate(18%, -36px) scale(1.6) rotate(20deg); }
}
/* Strong / ultra toots get a bigger, billowier cloud. */
.fart-puff-strong { font-size: 30px; }
.fart-puff-ultra  { font-size: 40px; left: 50%; animation: fart-puff-ultra 1.15s ease-out forwards; }
@keyframes fart-puff-ultra {
  0%   { opacity: 0; transform: translate(-50%, 10px) scale(0.4) rotate(0deg); }
  20%  { opacity: 1; transform: translate(-50%, -4px) scale(1.3) rotate(-10deg); }
  100% { opacity: 0; transform: translate(-50%, -60px) scale(2.6) rotate(14deg); }
}
.happy-hop { animation: happy-hop 0.5s ease; }
@keyframes happy-hop {
  0%, 100% { rotate: 0deg; }
  25% { rotate: -6deg; scale: 1.05; }
  75% { rotate:  6deg; scale: 1.05; }
}

/* numeral above the head */
.numeral {
  position: absolute;
  top: -30px;
  left: 50%;
  translate: -50% 0;
  font-size: 24px;
  font-family: 'NumberlingOne', 'Learninglings', 'Comic Sans MS', 'Chalkboard SE', system-ui, sans-serif;
  color: #141414;
  white-space: nowrap;
  /* The number label must stay readable even when a head-topper (propeller
     cap 37, quiff 90, bowler 26, helmet 14, …) floats into the same slot.
     Those toppers sit at z-index 5–6, so keep the numeral above them — the
     perfect-number crown already dodges this by floating higher, but the newer
     el-toppers land right on the numeral and used to bury it. */
  z-index: 7;
  /* Canon numberling: black glyph with a white outline + glow (the show's
     Series-6 treatment — black on light, kept readable on the night sky by the
     white halo, exactly the glow the show added for dark scenes). */
  text-shadow:
    -2px 0 0 #fff, 2px 0 0 #fff, 0 -2px 0 #fff, 0 2px 0 #fff,
    -1px -1px 0 #fff, 1px 1px 0 #fff, -1px 1px 0 #fff, 1px -1px 0 #fff,
    0 0 6px rgba(255, 255, 255, 0.85), 0 3px 4px rgba(20, 50, 80, 0.3);
  pointer-events: none;
}
.numeral-mid  { font-size: 18px; top: -24px; }
.numeral-long { font-size: 14px; top: -20px; }
/* ✋ Base-1 tally marks (block.js paintTally): four sticks per group, the fifth
   drawn as a stroke across them, so 23 reads as |||| |||| |||| |||| ||| */
.numeral-tally { letter-spacing: -2.5px; }
.tally-line { display: block; white-space: nowrap; }
.tally-group { position: relative; display: inline-block; margin-right: 5px; }
.tally-five::after {
  content: ''; position: absolute; left: -2px; right: -1px; top: 50%;
  height: 2px; background: #141414; transform: rotate(-28deg);
  box-shadow: 0 0 0 1px #fff; border-radius: 1px;
}
/* 🎩 A block the player put a HAT on lifts its number clear of the brim, so the
   hat and the numeral are both legible instead of the badge painting over it.
   Scoped to .dress-head — set only by applyAccessory — so no canon block moves;
   the same collision on ~17 shipped numbers is a separate, pre-existing bug
   (see the note on ACCESSORY_CHOICES in block.js).
   --dress-lift is measured per accessory by applyAccessory from the topper's own
   offsetTop, because a fixed lift that clears a fitted bowler lands squarely on
   a tall crown. All three size variants take it, since each sets its own `top`.
   The fallback only applies if the property is somehow unset. */
.block.dress-head .numeral,
.block.dress-head .numeral-mid,
.block.dress-head .numeral-long { top: calc(-1 * var(--dress-lift, 62px)); }
/* Big word-named blocks stack the canon name over its a×10ⁿ scientific form. */
.numeral-sci-wrap { display: flex; flex-direction: column; align-items: center; line-height: 1.08; }
.numeral-sci { font-size: 11px; opacity: 0.92; margin-top: 1px; letter-spacing: 0.2px; }

/* crown — worn by perfect numbers (6, 28, 496…), floats above the numeral */
.crown {
  position: absolute;
  top: -58px;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  filter: drop-shadow(0 2px 2px rgba(120, 90, 0, 0.45));
  transform-origin: 50% 100%;
  animation: crown-bob 2.4s ease-in-out infinite;
}
@keyframes crown-bob {
  0%, 100% { transform: translateY(0) rotate(-5deg); }
  50%      { transform: translateY(-4px) rotate(5deg); }
}

/* 🚀 rocket — a trembling countdown, then a flame under the block while it
   burns upward. Animates `rotate` / flame `scale` only; the loop owns `transform`. */
/* The countdown bubble stays up for all ten numbers: hold greet-pop's settled
   frame instead of letting its 1.3s fade run. */
.greet-bubble.rocket-count {
  animation: none;
  transform: translateX(calc(-50% + var(--greet-dx, 0px)));
  font-size: 22px;
}
.block.rocket-prep { animation: rocket-rumble 0.12s linear infinite; }
@keyframes rocket-rumble {
  0%, 100% { rotate: -1deg; }
  50%      { rotate: 1deg; }
}
.rocket-flame {
  position: absolute;
  left: 50%;
  top: 100%;
  translate: -50% -10%;
  rotate: 180deg;
  font-size: 34px;
  line-height: 1;
  pointer-events: none;
  z-index: 6;
  animation: rocket-flicker 0.1s ease-in-out infinite alternate;
}
@keyframes rocket-flicker {
  from { scale: 0.85 1; }
  to   { scale: 1.1 1.25; }
}
@media (prefers-reduced-motion: reduce) {
  .block.rocket-prep, .rocket-flame { animation: none; }
}

/* 🪂 parachute — pops above a block that got caught floating when gravity came
   back on ("wait, numberblocks don't fly!", Newhaven). Canopy + three strings
   converging to the block's top; width is set inline per block, --chute is the
   block's own colour so it reads as ITS chute. It swings gently as it drifts. */
.parachute {
  position: absolute;
  left: 50%;
  bottom: 100%;
  margin-bottom: -4px;
  translate: -50% 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  pointer-events: none;
  z-index: 7;
  transform-origin: 50% 130%;
  animation: chute-open 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both,
             chute-sway 2.4s ease-in-out 0.35s infinite;
}
.chute-canopy {
  width: 100%;
  aspect-ratio: 2 / 1.08;
  /* radial panels fanning across the dome — classic segmented parachute */
  background: conic-gradient(from 270deg at 50% 100%,
    var(--chute) 0 30deg, #fff 0 60deg, var(--chute) 0 90deg,
    #fff 0 120deg, var(--chute) 0 150deg, #fff 0 180deg);
  border-radius: 50% 50% 42% 42% / 96% 96% 14% 14%;
  box-shadow: inset 0 -4px 6px rgba(0, 0, 0, 0.16), 0 2px 4px rgba(0, 0, 0, 0.22);
}
.chute-strings {
  width: 62%;
  height: 20px;
  overflow: visible;
}
.chute-strings path {
  stroke: rgba(60, 55, 50, 0.7);
  stroke-width: 1.4;
  fill: none;
  vector-effect: non-scaling-stroke;
}
@keyframes chute-open {
  0%   { transform: translateY(14px) scale(0.2); opacity: 0; }
  100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes chute-sway {
  0%, 100% { transform: rotate(-6deg); }
  50%      { transform: rotate(6deg); }
}
/* A cat sticking a feet-first landing squashes down onto its paws — pin the
   transform-origin to the bottom so the JS-composed squash pulse compresses
   toward the ground, not the center (SKrAPd4N0w's "cats always land on feet"). */
.block.cat-landing { transform-origin: 50% 100%; }
/* anti-blocks fly a dark, inverted chute to match their inverted toppers */
.block-negative .parachute { filter: invert(1) hue-rotate(180deg); }
@media (prefers-reduced-motion: reduce) {
  .parachute { animation: chute-open 0.35s ease both; }
}

/* top hat — Twenty the dapper showman (canon top hat + bowtie); over the head */
.tophat {
  position: absolute;
  top: -52px;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.35));
  transform-origin: 50% 100%;
  animation: tophat-tip 2.6s ease-in-out infinite;
}
@keyframes tophat-tip {
  0%, 100% { transform: translateY(0) rotate(-6deg); }
  50%      { transform: translateY(-3px) rotate(6deg); }
}

@keyframes rainbow-bob {
  0%, 100% { transform: translateY(0) rotate(-3deg); }
  50%      { transform: translateY(-3px) rotate(3deg); }
}

/* Forty-Nine's rainbow hair — seven tufts in the seven rainbow colours (7×7=49),
   a callback to Seven the rainbow Numberblock. Fanned across the crown, bobbing. */
.rainbow-hair {
  position: absolute;
  bottom: calc(100% - 8px);
  translate: -50% 0;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 1px;
  pointer-events: none;
  z-index: 5;
  transform-origin: 50% 100%;
  animation: rainbow-bob 2.8s ease-in-out infinite;
}
.rainbow-hair .hair-tuft {
  display: block;
  height: 22px;
  border-radius: 99px 99px 2px 2px;
  transform-origin: 50% 100%;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.18));
}

/* Ninety the magician — his canon tri-coloured grey swept quiff. One swept
   wave-shape banded in Ninety's three greys (light → mid → dark), sitting on
   the head like the rainbow hair, with the same gentle bob. */
.quiff-90 {
  position: absolute;
  bottom: calc(100% - 10px);
  translate: -50% 0;
  pointer-events: none;
  z-index: 5;
  background: linear-gradient(102deg,
    #d2d2d6 0 34%, #909095 34% 67%, #4f4f54 67% 100%);
  border-radius: 82% 26% 62% 40% / 100% 100% 22% 40%;
  transform-origin: 50% 100%;
  transform: rotate(-8deg);
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.22));
  animation: rainbow-bob 2.8s ease-in-out infinite;
}
/* Ninety's side pocket, holding his magic wand — a grey pentagon pouch hugging
   the right edge of the body, outlined via a slightly larger dark shape behind
   it, with a little wand tip poking out the top. */
.pocket-90 {
  position: absolute;
  right: 3px;
  top: 42%;
  pointer-events: none;
  z-index: 4;
  background: #e2e2e6;
  clip-path: polygon(0 0, 100% 0, 100% 60%, 50% 100%, 0 60%);
}
.pocket-90::before {
  content: '';
  position: absolute;
  inset: -1.5px;
  background: #6a6a70;
  clip-path: polygon(0 0, 100% 0, 100% 60%, 50% 100%, 0 60%);
  z-index: -1;
}
/* a subtle flap line across the top of the pocket */
.pocket-90::after {
  content: '';
  position: absolute;
  top: 20%;
  left: 12%;
  right: 12%;
  height: 2px;
  background: #9a9aa0;
  border-radius: 2px;
}
.pocket-90-wand {
  position: absolute;
  top: -16px;
  left: 50%;
  width: 4px;
  height: 20px;
  background: #2f2f33;
  border-radius: 2px;
  transform: rotate(22deg);
  transform-origin: 50% 100%;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
.pocket-90-wand::after {
  content: '';
  position: absolute;
  top: -5px;
  left: 50%;
  width: 9px;
  height: 9px;
  translate: -50% 0;
  background: #ffd83a;
  border-radius: 50%;
  box-shadow: 0 0 4px 1.5px rgba(255, 216, 58, 0.8);
}

/* calendar board — Thirty-One the calendar Numberblock; sits over the head */
.calendar-topper {
  position: absolute;
  top: -50px;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  border-radius: 4px;
  background: #fff;
  border: 2px solid #b81f1f;
  box-sizing: border-box;
  overflow: visible;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.3));
  transform-origin: 50% 100%;
  animation: tophat-tip 2.8s ease-in-out infinite;
}
.calendar-topper .cal-head {
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 32%;
  background: #d62828;
  border-radius: 1px 1px 0 0;
}
.calendar-topper .cal-hook {
  position: absolute;
  top: -22%;
  width: 12%;
  height: 26%;
  border: 2px solid #9a9a9a;
  border-bottom: none;
  border-radius: 99px 99px 0 0;
  box-sizing: border-box;
}
.calendar-topper .cal-hook-l { left: 22%; }
.calendar-topper .cal-hook-r { right: 22%; }
.calendar-topper .cal-day {
  position: absolute;
  left: 0; right: 0;
  bottom: 4%;
  text-align: center;
  font-weight: 800;
  font-size: 56%;
  line-height: 1;
  color: #b81f1f;
  font-family: system-ui, sans-serif;
}

/* Thirty-Five's rainbow flowers — five flowers of seven rainbow petals (5×7=35),
   laid in a W across his blocks. Each petal is one band of the rainbow. */
.flower-field {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 4;
}
.flower {
  position: absolute;
  translate: -50% -50%;
  transform-origin: 50% 50%;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.22));
  animation: flower-sway 3.2s ease-in-out infinite;
}
.flower .petal {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 42%;
  height: 56%;
  margin: -28% 0 0 -21%;
  border-radius: 50%;
  transform-origin: 50% 50%;
}
.flower .flower-core {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 36%;
  height: 36%;
  margin: -18% 0 0 -18%;
  border-radius: 50%;
  background: #fff2b0;
  box-shadow: 0 0 0 1.5px #f6c945 inset;
}
@keyframes flower-sway {
  0%, 100% { rotate: -6deg; }
  50%      { rotate: 6deg; }
}

/* ══ Series 9 debuts — 33, 41, 43, 44, 46, 47 ══════════════════════════════
   Aired 2026-09-07 → 2026-09-11 ("The Rise of Infinaughty"). Built from the
   aired designs; see block.js for the aired-vs-beta difference on each. Every
   piece here is registered in BOTH the html.night.sleeping pause list and the
   .block-negative dark-mirror list above — an accessory that skips those reads
   as a bug (it keeps dancing while the block naps, or a −47 wears a bright
   helmet its twin doesn't). */

/* Forty-Six's canon head flower: six yellow petals for the six, orange stamen.
   Reuses .flower/.petal geometry so she matches Thirty-Five's flowers, but she
   wears ONE on her head rather than a field of five, so the inherited
   translate is overridden to top-anchor it like the head-hats. */
.flower-46 {
  translate: -50% 0;
  z-index: 6;
}
.flower .petal-46 {
  background: radial-gradient(circle at 40% 32%, #ffe97a 0 42%, #f5cf2e 78%, #e0b31c);
}
.flower .core-46 {
  background: #f78f2a;
  box-shadow: 0 0 0 1.5px #d9701a inset;
}

/* Forty-Three's canon dark-yellow eyebrows. Reuses Nine's brow slot like
   Forty's do; colour is set inline in block.js, this only sets the shape. */
.brow-43 {
  top: calc(var(--fs) * -0.30);
  width: calc(var(--fs) * 0.40);
}
.brow-43.brow-left  { left: 0%; }
.brow-43.brow-right { right: 0%; }
.brow-43 .brow-bar {
  height: calc(var(--fs) * 0.17);
  border-radius: calc(var(--fs) * 0.08);
}

/* Thirty-Three's and Forty-Four's footballer crests — the two multiples of
   eleven. Same chest slot and size as Eighty's octagon badge; deliberately
   DIFFERENT emblems, because that is the canon distinction: 33 wears a round
   stitched ball, 44 a square-shaped checkered one. */
.badge33-field,
.badge44-field {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 5;
}
.badge-33,
.badge-44 {
  position: absolute;
  left: 50%;
  top: 55%;
  translate: -50% -50%;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.32);
}
/* 33 — green roundel, Eleven's kit colours */
.badge-33 {
  background: radial-gradient(circle at 34% 28%, #63c964 0 30%, #38983c 72%, #24701f);
  border: 1.5px solid #1c5a18;
}
.badge-33-ball {
  width: 56%;
  height: 56%;
  border-radius: 50%;
  background: #fdfdf6;
  box-shadow: 0 0 0 1px #2a2a2a inset;
  position: relative;
}
/* the single stitched pentagon patch that makes it read as a football */
.badge-33-ball::after {
  content: '';
  position: absolute;
  left: 50%; top: 50%;
  width: 46%; height: 46%;
  translate: -50% -50%;
  background: #23252b;
  clip-path: polygon(50% 0%, 100% 38%, 81% 100%, 19% 100%, 0% 38%);
}
/* 44 — green roundel with a SQUARE checkered ball (canon "square-shaped
   checkered football"), so the two crests never read as the same badge */
.badge-44 {
  background: radial-gradient(circle at 34% 28%, #5ec95f 0 30%, #2f8f36 72%, #1f6a1c);
  border: 1.5px solid #17530f;
}
.badge-44-ball {
  width: 54%;
  height: 54%;
  border-radius: 12%;
  background: repeating-conic-gradient(#23252b 0% 25%, #fdfdf6 0% 50%) 0 0 / 50% 50%;
  box-shadow: 0 0 0 1px #23252b inset;
}

/* Forty-One's siren — canon: her one red block "is shown to act as a siren".
   A soft red beacon above her head, sweeping like a light bar rather than just
   blinking, so it reads as a siren in motion and not as an error dot. */
.siren-41 {
  position: absolute;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  border-radius: 50% 50% 28% 28%;
  background: radial-gradient(circle at 50% 74%, #ff8a7a 0 24%, #e8261c 62%, #9c0f08);
  animation: siren-41-pulse 1.15s ease-in-out infinite;
  overflow: hidden;
}
/* the sweeping beam, clipped inside the dome so it never paints over the block */
.siren-41-glow {
  position: absolute;
  inset: 8% 8% 18% 8%;
  border-radius: 50% 50% 28% 28%;
  background: conic-gradient(from 0deg, rgba(255, 245, 230, 0.9) 0deg 26deg, transparent 26deg 360deg);
  animation: siren-41-sweep 1.6s linear infinite;
}
@keyframes siren-41-pulse {
  0%, 100% { box-shadow: 0 0 5px rgba(255, 60, 40, 0.45), inset 0 -1px 2px rgba(0, 0, 0, 0.3); }
  50%      { box-shadow: 0 0 11px rgba(255, 80, 55, 0.85), inset 0 -1px 2px rgba(0, 0, 0, 0.3); }
}
@keyframes siren-41-sweep {
  to { rotate: 360deg; }
}

/* Forty-Seven — canon "grey goggles with a brown strap and a brown helmet".
   The helmet is the FLAT two-tone plank the aired Series 9 frame shows lying
   across her whole top edge, NOT the dome-and-ear-flap aviation helmet, which
   is beta 1's design (see the ⚠️ in buildHelmet47). Width/height/top are set in
   block.js because they track the block, not the face — `--fs` lives on .face
   and is not in scope here, so any scaling below has to be a percentage. */
.helmet-47 {
  position: absolute;
  translate: -50% 0;
  pointer-events: none;
  z-index: 6;
  transform-origin: 50% 100%;
  filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.38));
  /* Its own gentler tip, not the shared tophat-tip: that one swings ±6deg,
     which on a plank as wide as the block seesaws the ends by ~9px and dips
     them into the cubes. Still `animation`, so the night-sleeping
     `animation-play-state: paused` registration keeps working unchanged. */
  animation: helmet-47-tip 3.1s ease-in-out infinite;
  /* A lit top surface over a darker front face — a flat board seen slightly
     from above. Two stops, so no ::before/::after needed at all. */
  background: linear-gradient(#a8723d 0 38%, #6f4726 40% 74%, #4a2e19 74%);
  /* Squarer than a stick: rounded ends made it read as a pole rather than a board. */
  border-radius: 1.5% / 20%;
}
@keyframes helmet-47-tip {
  0%, 100% { transform: translateY(0) rotate(-2deg); }
  50%      { transform: translateY(-2px) rotate(2deg); }
}
/* Her canon brown brows — Forty-Three's geometry, her own colour (set in
   block.js). `.brow` already carries the negative-block inversion. */
.brow-47 {
  top: calc(var(--fs) * -0.30);
  width: calc(var(--fs) * 0.40);
}
.brow-47.brow-left  { left: 0%; }
.brow-47.brow-right { right: 0%; }
.brow-47 .brow-bar {
  height: calc(var(--fs) * 0.16);
  border-radius: calc(var(--fs) * 0.08);
}

/* Her goggles sit on the face so they scale with --fs and stay on the eyes.
   Strapped, unlike Twenty-Six's frameless shades — the strap is the canon
   detail the request was pointing at. */
.goggles-47 {
  position: absolute;
  top: -4%;
  left: 0;
  width: 100%;
  height: 46%;
  z-index: 4;
  pointer-events: none;
}
/* brown strap running behind the lenses, right across the head */
.goggle-strap {
  position: absolute;
  top: calc(var(--fs) * 0.13);
  left: -8%;
  width: 116%;
  height: calc(var(--fs) * 0.1);
  background: linear-gradient(#7a4f2b, #4f3119);
  border-radius: calc(var(--fs) * 0.05);
}
.goggle-lens {
  position: absolute;
  top: 0;
  width: calc(var(--fs) * 0.34);
  height: calc(var(--fs) * 0.32);
  background: radial-gradient(circle at 34% 26%, #cfd6dc 0 18%, #8b949c 56%, #59616a);
  border: calc(var(--fs) * 0.045) solid #6b4425;
  border-radius: 50%;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
  box-sizing: border-box;
}
.goggle-lens.goggle-left  { left: 4%; }
.goggle-lens.goggle-right { right: 4%; }

/* Forty-Two's rainbow teleportation belt — a rainbow band across his waist with
   a glowing indigo hexagon buckle carrying six dice pips (6×7=42). */
.belt42-field {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 5;
}
.belt42 {
  position: absolute;
  left: 0; right: 0;
  top: 50%;
  height: 12%;
  translate: 0 -50%;
  border-top: 1px solid rgba(0, 0, 0, 0.18);
  border-bottom: 1px solid rgba(0, 0, 0, 0.18);
  box-shadow: 0 0 6px rgba(155, 81, 200, 0.55);
  animation: belt42-glow 2.4s ease-in-out infinite;
}
@keyframes belt42-glow {
  0%, 100% { box-shadow: 0 0 5px rgba(120, 90, 220, 0.45); }
  50%      { box-shadow: 0 0 12px rgba(155, 81, 200, 0.85); }
}
.belt42-buckle {
  position: absolute;
  left: 50%;
  top: 50%;
  translate: -50% -50%;
  background: #4b3aa0;
  clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
  box-shadow: 0 0 6px rgba(120, 90, 220, 0.8);
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3));
}
.belt42-pip {
  position: absolute;
  width: 11%;
  height: 11%;
  translate: -50% -50%;
  border-radius: 50%;
  background: #fff;
}

/* Fifty-Four's dice bow tie — a "5" die and a "4" die at his collar (the 5 and
   4 of 54), joined by a knot. Sits on the body below the face. */
.bowtie54-field {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 5;
}
.bowtie54 {
  position: absolute;
  left: 50%;
  top: 55%;
  translate: -50% -50%;
  display: flex;
  align-items: center;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.3));
  transform-origin: 50% 50%;
  animation: bowtie54-wobble 3s ease-in-out infinite;
}
.bowtie54-wing {
  position: relative;
  background: #f4f7fc;
  border: 1.5px solid #2b3a67;
  border-radius: 3px;
  box-sizing: border-box;
}
.bowtie54-l { rotate: -13deg; }
.bowtie54-r { rotate: 13deg; }
.bowtie54-knot {
  position: relative;
  z-index: 2;
  margin: 0 -6%;
  background: #2b3a67;
  border-radius: 3px;
  flex: none;
}
.bowtie54-pip {
  position: absolute;
  width: 16%;
  height: 16%;
  translate: -50% -50%;
  border-radius: 50%;
  background: #2b3a67;
}
@keyframes bowtie54-wobble {
  0%, 100% { rotate: -4deg; }
  50%      { rotate: 4deg; }
}

/* particles */
.particle {
  position: absolute;
  width: 10px; height: 10px;
  border-radius: 3px;
  pointer-events: none;
  z-index: 25;
  animation: fly 0.7s ease-out forwards;
}
.particle-big { width: 13px; height: 13px; animation-duration: 0.95s; }
@keyframes fly {
  to {
    transform: translate(var(--dx), var(--dy)) rotate(220deg);
    opacity: 0;
  }
}

/* fireworks (NEW BEST chain celebration) */
.fw-rocket {
  position: absolute;
  width: 6px; height: 6px;
  border-radius: 50%;
  pointer-events: none;
  z-index: 26;
  box-shadow: 0 0 8px 2px currentColor;
  animation: fw-rise 0.62s cubic-bezier(.18,.62,.4,1) forwards;
}
@keyframes fw-rise {
  0%   { transform: translateY(0); opacity: 1; }
  88%  { opacity: 1; }
  100% { transform: translateY(var(--rise)); opacity: 0; }
}
.fw-spark {
  position: absolute;
  width: 7px; height: 7px;
  border-radius: 50%;
  pointer-events: none;
  z-index: 26;
  box-shadow: 0 0 7px 1px currentColor;
  animation: fw-spark-fly 1.15s ease-out forwards;
}
@keyframes fw-spark-fly {
  0%   { transform: translate(0, 0); opacity: 1; }
  65%  { opacity: 1; }
  100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
}

/* ---------- badge ---------- */
#badge {
  position: fixed;
  top: 10px;
  right: 12px;
  z-index: 30;
  background: rgba(255, 255, 255, 0.92);
  border: 2px solid #ffd100;
  border-radius: 999px;
  padding: 6px 14px;
  display: flex;
  gap: 10px;
  align-items: baseline;
  box-shadow: 0 3px 8px rgba(30, 80, 110, 0.18);
}
#badge-count { font-size: 17px; color: #2d3a4a; }
#badge-best  { font-size: 12px; color: #7a8694; }
#badge-next  { font-size: 12px; font-weight: 700; color: #1f9c5b; }
#badge-next:empty { display: none; }
#badge-best:empty { display: none; }
#badge-chain { font-size: 13px; font-weight: 800; color: #ff8300; }
#badge-chain:empty { display: none; }
#badge-ach { font-size: 13px; font-weight: 700; color: #c08a00; }
#badge-ach:empty { display: none; }
#badge.pulse { animation: pulse 0.6s ease; }
@keyframes pulse {
  35% { scale: 1.18; }
}
#badge { cursor: pointer; -webkit-tap-highlight-color: transparent; user-select: none; }
#badge:active { scale: 0.96; }

/* ⭐ Number of the Day pill — a small daily goal chip, top-left under the
   wordmark (clear of the top-right badge). Matches the badge's rounded-pill
   language in a warm gold. Turns green with a ✓ once today's number is built. */
#dotd {
  position: fixed;
  top: 44px;
  left: 16px;
  z-index: 30;
  display: flex;
  gap: 5px;
  align-items: center;
  white-space: nowrap;
  background: rgba(255, 255, 255, 0.92);
  border: 2px solid #ffb300;
  border-radius: 999px;
  padding: 5px 12px;
  font-size: 13px;
  font-weight: 800;
  color: #a15c00;
  box-shadow: 0 3px 8px rgba(30, 80, 110, 0.18);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}
#dotd.hidden { display: none; }
#dotd:active { scale: 0.96; }
#dotd-n { color: #1f9c5b; font-size: 15px; }
#dotd.done { border-color: #1f9c5b; color: #1f9c5b; }
#dotd.done #dotd-n { color: #a15c00; }
#dotd.celebrate { animation: dotd-pop 0.55s ease; }
@keyframes dotd-pop { 40% { scale: 1.18; } }

/* 👥 live player count — deliberately the same chip shape as ⭐ Today so the two
   read as a pair flanking the second row (⭐ left, 👥 right). Right-anchored
   rather than placed after #dotd because #dotd is hidden until a new player's
   first number, and this chip must look intentional on its own from second one.
   z-index 30 matches #badge/#dotd, so the transient #ach-pop (95) and
   #badge-coach (31) banners pass OVER it — #ach-pop already overlaps #badge the
   same way, so that layering is existing behaviour, not a new defect. */
#playing {
  position: fixed;
  top: 44px;
  right: 12px;
  z-index: 30;
  display: flex;
  gap: 5px;
  align-items: center;
  white-space: nowrap;
  background: rgba(255, 255, 255, 0.92);
  border: 2px solid #3aa6df;
  border-radius: 999px;
  padding: 5px 12px;
  font-size: 13px;
  font-weight: 800;
  color: #16628a;
  box-shadow: 0 3px 8px rgba(30, 80, 110, 0.18);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}
#playing.hidden { display: none; }
/* No room beside the zoom pill (updateBounds decides) — hidden but still laid
   out, so the next pass can measure whether it fits again. */
#playing.crowded { visibility: hidden; }
#playing:active { scale: 0.96; }
#playing-n { color: #1f9c5b; font-size: 15px; }

/* One-time coachmark under the badge — explains the ★ on a new player's first
   discovery. Positioned just under the badge by JS (it wraps on phones). */
#badge-coach {
  position: fixed;
  right: 12px;
  z-index: 31;
  max-width: 62vw;
  background: #1f9c5b;
  color: #fff;
  font-size: 12.5px;
  font-weight: 700;
  padding: 5px 12px;
  border-radius: 999px;
  box-shadow: 0 3px 10px rgba(30, 80, 110, 0.25);
  opacity: 0;
  translate: 0 -6px;
  pointer-events: none;
  transition: opacity 0.3s ease, translate 0.3s ease;
}
#badge-coach.show { opacity: 1; translate: 0 0; }

/* ---------- collection gallery ---------- */
/* The discovery counter promises a collection; this is where you see it. A
   classroom hundred-chart you fill in by making each number — a completion
   chase that runs alongside the magnitude chase. */
#gallery {
  position: fixed;
  inset: 0;
  z-index: 80;
  background: rgba(20, 40, 60, 0.55);
  backdrop-filter: blur(2px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
}
#gallery.hidden { display: none; }
#gallery-card {
  position: relative;
  background: #fff;
  border: 3px solid #ffd100;
  border-radius: 22px;
  box-shadow: 0 12px 34px rgba(20, 50, 80, 0.35);
  padding: 20px 18px 16px;
  width: min(440px, 94vw);
  max-height: 88vh;
  overflow-y: auto;
  text-align: center;
}
#gallery-close {
  position: absolute;
  top: 8px;
  right: 10px;
  width: 34px;
  height: 34px;
  border: none;
  background: #f0f2f5;
  border-radius: 50%;
  font-size: 22px;
  line-height: 1;
  color: #5a6675;
  cursor: pointer;
}
#gallery-close:active { scale: 0.9; }
#gallery-title { margin: 2px 0 6px; font-size: 22px; color: #2d3a4a; }
#gallery-stats { margin: 0 0 12px; font-size: 13px; color: #1f9c5b; font-weight: 700; }
#gallery-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  margin: 0 0 10px;
}
#gallery-nav.hidden { display: none; }
#gallery-nav button {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 2px solid #d7dde3;
  background: #fff;
  color: #2d3a4a;
  font-size: 20px;
  line-height: 1;
  font-weight: 700;
  cursor: pointer;
}
#gallery-nav button:active { scale: 0.9; }
#gallery-nav button:disabled { opacity: 0.35; cursor: default; }
#gallery-range { font-size: 14px; font-weight: 700; color: #2d3a4a; min-width: 86px; text-align: center; }
#gallery-grid {
  display: grid;
  grid-template-columns: repeat(10, 1fr);
  gap: 4px;
}
/* The "Beyond 10,000" page is a SPARSE list of only your big finds, so it drops
   the rigid 10-col square grid for content-sized chips that can hold a long
   numeral (e.g. "1,250,000" or "1×10¹⁰⁰"). */
#gallery-grid.beyond {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
#gallery-grid.beyond .gcell {
  aspect-ratio: auto;
  width: auto;
  min-width: 44px;
  padding: 9px 11px;
  font-size: 13px;
}
.gcell {
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: clamp(8px, 2.4vw, 13px);
  font-weight: 700;
  border-radius: 6px;
  border: 2px solid #e7eaee;
  background: #f4f6f8;
  color: #b9c1cc;
  cursor: pointer;
}
.gcell.found { color: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); }
/* Imaginary chips wear a distinct purple so the 🔮 page reads at a glance as its
   own axis, off the real-number chart. */
.gcell.g-imag {
  background: linear-gradient(150deg, #9b5cf0, #6d3ad1);
  border-color: #5a2db0;
  color: #fff;
}
/* Anti-imaginary units (−i, −2i…) wear the dark anti-block shade over the lilac,
   so the two signs read apart at a glance — mirroring the anti-block chart. */
.gcell.g-imag-neg {
  background: linear-gradient(150deg, #3a2b5c, #241a3d);
  border-color: #12203a;
  color: #e9e2ff;
}
/* 📐 Constant chips — a slate-teal that reads as "not a counting number", apart
   from both the coloured integer cells and the lilac imaginary ones. The glyphs
   (π, √2, 1/φ) are wider than a digit, so these size to their content like the
   other sparse-page chips. */
.gcell.g-const {
  font-size: 15px;
  font-weight: 800;
  letter-spacing: 0.2px;
}
.gcell.g-const.found {
  background: linear-gradient(150deg, #2fa8a0, #1d7a76);
  border-color: #17615e;
  color: #fff;
}
/* A constant you HAVEN'T made yet: a dashed outline that reads as an empty slot
   on a checklist, not as a broken cell. Still tappable — its card tells you how
   to make it, which is the whole point of showing locked cells at all. */
.gcell.g-const-locked {
  background: #eef4f4;
  border-style: dashed;
  border-color: #b7cdcc;
  color: #7f9b9a;
}
/* Off-roster finds (2π, √10, ∛12½) sit after the canon in a lighter shade, so the
   fixed roster still reads as the thing to complete. */
.gcell.g-const-extra.found {
  background: linear-gradient(150deg, #63c3bd, #3f9d98);
  border-color: #2f8582;
}
.gd-constant { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #1d7a76; }
.gd-approx { margin: 2px 2px 6px; font-size: 13px; font-weight: 700; color: #4d6b6a; }
.gd-const-glyph { font-size: 30px; }
/* The 🔮 and 📐 tallies in the stats line are buttons that jump to their pages. */
.imag-jump,
.const-jump {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.imag-jump:active,
.const-jump:active { opacity: 0.7; }
.gcell:active { scale: 0.92; }
.gcell.sel { outline: 3px solid #ffb300; outline-offset: 1px; }
/* A number you've found GOLDEN wears a gold ring + a soft glow and a ✨ corner,
   so the shiny chase is visible on the chart at a glance. */
.gcell.golden {
  border-color: #ffcf33;
  box-shadow: 0 0 0 2px #ffd700, 0 1px 6px rgba(240, 165, 0, 0.5);
}
.gcell.golden::after {
  content: '✨';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
/* A number found DIAMOND wears an icy ring + 💎 corner — the ultra-rare tier,
   shown instead of the gold ring when a cell is both. */
.gcell.diamond {
  border-color: #8fd8ff;
  box-shadow: 0 0 0 2px #7fd0ff, 0 1px 7px rgba(120, 205, 255, 0.6);
}
.gcell.diamond::after {
  content: '💎';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
/* A number found RAINBOW wears a magenta ring + 🌈 corner — the apex tier, shown
   instead of the diamond or gold ring when a cell qualifies for several. */
.gcell.g-rainbow {
  border-color: #ff5bd0;
  box-shadow: 0 0 0 2px #ff4fd8, 0 1px 8px rgba(255, 120, 220, 0.65);
}
.gcell.g-rainbow::after {
  content: '🌈';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
/* A number found RADIOACTIVE wears a toxic-green ring + ☢️ corner — the event-only
   tier, shown when a cell isn't rainbow/diamond. */
.gcell.radioactive {
  border-color: #7fff2a;
  box-shadow: 0 0 0 2px #6cf01e, 0 1px 8px rgba(120, 255, 40, 0.6);
}
.gcell.radioactive::after {
  content: '☢️';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
/* A number found LAVA wears a molten-orange ring + 🌋 corner — the Rush's orange
   flavour, shown when a cell isn't rainbow/diamond/radioactive. */
.gcell.lava {
  border-color: #ff8c1a;
  box-shadow: 0 0 0 2px #ff6a00, 0 1px 8px rgba(255, 90, 0, 0.6);
}
.gcell.lava::after {
  content: '🌋';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
/* A number found CHARGED wears a cyan ring + ⚡ corner — the lightning tier,
   shown when a cell isn't rainbow/diamond/radioactive. */
.gcell.charged {
  border-color: #00e5ff;
  box-shadow: 0 0 0 2px #39d6ff, 0 1px 8px rgba(0, 229, 255, 0.6);
}
.gcell.charged::after {
  content: '⚡';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
/* Eclipse/aquatic/oxygen rings — the three keyword tiers, shown when a cell
   isn't a higher tier. Each gets a coloured ring + emoji corner like the rest. */
.gcell.eclipse {
  border-color: #ffd76a;
  box-shadow: 0 0 0 2px #e6a12b, 0 1px 8px rgba(255, 200, 90, 0.6);
}
.gcell.eclipse::after {
  content: '🌑';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
.gcell.aquatic {
  border-color: #3fd6e0;
  box-shadow: 0 0 0 2px #0077cc, 0 1px 8px rgba(0, 150, 220, 0.6);
}
.gcell.aquatic::after {
  content: '🌊';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
.gcell.oxygen {
  border-color: #a9f5e6;
  box-shadow: 0 0 0 2px #7fe0cc, 0 1px 8px rgba(169, 245, 230, 0.7);
}
.gcell.oxygen::after {
  content: '🫧';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
.gcell.obsidian {
  border-color: #6b5a9e;
  box-shadow: 0 0 0 2px #2a2340, 0 1px 8px rgba(107, 90, 158, 0.7);
}
.gcell.obsidian::after {
  content: '🪨';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
.gcell.saturn {
  border-color: #e8c88a;
  box-shadow: 0 0 0 2px #c9a15a, 0 1px 8px rgba(232, 200, 138, 0.7);
}
.gcell.saturn::after {
  content: '🪐';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
.gcell.bloodmoon {
  border-color: #d21f1f;
  box-shadow: 0 0 0 2px #7a0d0d, 0 1px 8px rgba(210, 31, 31, 0.7);
}
.gcell.bloodmoon::after {
  content: '🩸';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
.gcell.candy {
  border-color: #ff6fb5;
  box-shadow: 0 0 0 2px #ffffff, 0 1px 8px rgba(255, 111, 181, 0.7);
}
.gcell.candy::after {
  content: '🍬';
  position: absolute;
  top: -6px;
  right: -4px;
  font-size: 11px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
.gcell { position: relative; }
/* A 🎨 in the bottom-left corner marks a number you've hand-painted — the tier
   rings own the top-right (::after), so painted state stacks with any of them. */
.gcell.painted::before {
  content: '🎨';
  position: absolute;
  bottom: -5px;
  left: -4px;
  font-size: 10px;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
.gd-painted { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #b8557a; }
.gd-charged { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #0a8fb0; }
.gd-golden { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #d99400; }
.gd-diamond { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #2b9fd6; }
.gd-rainbow { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #d61f9e; }
.gd-radioactive { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #4a9e00; }
.gd-lava { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #d9531e; }
.gd-eclipse { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #b8860b; }
.gd-aquatic { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #0077cc; }
.gd-oxygen { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #2aa08a; }
.gd-obsidian { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #6b5a9e; }
.gd-saturn { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #b98a3d; }
.gd-bloodmoon { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #c21818; }
.gd-candy { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #e0408a; }
.gd-imaginary { margin: 6px 2px 0; font-size: 12px; font-weight: 700; color: #8f5fd6; }

/* Look up any number — extends the fun-facts card past the 1–100 chart, so a kid
   who builds a googol can read what's special about it too. */
#gallery-lookup-row {
  display: flex;
  gap: 8px;
  align-items: stretch;
  margin: 12px 0 0;
}
#gallery-lookup {
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  padding: 9px 12px;
  font-size: 14px;
  font-family: inherit;
  border: 2px solid #e7eaee;
  border-radius: 10px;
  background: #f7f9fb;
  color: #2d3a4a;
  outline: none;
}
#gallery-lookup:focus { border-color: #ffb300; background: #fff; }
/* 123/abc toggle — flips the lookup keyboard so word-numbers are typeable on
   phones. Always labels the mode you'd switch TO. */
#lookup-mode {
  flex: none;
  padding: 0 14px;
  font-size: 14px;
  font-weight: 700;
  font-family: inherit;
  color: #5b6b7d;
  background: #eef2f6;
  border: 2px solid #e7eaee;
  border-radius: 10px;
  cursor: pointer;
  transition: background 0.12s, color 0.12s;
}
#lookup-mode:hover { background: #e2e8ee; }
#lookup-mode:active { scale: 0.94; }
#gallery-lookup::placeholder { color: #aab3bf; }

/* Fun-fact panel: appears under the chart when you tap a number. */
#gallery-detail {
  margin: 12px 0 2px;
  min-height: 2.4em;
  text-align: left;
}
.gd-hint { margin: 6px 2px; font-size: 13px; color: #8a96a4; text-align: center; }
.gd-head { display: flex; align-items: baseline; gap: 8px; margin: 2px 0 6px; }
.gd-num { font-size: 26px; color: #2d3a4a; line-height: 1; }
.gd-name { font-size: 14px; font-weight: 700; color: #1f9c5b; text-transform: capitalize; }
/* The live mini-block preview in the collection card — the actual Numberblock
   character for a number you've discovered, scaled to fit. Sits on a baseline so
   place-value figures grow upward like they do in the sandbox. */
.gd-preview { position: relative; display: flex; justify-content: center; align-items: flex-end;
  min-height: 138px; margin: 2px 0 8px; overflow: hidden; cursor: zoom-in;
  -webkit-tap-highlight-color: transparent; }
/* Discoverability nudge for the tap-to-enlarge action on the collection
   character — small, unobtrusive, corner-anchored so it doesn't cover the
   block. */
.gd-preview-hint { position: absolute; right: 6px; bottom: 4px; font-size: 11px;
  color: #8a96a4; background: rgba(255,255,255,0.72); padding: 1px 6px;
  border-radius: 8px; pointer-events: none; }
/* Full-screen view of a collection character (tap the card preview). Dark
   backdrop over everything, block centred and scaled to the viewport, tap
   anywhere or ✕/Esc to dismiss. */
#big-preview { position: fixed; inset: 0; z-index: 120; display: flex;
  flex-direction: column; align-items: center; justify-content: center;
  gap: 10px; background: rgba(16,22,30,0.92); cursor: zoom-out;
  -webkit-tap-highlight-color: transparent; animation: bpFade 0.14s ease; }
@keyframes bpFade { from { opacity: 0; } to { opacity: 1; } }
.bp-stage { position: relative; display: flex; align-items: center; justify-content: center; }
.bp-name { font: 800 22px/1 inherit; color: #fff; text-transform: capitalize;
  text-shadow: 0 2px 6px rgba(0,0,0,0.5); text-align: center; padding: 0 12px; }
.bp-close { position: absolute; top: 14px; right: 16px; width: 44px; height: 44px;
  font-size: 30px; line-height: 1; color: #fff; background: rgba(255,255,255,0.14);
  border: none; border-radius: 50%; cursor: pointer; z-index: 1;
  -webkit-tap-highlight-color: transparent; }
.bp-close:hover { background: rgba(255,255,255,0.26); }
.gd-facts { margin: 0; padding-left: 18px; }
.gd-facts li { font-size: 13px; color: #4a5562; line-height: 1.5; margin: 2px 0; }
.gd-locked { margin: 6px 2px 0; font-size: 12px; color: #c08a2e; }
/* "Make this one" — spawns the number you're viewing straight from the chart.
   Full-width, touch-friendly (most visitors are on phones), tinted the game's
   green so it reads as the do-it action beneath the prose facts. */
.gd-spawn {
  display: block; width: 100%; margin: 10px 0 2px; padding: 11px 12px;
  font: 700 15px/1 inherit; color: #fff; background: #1f9c5b;
  border: none; border-radius: 12px; cursor: pointer; text-align: center;
  box-shadow: 0 2px 0 #167544; -webkit-tap-highlight-color: transparent;
  transition: transform 0.06s ease, background 0.15s ease;
}
.gd-spawn:hover { background: #23ad66; }
.gd-spawn:active { transform: translateY(2px); box-shadow: 0 0 0 #167544; }
/* Club chips: the "which clubs is this number in" strip in the collection card.
   A scannable row of coloured pills, each tinted to its family (Threes yellow
   like Three, Square blue, Diamond cyan…). Additive above the prose facts. */
.gd-clubs { display: flex; flex-wrap: wrap; gap: 5px; margin: 0 2px 8px; }
.gd-club { font-size: 11px; font-weight: 800; line-height: 1; padding: 4px 8px;
  border-radius: 999px; border: 1.5px solid; letter-spacing: 0.02em; }
.club-even     { color: #2f6fb0; border-color: #a9cbe8; background: #eef5fc; }
.club-odd      { color: #b06a1e; border-color: #e8cba9; background: #fcf5ee; }
.club-prime    { color: #c0392b; border-color: #e9b3ac; background: #fdeeed; }
.club-composite{ color: #1f9c7a; border-color: #a9e0d1; background: #eefaf6; }
.club-threes   { color: #b39100; border-color: #ecd98a; background: #fdf9e6; }
.club-sevens   { color: #fff; border-color: transparent;
  background: linear-gradient(90deg,#e2453d,#e6852f,#e8c93a,#3fa94b,#3a7fd1,#8a4fc0);
  text-shadow: 0 1px 1px rgba(0,0,0,.35); }
.club-elevens  { color: #c05a2f; border-color: #eac1ad; background: #fcefe9; }
.club-thirteens{ color: #8a5a1e; border-color: #d9a84a; background: #fdf4e0; border-style: dashed; }
.club-doubler  { color: #4a56c8; border-color: #bcc2ee; background: #eef0fc; }
.club-square   { color: #2f6fb0; border-color: #a9cbe8; background: #eaf3fc; }
.club-rectangle{ color: #b5651d; border-color: #e6c199; background: #fcf3e8; }
.club-cube     { color: #7a4fb0; border-color: #cdb6e8; background: #f5eefc; }
.club-diamond  { color: #1a8fc0; border-color: #a3d6ea; background: #eaf7fc; }
.club-hexagon  { color: #2f9c4a; border-color: #a9e0b3; background: #eefaf0; }
.club-step     { color: #b06a1e; border-color: #e8cba9; background: #fcf3ea; }
.club-perfect  { color: #a6791e; border-color: #ecd493; background: #fdf7e6; }
.club-twenties { color: #c0398f; border-color: #eaa9d3; background: #fceaf5; }
.club-thirties { color: #1a8f8f; border-color: #a3dede; background: #eafafa; }
.club-forties  { color: #6b4fc0; border-color: #bcaee8; background: #f1eefc; }
.club-onesend  { color: #d4433a; border-color: #eeb0ab; background: #fdeeed; }
.club-foursend { color: #2f9c4a; border-color: #a9e0b3; background: #eefaf0; }
.club-ninesend { color: #55606f; border-color: #c2c8d1; background: #f0f2f5; }
#gallery-foot { margin: 12px 0 2px; font-size: 12px; color: #7a8694; min-height: 1em; }

/* ---------- collection tabs (chart vs achievements) ---------- */
#gallery-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  justify-content: center;
  margin: 2px 0 14px;
}
.gtab {
  border: 2px solid #e7eaee;
  background: #f4f6f8;
  color: #8a96a4;
  font-weight: 800;
  font-size: 14px;
  padding: 7px 14px;
  border-radius: 999px;
  cursor: pointer;
}
.gtab.active {
  background: #ffd100;
  border-color: #f0a500;
  color: #5a4500;
}
.gtab:active { scale: 0.95; }
#tab-achievements.hidden, #tab-collection.hidden, #tab-myblocks.hidden, #tab-daily.hidden { display: none; }

/* 🧹 Clear all — bulk delete for the My Blocks gallery. Styled as the same quiet
   dim pill as ↺ Start over, NOT as a primary action: it destroys the player's own
   drawings, so it must never out-shout the art it sits above. There is no generic
   .hidden rule in this sheet (every element declares its own), so this one is
   required or the button shows on an empty gallery too. */
#myblocks-clear {
  margin: 0 auto 10px;
  display: block;
  border: 2px solid #e7eaee;
  background: #f4f6f8;
  color: #9aa4b0;
  font-weight: 700;
  font-size: 13px;
  padding: 7px 15px;
  border-radius: 999px;
  cursor: pointer;
}
#myblocks-clear.hidden { display: none; }
#myblocks-clear:active { scale: 0.95; }
#myblocks-clear.armed {
  background: #ffe3e3;
  border-color: #ff9a9a;
  color: #c0392b;
}

/* ---------- 🏆 Daily Leaderboard ---------- */
/* 🔢 / 🎯 axis switch. Deliberately a SMALLER, quieter pill than .gtab above:
   it sits inside the 🏆 tab and switching axis is a lesser move than switching
   tab, so it must not read as a second row of top-level tabs. */
#daily-views { display: flex; gap: 6px; justify-content: center; margin: 0 0 10px; }
.lbview {
  border: 2px solid #e7eaee;
  background: #f4f6f8;
  color: #8a96a4;
  font-weight: 800;
  font-size: 12px;
  padding: 5px 12px;
  border-radius: 999px;
  cursor: pointer;
}
.lbview.active { background: #fff3bf; border-color: #f0a500; color: #5a4500; }
.lbview:active { scale: 0.95; }
#daily-title { margin: 0 0 4px; font-size: 20px; color: #5a4500; text-align: center; }
#daily-sub { margin: 0 0 12px; font-size: 13px; color: #1f9c5b; font-weight: 700; text-align: center; }
#daily-list { display: flex; flex-direction: column; gap: 6px; min-height: 60px; }
.daily-empty { text-align: center; color: #8a96a4; font-weight: 700; font-size: 14px; padding: 18px 8px; margin: 0; }
.daily-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  border: 2px solid #eef1f4;
  border-radius: 14px;
  background: #f8fafc;
}
.daily-row.daily-me {
  border-color: #f0a500;
  background: #fff7d6;
}
/* The remainder of a capped tie ("+20 more tied at #1"). Deliberately quieter
   than a real player row — it is a note about the board, not a competitor. */
.daily-row.daily-tie {
  background: #f1f4f8;
  border-style: dashed;
  padding: 5px 12px;
}
.daily-tie .daily-name {
  color: #8a96a4;
  font-size: 13px;
  font-weight: 700;
  text-align: center;
}
.daily-rank {
  flex: 0 0 auto;
  min-width: 30px;
  text-align: center;
  font-weight: 900;
  font-size: 17px;
  color: #8a96a4;
}
.daily-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: 800;
  color: #3a4756;
}
.daily-me .daily-name { color: #5a4500; }
.daily-score {
  flex: 0 0 auto;
  font-weight: 900;
  color: #1f9c5b;
  font-variant-numeric: tabular-nums;
}
#daily-foot { margin: 14px 2px 2px; font-size: 12px; color: #9aa4b0; text-align: center; }
#ach-stats { margin: 0 0 12px; font-size: 13px; color: #1f9c5b; font-weight: 700; }
#ach-tools { display: flex; gap: 8px; align-items: stretch; margin: 0 0 12px; }
#ach-search {
  flex: 1; min-width: 0; box-sizing: border-box; padding: 9px 12px;
  font-size: 16px; font-family: inherit; border: 2px solid #e7eaee;
  border-radius: 10px; background: #f7f9fb; color: #2d3a4a; outline: none;
}
#ach-search:focus { border-color: #ffb300; background: #fff; }
#ach-search::placeholder { color: #aab3bf; }
#ach-newest {
  flex: none; padding: 0 12px; min-height: 40px; font-size: 14px; font-family: inherit;
  font-weight: 700; border: 2px solid #e7eaee; border-radius: 10px;
  background: #f7f9fb; color: #2d3a4a; cursor: pointer;
}
#ach-newest[aria-pressed="true"] { border-color: #1f9c5b; background: #e6f6ed; color: #1f9c5b; }
#ach-none.hidden { display: none; }
#ach-none { margin: 4px 0 12px; font-size: 14px; color: #8a94a3; }

/* ---------- start-over button (foot of the gallery card) ---------- */
#gallery-reset {
  margin: 16px auto 2px;
  display: block;
  border: 2px solid #e7eaee;
  background: #f4f6f8;
  color: #9aa4b0;
  font-weight: 700;
  font-size: 13px;
  padding: 8px 16px;
  border-radius: 999px;
  cursor: pointer;
}
#gallery-reset:active { scale: 0.95; }
#gallery-reset.armed {
  background: #ffe3e3;
  border-color: #ff9a9a;
  color: #c0392b;
}
#gallery-foot-btns {
  display: flex;
  gap: 10px;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
  margin-top: 14px;
}
#gallery-foot-btns #gallery-reset { margin: 0; }
#backup-open {
  border: 2px solid #cfe3ff;
  background: #eef6ff;
  color: #2f6fb3;
  font-weight: 700;
  font-size: 13px;
  padding: 8px 16px;
  border-radius: 999px;
  cursor: pointer;
}
#backup-open:active { scale: 0.95; }

/* ---------- backup / restore dialog ---------- */
#backup-panel {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: rgba(20, 40, 60, 0.55);
  backdrop-filter: blur(2px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
}
#backup-panel.hidden { display: none; }
#backup-card {
  position: relative;
  background: #fff;
  border: 3px solid #7fb0e6;
  border-radius: 22px;
  box-shadow: 0 12px 34px rgba(20, 50, 80, 0.35);
  padding: 22px 18px 16px;
  width: min(440px, 94vw);
  max-height: 88vh;
  overflow-y: auto;
  text-align: center;
}
#backup-close {
  position: absolute;
  top: 8px;
  right: 10px;
  width: 34px;
  height: 34px;
  border: none;
  background: #f0f2f5;
  border-radius: 50%;
  font-size: 22px;
  line-height: 1;
  color: #5a6675;
  cursor: pointer;
}
#backup-close:active { scale: 0.9; }
#backup-title { margin: 2px 0 10px; font-size: 20px; color: #2d3a4a; }
.backup-hint { margin: 4px 0 8px; font-size: 13px; color: #5a6675; line-height: 1.4; }
#backup-code, #backup-paste {
  width: 100%;
  box-sizing: border-box;
  border: 2px solid #d8dee6;
  border-radius: 12px;
  padding: 8px 10px;
  font-size: 12px;
  font-family: ui-monospace, Menlo, Consolas, monospace;
  color: #2d3a4a;
  background: #f7f9fb;
  resize: vertical;
  word-break: break-all;
}
#backup-code:focus, #backup-paste:focus { border-color: #7fb0e6; background: #fff; outline: none; }
#backup-copy, #backup-restore {
  margin: 8px auto 0;
  display: block;
  border: none;
  background: #4a90d9;
  color: #fff;
  font-weight: 700;
  font-size: 14px;
  padding: 9px 20px;
  border-radius: 999px;
  cursor: pointer;
}
#backup-copy:active, #backup-restore:active { scale: 0.95; }
.backup-sep { border: none; border-top: 1px solid #e7eaee; margin: 16px 0 10px; }
#backup-msg { margin: 10px 0 2px; font-size: 13px; font-weight: 700; min-height: 1em; }
#backup-msg.backup-ok { color: #1f9c5b; }
#backup-msg.backup-err { color: #c0392b; }

/* ---------- My Blocks (saved custom drawings) ---------- */
#myblocks-stats { margin: 0 0 12px; font-size: 13px; color: #7a5cc0; font-weight: 700; }
#myblocks-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
  gap: 12px;
  align-items: end;
}
.mb-empty {
  grid-column: 1 / -1;
  font-size: 13px;
  color: #aab2bd;
  line-height: 1.5;
  text-align: center;
  padding: 18px 8px;
}
.mb-tile { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.mb-spawn {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 7px;
  padding: 10px 6px 9px;
  border: 2px solid #e7eaee;
  background: #f4f6f8;
  border-radius: 14px;
  cursor: pointer;
}
.mb-spawn:active { scale: 0.95; }
/* Fresh-save confirmation: the just-saved tile flashes a golden ring so a creator
   sees it landed even when numeric order tucks it mid-list. One-shot (the class is
   cleared on the next render). */
.mb-just-saved .mb-spawn { animation: mb-just-saved-flash 1.5s ease-out 1; }
@keyframes mb-just-saved-flash {
  0%   { border-color: #ffb300; box-shadow: 0 0 0 4px rgba(255, 179, 0, 0.5); }
  70%  { border-color: #ffb300; box-shadow: 0 0 0 4px rgba(255, 179, 0, 0.28); }
  100% { border-color: #e7eaee; box-shadow: 0 0 0 0 rgba(255, 179, 0, 0); }
}
@media (prefers-reduced-motion: reduce) {
  .mb-just-saved .mb-spawn { animation: none; border-color: #ffb300; }
}
.mb-mini {
  display: grid;
  width: 100%;
  max-width: 54px;
  gap: 1px;
}
.mb-cube { border-radius: 2px; min-height: 4px; }
.mb-label {
  font-size: 15px;
  font-weight: 800;
  color: #5a4500;
  text-align: center;
  max-width: 100%;
  overflow-wrap: anywhere;
  cursor: text;
}
.mb-name-input {
  width: 100%;
  box-sizing: border-box;
  /* Same as #shape-name: keep the themed font on the rename field. */
  font-family: inherit;
  font-size: 14px;
  font-weight: 700;
  text-align: center;
  border: 2px solid #6ea8ff;
  border-radius: 8px;
  padding: 3px 4px;
  color: #33475b;
}
.mb-rename {
  position: absolute;
  top: -7px;
  left: -7px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: 2px solid #fff;
  background: #6ea8ff;
  font-size: 10px;
  line-height: 1;
  cursor: pointer;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}
.mb-rename:active { scale: 0.9; }
.mb-del {
  position: absolute;
  top: -7px;
  right: -7px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: 2px solid #fff;
  background: #ff6b6b;
  color: #fff;
  font-weight: 800;
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
.mb-del:active { scale: 0.9; }

/* ---------- 🎯 Next Up strip (pinned above the achievements list) ---------- */
/* Deliberately the INVERSE of an .acell: an unearned achievement in the grid
   below is greyed out and inert, but the same thing framed as a live goal is
   the brightest thing on the panel. Teal rather than the grid's gold so a goal
   never reads as something already won. */
#ach-next { display: flex; flex-direction: column; gap: 8px; margin: 0 0 14px; }
#ach-next h4 {
  margin: 0;
  font-size: 13px;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: #1f9c5b;
}
.qcell {
  display: flex;
  align-items: center;
  gap: 12px;
  text-align: left;
  padding: 10px 12px;
  border-radius: 14px;
  border: 2px solid #7fd8ab;
  background: linear-gradient(135deg, #f2fff8, #dcf7e9);
  box-shadow: 0 1px 4px rgba(31, 156, 91, 0.16);
}
.qcell-icon { font-size: 26px; line-height: 1; width: 34px; text-align: center; }
.qcell-body { display: flex; flex-direction: column; gap: 1px; }
.qcell-body b { font-size: 15px; color: #17603a; }
.qcell-body span { font-size: 12px; color: #3f7a5c; line-height: 1.35; }
.qcell-done { border-color: #ffd100; background: linear-gradient(135deg, #fffdf0, #fff4c9); }
.qcell-done .qcell-body b { color: #7a5a00; }
.qcell-done .qcell-body span { color: #9a7b1a; }

/* ---------- achievements list ---------- */
#ach-grid { display: flex; flex-direction: column; gap: 8px; }
.acell {
  display: flex;
  align-items: center;
  gap: 12px;
  text-align: left;
  padding: 10px 12px;
  border-radius: 14px;
  border: 2px solid #e7eaee;
  background: #f4f6f8;
}
.acell-icon {
  font-size: 26px;
  line-height: 1;
  width: 34px;
  text-align: center;
  filter: grayscale(1);
  opacity: 0.5;
}
.acell-body { display: flex; flex-direction: column; gap: 1px; }
.acell-body b { font-size: 15px; color: #9aa4b0; }
.acell-body span { font-size: 12px; color: #aab2bd; line-height: 1.35; }
.acell.got {
  background: #fffbe9;
  border-color: #ffd100;
  box-shadow: 0 1px 4px rgba(240, 165, 0, 0.18);
}
.acell.got .acell-icon { filter: none; opacity: 1; }
.acell.got .acell-body b { color: #2d3a4a; }
.acell.got .acell-body span { color: #4a5562; }

/* ---------- achievement unlock banner ---------- */
#ach-pop {
  position: fixed;
  top: 14px;
  left: 50%;
  z-index: 95;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 16px 10px 12px;
  background: linear-gradient(135deg, #fff4c2, #ffd75e);
  border: 2px solid #f0a500;
  border-radius: 16px;
  box-shadow: 0 8px 24px rgba(180, 120, 0, 0.35);
  /* A fixed box at left:50% shrink-to-fits against the space from the LEFT
     edge, i.e. half the viewport — 195px on a 390px phone — so the banner has
     always wrapped "ACHIEVEMENT UNLOCKED!" onto two lines. `max-content` lets
     it size to its text instead, and the cap keeps it on screen. */
  width: max-content;
  max-width: min(92vw, 420px);
  opacity: 0;
  pointer-events: none;
  translate: -50% -120%;
  transition: translate 0.45s cubic-bezier(0.2, 1.4, 0.4, 1), opacity 0.3s ease;
}
#ach-pop.show { opacity: 1; translate: -50% 0; }
.ach-pop-icon { font-size: 30px; line-height: 1; }
.ach-pop-text { display: flex; flex-direction: column; text-align: left; }
.ach-pop-text b { font-size: 12px; letter-spacing: 0.4px; text-transform: uppercase; color: #9a6b00; }
.ach-pop-text span { font-size: 16px; font-weight: 800; color: #5a4500; }
/* The hand-off line: present only when this unlock cleared the badge's goal. */
.ach-pop-text .ach-pop-next { font-size: 12px; font-weight: 700; color: #9a6b00; margin-top: 2px; }

/* ---------- combo meter (chain reactions) ---------- */
/* A burst of energy near the top when merges cascade. --hue slides from gold
   toward hot-orange/red as the chain grows, so longer chains read hotter. */
#combo {
  position: fixed;
  left: 50%;
  top: 19%;
  translate: -50% 0;
  z-index: 55;
  display: flex;
  flex-direction: column;
  align-items: center;
  line-height: 1;
  pointer-events: none;
  opacity: 0;
  text-align: center;
  text-shadow: 0 2px 0 #fff, 0 3px 10px hsl(var(--hue) 100% 50% / 0.5);
  --hue: 45;
}
#combo-n {
  font-size: 64px;
  font-weight: 900;
  color: hsl(var(--hue), 100%, 50%);
}
#combo-label {
  font-size: 24px;
  font-weight: 900;
  letter-spacing: 0.06em;
  color: hsl(var(--hue), 95%, 45%);
}
#combo-sub { font-size: 15px; font-weight: 700; color: #2d3a4a; margin-top: 4px; }
#combo-sub:empty { display: none; }
/* Record-break: gold ribbon under the label so a new personal best reads as an
   event, not just another chain. */
#combo.best #combo-label {
  color: #fff;
  background: linear-gradient(90deg, #ffb300, #ff7a00);
  padding: 2px 12px;
  border-radius: 999px;
  box-shadow: 0 2px 8px rgba(255, 140, 0, 0.55);
}
#combo.show { animation: combo-pop 1.1s cubic-bezier(0.2, 1.5, 0.4, 1) forwards; }
@keyframes combo-pop {
  0%   { opacity: 0; scale: 0.4; }
  15%  { opacity: 1; scale: 1.12; }
  30%  { scale: 1; }
  75%  { opacity: 1; scale: 1; }
  100% { opacity: 0; scale: 1.05; translate: -50% -14px; }
}

/* ---------- hint & toast ---------- */
#hint {
  position: fixed;
  left: 50%;
  top: 34%;
  translate: -50% 0;
  z-index: 30;
  background: rgba(255, 255, 255, 0.94);
  color: #2d3a4a;
  font-size: 19px;
  padding: 12px 22px;
  border-radius: 999px;
  box-shadow: 0 4px 12px rgba(30, 80, 110, 0.22);
  pointer-events: none;
  animation: bob 2.2s ease-in-out infinite;
  text-align: center;
  /* Same dead cap as #toast (see the long note on that rule): `left: 50%` with
     no `right` and no `width` gives a shrink-to-fit box an available width of
     50vw, so this bubble has been rendering at EXACTLY half the screen forever
     — 160px on a 320 phone, 195px on a 390 — and the 86vw cap never bound.
     It matters more here than on the toast, because #hint carries the two
     highest-value strings in the game: the first-drag coach that teaches the
     core mechanic, and the r850 Challenge invite (which is also a TAP TARGET,
     so squeezing it shrinks the button). At 50vw the opening coach wrapped to
     THREE lines / 79px on every real phone bucket; it is now two / 61px.
     NB the toolbar collision that led me here (71% of the bubble covered) only
     reproduces near 320px WIDE, which PostHog puts at ~0% of traffic — the
     defect that found the bug is not the one worth fixing. The reason to ship
     this is the line count on 100% of phones, not the collision. */
  width: max-content;
  max-width: 86vw;
}
@keyframes bob {
  50% { translate: -50% -8px; }
}
#hint.fade-out { transition: opacity 0.6s; opacity: 0; }

/* The hint bubble is normally inert scenery (pointer-events:none above). The
   one-time Challenge invite is the single case where it's a real button, so it
   takes the taps back and dresses up to look pressable. */
#hint.hint-tappable {
  pointer-events: auto;
  cursor: pointer;
  background: #fff5e8;
  color: #8a4b06;
  font-weight: 800;
  border: 3px solid #f08a24;
  box-shadow: 0 5px 16px rgba(240, 138, 36, 0.34);
}
#hint.hint-tappable:active { translate: -50% 2px; }

/* First-drag coach (H1 onboarding): a finger that traces the demo drag between
   the two centre blocks, and a soft pulse on the block it "grabs". Positioned
   by JS in viewport space; pointer-events off so it never blocks a real drag. */
#coach {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 29; /* under the #hint bubble (30), over the blocks */
  font-size: 40px;
  line-height: 1;
  pointer-events: none;
  will-change: transform;
  filter: drop-shadow(0 3px 4px rgba(30, 80, 110, 0.35));
  -webkit-user-select: none;
  user-select: none;
}
/* The pulsing ring drawn around the demo drag-source block (its own fixed-
   position element, sized by JS, so it never collides with a block costume). */
#coach-ring {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 28;
  border-radius: 16px;
  border: 3px solid rgba(255, 209, 74, 0.95);
  box-shadow: 0 0 14px 3px rgba(255, 209, 74, 0.5);
  pointer-events: none;
  will-change: transform, opacity;
  animation: coach-ring 1.4s ease-in-out infinite;
}
@keyframes coach-ring {
  0%, 100% { opacity: 0.4; }
  50% { opacity: 1; }
}

#toast {
  position: fixed;
  left: 50%;
  bottom: var(--toast-bottom, 130px);
  translate: -50% 12px;
  z-index: 60;
  background: rgba(45, 58, 74, 0.92);
  color: #fff;
  font-size: 16px;
  padding: 10px 18px;
  border-radius: 12px;
  opacity: 0;
  transition: opacity 0.25s, translate 0.25s;
  pointer-events: none;
  text-align: center;
  /* `max-width: 86vw` below was dead code for as long as it has existed. With
     `position: fixed; left: 50%` and no `right`, a shrink-to-fit box gets an
     available width of (viewport − left) = 50vw, so the box could never grow
     past HALF the screen and the 86vw cap never bound. It went unnoticed
     because every toast was short; the discovery facts are up to 141
     characters, which stacked into an 8-line ribbon in a 180px column on a
     360px phone. `width: max-content` sizes to the text first, and THEN the
     86vw cap does the job it was always written to do. */
  width: max-content;
  max-width: 86vw;
}
#toast.show { opacity: 1; translate: -50% 0; }

/* Challenge banner — a friendly "build the answer" prompt that floats in from the
   top while challenge mode is on. pointer-events:none so taps reach blocks under it. */
#challenge {
  position: fixed;
  left: 50%;
  top: 10px;
  translate: -50% -90px;
  z-index: 40;
  background: rgba(255, 255, 255, 0.96);
  border: 3px solid #f5a623;
  color: #2d3a4a;
  padding: 8px 20px;
  border-radius: 16px;
  box-shadow: 0 5px 16px rgba(245, 166, 35, 0.32);
  text-align: center;
  pointer-events: none;
  opacity: 0;
  transition: translate 0.32s cubic-bezier(0.2, 1.4, 0.4, 1), opacity 0.3s, border-color 0.2s;
  /* dead-cap family, same as #toast/#hint — sized to 50vw, never to the text */
  width: max-content;
  max-width: 86vw;
}
#challenge.show { translate: -50% 0; opacity: 1; }
#challenge.win { border-color: #7ac143; animation: challenge-win 0.6s ease; }
@keyframes challenge-win {
  0%, 100% { transform: scale(1); }
  40% { transform: scale(1.12); }
}
#challenge-q { font-size: 22px; font-weight: 800; }
#challenge-sub { font-size: 13px; font-weight: 700; color: #b9771a; margin-top: 2px; }
#challenge-sub:empty { display: none; }
/* Deliberate streak reset — leaving Challenge mode now KEEPS your streak
   (divinegorilla's ask), so this button is the one way to start the ladder over.
   The panel is pointer-events:none so taps fall through to the game; the button
   re-enables just for itself. Hidden whenever the panel isn't shown. */
#challenge-restart {
  margin-top: 6px;
  pointer-events: auto;
  border: 2px solid #f5a623;
  background: #fff4e0;
  color: #b9771a;
  font-weight: 800;
  font-size: 12px;
  padding: 3px 12px;
  border-radius: 10px;
  cursor: pointer;
  font-family: inherit;
  transition: transform 0.1s;
}
#challenge-restart:active { transform: scale(0.92); }
#challenge:not(.show) #challenge-restart { display: none; }

/* ---------- Six's Quiz Show ---------- */
#quizshow {
  position: fixed;
  inset: 0;
  z-index: 85;
  background: rgba(20, 40, 60, 0.62);
  backdrop-filter: blur(2px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
}
#quizshow.hidden { display: none; }
#quiz-card {
  position: relative;
  background: linear-gradient(180deg, #fff 0%, #fff8e6 100%);
  border: 4px solid #ffd100;
  border-radius: 24px;
  box-shadow: 0 14px 40px rgba(20, 50, 80, 0.4);
  padding: 20px 18px 18px;
  width: min(440px, 94vw);
  max-height: 92vh;
  overflow-y: auto;
  text-align: center;
}
#quiz-leave {
  position: absolute;
  top: 8px;
  right: 10px;
  width: 34px;
  height: 34px;
  border: none;
  background: #f0f2f5;
  border-radius: 50%;
  font-size: 22px;
  line-height: 1;
  color: #5a6675;
  cursor: pointer;
  /* The bobbing host emoji below has a transform → it forms a stacking context
     that would otherwise paint over this button and swallow taps on the ×.
     Keep the escape control on top so it's always tappable. */
  z-index: 3;
}
#quiz-leave:active { scale: 0.9; }
#quiz-host {
  font-size: 46px;
  line-height: 1;
  animation: quiz-host-bob 1.6s ease-in-out infinite;
  /* Purely decorative — never let the full-width host box eat taps meant for
     the × close button (or anything beneath it). */
  pointer-events: none;
}
@keyframes quiz-host-bob {
  0%, 100% { transform: translateY(0) rotate(-3deg); }
  50% { transform: translateY(-6px) rotate(3deg); }
}
#quiz-title {
  font-size: 20px;
  font-weight: 900;
  color: #e0850e;
  margin: 2px 0 4px;
  letter-spacing: 0.3px;
}
#quiz-diff {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  margin: 2px 0 10px;
}
.quiz-diff-pill {
  font-family: inherit;
  font-size: 13px;
  font-weight: 800;
  color: #6a7a8a;
  background: #eef4fa;
  border: 2px solid #cfe0ef;
  border-radius: 999px;
  padding: 5px 14px;
  cursor: pointer;
  transition: transform 0.08s, background 0.15s, border-color 0.15s, color 0.15s;
  -webkit-tap-highlight-color: transparent;
}
.quiz-diff-pill:active { transform: scale(0.94); }
.quiz-diff-pill.active { background: #ffe7b3; border-color: #f5b942; color: #b9771a; }
#quiz-scoreline {
  font-size: 14px;
  font-weight: 800;
  color: #b9771a;
  margin-bottom: 12px;
  min-height: 18px;
}
#quiz-question {
  font-size: 34px;
  font-weight: 900;
  color: #2d3a4a;
  margin: 4px 0 16px;
  min-height: 42px;
}
#quiz-choices {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}
.quiz-choice {
  font-family: inherit;
  font-size: 26px;
  font-weight: 900;
  color: #2d3a4a;
  background: #fff;
  border: 3px solid #7ec8ff;
  border-radius: 16px;
  padding: 16px 8px;
  cursor: pointer;
  transition: transform 0.08s, background 0.15s, border-color 0.15s;
  -webkit-tap-highlight-color: transparent;
  min-height: 62px;
}
.quiz-choice:active { transform: scale(0.94); }
.quiz-choice.correct { background: #e3f8d8; border-color: #7ac143; color: #2f7a1f; }
.quiz-choice.wrong { background: #ffe3e3; border-color: #ff6b6b; color: #c22; }
#quiz-feedback {
  font-size: 17px;
  font-weight: 800;
  margin-top: 14px;
  min-height: 22px;
}
#quiz-feedback.good { color: #2f9b3f; }
#quiz-feedback.bad { color: #d0592a; }

/* ---------- toolbar ---------- */
#toolbar {
  position: fixed;
  left: 50%;
  bottom: 14px;
  translate: -50% 0;
  z-index: 50;
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  justify-content: center;
  background: rgba(255, 255, 255, 0.94);
  border-radius: 20px;
  padding: 10px 14px;
  box-shadow: 0 6px 18px rgba(30, 80, 110, 0.25);
  max-width: calc(100vw - 16px);
}

#spawn-buttons { display: flex; gap: 7px; flex-wrap: wrap; justify-content: center; }
#toolbar-row2 { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; justify-content: center; }

.spawn-btn {
  width: 46px;
  height: 46px;
  border: 3px solid rgba(0, 0, 0, 0.18);
  border-radius: 11px;
  font-family: inherit;
  font-size: 21px;
  color: #fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
  cursor: pointer;
  box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.14), 0 2px 4px rgba(30, 80, 110, 0.18);
  transition: transform 0.08s;
}
.spawn-btn:active { transform: scale(0.9); }
/* Optical-centre the lone "1". The bundled Learninglings.ttf "1" was re-spaced
   (2026-09-27) from a tabular 1148-unit advance to a proportional 678 — the
   stock glyph's side-bearings made "816" read as "8 16" (Sorrymeerkat's bug
   shot). The stem now sits ~0.04em right of the box centre; nudge by that. */
.spawn-btn.lone-one { text-indent: -0.04em; }

/* ✨ The vocabulary strip — tappable examples of what the make box accepts,
   shown on its own line directly above the input while the input has focus.
   `flex: 0 0 100%` is what buys that: #toolbar-row2 already wraps, so a
   full-basis item claims a whole row and the fixed, bottom-anchored #toolbar
   grows UPWARD by itself. No fixed percentage, no viewport measurement — the
   strip is exactly as visible as the make box it serves, which is the property
   that makes it safe on a phone with the software keyboard up.
   Horizontal scroll rather than wrap: a wrapping strip could claim three rows of
   a short landscape viewport and shove the board off the screen. */
#make-tips {
  /* ⚠️ A DEFINITE width in viewport units, never `flex-basis: 100%`.
     #toolbar-row2 has no explicit width outside the phone media queries, so a
     100% basis is a cyclic dependency: the browser resolved it against the
     chips' own natural width (~1400px), grew row2 past #toolbar's
     max-width: calc(100vw - 16px), and overflowed the centred toolbar off BOTH
     edges of a 1280px screen — `try:` and `cat` clipped left, `mars` clipped
     right, on Chromium AND WebKit. Phones PASSED the same probe, because their
     media queries set #toolbar-row2 { width: 100% } and only there does the
     percentage mean what it looks like it means. A percentage measures the
     frame it is in, which is not always the frame you are thinking about.
     Being wider than the space left on the line is what still puts this on its
     own row; the toolbar is bottom-anchored, so that row grows UPWARD and the
     make box does not move under the player's finger. Probe asserts 0px. */
  flex: 0 0 auto;
  min-width: 0;
  /* Capped WELL under the toolbar's own natural width on a wide screen. If the
     strip were the widest thing in #toolbar it would set the toolbar's width,
     which re-packs #toolbar-row2 and moves the make box — the 106px bug, one
     level up. Staying narrower than the deck means it can only ever add a row. */
  width: min(100vw - 52px, 560px);
  display: flex;
  gap: 6px;
  align-items: center;
  overflow-x: auto;
  overflow-y: hidden;
  padding: 1px 2px 3px;
  scrollbar-width: none;
  -ms-overflow-style: none;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
}
#make-tips::-webkit-scrollbar { display: none; }
#make-tips.hidden { display: none; }
.make-tip {
  flex: 0 0 auto;
  height: 34px;
  padding: 0 13px;
  border: 2px solid #cdd7e0;
  border-radius: 999px;
  background: #f2f6f9;
  color: #2d3a4a;
  font-family: inherit;
  font-size: 15px;
  font-weight: 800;
  white-space: nowrap;
  cursor: pointer;
  touch-action: manipulation;
  transition: transform 0.08s;
}
.make-tip:active { transform: scale(0.93); }
/* The lead-in label is not a button and must not read as one. */
.make-tip-label {
  flex: 0 0 auto;
  font-size: 13px;
  font-weight: 800;
  color: #7c8a99;
  white-space: nowrap;
  padding-left: 2px;
}

#custom-form { display: flex; gap: 6px; }
/* Keyboard toggle: flips the make-input between numeric and letter keyboards so
   phone players can type words (a million, googol, wormhole). Muted so it never
   competes with Make; lights up teal when letters are on. */
#kbd-toggle {
  width: 44px;
  height: 44px;
  border: 3px solid #cdd7e0;
  border-radius: 11px;
  background: #f2f6f9;
  color: #7c8a99;
  font-family: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  transition: transform 0.08s;
}
#kbd-toggle:active { transform: scale(0.93); }
#kbd-toggle.on { background: #00b6e9; color: #fff; border-color: #00b6e9; }
#custom-input {
  width: 188px;
  height: 44px;
  border: 3px solid #cdd7e0;
  border-radius: 11px;
  padding: 0 12px;
  font-family: inherit;
  font-size: 17px;
  color: #2d3a4a;
  outline: none;
}
#custom-input:focus { border-color: #00b6e9; }
#custom-input.shake { animation: shake 0.35s; }
/* − mode: typed numbers become anti-blocks, so the box wears the − spawn
   buttons' dark look (main.js setOp toggles .anti). */
#custom-form.anti #custom-input { background: #1b1b22; color: #fff; border-color: #e0525a; }
#custom-form.anti #custom-input::placeholder { color: #9aa3ad; }
#custom-form.anti #custom-go { background: #1b1b22; }
@keyframes shake {
  25% { translate: -5px 0; }
  50% { translate: 5px 0; }
  75% { translate: -3px 0; }
}
#custom-go {
  height: 44px;
  padding: 0 16px;
  border: none;
  border-radius: 11px;
  background: #00b6e9;
  color: #fff;
  font-family: inherit;
  font-size: 17px;
  cursor: pointer;
  box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.14);
  transition: transform 0.08s;
}
#custom-go:active { transform: scale(0.93); }

/* The dice "surprise" spawner — a gold button next to Make. It rolls a random
   1–50 with a little slot-machine wobble before the block drops in. */
#dice-btn {
  width: 44px;
  height: 44px;
  border: 3px solid #ffb300;
  border-radius: 11px;
  background: linear-gradient(#ffe08a, #ffc233);
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
  box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.14);
  transition: transform 0.08s;
}
#dice-btn:active { transform: scale(0.9); }
#dice-btn.rolling { animation: dice-spin 0.5s linear infinite; }
@keyframes dice-spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

/* Quick recoil kick when the 💣 detonate button is pressed. */
#detonate-btn.boom { animation: detonate-kick 0.4s ease-out; }
@keyframes detonate-kick {
  0% { transform: scale(1); }
  18% { transform: scale(1.35) rotate(-8deg); }
  45% { transform: scale(0.92) rotate(5deg); }
  100% { transform: scale(1) rotate(0deg); }
}

/* The floating mystery box that flickers through numbers before it lands. */
.mystery-roll {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 70px;
  height: 70px;
  border-radius: 16px;
  background: linear-gradient(#ffe08a, #ffc233);
  border: 4px solid #fff;
  box-shadow: 0 6px 0 rgba(0, 0, 0, 0.15), 0 0 26px rgba(255, 193, 0, 0.7);
  font-family: inherit;
  font-size: 30px;
  color: #6a4500;
  z-index: 20;
  pointer-events: none;
  animation: mystery-shake 0.13s linear infinite;
}
@keyframes mystery-shake {
  0%, 100% { rotate: -4deg; translate: 0 0; }
  50% { rotate: 4deg; translate: 0 -3px; }
}
.mystery-roll.mystery-mul { font-size: 19px; font-weight: 700; letter-spacing: -0.5px; }
.mystery-roll.mystery-mega {
  font-size: 22px;
  font-weight: 800;
  letter-spacing: -0.5px;
  background: linear-gradient(#e9d5ff, #b57bff);
  border-color: #fff;
  box-shadow: 0 6px 0 rgba(0, 0, 0, 0.18), 0 0 30px rgba(157, 78, 221, 0.75);
  color: #3a1d5e;
}
.mystery-roll.land { animation: mystery-pop 0.28s ease-out forwards; }
@keyframes mystery-pop {
  to { transform: scale(1.5); opacity: 0; }
}

/* Big floating rarity banner on an Uncommon+ mega-roll reveal (hold-🎲 pull). */
.mega-banner {
  position: absolute;
  left: 50%;
  top: 34%;
  transform: translate(-50%, -50%);
  font-family: inherit;
  font-weight: 900;
  font-size: clamp(28px, 8vw, 56px);
  text-shadow: 0 3px 0 rgba(0, 0, 0, 0.18), 0 0 18px currentColor;
  z-index: 60;
  pointer-events: none;
  white-space: nowrap;
  animation: mega-banner-pop 1.5s ease-out forwards;
}
@keyframes mega-banner-pop {
  0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); }
  18% { opacity: 1; transform: translate(-50%, -50%) scale(1.12); }
  30% { transform: translate(-50%, -50%) scale(1); }
  80% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -60%) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .mega-banner { animation: mega-banner-fade 1.5s ease-out forwards; }
  @keyframes mega-banner-fade { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
}

#op-toggle {
  display: flex;
  height: 44px;
  border: 3px solid #cdd7e0;
  border-radius: 11px;
  overflow: hidden;
  background: #fff;
}
#op-toggle button {
  width: 32px;
  border: none;
  background: transparent;
  font-family: inherit;
  font-size: 22px;
  line-height: 1;
  color: #9aa7b4;
  cursor: pointer;
  transition: background 0.12s, color 0.12s;
}
#op-add.active { background: #7ac143; color: #fff; }
#op-sub.active { background: #00b6e9; color: #fff; }
#op-mul.active { background: #9b59b6; color: #fff; }
#op-div.active { background: #16a085; color: #fff; }
#op-pow.active { background: #e8730c; color: #fff; }
#op-fac.active { background: #d63447; color: #fff; }
#op-root.active { background: #2e8b57; color: #fff; }
#op-cbrt.active { background: #1f6b4a; color: #fff; }
/* The ∛ glyph renders small in most fonts — bump it so it reads at button size. */
#op-cbrt { font-size: 24px; }
/* The ^ glyph sits high on its line; nudge it down so it reads centred. */
#op-pow { font-size: 24px; padding-bottom: 4px; }
/* The ! glyph is tall and thin — bolden it so it reads as a real op button. */
#op-fac { font-weight: 800; }

/* One-time coachmark flash drawing the eye to − mode (box-shadow would be
   clipped by #op-toggle's overflow:hidden, so pulse the button fill instead). */
#op-sub.coach-pulse { animation: sub-coach 0.9s ease-in-out 4; }
@keyframes sub-coach {
  0%, 100% { background: transparent; color: #9aa7b4; }
  50% { background: #00b6e9; color: #fff; }
}

/* Same one-time coachmark for the 🎯 Challenge button when the invite bubble is
   up — so "Tap here!" and the button it leads to are visibly the same offer.
   #controls doesn't clip, so this one can use a proper glow ring. */
#challenge-btn.coach-pulse { animation: challenge-coach 1.1s ease-in-out 8; }
@keyframes challenge-coach {
  0%, 100% { border-color: #cdd7e0; box-shadow: 0 0 0 0 rgba(240, 138, 36, 0); }
  50% { border-color: #f08a24; box-shadow: 0 0 0 7px rgba(240, 138, 36, 0.3); }
}

#controls { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
/* 🎬 is contextual — hidden until two blocks have say-lines. display:none (not
   visibility) so it takes no slot in the flex-wrap flow and the toolbar keeps
   its existing two-row shape on a phone until the scene actually exists. */
#scene-btn.hidden { display: none; }
#zoom-btn, #mix-btn, #detonate-btn, #gravity-btn, #night-btn, #season-btn, #land-btn, #challenge-btn, #quiz-btn, #fart-btn, #draw-btn, #say-btn, #scene-btn, #zombie-btn, #emote-btn, #photo-btn, #sneeze-btn, #events-btn, #shinies-btn, #mask-btn, #mute-btn, #music-btn, #voice-btn, #undo-btn, #clear-btn, #settings-btn {
  width: 44px;
  height: 44px;
  border: 3px solid #cdd7e0;
  border-radius: 11px;
  background: #fff;
  font-size: 19px;
  cursor: pointer;
  transition: transform 0.08s;
}
#zoom-btn:active, #mix-btn:active, #detonate-btn:active, #gravity-btn:active, #night-btn:active, #season-btn:active, #land-btn:active, #challenge-btn:active, #quiz-btn:active, #fart-btn:active, #draw-btn:active, #say-btn:active, #scene-btn:active, #zombie-btn:active, #emote-btn:active, #photo-btn:active, #sneeze-btn:active, #events-btn:active, #shinies-btn:active, #mask-btn:active, #mute-btn:active, #music-btn:active, #voice-btn:active, #undo-btn:active, #clear-btn:active, #settings-btn:active { transform: scale(0.9); }

/* ---------- 📷 Photo mode ----------
   Hide every piece of UI chrome so the playground reads as a clean scene for a
   screenshot (Lpn201898 asked for a button that removes the tabs, settings and
   spawning). Opt-in: nothing changes until body.photo-mode is set. The blocks
   themselves stay live and draggable — only the overlays go. */
body.photo-mode #toolbar,
body.photo-mode #badge,
body.photo-mode #zoom-ctl,
body.photo-mode #hint,
body.photo-mode #challenge,
body.photo-mode #combo,
body.photo-mode #badge-coach,
body.photo-mode #ach-pop,
body.photo-mode #dotd,
body.photo-mode #playing,
body.photo-mode #wordmark,
body.photo-mode #settings-panel { display: none !important; }

/* The lone control left visible in photo mode. Small + dimmed so it barely
   shows in a screenshot; brightens when you reach for it. */
#photo-exit {
  position: fixed;
  top: 10px;
  left: 12px;
  z-index: 60;
  border: 2px solid rgba(120, 140, 160, 0.5);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.7);
  color: #48586a;
  font-family: inherit;
  font-size: 13px;
  padding: 6px 12px;
  cursor: pointer;
  opacity: 0.42;
  transition: opacity 0.2s, transform 0.08s;
  -webkit-tap-highlight-color: transparent;
}
#photo-exit.hidden { display: none; }
#photo-exit:hover, #photo-exit:focus-visible { opacity: 1; }
#photo-exit:active { transform: scale(0.94); opacity: 1; }
.night #photo-exit { background: rgba(40, 54, 78, 0.7); color: #cfe6f5; border-color: rgba(120, 140, 160, 0.45); }
/* Every toggle button inside a settings row shares the round toolbar look. Keyed
   off the row (not the hardcoded id list above) so a newly added toggle — e.g.
   🤬 Temper — picks up the style automatically instead of rendering as a bare
   browser button (TheSecondaryBlob). Identical values to the id list, so the
   buttons already listed there are unchanged. */
.settings-row > button {
  width: 44px;
  height: 44px;
  border: 3px solid #cdd7e0;
  border-radius: 11px;
  background: #fff;
  font-size: 19px;
  cursor: pointer;
  transition: transform 0.08s;
}
.settings-row > button:active { transform: scale(0.9); }
#quiz-btn.active { border-color: #ffd100; background: #fff8e6; }
/* Lit up while the settings popover is open. */
#settings-btn.active { border-color: #6b8299; background: #eef2f6; }

/* ---------- settings popover (⚙️) ----------
   A small card that pops up above the ⚙️ button holding the preference toggles
   (sound / music / voice / time of day / weather). JS positions it on open. */
#settings-panel {
  position: fixed;
  z-index: 78;               /* above the toolbar (50), below the gallery (80) */
  background: rgba(255, 255, 255, 0.98);
  border-radius: 16px;
  padding: 12px 14px;
  box-shadow: 0 8px 26px rgba(30, 80, 110, 0.3);
  border: 1px solid #dce4ec;
  /* On a short/landscape phone the growing list of rows was taller than the
     space above the ⚙️ button, so the top rows (Sound, Music…) fell off the top
     of the screen and were unreachable (united_airlines). positionSettings caps
     max-height to the room above the button; this lets it scroll instead of
     clipping. On tall screens the cap is huge so nothing scrolls — no change. */
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}
#settings-panel.hidden { display: none; }
/* Scroll affordance: a soft fade + chevron pinned to the panel's visible bottom
   edge, revealed (via .more-below, toggled in JS on scroll) only while rows sit
   below the fold. Sticky bottom keeps it at the scrollport edge; the negative
   margins bleed it over the last visible row and out to the panel's padded,
   rounded corners. pointer-events:none so it never eats a tap on the row under it. */
#settings-scrollcue {
  position: sticky;
  bottom: 0;
  margin: -26px -14px -12px;
  height: 30px;
  display: none;
  pointer-events: none;
  text-align: center;
  font-size: 15px;
  font-weight: 700;
  line-height: 34px;
  color: #7089a0;
  background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.97) 62%);
  border-radius: 0 0 15px 15px;
}
#settings-panel.more-below #settings-scrollcue { display: block; }
#settings-title {
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #6b8299;
  margin: 0 2px 8px;
}
#settings-grid { display: flex; flex-direction: column; gap: 7px; }
.settings-row { display: flex; align-items: center; gap: 10px; }
.settings-row span { font-size: 15px; color: #3a4a5a; white-space: nowrap; }
/* Eight of these rows named a feature and explained it ONLY in the `title`
   attribute — which renders on hover, and 74.3% of this project's views are
   touch (7 days of $pageview: 24,131 mobile + 5,295 tablet vs 10,189 desktop).
   So three players in four met a 🌍 button, the words "World feed", and no way
   to find out what it meant; faintwood6935456 asked exactly that
   ("What World feed????"). The other seven are the same shape, including Base —
   the dropdown whose 38 numeral modes players already couldn't find.
   The explanation now renders under the label as a visible sub-caption; the
   `title` stays for mouse users, so nothing is lost and nothing moves. The nine
   self-evident rows (Sound, Music, Land, Cat mode…) get no caption — captioning
   all sixteen would just double the panel's height for no gain. */
.settings-row .settings-label { display: flex; flex-direction: column; gap: 1px; line-height: 1.2; }
.settings-row .settings-label small {
  font-size: 11px;
  color: #7089a0;
  /* Label lines stay nowrap (inherited above); captions are the one thing in
     this panel allowed to wrap, capped so a long one can't stretch the popover
     wider than a narrow phone. */
  white-space: normal;
  max-width: 178px;
}
/* Base is the one captioned row built around a <select>, and its label sits
   AFTER that select — so an inline caption adds to the row's minimum width 1:1.
   Measured: it was already the widest row in the panel (19 + 204 + 30 + gaps =
   exactly the 273px content box), so captioning it inline pushed the whole
   popover 303 -> 406px and overflowed a 320px phone. Its caption gets its own
   line instead: flex-basis 100% forces the wrap, and because the caption's
   min-content is just its longest word, the row's minimum width is unchanged.
   Costs 13px of height on one row. The seven toggle rows stay inline — there a
   caption is free, since the 44px touch target already sets the row height. */
/* Two flex tricks were tried here and BOTH leaked width, which is why this one
   is out of flow. `flex-wrap: wrap` alone took the row's max-content 273 -> 454
   (a flex container sizes max-content as if every item shared one line, wrap or
   not), and `width: 0; min-width: 100%` got it to 312 — the stubborn +39 being
   the caption's own margin and the 10px flex gap, which count toward an item's
   contribution even when its width is zero. An absolutely positioned child
   contributes nothing at all, so the row measures 273 exactly as before and the
   panel stays 303px. The reserved padding-bottom is what makes room for it. */
.settings-row-capbelow { position: relative; padding-bottom: 13px; }
.settings-cap-below {
  position: absolute;
  left: 29px;    /* lines up under the label column */
  right: 0;
  bottom: -1px;
  font-size: 11px;
  color: #7089a0;
  white-space: normal;
}
/* Volume slider row sits just under Music; the emoji stands in for the toggle
   button so the row lines up with the button column above it. */
.settings-slider-row .slider-emoji { font-size: 19px; width: 34px; text-align: center; }
.settings-slider {
  flex: 1;
  min-width: 90px;
  accent-color: #f5a623;   /* warm to match the toolbar's active states */
  height: 22px;
  cursor: pointer;
}
/* Lit up while a challenge is running. */
#challenge-btn.active { border-color: #f5a623; background: #fff4e0; }
/* Number-base picker: a native choice box (not a cycling button) that scales to
   many bases. Lit up while an alternate base is on. */
/* All the Settings pickers (Voice, Base, Land, Sky, Weather, Music mood) share
   one look so they read as the same kind of control — the .active class is
   toggled on each in JS. */
#base-select, #land-select, #voice-select, #sky-select, #season-select, #music-mood-select {
  flex: 1;
  min-width: 90px;
  height: 34px;
  border: 3px solid #cdd7e0;
  border-radius: 11px;
  background: #fff;
  font-family: inherit;
  font-size: 14px;
  color: #3a4a5a;
  padding: 0 6px;
  cursor: pointer;
}
#base-select.active, #land-select.active, #voice-select.active,
#sky-select.active, #season-select.active, #music-mood-select.active { border-color: #2fb1c9; background: #e7f8fc; font-weight: 700; }
/* Lit up while zero gravity is on — a cosmic violet glow. */
#gravity-btn.active { border-color: #8a2be2; background: #f3ecff; }
/* Lit up while fart mode is on — a cheeky green. */
#fart-btn.active { border-color: #7ac143; background: #eefbe3; }
/* Lit up while draw mode is on — a painterly blue. */
#draw-btn.active { border-color: #00a0d6; background: #e4f6fd; }
/* Lit up while say mode is on — a chatty violet. */
#say-btn.active { border-color: #8e6ad6; background: #f1ebfc; }
/* Lit up while a 🎬 scene is playing — a film-reel red, so the button reads as
   "recording, tap to stop". renderSceneBtn sets .active; every other toolbar
   toggle has a matching lit rule and this one was missing it at first — the
   same omission #emote-btn shipped with, which two players reported as
   "doesn't look like the other buttons". */
#scene-btn.active { border-color: #d6483f; background: #fdecea; }
/* Lit up while zombie mode is on — a sickly infected green. */
#zombie-btn.active { border-color: #4a7c2f; background: #e6f5df; }
/* Lit up while 😀 emotions mode is on — a warm happy-face amber. (The button
   was missing every shared #controls rule — base pill, :active, mobile size,
   and this lit state — so it rendered as a bare default button; TheSecondaryBlob
   + differentfirefly both flagged it "doesn't look like the other buttons".) */
#emote-btn.active { border-color: #e0a92b; background: #fff6e0; }
/* 🧟 Infected blocks wear a zombie face: a dead, unblinking half-lidded stare
   with dull, lifeless pupils — the green tint alone read as merely "recolored",
   not undead (AimDart + baremammoth asks). Killing the blink lets the droop
   transform stick. Night's sleeping droop (html.night.sleeping .eye) wins by
   specificity, and moans are skipped while dozing anyway. */
.block-zombie .eye {
  animation: none;
  transform: scaleY(0.5) translateY(45%);
  transform-origin: center bottom;
  background: #23121a;
}
.block-zombie .eye::after { opacity: 0.4; }
/* An anti (negative) block's body is near-black (#23232c), so the zombie's dark
   dead pupil (#23121a above) sank into it and the eyes vanished entirely
   (lmadeBattleForRegularCakeUdidnt). Anti blocks already flip their eyes light
   (#eef0f6, .block-negative .eye) but the later zombie rule overrode it at equal
   specificity. Give a negative zombie its own dull, sickly pale-green stare so the
   dead half-lidded look still reads on the dark body; the droop transform sticks. */
.block-negative.block-zombie .eye { background: #aeb69a; }
/* 🧟 A zombie's moan bubble — same shape as the sneeze bubble but a sickly
   undead green, drifting up over whichever zombie the moan scheduler picks. */
.zombie-moan {
  position: absolute;
  top: -42px;
  left: 50%;
  transform: translateX(-50%);
  font-size: 15px;
  font-weight: 800;
  line-height: 1;
  padding: 4px 9px;
  white-space: nowrap;
  background: #eaf6e0;
  color: #2f5a22;
  border-radius: 999px;
  box-shadow: 0 2px 6px rgba(30, 50, 20, 0.3);
  pointer-events: none;
  z-index: 7;
  animation: zombie-moan-in 0.5s ease both;
}
@keyframes zombie-moan-in {
  from { opacity: 0; transform: translateX(-50%) translateY(8px) scale(0.7) rotate(-3deg); }
  to   { opacity: 1; transform: translateX(-50%) translateY(0) scale(1) rotate(0); }
}
/* Lit up while Nine's sneeze is on — a pale pollen yellow. */
#sneeze-btn.active { border-color: #e6b800; background: #fdf6d8; }
/* Lit up while timed events are on — a festive party-popper pink. */
#events-btn.active { border-color: #e0559a; background: #fdeaf4; }
#shinies-btn.active { border-color: #e0b400; background: #fff8d6; }
/* Lit up while sleep masks are on — a soft nighttime purple, matching the mask. */
#mask-btn.active { border-color: #5a5280; background: #ece9f6; }
/* Lit up while temper explosions are on — an angry red, matching 🤬. */
#temper-btn.active { border-color: #e2231a; background: #ffe9e7; }
/* Lit up while zoomed out, so it reads as an active "wide view" toggle. */
#zoom-btn.active { border-color: #00b6e9; background: #e6f7fd; }
#clear-btn.armed { border-color: #e2231a; background: #ffe9e7; }
/* ↩️ with no step to go back to: dimmed, still tappable (it says why). */
#undo-btn.undo-empty { opacity: 0.45; }
/* ↪️ floats just above ↩️ (left/top set by renderUndoBtn) so showing it never
   reflows the bottom-anchored toolbar. */
#redo-btn {
  position: fixed; z-index: 61; width: 40px; /* over #toast (60) */ height: 40px;
  border: 2px solid #3aa6df; border-radius: 12px; background: #fff;
  font-size: 20px; line-height: 1; padding: 0; cursor: pointer;
  box-shadow: 0 3px 10px rgba(30, 80, 110, 0.25);
  animation: redo-pop 0.18s ease-out;
}
#redo-btn.hidden { display: none; }
#redo-btn:active { transform: scale(0.9); }
body.photo-mode #redo-btn { display: none; }
@keyframes redo-pop { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } }

/* Frozen state: icy border + a soft pulse so it reads as "tap to release". */
#mix-btn.frozen {
  border-color: #4db8ff;
  background: #e8f6ff;
  animation: mix-pulse 1.1s ease-in-out infinite;
}
@keyframes mix-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(77, 184, 255, 0.55); }
  50% { box-shadow: 0 0 0 8px rgba(77, 184, 255, 0); }
}
/* A gentle full-screen chill while frozen, so the mode is unmistakable. */
html.frozen #grass::after {
  content: '';
  position: fixed;
  inset: 0;
  pointer-events: none;
  box-shadow: inset 0 0 140px rgba(77, 184, 255, 0.2);
  z-index: 4;
}

/* 0 ÷ 0 easter egg: a swirling portal opens where the Zero was, and the whole
   world briefly flips to a mirror "other universe" before snapping back. The
   veil sits below the portal (z 22 < 25) so the portal stays vivid above the
   inverted scene. */
.wormhole {
  position: absolute;
  width: 22px; height: 22px;
  margin: -11px 0 0 -11px;
  border-radius: 50%;
  background: conic-gradient(from 0deg, #8a2be2, #00e5ff, #ff2d95, #ffe600, #8a2be2);
  /* RGB-split chromatic aberration + the base violet glow — sells the "torn
     signal" glitch look (summerwinter's ask to make the portal glitchier). */
  box-shadow: 0 0 34px 12px rgba(138, 43, 226, 0.75), inset 0 0 14px rgba(0,0,0,0.6),
              -6px 0 10px 0 rgba(255, 0, 90, 0.55), 6px 0 10px 0 rgba(0, 229, 255, 0.55);
  z-index: 25;
  pointer-events: none;
  /* two tracks: the scale/spin open, plus a fast positional jitter (margin, so
     it never fights the transform on wormhole-open) that makes it stutter. */
  animation: wormhole-open 1.7s ease-in-out forwards,
             wormhole-jitter 0.09s steps(2, end) infinite;
}
@keyframes wormhole-open {
  0%   { transform: scale(0) rotate(0deg);     opacity: 0; }
  18%  { transform: scale(8) rotate(420deg);   opacity: 1; }
  72%  { transform: scale(8) rotate(960deg);   opacity: 1; }
  100% { transform: scale(0) rotate(1440deg);  opacity: 0; }
}
@keyframes wormhole-jitter {
  0%   { margin: -11px 0 0 -13px; }
  33%  { margin: -13px 0 0 -8px; }
  66%  { margin: -9px 0 0 -12px; }
  100% { margin: -11px 0 0 -10px; }
}
.otherverse-veil {
  position: fixed;
  inset: 0;
  z-index: 22;
  pointer-events: none;
  overflow: hidden;
  /* The mirror-universe wash used backdrop-filter: invert(1), which some mobile
     browsers composite to a solid BLACK screen when the captured backdrop sits
     under a transformed ancestor (#playground scales on zoom) — a black-out that
     read as "everything turns black" when a phone player typed the "wormhole"
     secret (NotFound's report). hue-rotate/saturate/contrast recolour the world
     just as eerily but can never force black, and the richer purple gradient
     stands in on its own for any device that drops backdrop-filter entirely. */
  background: radial-gradient(circle at 50% 45%, rgba(138,43,226,0.30), rgba(28,0,52,0.44));
  -webkit-backdrop-filter: hue-rotate(180deg) saturate(1.7) contrast(1.08);
  backdrop-filter: hue-rotate(180deg) saturate(1.7) contrast(1.08);
  animation: otherverse-fade 1.7s ease-in-out forwards;
}
/* CRT scanlines rolling down the mirror universe. */
.otherverse-veil::before {
  content: '';
  position: absolute;
  inset: -20px 0;
  background: repeating-linear-gradient(0deg,
    rgba(0,0,0,0.22) 0 1px, transparent 1px 4px);
  mix-blend-mode: overlay;
  animation: veil-scan 0.4s linear infinite;
}
@keyframes veil-scan { to { transform: translateY(4px); } }
/* Chromatic tear bands that stutter side to side over the whole screen. */
.otherverse-veil::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    linear-gradient(90deg, rgba(255,0,90,0.12), transparent 42%, transparent 58%, rgba(0,229,255,0.12)),
    repeating-linear-gradient(0deg, transparent 0 34px, rgba(255,255,255,0.06) 34px 36px);
  mix-blend-mode: screen;
  animation: veil-tear 0.16s steps(3, end) infinite;
}
@keyframes veil-tear {
  0%   { transform: translateX(-4px); }
  50%  { transform: translateX(5px); }
  100% { transform: translateX(-2px); }
}
@keyframes otherverse-fade {
  0% { opacity: 0; }
  20%, 74% { opacity: 1; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  /* keep the eerie mirror wash, drop the strobe/jitter for motion-sensitive folks */
  .wormhole { animation: wormhole-open 1.7s ease-in-out forwards; }
  .otherverse-veil::before, .otherverse-veil::after { animation: none; }
}

@media (max-width: 640px) {
  #wordmark { font-size: 15px; }
  /* ---- touch targets ----------------------------------------------------
     This block used to SHRINK every control below the 44px the same buttons
     already get on desktop — the device with fingers got smaller targets than
     the device with a pixel-precise cursor. The cause was a layout hand-tuned
     for a 320px phone: `#spawn-buttons { width: 282px }` pinned the whole deck
     to 302px, so a 390px phone carried ~88px of dead margin either side while
     the op row sat at 30x34 with ZERO gap between its eight buttons.

     Worst case was `#op-toggle`: mis-tapping it silently changes what EVERY
     later drag does (+ vs − starts spawning anti-blocks), with no way for a
     child to tell what happened. So the deck now spans the screen and the rows
     share that width — targets grow with the device instead of staying frozen
     at the smallest phone. Widths are clamped so a 320px screen only ever gets
     BIGGER than it is today, never smaller.

     Costs no vertical space: #controls gains a column and drops from three
     rows to two on a >=360px screen, which more than pays for the op row. */
  #toolbar { flex-direction: column; flex-wrap: nowrap; gap: 8px; padding: 8px; bottom: 8px;
             width: calc(100vw - 16px); }
  /* six per row (0–5, then 6–10), sized from what the screen actually offers:
     inner width is 100vw-32, less five 6px gaps => (100vw - 62) / 6 */
  #spawn-buttons { width: 100%; gap: 6px; }
  #toolbar-row2 { flex-wrap: wrap; gap: 8px; justify-content: center; width: 100%; }
  .spawn-btn { width: clamp(40px, calc((100vw - 62px) / 6), 52px); height: 40px; font-size: 18px; }
  #custom-input { width: 84px; height: 40px; }
  #custom-go, #kbd-toggle, #dice-btn, #mix-btn, #detonate-btn, #gravity-btn, #challenge-btn, #quiz-btn, #fart-btn, #draw-btn, #say-btn, #scene-btn, #zombie-btn, #emote-btn, #undo-btn, #clear-btn, #settings-btn { height: 40px; }
  #kbd-toggle { width: 40px; }
  #dice-btn, #mix-btn, #detonate-btn, #gravity-btn, #challenge-btn, #quiz-btn, #fart-btn, #draw-btn, #say-btn, #scene-btn, #zombie-btn, #emote-btn, #undo-btn, #clear-btn, #settings-btn { width: 40px; }
  #controls { width: 100%; }
  /* The mode row is the one control where a mis-tap is both likely and silent,
     so it gets the whole width to itself: eight equal shares of (100vw - 38)
     — 44px on a 390px phone, up from a fixed 30px. 44 tall (not 40) so the
     3px border doesn't eat the button down to a 34px strip. */
  #op-toggle { height: 44px; width: 100%; }
  #op-toggle button { flex: 1 1 0; width: auto; min-width: 0; font-size: 19px; }
  #hint { font-size: 16px; }
  /* keep the badge from overflowing a narrow phone — wrap, stay right-aligned */
  #badge { flex-wrap: wrap; justify-content: flex-end; max-width: 58vw; gap: 4px 8px; padding: 5px 11px; }
  #badge-count { font-size: 15px; }
}

/* A tablet is wider than 640px, so the phone block above never applies to it —
   but it still has fingers, and the base 32x38 op button is a MOUSE size.
   Tablets are ~15% of this project's pageviews. Key off the pointer rather than
   the width, which is the property that actually matters here, and only for the
   one control where a mis-tap silently changes every later drag. */
@media (pointer: coarse) and (min-width: 641px) {
  /* Growing the op buttons to 44px on their own makes #op-toggle ~358px wide,
     which is more than the content-sized 386px deck can fit beside the 🎲 — so
     the dice got stranded alone on a row and the deck grew 62px taller. The
     cause is the same dead-space bug as the phone had: a 768px tablet was
     rendering a 386px deck. Widen it and the row packs as it did before. */
  #toolbar { width: min(560px, 100vw - 32px); }
  /* A wider deck fits ten spawn buttons on a line, which strands the 10 by
     itself. The palette reads as 0–5 / 6–10 on every other layout, so pin it to
     six columns explicitly rather than letting the deck width decide. */
  #spawn-buttons { max-width: calc(6 * 46px + 5 * 7px); }
  #op-toggle { height: 50px; }                /* less the 3px border either side => 44 tall */
  #op-toggle button { min-width: 44px; }
}

/* ---------- free-draw shape editor (× mode → "draw your own") ----------
   BlackyWebs' idea: arrange a block's cubes into any picture on a grid. Mirrors
   the gallery modal look; sits above every other overlay so it's a real focus. */
#shape-editor {
  position: fixed;
  inset: 0;
  z-index: 96;
  background: rgba(20, 40, 60, 0.55);
  backdrop-filter: blur(2px);
  display: flex;
  /* flex-start + margin:auto on the card centres it when it fits but lets you
     scroll to the very top when it's taller than the viewport (align-items:center
     would clip the top off-screen with no way to reach it). */
  align-items: flex-start;
  justify-content: center;
  padding: 16px;
  /* The whole card can overflow a short phone screen (title + name + palette +
     grid + buttons ≈ 670px). Let the overlay scroll vertically so the Draw/Done
     button is always reachable — Greendetector couldn't draw because it sat below
     the fold. pan-y allows that swipe; the grid keeps its own touch-action:none
     so drawing on it never scrolls the card. */
  overflow-y: auto;
  touch-action: pan-y;
}
#shape-editor.hidden { display: none; }
#shape-card {
  position: relative;
  background: #fff;
  border: 3px solid #ffd100;
  border-radius: 22px;
  box-shadow: 0 12px 34px rgba(20, 50, 80, 0.35);
  padding: 16px 16px 14px;
  width: min(360px, 92vw);
  margin: auto;
  text-align: center;
}
#shape-title { margin: 2px 0 2px; font-size: 20px; color: #2d3a4a; }
#shape-name {
  width: min(240px, 70vw);
  box-sizing: border-box;
  margin: 4px auto 8px;
  display: block;
  /* Inputs don't inherit font-family by default, so without this the name
     field rendered in the plain UA font instead of Learninglings like the
     rest of the game (this2). Matches #custom-input. */
  font-family: inherit;
  font-size: 14px;
  font-weight: 700;
  text-align: center;
  border: 2px solid #6ea8ff;
  border-radius: 8px;
  padding: 4px 6px;
  color: #33475b;
}
#shape-name::placeholder { color: #9fb3c8; font-weight: 600; }
#shape-count { margin: 0 0 12px; font-size: 14px; color: #1f9c5b; font-weight: 800; }
#shape-count.done { color: #f0a500; }
#shape-palette {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  justify-content: center;
  margin: 0 auto 12px;
  max-width: min(280px, 78vw);
}
.swatch {
  width: 26px;
  height: 26px;
  border-radius: 8px;
  border: 2px solid rgba(0, 0, 0, 0.15);
  padding: 0;
  cursor: pointer;
  box-shadow: 0 1px 2px rgba(20, 50, 80, 0.18);
  transition: transform 0.08s;
}
.swatch:active { transform: scale(0.9); }
.swatch.sel {
  border-color: #2d3a4a;
  transform: scale(1.16);
  box-shadow: 0 0 0 2px #fff, 0 0 0 4px #2d3a4a;
}
.swatch-custom {
  position: relative;
  overflow: hidden;
  background: conic-gradient(from 0deg, #e2231a, #ff8300, #ffd100, #7ac143, #00b6e9, #6e5ab0, #ec008c, #e2231a);
}
.swatch-custom input[type="color"] {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: none;
  opacity: 0;
  cursor: pointer;
}
#shape-expr {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  justify-content: center;
  margin: 0 auto 12px;
}
.expr-choice {
  width: 34px;
  height: 34px;
  font-size: 19px;
  line-height: 1;
  border-radius: 9px;
  border: 2px solid rgba(0, 0, 0, 0.15);
  background: #fff;
  padding: 0;
  cursor: pointer;
  box-shadow: 0 1px 2px rgba(20, 50, 80, 0.18);
  transition: transform 0.08s;
}
.expr-choice:active { transform: scale(0.9); }
.expr-choice.sel {
  border-color: #2d3a4a;
  transform: scale(1.12);
  box-shadow: 0 0 0 2px #fff, 0 0 0 4px #2d3a4a;
}
#shape-sizer {
  display: flex;
  align-items: center;
  gap: 10px;
  justify-content: center;
  width: min(280px, 78vw);
  margin: 0 auto 10px;
}
#shape-size-label {
  font-size: 13px;
  font-weight: 800;
  color: #2d3a4a;
  white-space: nowrap;
}
#shape-size-num {
  width: 3.2em;
  font-size: 14px;
  font-weight: 800;
  color: #2d3a4a;
  text-align: center;
  padding: 3px 4px;
  border: 2px solid #cdd6e2;
  border-radius: 8px;
  background: #fff;
  font-family: inherit;
}
#shape-size-num:focus {
  outline: none;
  border-color: #00b6e9;
}
#shape-size-mirror {
  font-size: 13px;
  font-weight: 800;
  color: #2d3a4a;
  white-space: nowrap;
}
#shape-size-range {
  flex: 1;
  accent-color: #00b6e9;
  height: 22px;
  cursor: pointer;
  touch-action: auto;
}
#shape-size-note {
  display: none;
  font-size: 12px;
  font-weight: 700;
  color: #6b7a8d;
  margin: -4px auto 10px;
  max-width: min(280px, 78vw);
  line-height: 1.3;
}
#shape-grid {
  display: grid;
  gap: 3px;
  width: min(280px, 78vw);
  margin: 0 auto 14px;
  touch-action: none;
}
.scell {
  aspect-ratio: 1;
  border-radius: 5px;
  background: #eef1f5;
  box-shadow: inset 0 0 0 1px #dce1e8;
  transition: background 0.06s, transform 0.06s;
}
.scell.on {
  /* --edge = the outline the committed cube gets (paintCell in main.js). */
  box-shadow: inset 0 0 0 2px var(--edge, rgba(0, 0, 0, 0.18));
  transform: scale(1.04);
}
#shape-btns { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.shape-btn {
  border: none;
  border-radius: 14px;
  padding: 9px 14px;
  font-size: 15px;
  font-weight: 800;
  cursor: pointer;
  color: #fff;
  font-family: inherit;
}
.shape-btn:active { scale: 0.94; }
#shape-rainbow {
  background: conic-gradient(from 0deg, #e2231a, #ff8300, #ffd100, #7ac143, #00b6e9, #6e5ab0, #ec008c, #e2231a);
  padding: 9px 12px;
}
#shape-checker {
  background:
    repeating-conic-gradient(#2b2f38 0deg 90deg, #e9edf2 90deg 180deg) 0 0 / 14px 14px;
  padding: 9px 12px;
}
#shape-clear { background: #9aa6b4; }
#shape-reset-def { background: #6b7a8d; }
#shape-reset-def.armed { background: #e2231a; }
#shape-costume { background: #8a8f98; }
#shape-costume.on { background: #6e5ab0; }
#shape-noface { background: #8a8f98; }
#shape-noface.on { background: #6e5ab0; }
/* 👒 accessory picker: a native <select> dressed as a chip (one tap on phones
   opens the OS list). Grey at rest, purple once something is worn, like 🎩. */
#shape-accessory { background: #8a8f98; appearance: none; -webkit-appearance: none; max-width: 170px; text-align: center; }
#shape-accessory.on { background: #6e5ab0; }
#shape-accessory option { color: #222; background: #fff; }
.shape-btn.dimmed { opacity: 0.4; cursor: default; }
#shape-cancel { background: #e56b6f; }
#shape-done { background: #1f9c5b; }
#shape-done:disabled { background: #cbd3dc; color: #eef1f5; cursor: default; }
#shape-face { background: #6e5ab0; padding: 9px 12px; }
#shape-rect { background: #00b6e9; padding: 9px 12px; font-size: 18px; }
.shape-btn.active { outline: 3px solid #ffd100; outline-offset: 1px; }
/* The picked first corner while waiting for the opposite corner. */
.scell.rect-pick {
  box-shadow: inset 0 0 0 2px #00b6e9, 0 0 0 2px #ffd100;
  position: relative;
  z-index: 1;
}
/* In face mode the placed cubes are the pickable targets; dim the empty ones. */
#shape-grid.face-mode .scell:not(.on) { opacity: 0.45; }
.scell.face-pick {
  box-shadow: inset 0 0 0 2px #1f9c5b, 0 0 0 2px #ffd100;
  position: relative;
  z-index: 1;
}
.scell.face-pick::after {
  content: '😊';
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: clamp(10px, 3.5vw, 20px);
  pointer-events: none;
  /* half-cube nudge + size the artist picked (setFace in main.js) */
  transform: translate(calc(var(--fdx, 0) * 100%), calc(var(--fdy, 0) * 100%)) scale(var(--fsz, 1));
}
/* Before you pick a cube, show where the face lands by default (the auto spot),
   so face mode never looks like it did nothing — tap any cube to move it. */
.scell.face-auto {
  box-shadow: inset 0 0 0 2px #6e5ab0;
  position: relative;
  z-index: 1;
}
.scell.face-auto::after {
  content: '😊';
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: clamp(10px, 3.5vw, 20px);
  opacity: 0.6;
  pointer-events: none;
}

/* ---- short viewports (landscape phones) --------------------------------
   Every layout rule in this file keys off WIDTH. There has never been a
   height or orientation rule, so the toolbar is ~343px tall no matter what
   it is sitting on. Measured on an 844x390 landscape phone: the deck covers
   the screen from y=39 to y=376 and the playground is a THIRTY-NINE PIXEL
   strip — the blocks are jammed behind the title and the game cannot be
   played at all. A 640x360 screen was worse: NINE pixels.

   A landscape phone is the one shape where the old layout is exactly
   backwards: it is starved for height and swimming in width, while the deck
   is pinned narrow (560px of an 844px screen, ~140px of dead margin each
   side) and allowed to grow tall. So on a short screen the deck spends the
   width it has to buy back the height it does not: rows span the viewport,
   the spawn deck straightens into one line of eleven instead of 6+5, and the
   button metrics come down from the touch sizes above — a coarse-pointer
   44px target is not worth much on a screen where there is nothing to touch.

   Gated on height ALONE, so no portrait phone, tablet or desktop that is
   laid out correctly today can be reached by any of it. */
@media (max-height: 560px) {
  #toolbar {
    flex-direction: column;
    flex-wrap: nowrap;
    gap: 4px;
    padding: 5px 8px;
    bottom: 5px;
    width: min(100vw - 12px, 1200px);
  }
  /* One straight line of 0–10. The 6+5 split exists to keep the deck narrow
     on a phone held upright; here width is the surplus, so spending it on a
     single row removes a whole ~46px band from the deck's height. */
  #spawn-buttons { width: 100%; gap: 4px; flex-wrap: nowrap; }
  .spawn-btn {
    width: clamp(30px, calc((100vw - 66px) / 11), 46px);
    height: 34px;
    font-size: 15px;
  }
  #toolbar-row2 { flex-wrap: wrap; gap: 5px; justify-content: center; width: 100%; }
  #controls { width: auto; gap: 4px; }
  #controls button { width: 34px; height: 34px; font-size: 16px; }
  #op-toggle { height: 36px; width: auto; }
  #op-toggle button { width: 32px; min-width: 0; flex: 0 0 auto; font-size: 16px; }
  #custom-input { height: 32px; font-size: 14px; }
  #custom-go, #kbd-toggle { height: 32px; font-size: 14px; }
  #dice-btn { width: 36px; height: 36px; font-size: 18px; }
  /* The strip costs a whole row on the viewport that has the least of them, so
     it pays that row at the same scale everything else here does. */
  #make-tips { padding: 0 2px 2px; gap: 5px; }
  .make-tip { height: 27px; padding: 0 10px; font-size: 13px; }
  .make-tip-label { font-size: 11px; }
}

/* 🌍 Live feed of what other players are building. Parked under the 👥 chip so
   the two read as one "other people" region. Deliberately a different colour
   family from every existing banner — #ach-pop is gold and means YOU did
   something, #toast is slate and means the game is talking to you. Teal-on-white
   here means somebody else did something, and nothing in the game has ever used
   it for a player's own action. Right-anchored and width-capped so a long name
   ("a quattuordecillion") wraps inside the corner instead of crossing the board.
   Pointer-events off: it is news, never a control, and must never eat a tap
   meant for a block underneath it. */
#world-pop {
  position: fixed;
  top: 78px;
  right: 12px;
  z-index: 29;
  max-width: min(66vw, 280px);
  background: rgba(255, 255, 255, 0.94);
  border: 2px solid #2bb3a3;
  border-radius: 14px;
  padding: 6px 11px;
  font-size: 12.5px;
  font-weight: 700;
  line-height: 1.32;
  color: #0f6f65;
  text-align: right;
  box-shadow: 0 3px 10px rgba(20, 90, 85, 0.2);
  pointer-events: none;
  user-select: none;
  opacity: 0;
  translate: 0 -6px;
  transition: opacity 0.28s ease, translate 0.28s ease;
}
#world-pop.show { opacity: 1; translate: 0 0; }
#world-pop.hidden { display: none; }
#world-pop b { color: #0b544c; }
/* When the count chip is hidden (fewer than 2 people, or no backend at all)
   there is nothing above to hang off, so sit where the chip would have been. */
#world-pop.solo { top: 44px; }


/* 🪁 The shared sky — other players' numbers drifting past, tappable to catch.
   See the long rationale on `export const room` in server.js and the 🪁 block in
   src/main.js. Two rules matter here and both are about not harming the game
   that already works:

   1. The LAYER is pointer-events: none and only the ghost itself takes a tap.
      A ghost is the one element in this game that can sit over a block, and a
      block is the primary interactive object — the whole grab-slop halo below
      .block exists because a 32px target with no slop was missing real thumbs.
      So the ghost is deliberately small, kept in an upper band well clear of
      where blocks pile up (the top is computed in pixels, not per-cent, so a
      sideways phone doesn't get ghosts through its HUD), and semi-transparent
      so it never reads as a block you can merge.
   2. z-index 20 sits ABOVE #playground (10) so a ghost is visible and tappable,
      and BELOW #world-pop (29) and every panel, so news and menus always win.

   Colour: the ghost wears its own number's canon fill, at low opacity with a
   dashed edge. Dashed + translucent is the "not really here" language — nothing
   else in the game uses it, so a ghost cannot be mistaken for a real block. */
#sky-layer {
  position: fixed;
  inset: 0;
  z-index: 20;
  pointer-events: none;
  overflow: hidden;
}

.sky-ghost {
  position: absolute;
  left: 0;
  pointer-events: auto;
  min-width: 44px;      /* the mobile-touch-targets floor — never smaller */
  min-height: 44px;
  padding: 4px 12px;
  border: 2.5px dashed color-mix(in srgb, var(--sky-fill) 70%, #ffffff);
  border-radius: 14px;
  background: color-mix(in srgb, var(--sky-fill) 34%, rgba(255, 255, 255, 0.72));
  color: #22303a;
  font-family: inherit;
  font-size: 20px;
  font-weight: 800;
  line-height: 1.15;
  /* One line, always. The ghost wears `displayNumeral()`, and the player may
     have picked any of the game's 38 numeral bases — 9999 in base 2 is fourteen
     characters. Wrapping would break the one-row-per-ghost placement that keeps
     ghosts from printing over each other. max-width keeps even the longest
     numeral inside a phone screen; nothing truncates at these lengths. */
  white-space: nowrap;
  max-width: 46vw;
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.85);
  box-shadow: 0 3px 10px rgba(30, 60, 80, 0.16);
  cursor: pointer;
  touch-action: manipulation;
  will-change: transform;
  animation: sky-drift var(--sky-ms, 15000ms) linear forwards;
}
.sky-ghost:active { scale: 0.93; }
.sky-ghost:focus-visible { outline: 3px solid #2bb3a3; outline-offset: 2px; }

/* One-time "tap to keep" caption, shown until the player has caught their first
   ghost (main.js `skyTaught`). Two players asked what the drifting numbers were
   within two hours of the sky going live, one of them AFTER catching one, so the
   affordance has to be on the ghost itself.

   THE HEIGHT IS THE WHOLE TRICK, and it is why this is a ::after inside a column
   rather than a second element below the pill. Ghost rows are only `max(46, …)`
   apart (SKY_SLOTS) — 2px of slack — so a pill that grew would print its caption
   through the ghost on the row below, which is the exact bug the slot system was
   introduced to fix. So the caption has to live inside the pill's existing 44px.

   The budget, and mind the border — `* { box-sizing: border-box }` is set
   globally, so `min-height: 44px` is the OUTER height and the 2.5px dashed edge
   is inside it:

       content box = 44 − 2×2.5 (border) − 2×4 (padding) = 31px
       one 20px/1.15 numeral line                        = 23px
       spare                                             =  8px   <- NOT 13

   8px is not a legible caption, so `.teach` buys the room back: 1px vertical
   padding (+6px) and a 1.0 line-height on the numeral (+3px) give a 37px box
   holding 20 + 11 = 31px, i.e. 6px of slack. Generous on purpose — this is
   font-metric-dependent and ~34% of players are on WebKit, so it is checked in
   both engines (`webkit-is-34pct-untested`).

   An earlier draft of this comment did the sum without the border, predicted
   44px, and shipped a 47px pill; test-sky-client caught it. If you change any
   number here, re-run that assertion rather than re-doing the arithmetic. */
.sky-ghost {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.sky-ghost.teach {
  padding-top: 1px;
  padding-bottom: 1px;
  line-height: 1;
}
.sky-ghost.teach::after {
  content: 'tap to keep';
  font-size: 11px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.1px;
  opacity: 0.78;
  white-space: nowrap;
}

/* Right edge to left edge, with a slow bob so it reads as floating rather than
   sliding. `forwards` + the animationend listener in main.js is what removes the
   element — there is no timer to leak. */
@keyframes sky-drift {
  from { transform: translate3d(calc(100vw + 10px), 0, 0); }
  25%  { transform: translate3d(75vw, -14px, 0); }
  50%  { transform: translate3d(50vw, 4px, 0); }
  75%  { transform: translate3d(25vw, -10px, 0); }
  to   { transform: translate3d(-120px, 0, 0); }
}

/* A player who asked for less motion gets a ghost that fades in where it is and
   sits still, rather than one that never appears — the catch is the feature, the
   drift is only decoration. */
@media (prefers-reduced-motion: reduce) {
  .sky-ghost {
    animation: sky-still var(--sky-ms, 15000ms) linear forwards;
    right: auto;
  }
  @keyframes sky-still {
    from { transform: translate3d(66vw, 0, 0); opacity: 0; }
    8%   { transform: translate3d(66vw, 0, 0); opacity: 1; }
    92%  { transform: translate3d(66vw, 0, 0); opacity: 1; }
    to   { transform: translate3d(66vw, 0, 0); opacity: 0; }
  }
}

/* 🪐 Dress-up Saturn ring: the saturn mutation's tilted ring, sized inline by
   buildDressRing to circle the head cube. */
.dress-ring {
  position: absolute;
  left: 50%;
  translate: -50% 0;
  box-sizing: border-box;
  border: 4px solid rgba(233, 200, 140, 0.95);
  border-radius: 50%;
  box-shadow: 0 0 8px 1px rgba(255, 236, 180, 0.7), 0 0 0 2px rgba(201, 161, 90, 0.5);
  transform: rotate(-18deg);
  pointer-events: none;
  z-index: 7;
}

/* 😇 Dress-up halo: a glowing gold ring floating above the head. Its `top` is
   set inline by buildDressHalo so the numeral lift can measure it. */
.dress-halo {
  position: absolute;
  left: 50%;
  translate: -50% 0;
  border: 4px solid #ffd54a;
  border-radius: 50%;
  box-sizing: border-box;
  box-shadow: 0 0 8px 2px rgba(255, 220, 90, 0.85), inset 0 0 4px rgba(255, 240, 160, 0.9);
  pointer-events: none;
  z-index: 6;
  animation: halo-bob 2.4s ease-in-out infinite;
}
@keyframes halo-bob {
  0%, 100% { translate: -50% 0; }
  50%      { translate: -50% -3px; }
}
/* 😈 Dress-up horns reuse .infernal-horns but sit ON the head (explicit top
   from the builder) instead of the summit's translate. */
.infernal-horns.dress-horns { translate: -50% 0; }
.infernal-horns.dress-horns::before,
.infernal-horns.dress-horns::after { bottom: 0; }

/* Twenty-Nine's club badges — little cards stuck on his bottom-row cubes. */
.club29 {
  position: absolute;
  left: 50%; top: 50%;
  translate: -50% -50%;
  width: 62%; height: 62%;
  display: flex; align-items: center; justify-content: center;
  font-family: inherit; font-weight: 900; line-height: 1;
  pointer-events: none;
  box-shadow: 0 1px 1.5px rgba(0, 0, 0, 0.3);
  z-index: 2;
}
.club29-card { background: #fdfdf6; border: 1.5px solid #e8892a; border-radius: 18%; }
.club29-nine { border-color: #8d8d8d; }
.club29-prime { background: #fff6e8; border: 1.5px solid #e8892a; border-radius: 18%; }
.club29-roundel { background: #fdfdf6; border: 2px solid #d9342b; border-radius: 50%; }
.club29-square { background: #d9342b; border: 1.5px solid #9e1f19; border-radius: 14%; }
.club29-square::after {
  content: ''; width: 42%; height: 42%;
  background: #f4a43c; border-radius: 10%;
}
.club29-card { color: #e8892a; }
.club29-nine { color: #7a7a7a; }
.club29-prime { color: #d9342b; }
.club29-roundel { color: #d9342b; }

/* 🎃 Spooky Land witch hat (main.js syncWitchHat). left/top = the head column's
   centre and top edge; the brim sits just over the top of the cube. Hidden under
   a dress-up hat or pet ears so a block never wears two things on its head. */
.witch-hat {
  position: absolute;
  width: calc(var(--wh) * 1.5);
  height: calc(var(--wh) * 1.25);
  translate: -50% -86%;
  pointer-events: none;
  z-index: 6;
}
.witch-hat::before { /* the cone, its tip flopped over */
  content: '';
  position: absolute;
  left: 22%; right: 22%; bottom: 14%; top: 0;
  background: linear-gradient(90deg, #3d1f63, #7a3fc0 55%, #3d1f63);
  clip-path: polygon(0 100%, 100% 100%, 64% 18%, 92% 0, 50% 6%);
  border-radius: 2px;
}
.witch-hat::after { /* brim with an orange band above it */
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0; height: 22%;
  border-radius: 50%;
  background: #4a2678;
  box-shadow: inset 0 calc(var(--wh) * -0.03) 0 #7a3fc0, 0 calc(var(--wh) * -0.16) 0 calc(var(--wh) * -0.06) #ff8a1e;
}
.block.dress-head > .witch-hat:not(.dress-witch), .block.block-cat > .witch-hat:not(.dress-witch) { display: none; }
.witch-hat.dress-witch { translate: -50% 0; }
.block.witch-head:not(.dress-head):not(.block-cat) :is(.numeral, .numeral-mid, .numeral-long) { top: calc(-1 * var(--witch-lift, 52px)); }
/* ↺ the player's own recent makes lead the try: strip (main.js RECENT_KEY) */
.make-recent { display: contents; }
.make-tip.make-tip-recent { border-style: dashed; }

/* 👓🕶️ On a one-eyed face (One, ½ and the Fraction Friends, One Hundred…) the
   dress-up glasses were a two-lens pair straddling the single eye, one lens
   hanging empty off ½'s flat edge (shyleaf0928724 10-15 "Bug"). One eye wears
   one lens: drop the left lens and bridge, centre the other on the eye. */
.face-one-eye .specs-20 .spec-left,
.face-one-eye .specs-20 .spec-bridge,
.face-one-eye .shades-26 .shade-left,
.face-one-eye .shades-26 .shade-bridge { display: none; }
.face-one-eye .specs-20 .spec-right,
.face-one-eye .shades-26 .shade-right {
  left: 50%; right: auto;
  /* a size up from the pair's lenses so the frame shows round the bigger eye,
     shifted up by half the growth to stay centred on it */
  width: calc(var(--fs) * 0.46);
  height: calc(var(--fs) * 0.46);
  translate: -50% calc(var(--fs) * -0.065);
}
