:root {
  --cell: 52px;
  --door-thickness: 22px;
  --gap-outer: 36px;
  --bg: #1d2433;
  --board-bg: #192e58;
  --cell-bg: #21365d;
  --cell-border: var(--board-bg);
  --hidden-bg: transparent;
  /* one shared outer diameter for every round overlay badge that sits on a
     block (scissors, key, bomb, timer-bomb, lock, color-crate drum, …) so they
     all read as the same size. */
  --badge-d: 32px;
}

* { box-sizing: border-box; }

html, body {
  height: 100%;
  margin: 0;
  font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  background: var(--bg);
  color: #f0f2f8;
}

.app {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-height: 100%;
  padding: 16px;
  gap: 16px;
}

.topbar {
  width: 100%;
  max-width: 720px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.topbar h1 {
  font-size: 20px;
  margin: 0;
  font-weight: 600;
}

.topbar h1 .sub {
  font-weight: 400;
  font-size: 14px;
  color: #9aa3bf;
}

.controls {
  display: flex;
  align-items: center;
  gap: 8px;
}

.controls select, .controls button {
  background: var(--board-bg);
  color: #f0f2f8;
  border: 1px solid var(--cell-border);
  border-radius: 8px;
  padding: 8px 12px;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.15s;
}

.controls select:hover, .controls button:hover {
  background: #3a4566;
}

#favToggle {
  font-size: 16px;
  line-height: 1;
  padding: 8px 10px;
}
#favToggle.active {
  color: #ffd34d;
  border-color: #ffd34d;
}

.stage {
  flex: 1;
  /* a flex item's default min-height is its content size, so a tall board would
     stretch the column and force a page scrollbar. Pin it to 0 and clip: the
     board is shrunk by fitBoard() to fit, and the (transparent, unshrunk) layout
     box beyond the viewport is clipped rather than scrolled. */
  min-height: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
}

.board-frame {
  position: relative;
  /* contain the wooden-frame layers (::before/::after use negative z-index) in
     this element's own stacking context, otherwise they slip behind the page */
  isolation: isolate;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

/* Safari: a mouse drag must never start a TEXT SELECTION. Chrome suppresses
   the default selection once the pointer is captured; WebKit keeps extending
   it — dragging a figure flashed selection over the HUD/topbar (and the held
   block itself lives in #fxLayer, OUTSIDE .board-frame's user-select:none).
   So: no selection anywhere in play mode + on the fx layer; text inputs get
   selection back (a none on the ancestor blocks editing selection in WebKit). */
body.play-mode,
#fxLayer {
  -webkit-user-select: none;
  user-select: none;
}
body.play-mode input,
body.play-mode textarea {
  -webkit-user-select: text;
  user-select: text;
}

/* ── Composite wooden frame ─────────────────────────────────────────────────
   The frame is assembled from separate timber pieces: two vertical rails, two
   horizontal rails and four corner blocks (each with an outer + inner rounded
   corner). All pieces share the .wood look; the grain runs vertically. The
   .wood-frame wrapper spans the board plus a `--frame` margin on every side and
   sits behind the board (which covers the empty centre). */
.board-frame {
  --frame: 26px;        /* timber thickness */
  --frame-r: 16px;      /* outer corner radius */
  --frame-ri: 9px;      /* inner corner radius (the playfield opening) */
  /* fitBoard() sets --view-scale to shrink big boards into the viewport. Driven
     by a variable (not inline transform) so the smash-shake keyframe can
     compose the same scale and not flash the board to full size mid-shake. */
  --view-scale: 1;
  transform: scale(var(--view-scale));
  transform-origin: center center;
}

/* layer that holds the generated timber pieces; shares the board's coordinate
   origin (inset:0) so JS can position pieces in board pixels, including the
   negative offsets that put them outside the playfield. */
.wood-frame {
  position: absolute;
  inset: 0;
  overflow: visible;
  z-index: -2;
  pointer-events: none;
  /* one soft shadow cast by the whole assembled ring, following its real shape */
  filter: drop-shadow(0 12px 22px rgba(0, 0, 0, 0.45));
}

/* shared timber surface — VERTICAL grain (used by the left/right rails and the
   corners). Each piece is positioned individually from JS. */
.wood {
  position: absolute;
  background:
    linear-gradient(90deg,
      rgba(255, 247, 232, 0.05) 0%,
      rgba(255, 247, 232, 0) 20%,
      rgba(255, 247, 232, 0) 58%,
      rgba(255, 247, 232, 0.11) 84%,
      rgba(255, 247, 232, 0.03) 100%),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='208'%3E%3Cg transform='rotate(90) translate(0,-26)'%3E%3Cg fill='none' stroke-linecap='round'%3E %3Cpath d='M0 2.2 C55 2.2 75 3.2 118 3.2 C160 3.2 175 2.2 208 2.2' stroke='%234a331c' stroke-width='0.5' opacity='0.22'/%3E %3Cpath d='M0 4.5 C40 4.5 55 6 104 6 C153 6 168 4.5 208 4.5' stroke='%234a331c' stroke-width='0.7' opacity='0.28'/%3E %3Cpath d='M0 8.5 C35 8.5 60 10.5 104 10.5 C150 10.5 175 8.5 208 8.5' stroke='%234a331c' stroke-width='0.5' opacity='0.20'/%3E %3Cpath d='M0 11.5 C50 11.5 92 10 118 10 C131 10 132 8.8 140 8.8 C148 8.8 149 10 162 10 C186 10 192 11.5 208 11.5' stroke='%234a331c' stroke-width='0.6' opacity='0.24'/%3E %3Cpath d='M0 17 C50 17 100 19.3 140 19.3 C180 19.3 186 17 208 17' stroke='%234a331c' stroke-width='0.6' opacity='0.24'/%3E %3Cpath d='M0 21.5 C45 21.5 70 20.2 104 20.2 C145 20.2 172 21.5 208 21.5' stroke='%234a331c' stroke-width='0.7' opacity='0.28'/%3E %3Cpath d='M0 24.3 C60 24.3 90 23.4 130 23.4 C170 23.4 185 24.3 208 24.3' stroke='%234a331c' stroke-width='0.45' opacity='0.18'/%3E %3Cellipse cx='140' cy='14' rx='2.6' ry='1.2' fill='%234a331c' opacity='0.20' stroke='none'/%3E %3Cellipse cx='140' cy='14' rx='4.5' ry='1.9' stroke='%234a331c' stroke-width='0.6' opacity='0.28'/%3E %3Cellipse cx='140.6' cy='14.1' rx='8' ry='3' stroke='%234a331c' stroke-width='0.5' opacity='0.20'/%3E %3Cellipse cx='141' cy='14.2' rx='11.5' ry='4.2' stroke='%234a331c' stroke-width='0.45' opacity='0.13'/%3E %3Cpath d='M0 6.8 C45 6.8 60 8 104 8 C150 8 170 6.8 208 6.8' stroke='%23fff3e0' stroke-width='0.8' opacity='0.055'/%3E %3Cpath d='M0 14.8 C40 14.8 70 15.8 100 15.8 C114 15.8 116 15 124 15' stroke='%23fff3e0' stroke-width='0.8' opacity='0.05'/%3E %3Cpath d='M157 15 C165 15 170 15.8 182 15.8 C196 15.8 200 14.8 208 14.8' stroke='%23fff3e0' stroke-width='0.8' opacity='0.05'/%3E %3Cpath d='M0 19.6 C55 19.6 85 21 130 21 C172 21 190 19.6 208 19.6' stroke='%23fff3e0' stroke-width='0.7' opacity='0.045'/%3E %3C/g%3E%3C/g%3E%3C/svg%3E") repeat,
    repeating-linear-gradient(90deg,
      rgba(60, 40, 20, 0.07) 0px,
      rgba(40, 22, 10, 0) 26px),
    linear-gradient(90deg, #d5c0a1 0%, #bda079 52%, #987a53 100%);
  background-size: auto, 26px 208px, auto, auto;
  box-shadow:
    inset 0 0 0 0.5px rgba(70, 48, 26, 0.38),     /* hairline seam outline */
    inset 1px 1px 0 rgba(255, 244, 224, 0.22),    /* lit top-left bevel    */
    inset -1px -1px 0 rgba(0, 0, 0, 0.14);        /* shaded bottom-right   */
}

/* HORIZONTAL grain — for the top/bottom rails, so the grain runs along the
   plank. Stripes turn 90°, and the cross-grain light gradient turns vertical. */
.wood.grain-h {
  background:
    linear-gradient(180deg,
      rgba(255, 247, 232, 0.05) 0%,
      rgba(255, 247, 232, 0) 20%,
      rgba(255, 247, 232, 0) 58%,
      rgba(255, 247, 232, 0.11) 84%,
      rgba(255, 247, 232, 0.03) 100%),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='208' height='26'%3E %3Cg fill='none' stroke-linecap='round'%3E %3Cpath d='M0 2.2 C55 2.2 75 3.2 118 3.2 C160 3.2 175 2.2 208 2.2' stroke='%234a331c' stroke-width='0.5' opacity='0.22'/%3E %3Cpath d='M0 4.5 C40 4.5 55 6 104 6 C153 6 168 4.5 208 4.5' stroke='%234a331c' stroke-width='0.7' opacity='0.28'/%3E %3Cpath d='M0 8.5 C35 8.5 60 10.5 104 10.5 C150 10.5 175 8.5 208 8.5' stroke='%234a331c' stroke-width='0.5' opacity='0.20'/%3E %3Cpath d='M0 11.5 C50 11.5 92 10 118 10 C131 10 132 8.8 140 8.8 C148 8.8 149 10 162 10 C186 10 192 11.5 208 11.5' stroke='%234a331c' stroke-width='0.6' opacity='0.24'/%3E %3Cpath d='M0 17 C50 17 100 19.3 140 19.3 C180 19.3 186 17 208 17' stroke='%234a331c' stroke-width='0.6' opacity='0.24'/%3E %3Cpath d='M0 21.5 C45 21.5 70 20.2 104 20.2 C145 20.2 172 21.5 208 21.5' stroke='%234a331c' stroke-width='0.7' opacity='0.28'/%3E %3Cpath d='M0 24.3 C60 24.3 90 23.4 130 23.4 C170 23.4 185 24.3 208 24.3' stroke='%234a331c' stroke-width='0.45' opacity='0.18'/%3E %3Cellipse cx='140' cy='14' rx='2.6' ry='1.2' fill='%234a331c' opacity='0.20' stroke='none'/%3E %3Cellipse cx='140' cy='14' rx='4.5' ry='1.9' stroke='%234a331c' stroke-width='0.6' opacity='0.28'/%3E %3Cellipse cx='140.6' cy='14.1' rx='8' ry='3' stroke='%234a331c' stroke-width='0.5' opacity='0.20'/%3E %3Cellipse cx='141' cy='14.2' rx='11.5' ry='4.2' stroke='%234a331c' stroke-width='0.45' opacity='0.13'/%3E %3Cpath d='M0 6.8 C45 6.8 60 8 104 8 C150 8 170 6.8 208 6.8' stroke='%23fff3e0' stroke-width='0.8' opacity='0.055'/%3E %3Cpath d='M0 14.8 C40 14.8 70 15.8 100 15.8 C114 15.8 116 15 124 15' stroke='%23fff3e0' stroke-width='0.8' opacity='0.05'/%3E %3Cpath d='M157 15 C165 15 170 15.8 182 15.8 C196 15.8 200 14.8 208 14.8' stroke='%23fff3e0' stroke-width='0.8' opacity='0.05'/%3E %3Cpath d='M0 19.6 C55 19.6 85 21 130 21 C172 21 190 19.6 208 19.6' stroke='%23fff3e0' stroke-width='0.7' opacity='0.045'/%3E %3C/g%3E %3C/svg%3E ") repeat,
    repeating-linear-gradient(0deg,
      rgba(60, 40, 20, 0.07) 0px,
      rgba(40, 22, 10, 0) 26px),
    linear-gradient(180deg, #d5c0a1 0%, #bda079 52%, #987a53 100%);
  background-size: auto, 208px 26px, auto, auto;
}

/* ── Butterfly (bowtie) joint keys ──────────────────────────────────────────
   Small dovetail-key inlays laid across each butt joint between timbers, like
   real butterfly joints. They reuse the .wood grain (long axis = grain axis),
   darkened a touch so they read as a contrasting inlay. JS drops one at the
   midpoint of every seam. */
.wood-btf {
  box-shadow: none;
  border-radius: 0;
  pointer-events: none;
  filter: brightness(0.93) saturate(1.03)
          drop-shadow(0 0.5px 1px rgba(40, 22, 8, 0.40));
}
/* horizontal axis — spans a VERTICAL seam; waist in the middle */
.wood-btf.btf-h {
  clip-path: polygon(0% 0%, 50% 28%, 100% 0%, 100% 100%, 50% 72%, 0% 100%);
}
/* vertical axis — spans a HORIZONTAL seam */
.wood-btf.btf-v {
  clip-path: polygon(0% 0%, 100% 0%, 72% 50%, 100% 100%, 0% 100%, 28% 50%);
}

.board {
  position: relative;
  /* background is cut to the playfield silhouette per-cell (JS), so the dark
     board panel itself is transparent — perimeter notches read as real gaps
     that the wooden frame wraps around. */
  background: transparent;
  border-radius: 12px;
  /* anything that slides past the edge of the playfield (a block exiting
     through a gate) is clipped here, so it visually disappears right at
     the gate instead of poking out past it */
  overflow: hidden;
}

/* doors and melt particles live in this unclipped layer, on top of the
   (clipped) board, so the gate always fully covers an exiting block while
   bubbles remain visible outside the playfield */
.fx-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: visible;
}

.cell {
  position: absolute;
  background: var(--cell-bg);
  border: 1px solid var(--cell-border);
  border-radius: 4px;
}

/* Colored target tile ("rug" / gridColors): a tinted mat on the cell floor that
   marks where a same-colour block must be delivered. Sits above the plain cell
   but below the blocks (painted earlier in DOM order; no positive z-index). */
.grid-color {
  position: absolute;
  z-index: 0;
  pointer-events: none;
  box-sizing: border-box;
  border-radius: 5px;
  background:
    color-mix(in srgb, var(--mat) 26%, transparent);
  box-shadow:
    inset 0 0 0 2px color-mix(in srgb, var(--mat) 60%, transparent),
    inset 0 0 10px color-mix(in srgb, var(--mat) 22%, transparent);
}

/* empty cells (perimeter notches and interior holes) aren't rendered at all —
   JS skips them — so the backing shows through and the timber frame wraps only
   the exterior silhouette. */

.block {
  position: absolute;
  border-radius: 10px;
  cursor: grab;
  /* left/top/transform/opacity transitions are applied dynamically in script.js
     so each action (drag-release, slide, snap-back, exit) can use its own
     duration and easing curve */
  touch-action: none;
  will-change: left, top, transform;
  /* Hit-testing lives on .block-inner, not here. The outer .block is always a
     full rectangle (the bbox), so for non-rectangular pieces it overlaps the
     empty notch where a neighbour sits — catching clicks meant for that
     neighbour. .block-inner is clip-path'd to the real silhouette, so moving
     the hit region there lets clicks in the notch fall through to the piece
     underneath. The pointerdown listener stays on .block (events bubble up,
     and e.currentTarget is still the outer element). */
  pointer-events: none;
}

.block.no-anim {
  transition: none !important;
}

/* Non-rectangular pieces (L/J/T/S/Z, plus, U) are clipped to their cell
   footprint via a clip-path set in JS — every corner (convex and concave) is
   rounded into a smooth, blobby silhouette. Squares and I-bars are NOT clipped
   and keep their rounded rectangle. clip-path cuts off any outer box-shadow, so re-create
   the drop/contact shadow as a filter on the outer element so it follows the
   real silhouette. The jelly theme adds a colour-matched glow (see below). */
.block.clipped {
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.38));
}

/* fixed grey blockers are immovable obstacles */
.block.blocker-fixed {
  cursor: default;
}

/* A fixed blocker is bolted to the board by a metal post at each corner of its
   footprint (corners sit on cell intersections). Top-down each post is a steel
   stud casting a short cylindrical shadow down-right (light from upper-left).
   Post diameter matches the gate corner screws (~10px). Replaces the 🔒 glyph.
   Sits on the unclipped outer .block so corner posts straddle the edge cleanly. */
.fixed-posts {
  position: absolute;
  inset: 0;
  overflow: visible;
  pointer-events: none;
  z-index: 3;
}
.fixed-post {
  position: absolute;
  width: 8px;
  height: 8px;
  transform: translate(-50%, -50%);
}
/* the shadow the upright cylinder casts onto the board: a soft dark ellipse
   offset and elongated toward the lower-right */
.fixed-post::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 10px;
  height: 7px;
  border-radius: 50%;
  background: radial-gradient(ellipse at 38% 38%,
    rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0) 72%);
  transform: translate(-22%, 2%) rotate(38deg);
  filter: blur(0.7px);
  z-index: -1;
}
/* the steel cap (top face of the post), lit from the upper-left */
.fixed-post::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%,
    #8f949b 0%, #6c7178 18%,
    #4a4e54 48%,
    #34373c 80%,
    #212327 100%);
  box-shadow:
    0 0 0 0.5px rgba(0, 0, 0, 0.5),
    inset 0 -1px 1px rgba(0, 0, 0, 0.35),
    inset 0 1px 1px rgba(255, 255, 255, 0.6);
}

/* Puzzle obstacles (#27): colourless cardboard boxes/pieces that slide like a
   normal block but have no gate. They read as plain crate-coloured clutter — no
   colour glow, a woodgrain-ish flat fill, and a dashed seam so they're visibly
   "the odd one out" versus coloured blocks. They auto-vanish on win. */
.block.puzzle .block-inner {
  background:
    repeating-linear-gradient(45deg,
      rgba(0, 0, 0, 0.06) 0 7px, rgba(255, 255, 255, 0.05) 7px 14px),
    linear-gradient(160deg, #c7a878 0%, #b08f5d 60%, #9c7c4c 100%);
  box-shadow:
    inset 0 0 0 2px rgba(255, 255, 255, 0.18),
    inset 0 0 0 4px rgba(90, 64, 30, 0.35);
}

/* a centred jigsaw-knob glyph so the piece reads as a "puzzle" obstacle */
.block.puzzle .block-inner::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 38%;
  height: 38%;
  transform: translate(-50%, -50%);
  background: radial-gradient(circle at 50% 38%, rgba(255, 255, 255, 0.35) 0 18%, transparent 19%),
              rgba(90, 64, 30, 0.28);
  -webkit-mask: radial-gradient(circle at 50% 22%, #000 22%, transparent 23%),
               linear-gradient(#000 0 0);
  mask: radial-gradient(circle at 50% 22%, #000 22%, transparent 23%),
        linear-gradient(#000 0 0);
  -webkit-mask-composite: source-over;
  mask-composite: add;
  border-radius: 18%;
  opacity: 0.55;
  pointer-events: none;
}

/* auto-vanish pop when all coloured blocks are cleared */
.block.puzzle.puzzle-vanish {
  animation: puzzle-poof 0.42s ease forwards;
  pointer-events: none;
}

@keyframes puzzle-poof {
  0%   { transform: scale(1);    opacity: 1; }
  35%  { transform: scale(1.12); opacity: 1; }
  100% { transform: scale(0.4);  opacity: 0; }
}

/* SVG overlay for clipped pieces: rides on the (unclipped) outer .block and
   carries what a box-shadow on the rectangular bbox can't do once the body is
   clipped to a blobby silhouette — a rim/held-ring that follows the real
   rounded outline, and glints anchored to actually-filled cells. */
.shape-overlay {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  z-index: 1;
}

.shape-overlay .spec { opacity: 0.85; }
.shape-overlay .spec-sm { opacity: 0.6; }

/* Base rim: a faint glassy edge that hugs the rounded silhouette. Drawn UNCLIPPED
   and centred on the exact silhouette path — the SAME geometry as the held ring
   below — so it traces the figure pixel-perfectly. (It used to live inside the
   clip group as a doubled half-stroke; that half-visible edge drifted ~1px off
   the body's clip-path on rounded corners.) non-scaling-stroke keeps it a crisp
   1px line under the pickup zoom, exactly matching the held ring. */
.shape-overlay .shape-rim {
  stroke: rgba(255, 255, 255, 0.30);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  transition: stroke 0.12s ease, stroke-width 0.12s ease;
}

/* Held "grab" ring: an unclipped stroke on the true outline, hidden at rest and
   lit bright white while dragging, so the highlight rings the whole silhouette
   instead of being cut off. */
.shape-overlay .shape-ring {
  stroke: transparent;
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  transition: stroke 0.12s ease;
}

.block.dragging .shape-overlay .shape-ring {
  stroke: #fff;
}

/* Held glow + contact shadow for clipped pieces WITHOUT a wrapper drop-shadow()
   filter — Safari re-rasterizes a filtered layer on the CPU as the held block
   moves, which made dragging stutter. The glow is a true gaussian pre-rendered
   ONCE to a canvas by buildDragGlow() (position/size set inline there), sitting
   under the body and simply faded in while held (opacity is composited, so the
   drag itself costs nothing). */
.drag-glow {
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.12s ease;
}
.block.dragging > .drag-glow { opacity: 1; }
/* resting shadow bitmap: visible by default, yields to .drag-glow in hand */
.idle-glow {
  pointer-events: none;
  transition: opacity 0.12s ease;
}
.block.dragging > .idle-glow { opacity: 0; }
/* crated pieces suppress the held glow, just like they suppress the held ring */
.block.crated .drag-glow,
.block.movecrated .drag-glow,
.block.colorcrated .drag-glow { display: none; }

/* clipped pieces draw their glints from the SVG overlay (positioned on real
   filled cells), so suppress the bbox-anchored CSS specular — otherwise it
   doubles up or lands in an empty notch */
.block.clipped .block-inner::after { display: none; }

/* #9 joined group: a member whose body is drawn by the unified group shape. Its
   visuals are dropped to 0 opacity (NOT visibility:hidden, so .block-inner still
   hit-tests and the group can still be grabbed). The unified .glue-shape-block
   sits on top, pointer-transparent, so grabs pass through to these members. */
.block.glue-hidden > * { opacity: 0; }

/* overlay glyphs: one-way movement axis (↔/↕) and fixed-blocker lock (🔒) */
.mod-mark {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 22px;
  font-weight: 700;
  color: #fff;
  opacity: 0.75;
  pointer-events: none;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
  z-index: 2;
}

/* one-way movement axis: a chevron at each outer edge of the block, matching
   the size-changing door arrows (◀▶ / ▲▼) — smaller and crisper than the old
   centred ↔/↕, and kept off the middle so timers/badges don't collide. */
.mod-mark.mod-axis-arrow {
  font-size: 14px;
  font-weight: 400;
  opacity: 0.92;
  color: rgba(255, 255, 255, 0.92);
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.6);
}

/* SVG triangle arrows (buildTriangleSvg) replacing the ◀▶▲▼ glyphs used by the
   one-way axis marks and the size-changing door arrows. A real shape renders
   identically on every browser, unlike the geometric glyphs that some mobile
   browsers promote to a colour emoji sitting on a rounded plate. Sized in `em`
   so it tracks each context's font-size; colour comes from currentColor. */
.tri-arrow { display: block; width: 0.85em; height: 0.85em; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.6)); }
.tri-arrow polygon { fill: currentColor; }

/* Star stamps: debossed star shapes pressed into a block (jelly) or a gate
   (steel). The SVG itself carries the bevel (inner shadow + highlight via a
   filter); CSS only positions the overlay and sets blend modes so the shading
   reads on any underlying colour. */
.star-stamp {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
}
.star-stamp-svg { position: absolute; left: 0; top: 0; overflow: visible; }
/* on the jelly the cavity floor and shadow multiply into the colour, the
   highlight screens over it — so the press reads as depth, not a grey decal */
.star-stamp-jelly { z-index: 3; mix-blend-mode: normal; }
.star-stamp-jelly .star-stamp-svg { mix-blend-mode: soft-light; opacity: 0.92; }
/* punched steel: sharper, a touch stronger, sitting just under the bolts */
.star-stamp-metal { z-index: 2; }
.star-stamp-metal .star-stamp-svg { mix-blend-mode: overlay; opacity: 0.9; }

.block.dragging {
  cursor: grabbing;
  transition: none;
  /* A held figure must be topmost — nothing should cover it. Neither #board nor
     #fxLayer establishes a stacking context (both z-index:auto), so their
     positioned descendants all compete in the same parent context: the static
     crate covers live on the board at z-index:30 (see buildCrateOverlay in
     script.js), gate scissors at 60, lock bars at 6. The pickup zoom spills the
     figure into neighbouring cells, so it must outrank all of them. 90 sits
     above every in-board element yet below the win/lose modal overlay (100). */
  z-index: 90;
  /* while held the block lives in the fx layer (pointer-events:none), so
     re-enable hit-testing here to keep the drag responsive */
  pointer-events: auto;
  /* the white "held" ring is drawn on .block-inner (not here) so it squashes
     and stretches together with the jelly wobble instead of staying a rigid
     rectangle while the inner layer deforms */
}

/* inner layer: carries the visible "jelly" surface (color, shading, gloss).
   Kept separate from .block so the snap "wobble" can squash/stretch this
   layer without fighting the drag/slide transform on the outer element. */
.block-inner {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  border: 2px solid rgba(0,0,0,0.15);
  box-shadow: inset 0 -4px 0 rgba(0,0,0,0.18), inset 0 2px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3);
  overflow: hidden;
  /* carries the hit region (see .block). For clipped pieces the clip-path on
     this element also clips its pointer hit-test to the real silhouette, so the
     empty notch no longer steals clicks from neighbouring pieces. */
  pointer-events: auto;
  cursor: inherit;
  will-change: transform;
}

.block.dragging .block-inner {
  /* the white "held" ring lives here (outset, not inset) so it rides the
     jelly squash/stretch transform applied to this layer */
  box-shadow: 0 0 0 2px #fff, inset 0 -4px 0 rgba(0,0,0,0.18), inset 0 2px 0 rgba(255,255,255,0.25), 0 6px 16px rgba(0,0,0,0.4);
}

/* Layered (multi-color) blocks: a smaller jelly of the hidden under-color is
   imprinted inside the block, inset by an equal margin from the outer outline.
   It reuses the .block-inner + .shape-overlay markup so it inherits every theme
   effect (bevel/depth, top sheen, rim contour, glints). The wrapper just holds
   it in place and keeps it from stealing pointer events. */
.layer-core {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 1;
}
.layer-core .block-inner { pointer-events: none; }
/* the core draws its glints from its overlay, so suppress the bbox-anchored
   CSS specular on its body (matches how clipped pieces behave) */
.layer-core .block-inner::after { display: none; }

/* soft top-down sheen: gives the block a gentle glassy/jelly sheen */
.block-inner::before {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, rgba(255,255,255,0.28) 0%, rgba(255,255,255,0.07) 42%, rgba(255,255,255,0) 68%);
  pointer-events: none;
}

/* small specular highlight ("reflex") in the upper-left corner */
.block-inner::after {
  content: '';
  position: absolute;
  top: 8%;
  left: 9%;
  width: 36%;
  height: 24%;
  border-radius: 50%;
  background: rgba(255,255,255,0.32);
  filter: blur(3px);
  pointer-events: none;
}

/* gentle squash/stretch wobble played when a block snaps onto a cell */
@keyframes jelly-snap {
  0%   { transform: scale(1, 1); }
  35%  { transform: scale(1.06, 0.94); }
  60%  { transform: scale(0.97, 1.04); }
  82%  { transform: scale(1.015, 0.99); }
  100% { transform: scale(1, 1); }
}

.block-inner.jelly {
  animation: jelly-snap 300ms ease-out;
}

/* the silhouette rim/held-ring lives in the SVG overlay (a sibling of
   .block-inner), so it must run the same wobble to squash and stretch in sync
   with the jelly body — otherwise the outline stays a rigid shape while the
   body deforms. inset:0 on both means they share the same box and centre. */
.shape-overlay.jelly {
  animation: jelly-snap 300ms ease-out;
}

/* На время желе-вобла деформируемые слои получают СВОИ композитные слои:
   иначе WebKit ре-растрирует весь слой фигуры (3 канваса + svg + градиенты)
   каждый кадр анимации — и первые ~300мс drag'а фигура «тупила на месте»,
   не поспевая за мышью (репорт юзера). С will-change трансформ-кейфреймы
   едут на компоузере, растр — один раз. */
.block-inner.jelly,
.shape-overlay.jelly {
  will-change: transform;
}

/* soap-bubble look: a glassy ring (cut out via mask) with a bright rim and
   highlight, so the bubble stays visible even when popping against a door
   of the exact same color as the block it came from */
/* white "melting glow" that creeps up the figure from the edge touching the
   hot plate. A linear white gradient anchored at the contact edge, fading to
   nothing by roughly the middle of the piece. Direction is set per-exit via
   the --melt-dir custom property. Sits inside .block-inner so it inherits the
   silhouette (border-radius / clip) and scales with the shrinking melt. */
/* --melt-front is the position of the bright "heat front" along the figure,
   measured from the gate (contact) edge. Registering it as a typed custom
   property makes it animatable, so the front can sweep from the gate to the
   far edge. */
@property --melt-front {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 10%;
}

.melt-glow {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  z-index: 6;
  opacity: 0;
  mix-blend-mode: plus-lighter;        /* additive blend */
  --melt-front: 2%;
  /* warm fill behind the front (from the gate edge), peaking to pure white at
     the moving front, then transparent (still-cold) beyond it. 0% sits at the
     gate edge because --melt-dir points away from the gate. */
  background: linear-gradient(var(--melt-dir, to left),
    rgba(255, 255, 255, 0.15) 0%,
    rgba(255, 255, 255, 0.20) calc(var(--melt-front) * 0.8),
    rgba(255, 255, 255, 0.097) var(--melt-front),
    rgba(255, 255, 255, 0) calc(var(--melt-front) + 12%));
  /* the front sweeps from the gate to the far edge; ease-out + a duration
     shorter than the melt so it reaches 100% while the figure is still visible
     (the figure fades/shrinks away over the full melt). */
  transition: opacity 200ms ease-out, --melt-front var(--melt-dur, 630ms) ease-out;
}

/* ---- gate-contact delivery flash ------------------------------------------
   The instant a figure touches its gate (start of the melt/exit animation) it
   gets a quick bright rim flare (drop-shadow on the wrapper, so it hugs the
   real silhouette even for clipped pieces — see igniteExitFx in script.js) plus
   a single bright highlight running once around its outline (.exit-contour). */
/* The running contour is drawn as an SVG stroke in the fx layer (see
   igniteExitFx in script.js) — no CSS needed for it here. */

/* The combo delivery jolt (field "sag" + damped settle) is generated
   procedurally in JS — a lightly-randomised damped Lissajous — so it's slightly
   different every time. See comboShake() in script.js. It's skipped entirely
   when prefers-reduced-motion is set. */

/* Block-Blast-style "Combo!" callout that punches in over the delivered figure.
   The zoom/float animation is applied in JS (see spawnComboLabel); this styles
   the type — bold italic with a gradient fill and dark outline so it pops on any
   background. */
.combo-pop {
  position: absolute;
  z-index: 12;
  pointer-events: none;
  display: flex;
  align-items: baseline;
  gap: 0.14em;
  /* a genuinely heavy display face (fat glyphs) rather than a thick outline,
     which keeps the letterforms clean */
  font-family: 'Arial Black', 'Arial Bold', 'Helvetica Neue', Gadget, sans-serif;
  font-weight: 900;
  font-style: italic;
  line-height: 1;
  white-space: nowrap;
  letter-spacing: 0.5px;
  transform: translate(-50%, -50%) scale(0.2);   /* start state before the pop */
  opacity: 0;
  will-change: transform, opacity;
  filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.35))
          drop-shadow(0 0 14px rgba(255, 180, 60, 0.7));
}
.combo-pop .combo-word {
  background: linear-gradient(180deg, #ffffff 0%, #ffe487 42%, #ffb43c 76%, #ff8a2b 100%);
  -webkit-background-clip: text;
          background-clip: text;
  -webkit-text-fill-color: transparent;
  -webkit-text-stroke: 1.6px rgba(74, 28, 4, 0.6);   /* thin, crisp outline for contrast */
  paint-order: stroke fill;      /* stroke UNDER the fill so glyphs stay crisp */
}
/* the multiplier reads in a distinct bright colour (green) with a SOLID fill —
   no background-clip, which was what sharply clipped the italic glyph's right
   overhang. A little right padding guards the slant from any edge cut. */
.combo-pop .combo-mult {
  font-size: 0.78em;
  align-self: center;
  color: #a6ff33;
  -webkit-text-fill-color: #a6ff33;
  -webkit-text-stroke: 1.2px rgba(24, 60, 4, 0.6);
  paint-order: stroke fill;
  padding-right: 0.12em;
}

.particle {
  position: absolute;
  border-radius: 50%;
  opacity: 1;
  pointer-events: none;
  z-index: 5;
  background: radial-gradient(circle at 34% 30%,
    rgba(255,255,255,0.98) 0%,
    var(--bubble-color, #fff) 30%,
    var(--bubble-color, #fff) 100%);
  box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}

/* tiny sugar/caramel grits flung out of the melt with gravity. Colour, size and
   exact shadow are set inline per-grain; this just carries the shared bits. */
.sugar-bit {
  position: absolute;
  pointer-events: none;
  z-index: 6;
  border-radius: 0;        /* crisp sugar/caramel squares — no rounding */
  will-change: transform, opacity;
}

/* ---- ice blocks -----------------------------------------------------------
   A frozen block wears a translucent frost pane (.ice-overlay) so its colour
   still reads faintly underneath. Crack tiers are revealed as the thaw advances
   (.ice-p1 -> light cracks, .ice-p2 -> heavy cracks); then the pane is removed
   and ice shards (.ice-shard) are flung out with gravity (animated in JS). */
.block.frozen { cursor: default; }
.block.frozen .block-inner { cursor: default; }

.ice-overlay {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 4;
  border-radius: 10px;                 /* matches .block base radius; jelly below */
  /* fully opaque white ice: the block's own colour is hidden entirely. A soft
     blue bevel is worked into all four edges so the white pane reads as a
     three-dimensional slab of ice rather than a flat fill */
  background:
    linear-gradient(135deg, #ffffff, #e9f4ff 50%, #f6fbff);
  box-shadow:
    inset 0 0 0 2px rgba(255,255,255,0.85),
    inset 0 3px 12px rgba(255,255,255,0.70),
    inset 0 -5px 14px rgba(120,170,210,0.55),
    inset 4px 0 12px rgba(120,170,210,0.30),
    inset -4px 0 12px rgba(120,170,210,0.30);
  overflow: hidden;
}
.style-jelly3d .ice-overlay { border-radius: 16px; }
/* clipped (non-rectangular) pieces carry a clip-path set in JS; drop the box
   rounding/shadow so only the silhouette-clipped frost shows */
.ice-overlay[style*="clip-path"] { border-radius: 0; box-shadow: cyan; }

.ice-svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.ice-svg rect { pointer-events: none; }

/* jagged inner bevel: a broken icy rim around the inside of the pane, in the
   crack colour with a crisp (unblurred) vector edge */
.ice-bevel {
  fill: rgba(198, 225, 255, 0.55);
  pointer-events: none;
}

/* crack strokes: bright frosted white with a soft glow; hidden until their tier
   is unlocked, then eased in */
.ice-cracks {
  fill: none;
  /* blue crack line with a white edging so it reads against the white ice.
     Depth (the offset twin) is added per-phase below, not here. */
  stroke: rgba(198, 225, 255, 0.95);
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
  opacity: 0;
  transition: opacity 0.45s ease;
  filter: drop-shadow(0 0 0.9px #ffffff) drop-shadow(0 0 0.9px #ffffff);
}
/* fine decorative hairlines (1px): subtle frost texture, present from frame 1
   onward, never gain depth — they just dress the ice */
.ice-cracks-fine {
  stroke: rgba(120,175,220,0.85);
  stroke-width: 1.5;
  opacity: 0.45;
}

/* frame 1 (intact frozen): light cracks clearly visible */
.block.frozen:not(.ice-p1):not(.ice-p2) .ice-cracks-light { opacity: 0.7; }
/* frame 2 (.ice-p1): same flat style as frame 1, light cracks at full strength */
.block.ice-p1 .ice-cracks-light { opacity: 1; }
/* frame 3 (.ice-p2): same style again, just more lines — the heavy tier joins in */
.block.ice-p2 .ice-cracks-light { opacity: 1; }
.block.ice-p2 .ice-cracks-heavy { opacity: 1; }

/* a short shudder when a new crack tier appears */
@keyframes ice-shudder {
  0%, 100% { transform: translate(0, 0); }
  20% { transform: translate(-1.5px, 1px); }
  45% { transform: translate(1.5px, -1px); }
  70% { transform: translate(-1px, -1px); }
}
.block.ice-shudder .ice-overlay { animation: ice-shudder 0.32s ease; }

/* move counter frozen into the ice: this is a move counter (ticks on delivery),
   not a timer. Bold frosted numeral, anchored on a filled cell in JS. */
.ice-count {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
  font-weight: 800;
  color: #eaf6ff;
  /* crisp 1px outline (no blur) instead of a soft glow */
  text-shadow:
    -1px -1px 0 rgba(40,90,130,0.95),  1px -1px 0 rgba(40,90,130,0.95),
    -1px  1px 0 rgba(40,90,130,0.95),  1px  1px 0 rgba(40,90,130,0.95),
     0   -1px 0 rgba(40,90,130,0.95),  0    1px 0 rgba(40,90,130,0.95),
    -1px  0   0 rgba(40,90,130,0.95),  1px  0   0 rgba(40,90,130,0.95);
  pointer-events: none;
  z-index: 5;
}

/* flung ice fragments: glassy, faintly blue, with a bright facet highlight */
.ice-shard {
  position: absolute;
  pointer-events: none;
  z-index: 6;
  background:
    linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(214,238,255,0.85) 45%, rgba(160,205,238,0.8) 100%);
  box-shadow: 0 1px 3px rgba(60,110,150,0.45);
  will-change: transform, opacity;
}

/* Gates rendered as bolted steel plates that heat up like metal on a forge.
   --door-color carries the gate's own colour, --heat (0..1) is the incandescence:
   the plate sits cool and metallic at rest, then glows as a matching block is
   dragged near. The glow terms below — bloom radius, white-hot core opacity,
   brightness, saturation — all scale off --heat, so the same rule covers a
   cool plate and a raskalyonnaya one with one knob. */
.door {
  --door-color: #ff5722;
  --heat: 0;
  position: absolute;
  border-radius: 3px;
  /* cool body: polished metal in the gate's OWN colour (anodized bar) — the
     colour is the base, full strength, and the metal look comes from the
     multi-band specular gloss laid across the bar. No grey undercoat, so the
     colour stays clean; a heated gate still glows clearly over it. */
  background:
    /* one crisp diagonal sheen streak */
    linear-gradient(120deg,
      transparent 34%,
      rgba(255,255,255,0.12) 44%,
      rgba(255,255,255,0.38) 49%,
      rgba(255,255,255,0.10) 54%,
      transparent 64%),
    /* polished-metal bands ACROSS the bar: hot top edge, dark midline,
       secondary reflection, deep bottom — the classic chrome curve */
    linear-gradient(180deg,
      rgba(255,255,255,0.50) 0%,
      rgba(255,255,255,0.16) 14%,
      rgba(255,255,255,0) 32%,
      rgba(0,0,0,0.05) 48%,
      rgba(255,255,255,0.14) 60%,
      rgba(0,0,0,0.06) 80%,
      rgba(0,0,0,0.18) 100%),
    /* clean colour body, lit top → shaded bottom */
    linear-gradient(180deg,
      color-mix(in srgb, var(--door-color) 78%, #cfd2d6) 0%,
      color-mix(in srgb, var(--door-color) 90%, #9a9da2) 42%,
      color-mix(in srgb, var(--door-color) 72%, #3a3d42) 100%);
  /* dark outer outline + a thin top inner highlight. The heat glow terms moved
     to the .door-bloom child: Safari re-rasterizes calc(var(--heat))-driven
     box-shadow and filter on every frame (the infinite ember animation made
     EVERY gate repaint constantly), so the hot look is now painted once at
     full strength and faded with opacity — which Safari composites for free. */
  box-shadow:
    0 0 0 1px rgba(20, 22, 28, 0.78),                  /* dark outer outline */
    inset 0 1px 0 0 rgba(255, 255, 255, 0.30);           /* soft top highlight */
}

/* incandescence bloom (JS appends one per gate, first child so arrows/stars/
   grille paint above it): the outer halo, the warm inner rim AND the
   brightness/saturation lift the old filter provided — all baked in at
   --heat:1 strength, faded as one layer by opacity:var(--heat). The ember
   shimmer is now an opacity pulse whose amplitude scales with heat, so a cool
   gate (heat 0) stays perfectly still and costs nothing. */
.door-bloom {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: var(--heat, 0);
  /* keep heat changes compositor-only: no repaint of the door while dragging */
  will-change: opacity;
}
/* ‼ Кейфреймы БЕЗ var(): анимация с var() в кейфреймах не может уйти на
   композитор — WebKit гонял её на main thread и пересобирал кейфреймы всех
   ворот на каждом изменении --heat/--hint (каждый кадр твина). Пульс вынесен
   на ::before с КОНСТАНТНЫМИ кейфреймами (1→0.82), а сила свечения — opacity
   родителя (var). Произведение прозрачностей даёт прежний вид (heat→heat×0.82),
   обе половины композитятся бесплатно. */
.door-bloom::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow:
    0 0 26px 7px var(--door-color),
    0 0 42px 6px var(--door-color),
    inset 0 0 9px rgba(255, 245, 220, 0.6);
  /* white-hot wash over the plate — stands in for the old brightness() bump,
     washing out the screws/sheen as the gate heats (same read as before) */
  background: radial-gradient(120% 150% at 50% 50%,
    rgba(255, 250, 235, 0.50) 0%,
    rgba(255, 242, 215, 0.22) 55%,
    rgba(255, 236, 200, 0.10) 100%);
  animation: door-ember 2.6s ease-in-out infinite;
}

/* Standing "these are your gates" hint while a matching figure is in hand. The
   halo is baked once on its own child layer and faded purely by OPACITY through
   the --hint custom property (JS rAF tween in driveDoorHints) — the EXACT
   mechanism of the heat bloom (--heat), which has run for months with no
   cumulative cost. Deliberately NO class toggle and NO CSS transition: the
   class flipped on .door invalidated the whole gate subtree on every grab, and
   a 0↔1 transition let Safari tear down the layer's backing store at opacity:0
   and re-rasterize the big blurred halo on every ignite — those lazily-freed
   rasters piled up (progressive lag after dozens of clicks, ср. HANDOFF ×19
   раунд 15). The infinite breathe animation below mirrors door-ember on
   .door-bloom: it keeps the layer permanently alive for the compositor (painted
   ONCE, never re-rasterized), and doubles as a subtle shimmer while lit. */
.door-hint-glow {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: var(--hint, 0);
  /* keep hint changes compositor-only: no repaint of the gate itself */
  will-change: opacity;
}
/* пульс на ::before с константными кейфреймами — та же схема, что у
   .door-bloom::before выше (var() в кейфреймах = main-thread анимация) */
.door-hint-glow::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow:
    0 0 9px 2px var(--door-color),               /* modest static colour halo */
    0 0 16px 4px color-mix(in srgb, var(--door-color) 55%, transparent);
  animation: door-hint-breathe 3.1s ease-in-out infinite;
}
@keyframes door-hint-breathe {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.88; }
}

/* vertical bars: the specular bands and the lit→shaded body turn 90° so they
   still run ACROSS the bar (the diagonal sheen streak stays as is) */
.door-v {
  background:
    linear-gradient(120deg,
      transparent 34%,
      rgba(255,255,255,0.12) 44%,
      rgba(255,255,255,0.38) 49%,
      rgba(255,255,255,0.10) 54%,
      transparent 64%),
    linear-gradient(90deg,
      rgba(255,255,255,0.50) 0%,
      rgba(255,255,255,0.16) 14%,
      rgba(255,255,255,0) 32%,
      rgba(0,0,0,0.05) 48%,
      rgba(255,255,255,0.14) 60%,
      rgba(0,0,0,0.06) 80%,
      rgba(0,0,0,0.18) 100%),
    linear-gradient(90deg,
      color-mix(in srgb, var(--door-color) 78%, #cfd2d6) 0%,
      color-mix(in srgb, var(--door-color) 90%, #9a9da2) 42%,
      color-mix(in srgb, var(--door-color) 72%, #3a3d42) 100%);
}

/* two screws set near the bar's ends, in the gate's own colour: a small bright
   highlight, a tinted head, and a dark rim. Position depends on orientation
   (.door-h = ends left/right, .door-v = ends top/bottom). Sits below the
   white-hot core (::after), so the screws wash out as the gate heats up. */
.door::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  /* layer order (top first): slot, slot, head, head — all on the same 10px
     tiles so their centres line up. closest-side keeps each head a true circle
     (farthest-corner would clip it to a rounded square). The slot is a diagonal
     dark band; a matching circular mask trims it flush to the head's edge. */
  background-image:
    linear-gradient(-45deg, transparent 45%, rgba(0,0,0,0.52) 45%, rgba(0, 0, 0, 0.55) 55%, transparent 55%),
    linear-gradient(-45deg, transparent 45%, rgba(0,0,0,0.52) 45%, rgba(0,0,0,0.55) 55%, transparent 55%),
    radial-gradient(circle closest-side,
      rgba(255,255,255,0.55) 0%, rgba(255,255,255,0.45) 13%,
      var(--door-color) 32%,
      color-mix(in srgb, var(--door-color) 75%, black) 84%,
      rgba(0,0,0,0.62) 95%, rgba(0,0,0,0.62) 100%,
      transparent 100%),
    radial-gradient(circle closest-side,
      rgba(255,255,255,0.55) 0%, rgba(255,255,255,0.45) 13%,
      var(--door-color) 32%,
      color-mix(in srgb, var(--door-color) 75%, rgb(57, 57, 57)) 84%,
      rgba(0,0,0,0.62) 95%, rgba(0,0,0,0.62) 100%,
      transparent 100%);
  background-repeat: no-repeat;
  background-size: 10px 10px;
  /* keep only the two circular screw areas, so the diagonal slot is trimmed */
  -webkit-mask-image:
    radial-gradient(circle closest-side, #000 96%, transparent 100%),
    radial-gradient(circle closest-side, #000 96%, transparent 100%);
  mask-image:
    radial-gradient(circle closest-side, #000 96%, transparent 100%),
    radial-gradient(circle closest-side, #000 96%, transparent 100%);
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-size: 10px 10px;
  mask-size: 10px 10px;
  pointer-events: none;
}
.door-h::before {
  background-position: 7px 50%, calc(100% - 7px) 50%, 7px 50%, calc(100% - 7px) 50%;
  -webkit-mask-position: 7px 50%, calc(100% - 7px) 50%;
  mask-position: 7px 50%, calc(100% - 7px) 50%;
}
.door-v::before {
  background-position: 50% 7px, 50% calc(100% - 7px), 50% 7px, 50% calc(100% - 7px);
  -webkit-mask-position: 50% 7px, 50% calc(100% - 7px);
  mask-position: 50% 7px, 50% calc(100% - 7px);
}

/* white-hot core that blooms out of the centre as the gate heats up */
.door::after {
  content: '';
  position: absolute;
  inset: 1px;
  border-radius: inherit;
  /* gradient frozen at its full-heat shape and faded by opacity alone — a
     calc(var(--heat)) gradient would be repainted on every heat change during
     a drag (Safari does it on the CPU); opacity fades are composited free */
  background: radial-gradient(
    closest-side at 50% 50%,
    rgba(255, 252, 240, 1),
    var(--door-color) 68%,
    transparent 100%);
  opacity: var(--heat);
  pointer-events: none;
  /* ‼ БЕЗ transition: все писатели --heat и так покадровые (rAF-твины в
     updateDoorHeat/fadeDoorHeat), а транзишен, ретаргетящийся на каждый кадр,
     плодил CSSTransition-объект на каждую дверь на каждый кадр фейда в Safari */
  /* keep heat changes compositor-only: no repaint of the door while dragging */
  will-change: opacity;
}

/* константные кейфреймы (см. .door-bloom::before) — амплитуду задаёт opacity
   родителя через var(--heat), произведение = прежний вид heat→heat×0.82 */
@keyframes door-ember {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.82; }
}

/* ---- Switch gate (on/off) ---------------------------------------------------
   The REAL door bar is the plate that sinks — its existing steel/colour/screw
   visual is kept exactly as the other gates, so a switch gate looks like a proper
   door, not a bespoke tile. We add only two overlays: a darkening "well" shadow
   that deepens as the gate closes (the plate reads as receding into a recess) and
   a sliding metal grille shutter on top. The gate colour still shows through the
   grille bars, so its identity reads while shut. State = `.is-open` class (closed
   is the default), toggled on every effective move. The plate sinks first then
   the shutter slides over (reverse on opening), sequenced via transition-delay.
   Don't clip the bar — the grille overhangs into the bezel. */
.door-switch {
  overflow: visible;
  /* the real plate recedes a touch when closed */
  transform: scale(0.93);
  transition: transform 0.42s cubic-bezier(0.5,0,0.2,1);
}
.door-switch.is-open {
  transform: none;
  /* on opening the shutter slides off first, then the plate rises */
  transition: transform 0.42s cubic-bezier(0.5,0,0.2,1) 0.16s;
}

/* darkening "well" cap, laid over the real plate: a recess vignette + deep inner
   shadow that fade IN as the gate shuts, so the plate looks sunk into shadow. */
.door-switch .sw-well {
  position: absolute; inset: 0; border-radius: inherit;
  background: radial-gradient(125% 150% at 50% -12%, transparent 34%, rgba(0,0,0,0.5) 100%);
  box-shadow:
    inset 0 0 10px 3px rgba(0,0,0,0.55),
    inset 0 7px 13px 2px rgba(0,0,0,0.6);
  opacity: 1;                      /* CLOSED default: shadow present */
  transition: opacity 0.42s ease;
  transition-delay: 0s;
  pointer-events: none;
}
.door-switch.is-open .sw-well {
  opacity: 0;                      /* OPEN: shadow gone */
  transition-delay: 0.16s;         /* lifts AFTER the shutter has slid away */
}

/* the grille shutter: metal frame + parallel slats, colour showing between them */
.door-switch .sw-grille {
  position: absolute; inset: 0; border-radius: inherit;
  box-shadow:
    inset 0 0 0 1.5px rgba(196,200,208,0.92),  /* bright frame */
    inset 0 0 7px rgba(0,0,0,0.55);            /* recessed top vignette */
  /* CLOSED state (default): covering, opaque */
  opacity: 1;
  transform: none;
  transition: transform 0.42s cubic-bezier(0.4,0,0.2,1), opacity 0.34s ease;
  transition-delay: 0.16s;         /* slides over AFTER the plate has sunk */
  pointer-events: none;
}
/* slat direction runs across the bar's long axis */
.door-switch.door-h .sw-grille {
  background: repeating-linear-gradient(90deg,
    rgba(214,218,226,0.95) 0 2.5px,
    rgba(120,124,132,0.18) 2.5px 7px);
}
.door-switch.door-v .sw-grille {
  background: repeating-linear-gradient(0deg,
    rgba(214,218,226,0.95) 0 2.5px,
    rgba(120,124,132,0.18) 2.5px 7px);
}

/* OPEN state: grille retracts into the bezel and fades (plate rise is on the
   .door-switch transform rule above) */
.door-switch.is-open .sw-grille {
  opacity: 0;
  transition-delay: 0s;            /* shutter slides first when opening */
}
/* the shutter slides outward, away from the board, into the bezel */
.door-switch.is-open.door-bottom .sw-grille { transform: translateY(115%); }
.door-switch.is-open.door-top    .sw-grille { transform: translateY(-115%); }
.door-switch.is-open.door-left   .sw-grille { transform: translateX(-115%); }
.door-switch.is-open.door-right  .sw-grille { transform: translateX(115%); }

.overlay {
  position: fixed;
  inset: 0;
  background: rgba(10, 14, 24, 0.7);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
}

.overlay.hidden { display: none; }

.win-card {
  background: var(--board-bg);
  padding: 32px 40px;
  border-radius: 16px;
  text-align: center;
  box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}

.win-card h2 {
  margin: 0 0 16px;
}

.win-card button {
  background: #2fdd6d;
  color: #0c1a12;
  border: none;
  border-radius: 8px;
  padding: 10px 20px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}

.hint {
  color: #9aa3bf;
  font-size: 13px;
  text-align: center;
  max-width: 480px;
}

/* ---- booster bar (testing tools) ---- */
.booster-bar {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin: 6px 0 2px;
}

.booster {
  display: flex;
  align-items: center;
  gap: 8px;
  background: var(--board-bg);
  color: #f0f2f8;
  border: 1px solid var(--cell-border);
  border-radius: 12px;
  padding: 8px 14px;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.15s, box-shadow 0.15s, transform 0.1s;
  user-select: none;
}

.booster:hover { background: #3a4566; }
.booster:active { transform: translateY(1px); }

.booster .booster-ico { font-size: 20px; line-height: 1; }

/* active = "молоток в руке" */
.booster.active {
  background: #5a4a1f;
  border-color: #f1c40f;
  box-shadow: 0 0 0 2px rgba(241, 196, 15, 0.45), 0 0 14px rgba(241, 196, 15, 0.35);
}
.booster.active .booster-ico { animation: hammer-wiggle 0.9s ease-in-out infinite; }

@keyframes hammer-wiggle {
  0%, 100% { transform: rotate(-12deg); }
  50%      { transform: rotate(12deg); }
}

/* ---- flying-hammer strike FX ---------------------------------------------
   A hammer sprite is flung from the booster button, scaled up, wound back via
   rotation, then snapped down onto the tapped block (animated in JS via WAAPI).
   It lives on <body> with fixed positioning so the board's overflow:hidden
   never clips it. transform-origin sits on the handle grip so rotation reads
   as a real swing. */
.hammer-fx {
  position: fixed;
  z-index: 9999;
  pointer-events: none;
  will-change: transform, opacity;
  transform-origin: 32% 72%;
  filter: drop-shadow(0 8px 12px rgba(0, 0, 0, 0.45));
  user-select: none;
}

/* shock ring that punches out of the struck block on contact */
.smash-ring {
  position: fixed;
  z-index: 9998;
  pointer-events: none;
  border-radius: 50%;
  border: 4px solid rgba(255, 255, 255, 0.85);
  box-shadow: 0 0 18px rgba(255, 255, 255, 0.55),
              inset 0 0 14px rgba(255, 255, 255, 0.4);
  will-change: transform, opacity;
}

/* the whole playfield jolts when the head lands */
@keyframes board-smash-shake {
  0%, 100% { transform: scale(var(--view-scale)) translate(0, 0) rotate(0); }
  18% { transform: scale(var(--view-scale)) translate(-6px, 4px) rotate(-0.6deg); }
  36% { transform: scale(var(--view-scale)) translate(6px, -3px) rotate(0.6deg); }
  54% { transform: scale(var(--view-scale)) translate(-4px, 3px) rotate(-0.3deg); }
  72% { transform: scale(var(--view-scale)) translate(4px, -2px) rotate(0.3deg); }
  88% { transform: scale(var(--view-scale)) translate(-2px, 1px); }
}
.smash-shake { animation: board-smash-shake 0.36s cubic-bezier(.36, .07, .19, .97); }

@media (prefers-reduced-motion: reduce) {
  .smash-shake { animation: none; }
}

/* while the hammer is armed, the board signals "tap to smash" */
body.hammer-armed .board .block { cursor: crosshair; }
body.hammer-armed .board .block:hover {
  filter: brightness(1.15);
  outline: 2px dashed rgba(255, 255, 255, 0.6);
  outline-offset: -3px;
}

/* ---- TNT booster FX --------------------------------------------------------
   The stick is flung from the booster button (WAAPI, see flyTntTo), lands on
   the tapped cell, shivers (fuse) and detonates: a fireball flash + the shared
   smash-ring + board shake. Wood splinters (.wood-splinter) fly out of broken
   crates with the same JS projectile physics as ice shards. */
.tnt-fx {
  position: fixed;
  z-index: 9999;
  pointer-events: none;
  will-change: transform, opacity;
  transform-origin: 50% 60%;
  filter: drop-shadow(0 8px 12px rgba(0, 0, 0, 0.45));
  user-select: none;
}

.tnt-flash {
  position: fixed;
  z-index: 9997;
  pointer-events: none;
  border-radius: 50%;
  background: radial-gradient(circle,
    rgba(255, 246, 216, 0.98) 0%,
    rgba(255, 196, 92, 0.9) 32%,
    rgba(255, 112, 46, 0.6) 58%,
    rgba(255, 92, 34, 0) 74%);
  will-change: transform, opacity;
}

/* while the TNT is armed, the whole board is a target */
body.tnt-armed .board { cursor: crosshair; }
body.tnt-armed .board .block { cursor: crosshair; }
#tntBtn.active .booster-ico { animation: hammer-wiggle 0.9s ease-in-out infinite; }

.wood-splinter {
  position: absolute;
  z-index: 60;
  pointer-events: none;
  border-radius: 2px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
  will-change: transform, opacity;
}

/* golden ray-burst on detonation — a smaller sibling of the Level Complete
   coin flare (radial glow + two ray fans, screen-blended, WAAPI shock pop) */
.booster-flare {
  position: fixed;
  z-index: 9998;
  pointer-events: none;
  mix-blend-mode: screen;
  will-change: transform, opacity;
}
.booster-flare svg { display: block; overflow: visible; }

/* expanding refractive ring — the "bent air / water wave" part of the booster
   distortion (the whole-board shimmer is an SVG displacement filter, see
   spawnDistortionFx). backdrop-filter over a thin masked annulus reads as a
   travelling refraction front. */
.distort-ring {
  position: fixed;
  z-index: 9996;
  pointer-events: none;
  border-radius: 50%;
  backdrop-filter: blur(3px) saturate(1.35) brightness(1.07);
  -webkit-backdrop-filter: blur(3px) saturate(1.35) brightness(1.07);
  -webkit-mask: radial-gradient(circle, transparent 50%, #000 62%, #000 74%, transparent 85%);
  mask: radial-gradient(circle, transparent 50%, #000 62%, #000 74%, transparent 85%);
  will-change: transform, opacity;
}

/* ---- Tornado booster FX ----------------------------------------------------
   The twister sprite rides a Lissajous curve over the board (JS-driven, see
   runTornadoFx) with a spinning vortex of conic-gradient rings under it.
   Picked-up figures hover with a wobble (.tornado-lift) until they are FLIPped
   down onto their new cells. */
.tornado-fx {
  position: absolute;
  z-index: 80;
  pointer-events: none;
  will-change: left, top, transform;
  filter: drop-shadow(0 12px 14px rgba(0, 0, 0, 0.4));
  animation: tornado-bob 0.5s ease-in-out infinite alternate;
}
@keyframes tornado-bob {
  from { transform: rotate(-5deg) scale(1); }
  to   { transform: rotate(5deg) scale(1.05); }
}

.tornado-vortex {
  position: absolute;
  z-index: 78;
  pointer-events: none;
  will-change: left, top;
}
.tv-ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  opacity: 0.55;
  background: conic-gradient(from 0deg,
    rgba(255, 255, 255, 0) 0deg,
    rgba(255, 255, 255, 0.8) 70deg,
    rgba(255, 255, 255, 0) 150deg,
    rgba(220, 230, 245, 0.55) 230deg,
    rgba(255, 255, 255, 0) 330deg);
  -webkit-mask: radial-gradient(circle, transparent 32%, #000 40%, #000 62%, transparent 70%);
  mask: radial-gradient(circle, transparent 32%, #000 40%, #000 62%, transparent 70%);
  animation: tv-spin 0.5s linear infinite;
  will-change: transform;
}
.tv-r2 { inset: 12%; opacity: 0.45; animation-duration: 0.38s; animation-direction: reverse; }
.tv-r3 { inset: 26%; opacity: 0.4;  animation-duration: 0.3s; }
@keyframes tv-spin { to { transform: rotate(360deg); } }

.tornado-fx.tornado-out,
.tornado-vortex.tornado-out { transition: opacity 0.3s ease-out; opacity: 0; }

/* an airborne figure: hovers above the board with a light wobble + drop shadow */
.block.tornado-lift {
  z-index: 90;
  animation: tornado-hover 0.42s ease-in-out infinite alternate;
  filter: drop-shadow(0 18px 14px rgba(0, 0, 0, 0.38));
}
@keyframes tornado-hover {
  from { transform: translateY(-8px)  scale(1.1)  rotate(-4deg); }
  to   { transform: translateY(-16px) scale(1.14) rotate(4deg); }
}
#tornadoBtn.active .booster-ico { animation: hammer-wiggle 0.9s ease-in-out infinite; }

@media (prefers-reduced-motion: reduce) {
  .tornado-fx, .tv-ring, .block.tornado-lift { animation: none; }
}

/* ============================================================
   3D-JELLY THEME (the only theme; <body class="style-jelly3d">)
   Reworks .block-inner into a glossy, translucent jelly with a
   bright top sheen, several specular bubbles, a glowing core and
   a colour-matched outer glow — inspired by the reference render.
   ============================================================ */

/* rounder, softer silhouette for the whole figure */
.style-jelly3d .block { border-radius: 16px; }

/* clipped (non-rectangular) pieces: the colour-matched outer glow / contact
   shadow that box-shadow can't trace (clip-path cuts it) is PRE-RENDERED to
   canvas bitmaps under the body — .idle-glow at rest, .drag-glow while held
   (see buildIdleGlow/buildDragGlow in script.js). NO CSS filter on blocks at
   all: even a static drop-shadow() made Safari re-rasterize the piece on the
   CPU whenever a repaint touched it, which kept dragging janky. The base
   (non-jelly) .block.clipped drop-shadow is neutralised here for the same
   reason — jelly3d is the only theme, so nothing loses its shadow. */
.style-jelly3d .block.clipped { filter: none; }

/* Colour-matched base outline for clipped (L/T/Z/S/…) pieces. On rectangular
   pieces the coloured edge is the `--wall` inset bevel, but on clipped pieces
   those box-shadows align to the bbox, not the real silhouette, so the colour
   edge doesn't trace the shape. The SVG rim DOES follow the true outline, so we
   stroke it with the block's own (slightly darkened) colour — giving asymmetric
   pieces the same coloured outline rectangles get, hugging the real silhouette.
   The brighter white "held" ring while dragging still wins (higher specificity). */
.style-jelly3d .shape-overlay .shape-rim {
  stroke: var(--glow, rgba(255, 255, 255, 1));
  stroke: color-mix(in srgb, var(--glow) 1%, #0a0c12);
  stroke-width: 1; /* unclipped, centred on the silhouette — pixel-perfect, like the held ring */
  opacity: 0.5;
}

/* The layered "core" rim is drawn UNCLIPPED and centred on the silhouette,
   sharing the exact path with the core body — perfectly concentric, plain 1px. */
.layer-core .shape-overlay .shape-rim {
  stroke-width: 1;
}
.style-jelly3d .shape-overlay .spec { opacity: 0.1; }
.style-jelly3d .shape-overlay .spec-sm { opacity: 0.1; }

/* Silhouette-following bevel for clipped (L/J/T/S/Z/+/U) pieces — the
   inner-shadow counterpart of the inset box-shadow bevel that rectangular
   .block-inner gets. PRE-RENDERED to a canvas (buildShapeBevelCanvas in
   script.js): it used to be an SVG feGaussianBlur filter, which WebKit
   re-runs on the CPU on every rasterization of the piece — the source of the
   "fresh figure stutters for its first drag" Safari jank. Only painted under
   jelly3d, wall tint baked in from the piece colour at build time. */
.shape-bevel-cv { display: none; }
.style-jelly3d .shape-bevel-cv { display: block; }

.style-jelly3d .block-inner {
  border-radius: inherit;
  /* glassy edge instead of the flat dark outline */
  border: 1px solid rgba(255, 255, 255, 0.28);
  /* a touch of translucency so the board shows through like real jelly */
  opacity: 0.99;
  /* darker shade of the block's own colour — paints the chamfered inner side
     wall so the figure reads as a thick slab, not a flat top. Plain-dark
     fallback first, in case color-mix() isn't supported. */
  --wall: rgba(0, 0, 0, 0.497);
  --wall: color-mix(in srgb, currentColor 90%, #0a0c12);
  /* INNER BEVEL: the perceived thickness lives entirely inside the silhouette.
     A bright top-face dome, then a coloured side wall chamfered well into the
     interior (deepest at the bottom, where a front-ish view shows the most
     thickness), with a soft bright lip where the top face rolls into the wall.
     Shading is kept light so the bevel is only just noticeable. Order matters
     — earlier shadows paint on top. */
  box-shadow:
    inset 0 2px 6px rgb(255, 255, 255),   /* bright top-face dome highlight */
    inset 0 -10px 10px rgba(255, 255, 255, 0.3),     /* soft bright bevel lip           */
    inset 0 -32px 8px -16px var(--wall),          /* deep, soft coloured BOTTOM wall */
    inset 14px 0 30px -1px var(--wall),           /* left side wall, set deeper in    */
    inset -14px 0 8px -10px var(--wall),          /* right side wall, set deeper in   */
    inset 0 10px 38px -2px var(--wall),           /* faint top inner wall            */
    0 1px 1px -1px currentColor,                 /* colour-matched outer glow       */
    0 8px 18px rgba(0, 0, 0, 0.38);               /* soft contact shadow             */
}

.style-jelly3d .block.dragging .block-inner {
  opacity: 1;
  box-shadow:
    0 0 0 1px #fff,                                /* held ring, rides the jelly wobble */
    inset 0 8px 13px rgba(255, 255, 255, 0.0),
    inset 0 -2px 0 rgba(255, 255, 255, 0.1),
    inset 0 -22px 20px -2px var(--wall),
    inset 14px 0 20px -8px var(--wall),
    inset -14px 0 20px -8px var(--wall),
    inset 0 13px 18px -6px var(--wall),
    0 6px 22px 0 currentColor,
    0 12px 26px rgba(0, 0, 0, 0.45);
}

/* top-face shading: bright dome up top, fading so the bevelled side wall
   (drawn by the inset box-shadow) stays visible at the bottom edge */
/* .style-jelly3d .block-inner::before {
  inset: 0;
  border-radius: inherit;
  background:
    radial-gradient(120% 70% at 50% 4%, rgba(255, 255, 255, 0.40) 0%, rgba(255, 255, 255, 0) 52%),
    linear-gradient(to bottom,
      rgba(255, 255, 255, 0.32) 0%,
      rgba(255, 255, 255, 0.05) 28%,
      rgba(0, 0, 0, 0) 60%,
      rgba(0, 0, 0, 0.07) 100%);
} */

/* specular "bubbles" — several soft white spots that read as glass reflections.
   Positions are percentage-based, so they scale with any block size. */
.style-jelly3d .block-inner::after {
  inset: 0;
  top: 0; left: 0;
  width: auto; height: auto;
  border-radius: inherit;
  filter: none;
  background:
    radial-gradient(circle at 82% 82%, rgba(255, 255, 255, 0.30) 2%, rgba(255, 255, 255, 0) 3%),
    radial-gradient(circle at 31% 27%, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0) 6%),
    radial-gradient(circle at 71% 15%, rgba(255, 255, 255, 0.40) 0%, rgba(255, 255, 255, 0) 2%),
    radial-gradient(circle at 56% 38%, rgba(222, 236, 255, 0.35) 0%, rgba(255, 255, 255, 0) 3%),
    radial-gradient(140% 22% at 50% 6%, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 30%);
}

/* The global rule (.block.clipped .block-inner::after { display: none }) hides
   the bbox bubbles on non-rectangular pieces (L/J/T/S/Z, +, U). Under jelly3d the
   clip-path keeps the spots inside the silhouette, so bring the same specular
   "bubbles" back for clipped pieces too. */
.style-jelly3d .block.clipped .block-inner::after {
  display: block;
  inset: 0;
  top: 0; left: 0;
  width: auto; height: auto;
  border-radius: inherit;
  filter: none;
  background:
    radial-gradient(circle at 82% 82%, rgba(255, 255, 255, 0.30) 2%, rgba(255, 255, 255, 0) 3%),
    radial-gradient(circle at 31% 27%, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0) 6%),
    radial-gradient(circle at 71% 15%, rgba(255, 255, 255, 0.40) 0%, rgba(255, 255, 255, 0) 2%),
    radial-gradient(circle at 56% 38%, rgba(222, 236, 255, 0.35) 0%, rgba(255, 255, 255, 0) 3%),
    radial-gradient(140% 22% at 50% 6%, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 30%);
}

/* ============================ ropes & scissors ============================ */
/* A roped block is lashed to the board: ropes pin it at silhouette anchors and
   it can't be dragged until matching scissors snip every cord. */
.block.roped { cursor: not-allowed; }

/* one rope BAND: a full stripe wound across the figure (edge to edge). The
   wrapper is centred on the band midpoint, sized to the band, and rotated to
   the band's heading (--rope-ang); the cord inside is twisted for a corded look
   and overhangs the edges a touch so it reads as wrapping around. */
.rope {
  position: absolute;
  transform: translate(-50%, -50%) rotate(var(--rope-ang));
  z-index: 4;            /* above the block body, below a dragged block */
  pointer-events: none;
  transition: transform .32s cubic-bezier(.3,.7,.3,1), opacity .32s ease;
}
.rope-cord {
  position: absolute;
  inset: 0;
  border-radius: 6px;
  /* diagonal twist stripes + a vertical round-shade so the cord looks 3D */
  background:
    repeating-linear-gradient(48deg,
      color-mix(in srgb, var(--rope-color) 94%, #000 6%) 0 4px,
      color-mix(in srgb, var(--rope-color) 60%, #000 40%) 4px 8px),
    linear-gradient(to bottom,
      color-mix(in srgb, var(--rope-color) 100%, #fff 0%),
      color-mix(in srgb, var(--rope-color) 66%, #000 34%));
  background-blend-mode: soft-light, normal;
  box-shadow:
    0 2px 3px rgba(0,0,0,.4),
    inset 0 1.5px 0 rgba(255,255,255,.5),
    inset 0 -2px 2px rgba(0,0,0,.32);
}
/* cut: the band snaps apart and recoils along its length, fading out */
.rope.cut {
  opacity: 0;
  transform: translate(-50%,-50%) rotate(var(--rope-ang)) scaleX(.18);
}

/* scissors badge on a carrier block */
.scissors-badge {
  position: absolute;
  transform: translate(-50%, -50%);
  width: var(--badge-d); height: var(--badge-d);
  display: grid; place-items: center;
  color: var(--sc-color);
  z-index: 5;
  pointer-events: none;
}
.scissors-badge svg {
  --sc-outline: color-mix(in srgb, var(--sc-color) 30%, #000 70%);
  filter:
    drop-shadow(0.5px 0 0 var(--sc-outline)) drop-shadow(-0.5px 0 0 var(--sc-outline))
    drop-shadow(0 0.5px 0 var(--sc-outline)) drop-shadow(0 -0.5px 0 var(--sc-outline))
    drop-shadow(0 1px 1px rgba(0,0,0,.35));
}

/* ===================================================================== */
/* P1 ExitCounter visuals (door ice / size / locks, curtain, bomb, etc.) */
/* ===================================================================== */

/* Lose card reuses the win-card shell with a warmer (alarm) accent. */
.lose-card { box-shadow: 0 20px 60px rgba(40,0,0,0.55); }
.lose-card button { background: #ec1c2e; color: #fff; }

/* ---- Iced gate: a frost sheet over the steel bar with a thaw counter ---- */
.door-ice { overflow: visible; }
.door-frost {
  position: absolute; inset: 0; border-radius: 3px; z-index: 4;
  overflow: hidden;
  background:
    linear-gradient(135deg, rgba(255,255,255,0.78) 0%, rgba(207,234,255,0.6) 48%, rgba(255,255,255,0.7) 100%);
  box-shadow: inset 0 0 0 1px rgba(255,255,255,0.65), inset 0 0 8px rgba(120,180,225,0.6);
}
/* the cracked-ice SVG (shared with frozen blocks) fills the frost pane */
.door-frost .ice-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
/* a gate has no thaw phases (.ice-p1/.ice-p2), so reveal the crack tiers and
   the jagged inner bevel from the start — the same flat cracked look a frozen
   block shows at frame 1 */
.door-frost .ice-cracks-light { opacity: 0.85; }
.door-frost .ice-cracks-heavy { opacity: 0.6; }
.door-frost .ice-cracks-fine  { opacity: 0.45; }
.door-ice-count {
  position: absolute; inset: 0; z-index: 5;
  display: flex; align-items: center; justify-content: center;
  font-size: 15px; font-weight: 800; color: #eaf6ff; pointer-events: none;
  text-shadow:
    -1px -1px 0 rgba(40,90,130,0.95), 1px -1px 0 rgba(40,90,130,0.95),
    -1px  1px 0 rgba(40,90,130,0.95), 1px  1px 0 rgba(40,90,130,0.95);
}
.door-thawing .door-frost { transition: opacity .35s ease; opacity: 0; }

/* ---- Size-changing gate growth arrows (one chevron per extra cell) ---- */
.door-size-arrow {
  position: absolute; inset: 0; z-index: 5; pointer-events: none;
  display: flex; align-items: center; justify-content: center;
  color: rgba(255,255,255,0.92); font-size: 11px; line-height: 1;
  letter-spacing: -1px;
  text-shadow: 0 1px 1px rgba(0,0,0,0.6);
}
.door-size-arrow.dsa-v { flex-direction: column; letter-spacing: normal; }
/* sit just OUTSIDE the gate, flush against its growth-side edge */
.door-size-arrow.dsa-h.dsa-end   { inset: 0 auto 0 100%; margin-left: 1px; } /* grows right → right of gate */
.door-size-arrow.dsa-h.dsa-start { inset: 0 100% 0 auto; margin-right: 1px; } /* grows left → left of gate */
.door-size-arrow.dsa-v.dsa-start { inset: auto 0 100% 0; margin-bottom: 1px; } /* grows up → above gate */
.door-size-arrow.dsa-v.dsa-end   { inset: 100% 0 auto 0; margin-top: 1px; } /* grows down → below gate */

/* ---- Migrating door lock (whole-door) and part-lock (one cell) ---- */
.door-lock {
  position: absolute; z-index: 6; pointer-events: none;
  display: flex; align-items: center; justify-content: center;
  font-size: 15px; filter: drop-shadow(0 1px 1px rgba(0,0,0,.5));
  inset: 0;
}
/* Moving-lock bar: a ribbed metal bar with two bolt holes (same hardware as the
   Level 1571 button-gate shutter). It is a free element on the fx layer, sized to
   whichever gate currently holds it; on each delivery it slides to the next gate
   with an ease-in-out transition (left/top/width/height, set inline in JS). The
   ribbing + bolt-hole mask come from .shutter-slats. */
.door-lock-bar {
  position: absolute; z-index: 6; pointer-events: none;
  border-radius: 3px; font-size: 0;
  filter: drop-shadow(0 1px 2px rgba(0,0,0,.5));
}
.door-lock-bar .shutter-slats { position: absolute; inset: 0; border-radius: inherit; }
/* Moving part-lock: a metal lid with a grille that covers ONE door cell (it hops
   between the cells of that door each delivery). NOT a padlock. */
.door-partlock-mark {
  position: absolute; z-index: 6; pointer-events: none;
  border-radius: 3px;
  background:
    repeating-linear-gradient(90deg,
      rgba(90,98,110,0.97) 0 3px, rgba(38,44,54,0.97) 3px 6px),
    linear-gradient(180deg, #6b7480, #444b56);
  box-shadow: inset 0 0 0 2px rgba(20,24,30,0.9),
              inset 0 1px 0 rgba(255,255,255,0.25), 0 1px 3px rgba(0,0,0,0.5);
}
.door-locked { filter: grayscale(0.5) brightness(0.8); }

/* ---- Curtain element: a striped drape over a block, lifts when open ---- */
.curtain-overlay {
  position: absolute; inset: 0; z-index: 7; overflow: hidden;
  pointer-events: none; border-radius: 14px;
}
.curtain-drape {
  position: absolute; inset: 0;
  background:
    repeating-linear-gradient(90deg,
      rgba(150,30,46,0.96) 0px, rgba(120,18,34,0.96) 7px,
      rgba(170,46,62,0.96) 14px, rgba(120,18,34,0.96) 21px);
  box-shadow: inset 0 -6px 12px rgba(0,0,0,0.4), inset 0 2px 0 rgba(255,255,255,0.18);
  transition: transform .32s cubic-bezier(.4,0,.3,1), opacity .32s ease;
}
.curtain-rail {
  position: absolute; left: 0; right: 0; top: 0; height: 6px;
  background: linear-gradient(180deg, #d9c27a, #8a7430);
  box-shadow: 0 1px 2px rgba(0,0,0,0.5);
  transition: opacity .32s ease;
}
.curtain-overlay.is-open .curtain-drape { transform: translateY(-86%); opacity: 0.12; }
.curtain-overlay.is-open .curtain-rail { opacity: 0.35; }

/* ---- CountBomb: a move-counter sphere with a short fuse on the block ---- */
.bomb-badge {
  position: absolute; z-index: 8; pointer-events: none;
  transform: translate(-50%, -50%);
  width: var(--badge-d); height: var(--badge-d);
}
.bomb-body {
  position: absolute; left: 0; bottom: 0; width: var(--badge-d); height: var(--badge-d);
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #5a5f6b 0%, #2a2d36 55%, #14161c 100%);
  box-shadow: inset 0 2px 3px rgba(255,255,255,0.25), 0 2px 4px rgba(0,0,0,0.5);
  display: flex; align-items: center; justify-content: center;
}
.bomb-fuse {
  position: absolute; right: 4px; top: -5px; width: 7px; height: 9px;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 40%, #ffd25a, #ff7a18 60%, #d63a00);
  box-shadow: 0 0 6px 2px rgba(255,150,40,0.7);
}
.bomb-count {
  font-size: 14px; font-weight: 800; color: #fff;
  text-shadow: 0 1px 2px rgba(0,0,0,0.8);
}
.block.bomb-armed .bomb-body { animation: bomb-pulse 0.7s ease-in-out infinite; }
.block.bomb-detonate { animation: bomb-flash 0.4s ease forwards; }
@keyframes bomb-pulse {
  0%, 100% { box-shadow: inset 0 2px 3px rgba(255,255,255,0.25), 0 0 4px 1px rgba(231,76,60,0.6); }
  50%      { box-shadow: inset 0 2px 3px rgba(255,255,255,0.25), 0 0 12px 4px rgba(231,76,60,0.95); }
}
@keyframes bomb-flash {
  0%   { filter: brightness(1); }
  30%  { filter: brightness(3) saturate(2); transform: scale(1.12); }
  100% { filter: brightness(0.6); transform: scale(0.9); opacity: 0.5; }
}

/* ---- TimerBomb (#7): real-time countdown bomb ---- */
.timerbomb-badge {
  position: absolute; z-index: 8; pointer-events: none;
  transform: translate(-50%, -50%);
  width: var(--badge-d); height: var(--badge-d);
}
.timerbomb-body {
  position: absolute; left: 0; bottom: 0; width: var(--badge-d); height: var(--badge-d);
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #5a5f6b 0%, #2a2d36 55%, #14161c 100%);
  box-shadow: inset 0 2px 3px rgba(255,255,255,0.25), 0 2px 4px rgba(0,0,0,0.5);
  display: flex; align-items: center; justify-content: center;
}
.timerbomb-fuse {
  position: absolute; right: 5px; top: -5px; width: 7px; height: 9px;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 40%, #ffd25a, #ff7a18 60%, #d63a00);
  box-shadow: 0 0 6px 2px rgba(255,150,40,0.7);
  animation: timerbomb-fuse-flicker 0.5s ease-in-out infinite;
}
.timerbomb-count {
  font-size: 12px; font-weight: 800; color: #fff;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 2px rgba(0,0,0,0.85);
}
.block.timerbomb-armed .timerbomb-body {
  animation: bomb-pulse 0.55s ease-in-out infinite;
}
.block.timerbomb-armed .timerbomb-count { color: #ffd7d0; }
@keyframes timerbomb-fuse-flicker {
  0%, 100% { box-shadow: 0 0 5px 1px rgba(255,150,40,0.6); }
  50%      { box-shadow: 0 0 9px 3px rgba(255,180,60,0.95); }
}

/* ---- Fuse sparkler (bengal-fire) -----------------------------------------
   Tiny crackling sparks flung from the lit fuse tip on both bomb types. Kept
   small and local but readable thanks to additive ('plus-lighter') blending,
   which lets overlapping sparks sum into bright white-hot specks. The emitter
   layer is anchored on the fuse tip; each spark carries its own angle/distance/
   timing in inline custom props (set in JS) and loops on its own. */
.fuse-sparks {
  position: absolute;
  left: 50%; top: 1px;
  width: 0; height: 0;
  pointer-events: none;
  z-index: 9;
  overflow: visible;
}
.fuse-spark {
  position: absolute;
  left: 0; top: 0;
  width: var(--sz, 2.5px); height: var(--sz, 2.5px);
  border-radius: 50%;
  /* white-hot core fading to ember orange */
  background: radial-gradient(circle at 50% 50%,
    rgba(255,255,255,1) 0%,
    rgba(255,224,140,0.95) 35%,
    rgba(255,140,40,0.85) 70%,
    rgba(255,90,20,0) 100%);
  mix-blend-mode: plus-lighter;   /* additive blending */
  will-change: transform, opacity;
  animation: fuse-spark-fly var(--dur, 0.7s) linear var(--delay, 0s) infinite;
}
@keyframes fuse-spark-fly {
  0%   { transform: translate(-50%, -50%) scale(0.25); opacity: 0; }
  12%  { opacity: 1; }
  70%  { opacity: 0.9; }
  100% {
    transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(0);
    opacity: 0;
  }
}

/* ---- Counter-blocker: grey blocker carrying a clear-after-N countdown ---- */
.blocker-count {
  position: absolute; transform: translate(-50%, -50%);
  width: 26px; height: 26px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 14px; font-weight: 800; color: #eef1f6;
  background: rgba(20,24,32,0.55);
  box-shadow: inset 0 0 0 2px rgba(255,255,255,0.3);
  pointer-events: none; z-index: 5;
}

/* the scissors that fly out of the gate before the ropes snap. A crisp 1px
   white outline (4 hard, zero-blur drop-shadows) rings the icon, then a soft
   dark shadow underneath for depth. */
.flying-scissors {
  position: absolute;
  z-index: 60;
  pointer-events: none;
  filter:
    drop-shadow(1px 0 0 #fff) drop-shadow(-1px 0 0 #fff)
    drop-shadow(0 1px 0 #fff) drop-shadow(0 -1px 0 #fff)
    drop-shadow(0 2px 4px rgba(0,0,0,.5));
}

/* ===================================================================== */
/* P4 Unlock visuals — Key+Lock (#10/#15) and Dirty blocks + cleaners    */
/* ===================================================================== */

/* ---- Key block (#10): a golden key stamped on a normal block ---- */
.key-badge {
  position: absolute; z-index: 8; pointer-events: none;
  transform: translate(-50%, -50%);
  width: var(--badge-d); height: var(--badge-d);
  display: grid; place-items: center;
  color: #ffd24a;
}
.key-badge svg {
  filter:
    drop-shadow(1px 0 0 rgba(120,80,0,.9)) drop-shadow(-1px 0 0 rgba(120,80,0,.9))
    drop-shadow(0 1px 0 rgba(120,80,0,.9)) drop-shadow(0 -1px 0 rgba(120,80,0,.9))
    drop-shadow(0 1px 2px rgba(0,0,0,.4));
}

/* ---- Lock block (#10): a chained block, undraggable until N keys ---- */
.block.locked-block { cursor: not-allowed; }
.block.locked-block .block-inner { filter: grayscale(0.35) brightness(0.86); }
.lock-badge {
  position: absolute; z-index: 8; pointer-events: none;
  transform: translate(-50%, -50%);
  width: var(--badge-d); height: var(--badge-d);
  display: grid; place-items: center;
}
.lock-body {
  width: var(--badge-d); height: var(--badge-d);
  display: grid; place-items: center; color: #fff7d6;
}
.lock-body svg { filter: drop-shadow(0 1px 1px rgba(0,0,0,.4)); }
.lock-count {
  position: absolute; right: -3px; bottom: -3px;
  min-width: 17px; height: 17px; padding: 0 3px; border-radius: 9px;
  display: flex; align-items: center; justify-content: center;
  font-size: 12px; font-weight: 800; color: #2a2d36;
  background: #ffd24a; box-shadow: 0 1px 2px rgba(0,0,0,0.5);
}
.lock-badge.lock-open { animation: lock-pop 0.34s ease forwards; }
@keyframes lock-pop {
  0%   { transform: translate(-50%, -50%) scale(1); opacity: 1; }
  45%  { transform: translate(-50%, -50%) scale(1.25); opacity: 1; }
  100% { transform: translate(-50%, -90%) scale(0.5); opacity: 0; }
}

/* ---- Join-lock joints (#17): a numbered link sitting between two members ---- */
.joint-badge {
  position: absolute; z-index: 9; pointer-events: none;
  transform: translate(-50%, -50%);
  width: 30px; height: 30px;
  display: grid; place-items: center;
}
.joint-count {
  min-width: 26px; height: 26px; padding: 0 5px; border-radius: 14px;
  display: flex; align-items: center; justify-content: center;
  font-size: 14px; font-weight: 800; color: #28406b;
  background: radial-gradient(circle at 38% 28%, #ffffff 0%, #e2ecff 58%, #c2d2f4 100%);
  border: 2px solid #ffffff;
  box-shadow: 0 2px 5px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.7);
}
.joint-badge.joint-broken { animation: joint-pop 0.32s ease forwards; }
@keyframes joint-pop {
  0%   { transform: translate(-50%, -50%) scale(1); opacity: 1; }
  45%  { transform: translate(-50%, -50%) scale(1.3); opacity: 1; }
  100% { transform: translate(-50%, -50%) scale(0.3); opacity: 0; }
}

/* ---- Locked gate (#15): a padlock with a key counter over the bar ---- */
.door-keylock { filter: grayscale(0.5) brightness(0.82); }
.door-lock-badge {
  position: absolute; inset: 0; z-index: 7; pointer-events: none;
  display: flex; align-items: center; justify-content: center;
}
.door-lock-badge .lock-body { width: 26px; height: 26px; }
.door-lock-badge .lock-body svg { width: 17px; height: 17px; }
.door-lock-badge .lock-count {
  right: auto; bottom: auto; left: 50%; top: 60%;
  transform: translate(2px, 2px);
}
.door-lock-badge.lock-open { animation: door-lock-pop 0.34s ease forwards; }
@keyframes door-lock-pop {
  0%   { transform: scale(1); opacity: 1; }
  45%  { transform: scale(1.2); opacity: 1; }
  100% { transform: scale(0.5); opacity: 0; }
}
.door-unlocked { transition: filter .35s ease; filter: none; }

/* ---- Dirty block: grime drape that peels off over a cleaner tile ---- */
.dirty-overlay {
  position: absolute; inset: 0; z-index: 7; pointer-events: none;
  border-radius: 14px;
  background:
    radial-gradient(circle at 28% 32%, rgba(78,64,40,0.0) 5px, rgba(78,64,40,0.55) 10px, rgba(60,48,30,0.0) 16px),
    radial-gradient(circle at 70% 60%, rgba(70,58,36,0.0) 6px, rgba(70,58,36,0.5) 12px, rgba(60,48,30,0.0) 18px),
    radial-gradient(circle at 52% 80%, rgba(64,52,32,0.0) 5px, rgba(64,52,32,0.5) 9px, rgba(60,48,30,0.0) 15px),
    linear-gradient(135deg, rgba(96,80,52,0.62), rgba(58,48,32,0.62));
  box-shadow: inset 0 0 10px rgba(30,22,10,0.6);
  transition: opacity .4s ease, transform .4s ease;
}
.dirty-overlay.is-clean { opacity: 0; transform: scale(1.08); }

/* ---- Cleaner tile: a scrub pad on the board floor ---- */
.cleaner-tile {
  position: absolute; z-index: 0; pointer-events: none;
  display: grid; place-items: center;
  color: rgba(90,170,210,0.9);
  background:
    repeating-linear-gradient(45deg,
      rgba(120,200,235,0.18) 0px, rgba(120,200,235,0.18) 5px,
      rgba(120,200,235,0.07) 5px, rgba(120,200,235,0.07) 10px);
  box-shadow: inset 0 0 0 2px rgba(120,200,235,0.4);
  border-radius: 6px;
}
.cleaner-tile svg { filter: drop-shadow(0 1px 1px rgba(0,0,0,.3)); }

/* ---- Colorful button (#33): a coloured push-pad on the floor ----
   A colour cap recessed in a steel well with four corner studs. A block of the
   pad's colour parking on the cell adds .is-pressed, sinking the cap (and, via
   the engine, raising the iron shutter on the gates this colour links to). */
.button-pad {
  position: absolute; z-index: 0; pointer-events: none;
  --btn-color: #0f87d7;
  display: flex; align-items: center; justify-content: center;
}
.button-well {
  width: 70%; height: 70%;
  border-radius: 32%;
  background: radial-gradient(circle at 50% 32%, #5b606a 0%, #34373d 100%);
  box-shadow: inset 0 3px 7px rgba(0,0,0,0.55), 0 1px 0 rgba(255,255,255,0.12);
  display: flex; align-items: center; justify-content: center;
}
.button-cap {
  position: relative;
  width: 82%; height: 82%;
  border-radius: 28%;
  background: radial-gradient(circle at 50% 30%,
    color-mix(in srgb, var(--btn-color) 65%, white) 0%,
    var(--btn-color) 46%,
    color-mix(in srgb, var(--btn-color) 72%, black) 100%);
  box-shadow:
    0 5px 0 color-mix(in srgb, var(--btn-color) 55%, black),
    inset 0 2px 3px rgba(255,255,255,0.55);
  transition: transform 100ms ease, box-shadow 100ms ease;
}
.button-stud {
  position: absolute; width: 13%; height: 13%; border-radius: 50%;
  background: radial-gradient(circle at 40% 35%, rgba(255,255,255,0.9), rgba(255,255,255,0.45));
}
.button-stud:nth-child(1) { left: 13%; top: 13%; }
.button-stud:nth-child(2) { right: 13%; top: 13%; }
.button-stud:nth-child(3) { left: 13%; bottom: 13%; }
.button-stud:nth-child(4) { right: 13%; bottom: 13%; }
.button-pad.is-pressed .button-cap {
  transform: translateY(5px);
  box-shadow:
    0 0 0 color-mix(in srgb, var(--btn-color) 55%, black),
    inset 0 2px 5px rgba(0,0,0,0.4);
}

/* ---- Colorful button gate (#33): sliding iron shutter ----
   The gate (.door-button) keeps the standard steel-plate look; a metal roller
   shutter fronts it. Closed = slats seal the opening with a down chevron; open =
   the slats slide out along the gate's long axis and fade, baring the gate so a
   matching block can pass. State classes (shutter-open / shutter-closed) are
   driven by updateColorfulButtons. */
.door-button { overflow: hidden; }
.door-shutter {
  position: absolute; inset: 0; border-radius: inherit;
  pointer-events: none; z-index: 3;
}
.shutter-slats {
  position: absolute; inset: 0; border-radius: inherit;
  /* fine thin ribbing on a FIXED 7px step (top gradient layer), so the groove
     spacing is identical on every gate regardless of size or orientation. The
     step runs along the gate's long axis via --groove-angle (90deg for a
     horizontal bar = vertical seams; 180deg for a vertical bar = horizontal
     seams), kept thin like the horizontal shutter. */
  --groove-angle: 90deg;
  background:
    repeating-linear-gradient(var(--groove-angle),
      transparent 0, transparent 6px,
      rgba(0,0,0,0.32) 6px, rgba(0,0,0,0.32) 6.5px,
      rgba(255,255,255,0.25) 6.5px, rgba(255,255,255,0.25) 7px),
    linear-gradient(180deg, rgba(255,255,255,0.32), rgba(255,255,255,0) 38%, rgba(0,0,0,0.12)),
    linear-gradient(150deg, #c2c5cd 0%, #8d9099 50%, #6c6f77 100%);
  /* no inner dark frame on the slats: the gate's own native 1px outline is the
     only edge, so the shutter reads with a single thin outline (the earlier dark
     frame + dark gradient bottom stacked into a thick-looking band). */
  transition: transform 230ms cubic-bezier(.4,0,.2,1), opacity 200ms linear;
}
.shutter-slats span { display: none; }
.shutter-v .shutter-slats { --groove-angle: 180deg; }
/* Punch two peep-holes in the shutter exactly over the gate's corner screws (see
   .door-h/.door-v::before: 10px screws set 7px from each end, centred on the
   short axis) so the gate's COLOUR reads through even while sealed — otherwise the
   puzzle can't be solved. mask-composite intersect keeps the slats opaque
   everywhere except the two transparent discs (default 'add' would fill them in). */
.shutter-h .shutter-slats {
  -webkit-mask-image:
    radial-gradient(circle 6px at 12px 50%, transparent 5px, #000 5.5px),
    radial-gradient(circle 6px at calc(100% - 12px) 50%, transparent 5px, #000 5.5px);
  mask-image:
    radial-gradient(circle 6px at 12px 50%, transparent 5px, #000 5.5px),
    radial-gradient(circle 6px at calc(100% - 12px) 50%, transparent 5px, #000 5.5px);
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
}
.shutter-v .shutter-slats {
  -webkit-mask-image:
    radial-gradient(circle 6px at 50% 12px, transparent 5px, #000 5.5px),
    radial-gradient(circle 6px at 50% calc(100% - 12px), transparent 5px, #000 5.5px);
  mask-image:
    radial-gradient(circle 6px at 50% 12px, transparent 5px, #000 5.5px),
    radial-gradient(circle 6px at 50% calc(100% - 12px), transparent 5px, #000 5.5px);
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
}
/* Shadow each drilled hole casts onto the recessed screw. Painted on .shutter-bore,
   a layer that sits ON the screws but UNDER the shutter (no z-index, so it paints
   below the z-indexed shutter wrapper); the opaque slats hide it except at the
   holes. So it reads as a shadow down on the screw, not a mark on the shutter.
   Light comes from the lower-right (matching the other elements), so each ring is
   nudged ~0.8px toward that corner — the dark rim then pools a touch thicker on the
   upper-left, while the screw's centre stays clear so its colour still reads. The
   feather is wide and the peak soft so the shadow reads blurred. Only while sealed. */
.shutter-bore { position: absolute; inset: 0; border-radius: inherit; pointer-events: none; }
.door-button.shutter-open .shutter-bore { background: none; }
.door-button:not(.shutter-open) .shutter-bore-h {
  background:
    radial-gradient(circle 6px at 12.8px calc(50% + 0.8px), transparent 2.4px, rgba(0,0,0,0.42) 3.8px, transparent 6px),
    radial-gradient(circle 6px at calc(100% - 11.2px) calc(50% + 0.8px), transparent 2.4px, rgba(0,0,0,0.42) 3.8px, transparent 6px);
}
.door-button:not(.shutter-open) .shutter-bore-v {
  background:
    radial-gradient(circle 6px at calc(50% + 0.8px) 12.8px, transparent 2.4px, rgba(0,0,0,0.42) 3.8px, transparent 6px),
    radial-gradient(circle 6px at calc(50% + 0.8px) calc(100% - 11.2px), transparent 2.4px, rgba(0,0,0,0.42) 3.8px, transparent 6px);
}
/* closed reads slightly dimmed/sealed */
.door-button.shutter-closed { filter: brightness(0.93) saturate(0.92); }
/* open: retract the slats out of the gate along its long axis */
.door-button.shutter-open .shutter-slats { transform: translateX(-105%); opacity: 0; }
.door-v.door-button.shutter-open .shutter-slats { transform: translateY(-105%); }

/* ---- Colourful path (#31): coloured floor only its colour may cross ---- */
/* A path is a draggable GROUP of cell tiles living on the board floor (under the
   blocks). The group carries the hit region + drag transform; the tiles inside
   are purely visual. */
.path-group {
  position: absolute; z-index: 0;
  cursor: grab;
  touch-action: none;
  will-change: left, top, transform;
}
.path-group.dragging {
  cursor: grabbing;
  /* lift just above the floor while held, but still beneath the blocks so it
     visibly tucks UNDER same-colour pieces as it slides */
  z-index: 1;
  filter: drop-shadow(0 3px 5px rgba(0, 0, 0, 0.35));
}

.path-tile {
  position: absolute; z-index: 0; pointer-events: none;
  box-sizing: border-box;
  background:
    repeating-linear-gradient(90deg,
      color-mix(in srgb, var(--path) 30%, transparent) 0px,
      color-mix(in srgb, var(--path) 30%, transparent) 7px,
      color-mix(in srgb, var(--path) 14%, transparent) 7px,
      color-mix(in srgb, var(--path) 14%, transparent) 14px);
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--path) 55%, transparent);
  border-radius: 5px;
}

/* ---- Barriers (#29): solenoids — a housing (body) + a piston rod that slides
   in/out of one adjacent half-rail cell, flipping every delivery. ---- */

/* Steel housing the rod retracts into. Mounted on the wall (frame margin) or in
   a hole; the open face points toward the rail cell (rod-* gives the side). */
.solenoid-body {
  position: absolute; z-index: 6; pointer-events: none; box-sizing: border-box;
  background: linear-gradient(150deg, #717a86 0%, #4a525c 48%, #2f343b 100%);
  border: 2px solid #23272d;
  border-radius: 6px;
  box-shadow: 0 2px 6px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.22);
}
/* bolt detail in the housing centre */
.solenoid-body::after {
  content: ''; position: absolute; inset: 30%;
  border-radius: 50%;
  background: radial-gradient(circle at 38% 35%, #99a3af, #353b43 70%);
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.4);
}
/* the mouth the piston slides out of — a dark slot on the face toward the rail */
.solenoid-body::before {
  content: ''; position: absolute; background: #15171b; border-radius: 2px;
}
.solenoid-body.rod-px::before { right: 0; top: 22%; bottom: 22%; width: 22%; }
.solenoid-body.rod-mx::before { left: 0;  top: 22%; bottom: 22%; width: 22%; }
.solenoid-body.rod-py::before { top: 0;    left: 22%; right: 22%; height: 22%; }
.solenoid-body.rod-my::before { bottom: 0; left: 22%; right: 22%; height: 22%; }

/* The half-rail groove cut into the FLOOR of the rail cell: a thin recessed
   guide-slot along the rod axis, so an open cell clearly reads as passable
   floor (not a closed plate). z-index sits below the blocks. */
.solenoid-rail {
  position: absolute; z-index: 2; pointer-events: none; box-sizing: border-box;
  background: transparent;
}
.solenoid-rail::after {
  content: ''; position: absolute; border-radius: 3px;
  background: linear-gradient(#2a2e34, #1b1e22);
  box-shadow: inset 0 1px 2px rgba(0,0,0,.6);
}
/* vertical-edge barrier => rod travels horizontally => groove runs left-right */
.solenoid-rail.solenoid-v::after { left: 4%; right: 4%; top: 38%; height: 24%; }
/* horizontal-edge barrier => rod travels vertically => groove runs up-down */
.solenoid-rail.solenoid-h::after { top: 4%; bottom: 4%; left: 38%; width: 24%; }

/* The hazard-striped piston. Extended = fills the cell (clearly a wall, above
   the blocks). Retracted = slides back into the body and vanishes, leaving the
   open groove so the cell reads as passable. */
.solenoid-rod {
  position: absolute; z-index: 7; pointer-events: none; box-sizing: border-box;
  border-radius: 5px;
  background: repeating-linear-gradient(45deg, #f2c200 0 10px, #1c1c1c 10px 20px);
  box-shadow: 0 1px 4px rgba(0,0,0,.5), inset 0 0 0 2px rgba(0,0,0,.35);
  transition: transform .2s ease, opacity .2s ease;
}
/* Rod OUT: fills the whole cell. */
.solenoid-rod.is-extended { transform: scale(1); opacity: 1; }
/* Rod IN: collapses toward the body side and fades out so the cell looks open. */
.solenoid-rod.is-retracted { opacity: 0; }
.solenoid-rod.rod-px.is-retracted { transform: scaleX(.04); transform-origin: left; }
.solenoid-rod.rod-mx.is-retracted { transform: scaleX(.04); transform-origin: right; }
.solenoid-rod.rod-py.is-retracted { transform: scaleY(.04); transform-origin: bottom; }
.solenoid-rod.rod-my.is-retracted { transform: scaleY(.04); transform-origin: top; }

/* ---- Moveable crate (#20): a box the block carries; stays draggable ---- */
.block.movecrated { cursor: grab; }
.movecrate-overlay { filter: saturate(1.05) brightness(1.02); }

/* ---- Crate (#13): a wooden box over a block with a move counter ---- */
.block.crated { cursor: default; }
.crate-overlay {
  position: absolute; inset: 0; z-index: 9; pointer-events: none;
  border-radius: 13px; overflow: hidden;
  transition: opacity .34s ease, transform .34s ease;
}
/* brown cardboard box top — same treatment as the colour-crate lid (movable #20) */
.crate-panel {
  position: absolute; inset: 0;
  background:
    radial-gradient(120% 120% at 30% 22%, rgba(255,255,255,0.22), rgba(255,255,255,0) 58%),
    linear-gradient(160deg, #b5803f 0%, #9c6a30 55%, #875a27 100%);
  box-shadow:
    inset 0 2px 0 rgba(255,236,200,0.45),
    inset 0 -5px 9px rgba(60,36,12,0.50),
    inset 0 0 0 1px rgba(255,225,170,0.15);
}
/* STATIC crate (#13) only: wooden plank box — horizontal boards (slats) with
   grooves between them, fine vertical wood grain, soft top-left sheen. */
.plank-crate .crate-panel {
  background:
    radial-gradient(120% 120% at 30% 22%, rgba(255,246,225,0.18), rgba(255,255,255,0) 56%),
    /* grooves + thin highlight lip = gaps between horizontal planks */
    repeating-linear-gradient(180deg,
      rgba(0,0,0,0) 0 24px,
      rgba(42,25,8,0.62) 24px 26px,
      rgba(255,234,196,0.30) 26px 27px),
    /* wood grain running ALONG the boards (horizontal streaks, not across) */
    repeating-linear-gradient(1deg,
      rgba(120,82,40,0) 0 5px,
      rgba(74,48,20,0.10) 5px 6px,
      rgba(120,82,40,0) 6px 12px),
    /* alternating board tone so neighbouring planks differ slightly */
    repeating-linear-gradient(180deg,
      #b9853f 0 13px,
      #a06d31 13px 26px),
    linear-gradient(160deg, #b5803f 0%, #9c6a30 55%, #875a27 100%);
}
/* one iron nail at each END of every plank — same domed head as the gate screws
   (bright centre highlight, tinted body, dark rim) but iron-grey and with NO slot.
   Two columns (near the left and right edge) repeat once per 27px board row; the
   panel's silhouette clip trims any head that lands outside the true outline. */
.plank-crate .crate-panel::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  border-radius: inherit;
  background-image:
    radial-gradient(circle 2.25px at 5.5px 12px,
      rgba(255,255,255,0.60) 0%, rgba(238,238,242,0.45) 16%,
      #c2c2c7 42%,
      #8f8f95 86%,
      rgba(60,60,66,0.45) 97%, transparent 100%),
    radial-gradient(circle 2.25px at 5.5px 12px,
      rgba(255,255,255,0.60) 0%, rgba(238,238,242,0.45) 16%,
      #c2c2c7 42%,
      #8f8f95 86%,
      rgba(60,60,66,0.45) 97%, transparent 100%);
  background-repeat: repeat-y, repeat-y;
  background-size: 11px 27px, 11px 27px;
  background-position: 6px 0, calc(100% - 6px) 0;
}
/* the cardboard edge, drawn along the true silhouette, raised above the lid */
.crate-rim {
  position: absolute; inset: 0; width: 100%; height: 100%;
  pointer-events: none; z-index: 2; overflow: visible;
  filter:
    drop-shadow(0 1.5px 1.2px rgba(40,24,6,0.60))
    drop-shadow(0 -0.5px 0.4px rgba(255,235,195,0.40));
}
/* jute twine tied crosswise, with a knot where the strands meet (movable #20) */
.crate-twine {
  position: absolute; width: 0; height: 0; z-index: 1;
  transform: translate(-50%, -50%);
}
.crate-twine i {
  position: absolute; left: 50%; top: 50%;
  width: 260px; height: 2.4px; margin: -1.2px 0 0 -130px;
  border-radius: 2px;
  /* diagonal fibres over a tan rope body = twisted jute look */
  background:
    repeating-linear-gradient(58deg,
      rgba(95,64,28,0.55) 0 1.2px, rgba(214,184,128,0) 1.2px 3px),
    linear-gradient(180deg, #d8b87e 0%, #c39a5e 50%, #a87c44 100%);
  box-shadow: 0 0.5px 1px rgba(50,32,12,0.55), inset 0 0.5px 0 rgba(255,242,205,0.45);
}
.crate-twine i:nth-child(1) { transform: rotate(34deg); }
.crate-twine i:nth-child(2) { transform: rotate(-34deg); }
.crate-knot {
  position: absolute; left: 50%; top: 50%; width: 6px; height: 6px;
  margin: -3px 0 0 -3px; border-radius: 50%;
  background: radial-gradient(circle at 38% 34%, #e2c389, #b58a4f 70%, #936f3c);
  box-shadow: 0 0.5px 1.5px rgba(50,32,12,0.6), inset 0 0 0 0.5px rgba(255,242,205,0.4);
}
/* counter — identical to the ice/secret counter: outlined number, no circle */
.crate-count {
  position: absolute; transform: translate(-50%, -50%);
  display: flex; align-items: center; justify-content: center;
  font-size: 24px; font-weight: 800; color: #fff7ea;
  text-shadow:
    -1px -1px 0 rgba(70,44,16,0.95),  1px -1px 0 rgba(70,44,16,0.95),
    -1px  1px 0 rgba(70,44,16,0.95),  1px  1px 0 rgba(70,44,16,0.95),
     0   -1px 0 rgba(70,44,16,0.95),  0    1px 0 rgba(70,44,16,0.95),
    -1px  0   0 rgba(70,44,16,0.95),  1px  0   0 rgba(70,44,16,0.95);
  pointer-events: none; z-index: 5;
}
/* Held "grab" highlight for a BOXED piece. The figure's own held ring is a bbox
   rounded-rect (block-inner box-shadow) or sits hidden under the lid, so it never
   matches the box outline. Suppress it and light the box rim instead — the rim
   already traces the true silhouette, so the pressed highlight fits every shape. */
.block.crated.dragging .block-inner,
.block.movecrated.dragging .block-inner,
.block.colorcrated.dragging .block-inner {
  box-shadow:
    inset 0 -4px 0 rgba(0,0,0,0.18),
    inset 0 2px 0 rgba(255,255,255,0.25),
    0 6px 16px rgba(0,0,0,0.4);
}
.block.crated.dragging .shape-overlay .shape-ring,
.block.movecrated.dragging .shape-overlay .shape-ring,
.block.colorcrated.dragging .shape-overlay .shape-ring {
  stroke: transparent;
}
.crate-rim .rim-ring,
.colorcrate-rim .rim-ring {
  vector-effect: non-scaling-stroke;   /* crisp 1px, like every other held ring */
}
.block.dragging .crate-rim .rim-ring,
.block.dragging .colorcrate-rim .rim-ring {
  stroke: #fff;
}

.block.crate-cracking .crate-overlay,
.crate-overlay.crate-cracking { animation: crate-shudder 0.5s ease-in-out infinite; }
@keyframes crate-shudder {
  0%, 100% { transform: translate(0, 0) rotate(0deg); }
  25%      { transform: translate(-0.8px, 0.5px) rotate(-0.6deg); }
  75%      { transform: translate(0.8px, -0.5px) rotate(0.6deg); }
}
.crate-overlay.crate-broken { opacity: 0; transform: scale(1.12); }

/* Colorful crate (#32): a coloured container. Tint the wooden box with its
   colour (--crate-tint) and ring the frame in it so the player can read which
   colour clears it. */
.crate-overlay.colorful-crate .crate-panel {
  background:
    radial-gradient(120% 120% at 30% 22%, rgba(255,255,255,0.22), rgba(255,255,255,0) 58%),
    linear-gradient(160deg,
      color-mix(in srgb, var(--crate-tint) 78%, #6b4a22),
      color-mix(in srgb, var(--crate-tint) 92%, #2c1b0c));
  box-shadow: inset 0 2px 0 rgba(255,255,255,0.28), inset 0 -5px 9px rgba(0,0,0,0.45);
}
.crate-overlay.colorful-crate .crate-rim .rim-edge {
  stroke: color-mix(in srgb, var(--crate-tint) 55%, #2c1b0c);
}

/* ---- Movable color crate (#34): a revolver-drum lid over a hidden figure ----
   A neutral metal panel hides the figure; a drum in the centre carries `slots`
   notches (revolver chambers) around its rim, each tinted in --drum-tint. A
   delivered ball of the collected colour lights up the next notch; a full drum
   pops the lid. */
.block.colorcrated { cursor: grab; }
.block.colorcrated:active { cursor: grabbing; }
.colorcrate-panel {
  position: absolute; inset: 0;
  /* light-beige cardboard lid */
  background:
    radial-gradient(120% 120% at 30% 22%, rgba(255,255,255,0.40), rgba(255,255,255,0) 58%),
    linear-gradient(160deg, #f0e0c2 0%, #e3cda3 55%, #d6bd92 100%);
  /* a bright top bevel and a soft darker bottom so the lid reads as a raised box
     top. The panel is clipped to the silhouette by .colorcrate-overlay and the
     3px cardboard edge is drawn separately as .colorcrate-rim, so both follow the
     true outline of L/J/T/U pieces (and rectangles) exactly. */
  box-shadow:
    inset 0 2px 0 rgba(255,255,255,0.70),
    inset 0 -5px 9px rgba(150,115,60,0.38),
    inset 0 0 0 1px rgba(255,255,255,0.25);
}
/* wide decorative stripe along the lid — midpoint tone between lid and rim, with
   thin edge lines so it reads like a printed paper band on a box of marmalade */
.colorcrate-band {
  position: absolute; left: 0; right: 0;
  /* top, height and transform are set inline (fixed px, filled-row anchored) */
  background:
    linear-gradient(180deg,
      rgba(255,255,255,0.12) 0, rgba(255,255,255,0) 30%,
      rgba(0,0,0,0.06) 100%),
    #d0b584;
  box-shadow:
    inset 0 2px 0 rgba(189,158,102,0.9),
    inset 0 -2px 0 rgba(189,158,102,0.9);
  z-index: 0;
}
.colorcrate-rim {
  position: absolute; inset: 0; width: 100%; height: 100%;
  pointer-events: none; z-index: 2;
  overflow: visible;
  /* the rim sits proud of the lid: a soft downward shadow cast onto the panel
     below, plus a faint upper highlight, so the edge reads as a raised lip */
  filter:
    drop-shadow(0 1.5px 1.2px rgba(70,45,10,0.55))
    drop-shadow(0 -0.5px 0.4px rgba(255,255,255,0.45));
}
.drum {
  position: absolute; width: var(--badge-d); height: var(--badge-d);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: #bd9e66;   /* same cardboard tone as the lid's rim */
  z-index: 1;
}
.drum-hub {
  position: absolute; left: 50%; top: 50%; width: 4px; height: 4px;
  transform: translate(-50%, -50%); border-radius: 50%;
  background: rgba(255,255,255,0.28);
}
.drum-notch {
  position: absolute; width: 9px; height: 9px;
  transform: translate(-50%, -50%); border-radius: 50%;
  background: #9c7c4c;   /* a touch darker than the drum's cardboard background */
  box-shadow: inset 0 0 0 1.4px color-mix(in srgb, var(--drum-tint) 50%, #20262c);
  transition: background .25s ease, box-shadow .25s ease, transform .2s ease;
}
.drum-notch.filled {
  width: 9px; height: 9px;
  box-shadow: inset -1px -1.5px 2px rgba(0,0,0,0.45);
  transform: translate(-50%, -50%);
}
/* shared glossy ball: same look in the clip and in a filled drum notch */
.clip-ball { position: relative; }
.clip-ball::after, .drum-notch.filled::after {
  content: ""; position: absolute;
  left: 22%; top: 16%; width: 34%; height: 34%;
  border-radius: 50%; background: rgba(255,255,255,0.9);
}
/* A clip / magazine of coloured balls riding on a block, waiting to be delivered.
   The balls sit in a small dark holder, in a row. */
.clip-badge {
  position: absolute; transform: translate(-50%, -50%);
  z-index: 8; pointer-events: none;
  display: flex; gap: 3px; align-items: center;
}
.clip-ball {
  width: 9px; height: 9px; border-radius: 50%;
  box-shadow: inset -1px -1.5px 2px rgba(0,0,0,0.45);
}

/* ============================================================
   P5 · ColorState — colour-changing gates and blocks
   ============================================================ */

/* Small glyph badge on a recolouring gate (⟳ changer, ⇄ switcher). Sits on the
   door bar, above its colour fill, so the player knows it cycles colour. */
.door-cycle-badge {
  position: absolute; inset: 0; z-index: 6; pointer-events: none;
  display: flex; align-items: center; justify-content: center;
  font-size: 13px; font-weight: 800; color: rgba(255,255,255,0.95);
  text-shadow: 0 1px 2px rgba(0,0,0,0.6);
}
/* a quick pop when a gate recolours, so the colour change reads as an event */
.door-recolor { animation: door-recolor-pop .26s ease; }
@keyframes door-recolor-pop {
  0%   { filter: brightness(1.6); transform: scale(1.06); }
  100% { filter: brightness(1);   transform: scale(1); }
}

/* ---- Two-tone colour-switcher gate (#22 / #26) ----------------------------
   Two clipped half-plates split by the stepped seam: .dt-near (current colour,
   field side) and .dt-far (alt colour, beyond), each on the same metal stack so
   together they read as one plate. Each half can flip about the long (seam) axis.
   Screws (::before) and the heat core (::after) are lifted above the halves. */
/* dark recess behind the two plates: when a half flips it lifts off this slot, so
   the turn reads as a real plate moving in depth — NOT as motion under a duplicate
   full-colour plate (the base .door fill would otherwise show through). */
.door-twotone {
  background:
    linear-gradient(160deg, #26262b 0%, #161619 100%) !important;
}
/* clipping stage: masks the flipping plates to the slot, carries the 3D depth */
.door-twotone .dt-stage {
  position: absolute; inset: 0; z-index: 1; border-radius: inherit;
  overflow: hidden; perspective: 760px;
}
.door-twotone .dt-half {
  position: absolute; inset: 0; border-radius: inherit; z-index: 1;
  pointer-events: none; backface-visibility: visible;
  background:
    linear-gradient(120deg,
      transparent 34%, rgba(255,255,255,0.10) 44%, rgba(255,255,255,0.30) 49%,
      rgba(255,255,255,0.08) 54%, transparent 64%),
    linear-gradient(180deg,
      rgba(255,255,255,0.3) 0%, rgba(255,255,255,0.1) 10%, rgba(255,255,255,0) 50%,
      rgba(0,0,0,0.18) 78%, rgba(0,0,0,0.34) 100%),
    linear-gradient(0deg,
      color-mix(in srgb, var(--dt-c) 45%, transparent),
      color-mix(in srgb, var(--dt-c) 45%, transparent)),
    linear-gradient(160deg, #8d8d92 0%, #76767c 100%);
}
.door-twotone .dt-near { --dt-c: var(--door-color); }
.door-twotone .dt-far  { --dt-c: var(--door-color2); }
/* the seam itself: a 1px dark-grey polyline drawn over the join between the plates */
.door-twotone .dt-seam {
  position: absolute; inset: 0; z-index: 4; pointer-events: none; overflow: visible;
}
/* transform-origin is set per-plate inline (each plate's own centre) */
.door-twotone::before { z-index: 2; } /* screws sit on top of the halves */
.door-twotone::after  { z-index: 3; } /* heat core blooms over everything */

/* the dissolve veil: a black sheet over the whole bar whose alpha the swap drives
   up so both halves fade smoothly into black (never fully — stops at ~90%). */
.door-twotone .dt-veil {
  position: absolute; inset: -1px; z-index: 7; border-radius: inherit;
  background: #000; opacity: 0; pointer-events: none;
}

/* swap: both halves shrink slightly (recede) while the veil fades them toward
   black down to ~10% visible; the JS swaps the two colours at the darkest point
   (~190ms), so they trade places unseen and the gate fades back already switched. */
.door-twotone.dt-swap { animation: door-twotone-swap 1.2s ease-in-out both; }
.door-twotone.dt-swap .dt-veil { animation: door-twotone-veil 1.2s ease-in-out both; }
/* Sequential flip (linear timing so the phases stay distinct). The FAR plate flips
   a full turn and lands flat in the FIRST half. The NEAR plate slides toward the far
   side and waits flat through that, then flips in the SECOND half and slides back.
   Both end flat (360°, no snap); the JS swaps each plate's colour mid-turn. */
.door-h.dt-swap .dt-far { animation: dt-flip-h-far 1.2s linear both; }
.door-v.dt-swap .dt-far { animation: dt-flip-v-far 1.2s linear both; }
.door-bottom.dt-swap .dt-near { animation: dt-near-bottom 1.2s linear both; }
.door-top.dt-swap    .dt-near { animation: dt-near-top    1.2s linear both; }
.door-left.dt-swap   .dt-near { animation: dt-near-left   1.2s linear both; }
.door-right.dt-swap  .dt-near { animation: dt-near-right  1.2s linear both; }
/* far: full flip done by the midpoint, then hold flat while near takes its turn */
@keyframes dt-flip-h-far {
  0% { transform: rotateX(0); } 50% { transform: rotateX(-360deg); } 100% { transform: rotateX(-360deg); }
}
@keyframes dt-flip-v-far {
  0% { transform: rotateY(0); } 50% { transform: rotateY(-360deg); } 100% { transform: rotateY(-360deg); }
}
/* near: slide toward far and hold flat through far's flip, then flip + slide back */
@keyframes dt-near-bottom {
  0%   { transform: translateY(0)    rotateX(0); }
  22%  { transform: translateY(9px)  rotateX(0); }
  50%  { transform: translateY(9px)  rotateX(0); }
  100% { transform: translateY(0)    rotateX(360deg); }
}
@keyframes dt-near-top {
  0%   { transform: translateY(0)    rotateX(0); }
  22%  { transform: translateY(-9px) rotateX(0); }
  50%  { transform: translateY(-9px) rotateX(0); }
  100% { transform: translateY(0)    rotateX(360deg); }
}
@keyframes dt-near-left {
  0%   { transform: translateX(0)    rotateY(0); }
  22%  { transform: translateX(-9px) rotateY(0); }
  50%  { transform: translateX(-9px) rotateY(0); }
  100% { transform: translateX(0)    rotateY(360deg); }
}
@keyframes dt-near-right {
  0%   { transform: translateX(0)    rotateY(0); }
  22%  { transform: translateX(9px)  rotateY(0); }
  50%  { transform: translateX(9px)  rotateY(0); }
  100% { transform: translateX(0)    rotateY(360deg); }
}
@keyframes door-twotone-swap {
  0%   { transform: scale(1); }
  50%  { transform: scale(0.9); }
  100% { transform: scale(1); }
}
@keyframes door-twotone-veil {
  0%   { opacity: 0; }
  45%  { opacity: 0.3; }
  55%  { opacity: 0.4; }
  100% { opacity: 0; }
}

/* Inner-colour core on a colour-switcher block reuses the layered-block core,
   but we keep its own hook in case the theme wants to distinguish them. */
.colorswitch-block .layer-core { opacity: 0.96; }

/* ⇄ / ⇆ glyph stamped on switcher / swapper blocks. */
.switch-badge {
  position: absolute; transform: translate(-50%, -50%);
  width: 24px; height: 24px; border-radius: 12px;
  display: flex; align-items: center; justify-content: center;
  font-size: 15px; font-weight: 800; color: #fff;
  background: rgba(20,22,30,0.55);
  box-shadow: inset 0 0 0 2px rgba(255,255,255,0.45), 0 1px 3px rgba(0,0,0,0.45);
  z-index: 4; pointer-events: none;
}

/* Hidden-colour cover: transparent, so the block keeps its own colour. The
   silhouette is tiled with small "?" marks in a checkerboard — no plate. */
.hidden-cover {
  position: absolute; inset: 0; z-index: 4; pointer-events: none;
  /* keep the "?" field inside the piece: rectangles clip to the rounded box,
     non-rectangular pieces are additionally clipped to their silhouette path. */
  border-radius: 10px; overflow: hidden;
}
.hidden-q-field { position: absolute; inset: 0; }
.hidden-q {
  position: absolute; transform: translate(-50%, -50%);
  font-size: 13px; font-weight: 900; line-height: 1;
  /* dark grey — a touch brighter than the charcoal body, still dark */
  color: #4d4f55;
  pointer-events: none;
}
/* counter of effective moves left — styled exactly like the ice-block counter */
.hidden-count {
  position: absolute;
  display: flex; align-items: center; justify-content: center;
  transform: translate(-50%, -50%);
  font-size: 24px; font-weight: 800; color: #eaf6ff;
  text-shadow:
    -1px -1px 0 rgba(40,90,130,0.95),  1px -1px 0 rgba(40,90,130,0.95),
    -1px  1px 0 rgba(40,90,130,0.95),  1px  1px 0 rgba(40,90,130,0.95),
     0   -1px 0 rgba(40,90,130,0.95),  0    1px 0 rgba(40,90,130,0.95),
    -1px  0   0 rgba(40,90,130,0.95),  1px  0   0 rgba(40,90,130,0.95);
  pointer-events: none; z-index: 5;
}
/* the block body underneath fades to its real colour when revealed */
.block.hidden-color .block-inner { transition: background .3s ease, color .3s ease; }

/* ============================================================
   Roaming plank (corrected hasMovingDoorPartLock, multi-gate)
   ============================================================ */
/* The gate's surround shows its BASE colour; the plank is a bold bar in the PLANK
   colour, inset a little so the base colour reads as a frame around it (the
   "preview" of what the gate reverts to when the plank hops away). A roam arrow
   marks it as the travelling piece. background colour is set inline (plank colour). */
/* Wrapper: carries the landing animation. The face is clipped; the outline SVG
   strokes the same polygon as a crisp single pixel on top. */
.door-plank {
  position: absolute; inset: 0; z-index: 8; pointer-events: none;
  --dt-c: #8d8d92;            /* tint, inherited by the face */
  animation: plank-land .28s cubic-bezier(.3,1.4,.5,1);
}
/* 1px outline tracing the plank silhouette (same colour as the main door) */
.door-plank-outline {
  position: absolute; inset: 0; width: 100%; height: 100%;
  overflow: visible; pointer-events: none;
}
/* Face: clipped to the plank shape (clip-path set inline per door side), painted
   with the same layered metal stack as the colour-switch gate half (.dt-half),
   tinted by the plank colour via --dt-c so it reads as the same material. */
.door-plank-face {
  position: absolute; inset: 0;
  background:
    linear-gradient(120deg,
      transparent 34%, rgba(255,255,255,0.10) 44%, rgba(255,255,255,0.30) 49%,
      rgba(255,255,255,0.08) 54%, transparent 64%),
    linear-gradient(180deg,
      rgba(255,255,255,0.3) 0%, rgba(255,255,255,0.1) 10%, rgba(255,255,255,0) 50%,
      rgba(0,0,0,0.18) 78%, rgba(0,0,0,0.34) 100%),
    linear-gradient(0deg,
      color-mix(in srgb, var(--dt-c) 45%, transparent),
      color-mix(in srgb, var(--dt-c) 45%, transparent)),
    linear-gradient(160deg, #8d8d92 0%, #76767c 100%);
  /* soft top sheen / bottom shade like the gate half (no white frame) */
  box-shadow: inset 0 1px 1px rgba(255,255,255,0.35),
              inset 0 -3px 6px rgba(0,0,0,0.35);
}
/* each hop re-creates the plank on the new gate, so this plays on every landing */
@keyframes plank-land {
  0%   { transform: scale(0.55); opacity: 0; }
  60%  { transform: scale(1.12); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}
/* unverified plank colour: a neutral hatched lid so the roam is still visible
   without asserting a (possibly wrong) colour */
.door-plank-unknown {
  background:
    repeating-linear-gradient(45deg,
      rgba(210,214,220,0.95) 0 5px, rgba(120,126,134,0.95) 5px 10px) !important;
}
.door-plank-host { filter: brightness(1.05); }

/* ?off=ember (диагностика, см. PERF_OFF в script.js): вечные пульс-анимации
   ворот выключены — ореолы остаются, просто не дышат */
.perf-no-ember .door-bloom::before,
.perf-no-ember .door-hint-glow::before { animation: none; }
