# Vue↔React 使用级 parity 对比工具 — 设计 spec

- **日期**：2026-07-13（初稿）· **2026-07-13 晚精化**：Step 0 现实复验证伪 slot-drop 前提，reframe 工具定位 + 拆 D1/D2（见下 §0）。
- **状态**：设计定稿 → writing-plans。**取代 [`2026-07-08-framework-structure-parity-tool-design.md`](./2026-07-08-framework-structure-parity-tool-design.md)**。
- **owner 定位**：owner 在 docs 站切 React 走查时发现 TopBar 效果与 Vue 有差异，要脚本比对两架构**使用级 1:1**（用户视角一致，除非技术实现本就不同）。

---

## 0. 本 spec 的范围 = D1（harness parity gate）· 精化后拆分（2026-07-13 晚）

Step 0 现实复验（live，见 §1）证伪了初稿的 slot-drop 前提，工具定位重排为**两个有序交付物**：

| | D1（**本 spec**） | D2（另立 spec） |
|---|---|---|
| 是什么 | **harness parity gate** — 两 harness 同 props+同 slot fixture 互比 Vue-actual↔React-actual | **两端 demo 单一来源** — Vue 页 + react-pilot demo 从一份 descriptor 生成 |
| 抓什么 | 框架间**使用级分歧**（结构残缺回归 + 样式漂移），承载 self-証 | 消除 docs demo **结构漂移**（owner 实际踩的 #1/#2/#3） |
| 状态 | 本 spec，现在建，= v0.11.0 owner-gated 前置 | D1 落地后在干净基线上另开 brainstorm→spec→plan |

owner 拍板（2026-07-13）：先做 D1、提交/push，再做 D2；v0.11.0 发版等 D1+D2 全完成 + owner 查看效果后再定（不急）。

---

## 1. 背景与根因（2026-07-13 晚 live 纠正 — slot-drop 前提证伪）

owner 反馈：docs 站 React 态 TopBar 与 Vue 有差异。**初稿归因为 slot-drop（shadow-DOM CE 具名 slot 静默丢失）——已被 Step 0 现实复验证伪。**

**Step 0 live 实证（2026-07-13 晚，高置信，playwright + 亲读证据）**：当前 shadow-DOM CE 构建下**具名 slot 投影正常**——
- `named-slot-projection.spec.ts`（INFRA-F54）六 case（FormItem/Tooltip/PopupBox）**全 pass**；
- TopBar CE live 渲染：`logo`/`right-content` 默认投影，`menu` 在 `showMenu=true` 时投影（默认不显是 `showMenu` prop 门控，**非** slot-drop）；
- 机制：`src/canonical/TopBar.vue` **无条件转发** slot（INFRA-F43，注释明禁 `v-if="$slots.x"`）+ `useHasSlot()` CE-safe guard（INFRA-F54）。slot-drop bug **已于 Jul-8 修好，当前不存在**。

**owner 实际踩的坑，按性质分层**（这决定工具分工）：

| # | 差异 | 性质 | 归哪个 gate 抓 |
|---|------|------|---------------|
| 1/2/3/7 | React docs demo 缺真 Logo / 九宫格 / search slot / Try-it 不全 | **docs demo 结构漂移**（不在 harness） | **D2 单一来源**（harness 两侧注同一 fixture，结构上抓不到） |
| 5 | React `<tvu-logo>` host 35px vs Vue SFC 32px（CE host `display:inline` 基线间隙） | **真框架间表示差异** | **D1 parity method-A**（已修于 `Logo.vue :host{display:inline-flex}`；= self-証靶） |
| 6 | 时钟丢 LED 数字字体（`:lang(en)` 特异度压过 `.timezone-block strong`） | **共享回归**（两端一样坏） | **Figma-conformance**（parity 互比抓不到，见 §4.6） |

> ⚠️ 初稿把 light-DOM 转换（`shadowRoot:false`）当"修复方向"——实际当前 TopBar/FormItem/PillStatus **全是默认 shadow-DOM 且 slot 正常**，light-DOM 转换**不必要**（components.config.ts 实证无 `shadowRoot:false`）。

**D1 harness parity gate 仍有价值（不因 slot-drop 不存在而废）**：
- **C 方法 = slot-drop 回归护栏**：slot 投影现在对，但 C 守住"未来有人再破坏无条件转发/useHasSlot 就红"。day-one 绿。
- **A 方法 = 真 style drift 侦测**：Step 0 已发现真漂移——`react-drift-full` 8 处 FormItem-radio rootWidth（React 176~384 vs Figma/Vue 440/638）+ logo-host 3px。A survey 先测漂移量；窄 A-gate 卡表示无关字段（§4.2）。

---

## 2. 目标

一个**确定性 Playwright parity gate**：对双框架组件，用**同一份 props + 同一份 slot 内容**在两 harness 各渲染一次，直接互比 **Vue-actual ↔ React-actual**（不再各自 vs Figma），抓：
- **结构残缺回归**（slot-drop 现已修好、C 守住"未来再破坏就红"——缺 logo/menu = 缺可见元素/文本）；
- **样式/视觉漂移**（padding/color/size 两框架不一致；#5 logo-host 是当前真实存在过的一例）。

展示效果允许不同（如 Vue 用 SFC、React 用 CE 是实现差异），但**使用级语义结构需一致**。

---

## 3. 关键约束（决定一切设计）：表示层非对称

两 harness 底层 DOM 表示天然不同，这不是 bug、是 docs 站真实用法：

| | Vue harness（5173, `RenderHarnessPage.vue`） | React harness（5174, `RenderHarness.tsx`） |
|---|---|---|
| 渲染物 | canonical Vue SFC（`src/canonical/*.vue`） | React wrapper → `<tvu-*>` custom element |
| DOM | light DOM，无 shadow root | CE，含 shadow root |
| slot | VDOM slot（投影正常） | native `<slot>` 投影（**现已正常**，Step 0 实证；C 守其不回归） |

**推论（直接塑造方法分工与 rollout）**：
- 比 Vue-actual ↔ React-actual = 比 **SFC-light-DOM ↔ CE-shadow-DOM**。这正是 owner 要比的"使用级"两条真实路径。
- 但**计算样式互比（method A）会天然撞上表示差异导致的良性 drift**（box model / 默认 margin / shadow 边界等），从没跨框架 baseline 过 → A 不能 day-one 盲上硬 gate。
- **可见文本 + 语义元素轮廓互比（method C）是表示无关的**，slot-drop = 缺可见元素/文本，跨 SFC↔CE gap 稳健、假阳低 → C 可 day-one 作 gate。

---

## 4. 关键决策

### 4.1 产物 = Playwright parity gate（非 HTML 报告）
与现有 render-verification / `audit:render-drift-gate` 同构，复用 `collectActual`，长期自动守护回归。失败时打印 per-entry diff（Vue-actual vs React-actual 的数值/文本差）即可定位，不维护 HTML 报告+截图基线那套重设施。
- **不做截图像素 diff（method B）**：确定性弱（字体/子像素/Chart canvas），A+C 之后按需再议。
- **不产 gitignored HTML 报告**（旧 spec 那套）：gate 失败输出已足够诊断。

### 4.2 两方法分工 + 差异化 rollout
| 方法 | 抓什么 | 数据基础 | rollout |
|---|---|---|---|
| **C 可见文本 + 语义元素轮廓互比** | 结构残缺 / slot-drop **回归** | 复用 `collectVisibleText`（穿 shadow root）+ 元素轮廓计数（nav/a/button/input/img/svg/CE） | **day-one gate**（回归护栏），限风险集（见 4.4） |
| **A-broad 计算样式互比 survey** | 全字段样式/视觉漂移 | 复用 `collectActual`（root `[data-manifest-id]` + per-entry selector，读 width/height/padding/radius/gap/opacity/colors/border/text；force `transition:none`） | **baseline-first / 非阻塞**：广撒网 survey 测漂移量 → 三元分类 → 带 allowlist 后再议升 gate |
| **A-narrow 窄 gate** | **表示无关、该 1:1 的 host 盒模型字段**（独立组件 host 高度） | 同 `collectActual`，但**只断言 §4.4 窄集的单一字段**（Logo host height） | **day-one gate**（阻塞）：跨 SFC↔CE 无良性 drift，是 self-証靶（§6） |

> **A 为何分 broad-survey / narrow-gate 两层**（[[feedback_baseline-before-plan]] / FIGMA_SOT 禁启发式 / [[feedback_lead-with-robust-not-cheapest]]）：
> - **broad** — Vue↔React 全字段计算样式从没互比过，表示非对称必带良性 drift（box model / 默认 margin / shadow 边界）；直接硬 gate = day-one 红一片。先 survey 实测再定阈值/allowlist，证据驱动。allowlist accept 型收口沿用 contrast/non-text 范式（[[feedback_audit-whitelist-vs-sot-refactor]]：良性差异显式 accept，不为凑 pass 改 SoT）。
> - **narrow** — 但有一类字段**没有良性 drift 余地**：独立组件的 host 盒模型（Logo host 高度）两框架就该完全一致，任何差异都是真 bug（#5 logo-host 正是此类）。这类**必须 day-one 硬 gate**，否则 §6 self-証（要求 pre-fix FAIL / post-fix PASS 的真 gate）无处落。窄集起步只含 self-証所需的 Logo host height，triage 后按 survey 结果扩。

### 4.3 对比器 = 专用 actual-vs-actual（不复用 buildChecks）
`buildChecks` 是 Figma-expected 专用（读只存在于 `ExpectedFromFigma` 的 `rootWidthMode/rootHeightMode/rootGapMeaningful`），套 actual-vs-actual 要伪造 expected-shape，脏。**写独立小对比器**：
- **A**：逐字段比两个 `collectActual` 扁平对象——数值字段带容差、颜色字段归一 hex 后精确（复用 `colorToHex`/`normalizeHex`），按 `manifestId`+`theme` 配对。
- **C**：比 `collectVisibleText` 输出的可见文本 token 集 + 元素轮廓计数。
- `collectVisibleText` 现为 `named-slot-projection.spec.ts` 内**局部 const**、须 self-contained 传 `page.evaluate`（不能闭包 module 作用域）→ 抽共享时导出**函数引用/字符串**，不是普通可 import 的 util（序列化约束写进 plan）。

### 4.4 覆盖范围
双框架组件 25 个（`PILOT_MAP`），24 个有 manifest 条目（**DateTime 0 条 = 已知缺口**，manifest 驱动路径覆盖不到，标注、暂不为它单建，YAGNI）。
- **C gate（day-one 回归护栏）= 具名插槽风险集**：**TopBar / FormItem / PillStatus**（均有 manifest 条目）。MenuList/UserMenu 无 demo/无 manifest、只是 TopBar 复合体子件 → 不单测，随 **TopBar 的 menu slot 内容搭车**覆盖。
- **A-broad survey（baseline 非阻塞）= 广撒网**：两 harness 都能渲染的 24 组件全 930 条互比，产漂移量报告供 triage。
- **A-narrow gate（day-one 阻塞）= 独立 Logo host**：`Logo` 不在现有 harness `entries`（React harness filter 无 Logo、manifest 无 Logo 条目）→ **两 harness 各加一条 parity 专属 Logo 渲染路径**（`<Logo type="tvu" size={32}>`，data-manifest-id="parity-logo"），窄 gate 断言两侧 host `height` 互等（±1px）。这是唯一 day-one 硬断言的 A 字段，承载 §6 self-証。**为何单独渲染而非借 TopBar logo slot**：harness slot fixture 用纯文本（§4.5），logo-host 差异只在真 `<tvu-logo>` 元素上显现；且 method-A 读 root `[data-manifest-id]` 元素，借 slot 要加嵌套 sub-selector，更脏。独立 Logo 条目最干净。

### 4.5 slot 注入机制（crux）
- slot 内容 = 合成测试内容（非 Figma-derived）→ **放独立 parity 专属 fixture**（如 `tests/parity/slot-fixtures.ts`），按 `manifestId`（或 `codeComponent`+variant）键，列具名 slot → 内容（纯文本/简单带文字元素最稳、两侧可等价渲染）。
  - **不放 Figma manifest**：manifest 由 generator 生成（L947-977），手加字段会被 regen 清掉；且 manifest 应保持 Figma-derived 纯净。fixture 独立 = 无 clobber 风险 + 落我车道。
- **两 harness 各加最小 slot 注入路径**，读同一 fixture、渲染语义等价内容：
  - Vue（`RenderHarnessPage.vue`）：把 fixture 转 Vue slot children 渲进 `<component :is>`。
  - React（`RenderHarness.tsx`）：把 fixture 转 `slot="..."` children 渲进 `<tvu-*>`。
- 满足 pickup §3 前提：**共享 props harness、不用 docs demo 页**（docs demo 的 slot 故意不同：Vue 用 Logo/Icon/Switch SFC、React 用 img+markup，比它会报预期差异当 bug）。

### 4.6 三 gate 分工（写清各抓什么，别互相错位 — pickup §3 核心）

| gate | 比什么 | 抓的失败类 | owner 案例 |
|---|---|---|---|
| **parity（本 spec D1）** | Vue-actual ↔ React-actual（两 harness 互比） | **框架间分歧**：一侧有另一侧没有 / 一侧样式偏离另一侧 | #5 logo-host（React≠Vue） |
| **Figma-conformance（现有 `test:render-verification`）** | 各侧 actual vs Figma expected | **共享偏离**：两框架**一样**偏离 Figma（parity 互比会互相匹配 → 假阴） | #6 时钟字体（两端一样丢 LED 字体，parity PASS 但都错） |
| **demo-parity（D2 单一来源）** | 两端 demo 从**同一 descriptor** 生成 | **docs demo 结构漂移**：demo 少了该有的元素（harness 注同 fixture 抓不到） | #1/#2/#3（React demo 缺 Logo/九宫格/search） |

**关键**：parity 抓"框架间不一致"，**天然抓不到**"两端一样坏"（#6 类共享回归）——那归 Figma-conformance。#6 时钟字体是 **demo 内容**（harness 不渲时钟），其 Figma-conformance 断言归 D2 demo 层落地（单一来源 demo 里加"clock 用 `--font-family-clock`"的期望断言）；D1 本 spec 只负责把分工写清、不越界替 demo 做 conformance。

---

## 5. 架构

**新增（我车道，均不碰并行 session 的 5 个 dirty 文件）：**
- `tests/parity/framework-parity.spec.ts` — Playwright spec：按 `manifestId`+`theme` 遍历风险集（C）/ 全集（A survey），各访 Vue（5173）与 React（5174）两 harness、收集、互比、断言/报漂移。
- `tests/parity/lib/parity-compare.ts` — 专用 actual-vs-actual 对比器（A 数值/颜色 + C 文本/轮廓）；复用 `drift-compare-core` 的 `colorToHex`/`normalizeHex`。
- 共享 `collectVisibleText`（从 `named-slot-projection.spec.ts` 抽出，保持可序列化传 `page.evaluate`）。
- `tests/parity/slot-fixtures.ts` — parity 专属 slot 内容 fixture（TopBar/FormItem/PillStatus）。
- `playwright.framework-parity.config.ts` — **两 webServer**（5173 `pnpm dev` + 5174 react-pilot vite），`testDir: tests/parity`。
- `package.json` script `test:framework-parity`；CI 与 render-verification 并列挂载。

**最小改（我车道）：**
- `playground/docs/pages/RenderHarnessPage.vue` + `react-pilot/harness/RenderHarness.tsx`：各加读 fixture 的 slot 注入路径（默认无 fixture 时行为不变，不影响现有 render-verification）。

**流程（单条 entry）**：设 `theme` → 访 Vue harness 收 `collectActual`+`collectVisibleText` → 访 React harness 收同两项 → parity-compare 互比 → C 断言（gate）/ A 记录（survey）。

**前置条件**：两 harness 被比的 root 都须带 `data-manifest-id`（`collectActual` 靠它定位）。Vue 侧 `RenderHarnessPage` 已 stamp；**React 侧 CE root 是否 stamp 待 plan 核实**，缺则补。

---

## 6. 自证（gate 必须证明能抓 bug — FP-critical，2026-07-13 晚改用真实可复现差异）

parity 是 FP-critical（该红却绿 = 无用）。**初稿的 self-証（pre-fix shadow-DOM → C gate FAIL）已作废**——slot-drop 不存在（§1），无 pre-fix 态可复现。**改用 §1 表 #5 logo-host 真实差异，走 A-narrow gate**：

1. **post-fix（当前 HEAD）** 跑 A-narrow gate（Logo host height）→ 断言 **PASS**（`Logo.vue :host{display:inline-flex}` 已修，React host = Vue host = 32px，±1px 内互等）。
2. **pre-fix**：在工作树临时**回滚 `src/canonical/Logo.vue` 的 `:host{display:inline-flex; vertical-align:middle}` 块**（4 行，自包含）→ 重建 `dist-wc` → 跑 A-narrow gate → 断言 **FAIL**（React `<tvu-logo>` host `display:inline` 基线间隙 → host ≈ 35px vs Vue SFC 32px，差 ~3px > ±1px 容差）。
3. 恢复 `:host` 块 + 重建 → 复跑 → 回到 **PASS**。三态（post PASS / pre FAIL / 恢复 PASS）stdout 记入 SDD ledger 作 gate 有效性证据。

> 为何不用回滚 F43 slot 转发做 C 的 self-証：技术上可行（re-gate `v-if="$slots.x"` 复现 slot-drop），但那要动 INFRA-F43 核心机制、且 C 是回归护栏（守未来），self-証成本高。#5 logo-host 是**当前真实存在过的差异**、回滚点自包含 4 行、A-narrow gate 承接干净——最低成本证明 gate 真能抓真差异（[[feedback_lead-with-robust-not-cheapest]]）。C 护栏的有效性由其对 slot fixture 的 day-one PASS + 逻辑单测覆盖（Task 2 `assertSlotTokensPresent` 缺失即报）间接保证。

---

## 7. 时序与车道分离
- **执行前置已解除**：初稿要求"等并行 session light-DOM 修复落地"——该修复**不必要且未发生**（§1），当前 shadow-DOM 基线即最终基线。Step 0 已确认工作树干净、baseline gate 状态已知（Vue render-verification 绿；React render-verification-react 有 8 处 **已知 committed** FormItem-radio rootWidth 红，不在 CI、非本工具引入，A-broad survey 会 triage 之）。**直接在当前 HEAD 建。**
- **车道**：两 session 已收尾，territory 全开。**本工具拥有** `tests/parity/*` + 两 harness 的 slot/Logo 注入小改 + 本 spec/plan + config + package.json。勿动别产品线文件（`mockup-conventions.md` / `_design-kickoffs/*`）。
- 执行方式 = **in-session SDD subagent**（非 Codex）：横切两 harness + FP-critical 验证回路，需紧迭代 + controller（我）/task-reviewer 复核；implementer 不自 commit（[[feedback_executor-no-self-commit]]）。

---

## 8. 非目标（YAGNI）
- 不做截图像素 diff（method B）。
- 不产 HTML 报告 / 不做 dark-light 并排（method A 本就 per-theme 跑，主题是对比维度不是展示）。
- **不做 D2（两端 demo 单一来源）**——那是独立交付物，D1 落地后另开 spec。D1 只建 harness parity gate。
- **不碰 demo 漂移覆盖**（#1/#2/#3 归 D2）；也不删/改现有浅 `audit:demo-framework-parity`（section 标题闸，D2 会 supersede，D1 不动它）。
- **不修 React FormItem-radio rootWidth 已知红**（committed、不在 CI）——A-broad survey 会记录、triage，是否真修属 survey 结论后事。
- 不为 DateTime（0 manifest 条目）单建 parity 路径。

---

## 9. 依赖与风险
- **A 漂移量未知** → 必须先 baseline survey 再定 A gate 阈值/allowlist（§4.2），不盲上。
- **generator-clobber** → slot 内容走独立 fixture 规避（§4.5）。
- **`collectVisibleText` 序列化约束** → 抽共享时保持 self-contained、导出函数引用（§4.3）。
- **React CE root `data-manifest-id`** 待 plan 核实补齐（§5 前置）。
- **两 webServer 同起** → Playwright 支持 webServer 数组；端口 5173/5174 与现有 config 一致，注意本地并发占端口。
- **MenuList/UserMenu 不可直测** → 随 TopBar menu slot 搭车（§4.4）。
- **DateTime 覆盖缺口** → 已标注，暂不建。

---

## 10. 待 plan 解决的开放项
1. slot fixture 的存放位置需两 harness 都能 import（`tests/` vs playground/react-pilot 构建边界）——plan 定最终路径。
2. A-broad survey 的漂移三元分类标准 + accepted-diff allowlist 落点。
3. C 的"语义元素轮廓"精确字段集（沿用旧 spec 的 nav/a/button/input/img/svg/CE 计数，plan 定稿）。
4. ~~React CE root 是否已 stamp `data-manifest-id`~~ **已解决**：`RenderHarness.tsx:237` 在 wrapper `<div data-manifest-id>` 上 stamp（不在 CE 本身，`collectActual` 靠它定位 OK）。
5. **A-narrow Logo 条目**：两 harness 加 `parity-logo` 渲染路径（Vue `RenderHarnessPage.vue` + React `RenderHarness.tsx`，各渲 `<Logo type="tvu" size={32}>` 带 `data-manifest-id="parity-logo"`）；spec 遍历时 A-narrow 只对该条目断言 host height 互等。React 侧 `Logo` wrapper 存在（`react-pilot/src/wrappers/Logo`，demo 已用）。
