  /* ═══════════ GAME SCREEN REDESIGN ═══════════ */
  #game {
    padding: 0;
    gap: 3px;
    /* Reserve only the HUD's real height plus a little air. The bottom controls keep their thumb-space;
       the board gains room mostly from the top, not by crowding the action buttons. */
    justify-content: flex-start;
    /* 2026-07-15 (owner): 80% of the original clamp(82,10.5vh,98) to reclaim board height. #hud itself
       (position:absolute, .hudLeft width clamp(210,58vw,290) / aspect-ratio 626:177) wasn't shrunk to
       match, so this buffer is now TIGHT against the HUD's own visual bottom edge at the wide end of the
       clamp — if the HUD ever visibly clips into the palette below, shrink .hudLeft's width clamp in step
       (styles/app/10-game-screen.css "#game .hudLeft") rather than re-growing this padding back up. */
    padding-top: calc(env(safe-area-inset-top,0px) + clamp(66px, 8.4vh, 78px));
    padding-bottom: calc(env(safe-area-inset-bottom,0px) + clamp(20px, 4vh, 40px));
    background: url('../../assets/stage/bg.webp') center / cover no-repeat;
    position: relative;
    overflow: hidden;
    isolation: isolate;   /* own stacking context → the bgVid (z-index:-1) paints ABOVE this webp bg, below content */
  }
  /* The first gather run reserves a compact second HUD row from load. The badge itself stays hidden until
     three consecutive correct strokes, but the board never jumps when that earned state appears. */
  body.streakBonusEligible #game {
    padding-top: calc(env(safe-area-inset-top,0px) + clamp(126px, 15.5vh, 132px));
  }

  /* HUD: back+moves left, star+lives right */
  /* ── stage HUD: sliced sprites ── */
  #game #hud {
    justify-content: space-between;
    align-items: flex-start;
    max-width: none;
    width: 100%;
    /* 위·옆 여백을 맵의 지갑 래퍼(.titleTopLeft/.titleTop, 04-screens-title.css)와 같은 식으로 맞춘다 —
       같은 바를 쓰는데 게임만 4px 높이 붙어 있었다(오너 2026-07-29: 맵이 기준). */
    padding: calc(env(safe-area-inset-top,0px) + clamp(8px,2.5vw,18px)) clamp(6px,2.5vw,14px) 0;
    gap: clamp(4px,1.5vw,8px);
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    z-index: 10;
  }
  /* ── 「빛 거두기」 연속 보너스 HUD ─────────────────────────────────────────────────────────────
     One plate carries cause (N consecutive correct strokes) and bank (+reward). A correction collapses it
     to "bonus saved" instead of showing a loss state; the best tier remains the settlement source of truth. */
  #game .streakBonusHud {
    --streak-jewel: #4bdd76;
    --streak-glow: rgba(75, 221, 118, .34);
    --streak-line: rgba(75, 221, 118, .42);
    --streak-icon-glow: rgba(75, 221, 118, .82);
    --streak-progress: 0;
    position: absolute;
    z-index: 11;
    /* Match the real center stroke plate: HUD top inset + its clamped aspect-ratio height + breathing room. */
    top: calc(env(safe-area-inset-top,0px) + clamp(8px, 2.5vw, 18px) + clamp(40px, 11.92vw, 49px) + 6px);
    left: 50%;
    width: clamp(184px, 50vw, 224px);
    min-height: 48px;
    box-sizing: border-box;
    display: grid;
    grid-template-columns: 34px minmax(0, 1fr) auto;
    grid-template-rows: minmax(31px, auto) 5px;
    align-items: center;
    column-gap: 8px;
    row-gap: 4px;
    padding: 5px 10px 5px 7px;
    border: 1px solid rgba(255, 226, 134, .78);
    border-radius: 8px;
    background:
      linear-gradient(115deg, rgba(30, 47, 27, .97), rgba(66, 51, 28, .96) 60%, rgba(34, 33, 24, .98));
    box-shadow:
      inset 0 1px 0 rgba(255, 248, 208, .28),
      inset 0 -2px 0 rgba(10, 23, 13, .42),
      0 5px 13px rgba(13, 10, 5, .43),
      0 0 12px var(--streak-glow);
    color: #fff7dc;
    pointer-events: none;
    transform: translateX(-50%);
    transform-origin: 50% 50%;
    isolation: isolate;
  }
  #game .streakBonusHud[hidden] { display: none; }
  #game .streakBonusHud::before {
    content: "";
    position: absolute;
    z-index: -1;
    inset: 2px;
    border: 1px solid var(--streak-line);
    border-radius: 5px;
    opacity: .62;
  }
  #game .streakBonusHud::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 8px;
    background: linear-gradient(110deg, transparent 26%, rgba(255, 248, 196, .64) 49%, transparent 70%);
    background-size: 260% 100%;
    background-position: 125% 0;
    opacity: 0;
    pointer-events: none;
  }
  #game .streakBonusIcon {
    grid-row: 1 / 3;
    width: 31px;
    height: 31px;
    object-fit: contain;
    filter:
      drop-shadow(0 0 4px rgba(255,255,255,.8))
      drop-shadow(0 0 8px var(--streak-icon-glow));
  }
  #game .streakBonusCopy {
    align-self: center;
    display: grid;
    min-width: 0;
    line-height: 1;
  }
  #game .streakBonusTitle {
    overflow: hidden;
    color: #efe6c8;
    font-family: system-ui, sans-serif;
    font-size: 10.5px;
    font-weight: 850;
    line-height: 1.05;
    white-space: nowrap;
    text-overflow: ellipsis;
    text-shadow: 0 1px 2px rgba(5, 12, 7, .8);
  }
  #game .streakBonusCount {
    margin-top: 2px;
    color: #fff6c4;
    font-family: 'Jua', system-ui, sans-serif;
    font-size: 20px;
    font-weight: 400;
    line-height: .9;
    -webkit-text-stroke: 1px #31542c;
    paint-order: stroke fill;
    text-shadow: 0 2px 0 #24381e, 0 0 8px rgba(255, 225, 105, .34);
    white-space: nowrap;
  }
  #game .streakBonusBank {
    color: #ffe477;
    font-family: 'Jua', system-ui, sans-serif;
    font-size: 22px;
    font-weight: 400;
    line-height: 1;
    -webkit-text-stroke: 1.2px #73521c;
    paint-order: stroke fill;
    text-shadow: 0 2px 0 #4b3411, 0 0 8px rgba(255, 208, 74, .5);
    white-space: nowrap;
  }
  #game .streakBonusMeter {
    grid-column: 2 / 4;
    position: relative;
    height: 4px;
    overflow: hidden;
    border-radius: 2px;
    background: rgba(5, 12, 8, .58);
    box-shadow: inset 0 1px 2px rgba(0,0,0,.55), 0 1px 0 rgba(255,255,255,.08);
  }
  #game .streakBonusMeter i {
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: linear-gradient(90deg, #4bdd76, #ffe477 56%, #bc79ff);
    box-shadow: 0 0 7px rgba(255, 221, 100, .72);
    transform: scaleX(var(--streak-progress));
    transform-origin: 0 50%;
    transition: transform .32s cubic-bezier(.2,.72,.3,1);
  }
  #game .streakBonusHud.tier2 {
    --streak-jewel: #5dcfff;
    --streak-glow: rgba(93, 207, 255, .34);
    --streak-line: rgba(93, 207, 255, .46);
    --streak-icon-glow: rgba(93, 207, 255, .84);
  }
  #game .streakBonusHud.tier3 {
    --streak-jewel: #bc79ff;
    --streak-glow: rgba(188, 121, 255, .48);
    --streak-line: rgba(188, 121, 255, .54);
    --streak-icon-glow: rgba(188, 121, 255, .9);
    border-color: rgba(255, 231, 143, .94);
    box-shadow:
      inset 0 1px 0 rgba(255, 250, 216, .38),
      inset 0 -2px 0 rgba(24, 13, 36, .42),
      0 6px 15px rgba(13, 8, 18, .48),
      0 0 16px rgba(188, 121, 255, .54),
      0 0 8px rgba(255, 214, 91, .44);
  }
  #game .streakBonusHud.banked {
    min-height: 40px;
    grid-template-rows: 30px;
  }
  #game .streakBonusHud.banked .streakBonusIcon { grid-row: 1; width: 27px; height: 27px; }
  #game .streakBonusHud.banked .streakBonusCount,
  #game .streakBonusHud.banked .streakBonusMeter { display: none; }
  #game .streakBonusHud.banked .streakBonusTitle {
    color: #fff2c2;
    font-family: 'Jua', system-ui, sans-serif;
    font-size: 15px;
    font-weight: 400;
    line-height: 1;
    text-overflow: clip;
  }
  #game .streakMote {
    position: absolute;
    z-index: 2;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: #fffbe1;
    box-shadow: 0 0 7px var(--streak-jewel);
    opacity: 0;
    pointer-events: none;
  }
  #game .streakMote.m1 { left: 8%; top: 54%; --mote-x: -17px; --mote-y: -28px; }
  #game .streakMote.m2 { left: 34%; top: 16%; --mote-x: -5px; --mote-y: -31px; }
  #game .streakMote.m3 { right: 27%; top: 14%; --mote-x: 9px; --mote-y: -28px; }
  #game .streakMote.m4 { right: 7%; top: 56%; --mote-x: 18px; --mote-y: -24px; }
  #game .streakBonusHud.streakHit { animation: streakHudHit .48s linear backwards; }
  #game .streakBonusHud.streakTierUp { animation: streakHudTierUp .78s linear backwards; }
  #game .streakBonusHud.streakTierUp::after { animation: streakHudSweep .72s ease-out backwards; }
  #game .streakBonusHud.streakTierUp .streakMote { animation: streakMoteRise .7s ease-out backwards; }
  #game .streakBonusHud.streakBank { animation: streakHudBank .62s linear backwards; }
  @keyframes streakHudHit {
    0%   { transform: translateX(-50%) scale(1); animation-timing-function: ease-in-out; }
    28%  { transform: translateX(-50%) scaleX(1.09) scaleY(.92); animation-timing-function: ease-in-out; }
    61%  { transform: translateX(-50%) scaleX(.98) scaleY(1.035); animation-timing-function: ease-in-out; }
    82%  { transform: translateX(-50%) scaleX(1.012) scaleY(.992); animation-timing-function: ease-in-out; }
    100% { transform: translateX(-50%) scale(1); }
  }
  @keyframes streakHudTierUp {
    0%   { transform: translateX(-50%) translateY(-5px) scaleX(.9) scaleY(1.08); filter: brightness(.94); animation-timing-function: ease-in-out; }
    32%  { transform: translateX(-50%) translateY(0) scaleX(1.10) scaleY(.91); filter: brightness(1.5); animation-timing-function: ease-in-out; }
    60%  { transform: translateX(-50%) scaleX(.975) scaleY(1.04); filter: brightness(1.16); animation-timing-function: ease-in-out; }
    82%  { transform: translateX(-50%) scaleX(1.012) scaleY(.992); filter: brightness(1.04); animation-timing-function: ease-in-out; }
    100% { transform: translateX(-50%) scale(1); filter: brightness(1); }
  }
  @keyframes streakHudBank {
    0%   { transform: translateX(-50%) scale(1); opacity: 1; animation-timing-function: ease-in-out; }
    32%  { transform: translateX(-50%) scaleX(.91) scaleY(1.08); opacity: .72; animation-timing-function: ease-in-out; }
    61%  { transform: translateX(-50%) scaleX(1.04) scaleY(.97); opacity: 1; animation-timing-function: ease-in-out; }
    82%  { transform: translateX(-50%) scaleX(.99) scaleY(1.012); animation-timing-function: ease-in-out; }
    100% { transform: translateX(-50%) scale(1); opacity: 1; }
  }
  @keyframes streakHudSweep {
    0% { background-position: 125% 0; opacity: 0; }
    20% { opacity: .9; }
    100% { background-position: -35% 0; opacity: 0; }
  }
  @keyframes streakMoteRise {
    0% { transform: translate(0, 3px) scale(.4); opacity: 0; }
    28% { opacity: 1; }
    100% { transform: translate(var(--mote-x), var(--mote-y)) scale(.7); opacity: 0; }
  }
  html[data-motion="reduced"] #game .streakBonusHud,
  html[data-motion="reduced"] #game .streakBonusHud::after,
  html[data-motion="reduced"] #game .streakMote {
    animation: none !important;
    transition: none !important;
  }
  /* ── 상단 3분할 (2026-07-28 오너): [하트 | 남은 획수 | 별빛] ───────────────────────────────
     옛 .hudLeft는 뒤로·획수·스테이지 이름이 **한 장으로 그려진 스프라이트**(hud_left.webp)였고 값들을 그
     위에 절대배치했다. 뒤로가 하단 버튼 줄로 내려가고 이름이 사라진 지금 그 그림을 그대로 두면 빈 슬롯
     둘만 남으므로 스프라이트를 걷어낸다. 지갑은 맵·배지·옷장과 같은 1단 바(.hudSolo)라 옛 2단 덩어리보다
     세로를 덜 먹는다 = 보드가 그만큼 커진다. */
  #game .hudLeft {
    position: relative; display: flex; flex-direction: column; align-items: flex-start;
    gap: 2px; flex: 0 0 auto; width: auto; aspect-ratio: auto; background: none;
  }
  #game .hudRight {
    display: flex; flex-direction: column; align-items: flex-end;
    gap: 2px; flex: 0 0 auto;
  }
  /* 남은 획수 — 좌우 지갑과 같은 전용 HUD 문법. 붓은 프레임에 통합되어 작은 화면에서도 이모지처럼
     분리되지 않고, 숫자는 좌우 값과 같은 흰색 입체 서체로 어두운 인레이 위에 놓인다. */
  #game #hudDrags {
    position: static; transform: none; align-self: center; flex: 0 0 auto;
    display: inline-flex; align-items: center; justify-content: center;
    width: clamp(76px, 22.5vw, 92px); aspect-ratio: 540 / 286; min-width: 0;
    height: auto; padding: 0 6px 0 clamp(27px, 7.5vw, 31px);
    background: url('../../assets/stage/stroke_counter_hud.webp') center / 100% 100% no-repeat;
    border: 0; box-shadow: none; border-radius: 0;
    filter: drop-shadow(0 2px 4px rgba(14,26,8,.42));
    font-weight: 900; font-size: 24px; line-height: 1;
    color: #fff8ec;
    text-shadow: 0 2px 0 #4a2c10, 1px 0 0 #4a2c10, -1px 0 0 #4a2c10,
      0 -1px 0 #4a2c10, 0 2px 4px rgba(0,0,0,.5);
    white-space: nowrap;
  }
  #game #hudDrags { cursor: pointer; }                       /* tappable → opens the refill menu */
  #game #hudDrags.low {
    color: #ffd06a;
    filter: drop-shadow(0 2px 4px rgba(14,26,8,.42)) drop-shadow(0 0 5px rgba(255,188,54,.5));
  }                                                          /* ≤2 strokes: warm the whole plate */
  #game #hudDrags.locked {
    color: #ffaca0;
    filter: drop-shadow(0 2px 4px rgba(14,26,8,.42)) drop-shadow(0 0 7px rgba(210,54,42,.62));
    animation: drPulse 1.1s ease-in-out infinite;
  }                                                          /* out → pulse "tap to refill" */
  /* 2026-07-28: 획수 알약이 더 이상 절대배치가 아니므로 keyframe에서 중앙정렬 translate를 뺐다.
     남겨두면 애니메이션이 도는 순간 알약이 왼쪽 위로 튄다 — 이 저장소가 네 번 겪은 transform 함정과 같은 부류. */
  @keyframes drPulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.06); } }
  html[data-motion="reduced"] { #game #hudDrags.locked { animation: none; } }

  /* ── 획을 하나 썼다 (오너 2026-08-04 "사용하면 숫자가 줄어드는 게 티가 안 나") ──────────────────
     원인은 연출이 약해서가 아니라 **아예 없어서**였다: `render/board.js`가 `textContent`만 갈아끼우고
     끝났다. 숫자가 소리 없이 8→7이 되니, 판을 보고 있던 눈은 그 순간을 아예 못 본다.

     그래서 **줄어드는 순간에만** 한 번 반응한다. 무엇을 골랐고 왜인지:
      · **눌렸다가 되돌아온다**(scaleX↑/scaleY↓ = 부피 보존 스쿼시, 연출 헌법 §4.6). 균일 `scale()`은
        "커졌다 작아졌다"로만 읽혀 탄성이 안 남는다. 되튐은 3단으로 점점 작아진다(감쇠).
      · **살짝 내려갔다 온다**(translateY 3px). 이게 「줄었다」는 **방향**을 말하는 유일한 장치다 —
        스쿼시만으로는 "뭔가 일어났다"까지만 전해지고 오르내림이 없다. 착지 뒤에는 이동이 없다(§4.6-4).
      · 색·filter는 건드리지 않는다. `.low`(호박)·`.locked`(붉음)가 이미 그 채널을 쓰고 있어서,
        여기서 색을 깜빡이면 **"이제 부족하다"는 경고와 "방금 하나 썼다"는 사실이 섞인다.**
      · 진폭보다 **머무는 시간**이다(§4.6-5). 총 .42초 — 더 세게가 아니라 더 길게.

     ⚠️ 이 규칙은 `.locked`보다 **뒤에** 있어야 한다. 두 규칙 다 `animation`을 쓰므로 하나만 이긴다.
        1→0인 순간에는 이쪽이 이겨 「썼다」를 먼저 보여주고, playClass가 클래스를 떼면 그때부터
        `.locked`의 "눌러서 충전" 맥동이 이어받는다. 순서가 바뀌면 마지막 한 획이 조용히 사라진다.
     ⚠️ 기본 transform이 없는 요소라 여기서는 순수 scale/translate를 써도 된다(2026-07-28에 절대배치를
        걷어냈다 — 위 drPulse 주석). **하트(#hudLives)에 같은 걸 복사하지 말 것**: 그쪽은
        `translate(-50%,-50%)` 중앙정렬이라 프레임마다 그 이동을 들고 가야 한다(세 번 재발한 사고). */
  #game #hudDrags.spendDrag { animation: dragSpend .42s linear; }
  @keyframes dragSpend {
    0%   { transform: translateY(0) scale(1);                animation-timing-function: cubic-bezier(.3,0,.2,1); }
    20%  { transform: translateY(3px) scaleX(1.13) scaleY(.88); animation-timing-function: ease-in-out; }
    52%  { transform: translateY(0) scaleX(.96) scaleY(1.05);  animation-timing-function: ease-in-out; }
    78%  { transform: translateY(0) scaleX(1.02) scaleY(.985); animation-timing-function: ease-in-out; }
    100% { transform: translateY(0) scale(1); }
  }
  html[data-motion="reduced"] { #game #hudDrags.spendDrag { animation: none; } }
  /* refill confirm: the pill pops and stays GLOWING through the whole count-up roll (~480ms) so the rising
     number is visibly "hot" while it climbs, settling as the roll lands (owner 2026-07: old .55s pop was too
     shallow to notice after an ad). Keeps its centering transform. */
  #game #hudDrags.refilled { animation: drRefill 1.15s linear; }
  @keyframes drRefill {
    0%   { transform: scale(1); animation-timing-function: ease-in-out; }
    18%  { transform: scaleX(1.143) scaleY(1.021); filter: brightness(1.6) drop-shadow(0 0 10px rgba(255,230,140,1)); animation-timing-function: ease-in-out; }
    62%  { transform: scaleX(1.008) scaleY(1.072); filter: brightness(1.35) drop-shadow(0 0 8px rgba(255,230,140,.85)); animation-timing-function: ease-in-out; }
    100% { transform: scale(1); }
  }
  /* ── 배우기3 되돌리기 레슨: 「여기 숫자가 돌아와요」 (오너 2026-08-08) ──────────────────────────
     이 레슨이 가르치는 것은 되돌리기가 **획수를 돌려준다**는 사실이고, 그 사실이 보이는 곳은 이 획수 표시
     하나뿐이다. 그런데 이 HUD는 원래 획을 **쓸** 때만 반응한다(위 .spendDrag의 ⑵ — 늘어날 때 튀면
     "썼다"의 몸짓이 정반대 뜻이 된다). 그래서 늘어나는 순간엔 아무 연출이 없다 → 눈이 먼저 와 있도록
     **미리 켜 두고 유지**한다. 「강제」의 뜻이 그것이다: 스스로 꺼지지 않고, 아무 곳이나 터치해야 꺼진다
     (해제는 game/stageEntrance.js `armStrokeHudRelease`).

     ⚠️ 이 규칙은 `.locked`·`.spendDrag`·`.refilled`보다 **뒤에** 있어야 한다 — 넷 다 `animation`을 써서
        하나만 이긴다. 레슨 중에는 이쪽이 이겨야 표식이 끊기지 않는다.
     ⚠️ transform이 아니라 **빛만** 쓴다. 되돌리기 버튼 표식(#undoBtn.learnPoint)과 같은 문법이라 둘이
        한 쌍으로 읽히고, 위 세 애니메이션의 transform과 섞여 표시가 튀는 일도 없다. */
  /* ⚠️ 맥동은 획수 표시 **자신이 아니라 뒤의 후광**에 건다. 이 표시는 이미 `animation`을 쓰는 상태가 셋이고
        (.locked · .spendDrag · .refilled) 한 요소에서 animation은 **하나만 이긴다** — 여기에 네 번째를
        얹으면 붓이 도착하는 그 프레임의 착지 팝(.refilled)이 통째로 죽는다. 즉 시선을 끌어오는 표식이
        정작 끌어온 뒤의 절정을 잡아먹는다. 가상 요소로 빼면 둘이 겹쳐서 산다.
        `position: relative`는 오프셋 없이 좌표계만 만든다 — 2026-07-28에 걷어낸 절대배치의 부활이
        아니고, 표시의 흐름 위치는 그대로다. */
  #game #hudDrags.learnPoint { position: relative; color: #ffeaad; }
  #game #hudDrags.learnPoint::after {
    /* 배경 없는 빈 상자다 — box-shadow만 **바깥으로** 번지므로 숫자를 가리지 않는다.
       ⚠️ `z-index:-1`을 쓰지 말 것: 이 표시는 쌓임 맥락을 만들지 않아서 후광이 조상(#hud·#game)의
          배경 뒤로 내려가 통째로 안 보일 수 있다. inset이 음수라 위에 놓아도 겹치지 않는다. */
    content: ""; position: absolute; inset: -7px; border-radius: 999px; pointer-events: none;
    animation: strokeLearnPointPulse 1.15s ease-in-out infinite;
  }
  @keyframes strokeLearnPointPulse {
    0%, 100% { box-shadow: 0 0 6px 1px rgba(255,224,140,.32); }
    50%      { box-shadow: 0 0 17px 5px rgba(255,224,140,.85); }
  }
  html[data-motion="reduced"] #game #hudDrags.learnPoint::after {
    animation: none; box-shadow: 0 0 14px 4px rgba(255,224,140,.7);
  }

  /* 되돌리기 환급의 **조용한 착지** (오너 2026-08-08). 위 `.refilled`를 재사용하지 않는 이유:
     저건 "전면 광고에서 눈이 막 돌아온 사람"을 위해 일부러 세게 만든 것(brightness 1.6 · 1.15초)이라,
     별을 하나 깎는 되돌리기에 붙이면 대가를 치르는 행동을 축하하는 꼴이 된다. 같은 몸짓(부피 보존
     스쿼시)을 절반 세기·절반 길이로 — 인정은 하되 자랑하지 않는다. */
  #game #hudDrags.refunded { animation: drRefund .5s ease-in-out; }
  @keyframes drRefund {
    0%   { transform: scale(1); animation-timing-function: ease-in-out; }
    26%  { transform: scaleX(1.07) scaleY(1.012); filter: brightness(1.18) drop-shadow(0 2px 4px rgba(14,26,8,.42)); animation-timing-function: ease-in-out; }
    64%  { transform: scaleX(.995) scaleY(1.03); animation-timing-function: ease-in-out; }
    100% { transform: scale(1); }
  }
  html[data-motion="reduced"] #game #hudDrags.refunded { animation: none; }

  /* 배우기3: 돌려받은 획이 **판에서 획수 표시로** 날아간다 (오너 2026-08-08).
     ⚠️ 이 붓이 이 연출의 전부다 — 표시를 아무리 세게 빛내도 눈이 거기 없으면 안 보인다는 것이
        오너 진단이었고, 해법은 밝기가 아니라 **눈이 이미 있는 곳에서 출발하는 것**이었다.
     궤적·이징은 JS(game/strokeGates.js `flyStrokeBack`)가 준다 — 출발·도착이 매번 다른 좌표라
     키프레임으로는 못 적는다. 여기서는 물성(크기·빛)만 정의한다. */
  .strokeFlyIcon {
    position: fixed; z-index: 80; pointer-events: none;
    width: 34px; height: 34px; object-fit: contain;
    filter: drop-shadow(0 2px 5px rgba(84,40,8,.5)) drop-shadow(0 0 9px rgba(255,224,140,.9));
  }

  /* "+N" floats up from the pill on a refill (positioned in JS at the pill center) — big enough to read
     while the eyes are still returning from the rewarded ad */
  .dragRefillFloat {
    position: fixed; z-index: 80; pointer-events: none; white-space: nowrap;
    display: flex; align-items: center; gap: 4px;
    color: var(--gold); font-family: 'Jua', system-ui, sans-serif; font-weight: 400; font-size: 27px;
    -webkit-text-stroke: 1.4px var(--gold-deep); paint-order: stroke fill; text-shadow: 0 2px 5px rgba(60,34,4,.55);
    animation: dragRefillRise 1.4s ease-out forwards;
  }
  .dragRefillFloat .strokeHudIcon {
    width: 25px; height: 25px; flex: none; object-fit: contain; margin-right: -2px;
    filter: drop-shadow(0 2px 2px rgba(84,40,8,.42));
  }
  @keyframes dragRefillRise {
    0%   { opacity: 0; transform: translate(-50%, 4px) scale(.7); }
    14%  { opacity: 1; transform: translate(-50%, -10px) scale(1.12); }
    72%  { opacity: 1; transform: translate(-50%, -30px) scale(1); }
    100% { opacity: 0; transform: translate(-50%, -48px) scale(.96); }
  }
  /* reduce-motion: the +N chip appears statically (JS removes it after ~1.3s) — meaning without motion */
  .dragRefillFloat.static { animation: none; opacity: 1; transform: translate(-50%, -26px); }
  /* arrival ring — a gold pulse expanding from the pill the frame the count-up LANDS (도착 충격) */
  .dragRefillRing {
    position: fixed; z-index: 79; pointer-events: none;
    width: 30px; height: 30px; margin: -15px 0 0 -15px; border-radius: 50%;
    border: 3px solid rgba(255,214,96,.95); box-shadow: 0 0 12px rgba(255,214,96,.8);
    animation: dragRingOut .55s cubic-bezier(.15,.7,.3,1) forwards;
  }
  @keyframes dragRingOut {
    0%   { opacity: .9; transform: scale(.4); }
    100% { opacity: 0; transform: scale(2.6); }
  }
  /* Stage value: inside the Stage value box (center ~70% across, ~57% down) */
  #game #puzzleName {
    position: absolute; left: 70.5%; top: 57%; transform: translate(-50%, -50%);
    max-width: 40%; text-align: center;
    overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
    font-weight: 900; font-size: clamp(15px, 4.4vw, 18px); line-height: 1;
    color: #5a3510; text-shadow: none;
  }


  /* time-seal countdown pill — the one place where fading light adds urgency */
  /* OVERLAY (not in-flow): the boss timer + coach must never consume column height — otherwise on short
     screens they push the bottom action bar (.controls) out of reach. Pinned just below the HUD, over the
     board's top margin. Centered via margin-inline:auto (NOT transform → leaves transform free for the
     pulse / coach-in animations). */
  /* .paletteZone wraps the palette + boss timer; it's the positioning context the timer anchors to. */
  #game .paletteZone { position: relative; }
  #game .bossTimer {
    position: absolute; left: 0; right: 0; margin-inline: auto; width: max-content;
    /* anchored to the palette's BOTTOM edge (top:100% of .paletteZone, whose height == the palette's,
       since the timer is out of flow) → tracks the shape-preview height at any resolution instead of a
       fixed offset that overlapped a 2-row palette. */
    top: 100%; margin-top: 4px;
    padding: 4px 16px 5px;
    display: flex; align-items: center; gap: 6px; z-index: 9;
    border-radius: 999px; border: 2px solid #f0c15a;
    background: linear-gradient(180deg, #6a2a26, #321617);
    color: #fff3e6; font-weight: 900; font-size: clamp(16px, 4.6vw, 21px); line-height: 1; letter-spacing: .5px;
    text-shadow: 0 1px 2px rgba(0,0,0,.5);
    box-shadow: 0 4px 12px rgba(70,18,12,.42), 0 0 9px rgba(255,110,80,.36), inset 0 1px 0 rgba(255,244,202,.28);
  }
  #game .bossTimer::before { content: "\231B"; font-size: .9em; filter: saturate(1.08) drop-shadow(0 1px 1px rgba(55,12,4,.55)); }
  #game .bossTimer.low {
    color: #fff0ec; border-color: #ff6a5a;
    background: linear-gradient(180deg, #5e2020, #3a1414);
    box-shadow: 0 4px 14px rgba(70,12,12,.5), inset 0 1px 0 rgba(255,255,255,.25);
    animation: bossTimerPulse 1s ease-in-out infinite;
  }
  @keyframes bossTimerPulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.09); } }
  html[data-motion="reduced"] { #game .bossTimer.low { animation: none; } }
  /* seal color on the calm timer; .low urgent state stays red for every seal (urgency cue must read the same). */
  #game .bossTimer.bossVar-tide:not(.low) { border-color: #7fd0d8; background: linear-gradient(180deg, #1f5b60, #0e2e31); box-shadow: 0 4px 12px rgba(12,60,64,.42), 0 0 9px rgba(70,190,205,.36), inset 0 1px 0 rgba(224,250,252,.28); }
  #game .bossTimer.bossVar-gilt:not(.low) { border-color: #f0cf6a; background: linear-gradient(180deg, #6a4e1a, #322616); box-shadow: 0 4px 12px rgba(70,52,12,.42), 0 0 9px rgba(238,178,60,.36), inset 0 1px 0 rgba(255,248,210,.28); }
  #game .timeSealCoach {
    position: absolute; left: 0; right: 0; margin-inline: auto; width: max-content; z-index: 10;
    top: auto; bottom: calc(env(safe-area-inset-bottom,0px) + clamp(120px, 17vh, 154px));
    max-width: min(320px, 86vw); padding: 6px 12px 6px 7px;
    border-radius: 13px; border: 1.5px solid rgba(255,221,142,.85);
    background:
      linear-gradient(180deg, rgba(255,232,168,.2), transparent 45%),
      linear-gradient(180deg, rgba(74,34,22,.94), rgba(39,20,15,.94));
    color: #fff4d2; font-size: clamp(15px, 3.4vw, 15px); font-weight: 850; line-height: 1.18;
    display: flex; align-items: center; justify-content: center; gap: 7px;
    text-align: left; text-shadow: 0 1px 2px rgba(24,8,3,.72);
    box-shadow: 0 4px 12px rgba(42,14,6,.38), 0 0 9px rgba(255,128,82,.28), inset 0 1px 0 rgba(255,245,205,.22);
    pointer-events: none;
  }
  /* tap-to-continue coaches (2026-07-18, bossTimer.js showTimeSealCoach/showEliteIntroCoach) — the ONLY
     two coaches on this shared surface that block until acknowledged, so they're the only ones that need
     pointer-events on. Unified with game/showWin.js's showForceEliteBridge (same day): matches this
     codebase's own "재도전 코치" pattern (stageEntrance.js showControlGuide's .ccRing) — a pulsing ring
     around the exact tap target IS the "waiting for you" signal, not a self brightness-pulse. */
  #game .timeSealCoach.tapToContinue { pointer-events: auto; cursor: pointer; }
  #game .timeSealCoach.tapToContinue::before {
    /* .timeSealCoach::before (below) is the tail-arrow triangle for the OTHER coach skins, targeting this
       same ::before pseudo-element — width/height/transform/filter must be reset here or its 0×0 triangle
       box + translateX(-50%) + drop-shadow leak through (nothing here overrides them otherwise), collapsing
       this ring to an invisible dot (owner report 2026-07-18: "코치에 펄스가 없어"). */
    content: ""; position: absolute; inset: -8px; width: auto; height: auto; transform: none; filter: none;
    border-radius: 17px; pointer-events: none;
    border: 3px solid rgba(255,224,122,.95);
    box-shadow: 0 0 0 4px rgba(255,224,122,.22), 0 0 16px rgba(255,224,122,.5);
    animation: tapRingPulse 1.2s ease-in-out infinite;
  }
  html[data-motion="reduced"] #game .timeSealCoach.tapToContinue::before { animation: none; }
  /* scale-only twin of .ccRing's own ccRingPulse (below) — that keyframe bakes in translate(-50%,-50%),
     correct ONLY for .ccRing's left/top-POINT positioning (it recenters on its own size). This ring and
     #badgeClaimToast's own use `inset` to stretch around their target instead, already correctly placed —
     reusing ccRingPulse verbatim shifted them by their own half-width/height (owner report 2026-07-18: the
     pulse position was visibly off). */
  @keyframes tapRingPulse {
    0%,100% { transform: scale(1);     opacity: .95; }
    50%     { transform: scale(1.08); opacity: .55; }
  }
  #game .timeSealCoach .timeSealSha {
    flex: 0 0 auto; width: 42px; height: 42px; object-fit: contain;
    filter: drop-shadow(0 2px 3px rgba(18,8,2,.5)) drop-shadow(0 0 8px rgba(255,220,96,.38));
    transform-origin: 50% 90%;
    animation: timeSealShaPeek 1s linear both;
  }
  #game .timeSealCoach .timeSealCoachText { display: block; max-width: 220px; }
  #game .timeSealCoach::before {
    content: ""; position: absolute; left: 50%; top: -7px; transform: translateX(-50%);
    width: 0; height: 0; border-left: 7px solid transparent; border-right: 7px solid transparent;
    border-bottom: 8px solid rgba(74,34,22,.94);
    filter: drop-shadow(0 -1px 0 rgba(255,221,142,.75));
  }
  /* tail points UP toward the board — all coaches sit below the board now */
  #game .timeSealCoach.omenCoach {
    border-color: rgba(255,232,135,.9);
    background:
      linear-gradient(180deg, rgba(255,241,174,.24), transparent 45%),
      linear-gradient(180deg, rgba(56,62,28,.95), rgba(34,47,22,.95));
    box-shadow: 0 4px 12px rgba(23,38,12,.36), 0 0 12px rgba(255,220,92,.32), inset 0 1px 0 rgba(255,250,210,.24);
  }
  #game .timeSealCoach.omenCoach::before { border-bottom-color: rgba(56,62,28,.95); }
  #game .timeSealCoach.trailCoach {
    border-color: rgba(235,216,139,.82);
    background:
      linear-gradient(180deg, rgba(255,235,160,.18), transparent 45%),
      linear-gradient(180deg, rgba(50,48,30,.95), rgba(30,37,24,.95));
    box-shadow: 0 4px 12px rgba(21,31,13,.34), 0 0 9px rgba(255,218,92,.24), inset 0 1px 0 rgba(255,250,210,.2);
  }
  #game .timeSealCoach.trailCoach::before { border-bottom-color: rgba(50,48,30,.95); }
  /* elite intro coach — amethyst skin (matches the elite gem/socket/pill grammar) */
  #game .timeSealCoach.eliteCoach {
    border-color: rgba(214,178,248,.85);
    background:
      linear-gradient(180deg, rgba(230,210,255,.2), transparent 45%),
      linear-gradient(180deg, rgba(58,34,92,.95), rgba(36,20,58,.95));
    box-shadow: 0 4px 12px rgba(30,14,52,.4), 0 0 11px rgba(154,85,224,.34), inset 0 1px 0 rgba(235,215,255,.22);
  }
  #game .timeSealCoach.eliteCoach::before { border-bottom-color: rgba(58,34,92,.95); }
  #game .timeSealCoach.show { animation: timeSealCoachIn .30s linear both; }
  @keyframes timeSealCoachIn {
    0%   { opacity: 0; transform: translateY(6px) scale(.97); animation-timing-function: ease-in-out; }
    55%  { opacity: 1; transform: translateY(0) scaleX(1.05) scaleY(.953); animation-timing-function: ease-in-out; }
    80%  { transform: translateY(0) scaleX(.985) scaleY(1.016); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  @keyframes timeSealShaPeek {
    0%   { opacity: 0; transform: translateY(7px) rotate(-4deg) scaleX(.795) scaleY(.845); animation-timing-function: ease-in-out; }
    58%  { opacity: 1; transform: translateY(-2px) rotate(2deg) scaleX(1.121) scaleY(1.002); animation-timing-function: ease-in-out; }
    82%  { transform: translateY(0) rotate(0) scaleX(.970) scaleY(1.031); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
  }
  html[data-motion="reduced"] {
    #game .timeSealCoach.show,
    #game .timeSealCoach .timeSealSha { animation: none; }
  }

  /* Board area: horizontal centering only. The board's VERTICAL placement is owned by JS
     (game/boardFit.js `placeBoard()` writes .gameBoardWrap's margin-top) — see the block comment there.
     Short version: CSS centering is a LIVE calculation, so the moment the area's height settles a beat after
     stage entry (safe-area insets resolving / mobile URL-bar auto-hiding) it re-centers and the board
     teleports ~120px down (the 2026-07-25 bug that forced top-anchoring). fitBoard latches a reference
     height instead and centers against THAT, so a later height change only grows the empty space below.
     align-items:flex-start (not center) is what lets that margin-top be the only vertical term; it also
     means a shrunken area clips the BOTTOM, never the top. */
  .gameBoardArea {
    flex: 0 1 auto;            /* natural height (= framed canvas); don't grow → content packs toward the top */
    display: flex;
    align-items: flex-start;
    justify-content: center;
    position: relative;
    z-index: 5;
    width: 100%;
    min-height: 0;
    padding-top: 0;
  }
  /* Board placement polish — RESPONSIVE, board SIZE never changes (fitBoard owns it; owner 2026-06-30).
     Applied on screens with vertical headroom (≥760px tall: Fold cover, most modern phones) OR screens
     wide enough that they can't be a narrow phone (≥600px: Fold/tablet UNFOLDED — short but wide, e.g.
     ~808×720, so height alone wrongly excluded it and the board glued to the palette there — 2026-07-03):
       • flex:1 1 auto → the area GROWS to fill the space between the palette and the pinned bottom bar, and
         (it's display:flex + align/justify:center) CENTERS the board in it. This splits the leftover space
         ABOVE+BELOW the board instead of dumping it all below → the bottom action bar stays pinned
         (margin-top:auto) at a STABLE position and the board no longer glues to the palette.
       • margin-top → guarantees a MINIMUM palette↔board gap even for a large board that nearly fills the area
         (where centering alone would shrink the gap back toward 0).
     :not(.scrollY) → tall scroll boards keep their own rule (display:block + top-anchored overflow-y:auto),
     untouched. Small NARROW screens (SE 375×667, chrome already fills the column per boardFit's 320px
     reservation) fall outside both conditions → keep flex:0 1 auto, so the board can never be
     center-clipped there. CSS-only. */
  @media (min-height: 760px), (min-width: 600px) {
    /* This rule only makes the AREA grow to fill the palette↔action-bar gap. Where the board sits INSIDE that
       grown area is decided by JS (boardFit `placeBoard()`), not by align-items — see the .gameBoardArea
       comment above. History: 2026-06-30 centered it here in CSS; 2026-07-25 (c3f4f1b) reverted to
       align-items:flex-start because the live re-centering teleported the board ~120px down on the post-entry
       height settle; 2026-07-28 the owner asked for centering again, so the same visual result is produced
       from a LATCHED reference height instead of a live one — the teleport's cause is gone, not just its symptom.
       `:not(.zoomed)` stays only because .zoomed brings its own flex/scroll rules; vertical placement is now
       identical in both (the JS margin-top applies in every state). */
    #game .gameBoardArea:not(.scrollY):not(.scrollXY):not(.scrollX):not(.zoomed) { flex: 1 1 auto; margin-top: 12px; }   /* fixed (was 1.6vh): the vh term was the last viewport-dependent bit that still nudged the board a few px on a height settle — a fixed gap keeps the board pixel-stable */
  }

  /* Zoomed board: 어떤 보드든(fit되던 보드 포함) 팬 뷰포트가 되게 함. transform/scale은 boardFit.applyZoom()이
     #board에 인라인으로 건다 — 여기서는 스크롤 컨테이너화 + #board를 좌상단 고정만 담당. */
  .gameBoardArea.zoomed {
    display: block;
    flex: 1 1 0;
    min-height: 0;
    overflow: auto;
    /* Breathing room at every scroll extreme (2026-07-17 fix) — without this, panning a zoomed board all
       the way to an edge left it flush against the container wall (looked unfinished). Every zoomed board
       gets this .zoomed class regardless of its pre-zoom scroll axis (plain/.scrollY/.scrollX), so setting
       it here once covers all of them; matches .scrollXY's existing values for consistency. .scrollX's own
       left/right padding (declared later) resolves to the same value, so no visible seam between the two. */
    padding-top: max(28px, calc(env(safe-area-inset-top, 0px) + 14px));
    padding-bottom: max(28px, calc(env(safe-area-inset-bottom, 0px) + 14px));
    padding-left: max(28px, calc(env(safe-area-inset-left, 0px) + 14px));
    padding-right: max(28px, calc(env(safe-area-inset-right, 0px) + 14px));
    scroll-padding-top: max(28px, calc(env(safe-area-inset-top, 0px) + 14px));
    scroll-padding-bottom: max(28px, calc(env(safe-area-inset-bottom, 0px) + 14px));
    scroll-padding-left: max(28px, calc(env(safe-area-inset-left, 0px) + 14px));
    scroll-padding-right: max(28px, calc(env(safe-area-inset-right, 0px) + 14px));
    -webkit-overflow-scrolling: touch;
  }
  /* 축소 안착 상태(2026-07-28)에서는 한 축이 꽉 차고 다른 축엔 여백이 남는다 — 그 여백을 양쪽으로 나눠야
     보드가 영역 한가운데에 온다.
       · 가로: margin-inline auto. 보드가 컨테이너보다 넓어지면(확대·팬) 블록 레이아웃 규칙상 auto가 0으로
         풀려 좌측 기준이 되므로 왼쪽 끝이 항상 닿는다 = 잘림 없음.
       · 세로: boardFit `placeBoard()`가 인라인 margin-top으로 직접 쓴다(아래 shorthand의 top을 덮어쓴다).
         남는 공간이 없으면 0 → 상단 기준 + 스크롤이라 위쪽이 잘리지 않는다.
     (2026-07-21에 이 목적으로 넣었던 위쪽 `& > .gameBoardWrap { margin-inline: auto }`는 같은 특정도의 이
      규칙이 뒤에 와서 덮어쓰는 바람에 계속 죽어 있었다 — 그래서 축소해도 보드가 좌상단에 붙어 있었다.) */
  .gameBoardArea.zoomed .gameBoardWrap { margin: 0 auto; display: block; }
  /* Scoped to .zoomed only (2026-07-15 fix): this used to be unscoped and hit EVERY #board transform, not
     just the zoom one — the "finish PUNCH" replayPunch scale(1.05) bounce (game/winSequence.js, fires the
     instant a solve completes) pivoted from the top-left corner instead of center, reading as the board
     jolting/lifting toward one corner. boardFit.applyZoom()/resetZoom() already set this inline in JS
     whenever a zoom gesture is actually active, so scoping it here costs nothing for zoom itself. */
  .gameBoardArea.zoomed #board { transform-origin: 0 0; }

  /* Tall-board (rows > 9) vertical scroll: cells stay full-size, the board scrolls. Block + top-anchor so
     the top is reachable (flex `align-items:center` clips the top of an overflowing child); flex governs the
     height (no maxHeight). Long-press one finger OR a second finger scrolls it (input/paint.js); one finger drags = paint. */
  .gameBoardArea.scrollY {
    display: block;
    flex: 1 1 0;            /* take ALL remaining space between palette and the action bar … */
    min-height: 0;         /* … and be allowed to shrink below the board height so it scrolls internally */
    overflow-y: auto;
    overflow-x: hidden;
    -webkit-overflow-scrolling: touch;
  }
  .gameBoardArea.scrollY .gameBoardWrap { margin: 0 auto; }
  /* 2XL / Mirror Mural: same fixed chrome as XL, but the board can pan both ways. */
  .gameBoardArea.scrollXY {
    display: block;
    flex: 1 1 0;
    min-height: 0;
    overflow: auto;
    padding-top: max(28px, calc(env(safe-area-inset-top, 0px) + 14px));
    padding-bottom: max(28px, calc(env(safe-area-inset-bottom, 0px) + 14px));
    padding-left: max(28px, calc(env(safe-area-inset-left, 0px) + 14px));
    padding-right: max(28px, calc(env(safe-area-inset-right, 0px) + 14px));
    scroll-padding-top: max(28px, calc(env(safe-area-inset-top, 0px) + 14px));
    scroll-padding-bottom: max(28px, calc(env(safe-area-inset-bottom, 0px) + 14px));
    scroll-padding-left: max(28px, calc(env(safe-area-inset-left, 0px) + 14px));
    scroll-padding-right: max(28px, calc(env(safe-area-inset-right, 0px) + 14px));
    -webkit-overflow-scrolling: touch;
  }
  .gameBoardArea.scrollXY .gameBoardWrap { margin: 0 auto; }
  /* Horizontal-only scroll: a board wider than REF cols but not taller than REF rows (rare — most boards
     that exceed REF on one axis exceed it on both, and get .scrollXY instead). Mirrors .scrollY for the X axis. */
  .gameBoardArea.scrollX {
    display: block;
    flex: 1 1 0;
    min-height: 0;
    overflow-x: auto;
    overflow-y: hidden;
    padding-left: max(28px, calc(env(safe-area-inset-left, 0px) + 14px));
    padding-right: max(28px, calc(env(safe-area-inset-right, 0px) + 14px));
    scroll-padding-left: max(28px, calc(env(safe-area-inset-left, 0px) + 14px));
    scroll-padding-right: max(28px, calc(env(safe-area-inset-right, 0px) + 14px));
    -webkit-overflow-scrolling: touch;
  }
  .gameBoardArea.scrollX .gameBoardWrap { margin: 0 auto; }
  /* Pinch-zoomed board must pan in BOTH directions regardless of its un-zoomed scroll axis — a scrollY-only
     board's overflow-x:hidden (and scrollX's overflow-y:hidden) otherwise wins over .zoomed's overflow:auto
     above (same specificity, .zoomed declared earlier in this file → loses the source-order tie-break) once
     zoom makes the OTHER axis overflow too. Declared last so it wins. */
  .gameBoardArea.scrollY.zoomed,
  .gameBoardArea.scrollX.zoomed {
    overflow: auto;
  }
  /* "↕ 스크롤" cue shown while the long-press scroll mode is active (sticky → stays at the top while scrolling) */
  .gameBoardArea.scrollOn::before {
    content: "↕ 스크롤"; position: sticky; top: 6px; left: 50%; transform: translateX(-50%); z-index: 12;
    display: block; width: max-content; margin: 0 auto -34px;
    padding: 5px 14px; border-radius: 999px;
    background: rgba(40,20,8,.78); color: #ffe7bd; font-weight: 800; font-size: 15px;
    box-shadow: 0 2px 8px rgba(0,0,0,.35); pointer-events: none;
  }
  .gameBoardArea.scrollXY.scrollOn::before { content: "↕↔ 이동"; }
  .gameBoardArea.scrollX.scrollOn::before { content: "↔ 스크롤"; }

  /* Board wrapper: creates positioning context for frame + puzzle name */
  .gameBoardWrap {
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .gameBoardWrap.stageMapEntry::before {
    content: "";
    position: absolute;
    inset: -18%;
    z-index: 7;
    pointer-events: none;
    border-radius: 28%;
    background:
      radial-gradient(circle at 50% 48%, rgba(255,252,226,.72), rgba(255,216,98,.28) 34%, rgba(255,216,98,0) 68%),
      radial-gradient(circle at 50% 50%, rgba(91,221,229,.2), transparent 62%);
    filter: blur(.2px);
    opacity: 0;
    animation: stageMapEntryBloom .82s cubic-bezier(.2,.76,.22,1) both;
    mix-blend-mode: screen;
  }
  @keyframes stageMapEntryBloom {
    0%   { opacity: 0; transform: scale(.72); }
    22%  { opacity: .95; transform: scale(.92); }
    64%  { opacity: .5; transform: scale(1.06); }
    100% { opacity: 0; transform: scale(1.16); }
  }
  /* 4/4 Apex Threshold: the bitmap supplies authored material; CSS stages the approach, impact, and entry. */
  .apexEntrance[hidden] { display: none !important; }
  .apexEntrance {
    position: fixed;
    inset: 0;
    z-index: 120;
    overflow: hidden;
    isolation: isolate;
    opacity: 0;
    pointer-events: auto;
    transition: opacity .16s ease-out;
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
  }
  .apexEntrance.is-active { opacity: 1; }
  body.apex-opening #game > #hud {
    opacity: .16;
    filter: saturate(.55) brightness(.72);
    transition: opacity .22s ease, filter .22s ease;
  }
  body.apex-opening #stageDiff,
  body.apex-opening #gameGuide,
  body.apex-opening .paletteZone,
  body.apex-opening #game .controls,
  body.apex-opening #game .statusSlot,
  body.apex-opening #game #badge,
  body.apex-opening #game #stageBuddy {
    opacity: 0 !important;
    pointer-events: none !important;
    transition: opacity .18s ease;
  }
  .apexShade {
    position: absolute;
    inset: 0;
    z-index: 0;
    background:
      radial-gradient(ellipse at 50% 44%, rgba(13,24,20,.32) 0 30%, rgba(4,12,10,.76) 79%),
      rgba(7,16,13,.4);
    backdrop-filter: blur(1.5px) saturate(.58) brightness(.63);
    -webkit-backdrop-filter: blur(1.5px) saturate(.58) brightness(.63);
    opacity: 0;
    transition: opacity .32s ease-out;
  }
  .apexEntrance.is-active .apexShade { opacity: 1; }
  .apexPortal {
    position: absolute;
    inset: 0;
    z-index: 1;
    overflow: hidden;
    pointer-events: none;
    perspective: 900px;
  }
  .apexPortal > i {
    position: absolute;
    inset: 5% 0 14%;
    display: block;
    pointer-events: none;
    background:
      linear-gradient(90deg, transparent calc(50% - 1px), rgba(255,228,158,.16) calc(50% - .5px), rgba(255,248,216,.9) 50%, rgba(255,210,112,.22) calc(50% + .5px), transparent calc(50% + 1px)),
      linear-gradient(90deg, transparent 49.55%, rgba(48,181,174,.2) 49.8%, transparent 50.2%, rgba(48,181,174,.2) 50.45%, transparent 50.7%);
    box-shadow: none;
    opacity: 0;
    transform: translateZ(-28px) scale(.82);
    transform-origin: 50% 78%;
    transition: opacity .28s ease-out, transform .7s cubic-bezier(.16,.84,.2,1);
  }
  .apexPortal > i::before,
  .apexPortal > i::after {
    content: "";
    position: absolute;
    pointer-events: none;
    opacity: 0;
    transition: opacity .32s ease-out, transform .48s cubic-bezier(.16,.84,.2,1);
  }
  /* Handoff rays stay short and directional; the warm core is deliberately never a board-wide fill. */
  .apexPortal > i::before {
    inset: 12% 25% 35%;
    background:
      linear-gradient(104deg, transparent 0 43%, rgba(255,228,154,.12) 46%, rgba(255,242,198,.62) 49%, transparent 53%),
      linear-gradient(76deg, transparent 0 44%, rgba(255,215,125,.1) 47%, rgba(255,235,174,.48) 50%, transparent 54%),
      linear-gradient(112deg, transparent 0 48%, rgba(255,198,93,.16) 50%, transparent 53%),
      linear-gradient(68deg, transparent 0 47%, rgba(255,211,115,.14) 50%, transparent 53%);
    transform: scaleX(.2) translateY(4%);
  }
  /* Low floor spill gives the opening a contact point without using a fullscreen blur or glow. */
  .apexPortal > i::after {
    inset: auto 15% 4% 15%;
    height: 24%;
    background: radial-gradient(ellipse at 50% 0%, rgba(255,222,139,.28) 0 8%, rgba(255,191,85,.12) 30%, transparent 72%);
    transform: scaleX(.35) translateY(10%);
  }
  .apexEntrance.is-active .apexPortal > i {
    opacity: .62;
    transform: translateZ(0) scale(1);
  }
  .apexEntrance.is-handoff .apexPortal > i {
    opacity: .9;
    transform: translateZ(20px) scale(1.09);
    transition-duration: .48s;
  }
  .apexEntrance.is-handoff .apexPortal > i::before,
  .apexEntrance.is-handoff .apexPortal > i::after { opacity: 1; }
  .apexEntrance.is-handoff .apexPortal > i::before { transform: scaleX(1) translateY(0); }
  .apexEntrance.is-handoff .apexPortal > i::after { transform: scaleX(1) translateY(0); }
  .apexDoors {
    position: absolute;
    inset: 0;
    z-index: 2;
    overflow: hidden;
    perspective: 1050px;
    pointer-events: none;
  }
  .apexDoor {
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    object-fit: fill;
    opacity: 0;
    will-change: transform, opacity;
    transition: transform .5s cubic-bezier(.2,.78,.22,1), opacity .18s ease-out;
    transform-style: preserve-3d;
  }
  .apexDoorLeft {
    clip-path: inset(0 50% 0 0);
    transform-origin: 50% 72%;
  }
  .apexDoorRight {
    clip-path: inset(0 0 0 50%);
    transform-origin: 50% 72%;
  }
  .apexEntrance.is-active .apexDoor {
    opacity: 1;
    transform: translate3d(0, 0, 0) rotateY(0deg);
  }
  .apexEntrance.is-handoff .apexDoorLeft {
    transform: translate3d(-76%, 0, 0) rotateY(-58deg) scale(1.06);
    transform-origin: 100% 72%;
  }
  .apexEntrance.is-handoff .apexDoorRight {
    transform: translate3d(76%, 0, 0) rotateY(58deg) scale(1.06);
    transform-origin: 0 72%;
  }
  .apexGate {
    position: absolute;
    inset: 0;
    z-index: 3;
    width: 100%;
    height: 100%;
    object-fit: fill;
    opacity: 0;
    will-change: transform, opacity, clip-path;
    transform-origin: 50% 36%;
    filter: drop-shadow(0 12px 22px rgba(2,9,7,.72));
    transition:
      transform .72s cubic-bezier(.16,.84,.2,1),
      opacity .22s ease-out,
      clip-path .72s cubic-bezier(.16,.84,.2,1);
  }
  .apexGateRear {
    transform: translate3d(0, -1.8%, 0) scale(.86);
    transform-origin: 50% 36%;
  }
  .apexGateFront {
    z-index: 4;
    clip-path: polygon(0 0, 8% 0, 8% 84%, 92% 84%, 92% 0, 100% 0, 100% 100%, 0 100%);
    transform: translate3d(0, 1.2%, 0) scale(1.08);
    transform-origin: 50% 64%;
  }
  .apexEntrance.is-active .apexGateRear,
  .apexEntrance.is-active .apexGateFront {
    opacity: 1;
    transform: translate3d(0, 0, 0) scale(1);
  }
  .apexImpactDust {
    position: absolute;
    inset: 0;
    z-index: 5;
    overflow: hidden;
    pointer-events: none;
  }
  .apexImpactDust i {
    position: absolute;
    left: 50%;
    top: 73%;
    width: clamp(3px, .9vw, 7px);
    height: clamp(3px, .9vw, 7px);
    border-radius: 1px;
    background: linear-gradient(135deg, #fff8c7, #b4e7bb 48%, #55a88d);
    box-shadow: 0 0 5px rgba(219,255,199,.72);
    opacity: 0;
    will-change: transform, opacity;
    transform: translate3d(-50%, -50%, 0) rotate(0deg) scale(.4);
  }
  .apexEntrance.is-impact .apexImpactDust i {
    animation: apexDustFleck .58s cubic-bezier(.16,.8,.22,1) both;
    animation-delay: calc(var(--dust-i) * 12ms);
  }
  .apexEntrance.is-impact .apexImpactDust {
    animation: apexImpactFlash .13s ease-out both;
  }
  .apexEntrance.is-impact .apexDoors,
  .apexEntrance.is-impact .apexGateRear,
  .apexEntrance.is-impact .apexGateFront {
    animation: apexThresholdImpact .16s cubic-bezier(.2,.8,.3,1) both;
  }
  @keyframes apexImpactFlash {
    0% { opacity: 0; }
    30% { opacity: .82; }
    100% { opacity: 0; }
  }
  @keyframes apexDustFleck {
    0% { opacity: 0; transform: translate3d(-50%, -50%, 0) rotate(0deg) scale(.4); }
    16% { opacity: .95; }
    100% {
      opacity: 0;
      transform: translate3d(calc(-50% + var(--dust-x, 0px)), calc(-50% + var(--dust-y, 0px)), 0) rotate(var(--dust-r, 0deg)) scale(1);
    }
  }
  @keyframes apexThresholdImpact {
    0% { translate: 0 0; }
    32% { translate: 0 2px; }
    68% { translate: 0 -1px; }
    100% { translate: 0 0; }
  }
  .apexImpactDust i:nth-child(1) { --dust-x: -21vw; --dust-y: 7vh; --dust-r: -28deg; }
  .apexImpactDust i:nth-child(2) { --dust-x: -15vw; --dust-y: 3vh; --dust-r: 34deg; }
  .apexImpactDust i:nth-child(3) { --dust-x: -9vw; --dust-y: 8vh; --dust-r: -18deg; }
  .apexImpactDust i:nth-child(4) { --dust-x: -4vw; --dust-y: 1vh; --dust-r: 42deg; }
  .apexImpactDust i:nth-child(5) { --dust-x: 4vw; --dust-y: 2vh; --dust-r: -36deg; }
  .apexImpactDust i:nth-child(6) { --dust-x: 10vw; --dust-y: 8vh; --dust-r: 19deg; }
  .apexImpactDust i:nth-child(7) { --dust-x: 16vw; --dust-y: 4vh; --dust-r: -25deg; }
  .apexImpactDust i:nth-child(8) { --dust-x: 22vw; --dust-y: 10vh; --dust-r: 31deg; }
  .apexImpactDust i:nth-child(9) { --dust-x: -12vw; --dust-y: 12vh; --dust-r: 47deg; }
  .apexImpactDust i:nth-child(10) { --dust-x: 13vw; --dust-y: 13vh; --dust-r: -43deg; }
  .apexSealRow {
    position: absolute;
    z-index: 6;
    top: 12.25%;
    left: 25.6%;
    width: 48.8%;
    height: 5.45%;
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    column-gap: 2.8%;
    pointer-events: none;
    will-change: transform, opacity;
  }
  .apexSealRow i {
    display: grid;
    place-items: center;
  }
  .apexSealRow i::before {
    content: "";
    width: 61%;
    aspect-ratio: 1;
    border-radius: 19%;
    opacity: 0;
    transform: scale(.35);
    background:
      linear-gradient(145deg, rgba(255,255,255,.8), rgba(255,255,255,0) 38%),
      linear-gradient(180deg, #fff0a8 0%, #efbd38 55%, #ad6817 100%);
    border: 1px solid rgba(255,242,177,.9);
    box-shadow:
      0 0 5px rgba(255,228,116,.9),
      0 0 15px rgba(255,174,42,.72),
      inset 0 -2px 3px rgba(100,48,8,.32);
  }
  .apexEntrance.is-active .apexSealRow i::before {
    animation: apexSealWake 440ms linear both;
    animation-delay: calc(500ms + var(--apex-i) * 135ms);
  }
  @keyframes apexSealWake {
    0%   { opacity: 0; transform: scaleX(.339) scaleY(.361); filter: brightness(.8); animation-timing-function: ease-in-out; }
    62%  { opacity: 1; transform: scaleX(1.206) scaleY(1.077); filter: brightness(1.45); animation-timing-function: ease-in-out; }
    84%  { opacity: 1; transform: scaleX(.96) scaleY(1.021); filter: brightness(1.05); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: scale(1); filter: brightness(1); }
  }
  .apexCopy {
    position: absolute;
    z-index: 7;
    top: 24.5%;
    left: 50%;
    width: min(86vw, 440px);
    transform: translate(-50%, 12px);
    text-align: center;
    color: #fff4d4;
    opacity: 0;
    transition: opacity .3s ease-out, transform .38s cubic-bezier(.2,.8,.25,1);
    pointer-events: none;
  }
  .apexEntrance.is-active.is-speaking .apexCopy {
    opacity: 1;
    transform: translate(-50%, 0);
  }
  .apexTitle,
  .apexSubtitle { display: block; letter-spacing: 0; }
  .apexTitle {
    font-family: 'Jua', system-ui, sans-serif;
    font-size: clamp(34px, 10.5vw, 52px);
    font-weight: 400;
    line-height: 1;
    color: #fff0bd;
    text-shadow:
      0 2px 0 #5b3211,
      0 0 12px rgba(255,191,62,.48),
      0 5px 10px rgba(17,8,2,.7);
  }
  .apexSubtitle {
    margin: clamp(9px, 1.7vh, 15px) auto 0;
    max-width: 100%;
    font-size: clamp(16px, 4.5vw, 21px);
    font-weight: 850;
    line-height: 1.28;
    color: #fff8e9;
    text-shadow: 0 2px 5px rgba(19,8,2,.92), 0 0 8px rgba(19,8,2,.52);
  }
  .apexSha {
    position: absolute;
    z-index: 7;
    left: clamp(10px, 3vw, 24px);
    bottom: max(5.5%, calc(env(safe-area-inset-bottom, 0px) + 18px));
    width: clamp(148px, 42vw, 220px);
    height: auto;
    opacity: 0;
    transform: translate(-18px, 18px) scale(.9);
    transform-origin: 36% 100%;
    filter: drop-shadow(0 8px 10px rgba(3,10,7,.62));
    transition: opacity .3s ease-out, transform .48s cubic-bezier(.2,.9,.3,1.12);
    pointer-events: none;
  }
  .apexEntrance.is-active.is-speaking .apexSha {
    opacity: 1;
    transform: translate(0, 0) scale(1);
  }
  /* Handoff is a physical opening: the doors split, the portal blooms, and the two gate planes leave at different rates. */
  .apexEntrance.is-handoff .apexShade { opacity: 0; transition-duration: .48s; }
  .apexEntrance.is-handoff .apexDoor { opacity: 1; transition-duration: .5s; }
  .apexEntrance.is-handoff .apexGateRear {
    opacity: 1;
    transform: translate3d(0, -14%, 0) scale(1.2);
    transition-duration: .44s;
    transition-timing-function: cubic-bezier(.4,0,.7,1);
  }
  .apexEntrance.is-handoff .apexGateFront {
    opacity: 1;
    clip-path: polygon(0 0, 12% 0, 12% 76%, 88% 76%, 88% 0, 100% 0, 100% 100%, 0 100%);
    transform: translate3d(0, 16%, 0) scale(1.34);
    transition-duration: .5s;
    transition-timing-function: cubic-bezier(.4,0,.7,1);
  }
  .apexEntrance.is-handoff .apexCopy,
  .apexEntrance.is-handoff .apexSha {
    opacity: 0;
    transform: translate(-50%, -8px);
    transition: opacity .2s ease-in, transform .25s ease-in;
  }
  .apexEntrance.is-handoff .apexSha { transform: translate(-10px, 14px) scale(.96); }
  .apexEntrance.is-handoff .apexSealRow {
    opacity: 0;
    transform: translateY(-8px) scale(.92);
    transition: opacity .22s ease-in, transform .3s ease-in;
  }
  /* Re-entry/restart: keep only a brief crest recognition. It cannot become a repeated blocker. */
  .apexEntrance.is-compact .apexPortal,
  .apexEntrance.is-compact .apexDoors,
  .apexEntrance.is-compact .apexGateFront,
  .apexEntrance.is-compact .apexImpactDust,
  .apexEntrance.is-compact .apexCopy,
  .apexEntrance.is-compact .apexSha { display: none; }
  .apexEntrance.is-compact .apexShade {
    background: rgba(7,16,13,.35);
    backdrop-filter: blur(.5px) saturate(.8) brightness(.82);
    -webkit-backdrop-filter: blur(.5px) saturate(.8) brightness(.82);
  }
  .apexEntrance.is-compact .apexGateRear {
    clip-path: inset(0 0 65% 0);
    opacity: .7;
    transform: translate3d(0, -2%, 0) scale(.98);
    transition-duration: .18s;
  }
  .apexEntrance.is-compact.is-active .apexGateRear {
    opacity: .82;
    transform: translate3d(0, 0, 0) scale(1);
  }
  .apexEntrance.is-compact .apexSealRow i::before {
    animation-duration: .16s;
    animation-delay: calc(25ms + var(--apex-i) * 26ms);
  }
  .apexEntrance.is-skipped * { transition-delay: 0ms !important; }
  #stageDiff.apexLand { animation: apexDifficultyLand .62s ease-out; }
  @keyframes apexDifficultyLand {
    0%   { opacity: .35; transform: translateY(-3px); filter: brightness(1.28); }
    62%  { opacity: 1; transform: translateY(1px); filter: brightness(1.08); }
    100% { opacity: 1; transform: translateY(0); filter: none; }
  }
  html[data-motion="reduced"] {
    .apexEntrance,
    .apexEntrance *,
    #stageDiff.apexLand {
      animation: none !important;
      transition: none !important;
    }
    .apexEntrance.is-active .apexShade,
    .apexEntrance.is-active .apexGate,
    .apexEntrance.is-active .apexDoor,
    .apexEntrance.is-active .apexPortal > i { opacity: 1; }
    .apexEntrance.is-active .apexGateRear,
    .apexEntrance.is-active .apexGateFront,
    .apexEntrance.is-active .apexDoorLeft,
    .apexEntrance.is-active .apexDoorRight,
    .apexEntrance.is-active .apexPortal > i { transform: translate3d(0, 0, 0) scale(1) rotateY(0deg); }
    .apexEntrance.is-active.is-reduced .apexCopy,
    .apexEntrance.is-active.is-reduced .apexSha {
      opacity: 1;
      transform: translate(-50%, 0) scale(1);
    }
    .apexEntrance.is-active.is-reduced .apexSha { transform: translate(0, 0) scale(1); }
    .apexEntrance.is-active .apexSealRow i::before {
      animation: none !important;
      opacity: 1;
      transform: scale(1);
    }
    .apexEntrance.is-handoff .apexDoor,
    .apexEntrance.is-handoff .apexGate,
    .apexEntrance.is-handoff .apexPortal > i { opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce) {
    .apexEntrance,
    .apexEntrance *,
    #stageDiff.apexLand {
      animation: none !important;
      transition: none !important;
    }
  }
  @media (max-height: 700px) {
    .apexCopy { top: 23.5%; }
    .apexSha { width: clamp(130px, 37vw, 182px); bottom: 3.5%; }
  }
  /* Stage opening camera owns only the wrapper. Gameplay zoom continues to own #board, so a cinematic
     overview can never overwrite the pinch transform or its anchor math. Layout/scroll positions stay put
     underneath and are restored exactly when the wrapper returns to identity. */
  .gameBoardArea.stageOpening { overscroll-behavior: none; }
  /* overflow clip is ONLY for the camera overview (large boards). Small boards (no camera, board flush at the
     area top) must NOT clip — otherwise the shape-reveal outline's top glow gets shaved (owner 2026-07-26). */
  .gameBoardArea.stageClip { overflow: hidden !important; }
  .gameBoardWrap.stageCamera {
    z-index: 8;
    transform-origin: 0 0;
    will-change: transform;
    transition: transform var(--stage-cam-ms, 300ms) cubic-bezier(.22,.72,.24,1);
  }
  .gameBoardWrap.stageCamera.stageOverview {
    transform: translate(var(--stage-cam-x, 0), var(--stage-cam-y, 0)) scale(var(--stage-cam-scale, 1));
  }
  .gameBoardWrap.stageCamera.stageCameraReturn {
    /* SETTLE from the static overview hold (2026-07-24, measured/tuned via Playwright): after the board sits
       still, a pure ease-OUT (.2,.7,.24,1) lurched into motion at the start (fastest right off the hold), and
       the original ease-IN (.3,.02,.22,1) snapped at the end. An ease-IN-OUT accelerates gently FROM rest and
       decelerates gently INTO place — no start lurch, no end snap. */
    transition-timing-function: cubic-bezier(.5,0,.22,1);
  }
  /* The reveal outline is a SIBLING of #board inside .gameBoardWrap, so the board's own zoom transform does
     NOT scale it. It's built at the board's un-zoomed size and centred on the wrap (which applyZoom keeps
     exactly equal to the board's visual box), so re-scaling it about that same centre by the live zoom keeps
     outline and tiles locked together while the entrance settles the board to its fit scale (2026-07-28).
     --board-zoom is written by boardFit (fitBoard sets 1, applyZoom updates it) on .gameBoardWrap. */
  .stageShapeReveal {
    position: absolute;
    left: 50%;
    top: 50%;
    z-index: 9;
    overflow: visible;
    pointer-events: none;
    opacity: 0;
    transform: translate(-50%, -50%) scale(var(--board-zoom, 1));
    mix-blend-mode: normal;
    transition: opacity .16s ease-out;
  }
  .stageShapeReveal.forming { opacity: 1; }
  .stageShapeReveal path { pointer-events: none; }
  .stageShapeFill {
    fill: rgba(255,218,105,.17);
    stroke: none;
    opacity: 0;
    filter: drop-shadow(0 0 6px rgba(255,199,70,.34));
    transition: opacity .24s ease-out;
  }
  .stageShapeReveal.forming .stageShapeFill { opacity: 1; }
  .stageShapeGlow,
  .stageShapeCore {
    fill: none;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-dasharray: 1;
    stroke-dashoffset: 1;
    transition: stroke-dashoffset .32s cubic-bezier(.2,.75,.28,1);
  }
  .stageShapeGlow {
    stroke: rgba(255,184,47,.86);
    stroke-width: 10px;
    filter: drop-shadow(0 0 8px rgba(255,198,78,.84));
  }
  .stageShapeCore {
    stroke: rgba(255,250,222,.98);
    stroke-width: 3px;
    filter: drop-shadow(0 0 3px rgba(91,221,229,.9));
  }
  .stageShapeReveal.tracing .stageShapeGlow,
  .stageShapeReveal.tracing .stageShapeCore { stroke-dashoffset: 0; }
  .stageShapeReveal.settling {
    opacity: 0;
    transition-duration: .2s;
  }
  html[data-motion="reduced"] {
    .gameBoardWrap.stageCamera,
    .stageShapeReveal,
    .stageShapeReveal path { transition: none !important; }
  }
  #game #board.mirrorVariantFlash::after {
    content: "";
    position: absolute;
    inset: -10px;
    z-index: 6;
    pointer-events: none;
    background: linear-gradient(115deg, transparent 18%, rgba(235,215,255,.46) 48%, transparent 76%);
    mix-blend-mode: screen;
    opacity: 0;
    animation: mirrorSweep 1.5s ease-out both;
  }
  #game #board.mirrorVariantFlash .cell:not(.void) {
    animation: mirrorCellGlint .5s ease-out both;
    animation-delay: var(--mvd, 0ms);   /* set per-cell in JS along the transformed axis */
  }
  @keyframes mirrorSweep {
    0% { opacity: 0; transform: rotate(var(--mv-rot, 0deg)) translateX(-24%) skewX(-8deg); }
    16%, 70% { opacity: .8; }
    100% { opacity: 0; transform: rotate(var(--mv-rot, 0deg)) translateX(24%) skewX(-8deg); }
  }
  @keyframes mirrorCellGlint {
    0%   { filter: brightness(1); }
    50%  { filter: brightness(1.45); }
    100% { filter: brightness(1); }
  }
  /* reduced-motion + demoted (no toast, no flip): a motion-free edge glow so meaning isn't lost */
  #game #board.mirrorVariantEdge { animation: mirrorEdgePulse 1.5s ease-in-out both; }
  @keyframes mirrorEdgePulse {
    0%, 100% { box-shadow: 0 0 0 0 rgba(214,183,255,0); }
    45%      { box-shadow: 0 0 16px 4px rgba(214,183,255,.45); }
  }
  /* actually SHOW the flip/turn (not just imply it via the sweep above) — the board holds the INVERSE
     transform for a beat, then settles to identity, so it visibly turns from the old orientation into the
     new one. Dimension-preserving kinds only (flipH/flipV/rot180); rot90/rot270/diag* swap rows↔cols and
     would mis-size the box mid-spin, so they stay sweep/glint-only (game/stageEntrance.js). */
  #game #board.mvSpinFlipH { animation: mvSpinFlipH .6s cubic-bezier(.22,.9,.32,1) both; }
  #game #board.mvSpinFlipV { animation: mvSpinFlipV .6s cubic-bezier(.22,.9,.32,1) both; }
  #game #board.mvSpinRot180 { animation: mvSpinRot180 .6s cubic-bezier(.22,.9,.32,1) both; }
  /* #game #board is centered via transform: translate(-50%,-50%) (left:50%;top:50%) — every keyframe here
     MUST re-state that translate, or the keyframe's transform replaces it outright and the board loses its
     centering for the animation's duration (recurring trap in this codebase: HUD numbers hit the same bug
     with a scale-only keyframe). */
  @keyframes mvSpinFlipH { 0% { transform: translate(-50%, -50%) scaleX(-1); } 100% { transform: translate(-50%, -50%) scaleX(1); } }
  @keyframes mvSpinFlipV { 0% { transform: translate(-50%, -50%) scaleY(-1); } 100% { transform: translate(-50%, -50%) scaleY(1); } }
  @keyframes mvSpinRot180 { 0% { transform: translate(-50%, -50%) rotate(180deg); } 100% { transform: translate(-50%, -50%) rotate(0deg); } }
  html[data-motion="reduced"] {
    #game #board.mirrorVariantFlash::after,
    #game #board.mirrorVariantFlash .cell:not(.void) { animation: none; }
    #game #board.mvSpinFlipH, #game #board.mvSpinFlipV, #game #board.mvSpinRot180 { animation: none; }
  }

  /* first-levels (사각형 1·2) drag tutorial: a touch dot + ripple + finger demoing a drag across the board.
     JS overlays #dragCoach exactly on #board and removes .on at the player's first touch. */
  #dragCoach { position: absolute; z-index: 9; pointer-events: none; opacity: 0; transition: opacity .3s ease; }
  #dragCoach.on { opacity: 1; }
  /* control-button guide: a pulsing ring on a bottom tool + a tooltip above it (one-time, tap to dismiss) */
  #controlCoach { position: fixed; inset: 0; z-index: 75; pointer-events: none; opacity: 0; transition: opacity .25s ease; }
  #controlCoach.on { opacity: 1; }
  #controlCoach .ccRing {
    position: fixed; transform: translate(-50%, -50%); border-radius: 50%; box-sizing: border-box;
    border: 3px solid rgba(255,224,122,.95);
    box-shadow: 0 0 0 4px rgba(255,224,122,.22), 0 0 16px rgba(255,224,122,.5);
    animation: ccRingPulse 1.2s ease-in-out infinite;
  }
  @keyframes ccRingPulse {
    0%,100% { transform: translate(-50%,-50%) scale(1);    opacity: .95; }
    50%     { transform: translate(-50%,-50%) scale(1.16); opacity: .55; }
  }
  #controlCoach .ccBubble {
    position: fixed; transform: translateX(-50%); max-width: 240px; word-break: keep-all;
    padding: 9px 14px; border-radius: 12px; text-align: center;
    background: rgba(40,28,14,.95); color: #fff3d6;
    font-family: system-ui, sans-serif; font-size: 15px; font-weight: 700; line-height: 1.35;
    box-shadow: 0 6px 18px rgba(20,12,4,.5);
    animation: ccBubbleIn .38s linear both;
  }
  #controlCoach .ccBubble::after {
    content: ""; position: absolute; left: 50%; bottom: -7px; transform: translateX(-50%);
    border: 7px solid transparent; border-bottom: 0; border-top-color: rgba(40,28,14,.95);
  }
  @keyframes ccBubbleIn {
    0%   { opacity: 0; transform: translateX(-50%) translateY(5px); animation-timing-function: ease-in-out; }
    55%  { opacity: 1; transform: translateX(-50%) translateY(0) scaleX(1.058) scaleY(.945); animation-timing-function: ease-in-out; }
    80%  { transform: translateX(-50%) translateY(0) scaleX(.970) scaleY(1.031); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
  }
  html[data-motion="reduced"] { #controlCoach .ccRing, #controlCoach .ccBubble { animation: none; } }
  #dragCoach .dcPoint { position: absolute; left: var(--x1, 25%); top: var(--y1, 25%); transform: translate(-50%, -50%);
    width: clamp(28px, 9vw, 40px); aspect-ratio: 1; border-radius: 50%;
    background: radial-gradient(circle at 38% 34%, rgba(255,255,255,.96), rgba(255,255,255,.34) 62%, transparent 74%);
    box-shadow: 0 0 0 3px rgba(255,255,255,.5), 0 3px 10px rgba(0,0,0,.4);
    /* 2026-07-21 (오너): 첫 습관을 "천천히·부드럽게"로. 예전 cubic-bezier(.5,0,.5,1)은 구간마다 느리게 출발→
       중간 급가속→급정거라 **칸마다 섰다 튀는 stop-and-go**를 시연했다(가르치려는 것의 정반대). linear로 교체.
       2026-07-22 2차(오너 "더 부드럽게·천천히·침착하게"): 직선 구간은 linear(등속 활주)를 유지하되, 남아 있던
       거슬림 두 가지를 없앤다 — ① 정지 상태에서 **최고 속도로 즉시 출발**하고 도착도 뚝 멈추던 것 → 첫 구간은
       완만한 가속, 마지막 구간은 완만한 감속(키프레임별 timing-function). ② 전체 속도 3.8s→4.8s.
       물결(dcRipple)도 같은 주기를 공유하므로 함께 맞추고, 톡톡 튀던 세기를 낮춰 "침착하게"에 맞춘다. */
    animation: dcDrag 4.8s linear infinite; }
  #dragCoach .dcRipple { position: absolute; inset: 0; border-radius: 50%; border: 2px solid rgba(255,255,255,.72);
    animation: dcRipple 4.8s ease-out infinite; }
  #dragCoach .dcHand { position: absolute; left: 60%; top: 78%; font-size: clamp(24px, 8vw, 36px); line-height: 1;
    filter: drop-shadow(0 3px 4px rgba(0,0,0,.45)); }
  /* demo a full 2×2 square fill: press top-left, drag → top-right → bottom-right → bottom-left (all 4 cells). */
  @keyframes dcDrag {
    0%   { left: var(--x1,25%); top: var(--y1,25%); opacity: 0; }
    10%  { opacity: 1; }                                 /* 페이드도 완만하게(6%→10%) — 깜빡이지 않게 */
    15%  { left: var(--x1,25%); top: var(--y1,25%); animation-timing-function: cubic-bezier(.42,0,1,1); }  /* 정지에서 완만히 출발 */
    38%  { left: var(--x2,75%); top: var(--y2,25%); animation-timing-function: linear; }   /* drag → top-right */
    60%  { left: var(--x3,75%); top: var(--y3,75%); animation-timing-function: cubic-bezier(0,0,.58,1); }  /* 마지막 구간은 완만히 감속 */
    82%  { left: var(--x4,25%); top: var(--y4,75%); }    /* drag ← bottom-left: square filled */
    90%  { left: var(--x4,25%); top: var(--y4,75%); opacity: 1; }   /* 다 그린 상태를 잠시 보여주고 */
    /* ⚠️ 2026-08-01 (오너 신고: "배우기2 가이드가 사선으로 움직여 오해하는 사용자 발생").
       원인: 예전에는 97%에 `opacity:0`만 있고 위치가 없어서, **90%→100% 내내 x4에서 x1로 위치가
       보간**됐다. 즉 손가락이 도착점에서 출발점으로 **판을 가로질러 미끄러지는데**, 투명도는 97%에서야
       0이 되므로 그 대각선 이동의 앞부분(4.8초 주기 중 약 0.34초, 거리의 70%가량)이 **그대로 보였다**.
       이 게임은 상하좌우로만 이어 그릴 수 있는데 시범이 대각선을 그어 보이니, 그대로 따라 하다 막힌다.
       고침: 97%에 **위치를 x4로 고정**해 "제자리에서 사라진 뒤" 움직이게 한다. 97~100%는 opacity가
       0→0이라 되돌아가는 구간이 완전히 안 보인다. 시범 자체(경로·속도·리듬)는 1바이트도 안 바뀐다.
       ⚠️ 이 줄에서 left/top을 빼면 그 순간 대각선이 되살아난다 — opacity만으로는 못 막는다.
       (배우기1의 dcDrawTrace는 마지막 점에 머물다 루프 경계에서 순간이동이라 이 문제가 없다.) */
    97%  { left: var(--x4,25%); top: var(--y4,75%); opacity: 0; }
    100% { left: var(--x1,25%); top: var(--y1,25%); opacity: 0; }
  }
  /* ── 되짚기가 있는 경로(T 등 = 경유점 5개) 전용 (2026-08-01, 오너 신고 "4번 스테이지 사선") ──────
     T자에는 한붓 경로가 없어 손가락이 **왔던 길을 되짚어** 갈라 그려야 한다(game/stageEntrance.js
     regionWalk). 위 dcDrag는 --x4까지만 쓰므로 그대로 두면 되짚는 칸에서 멈추고 마지막 칸을 못 그린다.
     기존 dcDrag를 고치지 않고 별도로 두는 이유: 4점 판이 절대다수인데, 거기에 구간을 하나 더 끼우면
     오너가 07-21·07-22에 두 번 조정한 리듬(약 1.07초/칸)이 전부 틀어진다. 클래스 토글이라 4점 판은
     이 규칙에 아예 닿지 않는다.
     길이 4.8s→5.6s: 구간이 3개→4개로 늘었으므로 칸당 속도를 유지하려면 총 길이도 늘어야 한다
     (dcDrawTrace가 2026-07-29에 같은 이유로 4.2→4.8로 늘린 것과 동일한 계산). 18% × 5.6s ≈ 1.0초/칸.
     ⚠️ 특이도: 이 규칙은 (1,2,0)이라 배우기1의 `#dragCoach[data-phase="draw"] .dcPoint` (1,2,1)에
     **진다** — 의도한 것이다. 배우기1은 자기 시범(dcDrawTrace)이 이미 5점을 처리한다.
     ⚠️ 96%에 위치를 --x5로 고정하는 것은 dcDrag와 같은 이유다(사라진 뒤에 되돌아간다). 빼면 대각선 복귀가
     보인다 — 위 dcDrag 97% 주석 참조. */
  #dragCoach .dcPoint.dc5 { animation: dcDrag5 5.6s linear infinite; }
  #dragCoach .dcPoint.dc5 .dcRipple { animation-duration: 5.6s; }
  @keyframes dcDrag5 {
    0%   { left: var(--x1,25%); top: var(--y1,25%); opacity: 0; }
    8%   { opacity: 1; }
    13%  { left: var(--x1,25%); top: var(--y1,25%); animation-timing-function: cubic-bezier(.42,0,1,1); }
    31%  { left: var(--x2,75%); top: var(--y2,25%); animation-timing-function: linear; }
    49%  { left: var(--x3,75%); top: var(--y3,75%); animation-timing-function: linear; }
    67%  { left: var(--x4,25%); top: var(--y4,75%); animation-timing-function: linear; }
    85%  { left: var(--x5,25%); top: var(--y5,75%); animation-timing-function: cubic-bezier(0,0,.58,1); }
    90%  { left: var(--x5,25%); top: var(--y5,75%); opacity: 1; }
    96%  { left: var(--x5,25%); top: var(--y5,75%); opacity: 0; }
    100% { left: var(--x1,25%); top: var(--y1,25%); opacity: 0; }
  }
  /* 물결: 톡톡 치는 인상을 줄인다 — 시작 불투명도 .9→.6, 최대 1.7→1.5배, 퍼지는 시간을 넉넉히(26%→34%). */
  @keyframes dcRipple { 0%,12% { transform: scale(.75); opacity: .6; } 34% { transform: scale(1.5); opacity: 0; } 100% { transform: scale(1.5); opacity: 0; } }
  /* 배우기1 (stage 1-1) STRICT 3-step touch lesson: #dragCoach[data-phase] swaps the finger's motion per phase
     (set by game/stageEntrance.js setLearnPhase). ①press = pulse in place on the start cell · ②draw = trace the
     4-cell path staying "down" (no lift/fade) · ③release = float up + fade to mime lifting off. base transform
     translate(-50%,-50%) is PRESERVED in every keyframe (HUD-transform-trap: a scale/translate that drops it
     jumps the finger off-anchor). */
  /* 누름 맥박: 1.25s·1.18배는 재촉하듯 뛰었다 → 2s·1.10배 = 느린 호흡 ("침착하게", 오너 2026-07-22). */
  /* ⚠️ 2026-08-02 (오너 "배우기1 손가락 코치가 움직이지 않는다"). **회귀가 아니었다** — 이 규칙은
     2026-07-20 도입 이후 한 번도 바뀐 적이 없고, 사고 이전 커밋(a9e211c)을 따로 띄워 비교해도 동작이
     같았다. 진짜 문제는 설계의 빈칸이었다: ①누르기 단계에서 손가락은 시작 칸 위에서 숨만 쉬고,
     **어디로 그어야 하는지는 누른 뒤에야** 보여 줬다. 처음 오는 사람에게는 "떠 있기만 하고 아무것도
     안 한다"로 읽힌다(같은 뿌리의 신고가 4번 스테이지 사선 오해였다 — 손가락이 갈 곳을 안 보여 준 것).
     고침: 누르기 단계를 **한 번 숨쉬고 → 경로를 한 바퀴 그려 보이고 → 시작 칸으로 돌아와 반복**으로
     바꾼다(dcPressPreview). 「여기서 시작해서 저쪽으로」를 둘 다 말한다. 단계 구조·전환·문구는 그대로다
     — 누르면 종전처럼 ②그리기로 넘어간다(input/paint.js). */
  #dragCoach[data-phase="press"] .dcPoint {
    left: var(--x1,25%); top: var(--y1,25%);
    animation: dcPressPreview 3.6s linear infinite;
  }
  /* 물결은 "여기를 눌러요" 표식이라 **숨 쉬는 구간에서만** 퍼진다 — 손가락이 경로를 그리는 동안에도
     계속 튕기면 시작 칸이 목적지처럼 읽힌다. 손가락과 **같은 주기를 공유해야** 어긋나지 않는다 —
     위 dcPressPreview와 이 값은 항상 같은 숫자여야 한다(2026-08-05 함께 7.2s→3.6s). */
  #dragCoach[data-phase="press"] .dcRipple { animation: dcPressPreviewRipple 3.6s ease-out infinite; }
  /* draw 단계도 같은 이유로 linear (위 dcDrag 주석 참조). 배우기1은 이 시범을 보며 실제로 손가락을 올리는
     자리라 모델 동작이 가장 중요하다. 출발/도착 완만화는 dcDrawTrace 참조.
     길이 이력: 2.4s→3.2s→4.2s→4.8s(2026-07-29, 경유점이 4개→5개로 늘어 칸당 약 1.1초를 유지하려고)
     → **4.8s→2.4s (오너 2026-08-05 "손가락 코치 속도를 2배로")**. 칸당 약 0.55초가 된다.
     ⚠️ 이 파일의 코치 애니 넷(dcPressPreview·dcPressPreviewRipple·dcDrawTrace·dcLift)은 **같이** 절반이
     됐다. 하나만 되돌리면 단계마다 리듬이 어긋나므로, 다시 조정할 때도 넷을 함께 볼 것. */
  #dragCoach[data-phase="draw"]  .dcPoint { animation: dcDrawTrace 2.4s linear infinite; }
  /* 지우기 레슨(배우기2)은 **그리기와 똑같은 동작**을 시범한다 — 쓸어서 지운다. 전용 애니를 만들지 않는
     이유가 곧 이 레슨의 요지다: 새로 배우는 손동작이 아니라 "그린 것과 같은 길로 되돌린다"는 것.
     (문구는 tutorial.learn.erase가 "지워져요"로 결과를 말하므로 손가락은 경로만 보여 주면 된다.) */
  #dragCoach[data-phase="erase"] .dcPoint { animation: dcDrawTrace 2.4s linear infinite; }
  #dragCoach[data-phase="release"] .dcPoint { animation: dcLift .95s ease-in-out infinite; }
  #dragCoach[data-phase="draw"] .dcRipple, #dragCoach[data-phase="release"] .dcRipple,
  #dragCoach[data-phase="erase"] .dcRipple { display: none; }  /* ripple only reads as "tap here" — mute it once the finger is drawing/lifting */
  /* (옛 dcPressPulse는 아래 dcPressPreview의 0~25% 구간으로 흡수됐다 — 2s·1.10배라는 오너 튜닝은
     그대로 살아 있고, 뒤에 경로 미리보기가 붙었을 뿐이다. 붙는 곳이 없어져 규칙만 삭제.) */
  /* ①누르기 = 숨쉬기(0~25%, 약 1.8초) + 경로 미리보기(25~93%, 약 4.9초 = ②그리기와 같은 칸당 리듬).
     ⚠️ 모든 키프레임이 base translate(-50%,-50%)를 유지한다 — 하나라도 빠지면 손가락이 앵커에서 튄다
     (이 저장소의 transform-clobber 함정, 위 블록 주석 참조).
     ⚠️ 97%에 위치를 --x5로 **고정**하는 것이 핵심이다. 빼면 도착점→시작점이 보간돼 손가락이 판을
     가로지르는 **대각선**을 그어 보인다 — 2026-08-01에 실제로 오너가 신고한 그 버그다(dcDrag 주석).
     97~100%는 opacity 0→0이라 되돌아가는 구간이 아예 안 보인다. */
  @keyframes dcPressPreview {
    0%   { left: var(--x1,25%); top: var(--y1,25%); transform: translate(-50%,-50%) scale(1);    opacity: 0; }
    4%   { left: var(--x1,25%); top: var(--y1,25%); transform: translate(-50%,-50%) scale(1);    opacity: 1; animation-timing-function: ease-in-out; }
    14%  { left: var(--x1,25%); top: var(--y1,25%); transform: translate(-50%,-50%) scale(1.10); opacity: .82; animation-timing-function: ease-in-out; }
    25%  { left: var(--x1,25%); top: var(--y1,25%); transform: translate(-50%,-50%) scale(1);    opacity: 1; animation-timing-function: cubic-bezier(.42,0,1,1); }  /* 정지에서 완만히 출발 */
    44%  { left: var(--x2,75%); top: var(--y2,25%); transform: translate(-50%,-50%) scale(1);    opacity: 1; animation-timing-function: linear; }
    61%  { left: var(--x3,75%); top: var(--y3,75%); transform: translate(-50%,-50%) scale(1);    opacity: 1; animation-timing-function: linear; }
    79%  { left: var(--x4,25%); top: var(--y4,75%); transform: translate(-50%,-50%) scale(1);    opacity: 1; animation-timing-function: cubic-bezier(0,0,.58,1); }  /* 마지막 구간 완만히 감속 */
    93%  { left: var(--x5,25%); top: var(--y5,75%); transform: translate(-50%,-50%) scale(1);    opacity: 1; }
    97%  { left: var(--x5,25%); top: var(--y5,75%); transform: translate(-50%,-50%) scale(1);    opacity: 0; }
    100% { left: var(--x5,25%); top: var(--y5,75%); transform: translate(-50%,-50%) scale(1);    opacity: 0; }
  }
  /* 물결: 숨쉬는 구간에서 한 번 퍼지고, 경로를 그리는 동안은 완전히 없다. */
  @keyframes dcPressPreviewRipple {
    0%,6%    { transform: scale(.75); opacity: .6; }
    22%      { transform: scale(1.5); opacity: 0; }
    100%     { transform: scale(1.5); opacity: 0; }
  }
  /* finger stays down (opacity 1) and traces press → →TR → ↓BR → ←BL, then loops.
     직선은 등속(linear), 맨 처음 출발과 맨 마지막 도착만 완만하게 — 정지에서 즉시 최고 속도로 튀어나가고
     뚝 멈추던 것이 남은 거슬림이었다. 코너는 등속 유지(코너마다 감속하면 예전 stop-and-go로 되돌아간다). */
  /* 경유점 5개 (2026-07-29). ③단계의 T는 한붓으로 이을 수 없어 **가운데 칸을 두 번 밟는** 궤적이 필요하다
     (game/stageEntrance.js regionWalk) — 그 되짚기 구간이 4번째→5번째다. ①②처럼 4점 경로면 --x5/--y5가
     --x4/--y4와 같은 값으로 들어와 마지막 구간이 정지가 된다(경로 길이별 분기 없이 한 키프레임으로 처리). */
  /* ⚠️ 2026-08-05 (오너 신고: "손가락 코치가 시작점으로 이동하는 애니메이션이 발생해 조작법을 헷갈리게
     만든다"). 위 dcDrag가 2026-08-01에 고친 것과 **같은 결함이 여기 남아 있었다** — 그때 이 키프레임은
     "마지막 점에 머물다 루프 경계에서 순간이동이라 문제없다"고 적어 두고 넘어갔는데, 그 판단이 틀렸다:
     이 키프레임은 **opacity를 한 번도 건드리지 않아 손가락이 내내 보인다.** 그래서 주기가 끝날 때마다
     끝점 → 시작점으로 **보이는 채로 순간이동**한다. 4.8초마다 손가락이 판을 가로질러 튀니, 배우는 사람은
     그것도 조작의 일부로 읽는다("저기로 옮기라는 건가?").
     고침은 dcDrag와 동일하다: **제자리에서 사라진 뒤** 안 보이는 동안 시작점으로 돌아간다.
     ⚠️ 97%에서 left/top을 x5로 **고정**하는 것이 핵심이다 — 빼면 94~100% 내내 x5→x1로 위치가 보간돼
     대각선 이동이 되살아난다(opacity만으로는 못 막는다. dcDrag 주석의 그 사고와 같은 것). */
  @keyframes dcDrawTrace {
    0%       { left: var(--x1,25%); top: var(--y1,25%); opacity: 0; }
    6%       { left: var(--x1,25%); top: var(--y1,25%); opacity: 1; animation-timing-function: cubic-bezier(.42,0,1,1); }
    29%      { left: var(--x2,75%); top: var(--y2,25%); animation-timing-function: linear; }
    52%      { left: var(--x3,75%); top: var(--y3,75%); animation-timing-function: linear; }
    75%      { left: var(--x4,25%); top: var(--y4,75%); animation-timing-function: cubic-bezier(0,0,.58,1); }
    94%      { left: var(--x5,25%); top: var(--y5,75%); opacity: 1; }
    97%      { left: var(--x5,25%); top: var(--y5,75%); opacity: 0; }
    100%     { left: var(--x1,25%); top: var(--y1,25%); opacity: 0; }
  }
  /* ⚠️⚠️ 2026-08-05 (오너 신고, 두 번째 정정): "'이제 손을 떼요' 시점에 손가락이 최종점에서 시작점으로
     돌아가는 애니메이션 발생 — 이게 내가 고치려던 부분이야."
     원인은 **left/top을 0% 프레임에만 적어 둔 것**이었다. 어떤 속성이 일부 키프레임에만 있으면 브라우저는
     나머지를 **요소의 기본값**으로 채운다. 그런데 `.dcPoint`의 기본값이 하필 `left: var(--x1)` —
     **시작점**이다(위 1076행). 그래서 손가락이 뜨면서 **끝점 → 시작점으로 0.95초 내내 미끄러졌고**,
     opacity는 78%에서야 0이 되므로 그 이동의 대부분이 **그대로 보였다.** 게다가 `infinite`라 반복된다.
     "손을 떼라"는 신호가 정확히 **반대 동작(저 자리로 끌어라)** 을 시연하고 있었던 셈이다.
     고침: 모든 프레임에 위치를 **x5로 못박는다** — 손가락은 제자리에서 뜨기만 한다.
     ⚠️ 이 키프레임에서 left/top을 다시 빼지 말 것. 같은 부류의 사고가 이 파일에서 세 번째다
     (dcDrag 2026-08-01 · dcDrawTrace 2026-08-05 · 여기). **위치는 마지막 프레임까지 적는다.** */
  @keyframes dcLift {   /* at the LAST waypoint, float up + fade → "lift your finger" */
    0%   { left: var(--x5,25%); top: var(--y5,75%); transform: translate(-50%,-50%)  scale(1);   opacity: 1; }
    55%  { left: var(--x5,25%); top: var(--y5,75%); transform: translate(-50%,-92%)  scale(.9);  opacity: 1; }
    78%  { left: var(--x5,25%); top: var(--y5,75%); transform: translate(-50%,-150%) scale(.78); opacity: 0; }
    100% { left: var(--x5,25%); top: var(--y5,75%); transform: translate(-50%,-150%) scale(.78); opacity: 0; }
  }
  html[data-motion="reduced"] { #dragCoach .dcPoint, #dragCoach .dcRipple { animation: none !important; } }  /* keep finger VISIBLE (static) — only drop motion, matches #startCoach (§8); battery-saver hid the whole tutorial before */

  /* 스크롤 미니맵 (2026-07-22 · 기획 페이블5). 팬/핀치 하는 동안에만 구석에 떠서 "전체 중 지금 여기"를
     알려주고 손을 떼면 사라진다. 축소 복제가 아니라 **청사진** — 보석색을 그리지 않고 칠한 곳을 조각 구분
     없이 한 덩어리 잉크로만 그린다(채도는 퍼즐 영역만의 것). 미니맵 안에서 눈이 가는 유일한 요소는 금빛
     위치 창. 위치·크기는 boardFit.js가 보드 영역을 실측해 인라인으로 넣는다(fixed 좌표).
     순수 표시물이라 터치를 먹지 않는다 — 탭해서 점프하는 조작으로 승격 금지. */
  /* 2026-07-22: 카드 배경을 없앤다 — 보이드 칸을 캔버스가 안 그리면 그 자리가 진짜로 투명(뚫린 구멍)이 돼야
     "여긴 퍼즐이 아니다"가 읽힌다. 배경이 있으면 보이드에도 배경색이 비쳐 다시 밝기 한 축으로 돌아간다.
     밝은 종이(빈 칸)는 이제 캔버스가 직접 그린다. 실루엣을 띄우는 그림자만 남긴다. */
  #miniMap { position: fixed; z-index: 8; pointer-events: none; opacity: 0;
    border-radius: 6px; overflow: hidden;
    filter: drop-shadow(0 2px 6px rgba(14,26,8,.4));   /* 뚫린 실루엣을 배경에서 띄운다(사각 테두리 대신) */
    transition: opacity .2s ease; }
  #miniMap.on { opacity: 1; transition-duration: .12s; }   /* 나타남 120ms · 사라짐 200ms */
  #miniMap .mmInk { display: block; width: 100%; height: 100%; }
  #miniMap .mmWin { position: absolute; left: 0; top: 0; box-sizing: border-box;
    border: 1.5px solid var(--gold); border-radius: 3px;
    background: rgba(255,255,255,.18); }
  html[data-motion="reduced"] #miniMap { transition: none; }   /* 정보는 유지, 움직임만 제거 */

  /* 6x6+ idle "여기서 시작" pointer: a finger taps the forced start region (which also glows) so it
     clearly reads as a hint — not just a brighter tile. JS overlays #startCoach on #board + positions
     the point at the region centroid; removed on the player's first board input. */
  #startCoach { position: absolute; z-index: 9; pointer-events: none; opacity: 0; transition: opacity .3s ease; }
  #startCoach.on { opacity: 1; }
  #startCoach .scPoint { position: absolute; left: var(--cx, 30%); top: var(--cy, 30%); transform: translate(-50%, -50%);
    width: clamp(30px, 10vw, 44px); aspect-ratio: 1; border-radius: 50%;
    background: radial-gradient(circle at 38% 34%, rgba(255,238,188,.98), rgba(255,206,110,.42) 60%, transparent 74%);
    box-shadow: 0 0 0 3px rgba(255,214,120,.6), 0 3px 10px rgba(0,0,0,.42);
    animation: scTap 1.5s ease-in-out infinite; }
  #startCoach .scRipple { position: absolute; inset: 0; border-radius: 50%; border: 2px solid rgba(255,220,130,.95);
    animation: scRipple 1.5s ease-out infinite; }
  #startCoach .scHand { position: absolute; left: 62%; top: 72%; font-size: clamp(28px, 9vw, 42px); line-height: 1;
    filter: drop-shadow(0 3px 4px rgba(0,0,0,.5)); animation: scHandBob 1.5s ease-in-out infinite; }
  @keyframes scTap { 0%,100% { transform: translate(-50%,-50%) scale(1); } 50% { transform: translate(-50%,-50%) scale(.8); } }
  @keyframes scRipple { 0% { transform: scale(.55); opacity: .95; } 55% { transform: scale(1.95); opacity: 0; } 100% { transform: scale(1.95); opacity: 0; } }
  @keyframes scHandBob { 0%,100% { transform: translateY(-7px); } 50% { transform: translateY(0); } }
  html[data-motion="reduced"] { #startCoach .scPoint, #startCoach .scRipple, #startCoach .scHand { animation: none; } }

  /* The playfield frame is drawn dynamically from puzzle data. The title uses the map card header
     because it is a self-contained banner and does not add loose rails around irregular boards. */
  /* Stage title banner retired — the stage name now lives in the HUD bar's "Stage" slot */
  /* (legacy in-board stage-title CSS removed — the live stage name is #puzzleName in the HUD;
     the old .stageTitle / .stageTitle--legacy element + its map_card_header.webp are deleted.) */
  /* Board cells: remove standalone shadow/animation (frame provides the decoration) */
  #game #board {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    margin: 0;
    filter: none;
    animation: none;
    overflow: visible;
    background: transparent;
    box-shadow: none;
    z-index: 2;
  }
  /* The generic MINI keyframe animates `transform:scale()` and would temporarily erase the game's required
     translate(-50%,-50%) centering. Keep the same fade while preserving this screen's base transform. */
  #game #board.stageInMini { animation-name: gameBoardInMini; }
  @keyframes gameBoardInMini {
    0% { opacity: .3; transform: translate(-50%, -50%) scale(.985); }
    100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  }
  #game .leadLayer {
    filter:
      drop-shadow(0 2px 1px rgba(35,18,8,.34))
      drop-shadow(0 0 2px rgba(207,163,82,.16));
  }
  /* 2026-07-14 (owner: "num처럼 격리된 개별 타일" → simplified after "왜 3개나 겹쳐야 하지?"): num's OWN tile
     art doesn't shrink the cell either — its tiles sit flush, full-size, and the "isolated" look comes
     entirely from the border baked into the image. The earlier pass here used transform:scale(.88) to
     reveal a dark plate layer as a gap UNDERNEATH a border on top — two overlapping dark mechanisms
     stacking at the same edge. Border alone is enough: two flush neighbours' own borders meet and combine
     into one thick dark divider, no gap-reveal needed. (2026-07-15: the plate layer itself — .boardPlateLayer
     /.boardPlateCells, drawn by render/board.js's drawBoardPlate() — was removed entirely; border-radius on
     the cell alone now provides the corner rounding, no backing shape needed.) */
  #game .cell {
    position: relative;
    z-index: 2;
    /* ── 바닥과 블록의 라운드를 **같게** 맞춘다 (오너 2026-08-06) ─────────────────────────────
       ⚠️ 「같게」는 **같은 px이 아니라 같은 곡선**이다. 중첩된 둥근 사각형이 나란히 보이려면
          **바깥 반경 = 안쪽 반경 + 여백**이어야 한다(오프셋 곡선). 그렇지 않으면 두 곡선이 서로
          벌어지거나 파고들어 모서리에서 어긋나 보인다.
       ⚠️ 칩 규칙이 이미 그 관계를 담고 있다: `.22 − inset`. 즉 **바닥이 `.22`일 때** 두 곡선이
          정확히 평행해진다 — 칩 공식은 애초에 「바깥 .22」에서 여백을 뺀 것이었다.
       ── 여기까지 온 경위 (되돌릴 사람을 위해) ──
       `.22`(원래) → `.07`(각진 바닥 실험) → `.14`(그 절반의 철회) → **`.22`(지금, 원위치)**.
       `.07`은 "바닥을 평평하게 해 대비를 키우자"는 시도였는데 오너 판정이 **"바닥은 날카롭고 블록은
       쥬시해서 두 개가 충돌한다"**였다 — 플랫 UI 언어와 캔디 언어가 한 화면에 있는 상태였다.
       그때 배운 것은 그대로 유효하다: **대비는 모서리가 아니라 재질로 낸다**(무광 돌 ↔ 광택 유리).
       모서리로 대비를 만들려는 시도는 두 번 실패했으므로 다시 하지 말 것. */
    border-radius: calc(var(--cell-px, 60px) * .22);
    /* ⚠️ **자르지 않는다** (오너 B안 2026-08-06). 예전엔 `overflow: hidden`이었는데, 그게 곧
       **타일이 커지는 연출의 상한**이었다 — 칩 여백이 2.7px(60px 칸)뿐이라 1.09배에서 잘렸고,
       오너의 표본 영상은 **1.21배**로 타일이 이웃 칸 위로 넘어간다.
       원래 자르던 이유는 돌 결(`​.cell.tex::before`)이 칸보다 18px 크게 그려져 있어서였는데, 그 여유는
       지금은 없는 표류 애니메이션의 흔적이라 **결을 inset:0으로 줄여 이유 자체를 없앴다**(01-base-tokens).
       ⚠️ 자르기를 푸는 순간 **칸 위에 얹힌 사각 덮개들이 둥근 모서리 밖으로 새어 나간다.** 그래서
          `.cell.tex::before`·`.cell.unfilled::after`·`.cell.bad::after` 셋 다 `border-radius: inherit`를
          받았다. 칸에 새 덮개를 얹을 때도 **반드시** 같이 줄 것. */
    overflow: visible;
    background: transparent;
    border: max(1.5px, calc(var(--cell-px, 60px) * .035)) solid rgba(8,6,10,.62);   /* 2026-07-20: halved .07→.035 — thinner, lighter grout; interior(2×) vs silhouette(1×) gap now ~half so the 2× mismatch reads as negligible, and the soft gold glow (drawLead) carries the region separation */
    box-sizing: border-box;
  }
  /* 커지는 칸은 **이웃보다 위에 그린다.** 칸들은 형제라, z-index가 없으면 DOM 순서상 뒤에 오는
     오른쪽·아래 이웃이 부풀어 오른 타일을 덮어 **아래·오른쪽만 잘린 것처럼** 보인다(자르기를 푼 것이
     반만 효과를 낸다).
     ⚠️ 격자선(`.leadLayer`)이 z-index **3**이므로 여기는 **2**다 — 일부러 그 아래다. 부푼 타일이 이웃
        타일은 덮되 **영역 경계선은 못 덮게** 한다. 선은 정보고 부풀기는 장식이다. */
  #game .cell.strokeLive,
  #game .cell.releasePop,
  #game .cell.sealflash { z-index: 2; }
  #game .cell.void { border: none; }
  #game .cell.void {
    background: transparent !important;
  }
  /* 결 .42 → .54 — 바닥을 어둡게 하면서 질감까지 함께 묻혀 **매끈한 무광 = 플라스틱**이 됐다.
     돌은 **표면이 보여야** 돌이다. 이 한 장이 바닥과 블록을 함께 입히므로 유리 쪽 facet도 같이 또렷해진다
     (원래 유리 조각 무늬라 어울린다). */
  #game .cell.tex::before {
    opacity: .54;
    filter: saturate(.72) contrast(1.04) brightness(1.08);
  }
  /* ── 두 겹 재편: 「칩」= 바닥 위에 얹히는 블록 (오너 2026-08-06) ──────────────────────────────
     정본 = claudedocs/board_tile_mass_plan.md §9. 오너 판정: "바닥 타일을 블록 타일이 **교체하는
     느낌**이라 효과가 없다." 한 요소로는 원리적으로 안 된다 — 배경이 칸을 덮어 바닥이 소멸하고,
     `transform`은 요소 단위라 봉인 스쿼시가 바닥까지 눌러 **판 전체가 출렁인다.**
     그래서 칸마다 자식 하나(`.chip`)를 얹는다. 칸 = 항상 바닥 · 칩 = 블록.
     ⚠️ **이 규칙만으로는 화면이 하나도 안 바뀐다**(배경이 없으니 투명). 1단계의 합격선이 그것이다 —
        기하만 먼저 고정하고, 블록의 색·질감·광택은 다음 단계에서 이사한다.
     ⚠️ `position: absolute`가 필수다. 그냥 두면 블록 요소라 글리프(span)를 밀어낸다.
     ⚠️ 반경은 **동심 공식**(칸 반경 − inset)이라야 칸과 칩의 곡률이 같은 중심을 공유한다.
        그냥 같은 값을 주면 안쪽이 더 각져 보여 「끼워 넣은 것」이 아니라 「덧댄 것」이 된다.
     ⚠️ inset은 비율(칸의 5%)이되 **1~4px로 묶는다.** 작은 칸(18px)에서 비율만 쓰면 여백이 0.9px로
        사라지고, 큰 칸(112px)에서는 5.6px로 벌어져 같은 도형이 조각조각 떨어져 보인다. */
  /* ── 여백을 **0**으로 (오너 2026-08-06 "바닥 프레임 크기와 퍼즐 프레임 크기를 동일하게 맞춰봐") ──
     칩이 칸의 **안쪽을 꽉 채운다.** 두 프레임의 크기가 같아지고, 반경 공식(`.22 − inset`)도 자동으로
     칸과 **같은 값**이 되어 두 곡선이 완전히 겹친다.
     ⚠️ 타일 사이를 벌리는 일은 이제 **테두리(grout)가 전담**한다 — `border: max(1.5px, 칸×.035)`.
        이웃한 두 칠한 칸 사이는 여전히 테두리 2겹(60px 칸에서 4.2px)만큼 떨어져 있으므로 붙지 않는다.
     ⚠️ **되돌릴 때 볼 것:** 칠한 칸에서 바닥이 **한 픽셀도 안 보이게 된다.** 종전에는 칩 둘레에
        바닥이 얇게 보여 「홈에 끼워진 블록」으로 읽혔는데, 이제 그 단서가 사라지고 블록의 자체 입체
        (윗면 광 · 아래 벽 · 접지 그림자)만 남는다. 오너가 예전에 반려한 **"바닥을 블록이 교체하는
        느낌"**으로 돌아갈 위험이 여기 있다 — 그렇게 읽히면 이 값을 되살리는 것이 첫 번째 처방이다.
     ── 경위: `.05`(액자) → `.032`(접지 그림자가 안 보임) → `.045` → **0(지금)**. */
  #game { --chip-inset: 0px; }
  #game .cell > .chip {
    position: absolute;
    inset: var(--chip-inset);
    border-radius: calc(var(--cell-px, 60px) * .22 - var(--chip-inset));
    z-index: 2;               /* 바닥 광택(::after z1) 위 · 글리프 span·힌트 레이어 아래 */
    pointer-events: none;     /* closest(".cell")로 이미 안전하지만 이중 안전 */
  }
  /* 접지 그림자 — 물체가 면 위에 놓이면 **바로 아래**에 그림자가 생긴다. 이게 없어서 「올라온 것」이
     아니라 **붙여 놓은 스티커**로 읽혔다(오너 2026-08-06).
     ⚠️ 짧고 진하게. 길게 늘어뜨리면 「놓였다」가 아니라 **떠 있다**가 된다.
     ⚠️ **블록이 실제로 있는 칸에만 건다.** 처음엔 `.chip` 전체에 걸었더니 **빈 칸의 투명한 칩에도**
        그림자가 생겨 「도형이 올라갈 자리」가 선으로 드러났다(오너 버그 신고 2026-08-06).
        빈 칸의 칩은 자리만 잡고 **아무것도 그리지 않아야** 한다.
     ⚠️ **그래서 규칙이 아니라 토큰이다.** 상태별 셀렉터에 따로 걸면 **`box-shadow`는 통째로 덮어쓰기라**
        뒤에 오는 `.paint`/`.locked`의 자기 그림자에 조용히 지워진다 — 실제로 그렇게 하루 죽어 있었다
        (문법은 멀쩡하고 검사도 안 걸리는 종류의 사고다). 각 상태가 자기 목록 **끝에 이 변수를 붙인다.** */
  #game { --chip-contact: 0 1.4px 2.2px rgba(38, 26, 10, .42), 0 0 0 1px rgba(38, 26, 10, .10); }

  /* ── 바닥은 각지고, 조각은 둥글다 (오너 아이디어 2026-08-06) ──────────────────────────────────
     오너: "바닥 타일의 라운드를 줄이면 그 위에 둥근 도형 타일이 올라가는 전/후 비교가 명확해질 것 같다."
     맞다. 앞선 두 번(19f41e6 칠한 칸 올림 · ab743a5 빈 칸 눕힘)은 **전부 명암 축**이었는데, 셋 다
     **윤곽이 똑같아서** 아무리 벌려도 「같은 물건의 밝기 차이」로만 읽혔다. 윤곽이 갈리면 밝기가 같아도
     **다른 물건**이 된다 — 「각진 홈 = 바닥 / 둥근 것 = 올려놓은 조각」은 퍼즐 장르의 검증된 문법이다.
     덤: 색이 아니라 **모양**으로도 칠함/안 칠함이 갈려 색약 사용자 가독에 이득이다.
     조각 쪽(위 .22)은 **그대로 둔다** — 칠한 칸을 더 둥글게 하면 같은 도형 안 이웃 칸 사이 다이아꼴
     틈이 커져 **완성된 도형이 오히려 더 조각나 보인다**(원하는 것의 반대).
     ⚠️ 완전 직각(0)은 쓰지 않는다 — 싸구려·차가움으로 읽힌다. 작은 칸에서도 2.5px는 남긴다.
     ⚠️ **반경을 애니메이션하지 말 것.** 칠하는 순간 클래스 교체로 한 프레임에 스냅되고(기본 .cell에
        transition 없음), 그 프레임엔 이미 `strokePop` 스쿼시가 있어 「올라옴」은 몸이 말한다.
        border-radius 애니는 매 프레임 리페인트인데 리플레이·리빌은 최대 168칸이 동시에 바뀌고,
        모서리가 변하는 것은 「올라옴」이 아니라 **「젤리」**로 읽힌다. 정적 스냅이면 런타임 비용 순증 0.
     ⚠️ 납선(drawLead)과 어긋날 걱정은 실측상 성립하지 않는다 — 그 함수는 2026-07-14 이후 **영역 경계와
        외곽에 아무 선도 그리지 않는다**(render/board.js의 해당 주석). 칸 위에 실제로 그려지는 것(힌트·
        배우기 둘레)은 전부 직선이라 바닥이 각져지면 오히려 더 잘 맞는다.
     `cleared`(「빛 거두기」에서 사라진 칸)도 바닥이므로 함께 각진다. */
  /* (§8의 unfilled/cleared 반경 오버라이드는 2단계에서 `.cell` base로 승격돼 삭제됐다.) */

  /* ── 빈 칸의 「바닥」 그림자 — 한 곳에서만 정한다 (오너 2026-08-06) ────────────────────────────
     오너 제안: "칠하기 전 타일을 더 평평해 보이도록 하면 대비 효과가 커지지 않을까?" — 맞다.
     대비는 상대적이라 한쪽만 올리는 것보다 **양쪽을 벌리는** 것이 크다. 어제 칠한 칸을 올렸으니
     이번엔 빈 칸을 눕힌다.
     ⚠️ **밝기는 안 내린다.** 빈 칸이 밝은 데는 기능이 있다 — 아래 원 주석대로 "판·void보다 확실히
        밝아야 어느 칸을 채울지 읽는다". 어둡게 하면 대비는 커지지만 **퍼즐 모양이 안 읽힌다.**
        그래서 **밝기(바탕색)는 두고 입체 단서만** 걷어낸다: 윗변 강조 .7 → .22 · 위 안쪽 광 제거 ·
        아래 안쪽 그림자 .26 → .16. 테두리 한 줄은 남긴다(칸 경계를 읽는 유일한 선이다).
     ⚠️ **이 값이 8곳에 복제돼 있었다** — 본체 말고도 「여기서 시작」 안내 애니(2프레임)·감속 모드 2곳·
        배우기 프레스 애니(2프레임)·조짐 칸. 본체만 고치면 **안내가 켜지는 순간 옛 모습으로 되돌아간다.**
        그래서 값이 아니라 **변수 하나로 묶었다.** 평평함의 정도는 눈으로 맞출 값이라, 다음 조절은
        여기 한 줄만 고치면 8곳이 함께 따라온다.
     ⚠️ box-shadow 목록은 쉼표로 이어지므로 `var(--cell-idle-depth), inset …`처럼 뒤에 덧붙일 수 있다. */
  /* ⚠️ 두 겹이 되면서 **바닥의 목표가 바뀌었다** (2026-08-06). 한 겹 시절엔 「평평하게」가 맞았지만
     (ab743a5), 이제 바닥은 **블록이 앉는 홈**이라 **가장자리에 벽 그림자**가 있어야 한다. 벽이 없으면
     블록 둘레의 띠가 홈의 벽이 아니라 **그냥 액자 테두리**로 읽힌다.
     그래서 위쪽은 여전히 눕히되(윗변 광 .22 유지 — 바닥이 스스로 솟아 보이면 안 된다) 아래·안쪽
     그림자를 되살려 **오목함**을 만든다. */
  #game { --cell-idle-depth:
    inset 0 2px 0 rgba(255,252,244,.16),
    inset 0 0 0 1px rgba(74,66,54,.40),
    inset 0 3px 6px rgba(52,46,38,.34),
    inset 0 -5px 9px rgba(52,46,38,.24); }   /* 회갈색 — 따뜻한 갈색은 회색 바닥 위에서 주황으로 뜬다 */

  /* empty cell = quiet idle glass, not a flat CSS fill: ivory/bronze transparency, shallow
     inner depth, and only a faint facet texture so the puzzle shape remains the main signal. */
  /* ── 칸 = **항상 바닥** (두 겹 재편 2단계) ─────────────────────────────────────────────────────
     전에는 이 바닥 시각이 `.unfilled`에만 붙어, 칠하는 순간 바닥이 **소멸**하고 블록이 그 자리를
     차지했다 — 그게 오너가 말한 「교체되는 느낌」의 정체다. 이제 바닥은 어떤 상태에서도 남고,
     블록은 그 위에 얹힌 `.chip`이 맡는다.
     ⚠️ `.unfilled` 선택자는 그대로 두되 **`.cell`에도 같은 것을 준다** — startHint·learnTarget 등
        빈 칸 전용 안내가 `.unfilled`를 기준으로 걸려 있어 그 선택자를 없애면 함께 무너진다. */
  #game .cell:not(.void),
  #game .cell.unfilled {
    /* light warm cream "empty slot" — clearly brighter than the tan board/void so players can read which
       cells to fill; sits on the dark #2f2818 plate grout, so the lit grid pops as the fill area. */
    /* 대각 광택 .42 → .16, 빛의 중심을 32% → 48%로 내리고 색 폭을 좁힌다 = **볼록함 제거**.
       평균 밝기는 거의 그대로라(가운데 정지점 유지) "어느 칸을 채울지"는 계속 읽힌다. */
    background:
      /* ⚠️ 밝기를 한 단 낮춘다 (오너 2026-08-06). 빛은 위에서 오므로 **올라온 것이 더 밝아야** 하는데
         실측상 바닥 223 vs 칠한 블록 147로 **1.5배 뒤집혀** 있었다 — 그래서 눈이 블록을 「올라온 것」이
         아니라 **파인 구멍**으로 읽었다. void(≈20)보다는 여전히 훨씬 밝아 "채울 칸"은 그대로 읽힌다. */
      /* 대각 광택 **제거**(.12 → 0) — 「빛나는 건 유리뿐」. 돌이 빛을 튕기면 그 순간 플라스틱이 된다. */
      /* ⚠️ 더 「돌」답게 (오너 2026-08-06 "바닥 타일 존재감이 커서 도형의 밝기가 덜 살아나").
         두 가지를 함께 내린다 — **채도**(크림 → 따뜻한 회갈색)와 **밝기**(191 → 141).
         크림색은 그 자체로 존재감이 커서 유리의 채도를 잡아먹는다. 돌은 물러나 있어야 유리가 산다.
         void(≈20) 대비로는 여전히 7배 밝아 "어느 칸을 채울지"는 그대로 읽힌다. */
      radial-gradient(100% 96% at 50% 48%, #a1978a 0%, #948b7d 72%, #857d70 100%);
    color: #5a4720;
    box-shadow:
      var(--cell-idle-depth);
  }
  /* "여기서 시작" idle-help glow (6x6+): the old warm-gold-on-warm-beige glow blended into the empty
     tiles. Lift the hinted region OFF the warm floor two ways so its SHAPE reads at a glance: a bright
     near-white fill (a clear LUMINANCE jump vs the #ecdfbf floor) + a crisp amber outline (--s2) that
     stays strong even at the swell's trough, so the silhouette never fades. Gentle swell, not a blink. */
  @keyframes startHereGlow {
    0%, 100% { box-shadow:
      var(--cell-idle-depth),
      inset 0 0 6px 1px rgba(245,160,35,.45), inset 0 0 0 2.5px rgba(220,130,18,.92); }
    50%      { box-shadow:
      var(--cell-idle-depth),
      inset 0 0 18px 5px rgba(255,165,40,.8), inset 0 0 0 2.5px rgba(235,140,20,1); }
  }
  #game .cell.unfilled.startHint {
    background:
      linear-gradient(154deg, rgba(255,255,255,.55) 0%, rgba(255,252,238,.16) 44%, transparent 70%),
      radial-gradient(100% 96% at 50% 32%, #fffef9 0%, #fff3cc 60%, #ffe2a0 100%);
    animation: startHereGlow 2.4s ease-in-out infinite;
  }
  html[data-motion="reduced"] {
    #game .cell.unfilled.startHint { animation: none; box-shadow:
      var(--cell-idle-depth),
      inset 0 0 12px 3px rgba(255,165,40,.6), inset 0 0 0 2.5px rgba(228,135,18,1); }
  }
  /* 배우기1 (stage 1-1) 레슨 대상 영역: 이번 단계에 칠할 칸만 빛난다 (render/board.js `learnTarget`).
     startHint와 같은 시각 문법을 쓰되 **전용 규칙으로 분리**한 이유는 게이트가 다르기 때문이다 —
     startHint는 빈 보드에서만 뜨는 "시작점" 힌트고, 이건 앞 영역이 봉인된 상태에서도 떠야 하는 "지금 칠할
     범위"다. 대상 밖은 눌러도 획이 시작되지 않으므로(input/paint.js) 이 빛이 곧 조작 가능 범위의 표시다.
     startHint보다 한 톤 차분하게: 채도를 낮추고 맥박도 느리게(가이드가 계속 켜져 있는 판이라 재촉하면 안 됨). */
  #game .cell.unfilled.learnTarget {
    background:
      linear-gradient(154deg, rgba(255,255,255,.48) 0%, rgba(255,252,238,.14) 44%, transparent 70%),
      radial-gradient(100% 96% at 50% 32%, #fffef9 0%, #fff6db 62%, #ffe9bb 100%);
    animation: startHereGlow 3.2s ease-in-out infinite;
  }
  html[data-motion="reduced"] {
    #game .cell.unfilled.learnTarget { animation: none; box-shadow:
      var(--cell-idle-depth),
      inset 0 0 10px 2px rgba(255,180,70,.5), inset 0 0 0 2px rgba(228,150,40,.9); }
  }

  /* ── 레슨 경로는 **이어진 선 하나**다 (오너 2026-08-05) ────────────────────────────────────────
     칸마다 삼각형을 하나씩 놓던 옛 방식(`.learnArrow`/`.learnEnd` 가상요소)은 **떨어진 기호 넷**으로
     읽혀 "한 붓에 이 순서로 지나간다"가 전해지지 않았다(오너 판정 "요구한 디자인과 거리가 멀어").
     지금은 `render/board.js drawLearnPath`가 칸을 넘어 이어지는 SVG 선 하나로 그린다 — 선은 칸 경계를
     넘어야 하는데 가상요소는 자기 칸 안에만 그릴 수 있어서 구조적으로 불가능했다.
     ⚠️ 옛 방식이 남긴 교훈은 지우지 않는다: `.cell.unfilled::after`에는 **`mix-blend-mode: screen`**이
        걸려 있어(빈 칸 유리 광택) 거기 어두운 그림을 얹으면 **완전히 사라진다.** 칸 위에 무언가를
        그릴 일이 또 생기면 그 규칙부터 확인할 것. */
  #game .learnPathLayer {
    position: absolute; inset: 0; pointer-events: none; z-index: 5;   /* 둘레(4) 바로 위 */
    animation: learnOutlineBreath 3.2s ease-in-out infinite;          /* 둘레와 **같은 호흡** = 한 그림 */
  }
  html[data-motion="reduced"] #game .learnPathLayer {
    animation: none; opacity: 1; filter: drop-shadow(0 0 4px rgba(255,180,60,.7));
  }
  /* 배우기1 대상 도형의 둘레 (render/board.js drawLearnOutline, 2026-07-29 오너 제안: "그려야 하는 도형에
     테두리를 쳐서 사용자가 더 확실히 인지하게"). 칸이 따로따로 빛나면 "칸 네 개"로 읽히지만 둘레를 이으면
     **하나의 도형**으로 읽힌다 — 이 판이 가르치는 것이 정확히 그것(모양대로 한 번에)이다.
     보드 칸(.learnTarget)·팔레트 아이콘(.swatchLearn)과 **같은 3.2s 호흡**이라 셋이 한 몸으로 뛴다. */
  #game .learnOutlineLayer {
    position: absolute; inset: 0; pointer-events: none; z-index: 4;
    animation: learnOutlineBreath 3.2s ease-in-out infinite;
  }
  @keyframes learnOutlineBreath {
    /* 최저점에도 꺼지지 않는다 — 이 테두리가 "지금 그릴 모양"의 유일한 윤곽이라 항상 읽혀야 한다. */
    0%, 100% { opacity: .82; filter: drop-shadow(0 0 2px rgba(255,180,60,.35)); }
    50%      { opacity: 1;   filter: drop-shadow(0 0 7px rgba(255,180,60,.9)); }
  }
  html[data-motion="reduced"] #game .learnOutlineLayer {
    animation: none; opacity: 1; filter: drop-shadow(0 0 4px rgba(255,180,60,.7));
  }
  /* 배우기1 팔레트 짝 표식 (render/board.js markLearnPaletteTarget, 2026-07-29 오너 제안).
     이번 단계에 칠할 도형이 팔레트의 **어느 아이콘인지** 계속 짚어 준다. 위 .learnTarget과 **같은 3.2s
     리듬**으로 숨쉬는 것이 핵심 — 보드의 빛나는 칸과 팔레트 아이콘이 함께 밝아졌다 어두워지면 둘이 한 쌍
     ("저 칸들 = 이 도형")임이 말 없이 읽힌다. 단, 시선의 주인공은 보드 쪽이므로 세기는 한 단계 낮게 둔다
     (아이콘 바깥 글로우 + 아주 작은 확대만; 배경 워시는 팔레트 전체 신호인 paletteMissHint의 몫으로 남긴다). */
  #game .palette .shapeicon.swatchLearn {
    animation: swatchLearnBreath 3.2s ease-in-out infinite;
    transform-origin: center;
  }
  @keyframes swatchLearnBreath {
    /* 바닥에서도 완전히 꺼지지 않는다 — 어르신 가독성 피드백(2026-07-29) 이후, 사이클의 어느 순간에
       화면을 봐도 "저 아이콘이다"가 읽혀야 하므로 최저점에도 옅은 글로우를 남긴다. */
    0%, 100% { filter: drop-shadow(0 0 3px rgba(255,196,74,.45)) brightness(1.06); transform: scale(1.02); }
    50%      { filter: drop-shadow(0 0 10px rgba(255,188,60,1)) brightness(1.34);   transform: scale(1.13); }
  }
  html[data-motion="reduced"] #game .palette .shapeicon.swatchLearn {
    animation: none; filter: drop-shadow(0 0 7px rgba(255,188,60,.95)) brightness(1.2); transform: scale(1.08);
  }
  @keyframes learnPressConfirm {
    0%   { filter: brightness(1.02) saturate(1); box-shadow:
      var(--cell-idle-depth),
      inset 0 0 0 0 rgba(255,205,82,0); }
    36%  { filter: brightness(1.2) saturate(1.14); box-shadow:
      inset 0 2px 0 rgba(255,255,250,.82), inset 0 0 0 1px rgba(120,90,38,.28),
      inset 0 -5px 9px rgba(120,82,28,.18), inset 0 8px 14px rgba(255,248,222,.32),
      inset 0 0 18px 5px rgba(255,198,70,.76), 0 0 16px rgba(255,190,76,.46); }
    100% { filter: brightness(1.04) saturate(1.02); box-shadow:
      var(--cell-idle-depth),
      inset 0 0 0 2px rgba(255,184,61,.22); }
  }
  #game .cell.unfilled.learnPressCue {
    animation: learnPressConfirm .24s cubic-bezier(.2,.9,.25,1) both;
  }
  html[data-motion="reduced"] {
    #game .cell.unfilled.learnPressCue {
      animation: none;
      filter: brightness(1.1) saturate(1.06);
      box-shadow:
        inset 0 2px 0 rgba(255,255,250,.78), inset 0 0 0 1px rgba(120,90,38,.28),
        inset 0 -5px 9px rgba(120,82,28,.2), inset 0 8px 14px rgba(255,248,222,.28),
        inset 0 0 12px 3px rgba(255,198,70,.5), 0 0 11px rgba(255,190,76,.3);
    }
  }
  #game .cell.unfilled::before {
    content: "";
    opacity: .18;
    mix-blend-mode: soft-light;
    filter: saturate(.34) contrast(.92) brightness(1.18);
  }
  #game .cell.unfilled::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;   /* 칸이 더 이상 넘침을 자르지 않으므로 필수 */
    pointer-events: none;
    z-index: 1;
    background:
      linear-gradient(180deg, rgba(255,253,232,.16) 0%, rgba(255,253,232,.03) 13%, transparent 28%),
      linear-gradient(90deg, rgba(255,253,232,.09) 0%, transparent 12%);
    mix-blend-mode: screen;
  }
  #game .cell .omenGlyph {
    position: absolute; inset: 8%; z-index: 3; pointer-events: none;
    display: grid; place-items: center;
    opacity: 0; transform: scale(.92);
    color: rgba(133,84,17,.62); font-weight: 1000; font-size: calc(var(--cell-px) * .34); line-height: 1;
    text-shadow: 0 1px 0 rgba(255,255,230,.44), 0 0 8px rgba(255,225,104,.24);
    mix-blend-mode: multiply;
  }
  #game .cell.omenCell {
    box-shadow:
      var(--cell-idle-depth),
      inset 0 0 0 2px rgba(255,217,99,.26),
      0 0 9px rgba(255,216,92,.14);
  }
  #game .cell.omenCell .omenGlyph {
    opacity: .72;
    animation: omenGlyphPulse 2.8s ease-in-out infinite;
  }
  #game .cell.omenCell.paint .omenGlyph,
  #game .cell.omenCell.locked .omenGlyph { opacity: .34; mix-blend-mode: screen; }
  /* 관계 표식(「같다 · 다르다」) — 두 칸 **사이 경계** 위에 앉는 작은 배지.
     칸 안 글자(보석·오멘)와 달리 어느 한 칸에 속하지 않는다는 것이 규칙의 전부라, 경계선을 물고 앉은
     모양으로 그린다. 칠한 색과 겹쳐도 읽혀야 하므로 밝은 원판 + 진한 글자 + 얇은 테두리.
     z-index는 lead(경계선) 위 — 경계선에 가리면 "어느 경계인지"를 잃는다. */
  #game .cell ~ .relMark, #game .relMark {
    position: absolute; z-index: 6; pointer-events: none;
    width: calc(var(--rel-size) * 1px); height: calc(var(--rel-size) * 1px);
    margin-left: calc(var(--rel-size) * -0.5px); margin-top: calc(var(--rel-size) * -0.5px);
    display: grid; place-items: center;
    border-radius: 50%;
    background: radial-gradient(circle at 38% 32%, #fffdf2 0%, #f4e6c6 62%, #dcc79a 100%);
    box-shadow: 0 0 0 1.5px rgba(92,62,26,.62), 0 2px 4px rgba(52,34,10,.38);
    color: #4a3413; font-weight: 1000; line-height: 1;
    font-size: calc(var(--rel-size) * .72px);
    /* ── 배율 상쇄 (오너 보고 2026-08-07 "너무 작아서 구별하기 힘들어") ─────────────────────────
       표식이 있는 57판 중 **54판이 스크롤 크기**라 전부 축소 배율로 시작한다(큰 보드는 전체가
       보이는 배율에서 시작 — Architecture 참조). 그래서 기본 크기는 이미 20px인데 화면에서는
       390×844 실측으로 12×8 판 배율 0.52 → 배지 10.3px · **기호 7.4px**까지 줄었다.
       `=`와 `≠`는 그 크기에서 머리카락 굵기 사선 하나 차이라 눈이 나쁘면 구별이 안 된다.
       ⚠️ **기본 크기를 키우는 것으로는 못 고친다** — 배율이 곱해지므로 확대 상태만 커지고
          정작 문제인 축소 상태는 그대로 작다. 배율을 **되돌려야** 한다.
       `--board-zoom`은 applyZoom이 래퍼에 이미 심어 둔 값이고(핀치할 때마다 갱신), 상속으로
       여기까지 내려온다. k = 화면 목표(22) ÷ 실제 화면 크기, 상한 1.9 — 상한이 없으면 크게
       축소한 판에서 배지가 칸을 삼킨다(0.52 배율에서 칸이 화면상 28.6px뿐이다). */
    --rel-k: min(1.9, 22 / (var(--rel-size) * var(--board-zoom, 1)));
    transform: scale(var(--rel-k));
  }
  /* 소거 모드(「빛 거두기」): 양끝이 모두 녹은 표식은 사라진다 (오너 2026-08-07).
     ⚠️ `display:none`이 아니라 **페이드**다 — 영역이 녹는 순간 표식이 툭 사라지면 그 자리가 깜빡인 것처럼
     보인다. 칸의 해방 연출(clearFlash)과 같은 호흡으로 빠지게 해서 "같이 녹았다"로 읽히게 한다.
     ⚠️ 한쪽만 녹은 표식은 여기 안 걸린다 — 아직 살아 있는 제약이다(render/board.js 주석 참조). */
  #game .relMark.relCleared {
    /* ⚠️ **기본 transform을 들고 간다.** 이 배지는 `transform: scale(var(--rel-k))`로 크기를 잡는데
       여기서 `scale(.86)`만 쓰면 그 배율이 통째로 날아가 표식이 사라지기 직전에 **커졌다 줄어든다**.
       이 코드베이스에서 여섯 번 재발한 덮어쓰기 사고라 `npm run check:transform`이 감시한다 — 곱한다. */
    opacity: 0; transform: scale(calc(var(--rel-k) * .86));
    transition: opacity .34s ease-out, transform .34s ease-out;
    pointer-events: none;
  }
  html[data-motion="reduced"] #game .relMark.relCleared { transition: none; }

  /* ② 표식이 **누구를** 말하는지 보이게 — 배지 뒤로 두 칸을 잇는 다리를 깐다.
     이게 없으면 양옆이 빈 칸일 때 기호만 벌판에 떠 있어 "무엇의 양쪽인지"를 읽을 수 없다
     (오너 보고 2026-07-31 "규칙을 이해하지 못해서 재미가 없다"의 직접 원인). */
  #game .relMark::before {
    content: ""; position: absolute; z-index: -1;
    border-radius: 999px;
    background: linear-gradient(90deg, rgba(120,86,34,0) 0%, rgba(120,86,34,.30) 22%, rgba(120,86,34,.30) 78%, rgba(120,86,34,0) 100%);
  }
  #game .relMark.relH::before { left: 50%; top: 50%; width: var(--rel-span); height: 26%;
    /* 부모가 k배 커졌으므로 다리는 1/k로 되돌린다 — 다리는 **두 칸 사이**를 잇는 자라 같이 늘어나면
       엉뚱한 칸까지 넘어간다. */
    transform: translate(-50%, -50%) scale(calc(1 / var(--rel-k))); }
  #game .relMark.relV::before { left: 50%; top: 50%; height: var(--rel-span); width: 26%;
    transform: translate(-50%, -50%) scale(calc(1 / var(--rel-k)));
    background: linear-gradient(180deg, rgba(120,86,34,0) 0%, rgba(120,86,34,.30) 22%, rgba(120,86,34,.30) 78%, rgba(120,86,34,0) 100%); }
  /* ≠ 는 **채운 배지**다 (오너 승인 2026-08-07 — 시안 ②「채움」).
     발단 = 오너 보고 "= 와 같지 않음 표시가 너무 작아서 도형이 많은 경우 눈 나쁜 사람은 구별하기
     힘들어". 크기는 위의 배율 상쇄로 고쳤지만, 15px에서도 두 기호의 차이는 **가는 사선 하나**였고
     나머지 단서는 글자색뿐이었다 — 헌법 §8이 「색만으로 구분 금지」를 이미 못박고 있다.
     ⚠️ **밝기 차이라서 고른 것이다.** 사람 눈은 색보다 밝기에 훨씬 민감해서, 흐릿하게 보이거나
        (저시력) 색이 안 보여도(색각 이상) 채움/비움은 남는다. 배지 모양·크기·자리는 그대로라
        기존 화면 구성이 하나도 안 바뀐다. 후보였던 「마름모」는 다리·기호 정렬까지 함께
        틀어야 해서 미뤘다.
     ⚠️ **위반 상태(relBad)보다 어둡게 둔다.** 둘 다 루비 계열이 되므로 밝기로 갈라야 한다 —
        위반은 더 밝고(#e0705f) 흰 후광이 돌며 맥동한다. 이 값을 밝히면 그 구분이 사라진다. */
  #game .relMark.relDiff {
    color: #fff2ec;
    background: radial-gradient(circle at 38% 32%, #c8564a 0%, #a33528 70%, #7d271d 100%);
    box-shadow: 0 0 0 1.5px rgba(70,30,20,.7), 0 2px 4px rgba(52,34,10,.4);
  }
  #game .relMark.relSame { color: #2f6a28; }     /* = = 초록 계열(같음) */
  /* ① 어긴 표식을 짚는다 — 배지가 붉게 맥동하고, 관련된 두 영역에 붉은 테를 두른다.
     문구가 "여기"라고 말할 때 화면이 실제로 여기를 가리키게 하는 것이 이 규칙의 최소 조건이다.
     ⚠️ **이 블록은 relSame/relDiff 뒤에 있어야 한다** — 특이도가 같아 순서로만 이긴다. 앞에 두면
        ≠의 정지 상태 값(2026-08-07부터 글자색뿐 아니라 **배경·그림자까지**)이 위반 상태를 덮어
        "틀렸다"가 안 보인다. 옛 주석은 글자색 하나만 근거로 들었는데, ≠가 채운 배지가 된 지금은
        덮이는 속성이 셋이라 더 강하게 성립한다. */
  #game .relMark.relBad {
    color: #fff2ec;
    background: radial-gradient(circle at 38% 32%, #e0705f 0%, #b8402f 70%, #8a2f2a 100%);
    box-shadow: 0 0 0 2px rgba(255,180,160,.9), 0 0 12px rgba(200,70,50,.6);
    animation: relBadPulse 1s ease-in-out infinite;
  }
  #game .relMark.relBad::before { background: rgba(184,64,47,.45); }
  #game .cell.relBadCell { box-shadow: inset 0 0 0 3px rgba(184,64,47,.62), inset 0 0 12px rgba(184,64,47,.3); }
  /* ⚠️ 두 프레임 모두 **배율 상쇄(--rel-k)를 들고 간다.** `scale(1)`로 두면 위반 표식이 맥동하는
     동안 상쇄가 풀려 도로 작아진다 — 정작 "여기가 틀렸다"를 말해야 하는 순간에 가장 안 보이게 된다.
     (이 코드베이스에서 여섯 번 재발한 transform 덮어쓰기 함정. 헌법 §4.6 참조.) */
  @keyframes relBadPulse {
    0%, 100% { transform: scale(var(--rel-k, 1)); }
    50% { transform: scale(calc(var(--rel-k, 1) * 1.14)); }
  }
  html[data-motion="reduced"] #game .relMark.relBad { animation: none; }
  /* 규칙 표식(「보석 하나씩」) — 오멘 글자 레이어를 빌려 쓰되 성격이 다르다.
     오멘 = 보상 예고(눈길을 끌어야 함, 맥동). 표식 = 규칙의 일부(항상 읽혀야 하지만 조용해야 함).
     그래서 맥동 없이 상시 표시하고, 칠한 뒤에도 사라지지 않는다 — 영역마다 하나씩인지 세어야 하니까. */
  #game .cell.markCell .omenGlyph {
    opacity: .78;
    color: rgba(120,66,150,.72);          /* 자수정 계열 — 보석 문법 재사용, 신호색(초록)과 겹치지 않는다 */
    /* 오너 2026-08-08 "보석의 크기를 2배로": .30 → .60.
       ⚠️ 이 값은 **오멘 글자(.34)와 별개다** — 오멘은 보상 예고(하트·힌트·점프)라 눈길을 끌면 되지만,
          보석은 **규칙의 일부**라 판 내내 세어야 한다. 커진 것은 보석뿐이다.
       ⚠️ 글리프 상자는 `inset: 8%`라 가용 폭이 칸의 84%다 — .60은 그 안이라 어느 칸 크기에서도
          안 잘린다(44/55/59/84px 계산 확인). 더 키우려면 그 inset부터 봐야 한다. */
    font-size: calc(var(--cell-px) * .60);
    /* ⚠️ **레슨 오버레이 위로 올린다** (오너 2026-08-08 "가이드 코치가 발동하면 보석이 안 보인다").
       기본 글리프는 z-index 3인데 강제 레슨이 보드 위에 깔는 두 레이어가 그보다 위다 —
       `.learnOutlineLayer`(4, 대상 도형 둘레) · `.learnPathLayer`(5, 손가락 경로). 경로는 칸 **한가운데**를
       지나가므로 정확히 보석 자리를 덮는다. 2026-08-08에 보석 판(12번)에 강제 레슨을 처음 붙이면서
       드러난 충돌이다 — 그전까지 둘은 같은 판에서 만난 적이 없었다.
       ⚠️ 6을 고른 이유: 레슨 레이어(4·5)보다 위이면서 `.relMark`(6)와 같은 층이다. 규칙 표식과 보석은
          `puzzle.rule`이 하나뿐이라 **같은 판에 공존하지 않으므로** 그 동률은 실제로 부딪히지 않는다.
       ⚠️ 오멘 글자(보상 예고)는 올리지 않았다 — 그건 규칙이 아니라 예고라 가려도 판을 못 풀게 되지 않는다. */
    z-index: 6;
  }
  #game .cell.markCell.paint .omenGlyph,
  #game .cell.markCell.locked .omenGlyph { opacity: .5; mix-blend-mode: screen; color: rgba(240,222,255,.9); }
  /* ── 새 규칙 첫 등장 코치가 짚는 반짝임 (render/board.js pulseRuleMarks, 오너 2026-08-05) ─────────
     코치 버블이 "보석은 한 도형에 하나까지"라고 말할 때, 그 「보석」이 화면 어디인지 동시에 보여 준다.
     ⚠️ 색을 바꾸지 않고 **밝기·크기만** 흔든다 — 표식의 자수정 색은 보석 문법이고, 초록(맞았다)·
        붉은색(도형 미스)과 겹치면 신호가 섞인다.
     ⚠️ 착지 뒤 이동·회전 없음 · 3회로 끝난다(무한 반복은 헌법 §8 「번쩍임 반복 금지」에 걸린다).
     ⚠️ `.relMark`는 `translate(-50%,-50%)`로 자리를 잡는 ::before를 갖는다 — 그래서 스케일은 부모에
        걸어 그 기본 변환을 건드리지 않는다(이 저장소 6회 재발한 transform 덮어쓰기 함정). */
  #game .relMark.ruleIntroPulse,
  #game .cell.markCell.ruleIntroPulse { animation: ruleIntroPulse .62s ease-in-out 3; }
  @keyframes ruleIntroPulse {
    0%   { filter: none; }
    45%  { filter: brightness(1.45) drop-shadow(0 0 6px rgba(186,138,224,.75)); }
    100% { filter: none; }
  }
  html[data-motion="reduced"] #game .relMark.ruleIntroPulse,
  html[data-motion="reduced"] #game .cell.markCell.ruleIntroPulse {
    animation: none;
    filter: brightness(1.3);   /* 움직임은 지우되 "여기를 보라"는 결과는 남긴다 */
  }
  #game .cell.omen-heart .omenGlyph { color: rgba(150,39,33,.64); }
  #game .cell.omen-hint .omenGlyph { color: rgba(67,112,28,.64); }
  #game .cell.omen-jump .omenGlyph { color: rgba(30,98,143,.64); }
  @keyframes omenGlyphPulse {
    0%, 100% { transform: scale(.92); filter: brightness(.94); }
    50% { transform: scale(1.04); filter: brightness(1.18); }
  }
  html[data-motion="reduced"] {
    #game .cell.omenCell .omenGlyph { animation: none; transform: scale(1); }
  }
  /* in-progress paint = tactile placed glass. It is brighter and thicker than empty glass,
     but stays neutral so it cannot be mistaken for a sealed stained-glass reward. */
  /* in-progress painted region (not yet a valid/sealed shape) — a muted cool slate, deliberately
     OFF the warm palette so "I painted here, pending" reads at a glance, distinct from both the warm
     floor/void and the light empty cells, and clearly not a (saturated) sealed jewel. */
  /* ── 칠한 칸은 「올라온 타일」이어야 한다 (오너 2026-08-06) ────────────────────────────────────
     오너: "아무것도 없는 평평한 바닥에 타일이 칠해져서 올라오면 그 차이가 명확해야 해."
     ⚠️ 실측된 문제 — 빈 칸과 칠한 칸이 **같은 「파인 것」 문법**(안쪽 그림자)을 쓰는데, 하필
        **칠한 쪽의 윗변 광이 더 약했다**: 빈 칸 `.7` vs 여기 `.38`(2026-08-06 이전 값).
        즉 타일이 솟은 게 아니라 **색만 다른 구멍**으로 보였다. 그게 「심심함」의 절반이다.
     처방(정본 = claudedocs/board_tile_mass_plan.md §2): 정지 높이는 **비대칭 베벨**로 만든다 —
     윗변은 밝게(빛을 받는 윗면), 아랫변에 **단단한 챔퍼 한 줄**을 더해 「두께」의 모서리를 준다.
     ⚠️ 바깥 그림자(drop-shadow)는 쓰지 않는다 — 타일이 틈 없이 맞붙어 이웃 밑에 깔려 안 보이고,
        168칸이 각자 레이어 비용을 낸다. 효과 대비 비용이 최악이라 기각했다.
     ⚠️ 이 변화는 **모든 칠한 칸에 똑같이** 걸린다 — 맞은 획이든 틀린 획이든 동일해야 도형 트랩이
        유지된다(칠하는 연출로 정답을 알려주면 안 된다). 정적 스타일이라 그 불변식이 자동 보장된다. */
  /* ── 칠하는 중의 옅은 색 (오너 2026-08-08 "색이 스위치처럼 켜져서 칠하는 재미가 낮다") ─────────
     `--paint-tint`는 아래 `.pt1~.pt5`가 정한다 — 봉인과 **같은 색인**(id % 5)이라, 완성 순간 색이
     바뀌지 않고 **짙어지기만** 한다. 그래서 「켜졌다」가 아니라 「차올랐다」로 읽힌다.
     ⚠️ 기본값이 **투명**이라 `color-mix`를 모르는 옛 웹뷰에서는 종전 회색 그대로다(우아한 퇴화).
     ⚠️ 22%를 넘기지 말 것 — 진해질수록 봉인의 「진해짐」이 먹힌다. 신호는 농도 차가 지고 있다. */
  #game .cell.paint > .chip {
    background:
      linear-gradient(0deg, var(--paint-tint, transparent), var(--paint-tint, transparent)),
      linear-gradient(152deg, rgba(255,255,255,.10) 0%, rgba(232,243,244,.05) 44%, transparent 70%),
      radial-gradient(96% 92% at 48% 45%, #adbec0 0%, #85999b 74%, #647c7f 100%);
    color: #fff;
    box-shadow:
      /* 윗면 광 — ⚠️ 낮게 유지할 것. 2026-08-06에 .38→.52로 올렸는데 그 근거가 "빈 칸(.7)에 밀리면
         안 된다"였고, **같은 날 바닥을 돌로 바꾸며 그 .7을 .16으로 내렸다**(`--cell-idle-depth`).
         근거가 사라진 값만 남아 과하게 밝았다 → .26. 바닥(.16)보다는 여전히 위다. */
      inset 0 2px 0 rgba(255,255,255,.26),
      /* 아래 벽 = 「두께」. 오너 지시 2026-08-06 "색칠한 퍼즐의 높이가 지금보다 2배로 느껴지도록" →
         3px → 6px로 올렸다가, 오너 판정으로 **30% 되돌렸다**(2026-08-06 "타일의 높이를 30% 줄여") →
         **4.2px**. 봉인 쪽도 같은 비율로 8 → **5.6px**, 접지도 2 → **1.4px**. 세 값은 **한 묶음**이다 —
         하나만 만지면 「두께」와 「바닥에 닿음」이 어긋난다.
         ⚠️ 두께는 **위를 밝혀서** 만들지 않는다(그러면 윗변이 흰색으로 날아간다 —
         097a041에서 고친 바로 그 문제다). 아래에 보이는 **옆면**을 키워서 만든다.
         ⚠️ 짝으로 아래 부드러운 그늘을 줄였다(-6px 10px → -4px 7px). 흐릿한 그늘은 「두께」가 아니라
            그냥 어둠이라, 그걸 **테두리가 뚜렷한 벽**으로 바꾸는 것이 핵심이다. 안 줄이고 벽만 키우면
            아래 절반이 통째로 어두워져 밝기가 다시 위로 쏠린다(실측: 이 짝으로 쏠림 10%→11%에 그친다). */
      inset 0 -4.2px 0 rgba(20,32,33,.46),
      inset 0 -4px 7px rgba(26,40,41,.26),
      inset 0 0 0 1px rgba(48,68,70,.34),
      var(--chip-contact);
  }
  #game .cell.paint.pt1 > .chip { --paint-tint: color-mix(in srgb, var(--s1) 22%, transparent); }
  #game .cell.paint.pt2 > .chip { --paint-tint: color-mix(in srgb, var(--s2) 22%, transparent); }
  #game .cell.paint.pt3 > .chip { --paint-tint: color-mix(in srgb, var(--s3) 22%, transparent); }
  #game .cell.paint.pt4 > .chip { --paint-tint: color-mix(in srgb, var(--s4) 22%, transparent); }
  #game .cell.paint.pt5 > .chip { --paint-tint: color-mix(in srgb, var(--s5) 22%, transparent); }
  #game .cell.paint::before {
    opacity: .34;
    filter: saturate(.48) contrast(1.02) brightness(1.10);
  }
  /* 🪦 SHAPE TRAP "dead space" 주황 표시 — 2026-08-01 제거(오너). 되살리지 말 것.
     막힌 빈 덩어리를 주황 균열로 칠하던 규칙(.deadSpace/.deadFlash)이 여기 있었다. 판정
     (core/shapes.js deadSpaceInfo)은 의도적으로 INCOMPLETE라 **뜨는 판과 안 뜨는 판이 갈리는데**,
     플레이어는 왜 연출이 다른지 알 수 없어 갸우뚱한다 — 게다가 그 차이는 할 일을 하나도 안 바꾼다
     (두 경우 다 선택지·비용·되감는 방식이 같다). 판정 자체는 침묵 시계의 출발 신호로 남아 있다
     (render/board.js). 부활 조건: 뜨는 판과 안 뜨는 판의 차이를 플레이어가 이해할 수 있을 것. */
  /* Shape mode: jewels render as flat --sN colour (no webp gem tiles) so the stained-glass texture
     (.cell.tex::before) + specular sheen (.cell.sN::after) + .locked lighting read as backlit glass.
     NB: must repeat at #game specificity — `#game .cell{background:transparent}` (an id selector) would
     otherwise beat the base `.cell.sN{background:var(--sN)}` and blank every jewel to the plate behind. */
  /* ── 봉인된 보석도 「올라온 것」이어야 한다 (오너 신고 2026-08-06) ─────────────────────────────
     오너: "칠하는 중인 회색 상태와 도형을 완성한 상태가 서로 달라. 의도한 거야?"
     **색이 다른 건 의도**(회색 = 아직 유효한 도형 아님 / 보석 = 됐다. 이 게임에서 「맞았다」가 확정되는
     유일한 순간이라 회색은 일부러 따뜻한 팔레트 바깥에 둔다). **입체감이 다른 건 사고였다.**
     같은 날 질량감 작업에서 회색(.paint)에만 돔+아래 챔퍼를 줬고 보석은 단색 평면으로 남겨,
     **덜 된 타일이 다 된 타일보다 더 도톰해 보였다** — 바로 아래 2026-07-14 주석이 "봉인은 진행 중인
     조각보다 **더** 솟아 보여야 한다"고 못 박아 둔 그 원칙을 내가 깬 것이다.
     처방: 색은 그대로 두고 **공통 명암 한 겹(--chip-dome)**만 얹는다. 5색을 각각 다시 칠하지 않아도
     되고, 나중에 색이 늘어도 자동으로 따라온다.
     ⚠️ 돔은 흑백 rgba라 아래 `filter: saturate(1.7)`에 휘둘리지 않는다(중성색은 채도가 없다).
     ⚠️ 회색보다 **한 단 위**로 유지할 것 — 윗광 .26→.34, 챔퍼 3px→4px. 이 순서가 뒤집히면 손끝의
        「올려놓음 → 자리잡음」 단계가 다시 거꾸로 읽힌다. */
  /* ⚠️ 이 돔에는 **흰 그라데이션이 없다. 일부러 뺐다.**
     오너 신고 2026-08-06: "색칠한 도형의 위쪽만 하얗게 빛난다." 실측해 보니 **흰빛 세 겹이 전부 윗변
     같은 자리에 겹쳐** 있었다 — ① 이 돔의 흰 선형 ② 윗면 광 `inset 0 2px 0` ③ 공용 광택(`.chip::after`)의
     상단 띠 `.40`인데 **`mix-blend-mode: screen`이라 곱으로 밝힌다**. 합치면 윗변 밝기가 여섯 색 전부
     227~239 — 즉 **무슨 색이든 위쪽은 그냥 흰색**이 됐다(윗변 채도 27~40까지 꺾임).
     처방: ③은 sha·num·lala가 공유하는 유리 문법이라 건드리지 않고, **내가 얹은 ①②만 걷었다.**
     ①은 통째로 삭제(볼륨은 어차피 **우하단 림**이 만든다), ②는 낮춤.
     결과 윗변 196~220 · 채도 55~83 — 밝은 **모서리**로 읽히고 색이 살아 있다.
     ⚠️ 여기에 흰빛을 다시 더하고 싶어지면 **③이 이미 곱으로 밝히고 있다**는 걸 먼저 떠올릴 것.
     ⚠️ **돔 중심은 세로 45%다. 32%로 올리지 마라.** 오너 신고 2026-08-06 "색칠된 타일이 약간 위로
        쏠려서 배치된 것 같다" — 재보니 칸 안에서 기하학적으로는 **정확히 가운데**였고(`inset`이 사방
        같은 값), 원인은 **밝기 무게중심**이었다. 처음 넣은 32%(위 3분의 1)는 쏠림을 8%→13%로 키웠다.
        45%로 내리면 10%. 낮은 쪽 1/4의 밝기가 78→95(루비)로 올라 **아래 절반이 미리 어두워지지 않는다.**
        밑변(43)은 그대로라 「두께·접지」는 유지된다. 볼륨은 어차피 **림**이 만들지 중심 위치가 아니다. */
  #game { --chip-dome:
    radial-gradient(96% 92% at 48% 45%, rgba(255,255,255,.05) 0%, rgba(255,255,255,0) 54%, rgba(0,0,0,.26) 100%); }
  #game .cell.s1 > .chip { background: var(--chip-dome), var(--s1); }
  #game .cell.s2 > .chip { background: var(--chip-dome), var(--s2); }
  #game .cell.s3 > .chip { background: var(--chip-dome), var(--s3); }
  #game .cell.s4 > .chip { background: var(--chip-dome), var(--s4); }
  #game .cell.s5 > .chip { background: var(--chip-dome), var(--s5); }
  #game .cell.sX > .chip { background: var(--chip-dome), var(--sX); }
  /* 2026-07-14: bumped alongside the base .cell.sN dome (this stays the richest tier — sealed/correct is
     the reward moment, so it must read as MORE raised than an in-progress shard, not just brighter). */
  #game .cell.locked > .chip {
    filter: brightness(1.1) saturate(1.7) contrast(1.03);
    box-shadow:
      inset 0 2px 0 rgba(255,255,255,.34),      /* 윗면 광 — .paint(.26)보다 한 단 위 */
      inset 0 -5.6px 0 rgba(0,0,0,.28),           /* 아래 벽 = 「두께」. .paint(4.2px)보다 여전히 위 */
      inset 0 -5px 9px rgba(0,0,0,.20),         /* 짝으로 줄인 부드러운 그늘 — 위 .paint 주석 참조 */
      inset 0 0 16px rgba(255,232,158,.16),
      var(--chip-contact);
  }
  /* 2026-07-14: kept at num's own value (.92) — num layers this SAME crack-glass texture over its glossy
     tile webp at full strength (verified: num's #game .cell.locked::before is also opacity .92), so the
     two aren't mutually exclusive as first assumed. The glossy radial highlight + saturation boost above
     compose WITH the texture, not instead of it — that combination is what reads as "num-like" richness. */
  #game .cell.locked > .chip::before {
    opacity: .92;
    filter: saturate(1.08) contrast(1.08) brightness(1.08);
  }
  #game .cell.locked > .chip::after {
    background:
      linear-gradient(180deg, rgba(255,255,255,.46) 0%, rgba(255,255,255,.10) 13%, transparent 22%),
      linear-gradient(90deg, rgba(255,255,255,.24) 0%, transparent 15%),
      radial-gradient(46% 34% at 28% 24%, rgba(255,255,255,.62), transparent 68%),
      radial-gradient(100% 70% at 50% 0%, rgba(255,247,204,.24), transparent 58%);
  }
  /* idle shimmer: when the player pauses (#game.idle, set after a few idle seconds), the sealed jewels
     softly + IRREGULARLY catch the light — a calm "stained-glass shine". Each cell uses its own random
     --wd (9–17s period) / --wdl (random negative delay) so they never pulse in unison. Off during play
     (any input clears .idle) and off the game screen. Subtle by design (Quiet Canvas). */
  /* not a blink — the glass rests at its normal brightness most of the cycle, then ONE slow gentle
     swell (+~8%) and an equally slow return. Low amplitude + long rest = a soft glow, not a pulse. */
  /* idle light-wave: a gentle warm swell travels L→R, each sealed jewel catching the light AS IT IS REACHED
     (delay = column × step + small per-cell jitter, so it reads as light caught BY the glass — organic,
     not a band on top). Replaces the old linear "shine sweep" overlay (2026-06-24). Rests at a soft boost. */
  @keyframes idleGlassWave {
    0%, 14%, 100% { filter: brightness(1.06) saturate(1.42) contrast(1.03); }
    7%            { filter: brightness(1.26) saturate(1.6) contrast(1.05); }
  }
  #game.idle .cell.locked { animation: idleGlassWave 9s calc(var(--col, 0) * 0.16s + var(--wj, 0s)) ease-in-out infinite; }
  html[data-motion="reduced"] { #game.idle .cell.locked { animation: none; } }

  /* ── idle ambient life: peripheral, calm, ONLY while the player pauses; suppressed during the win/reward
     sequence so it never competes with the celebration. Reading zone (clues/unfilled) stays untouched. ── */
  /* ① drifting light motes — **별빛**이지 먼지가 아니다 (오너 2026-08-02).
     발단: "배경 동영상이 사라져서 지금 상태로는 화면에 먼지가 낀 것으로 오해받는 얌전한 상태."
     맥락 = `data-playbg="black"` 실험(풀이 중 배경 영상 대신 아주 어두운 갈회색, styles/app/05-map.css).
     그 어둠 위에서 **7px 균일 크기의 반투명 원 3개가 밝기 변화 없이 떠오르기만** 하니 렌즈에 낀 먼지로
     읽혔다. 먼지와 별을 가르는 것은 밝기가 아니라 셋이다 —
       ⑴ **흰 핵**: 별은 광원이라 중심이 하얗게 탄다. 따뜻한 색만으로는 아무리 밝혀도 얼룩이다.
       ⑵ **반짝임(scintillation)**: 먼지는 밝기가 변하지 않는다. 셋 중 가장 큰 차이.
       ⑶ **밝기 등급**: 먼지는 균일하고, 별밭은 크고 밝은 것과 작고 흐린 것이 섞인다.
     ⚠️ **화폐 코인(assets/common/star_coin.webp)의 생김새를 흉내 내지 않는다.** 배경 장식이 화폐를
     닮으면 "주울 수 있나?"라는 없는 약속이 생긴다.
     ⚠️ **2026-08-05 개명(별빛 → 코인)으로 이 규칙의 판별 기준이 바뀌었다.** 옛 화폐는 테두리 없는
     4각 금빛 스파클이라 "뾰족하지만 않으면 안 닮는다"가 성립했고, 그래서 여기를 둥근 광점으로 뒀다.
     그런데 지금 화폐는 **테두리가 또렷한 둥근 금화**(그 안에 반짝임이 새겨져 있다)라, **둥글다는
     것만으로는 더 이상 안 갈린다.** 지금 둘을 가르는 것은 모양이 아니라 **재질**이다 —
     여기는 반투명하고 핵이 거의 흰색인 **부드러운 광점**, 화폐는 불투명한 금색에 **또렷한 테두리**.
     그러므로 또렷한 테두리·불투명한 금색·십자 광채를 추가하지 말 것.
     ⚠️ 쫀득 물리(visual_audio_effects_principles.md §4.6)의 대상이 **아니다** — 빛·입자는 눌릴 몸이 없다. */
  .idleMotes { position: absolute; inset: 0; z-index: 6; pointer-events: none; opacity: 0; transition: opacity .8s ease; }
  body:not(.reward-sequence) #game.idle .idleMotes { opacity: 1; }
  /* 헤일로 층 — 여기는 **깜빡이지 않는다**(별이 그 자리에 있다는 표시). 깜빡임은 핵만 맡는다;
     헤일로까지 같이 점멸하면 별이 아니라 신호등으로 읽힌다. */
  .idleMotes i {
    position: absolute; width: var(--mote-size, 7px); height: var(--mote-size, 7px);
    border-radius: 50%; will-change: transform, opacity;
    background: radial-gradient(circle at 50% 45%,
      rgba(255,250,231,.62) 0 26%, rgba(255,236,183,.40) 52%, rgba(255,206,112,.18) 72%, transparent 84%);
  }
  /* 핵 — 흰 점 + 자기 글로우. 부모가 표류(transform·opacity 봉투)를 맡고 이 층이 반짝임만 맡으므로
     두 애니메이션이 같은 속성을 다투지 않는다(불투명도는 곱해져 봉투가 그대로 산다). */
  .idleMotes i::after {
    content: ""; position: absolute; left: 50%; top: 50%; width: 50%; height: 50%;
    border-radius: 50%; background: #fffdf4;
    box-shadow: 0 0 6px 1px rgba(255,238,182,.85);
    /* ⚠️ 중앙정렬 transform은 키프레임 **모든 프레임**이 그대로 들고 간다 (이 코드베이스에서 다섯 번
       재발한 덮어쓰기 함정 — §4.6). */
    transform: translate(-50%, -50%) scale(1);
    animation: moteTwinkle var(--mote-tw, 3.1s) ease-in-out var(--mote-twd, 0s) infinite;
  }
  /* 위치·크기·주기·위상을 전부 다르게 — 같은 값이 반복되면 그 순간 "입자 효과"로 보인다. */
  .idleMotes i:nth-child(1) { left: 16%; bottom: 10%; --mote-size: 9px; --mote-x: 14px;  --mote-tw: 2.7s; --mote-twd: -.3s;  animation: moteDrift 13s ease-in-out infinite; }
  .idleMotes i:nth-child(2) { left: 74%; bottom: 16%; --mote-size: 6px; --mote-x: -9px;  --mote-tw: 3.9s; --mote-twd: -1.6s; animation: moteDrift 17s ease-in-out -6s infinite; }
  .idleMotes i:nth-child(3) { left: 46%; bottom: 6%;  --mote-size: 8px; --mote-x: 12px;  --mote-tw: 3.2s; --mote-twd: -2.4s; animation: moteDrift 21s ease-in-out -11s infinite; }
  .idleMotes i:nth-child(4) { left: 28%; bottom: 20%; --mote-size: 5px; --mote-x: -13px; --mote-tw: 4.4s; --mote-twd: -.9s;  animation: moteDrift 19s ease-in-out -14s infinite; }
  .idleMotes i:nth-child(5) { left: 61%; bottom: 4%;  --mote-size: 10px;--mote-x: 8px;   --mote-tw: 2.9s; --mote-twd: -3.5s; animation: moteDrift 15s ease-in-out -3s infinite; }
  .idleMotes i:nth-child(6) { left: 88%; bottom: 12%; --mote-size: 6px; --mote-x: -6px;  --mote-tw: 3.6s; --mote-twd: -2s;   animation: moteDrift 23s ease-in-out -17s infinite; }
  @keyframes moteDrift {
    0%   { opacity: 0; transform: translate(0,0) scale(.6); }
    18%  { opacity: 1; }
    82%  { opacity: .72; }
    100% { opacity: 0; transform: translate(var(--mote-x, 12px), -120px) scale(1.08); }
  }
  /* 밝기가 0으로 떨어지지 않는다 — 완전히 꺼지면 별이 아니라 점멸 표시등이다. */
  @keyframes moteTwinkle {
    0%, 100% { opacity: .38; transform: translate(-50%, -50%) scale(.78); }
    50%      { opacity: 1;   transform: translate(-50%, -50%) scale(1.28); }
  }
  /* ② board light-wave — RETIRED (2026-06-24): the linear "shine sweep" band read as a cheap overlay.
     Replaced by idleGlassWave above (the sealed jewels themselves catch a L→R light wave). */
  /* ③ Sha companion — peeks from a bottom corner on a longer idle; small, calm, non-blocking */
  .stageBuddy {
    position: absolute; left: 1%; bottom: 1%; z-index: 7; width: clamp(76px, 22vw, 116px);
    pointer-events: none; opacity: 0; transform: translateY(42%) rotate(-4deg);
    transition: opacity .5s ease, transform .5s cubic-bezier(.34,1.4,.5,1);
    filter: var(--sha-tint, brightness(1)) drop-shadow(0 3px 5px rgba(20,30,12,.4));
  }
  /* 2026-07-29 오너: `.buddySpeaking`(= 말풍선이 떠 있는 동안, game/stageEntrance.js)을 **함께** 요구한다.
     예전엔 .idleDeep만으로 나타났는데 말풍선은 판당 한 번뿐이고 idleDeep은 15초마다 걸려서, 두 번째 쉼부터는
     샤가 **아무 말 없이 나와 보드만 가렸다.** 이제 할 말이 있을 때만 등장한다. */
  body:not(.reward-sequence) #game.idleDeep.buddyShaSpeaking .stageBuddy {
    opacity: 1; transform: translateY(0) rotate(-4deg); animation: buddyBob 3.2s ease-in-out infinite;
  }
  /* Emotion mirror uses a close portrait rather than the tall celebration sprite. It occupies the same
     peripheral corner; compact-height screens crop it into a side peek so the larger face stays legible
     without meaningfully covering the board. */
  .stageBuddy[data-emotion="focused"] {
    position: fixed;
    left: 1.5%;
    bottom: calc(env(safe-area-inset-bottom, 0px) + clamp(126px, 17vh, 158px));
    width: clamp(104px, 27vw, 138px);
    transform: translateY(34%) rotate(-2deg);
    transform-origin: 42% 88%;
    filter: drop-shadow(0 6px 9px rgba(18,22,12,.38));
    /* 퇴장 곡선 — 이 규칙은 **숨은 상태**라, 클래스가 떨어질 때(=나갈 때) 이 transition이 쓰인다.
       기본값 `cubic-bezier(.34,1.4,.5,1)`은 y=1.4로 지나쳤다 돌아오는 스프링이라 **들어올 때만** 옳다.
       나갈 때 그대로 쓰면 아래로 튕겼다 돌아오면서 걸리는 것처럼 읽힌다(오너 신고 2026-08-13).
       방향마다 다른 곡선을 줄 수 있는 것이 전이의 성질이다 — 전이는 「지금 상태의 규칙」을 쓴다. JS 0줄. */
    transition: opacity .34s ease, transform .34s ease-in;
  }
  body:not(.reward-sequence) #game.idleDeep.buddyShaSpeaking .stageBuddy[data-emotion="focused"] {
    transform: translateY(0) rotate(-2deg);
    animation: buddyFocus 3.6s ease-in-out infinite;
  }
  /* 집중 동행 (game/stageEntrance.js showFocusBuddy, 오너 2026-08-13) — 판이 열린 직후와 판 전체가
     보이도록 축소한 직후에만 잠깐 함께 선다. idle 넛지(.idleDeep.buddySpeaking)와 **일부러 다른 게이트**를
     쓴다: 저쪽 수명은 "할 말이 있는 동안"이고 이쪽은 "읽는 동안"이라, 한 클래스로 묶으면 한쪽을 고칠 때
     다른 쪽이 조용히 따라 움직인다. 요소·앵커·감속 처리만 공유한다.
     ⚠️ 위 #game.silenceRescueActive / #game.learnStage 억제 규칙은 `.stageBuddy` 전체를 대상으로 하므로
        이 게이트도 자동으로 덮인다(막힘 구제·손 잡는 판에서는 등장하지 않는다). 새 게이트를 만들 때
        그 두 규칙의 선택자를 좁히지 말 것 — 좁히는 순간 우선순위가 조용히 뒤집힌다. */
  body:not(.reward-sequence) #game.focusCompanion .stageBuddy[data-emotion="focused"] {
    opacity: 1;
    transform: translateY(0) rotate(-2deg);
    /* 등장 곡선 — 이 규칙은 **보이는 상태**라 들어올 때 쓰인다. 여기서만 스프링을 쓴다. */
    transition: opacity .3s ease, transform .44s cubic-bezier(.22,1.5,.4,1);
  }
  /* ⚠️ **여기에 `animation`을 얹지 말 것 (오너 신고 2026-08-13 "턱하고 걸리면서 사라짐").**
     처음엔 위 `buddyFocus` 무한 호흡을 걸었는데, 애니가 도는 동안 `transform`의 주인은 애니다.
     클래스가 떨어지는 순간 애니가 통째로 사라지면서 transform이 **애니 프레임값(translateY(-3px))에서
     선언값(translateY(34%))으로 즉시 점프**한다 — transition은 "애니가 없어진 것"으로는 발동하지
     않으므로, 투명도만 부드럽게 빠지고 위치는 툭 튄다. 그게 신고된 그 현상이다.
     지금은 애니 없이 전이만 쓴다: 들어올 때 스프링으로 떠오르고, 나갈 때 가라앉는다.
     쫀득 규칙 4(「자리를 잡은 뒤에는 이동도 회전도 없다」)와도 이쪽이 맞다 — 2초짜리 등장에
     3.6초 호흡은 한 바퀴도 못 돈다.
     ⚠️ 바로 위 `.idleDeep.buddySpeaking` 쪽 focused 규칙은 **지금 아무도 안 쓴다**(2026-08-13부터
     idle 넛지는 언제나 emotion "default"). 같은 함정을 안고 있으니 되살릴 일이 있으면 여기부터 읽을 것. */
  @media (max-width: 370px) and (max-height: 620px) {
    .stageBuddy[data-emotion="focused"] { left: -34px; }
  }
  /* Sha rescue is the higher-priority character moment. Its prompt can stay open longer than the idleDeep
     timer, so suppress the ambient buddy even if that timer expires underneath it. */
  #game.silenceRescueActive .stageBuddy,
  #game.silenceRescueActive #buddyNudge {
    opacity: 0 !important;
    animation: none !important;
  }
  /* 배우기 챕터(전역 1~3)에서는 힌트 마스코트가 **등장 자체를 하지 않는다** (오너 2026-08-04).
     JS(game/stageEntrance.js maybeShowBuddyNudge)가 이미 앞에서 return하므로 `.buddySpeaking`이 붙을 일이
     없지만, 샤가 화면에 나오는 조건은 함수 하나가 아니라 **#game의 클래스 조합**이다 — 그 조합이 다른
     경로로 생겨도 배우기에서는 안 나오도록 같은 층에서 한 번 더 막는다.
     `.learnStage`를 켜고 끄는 곳은 game/puzzleLoader.js 한 곳뿐(isLearnStage). */
  #game.learnStage .stageBuddy,
  #game.learnStage #buddyNudge {
    opacity: 0 !important;
    animation: none !important;
  }
  @keyframes buddyBob { 0%,100% { transform: translateY(0) rotate(-4deg); } 50% { transform: translateY(-5px) rotate(-2deg); } }
  @keyframes buddyFocus {
    0%, 100% { transform: translateY(0) rotate(-2deg); }
    48% { transform: translateY(-3px) rotate(-.5deg); }
    62% { transform: translateY(-2px) rotate(-1deg); }
  }
  html[data-motion="reduced"] {
    .idleMotes { display: none; }
    body:not(.reward-sequence) #game.idleDeep.buddyShaSpeaking .stageBuddy { animation: none; opacity: 1; transform: rotate(-4deg); }
    body:not(.reward-sequence) #game.idleDeep.buddyShaSpeaking .stageBuddy[data-emotion="focused"] { transform: rotate(-2deg); }
    /* 감속 모드에서도 **등장 자체는 한다** — 그림 하나가 곧 메시지라 정지 상태로도 의미가 성립한다
       (연출 헌법 §8: 이동·반복만 걷어내고 결과는 즉시 명확히). 걷어내는 것은 **이동**이다.
       ⚠️ 보이는 쪽만 고치면 안 된다 — 숨은 상태의 `translateY(34%)`를 함께 걷어야 퇴장에서
          미끄러지지 않는다. 전이는 두 상태의 **차이**를 재기 때문이다. */
    .stageBuddy[data-emotion="focused"] { transform: rotate(-2deg); transition: opacity .2s ease; }
    body:not(.reward-sequence) #game.focusCompanion .stageBuddy[data-emotion="focused"] { opacity: 1; transform: rotate(-2deg); transition: opacity .2s ease; }
  }
  /* equipped Sha costume tint also rides the win-card mascot */
  #winMascot img { filter: var(--sha-tint, brightness(1)) drop-shadow(0 6px 9px rgba(40,20,8,.42)); }

  /* ── Wardrobe (Sha costumes) ── */
  #wardrobe {
    position: fixed;
    inset: 0;
    isolation: isolate;
    background:
      linear-gradient(180deg, rgba(3,8,7,.04), rgba(3,8,7,.18) 46%, rgba(3,8,7,.62) 100%),
      url('../../assets/wardrobe/fitting_room_bg.webp') center top / cover no-repeat;
    overflow: hidden;
  }
  #wardrobe::before {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    background:
      radial-gradient(circle at 50% 42%, rgba(255,225,119,.18), transparent 30%),
      radial-gradient(circle at 50% 68%, rgba(255,220,99,.24), transparent 22%),
      linear-gradient(90deg, rgba(0,0,0,.22), transparent 24% 76%, rgba(0,0,0,.22)),
      linear-gradient(180deg, rgba(1,5,4,.12), transparent 18% 60%, rgba(1,5,4,.66));
  }
  #wardrobe .wardrobeContextBack {
    position: fixed;
    z-index: 8;
    top: calc(env(safe-area-inset-top, 0px) + clamp(78px, 12vh, 104px));
    left: calc(env(safe-area-inset-left, 0px) + 14px);
  }
  #wardrobe .wardrobeContextBack[hidden] { display: none !important; }
  /* 상점의 문맥형 뒤로가기 — 옷장과 **같은 문제·같은 해법**이되 **좌표는 베끼지 않는다.**
     옷장 밑에는 미리보기(빈 공간)가 있지만 상점 밑에는 **상품 목록이 `safe+104px`에서 바로 시작한다**
     (09-daily-badges-shop.css `#shop .mapScroll`의 근거 주석 참조 — 104는 지갑 칩 아래끝 safe+54에서
     50px 띄운 값). `.levelBackBtn`은 60px이라 옷장 좌표(top safe+78~104)를 그대로 쓰면 아래끝이
     safe+138~164가 되어 **첫 상품 줄을 덮는다.**
     그래서 ⑴ 버튼을 목록이 시작하던 자리(safe+104)에 세우고 ⑵ 버튼이 있을 때만 목록을 그 아래로
     밀어 **겹침이 구조적으로 불가능**하게 한다. 176 = 104(버튼 위) + 60(버튼) + 12(숨).
     ⚠️ 간격을 가정하는 상수를 새로 만들지 않았다 — 104는 저 파일이 이미 검증해 둔 값을 그대로 쓴 것이고,
        나머지는 버튼 실제 크기에서 나온다. 버튼 크기를 바꾸면 176도 같이 바뀐다.
     ⚠️ `[hidden]` 짝 규칙을 반드시 함께 둔다 — `.levelBackBtn`이 작성자 `background`/크기를 가진
        요소라 `hidden`만 걸면 안 숨는다(이 저장소가 `#noDrags` saleRow에서 이미 겪은 사고). */
  #shop .shopContextBack {
    position: fixed;
    z-index: 8;
    top: calc(env(safe-area-inset-top, 0px) + 104px);
    left: calc(env(safe-area-inset-left, 0px) + 14px);
  }
  #shop .shopContextBack[hidden] { display: none !important; }
  #shop.hasContextBack .mapScroll { padding-top: calc(env(safe-area-inset-top, 0px) + 176px); }
  /* 허브 옷장은 하단 내비가 공간 관계를 맡고, 게임/수여식 출처만 위의 문맥형 뒤로가기를 쓴다.
     지갑은 두 모서리(.hudSolo), 미리보기는 화면 위쪽을 그대로 사용한다. */
  #wardrobe .wardrobePreview {
    position: relative;
    z-index: 3;                    /* render Sha + its stage pool OVER the tray top → "standing on the shelf" */
    flex: 0 0 clamp(250px, 35vh, 330px);
    width: min(100%, 560px);
    min-height: 0;
    margin: -2px auto 0;
    padding: clamp(6px, 1.5vh, 12px) clamp(10px, 2.5vw, 16px) 4px;
    box-sizing: border-box;
    display: grid;
    grid-template-rows: minmax(0, 1fr) auto;
    justify-items: center;
    align-items: end;
  }
  /* ── 첫 방문 선물 개봉 (오너 2026-08-01) ────────────────────────────────────────────────────────
     포장이 무대를 **덮는다**(미리보기 이미지는 visibility로 감춘다) — 모자를 미리 보여 주면 열어 볼
     이유가 사라진다. 이름·등급·「장착」 줄도 같이 감춘다(showcase). 탭 한 번이 유일한 조작이다.
     ⚠️ 해금 팡파르(costumeUnlockReveal)를 쓰지 않는다 — 방금 수여식이 절정을 한 번 했다. 여기는
     **조용한 전달**이지 두 번째 절정이 아니다(연출 헌법 §3 중복 클라이맥스 금지). */
  #wardrobe .wardrobeGift {
    /* 화면 전체 한 겹 (오너 2026-08-02 "상자가 너무 위에 있고 다른 UI를 침범한다"). 미리보기 안에 두니
       상단 HUD와 겹치고, 아래 카드의 「탐험가 모자 · 보유 중」이 뜯기 전에 보였다. 덮개가 둘 다 해결한다:
       상자는 화면 한가운데 서고, 목록은 덮개 아래로 들어간다. 탭은 어디를 눌러도 개봉이다. */
    position: fixed; inset: 0; z-index: 30;
    display: grid; place-content: center; justify-items: center; gap: clamp(10px, 2vh, 18px);
    margin: 0; padding: 0 24px; border: 0; box-shadow: none;
    background:
      radial-gradient(58% 44% at 50% 42%, rgba(92,60,18,.55), rgba(24,14,4,0) 72%),
      rgba(22,13,4,.93);
    cursor: pointer; touch-action: manipulation;
    animation: wgVeilIn .22s ease-out both;
  }
  #wardrobe .wardrobeGift[hidden] { display: none; }
  #wardrobe .wardrobeGift:focus-visible { outline: 3px solid #fff1a0; outline-offset: -6px; border-radius: 18px; }
  #wardrobe .wgBox {
    position: relative; width: clamp(116px, 32vw, 158px); aspect-ratio: 1; border-radius: 16px;
    background: linear-gradient(158deg, #8a5a27 0%, #6b4319 58%, #52320f 100%);
    box-shadow: 0 12px 20px rgba(38,22,6,.46), inset 0 2px 0 rgba(255,238,186,.3), inset 0 -8px 14px rgba(28,16,4,.34);
    animation: wgIdle 2.6s ease-in-out infinite;
  }
  #wardrobe .wgRibbonV, #wardrobe .wgRibbonH, #wardrobe .wgKnot {
    position: absolute; background: linear-gradient(90deg, #ffd469, #fff3bd 46%, #e8ac35);
    box-shadow: 0 0 8px rgba(255,214,110,.5);
  }
  #wardrobe .wgRibbonV { left: 50%; top: -4px; bottom: -4px; width: 22px; transform: translateX(-50%); }
  #wardrobe .wgRibbonH { top: 50%; left: -4px; right: -4px; height: 22px; transform: translateY(-50%);
    background: linear-gradient(180deg, #ffd469, #fff3bd 46%, #e8ac35); }
  #wardrobe .wgKnot { left: 50%; top: 50%; width: 40px; height: 40px; border-radius: 50%;
    transform: translate(-50%, -50%); box-shadow: 0 0 12px rgba(255,222,130,.62), inset 0 -3px 6px rgba(184,126,26,.5); }
  #wardrobe .wgHint {
    max-width: min(86%, 300px);
    color: #fff2c8; font-size: clamp(12px, 3.1vw, 14px); font-weight: 800; line-height: 1.25;
    text-align: center; text-shadow: 0 2px 4px rgba(30,17,4,.7);
  }
  /* 덮개가 화면을 가리므로 미리보기를 따로 숨기지 않는다 — 개봉 순간 이미 제자리에 있는 모자가
     덮개가 걷히며 드러나는 편이, 빈 무대가 한 프레임 보였다가 채워지는 것보다 자연스럽다. */
  /* 개봉: 리본이 먼저 풀려 나가고(좌우/상하) 상자가 열리듯 커지며 사라진다. 0.42초, 절정 없음. */
  #wardrobe .wardrobeGift.wgOpening { pointer-events: none; animation: wgVeilOut .42s ease-in both; }
  #wardrobe .wardrobeGift.wgOpening .wgHint { animation: wgHintOut .2s ease-in both; }
  #wardrobe .wardrobeGift.wgOpening .wgBox { animation: wgBoxOut .42s cubic-bezier(.25,.9,.3,1) both; }
  #wardrobe .wardrobeGift.wgOpening .wgRibbonV { animation: wgRibbonVOut .3s ease-in both; }
  #wardrobe .wardrobeGift.wgOpening .wgRibbonH { animation: wgRibbonHOut .3s ease-in both; }
  #wardrobe .wardrobeGift.wgOpening .wgKnot { animation: wgKnotOut .26s ease-in both; }
  #wardrobe .wardrobePreview img.giftReveal { animation: wgReveal .68s linear both; }
  /* ── 상자 착지 (코스튬 해금 1회째, claudedocs/costume_unlock_ceremony_plan.md §5) ─────────────
     리본 상자가 화면 위에서 떨어져 쿵 앉는다. 정본 §4.6의 다섯 규칙을 그대로 따른다:
     낙하 중에는 변형 없이 가속만(규칙 2) → 착지에서 진행의 수직 방향으로 눌림 → 되튐이 점점 작아짐
     (규칙 3) → **착지 뒤에는 이동이 없다**(규칙 4, translate는 28%에서 끝난다).
     진폭은 최대 6%로 얕고 한 단계가 ≥150ms다 — "더 세게 주고 싶으면 줄이고 늦춰라"(규칙 5, 2026-08-02
     두 번 반려당한 지점). 부피 보존: 1.06×.94≈.996 · .96×1.05≈1.008 · 1.02×.99≈1.01(규칙 1).
     ⚠️ `wgIdle`(부유)과 **동시에 돌리지 않는다** — 둘 다 transform이라 한쪽이 다른 쪽을 덮는다.
     `wgLanding`이 끝나면 클래스가 떨어지고(playClass) 그때부터 기본 규칙의 `wgIdle`이 이어받는다. */
  #wardrobe .wgBox.wgLanding { animation: wgDrop 1.24s linear both; }
  @keyframes wgDrop {
    0%   { transform: translateY(-46vh) scale(1); animation-timing-function: cubic-bezier(.55,0,.9,.55); }
    28%  { transform: translateY(0) scaleX(1.06) scaleY(.94);  animation-timing-function: ease-in-out; }
    46%  { transform: translateY(0) scaleX(.96)  scaleY(1.05); animation-timing-function: ease-in-out; }
    68%  { transform: translateY(0) scaleX(1.02) scaleY(.99);  animation-timing-function: ease-in-out; }
    88%  { transform: translateY(0) scaleX(.99)  scaleY(1.005); animation-timing-function: ease-in-out; }
    100% { transform: translateY(0) scale(1); }
  }
  #wardrobe .wardrobeGift .wgHint[hidden] { display: none; }   /* 5회째부터 힌트 생략 */
  @keyframes wgVeilIn { from { opacity: 0; } to { opacity: 1; } }
  @keyframes wgVeilOut { 0% { opacity: 1; } 100% { opacity: 0; } }
  @keyframes wgIdle { 0%,100% { transform: translateY(0) rotate(-.6deg); } 50% { transform: translateY(-7px) rotate(.6deg); } }
  @keyframes wgHintOut { to { opacity: 0; transform: translateY(4px); } }
  @keyframes wgBoxOut { 0% { transform: scale(1); opacity: 1; } 38% { transform: scale(1.07); opacity: 1; } 100% { transform: scale(1.3); opacity: 0; } }
  @keyframes wgRibbonVOut { to { opacity: 0; transform: translateX(-50%) scaleY(1.35); } }
  @keyframes wgRibbonHOut { to { opacity: 0; transform: translateY(-50%) scaleX(1.35); } }
  @keyframes wgKnotOut { to { opacity: 0; transform: translate(-50%,-50%) scale(1.7); } }
  /* ⚠️ .wardrobePreview img의 정지 상태는 translateY(2px)다(위 base 규칙) — 모든 프레임이 이를 들고 간다
     (정본 §4.6 transform 덮어쓰기 함정). */
  @keyframes wgReveal {
    0%   { opacity: 0; transform: translateY(2px) scaleX(.698) scaleY(.742); animation-timing-function: ease-in-out; }
    58%  { opacity: 1; transform: translateY(2px) scaleX(1.143) scaleY(1.021); animation-timing-function: ease-in-out; }
    82%  { transform: translateY(2px) scaleX(.960) scaleY(1.021); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(2px) scale(1); }
  }
  html[data-motion="reduced"] #wardrobe .wgBox.wgLanding,
  html[data-motion="reduced"] #wardrobe .wgBox,
  html[data-motion="reduced"] #wardrobe .wardrobeGift.wgOpening .wgBox,
  html[data-motion="reduced"] #wardrobe .wardrobeGift.wgOpening .wgRibbonV,
  html[data-motion="reduced"] #wardrobe .wardrobeGift.wgOpening .wgRibbonH,
  html[data-motion="reduced"] #wardrobe .wardrobeGift.wgOpening .wgKnot,
  html[data-motion="reduced"] #wardrobe .wardrobePreview img.giftReveal { animation: none; }
  /* STAGE: a wide warm glow straddling the seam (dips onto the tray top) so the two halves read as one
     room — Sha stands on a lit shelf/stage, not on a hard dividing line. */
  #wardrobe .wardrobePreview::before {
    content: "";
    position: absolute;
    left: 50%;
    bottom: clamp(54px, 7.2vh, 68px);
    transform: translateX(-50%);
    width: min(99%, 580px);
    height: clamp(80px, 14vh, 130px);
    z-index: 0;
    pointer-events: none;
    background: radial-gradient(ellipse at 50% 42%, rgba(255,243,182,.42), rgba(150,108,40,.16) 47%, transparent 71%);
    border-radius: 50%;
  }
  /* contact pool — Sha's feet meet the shelf right at the seam */
  #wardrobe .wardrobePreview::after {
    content: "";
    position: absolute;
    left: 50%;
    bottom: clamp(57px, 7.5vh, 72px);
    width: min(60vw, 320px);
    height: clamp(34px, 6vh, 58px);
    transform: translateX(-50%);
    z-index: 0;
    background:
      radial-gradient(ellipse at 50% 28%, rgba(255,246,168,.72), rgba(160,112,34,.22) 44%, transparent 67%),
      radial-gradient(ellipse at 50% 68%, rgba(46,32,14,.52), rgba(22,15,8,.18) 60%, transparent 74%);
    border-radius: 50%;
    box-shadow: 0 9px 17px rgba(5,8,4,.38);
    opacity: .92;
  }
  /* STAGE FX stack — all behind Sha (preview img is z-index 1), layered for a lavish living spotlight. */
  #wardrobe .stageFx { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
  /* rotating god-rays — a radiant halo of light wedges spinning slowly behind Sha, with a warm↔cool shimmer */
  #wardrobe .stageRays {
    position: absolute;
    left: 50%; bottom: clamp(48px, 6.5vh, 62px);
    transform: translateX(-50%);
    width: min(132%, 660px); height: min(132%, 660px);
    pointer-events: none;
    background: repeating-conic-gradient(from 0deg,
      rgba(255,243,182,.20) 0deg 6deg, transparent 6deg 25deg);
    -webkit-mask: radial-gradient(circle at 50% 50%, #000 8%, rgba(0,0,0,.55) 34%, transparent 70%);
            mask: radial-gradient(circle at 50% 50%, #000 8%, rgba(0,0,0,.55) 34%, transparent 70%);
    mix-blend-mode: screen;
    filter: blur(2px);
    opacity: .6;
    animation: raysSpin 22s linear infinite, fxHue 9s ease-in-out infinite;
  }
  @keyframes raysSpin {
    from { transform: translateX(-50%) rotate(0deg); }
    to   { transform: translateX(-50%) rotate(360deg); }
  }
  @keyframes fxHue {
    0%,100% { filter: blur(2px) hue-rotate(0deg)   brightness(1); }
    50%     { filter: blur(2px) hue-rotate(-22deg) brightness(1.12); }
  }
  /* slow horizontal sweep glint crossing the stage */
  /* curtain-like L→R light sweep (stageSweep) removed 2026-06-23 — kept the spinning rays + footlight beam. */
  /* STAGE SPOTLIGHT: a soft warm beam thrown UP from the shelf onto Sha (footlight cone — narrow at the
     stage base, widening toward the character). Behind Sha, screen-blended so it reads as light, not haze. */
  #wardrobe .stageBeam {
    position: absolute;
    left: 50%;
    bottom: clamp(52px, 7vh, 66px);
    transform: translateX(-50%);
    width: min(82%, 430px);
    height: clamp(236px, 46vh, 430px);
    z-index: 0;
    pointer-events: none;
    clip-path: polygon(35% 100%, 65% 100%, 96% 0, 4% 0);   /* narrow at the shelf, wide toward Sha */
    background: linear-gradient(0deg,
      rgba(255,240,165,.36) 4%,
      rgba(255,233,150,.19) 38%,
      rgba(255,228,138,.07) 70%,
      transparent 92%);
    filter: blur(8px);
    mix-blend-mode: screen;
    opacity: .92;
    animation: beamBreathe 4.2s ease-in-out infinite;   /* the light is alive — slow breathe + gentle sway */
  }
  @keyframes beamBreathe {
    0%,100% { opacity: .72; transform: translateX(-50%) scaleX(.92) rotate(-1.4deg); }
    50%     { opacity: 1;   transform: translateX(-50%) scaleX(1.10) rotate(1.4deg); }
  }
  /* dust motes drifting UP through the beam (clipped to the cone) → a living spotlight */
  #wardrobe .stageBeam::before, #wardrobe .stageBeam::after {
    content: ""; position: absolute; inset: 0; pointer-events: none;
    background-repeat: no-repeat;
    background-image:
      radial-gradient(circle, rgba(255,249,206,.95) 0, transparent 62%),
      radial-gradient(circle, rgba(255,244,182,.70) 0, transparent 62%),
      radial-gradient(circle, rgba(255,250,214,.85) 0, transparent 62%);
    background-size: 7px 7px, 5px 5px, 6px 6px;
    background-position: 30% 92%, 62% 72%, 46% 52%;
    animation: moteRise 5.2s linear infinite;
  }
  #wardrobe .stageBeam::after {
    animation-delay: 2.6s;
    background-position: 66% 90%, 38% 66%, 55% 46%;
    background-size: 5px 5px, 6px 6px, 4px 4px;
  }
  @keyframes moteRise {
    0%   { transform: translateY(12%);  opacity: 0; }
    18%  { opacity: 1; }
    82%  { opacity: 1; }
    100% { transform: translateY(-44%); opacity: 0; }
  }
  html[data-motion="reduced"] {
    #wardrobe .stageBeam { animation: none; opacity: .9; transform: translateX(-50%); }
    #wardrobe .stageBeam::before, #wardrobe .stageBeam::after { animation: none; opacity: .5; }
    #wardrobe .stageRays { animation: none; }
    #wardrobe .stageFx::before { animation: none; opacity: 0; }
  }
  #wardrobe .wardrobePreview img {
    position: relative;
    z-index: 1;
    grid-row: 1;
    width: clamp(170px, 45vw, 250px);
    max-height: clamp(174px, 27vh, 250px);
    object-fit: contain;
    filter: var(--preview-tint, brightness(1)) drop-shadow(0 9px 10px rgba(18,12,6,.46));
    transition: filter .25s ease, transform .25s ease;
    transform: translateY(2px);
  }
  @keyframes equipPop {
    0%   { transform: translateY(2px) scale(1); animation-timing-function: ease-in-out; }
    35%  { transform: translateY(-6px) scaleX(1.038) scaleY(1.103); animation-timing-function: ease-in-out; }
    65%  { transform: translateY(1px) scaleX(1.026) scaleY(.917); animation-timing-function: ease-in-out; }
    100% { transform: translateY(2px) scale(1); }
  }
  #wardrobe .wardrobePreview img.equipPop {
    animation: equipPop 0.58s linear both;
  }
  @keyframes previewPick {
    0%   { opacity: .72; transform: translateY(5px) scale(.97); animation-timing-function: ease-in-out; }
    50%  { opacity: 1; transform: translateY(1px) scaleX(1.044) scaleY(.958); animation-timing-function: ease-in-out; }
    78%  { transform: translateY(2px) scaleX(.978) scaleY(1.016); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(2px) scale(1); }
  }
  #wardrobe .wardrobePreview img.previewPick {
    animation: previewPick .33s linear both;
  }
  @keyframes costumeUnlockPreview {
    0%   { opacity: .3; transform: translateY(8px) scaleX(.873) scaleY(.928); filter: brightness(1.7) drop-shadow(0 0 22px #fff2a1); animation-timing-function: ease-in-out; }
    48%  { opacity: 1; transform: translateY(-5px) scaleX(1.132) scaleY(1.011); filter: brightness(1.22) drop-shadow(0 0 20px #ffd45c); animation-timing-function: ease-in-out; }
    76%  { transform: translateY(1px) scaleX(.955) scaleY(1.016); animation-timing-function: ease-in-out; }
    100% { transform: translateY(2px) scale(1); filter: var(--preview-tint, brightness(1)) drop-shadow(0 9px 10px rgba(18,12,6,.46)); }
  }
  @keyframes costumeUnlockLight {
    0%, 100% { opacity: .72; transform: translateX(-50%) scaleX(.92); }
    45% { opacity: 1; transform: translateX(-50%) scaleX(1.16); filter: blur(5px) brightness(1.22); }
  }
  #wardrobe .wardrobePreview.costumeUnlockReveal img {
    animation: costumeUnlockPreview 1.05s linear both;
  }
  #wardrobe .wardrobePreview.costumeUnlockReveal .stageBeam {
    animation: costumeUnlockLight .76s ease-out both;
  }
  .wardrobeShowcase {
    position: relative;
    z-index: 3;
    grid-row: 2;
    width: min(100%, 470px);
    min-height: 68px;
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas:
      "title acquire"
      "perks acquire";
    column-gap: 10px;
    align-items: center;
    padding: 7px 8px 6px;
    box-sizing: border-box;
    border-top: 1px solid rgba(255,224,153,.28);
    border-bottom: 1px solid rgba(22,14,7,.48);
    background: linear-gradient(90deg, rgba(17,11,6,.78), rgba(39,25,11,.68), rgba(17,11,6,.78));
    box-shadow: 0 8px 18px rgba(5,5,3,.26), inset 0 1px 0 rgba(255,244,196,.08);
  }
  .wardrobeShowcaseTitle {
    grid-area: title;
    min-width: 0;
    display: flex;
    align-items: center;
    gap: 7px;
  }
  #wardrobePreviewName {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: #fff0c8;
    font-size: clamp(15px, 4vw, 19px);
    font-weight: 950;
    line-height: 1.15;
    text-shadow: 0 2px 0 #3b210e, 0 3px 7px rgba(0,0,0,.52);
  }
  .wardrobeTierBadge {
    flex: none;
    padding: 3px 7px 2px;
    border: 1px solid var(--tier-line, rgba(214,194,142,.58));
    border-radius: 999px;
    background: var(--tier-chip, rgba(95,80,54,.8));
    color: var(--tier-text, #fff0c8);
    font-size: 12px;
    font-weight: 900;
    line-height: 1.2;
    text-shadow: 0 1px 2px rgba(0,0,0,.5);
  }
  .wardrobePreview[data-tier="normal"] { --tier-chip: #5f625d; --tier-line: #aeb7ae; --tier-text: #f3f0df; }
  .wardrobePreview[data-tier="advanced"] { --tier-chip: #356b4b; --tier-line: #8fd091; --tier-text: #e8ffd8; }
  .wardrobePreview[data-tier="rare"] { --tier-chip: #55488a; --tier-line: #b9a9ff; --tier-text: #f2ebff; }
  .wardrobePreview[data-tier="heroic"] { --tier-chip: #95501d; --tier-line: #ffd777; --tier-text: #fff3bc; }
  .wardrobeShowcasePerks {
    grid-area: perks;
    min-height: 22px;
    display: flex;
    align-items: center;
    gap: 4px;
    overflow: hidden;
  }
  .wardrobeShowcasePerks .cPerkChip {
    display: inline-flex;
    align-items: center;
    gap: 3px;
    min-width: 0;
    padding: 2px 6px 1px;
    border-radius: 999px;
    background: rgba(238,208,136,.88);
    border: 1px solid rgba(108,72,21,.45);
    color: #50300f;
    font-size: clamp(12px, 2.4vw, 10.5px);
    font-weight: 900;
    line-height: 1.25;
    white-space: nowrap;
  }
  .wardrobeShowcasePerks .cPerkChip .ico { width: 11px; height: 11px; flex: none; }
  .wardrobeShowcaseAcquire {
    grid-area: acquire;
    width: clamp(112px, 31vw, 150px);
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 4px;
  }
  .wardrobeShowcaseProgress {
    min-height: 13px;
    overflow: hidden;
    color: #e8d7ad;
    font-size: clamp(12px, 2.2vw, 10px);
    font-weight: 800;
    line-height: 1.2;
    text-align: center;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .wardrobeShowcaseAction {
    min-height: 34px;
    padding: 0 8px 2px;
    border: 0;
    border-radius: 0;
    /* ⚠️ `box-shadow: none`은 장식이 아니라 **버그 수정**이다 (오너 2026-08-05 "선물로 받았어요 밑에
       흰줄이 어색해"). ui.css의 기본 버튼은 `inset 0 1px 0 rgba(255,255,255,.7)` 안쪽 하이라이트를
       가지는데, 그건 **CSS로 그린 입체 타일**의 윗면 빛이다. 이 버튼은 배경이 PNG(btn_green)라
       그림 위쪽에 투명 여백이 있고, 그 하이라이트가 초록 그림에 닿지 못한 채 상자 맨 위 모서리에
       그려져 **초록 버튼과 떨어진 흰 줄**로 보였다(문구 바로 밑, 버튼 폭만큼).
       같은 PNG를 쓰는 `#game .shaSilenceRescueCta`는 이미 같은 이유로 끄고 있다 — 이 버튼만 빠져 있었다.
       ⚠️ 이미지 배경 버튼에 ui.css 기본 그림자를 남기면 이 증상이 다시 난다. 깊이는 아래 `filter:
       drop-shadow`가 담당한다(그건 PNG의 **알파 모양**을 따라가므로 투명 여백에 안 그려진다). */
    box-shadow: none;
    background: url('../../assets/common/btn_green.png') center / 100% 100% no-repeat;
    color: #fffbe8;
    font-size: clamp(15px, 3.2vw, 14px);
    font-weight: 950;
    line-height: 1;
    text-shadow: 0 2px 0 #25510e, 0 3px 4px rgba(0,0,0,.28);
    filter: drop-shadow(0 3px 3px rgba(14,10,4,.34));
  }
  .wardrobeShowcaseAction.cWinGate {
    border: 1px solid rgba(255,217,124,.48);
    border-radius: 6px;
    background: linear-gradient(180deg, #a8672a, #694019);
    text-shadow: 0 2px 0 #3c210b;
    /* 이 변종만 안쪽 하이라이트를 되살린다 — 배경이 PNG가 아니라 **CSS 그라디언트**라 상자 전체가
       칠해져 있고, 윗면 빛이 그 위에 정확히 얹힌다(위 base에서 끈 이유가 여기엔 해당하지 않는다). */
    box-shadow: 0 2px 6px rgba(90,76,40,.13), inset 0 1px 0 rgba(255,255,255,.28);
  }
  .wardrobeShowcaseAction:disabled {
    filter: grayscale(.5) brightness(.7);
    opacity: .72;
  }
  /* Outer ornate tray frame RETIRED (2026-06-27): each costume card already carries its own frame
     (.costumeCard::before) → the second wrapping frame made layout (border-image insets) hard to manage and
     read as frame-in-frame. Now a flat flex-column panel: tab bar on top, scrolling card grid below. */
  #wardrobe .mapScroll {
    position: relative;
    z-index: 1;
    box-sizing: border-box;
    flex: 0 0 clamp(346px, calc(56vh - 92px), 500px);
    height: clamp(346px, calc(56vh - 92px), 500px);
    min-height: 0;
    width: 100%;
    max-width: 560px;
    margin: 2px auto 0;
    padding:
      clamp(8px, 2vw, 12px)
      clamp(8px, 2.2vw, 14px)
      calc(env(safe-area-inset-bottom,0px) + clamp(10px, 2.6vw, 16px));
    display: flex;
    flex-direction: column;
    border-radius: 20px;
    border: 1px solid rgba(120,86,44,.42);
    background:
      radial-gradient(ellipse 120% 64% at 50% 0%, rgba(92,66,36,.32), transparent 64%),
      linear-gradient(180deg, rgba(34,24,14,.60), rgba(15,10,6,.70));
    box-shadow: inset 0 1px 0 rgba(255,231,176,.12), 0 6px 18px rgba(5,8,4,.3);
    overflow: hidden;
  }
  /* name plate retired with the frame plaque it sat on — costume name now reads from the hero preview +
     each card's own label, so a separate plate is redundant. Element/JS kept inert to minimise churn. */
  #wardrobe .wardrobePlate { display: none; }
  /* ── wardrobe tabs: Daily 탭과 동일한 row_bar 디자인 언어 ── */
  #wardrobe .wardrobeTabs {
    flex: 0 0 auto;
    display: flex;
    gap: 4px;
    margin: 0 0 clamp(8px, 2vw, 12px);
    padding: 5px 6px 8px;
    background: linear-gradient(180deg, rgba(128,103,59,.18), rgba(248,237,202,.18));
  }
  #wardrobe .wardrobeTab {
    position: relative;
    flex: 1 1 0;
    min-width: 0;
    min-height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    padding: 0 6px 3px;
    border: 0;
    border-radius: 0;
    background: url('../../assets/badge/row_bar.png') center / 100% 100% no-repeat;
    color: rgba(255,246,226,.62);
    font-size: clamp(15px, 3.4vw, 14px);
    font-weight: 900;
    letter-spacing: .2px;
    text-shadow: 0 2px 0 #4a3214, 0 2px 4px rgba(0,0,0,.46);
    filter: grayscale(.3) brightness(.78);
    opacity: .76;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: transform .12s, filter .12s, opacity .12s;
  }
  #wardrobe .wardrobeTab .wtCount {
    font-size: .78em;
    font-weight: 800;
    padding: 1px 6px;
    border-radius: 999px;
    background: rgba(0,0,0,.28);
    color: rgba(255,241,168,.75);
  }
  #wardrobe .wardrobeTab.active {
    color: #fff6e2;
    text-shadow: 0 2px 0 #4a3214, -1px 0 0 #4a3214, 1px 0 0 #4a3214, 0 2px 6px rgba(0,0,0,.58);
    filter: none;
    opacity: 1;
    transform: translateY(-1px);
    z-index: 2;
  }
  #wardrobe .wardrobeTab.active::after {
    content: "";
    position: absolute;
    left: 18%; right: 18%; bottom: 6px;
    height: 3px;
    border-radius: 999px;
    background: linear-gradient(90deg, transparent, #ecd590 30%, #cda53a 50%, #ecd590 70%, transparent);
    box-shadow: 0 0 4px rgba(255,226,112,.28);
    pointer-events: none;
  }
  #wardrobe .wardrobeTab.active .wtCount { background: rgba(0,0,0,.34); color: #ffe7b0; }
  .wardrobeIntro {
    margin: 0 0 clamp(10px, 2.5vw, 14px);
    padding: clamp(9px, 2.2vw, 12px) clamp(14px, 3.5vw, 18px);
    background: var(--grad-card);
    border: 1px solid var(--line);
    border-radius: 12px;
    box-shadow: var(--lift-sm), var(--sheen);
    font-size: clamp(15px, 2.8vw, 12.5px);
    font-weight: 600;
    color: #5c3515;
    text-align: center;
    line-height: 1.5;
  }
  .wardrobeIntro::before {
    content: "✦ ";
    color: #c8921a;
    font-weight: 700;
  }
  .wardrobeGrid {
    position: relative;
    box-sizing: border-box;
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-auto-rows: min-content;   /* rows size to cards, not stretched to fill the flex track */
    gap: clamp(9px, 2.6vw, 14px);
    flex: 1 1 auto;
    min-height: 0;
    margin: 0;
    padding: 2px 2px clamp(12px, 3vw, 20px);
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
  }
  .costumeCard {
    /* Tier mood is carried by RESTRAINED accents, not a full-card colour film: the warm frame MATERIAL stays
       consistent across tiers (no hue-rotate — that twisted the one warm frame image into a cheap cyan/olive
       tarp and killed the material). Per tier we vary only (a) --tier-wash: a low-alpha radial tint behind Sha
       so the material shows through, (b) --tier-rim: a soft inner jewel glow that hugs the frame, (c) the
       selected/equipped --tier-glow. Accent STRENGTH escalates with prestige (normal → heroic). Quiet Canvas:
       saturated colour lives only in these small jewel accents. */
    --tier-frame-filter: saturate(.9) brightness(.96);
    --tier-wash: transparent;
    --tier-rim: rgba(0,0,0,0);
    --tier-glow: rgba(201,205,190,.42);
    position: relative;
    min-height: clamp(174px, 24vh, 204px);
    padding:
      clamp(20px, 4.8vw, 27px)
      clamp(14px, 3.8vw, 21px)
      clamp(23px, 5.4vw, 31px);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-start;
    gap: clamp(4px, 1.2vw, 7px);
    border: 0;
    border-radius: 0;
    background: transparent;
    color: inherit;
    font: inherit;
    text-align: center;
    appearance: none;
    isolation: isolate;
    overflow: visible;
    filter: drop-shadow(0 5px 5px rgba(12,14,8,.32));
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: transform .16s ease, filter .16s ease;
  }
  /* pewter → emerald → amethyst → gold. Matches the .wardrobePreview[data-tier] tokens (rare = violet, NOT
     the old hue-rotate cyan). wash + rim alpha climb with tier so prestige reads without over-tinting. */
  .costumeCard.tier-normal   { --tier-frame-filter: saturate(.86) brightness(.95);  --tier-wash: rgba(196,204,190,.07); --tier-rim: rgba(200,208,196,.24); --tier-glow: rgba(204,211,199,.46); }
  .costumeCard.tier-advanced { --tier-frame-filter: saturate(.98) brightness(.98);  --tier-wash: rgba(96,178,116,.11);  --tier-rim: rgba(126,196,138,.34); --tier-glow: rgba(99,205,132,.5); }
  .costumeCard.tier-rare     { --tier-frame-filter: saturate(1.02) brightness(1.0); --tier-wash: rgba(150,124,236,.13); --tier-rim: rgba(182,162,255,.42); --tier-glow: rgba(160,140,255,.58); }
  .costumeCard.tier-heroic   { --tier-frame-filter: saturate(1.08) brightness(1.04);--tier-wash: rgba(255,182,74,.14);  --tier-rim: rgba(255,206,112,.5);  --tier-glow: rgba(255,190,67,.66); }
  .costumeCard::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    /* warm frame material + a soft tier wash concentrated behind Sha (fades out before the border, so the
       frame edge stays warm) — the wash is the ONLY per-tier tint and it's low-alpha so the material shows. */
    background:
      radial-gradient(86% 56% at 50% 33%, var(--tier-wash), transparent 62%),
      url('../../assets/wardrobe/card_frame.webp') center / 100% 100% no-repeat;
    /* --tier-rim = a soft inner jewel halo hugging the frame (blurred inset, so it follows the baked rounded
       corners instead of drawing a hard square border). This is the tier "trim", never a crisp coloured line. */
    box-shadow: inset 0 0 clamp(11px, 3.2vw, 17px) var(--tier-rim);
    filter: var(--tier-frame-filter);
    transition: filter .16s ease, box-shadow .16s ease;
  }
  .costumeCard.equipped::before {
    filter: var(--tier-frame-filter) drop-shadow(0 0 10px rgba(255,226,92,.72));
  }
  .costumeCard.selected {
    transform: translateY(-2px) scale(1.015);
    filter: drop-shadow(0 5px 5px rgba(12,14,8,.32)) drop-shadow(0 0 8px var(--tier-glow));
  }
  .costumeCard.selected::before {
    filter: var(--tier-frame-filter) brightness(1.1) drop-shadow(0 0 7px var(--tier-glow));
  }
  @keyframes costumeUnlockCard {
    0% { transform: translateY(-2px) scale(.96); filter: brightness(1.55) drop-shadow(0 0 16px #fff0a5); }
    55% { transform: translateY(-4px) scale(1.035); filter: brightness(1.12) drop-shadow(0 0 14px var(--tier-glow)); }
    100% { transform: translateY(-2px) scale(1.015); filter: drop-shadow(0 5px 5px rgba(12,14,8,.32)) drop-shadow(0 0 8px var(--tier-glow)); }
  }
  .costumeCard.newlyUnlocked {
    animation: costumeUnlockCard .72s cubic-bezier(.2,.72,.24,1) both;
  }
  .costumeCard:focus-visible {
    outline: 3px solid #fff0a5;
    outline-offset: -6px;
  }
  .costumeCard.equipped::after {
    content: "";
    position: absolute;
    inset:
      clamp(15px, 4.2vw, 22px)
      clamp(15px, 4.2vw, 22px)
      clamp(26px, 5.8vw, 34px);
    z-index: -1;
    border-radius: 13px;
    background:
      linear-gradient(180deg, rgba(255,232,90,.24), rgba(123,211,72,.13)),
      radial-gradient(circle at 50% 24%, rgba(255,247,151,.45), transparent 56%);
    pointer-events: none;
  }
  /* Locked costumes remain identifiable: the veil signals ownership without hiding the material and
     silhouette that create desire. The lock stays in the frame corner instead of covering Sha's face. */
  .costumeCard.locked:not(.selected) { filter: brightness(.96) drop-shadow(0 4px 5px rgba(12,14,8,.26)); }
  .costumeCard.locked .cSwatch {
    filter: var(--costume-art-filter, brightness(1)) grayscale(.34) saturate(.66) brightness(.76) drop-shadow(0 5px 6px rgba(10,8,3,.46));
    opacity: .9;
  }
  .costumeCard.locked::after {
    content: "";
    position: absolute;
    top: clamp(19px, 4.5vw, 25px);
    right: clamp(16px, 4vw, 22px);
    width: clamp(24px, 6.4vw, 31px);
    height: clamp(24px, 6.4vw, 31px);
    background: url('../../assets/badge/padlock.png') center / contain no-repeat;
    filter: drop-shadow(0 2px 3px rgba(0,0,0,.5)) brightness(1.12);
    opacity: .96;
    z-index: 2;
    pointer-events: none;
  }
  .costumeCard .cSwatch {
    width: clamp(96px, 26vw, 132px);
    height: clamp(104px, 28vw, 142px);
    object-fit: contain;
    object-position: center center;
    filter: var(--costume-art-filter, brightness(1)) drop-shadow(0 5px 6px rgba(14,10,4,.42));
    transform: none;
    transition: filter .16s ease, opacity .16s ease, transform .16s ease;
  }
  .costumeCard .cName {
    min-height: 18px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: clamp(15px, 3.45vw, 14px);
    font-weight: 950;
    color: #6b3912;
    text-align: center;
    line-height: 1.1;
    text-shadow: 0 1px 0 rgba(255,246,205,.72);
  }
  .costumeCard .cCardStatus {
    max-width: 100%;
    min-height: 15px;
    overflow: hidden;
    color: #775126;
    font-size: clamp(12px, 2.45vw, 10.5px);
    font-weight: 850;
    line-height: 1.2;
    text-overflow: ellipsis;
    text-shadow: 0 1px 0 rgba(255,246,205,.62);
    white-space: nowrap;
  }
  .costumeCard.equipped .cCardStatus {
    color: #356118;
  }
  .costumeCard.locked .cCardStatus {
    color: #684d2f;
  }
  html[data-motion="reduced"] {
    #wardrobe .wardrobePreview.costumeUnlockReveal img,
    #wardrobe .wardrobePreview.costumeUnlockReveal .stageBeam,
    .costumeCard.newlyUnlocked {
      animation: none;
    }
  }
  @media (max-height: 700px) {
    #wardrobe .wardrobePreview { flex-basis: clamp(222px, 33vh, 248px); padding-top: 4px; }
    #wardrobe .wardrobePreview img { width: clamp(142px, 39vw, 200px); max-height: clamp(140px, 21vh, 168px); }
    #wardrobe .mapScroll {
      flex-basis: clamp(306px, 47vh, 350px);
      height: clamp(306px, 47vh, 350px);
      margin-top: 2px;
    }
    .costumeCard { min-height: clamp(158px, 25vh, 182px); }
    .costumeCard .cSwatch { width: clamp(78px, 21vw, 100px); height: clamp(84px, 23vw, 108px); }
  }
  @media (min-width: 640px) {
    .wardrobeGrid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    /* iPad+: mascot image capped to fit inside the 228px inner content area → no HUD overlap */
    #wardrobe .wardrobePreview img {
      width: clamp(140px, 22vw, 210px);
      max-height: clamp(160px, 18vh, 216px);
    }
    /* iPad+: grid tray fills remaining space instead of fixed height → no dead space above nav.
       Reserve the fixed bottom nav's height (≈121px stone slab + safe-area) as bottom margin so the
       tray ends AT the nav top, not the viewport bottom — otherwise the last grid rows scroll behind
       the fixed nav (z-index 6) and their 구매 buttons become untappable (iPad, 3+ rows of items). */
    #wardrobe .mapScroll {
      flex: 1 1 auto;
      height: auto;
      max-height: none;
      margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 124px);
    }
  }
  /* ④ Sha's one-time hint nudge (2026-07-14, replaces the old "#solveBtn breathes" 30s cue — see
     game/stageEntrance.js maybeShowBuddyNudge): a small speech bubble above her that fades on its own after
     a few seconds. Positioned relative to her existing peek spot (.stageBuddy: left 1%, ~clamp(76-116px)
     tall) so it sits just above her regardless of viewport. */
  #buddyNudge {
    /* clears her head, not just half her sprite: .stageBuddy's width clamp(76,22vw,116px) renders at
       ~1.685x that height (673x1134 source) → this must track that, not a smaller guess, or the bubble
       lands mid-face (2026-07-15 fix). */
    position: absolute; left: 4%; bottom: calc(1% + clamp(142px, 38vw, 210px)); z-index: 8;
    max-width: 190px; opacity: 0; pointer-events: none; transition: opacity .3s ease;
  }
  #buddyNudge.on { opacity: 1; }
  /* 「힌트 써볼까요?」는 **말풍선만** 나간다 (오너 2026-08-13) — 샤는 등장하지 않는다.
     그래서 자리도 옮긴다: 위 좌표는 **샤의 머리 위**를 겨냥한 값이라(꼬리도 아래를 가리킨다) 샤만
     빼면 말풍선이 허공에 뜨고 꼬리가 아무것도 안 가리킨다. 이제 이 문구가 말하는 그 버튼
     (#solveBtn) 위에 붙어 꼬리가 버튼을 가리킨다 — 같은 버튼이 이미 `.hintNudged`로 숨쉬고 있으므로
     둘이 **하나의 신호**로 읽힌다(연출 헌법 §4.3: 도착점 = 내가 할 일).
     ⚠️ 좌표를 고정값으로 적지 않는다. 버튼 높이·세이프에어리어·모바일 주소창이 다 달라서
        "간격을 가정하는" 상수는 이 저장소가 이미 두 번 당한 함정이다(bossTimer `_pinAboveControls`,
        silenceRescue 패널). 실제 rect를 재서 JS가 left/bottom을 넣는다(stageEntrance pinNudgeAboveHint). */
  /* ⚠️ `#game`을 앞에 붙여 특정도를 올린 것은 장식이 아니다. 집중 샤가 지나간 판에는 `data-emotion
     ="focused"`가 요소에 **남아 있고**(퇴장 전이가 그 규칙을 쓰므로 도중에 지울 수 없다), 그러면 위
     `.stageBuddy[data-emotion="focused"] ~ #buddyNudge`(id 1 + class 2)가 `.atHint`(id 1 + class 1)를
     이겨 좌표를 도로 가져간다. 그때도 인라인 좌표 덕에 화면은 맞지만, 그건 JS가 매번 넣어 준다는
     전제에 기댄 것이다 — 그 전제가 깨지면 조용히 어긋난다. 규칙 자체가 이기게 둔다. */
  #game #buddyNudge.atHint { position: fixed; left: auto; bottom: auto; transform: translateX(-50%); }
  #buddyNudge.atHint .bnBubble::after { left: 50%; margin-left: -7px; }
  .stageBuddy[data-emotion="focused"] ~ #buddyNudge {
    position: fixed;
    left: 5%;
    bottom: calc(
      env(safe-area-inset-bottom, 0px) +
      clamp(126px, 17vh, 158px) +
      clamp(104px, 27vw, 138px) + 8px
    );
  }
  #buddyNudge .bnBubble {
    position: relative; display: inline-block; padding: 9px 14px; border-radius: 12px; text-align: center;
    background: rgba(40,28,14,.95); color: #fff3d6; font-size: 15px; font-weight: 700; white-space: nowrap;
    animation: bnBubbleIn .38s linear both;
  }
  #buddyNudge .bnBubble::after {
    content: ""; position: absolute; left: 22px; bottom: -7px;
    border: 7px solid transparent; border-bottom: 0; border-top-color: rgba(40,28,14,.95);
  }
  /* NOT ccBubbleIn — that keyframe bakes in translateX(-50%) for .ccBubble's self-centering, which .bnBubble
     doesn't use (it's positioned by its #buddyNudge parent's `left`, not self-centered). */
  @keyframes bnBubbleIn {
    0%   { opacity: 0; transform: translateY(5px); animation-timing-function: ease-in-out; }
    55%  { opacity: 1; transform: translateY(0) scaleX(1.058) scaleY(.945); animation-timing-function: ease-in-out; }
    80%  { transform: translateY(0) scaleX(.970) scaleY(1.031); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  html[data-motion="reduced"] { #buddyNudge { transition: none; } #buddyNudge .bnBubble { animation: none; } }
  #game .cell .clue {
    font-size: clamp(30px, calc(var(--cell-px, 70px) * .62), 48px);
    font-weight: 950;
    color: #fff;
    text-shadow:
      0 3px 0 #142437,
      2px 0 0 #142437,
      -2px 0 0 #142437,
      0 -2px 0 #142437,
      0 5px 7px rgba(0,0,0,.35);
  }

  /* Status toast: zero flow footprint (2026-07-15) — .statusSlot (index.html) is the zero-height flow
     anchor at the exact spot #status used to sit; #status is an absolute overlay pinned to it, so a
     message appearing/clearing never resizes anything around it or shifts the board. A PLAIN text overlay
     here used to collide with the controls row right below it (same reason: zero reserved space, but text
     with no backdrop reads as broken when it lands on top of icon glyphs) — now a proper toast pill with
     its own background/border/shadow, so any overlap reads as an intentional floating alert, not a layout
     collision. Also wraps instead of clipping (was nowrap → long messages got cut off on narrow phones).
     2026-07-15 bugfix: `width` MUST be set explicitly — #game's `align-items:center` sizes flex children
     to shrink-to-fit their own in-flow content, and .statusSlot's only child (#status) is position:absolute
     (out of flow), so with no width set the slot collapsed to 0px. #status's white-space:normal text then
     had a 0-width containing block to wrap against, so EVERY character wrapped onto its own line — a tall
     single-column ransom-note banner instead of a pill (found live: bounding rect was 56×312px, one glyph
     per line). width:100% gives #status's absolute-position shrink-to-fit algorithm real room again. */
  #game .statusSlot { position: relative; height: 0; width: 100%; }
  #game #status {
    display: block;
    position: absolute; left: 50%; top: 0; z-index: 10;
    /* 2026-07-17 fix: was translate(-50%,-50%) — centered ON the anchor, so the pill straddled it half-
       above/half-below. A short 1-line message never dipped low enough to matter, but a longer message
       that WRAPS to 2 lines (e.g. status.shapeErr "막힌 영역이 도형과 안 맞아요") grows the box tall enough
       that its bottom half sinks into the .controls button row right below the anchor — text overlapping
       icon glyphs, not just floating over open board space. Anchoring the BOTTOM edge at the slot instead
       (grow upward only) keeps every message, however many lines, entirely above .controls; overlapping
       the board's own bottom edge is the accepted "floating alert" look this toast already has by design. */
    transform: translate(-50%, -100%);
    max-width: min(90vw, 340px);
    margin: 0;
    padding: 8px 18px 9px;
    border-radius: 999px;
    background: linear-gradient(180deg, #4b2b14, #25160c);
    border: 1.5px solid rgba(255,236,170,.55);
    box-shadow: 0 5px 14px rgba(24,10,2,.4), inset 0 1px 0 rgba(255,246,205,.22);
    color: #fff8ef;
    font-size: clamp(15px, 3.8vw, 18px);
    font-weight: 900;
    line-height: 1.25;
    opacity: 0;
    white-space: normal;
    /* 한국어는 기본이 "아무 데서나 줄바꿈"이라 「한 번 / 에 그려요」처럼 단어 중간이 잘렸다
       (오너 스크린샷 2026-08-03에서 겹침과 함께 보였다). 모달 본문이 이미 쓰는 규칙과 같다.
       `overflow-wrap:anywhere`는 안전판 — keep-all이 못 자르는 초장문 토큰이 오면 그때만 자른다. */
    word-break: keep-all;
    overflow-wrap: anywhere;
    text-align: center;
    pointer-events: none;
    text-shadow: 0 1px 2px rgba(20,8,2,.6);
  }
  #game #status.ok { border-color: rgba(150,230,170,.75); color: #d8ffd9; }
  #game #status.warn { border-color: rgba(255,150,140,.75); color: #ffd9d4; }
  #game #status.ok,
  #game #status.warn {
    opacity: 1;
  }

  /* ── 「처음부터」 안내가 짚는 버튼 (game/restartLesson.js, 오너 2026-08-09 "메뉴 버튼을 펄스") ──
     ⚠️ **버튼 자신에게 transform을 걸지 않는다.** 같은 파일의 hintNudged·undoBtn 주석이 경고하는 그대로 —
        `.gameActBtn:active { transform: scaleX(1.06) scaleY(.90) }`와 겹치면 애니메이션이 언제나 이겨서
        **눌러도 안 눌린 것처럼 보인다.** 처음에 그렇게 짰다가 이 경고를 읽고 되돌렸다.
     그래서 퍼지는 것은 버튼이 아니라 **뒤의 고리**다 — 오너가 되돌리기 버튼에 쓴 그 문법을 그대로 쓴다
     (`#undoBtn.learnPoint::after`). 몸짓이 화면마다 다르면 어휘만 는다.
     ⚠️ 리듬(1.15s)과 색도 그쪽과 맞춘다. 같은 버튼 줄에서 두 안내가 다른 박자로 뛰면 무엇이 지시인지
        흐려진다. 트레이 안 「처음부터」는 배경이 달라 고리 대신 빛만 쓴다. */
  #game .controls #menuBtn.lessonPoint::after {
    content: ""; position: absolute; inset: -7px; z-index: 0;
    border-radius: 50%; pointer-events: none;
    border: 3px solid rgba(255,224,140,.95);
    box-shadow: 0 0 12px rgba(255,224,140,.7);
    animation: undoLearnRing 1.15s cubic-bezier(.15,.7,.3,1) infinite;
  }
  html[data-motion="reduced"] #game .controls #menuBtn.lessonPoint::after { animation: none; opacity: .8; }
  /* 트레이 안 「처음부터」 — 빛만(크기 0). 트레이 버튼도 눌림 피드백을 가지므로 같은 이유다. */
  #gameMenu .gameMenuBtn.lessonPoint { animation: restartLessonGlow 1.15s ease-in-out infinite; }
  /* ⚠️ 기본이 `box-shadow`(filter 아님)라, 없던 그림자를 지어내지 않고 **빛만 얹는다**.
     여기서 base drop-shadow를 흉내 내면 평소와 다른 그림자가 생겨 버튼이 떠 보인다. */
  @keyframes restartLessonGlow {
    0%, 100% { filter: none; }
    50%      { filter: drop-shadow(0 0 13px rgba(255,224,140,.98)); }
  }
  html[data-motion="reduced"] #gameMenu .gameMenuBtn.lessonPoint {
    animation: none; filter: drop-shadow(0 0 11px rgba(255,224,140,.9));
  }
  /* 트레이가 열렸을 때 짚는 대상만 남기고 나머지는 물러난다 — 「지금 누를 것은 하나」를 말한다. */
  #gameMenu.lessonFocus .gameMenuBtn:not(.lessonPoint) { opacity: .38; }

  /* ── 막힘 구제 패널과의 충돌 회피 (오너 스크린샷 2026-08-03) ────────────────────────────────────
     근거·처방은 `game/silenceRescue.js`의 syncCompactLift 주석에 있다. 요지: 이 토스트는 **판을 따라
     움직이고** 구제 패널은 **화면 바닥에 고정**돼서 둘 사이 간격을 아무도 보장하지 않는다.
     ⑴ 패널이 **펼쳐져 있으면** 토스트를 숨긴다 — 그 프레임의 주인공은 샤 하나다(연출 헌법 §4.1).
        보드를 건드리면 패널이 먼저 접히므로, 이때 남아 있는 문구는 직전 획의 잔상이다.
        ⚠️ `.ok`/`.warn`이 `opacity:1`을 켜므로 그보다 **뒤에·같은 특이도 이상**으로 와야 이긴다. */
  #game.silenceRescueActive #status.ok,
  #game.silenceRescueActive #status.warn { opacity: 0; }
  /* ⑵ 옛 「접힘」 상태용 토스트 띄우기(--statusRescueLift)는 접힘과 함께 2026-08-08에 삭제됐다 —
        이제 패널은 닫으면 통째로 사라지므로 토스트가 피할 대상이 없다. */
  #game .shaSilenceRescue {
    position: absolute;
    left: 50%;
    bottom: calc(env(safe-area-inset-bottom, 0px) + clamp(78px, 13vh, 100px));
    z-index: 11;
    display: flex;
    align-items: flex-end;
    width: min(96vw, 414px);
    transform: translate(-50%, 14px);
    opacity: 0;
    pointer-events: none;
    transition: opacity .16s ease, transform .3s cubic-bezier(.2, 1.12, .35, 1);
  }
  #game .shaSilenceRescue[hidden] { display: none; }
  #game .shaSilenceRescue.show { opacity: 1; transform: translate(-50%, 0); }
  #game .shaSilenceRescueImg {
    position: relative;
    z-index: 2;
    flex: 0 0 auto;
    width: clamp(112px, 30vw, 138px);
    height: auto;
    transform-origin: 54% 100%;
    filter: var(--sha-tint, brightness(1)) drop-shadow(0 7px 8px rgba(22,13,4,.42));
    animation: silenceShaNotice .65s linear both;
  }
  #game .shaSilenceRescuePanel {
    position: relative;
    z-index: 1;
    display: grid;
    flex: 1 1 0;
    gap: 6px;
    min-width: 0;
    width: 100%;
    min-height: 116px;
    margin-left: -20px;
    padding: 3px 4px 5px;
    border-style: solid;
    border-width: 14px;
    border-image: url('../../assets/stage/statusbar.webp') 40 fill / 14px / 0 stretch;
    border-radius: 0;
    background: none;
    color: #fff4d1;
    filter: drop-shadow(0 7px 8px rgba(30,18,6,.42));
    animation: silenceRescuePanelIn .46s .05s linear both;
  }
  #game .shaSilenceRescueBubble {
    position: relative;
    display: grid;
    gap: 1px;
    min-width: 0;
    padding: 0 3px;
    line-height: 1.16;
    text-align: left;
    text-shadow: 0 2px 3px rgba(20,10,3,.72);
  }
  #game .shaSilenceRescueBubble strong {
    color: #fff0ad;
    font-family: 'Jua', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    font-size: clamp(15px, 4vw, 18px);
    font-weight: 400;
    letter-spacing: 0;
  }
  #game .shaSilenceRescueBubble span {
    color: #f2e7ce;
    font-size: clamp(15px, 3.05vw, 13px);
    font-weight: 750;
    letter-spacing: 0;
  }
  #game .shaSilenceRescueCta {
    justify-self: stretch;
    min-width: 0;
    min-height: 42px;
    padding: 7px 14px 10px;
    border: 0;
    border-radius: 14px;
    background-color: transparent;
    background-image: url('../../assets/common/btn_green.png');
    background-position: center;
    background-size: 100% 100%;
    background-repeat: no-repeat;
    box-shadow: none;
    color: #fff8d9;
    font-family: 'Jua', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    font-size: clamp(15px, 3.45vw, 16px);
    font-weight: 400;
    line-height: 1.1;
    letter-spacing: 0;
    text-shadow: 0 2px 2px rgba(20,66,7,.78), 0 0 2px rgba(27,69,8,.65);
    filter: drop-shadow(0 3px 2px rgba(25,16,4,.32));
    pointer-events: auto;
    touch-action: manipulation;
  }
  #game .shaSilenceRescueCta:hover { box-shadow: none; filter: brightness(1.04) drop-shadow(0 3px 2px rgba(25,16,4,.32)); }
  #game .shaSilenceRescueCta:active {
    transition: transform .06s ease-out;
    transform: scaleX(1.06) scaleY(.90);
    box-shadow: none;
    filter: brightness(.94) drop-shadow(0 1px 1px rgba(25,16,4,.3));
  }
  #game .shaSilenceRescueCta:focus-visible { outline: 3px solid #fff1a0; outline-offset: 1px; }
  #game .shaSilenceRescueCta:disabled { opacity: .68; filter: grayscale(.18) brightness(.9); }
  /* 두 선택지 (오너 2026-08-01). 취소가 왼쪽인 확인 모달과 같은 배치 — 오탭이 비싼 쪽(되돌리기 예산 1회)
     으로 가지 않는다. 「닫기」는 물러남이므로 돌 타일, 「도움 받기」만 초록 CTA를 유지한다. */
  #game .shaSilenceRescueActions {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    gap: 8px;
    align-items: stretch;
    min-width: 0;
  }

  /* 방패 잔량 — 지불 버튼 **바로 아래 한 줄**(모달 디자인 시스템 §5.4). 작고 조용하게: 이 패널의
     주인공은 「막혔다」는 사실과 두 선택지이고, 숫자는 고를 때 참고하는 정보다. */
  #game .shaSilenceRescueStock {
    display: flex; align-items: center; justify-content: flex-end; gap: 5px;
    margin: 6px 0 0;
    font-size: clamp(11px, 3vw, 13px); font-weight: 700; color: rgba(255,243,214,.82);
    text-shadow: 0 1px 2px rgba(28,14,4,.5);
  }
  /* 방패 그림은 글자 높이에 맞춘다 — 이 줄의 주인공은 **숫자**이고 그림은 무엇의 개수인지 알려주는 표식이다. */
  #game .shaSilenceRescueStock img { width: auto; height: 1.35em; flex: none; filter: drop-shadow(0 1px 2px rgba(28,14,4,.5)); }
  /* 숫자를 못 정한 순간엔 줄 전체를 감춘다(빈 그림만 떠 있는 상태를 막는다). */
  #game .shaSilenceRescueStock:has(b:empty) { display: none; }
  #game .shaSilenceRescueClose {
    min-width: 64px;
    min-height: 42px;
    padding: 6px 12px 8px;
    border: 0;
    border-radius: 12px;
    background: rgba(40,25,10,.46);
    box-shadow: inset 0 0 0 1px rgba(255,226,150,.32), inset 0 2px 0 rgba(255,246,214,.14);
    color: #ece1ca;
    font-family: 'Jua', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    font-size: clamp(13px, 3.1vw, 15px);
    font-weight: 400;
    line-height: 1.1;
    letter-spacing: 0;
    text-shadow: 0 2px 3px rgba(20,10,3,.7);
    pointer-events: auto;
    touch-action: manipulation;
  }
  #game .shaSilenceRescueClose:active { transition: transform .06s ease-out; transform: scaleX(1.06) scaleY(.90); filter: brightness(.94); }
  #game .shaSilenceRescueClose:focus-visible { outline: 3px solid #fff1a0; outline-offset: 1px; }
  /* ⚠️ 「접힘」 상태(.is-collapsed = 「도움 받기」만 남는 작은 플로팅 배너)는 **2026-08-08에 폐기됐다.**
     그 배너는 구제가 스테이지당 1회 무료이던 시절, 닫아도 그 한 번을 잃지 않게 하려고 있던 것이다.
     구제가 하트를 내고 반복해서 받는 장치가 되면서 존재 이유가 사라졌다(오너 "이 배너가 필요없어").
     이제 닫기·보드 터치는 **완전히 숨기고**, 획을 하나 더 그으면 다시 제안된다
     (game/silenceRescue.js의 keyForCurrentDeadEnd에 획수가 들어 있다). 부활은 git 이력에서. */
  #game .shaSilenceRescue.is-helping { opacity: 0; transform: translate(-50%, -8px); }
  @keyframes silenceShaNotice {
    0%   { opacity: 0; transform: translate(-18px, 24px) rotate(-6deg) scaleX(.717) scaleY(.763); animation-timing-function: ease-in-out; }
    55%  { opacity: 1; transform: translate(3px, -3px) rotate(2deg) scaleX(1.106) scaleY(.988); animation-timing-function: ease-in-out; }
    80%  { transform: translate(0, 0) rotate(0) scaleX(.970) scaleY(1.031); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: none; }
  }
  @keyframes silenceRescuePanelIn {
    0%   { opacity: 0; transform: translateX(-10px) scale(.96); animation-timing-function: ease-in-out; }
    55%  { opacity: 1; transform: translateX(0) scaleX(1.058) scaleY(.945); animation-timing-function: ease-in-out; }
    80%  { transform: translateX(0) scaleX(.970) scaleY(1.031); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: none; }
  }
  html[data-motion="reduced"] #game .shaSilenceRescue { transition: none; }
  html[data-motion="reduced"] #game .shaSilenceRescueImg,
  html[data-motion="reduced"] #game .shaSilenceRescuePanel { animation: none; }
  @media (max-width: 370px) {
    #game .shaSilenceRescue { bottom: calc(env(safe-area-inset-bottom, 0px) + 86px); width: 98vw; }
    #game .shaSilenceRescueImg { width: 104px; }
    #game .shaSilenceRescuePanel {
      min-height: 110px;
      margin-left: -18px;
      gap: 4px;
      border-width: 12px;
      border-image-width: 12px;
      padding: 2px 3px 4px;
    }
    #game .shaSilenceRescueCta { min-height: 40px; padding-top: 6px; padding-bottom: 9px; }
  }
  /* hint banner: gold (distinct from green .ok / red .warn), plays a brief rise-hold-fade so it reads
     as a MOMENT — the "인정" beat naming what the hint just did. Ends hidden (fill: both → opacity 0). */
  #game #status.hint {
    color: #ffe7a3; border-color: rgba(255,224,140,.7); animation: hintBanner 2.8s linear both;
  }
  /* ⚠️ 기본 transform(translate(-50%,-100%))을 모든 프레임이 그대로 들고 간다(정본 §4.6). */
  @keyframes hintBanner {
    0%   { opacity: 0; transform: translate(-50%, -100%) translateY(6px) scaleX(.95) scaleY(1.02); animation-timing-function: ease-in-out; }
    9%   { opacity: 1; transform: translate(-50%, -100%) translateY(0) scaleX(1.058) scaleY(.945); animation-timing-function: ease-in-out; }
    18%  { transform: translate(-50%, -100%) translateY(0) scaleX(.970) scaleY(1.031); animation-timing-function: ease-in-out; }
    80%  { opacity: 1; transform: translate(-50%, -100%) translateY(0) scale(1); animation-timing-function: ease-in-out; }
    100% { opacity: 0; transform: translate(-50%, -100%) translateY(0) scale(1); }
  }
  html[data-motion="reduced"] { #game #status.hint { animation: none; opacity: 1; } }
  /* ── 레슨 안내(배우기 1~3) — 손가락 옆 (오너 승인 2026-08-04, 방안 A) ─────────────────────────
     근거·실측은 `game/stageEntrance.js`의 placeLearnCaption 주석에 있다. 요지: 위 #gameGuide는
     팔레트판보다도 위라 대상까지 216~410px(화면의 26~49%)이고, 그 도착지엔 **움직이는 손가락**이
     있어 정지한 글자가 진다. 그래서 대상에 가까운 쪽 빈 띠로 내린다.
     ⚠️ `.gameBoardWrap`은 보드와 정확히 같은 상자이고 `overflow: visible`이라, 보드 **밖** 위/아래에
        놓을 수 있다. 래퍼에 overflow를 주면 이 안내가 잘린다.
     ⚠️ 이 안내는 **레슨 판에서만** 뜬다(setLearnPhase가 켜고 끈다). 큰 보드의 스크롤 안내가 쓰는
        위 슬롯과 자리를 다투지 않는다 — 레슨 판은 스크롤이 없다.
     ⚠️ 손가락(#dragCoach)과 **한 덩어리로 읽혀야** 한다(연출 헌법 §4.1 주인공은 하나). 그래서 글자를
        더 세게 만들지 않는다 — 위 줄과 같은 석판 알약을 조금 작게 쓰고, 위치로만 이긴다. */
  #learnCaption {
    position: absolute;
    left: 50%;
    top: calc(100% + var(--capGap, 10px));          /* 기본 = 보드 아래 띠 */
    transform: translateX(-50%);
    z-index: 12;                                    /* 손가락(#dragCoach)보다 위 — 겹칠 일은 없지만 순서를 못 박는다 */
    /* ⚠️ `width: max-content`가 **없으면 보드 폭에 갇힌다.** 이 줄은 보드 래퍼 안의 absolute이고
       `left: 50%`라, 폭을 안 주면 쓸 수 있는 자리가 **래퍼의 오른쪽 절반**뿐이다. 좁은 보드(배우기 4열)
       에서는 그게 140px 남짓이라 한 줄짜리 문구가 억지로 두 줄이 됐다(오너 리포트 2026-08-08 —
       폴드 펼침에서 「모양대로 한 번에 / 그려요.」). max-content면 글자 길이대로 잡고 아래 max-width가
       화면 밖으로 나가는 것만 막는다(translateX(-50%)가 가운데로 되돌리므로 좌우 대칭도 유지된다). */
    width: max-content;
    max-width: min(92vw, 340px);
    padding: 5px 14px;
    border-radius: 999px;
    background: rgba(40,28,14,.9);
    color: #fff8ef;
    font-size: clamp(14px, 3.7vw, 17px);
    font-weight: 800;
    line-height: 1.2;
    text-align: center;
    white-space: pre-line;
    word-break: keep-all;
    text-shadow: 0 2px 4px rgba(46,11,2,.45);
    box-shadow: 0 4px 12px rgba(18,10,2,.4);
    pointer-events: none;
    opacity: 0;
    transition: opacity .18s ease-out;
  }
  #learnCaption.above { top: auto; bottom: calc(100% + var(--capGap, 10px)); }
  #learnCaption.on { opacity: 1; }
  /* ── 이 줄은 흐름 밖에 뜬다 (오너 2026-08-06 "퍼즐 보드의 위치가 상단 텍스트에 따라서 밀린다") ────
     발단 = 오너가 같은 판의 두 순간을 나란히 보냈다. 안내가 있을 때와 없을 때 보드가 통째로 움직인다.
     원인: 이 줄이 **흐름 안**에 있고, 보드는 `.gameBoardArea`(유일한 flex:1)에 남은 높이의 한가운데
     (game/boardFit.js `placeBoard`)에 놓인다 — 즉 이 줄이 생기면 남은 높이가 줄어 보드가 내려앉는다.
     같은 문제를 두 번 막은 적이 있는데(2026-07-29 `min-height`로 한 줄↔두 줄 고정 · 2026-08-04 레슨 중
     `display:none` 대신 `visibility:hidden`으로 자리 보존, 실측 56px), **둘 다 「줄이 아예 없는 판」은
     못 막았다** — 안내 없는 판은 `display:none`이라 자리가 0이기 때문이다(덱 904판 중 856판, 94.7%).
     그래서 이번엔 자리를 지키는 대신 **자리를 아예 안 먹게** 한다. 오너가 두 갈래 중 이쪽을 택했다:
     ① 모든 판이 빈 띠 60px을 양보 vs ② 안내가 떠서 살짝 겹침 → **②**("살짝 겹쳐도 좋아").
     ⚠️ 그래서 `position: absolute`는 장식이 아니라 **이 결함의 수정 그 자체**다. 흐름으로 되돌리면
        보드 밀림이 그대로 재발한다(위 두 장치로는 못 막는다는 것이 이미 확인됐다).
     ⚠️ 기준점은 `.paletteZone`(index.html에서 그 안으로 옮겼다). 판때기 **아래** 가장자리에 매달린다.
        처음엔 위 가장자리에 달았다가 실측에서 **HUD(획수)를 39px 가려** 내렸다 — 위쪽 빈틈은 HUD 아래
        19px뿐이라 두 줄 알약(48px)이 애초에 들어가지 않는다. 아래쪽은 팔레트~보드 사이가 88px이라
        보통은 아무것도 안 가리고, 보드가 큰 판에서만 보드 윗줄에 살짝 걸친다(오너가 허용한 겹침).
     ⚠️ `pointer-events: none` 필수 — 보드에 걸치는 순간 이 줄이 **그 칸의 터치를 먹는다.** 안내는
        읽는 것이지 누르는 것이 아니다.
     ⚠️ `--timer-reserve`는 `game/boardFit.js`가 넘겨주는 값이다(보스·거울 변이 시계가 바로 이 띠를
        쓴다). 시계가 있는 판에서는 그만큼 더 내려가 겹치지 않는다. 지금 덱에서 보스∩규칙 판은 0이지만
        변이 재도전은 어느 판에서나 열리므로 실재하는 경우다.
     ⚠️ `min-height`(두 줄 미리 잡기)는 **함께 없앴다.** 흐름 밖이라 높이가 변해도 아무것도 안 밀리므로
        더는 필요 없고, 남겨 두면 한 줄 문구가 두 줄짜리 알약 안에 떠 있는 모양이 된다.
     ⚠️ `game/stageEntrance.js placeLearnCaption`이 이 줄의 rect를 후보 자리로 재고, 못 고치는 화면에서는
        여기에 문구를 되돌려 놓는 퇴로를 갖고 있다 — 그 코드는 그대로 둔다(흐름 밖이어도 rect는 살아 있다).
        오히려 그 퇴로가 발동할 때 보드가 튀던 것이 이 수정으로 함께 사라진다. */
  #gameGuide {
    position: absolute;
    left: 50%;
    top: calc(100% + 6px + var(--timer-reserve, 0px));
    transform: translateX(-50%);
    width: 92vw;
    z-index: 10;
    pointer-events: none;
    color: #fff8ef;
    font-size: clamp(15px, 4vw, 19px);
    font-weight: 800;
    line-height: 1.15;
    text-align: center;
    letter-spacing: 0;
    max-width: 92vw;
    /* pre-line (was normal): 문구가 스스로 줄바꿈 지점을 정할 수 있게 한다. 문자열의 `\n`만 존중하고
       일반 줄바꿈은 그대로라, `\n`이 없는 기존 문구들의 동작은 바뀌지 않는다. (배우기2처럼 두 문장이
       한 줄씩 읽혀야 하는 문구가 "…방향/순서는 / 상관없어요." 같은 엉뚱한 위치에서 끊기는 것을 막는다.) */
    white-space: pre-line;
    text-shadow: 0 2px 4px rgba(46,11,2,.45);
    /* 2026-07-14: bright/varied stage backgrounds behind this line leave near-white text + a small
       shadow unreadable (same fix already shipped for num). A dark pill guarantees contrast regardless
       of what's behind it. */
    background: rgba(40,28,14,.88);
    padding: 6px 16px;
    border-radius: 999px;
    /* 옛 `min-height: calc(2*1.15em + 12px)`(2026-07-29 두 줄 미리 잡기)는 2026-08-06에 **삭제**했다.
       흐름 밖으로 나오면서 높이가 변해도 아무것도 안 밀리므로 존재 이유가 사라졌고, 남겨 두면 한 줄
       문구가 두 줄짜리 알약 안에 떠 있는 모양이 된다. 되살리지 말 것 — 위 블록 주석 참고. */
    display: flex; align-items: center; justify-content: center;
  }
  /* 2026-07-18 (owner): the in-progress tip ("도형 모형으로 칸을 칠해요" etc.) is stale once the puzzle is
     solved, and sat right in #badgeClaimToast's new lower top position — hide it during the reward
     sequence, same pattern as #status just above (body.reward-sequence #status). */
  body.reward-sequence #gameGuide { opacity: 0; }
  /* 무결점 첫 클리어 → 「빛 거두기」 강제 진입 다리 (game/showWin.js showForceEliteBridge, owner 2026-07-30).
     이 다리 위에서는 승리 카드가 뜨지 않아 게임 화면이 그대로 살아 있었고, 하단 [뒤로]로 생애 1회뿐인
     학습 장면을 건너뛸 수 있었다. #game 한 겹만 잠그면 보드·HUD·하단 버튼이 전부 막히고, 안내 토스트
     (#badgeClaimToast)는 body 직속이라 그대로 눌린다 → 유일한 출구가 "진행"이 된다.
     ⚠️ 잠금 해제는 CSS가 아니라 reward-sequence를 푸는 두 지점(puzzleLoader / navigation)이 책임진다.
     여기에 시간 기반 해제를 넣지 말 것 — 다리는 탭을 기다리므로 길이가 정해져 있지 않다. */
  body.forceBridge #game { pointer-events: none; }
  /* ⚠️ **#win도 함께 잠근다 (2026-08-15).** `#win`은 `#game` 안이 아니라 **body 직속**이라 위 한 줄로는
     안 막힌다. 옛 다리(무결점 첫 클리어)에서는 승리 카드가 아예 안 떠서 드러나지 않았지만, 소거 모드
     강제 체험(game/clearIntro.js)은 **카드가 뜬 뒤** 그 위에 다리를 놓는다. 안 막으면 카드를 한 번 더
     탭해 「다음」이 눌리고(initShowWin의 카드 전역 탭 위임) 체험을 건너뛴 채 다음 판으로 넘어간다. */
  body.forceBridge #win { pointer-events: none; }
  /* ── 난이도 = 명시적 현지화 문구 (오너 2026-08-03) ─────────────────────────────────────────────
     금색 핍은 맵의 성취/화폐 문법과 겹쳤고, 산등성이와 계기판은 작게 렌더되자 뜻 없는 장식이 됐다. 난이도는
     플레이어가 얻는 것이 아니라 판의 속성이다. 그래서 추상 기호를 더 만들지 않고 `난이도 · 조금 어려움`처럼
     준비된 현지화 문자열이 의미를 전부 책임진다. 한 줄짜리 석판 각인으로 만들어 도형 선반의 폭도 보존한다.

     ⚠️ 사다리는 `쉬움 · 보통 · 조금 어려움 · 어려움` 네 칸이고 3번째 칸의 한국어 문구는 **의미가 아니라
     순서가 결정한다** (오너 2026-08-03). 원래는 「꽤 어려움」이었는데, 「꽤」가 정도를 **더하는** 말이라
     최상위인 「어려움」보다 세게 읽혀 **사다리가 뒤집혀 보였다.** 영어 원본 `Fairly hard`는 "Medium과
     Hard 사이"라는 뜻이었지만 한국어로 옮기며 그 "사이"가 사라진 것이다.
     그래서 3번째 칸에는 **정도를 낮추는 말만** 쓴다(조금·약간). 「꽤」·「상당히」·「제법」으로 되돌리지 말 것.
     영어(`Fairly hard`)와 일본어(`やや難しい`)는 이미 순서대로 읽히므로 건드리지 않았다.
     라벨 자체는 `content/strings/<lang>.json`의 `stage.diff.*`가 정본이고, 어느 판이 몇 번째 칸인지는
     `puzzle.pips`(= tools/build-pips.mjs가 굽는 측정값)가 정한다 — 이 주석은 **문구 규칙만** 소유한다. */
  #stageDiff {
    position: relative;
    z-index: 10;
    flex: 1 1 auto;
    align-self: stretch;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    box-sizing: border-box;
    min-width: 0;
    height: 100%;
    margin: 0;
    padding: 0;
    background: none;
  }
  #stageDiff .diffCopy {
    display: flex;
    align-items: baseline;
    gap: 5px;
    min-width: 0;
    max-width: 100%;
    white-space: nowrap;
    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  }
  #stageDiff .diffTitle {
    color: rgba(229,228,216,.72);
    font-size: 13px;
    font-weight: 800;
    line-height: 1;
    letter-spacing: 0;
  }
  #stageDiff .diffSeparator {
    color: rgba(211,211,198,.42);
    font-size: 12px;
    font-weight: 800;
  }
  #stageDiff .diffValue {
    color: #f6f0e4;
    font-size: 14px;
    font-weight: 900;
    line-height: 1;
    letter-spacing: 0;
    text-shadow: 0 1px 1px rgba(24,18,10,.55);
  }
  .stageFloor {
    position: relative;
    z-index: 10;
    flex: 0 0 auto;
    align-self: stretch;
    display: flex;
    align-items: center;
    gap: 5px;
    min-width: 0;
    color: #f4f0e6;
    white-space: nowrap;
    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    font-size: 14px;
    font-weight: 900;
    line-height: 1;
    letter-spacing: 0;
    text-shadow: 0 1px 1px rgba(24,18,10,.62);
  }
  .stageFloor[hidden] { display: none !important; }
  .stageFloorIcon {
    width: 15px;
    height: 15px;
    flex: 0 0 auto;
    fill: none;
    stroke: #adc49b;
    stroke-width: 1.65;
    stroke-linecap: round;
    stroke-linejoin: round;
    filter: drop-shadow(0 1px 1px rgba(20,24,16,.72));
  }
  .stageFloorText {
    display: inline-flex;
    align-items: baseline;
    min-width: 0;
    font-variant-numeric: tabular-nums;
  }
  .stageFloorCurrentClip {
    position: relative;
    display: inline-grid;
    overflow: hidden;
    height: 1em;
    align-items: center;
  }
  .stageFloorCurrentClip > strong {
    display: block;
    font: inherit;
  }
  .stageFloorCurrentClip > strong.advance {
    animation: stageFloorAdvance .18s cubic-bezier(.22,.7,.28,1) both;
  }
  @keyframes stageFloorAdvance {
    0% { opacity: 0; transform: translateY(55%); }
    100% { opacity: 1; transform: translateY(0); }
  }
  html[data-motion="reduced"] .stageFloorCurrentClip > strong.advance {
    animation: none;
  }
  body.reward-sequence #stageDiff { opacity: 0; }  /* mirror #gameGuide: stale during win sequence */
  #stageDiff.diffIntro .diffCopy { animation: diffCopyIn .36s ease-out both; }
  @keyframes diffCopyIn {
    0% { opacity: .35; transform: translateY(2px); filter: brightness(1.22); }
    100% { opacity: 1; transform: translateY(0); filter: none; }
  }
  html[data-motion="reduced"] #stageDiff.diffIntro .diffCopy { animation: none; }
  @media (prefers-reduced-motion: reduce) {
    #stageDiff.diffIntro .diffCopy { animation: none; }
  }
  /* (legacy #game .legend / .dot / swatch CSS removed — legend is display:none in shape mode,
     replaced by the shape palette in render(); the game_swatch_*.webp it used are deleted.) */

  /* Action buttons row: no card background */
  #game .controls {
    background: none;
    border: none;
    box-shadow: none;
    padding: 0;
    /* 2026-07-15 (owner): shrunk to 80% of the original clamp(14,5vw,22)/clamp(4,1.2vh,12) to give the
       board more vertical room — scaled alongside .gameActBtn below. */
    gap: clamp(11px, 4vw, 18px);
    /* flow right below the instruction (was absolute-bottom) so the whole stack packs toward the top;
       the leftover space falls to the bottom of the screen. */
    position: static;
    margin: clamp(3px, 1vh, 10px) auto 0;
    z-index: 10;
  }

  /* Bottom action bar: icon-only controls for localization-safe play. */
  #game .controls .gameActBtn {
    position: relative;
    display: grid;
    place-items: center;
    /* ⏫ **1.5배 확대 (오너 결정 2026-08-03)** — 2026-07-15에 원래의 80%로 줄였던 값을 되돌리고 더 키운다.
       발단: 플레이 화면이 맵·타이틀보다 완성도가 낮아 보인다는 오너 진단. 디자인 패널 4인은 재질·구조·
       서사를 각각 제안했는데, 오너가 **버튼 크기**를 짚었고 그게 가장 싸고 확실했다. 작은 버튼 4개는
       넓은 검정 위의 **점**으로 읽혀 시선이 잡을 것이 없다 — 커지면 하단이 하나의 조작 영역이 되고
       위(석판)·가운데(보드)·아래(조작부) 삼단 구성이 **판을 하나도 안 깔고** 생긴다.
       (석판을 까는 안은 메뉴 트레이와 겹쳐 「판때기 위에 판때기」가 되어 폐기됐다 — 오너 실기기 확인.)
       ⚠️ 보드 손해(390 기준, **실제 코드로 새로 로드해 잰 값**): 8×8 45→45 · 10×8 44.8→44.8
       **변화 없음** — 보드 아래 여백이 이미 충분해 버튼이 그 빈 곳만 먹는다.
       가장 큰 12×8만 43.2 → **38.1**(−11.8%).
       ⚠️⚠️ **그 38.1px은 `zoomAssistMinTile`(38) 바로 위다 — 여유가 0.1px이다.** 이 아래로 내려가면
       타일을 탭했을 때 칠해지지 않고 **그 지점이 확대된다**(조작 방식 자체가 바뀐다, input/paint.js
       `needsZoomAssist`). 12×8은 진입 시 전체가 보이도록 0.646배로 축소돼 시작하므로, 세이프에어리어가
       다른 기기에서는 넘어갈 수 있다. **오너가 이 사실을 알고 72를 선택했다(2026-08-03).**
       넘어간 정황이 보이면 첫 번째 조절 손잡이는 이 값을 66px로 낮추는 것이다(그러면 40px대를 유지한다).
       ⚠️ 측정 함정(내가 실제로 빠졌다): 브라우저에 CSS만 **덧입혀** 재면 `fitBoard`가 완전히 다시 돌지
       않아 손해가 **절반으로 축소돼** 나온다(41.1로 쟀는데 실제 38.1). 레이아웃 비용은 반드시
       **새로 로드해서** 재라.
       ⚠️ 하한이 72인 이유: 75로 잡으면 360px 폰에서 조작부 폭 343 = 좌우 여백 8.5px씩으로 딱 붙는다.
       72면 14.5px씩이 남고, 390px 화면에서는 19.2vw=74.9라 체감 차이가 없다.
       ⚠️ 판마다 크기를 바꾸지 않는다(여백 있을 때만 키우는 안은 기각) — 조작부는 어느 판에서나 같은
       자리·같은 크기여야 손이 위치를 외운다. 44px HIG 최소 터치 타깃은 전 구간에서 여유롭게 넘는다. */
    width: clamp(72px, 19.2vw, 96px) !important;
    height: clamp(72px, 19.2vw, 96px) !important;
    min-width: 0 !important;
    padding: 0 !important;
    border-radius: 50% !important;
    border: none !important;
    background-color: transparent !important;
    background-image: var(--act-bg) !important;
    background-position: center !important;
    background-size: contain !important;
    background-repeat: no-repeat !important;
    color: #fff3c8;
    box-shadow: none !important;
    filter: drop-shadow(0 4px 4px rgba(48,28,10,.34));
    -webkit-tap-highlight-color: transparent;
    transition: transform .12s ease, filter .12s ease, box-shadow .12s ease;
  }

  /* ── 아주 좁은 화면(360px 미만)에서만 조작부를 좁힌다 ────────────────────────────────────────
     위 72px 하한은 **360px 폰 기준으로 오너가 직접 잡은 값**이다(바로 위 주석의 근거). 그런데 그보다
     좁은 화면이 실재한다 — **폴더블의 접힌 커버 화면**과 구형 소형 폰이 320px대다. 거기서는
     버튼 4개(288) + 간격 3개(38) = 326px이라 **320px 화면을 6px 넘친다**(tools/check-layout.mjs가
     잡았다, 2026-08-08). 넘치면 마지막 버튼이 잘려 되돌리기를 누를 수 없다.
     ⚠️ 360px 이상은 **한 픽셀도 건드리지 않는다** — 오너가 튜닝한 구간을 이 수정이 침범하지 않게
     경계를 359px로 둔다. 320px에서는 버튼 64 + 간격 9.6 → 285px로 좌우 17px씩 남는다.
     ⚠️ 64px는 여전히 터치 최소 권장(44px)을 크게 넘는다. */
  @media (max-width: 359px) {
    #game .controls .gameActBtn { width: clamp(64px, 19.2vw, 96px) !important; }
    #game .controls { gap: clamp(8px, 3vw, 18px); }
  }
  #game .controls .gameActBtn::before {
    content: none;
  }
  #game .controls .gameActBtn::after {
    content: none;
  }
  #game .controls .gameActBtn svg {
    display: none;
  }
  /* 「힌트 볼래?」 말풍선이 떠 있는 동안 힌트 버튼도 함께 숨쉰다 (오너 2026-07-29).
     말풍선만 뜨면 "그래서 어디를 누르라는 건데?"가 되어 안내가 절반만 전달된다 — 샤가 말을 걸면 손이 갈
     곳도 같이 가리켜야 한다.
     ⚠️ transform이 아니라 **빛만** 쓴다: .gameActBtn의 `:active { transform: scale(.9) }`와 겹치면
     애니메이션이 이겨서 눌러도 안 눌리는 것처럼 보인다. 베이스 drop-shadow를 그대로 들고 가며 따뜻한
     빛을 하나 더 얹을 뿐이라, 크기·위치는 전혀 움직이지 않는다.
     압박이 아니라 안내이므로 붉은색·느낌표·확대는 쓰지 않는다(헌법: 손실 압박 프레이밍 금지). */
  #game .controls #solveBtn.hintNudged { animation: hintNudgePulse 1.5s ease-in-out infinite; }
  @keyframes hintNudgePulse {
    0%, 100% { filter: drop-shadow(0 4px 4px rgba(48,28,10,.34)); }
    50%      { filter: drop-shadow(0 4px 4px rgba(48,28,10,.34)) drop-shadow(0 0 11px rgba(255,214,110,.92)); }
  }
  html[data-motion="reduced"] #game .controls #solveBtn.hintNudged { animation: none; }

  /* 배우기3의 되돌리기 레슨 — 「지금 누를 곳은 여기」. 위 힌트 넛지와 **같은 문법**을 쓴다(빛만, 크기 0):
     같은 버튼 줄에서 두 안내가 서로 다른 몸짓을 하면 무엇이 지시인지 흐려지고, transform을 쓰면
     .gameActBtn의 `:active { transform }`을 이겨 눌러도 안 눌린 것처럼 보인다(그 블록 주석 참조).
     레슨은 지시이므로 힌트 넛지보다 조금 빠르고 밝게 숨쉬되, 붉은색·느낌표·확대는 여전히 쓰지 않는다. */
  /* 획수 HUD 쪽 표식(#hudDrags.learnPoint, 이 파일 위쪽)과 **같은 리듬**(1.15s ease-in-out)이다 —
     한 쌍으로 읽혀야 하므로 박자가 갈리면 안 된다.
     ⚠️ 그런데 keyframes는 **공유하면 안 된다**: 키프레임의 filter는 요소에 선언한 filter를 언제나
        이기므로, 하나를 나눠 쓰면 한쪽이 다른 쪽의 그림자(오프셋·색이 다르다)를 뒤집어쓴다. */
  #game .controls #undoBtn.learnPoint { animation: undoLearnPointPulse 1.15s ease-in-out infinite; }
  @keyframes undoLearnPointPulse {
    0%, 100% { filter: drop-shadow(0 4px 4px rgba(48,28,10,.34)); }
    50%      { filter: drop-shadow(0 4px 4px rgba(48,28,10,.34)) drop-shadow(0 0 13px rgba(255,224,140,.98)); }
  }
  /* ── 펄스 (오너 2026-08-09 "하단 되돌리기 버튼에 펄스 추가") ──────────────────────────────────
     ⚠️ **버튼 자신에게 transform을 걸지 않는다.** 위 주석(hintNudged)이 경고하는 그대로 —
        `.gameActBtn:active { transform: scaleX(1.06) scaleY(.90) }`와 겹치면 애니메이션이 언제나
        이겨서 **눌러도 안 눌린 것처럼 보인다.** 그래서 퍼지는 것은 버튼이 아니라 **뒤의 고리**다.
        (같은 날 획수 표시의 맥동을 가상 요소로 뺀 것과 같은 해법 — 남의 상태를 잡아먹지 않는다.)
     ⚠️ `.gameActBtn::after`는 기본이 `content: none`이라 비어 있고, 버튼은 이미 `position: relative`다
        (`.undoCount` 배지가 그 좌표계를 쓴다). 그래서 새로 만들 것이 없다.
     고리 문법은 이 저장소의 `.dragRefillRing`(충전 도착 링)에서 가져왔다 — 「여기다」를 말하는
     몸짓이 화면마다 다르면 어휘만 는다. 다른 점은 한 번이 아니라 **계속 도는 것**뿐이다. */
  #game .controls #undoBtn.learnPoint::after {
    content: ""; position: absolute; inset: -7px; z-index: 0;   /* 배지(z-index 3) 아래 */
    border-radius: 50%; pointer-events: none;
    border: 3px solid rgba(255,224,140,.95);
    box-shadow: 0 0 12px rgba(255,224,140,.7);
    animation: undoLearnRing 1.15s cubic-bezier(.15,.7,.3,1) infinite;
  }
  @keyframes undoLearnRing {
    0%   { opacity: 0;   transform: scale(.82); }
    18%  { opacity: .95; }
    100% { opacity: 0;   transform: scale(1.42); }
  }
  html[data-motion="reduced"] #game .controls #undoBtn.learnPoint::after { animation: none; opacity: .8; }
  html[data-motion="reduced"] #game .controls #undoBtn.learnPoint {
    animation: none;
    filter: drop-shadow(0 4px 4px rgba(48,28,10,.34)) drop-shadow(0 0 11px rgba(255,224,140,.9));
  }
  #game .controls .gameActBtn:active {
    /* 「쫀득함」 전파 (오너 2026-08-02) — 균일 축소가 아니라 **눌림**. 정본 = 타이틀 PLAY 버튼. */
    transition: transform .06s ease-out;
    transform: scaleX(1.06) scaleY(.90);
  }
  #undoBtn   { --act-bg: url('../../assets/stage/btn_action_undo.png');  }
  #solveBtn  { --act-bg: url('../../assets/stage/btn_action_hint.png');  }
  #menuBtn   { --act-bg: url('../../assets/stage/btn_action_menu.png');  }
  /* ⚠️ 임시 아이콘 (오너 2026-07-28). 아이콘은 인라인 SVG가 아니라 **버튼별 배경 이미지**(--act-bg)로 들어가고
     .gameActBtn svg는 display:none이다 — 그래서 --act-bg가 없는 버튼은 SVG를 넣어놔도 **완전히 빈 칸**으로
     보인다(뒤로가 실제로 그랬다). 다른 화면이 쓰는 둥근 뒤로 이미지를 임시로 빌려온다. 액자 문법이 나머지
     btn_action_* 4종과 달라 눈에 띈다 → 같은 결의 btn_action_back.png가 나오면 이 줄만 바꾸면 된다. */
  #hudBack   { --act-bg: url('../../assets/badge/back_round.png'); }
  /* per-attempt Undo budget: dim when spent (disabled), and show the remaining count as a small badge */
  #game .controls #undoBtn:disabled { filter: grayscale(.8) drop-shadow(0 4px 4px rgba(48,28,10,.34)); opacity: .42; cursor: default; }
  #game .controls #undoBtn:disabled:active { transform: none; }
  #undoBtn .undoCount {
    position: absolute; top: -2px; right: -2px; z-index: 3;
    min-width: 18px; height: 18px; padding: 0 3px; box-sizing: border-box;
    display: grid; place-items: center; border-radius: 999px;
    font-size: 12px; font-weight: 900; line-height: 1; color: #5a3c08;
    background: radial-gradient(circle at 50% 32%, #fff3c8, #ffce5e 52%, #f0a81f);
    border: 1.5px solid #fff6e0; box-shadow: 0 1px 3px rgba(8,50,90,.5); }
  #undoBtn:disabled .undoCount { background: #c9c2b4; border-color: #e8e3d8; color: #6b6658; }

  /* ⚠️ 2026-07-29 회귀 복구: 하트 칩 규칙을 지울 때 이 블록까지 같이 지워졌다. 그러면 #gameMenu가
     position/display를 잃어 **문서 흐름대로 화면 맨 위에 그냥 쌓이고**(fixed도 아니고 숨지도 않음),
     body 레벨 요소라 맵 같은 다른 화면에서도 그대로 보였다. `.show`만으로는 display만 켤 뿐 자리를
     못 잡는다 — 이 기본 규칙이 위치·숨김·배경을 전부 소유한다. */
  #gameMenu {
    position: fixed;
    inset: 0;
    z-index: 61;
    display: none;
    align-items: flex-end;
    justify-content: center;
    /* 좌우 10px — 아이콘 5개가 프레임 **안에** 들어가도록 트레이 폭과 함께 계산한다. */
    padding: 0 10px calc(env(safe-area-inset-bottom,0px) + 98px);
    background: rgba(20,29,18,.08);
  }
  #gameMenu.show { display: flex; }
  .gameMenuTray {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;   /* center the single actions row (wallet row removed → was top-pinned) */
    gap: 8px;
    box-sizing: border-box;
    width: min(92vw, 360px);
    min-height: 90px;
    padding: 0;
    /* 9-slice the statusbar STONE FRAME so its rounded corners stay crisp + even at the menu's size.
       (was: bg stretched to 100%×100% + border-radius:999px → the pill clipped the corners unevenly,
       and the wrong aspect distorted them.) Frame fixed; only the centre stretches to hold the buttons. */
    border-style: solid;
    border-width: 16px;
    border-image: url('../../assets/stage/statusbar.webp') 40 fill / 16px / 0 stretch;
    border-radius: 0;
    background: none;
    box-shadow: none;
    filter: drop-shadow(0 6px 8px rgba(36,22,8,.34));
  }
  /* ⚠️ **아이콘 줄은 프레임 안쪽 폭 안에서 스스로 줄어야 한다** (오너 실기기 리포트 2026-08-11
     "메뉴 아이콘 좌/우 오버플로우"). 종전엔 버튼 폭·간격이 **고정값**(50px·14px)이라 줄이 언제나
     `6×50 + 5×14 = 370px`를 요구했는데, 프레임 안쪽은 아무리 넓어도 `min(92vw,360px) − 32px`
     = 최대 **328px**였다. 지금은 옷장 바로가기를 없애고 기록/건너뛰기가 한 슬롯을 교대해 최대 5개지만,
     좁은 기기에서 원형을 지키는 아래 축소 규칙은 그대로 둔다.
     그때 일어난 일: 버튼은 `min-width:0` 덕에 **가로만** 줄어드는데 `height:50px`는 고정이라
     원이 세로 타원으로 눌렸고, 그러고도 남은 만큼 좌우 스톤 테두리 위로 삐져나왔다.
     이제 `flex: 0 1 50px` + `aspect-ratio: 1`로 **줄어들되 원을 유지**한다(50px는 상한).
     ⚠️ `align-items: center`가 필요하다 — 기본값 `stretch`면 버튼이 줄 높이만큼 늘어나 aspect-ratio를
        이겨서 다시 타원이 된다.
     ⚠️ 버튼 크기를 다시 고정값으로 되돌리려거든 위 산수부터 다시 할 것. 화면이 넓어져서 해결되지
        않는다 — 트레이 폭에 `360px` 상한이 있어 큰 화면에서도 안쪽은 328px가 천장이다. */
  .gameMenuActions {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: clamp(5px, 1.6vw, 12px);
    width: 100%;
    min-width: 0;
  }
  .gameMenuBtn {
    position: relative;
    display: grid;
    place-items: center;
    flex: 0 1 50px;
    width: auto;
    height: auto;
    aspect-ratio: 1;
    min-width: 0;
    padding: 0;
    border-radius: 50%;
    border: none;
    color: #fff4cf;
    background:
      radial-gradient(circle at 50% 51%, #637b67 0 43%, #334d3e 44% 56%, transparent 57%),
      radial-gradient(circle at 44% 39%, #fff0bc 0 8%, #bdae77 28%, #627056 58%, #273b2f 100%);
    box-shadow: inset 0 2px 0 rgba(255,255,224,.25), 0 2px 3px rgba(42,24,8,.22);
  }
  .gameMenuBtn[hidden] { display: none; }   /* .gameMenuBtn sets display:grid, which would override [hidden]'s UA display:none */
  .gameMenuBtn .ico { width: 24px; height: 24px; stroke-width: 2.2; }
  /* skip = » glyph (same mark as the skipped-node map badge) — clearer than a compass for "skip stage" */
  .gameMenuBtn.skipGlyph { font-size: 30px; font-weight: 900; line-height: 1; padding-bottom: 5px; }
  /* 물음표도 글리프 버튼이다(아이콘 세트에 ? 심볼이 없다). 건너뛰기(»)보다 한 톨 작게 —
     둘이 나란히 서면 같은 크기일 때 물음표가 더 커 보인다(글자 폭이 좁아 획이 굵게 읽힌다). */
  .gameMenuBtn.helpGlyph { font-size: 27px; font-weight: 900; line-height: 1; padding-bottom: 3px; }

  /* ── 완료 스테이지 「복원 기록」 ───────────────────────────────────────────────────────────────
     승리 카드의 밝은 축하 석판이 아니라, 판 위에서 조용히 펼치는 **어두운 복원 대장**이다. 기존
     statusbar 9-slice를 세로로 늘려 새 에셋 없이 같은 석재·이끼·금속 문법을 유지하고, 내부는 카드 중첩
     대신 선과 새김 깊이만으로 계층을 나눈다. */
  #stageRecord.stageRecordModal {
    position: fixed;
    inset: 0;
    z-index: 63;
    display: none;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    padding: max(16px, env(safe-area-inset-top, 0px)) 14px max(16px, env(safe-area-inset-bottom, 0px));
    background: rgba(13,20,13,.74);
    backdrop-filter: blur(2px);
  }
  #stageRecord.stageRecordModal.show { display: flex; animation: stageRecordScrimIn .2s ease-out both; }
  .stageRecordSheet {
    position: relative;
    box-sizing: border-box;
    width: min(92vw, 382px);
    max-height: calc(100dvh - max(32px, env(safe-area-inset-top, 0px) + env(safe-area-inset-bottom, 0px)));
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 8px 14px 14px;
    border-style: solid;
    border-width: 22px;
    border-image: url('../../assets/stage/statusbar.webp') 40 fill / 22px / 0 stretch;
    border-radius: 0;
    color: #f7edca;
    text-align: center;
    text-shadow: 0 1px 1px rgba(17,12,5,.8);
    filter: drop-shadow(0 16px 24px rgba(10,8,3,.46));
    isolation: isolate;
    scrollbar-width: thin;
    scrollbar-color: rgba(220,190,105,.5) transparent;
  }
  .stageRecordSheet * { box-sizing: border-box; min-width: 0; letter-spacing: 0; }
  #stageRecord.show .stageRecordSheet { animation: stageRecordSheetIn .38s cubic-bezier(.2,.82,.28,1) both; }
  .stageRecordClose {
    position: absolute;
    top: 2px;
    right: 2px;
    z-index: 2;
    display: grid;
    place-items: center;
    width: 42px;
    height: 42px;
    min-width: 42px;
    padding: 0;
    border: 2px solid #a99d78;
    border-radius: 50%;
    color: #fff3c8;
    background: #405345;
    box-shadow: inset 0 1px 0 rgba(255,255,230,.2), 0 3px 7px rgba(15,10,3,.42);
    outline: none;
  }
  .stageRecordClose:focus-visible {
    box-shadow: inset 0 1px 0 rgba(255,255,230,.2), 0 3px 7px rgba(15,10,3,.42), 0 0 0 3px rgba(246,211,105,.72);
  }
  .stageRecordClose .ico { width: 20px; height: 20px; stroke-width: 2.5; }
  .stageRecordHeader { padding: 1px 38px 8px; border-bottom: 1px solid rgba(225,199,126,.32); }
  .stageRecordStage {
    display: block;
    margin-bottom: 3px;
    color: #c7b886;
    font-size: 11px;
    font-weight: 900;
    line-height: 1.2;
    text-transform: uppercase;
  }
  .stageRecordHeader h2 {
    margin: 0;
    color: #fff1bd;
    font-size: 24px;
    font-weight: 900;
    line-height: 1.15;
    overflow-wrap: anywhere;
  }
  .stageRecordGrade { padding: 10px 0 8px; }
  .stageRecordGrade > span,
  .stageRecordMastery h3,
  .stageRecordGoal > span {
    color: #c9bc90;
    font-size: 11px;
    font-weight: 900;
    line-height: 1.2;
  }
  .stageRecordStars {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    height: 36px;
    margin-top: 1px;
    font-size: 31px;
    line-height: 1;
  }
  .stageRecordStars .earned { color: #f6cb4e; text-shadow: 0 2px 0 #8b5c18, 0 0 10px rgba(247,199,61,.38); }
  .stageRecordStars .unearned { color: rgba(184,177,144,.22); text-shadow: 0 1px 0 rgba(0,0,0,.35); }
  .stageRecordFacts {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    margin: 0;
    border-top: 1px solid rgba(225,199,126,.26);
    border-bottom: 1px solid rgba(17,13,7,.48);
    background: rgba(15,22,15,.18);
  }
  .stageRecordFacts > div { padding: 9px 6px 8px; }
  .stageRecordFacts > div + div { border-left: 1px solid rgba(225,199,126,.2); }
  .stageRecordFacts dt { color: #c9bc90; font-size: 11px; font-weight: 800; line-height: 1.2; }
  .stageRecordFacts dd {
    min-height: 24px;
    margin: 3px 0 0;
    color: #fff0bb;
    font-size: 20px;
    font-weight: 900;
    line-height: 1.2;
    font-variant-numeric: tabular-nums;
    overflow-wrap: anywhere;
  }
  .stageRecordGather {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    align-items: center;
    gap: 7px;
    padding: 8px 4px;
    border-bottom: 1px solid rgba(225,199,126,.24);
    color: #d7cda9;
    font-size: 12px;
    font-weight: 800;
    text-align: left;
  }
  .stageRecordGather[hidden] { display: none; }
  .stageRecordGather .ico { width: 18px; height: 18px; color: #a98be7; stroke-width: 2; }
  .stageRecordGather strong { color: #c7b3f4; font-size: 12px; text-align: right; }
  .stageRecordGather.achieved strong { color: #f2cf62; }
  .stageRecordMastery { padding-top: 9px; }
  .stageRecordMastery h3 { margin: 0 0 7px; }
  .stageRecordMastery ul {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .stageRecordMastery li {
    display: grid;
    grid-template-rows: 29px auto;
    justify-items: center;
    align-content: start;
    gap: 4px;
    min-height: 57px;
    padding: 2px;
    color: rgba(224,216,185,.42);
    font-size: 11px;
    font-weight: 800;
    line-height: 1.2;
    overflow-wrap: anywhere;
  }
  .stageRecordMastery li + li { border-left: 1px solid rgba(225,199,126,.17); }
  .stageRecordSeal {
    display: grid;
    place-items: center;
    width: 27px;
    height: 27px;
    border: 1px solid rgba(190,181,145,.24);
    border-radius: 50%;
    color: rgba(213,205,173,.26);
    background: rgba(13,19,13,.22);
  }
  .stageRecordSeal .ico { width: 20px; height: 20px; stroke-width: 2.2; }
  .stageRecordMastery li.achieved { color: #f4e6b8; }
  .stageRecordMastery li.achieved .stageRecordSeal {
    border-color: #d1ad49;
    color: #ffe28a;
    background: #5d542f;
    box-shadow: inset 0 1px 0 rgba(255,244,176,.24), 0 0 7px rgba(226,181,67,.22);
  }
  .stageRecordGoal {
    margin-top: 8px;
    padding: 9px 4px 0;
    border-top: 1px solid rgba(225,199,126,.3);
  }
  .stageRecordGoal > span { display: block; margin-bottom: 3px; }
  .stageRecordGoal strong {
    display: block;
    color: #ffdb6d;
    font-size: 15px;
    font-weight: 900;
    line-height: 1.25;
    overflow-wrap: anywhere;
  }
  @keyframes stageRecordScrimIn { from { opacity: 0; } to { opacity: 1; } }
  @keyframes stageRecordSheetIn {
    0% { opacity: 0; transform: translateY(18px) scale(.97); }
    68% { opacity: 1; transform: translateY(-2px) scale(1.006); }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  @media (max-height: 620px) {
    .stageRecordSheet { padding-bottom: 10px; border-width: 18px; border-image-width: 18px; }
    .stageRecordHeader { padding-bottom: 6px; }
    .stageRecordHeader h2 { font-size: 21px; }
    .stageRecordGrade { padding: 6px 0 5px; }
    .stageRecordStars { height: 31px; font-size: 27px; }
    .stageRecordFacts > div { padding-block: 6px; }
    .stageRecordMastery { padding-top: 6px; }
    .stageRecordMastery li { min-height: 51px; }
    .stageRecordGoal { margin-top: 5px; padding-top: 6px; }
  }
  html[data-motion="reduced"] #stageRecord.stageRecordModal.show,
  html[data-motion="reduced"] #stageRecord.show .stageRecordSheet { animation: none; }

  .heartNote {
    position: relative;
    width: fit-content;
    max-width: 100%;
    margin: 8px auto 14px;
    padding: 5px 12px 4px;
    border-radius: 999px;
    border: 1px solid rgba(150,90,30,.26);
    background: rgba(255,246,218,.52);
    /* 크림 워시 자체는 유지한다(어두운 「새김 홈」과 구분되는 「알림 칩」의 얼굴). 워시 위 실측은
       #7d5130 → 3.63:1로 13px 라벨엔 모자랐고, 잉크만 내리면 워시를 안 건드리고 9.14:1이 된다. */
    color: #26190b;
    font-size: 13px;
    font-weight: 900;
    line-height: 1.2;
    text-shadow: 0 1px 0 rgba(255,246,205,.58);
  }
  .heartNote .ico {
    width: 14px;
    height: 14px;
    margin-right: 4px;
    color: #e8475c;
    vertical-align: -2px;
  }
  #noDragsLifeBal.heartNote { margin: 10px auto 0; background: rgba(42,31,20,.72); color: #ffe6a2; border-color: rgba(255,218,127,.28); text-shadow: 0 2px 2px rgba(0,0,0,.45); }

  /* the green button graphic is baked into the frame image; #winNext is the transparent label + tap
     target sitting over it (width/height cover the baked button; top % set in the positioning block). */
  #win #winNext {
    width: 54%;
    height: 13%;
    white-space: nowrap;
    border: none;
    border-radius: 0;
    background: transparent;
    box-shadow: none;
    display: flex; align-items: center; justify-content: center;
    color: #fff6df;
    font-family: 'Jua', system-ui, sans-serif;
    font-size: clamp(19px, 5.4vw, 24px);
    font-weight: 400;
    text-shadow:
      0 2px 0 #1f7a27,
      0 3px 5px rgba(8,60,16,.45),
      1px 0 0 #1f7a27,
      -1px 0 0 #1f7a27;
  }
  #win #winNext:active {
    transition: transform .06s ease-out;
    transform: scaleX(1.06) scaleY(.90);
    box-shadow: 0 3px 7px rgba(28,40,12,.3);
  }

  /* ── Scene Overlay ── */
  #sceneOverlay { position: fixed; inset: 0; z-index: 150; display: none; overflow: hidden; cursor: pointer; }
  #sceneOverlay.active { display: block; animation: scFadeIn .45s ease forwards; }
  #sceneBg { position: absolute; inset: 0; background-size: cover; background-position: center; }
  /* slow ken-burns so the scene keeps gentle life (never a dead freeze) while dialogue is read */
  #sceneOverlay.active #sceneBg { animation: scKenBurns 22s ease-out both; transform-origin: 50% 42%; }
  @keyframes scKenBurns { from { transform: scale(1.02); } to { transform: scale(1.13) translateY(-2%); } }
  #sceneVideo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: none;
    transform-origin: 50% 42%; }
  /* one-shot video ends → HOLD the last frame with a gentle ken-burns (never rewind to the start poster,
     never a dead freeze). Role shapes the hold: payoff pushes IN (climax), preview drifts calmly (여운). */
  #sceneVideo.scHoldPayoff  { animation: scVideoPayoff 16s ease-out forwards; }
  #sceneVideo.scHoldPreview { animation: scVideoPreview 16s ease-out forwards; }
  @keyframes scVideoPayoff  { from { transform: scale(1); filter: brightness(1); } to { transform: scale(1.08) translateY(-2%); filter: brightness(1.06); } }
  @keyframes scVideoPreview { from { transform: scale(1); } to { transform: scale(1.04) translateY(-1%); } }
  #sceneChar { position: absolute; bottom: 148px; right: 12px;
               height: 52vh; max-height: 340px; object-fit: contain; pointer-events: none;
               animation: scCharIn .7s .1s linear both; }
  #scenePanel { position: absolute; bottom: 0; left: 0; right: 0;
                background: linear-gradient(transparent, rgba(0,0,0,.8) 40%);
                padding: 52px 22px 38px; min-height: 148px; }
  #sceneTitle { font-size: 12px; font-weight: 700; color: rgba(255,255,255,.55);
                letter-spacing: .1em; text-transform: uppercase; margin: 0 0 6px; }
  #sceneDialog { font-size: 17px; font-weight: 500; line-height: 1.6; color: #fff;
                 margin: 0; min-height: 54px; }
  #sceneProgress { display: flex; justify-content: center; gap: 6px; min-height: 8px; margin-top: 7px; }
  #sceneProgress i { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,.28); box-shadow: 0 1px 2px rgba(0,0,0,.45); }
  #sceneProgress i.on { background: #ffe6a0; box-shadow: 0 0 8px rgba(255,218,110,.7); }
  #sceneContinue { display: block; text-align: right; font-size: 18px; color: rgba(255,255,255,.5);
                   margin-top: 6px; opacity: 0; transition: opacity .3s;
                   animation: scPulse 1.1s ease-in-out infinite; }
  #sceneSkipBtn { position: absolute; top: calc(env(safe-area-inset-top,0px) + 14px); right: 16px;
                  background: rgba(0,0,0,.38); color: rgba(255,255,255,.65);
                  border: 1px solid rgba(255,255,255,.22); border-radius: 20px;
                  padding: 5px 14px; font-size: 15px; font-weight: 600; letter-spacing: .04em;
                  cursor: pointer; z-index: 1; }
  @keyframes scFadeIn  { from { opacity: 0 } to { opacity: 1 } }
  @keyframes scCharIn {
    0%   { opacity: 0; transform: translateY(28px); animation-timing-function: ease-in-out; }
    55%  { opacity: 1; transform: translateY(0) scaleX(1.058) scaleY(.945); animation-timing-function: ease-in-out; }
    80%  { transform: translateY(0) scaleX(.970) scaleY(1.031); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  @keyframes scPulse   { 0%,100% { opacity: .4 } 50% { opacity: .9 } }
  html[data-motion="reduced"] { #sceneContinue, #sceneVideo.scHoldPayoff, #sceneVideo.scHoldPreview { animation: none; } }

  /* ═══════════ TABLET / WIDE (≥640px: iPad 10", tablets) ═══════════ */
  @media (min-width: 640px) {
    /* top nav bar + game HUD */
    .mapBar, #hud         { max-width: 700px; }
    /* map / achievements scrollable content */
    .mapScroll            { max-width: 700px; }
    /* ⚠️ **맵만 넓히지 않는다** (오너 리포트 2026-08-08 "맵이 너무 줌인된 느낌 — 좌/우 폭을 모두 쓰니까
       섹션 프레임이 커져서 타일이 너무 크게 표시된다").
       왜 맵만 다른가: 배지·설정·상점은 **세로 목록**이라 넓어지면 줄이 길어질 뿐 항목 크기는 그대로다.
       맵은 **4열 고정 격자**라 폭이 늘면 그만큼 타일이 그대로 커진다 — 460일 때 ~88px이던 타일이
       700에서 ~145px(+65%)이 된다. 그게 「확대해 놓은 것 같다」의 정체다.
       ⚠️ **열을 늘려 해결하지 말 것.** 4열은 2026-07-28에 오너가 5열에서 **일부러 줄인** 값이다 —
       타일 안에 전역 스테이지 번호(3자리)를 넣기 위해서였고, 열을 늘리면 그 숫자가 다시 안 들어간다
       (styles/app/05-map.css `.node` 폰트 주석 참조). 그래서 폭을 잡는다.
       숫자를 조절하고 싶으면 여기 하나만 바꾸면 된다: 460 = 폰과 완전히 같은 타일 크기(가장 안전),
       520 = 타일 ~100px, 560 = ~110px. 위 `.mapBar`도 같이 맞춰야 바와 본문이 어긋나 보이지 않는다. */
    #map .mapScroll, #map .mapBar { max-width: 460px; }
    /* settings content column */
    #settings .mapScroll  { max-width: 660px; }
    /* 상점도 맵과 같다 (오너 2026-08-08 "상점도 같은 원칙으로 조절") — 넓은 화면에서 넓히지 않고
       제 기본 폭(430px, styles/app/09-daily-badges-shop.css)을 그대로 쓴다. 옛 660px 확장은 삭제했다.
       ⚠️ 상점은 세로 목록이라 맵처럼 항목이 커지지는 않지만, 오너가 두 화면을 같은 원칙으로 묶기로 했다. */
    /* gallery: wider journey column */
    /* title nav bar — cap width AND CENTER it. Base sets left+right(!important); adding only `width`
       over-constrains the fixed box → it pins to the left. Switch to left:50% + translateX to center. */
    .titleNav { left: 50% !important; right: auto !important; transform: translateX(-50%); width: min(100%, 600px); }
  }
/* The geometry readout is useful while tuning a live board in ?dev mode, but it is appended to
   <body> and otherwise survives navigation. Keep developer instrumentation scoped to gameplay. */
body:not([data-screen="game"]) #fitDebug {
  display: none !important;
}

  /* ═══════════ 다층 연습판 — 층 전환 (fx/layerHandoff.js) ═══════════
     전용 복제 레이어만 움직인다. #board와 .gameBoardWrap의 transform은 읽거나 쓰지 않는다. */

  /* Layer state is a neutral location label, not progress or payout. The persistent state now lives in
     .plateMetaRow beside difficulty; the board itself carries the transition. */

  /* 완성판의 복제본이 사는 전용 레이어. 같은 완성 영역의 칸은 동일한 WAAPI 벡터를 받아 도형 형태를
     유지하고, 바깥 컨테이너는 boardFit의 현재 줌만 상속한다. */
  #layerLift {
    position: absolute;
    left: 0;
    top: 0;
    z-index: 3;
    pointer-events: none;
    transform-origin: 0 0;
    transform: scale(var(--board-zoom, 1));
  }
  #layerLift .layerLiftInner {
    position: relative;             /* 복제된 .leadLayer(absolute)의 기준점 */
    display: grid;
    gap: 0;
    will-change: transform, opacity, filter;
  }
  /* 진짜 보드는 **투명해질 뿐**이다 (transform 무접촉). 복제본이 그 자리를 대신 선다. */
  #game #board.layerLifting { opacity: 0; }

  html[data-motion="reduced"] {
    /* 퇴장은 생략하고 새 층의 120ms 불투명도 전환만 남긴다. */
    #layerLift { display: none; }
  }

/* ── 칸노의 인사 (pet_recruit_spec §5, 오너 2026-08-06 개정) ────────────────────────────────────
   손이 멈춰 있을 때만 보드 **아래 여백**에 나타나 **앉았다 → 윙크(또는 엄지척) → 일어나 걸어 나간다.**
   판 위(보드 영역)에는 올라가지 않는다 — 옛 금지의 대상은 판 위였고 여기는 여백이다.
   ⚠️ 처음에는 「가로질러 지나가기」였는데 오너가 바꿨다: "윙크, thumbs up을 애니메이션으로 만들어서
   이곳에 쓰는 게 더 어울려" → "윙크하고 일어나서 나가버린다. 엄지척하고 일어나서 나가버린다."
   지나가기만 하면 **칸노가 플레이어를 본 적이 없다** — 반응이 들어가야 동행자가 된다.

   ⚠️ 세 반응 프레임은 **한 시트(thumsup)에서만** 뽑았다. 시트를 섞으면 색·얼굴·배율이 어긋난다.
   ⚠️ 걷기 프레임 간격 130ms = 오너가 GIF로 고른 값. 나가는 구간 1.58초 = 4프레임 3바퀴.
   ⚠️ 반응 한 컷만 var(--kanno-react)로 갈아 끼운다 — 윙크/엄지척 두 벌을 통째로 복제하면 타임라인을
   고칠 때 한쪽만 고치는 사고가 난다(커스텀 속성은 계산값 시점에 대입되므로 키프레임 안에서 동작한다).
   ⚠️ 방향이 **왼쪽**인 이유: 그림이 전부 머리를 왼쪽에 두고 그려져 있다. 반전하면 수여식의 앉기와
   방향이 어긋난다(같은 에셋을 두 곳에서 쓴다).
   ⚠️ 샤(.stageBuddy)는 왼쪽 아래(left:1%)에 있다 — 칸노는 **오른쪽**에서 시작하고, 샤가 말하는 중이면
   JS가 아예 시작하지 않는다(stageEntrance.js maybePetCameo). --sha-tint를 쓰지 않는 별도 요소다. */
  .stagePetCameo {
    position: absolute; right: 7%; bottom: 2%; z-index: 6;
    width: clamp(62px, 17vw, 92px); aspect-ratio: 250 / 224;
    pointer-events: none; opacity: 0;
    background: url('../../assets/companion/kanno/react-1.webp') center bottom / contain no-repeat;
    filter: drop-shadow(0 3px 6px rgba(20,30,12,.42));
    --kanno-react: url('../../assets/companion/kanno/react-2.webp');   /* 기본 = 엄지척 */
  }
  .stagePetCameo[data-react="wink"] { --kanno-react: url('../../assets/companion/kanno/react-3.webp'); }
  #game .stagePetCameo.on {
    /* 네 겹으로 나눈다 — 자세(앉음/반응) · 걷기 프레임 · 이동 · 소리 신호.
       ⚠️ 자세와 걷기를 **한 키프레임에 몰아넣지 않는다.** 처음엔 그렇게 짰는데 걷기 프레임을
       퍼센트로 20줄 늘어놓아야 했고, 총 길이를 바꿀 때마다 그 20줄을 다시 계산해야 했다.
       뒤에 오는 애니가 같은 속성을 이기므로, 걷기는 **지연 시작 + infinite**로 두면 자세를 덮는다.
       ⚠️ `petCameoCue`는 아무것도 그리지 않는다 — 소리 낼 순간을 알리는 신호다(숫자는 CSS만 안다). */
    animation: petCameoMove 4.8s linear both,
               petCameoPose 2.2s steps(1) both,
               petCameoStepOut .52s steps(1) 2.2s infinite,
               petCameoCue 1ms linear 1.32s both;
  }
  @keyframes petCameoCue { from { --kanno-cue: 0; } to { --kanno-cue: 1; } }
  /* 앉아 있다가(0~60%) 반응한다(60%~). 반응 한 컷만 변수로 갈아 끼운다. */
  @keyframes petCameoPose {
    0%   { background-image: url('../../assets/companion/kanno/react-1.webp'); }
    60%  { background-image: var(--kanno-react); }
  }
  /* 일어나 걷는다 — 4프레임 130ms 순환. 자세 애니보다 **뒤에** 있으므로 도는 동안 그것을 이긴다. */
  @keyframes petCameoStepOut {
    0%   { background-image: url('../../assets/companion/kanno/walk-1.webp'); }
    25%  { background-image: url('../../assets/companion/kanno/walk-2.webp'); }
    50%  { background-image: url('../../assets/companion/kanno/walk-3.webp'); }
    75%  { background-image: url('../../assets/companion/kanno/walk-4.webp'); }
  }
  /* 나타남(살짝 떠오르며) → 제자리에서 반응 → **화면 왼쪽 밖으로 완전히 걸어 나간다.**
     ⚠️ 페이드아웃으로 끝내지 않는다. 처음엔 -42vw만 가고 사라지게 뒀는데, 시작 위치가 오른쪽
     끝(right:7%)이라 **화면 한가운데서 스르륵 없어졌다** — 오너 지적 "화면 바깥으로 걸어나간다"와
     다른 연출이었다. -112vw면 어느 화면 폭에서도 스프라이트가 왼쪽 밖으로 완전히 빠진다.
     자리를 잡은 뒤 반응하는 동안에는 이동이 없다(연출 헌법 §4.6 ④). */
  @keyframes petCameoMove {
    0%   { opacity: 0; transform: translate(0, 14px); }
    5%   { opacity: 1; transform: translate(0, 0); }
    46%  { opacity: 1; transform: translate(0, 0); }
    100% { opacity: 1; transform: translate(-112vw, 0); }
  }
  /* 감속 모드에서는 아예 나오지 않는다 — JS가 앞에서 return하므로 이 규칙은 안전망이다. */
  html[data-motion="reduced"] #game .stagePetCameo { animation: none; opacity: 0; }

  /* ── [DEV] 스테이지 스킵 버튼 (오너 2026-08-07) ────────────────────────────────────────────────
     `?dev=1`/`gf_dev`에서만 보이는 개발 도구. 출시 유저에게는 game/main.js가 DOM 수준에서 막는다.
     ⚠️ **`[hidden]` 짝 규칙이 없으면 이 버튼은 안 숨는다.** 바로 위 `#game .controls .gameActBtn`이
        작성자 `display: grid`를 주는데, 그건 브라우저 기본 `[hidden] { display: none }`을 **이긴다**.
        이 저장소에서 실제로 겪은 함정이다(`#noDrags`의 `saleRow.hidden`이 아무 일도 안 했던 건,
        `#noDrags .row { display: grid }`가 같은 이유로 이기고 있었기 때문 — CLAUDE.md에 기록).
        `hidden`을 쓰는 순간 이 두 줄은 한 몸이다.
     ⚠️ 출시 버튼처럼 보이지 않게 **일부러 다르게** 둔다 — 스크린샷에 섞여 들어갔을 때 "이게 뭐지"가
        바로 보여야 한다. 점선 테두리 + 낮은 채도. */
  #game .controls .devSkipBtn[hidden] { display: none; }
  #game .controls .devSkipBtn {
    font-size: clamp(17px, 4.6vw, 22px);
    line-height: 1;
    color: #cbb9a0;
    border-style: dashed;
    opacity: .8;
  }
