# read_swipe 元件交付

> **這是過去某一輪的 worker 交付紀錄，不是現行約束。**
> 文中的 `D`／`O` 代號、Approved／Candidate／OPEN 分類，以及對 `DECISIONS.md` 的引用，
> 都來自已廢止的 `LILA_v2/` 決策紀錄。那份紀錄現在只是歷史探索紀錄，**不得當成已定案的依據**。
> 本檔仍有參考價值的部分只有技術事實：DOM 結構、class 名、狀態、變體與 token 依賴。

> 目前路徑：元件已搬到 `design-system/`；現行組裝入口與檔案位置以 `../COMPONENTS.md` 為準。下文的 `prototype/*` 是 worker 交付當時的歷史路徑，保留作執行紀錄。

結論：`confirmed` 已把 0730 的「一個判讀 lens 為一段、縱向連續捲動、接近段首時吸附、頂部進度可跳段」還原成可直接組裝的 `ReadSwipe`；`preview-read.html` 內的內容可實際上下滑，並提供進度跳段、鍵盤替代路徑、loading／empty 與第一段／中段／最後一段狀態。判讀文字與真實數字仍是 OPEN，畫面只放骨架；唯一具體圖形是 inline SVG 假資料 K 線。

## 1. 機制還原

- `confirmed` 滑動單位不是一檔股票，而是一個判讀 lens／步驟。0730 以 `LENSES.map(lensHTML)` 產生四段，再接一段整體判讀；每段是 `.reel[data-reel-index]`。證據：`0730-stock-content-prototype/reels.js:340-386, 405-406`。
- `confirmed` 主方向是縱向 reels，不是橫向卡片 carousel。`.reels-scroll` 使用 `overflow-y:auto` 與 `scroll-snap-type:y proximity`，`.reel` 使用 `min-height:100dvh`、`scroll-snap-align:start`。證據：`0730-stock-content-prototype/reels.js:213-218`。
- `confirmed` 一次手勢沒有被程式固定成「前進一張」；它先是原生連續捲動，卡片可高於一個 viewport，只有靠近段首時由 `proximity` snap 吸附。點頂部 segment 才會精確跳到某一段的 `offsetTop`。證據：`0730-stock-content-prototype/reels.js:441-447, 491-493, 541-550`。
- `confirmed` 0730 沒有自行實作縱向回彈；`overscroll-behavior:contain` 只限制捲動鏈。實際觸控減速、吸附門檻與平台回彈由瀏覽器掌管，原始碼沒有數值契約。證據：`0730-stock-content-prototype/reels.js:213-218`。
- `confirmed` 0730 另有一條橫向手勢：向左超過 `60px`，且橫向距離大於縱向的 `1.5` 倍時，第一段關閉 reader，其餘段回前一段。證據：`0730-stock-content-prototype/reels.js:553-566`。本元件保留語意，但把門檻寫成 `calc(var(--space-12) + var(--space-3))`，不再寫死距離。
- `confirmed` 0730 的滑鼠替代路徑是 wheel／trackpad 原生捲動與點 segment；鍵盤只處理 Escape 與 Tab focus trap，沒有 Arrow／Page 鍵換段。證據：`0730-stock-content-prototype/reels.js:568-589`。
- `inferred` 元件基準補上 `ArrowUp`、`ArrowDown`、`PageUp`、`PageDown`、`Home`、`End`；鍵盤跳段是立即完成，不播放位移動畫。這是可及性替代路徑，不代表判讀內容或產品方向。
- `coverage gap` 無。規格點名的 `reels.js`、`app.js`、`stock/version-1.4.html`、`docs/` 與 LILA_v2 制度檔均可讀；沒有用舊文件的建議或驗證字樣當本輪決定。

## 2. 元件拆解

- `confirmed` 來源已存在四個可分離邊界：overlay、頂部 segment progress、scroll viewport、單段 reel。證據：`0730-stock-content-prototype/reels.js:198-219, 397-406`。
- `inferred` 本輪只抽出 shell 內需要的 reader：`.read-swipe`、`.read-swipe__progress`、`.read-swipe__viewport`、`.read-card`。來源的全螢幕 overlay、close、body scroll lock 不放進 reader，交給 stack／shell。
- `inferred` 卡內再拆 `.read-card__content`、骨架 primitives、`.read-kline`、狀態層。下一個 agent 可只拿 viewport＋card，也可整組使用。

| Before | After | Why |
| --- | --- | --- |
| 0730 把 overlay、內容、進度、資料與事件寫在同一個 `reels.js` | CSS、可初始化的 `ReadSwipe`、可替換 DOM 分離 | 讓下一個 agent 可替換卡內內容，不必重寫手勢與狀態 |
| 0730 的 overlay 直接鎖 `html/body` 捲動 | reader 只要求父容器給定高度；不碰 shell 或 body | 避免 reader 隱含接管 stack 導航 |
| segment、捲動與內容數量由固定資料陣列一起生成 | progress 依 `[data-read-card]` 數量自動生成 | 卡片數量是組裝資料，不是元件常數 |
| 0730 的橫向返回門檻寫死 `60px` | `--read-swipe-threshold` 由 `--space-12 + --space-3` 組成 | 手勢門檻沿用 token 契約 |
| 0730 的程式跳段使用瀏覽器 `smooth` | 程式跳段讀 `--duration-base` 與 `--ease-standard`；鍵盤、reduced motion 立即跳段 | 轉場可重算，也尊重 reduced motion |
| 0730 沒有 loading／empty 契約 | `setState("ready" | "loading" | "empty")` | 組裝者不必另造狀態結構 |

## 3. 狀態與手勢

| 狀態／邊界 | 本元件行為 | 證據等級 |
| --- | --- | --- |
| 第一段 | `data-position="first"`；向左返回手勢觸發 `read:dismiss`，由 shell 決定如何 back | `confirmed` 來源第一段左滑 close；本輪事件接縫為 `inferred` |
| 中段 | `data-position="middle"`；上／下捲動，或點 progress 跳段；左滑回前一段 | `confirmed` 機制、`inferred` class 契約 |
| 最後一段 | `data-position="last"`；索引 clamp，不會超出最後一段；原生 overscroll 受 contain 限制 | `confirmed` 來源 `clamp()`；本輪 data attribute 為 `inferred` |
| 縱向滑到一半放手 | 由 `scroll-snap-type:y proximity` 決定是否吸附；元件不偽造一個來源不存在的數值門檻 | `confirmed`；精確平台門檻為 `unknown` |
| 橫向返回滑到一半放手 | 未達 `--read-swipe-threshold` 不動作 | `confirmed` 語意、`inferred` token 化 |
| 快速連續點 progress | 新的程式跳段會取消前一個 animation clock，再從當下位置前往新目標 | `inferred` 可中斷轉場 |
| 快速連續原生縱滑 | 保留瀏覽器原生慣性與 proximity snap，不加 JS 鎖 | `confirmed` 來源方向；平台結果為 `unknown` |
| 載入中 | `data-state="loading"`、`aria-busy=true`、viewport inert，顯示骨架狀態層 | `inferred` |
| 無資料 | `data-state="empty"`、viewport inert，顯示空狀態骨架；若初始化時沒有 card，自動進 empty | `inferred` |
| reduced motion | 程式跳段立即完成，spinner 停止旋轉 | `inferred` |

手勢與轉場 token：

- `--read-swipe-threshold` → `calc(var(--space-12) + var(--space-3))`。
- `--read-snap-duration` → `var(--duration-base)`。
- `--read-snap-ease` → `var(--ease-standard)`。
- `unknown` 瀏覽器原生縱向 inertia／proximity snap 沒有可由 CSS 指定的 duration 或 threshold；本元件沒有把猜測值寫成結論。

## 4. K 線畫法

- `confirmed` `preview-read.html` 只用 inline SVG，沒有外部圖表函式庫、網路請求或來源圖片。
- `confirmed` candle 只有 `is-up`、`is-down`、`is-flat` 形狀，顏色只引用 `--color-up`、`--color-down`、`--color-flat`；網格只引用 `--color-divider`。
- `confirmed` SVG 沒有價格、日期、代號、百分比或座標文字；所有 candle 座標都是為了展示形狀的假幾何，不構成任何個股資料。
- `confirmed` 除 K 線外，卡內可見資訊位置全用 `--color-skeleton`／`--color-skeleton-shimmer` 骨架；沒有判讀文案、籌碼數字或百分比。

## 5. 與 shell_nav 的接縫契約

- `inferred` shell 的內容區需給 `.read-swipe` 一個已確定的可用高度，且該 grid／flex child 必須能 `min-height:0`；reader 自己使用 `height:100%`。
- `inferred` shell 提供 stack header 與 back。reader 不渲染全域 header、不鎖 `body`、不決定 navbar 是否顯示。
- `inferred` 頁面／翻卡主軸的縱向 scroll 歸 `.read-swipe__viewport`；shell 內容區不可同時成為另一個頁面級 scroller，否則會有雙重捲動。2026-08-10 的 fit panel 是受卡片剩餘高度約束的局部 disclosure scroller，不接管翻卡。
- `inferred` shell 收到 bubbling `read:dismiss` 時，映射到既有 stack back；是否 pop、關閉 overlay 或回到哪個 category 由 shell 決定。
- `inferred` shell 可監聽 `read:change` 更新外部狀態；不要反向讀取 progress DOM。
- `unknown` `shell_nav` 最終容器 class、個股 stack 是否保留 bottom nav、header 實際高度由 PO 收斂；本元件沒有代定。

最小組裝：

```html
<section class="read-swipe" data-read-swipe data-state="ready">
  <div class="read-swipe__progress" data-read-progress role="group"></div>
  <div class="read-swipe__viewport" data-read-viewport tabindex="0">
    <article class="read-card" data-read-card>...</article>
  </div>
  <div class="read-swipe__state" data-read-loading hidden>...</div>
  <div class="read-swipe__state" data-read-empty hidden>...</div>
</section>
```

初始化與外部控制：

```js
const root = document.querySelector("[data-read-swipe]");
const reader = ReadSwipe.get(root) || new ReadSwipe(root);

reader.goTo(0);
reader.setState("loading");
reader.setState("ready");
root.addEventListener("read:dismiss", shellBack);
```

## 6. 元件清單

| 元件名（class） | 用途 | 狀態清單 | 變體清單 | 必要 DOM 結構 | 依賴 token | JS 事件／API |
| --- | --- | --- | --- | --- | --- | --- |
| `.read-swipe` | reader 根節點與狀態邊界 | `ready`、`loading`、`empty`；`first`、`middle`、`last` | 卡片數量不限 | 必須含 progress、viewport；loading／empty layer 可選但建議保留 | 背景、層級、space、duration、ease | `new ReadSwipe(root)`；emit `read:change`、`read:dismiss`、`read:statechange` |
| `.read-swipe__progress` | 顯示目前段落並提供直接跳段 | 正常、disabled by root state | 段數依 card 自動生成 | `[data-read-progress][role=group]`；JS 另建 visually-hidden progressbar meter，避免互動按鈕被 progressbar role 吃掉 | `--space-*`、`--color-*`、`--z-header` | JS 建立 meter／segment、維護 `aria-valuenow` |
| `.read-swipe__segment` | 單一段落的跳轉 hit target | pending、`is-complete`、`aria-current=step`、focus、active | 無內容變體 | 由 JS 產生在 progress 內 | `--color-accent*`、`--duration-fast`、ease | click → `goTo(index)` |
| `.read-swipe__viewport` | 唯一頁面級縱向 scroller 與 gesture surface | ready、inert、focus | 原生 wheel／touch／trackpad；鍵盤替代 | `[data-read-viewport]` 包直接 card children | surface、focus、snap；程式跳段讀 duration/ease | scroll、keydown、pointerdown/up/cancel |
| `.read-card` | 一個可獨立替換的判讀 lens／步驟 | active、inactive/inert；first／last 由 root 表示；Info fit opt-in | 預設內容可短於或長於 viewport；consumer 可在已驗證範圍加 `[data-read-info-fit="viewport"]` 固定單卡 | `[data-read-card]`；內部內容由組裝者提供 | space、border、surface | JS 只寫入 group、序號與 active；不知道 Info 或 fit |
| `.read-card__content` | 卡內資訊分區容器 | 靜態 | K 線、metrics、block 可任意組合 | 放在 card 內 | spacing、shell width | 無；內容互動由消費者自行掛載 |
| `.read-card__eyebrow`、`.read-card__title`、`.read-card__line`、`.read-card__metric*`、`.read-card__block` | 可替換的資訊骨架 primitives | skeleton | line 寬度、metric grid、block | 放在 content 內，無資料語意 | `--color-skeleton*`、space、radius、border | 無 |
| `.read-kline` | 唯一具體圖形；展示假資料 K 線形狀 | up、down、flat candle | candle 數量與假幾何可替換 | `figure > svg > g.read-kline__candle > line + rect` | 漲跌語意色、divider、surface、space | 無；若未來加 crosshair，另立事件契約，不在本輪代定 |
| `.read-swipe__state[data-read-loading]` | blocking loading layer | visible／hidden | spinner＋骨架 | root 的直接 child | surface、skeleton、duration/ease、z-push | `reader.setState("loading")` |
| `.read-swipe__state[data-read-empty]` | blocking empty layer | visible／hidden | 空狀態骨架 | root 的直接 child | surface、skeleton、z-push | `reader.setState("empty")` |

## 驗收與限制

- `confirmed` `node --check prototype/read.js` 通過。
- `confirmed` 靜態掃描未找到 `read.css`／`read.js`／`preview-read.html` 寫死的 hex、rgb、hsl 顏色，也未找到寫死的 CSS font-size、padding、margin、gap 數值。
- `confirmed` macOS Quick Look 已成功渲染 ready 畫面，可見骨架卡與 inline SVG K 線；此項只確認靜態版面，不升格為手勢驗證。
- `confirmed` `read.css` 只 import `tokens.css`，且沒有修改 `tokens.css`、`DECISIONS.md`、`AGENTS.md`、`index.html`、`phone.html` 或其他 worker 檔案。
- `coverage gap` 本執行環境沒有可連線的內建 Browser；Playwright CLI 也未預載，安全審核不允許臨時從 npm 下載執行。因此目前能確認程式契約、語法與靜態 DOM／CSS，不能宣稱已在真機驗證觸控慣性或不同瀏覽器的 proximity snap 手感。
- `inferred` token 建議：若 PO 要把不同 gesture 共用為產品級契約，可新增專用 `--gesture-swipe-threshold`；目前依規格不修改 `tokens.css`，先以既有 space token 組合。

## OPEN，未代定

- `unknown` 新手要看懂籌碼 K 線的哪一段。
- `unknown` 每個判讀 step 的實際內容、順序、數量與文案。
- `unknown` shell 最終 class、個股 stack 底部 navbar 與 header 配置。
- `unknown` `read:dismiss` 在最終 stack 的具體 pop 行為；只提供事件，不替 PO 決定。

## 追加：跨檔污染修正（PO 指派）

結論：`confirmed` `read.css` 已不再對 app page 寫入裸 `html`、`body`、`button`、`*` 或 `.visually-hidden` 規則；preview 規則只在 `<body class="read-preview">` 下生效，reader 必要 reset 只在 `.read-swipe` domain 內生效。`confirmed` `phone.html` 依賴的四項 reader 契約均未修改；`coverage gap` 本輪沒有可用瀏覽器，因此以下只宣稱靜態契約與語法檢查，不宣稱完成互動回歸。

### 1. Selector scope 對照

| 原本的選擇器 | 修改後的選擇器 | 證據等級與影響 |
| --- | --- | --- |
| `*`, `*::before`, `*::after` | `body.read-preview` 與其 descendants／pseudo-elements；另限於 `.read-swipe` 與其 descendants／pseudo-elements | `confirmed` 不再改到 reader 以外的 app sibling；preview 與 reader 各自保留 `border-box` invariant |
| `html, body` | `body.read-preview` | `confirmed` 移除對 app `html` 的寫入；preview 仍保留原本的 `min-height` |
| `body` | `body.read-preview` | `confirmed` margin、顏色、背景、字體、字級與行高只作用於獨立 preview body |
| `button` | `body.read-preview button, .read-swipe button` | `confirmed` preview 控制與 reader 內按鈕繼承字體，不再改寫其他 domain 的 button |
| `.preview-page` | `body.read-preview .preview-page` | `confirmed` preview 版面只在指定 body 生效 |
| `.preview-stage` | `body.read-preview .preview-stage` | `confirmed` 同上 |
| `.preview-shell__header`, `.preview-shell__back`, `.preview-shell__header-skeleton` 及其 active／hover／focus／reduced-motion 規則 | 全部加上 `body.read-preview` 前綴 | `confirmed` preview shell 示意不再碰 app shell |
| `.preview-controls` 及其 button、active、pressed、hover、focus、reduced-motion 規則 | 全部加上 `body.read-preview` 前綴 | `confirmed` preview 狀態控制不再碰其他頁面控制元件 |
| `.visually-hidden` | 從 `read.css` 完整移除 | `confirmed` 唯一定義改由 PO 擁有的 `utilities.css` 提供 |

`confirmed` `preview-read.html` 的 `<body>` 已改為 `<body class="read-preview">`；沒有把這個 class 加進 `phone.html`。

### 2. Reader 的 button reset

`confirmed` 保留 `font: inherit`，但 selector 改成 `.read-swipe button`；理由是 progress segment 由 `read.js` 動態建立，未來 card 內也可能有 reader-domain action，應繼承既有字體契約。preview 自己的 back／狀態切換按鈕則由 `body.read-preview button` 處理。兩者都不會命中 shell、lists 或 navbar 的 button。

### 3. Import 順序

`confirmed` `read.css` 檔頭現在是：

```css
@import url("./tokens.css");
@import url("./utilities.css");
```

`confirmed` 順序符合 `tokens → utilities → domain`：tokens 先建立全域設計值；utilities 再提供跨 domain、沒有元件判斷的共用 class；其後才是 `read.css` 的 domain 規則。`confirmed` 本輪沒有修改 `tokens.css` 或 `utilities.css`，也沒有在 read domain 重複定義 `.visually-hidden`。

### 4. phone.html 四項契約回歸檢查

| phone 組裝契約 | 驗證方式 | 結果 |
| --- | --- | --- |
| `.read-swipe` 保留 `block-size:100%`／`min-block-size:0` | 靜態讀取 `read.css` 的 `.read-swipe` declaration | `confirmed` 兩項仍存在，值未改 |
| `.read-swipe__viewport` 是 reader 唯一頁面級縱向 scroller | 靜態搜尋 `read.css` 的 `overflow-y` 與 `scroll-snap-type` | `confirmed` 翻卡用的 `overflow-y:auto` 與 `scroll-snap-type:y proximity` 仍只在 `.read-swipe__viewport`；fit panel 的局部 overflow 由 Info domain 負責 |
| `read.js` 載入時自動掃描已 clone 的 `[data-read-swipe]` | 對照 `phone.html`：template `cloneNode(true)` 在 `read.js` script 前；對照 `read.js`：`initAll()` 執行 `document.querySelectorAll("[data-read-swipe]")` | `confirmed` 載入順序與自動初始化邏輯未改；本輪未修改 `read.js` |
| `read:dismiss` bubbling | 靜態讀取 `read.js` 的 `new CustomEvent("read:dismiss", { bubbles:true })`，並確認 `phone.html` 在 shell 監聽該事件 | `confirmed` 事件仍 bubbling，組裝端 listener 仍存在；本輪未修改兩端 |

`confirmed` `node --check prototype/read.js` 通過。`confirmed` selector／import 靜態檢查通過。`coverage gap` 本輪執行環境沒有可用 Browser，因此沒有把這些檢查描述成觸控、wheel、鍵盤或 stack back 的實際互動驗證。

### 5. 其他跨檔污染風險

- `confirmed` 裸 `*` 已消失；目前 universal reset 只存在於 `body.read-preview` 或 `.read-swipe` 邊界內，不會改到 sibling domain。`.read-swipe *` 仍會讓 reader 內被組裝的內容採 `border-box`，這是 reader layout invariant；若未來要嵌入明確要求 `content-box` 的第三方 widget，該 widget 需在自己的 boundary 覆寫，現階段沒有此需求證據。
- `confirmed` `read.css` 沒有 `:root` 或自訂 token 寫入；三個 `--read-*` local custom properties 只定義在 `.read-swipe`。
- `confirmed` 沒有對 `html`／`body` 寫入全域 `overflow`；捲動限制只在 `.read-swipe` 與 `.read-swipe__viewport`。
- `confirmed` reader root 有 `isolation:isolate`，progress／state 的 token z-index 留在 reader stacking context，不會逃到 app shell stacking context。
- `confirmed` `@keyframes read-spin` 的名稱位於 CSS 全域 namespace，但已帶 `read-` domain 前綴，且只由 `.read-swipe__spinner` 引用；目前未在其他 domain 找到同名 keyframe。這是低風險的 CSS 語言限制，不構成已觀察到的污染。
- `unknown` 未來若其他 domain 主動在 reader DOM 內插入未隔離的元件，其內部 cascade 仍需由該 domain 自己驗證；本輪沒有替未知組裝情境新增 reset 或 token。

`confirmed` 補充靜態渲染：macOS Quick Look 可成功產生 `preview-read.html` 與 `phone.html` 縮圖；preview 的骨架與 K 線仍可見，phone 初始 Feed／navbar 未見靜態破版。`coverage gap` Quick Look 不是可操作瀏覽器，此證據不能支持滑動、狀態切換或 dismiss 實際可用。

## 追加：Read × Info opt-in fit pattern（2026-08-10）

### 1. 能力邊界與責任

- `.read-card` 預設可高於 viewport，由 `.read-swipe__viewport` 承接原生長卡捲動。
- Consumer 只有在自己已驗證的 viewport／orientation／字級格子，才同時加入 card 的 `data-read-info-fit="viewport"` 與各 trigger 的 `data-info-fit-trigger`。Fit 一體適用 term 與 judgment，不綁 `data-info-kind`。
- Fit CSS 把 card 固定為一個 Read viewport，並把 expanded Info panel 分配到卡片剩餘空間；**系統不偵測這個 layout 是否可行，也不會自動退出 fit**。
- Consumer 必須逐格宣告是否使用 fit；不支援時成對移除 card 與所有 opt-in trigger 的 fit attributes，回到 base auto-height 長卡。只移除一邊是不完整退出。
- `ReadSwipe` JS 不知道 Info 或 fit，只維持既有翻卡、progress、鍵盤、pointer、resize 與 `read:*` 事件。Fit seam 完全由 Read／Info CSS 與 consumer policy 承接。
- Info 負責 disclosure、panel overflow、有效可見度、failure signal 與條件式 focusability；不替 consumer 切換 layout。

### 2. 最小 DOM 與 consumer 退場

每個 fit trigger 與自己的 panel 各放在一個 `.info-stack`：

```html
<article class="read-card" data-read-card data-read-info-fit="viewport">
  <div class="read-card__content">
    <div class="info-stack">
      <div class="info-trigger-row">
        <button
          id="term-trigger"
          class="info-button"
          type="button"
          data-info="term"
          data-info-fit-trigger
          aria-expanded="false"
          aria-controls="term-panel"
        >術語 ℹ</button>
      </div>
      <p
        class="info-panel"
        id="term-panel"
        data-info-panel="term"
        aria-labelledby="term-trigger"
        hidden
      >…</p>
    </div>
    <div data-info-collapse-on-open>…BRIEF 指定的低優先內容…</div>
  </div>
</article>
```

Consumer 的尺寸 policy 必須成對切換。Fit candidates 必須來自 BRIEF／component refs，不可只查當下已存在的 attribute，否則 cold-start `useFit:false` 會失去原集合：

```js
const fitTriggers = [termTrigger, judgmentTrigger]; // consumer 擁有的 refs

function setReadInfoFit(enabled) {
  card.toggleAttribute("data-read-info-fit", enabled);
  fitTriggers.forEach((trigger) => {
    trigger.toggleAttribute("data-info-fit-trigger", enabled);
  });
}
```

`InfoControl` 支援 cold-start `false→true` 與 `true→false→true`：attribute 加入時動態建立 fit-only listeners／observers並立即同步，移除時完整拆除並清掉 managed a11y state。非 fit instance 只有 click disclosure 與共用的 attribute activation watcher，不常駐自己的 ResizeObserver、panel MutationObserver、window resize或ancestor scroll listener。`catalog/preview-read.html` 的 portrait 初始即實跑 cold-start false→true。

### 3. CSS 行為

| Consumer 選擇 | 結果 |
|---|---|
| 使用 fit、collapsed | Card 固定一個 viewport；panels hidden；`[data-info-collapse-on-open]` 正常顯示。 |
| 使用 fit、expanded | Expanded stack 取得剩餘 track；只收起 consumer 明確標記的低優先內容；panel 放不下時成為局部垂直 scroller。 |
| 不使用 fit | Card 與 triggers 都沒有 fit attributes；回到 base auto-height 長卡，所有內容由 `.read-swipe__viewport` 到達。 |
| 使用 fit 但尺寸不可行 | 系統不退場；panel 可能被 ancestor clip。Info 會移除 tabindex／role並寫 `data-info-fit-unusable`，gate 必須 FAIL。 |

Runtime selector 不依賴 JS state：

```css
.read-card[data-read-info-fit="viewport"]:has(
  .info-button[data-info-fit-trigger][aria-expanded="true"]
) [data-info-collapse-on-open] {
  display: none;
}
```

只有含直接 `[data-info-fit-trigger]` 的 stack 之直接 panel 取得 `max-block-size:100%`、`align-self:start` 與垂直 overflow；未 opt-in `.info-panel` 維持基礎 computed 行為。Info 仍允許 term／judgment 單開或多開，但 consumer 宣告使用 fit 的每一格都要把所有合法展開組合跑完 gate。

### 4. 可及性與失敗訊號

- Fit panel 必須以 `aria-labelledby` 指向 trigger，或自行提供 `aria-label`。
- `InfoControl` 動態監測 `data-info-fit-trigger`；無論初始是否 opt-in，只要 attribute 加入，就先啟用 fit management並立即重算。只有在 panel 自身 overflow、computed `overflow-y` 可捲，且 panel 與**所有 clipping ancestors**的有效交集至少等於 minimum 時，才加入 `tabindex="0"` 與 `role="region"`。
- 有效高度不足或 minimum 無法解析成正長度時，不得加入可聚焦 region，並寫 `data-info-fit-unusable`。這是 failure signal，不是自動退場指令。
- Minimum 由 Info domain-local `--info-fit-min-visible-block-size` 提供：`--text-sm × --leading-normal + --space-3 × 2`，不依賴 `lh` unit。
- `getBoundingClientRect()` 是 transform 後座標；minimum 也要乘同一 block scale。Stage 100%／65%／50%／25% 都必須在相同 visual coordinate space 比較。
- Focused 可捲 panel 使用原生 Arrow／PageUp／PageDown／Home／End；Info 只停止事件冒泡到 Read，不阻止原生捲動。
- `[data-info-collapse-on-open]` 不得標在必要證據、必要操作、trigger／panel ancestor 或目前 focus 所在區。

### 5. Consumer fit-usage matrix 與 gate

每格必須宣告 `viewport`、`orientation`、文字縮放、stage scale 與 `useFit: true | false`。缺 `useFit` 不算完成。Canonical machine-readable baseline 位於 `catalog/preview-read.html` 的 `data-preview-fit-usage` JSON：

| Viewport | Orientation | 文字 | useFit |
|---|---|---:|---:|
| 393×852 | portrait | 100% | true |
| 480×600 | portrait short-height | 100% | true |
| 950×480 | wide-flat | 100% | false |
| 852×393 | landscape | 100% | false |
| 480×950 | portrait | 100% | true |
| 320×950 | narrow portrait | 100% | true |

另測 393×852、term＋judgment 全開、200% 文字，在 stage 100%／65%／50%／25% 四格；canonical `useFit` 均為 true。這只是 canonical 內容基線，consumer 必須以自己的資料與內容重新宣告。

先等待 `document.fonts.ready`、資料綁定與圖表 layout complete，並確認 rect 連續兩個 animation frames 穩定在 `EPSILON = 1 CSS px` 內。

有效可見高度 oracle 必須與所有 clipping ancestors 取交集；只看 panel 自身 rect、`clientHeight` 或 `scrollHeight` 會讓 0-track 得到假 PASS：

```js
function visibleBlockSize(panel) {
  let top = panel.getBoundingClientRect().top;
  let bottom = panel.getBoundingClientRect().bottom;
  for (let node = panel.parentElement; node; node = node.parentElement) {
    const styles = getComputedStyle(node);
    const clips = [styles.overflow, styles.overflowY]
      .some((value) => /^(hidden|clip|auto|scroll)$/.test(value));
    if (!clips) continue;
    const rect = node.getBoundingClientRect();
    top = Math.max(top, rect.top);
    bottom = Math.min(bottom, rect.bottom);
  }
  return Math.max(0, bottom - top);
}
```

以 hidden absolute probe 將 `--info-fit-min-visible-block-size` 解析成未 transform 的 `minimumLayout`。Visual gate 比較 `visibleBlockSize(panel) >= minimumLayout * visualScale`，其中 `visualScale = panelRect.height / panelLayoutBlockSize`。

`useFit: true` 的斷言：

1. Card 與所有 opt-in triggers 的 fit attributes 都存在；Read card rect 完整落在 viewport rect。
2. 每個單開與全開 panel 都有 accessible name，`visibleBlockSize >= minimumVisible`，且沒有 `data-info-fit-unusable`。
3. `.read-card__content.scrollHeight <= clientHeight + EPSILON`；ancestor clip 不得吞掉固定內容。
4. Expanded grid 最後一列 `>= minimumLayout`；`44px 0px`、0.75px 或其他低於 minimum 的 row 一律 FAIL。
5. Panel 完整可見時不增加 Tab stop；真的 overflow 時必須有 `tabindex="0"`、`role="region"` 並能捲到底。
6. `panel.scrollWidth <= panel.clientWidth + EPSILON` 且 `overflow-x:hidden`；focus indicator 四邊完整。
7. Failure signal、有效高度不足、minimum 非正長度或任何不可達文字都直接 FAIL；系統不會代為修正。

`useFit: false` 的斷言：

1. Card 沒有 `data-read-info-fit`，原 opt-in triggers 也沒有 `data-info-fit-trigger`；不完整退出即 FAIL。
2. Card、content、expanded stack 與 panel 的 `scrollHeight <= clientHeight + EPSILON`，內容由 `.read-swipe__viewport` 長卡路徑到達。
3. 將 Read viewport 捲到 panel 後，文字完整可見；panel 自身不 overflow 時沒有 tabindex／role，也沒有 stale failure signal。
4. Read 的 progress、scroll snap、鍵盤、pointer 與事件契約維持原行為。

Canonical 另提供「短高但不退出 fit」反例：它刻意保留 attributes，預期有效可見度 gate FAIL、panel 無 tabindex／role且帶 `data-info-fit-unusable`。反例不是支援模式，也不得放進通過矩陣。

### 6. Token、JS 與相容性

本 pattern 沒有新增 `tokens.css` token。Minimum 只由既有文字／padding token 推導。`read.js` 與未收割 Info 前的 production baseline byte-for-byte 相同，不讀 Info DOM、不建立 fit observers、不寫 fit state；一般 `.read-card` 與非 fit consumers 維持相容。`info.js` 保留 fit-only visibility observer、failure signal與條件式 focusability。
