  /* heavy rounded Korean display font for the win/celebration UI (self-hosted for offline/Capacitor) */
  @font-face {
    font-family: 'Jua';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    /* subset to the glyphs actually used (ASCII + the ~254 Hangul syllables in our copy): 2.0MB TTF
       -> 104KB WOFF. Re-subset from the source TTF (kept in repo, .vercelignored) when adding copy.
       Missing glyphs fall back to system-ui below — graceful, never tofu. */
    src: url('../../assets/fonts/Jua-subset.woff') format('woff');
  }
  :root {
    --sha-tint: ;   /* equipped Sha costume tint (set by main on equip; empty = base/no tint) */
    /* Quiet Canvas — warm light theme (calm + vivid). The vivid jewels are the only saturated color;
       everything else is a calm warm-neutral so the puzzle content pops. */
    --bg: #e7dfce;          /* warm beige page */
    --bg2: #f0eadc;         /* lighter beige (gradient top / raised) */
    --panel: #f4efe2;       /* raised card/panel surface */
    --card: #f7f2e7;        /* lightest surface (pills, tiles) */
    --lead: #d3c9b2;        /* card/modal border (CSS only; board leading is drawn dark in board.js) */
    --line: #d8cfba;        /* subtle hairline border */
    --scrim: rgba(46,39,24,.42); /* modal overlay */
    --glass: #ded4bf;       /* empty (unfilled) cell — warm neutral, reads as empty on beige */
    --text: #262a31;        /* ink */
    --muted: #8a836f;
    --s1: #e8475c;          /* ruby */
    --s2: #f0a01e;          /* amber */
    --s3: #21b083;          /* emerald */
    --s4: #2f86e0;          /* sapphire */
    --s5: #9a55e0;          /* amethyst */
    --sX: #b4a98f;
    --bad: #e23b46;
    /* gold text family — the one source for gold UI text (PLAY, rewards, victory, grade) */
    --gold: #ffe07a;        /* gold text fill */
    --gold-pale: #fff0a5;   /* pale gold (grade title) */
    --gold-deep: #7a4a08;   /* gold text stroke / shadow */
    --good: #1aa377;
    /* ── shared depth kit (2026-06-06): one source for the raised/pressed/sheen look across ALL screens.
       Surfaces & buttons compose these so every panel reads as the same warm, lifted glass. ── */
    --lift-sm: 0 2px 6px rgba(90,76,40,.13);             /* pills, list rows */
    --lift:    0 8px 20px -6px rgba(90,76,40,.28);       /* cards, nodes, raised buttons */
    --lift-lg: 0 18px 44px -14px rgba(90,76,40,.36);     /* modals, hero, elevated panels */
    --sheen:   inset 0 1px 0 rgba(255,255,255,.7);       /* top-edge highlight (light catches the rim) */
    --press:   inset 0 2px 6px rgba(120,98,54,.26);      /* recessed/pressed-in */
    --grad-card: linear-gradient(180deg, var(--card), var(--panel)); /* default raised surface fill */
    --app-h: 100dvh;
    /* 보스 봉인 색 3종 (2026-08-13, 보스 정산을 노멀과 확실히 가르는 작업) — 값은 맵 노드가 이미 쓰는
       색조를 그대로 옮겼다: ember=`.node.bossNode`(기준) 글로우 rgba(255,110,80,.58), tide/gilt=
       `.node.bossNode.bossVar-*::after`의 border-color. 새 팔레트를 발명하지 않았다.
       ⚠️ 짝 주의: `styles/app/05-map.css`의 `.node.bossNode.bossVar-*`와 `styles/app/10-game-screen.css`의
       `.bossTimer.bossVar-*`가 같은 색조를 **리터럴로** 쓴다(이 토큰을 참조하지 않는다). 이 토큰 값을
       바꾸면 그 두 곳도 같이 볼 것 — 이번 작업은 그 두 파일을 건드리지 않았다(범위 밖). */
    --seal-ember: rgba(255,110,80,.6);
    --seal-tide:  rgba(120,214,222,.6);
    --seal-gilt:  rgba(246,206,120,.62);
  }
  /* Context jewel palettes — override --s1..--s5 on the BOARD ONLY (scoped so HUD/nav/modals keep base jewels).
     loadPuzzle() toggles these every entry, so they self-correct. See game/puzzleLoader.js. */
  /* gather — 「빛 거두기」는 **기본 보석색을 그대로 쓴다** (오너 2026-07-31, 아래 팔레트 폐지).
     폐지된 것 = "Twilight Jewels"(2026-07-10 커밋 317baac): s1 어스름장미 · s2 남색 · s3 자두난초 ·
     s4 황혼청색 (s5 자수정만 유지). 엘리트 자수정 모티프를 보드까지 확장하려던 것이었는데, 결과가
     **색상환의 108° 구간(파랑~보라~분홍)에 다섯 슬롯이 몰리는** 구성이라 오너 플레이에서
     "노멀보다 색이 적고 차분하다"로 읽혔다. 기본 팔레트는 37°·161°·211°·270°·352°로 한 바퀴를 다 쓴다.
     ⚠️ 되살리려면 여기에 블록만 다시 넣으면 된다(`#board.ctxGather` 토글은 puzzleLoader에 그대로 있다).
     단 그 전에 알 것: 황혼 무드는 팔레트가 아니라 **`body.gatherRun`(배경 황혼·비네트)과 회색 빈 타일**
     (11-play-modes.css)이 만든다 — 그 둘은 이번에 건드리지 않았으므로 분위기는 그대로다. */
  /* flawlessReplay — "Moonlight Jewels": each base slot shifted cooler/paler, identity preserved per slot. */
  #board.ctxMoonlight {
    --s1: #de7384;  /* moonlit rose  ← ruby */
    --s2: #ebc547;  /* pale citrine  ← amber */
    --s3: #33ccb8;  /* aquamarine    ← emerald */
    --s4: #56afe6;  /* moonstone ice ← sapphire */
    --s5: #a685e0;  /* silver lilac  ← amethyst */
  }
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
  html, body {
    margin: 0; height: 100%;
    background: radial-gradient(120% 95% at 50% 8%, var(--bg2) 0%, var(--bg) 50%, #dccfb2 100%);
    background-color: var(--bg);
    color: var(--text);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
    overscroll-behavior: none;
  }
  body { min-height: 100%; }
  h1 { margin: 0; font-size: 22px; letter-spacing: .5px; }

  #board {
    position: relative;
    display: grid;
    gap: 0;
    padding: 0;
    background: transparent;      /* void cells show the page → shape stands free, not a rectangle */
    touch-action: none;
    user-select: none; -webkit-user-select: none;
    margin: auto 0 6px;   /* top-auto pairs with .controls margin-top:auto → board centers, controls pin bottom */
    /* a light halo + deeper warm lift float the window OFF the darker-edged canvas (board ≠ background) */
    filter: drop-shadow(0 0 1px rgba(255,252,244,.7)) drop-shadow(0 16px 28px rgba(90,76,40,.28));
    transition: filter .3s;
    animation: glassBreath 8s ease-in-out infinite; /* whole window breathes very subtly */
  }
  @keyframes glassBreath {
    0%, 100% { filter: drop-shadow(0 0 1px rgba(255,252,244,.7)) drop-shadow(0 16px 28px rgba(90,76,40,.28)) brightness(1); }
    50%      { filter: drop-shadow(0 0 2px rgba(255,252,244,.85)) drop-shadow(0 18px 31px rgba(90,76,40,.32)) brightness(1.015); }
  }
  html[data-motion="reduced"] { #board { animation: none; } }
  /* stage entrance — board "assembles" on a new stage. FULL: cells pop in as a wave (per-cell delay set in
     JS via --cin). MINI: a quick whole-board fade for the fast in-loop stages. Never blocks input. */
  #board.stageIn .cell { animation: cellIn .40s linear both; animation-delay: var(--cin, 0ms); }
  @keyframes cellIn {
    0%   { opacity: 0; transform: scaleX(.44) scaleY(.57); animation-timing-function: cubic-bezier(.2,.72,.3,1); }
    62%  { opacity: 1; transform: scaleX(1.07) scaleY(.945); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: scale(1); }
  }
  #board.stageInMini { animation: boardInMini .18s ease-out both; }
  @keyframes boardInMini { 0% { opacity: .3; transform: scale(.985); } 100% { opacity: 1; transform: scale(1); } }
  /* Layer handoff uses a deterministic row/distance wave. No gold outline or solution-region grouping:
     the neutral cells themselves rise into place and provide the only visual arrival cue. */
  #board.stageInLayer .cell {
    animation: cellInLayer .22s linear both;
    animation-delay: var(--cin, 0ms);
  }
  @keyframes cellInLayer {
    0%   { opacity: .12; transform: translateY(4px) scale(.96); animation-timing-function: cubic-bezier(.2,.72,.3,1); }
    72%  { opacity: 1; transform: translateY(-1px) scale(1.012); animation-timing-function: ease-out; }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  #board.stageInLayerReduced .cell { animation: cellInLayerReduced .12s linear both; }
  @keyframes cellInLayerReduced {
    0% { opacity: .35; }
    100% { opacity: 1; }
  }
  html[data-motion="reduced"] { #board.stageIn .cell, #board.stageInMini { animation: none !important; } }
  /* lead "tubes": the leading (outer outline + interior dividers) is drawn as one SVG overlay in
     render() — rounded, glossy black rods with a top-left sheen + bottom-right shade, so each line
     reads as a raised 3D came. Inherits #board's url(#rough) wobble → stays hand-drawn. */
  .leadLayer { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; z-index: 3; }
  /* each cell is a shard of glass; the leading between/around shards is the SVG .leadLayer above */
  .cell {
    position: relative;
    background: var(--glass);
    display: flex; align-items: center; justify-content: center;
    font-size: 20px; font-weight: 800;
    color: var(--text);   /* clue on an empty (light) cell reads dark; filled jewel cells go white below */
  }
  /* empty cell = an "off" glass tile: top-left sheen + recessed inner shadow so it reads as glass,
     not flat paper, BEFORE any jewel is painted (fixes the bland first-impression on an empty board) */
  .cell.unfilled {
    background: linear-gradient(157deg, rgba(255,255,255,.55), rgba(255,255,255,0) 46%), var(--glass);
    box-shadow: inset 0 1.5px 0 rgba(255,255,255,.6), inset 0 -4px 7px rgba(120,98,54,.18), inset 0 0 0 1px rgba(120,98,54,.05);
  }
  .cell.void { background: transparent; } /* outside the glass shape → shows the lead field behind */
  /* stained-glass pattern: grayscale fractal-noise (cloudy cathedral glass) overlaid on each FILLED
     shard — empty cells stay clean for the Quiet Canvas look (the texture only reads well over a
     saturated/painted shard, not on light beige). --bx/--by keeps the cloud continuous across a
     region; per-tile vars (set in render) drift each shard independently. */
  .cell.tex { overflow: hidden; }
  /* ⚠️ 이 질감은 **바닥의 표면이자 블록의 표면**이다 — `.tex`가 (void를 뺀) **모든 칸**에 붙는다.
     2026-08-06 두 겹 재편 때 이걸 「블록 쪽」으로 착각해 칩으로 옮겼다가 **바닥의 돌 질감이 칩 크기로
     쪼그라들어** 어두운 칸 한가운데 크림 조각이 뜨는 꼴이 났다(오너: "미적으로 엉망"). 되돌렸다.
     ⚠️ 두 장으로 나누지 않는 이유: 이건 `mix-blend-mode: soft-light`인 **덧씌우는 결**이지 불투명
        레이어가 아니다. 그래서 **칩보다 위에 한 장만** 두면 드러난 바닥과 그 위 블록을 **동시에** 입힌다.
        칸마다 두 장(큰 판이면 336장) 그릴 이유가 없다.
     ⚠️ z-index 3 = 칩(2) 위 · 글리프(3, DOM상 뒤라 이 위) 아래. 이 순서가 깨지면 질감이 블록 밑에
        깔려 다시 안 보인다. */
  /* ⚠️ `inset`은 **0**이다. 예전엔 -18px이었는데, 그건 지금은 없는 **표류 애니메이션**(칸마다 ±11px씩
     떠다니던 것 — 성능 때문에 제거됨) 때문에 여유를 둔 것이었다. 그 18px이 남아 있는 동안 이 결이 칸
     **밖으로 삐져나가서**, 그걸 막으려고 칸이 넘침을 잘라야 했고(`overflow:hidden`), 그 자르기가 다시
     **타일이 커지는 연출의 상한**이 됐다(2026-08-06 오너 B안: 1.21배가 안 나오던 진짜 이유).
     결은 `background-repeat`로 타일링되고 위치는 칸마다 `--bx/--by`로 잡으므로 **여유가 필요 없다**.
     0으로 바꾸면 무늬가 전체적으로 18px 밀릴 뿐, 칸 사이 연속성은 그대로다(칸당 증가폭이 같으므로).
     ⚠️ `border-radius: inherit` 필수 — 칸이 더 이상 안 자르므로, 없으면 이 사각형이 칸의 둥근 모서리
        밖으로 튀어나온다(칩 광택에서 이미 한 번 겪은 함정). */
  .cell.tex::before {
    content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 3;
    background-image: url("../../assets/common/glass_facets.png");
    background-size: 256px 256px; background-repeat: repeat;
    background-position: var(--bx, 0px) var(--by, 0px);
    mix-blend-mode: soft-light;   /* VORONOI cracked-glass mosaic (tileable, tools/gen-glass-facets.py): irregular polygon CELLS with darker crack edges, soft-light onto the shard → same-hue glass-piece facets. NOT a smooth crumple (that read as foil/cloth) and NOT directional bands (a monotonous river). */
    opacity: 1;
    /* static texture: the per-tile drift animation was removed for perf (an infinite per-cell transform
       promoted EVERY filled cell to its own compositor layer → idle jank). The reams still read as glass. */
  }
  .cell.unfilled::before { content: none; }   /* base boards keep empty cells clean; the game board overrides this with a very quiet idle-glass texture */
  @keyframes glassWanderA { 0%,100% { transform: translate(0,0); } 25% { transform: translate(-11px,8px); } 50% { transform: translate(9px,11px); } 75% { transform: translate(11px,-8px); } }
  @keyframes glassWanderB { 0%,100% { transform: translate(0,0); } 33% { transform: translate(10px,9px); } 66% { transform: translate(-9px,10px); } }
  @keyframes glassWanderC { 0%,100% { transform: translate(0,0); } 25% { transform: translate(8px,-11px); } 50% { transform: translate(-11px,-7px); } 75% { transform: translate(-8px,10px); } }
  @keyframes glassWanderD { 0%,100% { transform: translate(0,0); } 30% { transform: translate(-10px,-9px); } 60% { transform: translate(11px,-6px); } }
  html[data-motion="reduced"] { .cell.tex::before { animation: none; } }
  .cell .clue { position: relative; pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.22); }
  /* white reads on saturated jewels; top gloss + bottom depth makes each filled shard read as a gem */
  /* 2026-07-14 (owner: stronger "I touched this" volume, num-inspired): deepened from a near-flat 1px/5px
     hint to a real domed inset — still no per-cell border/rounded-corner (region cohesion stays intact,
     framed by the lead outline, not per-cell edges), just a more pronounced top-light + bottom-depth pair
     so each shard reads as a physically raised gem, not a flat color swatch. */
  .cell.s1 > .chip,.cell.s2 > .chip,.cell.s3 > .chip,.cell.s4 > .chip,.cell.s5 > .chip,.cell.sX > .chip { color: #fff; box-shadow: inset 0 2px 0 rgba(255,255,255,.42), inset 0 -7px 12px rgba(0,0,0,.24); }
  .cell.s1 > .chip{background:var(--s1)} .cell.s2 > .chip{background:var(--s2)} .cell.s3 > .chip{background:var(--s3)}
  .cell.s4 > .chip{background:var(--s4)} .cell.s5 > .chip{background:var(--s5)} .cell.sX > .chip{background:var(--sX)}
  .cell.locked > .chip { filter: brightness(1.04) saturate(1.42); }  /* richer jewels; brightness stays low so the colour-density variation reads */
  /* a region just SEALED (matched an allowed shape): one-shot gem flash + green glow (in-play reward) */
  /* ── 봉인에 **몸**을 준다 (오너 2026-08-06 "도형에 질량감을 허용할께") ──────────────────────────
     발단 = "타일이 칠해지고 도형이 완료되는 과정이 얌전하고 심심하다". 실측해 보니 이 애니는 **filter만**
     쓰고 `transform`이 한 줄도 없었다 — 가장 기뻐야 할 순간에 도형이 **번쩍일 뿐 아무 데도 안 움직였다.**
     축하는 전부 타일 **위에 뜨는 오버레이**(칭찬 알약·Sha·글로우)였고 완성된 도형 자체는 색만 바뀌었다.
     정본 기획 = claudedocs/board_tile_mass_plan.md.
     주는 물리 = **위에서 무게가 내려앉는다** — 유리 조각이 납틀에 눌려 안착하는 순간. 타일은 바닥에
     붙어 있으므로 `transform-origin: 50% 100%`로 **아래로만** 눌린다.
     ⚠️ 칸들은 형제 DOM이라 도형 단위 래퍼가 없다. 그래도 한 덩어리로 읽히는 조건은 **동시성 + 동일 위상**
        이다(같은 프레임·같은 키프레임·`animation-delay` 없음). **칸별 지터를 주지 말 것** — 주는 순간
        "칸 네 개가 각자 들썩임"으로 분해된다.
     ⚠️ 빛의 3박과 몸의 3박을 **같은 프레임에** 맞물렸다(16 예고 · 40 타격 · 62 되튐). 어긋나면 번쩍임과
        눌림이 따로 놀아 둘 다 흐려진다.
     ⚠️ 부피 보존(§4.6-1): 모든 프레임에서 scaleX × scaleY ≈ 1. 균일 scale만 쓰면 탄성이 안 읽힌다.
     ⚠️ **더 세게 하고 싶으면 진폭이 아니라 길이를 늘려라**(§4.6-5). 이 저장소는 진폭을 키웠다가 두 번
        반려당했다 — "탁 튕김"으로 읽히면 .62s → .72s 쪽으로만 조정할 것.
     ⚠️ 100% 이후 이동·회전 0(§4.6-4). 지속적으로 띄우지 않는 이유: 납선(lead) SVG가 칸 위에 고정으로
        그려지므로 칸만 올라가면 **납틀에서 어긋난 유리**가 된다.
     ⚠️ 바깥 shorthand를 `ease-out` → `linear`로 바꿨다 — 이징은 키프레임 안에서 단계마다 준다
        (badgeTick·palLeftTick과 같은 이 저장소 표준). 바깥에 또 얹으면 물리가 뭉갠다.
     이웃 침범 검산: 타격 scaleX 1.04 = 44px 타일에서 좌우 각 +0.9px. grout 테두리(1.5~2.1px)와 납선
     레이어가 경계 위에 있어 **겹침은 전부 어두운 경계선 밑에 숨는다.** scaleY는 아래로만 눌린다.
     감속 모드는 이 파일 아래 `animation: none !important`가 이미 덮으므로 별도 처리가 필요 없다. */
  /* ⚠️ 두 겹 재편 2단계: 몸과 빛을 **갈랐다.**
     · 몸(transform 스쿼시) = **칩**에만 — 바닥까지 누르면 판 전체가 출렁인다(1순위 함정).
     · 빛(filter·drop-shadow) = **칸**에 — 칸이 `overflow:hidden`이라 글로우를 칩에 두면 칸 모양으로
       잘려 번짐이 사라진다. 밝기 총량·도달 범위는 재편 전과 같다.
     ⚠️ 두 벌은 **같은 길이·같은 타임스탬프**여야 한다. 하나만 고치면 몸과 빛이 어긋난다. */
  /* ── 손을 뗄 때의 눌림 (오너 지시 2026-08-06) ─────────────────────────────────────────────────
     오너: "도형을 그리고 손을 뗄 때 칠해진 타일이 **빠르게** 스퀴즈되면서 손을 떼는 조작감이 나야 한다."
     지금까지 손을 떼는 순간에는 시각 반응이 **하나도** 없었다 — 봉인은 맞았을 때만 도는 별개 사건이고,
     도형 트랩 판에서는 그마저 꺼진다(render/board.js `quietJuice = trap`). 획이 끝나도 손끝이 조용했다.
     ⚠️ **`:not(.sealflash)` 필수.** 같은 칸에 봉인이 겹치면 봉인이 이긴다 — 봉인은 「맞았다」가 확정되는
        순간이라 더 큰 사건이고, 한 프레임의 정점은 하나여야 한다(연출 헌법 §3). 순서가 아니라 선택자로
        비켜 준다(파일 순서에 기대면 나중에 블록을 옮기다 조용히 뒤집힌다).
     ── 개정 2026-08-06 (오너): "손을 떼면 칠한 칸이 **지금의 1/2 속도로 커졌다가** 원래 크기로 돌아가는
        게 연출감이 더 좋다." → 눌림(squash)이 아니라 **솟아오름(swell)**으로, 길이는 .22s → **.44s**.
        물리적으로도 이쪽이 맞다: 손이 떠나는 순간은 **눌려 있던 것이 풀리는** 순간이라 타일이 위로
        솟는다. 위로 솟는 것은 화면에서 **커지는 것**으로 읽힌다 — 그래서 "커졌다가"가 정확한 관찰이다.
     ⚠️ 그래서 여기만 부피 보존(§4.6-1)을 **일부러 벗어난다.** 이 움직임은 옆으로 퍼지는 변형이 아니라
        **보는 쪽으로 다가오는 이동**이라, 가로세로가 함께 커져야 맞다. 대신 정점에서 세로를 가로보다
        조금 더 줘(1.075 vs 1.055) 「위로 뻗는다」는 방향을 남기고, 되돌아올 때 아주 살짝 지나쳤다가
        (.992/.986) 안착해 **감쇠**(§4.6-3)를 지킨다. 균일 scale()만 쓰면 그냥 확대·축소로 읽힌다.
     ⚠️ `transform-origin`을 **가운데**로 바꿨다(봉인은 아래 기준 유지). 아래 기준으로 커지면 위로만
        뻗어 윗 이웃을 밀고 칸 밖으로 잘린다(칸은 `overflow:hidden`). 가운데면 고르게 부푼다 —
        정점 1.075에서도 칩이 칸 안에 남는다(60px 칸: 54.6→58.7px, 여백 2.7px > 확장 2.05px).
     ⚠️ 길이를 JS에 적지 마라. 이 연출은 클래스 한 프레임 + CSS만으로 돈다(타이머 0). */
     ── 개정 2026-08-06 #2 (오너): "솟아났다는 느낌이 더 강해지려면? **너무 정직하게** 커졌다 작아지는
        게 아니어야 한다." 진단해 보니 「정직함」의 정체가 **둘**이었다:
        ⑴ **타이밍이 좌우대칭**이었다(32%-34%-34%에 전부 ease-in-out). 대칭은 기계로 읽힌다. 튀어오르는
           것은 **올라갈 땐 순식간, 가라앉을 땐 오래**다 → 정점을 32%에서 **16%**로 당기고 꼬리를 길게.
        ⑵ **크기만 변했다.** 크기 변화 하나는 「가까워졌다」와 「확대됐다」를 구분 못 한다. 물체가
           **올라오면 빛을 더 받는다** — 그래서 정점에 밝기를 얹었다. 높이를 파는 건 사실 이쪽이다.
        + **예비 동작**(anticipation): 0%를 눌린 상태에서 시작한다. 손가락이 방금까지 누르고 있었으니
          거짓이 아니고, 튀어오름의 체감을 가장 싸게 키우는 고전 기법이다.
     ⚠️ 빛은 **칸**에 건다(몸=칩 / 빛=칸, 봉인과 같은 분업). 칩에 `filter`를 걸면 보석의 기본
        `filter: brightness(1.1) saturate(1.7)`를 **통째로 덮어써** 0.44초 동안 색이 빠진다.
        칸에는 기본 filter가 없다(`.cell.pendingUndo`뿐이고 그 상태와는 겹치지 않는다).
     ⚠️ 밝기는 **얕게**(1.14). 봉인의 정점이 brightness(2.0)이라 그보다 확실히 아래여야 한다 — 손 뗄 때가
        「맞았다」보다 세면 정점이 뒤바뀐다(§3).
     ⚠️ 칸 filter는 그 칸을 합성 레이어로 올린다. **한 획의 칸들에만·한 번만** 도는 0.44초라 괜찮다
        (예전에 걷어낸 건 **무한 반복**이라 모든 칸이 영구히 레이어가 되던 것이다). 상시 애니로 바꾸지 마라. */
  .cell.releasePop:not(.sealflash) > .chip { animation: releasePop .44s linear; transform-origin: 50% 50%; }
  .cell.releasePop:not(.sealflash)         { animation: releasePopGlow .44s linear; }
  @keyframes releasePop {
    0%   { transform: scaleX(1.03)  scaleY(.965);  animation-timing-function: ease-out; }     /* 예비: 아직 눌려 있다 (손가락의 자국) */
    16%  { transform: scaleX(1.05)  scaleY(1.09);  animation-timing-function: ease-in-out; }  /* 튀어오름 — 순식간. 세로를 더 = 위로 뻗는 방향 */
    40%  { transform: scaleX(1.015) scaleY(.976);  animation-timing-function: ease-in-out; }  /* 내려앉으며 지나침 */
    66%  { transform: scaleX(.997)  scaleY(1.014); animation-timing-function: ease-in-out; }  /* 2차 되튐 — 한 단 작게 (감쇠) */
    100% { transform: scale(1); }                                                             /* 안착 — 이후 이동 없음 */
  }
  @keyframes releasePopGlow {
    0%   { filter: brightness(.97);  animation-timing-function: ease-out; }
    16%  { filter: brightness(1.14); animation-timing-function: ease-in-out; }   /* 올라온 만큼 빛을 더 받는다 = 「높이」 */
    40%  { filter: brightness(1.02); animation-timing-function: ease-in-out; }
    100% { filter: brightness(1); }
  }
  html[data-motion="reduced"] .cell.releasePop,
  html[data-motion="reduced"] .cell.releasePop > .chip { animation: none; }
  /* ── 봉인 전파 (오너 2026-08-08) ─────────────────────────────────────────────────────────────
     `--seal-d`는 render/board.js가 칸마다 심는다 — 도형을 완성시킨 칸에서 거리 × 40ms.
     ⚠️ **`backwards`가 핵심이다.** 지연만 주면 색(.sN 클래스)은 이미 붙어 있어서 전부 동시에 켜지고
        애니만 늦게 돈다 = 고치려던 「스위치」 그대로다. `backwards`가 대기 시간 동안 0% 프레임을
        입히므로, 아래 0%/16%를 **무채색**으로 두면 자기 차례가 올 때까지 회색으로 기다린다.
     ⚠️ `both`가 아니라 `backwards`다 — `both`는 끝난 뒤에도 100% 프레임을 물고 있어 그 칸의
        `:active` 눌림 피드백이 영구히 죽는다(이 저장소의 기존 함정, 연출 정본 §4.6). */
  .cell.sealflash { animation: sealShineGlow .62s linear var(--seal-d, 0s) backwards; }
  /* ⚠️ 기준점이 **도형의 중심 한 곳**이다(render/board.js sealOrigin). 칸마다 거는 같은 scale이
     합쳐져 **영역 전체의 scale**이 되므로, 네 칸이 제각각 눌리는 대신 도형 하나가 부풀었다 안착한다.
     `--seal-ox/oy`가 없는 칸(전파 대상이 아닌 봉인)은 종전대로 자기 아래변을 쓴다. */
  .cell.sealflash > .chip { animation: sealShine .62s linear;
    transform-origin: var(--seal-ox, 50%) var(--seal-oy, 100%); }
  /* ③ seal 3-beat: 예고(inhale) → 타격(punch) → 여운. juice_upgrade_plan.md P1-③
     ⚠️ **몸(transform)과 빛(filter)이 갈라져 있다 (오너 2026-08-08).** 아래 sealShine은 **몸만**,
        sealShineGlow는 **빛만** 맡는다. 이유: 빛에는 칸마다 `--seal-d` 지연을 걸어 색이 번지게 하고,
        몸은 **지연 없이 다 함께** 움직여 도형 하나가 통째로 부풀게 해야 하기 때문이다.
        둘을 합치면 지연이 몸까지 늦춰 도형이 조각조각 움직인다 = 「칸 여럿」으로 되돌아간다.
     ⚠️ 그래서 몸 키프레임에는 filter가 **없다.** 다시 넣으면 칸(Glow)의 필터와 곱해져 두 배로 세지고,
        지연이 안 걸린 채 색이 먼저 들어와 전파가 깨진다. */
  @keyframes sealShine {
    0%   { transform: scale(1); animation-timing-function: ease-in-out; }
    16%  { transform: scaleX(.99) scaleY(1.015); animation-timing-function: ease-in-out; }   /* 예고: 숨 들이쉼 + 몸을 편다 */
    40%  { transform: scaleX(1.04) scaleY(.945); animation-timing-function: ease-in-out; }  /* 타격: 무게가 내려앉는다 */
    62%  { transform: scaleX(.985) scaleY(1.02); animation-timing-function: ease-in-out; }     /* 되튐: 한 단 작게 */
    100% { transform: scale(1); }  /* 안착 — 이후 이동 없음 */
  }
  /* final-stretch seals (완성 근접, board.js sealProgress≥.72) punch harder — "초반 속삭임 → 마지막 펀치"
     몸도 빛을 따라간다: 타격을 한 단 깊게(.945 → .93) + 되튐 한 단 추가. */
  .cell.sealflash.sealLate { animation: sealShineLateGlow .7s linear var(--seal-d, 0s) backwards; }
  .cell.sealflash.sealLate > .chip { animation: sealShineLate .7s linear;
    transform-origin: var(--seal-ox, 50%) var(--seal-oy, 100%); }
  @keyframes sealShineLate {
    0%   { transform: scale(1); animation-timing-function: ease-in-out; }
    15%  { transform: scaleX(.985) scaleY(1.02); animation-timing-function: ease-in-out; }
    38%  { transform: scaleX(1.055) scaleY(.93); animation-timing-function: ease-in-out; }
    60%  { transform: scaleX(.978) scaleY(1.028); animation-timing-function: ease-in-out; }
    80%  { transform: scaleX(1.012) scaleY(.99);  animation-timing-function: ease-in-out; }                                           /* 되튐 2단 — 감쇠 */
    100% { transform: scale(1); }
  }

  /* 빛 전용(칸) — 위 sealShine/sealShineLate의 filter 줄을 그대로 옮겨 온 짝이다. 값이 달라지면
     번쩍임과 눌림이 따로 놀아 둘 다 흐려진다. 몸 쪽을 고치면 여기도 같은 프레임에서 고칠 것. */
  @keyframes sealShineGlow {
    /* ⚠️ 위 sealShine의 filter 줄과 **짝**이다 — 0%/16%의 saturate(0)은 「아직 내 차례가 아니다」를
       뜻하고, 몸 쪽만 고치면 빛과 눌림이 따로 놀아 둘 다 흐려진다. */
    /* ⚠️ 대기 상태는 **칠하는 중의 옅은 색**과 같은 농도여야 한다(2026-08-08). 0으로 두면 봉인이
       시작되는 순간 오히려 회색으로 후퇴해, 「차오른다」가 「한 번 꺼졌다 켜진다」가 된다.
       칠하기 틴트가 22%이므로 봉인 대기도 그 언저리(.28)로 맞춘다 — 둘은 짝이다. */
    0%   { filter: brightness(.92) saturate(.28); }
    16%  { filter: brightness(.9)  saturate(.28); }
    40%  { filter: brightness(2.0)  saturate(1.34) drop-shadow(0 0 11px rgba(120,255,180,.98)); }
    62%  { filter: brightness(1.4)  saturate(1.28) drop-shadow(0 0 5px rgba(120,255,180,.5)); }
    100% { filter: brightness(1.16) saturate(1.2)  drop-shadow(0 0 0 rgba(120,255,180,0)); }
  }
  @keyframes sealShineLateGlow {
    0%   { filter: brightness(.88) saturate(.28); }
    15%  { filter: brightness(.85) saturate(.28); }
    38%  { filter: brightness(2.5)  saturate(1.5)  drop-shadow(0 0 18px rgba(140,255,190,1)); }
    60%  { filter: brightness(1.6)  saturate(1.35) drop-shadow(0 0 8px rgba(140,255,190,.6)); }
    100% { filter: brightness(1.16) saturate(1.2)  drop-shadow(0 0 0 rgba(140,255,190,0)); }
  }
  /* the matched-shape palette swatch celebrates the moment a region completes it (replaces num's clue-pop) */
  .swatchPop { animation: swatchPop .95s cubic-bezier(.2,1.5,.4,1); transform-origin: center; }
  @keyframes swatchPop {
    0%   { transform: scale(1); animation-timing-function: ease-in-out; }
    25%  { transform: scaleX(1.36) scaleY(1.55); filter: brightness(1.6) drop-shadow(0 0 10px rgba(110,255,170,.9)); animation-timing-function: ease-in-out; }
    58%  { transform: scaleX(1.26) scaleY(1.11); filter: brightness(1.3) drop-shadow(0 0 6px rgba(110,255,170,.55)); animation-timing-function: ease-in-out; }
    80%  { transform: scaleX(.965) scaleY(1.036); animation-timing-function: ease-in-out; }
    100% { transform: scale(1); filter: none; }
  }
  /* a region is closed off but isn't an allowed shape (can't grow → definitively wrong): ruby-tinted
     tile + a gentle breathing pulse so it reads as "fix me" without color alone (also a status line). */
  .cell.err { box-shadow: inset 0 0 0 99px rgba(206,52,62,.30), inset 0 1px 0 rgba(255,255,255,.18), inset 0 -5px 9px rgba(0,0,0,.16); animation: errBreathe 1.5s ease-in-out infinite; }
  @keyframes errBreathe { 0%,100% { filter: brightness(1) } 50% { filter: brightness(1.12) saturate(1.15) } }
  .cell.errflash { animation: errShake .42s ease-out; }
  @keyframes errShake {
    0%   { filter: brightness(1.3) drop-shadow(0 0 8px rgba(226,59,70,.9)); transform: translateX(0); }
    20%  { transform: translateX(-2px); } 45% { transform: translateX(2px); } 70% { transform: translateX(-1px); }
    100% { filter: brightness(1) drop-shadow(0 0 0 rgba(226,59,70,0)); transform: translateX(0); }
  }
  /* hint reveal: the matching palette icon glows pale sage to connect board outline → palette reference without
     borrowing the gold currency signal.
     쫀득 물리 적용(2026-08-02, visual_audio_effects_principles.md §4.6): 균일 scale → 부피보존 스쿼시/스트레치
     감쇠 사다리(첫 눌림 → 늘어남 → 한 단 더 → rest).
     ⚠️ **지속시간은 1.8s에서 늘리지 마라.** `render/board.js`가 1800ms 뒤에 이 클래스를 떼어낸다
     (`setTimeout(() => …remove("swatchHint"), 1800)`). 늘리면 애니가 중간에서 잘려 튄다.
     여기는 원래 1.8s라 사다리 4단이 이미 넉넉히 들어간다 — 늘릴 이유가 없다.
     (2026-08-02 전파 작업에서 2.4s로 늘렸다가 이 결합을 발견하고 되돌린 자리다.) */
  .swatchHint { animation: swatchHint 1.8s linear forwards; transform-origin: center; }
  @keyframes swatchHint {
    0%   { filter: none; transform: scale(1); animation-timing-function: ease-in-out; }
    18%  { filter: drop-shadow(0 0 7px #c7ead3) brightness(1.42); transform: scaleX(1.22) scaleY(1.09); animation-timing-function: ease-in-out; }
    46%  { filter: drop-shadow(0 0 5px rgba(199,234,211,.62)) brightness(1.22); transform: scaleX(.965) scaleY(1.04); animation-timing-function: ease-in-out; }
    72%  { filter: drop-shadow(0 0 4px rgba(199,234,211,.46)) brightness(1.1); transform: scaleX(1.025) scaleY(.976); animation-timing-function: ease-in-out; }
    100% { filter: none; transform: scale(1); }
  }
  html[data-motion="reduced"] { .cell.sealflash, .cell.sealflash > .chip, .swatchPop, .swatchHint, .cell.err, .cell.errflash { animation: none !important; } }
