:root {
  --ease-reaction: cubic-bezier(.2,0,0,1);
  --ease-settle: cubic-bezier(.22,.61,.36,1);
  --ease-cinema: var(--ease-settle);
  --safe-top: max(12px, env(safe-area-inset-top));
  --safe-bottom: max(12px, env(safe-area-inset-bottom));
  --world-tilt: 0deg;
  --warmth: 0;
}

.game {
  transform: rotate(var(--world-tilt));
  transform-origin: 50% 58%;
  transition: transform 280ms var(--ease-cinema), background-color 800ms ease;
}

/* Background: one image, three light states, era grade, and one blended blur copy. */
.backdrop {
  z-index: 0;
  background:
    radial-gradient(circle at 68% 21%, rgba(218,168,92,.18), transparent 23%),
    linear-gradient(158deg, #4b5045 0%, #24322c 52%, #0c1413 100%);
  filter: saturate(.88) sepia(.08) brightness(calc(.9 + var(--warmth) * .025));
  transition: filter 1000ms var(--ease-cinema);
}

.game[data-era="modern"] .backdrop { filter: saturate(1.05) brightness(calc(.94 + var(--warmth) * .025)); }
.game[data-background="blackout"] .backdrop { background: #050606; }
.backdrop::before { opacity: .12; background: linear-gradient(115deg, rgba(255,255,255,.08), transparent 48%); }
.backdrop::after { background: linear-gradient(180deg, rgba(3,5,7,.08), transparent 38%, rgba(3,5,7,.82)); }
.horizon, .light-path { display: none; }

.bg-image {
  position: absolute;
  inset: -5%;
  background-image: var(--scene-image);
  background-position: center;
  background-size: cover;
  transform: scale(var(--camera-scale, 1));
  transform-origin: var(--camera-origin, 50% 48%);
  transition: transform 1000ms var(--ease-cinema), opacity 1000ms var(--ease-cinema), filter 1000ms var(--ease-cinema);
  animation: ambientKenBurns 78s ease-in-out infinite alternate;
  will-change: transform, opacity;
}
.bg-sharp { opacity: 1; }
.bg-soft { opacity: var(--blur-mix, 0); filter: blur(7px); transform: scale(var(--camera-scale, 1)); }
.game[data-shot="wide"] { --camera-scale: 1; --blur-mix: 0; }
.game[data-shot="mid"] { --camera-scale: 1.10; --blur-mix: .32; }
.game[data-shot="close"] { --camera-scale: 1.26; --blur-mix: .72; }
.game[data-shot="over"] { --camera-scale: 1.34; --blur-mix: .88; --camera-origin: 58% 46%; }
.game[data-stage-art="fallback"] { --camera-origin: 50% 41%; }

.lighting, .water-plane, .vignette, .ambient-particles { position: absolute; inset: 0; pointer-events: none; }
.lighting { opacity: .78; transition: background 700ms ease; }
.game[data-light="day"] .lighting { background: linear-gradient(125deg, rgba(255,255,255,.05), transparent 52%); }
.game[data-light="evening"] .lighting { background: radial-gradient(circle at 68% 31%, rgba(224,137,72,.2), transparent 50%); }
.game[data-light="night"] .lighting { background: linear-gradient(rgba(17,30,65,.35), rgba(8,15,36,.35)); }
.vignette { background: radial-gradient(ellipse at center, transparent 42%, rgba(3,7,11,.4) 100%); }
.water-plane { top: 52%; opacity: 0; background: repeating-linear-gradient(174deg, transparent 0 15px, rgba(194,223,220,.1) 16px 18px, transparent 19px 29px); animation: waterFlow 11s linear infinite; }
.game[data-mood="water"] .water-plane { opacity: .8; }
.grain { opacity: .06; }

.ambient-particles i {
  position: absolute; left: var(--x); top: var(--y); width: var(--size); height: var(--size); border-radius: 50%;
  opacity: .22; background: rgba(248,230,188,.72); box-shadow: 0 0 12px rgba(250,219,151,.22);
  animation: ambientDrift var(--duration) ease-in-out infinite alternate; animation-delay: calc(var(--duration) * -.37);
}
.game[data-mood="rain"] .ambient-particles i { width: 1px; height: 34px; border-radius: 0; background: linear-gradient(transparent, rgba(197,222,230,.52)); animation-name: ambientRain; }
.game[data-mood="water"] .ambient-particles i { width: calc(var(--size) * 2.3); height: 2px; background: rgba(230,240,219,.65); animation-name: ambientGlint; }
.game[data-mood="candle"] .ambient-particles i { background: rgba(255,178,77,.78); box-shadow: 0 0 18px rgba(255,126,37,.42); animation-name: ambientCandle; }
.game[data-mood="petal"] .ambient-particles i { width: calc(var(--size) * 1.8); border-radius: 85% 15% 70% 30%; background: rgba(247,189,180,.75); animation-name: ambientPetal; }

/* One subject owns the frame; the listener is only an edge presence. */
.character-stage { inset: 0; z-index: 2; display: block; padding: 0; overflow: hidden; }
.stage-figure {
  position: absolute; top: auto; bottom: -14vh; left: 50%; width: 88vw; height: 70vh; margin: 0;
  opacity: 1; filter: none; transform: translateX(-50%) scale(var(--figure-scale, .9)); transform-origin: 50% 100%;
  transition: left 1000ms var(--ease-cinema), transform 1000ms var(--ease-cinema), opacity 420ms ease, filter 320ms ease;
}
.character-stage[data-shot="wide"] .stage-figure { --figure-scale: .86; }
.character-stage[data-shot="mid"] .stage-figure { --figure-scale: 1; }
.character-stage[data-shot="close"] .stage-figure { --figure-scale: 1.14; }
.character-stage[data-shot="over"] .stage-figure { --figure-scale: 1.22; }
.stage-figure[data-role="subject"][data-who="dahee"] { left: 38%; }
.stage-figure[data-role="subject"][data-who="minui"] { left: 62%; }
.stage-figure[data-role="subject"][data-who="artisan"] { left: 58%; }
/* ⚠ 2026-08-05 4차 — `opacity: .54` 였다. `wire-runtime.css` 가 뒤에 실려 한 번도 이기지 못한
   죽은 값이지만, 그 파일이 빠지는 날 **인물이 반투명으로 되살아나는 뇌관**이라 1 로 맞춘다.
   사람은 불투명하다(위 wire-runtime 의 listener 주석 참조). */
.stage-figure[data-role="listener"] { --figure-scale: .72; opacity: 1; filter: brightness(.78) saturate(.68); }
.stage-figure[data-role="listener"][data-who="dahee"] { left: -8%; }
.stage-figure[data-role="listener"][data-who="minui"], .stage-figure[data-role="listener"][data-who="artisan"] { left: 108%; }
.stage-figure[data-speaking="true"] { filter: brightness(1.06); }
/* ⚠ 2026-08-05 발주자 지시 — 등장·퇴장은 **크로스페이드만.** 여기 `translate: 0 12px` 가 있었다.
   `wire-runtime.css` 가 `transition: opacity 300ms !important` 로 덮어써서 트윈은 안 됐지만
   `translate` 는 `transform` 과 별개 속성이라 **안 덮인다** — 붙는 첫 프레임에 인물이 12px 아래에
   찍혔다가 다음 프레임에 튀어 올랐다(바운스). 이동은 빼고 투명도만 남긴다. */
.stage-figure.is-entering { opacity: 0; }
.stage-figure.is-leaving { opacity: 0; transition-duration: 300ms; }
.figure-art { position: absolute; inset: 0; transform: translate(var(--gaze-x), var(--gaze-y)); }
.character-sprite { width: 100%; height: 100%; object-fit: contain; object-position: 50% top; filter: drop-shadow(0 0 14px var(--rim)) drop-shadow(0 22px 22px rgba(0,0,0,.35)); user-select: none; }
.character-sprite.is-paper { filter: saturate(.82) sepia(.08) drop-shadow(0 0 14px var(--rim)); }
.stage-figure[data-costume="hanbok"] .figure-art { mix-blend-mode: multiply; }
.character-sprite.is-paper.keying-failed { mix-blend-mode: multiply; filter: none; }
.stage-figure[data-costume="hanbok"] .character-sprite { filter: saturate(.82) sepia(.08) drop-shadow(0 0 14px var(--rim)) drop-shadow(0 22px 22px rgba(0,0,0,.35)); }
.blink-mask { position: absolute; left: 43%; top: 11.6%; width: 15%; height: 1.4%; opacity: 0; border-top: 2px solid rgba(35,25,23,.75); border-radius: 50%; transform: scaleY(.05); }
.stage-figure[data-who="minui"] .blink-mask { top: 12.4%; left: 42.5%; }
.blink-mask.is-blinking { opacity: 1; transition: opacity 45ms ease; }
.stage-figure figcaption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.stage-figure[data-renderer="fallback"] { bottom: 18vh; width: 56vw; height: 45vh; --figure-scale: .82 !important; opacity: .38; filter: brightness(.62) saturate(.42); }
.character-stage[data-shot="mid"] .stage-figure[data-renderer="fallback"] { --figure-scale: 1 !important; }
.stage-figure[data-renderer="fallback"][data-role="listener"] { --figure-scale: .64 !important; opacity: .24; }
.stage-figure[data-renderer="fallback"][data-speaking="true"] { filter: brightness(.68) saturate(.46); }
.fallback-silhouette { position: absolute; inset: 4% 9% 0; filter: drop-shadow(0 18px 20px rgba(0,0,0,.3)); }
.fallback-head { position: absolute; left: 50%; top: 3%; width: 36%; aspect-ratio: .84; transform: translateX(-50%); border-radius: 52% 48% 46% 50%; background: radial-gradient(circle at 42% 38%,#81786a,#35403e 56%,#172023 100%); box-shadow: inset 0 9px 0 rgba(14,20,22,.42); }
.fallback-neck { position: absolute; left: 43%; top: 22%; width: 14%; height: 9%; background: #343b38; }
.fallback-body { position: absolute; left: 50%; bottom: 0; width: 77%; height: 74%; transform: translateX(-50%); clip-path: polygon(36% 0,64% 0,74% 7%,90% 26%,80% 100%,20% 100%,10% 26%,26% 7%); border-radius: 18% 18% 7% 7%; background: linear-gradient(125deg,#4e5d59,#172023 70%); }
.costume-guard .fallback-head::after { content: ""; position: absolute; left: -24%; right: -24%; top: -10%; height: 20%; border-radius: 50%; background: #172023; }
.costume-hanbok .fallback-body { width: 98%; clip-path: polygon(33% 0,67% 0,100% 100%,0 100%); background: linear-gradient(125deg,#a98f7e,#423d3b); }

/* HUD retreats to three corners. */
.game-header, .story-progress, .memory-status { display: none !important; }
.corner-hud { position: absolute; z-index: 50; inset: 0; pointer-events: none; transition: opacity 220ms ease; }
.corner-hud > button { position: absolute; top: var(--safe-top); width: 44px; height: 44px; border: 0; border-radius: 50%; padding: 0; color: rgba(255,255,255,.82); background: rgba(5,9,12,.14); font: 600 22px/1 system-ui,sans-serif; text-shadow: 0 2px 8px rgba(0,0,0,.7); pointer-events: auto; }
.sound-control { left: 2vw; }.menu-control { right: 2vw; }
.sound-control { font-size: 17px !important; }
/* ⚠ 2026-08-17 — UI 표면의 backdrop-filter 전면 금지. 카톡 인앱(시스템 WebView)에서 blur 합성이
   검은 판으로 렌더되며 내용(버튼 글자)까지 가렸다. 발주자 원문: "메뉴를 누르면 저렇게 나와"(빈 검은
   상자 스샷) / "게임 시작시 로딩때 … 카톡 링크 브라우저는 깜빡임이 있어". Chrome 앱·에뮬에선 재현
   안 되는 WebView GPU 계열 결함이라 실기기 판정이 정본. 같은 병으로 대사 카드에서 이미 제거한 전례
   (wire-runtime.css:718 주석, 2026-08-11). 판독성은 배경 불투명도 상향으로 보상 — blur 되살리기 금지. */
/* 2026-08-17 batch6 — 발주자 실기기 진단(/story/diag 스샷)으로 transform·transition·
   box-shadow·opacity 전부 무죄 확정 → batch5에서 뺐던 등장 애니·그림자 원복(발주자
   지적: "그게 원인이 아닌데 왜 다 없앴냐"). blur 만 전과(2026-08-11 카드 반짝임)가
   있어 메뉴 정상 확인 후 별도 원복. 은폐는 visibility 병행 유지(진입 FOUC 방지 겸). */
.menu-sheet { position: absolute; top: calc(var(--safe-top) + 48px); right: 4vw; width: min(250px,76vw); padding: 14px; border: 1px solid rgba(222,190,128,.28); border-radius: 16px; opacity: 0; visibility: hidden; transform: translateY(-8px); background: rgba(8,13,17,.96); box-shadow: 0 16px 38px rgba(0,0,0,.35); pointer-events: none; transition: opacity 180ms ease, transform 180ms ease, visibility 0s 180ms; }
.menu-sheet[aria-hidden="false"] { opacity: 1; visibility: visible; transform: none; pointer-events: auto; transition: opacity 180ms ease, transform 180ms ease; }
.menu-sheet button { width: 100%; min-height: 44px; border: 0; color: #f4ece0; background: transparent; text-align: left; font: 500 13px/1.5 system-ui,sans-serif; }
.menu-progress { margin: 5px 0 0; padding-top: 10px; border-top: 1px solid rgba(255,255,255,.11); color: rgba(255,255,255,.55); font: 400 12px/1.5 system-ui,sans-serif; }
.menu-progress span { float: right; color: #dfbd79; }
.scene-caption { position: absolute; z-index: 20; left: 5vw; bottom: 6vh; margin: 0; opacity: 0; color: rgba(255,255,255,.72); font: 500 11px/1.5 system-ui,sans-serif; letter-spacing: .18em; text-shadow: 0 2px 8px rgba(0,0,0,.8); pointer-events: none; }
.scene-caption.is-showing { animation: sceneCaption 1.6s ease both; }
/* ⚠ 2026-08-06 r10 실측 — 장 캡션과 씬 첫 자막이 **같은 띠에 겹쳐 찍혔다**
   (c3-cave 진입: 캡션 [777,793] · 자막창 [728,786], 0~800ms 동안 두 글줄이 포개짐).
   `[data-cg="on"] .scene-caption { opacity: 0 }` 이 있는데도 소용없었다 —
   달리고 있는 **애니메이션이 보통 선언을 이긴다**(cascade origin). 그래서 숨김이 아니라
   **순서**로 푼다: 캡션이 제 말을 끝내고 이 클래스로 걷힌 뒤에 자막이 들어온다(engine.js). */
/* ⚠ `animation: none; opacity: 0` 로는 못 녹인다 — 애니메이션을 떼는 순간 기준값이 0 이라
   트윈할 출발점이 없어 **툭 꺼진다**(실측). 나가는 것도 애니메이션이어야 한다. */
.scene-caption.is-ending { animation: sceneCaptionOut 220ms ease both; }
@keyframes sceneCaptionOut { from { opacity: .7; } to { opacity: 0; } }

/* Text lives on the image. Long narration alone gets a bounded, non-clipping paper wash. */
/* ⚠ 2026-08-05 발주자 라이브 판정 — **"대사도 중앙에 있고"**.
   여기가 `bottom: 30vh` 였다. 844px 화면에서 대사 블록이 y 590 언저리, 즉 **화면 한가운데**에
   떠 있었고, 긴 지문(`.long-copy`)만 8vh 로 내려갔다 — 같은 씬 안에서 대사가 길면 아래,
   짧으면 가운데로 **오르내렸다.** 상용 VN 은 예외 없이 대사창이 화면 맨 아래에 못 박혀 있고,
   그래야 인물의 얼굴·상체가 비고 눈이 한 곳만 본다.
   규율: **대사·내레이션 창은 항상 하단.** 위치는 이 변수 하나가 쥔다 —
   길이·타입·씬으로 갈라지는 분기를 다시 만들지 마라.
   ⚠ 예외는 레터박스가 내려온 `is-decisive` 뿐이고, 그것도 "가운데로 올린다"가 아니라
   **띠 바로 위**다(띠는 정본 연출이라 건드리지 않는다). 아래 `--dialogue-bottom` 참조. */
.game { --dialogue-bottom: calc(var(--safe-bottom) + 5vh); }
.text-panel {
  z-index: 20; left: 8vw; right: 8vw; bottom: var(--dialogue-bottom); min-height: 0; max-height: none; padding: 0;
  border: 0; border-radius: 0; overflow: visible; background: transparent; box-shadow: none; backdrop-filter: none;
  color: #fff; text-shadow: 0 2px 10px rgba(0,0,0,.85);
}
.dialogue-scrim { display: none; }
.game:has(.text-panel[hidden]) .dialogue-scrim, .game.is-transitioning .dialogue-scrim { opacity: 0; }
.text-panel::before { display: none; }
.text-panel::after { display: none; }
.speaker, .story-text {
  display: inline;
  margin: 0;
  padding-block: .08em;
  background: rgba(3,6,8,.34);
  box-shadow: .24em 0 rgba(3,6,8,.34), -.24em 0 rgba(3,6,8,.34);
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  text-shadow: 0 2px 10px rgba(0,0,0,.98), 0 0 3px rgba(0,0,0,.92), 1px 1px 1px #000, -1px -1px 1px rgba(0,0,0,.82);
}
.speaker { min-height: 0; margin-right: 9px; color: #d8c098; font: 600 13px/1.6 system-ui,sans-serif; letter-spacing: .08em; }
.speaker[data-speaker="minui"] { color: #8fb4d8; }.speaker[data-speaker="dahee"] { color: #e0b878; }.speaker[data-speaker="artisan"] { color: #a9d0ad; }
.story-text, .text-panel.narration .story-text { color: #fff; font: 400 17px/1.6 "Noto Serif KR","Nanum Myeongjo","Batang",serif; font-style: normal; word-break: keep-all; overflow-wrap: anywhere; }
.game[data-era="joseon"] .story-text { color: #f4ece0; }
/* 긴 지문도 **같은 바닥**에 앉는다. 다른 것은 높이 상한뿐이다(길면 위로 자란다). */
.text-panel.long-copy { bottom: var(--dialogue-bottom); max-height: 26vh; padding: 17px 19px; overflow-y: auto; border: 0; border-radius: 0; background: transparent; box-shadow: none; backdrop-filter: none; }
.text-panel.long-copy::before { display: none; }
.text-panel.memory .story-text, .text-panel.ending .story-text, .text-panel.quest .story-text { color: #f4dfab; text-align: left; font-size: 18px; letter-spacing: .02em; }
.next-hint { position: absolute; z-index: 51; right: 4vw; bottom: calc(var(--safe-bottom) + 2vh); width: 44px; height: 44px; display: grid; place-items: center; border: 0; opacity: 0; transform: none; }
.next-hint::before { content: "▼"; color: rgba(255,255,255,.74); font: 500 15px/1 system-ui,sans-serif; animation: progressBounce 1.4s ease-in-out infinite; }
.next-hint.visible { opacity: 1; animation: none; }

/* Choice gate: only Answer is visible until the guest asks to speak. */
.choices { z-index: 48; left: 8vw; right: 8vw; bottom: calc(var(--safe-bottom) + 7vh); display: none; gap: 12px; padding: 0; border: 0; background: transparent; backdrop-filter: none; }
.choices.visible { display: grid; }
.choice-title { margin: 0 0 4px; color: rgba(255,255,255,.82); text-align: center; font: 500 14px/1.6 system-ui,sans-serif; text-shadow: 0 2px 10px #000; }
.answer-button { justify-self: center; width: 62%; min-height: 54px; border: 1px solid #b99554; border-radius: 999px; color: #3b3023; background: #f8eed9; box-shadow: 0 10px 32px rgba(0,0,0,.24); font: 700 16px/1 system-ui,sans-serif; animation: choiceRise 260ms var(--ease-cinema) both; }
/* ⚠ 2026-08-14 독립 검수 C — 「답하기」 알약이 **다희의 저고리 한복판**에 떠 있고 화면 아래
   절반이 비어 있었다(`q3-minui-choice`). 원인은 자리 지정이 아니라 **안 보이는 것이 자리를
   차지하고 있던 것**이다: 선택지 셋은 `opacity: 0` 이라 안 보일 뿐 레이아웃에는 그대로 있어서
   (54px × 3 + 간격 ≈ 190px) 그 높이만큼 알약을 그림 한가운데로 밀어 올렸다.
   `.choices` 는 원래 `bottom: safe + 7vh` 로 **아래**에 앉게 돼 있었다 — 계약은 멀쩡했다.
   닫혀 있는 동안에는 흐름에서 빼면, 알약이 제자리(엄지 자리)로 내려가고 열릴 때
   선택지가 위로 펼쳐진다. ⚠ `is-open` 을 처음부터 켜는 씬(c1·c2)은 이 규칙에 안 걸린다. */
.choice-candidates { display: grid; gap: 9px; pointer-events: none; }
.choice-candidates:not(.is-open):empty, .choices:has(.answer-button) .choice-candidates:not(.is-open) { display: none; }
.choice-candidates .choice-button { min-height: 54px; opacity: 0; transform: translateY(10px); border: 1px solid rgba(228,194,127,.5); border-radius: 14px; color: #f8f0e1; background: rgba(9,14,17,.78); box-shadow: 0 10px 28px rgba(0,0,0,.2); text-align: center; transition: opacity 260ms ease, transform 260ms ease; transition-delay: calc(var(--choice-index) * 70ms); }
.choice-candidates.is-open { pointer-events: auto; }
.choice-candidates.is-open .choice-button { opacity: 1; transform: none; }
.choice-button.is-dismissed { opacity: 0 !important; transform: translateY(6px) scale(.98) !important; transition-duration: 180ms !important; transition-delay: 0ms !important; }
.choice-button.is-selected { opacity: 1 !important; transform: scale(1.015) !important; transition-delay: 0ms !important; }

/* 오답은 씬 안에서 받는다 — 엔딩 화면으로 나가지 않는다.
   말한 사람의 얼굴이 넘어가고, 그 자리에서 다시 고른다. 배경·BGM 은 끊기지 않는다. */
/* ⚠ 밝은 배경(작업장 낮·분홍 한복) 위에 얹히므로 `.ending-return` 의 반투명 검정으로는 안 읽힌다.
   실측: 그 스타일 그대로 두었을 때 화면에서 버튼을 찾을 수 없었다. 선택지 버튼과 같은 무게로 세운다. */
.choice-retry { justify-self: center; min-width: 168px; min-height: 54px; padding: 10px 22px; border: 1px solid rgba(228,194,127,.62); border-radius: 999px; opacity: 0; translate: 0 8px; color: #f8f0e1; background: rgba(9,14,17,.86); box-shadow: 0 10px 28px rgba(0,0,0,.34); font: 600 15px/1.4 system-ui,sans-serif; pointer-events: none; transition: opacity 420ms ease, translate 420ms ease, background 180ms ease; }
.choice-retry[hidden] { display: none; }
.choice-retry.is-visible { opacity: .88; translate: 0 0; pointer-events: auto; }
.choice-retry:focus-visible { outline: 2px solid #f0d49d; outline-offset: 3px; }
.choice-retry:active { background: rgba(77,59,39,.72); }
/* ⚠ 2026-08-05 프레임 통독에서 잡혔다 — 게이트도, 로그도, 수치도 못 잡았다.
   `is-decisive` 인 선택 씬(q3-dahee-choice · q3-minui-choice)에서 **정답 선택지가 레터박스에 통째로 잠겨 있었다.**
   실측(390×844, 띠 상단 y656): q3-dahee-choice 3번 [697-773] 117px 잠김 · 2번 32px 잠김,
   q3-minui-choice 3번 [719-773] 117px 잠김 · 2번 54px 잠김. 띠는 상단 52%가 완전 불투명이라
   3번(“몇 번을 다시 만나도 민의를 선택하겠습니다” · “다희와의 약속을 기억해 낸다”)이 안 읽힌다 —
   두 씬 모두 그게 **진엔딩으로 가는 유일한 선택지**다. 미관이 아니라 기능 파손이다.
   레터박스(정본 연출)는 건드리지 않고 선택지 블록만 띠 위로 올린다.
   선택지 없는 decisive 씬은 `.choices.visible` 이 안 붙으므로 영향이 없다. */
/* ⚠ 레터박스가 하단 22%를 먹는 씬이다. "하단 고정"의 바닥이 화면 끝이 아니라 **띠 위**로 올라간다 —
   대사창·선택지·되받기가 전부 이 변수 하나를 따라 같이 올라간다(따로 노는 값을 만들지 마라). */
.game.is-decisive { --dialogue-bottom: 24vh; }
.game.is-decisive .choices.visible { bottom: var(--dialogue-bottom); }
/* ⚠ 두 오답 씬(q3-dahee-choice · q3-minui-choice)은 `is-decisive` 라 레터박스가 내려와 있다.
   실측: 하단 띠가 844px 화면에서 y 656 부터다. `.choices` 기본값(7vh)에 두면 버튼이 그 띠 밑으로
   들어가 화면에서 사라졌다. 대사와 버튼을 띠 위로 함께 올린다.
   ⚠ 합성컷 판정(`artifacts/compare-sheets/rebuff-dialogue-142601.jpg`, REF: love-and-deepspace 실플레이):
   실물은 대사와 조작을 **화면 맨 아래에 하나의 블록으로** 붙여 인물의 얼굴·상체를 비운다.
   첫 배치(대사 36vh · 버튼 25vh)는 둘이 39px 벌어져 인물 몸통 한가운데를 따로따로 덮었다.
   레터박스가 하단 22%를 먹으므로 완전한 하단 정렬은 못 한다 — 대신 띠 바로 위로 모아 붙인다.
   ⚠ 이 주석은 한 덩어리다. 주석 종료 기호를 문장 안에 적으면 거기서 주석이 끊기고
      아래 두 줄이 통째로 죽는다 — 실제로 두 번 당했고, 두 번 다 화면으로만 잡혔다. */
.game.choice-rebuff .text-panel.rebuff { bottom: calc(var(--dialogue-bottom) + 8vh); }
.game.choice-rebuff .choices.visible { bottom: var(--dialogue-bottom); }

/* Minigames remain inside the scene. Instruction exists for 2.2 seconds only. */
.minigame-layer { z-index: 31; background: transparent; }
.minigame-shell { position: relative; display: block; padding: 0; }
.minigame-head { position: absolute; z-index: 12; left: 8vw; right: 8vw; bottom: 9vh; padding: 10px 14px; border-radius: 12px; background: rgba(5,9,11,.58); text-align: center; pointer-events: none; animation: tutorialCaption 2.2s ease both; }
.minigame-head .eyebrow, .minigame-head h2 { display: none; }
.minigame-head p:last-child { color: #f5ead8; font: 500 13px/1.5 system-ui,sans-serif; }
.minigame-play { position: absolute; inset: 0; overflow: hidden; }
.skip-scene { position: absolute; z-index: 20; left: 50%; bottom: calc(var(--safe-bottom) + 2vh); transform: translateX(-50%); }
.game.minigame-active .corner-hud, .game.minigame-active .scene-caption { opacity: 0; pointer-events: none; }
.game.minigame-active .character-stage { filter: blur(1px) brightness(.68); transform: scale(1.02); transition: 700ms var(--ease-cinema); }

.palm-scene { position: absolute; inset: 0; display: block; }
.game[data-minigame="q1-palm-game"] .minigame-play::before { content: ""; position: absolute; z-index: 0; inset: 0; background: rgba(2,6,8,.58); pointer-events: none; }
.trace-field { position: absolute; z-index: 2; left: 50%; top: 31vh; width: min(58vw,240px); height: min(32vh,270px); transform: translateX(-50%); animation: traceFieldEnter 700ms var(--ease-cinema) 800ms both; }
.lantern-row { position: absolute; z-index: 8; top: 14vh; right: 5vw; display: grid; gap: 8px; padding: 0; }
.lantern-row i { width: 9px; height: 13px; transition: transform 700ms var(--ease-cinema), opacity 400ms ease; }
.lantern-row.converge i { position: absolute; right: 20vw; top: 22vh; transform: scale(1.5); }
.trace-letter, .glow-letter { font-size: min(26vw,108px); }
.trace-canvas { filter: drop-shadow(0 0 7px rgba(255,211,117,.75)); }
.glow-letter.flash { animation: letterFlashGold 1.2s ease; }
.letter-progress { position: absolute; left: 0; right: 0; bottom: 4vh; opacity: .52; font-size: 12px; }
.wrong-words { position: absolute; left: 0; right: 0; bottom: 12vh; opacity: 0; pointer-events: none; animation: temptation 1.4s ease 2.3s both; }
.wrong-words button { color: rgba(255,255,255,.6); background: rgba(5,9,11,.38); }
.minigame-dialogue { bottom: 18vh; }

.river-game, .pattern-game, .ring-pair-game { position: absolute; inset: 0; min-height: 0; border-radius: 0; }
.river-game { background: linear-gradient(90deg,rgba(4,18,25,.62),rgba(20,56,64,.54),rgba(4,23,31,.64)); }
.pattern-game { background: radial-gradient(circle at 50% 45%,rgba(51,45,36,.72),rgba(10,14,14,.68) 68%); }
.ring-pair-game { background: radial-gradient(circle at 50% 42%,rgba(57,49,40,.68),rgba(8,12,12,.66) 72%); }
.river-game::before { animation: riverCurrent 5s linear infinite; }
.river-game.tilting .boat { transform: translateX(-50%) rotate(2.5deg); }
.river-game.tilting { --river-tilt: 2.5deg; }
.moon-shard.is-rippling::after { content: ""; position: absolute; left: 50%; top: 50%; width: 16px; height: 7px; border: 1px solid #f7e9b8; border-radius: 50%; animation: ripple 720ms ease-out both; }
.river-game.moon-complete { animation: freezeFlash 600ms steps(1,end) both; }
.pattern-piece.near { filter: drop-shadow(0 0 8px #e6b959); }
.pattern-piece.snap { animation: ringSnap 140ms ease-out both; }
.pattern-game.complete .pattern-target { animation: ringComplete 1100ms var(--ease-cinema) both; }
.pair-bridge { position: absolute; left: 24%; right: 24%; top: 50%; height: 2px; opacity: .25; transform: rotate(var(--line-angle,0deg)); background: #b78458; }
.ring-options button[data-answer="correct"] .pair-bridge { opacity: .85; background: linear-gradient(90deg,transparent,#f0c96f,#fff0b3,#f0c96f,transparent); box-shadow: 0 0 8px #e8bf62; }
.ring-options button[data-answer="wrong"]:first-child .pair-bridge { --line-angle: 9deg; }.ring-options button[data-answer="wrong"]:last-child .pair-bridge { --line-angle: -11deg; }
.ring-options button.discarded { opacity: 0; transform: translateY(6px) scale(.98); transition: opacity 180ms ease, transform 180ms ease; }
.ring.ring-complete { animation: ringCompleteStandalone 1.1s var(--ease-cinema) both; }

/* Transitions speak: dissolve, blackout cut, memory-white, and decisive mode. */
.transition-layer { position: absolute; z-index: 80; inset: 0; pointer-events: none; opacity: 0; overflow: hidden; }
.transition-old, .transition-new, .white-veil, .black-veil { position: absolute; inset: -6%; }
.white-veil, .black-veil { opacity: 0; }
.transition-old, .transition-new { background-image: var(--transition-bg); background-position: center; background-size: cover; }
.transition-old[data-era="joseon"], .transition-new[data-era="joseon"] { background-color: #25352e; background-image: var(--transition-bg), radial-gradient(circle at 65% 20%,#8d704b33,transparent 32%), linear-gradient(155deg,#475044,#182521); }
.white-veil { inset: 0; background: radial-gradient(circle at center,rgba(255,255,255,.98),rgba(255,250,235,.92) 38%,rgba(255,255,255,0) 79%); }
.black-veil { inset: 0; background: #05070b; }
.game.is-transitioning .transition-layer { opacity: 1; }
.game.era-forward .transition-old { animation: eraOldForward 3s var(--ease-cinema) both; }
.game.era-forward .transition-new { animation: eraNewForward 3s var(--ease-cinema) both; }
.game.era-forward .white-veil { animation: eraVeilForward 3s linear both; }
.game.era-reverse .transition-old { animation: eraOldReverse 1.8s var(--ease-cinema) both; }
.game.era-reverse .transition-new { animation: eraNewReverse 1.8s var(--ease-cinema) both; }
.game.era-reverse .white-veil { animation: eraVeilReverse 1.8s linear both; }
.game.is-transitioning :is(.corner-hud,.text-panel,.choices,.scene-caption,.character-stage,.next-hint) { opacity: 0 !important; transition: opacity 300ms ease; }
.game.transition-black::after { content: ""; position: absolute; z-index: 79; inset: 0; pointer-events: none; background: #05070b; animation: blackoutCut 980ms ease both; }
/* 장소가 바뀌는 전환은 **두 조각**이다: 앞 씬 위에서 차오르는 `-in`, 새 씬 위에서 걷히는 `-out`.
   한 장짜리 `blackoutCut` 은 씬을 먼저 갈아 끼우고 나중에 덮어서 검정↔내용 왕복을 만들었다
   (2026-08-08 프롤로그 실측). 두 조각은 engine 의 BLACKOUT_IN_MS/OUT_MS 와 길이가 같아야 한다. */
.game.transition-black-in::after { content: ""; position: absolute; z-index: 79; inset: 0; pointer-events: none; background: #05070b; animation: blackoutIn 420ms ease-in both; }
.game.transition-black-out::after { content: ""; position: absolute; z-index: 79; inset: 0; pointer-events: none; background: #05070b; animation: blackoutOut 560ms ease-out both; }
.game.transition-dissolve .bg-image { animation: dissolveIn 620ms ease both; }
/* 빛이 차오르는 씬 경계 (2026-08-16). 새 어휘를 만들지 않았다 — 시대 전환이 이미 쓰는
   `.white-veil` 판과 `eraVeilForward` 곡선을 그대로 빌려 **길이만** 1.1초로 줄인 것이다.
   왜 필요했나 — `c4-invitation-appears` 마지막 지문이 「두 사람은 **그 빛을 따라** 개천으로
   향한다」인데, 다음 씬(`q1-palm-setup`)으로 넘어가는 길이 CG hold-cut 하드컷이었다.
   글은 빛을 말하는데 화면은 툭 끊겼다.
   ⚠ `is-transitioning` 은 쓰지 않는다 — 그 클래스는 대사창·HUD 를 통째로 감춘다.
      여기서는 마지막 문장이 화면에 남은 채로 그 위에서 빛이 차올라야 한다.
   ⚠ `.transition-old/.transition-new` 는 **명시적으로 0** 으로 못박는다. 앞선 시대 전환이
      남긴 `--transition-bg` 와 `data-era` 가 그대로 붙어 있어서, 막만 켜면 옛 판이 뜬다. */
.game.transition-light-swell .transition-layer { opacity: 1; }
.game.transition-light-swell :is(.transition-old, .transition-new) { opacity: 0; }
.game.transition-light-swell .white-veil { animation: eraVeilForward 1100ms linear both; }
.game.hpunch :is(.backdrop,.character-stage) { animation: hpunch 250ms ease both; }
.letterbox { position: absolute; z-index: 75; inset: 0; opacity: 0; pointer-events: none; transition: opacity 500ms ease; }
.letterbox::before, .letterbox::after { content: ""; position: absolute; left: 0; right: 0; height: max(0px,calc((100% - min(120vw,560px))/2)); }
.letterbox::before { top: 0; background: linear-gradient(180deg,#05070b 0 52%,rgba(5,7,11,.82) 72%,transparent); }
.letterbox::after { bottom: 0; background: linear-gradient(0deg,#05070b 0 52%,rgba(5,7,11,.82) 72%,transparent); }
.game.is-decisive .letterbox { opacity: 1; }
.game.is-decisive .corner-hud, .game.is-decisive .scene-caption, .game.is-decisive .next-hint { opacity: 0 !important; pointer-events: none; }
.game.decisive-entering::after { content: ""; position: absolute; z-index: 78; inset: 0; pointer-events: none; background: #05070b; animation: decisiveReveal 3.2s ease both; }
.game.is-decisive .text-panel { text-align: center; }
/* ⚠ 2026-08-12 — 결정적 진입(위 `decisiveReveal 3.2s` 검은 홀드) 동안 **빈 대사 박스**를 띄우지
   않는다. engine `enterDecisive` 는 3.2초 홀드가 끝난 뒤에야 `showNode` 로 대사를 찍는데, 그 전까지
   `clearLayers` 가 패널을 `hidden=false`+빈 글자로 세워 둬 3.2초간 텅 빈 검은 상자가 그대로 보였다
   (실측 `artifacts/empty-probe/t2/a-page0.png`: c8-name-restored 3s 시점 빈 박스). 스윕·감사가 이걸
   "빈 말풍선"으로, 발주자가 "말풍선 안 보임/반짝임"으로 읽었다. is-transitioning·felt-active 와 같은
   방식으로 진입 중엔 감추고 — 홀드가 끝나 대사가 시작될 때(같은 틱에 클래스 해제→showNode) 180ms
   페이드로 글자와 함께 떠오르게 한다. 빈 상자가 노출되는 창을 없앤다. */
.game.decisive-entering :is(.text-panel, .dialogue-scrim) { opacity: 0; }
.game.is-decisive .speaker { display: none; }
.game.is-decisive .story-text { font-size: 19px; line-height: 1.75; }

/* Bad endings: no red, no judgement, only drained time. */
.game.is-bad-ending .backdrop { filter: saturate(.15) brightness(.68); transition: filter 1200ms ease; }
.game.is-bad-ending .bg-image { animation: badPush 4s linear both; }
.game.is-bad-ending :is(.ambient-particles i,.water-plane) { animation-play-state: paused; }
.game.is-bad-ending .corner-hud, .game.is-bad-ending .scene-caption { opacity: 0; transition: opacity 300ms ease; }
.quiet-ending { background: radial-gradient(circle at 9% 27%,rgba(5,7,8,.08),rgba(5,7,8,.64) 48%,rgba(5,7,8,.82)); overflow: hidden; cursor: default; }
/* ⚠ 2026-08-13 화면 통독에서 잡혔다 — 게이트도 로그도 못 잡는다.
   `.quiet-ending` 의 비네트는 화면 중앙(9% 27% 기준 .08)이 가장 옅은데, 이 글이 앉는 자리가
   바로 거기다. be2 는 밝은 분홍 치마가 그 자리에 오고, 글자가 **눈을 찌푸려야 읽혔다.**
   읽으려고 찌푸린 순간 감정이 끊긴다 — 나쁜 엔딩은 하객이 돌아가고 싶어져야 하는 화면인데
   글이 안 읽히면 돌아갈 이유가 안 전해진다.
   전체를 어둡게 깔면 그림이 죽으므로 **글 밑에만** 어두운 판을 깐다
   (마지막 청첩장에서 채택된 문법과 같다). 그림 밝기는 건드리지 않는다. */
.bad-ending-copy { position: absolute; left: 8vw; right: 8vw; top: 43%; margin: 0; padding: 16px 20px; border-radius: 12px; opacity: 1; transform: translateY(-50%); color: #f0e8da; background: rgba(4,7,9,.66); box-shadow: 0 0 30px 20px rgba(4,7,9,.52); text-align: center; font: 400 18px/1.75 "Noto Serif KR","Batang",serif; text-shadow: 0 2px 10px #000, 0 1px 2px #000; word-break: keep-all; }
.bad-ending-copy[data-has-copy="false"] { display: none; }
/* ⚠ 201줄이 `.choice-retry` 에 대해 적어 둔 것과 **같은 함정**이 여기에도 있었다 —
   밝은 플레이트 위에서 반투명 검정 버튼은 버튼으로 안 보인다. 같은 무게로 세운다. */
.ending-return { position: absolute; left: 50%; bottom: max(7vh,env(safe-area-inset-bottom)); min-width: 168px; min-height: 48px; padding: 10px 22px; border: 1px solid rgba(228,194,127,.62); border-radius: 999px; opacity: 0; translate: -50% 8px; color: #f8f0e1; background: rgba(9,14,17,.86); box-shadow: 0 10px 28px rgba(0,0,0,.34); font: 600 14px/1.5 system-ui,sans-serif; pointer-events: none; transition: opacity 520ms ease, translate 520ms ease, background 180ms ease; }
.ending-return[hidden] { display: none; }
.ending-return.is-visible { opacity: .84; translate: -50% 0; pointer-events: auto; }
.ending-return:focus-visible { outline: 2px solid #f0d49d; outline-offset: 3px; }
.ending-return:active { background: rgba(77,59,39,.72); }
.game.bad-returning .backdrop { filter: saturate(.88); transition: filter 900ms ease; }

/* Start and final frames. */
/* ⚠ 2026-08-05: 타이틀 화면이 **무지 암록 배경 + 버튼 2개**였다(frames/19-game-title.png).
   L&D 계열은 이 화면이 게임에서 그림이 제일 진한 곳이다 — CANON.md 1번 "최상위 결핍".
   포스터 키 아트(assets/ui/poster-key.webp)를 전면 배경으로 깔고 DOM 은 그대로 둔 채
   글자만 상단, 버튼만 하단으로 밀었다. 스크림 둘은 글자 가독성 때문이지 장식이 아니다. */
.start-layer { position: absolute; z-index: 100; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; overflow: hidden; padding: 7vh 9vw calc(5vh + env(safe-area-inset-bottom,0px)); color: #f7efe2; background: #091113; text-align: center; }
.start-layer[hidden] { display: none; }
.start-motion { position: absolute; z-index: 0; inset: 0; overflow: hidden; pointer-events: none; }
/* ⚠ 2026-08-14 독립 검수 A — 여기가 `scale(1.2)` 였고, 그 20% 확대가 **타이틀의 달을
   잘라 내고 있었다.** 첫 프레임과 끝 프레임이 같아 전 구간 지속되는 상태였다.
   확대의 목적은 아래 레이어들이 미세하게 움직일 때 가장자리가 비지 않게 하는 **오버스캔**이다.
   그런데 실제 이동량은 `js/title-motion-runtime.js:34` 기준 `atmosphereX = atmosphere * .72`
   — **최대 ±0.72px** 이고 물결도 ±1.35px 다. 390px 폭에서 1.2 배는 약 78px 의 오버스캔이라
   필요량의 오십 배가 넘었다. 1.02(≈8px)면 넉넉하다.
   ⚠ 다시 키우지 마라. 키우는 만큼 달이 먼저 잘린다. */
/* ⚠⚠ 2026-08-14 — **여기에 `transform` 을 두면 안 된다. 「타이틀 애니메이션이 없다」의 진짜 원인이었다.**
   `.start-motion img` 는 클래스+요소라 특정도 0-1-1 이고, 레이어별 규칙(`.start-motion-water-near` 등)은
   클래스 하나라 0-1-0 이다. **여기 있던 `transform: scale(1.02)` 가 레이어별 `translate3d(var(--title-…-x),…)`
   를 통째로 이겼다.** JS(`title-motion-runtime.js`)가 매 프레임 계산해 넣는 좌표가 **아무 데도 안 쓰였다.**
   그래서 기계는 「72/72 프레임이 다르다」고 답했지만(그건 opacity 변화였다) 화면은 정지 사진이었다.
   발주자가 몇 회차에 걸쳐 「애니메이션이 없다」고 한 것이 이것이다.
   ⚠ 크기는 레이어마다 따로 준다. 움직이는 레이어는 여유가 있어야 움직인다 — 아래 참조. */
.start-motion img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; transform-origin: 50% 50%; user-select: none; }
.start-motion-base { transform: scale(1.02); }
.start-motion-base { z-index: 0; }
/* ⚠ 2026-08-14 — 움직이는 셋만 `scale(1.02)` → `scale(1.12)`. **진폭과 한 쌍이다.**
   `object-fit: cover` 라 확대분이 곧 움직일 여유다. 375px 폭에서 1.02 는 한쪽 3.75px 뿐이고,
   `js/title-motion-runtime.js` 의 새 진폭(물빛 ±6.4px)은 그 여유를 넘는다 — 넘으면
   가장자리에 빈 모서리가 드러난다. 1.06 은 한쪽 11.25px. **한쪽만 되돌리지 마라.**
   `.start-motion-base` 는 안 움직이므로 1.02 그대로 둔다. */
.start-motion-atmosphere { z-index: 1; opacity: var(--title-atmosphere-opacity,.22); transform: translate3d(var(--title-atmosphere-x,0),0,0) scale(1.12); will-change: transform,opacity; }
/* ⚠ 2026-08-14 — **이것이 「타이틀에 애니메이션이 없다」의 진짜 원인이었다.**
   발주자가 두 번 지적했고, 총괄은 두 번 다 진폭만 올렸다(0.9px → 12.8px). 그래도 안 보였다.
   W4 워커가 찾은 것: **게임의 빛 96개는 전부 `mix-blend-mode: screen` 인데 타이틀만 일반 알파였다.**
   `lantern-glow.webp` 는 **검은 바탕 위의 갈색 고리**라(최대 α 54 = 21%), screen 없이는
   opacity 를 아무리 흔들어도 **빛이 아니라 얼룩**이 커졌다 작아진다.
   숫자를 키우는 것으로는 절대 안 풀리는 자리였다 — 합성 방식이 틀렸던 것이다. */
.start-motion-lanterns { z-index: 2; transform: scale(1.04); will-change: opacity; mix-blend-mode: screen; }
.start-motion-water { mix-blend-mode: screen; }
.start-motion-atmosphere { mix-blend-mode: screen; }
.start-motion-lanterns-left { opacity: var(--title-lantern-left-opacity,.44); clip-path: inset(0 50% 0 0); }
.start-motion-lanterns-right { opacity: var(--title-lantern-right-opacity,.44); clip-path: inset(0 0 0 50%); }
.start-motion-water { z-index: 3; will-change: transform,opacity; }
.start-motion-water-far { opacity: var(--title-water-far-opacity,.36); clip-path: inset(47% 0 33%); transform: translate3d(var(--title-water-far-x,0),var(--title-water-far-y,0),0) scale(1.12); }
.start-motion-water-near { opacity: var(--title-water-near-opacity,.38); clip-path: inset(60% 0 0); transform: translate3d(var(--title-water-near-x,0),var(--title-water-near-y,0),0) scale(1.12); }
/* ⚠ 2026-08-14 W8 — 타이틀 JS 는 reduce 에서 변수를 0 으로 고정한다(styles 만 고칠 수 있다).
   이미지 위 의사요소는 못 붙이므로(img) 스택 자체에 등불·수면 빛만 얹는다. 이동 없음.
   기준 모드에서도 JS 진폭이 눈 아래(실측 MAD 0.22)여서 광원을 심는다. 화면 전체를 밝히지 마라. */
.start-motion::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
  mix-blend-mode: screen;
  background:
    radial-gradient(18% 14% at 12% 72%, rgba(255, 186, 96, .46), transparent 72%),
    radial-gradient(16% 12% at 88% 64%, rgba(255, 176, 88, .38), transparent 72%),
    radial-gradient(28% 10% at 48% 86%, rgba(168, 206, 255, .28), transparent 74%);
  animation: titleLightWash 4.8s ease-in-out infinite;
}
@keyframes titleLightWash {
  0%, 100% { opacity: .38; }
  50% { opacity: .92; }
}
/* 위 스크림(타이틀 자리) · 아래 스크림(버튼 자리) — 포스터 위에 얹힌 글자를 읽히게 한다 */
/* ⚠ 2026-08-14 독립 검수 A — 위쪽 막이 `rgba(6,11,14,.78)` 로 시작해 **달을 눌러 버렸다.**
   달은 화면 오른쪽 위 약 7% 지점이고 제목 글자는 가운데다 — 즉 막이 필요한 곳은
   **가로 전폭이 아니라 글자 자리**뿐인데 전폭으로 덮고 있었다.
   위쪽 전폭 막을 낮추고(.78 → .52), 그만큼을 **제목 뒤 타원**으로 돌려준다.
   크레딧 화면에서 같은 처방이 통했다(`.credits-layer`) — 막을 걷고 글자에 대비를 준다.
   ⚠ 아래쪽 막은 그대로다. 거기엔 버튼 셋이 앉고 가릴 그림도 없다. */
.start-layer::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background:
    radial-gradient(ellipse 62% 17% at 50% 15%,rgba(6,11,14,.62),rgba(6,11,14,.28) 62%,rgba(6,11,14,0) 100%),
    linear-gradient(180deg,rgba(6,11,14,.52) 0%,rgba(6,11,14,.26) 26%,rgba(6,11,14,0) 44%),
    /* ⚠ 2026-08-14 — 아래 스크림이 **`water-near` 를 통째로 죽이고 있었다.**
       그 레이어는 `clip-path: inset(60% 0 0)` 로 화면 아래 40% 에만 사는데,
       스크림이 하단 38% 를 `.86` 로 덮어 **실측 변화량(MAD)이 0.000** 이었다.
       움직이라고 만든 레이어가 한 픽셀도 안 보이고 있었던 것이다.
       버튼 글자는 읽혀야 하므로 지우지 않고 **fade 를 28% 로 당긴다** —
       버튼이 앉는 자리(하단 ~20%)는 여전히 덮이고, 그 위 물빛은 살아난다. */
    linear-gradient(0deg,rgba(6,11,14,.86) 0%,rgba(6,11,14,.44) 14%,rgba(6,11,14,0) 28%);
}
.start-layer > * { position: relative; z-index: 1; }
.start-layer > .start-motion { position: absolute; z-index: 0; }
.start-layer::before { content: ""; position: absolute; inset: 18px; z-index: 1; border: 1px solid rgba(223,189,121,.26); border-radius: 48% 48% 8px 8px / 14% 14% 8px 8px; pointer-events: none; }
.start-layer p { margin: 0 0 14px; color: #e6c88d; font-size: 11px; letter-spacing: .28em; text-shadow: 0 2px 10px rgba(0,0,0,.85); }
.start-layer h1 { margin: 0 0 auto; font-size: clamp(28px,8vw,40px); font-weight: 400; line-height: 1.55; letter-spacing: .04em; text-shadow: 0 3px 18px rgba(0,0,0,.9),0 1px 3px rgba(0,0,0,.7); }
/* titlelife-1 (2026-08-18) — 비교판 추천(입장+반딧불). 진폭은 그대로, 밝기만 연다.
   기본 변수 0 = 지연 CSS가 오기 전 첫 페인트에서 글자가 번쩍이지 않게.
   JS(`title-motion-runtime.js`)가 봉투대로 연다. 미가동 시 8초 안전망. */
.start-layer {
  --title-ui-names: 0;
  --title-ui-title: 0;
  --title-ui-menu: 0;
  --title-ui-fine: 0;
}
.title-fireflies,
.title-night-veil {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
}
.title-fireflies { z-index: 5; }
.title-night-veil {
  z-index: 6;
  background: radial-gradient(circle at 76% 9%, transparent 1.4%, #05080a 4%);
  opacity: 1;
}
.start-layer > p { opacity: var(--title-ui-names, 1); transform: translateY(var(--title-ui-names-y, 0px)); }
.start-layer > h1 { opacity: var(--title-ui-title, 1); transform: translateY(var(--title-ui-title-y, 0px)); }
.start-layer > .start-menu { opacity: var(--title-ui-menu, 1); transform: translateY(var(--title-ui-menu-y, 0px)); }
.start-layer > small { opacity: var(--title-ui-fine, 1); }
@keyframes titleUiFailsafe { to { opacity: 1; transform: none; } }
.start-layer:not(.title-motion-on) > p,
.start-layer:not(.title-motion-on) > h1,
.start-layer:not(.title-motion-on) > .start-menu,
.start-layer:not(.title-motion-on) > small { animation: titleUiFailsafe 0.01s 8s forwards; }
.start-layer button { position: relative; width: 62%; min-height: 52px; border: 1px solid rgba(231,202,145,.55); border-radius: 999px; color: #f6e7ca; background: rgba(10,20,23,.8); font-weight: 650; box-shadow: 0 10px 28px -18px rgba(0,0,0,.9); }
.start-layer small { margin-top: 13px; color: rgba(255,255,255,.62); font: 400 11px/1.5 system-ui,sans-serif; text-shadow: 0 1px 6px rgba(0,0,0,.9); }
.start-layer.is-leaving { animation: irisOpen 700ms var(--ease-cinema) forwards; }

/* ── 결합 전 「준비 중」 표시(2026-08-16, titlebind-1) ─────────────────────────
   왜 있나 — 버튼은 0.6초에 뜨는데 손이 붙는 것은 3Mbps 에서 9.5초다. 그 사이 버튼은
   **다 준비된 것처럼 생겼는데 아무것도 안 한다.** 죽은 화면으로 읽히지 않게, 아직
   준비 중이라는 것을 **숨으로** 말한다. 새 글자·새 상자를 만들지 않는다(화풍 불변).
   ⚠ 0.9초 지연이 핵심이다 — 빠른 회선은 결합이 0.2초라 이 숨이 아예 안 보여야 한다.
      지연을 지우면 잘 되는 회선에서 없던 깜빡임이 생긴다.
   손잡이는 js/title-bind-early.js 가 건다(.is-preparing · [data-tb-state="queued"]). */
.start-menu.is-preparing button {
  animation: titleBindBreathe 2.6s ease-in-out .9s infinite;
}
@keyframes titleBindBreathe {
  0%, 100% { border-color: rgba(231,202,145,.55); box-shadow: 0 10px 28px -18px rgba(0,0,0,.9); }
  50%      { border-color: rgba(231,202,145,.82); box-shadow: 0 10px 28px -18px rgba(0,0,0,.9), 0 0 18px -4px rgba(233,178,94,.34); }
}
/* 큐에 들어간 버튼 — 「받았다」가 읽혀야 한다. 숨이 빨라지고 테가 밝아진다. */
.start-menu button[data-tb-state="queued"] {
  border-color: rgba(255,230,172,.9);
  background: rgba(30,44,44,.78);
  animation: titleBindQueued 1.1s ease-in-out infinite;
}
@keyframes titleBindQueued {
  0%, 100% { box-shadow: 0 10px 28px -18px rgba(0,0,0,.9), 0 0 14px -6px rgba(255,230,172,.45); }
  50%      { box-shadow: 0 10px 28px -18px rgba(0,0,0,.9), 0 0 26px -2px rgba(255,230,172,.62); }
}

/* ── 빌드 스탬프(2026-08-16) ─────────────────────────────────────────────────
   발주자 폰과 실험실이 **같은 판을 보고 있는지** 화면만으로 가리는 표식이다.
   ⚠ `#start-layer` 안에 있어야 한다 — 타이틀이 걷히면 같이 사라진다(게임 화면엔 없다).
   ⚠ 값은 index.html 의 정적 글자다. 스크립트가 아니라 **판을 굽는 사람**이 올린다. */
.start-build-stamp {
  position: absolute; z-index: 2;
  right: 10px; bottom: calc(6px + env(safe-area-inset-bottom, 0px));
  margin: 0;
  font: 400 8px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
  letter-spacing: .06em;
  color: rgba(214,205,190,.34);
  text-shadow: 0 1px 3px rgba(0,0,0,.9);
  pointer-events: none; user-select: none;
}

@media (prefers-reduced-motion: reduce) {
  /* 절전 모드에서도 「준비 중」은 말해야 한다 — 움직임 대신 **밝기 차이**로. */
  .start-menu.is-preparing button { animation: none; border-color: rgba(231,202,145,.34); }
  .start-menu button[data-tb-state="queued"] { animation: none; border-color: rgba(255,230,172,.9); }
  .start-motion :is(.start-motion-atmosphere,.start-motion-lanterns,.start-motion-water) { will-change: auto; }
  /* JS 가 변수를 멈춰도 레이어 불투명도만 숨 쉰다. transform 은 건드리지 않는다. */
  .start-motion-lanterns-left,
  .start-motion-lanterns-right {
    animation: titleLanternBreathe 4.8s ease-in-out infinite;
  }
  .start-motion-lanterns-right { animation-delay: -1.6s; }
  .start-motion-water-far { animation: titleWaterBreathe 5.6s ease-in-out infinite; }
  .start-motion-water-near { animation: titleWaterBreathe 4.2s ease-in-out infinite; animation-delay: -1.1s; }
  .start-motion-atmosphere { animation: titleHazeBreathe 6.4s ease-in-out infinite; }
  .start-motion::after { animation-duration: 4.8s; animation-iteration-count: infinite; }
  .start-layer {
    --title-ui-names: 1;
    --title-ui-title: 1;
    --title-ui-menu: 1;
    --title-ui-fine: 1;
  }
  .title-night-veil,
  .title-fireflies { opacity: 0 !important; }
}
@keyframes titleLanternBreathe {
  0%, 100% { opacity: .42; }
  50% { opacity: .86; }
}
@keyframes titleWaterBreathe {
  0%, 100% { opacity: .34; }
  50% { opacity: .72; }
}
@keyframes titleHazeBreathe {
  0%, 100% { opacity: .20; }
  50% { opacity: .40; }
}
.special-layer { background: transparent; }
/* 2026-08-13: 사진 전체를 누르는 단조 스크림은 얼굴과 종이의 정서를 같이 지운다. 전면 막은 .14만 남기고
   이름·일시와 안내/버튼 자리의 두 국소 타원만 보조한다. 뒤에서 로드되는 felt-interactions의 옛 그라디언트가
   되살아나지 않도록 .special-layer까지 묶은 선택자이므로, 이 명시도를 낮춰 전면 암막으로 되돌리지 않는다. */
/* ⚠ 2026-08-14 — 뒷판을 `cg-invitation-complete.webp`(완성된 청첩장을 든 손)로 바꾸면서
   흐림을 1px → 4px 로 올렸다. 그 판에는 「홍민의 · 정다희 / 2026. 10. 09. / 서울대 연구공원」이
   **인쇄돼 있고**, 그 위에 DOM 청첩장이 같은 내용을 다시 쓴다. 1px 에서는 두 글자가 서로 다른
   크기·각도로 겹쳐 **이중 인쇄**로 읽혔다. 글자를 녹이고 **빛과 손만** 남긴다.
   ⚠ 흐림을 다시 낮추지 마라 — 판을 바꾸지 않는 한 글자가 또 올라온다. */
.special-layer.final-invitation { background: rgba(5,8,10,.34); animation: invitationIn 900ms ease both; }
.special-layer.final-invitation .invitation-card { min-height: 100%; padding: calc(max(0px,(100vh - min(120vw,560px))/2) + 12px) 20px; background: radial-gradient(ellipse 62% 28% at 50% 36%,rgba(4,8,10,.22),transparent 100%),radial-gradient(ellipse 74% 38% at 50% 76%,rgba(4,8,10,.26),transparent 100%); }
/* 2026-08-13: 밝은 셔츠·얼굴이 어디로 움직여도 글자 바로 뒤는 최대 32%만 비치게 한다. 패딩으로 배치를
   바꾸지 않고 각 글자 상자와 부드러운 번짐만 어둡힌 것이므로, 이 판을 카드 전체 불투명 면으로 합치지 않는다. */
/* ⚠ 2026-08-13 독립 검수 E: 「e1 조판 불량」. 합성컷으로 보니 반투명 상자가 **다섯 개
   서로 다른 너비**로 쌓여 있었고, 날짜와 시각이 따로 노는 두 상자였다.
   원인은 `time` 이 **인라인 요소**라는 것이다 — 인라인 배경은 요소당 하나가 아니라
   **줄 조각마다 하나씩** 그려진다. `<time>날짜<br><b>시각</b></time>` 이 두 줄이라
   상자도 둘이 됐고, 각 상자가 제 줄 글자 폭에 맞춰져 너비가 제각각이 됐다.
   ⚠ 배경을 인라인 요소에 걸지 마라. 걸어야 하면 `display:block` 을 같이 줘라.
   너비를 하나로 묶어 다섯 조각이 **한 기둥**으로 읽히게 한다. */
/* ⚠ `display` 는 **`time` 에만** 준다. 처음엔 다섯에 다 `display:block` 을 걸었다가
   `.couple` 의 가로 배치(신랑 | 신부)가 세로로 무너졌다 — 그 요소는 제 배치를 갖고 있다.
   고치려던 것은 "인라인이라 줄마다 상자가 생기는 것"이고, 그건 `time` 하나의 문제였다. */
.final-invitation time { display: block; }
.final-invitation :is(.invitation-kicker,.couple,time,address,.final-narration) {
  box-sizing: border-box;
  width: min(100%, 350px); margin-inline: auto; padding: 7px 14px;
  border-radius: 10px; background: rgba(4,8,10,.72);
  /* 번지는 그림자를 줄인다 — 상자마다 12px 씩 번져서 경계가 뭉개져 있었다. */
  box-shadow: 0 0 14px 4px rgba(4,8,10,.44);
  text-shadow: 0 2px 10px rgba(0,0,0,.96),0 1px 2px #000;
}
.final-invitation .invitation-kicker { display: block; max-width: 350px; min-height: 4.8em; margin-bottom: 10px; color: #fff4df; font-size: 13px; line-height: 1.6; }
.final-invitation .invitation-card > :not(.invitation-kicker) { opacity: .28; transition: opacity 700ms ease; }
.final-invitation .invitation-card.is-ready > :not(.invitation-kicker) { opacity: 1; }
/* 2026-08-13: 고리 위치는 얼굴 회피를 위해 움직이지 않는다. 작은 반투명 후광만 두며, 여백 조정이나 큰 판으로
   바꾸면 이번 발주의 레이아웃 불변 계약을 깨므로 되돌리지 않는다. */
.final-invitation .double-ring { margin-bottom: 9px; border-radius: 999px; background: rgba(4,8,10,.28); box-shadow: 0 0 16px 10px rgba(4,8,10,.38); }
/* ⚠ 2026-08-13 독립 검수 D — 다섯 조각 중 **이름 행만** 중앙에서 왼쪽으로 밀려 있었다.
   원인: `.couple` 은 `display:flex` 인데(`game.css:424`) `justify-content` 가 없어
   기본값 `flex-start` 로 시작한다. 위 §391 이 슬래브를 350px 로 묶어 가운데 놓아도,
   **슬래브 안의 내용은 왼쪽에 붙는다.** 나머지 넷은 텍스트라 `text-align` 이 먹혀서
   가운데 있었고, 그래서 이 한 줄만 어긋나 보였다.
   ⚠ `text-align` 으로는 안 고쳐진다 — flex 자식의 가로 위치는 `justify-content` 가 정한다. */
.final-invitation .couple { justify-content: center; margin-bottom: 12px; }
.final-invitation .final-narration { margin-bottom: 11px; font-size: 12px; line-height: 1.65; }
.final-invitation .invitation-actions button { min-height: 44px; }
.final-invitation .couple span, .final-invitation address { color: #f4ecdf; }
.final-invitation .couple b, .final-invitation time, .final-invitation .final-narration { color: #fff7e8; }
/* 2026-08-13: 다섯 목적지는 사진을 완전히 숨기지 않는 유리판이되, 채움·금색 경계·입체 그림자를 함께 써서
   배경 속 선이 아니라 누를 물건으로 읽혀야 한다. 44px 터치 하한과 버튼 순서/문구는 절대 줄이거나 바꾸지 않는다. */
.final-invitation .invitation-actions button { color: #fff3df; background: rgba(4,9,11,.8); border-color: rgba(239,216,169,.78); box-shadow: inset 0 1px 0 rgba(255,255,255,.1),0 5px 16px rgba(0,0,0,.36); text-shadow: 0 1px 5px #000; }
.invitation-card::before { inset: 22px; }
/* 「도전하기」 아래 설명 한 줄(2026-08-14 검수 D — 단서가 없다). 이름은 발주자 표기라 그대로 둔다. */
.final-invitation .invitation-actions button { display: grid; gap: 3px; line-height: 1.35; }
.final-invitation .invitation-actions button small { color: rgba(247,236,214,.62); font-size: 10.5px; letter-spacing: .02em; }
/* ⚠ 2026-08-14 검수 D — 아치 테두리가 `rgba(230,203,150,.24)` 1px 이라 사진 위에서 거의 안 보였다.
   이 아치는 청첩장이 **인쇄물**로 읽히게 하는 유일한 테두리다. 금색을 올리고 안쪽에
   얇은 빛선을 하나 더 둬 종이 가장자리처럼 두께를 준다. */
.final-invitation .invitation-card::before {
  border-color: rgba(233,206,152,.46);
  box-shadow: inset 0 0 0 1px rgba(255,247,229,.09), 0 0 22px rgba(0,0,0,.28);
}
/* ⚠ 2026-08-13 독립 검수 E: 「그림 없는 단색으로 끝난다」. 사실이었다 — 하객이 끝까지 본
   보상으로 받는 마지막 화면이 근검정 그라데이션 한 장이었다.
   `data/presentation.js` 에 CG 선언을 걸 수는 없다: `js/engine.js:830` 이 이 씬을 가로채
   `renderCredits()` 의 DOM 화면으로 보내기 때문에 선언은 "배선돼 있다"는 거짓말만 남긴다.
   그래서 **뒷벽을 CSS 로 깐다** — 준비돼 있던 예식 홀 그림(빈 통로·꽃·드는 빛)이다.
   ⚠ 그림 위에 어둠막을 겹치는 순서를 지켜라. 크레딧 글자는 밝은 색이라 막이 없으면 안 읽힌다. */
.credits-layer {
  /* ⚠ 처음엔 어둠막을 전면에 두껍게 깔았다가 **홀이 침침해졌다.** 창에서 드는 빛과
     통로의 꽃이 그림의 전부인데 그것을 덮어 버린 것이다.
     어둡게 해야 하는 것은 화면이 아니라 **글자가 앉는 가운데 띠**다. 가장자리는 열어 둔다.

     ⚠ 2026-08-14 독립 검수 D — 한 번 더 좁혔다. 원판(`hall.webp`)과 화면을 나란히 놓고
       보니(`sheet-D-e2-plate-vs-screen.png`) 위 2/3 이 통째로 검었다: 천장·창·빛기둥이
       전부 사라지고 아래쪽 꽃과 마루만 남았다. 타원이 `66% 44%` 에 `.88` 이었다 —
       **크레딧 글자는 두 줄뿐인데 막은 화면만 했다.**
       글자 칸(`.credits-card dl` 은 `min(280px,80vw)`)만 덮도록 타원을 줄이고 농도를 낮췄다.
       그 대신 **글자 쪽에 그림자를 줘서** 막이 아니라 글자가 자기 대비를 갖게 했다
       (`styles/game.css` 의 `.credits-card` 규칙들). 막을 다시 두껍게 하려거든
       그림자부터 확인해라 — 두 곳이 한 쌍이다. */
  background:
    radial-gradient(ellipse 54% 30% at 50% 54%,rgba(6,10,11,.60),rgba(6,10,11,.26) 64%,rgba(6,10,11,0) 100%),
    linear-gradient(rgba(9,13,14,0),rgba(9,13,14,.10) 30%,rgba(9,13,14,.20)),
    url('../assets/cg/e2-credits-quiet/hall.webp') center/cover no-repeat,
    #090d0e;
  animation: none;
}

.time-frozen :is(.ambient-particles i,.water-plane,.bg-image,.stage-figure,.cg-plate,.cg-motion-layer) { animation-play-state: paused !important; }

@keyframes ambientKenBurns { from { translate: 0 0; } to { translate: 0 -1.5%; } }
@keyframes waterFlow { to { transform: translateX(4%); } }
@keyframes ambientDrift { from { transform: translate3d(0,0,0); opacity: .15; } to { transform: translate3d(var(--dx),var(--dy),0); opacity: .35; } }
@keyframes ambientRain { from { transform: translate3d(0,-18vh,0) rotate(10deg); opacity: .1; } to { transform: translate3d(-8vw,112vh,0) rotate(10deg); opacity: .33; } }
@keyframes ambientGlint { 0%,100% { opacity: .08; transform: translateX(-12px) scaleX(.5); } 50% { opacity: .35; transform: translateX(18px) scaleX(1.4); } }
@keyframes ambientCandle { 0%,100% { opacity: .16; transform: translateY(0) scale(.8,1.15); } 44% { opacity: .34; transform: translate(3px,-14px) scale(1.1,.85); } }
@keyframes ambientPetal { from { transform: translate3d(-4vw,-12vh,0) rotate(0); opacity: .14; } to { transform: translate3d(11vw,112vh,0) rotate(410deg); opacity: .32; } }
@keyframes sceneCaption { 0% { opacity: 0; transform: translateY(5px); } 20%,64% { opacity: .7; transform: none; } 100% { opacity: 0; } }
@keyframes progressBounce { 0%,100% { transform: translateY(-4px); } 50% { transform: translateY(0); } }
@keyframes choiceRise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes tutorialCaption { 0%,70% { opacity: 1; } 100% { opacity: 0; visibility: hidden; } }
@keyframes traceFieldEnter { from { opacity: 0; transform: translate(-50%,18%); } to { opacity: 1; transform: translate(-50%,0); } }
@keyframes temptation { 0% { opacity: 0; } 18%,72% { opacity: .75; pointer-events: auto; } 100% { opacity: 0; pointer-events: none; } }
@keyframes letterFlashGold { 0% { opacity: 0; transform: scale(.84); } 30%,58% { opacity: 1; transform: scale(1.06); } 100% { opacity: 0; transform: scale(1.16); } }
@keyframes riverCurrent { to { background-position: 0 96px; } }
@keyframes ripple { from { opacity: .82; transform: translate(-50%,-50%) scale(.5); } to { opacity: 0; transform: translate(-50%,-50%) scale(5.4); } }
@keyframes freezeFlash { from,to { filter: none; } 50% { filter: brightness(1.14); } }
@keyframes ringSnap { from { transform: translate(-50%,-50%) rotate(var(--angle)) scale(1.08); } to { transform: translate(-50%,-50%) rotate(var(--angle)) scale(1); } }
@keyframes ringComplete { 0% { transform: translate(-50%,-50%) rotate(0); } 55% { transform: translate(-50%,-50%) rotate(360deg) scale(1.05); filter: drop-shadow(0 0 16px #e8c269); } 100% { transform: translate(-50%,-50%) rotate(360deg); } }
@keyframes ringCompleteStandalone { 0% { rotate: 0deg; } 55% { rotate: 360deg; scale: 1.05; filter: drop-shadow(0 0 16px #e8c269); } 100% { rotate: 360deg; scale: 1; } }
@keyframes eraOldForward { 0%,10% { opacity: 1; transform: scale(1); filter: saturate(.88) sepia(.08); } 33%,50% { opacity: 1; transform: scale(1.06); filter: saturate(.35); } 51%,100% { opacity: 0; transform: scale(1.06); filter: saturate(.35); } }
@keyframes eraNewForward { 0%,20% { opacity: 0; transform: scale(1.06); filter: saturate(.35) brightness(.82); } 36% { opacity: .2; transform: scale(1.04); filter: saturate(.35) brightness(.82); } 50% { opacity: .34; transform: scale(1.03); filter: saturate(.35) brightness(.84); } 80% { opacity: 1; transform: scale(1); filter: saturate(.55) brightness(.9); } 100% { opacity: 1; transform: scale(1); filter: saturate(1.05) brightness(1); } }
@keyframes eraVeilForward { 0%,20% { opacity: 0; } 36.7%,50% { opacity: .7; } 80%,100% { opacity: 0; } }
@keyframes eraOldReverse { 0% { opacity: 1; transform: scale(1); filter: saturate(1.05); } 45% { opacity: 1; transform: scale(1.06); filter: saturate(.35); } 46%,100% { opacity: 0; } }
@keyframes eraNewReverse { 0%,45% { opacity: 0; transform: scale(1.06); filter: saturate(.35); } 100% { opacity: 1; transform: scale(1); filter: saturate(.88) sepia(.08); } }
@keyframes eraVeilReverse { 0% { opacity: 0; } 36%,58% { opacity: .7; } 100% { opacity: 0; } }
@keyframes blackoutCut { 0%,18% { opacity: 0; } 48%,58% { opacity: 1; } 100% { opacity: 0; } }
@keyframes blackoutIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes blackoutOut { 0%,16% { opacity: 1; } 100% { opacity: 0; } }
@keyframes dissolveIn { from { opacity: .2; } to { opacity: 1; } }
@keyframes decisiveReveal { 0% { opacity: 0; } 15.625%,37.5% { opacity: 1; } 62.5%,100% { opacity: 0; } }
@keyframes hpunch { 0%,100% { translate: 0 0; } 25% { translate: -4px 0; } 50% { translate: 4px 0; } 75% { translate: -3px 0; } }
@keyframes badPush { from { transform: scale(var(--camera-scale,1)); } to { transform: scale(calc(var(--camera-scale,1) * 1.03)); } }
@keyframes badCopy { to { opacity: 1; } }
@keyframes returnHint { to { opacity: .45; } }
@keyframes irisOpen { from { clip-path: circle(100% at 50% 50%); opacity: 1; } to { clip-path: circle(0 at 50% 50%); opacity: 0; } }
@keyframes irisClose { from { clip-path: circle(0 at 50% 50%); } to { clip-path: circle(110% at 50% 50%); } }
@keyframes invitationIn { from { opacity: 0; transform: scale(1.03); } to { opacity: 1; transform: none; } }

@media (min-width:700px) {
  .corner-hud, .transition-layer, .letterbox { left: 50%; width: min(100%,520px); transform: translateX(-50%); }
}

@media (max-height:620px) {
  /* 낮은 화면에서도 갈리지 않는다 — 바닥 하나만 조금 당긴다. */
  .game { --dialogue-bottom: calc(var(--safe-bottom) + 3vh); }
  .text-panel.long-copy { max-height: 28vh; }
  .stage-figure { height: 70vh; bottom: -14vh; }
}

@media (prefers-reduced-motion:reduce) {
  /* 큰 이동·줌·흔들림·비/꽃잎 낙하만 끈다. 시대 전환 크로스페이드(불투명도)는 산다. */
  .bg-image, .ambient-particles i, .water-plane, .stage-figure, .next-hint::before { animation: none !important; }
  .game.hpunch :is(.backdrop, .character-stage) { animation: none !important; }
  .stage-figure, .bg-image { transition-duration: 1ms !important; transition-delay: 0ms !important; }
  .choice-button { transition-duration: 180ms !important; transition-delay: 0ms !important; }
  /* 2026-08-13: 모션 축소에서는 이동/페이드 시간만 없애고 is-ready 공개 순서는 유지한다. 일반 모드의
     invitationIn과 본문 등장 계약까지 삭제하거나 display 전환으로 바꾸지 않는다. */
  .final-invitation { animation: none !important; }
  .final-invitation .invitation-card > :not(.invitation-kicker) { transition-duration: 200ms !important; transition-delay: 0ms !important; }
  .game.era-forward .transition-old, .game.era-reverse .transition-old { animation: reducedOld 700ms ease both !important; }
  .game.era-forward .transition-new, .game.era-reverse .transition-new { animation: reducedNew 700ms ease both !important; }
  .game.era-forward .white-veil, .game.era-reverse .white-veil { animation: reducedVeil 700ms ease both !important; }
  .game.decisive-entering::after { animation-duration: 700ms !important; }
  .game.transition-black-in::after, .game.transition-black-out::after { animation-duration: 1ms !important; }
  .game.transition-light-swell .white-veil { animation: reducedVeil 400ms ease both !important; }
}
@keyframes reducedOld { from { opacity: 1; } to { opacity: 0; } }
@keyframes reducedNew { from { opacity: 0; } to { opacity: 1; } }
@keyframes reducedVeil { from { opacity: 0; } 50% { opacity: .7; } to { opacity: 0; } }
