    /* ── 旅程頁（04e 批次 B）：SVG 蜿蜒路徑 ＋ 節點詳情卡 ────────────────────────
       版型照 demo（`/Users/tommy/olitbit-demo/v1/index.html` 的 S3 區塊），
       **配色一律換成本站 token**——founder 2026-08-22 裁定第 6 條：版型照它、配色用現在 app 的。
       demo 用了三個本站沒有的值，對照如下（改動時照這張表走，不要把 demo 的 hex 抄進來）：
         demo `--border-dashed` → `--border-strong`
         demo `--note-bg`       → `--surface`
         demo `#fff`（節點內圈）→ `--bg`（本站的紙是暖白 #fdfcfa，純白會在暖底上發青）
       demo 的路徑線用了兩個寫死的灰（#c5c0ba／#d8d4cf），這裡分別換成 `--border-strong`
       與 `--border`，由 CSS 給色、SVG 只寫 `stroke="currentColor"` 之外的 class（見下）。 */

    /* 路徑欄寬刻意比內容欄窄很多：demo 是手機框（375px）裡的一條路，
       在 768px 的內容欄裡等比放大會讓節點變成大圓餅、字也跟著糊。
       固定成 420px 上限、置中，桌機與手機看到的是同一條路，只有左右留白不同。 */
    .journey-path-wrap { max-width: 420px; margin: 0 auto; }
    .journey-svg { display: block; width: 100%; height: auto; }

    /* 節點：ring 是看得見的圈，hit 是透明的大命中區（手指比游標粗，8px 的圈點不到）。
       ⚠️ `-webkit-tap-highlight-color: transparent` 是**手機上的那半**（founder 2026-08-28
          真機走查回報：桌機的方框沒了，手機一點還是浮出一個正方形的底）。那是 iOS／Android
          預設的 tap highlight，蓋的是元素的**外接矩形**——矩形套在一顆圓上就是那個方塊，
          跟下面 `:focus` 那圈 outline 是**兩個不同的東西**，只拿掉一個另一個還在。
       ⚠️ 拿掉它之後，手機唯一的按壓回饋就是 `.is-entry:active` 那下縮放（見下方）。
          要改動 `:active` 那條，先想清楚手機上還剩什麼——不是「桌機還有 hover」就沒事。
       ⚠️ 射程刻意只有節點：一般矩形按鈕的 tap highlight 形狀本來就對，不必跟著關。 */
    .j-node { cursor: pointer; -webkit-tap-highlight-color: transparent; }
    .j-node .j-hit { fill: transparent; }

    /* ── 點下去不要方形藍框（founder 2026-08-28 走查回報）──────────────────────
       節點是 `<g role="button" tabindex="0">`，瀏覽器給它一圈預設 focus outline——
       在 SVG 上畫出來是**方形**的，套在一顆圓上特別突兀。整站早有先例：
       `10-base.css` 的 `input:focus` 也是拿掉藍框（Dogfood-7）。
       ⚠️ **不能只寫 `outline: none` 就走**——那會讓鍵盤使用者完全看不到焦點在哪。
          分成兩件事：滑鼠點下去不留框（`:focus`）、鍵盤 Tab 過來時把節點本身描粗、加深、
          放大成跟 hover 同一個手勢（`:focus-visible`）——圓的東西用圓的方式標記。
       ⚠️ 焦點指示**刻意做在 `.j-ring` 上，不做在 `.j-hit` 上**：命中區半徑 22–26，
          描出來會超出 viewBox 上緣被**裁掉半圈**（2026-08-28 實際拍到）。
       ⚠️ 焦點環刻意**實線**：虛線在這一頁已經是「還沒走到的路」的語彙，會講錯話。
       ⚠️ 整段包在 `@supports selector(:focus-visible)` 裡：不支援的舊瀏覽器保留預設方框，
          寧可醜也不要沒有焦點指示。 */
    @supports selector(:focus-visible) {
      .j-node:focus { outline: none; }
      .j-node:focus-visible .j-ring {
        stroke: var(--text); stroke-width: 3;
        transform-box: fill-box; transform-origin: center; transform: scale(1.12);
      }
    }
    .j-node .j-ring {
      fill: var(--bg); stroke: var(--border-strong); stroke-width: 1.5;
      transition: fill 0.2s ease, stroke 0.2s ease;
    }
    .j-node:hover .j-ring { stroke: var(--text-muted); }
    /* 被選中的節點填實：這是「你正在看這一顆」的唯一訊號，不加位移也不加陰影。
       ⚠️ 填色 2026-08-27 從 --text-muted 降到 --text-faint（founder 走查後）：
       進站預設選的是最新那一顆（也就是路最頂端那顆走完的圈），一顆實心深灰圓在靜態畫面上會把眼睛
       先吸走——但真正要點的是「開始今天」。**畫面只能有一個主角，而主角是入口那顆。**
       降一階之後它仍然是整條路上唯一被填滿的圓（選取訊號沒有消失），只是不再最重。
       ⚠️ 別為了「選取要更明顯」把它調回 --text-muted，那會把主角搶回來。 */
    .j-node.is-active .j-ring { fill: var(--text-faint); stroke: var(--text-faint); }

    /* ── 入口節點：整條路唯一的主角（founder 2026-08-27 走查後裁定）────────────
       空路那顆起點、以及「今天可以開始」時最上端那一顆，共用這一套——它們做的是同一件事。
       🔴 **大小不再拿來表示重要性**：每一顆節點都是 J_NODE_R，差別只在描邊與光暈。
       改版前是反過來的：最頂端那顆（當時標「你在這」）最大、最深、還會脈動，但它**點不動**
       ——視覺焦點與操作焦點分離，第一次用的人會去戳那顆黑的然後覺得沒反應。
       ⚠️ 光暈是**柔和的呼吸**，不是 CTA 按鈕那種強脈動：目的是讓人自然覺得「這裡可以開始」，
       不用把它做成一顆按鈕。原本那圈 2 秒擴散到 21px 的 ripple（jRingPulse）已整組移除。
       ⚠️ 別把大小、脈動或「你在這」那三個字加回最頂端那顆——那會立刻長回兩個主角
          （那三個字 2026-08-28 也拿掉了，理由在 95-journey.js 的 labelText 那段）。 */
    .j-node.is-entry { cursor: pointer; }
    /* ⚠️ 描邊粗細**不覆寫**，沿用 .j-ring 的 1.5（founder 2026-08-27：之前那個粗細比較合適）。
       2026-08-27 曾短暫加到 2，走查後退回。深色描邊本身已經夠把它跟其他節點分開。 */
    /* 🔴 **靜止時是路的顏色，墨黑只留給滑過去那一刻**（founder 2026-08-28 走查後裁定）。
       改版前描邊是 `--text`（#2c2825）＝跟 h1 同一支墨，而整條路上其他東西是 #d8d3ca
       （線與其他節點）／#857f77（選中那顆）／#6d6862（它自己的標籤）——它是路上唯一的
       純墨物件，還比自己的標籤深兩階，配色階梯在那裡整個斷掉，所以它會「跳出來」。
       ⚠️ 收到 `--text-muted` **不是**把主角讓出去：它仍然是整條路上最深的描邊
          （其餘節點是 --border-strong，差三階），加上光暈與 hover／按壓回饋，
          「這顆可以按」的訊號一格都沒少。
       ⚠️ **別改成中間值**（#4a4540 那種）：那要在色票裡新增第四層灰，而 10-base.css 明講
          三層灰不再加層（加了會塌成兩層）。要加是另一個決定，不在這一批裡順手做。
       ⚠️ 射程含**空路那顆**：兩顆做的是同一件事，長得不一樣會讓人猜（journey.md）。
          而且空路那一屏 2026-08-28 才把圓從 r=8 收到 r=6（「單獨呈現時有點重」），
          描邊變淡是**同一個方向**，不是新方向。 */
    .j-node.is-entry .j-ring { stroke: var(--text-muted); }
    /* hover 才回到墨黑：桌機原本只有放大一個層次，現在多一個「它注意到你了」。
       ⚠️ 手機沒有 hover，摸得到的仍然只有 `:active` 那下縮放——別把這條當成手機上也有的回饋。
       ⚠️ 顏色過渡吃的是下面那條 transition 裡的 `stroke 0.2s ease`（已經在了），別另外再寫一次。 */
    .j-node.is-entry:hover .j-ring { stroke: var(--text); }

    /* ── 入口那顆的觸碰回饋（founder 2026-08-28）────────────────────────────────
       🔴 **這不是裝飾，是空路那一屏成立的前提。** 那一屏的文字全部拿掉之後（標題以外
       一個字都沒有），「這顆圓可以點」只能由互動自己講：命中區（JS 那邊 r=26）、
       呼吸光暈、cursor:pointer，加上這裡的兩個回饋。要拿掉它就得先把文字談回來。
       ⚠️ hover 包在 `@media (hover: hover)` 裡：手機沒有真的 hover，不包的話點完那顆會
          **黏在放大狀態**（觸控裝置把最後點到的元素留在 :hover），看起來像壞掉。
       ⚠️ 手機唯一摸得到的是 :active——按下去縮一下就是那個「我碰到它了」的即時回饋。
       ⚠️ SVG 的 transform 預設繞 viewBox 原點轉，不加 transform-box/origin 會整顆飛走。
       ⚠️ **hover 與按下的速度刻意不同，別統一成同一個數字**：
          hover ＝ 0.5s 的慢速緩出（founder 2026-08-28 走查：0.18s 太快、「有點活潑」）。
            這一屏的語彙是安靜的，放大是「它注意到你了」，不是彈一下。
          按下 ＝ 0.07s，快到幾乎同步。按壓回饋一旦慢下來就會讀成「卡頓／沒反應」，
            而且手機上沒有 hover、tap highlight 也拿掉了，它是**唯一**的觸控回饋。
          兩者的工作不同：一個是氣氛，一個是「我收到了」。 */
    .j-node.is-entry .j-ring {
      transform-box: fill-box; transform-origin: center;
      transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.35, 1), fill 0.2s ease, stroke 0.2s ease;
    }
    @media (hover: hover) {
      .j-node.is-entry:hover .j-ring { transform: scale(1.12); }
    }
    .j-node.is-entry:active .j-ring {
      transform: scale(0.92);
      transition: transform 0.07s ease-out;
    }
    /* 脈動：外圈擴散。⚠️ 2026-08-27 曾短暫換成「柔和呼吸的填色光暈」（jEntryGlow），
       founder 走查後**退回這一版**——他判定舊的比較好看。純 CSS 動畫，不寫 JS
       （分頁切到背景時 rAF 不跑，那類坑直接避開）。
       ⚠️ 它原本掛在最頂端那顆（當時標「你在這」）身上，現在只掛在**入口**那顆身上，
          這是整批最核心的一個換位。 */
    .j-node.is-entry .j-pulse {
      fill: none; stroke: var(--text-muted); opacity: 0.5;
      animation: jRingPulse 2s ease-out infinite;
    }
    @keyframes jRingPulse { 0% { r: 9px; opacity: 0.45; } 100% { r: 21px; opacity: 0; } }
    /* 空路那一屏只有一顆圓（`.is-solo`，JS 那邊 r 從 8 收到 6）——脈動要**跟著等比收**，
       不然光暈會從圓的外面一段距離才開始擴散，中間裂一圈空隙。
       ⚠️ 起點 7px 必須跟 `95-journey.js` 的 `J_SOLO_PULSE_R` 同值：
          CSS 這條管有動畫時的樣子，JS 那個 attr 管**關掉動畫**時（減少動態）的靜態光暈，
          兩邊不同步不會報錯，只會在其中一種狀態下看起來怪。 */
    .j-node.is-solo .j-pulse { animation-name: jSoloPulse; }
    @keyframes jSoloPulse { 0% { r: 7px; opacity: 0.45; } 100% { r: 17px; opacity: 0; } }
    /* 入口那顆的標籤不用 .j-label-future 的斜體＋降透明度：它不是「還沒走到」的旁白，
       是這一頁唯一要人動手的地方。 */
    .j-node.is-entry .j-label { fill: var(--text-muted); }

    /* 進行中那一圈（`.is-now`）：不可點，所以連 hover 的描邊回饋也要一起關掉——
       不然它摸起來像可以點、按下去卻沒事（跟下面 .is-future 同一條規則）。
       ⚠️ `.is-now` 2026-08-28 收窄成「**進行中那一圈**」，不再是「最頂端那顆」——
          最頂端那顆走完的圈是可以點的，套上這組會把它點死。
       ⚠️ 這個狀態**正常流程到不了**（旅程頁只在「今天沒有進行中的事」時到得了），
          只在 /dev/screen?screen=journey&variant=in-progress 看得到。它是防守、不是常態；
          別為它加專屬視覺，那是替不可能發生的狀態做工程。 */
    .j-node.is-now { cursor: default; }
    .j-node.is-now:hover .j-ring { stroke: var(--border-strong); }

    /* 還沒走到的那一顆（＝今天已經走完，路還沒完的暗示）：虛線空心、不可點
       （游標不變手指，也沒有 hover 回饋）。 */
    .j-node.is-future { cursor: default; }
    .j-node.is-future .j-ring { stroke: var(--border-strong); stroke-dasharray: 3 3; }
    .j-node.is-future:hover .j-ring { stroke: var(--border-strong); }
    /* 尊重「減少動態」的系統設定：脈動是裝飾，不是資訊。
       ⚠️ 上面那組 hover／:active 的縮放**刻意不關**：它不是裝飾，是「我點到了」的操作回饋，
          而空路那一屏已經沒有文字可以代替它。真要收，改的是幅度不是有無。 */
    @media (prefers-reduced-motion: reduce) {
      .j-node.is-entry .j-pulse { animation: none; opacity: 0.35; }
    }

    .j-label { font-size: 10px; fill: var(--text-faint); letter-spacing: 0.04em; }
    .j-label-future { font-style: italic; opacity: 0.75; }
    .j-line-walked { fill: none; stroke: var(--border-strong); stroke-width: 2.5; stroke-linecap: round; }
    .j-line-future { fill: none; stroke: var(--border); stroke-width: 2; stroke-dasharray: 4 7; stroke-linecap: round; }

    /* 🔴 **卡片浮在畫面底部的內距 ＝ 頁尾留白**，一個數字管兩件事（founder 2026-08-29）。
       掛在 `#s-journey` 上而不是版面那條規則裡，因為 `.journey-panel` 要繼承得到它。
       ⚠️ 它同時是 `.is-empty` 的父選擇器，但空路那一屏的面板是 `position: static`、
          也沒有負 margin，所以那一屏吃不到這個變數的任何效果——空路的版面一格未動。

       🔴 **三個值不是三種螢幕大小，是三種「底下有沒有東西替版面收尾」**
       （founder 2026-08-29 提問：「你取的值都是針對手機，桌機卡片會太低嗎？」）。
       **這組數值不是新定的**——04e 的輸入列已經為同一個問題走查定案過，理由與數字逐條
       沿用 `50-input.css` 那兩段（那裡是真源，改這裡之前先讀那邊）：
         手機瀏覽器 1.25rem —— 底下真的有一條 Safari 工具列頂著，版面自己收得了尾。
         桌機       2rem    —— 分頁列／網址列都在**上面**，卡片底下就是視窗邊緣，
                              沒有東西收尾。⚠️ 2.5rem 在桌機經 founder 走查判為**過多**。
         加到主畫面 2.5rem  —— 沒有瀏覽器 chrome，視口一路延伸到安全區邊界。
       ⚠️ **桌機那條刻意排在最後、而且把 standalone 也涵蓋進去**，複製輸入列那邊的層疊
          結果：桌機安裝成 app 時吃 2rem 而不是 2.5rem（2.5rem 是**手機** standalone 走查
          過的值，桌機沒有）。少了選擇器清單裡的 `html.is-standalone`，id ＋ class 的權重
          會讓 standalone 那條贏，桌機 PWA 就會拿到沒走查過的 2.5rem。
       ⚠️ **刻意不沿用輸入列的 `:not(.chat-short)`**：那個 class 只由對話頁的 rAF 迴圈
          維護（`syncChatShort` 只在 `syncChatViewportHeight` 裡被呼叫），旅程頁根本不會
          設它——寫上去是一條這一頁不維護的假相依。
       ⚠️ 這個變數同時餵四個地方（黏著位置、負 margin、min-height、`--j-gap-end`），
          所以換值時**版面會自己補償、構圖不會跟著跑**；那正是把它們綁成一個變數的理由。
       ⚠️ `env(safe-area-inset-bottom)` 刻意不加：本檔的 viewport meta 沒有 viewport-fit=cover，
          那個值恆為 0（`50-input.css` 記過一次把 34px 誤判成 safe-area 的坑）。 */
    #s-journey { --j-panel-inset: var(--body-pad-x); }
    html.is-standalone #s-journey { --j-panel-inset: 2.5rem; }
    @media (min-width: 481px) {
      html #s-journey, html.is-standalone #s-journey { --j-panel-inset: 2rem; }
    }

    /* ── 詳情卡：跟路徑同寬、緊接在下方（demo 同型，手機桌機同版型）───────────
       min-height 讓卡片在節點之間切換時不會上下跳動；批次 A 的探針量到摘要偶爾會
       溢出 80 字的軟上限，所以這裡**不設任何截斷**（clamp／ellipsis 一律不加）——
       節點卡要吃得下 80–90 字，字多就把卡撐高。 */
    /* sticky 面板：路很長時（12 顆約一屏半到兩屏）卡片仍然看得到。
       ⚠️ 成立的前提是**沒有任何祖先設 overflow: hidden/auto**——#s-journey 是普通的
       .screen（display:block），body 也沒有；html.chat-fullscreen 的 overflow:hidden
       只掛在 s-input／s-record 那兩頁。哪天有人給 body 加 overflow，這裡會安靜地退回
       普通流（不報錯、只是卡片捲走了）。
       路短的時候（1 圈、5 圈）整頁不捲動，sticky 不會生效，看起來就是 demo 原本的樣子。 */
    /* 🔴 **黏著位置刻意等於 body 下緣留白（3rem），不是隨手取的小數字**
       （founder 2026-08-28 走查回報：「twelve 捲到最底，感覺留的空白好像比其他頁多」）。
       成因是**卡片在捲動尾端會退開**：黏著時它離畫面底 0.75rem，但捲到最底、黏不住之後
       它停的位置是離文件底 3rem（body 的下緣留白）——中間那 36px 的差就是它退開的距離，
       讀起來像「最後突然多出一塊空白」。兩個數字對齊之後，卡片從頭到尾停在同一個高度，
       退開的動作整個消失（實測飄移 36px → 0px）。
       ⚠️ 代價是捲動途中卡片比原本高 36px，路少看到 36px——這是**刻意換的**：
          「卡片不會自己動」比「多看 36px 的路」重要（那 36px 還是 SVG 自己的留白）。
       🔴 **這個值跟「頁尾留白」必須永遠相等，所以兩邊綁在同一個變數 `--j-panel-inset` 上**
          （founder 2026-08-29 走查：對齊成 3rem 之後「卡片離底部的空間好像變多了」）。
          第一版是把黏著位置拉高去對齊 body 的 3rem 頁尾留白——飄移確實沒了，但卡片也
          跟著浮高 36px。正確的做法是**把頁尾留白降下來就它**，不是把它升上去就頁尾留白。
          怎麼降：`#s-journey.active:not(.is-empty)` 用一段負的 `margin-bottom` 把 body 那
          3rem 抵掉一部分（見下方那條規則），min-height 與 `--j-gap-end` 同步補償。
          ⚠️ 四個地方吃同一個變數，改一個就要四個一起對：黏著位置、負 margin、min-height、
             `--j-gap-end`。動之前先跑一次那張 42 組合的對照表。 */
    /* 🔴 **路與卡片的交界不加淡出／漸層遮罩**（founder 2026-08-29 裁定，跟下面〈頁首〉那條「標題不 sticky」同一輪）。
       四個理由，日後重提時要一起帶著：
         ① 路本身對比已經偏低（線是 `--border`／`--border-strong`），再淡出，節點會讀成
            「停用／還沒走到」——而「還沒走到」在這一頁已經是虛線的語彙，會講錯話。
         ② 淡出只有在「確實還能繼續捲」時才是實話。捲到頂／底還留著常駐漸層＝在說謊，
            要做對就得補 scroll 監聽——那正是〈頁首〉那條不想養的東西。
         ③ 半透明但仍然點得到的節點＝視覺狀態與互動狀態分家。這一頁 8/27 才為了同一個
            毛病把大小與脈動從「你在這」搬去入口那顆，別從另一個方向做回來。
         ④ 分層已經由紙底 ＋ 框線做掉了，那圈框線的工作就是這個
            （`visual-foundation.md` ⑤〈框線規則〉，12 圈那個 variant 正是它的走查點）。
            再疊漸層＝兩個訊號做同一件事。
       ⚠️ **那道留著的口子 2026-08-29 打開了，但開的不是上緣**：founder 真機走查判定
          「路捲進卡片上緣沒問題」，要柔化的是**卡片下緣那條縫**——做法與射程見下方
          〈卡片下緣那道縫的柔化〉。整頁上下雙遮罩、路上緣的漸層仍然一律不做。 */
    .journey-panel { position: sticky; bottom: var(--j-panel-inset, 3rem); }
    /* ── 卡片下緣那道縫的柔化（founder 2026-08-29 走查後指定）─────────────────────
       捲動途中，卡片下緣與畫面底之間那條 `--j-panel-inset` 的縫**看得到路從底下穿過去**
       （手機 20px／桌機 32px；`twelve` 捲到一半時「起點」那個標籤就從縫裡露出來）。
       founder 走查判定：路捲進卡片上緣沒問題，但這條縫要**淡下去**，焦點才留在上面的路。
       🔴 **這不是被否決的那個「整頁上下淡出」**（那條與四個理由仍然有效，見上方 🔴）：
          射程只有**卡片下緣這一道接縫**，路的上緣、標題、卡片邊緣一律不加任何漸層。
          這正是那段註記裡留著的、唯一那道口子。
       🔴 **掛在面板身上（`top: 100%`），不是掛在畫面底部的 fixed 長條**，三個理由：
          ① 它跟著卡片走，卡片黏在哪它就在哪，不必再寫一份「卡片現在在哪」的邏輯；
          ② 它永遠落在面板**下方**，所以**碰不到卡片，也碰不到錯誤訊息**
             （`#err-journey` 在面板**裡面**、在卡片下面，位置永遠在這道漸層之上）——
             founder 走查時擔心的「錯誤文字會不會被淡掉」不會發生，不需要通融；
          ③ 捲到最底時那條縫底下**本來就沒有路**，漸層是紙色疊紙色＝看不見，
             不會變成「捲不動了卻還在暗示可以捲」那種說謊的常駐遮罩。
       ⚠️ `pointer-events: none` 不是保險而是必要：那道縫裡有節點的命中區
          （r=22 的透明圈比看得見的圈大得多），少了它捲到底最下面那顆會**點不到**。
       🔴 **是整條縫「均勻壓淡」，不是漸層淡出**（founder 2026-08-29 走查第二輪指定）。
          第一版做的是 `linear-gradient(transparent → var(--bg))`，被退回的理由是
          **漸層上緣那段幾乎沒被壓到，路照樣搶焦點**——它只是把「消失」推到縫的下緣，
          縫的上半仍然是全對比的路。均勻壓淡則是整條縫的路都只剩 `1 − --j-seam-dim` 的濃度。
          ⚠️ 上緣不會有可見的接縫：那條縫的正上方就是不透明的卡片（兩者同寬），
             路是從卡片底下鑽出來的，沒有「壓淡前的樣子」可以拿來對比。
       ⚠️ **紙色 ＋ `opacity` 而不是 `rgba()`／`color-mix()`**：紙是 `--bg` 這個 token，
          寫死 hex 會多一份會過期的複本（`visual-foundation.md` ①）。
          要調濃淡就調 `--j-seam-dim` 這一個數字（大＝路更淡），別去加高度——
          高度就是那條縫本身（`--j-panel-inset`），加高就會壓到卡片。
       ⚠️ 面板要有 `z-index`：`::before` 是面板的最後一個子元素，不指定層級時它會疊在
          卡片內容**之上**。這裡讓它待在面板的背景之上、內容之下即可。 */
    .journey-panel { z-index: 1; }
    #s-journey { --j-seam-dim: 0.7; }   /* 縫裡的路只剩 30% 濃度。⚠️ 這是唯一的旋鈕 */
    #s-journey.active:not(.is-empty) .journey-panel::before {
      content: ''; position: absolute; left: 0; right: 0; top: 100%;
      height: var(--j-panel-inset); pointer-events: none;
      background: var(--bg); opacity: var(--j-seam-dim);
    }
    .journey-card {
      max-width: 420px; margin: 0.75rem auto 0;
      border: 1px solid var(--border); border-radius: var(--radius);
      background: var(--bg); padding: 1rem 1.125rem; min-height: 5rem;
    }
    /* ⚠️ **日期底下那條分隔線 2026-08-29 拿掉了**（founder）。理由：日期已經比內文小一階、
       淡一層，層次早就成立了；再加一條線就是同一件事講第二遍（visual-foundation 推論 2
       「每個視覺元素只做一件工作」），而且「一行字＋一條橫線」讀起來像資料表的表頭，
       不像一篇日記。**別因為覺得「上下太黏」把它加回來**——要的話調 margin-bottom。 */
    .j-card-date {
      font-size: 0.8125rem; color: var(--text-faint);
      margin-bottom: 0.75rem;
      display: flex; align-items: baseline; gap: 0.5rem;
    }
    /* ⚠️ `.j-card-edited`（「已編輯」）2026-08-29 走查後連同它的 DOM 一起移除——
       沒有下一步的標記，理由記在 95-journey.js 的 renderJourneyCard。
       留這行是為了讓下一個人知道那個 class 是**被拿掉的**，不是漏寫的。 */
    /* 編輯鈕（04e，2026-08-29）：日期列最右的一顆鉛筆，取代原本卡片底部那條「編輯」連結。
       規格逐條照 `docs/contracts/icons.md`：20px（三級 15/16/20 之一）、線寬 1.7、
       `--text-faint` → hover 升一層 `--text-muted`、44×44 熱區、**不畫圓底**
       （實心圓全 app 只有送出鈕一顆）。
       ⚠️ **負 margin 是必要的，不是微調**：熱區 44px 比 20px 的圖示高一大截，不倒扣回去
          整條日期列會被撐高 12px，卡片跟著長高——這批正在做的事就被它吃掉一部分。
          `-0.75rem` ＝ (44−20)/2 的那一半留白，上下各扣一次；右邊也扣，圖示才對得齊
          卡片的右內距（而不是讓透明熱區的邊去對齊）。
       ⚠️ `align-self: center`：這一列是 `align-items: baseline`，那對兩段文字是對的，
          但按鈕沒有有意義的基線，跟著 baseline 走會浮高一點。 */
    .j-card-edit {
      margin-left: auto; align-self: center;
      width: 44px; height: 44px; padding: 0;
      margin-top: -0.75rem; margin-bottom: -0.75rem; margin-right: -0.75rem;
      display: inline-flex; align-items: center; justify-content: center;
      background: none; border: none; cursor: pointer; color: var(--text-faint);
    }
    /* 補 background:none：否則全域 button:hover 的深底會洩進來變一坨黑（同 .link-btn 那個坑）*/
    .j-card-edit:hover { color: var(--text-muted); background: none; }
    /* ⚠️ **16px／線寬 2**（2026-08-29 走查後從 20px／1.7 收下來）：拿掉底下三顆之後，
       鉛筆變成卡上唯一的控件，20px 讓它有點像新的視覺焦點。
       線寬要跟著換——icons.md 明訂「顯示 20px 用 1.7；顯示 15–16px 用 2」，只改尺寸
       不改線寬，圖示在小尺寸下會變得比旁邊的字還細、看起來像沒畫完。
       🔴 **刻意不用 `opacity` 壓它**（提案是 55–65%）：`--text-faint` 已經是三層灰的最淡一層，
       再乘 opacity 等於偷偷開第四層（`visual-foundation.md` ② 明文不准），而且對 `--bg`
       的對比會從 3.86:1 掉到 2.2:1 上下——那是卡上**唯一**的控件，不能讓它變成看不見。
       減重量用**尺寸**，不用透明度。 */
    .j-card-edit svg {
      width: 16px; height: 16px; fill: none; stroke: currentColor;
      stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
    }
    .j-card-quote { font-size: var(--font-secondary); color: var(--text); line-height: 1.75; }
    /* ⚠️ **引號「」2026-08-29 走查後拿掉**（founder）。原本用 ::before/::after 掛（沿用 demo）。
       理由：這張卡本身就是使用者自己的紀錄，卡上沒有第二個說話者，再標一次「這是引用」
       會讓它讀起來像「系統正在引述你說過的話」，而不是他路上的一段紀錄。
       ⚠️ 我當時的反對意見留在這裡供日後對照：那句摘要**多數是 LLM 寫的**（第一人稱「我」，
       只有生不出來時才退回原話），所以引號其實也在講「這是你的話、不是系統的描述」。
       **哪天同一張卡上同時出現「AI 的整理」與「使用者原話」，引號才會重新有語意**——
       那時再談，不要現在補回來。 */
    /* 還沒有摘要的節點（那一圈沒有原話，或摘要當時生不出來）：說實話，不編一句話填進去。
       它仍然可以被編輯——見 docs/contracts/journey.md §6。 */
    .j-card-empty { font-size: var(--font-secondary); color: var(--text-faint); line-height: 1.75; }
    /* 「補一句」接在「這天還沒有留下文字。」後面同一行（2026-08-29）。
       ⚠️ 它刻意**不是**整句可點：可點的只有那個邀請本身，前面那句是陳述。
       空白狀態需要的是一句邀請，一顆鉛筆圖示做不到——所以沒有摘要的節點不給 .j-card-edit。 */
    .j-card-write { margin-left: 0.5rem; }
    /* ⚠️ **有照片時這一列整個不存在**（renderJourneyCard 只在 !has_image 時建立它）。
       原本這裡是「編輯／換一張／下載」三條並排，2026-08-29 收成「加一張照片」一顆。 */
    .j-card-actions { margin-top: 0.875rem; display: flex; gap: 1rem; }

    /* 批次 B 留的 .j-card-thumb 佔位框已於批次 D 換成真的圖，樣式見本檔最後
       〈節點照片〉那一段（`.j-card-photo`）。 */

    /* ── 編輯模式 ────────────────────────────────────────────────────────────
       為什麼一定要有字數：後端硬上限 500 字，而 LLM 掛掉時摘要存的是使用者自己打的
       原話、**刻意不受上限管**（docs/contracts/journey.md〈500 字上限的一個已知不對稱〉）。
       所以原話很長的那一圈，按下編輯就已經超過上限了——沒有字數的話，他會一路打完
       才被「太長」打回來，而且不知道要刪多少。 */
    /* 🔴 **字級不得低於 16px（1rem），也不要換回 `--font-secondary`（15px）**：iOS Safari
       對焦 computed font-size < 16px 的輸入框時會自動放大整頁，而鍵盤收起來**不會縮回去**，
       整個旅程頁就卡在 16/15 ≈ 1.067 倍的縮放（2026-09-02 founder 真機截圖，量到的倍率
       正好是這個數）。這是全站唯一低於 16px 的輸入框，所以只有這一頁會中。
       ⚠️ 跟 `.j-card-quote`（15px）刻意差 1px：讀跟寫本來想同尺寸，但「編輯時字跳 1px」
       感覺不到，「收鍵盤畫面歪掉」每次都看得到——用前者換後者。要消掉那 1px 就得連
       `.j-card-quote` 一起改，那會在字級系統裡多開一個尺寸，不划算。 */
    .j-edit-text {
      width: 100%; min-height: 7rem; margin-bottom: 0.5rem;
      font-size: 1rem; line-height: 1.75;
    }
    .j-edit-meta { display: flex; justify-content: flex-end; font-size: 0.8125rem; color: var(--text-faint); }
    .j-edit-meta.is-over { color: var(--danger); }
    .j-edit-actions { margin-top: 0.75rem; display: flex; gap: 0.75rem; justify-content: flex-end; align-items: center; }
    .j-edit-save { padding: 0.45rem 1.25rem; font-size: 0.9rem; }

    /* ── 頁首 ────────────────────────────────────────────────────────────────
       ⚠️ 這裡**曾經**有 .journey-back（「← 回到今天」）。批次 G 由 founder 走查後移除：
       導航的「探索」分頁回的是同一個地方，兩個出口冗餘。 */
    /* 🔴 **標題刻意不做 sticky、也不做捲動收合**（founder 2026-08-29 裁定）。
       它的工作是「看一眼就知道這是哪一頁」，看過一次就夠。收合要多養一套 scroll 監聽
       ＋動畫狀態機，換到的是一個沒有人回報過的問題。
       ⚠️ **卡片是另一回事，別為了「一致」把標題也黏住**：卡片是點節點時唯一會變的東西，
          捲走了就等於點了沒反應（理由在下方 `.journey-panel`）。兩者不同命是刻意的。 */
    #s-journey h1 { margin-bottom: 1.5rem; }

    /* ── 有路、但路還很短時的版面（founder 2026-08-28 走查：「節點少的時候重心偏上」）──
       實測 390×844：1 圈的內容在 383px 就結束，下面空 461px ＝**整屏的 55%**；
       5 圈剩 17% 且已經要捲，12 圈幾乎滿版。也就是這只是 **1–3 圈**的問題，路一長就自己好。
       成因不是「路太短」，是整組內容**貼在上緣**、下面那塊沒人管（其中 160px 還是結構性的：
       body 的 margin-bottom 3rem ＋ `has-fixed-input` 那 7rem——旅程頁根本沒有輸入列；
       ⚠️ 後者 2026-08-28 已整條移除，所以現在結構性的那塊只剩 3rem＝48px，這段文字
       描述的是**當時**的量測情境，數字別拿去對照今天的畫面）。
       做法：**標題釘在上方不動**（跟多節點時同一個位置），只有「路 ＋ 卡片」這一組在剩下的
       空間裡垂直置中。5 圈起沒有剩餘空間，版面**逐像素**等於改動前。
       🔴 **這一版是 founder 2026-08-28 真機走查後指定的，中間繞過一次，別再繞回去**：
          先做「標題不動」→ agent 看圖自己否決（標題與路之間空 183px，標題像被孤立）
          → 改成「標題／路／卡片整組一起置中」→ **founder 走查後要求退回標題不動**：
          「標題固定在上方就好，跟多個節點一樣，整個頁面重心才不會太集中中間。」
          ⚠️ 所以 h1 與路之間那段空白是**已知且被接受的取捨**，不是漏調的 bug——
             它換到的是「標題永遠在同一個位置」。要動它先問 founder，別自己「順手修好」。
          ⚠️ 也**別**把 auto margin 搬回 h1（那就是被退掉的整組置中版）。
       🔴 **用 auto margin，不用 `justify-content: center`**：內容比容器高時，
          `justify-content: center` 會把**上緣切掉**（捲不回去、也沒有錯誤訊息），
          而 auto margin 在沒有剩餘空間時直接解析成 0，自動退回今天的樣子。
          這一頁路長到 12 圈會是兩屏，那條路一定會走到。
       ⚠️ min-height 的算式跟下面 `.is-empty` **刻意同一條**（viewport 扣掉 body 上下 margin，
          就這樣而已）：兩屏的可用高度要一致，接起來才不會有落差。
       🔴 **2026-08-28 少扣了一格 `--input-bar-h + 1rem`，別加回來**：舊版扣那一格是因為
          `has-fixed-input` 在 body 上替「打字列」預留了同樣一格——旅程頁沒有打字列，
          那格預留已整條移除（見 10-base.css）。少扣之後這一頁剛好是一個視窗高，
          捲到底不再有那片什麼都沒有的空白（實測舊版 212px、與節點數無關）。
       ⚠️ 三條規則是一組，別只抄其中一條（各自的理由寫在下面）。 */
    #s-journey.active:not(.is-empty) {
      /* grid 而不是 flex：兩個接縫要能**按比例**分剩餘空間，flex 的 auto margin 只會均分。
         第 2、4 列是空的伸縮軌，`fr` 就是那個比例；沒有剩餘空間時空軌收成 0，
         版面逐像素回到改動前。 */
      --j-gap-top: 1.8fr;       /* 標題→路 */
      --j-gap-bottom: 1fr;      /* 路→卡片。⚠️ 這兩個是**比例**不是長度，別換成 px／rem */
      /* 🔴 **卡片的保留版位——這條是防抖動的，不是留白**（founder 2026-08-28 走查回報：
         「第三圈的節點點選時路線圖會抖動」）。
         成因：不同節點的摘要行數不同，卡片高度會變（實測 3 圈那組是 158 ↔ 132）。
         剩餘空間 ＝ 容器高 − 內容高，卡片一變矮剩餘就變多，**路就被重新分配往下跳 15px**。
         也就是改成分配剩餘空間之後，路的位置被綁在卡片高度上了。
         解法：第 5 列給**固定高度**，卡片多高都不參與剩餘空間的計算——路的位置只跟
         容器高、標題、路自己有關，全都是不會隨點選改變的東西。
         ⚠️ 先試過 `minmax(10rem, auto)`，**沒修乾淨**（抖動從 15px 只降到 7px）：
            那條軌道量的是**面板**高度＝卡片 ＋ 它上面的 12px margin（實測 170／144），
            10rem＝160px 蓋不住 170 那一版，於是超出的部分又回去推剩餘空間。
            **要蓋住的是面板不是卡片**，而且只要有一格蓋不住就等於沒修。
         ⚠️ 比保留版位更高的卡片會往**下**長（畫面底下還有 160px 的結構性留白接得住，
            再長就換頁面捲動）——它推的是自己下面的空白，**不會再動到路**。這是刻意的：
            長摘要本來就該給它空間，但不該讓上面的東西跟著跳。 */
      --j-card-reserve: 9rem;
      /* 🔴 **卡片下方的第六軌——這條是「構圖不要被剩餘空間撐開」的煞車**
         （founder 2026-08-28 走查回報：拿掉那格 112px 預留之後「one 整個重心太低了」）。
         成因：那 112px 從「頁面尾端的死空白」變成了**可分配的剩餘空間**，於是整組構圖
         被撐開——1 圈實測標題→路從 179 變成 251、路→卡片從 99 變成 139。
         🔴 **上限值 7rem 就是被移除的那格預留（`--input-bar-h` 6rem ＋ 1rem）**，不是湊的：
            剩餘空間先把這一軌填到 7rem，填滿之後**剩下的**才輪到上面兩個接縫按 1.8:1 分——
            也就是上面兩軌拿到的，逐像素等於「那格預留還在的時候」拿到的。1～4 顆的構圖
            因此回到 founder 8/28 走查通過的那一版，而卡片下方那 112px 現在是**畫面內、
            捲不到的**留白，不再是捲到底才看得到的死空白。
         🔴 **那個 `+ 0%` 是整條規則的開關，不是贅字，千萬別「化簡」掉。**
            它要同時滿足兩件互相矛盾的事：
              ① 路短（版面高度由 `min-height` 決定）→ 這一軌要**剛好 7rem**；
              ② 路長（版面高度由內容決定）→ 這一軌要**收成 0**，否則捲動尾端又多出
                 112px 的死空白，等於把剛修好的 bug 原封不動裝回去。
            CSS 的規則正好給了這個開關：**容器高度不定時，軌道裡的百分比會退化成 `auto`**，
            而這一軌沒有子元素，`auto` 就是 0。加一個 `0%` 進去，整個 `calc()` 就跟著吃這條
            規則——高度定得下來時它是 7rem，定不下來時它是 0。
         🔴 **三種寫法實測過，只有這個對**（2026-08-28，42 種組合＝6 種容器高 × 7 種路高，
            逐一跟「那格預留還在」的版面對照）：
              `minmax(0, 7rem)`  ✗ 路長時仍佔滿 7rem → base 多捲 112px、twelve 多捲 112px
              `1.13fr`           ✗ 按比例分，只有 1 圈剛好對上，中間路高最多差 51px
              `minmax(0, calc(7rem + 0%))` ✓ 42 種組合**全部逐像素相同**
            要改這一行，先把上面那張對照表重跑一次，別靠讀規格推。
         ⚠️ 剩餘空間是 0 時（實測 **5 圈起就是 0**）這一軌收成 0，版面逐像素不變——
            所以它天生只作用在 1～4 顆，**不需要也不要**加任何「幾顆以下才套用」的門檻
            （那條原則見下方 🔴）。
         ⚠️ 這一軌**沒有任何子元素**，它就是一塊空白。 */
      --j-gap-end: calc(7rem + 3rem - var(--j-panel-inset));
      display: grid;
      grid-template-rows: auto var(--j-gap-top) auto var(--j-gap-bottom)
                          var(--j-card-reserve) minmax(0, calc(var(--j-gap-end) + 0%));
      /* 🔴 **頁尾留白降到 `--j-panel-inset`，好讓卡片能貼得比 body 的 3rem 更低**
         （founder 2026-08-29）。body 的 `margin: 3rem auto` 是全站的，這一頁不該去改它
         （其他每一頁的頁尾留白都靠它）——所以用一段**負的 margin-bottom** 只在這一頁把
         多出來的那截抵掉。抵掉多少：`3rem − --j-panel-inset`。
         ⚠️ min-height 跟著從「扣 6rem」改成「扣 3rem ＋ 那段留白」：doc 高度必須仍然剛好
            等於一個視窗，否則短路徑又會冒出可捲的死空白（那正是這整批在修的東西）。
         ⚠️ `--j-gap-end` 也跟著補償：容器變高多少，那一軌就要多吃多少，短路徑的構圖才會
            繼續逐像素等於 founder 8/28 走查通過的那一版。
         ⚠️ **這三條 ＋ `.journey-panel` 的 `bottom` 是一組四條，不能只改其中一條。** */
      margin-bottom: calc(var(--j-panel-inset) - 3rem);
      min-height: calc(100vh - 3rem - var(--j-panel-inset));
      min-height: calc(100dvh - 3rem - var(--j-panel-inset));
    }
    /* 🔴 **剩餘空間按 1.35:1 分給兩個接縫**（標題→路 ／ 路→卡片），走過三版才定：
       ① 全倒進「路的上面」與「卡片的下面」→ 1 圈量到 177／52／311：中間那縫一格沒分到，
          **卡片貼在圓點下面**（founder 真機走查截圖指出）。
       ② 改成兩縫**均分** → 1 圈 175／163：卡片不擠了，但空間越大上下越接近 1:1，
          路往中間漂、**跟卡片脫節**（founder：「1,2,3 圈的路線圖整體偏上、和卡片脫節，
          4 圈的比例才平衡」）。
       ③ 現在按比例分。為什麼 4 圈本來就平衡：它的剩餘空間小，被固定的 24px（h1 的
          margin-bottom）與 12px（卡片的 margin-top）主導，上下比自然落在 1.29——
          **1.35 就是照著 4 圈那個比例回推的**，不是隨手挑的數字。
       ⚠️ **要調就只調 `--j-gap-top`**（往上加＝路再往下）。那是這一段唯一的旋鈕，
          別回去動 auto margin 或 padding，也別把 fr 換成 px——換成長度就不再隨節點數自適應了。
       🔴 **這條原則刻意不看節點數**：CSS 裡沒有任何「幾顆以下才套用」的門檻。
          有剩餘空間就分、沒有就自動失效——1 顆到 12 顆同一條規則，
          不需要替「節點少」另外養一套版型。 */
    #s-journey.active:not(.is-empty) > h1 { grid-row: 1; }
    /* ⚠️ `width: 100%` 不是裝飾，**少了它路會縮小**：`.journey-path-wrap` 有左右 auto margin，
       而 cross axis 上的 auto margin 會取消 grid／flex 的 stretch，寬度掉回內容本身的大小
       ——SVG 的內在寬度就是 viewBox 的 300，於是手機上從 350px 縮成 300px（2026-08-28 實測）。
       下面 `.is-empty > *` 那條 `width: 100%` 是同一個坑的同一個解，兩處刻意寫成一樣。 */
    #s-journey.active:not(.is-empty) .journey-path-wrap { grid-row: 3; width: 100%; }
    /* `align-self: start`：保留版位比卡片高時，面板維持自己的高度、不被拉伸
       （它是 `position: sticky` 的，拉伸會動到黏著行為）。多出來的空間留在卡片**下方**，
       不是塞進卡片裡——卡片本身維持貼合內容，不會變成一個鬆垮的空盒子。 */
    /* 🔴 **`min-height` 是「卡片不許飄」那條不變量的第五條，2026-08-29 補上。**
       第 5 軌是**固定** `--j-card-reserve`（9rem），面板 `align-self: start` 貼著軌道上緣。
       面板**比軌道矮**時，軌道下緣就多出一段沒有人佔用的空白——而長路徑時第 6 軌收成 0，
       那段空白就直接落在**文件的最尾端**：頁面因此比「黏著位置想停的地方」多捲得動那幾 px，
       捲到最底時卡片會往上「退開」同樣的距離。
       實測（`twelve`＠390×844）：詳情卡收窄前面板剛好 144 ＝ 軌道，餘裕 0、飄移 0；
       收窄後卡片矮了 7px（132→125），面板 137 < 144，尾端多出 7px，**飄移 6.8px**。
       ⇒ 讓面板永遠填滿那一格，餘裕恆為 0。
       ⚠️ 這**不會**動到構圖：面板仍然貼上緣，卡片的 top 一格未變（多出來的空白落在面板
          內部、`#err-journey` 底下）；比軌道高的面板（有照片時 258px）根本吃不到 min-height，
          照舊往下長。
       ⚠️ **不要改成 `align-self: end` 或把 `--j-card-reserve` 調小**來修這件事：前者會讓
          過高的面板往**上**溢出去壓到路（現況是刻意往下長）；後者是拿一個量出來的數字去
          追另一個會變的數字，下一次卡片內容一改又會漂掉。
       ⚠️ 這條跟 `bottom`／`margin-bottom`／`min-height`／`--j-gap-end` 那組**是同一組**，
          別只改其中一條。 */
    /* 🔴 **這個面板不准變成 flex／grid 容器。** 2026-08-29 一度加過
       `display:flex; flex-direction:column; justify-content:flex-end`（想把上面那段餘裕
       壓到卡片**上方**，好讓「卡片下緣到文件底」量回 20 而不是 27）。founder 走查當場抓到
       兩個症狀：**卡片時大時小**、**有照片的卡片按下編輯就縮掉**。
       成因：`.journey-card` 是 `margin: 0.75rem auto 0`——**在 flex 容器裡，橫向的
       `margin: auto` 會吸收剩餘空間並取消 stretch**，卡片於是變成「依內容決定寬度」。
       實測同一頁六顆節點量到 **176／240／350px** 三種寬度（摘要越短卡越窄）。
       ⇒ 維持**普通區塊流**，餘裕就留在卡片下方。那 7px 只出現在最矮的卡片上，
       面板下緣離文件底仍然精準等於 `--j-panel-inset`，飄移仍是 0——
       **拿「卡片下緣量回 20」去換一個看得見的寬度 bug，不划算。** */
    #s-journey.active:not(.is-empty) .journey-panel { grid-row: 5; align-self: start; }
    /* 🔴 **保留版位要由「卡片」撐滿，不是由面板撐滿**（2026-08-30 走查修正）。
       2026-08-29 先把 `min-height` 加在**面板**上——飄移是修好了，但短卡片填不滿面板，
       多出來的那幾 px 就空在**卡片下方**：走查回報「第三、四個節點底部留下的縫隙跟
       第一、二、五不一樣」（實測 gap 0 ↔ 7px）。
       ⇒ 改成卡片自己有下限。`--j-card-reserve` 減掉卡片的 `margin-top`，兩者相加剛好
       等於第 5 軌 → 面板永遠不比軌道矮（飄移仍 0），而且**卡片下緣永遠貼著面板下緣**
       （縫隙恆為 0，不再隨節點變）。
       ⚠️ 這是「下限」不是固定高度：長摘要照樣把卡片撐高、往上長（面板是 sticky-bottom，
       底邊釘住）——那正是 founder 要的「寬度固定、高度隨內容擴展」。
       ⚠️ 兩個數字是一組：改 `--j-card-reserve` 或卡片的 `margin-top`，這條要跟著改。 */
    #s-journey.active:not(.is-empty) .journey-card {
      min-height: calc(var(--j-card-reserve) - 0.75rem);
    }
    /* ⚠️ 三個 `grid-row` 要顯式寫：`#s-journey` 只有這三個子元素（h1／路／面板），
       哪天多一個沒指定列的子元素，它會**自動掉進第 2、第 4 或第 6 列**（那三軌都是空的
       伸縮軌），畫面會安靜地擠成一團。加子元素就順手給它一列。 */

    /* ── 第一次進來（空路）的版面（founder 2026-08-27 立、2026-08-28 收成兩個元素）──────
       這一屏現在只有**標題 ＋ 一顆圓**：圓圈旁的「開始今天」與底下那句「先走一步…」
       都拿掉了（理由在 95-journey.js 的 labelText 那段）。所以這裡的工作只剩一件事——
       讓那兩個元素讀成**一個小構圖**，下面那片空白才會像「還沒展開的路」而不是「還沒排完」。
       整組往畫面中央移，但**不是數學正中央**：視覺重心落在 viewport 高度約 45%（桌機）／
       41–42%（手機）的位置（optical center；正中央會看起來偏低，手機螢幕長更明顯）。
       ⚠️ 這兩個值是**逐值對照瀏覽器量出來的定位參數**，不是區塊之間的 gap，
          刻意不在間距尺標的八級上（同 `auth-access.md` §5 登入頁那組 padding 的理由）。
          改之前先量，不要「順手對齊尺標」。
       🔴 **重心的位置沒有變過，但表達它的方式 2026-08-28 換了第二次，別照時間順序回推**：
          容器是 `justify-content: center`，`padding-bottom` 把內容往**上**推、`padding-top`
          往**下**推，而重心＝`容器頂 + (容器高 − padding)/2`，所以 **min-height 一改，
          padding 就必須跟著重算**——這兩個永遠是一組，別只改一邊。
          ① 更早：`padding-bottom: 8vh`（「標題／圓／說明文字」三塊的時代）。
          ② 說明文字拿掉、整組變短 → 改 `padding-top`（3.5rem／手機 1rem）把重心壓回來。
          ③ 現在：min-height 少扣了一格 `--input-bar-h + 1rem`（那格 body 預留整條移除，
             見 10-base.css），容器變高 112px，`padding-top` 已經**沒有辦法**把重心推得夠高
             （要負值），所以換回 `padding-bottom`：桌機 3.5rem、手機 6rem。
          ⚠️ ②→③ 看起來像「把 ① 的做法繞回去」，**不是**——③ 的數值是照 ② 的實際重心
             反解出來的。四種尺寸（390／768 × 844／700）逐一量過，重心位移 **0px**、
             文件高也一致，也就是 founder 2026-08-28 兩輪真機走查定下的位置**逐值保住**。
             要再動它，先量現在的重心，別從註解的時間順序猜。
       ⚠️ 兩個元素收進同一個 420px 欄寬（跟路徑、卡片同寬），才會讀成一組而不是兩塊。 */
    #s-journey.is-empty.active {
      display: flex; flex-direction: column; justify-content: center;
      min-height: calc(100vh - 6rem);
      min-height: calc(100dvh - 6rem);
      padding-bottom: 3.5rem;   /* ⚠️ 跟上面 min-height 是一組，見上方 🔴 ③ */
    }
    #s-journey.is-empty > * { width: 100%; max-width: 420px; margin-left: auto; margin-right: auto; }
    /* ⚠️ **水平置中是這一頁刻意開的第二個例外**（第一個是完成畫面那句終點宣告，見 20-screens.css）。
       理由：路徑 SVG 本來就是置中的（節點畫在 viewBox 中線），標題若維持整站的左對齊，
       標題與圓圈會各站各的位置、讀不成「一組」——而 founder 要的正是「整組」。
       ⚠️ **別類推**到有節點的旅程頁或其他畫面：那些頁的左對齊骨架沒有變
       （visual-foundation.md 那條仍然有效）。這裡是空路那一屏專屬。 */
    /* ⚠️ 這裡**曾經**還有 `#s-journey.is-empty .j-card-empty`（置中＋次級灰）。
       2026-08-28 之後空路的卡片是空的，那兩條選不到任何東西，一起刪掉——
       留著會讓下一個人以為那一屏還有一句說明文字。 */
    #s-journey.is-empty h1 { text-align: center; }
    /* 標題 → 圓圈的距離。⚠️ **這個值不等於看到的間距**：SVG 自己上緣還留了一段透明留白
       給呼吸光暈擴散（viewBox 的 J_TOP_PAD，換算後桌機約 20px、手機約 16px），
       所以看到的是「margin ＋ 那一段」。桌機 2rem → 約 52px、手機 1.5rem → 約 40px。
       ⚠️ **這一屏刻意比原本的規格鬆**（2026-08-28 兩輪真機走查）：第一版照規格取
          32／24，founder 回報太擠；放寬到上緣的 36／28 仍然太擠；第三輪他直接指定
          「再加目前距離的一半」＝現在這組。原本的 28–36／24–32 規格**已被實機推翻**，
          別拿那個範圍回來「修正」這兩個值。
       ⚠️ 為什麼會偏離直覺：這一屏只有兩個元素，標題與圓之間沒有任何東西承接，
          同樣的數字在這裡看起來比在有內容的頁面上緊。
       ⚠️ 改這個值之前先想到那段透明留白，不然量出來的數字會跟直覺差一截。 */
    #s-journey.is-empty h1 { margin-bottom: 2rem; }
    /* 空路時沒有東西會捲，sticky 沒有意義，而且它會在 flex 容器裡佔一個奇怪的高度 */
    #s-journey.is-empty .journey-panel { position: static; }
    /* 空路的卡片現在是**空的**（renderJourneyCard 直接 return），margin-top 歸零才不會在
       圓圈下面留一段看不見的幽靈間距，把整組往上推歪。 */
    #s-journey.is-empty .journey-card {
      border: none; background: none; padding: 0; min-height: 0; margin-top: 0;
    }
    @media (max-width: 480px) {
      /* 手機比桌機再高一點（約 41–42vh vs 45vh）：螢幕長，數學置中會讓整組「飄在中間、
         手還要伸比較高」。所以往上推的 padding 給得比桌機**多**。
         ⚠️ 方向與桌機相反的直覺陷阱：改用 `padding-bottom` 之後「值越大＝重心越高」，
            跟舊版 `padding-top`（值越大＝重心越低）剛好相反，所以手機從「比桌機小」
            變成「比桌機大」。這不是筆誤。 */
      #s-journey.is-empty.active { padding-bottom: 6rem; }
      #s-journey.is-empty h1 { margin-bottom: 1.5rem; }
    }
    .journey-empty { color: var(--text-muted); font-size: var(--font-secondary); line-height: 1.75; }
    #err-journey { max-width: 420px; margin-left: auto; margin-right: auto; }
    #err-journey:empty { display: none; min-height: 0; margin-top: 0; }

    /* ⚠️ 完成畫面上那顆「看看你走過的路 →」（.journey-link／.journey-link-row／.j-arrow）
       在批次 G 由 founder 走查後移除，理由是「導航的旅程分頁到得了同一個地方」。
       「旅程當首頁」那批把導航拿掉，**那個理由就不成立了**——完成畫面與「今天先不做」的
       收尾刻意零按鈕零連結，沒有出口會變成真正的死路。所以那個位置現在有一顆
       「回到旅程」（.done-journey），樣式在下面。⚠️ 兩顆的下場不同：完成畫面那顆放回來了，
       **旅程頁那顆「← 回到今天」沒有**（今天的探索是單向的，見 index.html 那段註解）。 */

    /* 批次 B 的常駐小 pill（#btn-journey-entry）在批次 F 被雙分頁導航取代，導航本身又在
       「旅程當首頁」那批退場。留這段是為了那條經驗：常駐入口刻意不上 s-input／s-record
       （會壓在滿版對話上），而現在整條路上一顆常駐入口都沒有——那是設計，不是漏掉。 */

    /* ── 節點照片（批次 D，2026-08-22）────────────────────────────────────────
       圖片本身由 JS 用 objectURL 塞進來（私有 bucket 要帶 token，<img src> 直連會 401，
       見 public/app/95-journey.js）。這裡只管版型。 */
    .j-card-photo {
      display: block; width: 100%; padding: 0; margin-top: 0.75rem;
      border: 1px solid var(--border); border-radius: var(--radius-sm);
      background: var(--surface); overflow: hidden; cursor: zoom-in;
    }
    .j-card-photo:hover { background: var(--surface); border-color: var(--border-strong); }
    /* 縮圖裁進固定高度：卡片是「那天留下了什麼」的摘要，不是相簿——
       直拍的照片把卡片撐成一根柱子會把摘要擠出畫面。要看全張就點開。 */
    .j-card-photo img { display: block; width: 100%; max-height: 11rem; object-fit: cover; }
    /* 🔴 **手機再壓到 7rem（112px）**（founder 2026-08-29）。理由不是「手機比較小」，是
       **點縮圖就看得到全圖**——卡片裡的圖只需要讓人認出「喔是那張」，認出來不需要 176px。
       11rem 在 390pt 的螢幕上讓整張卡吃掉快 40% 的畫面，路就不是主角了。
       ⚠️ 桌機**刻意不跟著縮**：那張卡兩側留白很多，本來就不吃緊，縮了只是變小氣。
       🔴 **這段必須放在 `max-height: 11rem` 那條之後。** 本檔第 ~500 行另有一個
          `@media (max-width: 480px)` 區塊（服務 `#s-journey.is-empty`），寫進那裡會被
          下面這條基礎規則蓋掉，**而且 CSS 不會報錯**——畫面上就只是縮圖沒變小。
       ⚠️ 值是**上限**不是固定高度：寬圖照原比例算出來比 112px 矮時就維持它自己的高度，
          `object-fit: cover` 只在超過上限時才真的裁到。 */
    @media (max-width: 480px) {
      .j-card-photo img { max-height: 7rem; }
    }
    .j-photo-status {
      max-width: 420px; margin: 0.5rem auto 0;
      font-size: 0.8125rem; color: var(--text-faint); text-align: right;
    }
    .j-photo-status[hidden] { display: none; }

    /* 放大看：蓋滿畫面、點哪裡都關。不做縮放手勢與翻頁——那是相簿 App 的工作。
       用 rgba 而不是 token：這一層是「畫面之外」的暗底，不是頁面的表面之一。 */
    .journey-lightbox {
      position: fixed; inset: 0; z-index: 50;
      display: flex; flex-direction: column; align-items: center; justify-content: center;
      gap: 1rem;
      /* 🔴 **四周要留白**（2026-08-29 走查：照片幾乎塞滿，讀起來像「圖片蓋住畫面」而不是
         「我正在看一張照片」）。手機左右 20／上下 24，另外把 safe-area 加進去——
         不加的話瀏海機的上緣會壓到狀態列。桌機給到 40（兩側本來就寬鬆，留白才撐得住）。
         ⚠️ 這個 padding 直接決定圖片的可用高度（圖片是 flex:1 1 auto），
         實測 24px 時圖片佔 87% 視窗高、40/24 之後落在 ~80%。 */
      /* 上下不對稱是刻意的（2026-08-30 走查：「上方有一點空，整體可以往上移一些」）：
         上緣只留給 × 那一列的呼吸，下緣多留一點，整組（照片＋兩顆操作）的視覺重心因此
         往上移。⚠️ safe-area 兩邊都要加——上緣是瀏海／狀態列，下緣是 home indicator。 */
      padding: calc(0.75rem + env(safe-area-inset-top, 0px)) 1.25rem
               calc(2rem + env(safe-area-inset-bottom, 0px));
      /* 🔴 **0.88 → 0.95**（2026-08-29 走查）。88% 的時候背後的標題「你走過的路」與卡片
         輪廓還讀得出來，整層讀成「一張圖蓋在頁面上」而不是「我正在看一張照片」——
         走查回報的「viewer 不夠 modal」是這個，不是層級或覆蓋範圍的問題
         （實測 overlay 本來就是滿版 fixed、z-index 50，六個邊角取樣全部命中它）。
         ⚠️ 用**加深暗底**不用模糊：全 app 零 `backdrop-filter`／零陰影，為一個畫面開特例
         換不到東西，而且模糊在低階手機上是真的會掉幀的那種效果。
         ⚠️ 仍然不是純黑：純黑在暖紙底上會發灰、看起來像沒對準。 */
      background: rgba(20, 18, 16, 0.95); cursor: zoom-out;
    }
    /* 🔴 **`flex: 1 1 auto` ＋ `min-height: 0` 這一組是高度契約，不是排版裝飾。**
       底下多了一列動作之後，只留 `max-height: 100%` 是不夠的——那個 100% 量的是整個
       overlay 的內容高，**不會扣掉動作列與 gap**，於是直拍照片會把兩顆鈕擠出畫面底下。
       flex item 的 `min-height` 預設是 `auto`（＝不小於內容），少了 `min-height: 0`
       圖片就縮不下去，寫了 `flex-shrink` 也沒用。
       ⇒ 圖片可用高度 ＝ overlay 高 − padding 上下 − 動作列高 − gap，由 flex 自己算。
       ⚠️ 驗收條件是**行為**：390×700／390×844／768×900 三個尺寸，整張直拍圖與兩顆鈕
          都要完整看得到、點得到（走查用 variant=photos 那張直拍 fixture）。 */
    .journey-lightbox img {
      flex: 1 1 auto; min-height: 0;
      max-width: 100%; max-height: 100%; object-fit: contain;
      border-radius: var(--radius-sm);
    }
    /* 兩顆低頻操作（換圖／下載）。**文字鈕、不是 icon** —— 判準在 contracts/icons.md，
       理由記在 95-journey.js 的 openJourneyLightbox。
       ⚠️ `cursor: default`：overlay 是 zoom-out（點哪裡都關），但這兩顆不是「關閉」，
          游標要講不一樣的話。 */
    /* 關閉鈕：點背景就會關是對的，但**沒有任何地方講出來**——× 是講出來的那個版本。
       ⚠️ 它是 escape hatch，但**比另外兩顆文字鈕重要**（唯一的出口），所以只有它是 0.95。
       不加底、不加框（icons.md：實心圓全 app 只有送出鈕一顆）。 */
    /* × 自己一列（靠右）。⚠️ **不要改回 `position: absolute`**——不佔高度就一定會壓在
       滿版的照片上（2026-08-30 走查在真機上看到的就是那個）。
       ⚠️ 負的 `margin-right`／`margin-top` 是把**看得見的圖示**推到畫面右上角
       （走查：「X 位置應該可以更偏右上角一點」）：44px 熱區照留，只是熱區的內距被抵掉，
       所以圖示貼角、手指的目標仍然是 44×44。 */
    /* 🔴 **角落內距的算式**：看得見的圖示離角落 ＝ overlay 的 padding ＋ 這裡的負 margin
       ＋ 12px（20px 圖示在 44px 熱區裡置中，上下左右各留 12）。
       目標是**上下左右一致**：手機 20／桌機 28（桌機畫面大，貼太近反而像沒對齊）。
       ⚠️ 手機與桌機的 padding 差 28px（12 vs 40），所以**不能共用同一組負 margin**——
       2026-08-30 走查回報的「手機偏上、桌機偏下」就是共用造成的（實測手機 16／桌機 44）。
       改任何一邊的 padding，這裡的負 margin 要跟著重算。 */
    .j-lightbox-top {
      flex: 0 0 auto; width: 100%; display: flex; justify-content: flex-end;
      margin-top: -0.25rem;          /* 12 − 4 + 12 = 20 */
    }
    /* ⚠️ 走查要求「圖示再大一點、明確一點」。**尺寸維持 20px**——`contracts/icons.md`
       明訂顯示尺寸只有 15／16／20 三級，24px 要當獨立決定走一次。
       所以「更明確」改用另外兩個旋鈕：**線寬 1.7 → 2.2**、**常駐不透明度 0.75 → 0.95**
       （其餘兩顆文字鈕維持 0.75——× 是唯一的出口，它比它們重要）。
       暗底上的白線本來就比紙上的灰線容易糊，加粗比放大更直接解決「看不清楚」。 */
    .j-lightbox-close {
      width: 44px; height: 44px; padding: 0;
      display: inline-flex; align-items: center; justify-content: center;
      background: none; border: none; cursor: pointer;
      color: var(--on-dark); opacity: 0.95;
      margin-right: -0.75rem;        /* 20 − 12 + 12 = 20 */
    }
    .j-lightbox-close:hover { opacity: 1; background: none; }
    /* 🔴 **這個 media block 必須排在上面三條基礎規則之後**：選擇器特異性一樣，
       媒體查詢不加分，所以誰在後面誰生效。放前面的話桌機會吃到手機的值，
       而且不會有任何錯誤——2026-08-30 寫這批時就先擺錯過一次。 */
    @media (min-width: 481px) {
      .journey-lightbox { padding: 2.5rem; }
      .j-lightbox-top { margin-top: -1.5rem; }        /* 40 − 24 + 12 = 28 */
      .j-lightbox-close { margin-right: -1.5rem; }    /* 40 − 24 + 12 = 28 */
    }
    .j-lightbox-close svg {
      width: 20px; height: 20px; fill: none; stroke: currentColor;
      stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round;
    }
    /* gap 1.5rem：走查回報兩顆貼圖片太近。兩顆之間維持 2rem——是一個緊湊的小組，
       不是把它們推到畫面兩端。 */
    .j-lightbox-actions {
      flex: 0 0 auto; display: flex; gap: 2rem; cursor: default;
    }
    /* ⚠️ 上下 padding 是熱區不是留白：`.link-btn` 的 padding 是 0，純文字鈕在手機上
       只有約 17px 高，點不到。0.875rem × 2 ＋ 行高 ＝ 45px，跨過 icon 鈕那條 44px 的線
       （實測 0.75rem 只到 41px）。改字級或行高時要回頭重量這個數字。 */
    .j-lightbox-actions .link-btn {
      color: var(--on-dark); opacity: 0.75; padding: 0.875rem 0.5rem;
    }
    .j-lightbox-actions .link-btn:hover { color: var(--on-dark); opacity: 1; background: none; }
