  /* HINT LOCATION: a distinct-coloured OUTLINE around each hint-revealed region (static SVG layer drawn by
     render/board.js drawHintOutline — bright gold core + dark halo). Reads on ANY jewel colour, needs no
     animation/effect, so it stays visible on low-end / reduced-motion / battery-saver phones. */
  #board .hintOutlineLayer { position: absolute; left: 0; top: 0; pointer-events: none; z-index: 5; }
  /* in-play ambient juice: completion warmth (grows with solve progress) + combo edge-glow */
  #solveGlow { position: fixed; inset: 0; pointer-events: none; z-index: 4; opacity: 0; transition: opacity .5s ease;
    background: radial-gradient(circle at 50% 46%, rgba(255,210,120,.30), transparent 58%); }
  #comboGlow { position: fixed; inset: 0; pointer-events: none; z-index: 5; opacity: 0; transition: opacity .585s ease;
    background: radial-gradient(circle at 50% 60%, transparent 50%, rgba(255,196,92,.5) 118%); }
  #comboGlow.pulse { animation: comboPulse .4s ease-out; }
  @keyframes comboPulse { 0% { filter: brightness(1); } 35% { filter: brightness(1.7); } 100% { filter: brightness(1); } }
  /* P2-④ near-win drama: ONE radial gradient = vignette (dark toward edges) + spotlight (transparent hole
     over the empty last region). Positioned/sized in JS to the .gameBoardArea box so it never dims the HUD
     or action bar. z:3 sits under the warm solveGlow/comboGlow (which stay bright over the region) but over
     the settled board. pointer-events:none → the final placing stroke is never blocked. */
  #nearWinFocus {
    position: fixed; z-index: 3; pointer-events: none; opacity: 0;
    transition: opacity .55s ease;
    /* 2026-07-20 (owner: still a rectangle) — overlay is now FULL VIEWPORT (placeNearWinFocus), so the vignette
       fades toward the SCREEN corners (100% = farthest screen corner) instead of a board-rectangle edge → no box.
       Transparent hole over the last region, gentle dim to the corners. Kept subtle so the HUD stays readable. */
    background: radial-gradient(circle at var(--nwx) var(--nwy),
      rgba(20,13,5,0) 0, rgba(20,13,5,0) var(--nwhole),
      rgba(20,13,5,.24) calc(var(--nwhole) + 150px), rgba(20,13,5,.34) 100%);
  }
  #nearWinFocus.show { opacity: 1; }
  body:not([data-screen="game"]) #nearWinFocus { display: none !important; }
  html[data-motion="reduced"] #nearWinFocus { display: none !important; }
  #sealPraiseFx {
    position: fixed;
    z-index: 8;
    transform: translate(-50%, -50%);
    pointer-events: none;
    display: grid;
    place-items: center;
    min-width: 154px;
    min-height: 66px;
    filter: drop-shadow(0 7px 12px rgba(28,18,4,.38));
    animation: spfIn 1.55s cubic-bezier(.18,1.12,.3,1) both;
  }
  #sealPraiseFx .spfRing {
    position: absolute;
    width: 112px;
    height: 50px;
    border-radius: 50%;
    border: 2px solid rgba(255,231,126,.68);
    box-shadow: 0 0 13px rgba(255,211,88,.56), inset 0 0 10px rgba(255,255,255,.16);
    animation: spfRing 1.18s ease-out both;
  }
  #sealPraiseFx .spfText {
    position: relative;
    padding: 15px 22px 13px;
    border-radius: 999px;
    background:
      linear-gradient(180deg, rgba(61,91,47,.88), rgba(34,65,35,.92)),
      radial-gradient(circle at 50% 0, rgba(255,235,135,.35), transparent 70%);
    border: 2px solid rgba(224,172,73,.82);
    color: #fff4c7;
    font-size: clamp(20px, 5vw, 28px);
    font-weight: 950;
    line-height: 1;
    white-space: nowrap;
    text-shadow: 0 2px 0 #4b2b0d, 0 0 9px rgba(255,225,118,.38);
  }
  /* Sha cameo (tier5/8 only, see fx/seal.js) — anchored just outside the pill's left edge so it never
     covers the reward text; rides the parent's spfIn animation, no separate motion of its own. */
  #sealPraiseFx .spfSha {
    position: absolute;
    left: -34px;
    bottom: -6px;
    width: 42px;
    height: auto;
    filter: drop-shadow(0 3px 6px rgba(28,18,4,.32));
  }
  #sealPraiseFx.tier5 .spfRing { width: 132px; border-color: rgba(255,238,148,.78); }
  #sealPraiseFx.tier8 .spfRing { width: 154px; border-color: rgba(255,248,188,.86); box-shadow: 0 0 18px rgba(255,226,108,.74), inset 0 0 12px rgba(255,255,255,.20); }
  #sealPraiseFx.elite .spfRing { border-color: rgba(221,190,255,.86); box-shadow: 0 0 18px rgba(175,100,255,.72), inset 0 0 12px rgba(255,255,255,.18); }
  #sealPraiseFx.elite .spfText {
    background:
      linear-gradient(180deg, rgba(62,49,91,.9), rgba(38,31,65,.94)),
      radial-gradient(circle at 50% 0, rgba(221,190,255,.38), transparent 70%);
    border-color: rgba(218,183,255,.86);
  }
  @keyframes spfIn {
    0% { opacity: 0; transform: translate(-50%, calc(-50% + 12px)) scale(.82); }
    14% { opacity: 1; transform: translate(-50%, -50%) scale(1.04); }
    68% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
    100% { opacity: 0; transform: translate(-50%, calc(-50% - 28px)) scale(.98); }
  }
  @keyframes spfRing {
    0% { opacity: 0; transform: scale(.5); }
    24% { opacity: .95; }
    100% { opacity: 0; transform: scale(1.7); }
  }
  /* "last piece" quiet cameo (fx/seal.js notifyLastPiece) — corner-anchored, no ring/text/sound, just a
     gentle fade so it never competes with the player's final placing stroke. Deliberately calmer than
     #sealPraiseFx: this is anticipation, not celebration (celebration stays reserved for the win sequence). */
  #lastPieceSha {
    position: fixed;
    z-index: 6;
    width: 46px;
    height: auto;
    transform: translate(-100%, -100%);
    pointer-events: none;
    filter: drop-shadow(0 4px 8px rgba(28,18,4,.3));
    animation: lastPieceIn 2s ease-out both;
  }
  @keyframes lastPieceIn {
    0% { opacity: 0; transform: translate(-100%, -100%) translateY(6px) scale(.9); }
    18% { opacity: 1; transform: translate(-100%, -100%) scale(1); }
    72% { opacity: 1; }
    100% { opacity: 0; }
  }
  body:not([data-screen="game"]) #solveGlow, body:not([data-screen="game"]) #comboGlow { opacity: 0 !important; }
  body:not([data-screen="game"]) #sealPraiseFx { display: none !important; }
  body:not([data-screen="game"]) #lastPieceSha { display: none !important; }
  html[data-motion="reduced"] { #comboGlow.pulse, #sealPraiseFx, #sealPraiseFx .spfRing { animation: none; } #solveGlow, #comboGlow { transition: none; } }
  html[data-motion="reduced"] #lastPieceSha { animation: none; opacity: .9; }
  /* ⑧ undo comfort cameo (fx/seal.js notifyUndo) — bottom-LEFT corner (opposite #lastPieceSha), a quiet
     reassuring "괜찮아, 다시" nod. Visual only, calmer than any celebration. Base translate(0,-100%) is kept
     in every keyframe (transform-clobber trap). */
  #undoSha {
    position: fixed;
    z-index: 6;
    width: 44px;
    height: auto;
    transform: translate(0, -100%);
    pointer-events: none;
    filter: drop-shadow(0 4px 8px rgba(28,18,4,.3));
    animation: undoShaIn 1.5s ease-out both;
  }
  #undoSha.emotionMirror { width: 50px; animation-duration: 1.2s; }
  @keyframes undoShaIn {
    0%  { opacity: 0; transform: translate(0, -100%) translateY(8px) scale(.9); }
    16% { opacity: 1; transform: translate(0, -100%) scale(1); }
    36% { transform: translate(0, -100%) translateY(3px); }   /* nod down… */
    52% { transform: translate(0, -100%) translateY(0); }     /* …and back = 끄덕 */
    72% { opacity: 1; transform: translate(0, -100%); }
    100%{ opacity: 0; }
  }
  body:not([data-screen="game"]) #undoSha { display: none !important; }
  html[data-motion="reduced"] #undoSha { animation: none; opacity: .9; }
  /* glass specular → glossy candy specular (2026-07-14, "재질까지 num처럼 매끈하게"): cells are now fully
     isolated candy tiles (each with its own gap/border), not a flowing multi-cell pane, so a per-cell
     radial highlight no longer breaks any cross-cell continuity — added one (upper-left "shine spot",
     matching .cell.locked::after) alongside the edge sheen for a defined glossy-button highlight. */
  .cell.paint > .chip::after, .cell.s1 > .chip::after, .cell.s2 > .chip::after, .cell.s3 > .chip::after, .cell.s4 > .chip::after, .cell.s5 > .chip::after, .cell.sX > .chip::after {
    content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 1;
    /* ⚠️ `border-radius: inherit` 필수 — 이게 없으면 이 광택은 **정사각형**이라 칩의 둥근 모서리
       (≈9.7px) 밖으로 삐져나온다. 칩에 `overflow:hidden`도 없어 잘리지도 않는다.
       게다가 이 광택은 **위쪽이 밝고 아래쪽이 0**이라(상단 띠 .40) 위 두 모서리만 밝게 튀어나와,
       오너 눈에 "칠한 타일과 바닥 사이에 라운드 없는 정사각형 중간층"으로 보였다(2026-08-06).
       같은 이유로 타일의 밝은 덩어리가 위로 더 뻗어 "타일이 위로 쏠렸다"는 인상에도 가담했다. */
    border-radius: inherit;
    background:
      linear-gradient(180deg, rgba(255,255,255,.40) 0%, rgba(255,255,255,.08) 13%, transparent 22%),
      linear-gradient(90deg,  rgba(255,255,255,.20) 0%, transparent 14%),
      radial-gradient(46% 34% at 28% 24%, rgba(255,255,255,.5), transparent 68%);
    mix-blend-mode: screen;
  }
  /* ⚠️ 옛 BACKLIGHT(`.glassLight` + `::before` 태양 + glassSunWander/Pulse/Ambient 키프레임)는
     **제거됐다** (오너 2026-08-02). 보드와 같은 사각형에 `mix-blend-mode: screen`이라 판 영역만 밝아져
     어두운 배경에서 직사각형 경계가 드러났다("배경색과 퍼즐타일 백그라운드 색이 서로 달라").
     되살린다면 판보다 넓게 펼쳐 가장자리에서 0으로 빠지게 할 것. 부활은 git 이력에서. */
  /* shape mode: a region placed but not yet a valid shape — warm-neutral tactile tile (no jewel yet),
     so sealing into a valid shape (→ .sN.locked) reads as a vivid pop. */
  .cell.paint > .chip { background: #c7b89c; box-shadow: inset 0 2px 0 rgba(255,255,255,.35), inset 0 -5px 9px rgba(0,0,0,.14); }
  /* reverse-drag ghost (input/paint.js headCursor): this cell is still part of the live stroke's own path,
     but the finger has since retreated past it — dim it so "about to be undone" reads WHILE dragging.
     Nothing is actually undone yet (see finishPaint()), so this is purely a visual hint. */
  .cell.pendingUndo { opacity: .42; filter: saturate(.5); transition: opacity .1s ease, filter .1s ease; }
  html[data-motion="reduced"] .cell.pendingUndo { transition: none; }
  .cell.silenceRewindFlash { animation: silenceRewindRelease .5s ease-out both; }
  @keyframes silenceRewindRelease {
    0% { filter: brightness(1.55) saturate(1.12); box-shadow: inset 0 0 0 3px rgba(255,226,123,.9), 0 0 14px rgba(255,205,91,.72); }
    55% { filter: brightness(1.12); box-shadow: inset 0 0 0 2px rgba(255,226,123,.42), 0 0 8px rgba(255,205,91,.28); }
    100% { filter: none; }
  }
  html[data-motion="reduced"] .cell.silenceRewindFlash { animation: none; }
  /* ── 「팔레트에 없는 도형」 1회성 섬광 (render/board.js shapeMissFlash) ─────────────────────────
     .err(지속 상태: 무한 errBreathe + 항상 붉은 inset)와 **다른 것**이다. 여기 붉은 기운은 애니메이션 안에서만
     살고 끝나면 스스로 사라진다(fill-mode 없음 → 지속 표시 금지). 그 영역은 다음 획으로 덮어 완성할 수 있는
     정당한 발판일 수 있으므로 "틀렸다"고 낙인찍으면 안 되기 때문. 이동/회전 없음(색만) → reduced-motion에서도
     의미가 그대로 남고, 아래에서 밝기 스파이크만 눌러 더 조용하게 만든다. */
  .cell.shapeMissFlash { animation: shapeMissFlash .3s ease-out; }
  @keyframes shapeMissFlash {
    0%   { box-shadow: inset 0 0 0 99px rgba(206,52,62,.42), inset 0 1px 0 rgba(255,255,255,.18); filter: brightness(1.14); }
    55%  { box-shadow: inset 0 0 0 99px rgba(206,52,62,.26), inset 0 1px 0 rgba(255,255,255,.18); filter: brightness(1.04); }
    100% { box-shadow: inset 0 0 0 99px rgba(206,52,62,0),   inset 0 1px 0 rgba(255,255,255,.18); filter: none; }
  }
  html[data-motion="reduced"] .cell.shapeMissFlash { animation: shapeMissTint .3s linear; }
  @media (prefers-reduced-motion: reduce) { .cell.shapeMissFlash { animation: shapeMissTint .3s linear; } }
  @keyframes shapeMissTint {   /* 색만, 밝기 변화 없음 */
    0%,60% { box-shadow: inset 0 0 0 99px rgba(206,52,62,.34), inset 0 1px 0 rgba(255,255,255,.18); }
    100%   { box-shadow: inset 0 0 0 99px rgba(206,52,62,0),   inset 0 1px 0 rgba(255,255,255,.18); }
  }
  /* ② stroke response ladder — the live paint stroke's trail brightens as it grows (--sl = 0..1 step);
     the just-entered head cell gets a near-subliminal pop. Satisfaction lives in the cumulative ramp, so
     the per-cell pop stays tiny (Opus 재검토: 시각도 per-cell 함정 회피). filter composes (no transform base). */
  .cell.strokeLive {
    filter: brightness(calc(1 + var(--sl, 0) * .16)) sepia(calc(var(--sl, 0) * .85)) saturate(calc(1 + var(--sl, 0) * 1.7)) hue-rotate(-10deg);
    /* A안(2026-07-21) 손가락보다 넓은 번짐: 손끝은 타일 1개(≈44px)를 덮으므로, 바깥 블룸이 타일 경계를 겨우
       넘는 11px이면 빛이 통째로 손에 가려 "칠해지는 게 안 보인다". 블룸을 손가락 접지면보다 넓게(28px +
       spread 3px) 키워 빛이 손가락 **주변으로 새어 나오게** 한다. 강도(alpha)는 그대로 — 넓히되 세게 하지
       않는다(§3 per-cell은 여전히 near-subliminal, 커진 건 도달 범위지 세기가 아님). */
    box-shadow: inset 0 2px 0 rgba(255,255,255,.35), inset 0 -5px 9px rgba(0,0,0,.14),
                inset 0 0 calc(var(--sl, 0) * 17px) rgba(255,196,86, calc(var(--sl, 0) * .92)),   /* gold light fills the tile */
                0 0 calc(var(--sl, 0) * 28px) calc(var(--sl, 0) * 3px) rgba(255,196,86, calc(var(--sl, 0) * .62));  /* + outer bloom, 손가락 밖까지 */
    transition: box-shadow .09s ease, filter .09s ease;
  }
  /* ── 칸이 칠해지는 순간의 「튀어나옴」 (오너 지시 2026-08-06, 참고 영상 분석) ────────────────────
     오너가 원하는 칠하기 연출의 표본 영상(temp/KakaoTalk_Video_2026-08-06-11-15-56.mp4)을 프레임 단위로
     실측한 결과, 그 게임은 **칸이 칠해질 때마다**(손 뗄 때가 아니라 **드래그 도중에**) 타일이 이렇게 한다:
         0ms  **1.21배**로 **이미 커진 채 등장**(부풀어 오르는 게 아니라 크게 나타난다)
        83ms  1.00배
       150ms  **0.91배** ← 원래보다 작아진다
       217ms  1.00배 안착
     즉 한 번 튀고 **반대로 한 번 더** 튄다(정점 +21% → 반대 −9%, 비율 0.43). 총 220ms.
     **B안(오너 승인 2026-08-06): 표본 수치를 그대로 쓴다** — 정점 1.21배 · 되튐 0.91배 · 220ms.
     A안에서 1.07로 줄였던 것은 칸이 넘침을 잘라서였는데, B안에서 그 자르기를 풀었다
     (`#game .cell { overflow: visible }` + 새어 나가는 덮개 셋에 `border-radius: inherit`).
     ⚠️ 이제 **손 뗌(releasePop 1.09배)보다 칠하기가 더 크다.** 뒤집힌 게 아니라 의도다 —
        표본에서도 주인공은 **칠하는 순간**이고, 손 뗌은 그 뒤의 **안착**이다. 안착은 세 번째
        클라이맥스가 아니라 조용해야 한다(연출 헌법 §2.3). 진짜 정점은 여전히 봉인(brightness 2.0)이다.

     ⚠️ **`strokeHead`가 아니라 `strokeLive`에 건다.** head는 손가락을 따라 **옮겨 다니므로**, 거기에 걸면
        손가락이 다음 칸으로 가는 순간 앞 칸의 애니가 **잘린다**(빠르게 그으면 220ms를 채우지 못한다).
        strokeLive는 칠해진 순간부터 획이 끝날 때까지 **붙어 있어** 끝까지 돈다. 칸 요소는 렌더가
        재사용하므로(nodes[r][c]) 클래스 문자열이 바뀌어도 돌던 애니는 이어진다.
     ⚠️ **칸이 아니라 칩에 건다.** 옛 `strokePop`은 **칸**을 눌렀는데, 2층 구조가 생긴 뒤로 칸은 **바닥**이다.
        바닥이 같이 눌리면 「블록이 바닥에서 솟는다」가 성립하지 않는다.
     ⚠️ 이건 예전 결정("per-cell pop stays tiny — 시각도 per-cell 함정 회피")의 **의도적 번복**이다.
        근거는 오너의 표본 영상이다. 되돌릴 일이 생기면 그 판단을 먼저 다시 보라. */
  /* ⚠️ 정점에 **머무는 구간**(0~13%)이 핵심이다. 오너 판정 "부족해 보여"(2026-08-06)의 원인은 크기가
     아니라 **노출 시간**이었다 — 실측: 옛 곡선은 `ease-out`으로 정점에서 출발해 **10ms 만에** 1.15배
     아래로 떨어졌다. 60fps에서 **1프레임**이다. 값은 1.22였지만 그 크기를 본 사람은 아무도 없었다.
     연출 정본 §4.6-5가 말하는 그대로다: **머무는 시간이 곧 쫀득함이지 진폭이 아니다.**
     ⚠️ 그래서 첫 구간의 이징은 `linear`다. `ease-out`을 다시 쓰면 아무리 값을 키워도 같은 일이 벌어진다.
     ⚠️ 길이 .26s → **.40s** (오너 2026-08-06 "너무 빨리 줄어들어서 쫀득함이 아니라 **잔상만** 보는 느낌").
        같은 진단이 두 번째다 — 문제는 늘 진폭이 아니라 **머무는 시간**이다(§4.6-5).
        실측: 1.4배 이상 보이는 시간 50ms → **86ms**, 1.2배 이상 78ms → **130ms**.
        ⚠️ 여기서 더 늘리면(.46s면 1.2배 이상 168ms) 빠른 드래그의 칸 간격(~130ms)을 넘어 **이웃과
           겹치기 시작한다** — 그건 「쫀득」이 아니라 「물결」이 된다. 그때는 의도해서 고를 것.
     ⚠️ 정점 1.28 → **1.48** (오너 2026-08-06 "더 키워야 커졌다가 줄어드는 느낌이 직관적으로 전달된다").
        표본 영상(1.21)을 **의도적으로 넘어선다** — 표본은 칸 하나를 칠하는 노노그램이고, 우리는 한 획에
        여러 칸이 이어지므로 개별 칸의 사건이 더 커야 흐름 속에서 읽힌다는 오너 판단이다.
        대가: 60px 칸에서 이웃 타일을 **7.7px(이웃의 14%)** 덮는다(표본은 2%). 칸이 넘침을 자르지 않고
        (`overflow:visible`) 칠하는 칸이 z-index 2로 이웃 위에 그려지므로 잘리지는 않는다.
        더 키우려면: 1.55=18% · 1.70=25%. **20%를 넘기면 어느 칸이 칠해진 것인지 순간 헷갈리기 시작한다.** */
  .cell.strokeLive > .chip { animation: paintPop .40s linear; transform-origin: 50% 50%; }
  @keyframes paintPop {
    0%   { transform: scaleX(1.45)  scaleY(1.48); animation-timing-function: linear; }      /* 크게 등장 — 부풀어 오르지 않는다 */
    18%  { transform: scaleX(1.415) scaleY(1.44);  animation-timing-function: ease-in-out; }/* **머문다** — 정점이 실제로 보이는 구간(≈72ms) */
    50%  { transform: scale(1);                   animation-timing-function: ease-in-out; }
    76%  { transform: scaleX(.905)  scaleY(.895); animation-timing-function: ease-in-out; } /* 반대로 한 번 더 */
    100% { transform: scale(1); }                                                           /* 안착 — 이후 이동 없음 */
  }
  /* C안(2026-07-21) 완성 근접 램프: 지금 손을 떼면 유효 도형이 되는 순간, 영역 전체가 한 번 살아난다.
     칸 수가 아니라 의미에 반응하므로 매 칸 소란해지지 않는다 — 조용하다가 결정적일 때만 켜진다.
     클래스가 붙는 순간 1회만 재생(무한 애니메이션 아님 → 모바일 jank 회피, §6). */
  .cell.strokeReady {
    box-shadow: inset 0 2px 0 rgba(255,255,255,.4), inset 0 -5px 9px rgba(0,0,0,.12),
                inset 0 0 20px rgba(180,255,196,.7),
                0 0 34px 5px rgba(120,240,160,.5);          /* 초록 = seal 예고 (완성색과 같은 계열) */
    animation: strokeReadyIn .42s ease-out;
  }
  @keyframes strokeReadyIn {
    0%   { filter: brightness(1); }
    38%  { filter: brightness(1.5) saturate(1.3); }
    100% { filter: brightness(1); }
  }
  html[data-motion="reduced"] .cell.strokeLive > .chip,
  html[data-motion="reduced"] .cell.strokeReady { animation: none; }
  html[data-motion="reduced"] .cell.strokeLive { transition: none; }
  /* shape mode palette: "All regions must be one of these shapes" — a raised reference card (depth kit). */
  .palette { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; align-items: center;
             max-width: 92vw; margin: 0 auto 4px; padding: 4px 12px; border-radius: 11px;
             background: var(--grad-card); border: 1px solid var(--line); box-shadow: var(--lift-sm), var(--sheen); }
  /* ── 「이 판의 명세」 석판 — 난이도 측정값 + 도형 팔레트가 한 판 위에 산다 ───────────────────────
     발단: "상단 HUD, 난이도 인디케이터, 도형 팔레트를 지금처럼 배치하니까 학생이 디자인한 습작 같아."
     진단 = 화면 위쪽 네 밴드의 **그릇 규칙이 제각각**이었고, 그중 팔레트만 그릇이 아예 없었다:
       HUD = 돌+금 프레임 접시 3개 · 난이도 = 측정 게이지 · 안내 문구 = 평평한 검정 알약 ·
       **팔레트 = 맨몸**. 화면의 다른 모든 것(타일·버튼·HUD·모달)이 테두리를 가졌는데 여기만 금색
       사각형이 허공에 떠 있었다 → "미완성"으로 읽힌다.
     그릇 규칙을 이렇게 정한다 — **설비(판이 열리면 늘 있는 것) = 돌 / 상태(조건부로 뜨는 것) = 알약.**
       설비: 상단 HUD · 이 석판 · 하단 버튼 줄.  상태: 안내 문구 · 보스 시계 · 변이 시계.
       이 규칙에서 알약들은 위반이 아니라 **적용**이다 — 떴다 사라지는 것에 무거운 돌을 씌우면 오히려
       "나타났다 사라지는 돌덩이"가 된다. 그래서 시계 두 개는 손대지 않았다.
     난이도를 팔레트와 **같은 판에** 올린 이유: 둘 다 "이 판"에 대한 정보다(무엇으로 채우나 + 얼마나
       어렵나). HUD는 "내 것"(하트·획수·코인)이라 덩어리가 다르다 → 상단이 `내 것 / 이 판의 것`으로 갈린다.
     2026-08-03: 도형은 누르는 버튼이 아니라 **허용 도형 참조표**다. 개별 카드·선택 테두리는 쓰지 않고,
     참조 도형 자체는 고정 금빛으로 주목성을 확보한다. 현재 콘텐츠의 최악 조건은 6칸 대형 5개와 소형
     7개이므로 둘 다 같은 한 줄 프레임 안에서 맞아야 한다. 2–4개는 중앙에 모으고 5–7개만 전체 폭을
     나눠, 적은 도형이 양끝으로 찢어지지 않는다.
     덤으로 그릇 언어가 셋 → 둘로 줄고, 알약 자리와 그 위아래 여백만큼 세로 예산이 오히려 남는다.
     ⚠️ **2026-07-16 결정("그릇 없음")을 뒤집는다.** 그때 A/B가 물은 것은 "팔레트 **혼자**를 어떤 그릇에
     담을까"였고 답이 맨몸이었다 — 지금 문제는 팔레트 하나가 아니라 **밴드들의 관계**라 질문이 다르다.
     그때 없던 조건도 하나 생겼다: 풀이 중 배경이 검정이 됐다(playbg="black", 2026-08-02). 밝은 배경
     영상 위에서 "떠 있는 장식"으로 읽히던 맨 아이콘이 검은 배경에서는 그냥 허공의 조각이 된다.
     ⚠️ 새 에셋을 만들지 않았다 — `assets/stage/statusbar.webp` 9-slice는 이 화면이 이미 쓰는 문법이다
     (막힘 구제 패널 14px · 게임 메뉴 트레이 16px, styles/app/10-game-screen.css). 안쪽이 어두운 올리브라
     금빛 도형과 명시적 난이도 문구가 별도 카드 없이 읽힌다. */
  #game .palettePlate {
    box-sizing: border-box;
    /* 360px 화면에서 좌우 12px를 남긴다. 내부 높이 60px(난이도 24 + 도형 36)와 세로 테두리 8px×2로
       표시 스테이지는 총 76px가 된다. 난이도가 숨겨지는 도입/데일리는 자동으로 52px까지 접힌다. */
    width: min(calc(100vw - 24px), 366px);
    min-height: 52px;
    margin: 4px auto 6px;
    padding: 0;
    position: relative;
    overflow: hidden;
    display: flex; flex-direction: column; align-items: center;
    border-style: solid;
    border-width: 8px 14px;
    border-image: url('../../assets/stage/statusbar.webp') 40 fill / 8px 14px / 0 stretch;
    border-radius: 0; background: none;
    filter: drop-shadow(0 4px 6px rgba(18,14,9,.34));
  }
  #game .plateMetaRow {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 9px;
    width: 100%;
    height: 24px;
    min-width: 0;
    box-sizing: border-box;
    padding: 0 7px 2px;
    border-bottom: 1px solid rgba(31,25,17,.38);
  }
  #game .palettePlate:not(.hasDifficultyMeta):not(.hasLayerMeta) > .plateMetaRow {
    display: none;
  }
  /* ── 첫 탐험 완료 — 같은 석판의 상태 전환 ─────────────────────────────────────────────────────
     완성된 스테인드글라스 위에 독립 문구를 떨어뜨리던 방식은 그림·리플레이 임팩트와 같은 위치/시간을
     다퉜다. 이제 `이 판의 정보`가 있던 석판이 `이 판의 완료`를 확인한다. 판 크기·도형 수·언어와 무관하게
     자리가 고정되고, 완료 그림은 한 픽셀도 가리지 않는다.
     금색·섬광·파티클은 쓰지 않는다. 금색은 별빛/보상 문법이고 이 표시는 경제 보상이 아니라 임무 확인이다.
     나침반은 기존 공용 심벌을 이끼색으로만 새겨 세계관 연결을 남긴다. */
  #game .palettePlate > .plateMetaRow,
  #game .palettePlate > .palette {
    transition: opacity .14s ease-out, filter .14s ease-out;
  }
  #game .palettePlate.questComplete > .plateMetaRow,
  #game .palettePlate.questComplete > .palette {
    opacity: .12;
    filter: saturate(.55) brightness(.72);
  }
  #questStamp {
    position: absolute;
    inset: 0;
    z-index: 12;
    box-sizing: border-box;
    min-width: 0;
    padding: 0 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    pointer-events: none;
    visibility: hidden;
    opacity: 0;
    transform: translateY(4px) scale(.98);
    color: #f2eee4;
    white-space: nowrap;
    text-shadow: 0 1px 1px rgba(21,18,12,.86), 0 2px 3px rgba(21,18,12,.5);
  }
  /* 2026-08-03 오너 "더 키워서 읽기에 시원하게" — 아이콘 19 → 27px, 글자 상한 20 → 26px(약 1.4배).
     ⚠️ 상한을 정하는 것은 **영어**다("Exploration complete" 20자). 한국어("탐험 완료")·일본어("探検完了")는
     절반 이하라 여유가 크지만, 이 스탬프는 `white-space: nowrap` + `text-overflow: ellipsis`라 영어가
     넘치면 **말줄임으로 잘린다**(1차 시안 28px에서 실제로 잘렸다 — 내용 293px / 자리 277px).
     그래서 글자를 조금 낮추는 대신 **자리를 벌었다**: 좌우 여백 12 → 8px, 간격 10 → 9px.
     ⚠️ 더 키우려면 **영어부터** 재라. 한국어만 보고 올리면 영어에서 조용히 잘린다. */
  #questStamp .questStampIcon {
    flex: 0 0 auto;
    width: 27px;
    height: 27px;
    color: #adc49b;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.8;
    stroke-linecap: round;
    stroke-linejoin: round;
    filter: drop-shadow(0 1px 1px rgba(20,24,16,.72));
  }
  #questStamp .questStampLabel {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    font-size: clamp(23px, 6.6vw, 26px);
    font-weight: 900;
    line-height: 1;
    letter-spacing: 0;
  }
  #questStamp.on {
    visibility: visible;
    animation: questPlateIn .24s cubic-bezier(.2,.78,.25,1) both;
  }
  #questStamp.out {
    visibility: visible;
    animation: questPlateOut .16s ease-in both;
  }
  @keyframes questPlateIn {
    0% { opacity: 0; transform: translateY(4px) scale(.98); }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  @keyframes questPlateOut {
    0% { opacity: 1; transform: translateY(0) scale(1); }
    100% { opacity: 0; transform: translateY(-2px) scale(1); }
  }
  html[data-motion="reduced"] #game .palettePlate > .plateMetaRow,
  html[data-motion="reduced"] #game .palettePlate > .palette {
    transition: none;
  }
  html[data-motion="reduced"] #questStamp.on {
    visibility: visible;
    opacity: 1;
    transform: none;
    animation: none;
  }
  html[data-motion="reduced"] #questStamp.out {
    opacity: 0;
    animation: none;
  }
  @media (prefers-reduced-motion: reduce) {
    #game .palettePlate > .plateMetaRow,
    #game .palettePlate > .palette { transition: none; }
    #questStamp.on {
      visibility: visible;
      opacity: 1;
      transform: none;
      animation: none;
    }
    #questStamp.out { opacity: 0; animation: none; }
  }
  #game .palette { flex-direction: column; flex-wrap: nowrap; align-items: center;
                   width: 100%; max-width: none; aspect-ratio: auto;
                   background: none; border: none; box-shadow: none; padding: 0; margin: 0; border-radius: 0; }
  #game .palShapes {
    display: grid;
    grid-template-columns: repeat(var(--palette-count, 1), minmax(0, 1fr));
    gap: 0;
    align-items: center;
    justify-content: center;
    position: relative;
    width: 100%;
    height: 36px;
    box-sizing: border-box;
    padding: 0;
    background: none;
    border: none;
    box-shadow: none;
  }
  /* 트랙 너비는 buildPalette()가 `--pal-slot`으로 넘긴다 — 예전엔 68px이 JS와 CSS 양쪽에 적혀 있어
     한쪽만 고치면 조용히 어긋났다. 폴백 68px은 변수가 아직 안 붙은 첫 프레임용이다. */
  #game .palShapes[data-count="1"] { grid-template-columns: var(--pal-slot, 68px); }
  #game .palShapes[data-count="2"] { grid-template-columns: repeat(2, var(--pal-slot, 68px)); }
  #game .palShapes[data-count="3"] { grid-template-columns: repeat(3, var(--pal-slot, 68px)); }
  #game .palShapes[data-count="4"] { grid-template-columns: repeat(4, var(--pal-slot, 68px)); }
  #game .palShapes::after {
    content: "";
    position: absolute;
    z-index: 0;
    left: 8px;
    right: 8px;
    bottom: 3px;
    height: 1px;
    background: linear-gradient(90deg, transparent, rgba(30,28,22,.42) 10%, rgba(222,218,198,.16) 90%, transparent);
    pointer-events: none;
  }
  #game .palSpecimen {
    position: relative;
    min-width: 0;
    align-self: stretch;
    display: grid;
    place-items: center;
    box-sizing: border-box;
    /* 오른쪽 여백 = 배지 전용 차선. 아이콘은 그만큼 왼쪽으로 밀린 영역의 한가운데에 놓여
       **어떤 도형이 와도 배지와 안 겹친다.** 차선을 못 얻은 빽빽한 팔레트에선 0이라 옛 배치 그대로. */
    padding: 0 calc(1px + var(--pal-badge-lane, 0px)) 0 1px;
    pointer-events: none;
  }
  #game .palSpecimen .shapeicon { position: relative; z-index: 1; }
  /* ── 팔레트 「남은 개수」 (오너 2026-08-05) ───────────────────────────────────────────────────────
     "이 도형을 앞으로 몇 개 더 놓아야 하나". 채울 때마다 하나씩 줄어든다(render/board.js).
     자리: 아이콘 **오른쪽 아래 모서리**. 아이콘 위에 겹쳐 놓으면 도형의 실루엣을 가리는데, 팔레트는
     "무슨 모양인가"를 읽는 곳이라 그 실루엣이 본체다. 모서리로 밀어 두 정보가 서로를 안 가린다.
     ⚠️ 숫자 자체는 애니를 걸지 않는다 — 아이콘의 `swatchPop`(새 도형이 완성될 때 튀는 연출)과
        같은 순간에 겹치면 두 개가 동시에 움직여 어느 쪽이 소식인지 흐려진다. 소식은 아이콘이 전한다. */
  /* 크기 = 처음 값(13px)의 **150%** (오너 2026-08-05 "지금의 150%로 확대"). 11px 글자는 안 읽혔다.
     모든 치수를 `--pal-badge-w` 하나에서 비례로 뽑는다 — 값이 13px이면 옛 모습 그대로,
     19.5px이면 정확히 150%다. 차선을 못 얻은 빽빽한 팔레트는 자동으로 13px로 되돌아간다.
     ⚠️ `transform: scale()`로 키우지 말 것 — 아래 `.palTick`이 transform으로 도는데 둘이 같은
        속성을 다투면 줄어드는 순간 배지가 원래 크기로 튕겼다가 돌아온다(이 저장소 6회 재발한 함정,
        `npm run check`의 check:transform이 감시한다). 그래서 치수를 각각 비례로 키웠다.
     ⚠️ 겹침 방지는 여기가 아니라 `.palSpecimen`의 오른쪽 차선(--pal-badge-lane)이 한다.
        옛 13px 배지는 가로 트라이미노와 0.5px 차이로 간신히 안 겹치던 것이라, 차선 없이 조금이라도
        키우면 **반드시** 침범한다(오너 보고 2026-08-05). 크기만 다시 키우지 말 것. */
  #game .palSpecimen .palLeft {
    --w: var(--pal-badge-w, 13px);
    position: absolute;
    right: 0; bottom: calc(var(--w) * -1 / 13);
    z-index: 2;
    min-width: var(--w);
    padding: 0 calc(var(--w) * 3 / 13);
    border-radius: calc(var(--w) * 7 / 13);
    background: rgba(38, 24, 8, .82);
    color: #ffe9b8;
    font-size: calc(var(--w) * 11 / 13);
    font-weight: 900;
    line-height: calc(var(--w) * 15 / 13);
    text-align: center;
    text-shadow: 0 calc(var(--w) / 13) calc(var(--w) / 13) rgba(0, 0, 0, .5);
    pointer-events: none;
  }
  #game .palSpecimen .palLeft[hidden] { display: none; }
  /* 줄어드는 순간 — 획수 알약(`dragSpend`)과 **같은 물리**로, 한 단계 작게.
     부피 보존 스쿼시(가로 퍼지면 세로 눌린다) + 살짝 내려앉음(=「줄었다」는 방향) + 감쇠 되튐.
     ⚠️ `.14s` 지연은 장식이 아니다: 같은 순간 아이콘이 `swatchPop`으로 튄다. 동시에 움직이면 둘 다
        소식처럼 보여 어느 쪽을 볼지 흐려지므로, 팝이 **원인**이고 이 숫자가 **결과**로 따라오게 늦춘다.
     ⚠️ 진폭이 아니라 머무는 시간이 쫀득함이다(연출 헌법 §4.6-5) — 크게 대신 길게(.4s). */
  #game .palSpecimen .palLeft.palTick { animation: palLeftTick .4s linear .14s; }
  @keyframes palLeftTick {
    0%   { transform: translateY(0) scale(1);                  animation-timing-function: cubic-bezier(.3,0,.2,1); }
    22%  { transform: translateY(2px) scaleX(1.16) scaleY(.86); animation-timing-function: ease-in-out; }
    55%  { transform: translateY(0) scaleX(.95) scaleY(1.06);   animation-timing-function: ease-in-out; }
    80%  { transform: translateY(0) scaleX(1.02) scaleY(.98);   animation-timing-function: ease-in-out; }
    100% { transform: translateY(0) scale(1); }
  }
  html[data-motion="reduced"] { #game .palSpecimen .palLeft.palTick { animation: none; } }
  /* 다 놓은 도형은 **가라앉힌다.** 0을 남기지 않는 이유: 0은 읽어서 해석해야 하지만, 흐려진 것은
     안 읽어도 "끝났다"가 전해진다. 남은 것만 밝게 남아야 "이제 뭘 쓸 차례인가"가 한눈에 읽힌다.
     ⚠️ **전이(transition)가 핵심이다.** 처음엔 클래스만 토글해서 밝던 아이콘이 **한 프레임에 툭**
        회색이 됐다 — 마지막 하나를 놓은 순간은 이 팔레트에서 유일한 작은 성취인데 하드 컷으로 끝났다.
        `.34s`로 **가라앉게** 하면 "다 썼다"가 사건으로 읽힌다. 지연(.16s)은 위 숫자 틱과 같은 이유로
        아이콘의 팝 뒤에 오게 하려는 것이다 — 팝(만들었다) → 틱(하나 줄었다) → 가라앉음(다 썼다) 순서.
     ⚠️ 이건 **사라짐이 아니라 물러남**이다. 지우거나 되돌리면 다시 밝아져야 하므로(그 판정은 매
        render마다 다시 돈다) 양방향으로 도는 transition을 쓴다 — 애니메이션이면 되돌아올 때 툭 튄다.
     ⚠️⚠️ 전이는 **`opacity`에만** 건다. `filter`에도 걸었다가 실제로 깨졌다: 같은 `.shapeicon`을
        `palMissGlow`(팔레트에 없는 도형을 그렸을 때의 한 번 반짝임, 아래)가 **`filter`로** 애니메이션하는데,
        그 애니가 끝나는 순간 내 전이가 이어받아 **0.34초짜리 꼬리**가 붙는다 — 짧고 단호해야 할 경고가
        흐물흐물 사라진다. 같은 이유로 palDone에서 `grayscale`도 뺐다(그 속성이 곧 충돌 지점이다).
        투명도만으로도 "물러났다"는 충분히 읽힌다. */
  #game .palSpecimen .shapeicon { transition: opacity .34s ease .16s; }
  #game .palSpecimen .shapeicon.palDone { opacity: .3; }
  #game .palSpecimen .palLeft { transition: opacity .34s ease .16s; }
  #game .palSpecimen .shapeicon.palDone + .palLeft,
  #game .palSpecimen .palDone ~ .palLeft { opacity: .45; }
  html[data-motion="reduced"] {
    #game .palSpecimen .shapeicon, #game .palSpecimen .palLeft { transition: none; }
  }
  /* ── 팔레트 반짝임 (render/board.js paletteMissHint) ────────────────────────────────────────────────
     팔레트에 없는 도형으로 획이 끝났을 때, 상단 팔레트가 **한 번** 은은하게 반짝여 "이 중 하나여야 해요"를
     말없이 가리킨다. 시선을 규칙으로 보내는 것이 목적이므로 보드보다 팔레트가 이 순간의 주인공이다(§4.1).
     보상 순간이 아니므로 짧고 조용하게(.34s): 확대·이동 없음, 화면 플래시·파티클·팡파르 없음. 컨테이너는
     배경이 없으므로(#game .palette) 아주 옅은 온기 워시로 영역을 잡아 주고, 실제 강조는 도형 아이콘의 글로우가 맡는다. */
  .palette.paletteMissHint { animation: palMissWash .34s ease-out; }
  @keyframes palMissWash {
    0%   { background: radial-gradient(ellipse 70% 150% at 50% 50%, rgba(202,224,210,0), transparent 72%); }
    36%  { background: radial-gradient(ellipse 70% 150% at 50% 50%, rgba(202,224,210,.22), transparent 72%); }
    100% { background: radial-gradient(ellipse 70% 150% at 50% 50%, rgba(202,224,210,0), transparent 72%); }
  }
  .palette.paletteMissHint .shapeicon { animation: palMissGlow .34s ease-out; }
  @keyframes palMissGlow {
    0%   { filter: none; }
    38%  { filter: brightness(1.32) drop-shadow(0 0 7px rgba(190,226,204,.75)); }
    100% { filter: none; }
  }
  /* reduced-motion: 원래도 이동이 없으니 의미(=팔레트를 보라)는 유지하고 밝기 스파이크만 죽여 더 조용히. */
  html[data-motion="reduced"] .palette.paletteMissHint .shapeicon { animation: palMissGlowSoft .34s linear; }
  @media (prefers-reduced-motion: reduce) { .palette.paletteMissHint .shapeicon { animation: palMissGlowSoft .34s linear; } }
  @keyframes palMissGlowSoft { 0%,60% { filter: brightness(1.18); } 100% { filter: none; } }
  .shapeicon { display: grid; gap: 1px; }
  .shapeicon .ic { width: 11px; height: 11px; }
  .shapeicon .ic.on {
    background: linear-gradient(180deg, #ffe08a, #f0b43c);
    border: 1px solid rgba(121,75,16,.78);
    border-radius: 2px;
    box-sizing: border-box;
    box-shadow: inset 0 1px 0 rgba(255,255,255,.5), 0 1px 1px rgba(30,16,2,.5);
  }
  .shapeicon .ic.off { visibility: hidden; }
  /* ── 문장 안에 끼운 도형 그림 (render/shapeInline.js) ──────────────────────────────────────────
     팔레트와 **똑같은 그림**(.shapeicon 금빛 타일)을 그대로 빌린다 — 새 색·새 타일을 만들지 않는다.
     여기서 하는 일은 그 그림을 글줄에 앉히는 것뿐이고, 규칙은 셋이다:
       · `vertical-align: middle` — 글자의 가운데선에 맞춘다. 기본값(baseline)이면 그림이 글자 위로
         떠서 문장이 계단처럼 보인다.
       · `line-height: 0` — 감싸는 span이 **자기 글줄 높이를 따로 만들지 않게** 한다. 이게 없으면 안이
         그림뿐이어도 span이 한 줄치 leading을 더해 문장 줄 간격이 벌어진다("줄 높이를 밀지 않는다").
       · 좌우 여백은 `em` — 이 그림은 글자 크기가 다른 두 자리에 같이 쓰인다(상태 알약 15~18px, 오프닝
         제목 22px). px로 두면 한쪽에서만 붙거나 뜬다. 그림 여러 개가 잇달아 나올 때 서로 붙지 않는
         최소치다.
     ⚠️ 크기는 CSS가 정하지 않는다 — `shapeIcon(cells, size)`가 칸마다 px를 직접 박으므로, 자리마다
        맞는 `size`(칸 최대 크기)와 `maxH`(총 높이 상한)를 **부르는 쪽이** 넘긴다(#status는 10px/26px,
        기본은 13px·상한 없음). 여기서 다시 늘리거나 줄이면 두 곳이 같은 사실을 말하게 되어 한쪽만
        고쳐도 어긋난다. 특히 `max-height`를 걸지 말 것 — 그림이 잘리지 축소되지 않는다. */
  .inlineShape {
    display: inline-block;
    vertical-align: middle;
    line-height: 0;
    margin: 0 .16em;
  }
  .cell.bad::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: rgba(255,77,87,.42); }  /* 라운드 필수 — 칸이 더 이상 넘침을 자르지 않는다 */
  /* shard pops into place AND lights up: grows in from small with a punchy overshoot + bright flash.
     2026-07-14: reverted to settle at scale(1) — #game .cell no longer has a resting scale(.88) (removed,
     see #game .cell's own comment: border alone does the isolation now, no shrink needed). */
  /* 쫀득 물리(연출 정본 §4.6). **이 게임에서 가장 자주 보이는 착지**다 — 한 판에 수십 번 보므로
     여기가 게임의 촉감을 사실상 결정한다. 다른 착지보다 되튐을 한 단 줄여 잔상이 쌓이지 않게 했다
     (매번 오래 출렁이면 곧 피로가 된다 — 정본 §6.2 반복 피로 방지와 같은 원리). */
  /* ⚠️ 빛의 **색·반경·세기**만 변수로 뺐다 (오너 2026-08-10, 등급 차등 ①). 3★ 리플레이에서 조각이
     따뜻한 흰빛이 아니라 **금빛**으로 놓이게 하기 위해서다 — 값은 `#board.gradeTier3`가 넣는다
     (styles/app/03-modal-win-effects.css). 기본값은 종전 그대로라 1★·2★·힌트·빛 거두기는 안 변한다.
     ⚠️ **테두리(outline)로 만들지 않았다.** 한 영역의 칸들은 서로 붙어 있어서 칸마다 테를 두르면
        영역 안쪽에 격자선이 그어진다 — 「금테를 두른 조각」이 아니라 「모눈종이」가 된다.
        그래서 올리는 축은 §4.2의 ①(밝기·대비) 하나뿐이고, 크기·이동·입자는 전 등급 동일하다. */
  @keyframes fillpop {
    0%   { transform: scaleX(.19) scaleY(.21); opacity: 0;
           filter: brightness(3.4) drop-shadow(0 0 0 rgba(255,240,200,0));
           animation-timing-function: cubic-bezier(.2,.72,.3,1); }
    42%  { transform: scaleX(1.16) scaleY(1.33); opacity: 1;
           filter: brightness(var(--pop-bright, 2)) drop-shadow(0 0 var(--pop-glow-r, 10px) var(--pop-glow, rgba(255,240,200,.95)));
           animation-timing-function: ease-in-out; }
    68%  { transform: scaleX(1.10) scaleY(.955);
           filter: brightness(1.15) drop-shadow(0 0 calc(var(--pop-glow-r, 10px) * .4) var(--pop-glow-fade, rgba(255,240,200,.4)));
           animation-timing-function: ease-in-out; }
    88%  { transform: scaleX(.975) scaleY(1.026); animation-timing-function: ease-in-out; }
    100% { transform: scale(1); opacity: 1; filter: brightness(1) drop-shadow(0 0 0 rgba(255,240,200,0)); }
  }

  /* ── 3★ 마무리 착지 (오너 2026-08-10, 등급 차등 ②) ────────────────────────────────────────────
     **마지막 한 영역만** 이걸로 놓인다. 앞 조각들은 다음 조각이 놓이는 78~108ms 뒤에 그려질 때
     인라인 애니가 지워져 **되튐을 끝까지 못 보고 잘린다**(0.46s 중 ~20%만 재생된다). 마지막 조각만은
     끝까지 튀게 하려고 winSequence가 그만큼 **한 박자 더 머문다**(FINALE_HOLD_MS) — 즉 이 연출의
     정체는 「더 크게」가 아니라 **「끝까지 보여준다」**이다(연출 헌법 §4.6-5 체류가 곧 쫀득함).
     ⚠️ 그래서 길이(.34s)는 그 여유보다 짧아야 한다. 늘리려면 둘을 같이 늘릴 것. */
  @keyframes fillpopFinale {
    0%   { transform: scaleX(.19) scaleY(.21); opacity: 0;
           filter: brightness(3.4) drop-shadow(0 0 0 rgba(255,201,92,0));
           animation-timing-function: cubic-bezier(.2,.72,.3,1); }
    32%  { transform: scaleX(1.24) scaleY(1.42); opacity: 1;
           filter: brightness(2.6) drop-shadow(0 0 24px rgba(255,201,92,1));
           animation-timing-function: ease-in-out; }
    56%  { transform: scaleX(1.18) scaleY(.90);
           filter: brightness(1.5) drop-shadow(0 0 13px rgba(255,201,92,.78));
           animation-timing-function: ease-in-out; }
    76%  { transform: scaleX(.965) scaleY(1.05);
           filter: brightness(1.18) drop-shadow(0 0 6px rgba(255,201,92,.42));
           animation-timing-function: ease-in-out; }
    90%  { transform: scaleX(1.012) scaleY(.99); animation-timing-function: ease-in-out; }
    100% { transform: scale(1); opacity: 1; filter: brightness(1) drop-shadow(0 0 0 rgba(255,201,92,0)); }
  }

  /* B안(2026-07-21): 배지는 손가락 **위**(translate -150%)라 구조적으로 절대 가려지지 않는 유일한 표면 —
     폰을 바닥에 놓아 햅틱이 0이 되면 여기가 유일하게 살아있는 확인 채널이다. 그래서 정보 표시에 그치지 않고
     칸마다 톡 튕기는 확인 채널로 승격했다(paint.js badgeContent). 목표치는 이제 clues가 아니라 도형 팔레트에서
     온다 — Shape Mode 859판 전부 clues가 비어 있어 예전엔 맨 숫자만 떴고 target-met은 죽어 있었다. */
  #badge {
    position: fixed; z-index: 50; pointer-events: none;
    transform: translate(-50%, -150%);
    background: #0c0f17; color: #fff; border: 1px solid rgba(255,255,255,.18);
    padding: 5px 11px; border-radius: 999px; font-size: 16px; font-weight: 800;
    box-shadow: 0 4px 14px rgba(0,0,0,.5); display: none;
  }
  /* ⚠️ 배지는 translate(-50%,-150%)로 손가락 위에 정렬된다. 키프레임에 scale만 쓰면 그 translate가 소실돼
     숫자가 손가락 아래로 튄다 — 이 코드베이스에서 3회 재발한 함정. 모든 키프레임에 base translate 보존. */
  #badge.tick { animation: badgeTick .34s linear; }   /* 머무는 시간이 곧 쫀득함이다(정본 §4.6-5) */
  /* ⚠️ 모든 프레임이 기본 translate를 그대로 들고 간다(정본 §4.6 transform 덮어쓰기 함정). */
  @keyframes badgeTick {
    0%   { transform: translate(-50%, -150%) scale(1); animation-timing-function: ease-in-out; }
    38%  { transform: translate(-50%, -150%) scaleX(.945) scaleY(1.13); animation-timing-function: ease-in-out; }
    72%  { transform: translate(-50%, -150%) scaleX(1.07) scaleY(.945); animation-timing-function: ease-in-out; }
    100% { transform: translate(-50%, -150%) scale(1); }
  }
  /* "지금 손 떼면 완성" — 유효 도형이 된 순간. transform은 쓰지 않는다(tick 애니메이션과 충돌 방지):
     색·테두리·글로우만으로 구분하되, 색만으로 판정되지 않게 굵기까지 함께 바꾼다(§8 접근성). */
  #badge.target-met {
    background: #15351f; border-color: var(--good); color: var(--good);
    box-shadow: 0 4px 14px rgba(0,0,0,.5), 0 0 22px 4px rgba(120,240,160,.55);
  }
  html[data-motion="reduced"] #badge.tick { animation: none; }

  /* bottom action bar: margin-top:auto drops it to the floor of the game column — absorbs the dead
     space below the board and lands the controls in the thumb zone (raised card reads as a real bar) */
  .controls {
    display: flex; gap: 10px; justify-content: center;
    margin-top: auto;
    padding: 10px 16px;
    background: var(--card);
    border: 1px solid var(--line);
    border-radius: 20px;
    box-shadow: 0 6px 16px rgba(90,76,40,.16), inset 0 1px 0 rgba(255,255,255,.6);
  }
  /* button system (base / .primary / .go / .danger / icon) lives in styles/ui.css — shared with the editor */
  .status { font-size: 13px; font-weight: 600; min-height: 18px; text-align: center; }
  body.reward-sequence #status { opacity: 0; }
  .status.ok { color: var(--good); }
  .status.warn { color: var(--bad); }
  .status.progress { color: var(--muted); }
  .legend { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; font-size: 12px; color: var(--muted); }
  .swatch { display: inline-flex; align-items: center; gap: 4px; }
  .dot { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }

  #win, #ask, #noDrags, #skipModal {
    position: fixed; inset: 0; z-index: 60; display: none;
    align-items: center; justify-content: center; background: var(--scrim);
    backdrop-filter: none;   /* blur removed — backdrop-filter blur is a major jank source on mobile WebView */
  }
  /* stained-glass plaque: a thick black lead-came border (same language as the board) + a soft bezel */
  #win .card, #ask .card, #noDrags .card, #skipModal .card { background: var(--grad-card); border: 1px solid var(--line); border-radius: 22px; padding: 26px 30px; text-align: center; max-width: 320px; }
  #ask .card, #noDrags .card, #skipModal .card { box-shadow: var(--lift-lg), var(--sheen); }
  #skipModal.armLock .card { pointer-events: none; }
  #skipModal .skipUseBal { opacity: .85; font-weight: 800; }
  #win {
    /* 개화 비네트 (2026-07-23 오너): 예전엔 전체 rgba(10,15,8,.9) 준-불투명 암전이라, 클리어 순간 배경 영상이
       보드 중심에서 원형으로 깨어나(bloomBackdrop) vivid가 돼도 이 카드가 뜨는 즉시 화면 전체가 다시 어둠에
       먹혔다("충만한 개화"가 1.7초 번쩍 뒤 사라짐). 이제 카드가 앉는 중앙만 충분히 눌러 초점을 주고, 가장자리·
       모서리는 열어 깨어난 세계가 카드를 감싸며 계속 보이게 한다. (옛 주석의 "뒤 보드 프레임이 비쳐 액자
       이중으로 읽힘" 문제는 중앙 국소 암전으로 여전히 가려지고, 카드 자체는 불투명 plaque라 가독성 무관.) */
    background:
      radial-gradient(circle at 50% 42%, rgba(255,238,150,.22), transparent 52%),
      radial-gradient(ellipse 80% 64% at 50% 45%,
        rgba(8,12,6,.9) 0%, rgba(8,12,6,.82) 44%, rgba(8,12,6,.52) 72%, rgba(8,12,6,.2) 100%);
    backdrop-filter: none;   /* 모바일 perf: blur 없이 그라디언트만 */
  }
