  /* ══════════════════════════════════════════════════════════════════════════════════════════════
     16-tutorial-opening.css — 배우기 시작 레슨 오버레이 (tutorial opening)
     정본: claudedocs/tutorial_opening_lessons_plan.md v3 §4.4(레이아웃)·§6(샤)·§7(스토리보드)·§9(저감).
     계약: scratchpad tutorial-opening-contract.md §1 — 상태 신호는 #tutorialOpening의
           data-kind/data-phase/data-reduced/data-source 속성뿐이고, #tutOpenDemo 안쪽 클래스는
           game/tutorialOpeningScenes.js 전용이다(다른 파일이 읽지 않는다).

     설계 결정 (근거를 지우지 말 것):
     · 레이어 = 일반 게임 모달과 같은 z-index 60 (v3 반려안 G — 임의 구간 금지).
     · 프레임 = 석판 캔버스 36px 한 종(modal_design_system.md 공통 규칙 재사용, 새 프레임 발명 0).
       제목색은 여기 없다 — 캔버스 프레임 모달의 제목은 「새긴 초록」이고, 그 색·외곽선은 정본 블록
       (03-modal-win-effects.css)이 선택자 목록으로 소유한다(통합자가 이 제목을 그 목록에 편입).
     · CTA = 석판 타일 + 금빛 글자(#fff0a8) — 「권하는 길(무료 전진)」의 기존 어휘(오너 2026-07-31
       "모달 CTA 재질 통일"). 에메랄드 평면·초록 필리그리를 쓰지 않는다(광고·지불이 아니다).
     · 이 화면은 보상이 아니라 설명이다 — 별·컨페티·카메라 줌·화면 흔들림 없음(연출 헌법 §4.1).
     · 모든 상자(제목 2줄·샤·데모·CTA)는 뷰포트만으로 크기가 정해진다 — 문구 도착·이미지 로드·숫자
       변화가 배치를 밀지 않는다(v3 §4.4).
     · 애니 길이는 전부 이 파일이 소유한다. JS(tutorialOpeningScenes.js)는 playClass/animationend로
       끝만 받는다(check:anim 계약).
     ══════════════════════════════════════════════════════════════════════════════════════════════ */

  #tutorialOpening {
    position: fixed; inset: 0; z-index: 60;              /* 게임 모달 계층 — 새 구간을 만들지 않는다 */
    display: flex; align-items: center; justify-content: center;
    padding: calc(12px + env(safe-area-inset-top, 0px))
             calc(12px + env(safe-area-inset-right, 0px))
             calc(12px + env(safe-area-inset-bottom, 0px))
             calc(12px + env(safe-area-inset-left, 0px));
    /* 상자 치수의 단일 출처 — 아래 전부가 이 변수만 읽는다. 뷰포트에서만 계산되므로 콘텐츠가 커져도
       배치가 밀리지 않는다(v3 §4.4). */
    --tut-stage-h: clamp(212px, 44dvh, 296px);   /* 샤+데모 무대 높이 */
    /* 도전장(recap) 무대는 **더 낮다** — 이 화면엔 미니 보드가 없고 샤와 말풍선 하나뿐이다.
       ⚠️ 2026-08-16에 윗 말풍선을 제목·부가문으로 올린 뒤, 기본 높이 그대로면 그 자리가 **빈 돌바닥
       250px**로 남았다(스크린샷으로 확인). 무대 높이는 뷰포트로만 정해지므로(v3 §4.4) 내용이 줄어도
       저절로 줄지 않는다 — 이렇게 kind별로 명시해 주는 것이 이 파일의 문법이다. */
    /* 샤 고정 폭. ⚠️ 상한 92 → **72**로 내렸다 (2026-08-16 실측): 샤를 흐름에서 빼 무대 위에 겹쳐
       놓은 뒤로 넓은 화면에서 **배우기3의 미니 보드(4열)를 12px 덮었다**(750×606·884×1104·1104×884).
       보드는 패널 중앙에 서고 샤는 왼쪽 끝에 서므로, 샤가 커질수록 둘이 만난다. v3 §4.4가
       「샤는 조연, 데모가 주인공」이라 했으니 **줄어드는 쪽은 샤다.** 올릴 때는 check:layout의
       배우기3 조합(ko·2)을 반드시 함께 볼 것 — 그 검사가 이걸 잡아냈다. */
    --tut-sha-w: clamp(56px, 17vw, 72px);
    --tut-cell: 36px;                            /* 미니 보드 칸 상한 */
    --tut-spec: 13px;                            /* 팔레트 표본 타일(실제 팔레트 shapeicon과 동일값) */
    --tut-mini: 8px;                             /* 요약(recap) 행의 작은 타일 */
    --tut-orient: 17px;                          /* orient 장면의 변환 줄 타일 — 이 장면이 가르치는 것이
                                                    회전·반전이라 변환 줄이 주인공 크기를 갖는다(실측
                                                    2026-08-16: 8px 타일은 돌렸다/뒤집었다가 안 읽혔다) */
  }
  /* ⚠️ 짝 규칙 — 작성자 display:flex가 브라우저 기본 [hidden]을 이겨 "안 숨는" 이 저장소의 알려진
     사고(#noDrags saleRow 전례). 절대 지우지 말 것. */
  #tutorialOpening[hidden] { display: none; }
  /* 도전장 무대 높이 — 위 변수 블록의 주석 참조(빈 돌바닥 250px 사고). */
  #tutorialOpening[data-kind="recap"] { --tut-stage-h: clamp(150px, 27dvh, 190px); }

  /* 오버레이가 떠 있는 동안 **판 위 안내 문구는 내린다** (실측 2026-08-15, 390×844에서 발견).
     딤은 42%라 뒤 화면이 읽히는데(그건 의도다 — v3 §4.1 "보드는 뒤에서 보이되 veil로만 낮춘다"),
     `#gameGuide`만은 패널 **바로 위**에 놓여 옛 안내(「방향/순서는 상관없어요」)와 이 오버레이의 제목
     (「돌리거나 뒤집어서 그려도 돼요」)이 같은 것을 두 가지 말로 동시에 설명하고 있었다 — 이 기능이
     고치려던 혼동 그 자체다(연출 헌법 §4.1: 한 프레임의 주인공은 하나).
     ⚠️ `display:none`이 아니라 `visibility`인 이유: 이 줄은 팔레트 판때기의 높이를 차지하므로 지우면
        보드가 위로 튀었다가 CTA를 누르는 순간 도로 내려온다. 자리는 두고 글자만 감춘다.
     ⚠️ 표식은 `game/tutorialOpening.js`가 열고 닫을 때 세우고 지운다(짝이다 — 한쪽만 고치면 안내가
        영영 안 돌아온다). `#game`이 이전 형제라 CSS만으로는 닿을 수 없어 body를 경유한다. */
  body[data-tutorial-opening] #gameGuide { visibility: hidden; }

  /* 딤은 ::before 한 겹 — 패널과 따로 페이드하기 위해서다(딤이 먼저 스며들고 패널이 안착). */
  #tutorialOpening::before {
    content: ""; position: absolute; inset: 0; background: var(--scrim);
    animation: tutDimIn .26s ease-out backwards;
  }
  @keyframes tutDimIn { 0% { opacity: 0; } 100% { opacity: 1; } }

  /* ── 패널: 석판 캔버스 한 장. 카드 안에 또 카드를 만들지 않는다(v3 §4.4) ───────────────────── */
  #tutOpenPanel {
    position: relative; z-index: 1;               /* ::before 딤 위 */
    box-sizing: border-box;
    width: min(calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)), 380px);
    max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
    border: 36px solid transparent;
    border-image: url("../../assets/common/modal_canvas.webp") 120 fill / 36px / 0 stretch;
    display: grid;
    grid-template-rows: auto minmax(0, 1fr) auto; /* 제목 / 무대 / CTA — 고정 3트랙 */
    gap: 10px;
    padding: 4px 2px 8px;
    outline: none;                                 /* tabindex="-1" 포커스 링 억제 */
    filter: drop-shadow(0 18px 34px rgba(40,28,10,.4));
    /* 등장 = 「초대」 티어의 낮은 단 — 아래에서 조금 올라와 부피 보존 스쿼시로 안착(연출 헌법 §4.6).
       CTA는 0ms부터 보여야 하므로(v3 반려안 E) 투명 시작 금지 — 살짝만 어둡게 출발한다. */
    animation: tutPanelIn .46s linear backwards;
  }
  @keyframes tutPanelIn {
    0%   { opacity: .72; transform: translateY(15px) scaleX(1.02)  scaleY(.965); animation-timing-function: ease-in-out; }
    55%  { opacity: 1;   transform: translateY(-3px) scaleX(.988)  scaleY(1.016); animation-timing-function: ease-in-out; }
    80%  { transform: translateY(1px)  scaleX(1.006) scaleY(.995); animation-timing-function: ease-in-out; }
    100% { transform: translateY(0)    scale(1); }
  }

  /* 제목 — 크기·여백·줄바꿈만 여기서 잡는다. **색은 일부러 없다**: 캔버스 프레임 모달의 제목색은
     「새긴 초록」 정본 블록(03-modal-win-effects.css)이 선택자 목록으로 소유하고, 이 제목은 통합자가
     그 목록에 편입한다. 진갈색(#3b2814)은 Sha 스톤 셸(28px 셸 전용 표면)만의 예외인데 이 오버레이는
     일반 36px 캔버스라 해당 없다 — 갈색 통일은 2026-07-29에 오너가 되돌린 길이다(재제안 금지).
     min-height가 두 줄을 미리 잡아 문구(ko/en/ja)가 늦게 와도 무대가 밀리지 않는다.
     ⚠️ 크기 하한 = 22px. 정본 블록의 `-webkit-text-stroke: 3px`는 22~29px 제목에 맞춘 두께라, 그보다
        작게 렌더하면(첫 구현 17.94px 실측) 한글 획과 속공간이 외곽선에 먹혀 뭉갠다. 외곽선은 정본
        소유라 여기서 못 만지므로 **크기가 유일한 손잡이**다 — vw-clamp를 버리고 고정값을 쓰는 것도
        같은 이유(v3 §4.4 "글자 크기를 뷰포트 비율로 키우지 않는다"; 뷰포트가 줄면 샤·데모가 줄지
        글자가 줄지 않는다). 320폭만 media에서 20px로 — ja 최장 낱말(うらがえしたりしても 10자)이
        220px 내용폭을 넘지 않는 상한이고, 외곽선 비율 손해(3/20)는 그 화면 한정으로 감수한다. */
  .tutOpenTitle {
    margin: 0; min-height: 2.6em;
    display: grid; place-items: center;
    /* `white-space: pre-line` = 문구의 `\n`을 진짜 줄바꿈으로 (도전 장면 `.tutChallengeMain`과 같은 문법).
       배우기3 문구가 두 문장이라 어디서 끊을지를 **번역자가** 정해야 한다 — 폭에 맡기면 언어마다
       엉뚱한 자리에서 갈린다. 낱말 중간이 안 끊기는 것은 아래 word-break가 계속 맡는다. */
    text-align: center; word-break: keep-all; white-space: pre-line;
    font-size: 22px; font-weight: 900; line-height: 1.3;
    text-shadow: 0 1px 0 rgba(255,244,214,.55);   /* 아래 밝은 모서리 = 돌에 새긴 글 */
    padding: 0 2px;
  }
  /* 요약(recap) 보조문 — C(상태기계)가 #tutOpenTitle 안에 <span class="tutOpenSub">로 넣는다
     (v3 §7.4: 제목 「모양을 판에 그려 채워요」 + 보조문 「돌리거나 뒤집어도 돼요 · 모양마다 숫자만큼」).
     제목과 한 덩어리로 읽히되 위계는 크기·대비로만 가른다 — 같은 새김 문법, 한 단 작고 한 단 옅게.
     제목 상자는 display:grid라 이 블록이 자연히 아랫줄로 쌓이고 가운데 정렬을 물려받는다.
     ⚠️ 가장 긴 일본어 보조문이 320×568에서 3줄까지 갈 수 있다 — 제목 트랙은 auto라 늘어나지만,
        실측 여유(제목+보조 3줄 ≈84px + 무대 227px + CTA 46px ≪ 가용 440px)가 커서 CTA를 밀지 않는다.
        word-break: keep-all은 상속받는다(ko·ja 문구 모두 공백 분절이라 낱말 중간이 안 끊긴다). */
  .tutOpenTitle .tutOpenSub {
    display: block;
    margin-top: 3px;
    font-size: clamp(12px, 3.4vw, 14px);
    font-weight: 700;
    line-height: 1.35;
    color: #6b4c2a;                                /* 모달 본문 갈색 — 기존 모달들이 새긴 초록 제목과
                                                      이미 짝지어 쓰는 본문색이라 초록 편입 뒤에도 유지 */
    text-shadow: 0 1px 0 rgba(255,244,214,.4);     /* 같은 새김, 절반 세기 */
    /* ⚠️ 이 줄이 없으면 보조문이 외곽선에 먹힌다. 정본 초록 제목 블록(03-modal-win-effects.css)의
       `-webkit-text-stroke: 3px`는 **상속되는 속성**이고 이 보조문은 제목 요소의 **자식**이다 —
       22~29px 제목용으로 잡은 두께가 12~14px 글자에 그대로 얹히면 획끼리 붙어 뭉갠다.
       `text-shadow`도 같은 이유로 상속되지만 그건 위 줄이 이미 덮는다. */
    -webkit-text-stroke: 0;
  }

  /* 제목에는 도형 그림을 넣지 않는다 (오너 2026-08-16, count·draw 두 번에 걸쳐 확정 → orient도 함께).
     이 오버레이는 제목 **바로 아래**에 그 도형의 표본을 이미 보여 주므로 제목의 그림은 같은 것을
     두 번 말하는 셈이었다(연출 헌법 §4.1). 그래서 여기 있던 `.tutOpenTitle .inlineShape` 정렬 규칙과
     JS의 래퍼(.tutOpenTitleLine)·크기 상수를 함께 걷어냈다 — 부르는 곳이 없는 규칙만 남기지 않는다.
     ⚠️ 되살릴 때 필요한 실측은 남겨 둔다: 22px 제목의 한글 시각 중심은 베이스라인 위 8.06px라
        31px 그림은 vertical-align: -7px(320폭 20px 제목에서는 -8px)에서 가운데가 맞았다. */

  /* ── 무대: 샤(고정 폭, 조연) + 데모(나머지 전부, 주인공) ─────────────────────────────────────
     샤는 시선을 보내는 조연이다 — 면적·대비 모두 데모가 이긴다(v3 §4.4). */
  /* 무대는 **한 칸**이다 — 데모가 패널 중심을 그대로 쓴다 (2026-08-16 오너 리포트 "너무 오른쪽으로
     몰려서 그려짐").
     ⚠️ 왜 샤를 흐름에서 뺐나: 샤가 격자의 한 칸을 차지하면 데모만 그만큼 오른쪽으로 밀리는데
     (390 실측 46px), 제목과 CTA는 패널 중심을 쓰므로 **한 화면에 기준선이 둘**이 되어 바로 눈에 걸린다.
     오른쪽에 같은 폭의 빈 칸을 넣어 균형을 맞추는 방법도 재 봤지만, 그러면 트리오 줄(202px)이 남는
     폭(약 200px)을 넘겨 흘러넘친다 — 이 폭에서 「샤가 자리를 차지한다 · 도형이 읽히는 크기다 ·
     가운데 정렬이다」 셋은 동시에 성립하지 않아서 **가장 덜 중요한 첫째를 버렸다.**
     세로로 겹치지 않는다: 트리오 줄은 무대 위쪽, 샤는 아래 끝에 붙는다. */
  .tutOpenStage {
    position: relative; min-width: 0;
    height: var(--tut-stage-h);
    display: grid; grid-template-columns: minmax(0, 1fr);
    align-items: end;
  }
  /* 샤 상자는 CSS가 크기를 소유한다 — 디코딩이 늦거나 실패해도(alt="" = 아무것도 안 보임)
     문구·데모·CTA는 첫 프레임 그대로 남는다(v3 §6 마지막 줄). 회전·반전 금지(§6).
     ⚠️ 폭은 반드시 `--tut-sha-w`로 **직접** 준다. 흐름에서 뺀 뒤로 `width: 100%`는 격자 칸이 아니라
     무대 전체를 뜻해서, 그대로 두면 샤가 판을 통째로 덮는다(실측 확인).
     ⚠️ 높이는 `auto`로 두어 **그림 자기 비율**을 쓴다 — 비율을 상수로 박지 말 것. 자산마다 다르고
     (실측: circle 515×720 · follow 512×720 · **light 370×720**) 교체되면 조용히 어긋난다. 실제로
     2026-08-16에 `aspect-ratio: 515/720`을 박았다가 light에서 상자가 그림보다 넓어졌다.
     흐름 밖이라 로드 전 높이가 0이어도 문구·데모·CTA는 밀리지 않는다(v3 §6이 허용하는 상태). */
  .tutOpenSha {
    position: absolute; left: 0; bottom: 0; z-index: 1;
    width: var(--tut-sha-w); height: auto;
    object-fit: contain; object-position: 50% 100%;
    filter: drop-shadow(0 6px 10px rgba(40,26,8,.28));
    pointer-events: none;
  }
  /* 자산별 object-position (v4 §6 — 손끝·원·손빛이 잘리지 않고 데모 쪽을 향하게 앵커만 고정):
     follow(오른손이 위-오른쪽을 가리킴)·circle(오른쪽 위 원) = 왼쪽 아래 앵커 → 손끝이 데모를 향한다.
     light(가슴의 손빛)·lion(두 주먹 도전 자세) = 가운데 아래 앵커. */
  #tutorialOpening[data-kind="draw"] .tutOpenSha,
  #tutorialOpening[data-kind="orient"] .tutOpenSha { object-position: 0% 100%; }
  #tutorialOpening[data-kind="count"] .tutOpenSha,
  #tutorialOpening[data-kind="recap"] .tutOpenSha { object-position: 50% 100%; }
  /* recap은 규칙 설명의 조연이 아니라 도전장을 건네는 만화 주인공이다. 다른 세 레슨보다 한 단 크게
     세우되 말풍선보다 넓어지지 않는다. 원본 비율은 그대로라 팔다리가 늘어나지 않는다. */
  #tutorialOpening[data-kind="recap"] .tutOpenSha {
    width: clamp(82px, 27vw, 116px);
  }

  .tutOpenDemo { position: relative; height: 100%; min-width: 0; display: grid; }

  /* ── CTA: 석판 타일 + 금빛 글자 = 「권하는 길」 (03-modal-win-effects.css 석판 레시피와 동일) ── */
  .tutOpenCta {
    height: 46px; width: min(100%, 244px); justify-self: center;
    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: #fff0a8; font-size: 16px; font-weight: 800; white-space: nowrap;
    text-shadow: 0 1px 2px rgba(56,38,4,.78);
  }
  .tutOpenCta: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);
  }

  /* ══════════════════════════════════════════════════════════════════════════════════════════════
     여기서부터는 #tutOpenDemo 안쪽 — game/tutorialOpeningScenes.js 전용 클래스.
     ══════════════════════════════════════════════════════════════════════════════════════════════ */

  /* 장면 공통 뼈대: 위 = 참조(표본/방향 데모), 아래 = 미니 보드 */
  .tutScene {
    height: 100%; min-height: 0; min-width: 0;
    display: grid; grid-template-rows: auto minmax(0, 1fr);
    align-items: center; justify-items: center; align-content: center; gap: 8px;
  }
  /* 장면 안착 — 루트가 지휘(=await 앵커), 자식이 반 박자씩 따라온다. 자식 지연+길이(.12+.3)가
     루트 길이(.42) 안에 끝나므로 다음 국면과 겹치지 않는다. */
  .tutScene.tutIn { animation: tutSceneIn .42s ease-out backwards; }
  @keyframes tutSceneIn { 0% { opacity: .25; } 100% { opacity: 1; } }
  .tutScene.tutIn > * { animation: tutPartIn .3s ease-out backwards; }
  .tutScene.tutIn > :nth-child(2) { animation-delay: .07s; }
  .tutScene.tutIn > :nth-child(3) { animation-delay: .12s; }
  @keyframes tutPartIn { 0% { opacity: 0; transform: translateY(4px); } 100% { opacity: 1; transform: translateY(0); } }

  /* ── 팔레트 표본(참조 줄) — 실제 팔레트의 shapeicon(.ic.on 금빛 타일)을 그대로 빌려 쓴다 ──────
     타일 크기만 --ts 변수로 받는다(02-board.css의 고정 11px을 이긴다 — 이 파일이 뒤에 로드됨). */
  .tutSpecRow { display: flex; justify-content: center; align-items: flex-end; gap: 18px; min-height: 44px; }
  .tutSI { display: grid; gap: 1px; }
  .tutSI .ic { width: var(--ts, var(--tut-spec)); height: var(--ts, var(--tut-spec)); }
  .tutSpec { position: relative; display: grid; place-items: center; --ts: var(--tut-spec); padding: 0 5px 5px 0; }
  /* 남은 개수 배지 — 실제 팔레트 .palLeft와 같은 문법(어두운 알약 + 크림 숫자, 아이콘 오른쪽 아래).
     min-width로 숫자가 2→1→0이 되어도 폭이 변하지 않는다(v3 §4.4). */
  .tutCount {
    position: absolute; right: -4px; bottom: -4px; z-index: 2;
    min-width: 17px; padding: 0 4px; border-radius: 9px; box-sizing: border-box;
    background: rgba(38,24,8,.82); color: #ffe9b8;
    font-size: 11px; font-weight: 900; line-height: 16px; text-align: center;
    text-shadow: 0 1px 1px rgba(0,0,0,.5);
  }
  /* 카운터가 줄어드는 순간 — 실제 팔레트 palLeftTick과 같은 물리(부피 보존 스쿼시 + 내려앉음 + 감쇠).
     .12s 지연 = 봉인(원인)이 먼저, 숫자(결과)가 따라온다. 지연이 있으므로 backwards(both 금지). */
  .tutCount.tickGo { animation: tutCountTick .4s linear .12s backwards; }
  @keyframes tutCountTick {
    0%   { transform: translateY(0) scale(1);                    animation-timing-function: cubic-bezier(.3,0,.2,1); }
    22%  { transform: translateY(2px) scaleX(1.16) scaleY(.86);  animation-timing-function: ease-in-out; }
    55%  { transform: translateY(0) scaleX(.95) scaleY(1.06);    animation-timing-function: ease-in-out; }
    80%  { transform: translateY(0) scaleX(1.02) scaleY(.98);    animation-timing-function: ease-in-out; }
    100% { transform: translateY(0) scale(1); }
  }
  /* 0이 된 도형은 「사라짐」이 아니라 「물러남」 — 실제 팔레트 palDone과 같은 값(.3/.45)과 같은
     전이. 색이 아니라 밝기·숫자가 함께 변하므로 색맹에도 읽힌다. */
  .tutSpec .tutSI, .tutSpec .tutCount { transition: opacity .34s ease .16s; }
  .tutSpec.done .tutSI { opacity: .3; }
  .tutSpec.done .tutCount { opacity: .45; }

  /* ── 미니 보드 ────────────────────────────────────────────────────────────────────────────────
     실제 보드와 같은 문법: gap 0 + 칸 안쪽 1px 홈이 줄눈, 영역 경계는 SVG 외곽선이 긋는다.
     그래서 같은 색 ㅁ 두 개가 맞닿아도 획 경계가 끝까지 남는다(v3 §7.3). */
  .tutBoardWrap { position: relative; width: min(100%, calc(var(--tb-cols, 4) * var(--tut-cell))); margin-inline: auto; align-self: center; }
  .tsDraw .tutBoardWrap { --tut-cell: 44px; }   /* draw의 2열 보드는 칸을 키워 주인공답게 */
  /* orient의 보드는 count와 같은 36px로 **일부러 내린다** — 이 장면의 주인공은 결과(보드)가 아니라
     변환(위 트리오 줄)이다. 실측(2026-08-16): 보드 44px·트리오 8px에서는 화면에서 가장 큰 것이
     결과이고 배워야 할 회전·반전이 가장 작았다. 보드를 draw만큼 키우지 말 것. */
  .tsOrient .tutBoardWrap { --tut-cell: 36px; }
  .tutBoard {
    display: grid; width: 100%;
    grid-template-columns: repeat(var(--tb-cols, 4), minmax(0, 1fr));
    grid-template-rows: repeat(var(--tb-rows, 4), minmax(0, 1fr));
    gap: 0; aspect-ratio: var(--tb-cols, 4) / var(--tb-rows, 4);
  }
  .tutCell {
    position: relative; min-width: 0; min-height: 0;
    /* 빈 칸 = 「꺼진 유리 타일」 — 실제 .cell.unfilled의 광택·홈 문법 축소판 */
    background: linear-gradient(157deg, rgba(255,255,255,.5), rgba(255,255,255,0) 46%), var(--glass);
    box-shadow: inset 0 1px 0 rgba(255,255,255,.55), inset 0 -3px 5px rgba(120,98,54,.16),
                inset 0 0 0 1px rgba(120,98,54,.16);
  }
  .tutCell.void { background: none; box-shadow: none; }
  /* 칠해진 순간 = 실제 게임과 같은 「중립 모래색」 — 봉인 전에는 보석이 아니다(인과를 그대로 가르친다) */
  .tutCell.tp {
    background: #c7b89c;
    box-shadow: inset 0 2px 0 rgba(255,255,255,.35), inset 0 -4px 7px rgba(0,0,0,.14),
                inset 0 0 0 1px rgba(90,70,40,.28);
  }
  .tutCell.tpIn { animation: tutPaintIn .26s linear; }
  @keyframes tutPaintIn {                       /* 정점에 머문다 — 진폭이 아니라 체류(§4.6-5) */
    0%   { transform: scaleX(1.16) scaleY(1.2); filter: brightness(1.12); animation-timing-function: linear; }
    30%  { transform: scaleX(1.11) scaleY(1.14); animation-timing-function: ease-in-out; }
    70%  { transform: scaleX(.955) scaleY(.945); animation-timing-function: ease-in-out; }
    100% { transform: scale(1); filter: brightness(1); }
  }
  /* 봉인 = 보석 확정. --tc-fill은 JS가 도형별로 넣는다(:root 보석 토큰만 — 새 색 체계 없음) */
  .tutCell.tcSealed {
    background: linear-gradient(180deg, rgba(255,255,255,.2), rgba(255,255,255,0) 42%), var(--tc-fill, var(--s2));
    box-shadow: inset 0 2px 0 rgba(255,255,255,.42), inset 0 -5px 8px rgba(0,0,0,.22),
                inset 0 0 0 1px rgba(60,40,14,.3);
  }
  /* 봉인 섬광 — 실제 seal의 초록(=「맞았다」 신호색) 문법을 낮은 세기로. 몸은 부피 보존 + 감쇠. */
  .tutCell.sealGo { animation: tutSealCell .55s linear; }
  @keyframes tutSealCell {
    0%   { filter: brightness(1) saturate(.6); transform: scale(1); animation-timing-function: ease-in-out; }
    35%  { filter: brightness(1.75) saturate(1.3) drop-shadow(0 0 7px rgba(120,255,180,.85)); transform: scaleX(1.035) scaleY(.955); animation-timing-function: ease-in-out; }
    62%  { filter: brightness(1.22) saturate(1.12) drop-shadow(0 0 3px rgba(120,255,180,.4)); transform: scaleX(.985) scaleY(1.012); animation-timing-function: ease-in-out; }
    100% { filter: brightness(1) saturate(1); transform: scale(1); }
  }
  /* 영역 외곽선(SVG) — 실제 보드의 납선처럼 획 하나의 둘레를 긋는다. 봉인 후 끝까지 남는다.
     ⚠️ 이중선(후광+코어)이어야 한다 — v3 §7.3 "같은 색 ㅁ 두 개가 맞닿아도 2×4 한 덩어리로 합쳐
        보이게 만들지 않는다". 첫 구현(단일 2px·78% 갈색)은 셀 자신의 1px 홈·베벨과 굵기·색이 비슷해
        경계가 격자선에 묻혔다(실측 2026-08-16, tut-count-demo-zoom.png). 처방은 이 저장소의 확립된
        경계 문법 그대로다: render/board.js의 drawHintOutline/drawLearnOutline이 쓰는 「후광(w+2) 아래
        + 코어(w) 위」 이중선. 색도 발명하지 않았다 — 코어 #2f1810 = board.js PALETTE.leadCore(납선
        코어), 후광 크림 = 이 파일 제목 새김의 rgba(255,244,214,…) 계열. 밝은 후광이 어두운 코어를
        셀 줄눈(어두운 홈)과 갈라 주므로 보석 위에서도 「그어 놓은 경계」로 읽힌다.
        두 path는 JS(outlinePathEl)가 같은 d로 만든다 — 굵기·색은 여기가 소유한다. */
  .tutOutline { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 2; }
  .tutOutline path {
    fill: none; stroke-linecap: round; stroke-linejoin: round;
    animation: tutOutlineIn .4s ease-out backwards;
  }
  .tutOutline .toHalo { stroke: rgba(255,244,214,.72); stroke-width: 5px; }
  .tutOutline .toCore { stroke: #2f1810; stroke-width: 3px; }
  @keyframes tutOutlineIn { 0% { opacity: 0; } 100% { opacity: 1; } }
  /* 완성 외곽 유지 (count 마지막 국면·최종 정지) — 보상 폭발이 아니라 따뜻한 정돈 */
  .tutBoard.tbDone { filter: drop-shadow(0 0 8px rgba(255,214,120,.45)); }
  .tutBoard.completeGo { animation: tutCompleteGlow .7s ease-in-out; }
  @keyframes tutCompleteGlow {
    0%   { filter: drop-shadow(0 0 0 rgba(255,214,120,0)); }
    45%  { filter: drop-shadow(0 0 14px rgba(255,214,120,.7)) brightness(1.07); }
    100% { filter: drop-shadow(0 0 8px rgba(255,214,120,.45)); }
  }

  /* ── 「표본 ↔ 보드」 연결 (배우기1, §7.1 220~520ms) — 이동 없이 같은 금빛으로 함께 밝아진다.
     팔레트 표본을 보드로 이동시키지 않는다(끌어놓기 게임 오개념 금지). 금빛 = learnTarget 계열. */
  .tutBoard.linkGo { animation: tutLinkWash .6s ease-in-out; }
  @keyframes tutLinkWash { 0%, 100% { filter: brightness(1); } 45% { filter: brightness(1.1); } }
  .tutBoard.linkGo .tutCell:not(.void) { animation: tutLinkCell .6s ease-in-out; }
  @keyframes tutLinkCell {
    0%, 100% { filter: none; }
    45%      { filter: brightness(1.22) drop-shadow(0 0 6px rgba(255,188,60,.8)); }
  }
  .tutSpec.linkGo .ic.on { animation: tutLinkCell .6s ease-in-out; }

  /* ── 봉인 뒤 「같은 색으로 호흡」 (배우기1, §7.1 1700~2050ms) — 표본과 완성 도형이 같은 리듬 ── */
  .tutBoard.breathGo { animation: tutBreathAnchor .8s ease-in-out; }
  @keyframes tutBreathAnchor { 0%, 100% { filter: brightness(1); } 30%, 70% { filter: brightness(1.04); } }
  .tutBoard.breathGo .tutCell.tcSealed, .tutSpec.breathGo .ic.on { animation: tutBreathOnce .8s ease-in-out; }
  @keyframes tutBreathOnce {
    0%, 100% { filter: brightness(1); }
    30%      { filter: brightness(1.26) saturate(1.12); }
    55%      { filter: brightness(1.05); }
    75%      { filter: brightness(1.18); }
  }

  /* ── 손가락 점 — 움직이는 것은 이 점과 칸의 색뿐이다(§7.1 금지 조항의 반대편) ────────────────
     기본 transform이 중앙정렬 translate(-50%,-50%)이므로 모든 키프레임이 이것을 들고 간다
     (transform 덮어쓰기 함정 — check:transform이 감시). 위치(left/top)는 JS가 %문자열로 놓고,
     이동 애니는 left/top만 보간해 transform을 건드리지 않는다. */
  .tutFinger {
    position: absolute; z-index: 3;
    width: clamp(20px, 6.4vw, 28px); aspect-ratio: 1; border-radius: 50%;
    transform: translate(-50%, -50%);
    background: radial-gradient(circle at 38% 34%, rgba(255,255,255,.96), rgba(255,255,255,.35) 62%, transparent 74%);
    box-shadow: 0 0 0 2.5px rgba(255,255,255,.5), 0 3px 8px rgba(0,0,0,.38);
    opacity: 0; transition: opacity .2s ease;
    pointer-events: none;
  }
  .tutFinger .tfRipple {
    position: absolute; inset: 0; border-radius: 50%;
    border: 2px solid rgba(255,255,255,.7); opacity: 0;
  }
  /* 누름: 제자리 맥박 + 물결 한 번 — 실제 dragCoach의 「여기를 눌러요」 문법 축소판 */
  .tutFinger.tfPress { animation: tutPressPulse .5s ease-in-out; }
  @keyframes tutPressPulse {
    0%   { transform: translate(-50%, -50%) scale(1); }
    40%  { transform: translate(-50%, -50%) scaleX(1.12) scaleY(.9); }
    70%  { transform: translate(-50%, -50%) scale(.98); }
    100% { transform: translate(-50%, -50%) scale(1); }
  }
  .tutFinger.tfPress .tfRipple { animation: tutRippleOut .5s ease-out; }
  @keyframes tutRippleOut { 0% { opacity: .65; transform: scale(.7); } 100% { opacity: 0; transform: scale(1.9); } }
  /* 한 칸 이동 — 등속(느리게 출발/급정거 금지, dragCoach의 linear 교훈). 길이는 여기(.26s)가 정본.
     좌표는 JS가 --tf-* 변수로 넣는다(위치는 데이터, 길이는 CSS — 서로의 영역을 침범하지 않는다).
     forwards는 playClass가 클래스를 떼는 순간 JS가 안착 좌표를 인라인으로 넣으므로 잔류하지 않는다. */
  .tutFinger.tfMove { animation: tutFingerMove .26s linear forwards; }
  @keyframes tutFingerMove {
    0%   { left: var(--tf-x0); top: var(--tf-y0); }
    100% { left: var(--tf-x1); top: var(--tf-y1); }
  }
  /* 손 뗌: 위로 살짝 떠오르며 사라진다 — 획의 끝을 몸짓으로 말한다 */
  .tutFinger.tfLift { animation: tutFingerLift .32s ease-in; }
  @keyframes tutFingerLift {
    0%   { opacity: 1; transform: translate(-50%, -50%); }
    100% { opacity: 0; transform: translate(-50%, -50%) translateY(-9px) scale(.92); }
  }
  /* 시작점 표식(최종 정지·저감·요약 전용) — 「보드 안에서 시작한다」가 정지 그림에도 남게(§9) */
  .tutStartDot {
    position: absolute; z-index: 2; width: 12px; aspect-ratio: 1; border-radius: 50%;
    transform: translate(-50%, -50%); pointer-events: none; opacity: 0;
    background: radial-gradient(circle at 40% 35%, #fff, rgba(255,255,255,.5) 70%, transparent);
    box-shadow: 0 0 0 2px rgba(255,255,255,.45), 0 1px 3px rgba(0,0,0,.3);
  }
  .tutStartDot.sdOn { opacity: .9; }

  /* ── 배우기2 방향 데모 줄: 원본 / 회전(곡선 화살표) / 거울선 반전 ────────────────────────────
     회전과 반전은 서로 다른 사건으로 읽혀야 한다(v3 §7.2): 회전 = 중심 고정 회전 + 곡선 화살표,
     반전 = 세로 거울선 양쪽에 원본과 거울상이 동시에 남는다. 원근 카드 뒤집기 금지. */
  /* ⚠️ 변환 줄은 **이 장면의 주인공**이라 요약용 작은 타일(`--tut-mini` 8px)을 쓰지 않는다.
     실측(2026-08-16, 390×844): 8px에서는 「돌렸다/뒤집었다」가 안 읽혔다 — 제목이 회전·반전을
     말하는데 화면에서 그것이 가장 작고 결과(보드)가 가장 컸다. 보드를 36px로 내린 위 규칙과 짝이다
     (한쪽만 되돌리면 위계가 다시 뒤집힌다). 슬롯 최소 크기도 함께 커져야 3행짜리 ㄴ이 안 잘린다. */
  /* ⚠️ 간격은 **덩어리 수**를 따라간다. 회전 결과가 최종 화면에 남게 고친 뒤(위 rotDone) 이 줄의
     아이콘이 3개→4개가 되면서 12px 간격으로는 전부 붙어 12칸짜리 한 덩어리로 읽혔다(2026-08-16 실측).
     읽혀야 하는 것은 **원본 → 회전 | 반전** 세 덩어리이므로, 슬롯 사이는 넓히고(20px) 거울쌍 안쪽은
     좁게 둔다(.tutMirror의 5px) — 간격 차이가 곧 묶음 신호다. 390 실측 여유: 내용폭 296 − 줄 ~225. */
  .tutOrientRow { display: flex; align-items: center; justify-content: center; gap: 20px; min-height: 58px; --ts: var(--tut-orient); }
  .tutSlot { position: relative; display: grid; place-items: center; min-width: 40px; min-height: 56px; }
  /* 회전 결과 아이콘 — 등장하며 0→90°. 끝나면 .rotDone이 그 각도를 정지 상태로 소유한다. */
  .tutSlot .tutSI.rotTarget { opacity: 0; }
  .tutSI.rotGo { animation: tutRotIcon .55s ease-in-out backwards; }
  @keyframes tutRotIcon {
    0%   { opacity: 0; transform: rotate(0deg); }
    30%  { opacity: 1; }
    100% { opacity: 1; transform: rotate(90deg); }
  }
  /* ⚠️ 선택자에 `.tutSlot`이 **반드시** 있어야 한다 (2026-08-16 오너 리포트 "돌린 모양이 없어").
     위 `.tutSlot .tutSI.rotTarget { opacity: 0 }`이 클래스 3개라, 2개짜리(`.tutSI.rotDone`)로는
     특이도에서 진다. 애니메이션은 일반 선언을 이기므로 도는 0.55초 동안만 보이고 **끝나는 순간
     사라졌다** — 회전이 이 장면의 주인공인데 최종 정지 화면에 그 결과가 없었다(저감 모션도 같다,
     거긴 renderFinal이 곧바로 이 클래스만 얹기 때문이다). */
  .tutSlot .tutSI.rotTarget.rotDone { opacity: 1; transform: rotate(90deg); }
  /* 곡선 화살표 — 회전이 남기는 흔적. 저감·최종에서도 정지 표식으로 남는다(§9). */
  .tutArc { position: absolute; inset: -7px; pointer-events: none; opacity: 0; }
  .tutArc path { fill: none; stroke: var(--gold); stroke-width: 2.4px; stroke-linecap: round; }
  .tutArc .tutArcHead { fill: var(--gold); stroke: none; }
  .tutArc.arcGo { animation: tutArcIn .55s ease-out backwards; }
  @keyframes tutArcIn { 0% { opacity: 0; transform: rotate(-22deg); } 100% { opacity: 1; transform: rotate(0deg); } }
  .tutArc.arcOn { opacity: 1; }
  /* 세로 거울선 — 사파이어 토큰의 얇은 선. 반전 순간 한 번 반짝인다. */
  .tutMirror { display: flex; align-items: center; gap: 5px; }
  .tutMirLine {
    width: 2px; height: 36px; border-radius: 1px; opacity: .8;
    background: linear-gradient(180deg, transparent, var(--s4) 22% 78%, transparent);
  }
  .tutMirLine.mirGlint { animation: tutMirGlint .5s ease-out; }
  @keyframes tutMirGlint {
    0%   { filter: brightness(1); }
    40%  { filter: brightness(1.8) drop-shadow(0 0 5px var(--s4)); }
    100% { filter: brightness(1); }
  }
  /* 거울상 — 거울선에서 배어 나온다. 내용물의 반전(scaleX(-1))은 안쪽 래퍼가 정지 상태로 소유하고,
     등장 애니는 바깥 상자의 위치·투명도만 만진다(반전이 「움직임」으로 오독되지 않게). */
  .tutMirCopy { display: grid; transform: scaleX(-1); }
  .tutMirCopyBox { opacity: 0; }
  .tutMirCopyBox.mirGo { animation: tutMirrorIn .5s ease-out backwards; }
  @keyframes tutMirrorIn { 0% { opacity: 0; transform: translateX(-7px); } 100% { opacity: 1; transform: translateX(0); } }
  .tutMirCopyBox.mirDone { opacity: 1; }

  /* ── 요약→도전 만화(recap, v4 §7.4) ─────────────────────────────────────────────────────────
     같은 무대의 두 레이어를 순차 사용한다. 1컷은 세 규칙의 정지 그림, 2컷은 샤의 도전장이다.
     카드 안에 카드를 만들지 않고 말풍선만 얹는다. 최종 hold에는 제목·보조문이 규칙을 계속 말하므로
     저감 모션에서 바로 2컷으로 가도 세 규칙의 의미가 남는다. */
  .tsRecap { position: relative; height: 100%; min-height: 0; display: block; }
  .tutRecapRules,
  .tutComic { position: absolute; inset: 0; }
  .tutRecapRules {
    display: grid; grid-template-rows: repeat(3, minmax(0, 1fr)); gap: 4px; align-items: center;
  }
  .tutRecapRow { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 0; min-width: 0; --ts: var(--tut-mini); }
  .tutRecapRow .tutBoardWrap { --tut-cell: 20px; width: calc(var(--tb-cols, 2) * var(--tut-cell)); margin: 0; }
  /* 요약 행 안의 표본은 작은 타일로 — .tutSpec이 자기 --ts를 갖고 있어 행 상속만으로는 안 줄어든다 */
  .tutRecapRow .tutSpec { --ts: var(--tut-mini); padding: 0 4px 4px 0; }
  .tutRecapRow.rowIn { animation: tutRowIn .34s ease-out backwards; }
  .tutRecapRules .tutRecapRow:nth-child(2).rowIn { animation-delay: .09s; }
  .tutRecapRules .tutRecapRow:nth-child(3).rowIn { animation-delay: .18s; }
  @keyframes tutRowIn { 0% { opacity: 0; transform: translateY(5px); } 100% { opacity: 1; transform: translateY(0); } }

  .tutRecapRules.recapOut { animation: tutRecapOut .28s ease-in backwards; }
  @keyframes tutRecapOut { 0% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(-5px); } }
  .tutRecapRules.recapGone { opacity: 0; visibility: hidden; }

  .tutComic {
    opacity: 0; z-index: 2; pointer-events: none;
  }
  .tutComic.comicIn { animation: tutComicIn .54s linear backwards; }
  @keyframes tutComicIn {
    0%   { opacity: 0; transform: translateY(7px) scaleX(.982) scaleY(1.018); animation-timing-function: ease-in-out; }
    62%  { opacity: 1; transform: translateY(0) scaleX(1.018) scaleY(.982); animation-timing-function: ease-in-out; }
    82%  { transform: translateY(0) scaleX(.994) scaleY(1.006); animation-timing-function: ease-in-out; }
    100% { opacity: 1; transform: translateY(0) scale(1); }
  }
  .tutComic.comicDone { opacity: 1; transform: none; }
  .tutComicBubble {
    position: absolute; box-sizing: border-box;
    background: rgba(255,252,239,.98); color: #342514;
    border: 2px solid rgba(62,43,21,.88); border-radius: 46% 48% 44% 50% / 34% 38% 42% 40%;
    box-shadow: 0 4px 0 rgba(65,43,19,.13), 0 8px 16px rgba(42,29,14,.13);
    text-align: center; word-break: keep-all; white-space: pre-line;
  }
  .tutComicBubble::after {
    content: ""; position: absolute; left: 22px; bottom: -14px;
    width: 22px; height: 22px;
    background: inherit; border-left: 2px solid rgba(62,43,21,.88); border-bottom: 2px solid rgba(62,43,21,.88);
    transform: skew(-25deg) rotate(-34deg);
  }
  .tutComicHelp {
    /* ⚠️ 위쪽이다 (오너 2026-08-16 "말풍선 위로 올리기") — 말은 머리에서 나온다.
       아래에 두면 샤의 다리 옆에서 말이 나오는 그림이 된다. 꼬리는 그대로 왼쪽 아래를
       향하므로 올린 자리에서 정확히 샤의 얼굴을 가리킨다. */
    right: 0; top: 0; width: min(72%, 190px); min-height: 76px;
    display: grid; place-content: center; padding: 10px 14px 12px;
    font-size: 14px; font-weight: 800; line-height: 1.4;
    border-radius: 44% 48% 47% 42% / 40% 35% 42% 38%;
  }
  .tutComicHelp::after { left: 18px; bottom: -11px; width: 16px; height: 16px; }
  .tutComic.comicIn .tutComicHelp { animation: tutComicHelpIn .34s .16s ease-out backwards; }
  @keyframes tutComicHelpIn { 0% { opacity: 0; transform: translateY(5px); } 100% { opacity: 1; transform: translateY(0); } }

  /* 시작→끝 병기(요약 3행·저감 count): ㅁ2/T1 → ㅁ0/T0. 화살표는 글자가 아니라 도형 표식. */
  .tutPair { display: flex; align-items: center; gap: 8px; }
  .tutPair .tutSpec { --ts: var(--tut-mini); padding: 0 4px 4px 0; }
  .tutPair .tutCount { min-width: 15px; font-size: 10px; line-height: 14px; right: -3px; bottom: -3px; }
  .tutPair.dim .tutSI { opacity: .3; }
  .tutPair.dim .tutCount { opacity: .45; }
  .tutArrowMark {
    width: 0; height: 0; flex: 0 0 auto;
    border-block: 5px solid transparent; border-left: 7px solid rgba(59,40,20,.6);
  }

  /* ── 최종 정지·저감 모션 ─────────────────────────────────────────────────────────────────────
     · .tutFinal   = renderFinal()이 붙인다(장면 내부의 수렴 상태).
     · [data-phase="final"] = 상태기계(C)가 붙인다.
     · [data-reduced="1"]   = 저감 모션(C가 OS 설정으로 판정) — v3 §9: 이 오버레이만의 공인 예외.
     · html[data-motion="reduced"] = [DEV] 감속 토글 검수 경로.
     넷 모두 같은 결과: 움직임 0, 정지 그림만으로 세 의미가 남는다. */
  #tutOpenDemo .tutFinal, #tutOpenDemo .tutFinal *,
  #tutorialOpening[data-phase="final"] #tutOpenDemo, #tutorialOpening[data-phase="final"] #tutOpenDemo *,
  #tutorialOpening[data-reduced="1"] #tutOpenDemo, #tutorialOpening[data-reduced="1"] #tutOpenDemo *,
  html[data-motion="reduced"] #tutOpenDemo, html[data-motion="reduced"] #tutOpenDemo * {
    animation: none !important; transition: none !important;
  }
  #tutorialOpening[data-reduced="1"] #tutOpenPanel,
  #tutorialOpening[data-reduced="1"]::before,
  html[data-motion="reduced"] #tutOpenPanel,
  html[data-motion="reduced"] #tutorialOpening::before { animation: none; }
  @media (prefers-reduced-motion: reduce) {
    /* 출시판에서도 OS 설정을 존중하는 공인 예외(v3 §1 승인 표) — 이 오버레이에만 적용된다 */
    #tutOpenPanel, #tutorialOpening::before { animation: none; }
    #tutOpenDemo, #tutOpenDemo * { animation: none !important; transition: none !important; }
  }

  /* ── 작은 화면(320×568)·낮은 화면 — 샤와 데모만 줄이고 글자는 그대로(v3 §4.4) ─────────────── */
  @media (max-width: 350px) {
    /* ⚠️ 샤 56 → **44**. 320폭에서는 내용폭이 224px뿐이라, 배우기3의 4열 보드(32×4=128)가 가운데
       서면 48px부터 시작한다 — 샤가 56이면 그 8px을 덮는다(실측 10×85px, check:layout ko·2).
       넓은 화면과 같은 이유로 줄어드는 쪽은 샤다(v3 §4.4 조연). */
    #tutorialOpening { --tut-sha-w: 44px; --tut-spec: 11px; --tut-cell: 32px; }
    /* 제목 20px — 위 .tutOpenTitle 주석이 약속한 값인데 규칙이 빠져 있었다(2026-08-16 발견).
       ja 최장 낱말(うらがえしたりしても)이 220px 내용폭을 넘지 않는 상한이며, 그림이 든 제목이
       두 줄이 될 때 CTA 여유를 지키는 값이기도 하다. 그림 정렬도 같이 한 단 내린다 — 글자 시각
       중심이 8.06×20/22 ≈ 7.3px로 내려오므로 바닥 = 7.3 − 15.5 ≈ -8px. */
    .tutOpenTitle { font-size: 20px; }
    .tsDraw .tutBoardWrap, .tsOrient .tutBoardWrap { --tut-cell: 38px; }
    .tutOrientRow { gap: 8px; }
    .tutSpecRow { gap: 12px; }
    #tutorialOpening[data-kind="recap"] .tutOpenSha { width: 76px; }
    .tutComicHelp {
      width: 148px; min-height: 90px; top: 0; padding-inline: 9px;
      font-size: 12px;
    }
  }
  @media (max-height: 620px) {
    #tutorialOpening { --tut-stage-h: clamp(190px, 40dvh, 250px); }
    .tutOpenTitle { min-height: 2.2em; }
  }

  /* ── 「팔레트 도형도 돌리고 뒤집으면 같은 도형」 착륙 미리보기 ────────────────────────────────
     배우기 1~3의 해보기 뒤, 영역마다 실제 팔레트 표본을 들어 올려 정답 방향으로 바꾼 다음 착륙시킨다.
     JS는 좌표·회전값만 넘기고 길이는 여기만 소유한다(check:anim 계약). 보드에는 임시 span만 얹으므로
     상태(S.assign)는 한 칸도 바뀌지 않는다. 레이어는 시작 모달(60)보다 아래, 게임 HUD보다 위다. */
  .tpfLayer {
    position: fixed; inset: 0; z-index: 58;
    overflow: hidden; pointer-events: auto; touch-action: none;
  }
  .tpfCarrier {
    position: fixed; z-index: 2;
    display: grid; place-items: center;
    transform: translate(-50%, -50%);
    transform-origin: 50% 50%;
    pointer-events: none;
    will-change: transform, opacity;
  }
  .tpfCarrier::before {
    content: ""; position: absolute; inset: -18px; z-index: -1;
    border-radius: 50%; opacity: 0;
    background: radial-gradient(circle, rgba(255,225,132,.34), rgba(255,196,67,.1) 43%, transparent 70%);
    filter: blur(2px);
  }
  .tpfPiece {
    position: relative;
    transform-origin: 50% 50%;
    filter: drop-shadow(0 7px 7px rgba(21,12,3,.48)) drop-shadow(0 0 7px rgba(255,209,92,.55));
    will-change: transform, filter;
  }
  .tpfTransformGlint {
    position: absolute; left: 50%; top: 50%; z-index: 3;
    width: 3px; height: 150%; border-radius: 50%; opacity: 0;
    transform: translate(-50%, -50%);
    background: linear-gradient(180deg, transparent, rgba(255,248,211,.92) 22%, #fff 50%, rgba(255,224,118,.86) 74%, transparent);
    box-shadow: 0 0 9px rgba(255,232,146,.9);
    pointer-events: none;
  }
  #game .shapeicon.tpfSourceLift {
    animation: tpfSourceLift 1.28s ease-out both;
  }
  .tpfCarrier.tpfFly {
    animation: tpfCarrierFlight 1.72s cubic-bezier(.2,.76,.22,1) both;
  }
  .tpfCarrier.tpfFly::before { animation: tpfAuraFlight 1.72s ease-out both; }
  .tpfCarrier.tpfFly .tpfPiece { animation: tpfPieceTransform 1.72s cubic-bezier(.3,.04,.18,1) both; }
  .tpfCarrier.tpfFly.isReflected .tpfTransformGlint { animation: tpfMirrorGlint 1.72s ease-in-out both; }
  .tpfCarrier.tpfFly.isTurned:not(.isReflected) .tpfTransformGlint { animation: tpfTurnGlint 1.72s ease-in-out both; }
  @keyframes tpfSourceLift {
    0%   { opacity: 1; filter: brightness(1); }
    14%  { opacity: .95; filter: brightness(1.48) drop-shadow(0 0 6px rgba(255,214,98,.74)); }
    34%,100% { opacity: .18; filter: brightness(.86); }
  }
  @keyframes tpfCarrierFlight {
    0%   { opacity: .08; transform: translate(-50%,-50%) translate(0,0) scale(.92); }
    7.5% { opacity: 1; transform: translate(-50%,-50%) translate(0,-12px) scale(1.07); }
    15%  { opacity: 1; transform: translate(-50%,-50%) translate(0,-23px) scale(1.12); }
    28.5% { opacity: 1; transform: translate(-50%,-50%) translate(var(--tpf-air-x),var(--tpf-air-y)) scale(1.14); }
    53.5% { opacity: 1; transform: translate(-50%,-50%) translate(var(--tpf-air-x),var(--tpf-air-y)) scale(1.1); }
    94%  { opacity: 1; transform: translate(-50%,-50%) translate(var(--tpf-target-x),var(--tpf-target-y)) scale(var(--tpf-target-bounce)); }
    100% { opacity: .96; transform: translate(-50%,-50%) translate(var(--tpf-target-x),var(--tpf-target-y)) scale(var(--tpf-target-scale)); }
  }
  @keyframes tpfPieceTransform {
    0%,25.5% { transform: rotate(0deg) scaleX(1); filter: drop-shadow(0 7px 7px rgba(21,12,3,.48)) drop-shadow(0 0 7px rgba(255,209,92,.55)); }
    46%,100% { transform: rotate(var(--tpf-rotation)) scaleX(var(--tpf-reflection)); filter: drop-shadow(0 5px 5px rgba(21,12,3,.42)) drop-shadow(0 0 5px rgba(255,222,125,.48)); }
  }
  @keyframes tpfAuraFlight {
    0%,6%,100% { opacity: 0; transform: scale(.7); }
    22.5%,56.5% { opacity: 1; transform: scale(1); }
  }
  @keyframes tpfMirrorGlint {
    0%,27%,54%,100% { opacity: 0; }
    33% { opacity: .35; transform: translate(-50%,-50%) scaleY(.72); }
    39% { opacity: 1; transform: translate(-50%,-50%) scaleY(1); }
    47.5% { opacity: .22; transform: translate(-50%,-50%) scaleY(.82); }
  }
  @keyframes tpfTurnGlint {
    0%,27%,54%,100% { opacity: 0; transform: translate(-50%,-50%) rotate(-26deg) scaleY(.6); }
    39.5% { opacity: .78; transform: translate(-50%,-50%) rotate(24deg) scaleY(.9); }
  }

  /* 착륙은 실제 칸의 곡률·크기를 그대로 빌린다. 한 덩어리의 금빛 도형이 잠깐 채운 뒤 사라져야
     다음 손가락 코치가 "직접 그릴 차례"로 읽힌다. */
  #game .cell.tpfTargetCell { z-index: 4; }
  #game .cell > .tpfLandingTile {
    position: absolute; inset: -1px; z-index: 7;
    border-radius: inherit; box-sizing: border-box;
    pointer-events: none; opacity: 0;
    border: 1px solid rgba(112,68,13,.82);
    background:
      radial-gradient(48% 35% at 28% 22%, rgba(255,255,255,.76), transparent 70%),
      linear-gradient(180deg, #ffe18a 0%, #f4bb42 54%, #d88a1e 100%);
    box-shadow:
      inset 0 2px 0 rgba(255,255,255,.54), inset 0 -5px 8px rgba(91,48,4,.22),
      0 4px 7px rgba(28,15,2,.42), 0 0 0 1px rgba(255,231,146,.34), 0 0 13px rgba(255,200,70,.48);
  }
  .tpfLayer.tpfLand { animation: tpfLandingClock .78s linear both; }
  body.tutorialPaletteLanding #game .cell > .tpfLandingTile {
    animation: tpfTileLanding .72s linear var(--tpf-delay) both;
  }
  @keyframes tpfLandingClock { from { opacity: 1; } to { opacity: 1; } }
  @keyframes tpfTileLanding {
    0%   { opacity: 0; transform: translateY(-7px) scale(.84); filter: brightness(1.45); }
    15%  { opacity: 1; transform: translateY(1px) scaleX(1.08) scaleY(.92); filter: brightness(1.2); }
    31%  { opacity: 1; transform: translateY(0) scale(.98); filter: brightness(1); }
    72%  { opacity: 1; transform: scale(1); filter: brightness(1); }
    100% { opacity: 0; transform: scale(.96); filter: brightness(.92); }
  }

  /* 이동을 줄인 환경에서도 뜻은 남긴다: 팔레트 표본과 목적 칸을 동시에 짚고, 이동·회전은 생략한다. */
  html[data-motion="reduced"] #game .shapeicon.tpfSourceLift { animation: none; opacity: .55; }
  html[data-motion="reduced"] body.tutorialPaletteLanding #game .cell > .tpfLandingTile {
    animation: tpfTileLandingReduced .42s linear both;
  }
  @keyframes tpfTileLandingReduced {
    0%,68% { opacity: .88; }
    100% { opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce) {
    #game .shapeicon.tpfSourceLift { animation: none; opacity: .55; }
    body.tutorialPaletteLanding #game .cell > .tpfLandingTile { animation: tpfTileLandingReduced .42s linear both; }
  }
