# INFRA-F129 (b) — Tooltips 一族：视觉 paint 字段下降一层到具名子节点

> 计划文件。base = `ea4d9093`（origin/master）· worktree `worktree-f129-next-layer`
> 起手 prompt：`按 docs/superpowers/plans/2026-08-20-infra-f129-tooltips-paint-descent.md 从 Task N 接`

---

## 0. 为什么这份计划的第一句话是「原记载是假的」

backlog [[INFRA-F129]] (b) 逐字写着 Tooltips「root autolayout **整块**被 extract 丢掉
（padding / gap / fill 一起没）」，豁免表 9 行（⚠️ entry 写「6 行」，**现值 9**）全部归因于
同一句 `Tooltip Figma extract drops root autolayout padding/gap/fill`。

2026-08-20 逐字段核活源（`figma-data/normalized/components-tokenized/tooltips__1408_17152.json`，
12 变体全量）后：**那是三个不同机制，其中两个的归因是假的。**

| 豁免行 | 记的成因 | 活源真相 |
|---|---|---|
| `rootFillHex` · `rootBorderHex` | 「extract 没抓到」 | root COMPONENT `fills: []` / `strokes: []` **是 Figma 真值**。颜色在直接子节点 `SLOT "Content"` 上，**且带变量绑定**（dark `#353535`/`--color-grey-10` + `#595959`/`--color-grey-8` · light `#ffffff`/`--color-white` + `#f0f0f0`/`--color-grey-3`）⇒ 真问题 = manifest 拿 **root** 跟 code `.tooltip-box` 比，**比错了节点（差一层）** |
| `rootGap`（`*-down` 6 条） | 「expected -6 是缺数据的产物」 | root `gap: -6` 是**真值**。算术闭合：SLOT `h=24` + arrow `h=8.485281944` + `(-6)` = **26.485282 = root `h`**（逐字相等）。code 的 arrow 是绝对定位 ⇒ 那层 wrapper 在 code 里**没有对应物** = 拓扑差异，不是缺数据 |
| `rootPadding` ×4 | 「extract 丢了 root autolayout padding」 | **方向对、节点写错了**：padding 4/8 在 SLOT 上；`figma-sync/extract.mjs` 的 `nodeGeometry()` 给**所有子节点**只记 11 个字段（`id name type visible opacity w h x y r fills strokes`），**不含 `pH/pV/pR/pB` · `gap` · `layoutMode` · `strokeWeight` · `layoutSizing*`** —— 那些只在 root 走的 `extractNodeFrame()` 里有 ⛔ **2026-08-21 就地标注被推翻（本行是当时的记载、刻意不重写）**：「11 个」与紧邻的枚举互斥，枚举列的是 **12** 个；且真差集 **21** 个字段，这里点名的只占 9 个。现值与取数命令见 backlog `INFRA-F129` (b) |
| `rootWidth`/`rootHeight`（`*-up` 6 条） | 「文字 hug 测量值」 | 真因 = `*-up` 那批 root **完全没有 autolayout**（`layoutMode: null`、`layoutSizing*: null`）⇒ `pushSizedNumber` 没有 HUG 可 skip 就落到数值比。`*-down` 那批是 `HUG/HUG` ⇒ 走 `pass-by-mode-skip`，**从来没红过**，所以豁免表里也没有它们 |

⛔ **这条教训的形态**：豁免行的 `reason` 是**转述**，不是判据。9 行里 3 类归因错，且错的方式是
「把一个真值说成缺数据」—— 谁照它去「修 extract」会找不到东西可修。判据真源永远是活源。
（同族 memory：`entry-restatement-is-secondhand` · `external-claims-verify-on-move`）

---

## 1. Figma 侧结构（活源实测，`dark theme=on, pointing=left down` 为例）

```
COMPONENT "dark theme=on, pointing=left down"  1408:17135
  layoutMode=VERTICAL  gap=-6  pH/pV/pR/pB=null  r=null  fills=[]  strokes=[]
  w=81 (HUG)  h=26.48528289794922 (HUG)
  │
  ├─ SLOT "Content"  1408:17039        ← ★ 这才是 code `.tooltip-box` 的对应物
  │    w=81  h=24  r=4
  │    fills=[#353535 / --color-grey-10]   strokes=[#595959 / --color-grey-8]
  │    ⛔ 无 pH/pV/pR/pB（nodeGeometry 不记）
  │    └─ TEXT "Description"  w=65 h=16  fill=#f8f8f8
  │
  └─ FRAME "Frame 2465"  1669:25910    ← arrow 容器（`*-up` 那批直接是 RECTANGLE）
       w=81  h=8.485281944274902  fills=[]
       └─ RECTANGLE "Rectangle 2106"  8.485²  fill=#353535  stroke=#595959
```

code 侧（`src/components/Tooltip/Tooltip.vue`）：`.tooltip-box` = `padding: var(--sp-xxs) var(--sp-xs)`
（4/8）+ `border-radius: var(--r-s)`（4）+ `background: var(--tooltip-bg)` + `border: 1px solid var(--tooltip-border)`，
`.tooltip-arrow` 是它**内部**的绝对定位 span。

⇒ 正确的比较对是 **code `.tooltip-box` ↔ Figma `SLOT "Content"`**；root COMPONENT 那层 wrapper
（vertical autolayout，gap -6，把 box 和 arrow 叠起来）在 code 里由绝对定位替代，**没有对应物**。
这落在 `FIGMA_AS_SOURCE_OF_TRUTH.md` §合法差异的「拓扑合成」里。

---

## 2. 本轮的修法边界（⛔ 先读这段再动手）

### ✅ 做：paint 那半 —— per-target 显式声明「视觉 paint 字段取具名子节点」

只影响 5 个 expected 字段：`rootFillHex` · `rootFillCssVar` · `rootBorderHex` · `rootBorderCssVar`
· `rootBorderWidth`。判据 = **manifest 作者在 target 上显式声明子节点名**，子节点不存在就**硬错**
（不是静默回落）。⛔ 不做自动探测 —— 那是启发式，直接撞 `FIGMA_AS_SOURCE_OF_TRUTH.md` §验证方法。
先例 = `scripts/lib/full-bleed-cover.mjs`（同族但那条走的是「几何上必然满幅」的结构判据，
Tooltips 的 SLOT `h=24` ≠ root `h=26.485` ⇒ 它**正确地**抓不到，需要这条显式补位）。

### ⛔ 不做（各有理由，别顺手）

| 不做什么 | 为什么 |
|---|---|
| 扩 `nodeGeometry()` 的字段集去补 SLOT 的 padding | 那要重跑 extract（= 「同步 Figma 库」唤醒词的动作，需 Figma 凭据）且影响**全库所有组件**的 children 记录。是独立项，本轮只把 `rootPadding` 那 4 行的**成因订正**到真的那条 |
| 让 `rootWidth`/`rootHeight` 也下降到 SLOT | SLOT `h=24`（Figma INSIDE stroke）vs code `26`（CSS border 在 padding box 外）= 盒模型差，下降后会造**新的永久红** |
| 改 `closeEnough` 的 ±1 容差 | 它让 `rootBorderWidth` actual 1 / expected 0 判 pass。收紧容差是闸灵敏度改动，要先在稳定态量噪声底 —— 独立项 |
| 修 `Center down` / `right down` 的 r=2 vs code 4 | 动 src/** ⇒ 视觉签核；且是 Figma 同族不一致，要 designer ack。**登记，不修** |

---

## 3. Task 清单

### Task 1 — generator 加机制 + Tooltips 声明

`scripts/generate-render-verification-manifest.mjs`

1. `buildExpectedFromFigma(variant, theme, target, ...)` 里，在算 `rootFillPaints` / `rootBorder` 前，
   按 `target.expectedPaintFromChildName`（字符串）在 `variant.children` 里找 `name` 逐字相等的**直接子节点**：
   - 找不到 → `throw`（带 component 名 + variant 名 + 该 children 的实际 name 列表）。**fail closed。**
   - 找到 → 该节点的 `fills` / `strokes` 顶替 root 的，用于这 5 个字段；
     `rootBorderWidth` 仍取 `variant.strokeWeight`（子节点侧没有这个字段，root 的 `strokeWeight=1` 与 code 的 `1px` 一致）。
   - **未声明该字段的 target 走原路径，零变化**（其余 900+ entry 必须逐字不变）。
2. Tooltips target（约 L485-505）加 `expectedPaintFromChildName: 'Content'`。
3. 同一 target 的 `warnings` 那句假成因**逐字重写**（它会流进 manifest `_warnings` + 两份 report）。
   新文案要点：paint 已下降到 SLOT；仍豁免的是 padding（child 字段集窄）/ gap（拓扑差异）/ `*-up` 的 w/h（root 无 autolayout）。

### Task 2 — 单测

`tests/` 下新增（就近既有 manifest generator 单测）：
- 声明的子节点存在 → 5 个字段取子节点值；
- 子节点**不存在** → throw，且消息含实际 children name 列表（阴性对照）；
- **未声明**该字段的 target → 逐字回落 root（防止影响面外溢）；
- 多个 paint / 不可见 paint 时的选取与 root 路径一致。

### Task 3 — 离线自证（本轮零 src/** 改动 ⇒ 上次 actual 仍有效）

正对照：新 expected 的 `rootFillHex` / `rootBorderHex` / `rootBorderWidth` 在 **12 entry × 两链**
逐条 == 上次 report 的 actual。基线（本计划写时实测，Vue 与 React **12/12 全同值**）：

| variant | actual fill | actual border | actual borderWidth |
|---|---|---|---|
| `*` dark（6 条） | `#353535` | `#595959` | 1 |
| `*` light（6 条） | `#ffffff` | `#f0f0f0` | 1 |

负对照：**未变的那 900+ 条 expected 逐字不变**（改动前后 manifest 全量 diff，只允许 Tooltips 12 条 + warnings 变）。
⛔ 离线自证**不替代实跑**（Task 5）。

### Task 4 — 豁免表

`figma-data/audit-allowlist/render-excused-fields.json`（现 **35** 行）

- **删 2 行**：`Tooltips | rootFillHex` · `Tooltips | rootBorderHex` —— 由闸自己宣布（shrink-only：
  不再命中就 FAIL 要求删行）。⇒ 35 → **33**。
- **改 7 行的 `reason` + `fixDirection`**（成因订正，不是新豁免）：
  - `rootPadding.{top,right,bottom,left}` → 保 `extract-pipeline`，但写清缺口在 **child 侧字段集**
    （`nodeGeometry()` 不记 `pH/pV/pR/pB`），修法 = 扩 child 字段集后重跑 extract。
  - `rootGap`（`*--pointing-*-down--*`）→ 成因改成「root 是 Figma wrapper，`gap: -6` 是真值
    （24 + 8.485 − 6 = 26.485 算术闭合）；code arrow 绝对定位 ⇒ 该层无对应物」，`fixDirection`
    **不再是** `extract-pipeline`。
  - `rootWidth` / `rootHeight`（`*--pointing-*-up--*`）→ 成因改成「`*-up` 的 root `layoutMode: null`
    ⇒ 无 HUG 可 skip；且 root `h` 含 arrow」。

### Task 5 — 实跑两链 + 两道闸

⚠️ **顺序**（report 自报豁免行数进 S6b，不重跑闸会红在与改动无关的判据上）：
改 expected → 跑两链 → 看 `rowScopes` 的 `failingEntries` → 再 shrink 表 → 再跑两链 → 跑两道闸。

⚠️ **跑前先 `lsof -nP -iTCP:5173 -sTCP:LISTEN`** —— `playwright.render-verification.config.ts` 写着
`reuseExistingServer: true`，端口被并行 session 占着会**静默连上主仓那棵树 = 假证据**。
正解 = 临时 config 起隔离端口 + 独立 `cacheDir`（worktree 的 `node_modules` 是主仓 symlink，`.vite` 共享）。
⛔ 临时 `playwright*.config.ts` / `vite*.config.ts` **必须在 `git add` 前删掉** —— `audit:typecheck-scope`
对这两个 glob 是 `growth=blocking`。

- `pnpm test:render-verification`（Vue）
- `pnpm test:render-verification-react`（React；需 `ln -s` 主仓 `react-pilot/node_modules`）
- `pnpm audit:render-drift-gate` + `pnpm audit:render-drift-gate-react` —— 取真退出码，**不接管道**（zsh 无 `$PIPESTATUS`）

### Task 6 — 登记两个新发现（⛔ 不修）

1. **`rootRadius` 是静默缺口**：`pushNumber` 在 `expected === null` 时 early-return ⇒ Tooltips 的
   `rootRadius` **检查从未发出过**（report 里没有这一行），code 的 4px 从未被验。paint 下降后仍如此
   （radius 不在本轮的 5 个字段里）。
2. **同族 radius 不一致**：SLOT 实测 `left down` r=4 · `Center down` r=**2** · `right down` r=**2**
   · 全部 `*-up` r=4；code 一律 `--r-s` = 4px。改它动 src/** + 需 designer ack。

### Task 7 — 收尾

backlog F129 entry 更新（(b) 那条重写成三机制 + 本轮成果）· STATUS 顶部（⚠️ 3000 B 硬上限，
现值 2038 B / 余量 962）· `git commit -F <msg> -- <显式路径>`（新文件先 `git add`）·
`bash .husky/pre-commit` 手跑取真退出码（worktree 里 hook 一个都不跑，**且 commit 不加 `--no-verify`**）·
`git push origin HEAD:master`，落地以 `git ls-remote origin master` 为准。

⛔ **不写 changeset**：`scripts/generate-render-verification-manifest.mjs` **不在** `package.json`
`files[]` 白名单内（实核，白名单里 `scripts/` 只有 `audit-mockup-*` / `audit-product-code` /
`audit-stale-anchors` / `validate-upstream-gate` / `upstream-gate.schema.json` / `consumer-postinstall` /
`setup-consumer.sh` / `lib/figma-env` / `lib/figma-file-chunked` / `lib/figma-payload-limit` /
`lib/mockup-library-origin`）。判据真源 = `files[]`，见 AGENTS §「何时写」。
