  /* ── 조각 귀속(fx/pieceHandoff.js) — 원본 보드 감추기 ──────────────────────────────────────────
     복제본이 뜬 **다음 프레임**에 이 클래스가 붙는다(같은 프레임에 숨기면 한 프레임 깜빡인다).
     복제본은 #game 아래 fixed 레이어에 있으므로 이 규칙에 걸리지 않는다.
     감속 모드에서는 이동 없이 이 페이드만으로 "판이 물러나고 카드가 온다"를 전한다(150ms 크로스페이드). */
  #board.handoffOut > * { opacity: 0; transition: opacity .1s linear; }
  html[data-motion="reduced"] #board.handoffOut > * { transition: opacity .15s ease; }

  /* The ENTIRE modal (ornate frame + integrated button, exactly as the atlas designed it) is ONE baked
     image. The card just sizes to its native ratio; every piece of content is absolutely positioned at a
     fixed % over it — so content NEVER affects the card's size (no stretch, no aspect override, no
     positioning-context surprises). This is the robust ground-up rebuild. */
  #win .card {
    position: relative;
    width: min(92vw, 360px);
    max-width: none;                /* override shared .card max-width:320px */
    aspect-ratio: 760 / 794;        /* complete_modal_full.webp native ratio → painted undistorted */
    background: url('../../assets/win/modal_full_victory_glow.webp') center / 100% 100% no-repeat;
    border: none;                   /* override shared .card 1px border + radius */
    border-radius: 0;
    padding: 0;                     /* override shared .card padding */
    filter: saturate(1.08) brightness(1.04);
    overflow: visible;
  }
  /* The baked frame fixes the available slots. Group stars + verdict into one result block so they move as
     one fact; reward and action remain separate beats below it. */
  #win .card > .winResult,
  #win .card > .winCoins,
  #win .card > #winNext {
    position: absolute;
    left: 0; right: 0;
    margin: 0 auto;
    text-align: center;
    z-index: 5;
  }
  #win .card > .winResult {
    top: 13%;
    left: 9%;
    right: 9%;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 3px;
  }
  #win .winResult > h2 { margin: 0; }
  #win .card > #winFlawMeter {
    position: absolute;
    left: 0;
    right: 0;
    top: 44%;
    margin: 0 auto;
  }
  #win .card > .winCoins { top: 51%; }
  #win.wc-flawless:not(.no-meter) .card > .winCoins { top: 58%; }
  #win .card > #winNext { top: 79%; }
  /* Sparse cards use the center of the parchment; only the two variants with another information row need
     different coordinates. Keeping this to three layouts prevents independent percentage systems from
     drifting back into collisions. */
  #win.settling.wc-ordinary .card > .winResult,
  #win.settling.wc-elite    .card > .winResult,
  #win.settling.wc-flawless.no-meter .card > .winResult { top: 16%; }
  #win.settling.wc-ordinary .card > .winCoins,
  #win.settling.wc-elite    .card > .winCoins,
  #win.settling.wc-flawless.no-meter .card > .winCoins { top: 52%; }
  #win.settling.wc-boss .card > .winResult   { top: 8%; }
  #win.settling.wc-boss .card > .winCoins    { top: 40%; }
  #win.settling.wc-boss .card > #winAdDouble { top: 58%; }
  /* ── 광고 버튼이 뜨는 **모든** 카드에 보스와 같은 간격을 준다 (오너 2026-08-06 "31번 완료 모달에서
     텍스트와 도형이 침범한다") ────────────────────────────────────────────────────────────────
     ⚠️ 버그의 정체: `#winAdDouble`의 자리가 **`wc-boss`에만** 정의돼 있었다. 그런데 이 버튼은 보스가
        아닐 때도 뜬다 — **무결점 재도전 광고**와 **빛 거두기 반복 광고**가 같은 슬롯을 쓴다
        (showWin.js의 `showFlawlessRetryAd` · `showGatherRepeatAd`). 그 경우 기본값 `top: 52%`로 떨어져
        보상 줄(`.winCoins { top: 51% }`)을 **덮는다.**
     ⚠️ 실측(390×844, 카드 374px): wc-boss **0px** · wc-flawless/wc-ordinary/wc-elite **각 46px 겹침**.
        보상 숫자가 광고 버튼 뒤로 완전히 사라진다.
     ⚠️ 원인이 된 잘못된 전제는 showWin.js 주석에 적혀 있었다 — "same empty-on-non-boss reward zone
        position". 그 자리는 **비어 있지 않다.** 보상 줄이 거기 있다.
     ⚠️ 보스 카드에는 같은 값이 두 번 걸리지만(위 줄 + 이 줄) 값이 같아 충돌이 없다.
     ⚠️ 무결점 열쇠 미터와는 겹치지 않는다: 재도전 광고는 `flawlessRetryCapped`(이미 무결점인 판을 다시
        푼 경우)에만 뜨므로 그때 `newlyFlawless`가 거짓 → 미터가 없다(`no-meter`). */
  #win.settling.has-ad .card > .winResult   { top: 8%; }
  #win.settling.has-ad .card > .winCoins    { top: 40%; }
  #win.settling.has-ad .card > #winAdDouble { top: 58%; }
  /* ── 무결점 미터가 **함께** 뜨는 압축 배치 (오너 2026-08-07 "53번 보스판 무결점 시 겹침") ────────
     ⚠️ 어제 고친 것과 **같은 뿌리, 한 겹 아래**다. 카드의 줄 위치를 「변형 하나」(wc-*)로 고르는데,
        실제로는 **줄의 유무가 서로 독립**이다 — 광고 버튼 · 무결점 미터 · 연속 현수막이 각각 따로 뜬다.
     ⚠️ 이번 사고: `#winFlawMeter`는 **변형과 무관하게 44% 고정**인데, `wc-boss`/`has-ad`가 보상 줄을
        51% → **40%**로 올려 미터 자리로 밀어 넣었다. 실측 **27px 겹침** — `+30`과 `열쇠 조각 +1`이
        같은 자리에 겹쳐 찍혔다(스크린샷 확인).
        무결점 변형만 이 문제를 피하고 있었다: `wc-flawless:not(.no-meter)`가 보상을 58%로 **내려서**
        미터(44%)를 비켜 갔다. 보스·광고 배치에는 그 대응이 없었다.
     ⚠️ 그래서 다섯 줄을 **다시 쌓는다.** 미터가 없을 때(`no-meter`)는 위 규칙이 그대로 쓰인다.
        간격은 카드 374px(390×844) 기준으로 잡았고, 브라우저 실측으로 전 쌍 0px을 확인했다. */
  #win.settling.wc-boss:not(.no-meter) .card > .winResult,
  #win.settling.has-ad:not(.no-meter)  .card > .winResult   { top: 6%; }
  #win.settling.wc-boss:not(.no-meter) .card > .winCoins,
  #win.settling.has-ad:not(.no-meter)  .card > .winCoins    { top: 29%; }
  #win.settling.wc-boss:not(.no-meter) .card > #winFlawMeter,
  #win.settling.has-ad:not(.no-meter)  .card > #winFlawMeter { top: 42%; }
  #win.settling.wc-boss:not(.no-meter) .card > #winAdDouble,
  #win.settling.has-ad:not(.no-meter)  .card > #winAdDouble  { top: 56%; }
  /* ⚠️ **「다음」 버튼만 이 재배치에서 빠진다** (오너 신고 2026-08-13: "광고 보고 코인 받기 화면의
     다음 버튼 텍스트가 약간 위로 쏠려 있다").
     위 네 줄은 카드 안에서 자유롭게 움직여도 되는 **글자**지만, 「다음」은 다르다 — 초록 버튼 그래픽이
     프레임 이미지(modal_full_victory_glow.webp)에 **구워져** 있고 #winNext는 그 위에 얹힌 투명한
     라벨+터치 영역이다(10-game-screen.css 주석). 그림은 못 움직이는데 라벨만 77%로 올리면 글자가
     버튼 그림 위로 벗어난다.
     실측: 그림 속 버튼은 이미지 세로 78.3%~93.7%(중심 **86.0%**)에 있다. 라벨 상자는 높이 13%이므로
     중심 = top + 6.5% → 79%면 85.5%(−2px) · 80%면 86.5%(+2px) · **77%면 83.5%(−10px)**.
     그래서 여기서는 손대지 않고 위의 `#win.settling .card > #winNext { top: 80% }`를 그대로 쓴다.
     자리도 충분하다 — 광고 줄은 56%에서 시작해 78.3%에서 끝나고 버튼 상자는 80%에서 시작한다.
     ⚠️ 다시 올리고 싶어지면 **프레임 이미지의 버튼도 같이 옮겨야 한다.** 라벨만 옮기는 것은 금지. */
  /* 무결점 플레이 학습 한 줄: 보상 아래 여운의 또렷한 한 줄 (A=아차상, B=첫 무결점). 늦게 페이드인. */
  #win .card > #winFlawTeach {
    position: absolute; left: 0; right: 0; top: 66%; margin: 0 auto; max-width: min(68%, 250px);
    text-align: center; z-index: 6; pointer-events: none; line-height: 1.35;
    /* 2026-07-29 (readability fix): floor raised 11px→15px — this is a sentence the player must read
       ("도움 없이 풀면 별 셋 = 무결점!"), previously the smallest text on a card whose Next button is 21px.
       Max/vw left untouched per the readability-fix rule (min-only on clamp()); since max(12.5px) is below
       the new min, the clamp now always resolves to the flat 15px floor — wraps within its max-width, no
       nowrap, so this doesn't risk overflow. */
    font-size: clamp(15px, 3vw, 12.5px); font-weight: 800;
    /* 2026-07-19: gold fill + thin dark stroke so the plain-on-cream teach line reads on the bright victory-glow
       bg (the .eliteNudge variant below sits on its own dark pill and cancels the stroke).
       2026-07-31: 금색 채움 → 짙은 갈색. 이건 **읽어야 하는 문장**이라 대비가 가장 중요한 줄인데,
       금색+얇은 테두리는 크림 배경에서 가장 약한 조합이었다. */
    color: var(--gold-deep); -webkit-text-stroke: 0; paint-order: stroke fill;
    text-shadow: 0 1px 0 rgba(255,248,222,.5);
  }
  #win .card > #winFlawTeach[hidden] { display: none; }
  #win .card > #winFlawTeach.eliteNudge {
    max-width: min(74%, 270px);
    padding: 5px 10px 6px;
    border-radius: 999px;
    border: 1px solid rgba(218,183,255,.5);
    background: linear-gradient(180deg, rgba(88,48,130,.38), rgba(45,24,75,.32));
    color: #f1ddff; -webkit-text-stroke: 0;
    text-shadow: 0 1px 3px rgba(25,10,45,.75), 0 0 8px rgba(170,105,245,.26);
    box-shadow: inset 0 1px 0 rgba(255,246,255,.18), 0 2px 7px rgba(30,12,55,.22);
  }
  #win .card > #winFlawTeach.eliteNudge::before {
    content: "◆";
    margin-right: 5px;
    color: #d6b2ff;
    text-shadow: 0 0 8px rgba(196,136,255,.58);
  }
  #win.show #winFlawTeach { animation: winButtonIn .4s ease-out 1.45s both; }
  /* ── 학습 안내(`.teachHold`)만 **빈 세 번째 별에 붙여** 앞으로 당긴다 (오너 2026-08-03) ────────────
     문제: 1.45s는 「다음」 버튼(1.58s)과 사실상 동시였다 — 안내가 **주목받을 독립적인 박자가 없었다.**
     시선은 버튼으로 가고, 자동 진행(3.8s) 전에 탭하면 안내는 읽히기도 전에 사라진다.
     해법: 2★일 때 세 번째 별이 켜지려다 사그라드는 연출(winSequence.js, 610ms에 `.ghost` 부착)의
     **직후**에 붙인다. "별 하나가 비었다"를 눈이 본 그 순간에 "왜 비었는지"가 이어지므로 인과가 읽힌다.
     ⚠️ `.teachHold`가 없는 경우(자수정 안내 `eliteNudge` 등)는 **건드리지 않는다** — 그건 학습이 아니라
        놓친 보상 조건 설명이고, 코인 카운트업 뒤에 오는 지금 타이밍이 맞다. */
  #win.show.teachHold #winFlawTeach { animation-delay: .78s; }
  /* 안내가 읽힐 박자를 벌기 위해 **학습 카드에서만** 「다음」을 뒤로 민다. 자동 진행(3.8s)은 그대로 두므로
     버튼이 뜬 뒤에도 2초가 남는다 — 진행을 막지 않는다(오너: 확인 버튼·강제 차단 없음). */
  #win.show.teachHold #winNext { animation-delay: 1.8s; }
  #win.settling.show.teachHold #winNext { animation-delay: 1.8s; }
  /* secondary "replay this level" — pill below the baked card, reads on the dark dim */
  #win .card > #winReplay {
    position: absolute; left: 50%; top: calc(100% + 14px); transform: translateX(-50%);
    margin: 0; display: inline-flex; align-items: center; gap: 7px; white-space: nowrap;
    padding: 9px 20px 10px; border: 1.5px solid rgba(255,255,255,.55); border-radius: 999px;
    background: rgba(20,15,9,.5); color: #fff; font-size: 15px; font-weight: 800;
    box-shadow: 0 4px 12px rgba(0,0,0,.45); -webkit-tap-highlight-color: transparent; cursor: pointer; z-index: 6;
  }
  #win .card > #winReplay::before { content: "↺"; font-size: 16px; font-weight: 900; line-height: 1; }
  #win .card > #winReplay:active { transition: transform .06s ease-out; transform: translateX(-50%) scaleX(1.06) scaleY(.90); filter: brightness(.9); }
  /* Elite CTA — ONE amethyst pinnacle slot below Replay, shared by BOTH elite buttons (design panel 2026-07:
     "자수정 고정 슬롯, 라벨만 교체"): #winElite "◆ 엘리트 도전" after a 3★(무결점) clear · #winEliteNext
     "◆ 다음 엘리트 ›" after an elite clear. Their show-conditions are mutually exclusive, so they share the
     same fixed spot — the spot itself becomes the prestige signature ("자수정이 뜨는 그 자리 = 엘리트 문").
     Amethyst matches the map's elite gem (.eliteSocket.filled); both enter late (.eliteReveal) as an encore. */
  #win .card > #winElite,
  #win .card > #winEliteNext {
    position: absolute; left: 50%; top: calc(100% + 68px); transform: translateX(-50%);
    margin: 0; display: inline-flex; align-items: center; justify-content: center; gap: 7px; white-space: nowrap;
    min-height: 44px; box-sizing: border-box;   /* 44px minimum tap target (panel/UX) — floats over the dim, no layout impact */
    padding: 9px 22px 10px; border: 1.5px solid rgba(214,178,248,.92); border-radius: 999px;
    background: linear-gradient(180deg, rgba(122,66,190,.94), rgba(74,38,132,.96));
    color: #f0e2ff; font-size: 14.5px; font-weight: 900; letter-spacing: .2px;
    box-shadow: 0 4px 14px rgba(90,44,150,.55), inset 0 1px 0 rgba(230,210,255,.42), 0 0 9px rgba(154,85,224,.5); -webkit-tap-highlight-color: transparent; cursor: pointer; z-index: 6;
  }
  #win .card > #winElite::before,
  #win .card > #winEliteNext::before { content: "◆"; font-size: 12px; line-height: 1; color: #e9d8ff; text-shadow: 0 0 5px rgba(200,150,240,.8); }
  #win .card > #winElite:active,
  #win .card > #winEliteNext:active { transition: transform .06s ease-out; transform: translateX(-50%) scaleX(1.06) scaleY(.90); filter: brightness(.92); }
  #win .card > #winElite[hidden],
  #win .card > #winEliteNext[hidden] { display: none; }
  /* encore entrance — the same gentle amethyst pop for both, arriving after the settled card is read */
  /* fill-mode: backwards (both 아님) — #winElite/#winEliteNext에 :active 눌림 피드백이 있어서,
     both였다면 애니 종료 후에도 100% 프레임이 계속 입혀져 그 피드백을 영구히 죽인다(§7). */
  #win .card > #winElite.eliteReveal,
  #win .card > #winEliteNext.eliteReveal { animation: eliteReveal .7s cubic-bezier(.3,1.3,.5,1) backwards; }
  /* translateX(-50%) 중앙정렬은 모든 프레임이 그대로 들고 간다(기본 transform 보존). */
  @keyframes eliteReveal {
    0%   { opacity: 0; transform: translateX(-50%) translateY(9px) scale(.88);
           animation-timing-function: cubic-bezier(.22,.72,.3,1); }
    38%  { opacity: 1; transform: translateX(-50%) translateY(-2px) scaleX(.95) scaleY(1.08);
           animation-timing-function: cubic-bezier(.4,0,.2,1); }
    52%  { transform: translateX(-50%) translateY(0) scaleX(1.049) scaleY(.954); animation-timing-function: ease-in-out; }
    76%  { transform: translateX(-50%) translateY(0) scaleX(.968) scaleY(1.033); animation-timing-function: ease-in-out; }
    92%  { transform: translateX(-50%) translateY(0) scaleX(1.011) scaleY(.989); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
  }
  html[data-motion="reduced"] #win .card > #winElite.eliteReveal,
  html[data-motion="reduced"] #win .card > #winEliteNext.eliteReveal { animation: none; }
  /* 빛 거두기 = 보상(보너스) 스테이지 정체성: 승리 카드 오퍼는 두 줄 — 골드 "보너스" 배지 + 이름, 그 아래
     별빛 보상 명명. 자수정 슬롯(프레스티지)은 유지하되 ◆는 골드 배지로 대체(이건 REWARD 라운드라 골드 강조). */
  #win .card > #winElite { flex-direction: column; gap: 3px; padding: 7px 20px 8px; white-space: normal; text-align: center; }
  #win .card > #winElite::before { content: none; }
  #win .card > #winElite .weHead { display: inline-flex; align-items: center; gap: 6px; font-size: 15px; font-weight: 900; line-height: 1; }
  /* 12px → 14px: 카드 안에서 가장 작던 문구(같은 2026-07-29 가독성 지적). 버튼은
     white-space:normal이고 두 줄 구조라 커져도 넘치지 않는다 — 3개 언어 실측 확인. */
  #win .card > #winElite .weReward { font-size: 14px; font-weight: 800; letter-spacing: .1px; line-height: 1.15; color: rgba(240,226,255,.9); }
  /* gold "보너스" chip — reused by the win card + the map stage-choice; reward gold on the amethyst pill. */
  .eliteBonusTag { display: inline-flex; align-items: center; padding: 1px 7px 2px; border-radius: 999px; white-space: nowrap;
    background: linear-gradient(180deg, #ffd96a, #e0a83a); color: #5a3a08;
    font-size: 12px; font-weight: 950; letter-spacing: .12em; line-height: 1;
    box-shadow: 0 1px 2px rgba(80,50,8,.35), inset 0 1px 0 rgba(255,255,255,.55); }
  /* boss ad-double offer — same ornate filigree button as every other ad CTA (assets/shop/buy.png), but
     recolored GOLD (::before hue-rotate) so it reads as a premium bonus and stands clearly apart from the
     green "Next" baked just below it. Sits in the (empty-on-boss) reward zone; enters late (praise first).
     Centered via left/right + margin auto — NOT transform — so the winButtonIn entrance can't knock it
     off-center (same rule the title/Next obey, see note above). */
  #win .card > #winAdDouble {
    position: absolute; left: 0; right: 0; top: 52%; margin: 0 auto;
    width: min(72%, 232px); aspect-ratio: 354 / 104; min-width: 0;
    /* unlike the other buy.png buttons (single line, padding 0/4% top/bottom), this one stacks TWO lines
       (label + amount). 0%/4% sat too high (empty space below); flipping to 8%/0% overshot too low.
       Landed between the two failed attempts. */
    padding: 4% 11% 2%; border: none; border-radius: 0; background: none; box-shadow: none;
    display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
    color: #fff7e4; font-size: clamp(15px, 3.4vw, 14.5px); font-weight: 900; letter-spacing: .01em;
    text-shadow: 0 2px 0 #8a5806, 0 3px 5px rgba(74,46,8,.5);
    -webkit-tap-highlight-color: transparent; cursor: pointer; z-index: 7;
  }
  #win .card > #winAdDouble::before {
    content: ""; position: absolute; inset: 0; z-index: -1;
    background: url('../../assets/shop/buy.png') center / 100% 100% no-repeat;
    filter: hue-rotate(-72deg) saturate(1.5) brightness(1.07);   /* green filigree → warm gold */
  }
  #win .card > #winAdDouble[hidden] { display: none; }
  #win .card > #winAdDouble .winAdDoubleAmt { color: #fff3c4; font-weight: 900; }
  #win .card > #winAdDouble:active { transition: transform .06s ease-out; transform: scaleX(1.06) scaleY(.90); filter: brightness(.96); }
  #win .card > #winAdDouble:disabled { opacity: .6; filter: grayscale(.4); pointer-events: none; }
  #win.show #winAdDouble { animation: winButtonIn .42s cubic-bezier(.18,1.35,.3,1) 1.9s both; }
  #win .card::before {
    content: "";
    position: absolute;
    inset: -96px -80px;
    background: url('../../assets/win/light_burst.webp') center / contain no-repeat;
    opacity: .42;
    mix-blend-mode: screen;
    pointer-events: none;
    z-index: -1;
    animation: winAuraSpin 10s linear infinite;
  }
  /* inner ring removed — the modal_canvas.webp frame already provides the border */
  #win .winGem {
    display: none; /* gems now come from modal_canvas.webp 9-slice */
    position: absolute;
    width: 34px;
    height: 34px;
    border: 4px solid #d89625;
    border-radius: 9px;
    transform: rotate(45deg);
    box-shadow: 0 3px 5px rgba(72,31,3,.28), inset 0 2px 3px rgba(255,255,255,.55);
    z-index: 2;
  }
  #win .winGem::after {
    content: "";
    position: absolute;
    inset: 5px 5px auto auto;
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background: rgba(255,255,255,.65);
  }
  #win .winGem.tl { left: -17px; top: -17px; background: radial-gradient(circle at 34% 30%, #fff7ff 0 12%, #ff5fcf 28%, #c4147f 62%, #6b0643 100%); }
  #win .winGem.tr { right: -17px; top: -17px; background: radial-gradient(circle at 34% 30%, #f5fff3 0 12%, #43e06c 30%, #168b35 66%, #08551e 100%); }
  #win .winGem.bl { left: -17px; bottom: -17px; background: radial-gradient(circle at 34% 30%, #f5fff3 0 12%, #43e06c 30%, #168b35 66%, #08551e 100%); }
  #win .winGem.br { right: -17px; bottom: -17px; background: radial-gradient(circle at 34% 30%, #f0fbff 0 12%, #24c7ff 30%, #1378d5 66%, #073e86 100%); }
  /* win mascot: a thumbs-up buddy that pops in beside the card. Entrance/idle motion + side/tilt are
     randomized per win in main.js (revealWinMascot) so it never feels canned; sparkles only on 3★ (.super). */
  /* ⚠️ 크기는 **한 번에 크게 키우지 말 것** — 이미 걸어갔다 돌아온 길이다 (오너 2026-07-31, 같은 날 왕복).
     경위: "작아서 덜 귀엽다"는 리포트로 23vw(390→90px) → 45vw(→176px)로 **2배** 키워 배포했고
     (커밋 ac12830), 실물을 본 오너 판정은 **"원래 크기가 더 나았다"** — 되돌렸다. 즉 이 카드에서
     샤는 주인공이 아니라 **여운**이고(등장도 카드보다 1.7초 뒤다), 커지는 순간 별·별빛 숫자와
     시선을 다투기 시작한다(연출 헌법 §4.1 "한 프레임의 주인공은 하나").
     그때 함께 조정했던 값들(bottom -124px/-15vh로 아래로 자라기, side ±20px)도 같이 원복했다 —
     크기를 되돌리면 그 보정들은 존재 이유가 사라진다.
     ── 2026-08-03 (오너 "귀엽지만 지금은 크기가 좀 작다") ────────────────────────────────────────
     같은 방향의 리포트가 **두 번째**다. 그런데 7월 실패는 "키우면 안 된다"의 증거가 아니라 **"2배는
     과하다"**의 증거다 — 같은 사람이 90px에서 "작다", 176px에서 "원래가 나았다"고 했으므로 답은 그
     사이이고, "작다"가 두 번 먼저 나왔으니 아래쪽에서 조금 올린 지점일 가능성이 높다.
     그래서 **+13%만**(23vw → 26vw, 390px에서 90 → 101px). 틀려도 되돌리는 비용이 없는 폭이다.
     ⚠️ **한 번에 하나만 바꾼다.** 자리(bottom/side)·반짝임(.super 확대)은 이번에 건드리지 않았다 —
        같이 바꾸면 무엇이 좋아졌는지 판단할 수 없다. 이 폭으로 부족하면 그때 자리를 손댈 것.
     ── 실측 (500px 폭, 카드 강제 오픈 + 모션 고정) ────────────────────────────────────────────────
     7월 실패의 근거였던 "별·별빛 숫자와 시선을 다툰다"는 **이 폭에서는 성립하지 않는다**:
       별(.wgStars) 겹침 0 · 코인(.winCoins) 겹침 0 · 화면 밖으로 나감 0 — 좌우 배치 둘 다.
     ⚠️ 다만 **「다음」 버튼과는 원래 겹쳐 있었고**(옛 23vw에서 735px², 새 26vw에서 1323px²) 이번에
        1.8배가 됐다. 이건 이번 변경이 만든 문제가 아니라 **원래 있던 겹침이 커진 것**이다. 버튼은
        `z-index`가 위라 눌리는 데 지장은 없지만, 더 키울 일이 생기면 여기가 먼저 한계에 닿는다 —
        그때는 크기가 아니라 **자리**(bottom/side)를 손대야 한다.
     ⚠️ 다음에 또 키운다면 **실물 측정 먼저**. 취향 판정 전에 겹침 좌표부터 본다.
     ⚠️ "모달보다 먼저 샤를 크게 등장시키자"는 안도 검토 후 보류(같은 날): 카드가 뜬 뒤의 크기 제약은
     타이밍이 아니라 **자리**라서 그것만으로는 안 커지고, 새 비트는 매 클리어 +0.5초를 물린다. */
  #winMascot {
    position: absolute; bottom: -56px; width: clamp(92px, 26vw, 118px); z-index: 2;
    pointer-events: none; opacity: 0; will-change: transform, opacity;
  }
  #winMascot img { width: 100%; display: block; filter: drop-shadow(0 6px 9px rgba(40,20,8,.4)); -webkit-user-drag: none; }
  #winMascot.dance img { visibility: hidden; }
  #winMascot.dance::before {
    content: "";
    position: absolute; inset: 0;
    background: var(--sha-dance-src, url('../../assets/character/sha_dance.webp')) 0 0 / 600% 100% no-repeat;
    filter: var(--sha-tint, brightness(1)) drop-shadow(0 6px 9px rgba(40,20,8,.4));
    animation: shaWinDance .96s linear infinite;
    will-change: background-position;
  }
  /* buddy tucked at the bottom corner — kept close to the frame (not jammed against the screen edge) */
  #winMascot.side-l { left: -8px; }
  #winMascot.side-r { right: -8px; }

  /* ── 실험: 샤를 카드 **상단 중앙 위**로 올리고 1.5배 (오너 2026-08-04 "실험하고 싶어") ────────────
     플래그 `winMascotTopEnabled`로 껐다 켤 수 있다 — 이건 취향 판정이 필요한 변경이라 되돌릴 길을 남긴다.
     끄면 위의 좌/우 하단 배치로 그대로 돌아간다(아래 규칙이 전부 이 속성 선택자 안에 있다).

     ⚠️⚠️ **`transform`으로 가운데 정렬하지 않는다.** 샤의 등장·되튐은 JS(WAAPI, game/winSequence.js
     `revealWinMascot`)가 `translateY(...) rotate(...) scale(...)`을 **통째로 써서** 만든다. 그래서
     `left:50% + translateX(-50%)`로 가운데를 잡으면 애니가 도는 순간 그 이동이 지워져 **샤가 자기 폭의
     절반만큼 왼쪽으로 밀린다.** 이 코드베이스에서 여섯 번 재발한 사고이고(HUD 숫자 3회 · applyZoom ·
     모달 샤 · 맵 팁), `npm run check`의 transform 게이트가 감시하는 바로 그 함정이다.
     대신 `left:0; right:0; margin-inline:auto`로 **레이아웃으로** 가운데를 잡는다 — transform을 한 글자도
     쓰지 않으므로 JS가 무엇을 덮어써도 위치가 흔들리지 않는다.

     ⚠️ 좌/우 하단 배치(`side-l`/`side-r`)는 JS가 매 클리어 무작위로 고르므로 여기서 **무효화**한다.
        JS는 건드리지 않았다 — 플래그를 끄면 그 무작위 배치가 그대로 살아나야 하기 때문이다. */
  html[data-win-mascot="top"] #winMascot {
    /* 세로 위치를 **px로 적지 않는다.** `bottom:100%`는 샤의 **아래끝을 카드 윗변에 건다** — 샤의 키가
       얼마든(폭이 clamp라 화면마다 다르다) 항상 같은 자리에 선다. 고정 px(-200px 등)로 맞추면 좁은
       화면에서는 붕 뜨고 넓은 화면에서는 별을 다시 밟는다. 실측으로 고른 것은 **파고드는 깊이 하나**다. */
    bottom: 100%;
    top: auto;
    /* 카드 윗변을 이만큼 밟고 선다(0이면 공중에 떠 카드와 남남이 된다). -35는 실측으로 골랐다:
       더 깊이 넣으면 발이 **별 줄(.wgStars)을 밟는다** — 그 줄이 이 화면의 결과 표시다.
       390×844 실측: -104px일 때 별 겹침 11050px²·제목 겹침 1274px² → 이 방식에서 **둘 다 0**. */
    margin-bottom: -35px;
    left: 0;
    right: 0;
    margin-inline: auto;
    /* 크기는 **옛 값 그대로**다 — 자리만 옮긴다(오너 2026-08-04, 1.5배를 한 번 보고 되돌린 결정).
       ⚠️ 그래서 여기에 width를 다시 적지 않는다. 위의 기본 규칙 `clamp(92px, 26vw, 118px)`이 그대로
       살아 있고, 이 블록은 **위치만** 바꾼다. 나중에 크기를 만지고 싶어지면 기본 규칙을 고칠 게 아니라
       (그러면 하단 배치까지 같이 커진다) 여기에 한 줄을 더하는 쪽이다.
       1.5배를 시도했을 때의 실측은 남겨 둔다 — 다시 키우려는 사람이 같은 벽을 다시 만나기 때문이다:
       키 ~215px가 되면 **세로 700px보다 짧은 화면에서 머리가 잘린다**(폭 360 기준 840 안전 · 700 -3 ·
       640 -30). 애니 문제가 아니라 카드 위에 그만큼 설 자리가 없는 것이라 도약을 줄여도 안 풀린다. */
  }
  /* 하단 모서리 전용 오프셋을 지운다(위 주석 참조). `left/right: 0`을 되살려야 margin-auto가 산다. */
  html[data-win-mascot="top"] #winMascot.side-l,
  html[data-win-mascot="top"] #winMascot.side-r { left: 0; right: 0; }
  #winMascot .winSparkles { position: absolute; inset: -14% -10% 30%; display: none; }
  #winMascot.super .winSparkles { display: block; }
  #winMascot .winSparkles i {
    position: absolute; width: 12px; height: 12px; opacity: 0;
    background: radial-gradient(circle, #fff 0 22%, #ffe06a 40%, transparent 70%);
    animation: winSpark 1.1s ease-out var(--sd, 0s) infinite;
  }
  #winMascot .winSparkles i:nth-child(1){ left: 4%;  top: 8%;  --sd: 0s; }
  #winMascot .winSparkles i:nth-child(2){ right: 2%; top: 2%;  --sd: .22s; }
  #winMascot .winSparkles i:nth-child(3){ left: 50%; top: -6%; --sd: .44s; transform: translateX(-50%); }
  #winMascot .winSparkles i:nth-child(4){ right: 8%; top: 40%; --sd: .66s; }
  #winMascot .winSparkles i:nth-child(5){ left: 0%;  top: 44%; --sd: .88s; }
  @keyframes winSpark { 0% { opacity: 0; transform: scale(.3); } 35% { opacity: 1; transform: scale(1.1); } 70%,100% { opacity: 0; transform: scale(.5); } }
  html[data-motion="reduced"] { #winMascot .winSparkles i { animation: none; opacity: .85; } }
  html[data-motion="reduced"] #winMascot.dance::before { animation: none; background-position: 100% 0; }
  @keyframes shaWinDance {
    0%, 16.66% { background-position: 0% 0; }
    16.67%, 33.32% { background-position: 20% 0; }
    33.33%, 49.99% { background-position: 40% 0; }
    50%, 66.65% { background-position: 60% 0; }
    66.66%, 83.32% { background-position: 80% 0; }
    83.33%, 100% { background-position: 100% 0; }
  }

  /* Star grade — 2026-07-14 (owner: "별 등급 화면을 완료 모달의 상단으로 옮겨서 한개의 씬으로 통합"): was a
     separate full-screen overlay (#clearGrade) shown BEFORE the card, with its own hold+fade transition —
     now it's the card's OWN first reveal beat, so the whole thing reads as one continuous scene instead of
     two sequential curtains. No separate "완성"/"별 셋" title anymore — the star count itself IS the fact;
     the praise text below (h2) is the only words, so a clear still never double-praises. Sized down from the
     old full-screen centerpiece scale (a secondary accent within the card, not competing with h2/mascot for
     "single hero" focus) — see visual_audio_effects_principles.md §4.1. `perfect`/`flawless` state classes
     now live on #win itself (were on #clearGrade). */
  #win .winGrade {
    position: relative;
    margin: 0 auto;
    text-align: center;
    z-index: 5;
    filter: drop-shadow(0 4px 6px rgba(28,12,2,.5));
  }
  .wgStars { display: flex; align-items: center; justify-content: center; gap: clamp(3px, 1.4vw, 8px); }
  .wgStars span {
    position: relative;
    /* 2026-08-03 owner: rating stars may grow 50%. Preserve the larger center-star hierarchy while
       scaling all three by the same factor, so the grade becomes the card's first readable beat. */
    width: clamp(45px, 13.5vw, 63px); aspect-ratio: 1; opacity: 0;
    background: url('../../assets/win/modal_star.png') center / contain no-repeat;
    filter: drop-shadow(0 4px 3px rgba(96,46,0,.34)) drop-shadow(0 0 10px rgba(255,226,92,.85));
    transform: scale(.15) rotate(-24deg);
  }
  .wgStars span:nth-child(2) { width: clamp(55.5px, 16.5vw, 75px); }
  .wgStars span.on { animation: wgStarIn .74s linear both; }
  /* 무결점 실패 넛징(A): 세 번째=무결점 별이 잠깐 희미하게 켜지려다 사그라든다 — "한 끗" 갭 itch. */
  .wgStars span.ghost { animation: wgStarGhost 1.15s ease-out both; }
  @keyframes wgStarGhost {
    0%   { opacity: 0;   transform: scale(.2) rotate(-18deg); filter: grayscale(.7) brightness(.72) drop-shadow(0 0 6px rgba(255,226,92,.35)); }
    38%  { opacity: .44; transform: scale(.92) rotate(3deg);  filter: grayscale(.5) brightness(.86) drop-shadow(0 0 9px rgba(255,226,92,.55)); }  /* 거의 켜짐 */
    100% { opacity: 0;   transform: scale(.72) rotate(-5deg); filter: grayscale(.92) brightness(.6); }  /* 다시 빈 자리로 사그라듦 */
  }
  html[data-motion="reduced"] .wgStars span.ghost { animation: none; }
  #win.perfect .wgStars span:nth-child(3).on::after {
    content: ""; position: absolute; inset: -17%; border-radius: 50%;
    border: 3px solid rgba(255,239,151,.9);
    box-shadow: 0 0 0 4px rgba(255,191,52,.18), 0 0 14px rgba(255,219,83,.72);
    animation: wgPerfectRing .95s ease-out .12s both;
  }
  @keyframes wgPerfectRing {
    0%   { opacity: 0; transform: scale(.6); }
    30%  { opacity: 1; }
    100% { opacity: 0; transform: scale(1.7); }
  }
  @keyframes wgStarIn {
    0%   { opacity: 0; transform: scale(.15) rotate(-24deg); animation-timing-function: cubic-bezier(.2,.72,.3,1); }
    56%  { opacity: 1; transform: scaleX(1.10) scaleY(1.27) rotate(7deg); animation-timing-function: ease-in-out; }
    78%  { opacity: 1; transform: scaleX(1.09) scaleY(.955) rotate(-2deg); animation-timing-function: ease-in-out; }
    92%  { opacity: 1; transform: scaleX(.98) scaleY(1.02) rotate(-4.4deg); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: scale(1) rotate(-5deg); }
  }

  /* Newly-flawless reward: one physical shard + exact X/N. Chapter size never adds more SVG parts. */
  .flawMeter { display: none; align-items: center; justify-content: center; gap: 8px; margin: 2px auto 4px; z-index: 1; }
  .flawMeter.on { display: flex; }
  .flawMeter .flawShard { width: 58px; height: 40px; display: grid; place-items: center; flex: 0 0 58px;
    transform-origin: center; filter: drop-shadow(0 2px 3px rgba(86,48,9,.35)); }
  .flawMeter .flawShard svg { display: block; max-width: 100%; }
  .flawMeter .flawCopy { display: flex; flex-direction: column; align-items: flex-start; gap: 0; line-height: 1; }
  .flawMeter .flawGain { color: #81501b; font-size: clamp(13px, 3.5vw, 15px); font-weight: 900; }
  .flawMeter .flawCount { color: var(--gold-deep); font-size: clamp(17px, 4.4vw, 19px); font-weight: 950;
    font-variant-numeric: tabular-nums; text-shadow: 0 1px 0 rgba(255,248,222,.5); }
  .flawMeter.lit .flawShard { animation: flawShardLand .5s cubic-bezier(.2,1.4,.3,1); }
  .flawMeter.lit .flawCount { animation: flawCountLand .38s cubic-bezier(.2,1.4,.3,1); }
  @keyframes flawShardLand { 0% { transform: scale(.72) rotate(-8deg); } 55% { transform: scale(1.18) rotate(3deg); } 100% { transform: scale(1) rotate(0); } }
  @keyframes flawCountLand { 0% { transform: translateY(2px) scale(.84); opacity: .45; } 60% { transform: translateY(0) scale(1.12); opacity: 1; } 100% { transform: scale(1); opacity: 1; } }
  /* the forge-spark = a glowing mote flying from the solved board into the key (cause → stored progress) */
  .keySpark { position: fixed; z-index: 80; width: 14px; height: 14px; pointer-events: none; border-radius: 50%;
    background: radial-gradient(circle, #fff6d8 0%, #f6c83c 45%, rgba(246,200,60,0) 72%);
    filter: drop-shadow(0 0 6px rgba(246,200,60,.9)); }
  html[data-motion="reduced"] {
    .flawMeter.lit .flawShard, .flawMeter.lit .flawCount { animation: none; }
  }

  /* leftover-move bonus: a second victory staged over the solved board, not a small HUD transfer */
  #moveBonus {
    position: fixed; inset: 0; z-index: 74; display: none; overflow: hidden;
    pointer-events: none; isolation: isolate;
    background:
      radial-gradient(circle at 50% 48%, rgba(255,246,178,.05) 0 18%, rgba(8,19,12,.24) 58%, rgba(3,9,6,.76) 100%);
  }
  #moveBonus.show { display: block; animation: mbStageIn .28s ease-out both; }
  #moveBonus::before {
    content: ""; position: absolute; inset: -20%; z-index: -1;
    background: radial-gradient(circle, rgba(255,226,92,.18), transparent 48%);
    opacity: 0; transform: scale(.72);
  }
  #moveBonus.jackpot::before { animation: mbAura 1.1s ease-out both; }
  /* 빛 거두기(gather) 보너스는 finalReward가 base×timeMult×movesMult로 계산되지 않으므로(등급 기반 별개
     계산) ×배수 슬롯을 아예 숨긴다 — 보이면 "0×1×별등급"이 롤업된 총액과 안 맞아 보였다(owner 2026-07-16). */
  #moveBonus.noFactors .mbEquation { display: none; }
  #moveBonus .mbStreak {
    display: none;
    width: min(88vw, 300px);
    min-height: 66px;
    box-sizing: border-box;
    grid-template-columns: 42px minmax(0, 1fr) auto;
    align-items: center;
    gap: 9px;
    margin: clamp(14px, 2.2vh, 20px) auto 0;
    padding: 8px 13px 8px 9px;
    border: 1px solid rgba(255, 229, 142, .82);
    border-radius: 8px;
    background:
      linear-gradient(115deg, rgba(28, 54, 34, .95), rgba(70, 52, 27, .96) 61%, rgba(48, 31, 55, .96));
    box-shadow:
      inset 0 1px 0 rgba(255, 250, 218, .3),
      inset 0 -2px 0 rgba(12, 20, 13, .45),
      0 8px 16px rgba(8, 8, 4, .42),
      0 0 18px rgba(188, 121, 255, .26);
    text-align: left;
    transition: opacity .2s ease, transform .2s ease;
  }
  #moveBonus .mbStreak[hidden] { display: none !important; }
  #moveBonus.streakEncore .mbStreak:not([hidden]) { display: grid; }
  #moveBonus.streakEncore.reveal .mbStreak { opacity: 0; }
  #moveBonus.streakEncore.streakLanded .mbStreak { opacity: 1; }
  #moveBonus .mbStreak > img {
    width: 40px;
    height: 40px;
    object-fit: contain;
    filter: drop-shadow(0 0 5px #fff) drop-shadow(0 0 10px rgba(188,121,255,.82));
  }
  #moveBonus .mbStreakCopy {
    display: grid;
    min-width: 0;
    line-height: 1;
  }
  #moveBonus .mbStreakCopy small {
    overflow: hidden;
    color: #e8ddc0;
    font-family: system-ui, sans-serif;
    font-size: 12px;
    font-weight: 850;
    line-height: 1.1;
    white-space: nowrap;
    text-overflow: ellipsis;
    text-shadow: 0 1px 3px rgba(7,12,7,.8);
  }
  #moveBonus .mbStreakCopy strong {
    margin-top: 4px;
    color: #fff7ce;
    font-family: 'Jua', system-ui, sans-serif;
    font-size: 24px;
    font-weight: 400;
    line-height: .92;
    -webkit-text-stroke: 1px #38512f;
    paint-order: stroke fill;
    text-shadow: 0 2px 0 #24341d, 0 0 8px rgba(255, 226, 116, .34);
    white-space: nowrap;
  }
  #moveBonus .mbStreak > b {
    color: #ffe477;
    font-family: 'Jua', system-ui, sans-serif;
    font-size: 36px;
    font-weight: 400;
    line-height: 1;
    -webkit-text-stroke: 1.5px #75521c;
    paint-order: stroke fill;
    text-shadow: 0 3px 0 #4c3411, 0 0 10px rgba(255, 209, 72, .58);
    white-space: nowrap;
  }
  .mbCopy {
    position: absolute; z-index: 5; top: calc(env(safe-area-inset-top,0px) + clamp(150px, 19vh, 174px));
    left: 50%; width: min(92vw, 430px); transform: translateX(-50%); text-align: center;
    filter: drop-shadow(0 5px 8px rgba(16,8,2,.6));
  }
  #moveBonus.show .mbCopy { animation: mbCopyIn .7s cubic-bezier(.18,1.35,.3,1) both; }
  .mbTitle {
    color: var(--gold-pale); font-family: 'Jua', system-ui, sans-serif;
    font-size: clamp(28px, 8.8vw, 42px); line-height: 1.05;
    background: none; -webkit-text-fill-color: var(--gold-pale);
    -webkit-text-stroke: 2px #49632f; paint-order: stroke fill;
    text-shadow: 0 3px 0 #28391d, 0 5px 8px rgba(18,30,12,.48), 0 0 10px rgba(255,218,74,.34);
  }
  .mbSubtitle {
    margin-top: 7px; color: #fff7d6; font-size: clamp(13px, 3.8vw, 17px); font-weight: 900;
    text-shadow: 0 2px 5px rgba(20,8,2,.85); transition: opacity .18s ease;
  }
  .mbEquation {
    display: flex; align-items: center; justify-content: center; gap: clamp(10px, 4vw, 22px);
    margin-top: clamp(12px, 2.3vh, 20px); transition: opacity .2s ease, transform .2s ease;
  }
  .mbNumberBlock {
    position: relative; width: clamp(94px, 26vw, 120px); aspect-ratio: 1.04;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    border-style: solid; border-width: 20px;
    border-image: url('../../assets/common/modal_canvas.webp') 120 fill / 20px / 0 stretch;
    background: transparent;
    filter: drop-shadow(0 8px 8px rgba(18,24,11,.42)) drop-shadow(0 0 10px rgba(255,226,112,.2));
    transform: rotate(-1.5deg);
  }
  .mbNumberBlock.rate { transform: rotate(1.5deg); }
  .mbNumberBlock::before {
    content: ""; position: absolute; z-index: 2; width: 16px; height: 16px; left: -14px; top: -13px;
    transform: rotate(45deg); border: 2px solid #d9c483; border-radius: 4px;
    background: radial-gradient(circle at 32% 28%, #eaffcf 0 10%, #4bdd76 27%, #148341 64%, #064527 100%);
    box-shadow: 0 2px 3px rgba(21,31,13,.5), inset 0 1px 2px rgba(255,255,255,.72), 0 0 8px rgba(72,220,118,.45);
  }
  .mbNumberBlock.rate::before {
    left: auto; right: -14px;
    background: radial-gradient(circle at 32% 28%, #e8fbff 0 10%, #5dcfff 27%, #287bce 64%, #103d7a 100%);
    box-shadow: 0 2px 3px rgba(21,31,13,.5), inset 0 1px 2px rgba(255,255,255,.72), 0 0 8px rgba(80,182,255,.45);
  }
  .mbNumberBlock strong {
    color: var(--gold-pale); font-family: 'Jua', system-ui, sans-serif; font-size: clamp(38px, 11.5vw, 54px); line-height: .8;
    -webkit-text-stroke: 2px #39462b; paint-order: stroke fill;
    text-shadow: 0 3px 0 #202b19, 0 0 8px rgba(255,227,109,.38);
  }
  .mbNumberBlock small {
    max-width: 110%; margin-top: 7px; color: #f5ecd2; font-size: clamp(9px, 2.65vw, 11px); font-weight: 900; line-height: 1.05;
    white-space: nowrap; text-shadow: 0 1px 2px rgba(21,28,14,.9);
  }
  .mbTimes {
    color: #e8d79d; font-family: 'Jua', system-ui, sans-serif; font-size: clamp(30px, 8.5vw, 44px);
    -webkit-text-fill-color: #e8d79d;
    -webkit-text-stroke: 1.5px #41512d; paint-order: stroke fill;
    text-shadow: 0 3px 0 #27331e, 0 0 8px rgba(255,221,90,.28);
  }
  /* ── 보스 정산 vs 노멀 (오너 2026-08-13) ─────────────────────────────────────────────────────
     playMoveBonus(노멀)와 playBossBonus(보스)가 같은 #moveBonus 판을 쓰는데 리듬만 다르고 색이 같아서
     플레이어가 구분을 못 했다. fx/moveBonus.js가 playBossBonus(seal)일 때만 레이어에
     `bossVar-<ember|tide|gilt>`를 붙인다 — playMoveBonus 경로는 이 클래스가 절대 붙지 않고, 「빛
     거두기」(runGatherBonusFx)도 seal을 안 넘기므로 그대로 무색이다. 즉 이 색이 곧 "보스였다"는 표시다.
     ⚠️ 테두리·광채·강조만 물들인다 — 배경을 통째로 칠하지 않는다(숫자가 안 읽히면 실패, §4.1).
     `.mbNumberBlock`의 테두리는 돌 재질 border-image라 border-color로 못 물들이므로, 그 자리를 감싸는
     drop-shadow 글로우로 대신한다(같은 속성을 덮어쓰므로 특이도 걱정 없이 이긴다). */
  #moveBonus.bossVar-ember .mbNumberBlock { filter: drop-shadow(0 8px 8px rgba(18,24,11,.42)) drop-shadow(0 0 13px var(--seal-ember)); }
  #moveBonus.bossVar-tide  .mbNumberBlock { filter: drop-shadow(0 8px 8px rgba(18,24,11,.42)) drop-shadow(0 0 13px var(--seal-tide)); }
  #moveBonus.bossVar-gilt  .mbNumberBlock { filter: drop-shadow(0 8px 8px rgba(18,24,11,.42)) drop-shadow(0 0 13px var(--seal-gilt)); }
  /* 정산 화면 전체의 은은한 아우라(`#moveBonus::before`, 기본은 골드)도 봉인 색으로 — 이 레이어는
     jackpot 단계에서만 보이므로(`mbAura`) 절정의 색을 봉인으로 잇는 것이지 새 절정을 얹는 게 아니다. */
  #moveBonus.bossVar-ember::before { background: radial-gradient(circle, var(--seal-ember), transparent 48%); }
  #moveBonus.bossVar-tide::before  { background: radial-gradient(circle, var(--seal-tide),  transparent 48%); }
  #moveBonus.bossVar-gilt::before  { background: radial-gradient(circle, var(--seal-gilt),  transparent 48%); }
  /* 배수 슬롯을 보스에서만 크게(오너 "작게 스쳐 지나간다") — `[class*="bossVar-"]`로 3종을 한 줄에
     묶는다. 노멀은 애초에 `noFactors`라 이 슬롯 자체가 없으므로 영향이 없다. */
  #moveBonus[class*="bossVar-"] .mbNumberBlock     { width: clamp(104px, 29vw, 134px); }
  #moveBonus[class*="bossVar-"] .mbEquation strong { font-size: clamp(42px, 12.8vw, 60px); }
  .mbRewardLabel {
    margin-top: 4px; color: #fff3be; font-size: clamp(13px, 3.7vw, 17px); font-weight: 900;
    opacity: 0; transform: translateY(8px); transition: opacity .18s ease, transform .18s ease;
  }
  .mbReward {
    color: var(--gold-pale); font-family: 'Jua', system-ui, sans-serif; font-size: clamp(68px, 21vw, 98px); line-height: .95;
    background: none; -webkit-text-fill-color: var(--gold-pale);
    -webkit-text-stroke: 4px #3e512d; paint-order: stroke fill;
    text-shadow: 0 5px 0 #24331b, 0 0 12px rgba(255,247,202,.72), 0 0 24px rgba(255,205,42,.55);
    opacity: 0; transform: scale(.55); transition: opacity .18s ease, transform .18s cubic-bezier(.18,1.55,.3,1);
  }
  #moveBonus.bursting .mbSubtitle { opacity: 0; }
  #moveBonus.bursting .mbEquation { opacity: .22; transform: scale(.82); }
  #moveBonus.bursting .mbStreak { opacity: .22; transform: scale(.9); }
  #moveBonus.bursting .mbRewardLabel,
  #moveBonus.bursting .mbReward { opacity: 1; transform: translateY(0) scale(1); }
  #moveBonus.jackpot .mbEquation { opacity: 0; }
  #moveBonus.jackpot .mbStreak { opacity: 0; }
  #moveBonus.jackpot .mbReward { transform: scale(1.12); }
  .mbSha {
    position: absolute; z-index: 6; right: max(-14px, calc(50% - 230px)); bottom: max(0px, env(safe-area-inset-bottom,0px));
    width: clamp(132px, 40vw, 188px); opacity: 0; transform: translate(45%, 35%) rotate(8deg) scale(.7);
    filter: var(--sha-tint, brightness(1)) drop-shadow(0 10px 10px rgba(12,5,1,.48));   /* costume tint, matches #winMascot/.stageBuddy */
  }
  #moveBonus.jackpot .mbSha { animation: mbShaIn .95s cubic-bezier(.16,1.5,.3,1) both; }
  .mbFlash { position: absolute; inset: 0; z-index: 8; opacity: 0; pointer-events: none; background: #fff5b8; mix-blend-mode: screen; }
  #moveBonus.jackpot .mbFlash { animation: mbFlash .62s ease-out both; }
  .mbFlight, .mbSeal { position: fixed; z-index: 3; pointer-events: none; will-change: transform, opacity; }
  .mbFlight { width: clamp(42px, 12vw, 58px); filter: drop-shadow(0 0 10px #fff) drop-shadow(0 0 20px rgba(255,210,50,.95)); }
  .mbSeal { width: clamp(48px, 14vw, 66px); transform: translate(-50%, -50%); }
  .mbSeal img { display: block; width: 100%; filter: drop-shadow(0 0 8px #fff) drop-shadow(0 0 18px rgba(255,205,45,.95)); }
  .mbSeal::before {
    content: ""; position: absolute; inset: -55%; z-index: -1;
    background: url('../../assets/win/light_burst.webp') center / contain no-repeat; opacity: .72;
    animation: mbSealSpin 2.6s linear infinite;
  }
  .mbRing {
    position: fixed; z-index: 2; width: 22px; height: 22px; border: 4px solid rgba(255,235,126,.95);
    border-radius: 50%; transform: translate(-50%, -50%); box-shadow: 0 0 16px rgba(255,215,65,.9); pointer-events: none;
  }
  .mbSpark {
    position: fixed; z-index: 4; width: 9px; height: 16px; pointer-events: none;
    background: linear-gradient(180deg, #fffbd8, #ffc928); clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
    box-shadow: 0 0 8px rgba(255,225,85,.9); will-change: transform, opacity;
  }
  @keyframes mbStageIn { from { opacity: 0; } to { opacity: 1; } }
  /* translateX(-50%) 중앙정렬은 모든 프레임이 그대로 들고 간다(기본 transform 보존). */
  @keyframes mbCopyIn {
    0%   { opacity: 0; transform: translateX(-50%) translateY(-26px) scale(.86);
           animation-timing-function: cubic-bezier(.22,.7,.3,1); }
    34%  { opacity: 1; transform: translateX(-50%) translateY(-4px) scaleX(.94) scaleY(1.10);
           animation-timing-function: cubic-bezier(.4,0,.2,1); }
    46%  { transform: translateX(-50%) translateY(0) scaleX(1.062) scaleY(.942); animation-timing-function: ease-in-out; }
    70%  { transform: translateX(-50%) translateY(0) scaleX(.967) scaleY(1.035); animation-timing-function: ease-in-out; }
    88%  { transform: translateX(-50%) translateY(0) scaleX(1.014) scaleY(.987); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
  }
  @keyframes mbAura { 0% { opacity: 0; transform: scale(.55); } 34% { opacity: 1; } 100% { opacity: 0; transform: scale(1.25); } }
  @keyframes mbFlash { 0% { opacity: 0; } 18% { opacity: .92; } 100% { opacity: 0; } }
  /* Sha는 이 게임의 캐릭터 자산이다 — 진폭을 아끼지 않는다(연출 헌법 §5). 이동·회전은 착지(46%, 위치·
     회전이 (0,0)/0deg에 도달)까지만 — 그 뒤로는 스쿼시&스트레치 감쇠만 돈다(rule 8). */
  @keyframes mbShaIn {
    0%   { opacity: 0; transform: translate(45%,35%) rotate(9deg) scale(.62);
           animation-timing-function: cubic-bezier(.22,.68,.3,1); }
    30%  { opacity: 1; transform: translate(14%,11%) rotate(-6deg) scaleX(.92) scaleY(1.15);
           animation-timing-function: cubic-bezier(.4,0,.2,1); }
    46%  { transform: translate(0,0) rotate(0) scaleX(1.095) scaleY(.913); animation-timing-function: ease-in-out; }
    68%  { transform: translate(0,0) rotate(0) scaleX(.949) scaleY(1.054); animation-timing-function: ease-in-out; }
    87%  { transform: translate(0,0) rotate(0) scaleX(1.015) scaleY(.985); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translate(0,0) rotate(0) scale(1); }
  }
  @keyframes mbSealSpin { to { transform: rotate(360deg); } }

  #noDrags, #skipModal {
    background:
      radial-gradient(circle at 50% 42%, rgba(255,231,112,.18), transparent 42%),
      rgba(26,33,21,.62);
    backdrop-filter: none; /* same as #win: plain dim, no backdrop blur (avoids frame-vs-blur seam) */
  }
  /* (legacy 44px frame block deleted 2026-07-29 — every declaration was either dead under the shared
     "Shared modal anatomy pass" .modalCard block below, or hoisted into it: position/border-style/
     border-radius/overflow.) */
  /* no glow behind the card — the modal_canvas stone-tablet frame carries the look (clean, like the mockup) */
  #noDrags .card::before, #skipModal .card::before { content: none; }
  /* inner ring removed — the modal_canvas.webp frame already provides the border */
  #noDrags.show .card, #skipModal.show .card {
    animation: winPrizeDrop .67s cubic-bezier(.12,1.18,.28,1) both;
  }
  /* arm-lock: ignore taps on the card for the first 1s after the menu pops (it appears under the
     finger mid-drag) so a stray tap can't fire the ad/buy buttons. The overlay still swallows taps. */
  #noDrags.armLock .card { pointer-events: none; }
  #moveOffer.armLock .card { pointer-events: none; }
  /* skipModal title geometry. Colour/stroke/shadow come from the brown `#skipModal h2, #exitAsk h2`
     rule further down (the 2026-06-30 pass) — the carved-green declarations that used to sit here were
     dead under it and were removed 2026-07-29. #noDrags dropped from the selector: its <h2> carries
     .modalTitle, so the shared block wins on every property this rule declared. */
  #skipModal h2 {
    position: relative;
    margin: 0 0 8px;
    /* 2026-07-29: reserve the close-X's footprint (4px offset + 34px disc) on BOTH sides so the centred
       title can never slide under it. Symmetric padding keeps the title optically centred; it only ever
       changes the line-break point (en "Skip this stage?" now wraps to two lines instead of running into
       the disc). */
    padding-inline: 38px;
    font-size: clamp(26px, 7vw, 34px);
    font-weight: 900;
    line-height: 1.08;
    word-break: keep-all;
    /* keep-all은 한국어 단어 보호용인데 일본어에서는 **어디서도** 줄바꿈이 안 생겨 카드 밖으로
       삐져나온다(실측: ハートがありません 260px in 244px, もう少し余裕をもって冒険する？ 432px in 244px).
       anywhere는 "그냥 두면 넘칠 때만" 쪼개므로 한국어 단어 무결성은 그대로 두고 일본어만 구제한다.
       (2026-07-29 글자 크기 상향 검수에서 발견 — #skipModal h2에 먼저 적용한 것과 같은 처방) */
    overflow-wrap: anywhere;
    /* ⚠️ keep-all은 한국어 단어를 안 쪼개려는 설정인데, 일본어에서는 **어디서도** 줄바꿈이 안 생겨
       「このステージをスキップ？」가 카드 밖으로 72px 삐져나오고(실측) 위 padding도 무력해진다 —
       줄바꿈 기회가 있어야 패딩이 일한다. anywhere는 "그냥 두면 넘칠 때만" 쪼개므로 한국어 단어
       무결성(keep-all)은 그대로 두고 일본어만 구제한다. (2026-07-29, 4단계에서 X를 붙이며 발견) */
    overflow-wrap: anywhere;
  }
  /* :not(...)의 이유는 위 `#ask .card p` 규칙의 주석 참조 — 잔량/하트 줄도 `<p>`다. */
  #noDrags p:not(.walletNote):not(.heartNote),
  #skipModal p:not(.walletNote):not(.heartNote) {
    position: relative;
    margin: 0 auto 18px;
    max-width: 260px;
    color: #1e1408;   /* 「진한 잉크」 — 옛 #6f5636은 석판 위에서 1.73:1이었다 */
    text-shadow: 0 1px 0 rgba(255, 246, 214, .5);
    font-size: clamp(15px, 4.1vw, 17px);
    font-weight: 700;
    line-height: 1.4;
  }
  #noDrags .row, #skipModal .row {
    position: relative;
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 12px;
    align-items: center;
  }
  #noDrags .noDragsActions, #skipModal .skipActions {
    grid-template-columns: minmax(0, 1fr);
    justify-items: center;
    gap: 10px;
  }
  /* AD = the green filigree action button (assets/shop/buy.png) — ornate scrollwork gives the primary CTA
     the most visual pull; matches the mockup's decorated green button. Wider side padding clears the curls. */
  /* 2026-07-31: #wardrobeBuyAdBtn 합류 — 옷장 해금 모달의 광고 버튼만 ui.css 기본 타일(평면)이라
     같은 모달의 금테·석판 사이에서 혼자 웹 버튼으로 보였다. 이제 그 모달도 필리그리(광고)+금테(지불)+
     석판(취소) 세 어휘로 읽힌다. */
  /* 2026-08-01: #restartAd 합류 (config.js `restartAdPerStage`) — 「광고 = 무료로 지금 계속하는 길」은
     모든 모달에서 한 어휘로 말한다. 이 버튼이 붙는 순간 #restartAsk는 A(확인)에서 B(제안)로 승격된다
     (modal_design_system.md §3·§4 — 파괴 행동에 값이 둘이면 대칭 2택이 아니라 위계 스택이다). */
  #noDragsAd, #noLivesAd, #moveOfferAd, #skipAd, #askAd, #restartAd, #wardrobeBuyAdBtn {
    aspect-ratio: 354 / 104; height: auto; min-width: 0;
    padding: 0 11% 4%; border: none; border-radius: 0; box-shadow: none;
    background: url('../../assets/shop/buy.png') center / 100% 100% no-repeat;
    display: inline-flex; align-items: center; justify-content: center;
    color: #fff8e9; font-size: clamp(15px, 3.8vw, 16px); font-weight: 900; white-space: nowrap;
    text-shadow: 0 2px 0 #2c7a1f, 0 3px 5px rgba(18,55,8,.5);
  }
  #noDrags #noDragsAd { grid-column: 1 / -1; justify-self: center; width: min(100%, 240px); }
  #moveOfferAd, #skipModal #skipAd { width: min(100%, 240px); justify-self: center; grid-column: 1 / -1; }
  /* (#askYes 힌트-토큰 버튼 폐지 2026-07-06 — 힌트=하트 단일화) */
  /* skip modal: stack every action full-width (never the 2-col cram), including the lone close tile */
  #skipModal #skipUse, #skipModal #skipBuy { grid-column: 1 / -1; justify-self: center; width: min(100%, 240px); }
  #noDrags .noDragsActions #noDragsBuy,
  #noDrags .noDragsActions #noDragsHint {
    grid-column: 1 / -1;
    justify-self: center;
    width: min(100%, 224px);
  }
  #noDrags .spendChoice {
    grid-column: 1 / -1;
    justify-self: center;
    width: min(100%, 240px);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 5px;
  }
  #noDrags .spendChoice #noDragsBuy { width: 100%; }
  #noDrags .spendChoice #noDragsBal { margin: 2px auto 0; }
  /* BUY = cream/gold companion = the map_card_bg cream+gold panel (9-slice, crisp corners) — rhymes with
     btn_green's gold frame so the two CTAs share one gold vocabulary */
  /* 2026-07-29 (modal_design_system.md §5): #skipBuy LEFT this list — "buy more passes" is a shop detour,
     not spending what I already own, so it dropped to a stone tile. #bossTimeUpRetry JOINED it — retrying a
     timed-out boss really does spend ♥1 (the §10-4 exception). Gold = "spend my own currency, right now". */
  /* 2026-08-01 (오너 결정) → 2026-08-02 확정: 힌트의 지불 버튼은 #askCoin 하나다(옛 #askLife 하트 버튼은
     삭제). 통화만 바뀌었을 뿐 문법("지금 내 재화를 쓴다" = 금테)은 같아 같은 목록에 남는다. */
  #noDragsBuy, #noLivesBuy, #askCoin, #skipUse, #bossTimeUpRetry, #wardrobeBuyYes {
    box-sizing: border-box; min-width: 0; height: 54px; padding: 0 14px 1px;
    display: inline-flex; align-items: center; justify-content: center; gap: 6px;
    border: 12px solid transparent; border-radius: 0;
    border-image: url('../../assets/map/card_bg.webp') 80 fill / 12px / 0 stretch;
    background: transparent; box-shadow: none;
    filter: drop-shadow(0 3px 5px rgba(80,45,8,.24));
    color: #6b3912; font-size: 16px; font-weight: 900; white-space: nowrap;
  }
  #noDrags #noDragsBuy { grid-column: 1 / -1; justify-self: center; width: min(100%, 224px); }
  /* 2026-07-29 (modal_design_system.md §5.4-2): 잔량이 버튼 **안** 부제였다(.askLifeMain/.askLifeBalance
     2단 조판). 이제 잔량은 버튼 바로 아래 한 줄(#askLifeBal)이라 버튼은 라벨 한 줄로 되돌아간다. */
  #askCoin { width: 100%; font-size: clamp(15px, 3.8vw, 16px); }
  /* ⚠️ 이 저장소의 알려진 함정: 위 금테/필리그리 규칙이 display:inline-flex라 **[hidden]의 UA
     display:none을 이긴다** — 명시하지 않으면 JS로 숨긴 버튼이 그대로 보인다(바로 아래
     .spendChoice[hidden]와 .gameMenuBtn[hidden]이 같은 이유로 이미 박혀 있다). */
  #askCoin[hidden], #restartAd[hidden] { display: none; }
  /* 잔량 줄(#askLifeBal)은 코인 잔액 한 줄이다 — id만 옛 이름이 남았다(마크업/CSS를 흔들지 않으려고). */
  /* 이 줄도 기본형(=「새김 홈」)을 그대로 쓴다. 옛 값은 홈을 벗기고 rgba(92,62,26,.82)로 칠해
     석판 위 1.3:1이었다 — 힌트 값이 코인으로 바뀐 뒤로는 **지불 직전에 지갑을 확인하는 줄**이라
     안 보이면 안 된다. 버튼 바로 아래라 기본형보다 한 단계만 작게 둔다. */
  #ask.gameModal #askLifeBal.walletNote {
    margin: 0 auto; font-size: 13px;
  }
  /* 잔액이 모자라면 버튼이 disabled — 상점으로 밀어내지 않는다(광고가 이미 무료 통로다) */
  #askCoin[disabled] { opacity: .45; filter: grayscale(.5); pointer-events: none; }
  /* B안 광고 버튼은 확인 2택(.modalActions) **위**에 한 줄로 앉는다 — 무료 통로가 먼저 읽히도록.
     ⚠️ display를 flex로 덮어쓴다: 공용 필리그리 규칙은 inline-flex인데, 이 버튼만 .modalActions 밖
     (카드 직속)이라 **inline 레벨에는 margin:auto 가운데 정렬이 듣지 않는다**. 다른 광고 버튼들은
     그리드 칸 안이라 justify-self로 가운데를 잡으므로 이 문제가 없다. */
  #restartAsk.gameModal #restartAd {
    display: flex; width: min(100%, 250px); margin: 0 auto 10px;
  }
  /* ⚠️ **바로 위 줄이 위쪽의 `#restartAd[hidden]`을 이겨서 숨김이 통째로 무효였다 (오너 리포트 2026-08-09:
     "튜토리얼 모달인데 광고 버튼이 보인다").** 특이도 싸움이다 —
         `#restartAd[hidden]`               (1,1,0)   display:none
         `#restartAsk.gameModal #restartAd` (2,1,0)   display:flex   ← 이쪽이 이긴다
     JS(`adBtn.hidden = …`)도 `freeRestart` 판정도 멀쩡했고, **CSS만 그 결과를 무시하고 있었다.**
     그래서 같은 강도에 속성을 하나 더해 (2,2,0)으로 되돌린다.
     ⚠️ 위 줄에 선택자를 더 붙이거나 새 규칙을 얹을 때는 이 줄의 특이도도 같이 올릴 것 — 한쪽만
        강해지면 그 순간 다시 안 숨는다. 이 저장소가 `#noDrags`에서 이미 한 번 당한 함정이고
        (game/strokeGates.js의 「saleRow.hidden이 아무 일도 하지 않았다」 주석), 그때 배운 규칙
        **"작성자 display를 준 요소에 hidden을 쓰려면 [hidden] 짝 규칙을 같이 써라"**는 지켜졌는데
        **짝 규칙의 특이도까지는 안 지켜져서** 같은 증상이 다시 났다. */
  #restartAsk.gameModal #restartAd[hidden] { display: none; }
  /* 지불 버튼 + 잔량 줄 한 묶음 — noLives/noDrags의 .spendChoice와 같은 부품, 새 변종 아님.
     (#ask .row는 flex column이라 grid-column/justify-self는 여기선 불필요하다) */
  #ask.gameModal .spendChoice {
    width: min(100%, 224px);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 4px;
  }
  /* display:flex가 [hidden]의 UA display:none을 이기므로 명시한다(.gameMenuBtn[hidden]과 같은 함정) */
  #ask.gameModal .spendChoice[hidden] { display: none; }
  #noDragsBuy .ico, #noLivesBuy .ico { width: 20px; height: 20px; fill: #f5b21e; stroke: #c9821a; stroke-width: 1.3; }   /* filled gold star = the spent currency */
  #noDragsBuy:disabled, #noLivesBuy:disabled, #skipUse:disabled, #wardrobeBuyYes:disabled { filter: grayscale(.5) brightness(.85); opacity: .7; }
  #noDragsAd:active, #noDragsBuy:active, #noLivesAd:active, #noLivesBuy:active, #moveOfferAd:active, #skipAd:active, #skipUse:active, #askAd:active, #askCoin:active, #restartAd:active, #bossTimeUpRetry:active, #wardrobeBuyYes:active { transition: transform .06s ease-out; transform: scaleX(1.06) scaleY(.90); filter: brightness(.96); }
  /* utility row (start over / close) = carved stone tiles (assets/setting/row.png) — clearly secondary */
  #noDrags .rowUtil { margin-top: 16px; gap: 12px; }   /* (#skipModal .rowUtil dropped 2026-07-29 — its lone Close tile became the X) */
  #noDrags .rowUtil2 { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
  /* 2026-07-31 (오너 지시: "모달 CTA 재질을 '여기서 시작' 재질로 통일"): 모달 안에서 초록 CTA만 홀로
     ui.css의 **평면 CSS 버튼**(에메랄드 그라데이션)이라 석판·금테 사이에서 웹 버튼처럼 겉돌았다. 이제 주
     행동도 같은 석판 타일을 쓰고, 위계는 **글자색**으로만 준다(바로 아래 금빛 규칙). 재질을 하나로 줄인
     것이지 버튼 어휘를 새로 만든 게 아니다 — modal_design_system.md §5의 3어휘는 그대로다.
     제외: #wardrobeBuyYes(가격을 라벨에 단 **지불** 버튼 → 금테 유지, 위 §금테 목록) ·
           #ucBtn(수여식은 모달이 아니라 연출 표면 — 연출 헌법 관할, §10-3). */
  #noDragsHint, #noDragsOver, #noDragsSkip, #skipBuy, #bossTimeUpMap,
  #moveOfferNo, #askNo, #restartNo, #restartYes, #attemptSecondary, #exitNo, #exitYes, #leaveNo,
  #attemptPrimary, #leaveYes, #starGateOk, #wardrobeGateGo, #wardrobeBuyCloth,
  #wardrobeBuyConfirm .wardrobeUnlockCancel {
    height: 46px; min-width: 0; padding: 0 12px 3px; border: none; border-radius: 0; box-shadow: none;
    background: url('../../assets/setting/row.png') center / 100% 100% no-repeat;
    filter: drop-shadow(0 3px 4px rgba(18,28,12,.4));
    color: #ece1ca; font-size: 15px; font-weight: 800; white-space: nowrap;
    text-shadow: 0 1px 2px rgba(0,0,0,.55);
  }
  /* loss/destructive actions (start over + quit spend or discard) → ruby label so they're never tapped by
     reflex; harmless dismiss stays neutral stone. Matches the game's ruby = loss language. */
  #noDrags .rowUtil #noDragsOver,
  #restartYes, #attemptSecondary.danger, #exitYes { color: #ff8a76; text-shadow: 0 1px 2px rgba(60,8,4,.85); }
  /* 주 행동(무료로 이어가기·이동) = 같은 석판에 금빛 글자. 루비(손실) ↔ 금빛(권하는 길) ↔ 크림(중립)
     세 글자색이 곧 위계다. `#leaveYes`(나가기)는 권하는 길이 아니므로 크림 중립으로 남긴다 — 확인 모달의
     대칭 2택 문법(§4-A). 금빛 값은 apexRecovery의 기존 금빛(#fff0a8)을 그대로 재사용한다. */
  #attemptPrimary, #starGateOk, #wardrobeGateGo, #wardrobeBuyCloth {
    color: #fff0a8; text-shadow: 0 1px 2px rgba(56,38,4,.78);
  }
  #noDrags.apexRecovery #noDragsOver {
    color: #fff0a8;
    text-shadow: 0 1px 2px rgba(56,38,4,.78);
  }
  #noDragsHint:active, #noDragsOver:active, #noDragsSkip:active, #skipBuy:active, #bossTimeUpMap:active,
  #moveOfferNo:active, #askNo:active, #restartNo:active, #restartYes:active, #attemptSecondary:active, #exitNo:active, #exitYes:active, #leaveNo:active,
  #attemptPrimary:active, #leaveYes:active, #starGateOk:active, #wardrobeGateGo:active, #wardrobeBuyCloth:active,
  #wardrobeBuyConfirm .wardrobeUnlockCancel:active { transition: transform .06s ease-out; transform: scaleX(1.06) scaleY(.90); filter: drop-shadow(0 1px 2px rgba(18,28,12,.4)) brightness(.96); }
  /* starlight currency icon — gold coin with 4-point sparkle (atlas_star_currency.webp, star_coin.webp crop).
     DISTINCT from the rating ★ (modal_star.png / chwStars / ★ glyphs) by SHAPE (coin vs solid star). */
  .starIco { display: inline-block; width: 1.25em; height: 1.25em; vertical-align: -0.28em; flex: 0 0 auto;
    background: url('../../assets/common/star_coin.webp') center / contain no-repeat;
    filter: drop-shadow(0 1px 1.5px rgba(150,90,12,.5)); }
  /* starlight balance note (both refill modals) — small + muted, so spending is informed without HUD clutter */
  /* ── 잔량 줄 = 「새김 홈」 (2026-08-03) ────────────────────────────────────────────────────────
     이건 새 디자인이 아니라 **이미 있던 정답을 제자리에 돌려놓은 것**이다. `#noDragsBal`만 홀로 어두운
     홈 + 금빛(9.03:1)을 쓰고 있었고, 나머지 잔량 줄들은 석판 면에 맨몸으로 놓여 **#9a6a32 = 1.18:1 —
     사실상 안 보였다**(부족 상태의 빨강 #c0392b는 1.37:1로 더 나빴다). 금색·빨강 같은 **신호색은 이
     중간 톤 석판 위에서 어떤 값으로도 4.5:1을 못 넘는다**(가장 어두운 빨강까지 내려도 3.1:1이고 그쯤이면
     이미 신호색이 아니다). 그래서 색을 포기하는 대신 글자 뒤의 돌을 판다 — 같은 색이 8~9:1로 살아난다.
     ⚠️ 여기서 배경을 없애고 색만 조정하는 방향으로 되돌리지 말 것. 그게 원래 상태였다. */
  .walletNote { position: relative; margin: 12px auto 0; display: inline-flex; align-items: center; gap: 6px;
    padding: 5px 12px 4px; border-radius: 999px;
    border: 1px solid rgba(255,218,127,.28); background: rgba(42,31,20,.72);
    font-size: 14px; font-weight: 900; color: #ffe6a2; letter-spacing: .2px;
    text-shadow: 0 2px 2px rgba(0,0,0,.45); }
  .walletNote .ico { width: 15px; height: 15px; fill: #f2b234; stroke: #c9821a; stroke-width: 1.2; }
  .walletNote.short { border-color: rgba(224,111,90,.42); color: #ffd2c4; }
  .walletNote.short .ico { fill: #e0a3a3; stroke: #b34a3c; }
  #noDragsBal.walletNote { margin: 2px auto 0; }   /* 위 기본형이 곧 이 줄의 옛 모습이다 — 여백만 다르다 */
  /* move offer modal — self-contained (reuses #noDrags's frame + button look without touching its rules) */
  #moveOffer {
    position: fixed; inset: 0; z-index: 60; display: none;
    align-items: center; justify-content: center;
    background:
      radial-gradient(circle at 50% 42%, rgba(255,231,112,.18), transparent 42%),
      rgba(26,33,21,.62);
  }
  /* (legacy 44px frame block + carved-green title + display:none .modalGem rule deleted 2026-07-29 —
     all three were fully dead under the shared .modalCard/.modalTitle block below.) */
  #moveOffer.show .card { animation: winPrizeDrop .67s cubic-bezier(.12,1.18,.28,1) both; }
  #moveOffer .row { position: relative; display: flex; justify-content: center; }
  #moveOffer .rowUtil { margin-top: 14px; }
  #moveOfferNo { width: min(100%, 200px); }
  /* .settling is present on real win cards and supplies the final color treatment below. Keep type sizing
     synchronized between these two rules. */
  #win h2 {
    position: relative;
    margin: -.1em 0 6px;
    font-family: 'Jua', system-ui, sans-serif;
    /* 2026-07-14: the prior clamp(4px,1.1vw,5px) fell below the usable floor — mobile Chrome's
       text-size-adjust:auto font-boosting silently grows sub-minimum text back up (looked "unfixed"
       on phones) while desktop Chrome (no boosting) rendered it near-invisible. Fixed the floor to
       clamp(8px,2.2vw,9px), but once merged into the win card (see .winGrade above) that read as too
       small again — owner: "약 1.5배" back up toward the FIRST 1/3-reduction size (clamp(11px,3.3vw,15px)),
       which was never the problematic tier (the SECOND cut past it was). text-size-adjust:none still applies. */
    /* 2026-08-02 오너 "칭찬이 너무 작고 코인이 너무 커 — 둘을 바꿔라". 실측 결과 **맞바꾸기는 불가능**
       하다(칭찬을 코인 크기 23px로 올리면 영어 최장 문구가 333px > 카드 가용폭 319px라 nowrap 한 줄이
       터진다). 그래서 맞바꾸기 대신 **역전**: 칭찬 16→20px, 보상 23.4→18px(07-hub.css)로 비율을
       0.68 → 1.11로 뒤집어 칭찬을 주인공으로 만든다(연출 헌법 §4.1 시선의 주인공은 하나 + Warm
       Precision에서 칭찬이 이 게임의 서명이라는 전략 포지셔닝).
       ⚠️ 이전 주석은 "16px가 상한"이라고 못 박고 있었는데, 두 근거 다 지금은 성립하지 않는다:
       ⑴ 세로 — 상한을 정한 것은 열쇠미터(당시 33%)였고 그 미터는 **무결점 카드에만** 뜬다. 20px에서
          겹치므로 미터를 34.5%→37%로 내렸다(위 #winFlawMeter). 그 결과 여유가 3.2px→6.3px로 오히려
          늘었다(390×844 4개 변형 전수 실측: 일반/보스/무결점·미터없음은 20px에서도 13~21px 여유).
       ⑵ 가로 — "18px면 영어가 81%까지"는 20px에서도 290/319px(91%)로 들어간다. 실측 상한은 22px이고
          20px는 29px 여유를 남긴 안전값이다.
       ⚠️ 옛 값 `clamp(16px, 3.4vw, 14px)`는 **최솟값 > 최댓값이라 clamp가 죽어 있었다** — 어떤 화면
       에서도 16px 고정이고 3.4vw 확대는 한 번도 동작한 적이 없다. 새 값은 정상 방향(16 ≤ 5vw ≤ 20).
       ⚠️ **최솟값을 16px보다 올리지 말 것.** 320px 화면(카드 294px·가용 254px)에서 18px면 영어 최장
       문구가 261px로 넘친다(실측). 16px 바닥은 좁은 화면에서 지금과 동일하게 두겠다는 뜻이고,
       390px에서 19.5px·400px 이상에서 20px가 나온다. */
    font-size: clamp(16px, 5vw, 20px);
    text-size-adjust: none;
    -webkit-text-size-adjust: none;
    font-weight: 400;            /* Jua is a single heavy weight */
    line-height: 1.32;           /* roomy line box so background-clip:text can't crop the glyph tops */
    letter-spacing: 0;
    /* padding extends the clipped-text background box so the gradient covers the FULL glyph (incl. tops) */
    padding: .18em .14em .06em;
    /* bright gold gradient fill clipped to the glyphs (light top = gloss → deep amber bottom) */
    background: linear-gradient(180deg, #fff3b0 0%, #ffd24a 42%, #f9a81b 72%, #ef7d0a 100%);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    -webkit-text-stroke: 3.5px #3f9e3f;   /* green outline — the reference's signature edge (scaled with the 2026-07-14 font-size above) */
    /* dark-green under-bevel + soft drop + warm glow (text-shadow survives the brightness-only shine) */
    text-shadow:
      0 3px 0 #235e28,
      0 5px 11px rgba(20,50,20,.42),
      0 0 18px rgba(255,224,120,.5);
    paint-order: stroke fill;
  }
  #ask h2 { margin: 0 0 8px; font-size: 18px; }
  /* ⚠️ 여기가 「안 읽히는 모달 글씨」의 **진짜 뿌리였다** (2026-08-03 브라우저 실측으로 확정).
     `--muted`(#8a836f)는 밝은 카드 시절의 회색인데, 지금 이 모달들의 배경은 석판(#897f67)이라
     **1.05:1 — 거의 같은 색이다.** 아래 `.modalBody` 규칙이 나중에 덮어 주는 덕에 대부분 가려져
     있었을 뿐이고, `.modalBody` 목록에 없는 것들(#noLives 본문·잔량 줄·하트 줄)은 그대로 남았다.
     그래서 뿌리에서 잉크로 바꾸고, 자기 배경을 가진 줄(잔량·하트)만 빼낸다. */
     ⚠️ `#win p`는 **일부러 뺐다** — 승리 카드는 석판이 아니라 어두운 통짜 그림이라 회색이 맞다.
        원래 한 줄에 묶여 있었는데, 배경이 다르면 같은 색을 공유할 이유가 없다. */
  #win p { margin: 0 0 16px; color: var(--muted); font-size: 15px; line-height: 1.5; }
  #ask p:not(.walletNote):not(.heartNote) {
    margin: 0 0 16px; color: #1e1408; font-size: 15px; line-height: 1.5;
  }
  #ask .row { display: flex; flex-direction: column; align-items: center; gap: 11px; }
  .hudPill.low { color: var(--bad); border-color: rgba(255,77,87,.4); }
  /* lives modals (out-of-lives gate + start-over confirm) — reuse the lead-came plaque look */
  #noLives, #restartAsk, #attemptAsk, #leaveAsk , #bossTimeUp , #starGate , #wardrobeBuyConfirm , #wardrobeGateInfo { position: fixed; inset: 0; z-index: 60; display: none; align-items: center; justify-content: center; background: var(--scrim); backdrop-filter: none; }
  #noLives .card, #restartAsk .card, #attemptAsk .card, #leaveAsk .card , #bossTimeUp .card , #starGate .card { background: var(--grad-card); border: 1px solid var(--line); border-radius: 22px; padding: 26px 30px; text-align: center; max-width: 320px; box-shadow: var(--lift-lg), var(--sheen); }
  #noLives h2, #restartAsk h2, #attemptAsk h2, #leaveAsk h2 , #bossTimeUp h2 , #starGate h2 { margin: 0 0 8px; font-size: 18px; }
  /* 위 `#ask p`와 같은 뿌리 수정 — #noLives 본문은 `.modalBody` 목록에 없어서 이 규칙이 최종이었고,
     따라서 **석판 위 1.05:1로 사실상 안 보이던 유일한 본문**이었다. */
  #noLives p:not(.walletNote):not(.heartNote),
  #restartAsk p:not(.walletNote):not(.heartNote),
  #attemptAsk p:not(.walletNote):not(.heartNote),
  #leaveAsk p:not(.walletNote):not(.heartNote),
  #bossTimeUp p:not(.walletNote):not(.heartNote),
  #starGate p:not(.walletNote):not(.heartNote) {
    margin: 0 0 14px; color: #1e1408; font-size: clamp(15px, 4.1vw, 16.5px); line-height: 1.5;
  }
  #noLives .row { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
  #restartAsk .row, #attemptAsk .row, #exitAsk .row, #leaveAsk .row , #bossTimeUp .row , #starGate .row , #wardrobeGateInfo .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: center; }
  #wardrobeBuyConfirm .wardrobeUnlockActions { display: flex; flex-direction: column; gap: 10px; margin-top: 4px; }
  #wardrobeBuyConfirm .wardrobeUnlockRow { display: flex; gap: 8px; }
  #wardrobeBuyConfirm .wardrobeUnlockRow #wardrobeBuyAdBtn,
  #wardrobeBuyConfirm .wardrobeUnlockRow #wardrobeBuyYes { flex: 1 1 0; min-width: 0; box-sizing: border-box; }
  /* 2026-07-31: 평면 크림 CSS 버튼이었다 → 위 석판 타일 목록으로 편입. 여기서는 폭만 남긴다
     (석판 규칙이 배경·테두리·그림자·글자색을 소유하므로 그 선언들을 되살리지 말 것). */
  #wardrobeBuyConfirm .wardrobeUnlockCancel { width: 100%; }
  /* 옷감 버튼도 전폭 한 줄 — 광고·코인 줄(2분할) 아래에 선다.
     ⚠️ `[hidden]`을 **반드시 함께 선언한다.** 이 저장소에서 실제로 났던 사고다: 작성자 display를
        가진 요소에 `hidden`만 걸면 브라우저 기본 `[hidden]{display:none}`을 이겨 **안 숨는다**
        (modal_design_system.md §9-1의 `#noDrags` saleRow 전례). 여기 width만 주고 display를 안 주면
        지금은 괜찮지만, 나중에 누가 display를 얹는 순간 조용히 깨진다 — 짝을 미리 박아 둔다. */
  #wardrobeBuyCloth { width: 100%; }
  #wardrobeBuyCloth[hidden] { display: none; }
  #noLivesCount { font-size: 22px; font-weight: 800; color: var(--s1); margin: 2px 0 4px; }
  #noLives {
    background:
      radial-gradient(circle at 50% 42%, rgba(255,231,112,.18), transparent 42%),
      rgba(26,33,21,.62);
    backdrop-filter: none;
  }
  /* (legacy 44px frame block + carved-green title rule deleted 2026-07-29 — all five of these modals
     carry .modalCard/.modalTitle, so the shared block below out-specified every declaration; the frame's
     structural props were hoisted into it.) */
  /* 석판 면 위의 본문 = 「진한 잉크」(위 .modalBody 주석 참조). 옛 #6b3912는 2.39:1이었다.
     ⚠️ `:not(.walletNote):not(.heartNote)`는 장식이 아니다. 잔량 줄도 `<p>`라서, 이 선택자
     (특이도 1,1,1)가 `.walletNote`(0,1,0)를 **조용히 이겨** 어두운 홈 위에 진한 잉크를 칠한다
     = 검정 위 검정. 2026-08-03에 실제로 그렇게 만들었다가 브라우저 실측에서 잡았다. */
  #ask .card p:not(.walletNote):not(.heartNote),
  #restartAsk .card p:not(.walletNote):not(.heartNote),
  #attemptAsk .card p:not(.walletNote):not(.heartNote),
  #leaveAsk .card p:not(.walletNote):not(.heartNote),
  #bossTimeUp .card p:not(.walletNote):not(.heartNote),
  #starGate .card p:not(.walletNote):not(.heartNote) {
    color: #1e1408; text-shadow: 0 1px 0 rgba(255, 246, 214, .5);
  }
  /* ── 2026-07-29: #skipModal / #exitAsk / #legalConsent each carried a hand-copied duplicate of the 36px
     stone-tablet frame. The markup now carries `gameModal`+`modalCard`, so the shared ".modalCard" block
     below is the single source for the frame and those copies are gone. Deliberately NOT added to the
     shared *overlay* (dim/padding) list — that would change their scrim, which is a look change for a
     later step, not this cleanup. legalConsent keeps its wider body (legal copy + 2 links) via an
     explicit override right after the shared block. ── */
  /* exit confirm (Android back on title) — frame now shared; only the scrim is self-contained */
  #exitAsk {
    position: fixed; inset: 0; z-index: 60; display: none; align-items: center; justify-content: center;
    background: radial-gradient(circle at 50% 42%, rgba(255,231,112,.18), transparent 42%), rgba(26,33,21,.62);
  }
  /* exitAsk title geometry; colour/stroke/shadow come from the brown rule below (the carved-green
     declarations that used to sit here were dead under it — removed 2026-07-29). */
  #exitAsk h2 {
    margin: 0 0 8px; font-size: clamp(22px, 6vw, 30px); font-weight: 900; line-height: 1.12; word-break: keep-all; overflow-wrap: anywhere;
  }
  #exitAsk p {
    margin: 0 0 14px; font-size: 16px; line-height: 1.5;
    color: #1e1408; text-shadow: 0 1px 0 rgba(255, 246, 214, .5);   /* 「진한 잉크」 */
  }
  /* #exitAsk .row layout defined above (2-tile grid, shared with #restartAsk) */
  #legalConsent {
    position: fixed; inset: 0; z-index: 240; display: none;
    align-items: center; justify-content: center;
    padding: max(18px, env(safe-area-inset-top,0px)) 18px max(18px, env(safe-area-inset-bottom,0px));
    background:
      radial-gradient(circle at 50% 42%, rgba(255,231,112,.22), transparent 42%),
      rgba(11,18,12,.78);
  }
  /* (#legalConsent's duplicated frame block deleted 2026-07-29 — see the shared .modalCard block; its
     wider width/padding/shadow live in the override placed right after it.) */
  #legalConsent.show .card { animation: winPrizeDrop .67s cubic-bezier(.12,1.18,.28,1) both; }
  #legalConsent h2 {
    margin: 0 0 10px;
    font-size: clamp(23px, 6.2vw, 31px); font-weight: 900; line-height: 1.12; word-break: keep-all; overflow-wrap: anywhere;
  }
  #legalConsent p {
    margin: 0 auto 14px; max-width: 285px;
    color: #1e1408; text-shadow: 0 1px 0 rgba(255, 246, 214, .5);   /* 「진한 잉크」 */
    font-size: clamp(15px, 4.1vw, 16.5px); font-weight: 760; line-height: 1.48;
    word-break: keep-all;
    /* keep-all은 한국어 단어 보호용인데 일본어에서는 **어디서도** 줄바꿈이 안 생겨 카드 밖으로
       삐져나온다(실측: ハートがありません 260px in 244px, もう少し余裕をもって冒険する？ 432px in 244px).
       anywhere는 "그냥 두면 넘칠 때만" 쪼개므로 한국어 단어 무결성은 그대로 두고 일본어만 구제한다.
       (2026-07-29 글자 크기 상향 검수에서 발견 — #skipModal h2에 먼저 적용한 것과 같은 처방) */
    overflow-wrap: anywhere;
  }
  #legalConsent .legalLinks {
    display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
    margin: 14px auto 16px; max-width: 300px;
  }
  #legalConsent .legalDocLink {
    min-height: 43px; padding: 0 10px 3px; box-sizing: border-box;
    display: inline-flex; align-items: center; justify-content: center;
    color: #ece1ca; font-size: clamp(15px, 3.3vw, 14px); font-weight: 900; text-decoration: none; white-space: nowrap;
    background: url('../../assets/setting/row.png') center / 100% 100% no-repeat;
    filter: drop-shadow(0 3px 4px rgba(18,28,12,.34));
    text-shadow: 0 1px 2px rgba(0,0,0,.55);
  }
  #legalConsent .legalDocLink:active { transition: transform .06s ease-out; transform: scaleX(1.06) scaleY(.90); filter: drop-shadow(0 1px 2px rgba(18,28,12,.34)) brightness(.96); }
  #legalConsentAccept {
    width: min(100%, 260px); aspect-ratio: 354 / 104; height: auto; min-width: 0;
    padding: 0 11% 4%; border: none; border-radius: 0; box-shadow: none;
    background: url('../../assets/shop/buy.png') center / 100% 100% no-repeat;
    display: inline-flex; align-items: center; justify-content: center;
    color: #fff8e9; font-size: clamp(14px, 3.9vw, 17px); font-weight: 900; white-space: nowrap;
    text-shadow: 0 2px 0 #2c7a1f, 0 3px 5px rgba(18,55,8,.5);
  }
  #legalConsentAccept:active { transition: transform .06s ease-out; transform: scaleX(1.06) scaleY(.90); filter: brightness(.96); }
  #noLives h2 {
    margin: 0 0 12px;
    color: var(--gold-pale);
    font-size: clamp(24px, 6.4vw, 32px);
    font-weight: 900;
    line-height: 1.08;
    word-break: keep-all;
    /* keep-all은 한국어 단어 보호용인데 일본어에서는 **어디서도** 줄바꿈이 안 생겨 카드 밖으로
       삐져나온다(실측: ハートがありません 260px in 244px, もう少し余裕をもって冒険する？ 432px in 244px).
       anywhere는 "그냥 두면 넘칠 때만" 쪼개므로 한국어 단어 무결성은 그대로 두고 일본어만 구제한다.
       (2026-07-29 글자 크기 상향 검수에서 발견 — #skipModal h2에 먼저 적용한 것과 같은 처방) */
    overflow-wrap: anywhere;
    -webkit-text-stroke: 3px #684514;
    paint-order: stroke fill;
    text-shadow: 0 3px 0 #4f3310, 0 6px 9px rgba(52,25,5,.34), 0 1px 0 rgba(255,245,190,.45);
  }
  /* Tier A — STATE: hearts count + timer on ONE muted chip (was two mismatched pills) */
  .modalStateBlock {
    display: inline-flex; flex-direction: row; align-items: baseline; justify-content: center;
    gap: 10px; width: fit-content; max-width: 100%; margin: 0 auto 16px;
    padding: 7px 16px; border-radius: 14px;
    border: 1px solid rgba(255,218,127,.24); background: rgba(46,32,18,.5);
  }
  #noLivesCount {
    display: inline-flex; align-items: baseline; gap: 6px; white-space: nowrap; flex: 0 0 auto;
    margin: 0; padding: 0; border: none; background: none; box-shadow: none;
    color: #ffc0c8; font-size: 15px; font-weight: 850; text-shadow: 0 1px 2px rgba(0,0,0,.5);
  }
  #noLivesCount .ico {
    width: 15px; height: 15px; color: #e8475c; transform: translateY(2px);   /* nudge the icon onto the text baseline */
    filter: drop-shadow(0 1px 1px rgba(121,31,31,.4));
  }
  #noLivesTimer {
    margin: 0; padding: 0; border: none; background: none; white-space: nowrap; flex: 0 0 auto;
    color: #e9d6a8; font-size: 14px; font-weight: 800; line-height: 1.2; text-shadow: 0 1px 2px rgba(0,0,0,.5);
  }
  #noLivesTimer:empty { display: none; }                                  /* full → no timer, no stray separator */
  #noLivesTimer:not(:empty)::before { content: "·"; margin-right: 10px; color: rgba(255,244,208,.4); }
  /* top-right X — dismisses the gate/info modals (hearts-out, strokes-out). Sits inside the baked
     frame's top-right; decision dialogs keep their explicit Cancel button instead. */
  #noLives .card, #noDrags .card { position: relative; }
  /* 2026-07-22: was a flat semi-transparent brown oval — the one off-kit element in an otherwise
     stone+gold+vine modal. Retooled into a small carved-stone disc with a gold hairline + inset depth,
     matching the shop relic slots / settings gear. Shared by every gameModal's close button. */
  .modalX {
    position: absolute; top: 4px; right: 4px; z-index: 6;
    width: 34px; height: 34px; min-width: 0; padding: 0; margin: 0;   /* min-width:0 → beat ui.css button min-width so it's a true circle, not a wide oval */
    display: inline-flex; align-items: center; justify-content: center;
    border: 1px solid rgba(255,224,150,.55); border-radius: 50%;
    background:
      radial-gradient(circle at 38% 28%, rgba(255,247,214,.5) 0 11%, transparent 32%),
      linear-gradient(180deg, #6b5236 0%, #4a3722 60%, #2c1f12 100%);
    color: #f0e4cb; cursor: pointer;
    box-shadow: inset 0 1px 0 rgba(255,240,200,.28), inset 0 -3px 5px rgba(18,10,4,.5), 0 2px 4px rgba(16,9,3,.4);
    -webkit-tap-highlight-color: transparent;
  }
  /* 44px tap target without growing the 34px disc (§7): an invisible bleed layer, not extra padding —
     padding would inflate the border-radius:50% circle itself. */
  /* 히트 영역만 44px로 넓힌다(원판 크기는 그대로 — 패딩을 주면 border-radius:50% 원판 자체가 커진다).
     `inset:-5px`는 원판 크기에 따라 결과가 달라져(34px 원판 → 44px, 32px 원판 → 42px) 실측에서 2px
     모자랐다. 원판이 몇 px이든 최소 44px을 보장하도록 중앙 정렬 + max()로 고정한다. */
  .modalX::after {
    content: ""; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
    width: max(44px, 100%); height: max(44px, 100%); border-radius: 50%;
  }
  .modalX .ico { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.4; opacity: .9; }
  .modalX:active { transform: scale(.92); filter: brightness(.9); box-shadow: inset 0 2px 5px rgba(18,10,4,.6); }
  #noDrags .rowUtil2 { display: flex; justify-content: center; }   /* lone "start over" (close moved to X) → centered */
  #noLives .row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 12px;
    align-items: center;
  }
  #noLives .noLivesActions {
    grid-template-columns: minmax(0, 1fr);
    justify-items: center;
    gap: 10px;
  }
  #noLives .spendChoice {
    width: min(100%, 206px);                /* narrower than the AD → the free option clearly leads */
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 5px;
  }
  /* AD spans the row (green action tile); BUY + CLOSE share the row below it */
  /* AD = the headline action (free) → bigger + more prominent than the star-buy below it */
  #noLives #noLivesAd {
    grid-column: 1 / -1; justify-self: center; width: min(100%, 270px);
    aspect-ratio: 354 / 104; height: auto; min-width: 0;
    padding: 0 14% 4%; border: none; border-radius: 0; box-shadow: none;
    background: url('../../assets/shop/buy.png') center / 100% 100% no-repeat;
    display: inline-flex; align-items: center; justify-content: center;
    color: #fff8e9; font-size: 19px; font-weight: 900; white-space: nowrap;
    text-shadow: 0 2px 0 #2c7a1f, 0 3px 5px rgba(18,55,8,.5);
  }
  #noLives #noLivesBuy {
    box-sizing: border-box; min-width: 0; width: 100%; height: 46px; padding: 0 12px 1px;
    display: inline-flex; align-items: center; justify-content: center; gap: 5px;
    border: 11px solid transparent; border-radius: 0;
    border-image: url('../../assets/map/card_bg.webp') 80 fill / 11px / 0 stretch;
    background: transparent; box-shadow: none;
    filter: drop-shadow(0 2px 4px rgba(80,45,8,.2));
    color: #6b3912; font-size: 15px; font-weight: 900; white-space: nowrap;
  }
  #noLives #noLivesBuy .ico { width: 18px; height: 18px; fill: #f5b21e; stroke: #c9821a; stroke-width: 1.3; }
  #noLives #noLivesBuy .buySep { margin: 0 1px; opacity: .68; font-weight: 800; }
  #noLives #noLivesBuy .heartIco { width: 17px; height: 17px; fill: #e8475c; stroke: none; color: #e8475c; }
  #noLives #noLivesBuy:disabled,
  #noLives #noLivesAd:disabled { filter: grayscale(.5) brightness(.85); opacity: .7; }
  #noLives #noLivesAd:active, #noLives #noLivesBuy:active { transition: transform .06s ease-out; transform: scaleX(1.06) scaleY(.90); filter: brightness(.96); }
  /* 여기도 기본형(「새김 홈」)을 쓴다. 옛 값은 홈을 벗긴 채 **연한 금빛**이라 밝은 석판 위에서
     2.9:1이었다 — 어두운 배경을 전제한 그림자(0 1px 2px 검정)까지 달고 있었는데 정작 배경이 밝았다.
     "조용한 보조 줄"이라는 원래 의도는 색을 흐리게가 아니라 **홈을 작게** 해서 지킨다. */
  #noLivesBal.walletNote { margin: 4px auto 0; font-size: 13px; }

  /* Shared modal anatomy pass: the older ID-specific rules above still provide each modal's behavior,
     while these classes normalize hierarchy so every dialog has one clear job and one primary action.
     ⚠️ #skipModal / #exitAsk / #legalConsent carry `gameModal` but are intentionally absent from THIS
     (overlay dim + padding) list — they still use their own scrim. Adding them here is a visible change,
     not a cleanup; it belongs to the closing/notation pass, not step 1. */
  #ask.gameModal,
  #noDrags.gameModal,
  #noLives.gameModal,
  #restartAsk.gameModal,
  #attemptAsk.gameModal,
  #leaveAsk.gameModal,
  #bossTimeUp.gameModal,
  #starGate.gameModal,
  #moveOffer.gameModal,
  #wardrobeBuyConfirm.gameModal,
  #wardrobeGateInfo.gameModal {
    padding: max(16px, env(safe-area-inset-top, 0px)) 18px max(16px, env(safe-area-inset-bottom, 0px));
    background:
      radial-gradient(circle at 50% 40%, rgba(255,231,112,.14), transparent 40%),
      rgba(20,27,17,.68);
  }
  /* THE frame. Single source for the 36px stone tablet (2026-07-29: absorbed the three hand-copied
     duplicates — skipModal/exitAsk/legalConsent — and hoisted position/border-style/border-radius/
     overflow up from the deleted legacy 44px blocks, which were the only place those lived). */
  #ask.gameModal .modalCard,
  #noDrags.gameModal .modalCard,
  #noLives.gameModal .modalCard,
  #restartAsk.gameModal .modalCard,
  #attemptAsk.gameModal .modalCard,
  #leaveAsk.gameModal .modalCard, #bossTimeUp.gameModal .modalCard, #starGate.gameModal .modalCard,
  #moveOffer.gameModal .modalCard,
  #skipModal.gameModal .modalCard,
  #exitAsk.gameModal .modalCard,
  #legalConsent.gameModal .modalCard,
  #wardrobeBuyConfirm.gameModal .modalCard,
  #wardrobeGateInfo.gameModal .modalCard {
    position: relative;
    width: min(90vw, 360px);
    max-width: 360px;
    padding: 20px 22px 22px;
    border-style: solid;
    border-width: 36px;
    border-image: url('../../assets/common/modal_canvas.webp') 120 fill / 36px / 0 stretch;
    border-radius: 0;
    background: linear-gradient(180deg, #cbc2a5 0%, #aaa07f 100%);
    background-clip: padding-box;
    box-shadow: 0 0 24px rgba(255,218,76,.22), 0 20px 42px rgba(24,15,6,.44);
    text-align: center;
    overflow: visible;
    isolation: isolate;
  }
  /* legalConsent = the one deliberate size variant: legal copy + two doc links need a wider tablet.
     Same specificity as the block above, so it must stay directly after it. */
  #legalConsent.gameModal .modalCard {
    width: min(92vw, 384px);
    max-width: 384px;
    padding: 22px 24px 24px;
    box-shadow: 0 0 24px rgba(255,218,76,.22), 0 20px 42px rgba(24,15,6,.46);
  }
  #ask.gameModal .modalCard::before,
  #noDrags.gameModal .modalCard::before,
  #noLives.gameModal .modalCard::before,
  #restartAsk.gameModal .modalCard::before,
  #attemptAsk.gameModal .modalCard::before,
  #leaveAsk.gameModal .modalCard::before, #bossTimeUp.gameModal .modalCard::before, #starGate.gameModal .modalCard::before,
  #moveOffer.gameModal .modalCard::before,
  #wardrobeBuyConfirm.gameModal .modalCard::before,
  #wardrobeGateInfo.gameModal .modalCard::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: 8px;
    background:
      linear-gradient(180deg, rgba(218,209,180,.76), rgba(181,169,136,.8));
    box-shadow: inset 0 1px 0 rgba(255,248,217,.38);
  }
  #ask.gameModal .modalCard > :not(.modalX),
  #noDrags.gameModal .modalCard > :not(.modalX),
  #noLives.gameModal .modalCard > :not(.modalX),
  #restartAsk.gameModal .modalCard > :not(.modalX),
  #attemptAsk.gameModal .modalCard > :not(.modalX),
  #leaveAsk.gameModal .modalCard > :not(.modalX), #bossTimeUp.gameModal .modalCard > :not(.modalX), #starGate.gameModal .modalCard > :not(.modalX),
  #moveOffer.gameModal .modalCard > :not(.modalX),
  #wardrobeBuyConfirm.gameModal .modalCard > :not(.modalX),
  #wardrobeGateInfo.gameModal .modalCard > :not(.modalX) {
    position: relative;
    z-index: 1;
  }
  #ask.gameModal .modalX,
  #noDrags.gameModal .modalX,
  #noLives.gameModal .modalX,
  #restartAsk.gameModal .modalX,
  #attemptAsk.gameModal .modalX,
  #leaveAsk.gameModal .modalX, #bossTimeUp.gameModal .modalX, #starGate.gameModal .modalX,
  #moveOffer.gameModal .modalX {
    z-index: 2;
  }
  #ask.gameModal .modalTitle,
  #noDrags.gameModal .modalTitle,
  #noLives.gameModal .modalTitle,
  #restartAsk.gameModal .modalTitle,
  #attemptAsk.gameModal .modalTitle,
  #leaveAsk.gameModal .modalTitle, #bossTimeUp.gameModal .modalTitle, #starGate.gameModal .modalTitle,
  #moveOffer.gameModal .modalTitle,
  #wardrobeBuyConfirm.gameModal .modalTitle,
  #wardrobeGateInfo.gameModal .modalTitle {
    margin: 0 0 8px;
    padding: 0;
    font-size: clamp(22px, 5.8vw, 29px);
    font-weight: 900;
    line-height: 1.12;
    word-break: keep-all;
    /* keep-all은 한국어 단어 보호용인데 일본어에서는 **어디서도** 줄바꿈이 안 생겨 카드 밖으로
       삐져나온다(실측: ハートがありません 260px in 244px, もう少し余裕をもって冒険する？ 432px in 244px).
       anywhere는 "그냥 두면 넘칠 때만" 쪼개므로 한국어 단어 무결성은 그대로 두고 일본어만 구제한다.
       (2026-07-29 글자 크기 상향 검수에서 발견 — #skipModal h2에 먼저 적용한 것과 같은 처방) */
    overflow-wrap: anywhere;
  }
  /* Reserve the top-right close disc so long localized titles never run underneath it. */
  #attemptAsk.gameModal .modalTitle { padding-right: 40px; }
  /* ── 모달 제목색 = 새긴 초록 (오너 2026-07-29) ─────────────────────────────────────────────
     같은 날 오전에 갈색으로 통일했다가 되돌린 결정이다 — 오너 판단: 초록이 더 멋있고 읽기 좋다.
     ⚠️ **색은 여기서만 지정한다.** 크기·여백은 모달마다 다르게 유지하되(위 규칙들), 색·외곽선·그림자는
     이 한 곳이 유일한 출처다. 세 군데로 흩어져 있으면 하나만 고쳐 어긋난다(같은 날 HUD 글씨 크기가
     정확히 그렇게 어긋났다). 새 모달은 아래 목록에 이름만 추가하면 된다.
     읽어둘 것: 초록은 이 게임에서 "맞았다"의 신호색이다(영역 봉인·진행 버튼). 제목까지 초록이 되면서
     그 신호가 옅어질 수 있고, 손실을 알리는 모달(하트 없음·시간 초과·나가기)에서는 톤이 어긋나 보일 수
     있다 — 실제로 보고 어색하면 그 몇 개만 목록에서 빼면 된다. */
  #ask.gameModal .modalTitle,
  #noDrags.gameModal .modalTitle,
  #noLives.gameModal .modalTitle,
  #restartAsk.gameModal .modalTitle,
  #attemptAsk.gameModal .modalTitle,
  #leaveAsk.gameModal .modalTitle, #bossTimeUp.gameModal .modalTitle, #starGate.gameModal .modalTitle,
  #moveOffer.gameModal .modalTitle,
  #wardrobeBuyConfirm.gameModal .modalTitle,
  #wardrobeGateInfo.gameModal .modalTitle,
  #legalConsent h2, #skipModal h2, #exitAsk h2,
  /* 배우기 시작 레슨(styles/app/16-tutorial-opening.css)도 같은 36px 캔버스 프레임을 쓰므로 여기 편입한다.
     ⚠️ 진갈색(#3b2814)은 **Sha 스톤 셸 전용** 예외다 — 이 표면은 샤가 프레임 위로 나오지 않는 일반
     캔버스라 그 예외에 해당하지 않는다. 보조문(.tutOpenSub)은 본문색(#6b4c2a)이 그대로 짝이 된다. */
  #tutorialOpening .tutOpenTitle {
    color: #80d24c;
    -webkit-text-stroke: 3px #2f6a28;
    paint-order: stroke fill;
    text-shadow: 0 3px 0 #245a1f, 0 6px 9px rgba(10,45,6,.4), 0 1px 0 rgba(225,255,205,.45);
  }
  #ask.gameModal .modalBody,
  #noDrags.gameModal .modalBody,
  #restartAsk.gameModal .modalBody,
  #attemptAsk.gameModal .modalBody,
  #leaveAsk.gameModal .modalBody , #bossTimeUp.gameModal .modalBody , #starGate.gameModal .modalBody ,
  #wardrobeBuyConfirm.gameModal .modalBody,
  #wardrobeGateInfo.gameModal .modalBody {
    max-width: 260px;
    margin: 0 auto 15px;
    /* 「진한 잉크」 — 근거 전문은 `styles/app/05-map.css`의 동명 규칙(석판 셸)에 있다. 요약하면
       `modal_canvas.webp`의 면이 **#897f67(중간 톤)** 이라 순수 검정도 5.3:1이 천장이고, 옛 #6b4c2a는
       **1.97:1**로 읽기 기준(4.5:1)의 절반도 못 됐다. 어중간한 갈색으로 되돌리지 말 것. (2026-08-03) */
    color: #1e1408;
    font-size: clamp(15px, 4.1vw, 16.5px);
    font-weight: 760;
    line-height: 1.42;
    text-shadow: 0 1px 0 rgba(255, 246, 214, .5);
    word-break: keep-all;
    /* keep-all은 한국어 단어 보호용인데 일본어에서는 **어디서도** 줄바꿈이 안 생겨 카드 밖으로
       삐져나온다(실측: ハートがありません 260px in 244px, もう少し余裕をもって冒険する？ 432px in 244px).
       anywhere는 "그냥 두면 넘칠 때만" 쪼개므로 한국어 단어 무결성은 그대로 두고 일본어만 구제한다.
       (2026-07-29 글자 크기 상향 검수에서 발견 — #skipModal h2에 먼저 적용한 것과 같은 처방) */
    overflow-wrap: anywhere;
  }
  /* 자수정을 어떻게 버는지 — **본문보다 한 단 조용한 보조 줄** (오너 2026-08-07).
     한 프레임의 주인공은 하나라(연출 헌법 §4.1) 위 본문이 통로 셋을 말하는 주역이고, 이 줄은 그중
     하나에 붙는 각주다. 같은 크기·굵기로 두면 모달이 두 문단짜리 설명서가 된다.
     ⚠️ `display`를 주지 않는다 — 주는 순간 브라우저 기본 `[hidden] { display: none }`을 이겨서
        `hidden`이 아무 일도 안 하게 된다(이 저장소에서 실제로 겪은 함정, CLAUDE.md 참조).
        그래도 뒤에 누가 display를 얹을 때를 대비해 짝 규칙을 같이 둔다. */
  #wardrobeBuyConfirm.gameModal .wardrobeGemHow {
    margin-top: -9px;
    font-size: clamp(12.5px, 3.4vw, 14px);
    font-weight: 620;
    color: #4a3418;
    opacity: .92;
  }
  #wardrobeBuyConfirm.gameModal .wardrobeGemHow[hidden] { display: none; }
  /* ── wardrobe modals: game-frame variant (gate_frame.webp replaces modal_canvas.webp) ── */
  #wardrobeGateInfo.gameModal .modalCard,
  #wardrobeBuyConfirm.gameModal .modalCard {
    border-image: none;
    border-width: 0;
    border-radius: 0;
    background: url('../../assets/wardrobe/gate_frame.webp') center / 100% 100% no-repeat;
    background-clip: border-box;
    box-shadow: 0 18px 48px rgba(10,8,3,.52);
    padding: 88px 62px 72px;
  }
  #wardrobeGateInfo.gameModal .modalCard::before,
  #wardrobeBuyConfirm.gameModal .modalCard::before { display: none; }

  /* Costume thumbnail */
  .gateThumb {
    display: block;
    width: 270px;
    height: 270px;
    object-fit: contain;
    margin: 0 auto 8px;
  }

  /* Progress dots: filled = 금빛, empty = 연한 돌색 */
  .gateProgress {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    flex-wrap: wrap;
    margin: 0 auto 12px;
  }
  .gateDot {
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: rgba(255,255,255,.25);
    border: 2px solid rgba(80,55,20,.3);
    flex-shrink: 0;
  }
  .gateDot.on {
    background: linear-gradient(135deg, #ffe066 0%, #d4900a 100%);
    border-color: rgba(140,88,10,.5);
    box-shadow: 0 0 9px rgba(245,185,30,.75), 0 2px 4px rgba(10,6,2,.3);
  }

  .modalState {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 5px;
    width: fit-content;
    max-width: 100%;
    margin: 0 auto 9px;
    padding: 5px 12px 4px;
    border: 1px solid rgba(93,61,24,.18);
    border-radius: 999px;
    background: rgba(255,244,211,.45);
    /* 크림 워시 유지 + 잉크만 강화(.heartNote와 같은 처방). 워시 위 실측 #6d4b22 = 3.80:1 → 8.29:1. */
    color: #26190b;
    font-size: 12.5px;
    font-weight: 900;
    line-height: 1.2;
    text-shadow: 0 1px 0 rgba(255,250,230,.65);
  }
  .modalState .ico {
    width: 14px;
    height: 14px;
    color: #3d2b12;
    stroke-width: 2.4;
  }
  .modalActions {
    position: relative;
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    justify-items: center;
    gap: 9px;
  }
  #noDrags.gameModal .modalPrimary,
  #moveOffer.gameModal .modalPrimary,
  #ask.gameModal .modalPrimary,
  #restartAsk.gameModal .modalPrimary,
  #noLives.gameModal .modalPrimary {
    grid-column: 1 / -1;
    width: min(100%, 250px);
    font-size: clamp(15px, 3.8vw, 16px);
  }
  .modalGain {
    display: inline-block;
    margin-left: 7px;
    color: #fff7bb;
    font-weight: 950;
  }
  #noDrags.gameModal #noDragsBuy .modalGain {
    color: #81531e;
    font-size: .92em;
    text-shadow: 0 1px 0 rgba(255,246,205,.65);
  }
  /* ⚠️ `#noDragsRewind`(못 깨는 판 전용 무료 되감기) 규칙 일체를 2026-08-03에 삭제했다 — 버튼 자체가
     사라졌기 때문이다(오너 "3빼": 공짜 버튼이 있으면 광고·코인을 아무도 안 고른다). 충전 줄
     `.noDragsActions[hidden]` 짝 규칙도 같은 날 함께 삭제 — 그 줄을 숨기는 코드가 없어졌다.
     ⚠️ 둘 중 무엇이든 다시 조건부로 숨길 일이 생기면 `[hidden] { display: none }` 짝 규칙을 **같이**
     되살려라. 작성자 `display`(#noDrags .row { display: grid })가 UA 기본을 이겨서 `hidden`이 조용히
     무시된다 — 2026-07-31~08-01에 실제로 터진 함정이다. */
  #noDrags.gameModal .spendChoice {
    width: min(100%, 236px);
    gap: 3px;
  }
  #noDrags.gameModal #noDragsBuy {
    width: 100%;
    height: 48px;
    font-size: clamp(15px, 3.5vw, 15px);
    padding-inline: 10px;
  }
  /* 이 줄도 기본형(=「새김 홈」, 위 .walletNote 참조)을 그대로 쓴다. 옛 값은 홈을 벗기고
     rgba(92,62,26,.82)로 칠해 석판 위 1.3:1이었다 — 지불 직전에 지갑을 확인하는 줄이라 안 보이면 안 된다. */
  #noDrags.gameModal #noDragsBal.walletNote { margin: 1px auto 0; font-size: 13px; }
  #noDrags.gameModal .modalFooter {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 8px;
    width: min(100%, 246px);
    margin: 13px auto 0;
  }
  #noDrags.gameModal .modalFooter button {
    height: 44px;               /* was 39px — the one sub-44px tap target in the modal set (§7) */
    padding: 0 8px 2px;
    font-size: 15px;
  }
  /* 마지막 하나였다 — 이 줄만 홈을 벗기고 rgba(78,51,22,.72)/11.5px으로 칠해 석판 위 2.93:1이었다.
     `10-game-screen.css`의 `#noDragsLifeBal.heartNote`가 이미 어두운 홈 + 금빛(9.03:1)을 갖고 있었는데
     여기서 다시 벗겨내고 있었다. 이제 여백·크기만 남기고 그 홈을 통과시킨다. (2026-08-03) */
  #noDrags.gameModal #noDragsLifeBal.heartNote {
    display: block;
    margin: 7px auto 0;
    font-size: 12.5px;
  }
  #noLives.gameModal .modalStateBlock {
    margin-bottom: 14px;
    background: rgba(55,35,17,.45);
  }
  #restartAsk.gameModal .modalActions,
  #attemptAsk.gameModal .modalActions,
  #leaveAsk.gameModal .modalActions,
  #bossTimeUp.gameModal .modalActions {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    width: min(100%, 250px);
    margin: 0 auto;
  }
  /* 잔량 줄이 본문 아래 알약에서 **버튼 아래 한 줄**로 내려갔다(modal_design_system.md §5.4-2)
     → 기본 .heartNote(margin: 8px auto 14px)의 위/아래 여백을 뒤집는다. */
  #restartAsk.gameModal #restartLifeBal.heartNote {
    margin: 12px auto 0;
  }
  /* bossTimeUp is the ONE confirm modal allowed a gold (payment) button — modal_design_system.md §10-4:
     "A 유형에 지불 버튼을 넣는 것도 금지(bossTimeUp Try again의 금테 1건만 공인 예외)". Retrying spends ♥1.
     Geometry: the two-column A-modal row leaves each tile ~112px on a 390px screen, so the gold frame is
     slimmed (10px 9-slice instead of 12px, tighter side padding) and the label steps down a size — the same
     tactic #noDragsBuy already uses. No new colour, no new variant: still assets/map/card_bg.webp.
     Height 48px (>= the 44px touch floor, §7) keeps it near the 46px stone "Map" beside it. */
  #bossTimeUp.gameModal #bossTimeUpRetry {
    height: 48px;
    border-width: 10px;
    border-image: url('../../assets/map/card_bg.webp') 80 fill / 10px / 0 stretch;
    padding-inline: 7px;
    font-size: clamp(15px, 3.4vw, 15px);
  }
  /* same X-footprint reserve as #skipModal h2: the centred title can't run under the new close disc.
     No-op at the 390px review width (all three languages already fit); it earns its keep on wider
     screens, where the title font keeps growing after the card width caps at 360px. */
  #starGate.gameModal .modalTitle { padding-inline: 38px; }
  #starGate.gameModal .modalActions {
    grid-template-columns: minmax(0, 1fr);
    width: min(100%, 250px);
    margin: 0 auto;
  }
  /* min-height 44px = the touch floor (§7). This ui.css .primary tile computed to 39px (10px padding +
     one 14px line), the only sub-44px control in the modal set; raised here while the X lands in the
     same modal so both changes get verified in one pass. */
  #starGate.gameModal .modalActions > button { width: 100%; min-height: 44px; }
  #ask.gameModal .modalActions {
    gap: 10px;
  }
  #hudLives { color: var(--s1); font: inherit; font-weight: 700; font-size: 13px; cursor: pointer; }
  /* win overlay: result, reward, action enter as three readable beats */
  #win.show { animation: winFade .36s ease-out both; }
  @keyframes winFade { from { opacity: 0; } to { opacity: 1; } }
  /* winCardGlow dropped: it animated a rectangular box-shadow/inset highlight that doesn't fit the
     transparent-cornered frame image. The celebratory glow now comes from #win's radial dim + ::before. */
  #win.show .card { animation: winPrizeDrop .9s cubic-bezier(.12,1.18,.28,1) both; }
  #win.show h2 { animation: winTitlePop .56s cubic-bezier(.14,1.48,.32,1) .22s both, winTitleShine 1.8s ease-in-out 1.05s infinite; }
  #win.show #winNext { animation: winButtonIn .44s cubic-bezier(.18,1.35,.3,1) 1.58s both; }
  /* After the board-level grade, the modal is a warm wallet landing, not another star reveal. */
  #win.settling.show .card { animation: winSettleIn .67s cubic-bezier(.2,.9,.3,1) both; }
  #win.settling.show h2 { animation: winVerdictIn .32s ease-out .9s both; }
  #win.settling.show .winCoins { animation: winRewardIn .34s ease-out 1.2s both; }
  /* Piece handoff updates the reward immediately as pieces land, so its quiet reward row appears sooner
     while the verdict and action retain the same hierarchy. */
  #win.settling.piece-handoff.show .winCoins { animation-delay: .05s; }
  #win.settling h2 {
    font-size: clamp(16px, 5vw, 20px);
    white-space: nowrap;          /* praise lines vary in length → keep to ONE line (never wrap onto the reward) */
    /* ⚠️ 2026-07-31 (오너 "모든 글자 색깔이 금색인데 이게 맞아?"): **테두리를 초록으로 되돌린다.**
       옛 주석은 "보상과 같은 금+갈색 처리로 맞춰 한 글꼴 가족으로 읽히게"였는데(2026-07-14), 그 뒤
       가독성 수리가 무결점 봉인·열쇠 수·넛지 줄까지 하나씩 금색으로 바꾸면서(2026-07-19) 카드 안
       여섯 줄 중 다섯 줄이 같은 금색이 됐다 = 위계 소실(연출 헌법 §4.1 "한 프레임의 주인공은 하나").
       금색은 이 게임의 **별빛(통화) 신호색**이므로 칭찬·안내문까지 금색이면 그 신호도 옅어진다.
       이제 **금색 채움은 보상 줄의 것**이고, 칭찬은 이 카드의 원래 서명이던 초록 테두리로 구분된다
       (다른 모달 제목의 "새긴 초록"과 같은 가족 · 아래 Next 버튼의 초록과도 운율이 맞는다).
       테두리 굵기는 글꼴 크기에 비례한다 — 죽은 `#win h2` 규칙의 3.5px는 30~40px 시절 값이라
       16px에 그대로 쓰면 글자가 메워진다. */
    background: none; color: var(--gold); -webkit-text-fill-color: var(--gold);
    -webkit-text-stroke: 2px #3f9e3f;   /* 글꼴 크기에 비례 — 16px:1.6px 였고 20px가 됐다(2026-08-02) */
    paint-order: stroke fill;
    text-shadow: 0 2px 0 #235e28, 0 4px 7px rgba(20,50,20,.42);
  }
  #win.settling .card > #winNext { top: 80%; }
  #win.settling.show #winNext { animation: winButtonIn .4s ease-out 1.5s both; }
  #win.settling .card::before { opacity: .3; }
  /* 카드는 통짜 구운 이미지라 변형이 눈에 잘 띈다 — 첫 눌림은 보수적으로 r≈1.10(§4.6). settling은
     board-level 등급 연출 뒤에 오는 "조용한 착지"라 winPrizeDrop보다 한 단 얌전하다(감쇠 2단). */
  @keyframes winSettleIn {
    0%   { opacity: 0; transform: translateY(20px) scale(.93);
           animation-timing-function: cubic-bezier(.24,.74,.3,1); }
    30%  { opacity: 1; transform: translateY(4px) scaleX(.97) scaleY(1.045);
           animation-timing-function: cubic-bezier(.4,0,.2,1); }
    50%  { transform: translateY(0) scaleX(1.049) scaleY(.954); animation-timing-function: ease-in-out; }
    76%  { transform: translateY(0) scaleX(.974) scaleY(1.027); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  @keyframes winPrizeDrop {
    0%   { opacity: 0; filter: brightness(1.28); transform: translateY(42px) scale(.78);
           animation-timing-function: cubic-bezier(.18,.7,.28,1); }
    17%  { opacity: 1; filter: brightness(1.1); transform: translateY(-10px) scaleX(.95) scaleY(1.08);
           animation-timing-function: cubic-bezier(.4,0,.2,1); }
    25%  { filter: brightness(1); transform: translateY(0) scaleX(1.049) scaleY(.954); animation-timing-function: ease-in-out; }
    52%  { transform: translateY(0) scaleX(.970) scaleY(1.031); animation-timing-function: ease-in-out; }
    78%  { transform: translateY(0) scaleX(1.010) scaleY(.990); animation-timing-function: ease-in-out; }
    100% { opacity: 1; filter: brightness(1); transform: translateY(0) scale(1); }
  }
  @keyframes winTitlePop {
    0% { transform: translateY(8px) scale(.72); opacity: 0; }
    68% { transform: translateY(-2px) scale(1.08); opacity: 1; }
    100% { transform: translateY(0) scale(1); opacity: 1; }
  }
  @keyframes winVerdictIn {
    0% { opacity: 0; transform: translateY(7px); }
    100% { opacity: 1; transform: translateY(0); }
  }
  @keyframes winRewardIn {
    0% { opacity: 0; transform: translateY(8px) scale(.96); }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  @keyframes winTitleShine {
    0%,100% { filter: brightness(1); }
    46% { filter: brightness(1.24); }
  }
  @keyframes winAuraSpin { to { transform: rotate(360deg); } }
  @keyframes winButtonIn { 0% { transform: translateY(14px) scale(.82); opacity: 0; } 100% { transform: translateY(0) scale(1); opacity: 1; } }
  /* victory: a single shaft of light sweeps across the window (no full-board bloom) */
  #board.celebrate::after {
    content: ""; position: absolute; inset: 0; z-index: 4; pointer-events: none;
    background: linear-gradient(115deg, transparent 38%, rgba(255,247,210,.6) 50%, transparent 62%);
    background-size: 280% 100%; mix-blend-mode: screen;
    animation: lightSweep 1.15s ease-out both;
  }
  @keyframes lightSweep { 0% { background-position: 115% 0; opacity: 0; } 14% { opacity: 1; } 100% { background-position: -30% 0; opacity: 0; } }
  #board.perfectPraise::after {
    content: ""; position: absolute; inset: -2%; z-index: 4; pointer-events: none;
    background:
      radial-gradient(circle at 50% 50%, rgba(255,235,126,.38), transparent 55%),
      linear-gradient(115deg, transparent 30%, rgba(255,247,185,.78) 50%, transparent 70%);
    background-size: 100% 100%, 260% 100%;
    mix-blend-mode: screen;
    animation: perfectBoardGlow 1.12s ease-out both;
  }
  @keyframes perfectBoardGlow {
    0%   { opacity: 0; background-position: center, 120% 0; filter: brightness(1); }
    18%  { opacity: 1; filter: brightness(1.16); }
    100% { opacity: 0; background-position: center, -30% 0; filter: brightness(1); }
  }

  /* ── SOLVE ACK: the lightest beat — a soft shimmer over the just-completed board (the "촥"), held briefly
     BEFORE the replay empties it. Deliberately gentler than solveBloom (the peak) — first step of the rise. */
  /* ── solve-replay JUICE: accelerating wavefront that builds to a finish punch ── */
  /* ⚠️ **선택자에 `#game`이 반드시 붙어야 한다** (2026-08-06 오너 "리플레이가 투박하다" → 원인 규명).
     `10-game-screen.css`의 `#game #board { … animation: none; }`(보드 장식 초기화, 2026-06 이전부터
     존재)이 **아이디 2개**라 `#board.replayImpact`(아이디1+클래스1)를 이긴다. 그래서 게임 화면에서
     마무리 펀치와 떨림이 **한 번도 재생되지 않았다** — 브라우저 실측: 클래스를 붙여도 computed
     animationName이 `none`. 소리 셋(2600·180·1320Hz)과 0.23초 뒤 진동은 그대로 울리는데 화면은
     가만히 있었다 = 「소리만 나고 그림이 없는 절정」. 같은 파일의 거울 변이(`#game #board.mvSpin*`)는
     이 접두사를 붙여 살아남았고, 리플레이만 빠져 있었다.
     ⚠️ 새 규칙을 여기 추가할 때도 반드시 `#game #board.…`로 쓸 것. */
  /* 떨림(replayShake)은 **일부러 되살리지 않는다** — 0.11초 주기 ±2px 지터는 오너가 지적한 "투박함"을
     키우는 쪽이다. JS(winSequence.js)는 여전히 클래스를 붙이지만 대응 규칙이 없어 아무 일도 안 한다.
     되살리려면 아래에 `#game #board.replayShake { … }`를 추가하되, 그 전에 지터가 정말 필요한지부터
     판단할 것. (감속 모드용 `animation:none !important` 규칙은 아래에 그대로 남아 있다.) */
  /* Squash-and-stretch (2026-07-15, replacing a uniform scale pulse): a brief flatten then an overshoot
     stretch reads as directional weight/impact, not an isotropic "balloon" — the old version's only
     bounce came from the timing-function's overshoot, so it always looked the same in every direction. The
     timing-function here stays a plain ease-out; the bounce is now baked into the keyframes themselves. */
  #game #board.replayImpact { animation: replayPunch .26s cubic-bezier(.22,.8,.32,1) both; }
  /* ⚠️ `transform`이 아니라 **개별 `scale` 속성**을 쓴다. `#board`의 transform은 상태에 따라 둘로 갈린다:
     기본은 CSS의 `translate(-50%,-50%)`(중앙정렬), 확대 중에는 boardFit이 넣는 인라인 `scale(z)`.
     `transform` 키프레임은 그 둘을 **통째로 대체**하므로 어느 쪽이든 보드가 튄다(이 저장소에서 다섯 번
     재발한 함정 — 거울 변이는 키프레임마다 translate를 다시 적어 피했지만, 그 방법은 확대 상태에서
     깨진다). 개별 속성은 기존 transform과 **곱해지므로** 두 상태 모두에서 안전하다. */
  /* ⚠️ **세기를 절반으로 내렸다** (오너 2026-08-06 "없애거나 1/2로 줄이고 싶어").
     이 펀치는 CSS 우선순위 문제로 **한 번도 재생된 적이 없다가** 339f454에서 살아났고, 그제서야 오너가
     처음 보고 「퍼즐 전체가 움찔한다」고 판정했다. 즉 「원래 이랬다」가 아니라 **아무도 검증한 적 없는
     세기**였다 — 그래서 줄이는 것이 원상복구에 가깝다.
     네 축을 **전부 같은 비율(1/2)**로 내린다(§4.2: 한 축만 만지면 균형이 깨진다):
        눌림 1.04/.95 → 1.02/.975 · 펴짐 .97/1.05 → .985/1.025
        밝기 1.3/1.8 → 1.15/1.4  ·  링 3/10/24px → 1.5/5/12px
     길이(.26s)는 그대로다 — 오너 요청은 **세기**였고, 짧게 만들면 「움찔」이 「깜빡」이 된다.
     ⚠️ **완전히 없애려면** 위 `#game #board.replayImpact { animation: … }` 한 줄을 지우면 된다.
        JS(winSequence.js 1030)는 클래스를 계속 붙이지만 대응 규칙이 없으면 아무 일도 안 한다 —
        떨림(replayShake)이 이미 그 상태다. 소리 셋과 진동은 그대로 남으므로 「소리만 나고 그림은 없는」
        상태가 되는데, 그건 이 커밋 전까지의 실제 모습이기도 하다. */
  @keyframes replayPunch {
    0%   { scale: 1 1;        filter: brightness(1);    box-shadow: 0 0 0 0 rgba(255,245,210,0); }
    12%  { scale: 1.02 .975;  filter: brightness(1.15); box-shadow: 0 0 0 1.5px rgba(255,245,210,.5); }
    32%  { scale: .985 1.025; filter: brightness(1.4);  box-shadow: 0 0 0 5px rgba(255,245,210,.4); }
    100% { scale: 1 1;        filter: brightness(1);    box-shadow: 0 0 0 12px rgba(255,245,210,0); }
  }
  /* ── SOLVE BLOOM: three-layer victory flash fired between replayImpact and the win modal ── */
  /* A: jewel bloom — every filled cell surges to peak brightness with a golden halo */
  #board.solveBloom .cell:not(.void) {
    animation: solveJewelBloom .36s ease-out both;
  }
  @keyframes solveJewelBloom {
    0%   { filter: brightness(1); }
    28%  { filter: brightness(2.5) drop-shadow(0 0 16px rgba(255,245,200,.95)); }
    100% { filter: brightness(1)   drop-shadow(0 0 0   rgba(255,245,200,0)); }
  }
  /* B: number pop — all clues scale up gold then settle */
  #board.solveBloom .clue {
    animation: solveNumberPop .52s linear both;
  }
  @keyframes solveNumberPop {
    0%   { transform: scale(1); color: inherit; text-shadow: none; animation-timing-function: ease-in-out; }
    34%  { transform: scaleX(1.87) scaleY(2.14); color: #ffe068; text-shadow: 0 0 14px rgba(255,220,80,1); animation-timing-function: ease-in-out; }
    68%  { transform: scaleX(1.20) scaleY(1.05); animation-timing-function: ease-in-out; }
    88%  { transform: scaleX(.97) scaleY(1.03); animation-timing-function: ease-in-out; }
    100% { transform: scale(1); color: inherit; text-shadow: none; }
  }
  /* gold-wave directional sweep REMOVED (2026-06-23): the replay re-fill is already a L→R flow, so the
     gold wave read as a 2nd left→right sweep. Bloom peak is now the non-directional jewel flash + number
     pop (A+B) only — one directional flow (the fill) for the whole clear. */
  /* C: LEAD FLARE (2026-06-24) — the stained-glass LEADING (region boundary lines) radiates amber ONCE as
     the jewels bloom, so cell(face) + lead(line) light up together = the restored window "switches on".
     Synced to solveBloom (same single peak, no extra beat); .34s fits inside the 340ms bloom window. */
  #board.solveBloom .leadLayer { animation: leadFlare .34s ease-out both; transform-origin: 50% 50%; }
  @keyframes leadFlare {
    0%   { filter: brightness(1); }
    30%  { filter: brightness(1.75) drop-shadow(0 0 8px rgba(255,206,110,.95)) drop-shadow(0 0 3px rgba(255,228,154,.9)); }
    100% { filter: brightness(1); }
  }
  /* ── 등급 차등 (clearGradeTierEnabled · claudedocs/clear_grade_juice_tiering.md §0-2) ──
     소리가 꺼진 환경에서도 등급이 **눈으로** 읽혀야 하므로, 「자랑」 층의 시각 몫인 보석 개화 밝기를
     3단으로 만든다. 올리는 축은 ① 밝기·대비 하나뿐 — 크기·이동·채도·입자는 건드리지 않는다.
     ⚠️ 2★(클래스 없음)가 기준선이고 여기를 바꾸면 안 된다: 별 공식상 2★는 "한 번이라도 지운 사람"이라
     초보자 다수의 정상 등급이다. 1★만 내리고 3★만 올린다.
     ⚠️ 리드 플레어·배경 개화·마무리 임팩트는 「봤다」 층이라 전 등급 동일하게 남긴다. */
  /* ── ① 3★ 리플레이는 **금빛으로** 놓인다 (오너 2026-08-10) ──────────────────────────────────
     종전 사다리는 사실상 오디오 전용이었다(고역 반짝 볼륨 ×0.5·5도 화음). 소리를 끄고 하는 사람에게
     3★와 2★는 **보석 개화 0.3초의 밝기 차이**뿐이었고, 그래서 오너가 「차등을 두면 어때?」라고 물었다 —
     이미 있는데 안 보였다는 뜻이다. 그 몫을 리플레이로 옮긴다: 리플레이는 1~3초로 클리어 구간에서
     가장 길고 가장 몰입해서 보는 자리인데, 지금까지 등급과 무관하게 완전히 똑같이 그려졌다.
     ⚠️ **터지는 것이 아니라 깔려 있는 것**이라 반복해도 안 질린다(§6.2). 3★는 숙련자에겐 매판이므로
        여기에 폭죽류를 얹으면 한 시간 만에 벽지가 되고, 위층(정점 = 첫 무결점·연속 5·10회)이 천장을
        잃는다. 그래서 새 클라이맥스를 만들지 않고 **재질만** 바꾼다.
     ⚠️ 1★·2★는 **건드리지 않는다**(오너: "현재를 별1·2개 기준으로 삼고"). 기본값은 fillpop 안에 있다. */
  #board.gradeTier3 {
    --pop-glow: rgba(255,201,92,1);          /* 따뜻한 흰빛 → 금빛 */
    --pop-glow-fade: rgba(255,201,92,.45);
    --pop-glow-r: 17px;                      /* 10px → 17px */
    --pop-bright: 2.35;                      /* 2 → 2.35 */
  }

  #board.gradeTier1.solveBloom .cell:not(.void) { animation-name: solveJewelBloomSoft; }
  @keyframes solveJewelBloomSoft {
    0%   { filter: brightness(1); }
    28%  { filter: brightness(1.7) drop-shadow(0 0 9px rgba(255,245,200,.55)); }
    100% { filter: brightness(1)   drop-shadow(0 0 0  rgba(255,245,200,0)); }
  }
  #board.gradeTier3.solveBloom .cell:not(.void) { animation-name: solveJewelBloomBright; }
  @keyframes solveJewelBloomBright {
    0%   { filter: brightness(1); }
    28%  { filter: brightness(3.1) drop-shadow(0 0 22px rgba(255,246,205,1)); }
    100% { filter: brightness(1)   drop-shadow(0 0 0   rgba(255,245,200,0)); }
  }
  html[data-motion="reduced"] {
    #win.show, #win.show .card, #win.show h2, #win.show #winNext, #win .card::before, #board.celebrate, #board.celebrate::after, #board.perfectPraise::after,
    #board.replayShake, #board.replayImpact { animation: none !important; }
    #board.solveBloom .cell, #board.solveBloom .clue, #board.solveBloom::before, #board.solveBloom .leadLayer { animation: none !important; }
    #win .winGrade, #win .winGrade * { animation: none !important; }
    #moveBonus, #moveBonus *, #moveBonus::before { animation: none !important; transition: none !important; }
  }
  /* ── gameModal entrance (winPrizeDrop) under reduced motion — 2026-07-29, closing the gap noted in
     claudedocs/modal_design_system.md §8. winPrizeDrop runs with `both`, so its 0% frame (opacity 0,
     translateY(42px), scale(.78)) is what the element wears BEFORE/OUTSIDE the run; killing the
     animation without naming the end state is how a modal ends up invisible. So the settled state is
     spelled out here instead of relying on the cards' base values. */
  html[data-motion="reduced"] #noDrags.show .card,
  html[data-motion="reduced"] #skipModal.show .card,
  html[data-motion="reduced"] #moveOffer.show .card,
  html[data-motion="reduced"] #legalConsent.show .card {
    animation: none;
    opacity: 1;
    transform: none;      /* = winPrizeDrop 100%: translateY(0) scale(1) */
    filter: brightness(1);
  }
  /* ⚠️ OS의 `prefers-reduced-motion`은 **일부러 안 건다.** fx/motion.js의 오너 결정 — "폰 레벨 설정이
     모두의 juice를 죽여선 안 된다, 원하는 사람은 설정에서 opt-in한다"이고, 그래서 이 코드베이스의 모든
     감속 규칙은 `html[data-motion="reduced"]` 하나로만 스코프된다. 이번 정리에서 잠깐 @media 사본을
     넣었다가 되돌렸다: 정리 작업이 오너 정책을 조용히 바꾸면 안 된다. 정책을 바꾸려면 motion.js부터. */
  /* confetti overlay sits above everything; never blocks taps */
  #confetti { position: fixed; inset: 0; z-index: 70; pointer-events: none; display: block; }

  /* ── DEMO: rewarded-ad overlay (monetization mock; not part of the puzzle loop) ──
     fullscreen fake "video" ad that plays for 5s, then reveals the solution. The faux
     gameplay panel + AD badge + Install CTA mimic a real rewarded-video unit. */
  #ad { position: fixed; inset: 0; z-index: 80; display: none; flex-direction: column; background: #05060a; }
  /* 광고 로딩 오버레이 — 실 AdMob 로드(2~3초) 동안 입력 차단 + 스피너로 "불러오는 중" 피드백(오작동 방지) */
  #adLoading { position: fixed; inset: 0; z-index: 82; display: none; flex-direction: column;
    align-items: center; justify-content: center; gap: 14px; background: rgba(12, 9, 5, .62);
    -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); }
  #adLoading.show { display: flex; }
  .adLoadingSpinner { width: 44px; height: 44px; border-radius: 50%;
    border: 4px solid rgba(255, 246, 226, .25); border-top-color: var(--s3); animation: adSpin .8s linear infinite; }
  @keyframes adSpin { to { transform: rotate(360deg); } }
  .adLoadingText { margin: 0; color: #fff6e2; font-weight: 800; font-size: 15px; text-shadow: 0 1px 3px rgba(0,0,0,.5); }
  html[data-motion="reduced"] .adLoadingSpinner { animation-duration: 2.2s; }
  #ad.show { display: flex; animation: adFade .25s ease-out both; }
  @keyframes adFade { from { opacity: 0; } to { opacity: 1; } }
  /* the "video": a panning jewel-gradient with floating shards = pretend gameplay footage */
  #ad .adVideo {
    position: relative; flex: 1; overflow: hidden;
    background: linear-gradient(120deg, var(--s1), var(--s2), var(--s3), var(--s4), var(--s5));
    background-size: 320% 320%;
    animation: adPan 5s ease-in-out both;
  }
  @keyframes adPan { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } }
  #ad .adShape { position: absolute; border-radius: 16px; mix-blend-mode: screen; opacity: .8; box-shadow: 0 8px 30px rgba(0,0,0,.4); }
  #ad .s1 { width: 84px; height: 84px; left: 14%; top: 24%; background: #fff8; animation: adFloat 2.1s ease-in-out infinite alternate; }
  #ad .s2 { width: 64px; height: 64px; right: 18%; top: 38%; background: #fff6; transform: rotate(18deg); animation: adFloat 2.7s ease-in-out infinite alternate-reverse; }
  #ad .s3 { width: 100px; height: 100px; left: 30%; bottom: 18%; background: #ffffff55; transform: rotate(-12deg); animation: adFloat 3.1s ease-in-out infinite alternate; }
  @keyframes adFloat { from { transform: translateY(-12px) rotate(0deg); } to { transform: translateY(14px) rotate(8deg); } }
  #ad .adLogo { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
    font-size: 64px; color: #fff; text-shadow: 0 4px 24px rgba(0,0,0,.45); animation: adPulse 1.4s ease-in-out infinite; }
  @keyframes adPulse { 0%,100% { transform: scale(1); opacity: .92; } 50% { transform: scale(1.08); opacity: 1; } }
  #ad .adTag { position: absolute; left: 0; right: 0; bottom: 16px; text-align: center; color: #fff; font-size: 15px; letter-spacing: .4px; opacity: .85; }
  /* AD badge + live countdown, top-right (the classic "rewarded" timer) */
  #ad .adBar { position: absolute; top: calc(env(safe-area-inset-top, 0px) + 12px); right: 12px; display: flex; align-items: center; gap: 8px; }
  #ad .adBadge { font-size: 11px; font-weight: 700; letter-spacing: 1px; color: #000; background: #ffd15c; padding: 2px 7px; border-radius: 4px; }
  #ad .adCount { display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 26px; padding: 0 6px;
    font-size: 14px; font-weight: 700; color: #fff; background: rgba(0,0,0,.5); border: 1px solid #fff5; border-radius: 50%; }
  /* fake store CTA strip — sells the "real ad" feel; non-interactive in the demo */
  #ad .adCta { display: flex; align-items: center; gap: 12px; padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px)); background: var(--panel); }
  #ad .adIcon { width: 46px; height: 46px; border-radius: 11px; flex: none; display: flex; align-items: center; justify-content: center;
    font-size: 24px; background: linear-gradient(145deg, var(--s5), var(--s4)); }
  #ad .adInfo { flex: 1; min-width: 0; text-align: left; }
  #ad .adInfo b { display: block; font-size: 15px; }
  #ad .adStars { font-size: 12px; color: #ffd15c; letter-spacing: 1px; }
  #ad .adInstall { flex: none; padding: 9px 18px; border-radius: 999px; border: none; font-size: 14px; font-weight: 700;
    color: #04130c; background: var(--s3); opacity: .9; }
  html[data-motion="reduced"] {
    #ad .adVideo, #ad .adShape, #ad .adLogo { animation: none !important; }
  }

  /* (마스터리 흔적 표시는 승리 카드에서 완전히 사라졌다: 풀스크린 trace seal 2026-07-06 제거 → 카드 위 칩
     2026-07-22 제거. 이유는 game/showWin.js 상단 주석 참조. 세 항목은 맵의 Sha 복습 모달에만 남는다.
     fx/traceSeal.js + win.traceSeal.* 문자열도 이미 제거됨.) */

  /* ══ 모달 등장 연출 — 「쫀득」 2티어 (오너 지시 2026-08-02 · 2026-08-06 앱 기본 기능으로 영구 채택)
     훅은 `index.html`의 `<html data-modal-motion="tiered">` **상수**다 — 옛 `modalEntranceEnabled`
     플래그와 [DEV] 토글은 삭제됐고, 따라서 원격으로 끄는 비상 스위치도 없다. 문제가 생기면 앱
     업데이트로 고친다(CLAUDE.md Architecture의 영구 채택 규칙). ══════════════════════════════════
     발단 = 오너 "모달이 출현할 때 쫀득한 느낌이 나도록" → "차등하자".

     실측(2026-08-02): 게임모달 14개 중 **등장 애니가 있는 것은 4개뿐**이었고(noDrags · skipModal ·
     moveOffer · legalConsent), 그 넷은 전부 **승리 카드의 `winPrizeDrop`을 빌려 쓰고 있었다** — 막힘
     게이트가 상품 카드와 똑같이 통통 튀었다는 뜻이다. 나머지 10개는 딤도 카드도 하드 컷.
     그래서 이 블록이 하는 일은 "애니를 더 화려하게"가 아니라 **없던 등장을 만들고, 있던 것을 그 모달이
     알리는 소식에 맞게 다시 고르는 것**이다.

     ⑴ 딤이 **먼저** 스며들고(.14~.18s) ⑵ 카드가 그 위에 안착한다(딜레이 .05s). 이 순서가 「쫀득」의
        절반이다 — 딤과 카드가 같은 프레임에 튀어나오면 아무리 튕겨도 "붙었다 뗐다"로 읽힌다.
     ⑵ 티어는 **모달이 알리는 소식**으로 가른다:
        · 「초대」 = 제안·안내(힌트 · 광고 +3 · 스킵 · 옷장 · 별 게이트) → 아래에서 **탄력 있게
          올라와** 안착. 오버슈트 1회.
        · 「무게」 = 확인·손실(처음부터 · 나가기 · 종료 · 시간 초과 · 하트 0 · 획수 0 · 동의) →
          위에서 **무겁게 내려앉는다.** 오버슈트 없음, 착지에서 아주 얕게 한 번 눌린다.
        잃는 순간에 상품처럼 튀면 톤이 어긋난다. `noDrags`/`noLives`가 정확히 그 상태였고(승리 카드와
        동일한 튐) 이번에 무게 쪽으로 옮겼다 — 이건 "juice를 뺀" 게 아니라 **맞는 juice로 바꾼** 것이다.

     ⚠️ **스쿼시&스트레치를 쓴다 (오너 결정 2026-08-02 — 같은 날 오전의 "석판은 돌이니 비균일 스케일
        금지"를 뒤집는다).** 그 금지가 곧 천장이었다. 오너 판정: *"물리법칙을 흉내내서 스퀴징 액션을
        잘 가미하면 단일 부품인 약점을 보완할 수 있다."* 맞는 말이고, 이 모달의 조건에서는 **더** 맞다 —
        로얄 매치류 팝업은 프레임이 리본·배너·보석으로 **여러 부품**이라 부품끼리 어긋나는 것만으로
        생동감이 나오지만, 우리 카드는 돌판 **한 장**이다. 부품이 하나뿐이면 그 하나가 물리를 가져야 한다.
        규칙은 "쓰지 말 것"이 아니라 **"제대로 쓸 것"**이다:
        · **부피 보존** — 가로로 퍼지면 세로로 눌린다(scaleX × scaleY ≈ 1). 이걸 안 지키면 커졌다
          작아졌다 하는 것으로만 보이고 탄성이 안 읽힌다.
        · **빠를 때 늘어나고, 멈출 때 눌린다** — 상승 중 = 세로 스트레치, 착지 = 가로 스쿼시.
        · **되튐은 점점 작아진다** — 감쇠하지 않는 진동은 고무공이지 물체가 아니다.

     ⚠️⚠️ **「쫀득함」의 정본은 타이틀 PLAY 버튼의 `btnNudge`다 (오너 2026-08-02:
        "타이틀 >> 플레이 버튼에 적용된 애니메이션 효과가 쫀득함의 내 기준이야").**
        실측(styles/app/04-screens-title.css): `1.06/0.92 → 0.96/1.07 → 1.02/0.98 → 0.99/1.01 → 1`,
        `ease-in-out`, **이동 0 · 회전 0**. 여기서 배운 세 가지가 1차·2차가 밋밋했던 진짜 이유다:
        ⑴ **자리를 잡은 뒤에는 아무것도 움직이지 않는다 — 눌리고 늘어나기만 한다.** 이동과 회전은
           변형을 **희석시킨다**(눈이 위치 변화를 먼저 쫓아서 젤리를 놓친다). 그래서 이 파일의
           `translateY`는 **착지 전까지만** 존재하고, 착지 뒤 구간은 전부 `translateY(0)`이다.
           회전은 아예 없앴다(오너 어휘에 없다).
        ⑵ **각 눌림/늘어남이 머무는 시간이 곧 쫀득함이다.** `btnNudge`는 한 단계에 ~400ms를 쓴다.
           2차 시도는 `linear` + 총 .48s라 최대 스쿼시를 **60ms 만에 스쳐** 지나갔다 — 값은 컸지만
           눈에 남지 않았다. 지금은 착지 후 4단계에 ~150ms씩 준다.
        ⑶ **`ease-in-out`** — 단계마다 붙였다 떨어지듯 가감속해야 젤리가 "숨을 쉰다". `linear`는
           같은 궤적도 기계적으로 만든다. 키프레임 안의 `animation-timing-function`이 그 역할이고,
           그래서 shorthand의 함수는 `linear`(=쓰이지 않는 기본값)로 둔다.
     ⚠️ 진폭만 키우는 것으로는 절대 못 따라간다 — 이미 ±16%까지 갔었고 그래도 밋밋했다. 쫀득함은
        **진폭이 아니라 체류 시간과 가감속**에서 나온다.
        ⚠️ 한계선: 최대 변형은 **±16%**. 프레임이 36px 9-slice `border-image`라 그 이상 벌리면 테두리
        조각의 돌 무늬가 눈에 띄게 늘어나 고무로 읽힌다. 늘리고 싶으면 실기기에서 **모서리부터** 보라.
     ⚠️ **`.show`가 아니라 `.modalCard`에 직접 건다.** 이 모달들은 절반이 `style.display="flex"`로,
        절반이 `.show` 클래스로 열린다(modalA11y.js가 둘 다 감시하는 이유와 같다). display:none →
        flex 전환은 **어느 경로든 애니를 재시작**시키므로 카드에 직접 거는 쪽이 두 경로를 한 번에 덮는다.
     ⚠️ 건드리지 않는 것: `#win`(연출 헌법 관할) · `#windowView`/`#dailyCard`(D 뷰어) ·
        `#gameMenu`(트레이). modal_design_system.md §10-3.
     ⚠️ 특이도: 여기 선택자는 (1,2,1)이라 옛 `#noDrags.show .card`(1,2,0)를 이긴다. 감속 규칙은 그
        아래에서 속성 하나를 더 달아 (1,3,1)로 이 블록을 다시 이긴다 — 순서를 섞지 말 것. */

  html[data-modal-motion="tiered"] #ask,
  html[data-modal-motion="tiered"] #moveOffer,
  html[data-modal-motion="tiered"] #skipModal,
  html[data-modal-motion="tiered"] #starGate,
  html[data-modal-motion="tiered"] #wardrobeBuyConfirm,
  html[data-modal-motion="tiered"] #wardrobeGateInfo {
    animation: modalScrimIn .14s ease-out both;
  }
  html[data-modal-motion="tiered"] #restartAsk,
  html[data-modal-motion="tiered"] #attemptAsk,
  html[data-modal-motion="tiered"] #leaveAsk,
  html[data-modal-motion="tiered"] #exitAsk,
  html[data-modal-motion="tiered"] #bossTimeUp,
  html[data-modal-motion="tiered"] #noLives,
  html[data-modal-motion="tiered"] #noDrags,
  html[data-modal-motion="tiered"] #legalConsent {
    animation: modalScrimIn .18s ease-out both;   /* 무거운 소식일수록 딤이 조금 더 천천히 앉는다 */
  }

  /* 「초대」 — 아래에서 올라와 한 번 오버슈트하고 안착 */
  html[data-modal-motion="tiered"] #ask .modalCard,
  html[data-modal-motion="tiered"] #moveOffer .modalCard,
  html[data-modal-motion="tiered"] #skipModal .modalCard,
  html[data-modal-motion="tiered"] #starGate .modalCard,
  html[data-modal-motion="tiered"] #wardrobeBuyConfirm .modalCard,
  html[data-modal-motion="tiered"] #wardrobeGateInfo .modalCard,
  html[data-modal-motion="tiered"] #attemptAsk .modalCard {
    /* shorthand의 `linear`는 쓰이지 않는다 — 실제 이징은 키프레임 안의
       `animation-timing-function`(단계마다 ease-in-out)이 진다. 위 ⚠️⚠️ 참조. */
    animation: modalInviteIn 1.08s linear .04s backwards;
  }
  /* 「무게」 — 위에서 내려앉고, 착지에서 아주 얕게 눌렸다 편다 */
  /* 남은 「무게」 = **자원 게이트와 동의**뿐이다. 손실을 *확인*하거나 *통보*하는 화면은 아래
     `modalWeightCalmIn`으로 옮겼다(오너 2026-08-03) — 거기서 튀면 몸짓이 말과 반대가 된다.
     여기 셋은 잃는 것을 확인하는 자리가 아니라 "지금 이걸 할 수 있다"를 여는 자리라 착지가 어울린다. */
  html[data-modal-motion="tiered"] #noLives .modalCard,
  html[data-modal-motion="tiered"] #noDrags .modalCard,
  html[data-modal-motion="tiered"] #legalConsent .modalCard {
    animation: modalWeightIn .84s linear .05s backwards;
  }
  /* ── 「처음부터 할까요?」만 **스쿼시 없이** 내려앉는다 (오너 2026-08-03 "스퀴즈 액션이 안 어울려") ──
     이 판단의 근거는 이미 이 파일 안에 있었다 — 바로 아래 「무게」 내용 도착 블록이 이렇게 적고 있다:
       *"손실을 알리는 화면에서 버튼이 하나씩 통통 튀어오르면 그건 축하다."*
     그 논리를 **내용에만 적용하고 프레임에는 안 적용한 것**이 이번 어긋남의 정체다. 카드가 눌리고
     늘어나며 되튀는 동안, 그 카드가 묻는 말은 "지금까지 칠한 걸 버릴까요?"다 — 몸짓이 말과 반대다.
     낙하(위에서 내려옴)라는 「무게」의 성격은 유지하고 **탄성만 걷는다**: 14px 내려와 멈춘다.
     ── 전파 (오너 승인 2026-08-03, 전수 대조 후) ────────────────────────────────────────────────
     판정 기준 = **"모달이 하는 말"과 "등장 몸짓"이 같은 말을 하는가.** 그래서 이 넷이 여기 있다:
       · 처음부터 할까요? / 나갈까요? / 게임을 종료할까요? — 잃는 것을 **확인**한다
       · 빛이 꺼졌어요(bossTimeUp) — 확인도 아니고 **실패 통보**다. 셋 중 가장 튀면 안 되는 화면.
     남겨 둔 것(의도적):
       · 하트가 없어요 — 문구가 "잠깐 쉬어도 괜찮아요"라 손실 통보가 아니라 **위로**다.
       · 계속 이어갈까요?(획수0) · 동의 — 잃는 것을 확인하는 자리가 아니라 **제안·게이트**다.
       · 「초대」 6종(한 조각 열까요? · 건너뛸까요? · 봉인된 지역 · 구매 확인 · 획득 조건 · 여유롭게)
         — 전부 거절해도 그만인 제안이라 튀어오르는 게 맞다.
     ⚠️ 「이어서 할까요?」(attemptAsk)는 **반대 방향으로** 옮겼다 — 「무게」에 있었지만 실제 내용은
        "진행을 남겨뒀어요"라는 **반가운 소식**이라 초대 티어로 갔다(위 modalInviteIn 목록).
     ⚠️ 감속 모드는 네 id를 이미 포함한 블록이 덮으므로 따로 적지 않는다. */
  html[data-modal-motion="tiered"] :is(#restartAsk, #leaveAsk, #exitAsk, #bossTimeUp) .modalCard {
    animation: modalWeightCalmIn .34s cubic-bezier(.2,.78,.32,1) .05s backwards;
  }
  @keyframes modalWeightCalmIn {
    0%   { opacity: 0; transform: translateY(-14px); }
    100% { opacity: 1; transform: translateY(0); }
  }

  @keyframes modalScrimIn { from { opacity: 0; } to { opacity: 1; } }
  /* 「초대」 = 아래에서 **솟구쳐 올라 정점을 찍고 제자리로 떨어지며 착지**하는 한 덩어리의 물리.
     읽는 법(퍼센트 = 이야기):
       0–6%   웅크림(anticipation). 튀어오르기 직전 살짝 눌린 채로 시작한다.
       6–24%  최대 속도로 상승 → **세로로 늘어난다**(scaleY↑ scaleX↓).
       24–38% 정점에서 감속 → 늘어남이 풀린다.
       38–50% 제자리로 낙하해 **착지 = 최대 스쿼시**(scaleX↑ scaleY↓). 이 한 프레임이 손맛의 핵심이다.
       50–100% 되튐 → 스쿼시 → 되튐, **매번 절반씩 작아지며** 잦아든다(감쇠).
     기울기 -2.4°는 착지(50%)에서 거의 풀리고 나머지 진동에서 0으로 수렴한다 — 회전이 0이 되는 순간이
     "자리를 잡았다"는 두 번째 신호라, 크기 변화만 있을 때보다 착지가 훨씬 또렷하다. */
  @keyframes modalInviteIn {
    /* 0~22% = 도착(여기서만 움직인다). 22% = 착지 = 최대 스쿼시. 그 뒤로는 제자리에서 눌리고
       늘어나기만 한다 — 값은 `btnNudge`에서 역산했다: 첫 진폭 sx/sy ≈ 1.14, 반주기 ≈ 300ms.
       ⚠️ 이 두 수가 「쫀득함」의 실체다. 2026-08-02 실측으로 잡았다 — 그 전 시도는 진폭이 1.6배 크고
       리듬이 1.5배 빨라서, 같은 궤적인데도 "쫀득"이 아니라 "탁 튕김"으로 읽혔다.
       진폭을 더 키우고 싶어지면 반대로 가야 한다: **줄이고 늦춰라.** */
    0%   { opacity: 0; transform: translateY(44px) scale(.72);
           animation-timing-function: cubic-bezier(.2,.72,.3,1); }
    15%  { opacity: 1; transform: translateY(-9px) scaleX(.95) scaleY(1.08);
           animation-timing-function: cubic-bezier(.4,0,.2,1); }
    22%  { transform: translateY(0) scaleX(1.072) scaleY(.937); animation-timing-function: ease-in-out; }
    50%  { transform: translateY(0) scaleX(.949) scaleY(1.054); animation-timing-function: ease-in-out; }
    78%  { transform: translateY(0) scaleX(1.022) scaleY(.978); animation-timing-function: ease-in-out; }
    91%  { transform: translateY(0) scaleX(.993) scaleY(1.007); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  /* 「무게」 = **위에서 낙하해 바닥을 치는** 물리. 같은 문법, 다른 성격:
     스쿼시는 초대보다 **더 깊고**(무거우니까) 되튐은 **더 적고 빨리 죽는다**(무거우니까). 회전은 없다 —
     회전은 장난기라 손실을 알리는 화면의 것이 아니다.
     ⚠️ 되튐을 0으로 두지 말 것. 그러면 탄성이 아니라 그냥 페이드가 되어 손맛이 통째로 사라진다.
     무거운 물체에도 착지는 있고, 다만 한 번만 있다. */
  @keyframes modalWeightIn {
    /* 같은 리듬(반주기 ~294ms), 첫 진폭만 한 단 깊고(sx/sy ≈ 1.18) 되튐이 한 번 적다 = 무겁다. */
    0%   { opacity: 0; transform: translateY(-36px) scaleX(.96) scaleY(1.10);
           animation-timing-function: cubic-bezier(.3,.62,.3,1); }
    21%  { opacity: 1; transform: translateY(0) scaleX(1.085) scaleY(.922); animation-timing-function: ease-in-out; }
    56%  { transform: translateY(0) scaleX(.955) scaleY(1.047); animation-timing-function: ease-in-out; }
    86%  { transform: translateY(0) scaleX(1.015) scaleY(.985); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  /* 감속 모드 — 두 애니 모두 `both`라 애니만 죽이면 0% 프레임(투명·이동)을 그대로 입는다.
     위 2026-07-29 winPrizeDrop 블록과 같은 이유로 **최종 상태를 말로 적어** 둔다.
     선택자에 속성이 하나 더 붙어 (1,3,1) — 위 블록(1,2,1)을 이긴다. */
  html[data-motion="reduced"][data-modal-motion="tiered"] #ask,
  html[data-motion="reduced"][data-modal-motion="tiered"] #moveOffer,
  html[data-motion="reduced"][data-modal-motion="tiered"] #skipModal,
  html[data-motion="reduced"][data-modal-motion="tiered"] #starGate,
  html[data-motion="reduced"][data-modal-motion="tiered"] #wardrobeBuyConfirm,
  html[data-motion="reduced"][data-modal-motion="tiered"] #wardrobeGateInfo,
  html[data-motion="reduced"][data-modal-motion="tiered"] #restartAsk,
  html[data-motion="reduced"][data-modal-motion="tiered"] #attemptAsk,
  html[data-motion="reduced"][data-modal-motion="tiered"] #leaveAsk,
  html[data-motion="reduced"][data-modal-motion="tiered"] #exitAsk,
  html[data-motion="reduced"][data-modal-motion="tiered"] #bossTimeUp,
  html[data-motion="reduced"][data-modal-motion="tiered"] #noLives,
  html[data-motion="reduced"][data-modal-motion="tiered"] #noDrags,
  html[data-motion="reduced"][data-modal-motion="tiered"] #legalConsent,
  html[data-motion="reduced"][data-modal-motion="tiered"] #ask .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #moveOffer .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #skipModal .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #starGate .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #wardrobeBuyConfirm .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #wardrobeGateInfo .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #restartAsk .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #attemptAsk .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #leaveAsk .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #exitAsk .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #bossTimeUp .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #noLives .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #noDrags .modalCard,
  html[data-motion="reduced"][data-modal-motion="tiered"] #legalConsent .modalCard {
    animation: none;
    opacity: 1;
    transform: none;
  }

  /* ── 내용은 프레임과 **따로** 도착한다 (2026-08-02, 「쫀득」 2차) ────────────────────────────────
     1차 시도가 밋밋했던 **가장 큰 이유**가 이것이었다: 카드가 통째로 한 덩어리로 움직였다. 프레임과
     글자와 버튼이 한 몸이면 아무리 스프링을 세게 줘도 "판때기 하나가 들어왔다"로 끝난다. 슈퍼셀·드림
     게임즈 팝업의 손맛은 대부분 여기서 나온다 — **프레임이 먼저 앉고, 제목이 뒤따라 튀고, 버튼이 하나씩
     차례로 도착한다.** 새 그림이 한 장도 필요 없다: 제목·본문·상태 칩·버튼은 이미 각각 별개 요소다.
     타이밍: 상태 칩 .10s → 제목 .13s → 본문 .17s → 버튼 .21s부터 .05s 간격 → X .24s.
     (프레임 스프링이 아직 흔들리는 동안 시작한다 — 다 끝나고 시작하면 두 장면으로 쪼개져 늘어진다.)
     ⚠️ **`both`가 아니라 `backwards`다.** `both`는 애니가 끝난 뒤에도 100% 프레임을 계속 입히는데,
     애니는 일반 선언을 이기므로 버튼의 `:active { transform: … }` 눌림 피드백이 **영구히 죽는다**
     (`.modalX:active`의 scale(.92)가 대표적). `backwards`는 시작 전만 0% 프레임을 입히고 끝나면
     기본값으로 돌아가므로 눌림이 그대로 살아난다. 새 파트를 추가할 때도 반드시 `backwards`.
     ⚠️ 중첩(`&`)과 `:is()`를 쓴다 — 이 파일이 이미 쓰는 문법이다(위 감속 블록). */
  html[data-modal-motion="tiered"] :is(#ask, #moveOffer, #skipModal, #starGate, #wardrobeBuyConfirm, #wardrobeGateInfo, #attemptAsk) .modalCard {
    & > :is(.modalTitle, .modalStateBlock, .modalBody, .modalX, .walletNote, .heartNote),
    & :is(.row, .modalActions) > * {
      animation: modalPartIn .44s linear backwards;
    }
    /* 딜레이는 카드가 **솟구치는 동안** 겹쳐서 흐른다(overlapping action). 착지(.28s) 뒤에 몰면
       안 된다 — 카드는 최종 크기로 뜨는데 내용이 비어 있어서 **빈 석판이 1/4초간 보인다.**
       실측으로 잡은 결함이다(2026-08-02, 임팩트 프레임 스크린샷). 첫 내용은 .09s에 시작해 카드가
       비어 보이는 구간을 ~110ms로 줄이고, 마지막 내용이 착지 직후에 안착하도록 폭을 잡았다.
       ⚠️ 순서를 유지하는 게 핵심이지 "충격 뒤에 나오는 것"이 핵심이 아니다 — 스태거의 값어치는
       한 덩어리가 아니라는 감각에서 나오고, 그건 비행 중에 겹쳐도 그대로 얻어진다. */
    & > .modalStateBlock { animation-delay: .07s; }
    & > .modalTitle      { animation-delay: .09s; }
    & > .modalBody       { animation-delay: .12s; }
    & > .modalX          { animation-delay: .20s; }
    & > :is(.walletNote, .heartNote) { animation-delay: .26s; }
    & :is(.row, .modalActions) > *:nth-child(1)   { animation-delay: .15s; }
    & :is(.row, .modalActions) > *:nth-child(2)   { animation-delay: .18s; }
    & :is(.row, .modalActions) > *:nth-child(3)   { animation-delay: .21s; }
    & :is(.row, .modalActions) > *:nth-child(n+4) { animation-delay: .24s; }
  }
  /* 「무게」도 따로 도착하되 **튐 없이, 더 촘촘하게.** 손실을 알리는 화면에서 버튼이 하나씩 통통
     튀어오르면 그건 축하다. 여기서는 같은 "따로 도착"의 이점(한 덩어리가 아니라는 감각)만 가져온다. */
  html[data-modal-motion="tiered"] :is(#restartAsk, #leaveAsk, #exitAsk, #bossTimeUp, #noLives, #noDrags, #legalConsent) .modalCard {
    & > :is(.modalTitle, .modalStateBlock, .modalBody, .modalX, .walletNote, .heartNote),
    & :is(.row, .modalActions) > * {
      animation: modalPartInCalm .34s linear backwards;
    }
    /* 같은 이유로 비행 중에 겹친다(위 「빈 석판」 주석). 초대보다 촘촘하고 빨리 끝난다. */
    & > .modalStateBlock { animation-delay: .06s; }
    & > .modalTitle      { animation-delay: .08s; }
    & > .modalBody       { animation-delay: .10s; }
    & > .modalX          { animation-delay: .17s; }
    & > :is(.walletNote, .heartNote) { animation-delay: .22s; }
    & :is(.row, .modalActions) > *:nth-child(1)   { animation-delay: .13s; }
    & :is(.row, .modalActions) > *:nth-child(2)   { animation-delay: .16s; }
    & :is(.row, .modalActions) > *:nth-child(3)   { animation-delay: .19s; }
    & :is(.row, .modalActions) > *:nth-child(n+4) { animation-delay: .22s; }
  }
  /* 내용의 스쿼시는 프레임의 **절반 이하**(±7%)로 묶는다. 이미 프레임의 변형을 상속받고 있어서
     (자식이니까) 여기서 세게 주면 두 번 곱해져 글자가 출렁인다 — 프레임이 늘어나는 건 돌이라
     읽히지만 글자가 늘어나면 그냥 깨진 화면이다. 여기서 필요한 건 "따라 붙는다"는 감각뿐이다. */
  @keyframes modalPartIn {
    0%   { opacity: 0; transform: translateY(14px) scale(.88); animation-timing-function: cubic-bezier(.2,.72,.3,1); }
    34%  { opacity: 1; transform: translateY(0) scaleX(1.05) scaleY(.952); animation-timing-function: ease-in-out; }
    70%  { transform: translateY(0) scaleX(.972) scaleY(1.029); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  @keyframes modalPartInCalm {
    0%   { opacity: 0; transform: translateY(9px) scaleY(.96); animation-timing-function: cubic-bezier(.2,.72,.3,1); }
    58%  { opacity: 1; transform: translateY(0) scaleX(1.028) scaleY(.982); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  /* 감속 모드 — `backwards`라서 애니만 끄면 기본 스타일(보임)로 돌아간다. 최종 상태를 따로 적지
     않는 이유이자, 적으면 안 되는 이유다(적으면 위 `:active` 문제가 감속 모드에서 되살아난다). */
  html[data-motion="reduced"][data-modal-motion="tiered"] .modalCard > :is(.modalTitle, .modalStateBlock, .modalBody, .modalX, .walletNote, .heartNote),
  html[data-motion="reduced"][data-modal-motion="tiered"] .modalCard :is(.row, .modalActions) > * {
    animation: none;
  }

  /* ── 모달 주 행동에 idle 넛지 (오너 2026-08-02 "원해") ───────────────────────────────────────
     대상은 **초록 필리그리 하나뿐**이다 — modal_design_system.md §5에서 그것이 "무료로 지금 계속하는
     가장 좋은 길"로 정의돼 있고, 넛지는 그 길을 가리키는 손짓이다. 금테(지불)·석판(취소·물러남)에는
     붙이지 않는다(돈 쓰는 쪽이나 물러나는 쪽을 흔들어 부르면 뜻이 뒤집힌다 — styles/ui.css 참조).
     ⚠️ 등장 애니(`modalPartIn`)를 **같은 선언 안에** 다시 적는 이유: `animation` shorthand는 앞
     규칙을 통째로 교체하므로, 넛지만 적으면 그 버튼의 등장이 사라진다. 딜레이도 둘 다 명시한다
     (위 nth-child 딜레이 규칙이 단일 값이라 두 애니에 모두 적용돼 넛지 시작을 앞당기기 때문).
     ⚠️ 선택자에 `.modalCard`와 `:nth-child(-n+9)`가 붙은 것은 장식이 아니다 — 위 nth-child 딜레이
     규칙이 (1,4,1)이라, 그보다 낮으면 **넛지 딜레이가 등장 딜레이로 덮여** 등장 도중에 흔들린다
     (실측으로 잡았다: 1.6s로 적었는데 150ms가 적용됐다). 특이도를 맞추고 뒤에 두어 이긴다.
     ⚠️ 넛지 딜레이(1.6s / 1.4s)는 등장이 끝난 뒤에 시작하도록 잡은 값이다 — 등장 중에 겹치면
     두 물리가 싸워 첫인상이 흔들린다. */
  html[data-modal-motion="tiered"] :is(#ask, #moveOffer, #skipModal, #wardrobeBuyConfirm) .modalCard :is(.row, .modalActions) > :is(.modalPrimary, .primary):nth-child(-n+9) {
    animation: modalPartIn .44s linear backwards, ctaNudge 5.8s ease-in-out infinite;
    animation-delay: .15s, 1.6s;
  }
  html[data-modal-motion="tiered"] :is(#noLives, #noDrags, #restartAsk, #legalConsent) .modalCard :is(.row, .modalActions) > :is(.modalPrimary, .primary):nth-child(-n+9) {
    animation: modalPartInCalm .34s linear backwards, ctaNudge 5.8s ease-in-out infinite;
    animation-delay: .13s, 1.4s;
  }
  /* 감속 모드: 넛지는 끄고 등장만 남긴다(§4.6 — 모션 축소는 움직임을 줄이는 것이지 의미를 없애는 게 아니다). */
  html[data-motion="reduced"][data-modal-motion="tiered"] :is(#ask, #moveOffer, #skipModal, #wardrobeBuyConfirm) :is(.row, .modalActions) > :is(.modalPrimary, .primary),
  html[data-motion="reduced"][data-modal-motion="tiered"] :is(#noLives, #noDrags, #restartAsk, #legalConsent) :is(.row, .modalActions) > :is(.modalPrimary, .primary) {
    animation: none;
  }

  /* ════ #winStreakBanner — 무결점 연속 현수막 (2026-08-07, 오너 승인) ═══════════════════════════
     카드 **윗변에 걸쳐** 늘어뜨린다. 카드 안이 아닌 이유는 index.html의 주석에 있다(안쪽은 구운 프레임
     위의 절대좌표라 줄을 더하면 열쇠 미터를 덮는다 — 실측 12px 침범).

     ⚠️ **라벨 왼쪽 · 숫자 오른쪽으로 가르는 것이 설계의 핵심이다.** 카드 위 한가운데는 샤가 서 있는
        자리라(`winMascotTopEnabled`, 발을 윗변에 건다) 가운데 정렬하면 샤가 글자를 정확히 가린다 —
        390×844에서 실제로 "무… 5"만 남는 것을 확인하고 이 배치로 바꿨다. 지금은 글자와 샤 사이가
        각각 23px·78px 뜬다. 폭을 키우거나 padding을 줄이면 그 여유부터 사라진다.

     ⚠️ 색은 **석판 + 금장**이다. 붉은 현수막을 먼저 그려 봤는데, 이 게임에서 루비는 「잃는 행동」의
        뜻이라(모달 버튼 어휘) 축하 자리에 쓰면 신호가 어긋난다.

     ⚠️ `[hidden]` 짝 규칙이 **반드시 있어야 한다** — 작성자 `display`를 준 요소는 브라우저 기본
        `[hidden]{display:none}`을 이기므로, 없으면 숨겨도 안 숨는다(이 코드베이스의 기존 사고). */
  #win .card > #winStreakBanner {
    position: absolute; left: -10px; right: -10px; bottom: 100%; margin-bottom: -16px;
    z-index: 1;              /* 샤(z-index:2)보다 뒤 → 샤가 현수막 **앞에** 선다 */
    display: flex; align-items: center; justify-content: space-between;
    padding: 7px 34px 10px; pointer-events: none; white-space: nowrap;
    font-family: 'Jua', system-ui, sans-serif; font-weight: 400;
    font-size: clamp(13px, 3.6vw, 16px); letter-spacing: .02em;
    color: #ece1ca;
    /* 「띠」의 결 — 한 겹 색이 아니라 여섯 겹을 포갠다 (위에서부터 그려진다):
       ① 상단 금빛 실선  ② 안쪽 이중선(음각)  ③ 양끝 접힘 그늘 — 이게 "천이 접혀 뒤로 넘어간다"를 만든다
       ④ 사선 광택 한 줄  ⑤ 세로 결(아주 옅은 줄무늬)  ⑥ 석판 바탕.
       추가 DOM 0 — 배경 레이어만으로 낸다. */
    background:
      linear-gradient(180deg, rgba(255,228,160,.55) 0 1.5px, transparent 1.5px),
      linear-gradient(180deg, transparent 0 5px, rgba(255,214,130,.20) 5px 6px,
                      transparent 6px calc(100% - 6px), rgba(255,214,130,.13) calc(100% - 6px) calc(100% - 5px), transparent calc(100% - 5px)),
      linear-gradient(90deg, rgba(0,0,0,.46) 0 20px, transparent 20px calc(100% - 20px), rgba(0,0,0,.46) calc(100% - 20px)),
      linear-gradient(102deg, transparent 32%, rgba(255,242,205,.11) 46%, transparent 62%),
      repeating-linear-gradient(90deg, rgba(255,236,190,.045) 0 1px, transparent 1px 6px),
      linear-gradient(180deg, #74512a 0%, #5d3d1a 48%, #462c12 100%);
    border-top: 1px solid rgba(255,214,130,.5);
    border-bottom: 1px solid rgba(0,0,0,.4);
    box-shadow: 0 5px 0 rgba(0,0,0,.30), 0 8px 14px rgba(20,10,2,.35),
                inset 0 1px 0 rgba(255,240,200,.30), inset 0 -3px 8px rgba(0,0,0,.34);
    text-shadow: 0 2px 0 rgba(40,20,4,.55);
    /* 좌우 끝을 제비꼬리로 판다 — 현수막의 실루엣은 여기서 나온다(추가 DOM 0).
       ⚠️ 점 순서가 곧 도형이다: 좌상 → 우상 → **우측 중앙으로 파고듦** → 우하 → 좌하 → 좌측 중앙.
       순서를 섞으면 경로가 자기 교차해 꼬리가 안 파인다(첫 시도에서 실제로 그랬다). */
    clip-path: polygon(0 0, 100% 0, calc(100% - 15px) 50%, 100% 100%, 0 100%, 15px 50%);
    animation: wsbDrop .52s cubic-bezier(.16,1.12,.3,1) 1.15s backwards;
  }
  #win .card > #winStreakBanner[hidden] { display: none; }
  /* 양끝 금빛 못 — 천을 벽에 박아 둔 것처럼 보이게 하는 한 점. 꼬리(15px)와 글자(34px) 사이에 놓는다. */
  #winStreakBanner::before, #winStreakBanner::after {
    content: ""; position: absolute; top: 50%; width: 5px; height: 5px; border-radius: 50%;
    transform: translateY(-50%);
    background: radial-gradient(circle at 34% 28%, #fff4cf, #d9a53f 70%, #9c6a1c);
    box-shadow: 0 0 5px rgba(255,205,110,.55), inset 0 -1px 0 rgba(0,0,0,.4);
  }
  #winStreakBanner::before { left: 21px; }
  #winStreakBanner::after  { right: 21px; }
  #winStreakBanner .wsbLabel { opacity: .96; }
  #winStreakBanner .wsbCount {
    font-size: clamp(18px, 5vw, 22px); line-height: 1;
    /* 숫자만 금색 그라디언트 — 이 띠에서 눈이 먼저 닿아야 하는 곳이다. */
    background: linear-gradient(180deg, #fff6d8 0%, #ffd97e 52%, #e0a53c 100%);
    -webkit-background-clip: text; background-clip: text; color: transparent;
    filter: drop-shadow(0 2px 0 rgba(40,20,4,.6)) drop-shadow(0 0 7px rgba(255,205,110,.4));
  }
  /* 부피 보존 스쿼시&스트레치(연출 헌법 §4.6): 내려오며 세로로 늘고, 닿으며 눌리고, 감쇠 되튐.
     ⚠️ 이 요소는 `transform`을 다른 데서 쓰지 않는다(중앙정렬을 left/right로 잡은 이유). */
  @keyframes wsbDrop {
    0%   { opacity: 0; transform: translateY(-26px) scaleX(.94) scaleY(1.06); }
    55%  { opacity: 1; transform: translateY(0)     scaleX(1.05) scaleY(.94); animation-timing-function: ease-in-out; }
    78%  {             transform: translateY(0)     scaleX(.98) scaleY(1.02); animation-timing-function: ease-in-out; }
    100% {             transform: none; }
  }
  html[data-motion="reduced"] #win .card > #winStreakBanner { animation: none; }
