# read_swipe 元件交付

> 目前路徑：元件已搬到 `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，否則會有雙重捲動。
- `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 表示 | 內容可短於或長於 viewport | `[data-read-card]`；內部內容由組裝者提供 | space、border、surface | JS 寫入 group、序號、active 狀態；不綁內容事件 |
| `.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`；root 只負責 `overflow:hidden` |
| `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 實際可用。
