# INFRA-F88 — 视觉闸覆盖面设计（实测收敛版 · 待 owner 拍）

> 状态：**方案已实测收敛，等 owner 拍三件事**（见 §5）。拍完才动手 —— baseline 是视觉真源，必须 owner 签核后才能落地（`updateSnapshots: 'none'` 就是为此存在）。
> 背景（entry 已随收口删档，叙述在 [`STATUS-CHANGELOG.md`](../../internal/STATUS-CHANGELOG.md) 2026-07-31 session M 段）。前置事实：`test:visual` 已是 `scripts/release.mjs` step 1c 的阻塞闸（`e92f0b51`），66 张 baseline owner 已于 2026-07-31 逐页签核（`66a4becd`）。
> 本文所有数字都是 2026-07-31 本机实测，不是估算；复现方法写在 §6。

---

## 1. 问题（一句话）

这条闸的「绿」= **每页顶部 720px 没变**，占 33 页真实像素面的 **6.8%**。下一个人会拿它当「docs 站视觉已验证」。

---

## 2. 实测把 entry 里的首选解推翻了

entry 原写「候选解 = 按 demo 卡分段（`.docs-demo-card`），①是最贴近视觉回归本意的」。**实测该单位是错的**：

| 分段单位 | 覆盖像素 | 占 33 页总高 | baseline 张数 |
|---|---|---|---|
| 现状（viewport 720） | 23 760 px | **6.8%** | 66 |
| **`.docs-section`** | 327 121 px | **93.4%** | 374 |
| `.docs-demo-card`（entry 的首选） | 54 438 px | **15.5%** | 530 |

`.docs-demo-card` 在两个轴上同时更差：覆盖只有 section 的六分之一，张数却多 40%。原因是它只由 `DemoRenderer` 发射，**33 页里有 20 页一个都没有**（`overview` / `color` / `icon` / `typography` / `border` / `effect` 等全是 0）。这是「按使用中枚举」而非按结构枚举的典型误判（meta-rules 反模式 #6）。

**➡️ 分段单位取 `.docs-section`，不取 demo 卡。**

---

## 3. 三个实测结论（决定方案形状）

### 3a. 身份唯一性 —— 不是问题（entry 原判「要先解决卡的稳定标识」过虑了）

187 个 section 逐页核：**每一页内部 section 标题两两不重复，且无一个无标题**。所以「稳定标识」这个前置本来就成立。

⚠️ 但标题是 `t(sec.title)` 走 i18n 的**渲染文本**，不能直接当文件名（换 locale 就漂）。正解是让身份来自**页面数据的 key**，见 §4 的 `data-visual-segment`。

### 3b. 稳定性 —— 分段反而修好了 fullPage 的老毛病

fullPage 被排除的理由是「8 页抽样 4 页（button/chart/usermenu/form）连拍两次逐字节不一致」。改成分段后，同样两次独立加载比字节：

- **32 页里只有 `chart` 的 1 段不一致**；`button` / `usermenu` / `form` 三页**全部转稳**。
- 而 `chart` 那段（`Six Variants`，ECharts SVG renderer 未关动画）**4 帧 / 325ms 内自己收敛**。真闸用的 `expect().toHaveScreenshot()` 内建「连续两帧相同」等待，我这轮探针用的是**裸 `locator.screenshot()`（无重试）**，所以上面这个「1 段不稳」是**上界，不是闸会看到的数**。

**➡️ 分段方案的实际 flaky 面 = 0，不需要为 chart 加任何特例。**

### 3c. 两个结构异常，必须显式处理

| 页 | 现象 | 数字 |
|---|---|---|
| `steps` | 全库唯一一个用 `section.example-section` 而不是 `.docs-section` 的页（12 个 section），普查里因此是 **0 段** | `.example-section` 在 `react-pilot/src/demos/steps-demo.css:35` 有规则，与 `.docs-section` 的卡片外观（padding 24 / border / radius 18 / 渐变底）**不同** —— 直接改类名会改它的视觉 |
| `icon` | 单个 section `Action Icon` 高 **140 308px**（内含 1265 个 `.icon-card`）；另有 `Published Figma Inventory` 31 907px | 该页整页 179 938px = **全库像素的 51.4%**；实测整段截图单张 **12.5MB**，该页全段合计 14.6MB |

---

## 4. 方案（推荐）

### 4a. 分段单位用「测试钩子属性」，不用 CSS 类

`DemoRenderer` 渲染 `.docs-section` 时、以及 `StepsPage` 渲染 `.example-section` 时，各加一个 **`data-visual-segment="<slug>"`**，slug 由**页面数据里的 section key 派生**（不是渲染文本，不受 i18n 影响）。spec 按该属性取段。

为什么不直接用 `.docs-section` 选择器：

- `steps` 要么被永久漏掉，要么得改类名 → **改类名会动它的视觉**（3c），把一次「闸的覆盖面修复」扩成一次「docs 站外观变更」，两件事该分开（避免 root-cause scope creep）。
- 属性是零视觉影响的契约，`steps` 用现有 `.example-section` 也能挂上。
- 新增 docs 页时「加一个属性」是一处改动，符合反模式 #5（扩展只改一处）。

### 4b. 保留现有 66 张，分段是**增量**不是替换

这是本方案与 entry 设想的最大差别，也是成本的关键：

- 现有 66 张是 1280×720 viewport 截图，**viewport 不变它们就仍然有效**，owner 昨天刚签的签核不作废。
- 它们还顺带覆盖了页面外壳（header / 左导航 / 右侧 TOC）—— 这部分**不在任何 section 里**，分段截图天然看不到。留着正好补这块。
- 因此 **owner 只需签新增的分段图，不需要重签那 66 张**。

### 4c. 那行死掉的 `viewport: 1280×900` —— 删掉，不要改成真值

entry 说「若改成真生效的 900，66 张 baseline 全部作废需重签」。按 4b，我们**不需要**让它生效：分段截图是元素级的，元素多高就截多高，与 viewport 高度无关。所以：

**删掉 `playwright.config.ts:16` 那行 `viewport`，并留一行注释说明 viewport 由 project 级 `devices['Desktop Chrome']`（1280×720）决定。** 零成本、零 baseline 影响、消除误导。

### 4d. `icon` 页两个巨段：具名 + 带日期 + shrink-only 豁免

`Action Icon`（140 308px / 12.5MB 一张）与 `Published Figma Inventory`（31 907px）**不进闸**，其余 6 段照常。理由不是「太麻烦」，是三条：

1. 12.5MB 的 PNG 不是可用的 baseline（现有全部 66 张合计才 7.4MB）。
2. 按 `.icon-card` 再往下切是 1265 段 × 2 主题 = 2530 张，且**加一个图标就会让整个网格重排**、大批 baseline 连锁作废 —— 这种单位天生不稳。
3. 该内容的正确闸**已经存在且更强**：图标 SVG 由 Figma 管线生成（`sync:figma-library --with-extract` 默认含图标管线，INFRA-F38），命名由 `audit:icon-canonical-names` 卡（L4）。对 642 个图标做像素回归与之高度冗余。

豁免照本仓已确立的形态写：**具名 + 带日期 + 带理由 + shrink-only**（不再命中就 FAIL 要求删行），与 `audit-demo-css-page-scope.mjs` 的 S2 判据同范式。

### 4e. 分母 fail closed

照 `audit-demo-css-page-scope.mjs` 的 S3 先例：**某页取到 0 个 segment 就红**，不许「扫到 0 个所以通过」。这条正是 `steps` 这类结构异常唯一能被自动发现的方式 —— 本轮它是我手工普查抓到的，不该依赖下次也有人手工普查。

### 4f. 闸自己印出覆盖面

每次运行打印「本次覆盖 N 段 / X px / 占 Y%」+ 豁免清单。照 INFRA-F87 那条闸的先例（覆盖面每次自己声明），让「绿的含义」不再需要读文档才知道。

---

## 5. 待 owner 拍（三件）

| # | 要拍的 | 我的推荐 | 代价 |
|---|---|---|---|
| **D1** | 采不采纳「按 `.docs-section` 分段、作为现有 66 张的增量」 | **采纳** | baseline 66 → **394 张**（197 段 × 2 主题；含给 `steps` 补的 12 段、扣掉 `icon` 2 巨段）；磁盘 7.4MB → 约 **24MB**；owner 需签核 **328 张新增图**（审阅单位仍是「33 页 × 2 主题滚一遍」，不是 328 次独立判断）|
| **D2** | `icon` 两个巨段按 §4d 豁免 | **同意豁免** | 覆盖率口径变成：**非 icon 页 86.7%**（147 767 / 170 373 px）；若把 icon 页算进分母则是 44.2% —— icon 一页就占全库像素 51.4%，建议以前者为准并把这句话写进闸的输出，不要只报一个好看的数 |
| **D3** | `steps` 页：本次只挂属性（不动视觉），还是顺手把它的 `.example-section` 统一成 `.docs-section` | **只挂属性** | 统一类名会改它的卡片外观 = 一次 docs 站视觉变更，应作独立项排期，不搭 F88 的车 |

---

## 5b. 实现期实测补充 —— 限定了上面 §4b 的说法（2026-07-31 落地时发现）

owner 已拍 D1/D2/D3，实现时撞到**两件 §3b 的稳定性探针没能预见的事**。两条都已修并实测收口，但结论要写下来，否则下一个人会照 §4b 的原话重犯：

1. **「viewport 不变 → 66 张仍有效」不是无条件的，还要求「拍 viewport 之前没滚动过页面」。** 第一版实现把两张 viewport 图放在各自主题的分段循环之前，而元素截图会把目标滚进视口 → 轮到 light 那张 viewport 时页面已停在页底 → **33 张 `-light.png` 被静默改写**（`-dark.png` 没事，因为它在任何分段截图之前，这也正是它容易被漏掉的原因）。diff 实物显示差异只有一处：`aside.docs-contents` 的 TOC 当前项高亮，它随滚动位置变。**⚠️ 「滚回 0」不是解** —— 该高亮是异步更新的，`toHaveScreenshot` 的连续两帧判定可能在它追上之前就稳定了。正解 = 把两张 viewport 图放进**独立的第一阶段**，全程不滚动，逐字复刻 F88 之前的顺序（goto → dark → 切主题 → light）。
2. **元素截图会把吸顶层拍进去，且叠加位置随滚动落点变。** `header.docs-topbar` 是 `position: sticky; z-index: 20`，playwright 把 section 滚进视口时它**盖在 section 顶部**：同一段从下往上滚 vs 跳到 0 再拍，实测差 ~11 000 像素、全是那条被叠进来的 topbar。因为它是 `sticky`（已占流内空间），拍分段前把它设成 `position: static` 即可去掉叠加而不移动任何东西。§3b 的探针没抓到这条，是因为它两轮都走同一条代码路径、落点相同 —— **同路径复跑测不出「换了路径就漂」这类脆弱性**，这是那种探针的固有盲区。

修完的实测收口：`pnpm test:visual` **33/33 passed**，且 owner 已签的 66 张**零改动、逐张比对通过**（先删掉受污染的分段图重生成，再跑不带 `--update-snapshots` 的真闸做定论 —— 「没被覆盖」不等于「比对通过」，必须用后者）。

---

## 6. 复现方法（本文数字的来源）

本轮用的三个临时脚本已删（`scripts/_tmp-f88-*.mjs`）。要复现：

1. 起 `pnpm dev`（本轮复用了已在 5173 的实例；跑前先 `lsof -nP -iTCP:5173 -sTCP:LISTEN` 核实只有一个，避免并发 vite 污染共享 `.vite` 缓存）。
2. 用 `@playwright/test` 的 `chromium`，viewport 固定 `1280×720`（= `devices['Desktop Chrome']`，与现有 baseline 一致）。
3. 就绪信号必须用 `waitForSelector('.docs-loading', { state: 'detached' })` —— `networkidle` 不是就绪信号（INFRA-F86 ① 已实证）。
4. 几何普查：逐页 `document.documentElement.scrollHeight` + `.docs-section` / `.docs-demo-card` 的 `getBoundingClientRect()`。
5. 稳定性：**两次独立加载**各拍一轮分段图比 sha1；判「不稳」后必须再跑一次 settle 循环（连续两帧相同才算收敛），否则会把 `toHaveScreenshot` 本来就能吃掉的抖动误报成 flaky。
