/* Central screen-transition layer. Runtime mutates only transform and opacity. */
.stx-surface {
  pointer-events: none !important;
  will-change: transform, opacity;
}
/* The LEAVING screen is the only one that needs forcing: once swap() moves body[data-screen] on, CSS would
   hide it (`.screen { display:none }`), so it's lifted out of flow to keep playing its exit above the arriving
   screen. Its HEIGHT is pinned inline by fx/screenTransitionFx.js to whatever it measured in normal flow —
   `inset: 0` used to squeeze it to viewport height instead, and these screens are TALLER than the visual
   viewport (measured 2026-07-30: #map's box is 1016px tall on an 844px viewport, because of --app-h and the
   HUD/nav bands). Squeezing re-laid-out the screen mid-transition. */
.stx-outgoing {
  position: fixed !important;
  left: 0;
  top: 0;
  width: 100%;
  z-index: 120;
  display: flex !important;
}
/* The ARRIVING screen is already the active screen by the time it gets this class (reveal() runs after
   swap()), so CSS shows it naturally — it must NOT be re-boxed. It only needs to stack above the leaving
   overlay. Giving it `position:fixed; inset:0` was the cause of the arrival jump: everything inside it
   (map nodes included) sat at a squeezed-layout position during the transition and snapped ~156px when the
   class came off at cleanup — which also aimed the shared-subject clone at a place the node never stayed. */
.stx-incoming { position: relative; z-index: 121; }
/* ⚠️ **이 규칙은 2026-08-14에 삭제됐다 — 되살리지 말 것.**
   옛 규칙: `.stx-family-shared-axis.stx-incoming { opacity: 0 }`
   옛 근거: "JS가 나가는 화면을 swap 전에 0으로 지우므로, 새로 커밋된 HUD·카드가 한 프레임 불투명하게
   튀는 것을 막는다." — 그 전제(나가는 화면을 미리 0으로 지운다)가 **오너 지시로 없어졌다**
   (fx/screenTransitionFx.js의 endOpacity 주석: 사라짐을 「걷기」로 옮겨 두 화면을 겹친다).
   전제가 사라진 뒤 이 줄만 남으니 **양쪽이 동시에 자리를 비웠다** — 나가는 화면은 18vw 옆으로
   비키는데 들어오는 화면은 투명이라, 밀린 자리가 그대로 드러났다.
   실측(맵→옷장, 프레임 단위): 8~291ms 동안 21프레임에서 화면 옆이 비었고 빈 폭이
   17 → 32 → 44 → 52 → 58 → 61px로 벌어졌다. 그동안 보이는 화면은 map 하나뿐.
   오너 신고 "중간에 여백이 드러난다"의 정체가 이것이다(가운데는 계속 덮여 있어 가운데만 재면 안 잡힌다).
   ⚠️ 되살리려거든 **가장자리 폭**을 재라 — 가운데 기준 검사는 이 결함을 통과시킨다. */
.stx-family-utility.stx-outgoing { z-index: 120; }
.stx-family-utility.stx-incoming { z-index: 122; }
.stx-trigger-clone {
  position: fixed;
  z-index: 123;
  margin: 0 !important;
  pointer-events: none !important;
  transform-origin: center;
  will-change: transform, opacity;
}
/* Win return banks one earned star instead of stretching the settlement card into a map tile. The ring is
   attached to that semantic token, not ambient decoration: it travels with the star and disappears when
   the star seats into the node. */
.stx-trigger-clone.stx-reward-token {
  overflow: visible !important;
  filter:
    drop-shadow(0 0 5px rgba(255, 248, 196, .95))
    drop-shadow(0 0 13px rgba(255, 205, 72, .8)) !important;
}
.stx-trigger-clone.stx-reward-token::after {
  content: "";
  position: absolute;
  inset: -7px;
  border: 2px solid rgba(255, 231, 132, .82);
  border-radius: 50%;
  box-shadow: 0 0 12px rgba(255, 205, 72, .58);
  pointer-events: none;
}
/* 2026-07-30: replaced the debug-ring outline with a short warm glow (Quiet Canvas gold family, not a new
   color — rgba mirrors --gold #ffe07a in styles/app/01-base-tokens.css; kept literal because plain rgba()
   can't pull an alpha-adjusted channel out of a var() without color-mix(), which has no precedent elsewhere
   in this codebase). Self-fades on its own timeline so game/screenTransition.js never has to time its removal. */
.stx-target {
  outline: 0 solid rgba(255, 224, 122, 0);
  outline-offset: 0;
  animation: stxTargetGlow 520ms ease-out both;
}
@keyframes stxTargetGlow {
  0%   { outline-width: 0; outline-color: rgba(255, 224, 122, 0); outline-offset: 0; }
  35%  { outline-width: 3px; outline-color: rgba(255, 224, 122, .65); outline-offset: 3px; }
  100% { outline-width: 1px; outline-color: rgba(255, 224, 122, 0); outline-offset: 8px; }
}
/* The returning stage seal seats into the irregular stone tile. An outline is intentional here: map nodes
   force `box-shadow:none !important`, so the old shared target glow was completely invisible on the exact
   route that needed it most. */
.node.stx-target-depth.stx-target-back {
  outline-style: solid;
  border-radius: 16%;
}
/* The map must not show the stored stars before the flying reward arrives. Swap the token for the node's
   normal star row at transition cleanup, so the last frame reads as "earned here → banked here" instead of
   two simultaneous copies of the same reward. Board-only returns never receive this class. */
.node.stx-target-reward .ns {
  visibility: hidden !important;
}
html[data-motion="reduced"] .stx-target {
  animation: none;
  outline: 3px solid rgba(255, 224, 122, .55);
  outline-offset: 3px;
}
/* 2026-07-30: the opaque body[data-transition-state]::after input-blocking layer (and the pointer-events:none
   it forced on .titleNav/#titleSettings so it could sit above them) is gone. It did its own hit-testing, so
   the app never learned what the player was pressing — a real tap during a transition just vanished. Blocking
   now lives in game/screenTransition.js as a document-level capture-phase guard: it still blocks every
   pointerdown/click while a transition is active, but for an allow-listed queueable surface (bottom-nav/menu
   buttons, map nodes, daily pieces, settings open/close) it remembers the press and replays one synthetic
   click once the transition lands. That guard needs real hit-testing on those surfaces, so pointer-events
   stays enabled on them here — blocking is JS-only now, not CSS. .titleNav keeps its stacking bump so it
   still renders above the transition overlay layers. */
body[data-transition-state] .titleNav { z-index: 124 !important; }
html[data-motion="reduced"] .stx-trigger-clone { display: none; }

/* Forward-clear experiment: a short first-person walk advances into the jungle before the earned-star clone
   lands on the map. The bridge is below the departing win surface during cover, then rises above the arriving
   map only long enough to hide the route swap. */
.journeyWalkFx {
  position: fixed;
  inset: 0;
  z-index: 119;
  overflow: hidden;
  pointer-events: none;
  contain: strict;
  background-color: #17110c;
  background-position: center;
  background-size: cover;
  isolation: isolate;
}
.journeyWalkFx.journeyWalkMapPhase { z-index: 122; }
.journeyWalkCamera {
  position: absolute;
  inset: 0;
  z-index: 0;
  transform-origin: 50% 46%;
  backface-visibility: hidden;
  will-change: transform;
}
.journeyWalkScene {
  position: absolute;
  inset: -9%;
  width: 118%;
  height: 118%;
  display: block;
  object-fit: cover;
  object-position: 50% 52%;
  transform-origin: 50% 46%;
  backface-visibility: hidden;
  will-change: transform;
}
.journeyWalkFx[data-scene="forest_deep"] .journeyWalkScene { object-position: 50% 55%; }
.journeyWalkFx[data-scene="ruins"] .journeyWalkScene { object-position: 50% 50%; }
/* 2026-08-12 추가분 — 둘 다 길이 화면 아래쪽에서 안으로 뻗는 구도라 아랫부분을 보여 준다. */
.journeyWalkFx[data-scene="stone_steps"] .journeyWalkScene { object-position: 50% 62%; }
.journeyWalkFx[data-scene="falls"] .journeyWalkScene { object-position: 50% 58%; }
/* ── 걷기 화면의 밝기를 도착지(맵)에 맞춘다 (오너 2026-08-04 "밝기 격차를 좁혀") ────────────────
   발단 = 오너 보고 "정글 워킹 후 맵으로 전환될 때 어떤 단말기에서는 화면이 번쩍 빛나 눈이 피로하다".

   실측(에셋 중앙부 밝기, 0~255): 걷기 forest 178 · forest_deep 194 · ruins 154  ↔  **맵 배경 119**.
   걷기 화면이 도착지보다 **1.3~1.6배 밝다.** 어두운 승리 화면 → 밝은 정글 → 어두운 맵 순서라
   가운데서 밝기가 솟았다 꺼지고, 그게 「번쩍」으로 읽힌다. 게다가 이 다리는 **평범한 클리어마다**
   지나가므로(game/levelEntry.js) 피로가 누적된다.

   ⚠️ 원인은 그늘의 **분배가 거꾸로**였다는 것이다: 아래 비네트는 가운데 34~42%를 **일부러 완전히
   비워** 두는데(시선이 가는 곳을 안 가리려고), 정작 **가장 밝은 `forest_deep`에는 전용 그늘이 아예
   없어 기본값을 쓰고**, 가장 어두운 `ruins`만 강한 비네트를 갖고 있었다.

   그래서 비네트는 그대로 두고(가장자리 연출은 이 다리의 정체성이다) **균일 스크림 한 장**을 장면별
   세기로 깐다. 균일하게 깔아야 가운데도 함께 내려간다 — 비네트를 조여서 해결하려 하면 가장자리만
   시커메지고 정작 문제인 중앙은 그대로다.
   ⚠️ `filter: brightness()`로 하지 않았다. 전체 화면 이미지에 필터를 걸면 매 프레임 재합성이라
   **가장 피해가 큰 저사양 기기(fx/perf.js `lite`)에서 가장 무겁다.** 그라디언트 레이어는 이미 합성
   중인 것에 한 장 더 얹을 뿐이라 공짜에 가깝다.
   ⚠️ 에셋(assets/outing/bg/*)은 건드리지 않았다 — 「소풍」이 같은 그림을 쓴다. 여기 그늘만 손댄다.

   ── 2026-08-13 실측: 「걷기 배경이 물이 빠져 보인다」는 신고에 대한 답 (오너 확인 후 **그대로 두기로 결정**) ──
   오너 신고: *"정글 워킹 할 때 배경 이미지가 원래의 색보다 물이 빠져서 채도가 낮게 보여."*
   ⚠️ **체감은 사실이지만 원인은 채도가 아니라 밝기다.** 원본에 위 스크림을 실제로 합성해 재 봤다
   (ImageMagick, 색의 순도 = HSB 채도):
     forest       밝기 142 → 112 (−21%) · 순도 32.5% → 32.3% (**−1%**)
     forest_deep  밝기 159 → 113 (−29%) · 순도 30.3% → 29.9% (**−1%**)
     ruins        밝기 159 → 143 (−10%) · 순도 30.5% → 30.3% (**−1%**)
     stone_steps  밝기 129 → 122        · 순도 44.8% → 44.8% (0%)
     falls        밝기 124 → 118        · 순도 31.5% → 31.6% (0%)
   즉 색은 거의 그대로 있고 **어두워졌을 뿐**인데, 눈은 어두운 그림을 「색이 덜 선명하다」로 읽는다.
   그리고 그 어두움은 결함이 아니라 위 2026-08-04 결정이 **의도대로 일하고 있는 것**이다(도착지 맵 119에
   맞춰 137~140을 목표로 낮췄다). 밝기를 올리면 그때 고친 「맵 도착에서 화면이 푹 꺼진다」가 돌아온다.
   ⚠️ **HSL 채도로 재지 말 것** — 그 지표는 밝기가 내려가면 같이 떨어져(−28~−48%로 나온다) "채도가
      빠졌다"는 **거짓 결론**을 준다. 색이 실제로 빠졌는지는 **HSB 채도**로 재라.
   ⚠️ 진짜로 색이 빠지는 경우는 **안개 하나뿐**이다(아래 `data-weather="fog"`의 중립 회색 베일):
      forest 기준 순도 32.3% → 23.6%(−27%). 그건 의도된 것이다 — 그 규칙 옆 주석 참조.
   ⚠️ `stone_steps`·`falls`는 처지가 다르다 — 에셋 자체가 이미 감마 1.55 + 채도 118%로 보정돼 있어
      그늘이 6%뿐이고 애초에 눌림이 거의 없다. 세 장면과 같은 잣대로 비교하지 말 것. */
.journeyWalkShade {
  position: absolute;
  inset: 0;
  z-index: 2;
  --walk-dim: .24;   /* forest(178) → 목표 ~140 */
  background:
    linear-gradient(180deg, rgba(20,32,20,.1), transparent 27%, transparent 68%, rgba(18,24,14,.22)),
    radial-gradient(ellipse 66% 58% at 50% 50%, transparent 42%, rgba(18,27,16,.12) 74%, rgba(11,17,10,.46) 100%),
    linear-gradient(rgba(11,17,10,var(--walk-dim)), rgba(11,17,10,var(--walk-dim)));
}
/* 가장 밝은 장면 = 가장 센 스크림. 옛 코드에 이 선택자가 **없었던 것**이 이 결함의 핵심이었다. */
.journeyWalkFx[data-scene="forest_deep"] .journeyWalkShade { --walk-dim: .32; }   /* 194 → ~140 */
/* 2026-08-12 추가분. 원본이 112·116으로 **가족(162~184)보다 훨씬 어두웠다** — 그대로 쓰면 그늘을
   씌우기는커녕 밝기 바닥(119)을 뚫는다. 그래서 에셋 자체를 감마 1.55 + 채도 118%로 올려 148·152로
   맞췄고(원본은 스크래치패드 백업), 여기서는 남은 미세 조정만 한다.
   실측 합성(시간대×날씨 12조합): stone_steps 122~142 · falls 122~144 — 둘 다 밴드(119~147) 안. */
.journeyWalkFx[data-scene="stone_steps"] .journeyWalkShade { --walk-dim: .06; }   /* 148 → ~142 */
.journeyWalkFx[data-scene="falls"] .journeyWalkShade { --walk-dim: .06; }         /* 152 → ~144 */
.journeyWalkFx[data-scene="ruins"] .journeyWalkShade {
  --walk-dim: .11;                                                                /* 154 → ~139 */
  background:
    linear-gradient(180deg, rgba(40,43,24,.2), rgba(36,37,22,.1) 48%, rgba(19,25,15,.3)),
    radial-gradient(ellipse 64% 56% at 50% 50%, transparent 34%, rgba(24,29,17,.2) 70%, rgba(11,17,10,.52) 100%),
    linear-gradient(rgba(11,17,10,var(--walk-dim)), rgba(11,17,10,var(--walk-dim)));
}
/* ── 날씨 스킨 (2026-08-05 · 시간대는 08-12에 폐지, 아래 그 자리 주석 참조) ──────────────────────
   계약: `.journeyWalkFx` 루트에 `data-weather`(clear/overcast/rain/wet/fog)가 `data-scene`과 같은
   자리에 붙는다.
   ⚠️ **이 스킨은 두 곳이 쓴다** (오너 2026-08-10): 걷기 다리와 **발견 창**(`.wfSky`, styles/app/
   14-walk-find.css). 걷던 하늘이 발견 창까지 이어져야 「걷다가 멈췄다」가 유지되기 때문이다. 그래서
   아래 선택자는 `:is(.journeyWalkFx, .wfSky)`로 열려 있다 — 색을 고칠 자리는 계속 **여기 하나뿐**이고,
   판정(허용 목록)도 `fx/journeyWalkFx.js`의 `resolveSky` 하나뿐이다. 발견 창 쪽에 그라디언트를 베끼면
   한쪽만 고쳐도 아무 검사에 안 걸린다.
   ⚠️ 예외 둘은 일부러 `.journeyWalkFx` 전용으로 남겼다 — 저사양 대체(`.journeyWalkLite`)와 맨 아래
   감속 `display:none`. 발견 창엔 lite 판정이 없고, **감속에서도 발견 창은 떠야 한다**(안 뜨면 주운
   조각이 조용히 사라진다 — fx/screenTransitionFx.js 주석). 속성이 없으면 아래 선택자는 하나도 매치되지
   않는다 — 그게 되돌리기 스위치다(값이 허용 목록 밖이면 호출부가 애초에 속성을 안 붙인다).

   ⚠️ 밝기 원칙은 위 2026-08-04 커밋과 동일하게 승계한다 — 걷기 화면은 방금 도착지 맵(밝기 119)에
   맞춰 낮춰졌다(장면별 목표 ~137~140). 이 블록의 모든 색은 **그 목표보다 밝아지면 안 된다**: 시간대·
   날씨 오버레이는 항상 "더 어둡게"만 움직인다. 골라 쓴 색은 상대휘도(luma = .3R+.59G+.11B)가 목표
   이하가 되도록 계산해 뒀다(비의 가는 줄기 1건만 예외 — 그 이유는 해당 규칙 옆에 적었다).
   `filter: brightness()`/`saturate()`는 여전히 쓰지 않는다 — 화면 전체 이미지에 필터를 걸면 매 프레임
   재합성이라 저사양 기기에서 가장 무겁다(2026-08-04 커밋과 같은 이유). 그 대신 이미 합성 중인 레이어
   위에 그라디언트 한 장을 더 얹는 방식만 쓴다.

   구조: `.journeyWalkShade`(그늘, z-index 2) 위에 의사요소 두 장만 추가한다 — 시간대 `::before`
   (z-index 3, 장면 전체의 광원/색온도) · 날씨 `::after`(z-index 4, 대기 중의 층 — 안개·비는 광원보다
   앞에 있어야 자연스럽다). 새 DOM 노드도 새 JS도 없다. 시간대는 세 장면 공통 색조 하나만 쓴다 —
   "색조와 그늘 세기만으로"라는 요구가 장면별 분기까지 요구하지는 않는다. */
/* ── 시간대(아침/낮/해질녘/밤) — **2026-08-12 폐지** (오너) ─────────────────────────────────────
   원문: "몇 번을 개선해도 체감 변화가 없어. 디자인 만족도만 플레이어에게 떨어트릴 뿐이야."
   같은 날 세 번 고쳤다: 균일 색조 → 밝기 바닥 신설 → 지평선 빛·하늘색·별 추가. 그런데도 「시간」이
   아니라 「그림이 탁하다」로만 읽혔다. 즉 실패한 것은 구현이 아니라 **전제**다 — 한 장의 정지 배경
   위에 덮개를 얹는 방식으로는 시간을 말할 수 없다. 사람은 시간을 **비교**로 읽는데(같은 장소의 다른
   시간), 배경이 한 장뿐이면 비교 대상이 없어 그냥 「어두운 그림」이 된다.
   ⚠️ **덮개 조정으로 다시 시도하지 말 것 — 이미 세 번 했다.** 되살리려면 시간대별 **배경 그림**이
      필요하다(에셋 작업). 그때는 이 자리가 아니라 `SCENES`(fx/journeyWalkFx.js)를 늘리는 일이 된다.
   ⚠️ 날씨는 남는다. 날씨는 「지금 무슨 일이 일어나는가」라 덮개로도 읽히지만, 시간은 「지금 몇 시인가」라
      비교 없이는 알 수 없다 — 그 차이가 성패를 갈랐다.
   함께 사라진 것: `SKY_TIMES`·`journeyTimeFor`·`dataset.time`, 그리고 사건의 `times` 조건.
   반딧불이는 조건이 [dusk,night]이었어서 그대로 뒀으면 **영영 안 나오는 사건**이 될 뻔했다 →
   날씨(overcast/fog = 어둑한 날)로 옮겼다(game/levelEntry.js `WALK_EVENTS`). */

:is(.journeyWalkFx, .wfSky)[data-weather]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
}
/* clear = 기준점 — 의도적으로 오버라이드가 없다. */
/* ⚠️ **`overcast`(흐림)는 2026-08-12에 폐지됐다 — 오너.** 그 연출은 `rgba(112,114,108,.30)` 회색 한
   겹을 화면 전체에 **완전히 균일하게** 덮는 것뿐이었다(움직임 0 · 모양 0). 같은 날 폐지한 시간대와
   **똑같은 실패 구조**다 — 채널이 색 하나뿐이면 「날씨」가 아니라 「그림이 탁하다」로 읽힌다.
   비가 인지되는 이유는 움직임과 **빗줄기라는 명확한 형태**를 함께 갖기 때문이고, 흐림에는 그만한
   형태가 없다(구름은 화면 밖 하늘에 있다). 되살리려면 색이 아니라 **형태와 움직임**부터 정할 것.
   고르는 쪽도 함께 지웠다(game/levelEntry.js `WEATHER_CYCLE`·`SKY_WEATHERS`). */
:is(.journeyWalkFx, .wfSky)[data-weather="fog"]::after {
  /* 흰 그라데이션 + 대비↓ — 진짜 흰색(상대휘도 200대)은 밝기 원칙을 어기므로 쓰지 않는다. 대신
     목표 밝기와 거의 같은(상대휘도 132) 중립 회색을 바닥 쪽에 짙게, 위로 갈수록 옅게 깔아 "낮게
     깔린 안개"로 읽히게 한다 — 절대 밝기를 넘기지 않아도 숲의 초록·갈색보다 훨씬 무채색이라 안개로
     보인다(대비를 낮추는 것 = 색을 중립으로 수렴시키는 것).
     ── 2026-08-12: **움직임을 얹었다** (오너 "안개만 움직임을 줘") ────────────────────────────
     세로 결(아래가 짙다)만으로는 「뿌옇다」에 그쳤다 — 정지 화면이라 그렇다. 옆으로 **흘러야**
     안개가 된다. 그래서 가로로 반복되는 옅은 결을 한 겹 더 깔고 그 층을 통째로 민다.
     ⚠️ 세로 결은 가로로 밀어도 **아무 변화가 없다**(`0deg` 그라디언트는 가로로 균일하다). 그래서
        움직이는 것은 새로 깐 가로 결뿐이고, 「낮게 깔린」 성질은 그대로 남는다. 이게 두 겹으로
        나눈 이유다 — 한 겹으로 만들면 미는 순간 아래위 농도까지 같이 흘러 어색해진다.
     ⚠️ 이동 거리 = 가로 결의 **한 주기(180px)와 정확히 같아야** 이음매가 안 보인다. 주기를 바꾸면
        `@keyframes`의 이동량도 같이 바꿀 것(비가 같은 규칙을 쓴다 — 위 `journeyWalkRainFall` 주석).
     ⚠️ 속도: 걷기가 ~2초뿐이라 아주 느리면 정지로 보인다. 5초에 한 주기(36px/s)면 그 2초에 72px가
        흘러 「움직인다」가 읽히면서도 비(570px/s)와 성격이 갈린다. */
  inset: 0 -60%;   /* 가로 이동(180px) 중에도 빈 가장자리가 드러나지 않는 여유 */
  background:
    repeating-linear-gradient(90deg,
      rgba(150,148,142,0) 0px, rgba(150,148,142,.15) 90px, rgba(150,148,142,0) 180px),
    linear-gradient(0deg,
    rgba(134,132,126,.58) 0%, rgba(134,132,126,.34) 45%, rgba(134,132,126,.10) 78%, transparent 100%);
  animation: journeyWalkFogDrift 5s linear infinite;
  will-change: transform;
}
/* 이동량 = 가로 결의 한 주기(180px). 이 등식이 이음매 무결성의 전부다 — 주기를 바꾸면 여기도 바꿀 것. */
@keyframes journeyWalkFogDrift {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-180px, 0, 0); }
}
/* 감속 모드에서는 흐르지 않는다 — 안개는 「분위기」라 움직임을 빼도 뜻이 남는다(세로 결은 그대로).
   ⚠️ `background`는 건드리지 않는다. 여기서 배경까지 되돌리면 가로 결이 사라져 멈춘 채로 밋밋해진다. */
html[data-motion="reduced"] :is(.journeyWalkFx, .wfSky)[data-weather="fog"]::after { animation: none; }
/* 비 — 가늘고 옅은 빗방울 「마디」가 제 궤적을 따라 떨어진다 (2026-08-05 재작업. 오너 반려
   "비처럼 안 보이고 어색한 줄이 화면을 가린다" 대응. 여긴 승리의 여운 — 분위기지 볼거리가 아니다).
   옛 구현의 결함 3가지(전부 계산으로 확인된 사실):
   ① 이음매 불일치 — 한 바퀴 이동량의 그라디언트 축 투영(249.7px)이 주기(108px)의 정수배가 아니라
      0.5s 루프마다 눈에 보이게 점프했다(초당 2회 「툭」).
   ② 방향 어긋남 — 줄기는 수직에서 24° 기울었는데 이동은 6.2°. 근본 원인: repeating-linear-gradient는
      무한 평행선이라 줄기 방향으로 밀면 패턴이 전혀 안 변한다(수학적 불변). 즉 무한 줄로는 낙하를
      표현할 수 없고, 원작자는 그래서 어긋난 방향으로 밀 수밖에 없었다 → 옆으로 미끄러져 보였다.
   ③ 7px 굵기 · 0→.30 급전환 경계 · 30% 불투명 = 비가 아니라 줄무늬 벽지("줄이 화면을 가린다").
   해법: 줄기(배경 그라디언트)를 줄기 방향 축의 반복 마스크(주기 120px)로 유한한 마디로 끊고, 요소를
   **정확히 줄기 방향으로** 민다. 배경 줄무늬는 그 방향에 불변이라 마디만 흘러 = 빗방울이 제 궤적을
   따라 떨어지는 것으로 보인다. 이때 이음매 조건은 마스크 주기에 대해서만 지키면 된다.
   수치 근거(좌표 x→오른쪽·y→아래, 그라디언트 축 = (sinθ, −cosθ), 줄기는 그 축에 수직):
   · 줄기 100deg = 수직에서 10° — 이슬비의 자연스러운 기울기(옛 24°는 대각 줄무늬로 읽혔다).
     줄기 방향벡터 = (cos100°, sin100°) = (−.17365, .98481). 마스크 각 190deg의 축 = (sin190°, −cos190°)
     = 같은 벡터 → 마스크 마디가 줄기 방향으로 정렬된다.
   · 이동 = 240px × 줄기방향 = (−41.68, 236.36)px/바퀴, 키프레임엔 절반씩 대칭 분배(px 고정 —
     %는 요소 크기에 묶여 화면마다 투영값이 달라져 이음매가 다시 깨진다).
     이음매: 마스크 축 투영 240.007px ÷ 마스크 주기 120px = 2.0001 ≈ 정수(오차 0.007px/바퀴,
     서브픽셀·비누적). 줄기 그라디언트 축 투영 = −0.003px ≈ 0 → 배경도 불변. 끊김 없음.
     inset 여유: 최대 변위 (20.84, 118.18)px ≤ 30% 여유(390×844에서 117/253px. 세로는 화면 높이
     394px 이상이면 항상 안전).
   · 밝기(기준 140, luma = .3R+.59G+.11B, 합성 = base×(1−a)+luma×a): 줄기색 (165,178,190)의 luma 175.
     국소 최대 = 140 + 35.4×(.18×.9) ≈ 146 — 6px 폭의 가는 선 위에서만. 면적가중 평균: 줄기 평균
     α = ½×.18×6px/96px = .0056, 마스크 평균 = (26×.45+34×.9+6×.45)/120 = .375 → 평균 증가
     = .0056×.375×35.4 = 0.075/255 < 1/255 상한. 빛을 살짝 반사하는 결이지 화면을 밝히는 틴트가 아니다.
   · 속도 240px/.42s ≈ 570px/s — 노출 0.62s 동안 ~350px 낙하해 「내리는 중」이 읽히되, 마디가 짧고
     옅어(화면에 ~15개) 여운을 깨지 않는다.
   ⚠️ mask를 새로 쓰므로 아래 저사양(lite) override에서 반드시 해제한다 — 안 하면 정적 「비 온 뒤」
   틴트가 빗방울 모양으로 잘려 나간다. */
:is(.journeyWalkFx, .wfSky)[data-weather="rain"]::after {
  inset: -30%;   /* 대각 이동(최대 ±20.84/±118.18px) 중에도 빈 가장자리가 드러나지 않는 여유 */
  background-image: repeating-linear-gradient(100deg,
    rgba(165,178,190,0) 0px, rgba(165,178,190,0) 45px,
    rgba(165,178,190,.18) 48px,   /* 3px씩 선형 램프 = 모션블러처럼 부드러운 가장자리(급전환 금지) */
    rgba(165,178,190,0) 51px, rgba(165,178,190,0) 96px);
  /* 마디 = 빗방울: 긴 꼬리(26px 페이드인) → 몸통(34px) → 짧은 머리(6px) — 낙하 방향 앞쪽이 또렷 */
  -webkit-mask-image: repeating-linear-gradient(190deg,
    rgba(0,0,0,0) 0px, rgba(0,0,0,.9) 26px, rgba(0,0,0,.9) 60px,
    rgba(0,0,0,0) 66px, rgba(0,0,0,0) 120px);
  mask-image: repeating-linear-gradient(190deg,
    rgba(0,0,0,0) 0px, rgba(0,0,0,.9) 26px, rgba(0,0,0,.9) 60px,
    rgba(0,0,0,0) 66px, rgba(0,0,0,0) 120px);
  animation: journeyWalkRainFall .42s linear infinite;
  will-change: transform;
}
/* 이동은 정확히 줄기 방향(위 주석의 방향벡터) × 240px = 마스크 주기 120px의 2배 — 이 등식이 이음매
   무결성의 전부다. 각도·주기·이동량 중 하나를 바꾸면 셋을 함께 다시 계산할 것. */
@keyframes journeyWalkRainFall {
  from { transform: translate3d(20.84px, -118.18px, 0); }
  to   { transform: translate3d(-20.84px, 118.18px, 0); }
}
/* 비 온 뒤(wet) — 움직임 0, 젖은 반사감만 남는 정적 틴트(상대휘도 102/66, 바닥 쪽에 짙게 — 젖은
   지면이 빛을 머금은 느낌). 저사양 기기에서 비가 올 때도 이 모습을 재사용한다(`fx/perf.js`의 `lite`
   판정 — JS가 이미 `.journeyWalkFx`에 `.journeyWalkLite` 클래스를 붙여 준다. 새 판정을 만들지 않고
   그 클래스에 얹는다). 이동은 지우되 비가 왔다는 의미는 정적 텍스처로 남긴다.
   ⚠️ 감속 모드(`data-motion="reduced"`)용 대체 규칙은 두지 않는다 — 이 블록 맨 아래에서 걷기 다리
   **자체가** `display:none`이라 그런 규칙은 절대 실행되지 않는다(있으면 "감속도 챙겼다"고 잘못 읽힌다). */
:is(.journeyWalkFx, .wfSky)[data-weather="wet"]::after,
.journeyWalkFx.journeyWalkLite[data-weather="rain"]::after {
  background:
    linear-gradient(0deg, rgba(96,104,110,.34) 0%, rgba(96,104,110,.14) 42%, transparent 74%),
    linear-gradient(rgba(60,68,74,.14), rgba(60,68,74,.14));
}
.journeyWalkFx.journeyWalkLite[data-weather="rain"]::after {
  inset: 0;        /* 정적 모습에는 비의 대각 이동 여유가 필요 없다 */
  animation: none;
  will-change: auto;
  /* 비 규칙이 새로 쓰는 마디 마스크의 해제 — 위 background 오버라이드는 mask까지 지워 주지 않으므로
     여기서 안 지우면 이 정적 젖은 틴트가 빗방울 모양으로 잘려 나간다. */
  -webkit-mask-image: none;
  mask-image: none;
}
/* ── 걷는 중의 돌발 사건 (2026-08-05) ───────────────────────────────────────────────────────────
   계약: `fx/journeyWalkFx.js`가 걷기가 **출발하는 순간** `.journeyWalkEvent[data-kind]`를 붙이고
   조각 `<i class="journeyWalkEventPart">`를 개수만큼 넣는다. 지연은 `--evt-delay`(걷기의 30% 지점 =
   첫 발과 둘째 발 사이), 조각 순번은 `--i`. 사건이 없으면 이 요소 자체가 없어 아래 규칙은 하나도
   매치되지 않는다 — 그게 되돌리기 스위치다(platform/config.js `journeyEventEnabled`).

   ⚠️ **소리는 래퍼의 `animationstart`가 낸다.** 그래서 아래 `journeyWalkEventLife`의 지연을 바꾸면
   소리 시점도 같이 움직인다 — JS에 길이를 복제해 두지 않았으므로 한쪽만 어긋날 수 없다.
   ⚠️ 밝기 원칙 승계: 지나가는 것은 전부 **어두운 실루엣**이라 화면을 밝히지 않는다. 유일한 빛인
   반딧불이만 **실측**해 둔다(390×844 · 유적/밤/맑음 · t=1000ms에서 있을 때와 없을 때를 캡처 비교):
   화면 평균 64.2199 → 64.2279 = **+0.008/255**. 비의 가는 줄기(+0.075)보다도 10배 작다.
   국소 최대는 109 → 167로 오르지만 그건 3px 점 위에서만이고(비도 6px 줄기 위에서 146으로 같은 성격의
   예외를 이미 갖고 있다), 면적가중 평균이 상한 근처에도 가지 않는다. 애초에 가장 어두운 시간대
   (해질녘·밤)에만 나고 비 오는 날은 안 난다.
   ⚠️ 감속 모드용 대체 규칙을 두지 말 것 — 이 파일 맨 아래에서 걷기 다리 **자체가** `display:none`이라
   그런 규칙은 절대 실행되지 않는다(비 연출에 같은 함정이 있었다). 저사양(lite)만 조각 수가 줄고
   blur가 꺼진다. */
/* ── 발견의 예고 (오너 2026-08-10 ④) ────────────────────────────────────────────────────────────
   걷기가 발견으로 끊길 예정일 때만 붙는다(fx/journeyWalkFx.js `buildOmen`). 앞쪽 한 점에 빛이
   맺히고 부스러기 몇이 그리로 빨려 든다 — 그러다 걷기가 멈추고 **그 자리에서** 창이 열린다.
   ⚠️ 좌표 `left:50% / top:58%`는 발견 창의 `.wfGlow`와 **같은 값**이다(styles/app/14-walk-find.css).
      둘 중 하나를 옮기면 「걷다가 본 그 빛」이 아니라 무관한 반짝임 두 개가 된다 — 같이 옮길 것.
   ⚠️ 소리는 없다. 이유는 buildOmen 주석(발견 창의 첫 박자인 「정적」을 미리 채우면 안 된다).

   ── 2026-08-11 개정 (오너 「전조가 약하다」) ──────────────────────────────────────────────────
   ⚠️ **밝기를 올려서 고치지 않았다.** 절정(scale 1.6 = 42px)은 종전 그대로고, 바뀐 것은 **분배**다.
      옛 곡선은 0.15배에서 시작해 읽히는 크기에 **마지막 프레임에서만** 도달했다 — 1초 가까이 켜져
      있었는데도 실제로 보이는 건 끝의 0.3초뿐이라 「다가온다」가 아니라 「끝나기 직전에 켜졌다」로
      읽혔다. 이제 절반 밝기로 걷기 **내내** 있다가 도착할 때만 밝아진다. 세기가 아니라 체류 시간이다
      (연출 헌법 §4.6 "머무는 시간이 곧 쫀득함이다 — 진폭이 아니다").
   ⚠️ 색은 **발견물의 색**이다(`data-kind`). 종전 노란 점은 아무 일도 안 일어나는 반딧불이 사건
      (`[data-kind="fireflies"]`, 아래)과 같은 색이라 "그냥 풍경"으로 학습돼 있었다. 값이 없거나 모르는
      값이면 아래 오버라이드가 하나도 안 걸려 기본(열쇠조각 상아빛)으로 떨어진다 — 그게 되돌리기 스위치다.
      값의 출처 = 실제 에셋 색 실측(열쇠조각 `assets/walk/shard_*.webp` #D6CAA8 · 코인
      `assets/common/star_coin.webp` #F8D64B · 하트 `.wfhFill` #ff4f60). 에셋을 바꾸면 여기도 바꿀 것. */
.journeyWalkOmen {
  position: absolute;
  left: 50%; top: 58%;
  width: 0; height: 0;
  z-index: 5;      /* 사건과 같은 층 — 둘은 배타라 겹치지 않는다 */
  pointer-events: none;
  /* 기본 = 열쇠조각(상아빛). 돌·뼈에 가까운 저채도라 정글의 초록·반딧불이의 노랑 어느 쪽과도 안 겹친다. */
  --omen-core: rgba(248,242,224,.92);
  --omen-mid:  rgba(214,202,168,.52);
  --omen-edge: rgba(196,182,146,0);
}
.journeyWalkOmen[data-kind="heart"] {
  --omen-core: rgba(255,226,228,.92);
  --omen-mid:  rgba(255,110,120,.52);
  --omen-edge: rgba(255,79,96,0);
}
.journeyWalkOmen[data-kind="coin"] {
  /* 반딧불이(rgba(255,226,152)=옅은 노랑)와 갈리도록 **호박빛 쪽으로** 민다 — 같은 노랑 계열이라
     채도와 색상 둘 다 벌려야 "저건 다른 것"이 된다. */
  --omen-core: rgba(255,244,190,.92);
  --omen-mid:  rgba(248,214,75,.55);
  --omen-edge: rgba(232,159,37,0);
}
/* 주변을 낮추는 층 — 빛을 키우는 대신 **경쟁자를 낮춘다** (연출 헌법 §4.1 "한 프레임의 주인공은
   하나, 주변은 최소 두 항목을 낮춘다"). 중심은 빛과 **같은 좌표**(50%/58%)라 비네트의 빈 가운데가
   곧 빛의 자리다 — 어긋나면 정작 주인공이 같이 어두워진다.
   ⚠️ 이 그늘을 `.journeyWalkShade`에 얹지 말 것: 그늘은 이미 발걸음 WAAPI 애니를 갖고 있고 **한
      요소에서 animation은 하나만 이긴다** — 얹는 순간 발걸음 그늘이 조용히 죽는다(이 저장소 재발 함정).
   ⚠️ 어둡게만 움직인다 — 위 「밝기 원칙」(2026-08-04) 승계. */
.journeyWalkOmenHush {
  position: absolute;
  inset: 0;
  z-index: 5;      /* 빛과 같은 층이되 DOM 순서상 **먼저** 붙는다(buildOmen) → 빛이 위에 그려진다 */
  pointer-events: none;
  background: radial-gradient(ellipse 58% 48% at 50% 58%,
    transparent 26%, rgba(8,12,8,.28) 70%, rgba(6,9,6,.5) 100%);
  /* ⚠️ **기본 스타일 = 애니의 끝 상태**(opacity 1, 그래서 아무 선언도 안 한다). `backwards`는 끝난
     뒤를 안 붙잡고 기본 스타일로 되돌리므로, 여기에 `opacity: 0`을 적으면 걷기가 끝나는 그 프레임에
     비네트가 **툭 사라진다**. 0은 0% 키프레임이 맡는다 — 이 둘을 헷갈리지 말 것. */
  animation: journeyWalkOmenHush var(--omen-life, 700ms) ease-in var(--omen-delay, 0ms) backwards;
}
@keyframes journeyWalkOmenHush {
  0%   { opacity: 0; }
  62%  { opacity: .5; animation-timing-function: ease-in; }
  100% { opacity: 1; }
}
.journeyWalkOmen::before {
  content: "";
  position: absolute; left: 50%; top: 50%;
  width: 26px; height: 26px; margin: -13px 0 0 -13px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--omen-core) 0%, var(--omen-mid) 42%, var(--omen-edge) 72%);
  /* ⚠️ **기본 스타일 = 애니의 끝 상태**(위 hush와 같은 이유). 종전엔 이 두 줄이 없어서 걷기가 끝나는
     프레임에 빛이 42px→26px로 **줄었다가** 페이드아웃했다(2026-08-10부터 있던 결함). 발견 창의
     빛이 바로 이 크기에서 이어받으므로(styles/app/14-walk-find.css `wfGlowHandoff`), 여기서 줄어들면
     이음새가 「끊겼다 다시 켜진다」가 된다. 값을 바꾸면 **저쪽 0% 키프레임도 같이 바꿀 것.** */
  opacity: .94;
  transform: scale(1.6);
  animation: journeyWalkOmenGlow var(--omen-life, 700ms) linear var(--omen-delay, 0ms) backwards;
}
/* 걷는 내내 **거의 변하지 않는다** — 그게 「사건」이 아니라 「저기 있는 것」으로 읽히는 조건이다.
   변화는 마지막 34%에 몰아 준다: 그때 커지는 것이 곧 「도착했다」이고, 창의 wfGlow가 그 크기에서
   이어받는다. 중간 두 프레임의 값 차가 작은 것은 실수가 아니라 **의도한 정체 구간**이다. */
@keyframes journeyWalkOmenGlow {
  0%   { opacity: 0;   transform: scale(.34); animation-timing-function: ease-out; }
  15%  { opacity: .44; transform: scale(.58); animation-timing-function: ease-in-out; }
  66%  { opacity: .5;  transform: scale(.8);  animation-timing-function: ease-in; }
  100% { opacity: .94; transform: scale(1.6); }
}
.journeyWalkOmenMote {
  position: absolute; left: 50%; top: 50%;
  width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px;
  border-radius: 50%; background: var(--omen-mid);
  will-change: transform, opacity;
  opacity: 0;      /* `backwards`는 끝난 뒤를 안 붙잡는다 — 기본이 투명이어야 잔상이 안 남는다 */
  /* **반복한다.** 종전엔 걷기당 한 번뿐이라 절정 직전에 셋 다 사라졌다 — 유일하게 "무언가 모이는
     중"이라고 말하는 요소가 정작 도착하는 순간에 없었다. 이제 걷는 내내 흘러든다.
     ⚠️ `infinite`라도 잔상 걱정은 없다: 이 층은 걷기가 끝나면 통째로 제거된다(createJourneyWalkFx
        `cleanup`). 길이를 JS에 적지 않으므로 CSS와 어긋날 자리도 없다. */
  animation: journeyWalkOmenDrift calc(var(--omen-life, 700ms) * .42) ease-in-out
             calc(var(--omen-delay, 0ms) + var(--i) * 190ms) infinite backwards;
}
/* 빛으로 **빨려 들어간다** — 바깥에서 안으로, 가까워질수록 작아지며 삼켜진다. */
@keyframes journeyWalkOmenDrift {
  0%   { opacity: 0;   transform: translate(var(--ox), var(--oy)) scale(.55); }
  34%  { opacity: .8;  animation-timing-function: ease-in; }
  100% { opacity: 0;   transform: translate(0, 0) scale(.25); }
}
.journeyWalkOmenMote:nth-child(1) { --ox: -54px; --oy: -26px; }
.journeyWalkOmenMote:nth-child(2) { --ox:  48px; --oy: -34px; }
.journeyWalkOmenMote:nth-child(3) { --ox: -22px; --oy:  40px; }

.journeyWalkEvent {
  position: absolute;
  inset: 0;
  z-index: 5;      /* 그늘(2)·시간대(3)·날씨(4)보다 앞 — 카메라 가까이 지나가는 것들이다 */
  pointer-events: none;
  animation: journeyWalkEventLife 180ms ease-out var(--evt-delay) backwards;
}
.journeyWalkEventPart {
  position: absolute;
  display: block;
  will-change: transform, opacity;
  /* ⚠️ **기본이 투명이어야 한다 — 지우지 말 것.** 아래 네 사건은 전부 `animation-fill-mode: backwards`인데,
     `backwards`는 **시작 전만** 붙잡고 **끝난 뒤는 놓는다**. 그래서 애니가 끝나는 순간 조각은 자기
     선언값으로 돌아가는데, 여기에 opacity가 없으면 기본값 1 = **출발 자리에 불투명하게 다시 나타나** 걷기가
     끝날 때까지 서 있는다. 2026-08-06 오너 보고 "새 연출 마지막에 좌측으로 새 흔적 3개가 일렬로 정렬되고
     끝나서 버그처럼 보인다"의 정체다(실측: 1900ms에 셋 다 좌 30px·불투명 1·transform none, 걷기 끝
     2156ms까지 ~340ms 지속. 잎사귀는 1347ms에 끝나 **809ms**나 남아 있었다).
     `both`로 바꾸는 것도 답이지만 이쪽을 택했다 — ⑴ 이 저장소는 `backwards`를 규약으로 삼는다
     (연출 헌법 §4.6: `both`는 100% 프레임을 계속 입혀 `:active` 눌림을 죽인다) ⑵ 애니가 아예 안 도는
     상황(사건 레이어만 붙고 키프레임 미적용)에서도 조각이 정적 얼룩으로 보이지 않는다.
     키프레임이 일반 선언을 이기므로 **날아가는 동안의 불투명도에는 영향이 없다**(실측 재확인). */
  opacity: 0;
}
/* 래퍼는 「무리 전체가 스며든다」를 맡고, 동시에 소리의 시계 노릇을 한다(위 주석). */
@keyframes journeyWalkEventLife { from { opacity: 0; } to { opacity: 1; } }

/* 새 그림자 — 무언가에 놀라 날아오른다. 실루엣이라 안개 속에서는 멀리 있는 만큼 흐려진다. */
.journeyWalkEvent[data-kind="birds"] .journeyWalkEventPart {
  --evt-fade: .82;
  left: 6%;
  top: calc(54% - var(--i) * 6%);
  width: 22px;
  height: 11px;
  background: rgba(13, 17, 13, 1);
  /* 멀리서 본 갈매기꼴 — 작게 그리면 두께가 있는 「M」이 새로 읽힌다 */
  clip-path: polygon(0 28%, 26% 56%, 50% 34%, 74% 56%, 100% 28%, 74% 78%, 50% 62%, 26% 78%);
  animation: journeyWalkBirdFly 950ms cubic-bezier(.3, .06, .5, 1) calc(var(--evt-delay) + var(--i) * 110ms) backwards;
}
/* 안개에서만 흐려진다 — 키프레임이 일반 선언을 이기므로 세기를 변수로 빼서 넘긴다. */
.journeyWalkFx[data-weather="fog"] .journeyWalkEvent[data-kind="birds"] .journeyWalkEventPart { --evt-fade: .34; }
/* 날갯짓은 세로 눌림(scaleY)으로, 멀어짐은 축소(scale)로 — 한 transform 안에서 곱해진다.
   ⚠️ scale과 scaleY의 순서를 바꾸지 말 것: 뒤의 scaleY가 앞의 scale 결과에 다시 곱해지는 구조다. */
@keyframes journeyWalkBirdFly {
  0%   { transform: translate3d(0, 0, 0) scale(1) scaleY(1); opacity: 0; }
  10%  { transform: translate3d(9vw, -4vh, 0) scale(.96) scaleY(.42); opacity: var(--evt-fade); }
  25%  { transform: translate3d(23vw, -11vh, 0) scale(.92) scaleY(1.06); opacity: var(--evt-fade); }
  40%  { transform: translate3d(37vw, -18vh, 0) scale(.87) scaleY(.45); opacity: var(--evt-fade); }
  55%  { transform: translate3d(51vw, -25vh, 0) scale(.83) scaleY(1); opacity: calc(var(--evt-fade) * .82); }
  70%  { transform: translate3d(66vw, -32vh, 0) scale(.78) scaleY(.48); opacity: calc(var(--evt-fade) * .58); }
  100% { transform: translate3d(95vw, -46vh, 0) scale(.7) scaleY(.95); opacity: 0; }
}

/* 잎사귀 — 카메라 바로 앞을 훅 지나간다. 가까이 있으므로 커지고, 초점이 안 맞아 흐리다(깊이감).
   blur는 44px짜리 조각 하나에만 걸리므로 화면 전체 필터와 달리 저렴하지만, 저사양에서는 뺀다. */
.journeyWalkEvent[data-kind="leaf"] .journeyWalkEventPart {
  left: 76%;
  top: 2%;
  width: 50px;
  height: 34px;
  /* 잎맥 한 줄을 얹어 「잎」으로 읽히게 한다 — 없으면 그냥 어두운 얼룩이다(실측 캡처에서 확인).
     맥의 색은 잎보다 밝지만 상대휘도 90이라 목표(140)보다 어둡다 = 밝기 원칙 안. */
  /* ⚠️ 맥은 잎의 **긴 축을 따라** 흘러야 한다. 그라디언트의 줄무늬는 그 방향선과 **수직**으로
     생기므로, 축(왼위→오른아래 = 뾰족한 두 끝)을 따르게 하려면 방향선은 그 수직인 `to top right`다.
     `to bottom right`로 두면 맥이 잎을 가로질러 「접힌 자국」으로 보인다(실측에서 그렇게 나왔다). */
  background:
    linear-gradient(to top right, transparent 46%, rgba(96, 92, 60, .5) 49.5%, transparent 53%),
    linear-gradient(140deg, rgba(38, 36, 20, .8), rgba(20, 22, 13, .88));
  border-radius: 0 100% 0 100%;
  filter: blur(1px);   /* 1.4px은 형태까지 뭉개 얼룩이 됐다 — 스치는 느낌만 남기고 윤곽은 살린다 */
  animation: journeyWalkLeafPass 700ms cubic-bezier(.32, .1, .52, 1) var(--evt-delay) backwards;
}
.journeyWalkLite .journeyWalkEvent[data-kind="leaf"] .journeyWalkEventPart { filter: none; }
@keyframes journeyWalkLeafPass {
  0%   { transform: translate3d(0, 0, 0) rotate(-24deg) scale(.85); opacity: 0; }
  14%  { opacity: .9; }
  100% { transform: translate3d(-96vw, 64vh, 0) rotate(188deg) scale(1.55); opacity: 0; }
}

/* 작은 짐승 — 발 앞을 가로질러 사라진다. 정체는 끝까지 안 밝힌다(어두운 덩어리 하나).
   도약할 때 진행 방향으로 늘어나고 착지에서 눌렸다 되튄다 — 부피 보존(scaleX × scaleY ≈ 1),
   연출 헌법 §4.6의 「쫀득」 규칙. 화면 밖으로 나가므로 「멈춘 뒤 흔들림」 금지 조항에는 걸리지 않는다. */
.journeyWalkEvent[data-kind="critter"] .journeyWalkEventPart {
  left: -8%;
  top: 76%;
  width: 30px;
  height: 18px;
  /* ⚠️ 매끈한 타원은 짐승이 아니라 **검은 알약**으로 읽혔다(실측 캡처). 정체를 밝히지 않는 것과
     형태가 없는 것은 다르다 — 등이 굽고 주둥이·꼬리·다리가 있어야 「작은 짐승」이 된다.
     오른쪽을 보고 달리는 옆모습: 주둥이(오른쪽 끝) → 등 → 꼬리(왼쪽 위) → 뒷다리 → 배 → 앞다리. */
  background: rgba(16, 15, 11, .82);
  clip-path: polygon(100% 50%, 90% 32%, 74% 27%, 54% 16%, 32% 27%, 18% 32%, 4% 12%, 12% 40%,
    20% 62%, 26% 92%, 34% 62%, 50% 66%, 62% 92%, 70% 60%, 86% 60%, 94% 62%);
  animation: journeyWalkCritterDash 620ms linear var(--evt-delay) backwards;
}
@keyframes journeyWalkCritterDash {
  0%   { transform: translate3d(0, 0, 0) scale(1, 1); opacity: 0; }
  8%   { transform: translate3d(8vw, 0, 0) scale(1.06, .94); opacity: .92; }
  22%  { transform: translate3d(24vw, -3.4vh, 0) scale(1.14, .88); opacity: .92; animation-timing-function: ease-in; }
  34%  { transform: translate3d(36vw, 0, 0) scale(1.2, .83); opacity: .92; }                  /* 착지 = 눌림 */
  42%  { transform: translate3d(43vw, 0, 0) scale(.93, 1.08); opacity: .92; }                 /* 되튐 */
  58%  { transform: translate3d(58vw, -2.8vh, 0) scale(1.12, .9); opacity: .88; animation-timing-function: ease-in; }
  70%  { transform: translate3d(70vw, 0, 0) scale(1.16, .86); opacity: .82; }
  78%  { transform: translate3d(77vw, 0, 0) scale(.95, 1.06); opacity: .7; }
  100% { transform: translate3d(104vw, 0, 0) scale(1.04, .97); opacity: 0; }
}

/* 반딧불이 — 이 층에서 유일하게 빛나는 것. 위 주석의 계산대로 면적이 0.2% 미만이라 밝기 상한과
   무관하다. 흩어진 자리는 계산식 대신 다섯 개를 손으로 놓는다(식으로 만들면 일직선으로 늘어선다). */
.journeyWalkEvent[data-kind="fireflies"] .journeyWalkEventPart {
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: rgba(255, 226, 152, .9);
  box-shadow: 0 0 7px 1px rgba(255, 214, 130, .45);
  animation: journeyWalkFireflyDrift 1500ms ease-in-out calc(var(--evt-delay) + var(--i) * 170ms) backwards;
}
.journeyWalkEvent[data-kind="fireflies"] .journeyWalkEventPart:nth-child(1) { left: 22%; top: 58%; }
.journeyWalkEvent[data-kind="fireflies"] .journeyWalkEventPart:nth-child(2) { left: 63%; top: 44%; }
.journeyWalkEvent[data-kind="fireflies"] .journeyWalkEventPart:nth-child(3) { left: 41%; top: 71%; }
.journeyWalkEvent[data-kind="fireflies"] .journeyWalkEventPart:nth-child(4) { left: 78%; top: 63%; }
.journeyWalkEvent[data-kind="fireflies"] .journeyWalkEventPart:nth-child(5) { left: 12%; top: 40%; }
/* 깜빡임은 균일하지 않다 — 꺼졌다 켜지는 사이가 불규칙해야 살아 있는 것으로 읽힌다. */
@keyframes journeyWalkFireflyDrift {
  0%   { transform: translate3d(0, 0, 0); opacity: 0; }
  16%  { opacity: .74; }
  38%  { transform: translate3d(2.2vw, -1.8vh, 0); opacity: .18; }
  54%  { opacity: .68; }
  76%  { transform: translate3d(4.4vw, -3.9vh, 0); opacity: .3; }
  88%  { opacity: .55; }
  100% { transform: translate3d(6vw, -5.4vh, 0); opacity: 0; }
}

/* The hub nav becomes visible as soon as the route commits to map. Keep it out of the walk bridge, then let it
   return after the bridge cleans up; the earned star remains above the bridge at z-index 123. */
.titleNav { transition: opacity .16s ease; }
body.journeyWalkActive .titleNav { opacity: 0 !important; pointer-events: none !important; }
html[data-motion="reduced"] .journeyWalkFx { display: none !important; }

/* ── 칸노의 발견 띠지 — **삭제됨 (오너 역할 개편 2026-08-14)** ─────────────────────────────────
   칸노가 **따로 발견하던 시절**의 하단 띠지(.journeyFindBanner/.jfbIcon/.jfbText + jfbIn/jfbOut).
   이제 칸노는 스스로 발견하지 않고 **샤의 발견에 얹히므로**, 주어가 「칸노가」인 표면 자체가 없어졌다.
   삭제 근거 전문 = platform/config.js `petBonusChance`. 되살리려면 git 이력을 본다.
   ⚠️ 되살릴 생각이라면 먼저 그 구조가 만들던 문제를 볼 것: 같은 코인·하트가 **두 문법**(전용 창 vs
      이 띠지)으로 나왔고, 소리를 끄면 이 띠지는 글자 한 줄이라 밋밋했다(오너 2026-08-11 지적). */


/* ── 「장막」 전환 (fx/curtainFx.js, 오너 2026-08-13 · 2026-08-14 두 짝으로 개정) ──────────────────
   석판 두 짝이 문처럼 닫혔다 열린다. 들어감 = 좌우 · 나옴 = 상하(축이 직각이라 방향이 갈린다).
   ⚠️ **2026-08-14: 타일 격자(55장) → 두 짝.** 오너 판정 "파란색 타일로 열고 덮는게 징그러워" —
      원인은 재료가 아니라 개수였다(사방에서 몰려오는 55장 = 벌레 떼). 격자를 되살리지 말 것.
   ⚠️ z-index는 전환 표면(stx-surface, 120~124)보다 **위**여야 교체가 안 비친다. 일반 gameModal은
      z-index 60이지만 장막 경로와 공존하지 않는다(진입 선택·나가기·보스 모달은 먼저 닫히고, 승리 카드
      복귀는 preserveWinSurface 분기로 장막에서 제외). 130은 맵 보상 오버레이(220~222)보다 아래다.
   ⚠️ 짝의 좌표·크기는 자바스크립트가 넣는다(축이 방향에 따라 도므로) — 여기서는 재질과 층만 소유한다. */
#curtainFx {
  position: fixed; inset: 0; z-index: 130;
  pointer-events: none;                /* 전환 중 탭이 장막에 먹히면 안 된다 */
  overflow: hidden;
  contain: strict;
}
#curtainFx .curtainPanel {
  position: absolute;
  will-change: transform;
  /* 전용 유적문은 알파가 없는 완전 불투명 WebP다. 그래도 첫 로드 실패·디코딩 전 한 프레임까지 화면을
     가리는 것이 장막의 계약이라, 문과 같은 어두운 석재색을 바탕에 남긴다. */
  background-color: #20231b;
  background-image: var(--curtain-panel-image);
  background-repeat: no-repeat;
  background-size: var(--curtain-viewport-w, 100vw) var(--curtain-viewport-h, 100vh);
  /* 자산 한 장을 화면 크기로 그린 뒤 각 짝이 자기 몫만 보여 준다. 그래서 닫히면 반복 카드 두 장이 아니라
     하나의 완성된 문이 되고, 좌우·상하 어느 축으로 갈라도 무늬가 정확히 이어진다. */
  filter: brightness(.92) saturate(.92);
}
#curtainFx.curtainForward .curtainPanel:first-child { background-position: left top; }
#curtainFx.curtainForward .curtainPanel:last-child  { background-position: right top; }
#curtainFx.curtainBack .curtainPanel:first-child    { background-position: left top; }
#curtainFx.curtainBack .curtainPanel:last-child     { background-position: left bottom; }
/* 감속 모드에서는 장막 자체를 만들지 않는다(fx/curtainFx.js가 no-op) — 혹시 남아도 안 보이게 막는다. */
html[data-motion="reduced"] #curtainFx { display: none; }
