# Vue↔React 使用级 parity 对比工具 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 一个确定性 Playwright parity gate，对双框架组件用同一份 props + 同一份 slot 内容在两 harness 各渲一次，直接互比 Vue-actual ↔ React-actual，抓 slot-drop（结构残缺）与样式漂移。

**Architecture:** 复用现有两 render-verification harness（Vue 5173 = canonical SFC/light-DOM；React 5174 = `<tvu-*>` CE/shadow-DOM）。C（可见文本+元素轮廓互比）= day-one gate、限风险集 TopBar/FormItem/PillStatus；A（计算样式互比）= baseline-first survey。slot 内容走独立 fixture（不污染 Figma-derived manifest），两 harness 各加最小注入。对比器专用 actual-vs-actual（不复用 Figma 专用的 `buildChecks`）。

**Tech Stack:** Playwright（`@playwright/test`）、Vue 3.5、React 18、TypeScript、pnpm、vitest（对比器纯函数单测）。复用 `tests/visual-verify/lib/drift-compare-core.ts` 的 `collectActual`/`normalizeHex`/`colorToHex`/`closeEnough`。

## Global Constraints

- **设计真源**：`docs/superpowers/specs/2026-07-13-vue-react-usage-parity-tool-design.md`（本 plan 实现它 = **D1 harness parity gate**；D2 单一来源 demo 另立 spec）。
- **执行前置已解除（2026-07-13 晚精化）**：初稿要求"等并行 session light-DOM 修复落地"——该修复 **不必要且未发生**（slot-drop 前提被 Step 0 证伪，见 spec §1）。**当前工作树干净、master、shadow-DOM 基线即最终基线，直接在 HEAD 建。**
- **baseline 状态（Step 0 已核）**：Vue `test:render-verification` 绿；React `test:render-verification-react` 有 **8 处已知 committed FormItem-radio rootWidth 红**（`docs/internal/react-render-verification-report.md`，不在 CI、非本工具引入）。本工具**不修它**，A-broad survey 记录+triage（spec §8）。
- **implementer 不自 commit**：SDD implementer 只报 diff+stdout；controller（我）复审 + task-reviewer 过后由 controller commit（[[feedback_executor-no-self-commit]]）。commit 只 `git add` 具体文件、绝不 `-A`；push 后用 `git ls-remote` 权威确认 SHA（origin 双推 Gitea+GitHub，瞬时 rejected 常见）。
- **默认无 fixture 时两 harness 行为不变**：现有 `test:render-verification` / `test:render-verification-react` 必须**状态不变**（Vue 全绿；React 仍是上面那 8 处已知红，不新增）——slot/Logo 注入是 opt-in（仅当 manifestId 命中 fixture / == `parity-logo`）。

---

## Task 0: 执行前置核查（无代码，gate）— 已由本 session Step 0 完成

**Files:** 只读核查，不改文件。**本 session 现实复验已做完，结论固化如下**，实现 subagent 起手快速复核即可。

- [x] **Step 1: 工作树 + 基线（Step 0 已确认）**
  - `git status` 干净、master、up-to-date（无并行 session 在飞）。
  - `components.config.ts`：TopBar/FormItem/PillStatus **无 `shadowRoot:false`** = 默认 shadow-DOM（light-DOM 转换未发生、不必要）。
  - Vue `test:render-verification` 绿；React `test:render-verification-react` = 8 处**已知 committed** FormItem-radio rootWidth 红（非本工具引入，不在 CI）。

- [x] **Step 2: slot 投影 live 确认（Step 0）**：F54 六 case 全 pass；TopBar CE live logo/menu/right-content 三具名 slot 正常投影（menu 受 `showMenu` prop 门控）。→ **slot-drop 不存在，C 作回归护栏**。

- [x] **Step 3: 车道文件当前形态已读**：`RenderHarness.tsx`（`renderComponent`/`adaptProps` L116-153，data-manifest-id 在 L237 wrapper）、`RenderHarnessPage.vue`、`components.config.ts`、`drift-compare-core.ts`（`collectActual` L181 / `closeEnough` L135 = `(a,b)=>|a-b|<=1`）已在本 session 主线读过，行号以当前 HEAD 为准。

> 实现 subagent 起手仅需 `git status`（确认仍干净）+ `pnpm build:wc` 是否需重跑（Step 0 已于 18:12 重建过 dist-wc）。**无需再等任何并行修复。**

---

## Task 1: 抽共享可序列化收集器（`collectVisibleText` + `collectElementOutline`）

**Files:**
- Create: `tests/parity/lib/collect-visible.ts`
- Test: `tests/parity/lib/collect-visible.test.ts`

**Interfaces:**
- Produces: `export const collectVisibleText: () => string`（穿 open shadow root、checkVisibility 门控、`' | '` join）；`export const collectElementOutline: () => Record<string, number>`（计数 nav/a/button/input/img/svg + CE tag，即含 `-` 的 tagName）。两者**必须 self-contained**（无 module 外引用），供 `page.evaluate(fn)` 序列化。

- [ ] **Step 1: 写失败单测**

```ts
// tests/parity/lib/collect-visible.test.ts
import { describe, it, expect } from 'vitest'
import { collectVisibleText, collectElementOutline } from './collect-visible'

describe('collectVisibleText', () => {
  it('collects visible text tokens joined by pipe', () => {
    document.body.innerHTML = '<div><span>Alpha</span><span>Beta</span></div>'
    // jsdom 无 checkVisibility → 函数回退 true，全部计入
    const text = collectVisibleText()
    expect(text).toContain('Alpha')
    expect(text).toContain('Beta')
  })
})

describe('collectElementOutline', () => {
  it('counts semantic + custom elements', () => {
    document.body.innerHTML = '<nav><a>x</a><button>y</button></nav><tvu-top-bar></tvu-top-bar>'
    const outline = collectElementOutline()
    expect(outline.nav).toBe(1)
    expect(outline.a).toBe(1)
    expect(outline.button).toBe(1)
    expect(outline.customElement).toBe(1)
  })
})
```

- [ ] **Step 2: 跑测确认失败**

Run: `pnpm vitest run tests/parity/lib/collect-visible.test.ts`
Expected: FAIL — 模块不存在。

- [ ] **Step 3: 实现（`collectVisibleText` 逐字移植自 `named-slot-projection.spec.ts` L35-52，新增 outline）**

```ts
// tests/parity/lib/collect-visible.ts
// Self-contained collectors for page.evaluate() — NO external module refs
// (they are serialized into the browser context). collectVisibleText is moved
// verbatim from tests/render-verification-react/named-slot-projection.spec.ts.

export const collectVisibleText = (): string => {
  const out: string[] = []
  const walk = (root: Document | ShadowRoot) => {
    for (const el of Array.from(root.querySelectorAll('*'))) {
      if ((el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot) {
        walk((el as Element & { shadowRoot: ShadowRoot }).shadowRoot)
      }
      for (const n of Array.from(el.childNodes)) {
        if (n.nodeType === 3 && (n.textContent ?? '').trim()) {
          const vis = typeof (el as unknown as { checkVisibility?: (o: object) => boolean }).checkVisibility === 'function'
            ? (el as unknown as { checkVisibility: (o: object) => boolean }).checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
            : true
          if (vis) out.push((n.textContent ?? '').trim())
        }
      }
    }
  }
  walk(document)
  return out.join(' | ')
}

export const collectElementOutline = (): Record<string, number> => {
  const counts: Record<string, number> = { nav: 0, a: 0, button: 0, input: 0, img: 0, svg: 0, customElement: 0 }
  const walk = (root: Document | ShadowRoot) => {
    for (const el of Array.from(root.querySelectorAll('*'))) {
      const tag = el.tagName.toLowerCase()
      if (tag in counts) counts[tag]++
      if (tag.includes('-')) counts.customElement++
      if ((el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot) {
        walk((el as Element & { shadowRoot: ShadowRoot }).shadowRoot)
      }
    }
  }
  walk(document)
  return counts
}
```

- [ ] **Step 4: 跑测确认通过**

Run: `pnpm vitest run tests/parity/lib/collect-visible.test.ts`
Expected: PASS（两 describe 全绿）。

- [ ] **Step 5: 把 `named-slot-projection.spec.ts` 的局部 `collectVisibleText` 换成 import**（去重，但保持其序列化用法）

在 `tests/render-verification-react/named-slot-projection.spec.ts` 内，删除 L35-52 的局部 `const collectVisibleText`，改从共享模块引入。⚠️ 因 `page.evaluate(fn)` 序列化要求函数体不闭包 module 引用——`collectVisibleText` 无外部引用，直接 `import { collectVisibleText } from '../parity/lib/collect-visible'` 后 `page.evaluate(collectVisibleText)` 仍成立（Playwright 序列化函数源码）。

```ts
// 顶部 import 区
import { collectVisibleText } from '../parity/lib/collect-visible'
// 删除原 L35-52 局部定义；其余 CASES/for 循环不变
```

- [ ] **Step 6: 跑 React 名 slot gate 确认仍绿**

Run: `pnpm test:render-verification-react 2>&1 | tail -8`
Expected: INFRA-F54 named-slot projection 6 cases 仍 PASS。

- [ ] **Step 7: 报 diff + stdout（不 commit）**

---

## Task 2: 专用 actual-vs-actual 对比器

**Files:**
- Create: `tests/parity/lib/parity-compare.ts`
- Test: `tests/parity/lib/parity-compare.test.ts`

**Interfaces:**
- Consumes: `collectActual` 的返回扁平对象（`{ width, height, paddingTop..Left, borderRadius, gap, opacity, color, backgroundColor, borderColor, borderWidth, textColor?, ... } | { missingTarget: true }`）；`normalizeHex`/`colorToHex`/`closeEnough` from `../../visual-verify/lib/drift-compare-core`。
- Produces:
  - `export function compareVisibleText(vue: string, react: string): { vueOnly: string[]; reactOnly: string[]; shared: string[] }`
  - `export function assertSlotTokensPresent(text: string, tokens: string[]): { missing: string[] }`
  - `export function compareComputedStyle(vue: any, react: any): { field: string; vue: unknown; react: unknown }[]`（数值 field 用 closeEnough、颜色 field 归一 hex 后比、其余字符串精确）

- [ ] **Step 1: 写失败单测**

```ts
// tests/parity/lib/parity-compare.test.ts
import { describe, it, expect } from 'vitest'
import { compareVisibleText, assertSlotTokensPresent, compareComputedStyle } from './parity-compare'

describe('compareVisibleText', () => {
  it('splits pipe-joined text into token sets and diffs', () => {
    const r = compareVisibleText('Logo | Menu | Title', 'Title')
    expect(r.vueOnly.sort()).toEqual(['Logo', 'Menu'])
    expect(r.reactOnly).toEqual([])
    expect(r.shared).toEqual(['Title'])
  })
})

describe('assertSlotTokensPresent', () => {
  it('reports missing slot tokens', () => {
    expect(assertSlotTokensPresent('Title', ['Logo', 'Menu', 'Title']).missing.sort()).toEqual(['Logo', 'Menu'])
    expect(assertSlotTokensPresent('Logo | Menu | Title', ['Logo', 'Menu']).missing).toEqual([])
  })
})

describe('compareComputedStyle', () => {
  it('flags color diff by normalized hex, ignores sub-px numeric noise', () => {
    const vue = { width: 100.2, backgroundColor: 'rgb(255, 0, 0)', color: '#141414' }
    const react = { width: 100.0, backgroundColor: '#ff0000', color: 'rgb(20, 20, 20)' }
    const diffs = compareComputedStyle(vue, react)
    expect(diffs).toEqual([]) // width within closeEnough, colors equal after hex-normalize
  })
  it('flags a real background mismatch', () => {
    const diffs = compareComputedStyle({ backgroundColor: '#ffffff' }, { backgroundColor: '#000000' })
    expect(diffs.map(d => d.field)).toContain('backgroundColor')
  })
})
```

- [ ] **Step 2: 跑测确认失败**

Run: `pnpm vitest run tests/parity/lib/parity-compare.test.ts`
Expected: FAIL — 模块不存在。

- [ ] **Step 3: 实现**

```ts
// tests/parity/lib/parity-compare.ts
import { normalizeHex, colorToHex, closeEnough } from '../../visual-verify/lib/drift-compare-core'

const NUMERIC_FIELDS = new Set(['width', 'height'])
const COLOR_FIELDS = new Set(['color', 'backgroundColor', 'borderColor', 'textColor'])

function tokens(text: string): string[] {
  return text.split(' | ').map(t => t.trim()).filter(Boolean)
}

export function compareVisibleText(vue: string, react: string) {
  const v = new Set(tokens(vue))
  const r = new Set(tokens(react))
  return {
    vueOnly: [...v].filter(t => !r.has(t)),
    reactOnly: [...r].filter(t => !v.has(t)),
    shared: [...v].filter(t => r.has(t)),
  }
}

export function assertSlotTokensPresent(text: string, expected: string[]) {
  const have = new Set(tokens(text))
  return { missing: expected.filter(t => !have.has(t)) }
}

export function compareComputedStyle(vue: Record<string, unknown>, react: Record<string, unknown>) {
  const diffs: { field: string; vue: unknown; react: unknown }[] = []
  const fields = new Set([...Object.keys(vue), ...Object.keys(react)])
  for (const field of fields) {
    const a = vue[field]
    const b = react[field]
    if (a === undefined || b === undefined) continue
    if (NUMERIC_FIELDS.has(field)) {
      if (!closeEnough(Number(a), Number(b))) diffs.push({ field, vue: a, react: b })
    } else if (COLOR_FIELDS.has(field)) {
      const ah = normalizeHex(colorToHex(String(a)))
      const bh = normalizeHex(colorToHex(String(b)))
      if (ah !== bh) diffs.push({ field, vue: a, react: b })
    } else {
      if (String(a) !== String(b)) diffs.push({ field, vue: a, react: b })
    }
  }
  return diffs
}
```
> ⚠️ Task 0 落地后核实 `closeEnough` 容差语义（`drift-compare-core.ts:135`）；若签名非 `(a,b)=>bool` 则 re-anchor 调用。

- [ ] **Step 4: 跑测确认通过**

Run: `pnpm vitest run tests/parity/lib/parity-compare.test.ts`
Expected: PASS。

- [ ] **Step 5: 报 diff + stdout（不 commit）**

---

## Task 3: slot fixture（风险集）

**Files:**
- Create: `tests/parity/slot-fixtures.ts`
- Test: `tests/parity/slot-fixtures.test.ts`

**Interfaces:**
- Produces: `export type SlotFixture = { slots: Record<string, string>; expectTokens: string[] }`；`export const SLOT_FIXTURES: Record<string, SlotFixture>`（键 = `codeComponent`）。`slots` = 具名 slot → **纯文本 token**（两侧可等价渲染的最稳形态）；`expectTokens` = C gate 断言两侧可见文本都须含的 token 集。

- [ ] **Step 1: 发现风险集组件的真实 slot 名**

```bash
# TopBar slot outlets 在嵌套 BaseTopBar；canonical 只转发
grep -n "<slot" src/components/TopBar/TopBar.vue src/canonical/TopBar.vue
grep -n "<slot" src/canonical/FormItem.vue
grep -n "<slot" src/canonical/PillStatus.vue
```
Expected: 记录每个组件的具名 slot（如 TopBar 的 logo/menu/right-content、FormItem 的 label、PillStatus 的 default/count）。**fixture 的 slot 名以此实测为准**（post-fix 落地态）。

- [ ] **Step 2: 写失败单测**

```ts
// tests/parity/slot-fixtures.test.ts
import { describe, it, expect } from 'vitest'
import { SLOT_FIXTURES } from './slot-fixtures'

describe('SLOT_FIXTURES', () => {
  it('covers the named-slot risk set', () => {
    expect(Object.keys(SLOT_FIXTURES).sort()).toEqual(['FormItem', 'PillStatus', 'TopBar'])
  })
  it('every fixture declares non-empty slots and expectTokens', () => {
    for (const [name, fx] of Object.entries(SLOT_FIXTURES)) {
      expect(Object.keys(fx.slots).length, name).toBeGreaterThan(0)
      expect(fx.expectTokens.length, name).toBeGreaterThan(0)
      // expectTokens 必须都出现在某个 slot 值里（否则断言恒失败）
      const allSlotText = Object.values(fx.slots).join(' ')
      for (const tok of fx.expectTokens) expect(allSlotText, `${name}:${tok}`).toContain(tok)
    }
  })
})
```

- [ ] **Step 3: 实现（slot 名用 Step 1 实测值 re-anchor）**

```ts
// tests/parity/slot-fixtures.ts
// Synthetic, framework-neutral slot content shared by BOTH harnesses so a
// slot-drop shows up as a missing visible-text token on one side. Plain text
// tokens = the most representation-stable content. Slot NAMES verified against
// the post-fix canonical components (see plan Task 3 Step 1).
export type SlotFixture = { slots: Record<string, string>; expectTokens: string[] }

export const SLOT_FIXTURES: Record<string, SlotFixture> = {
  TopBar: {
    slots: { logo: 'PARITY_LOGO', menu: 'PARITY_MENU', 'right-content': 'PARITY_RIGHT' },
    expectTokens: ['PARITY_LOGO', 'PARITY_MENU', 'PARITY_RIGHT'],
  },
  FormItem: {
    slots: { label: 'PARITY_LABEL' },
    expectTokens: ['PARITY_LABEL'],
  },
  PillStatus: {
    slots: { default: 'PARITY_PILL' },
    expectTokens: ['PARITY_PILL'],
  },
}
```
> ⚠️ 若 Step 1 实测 slot 名与上表不同（如 TopBar 无 `right-content`），以实测改键；`expectTokens` 随之调整。default slot 在 React 侧 = 无 `slot` 属性的 children。

- [ ] **Step 4: 跑测确认通过**

Run: `pnpm vitest run tests/parity/slot-fixtures.test.ts`
Expected: PASS。

- [ ] **Step 5: 报 diff + stdout（不 commit）**

---

## Task 4: Vue harness slot 注入（`RenderHarnessPage.vue`）

**Files:**
- Modify: `playground/docs/pages/RenderHarnessPage.vue`（当前渲染在 L83-98；`componentAttrs` L69-72）

**Interfaces:**
- Consumes: `SLOT_FIXTURES` from `tests/parity/slot-fixtures`（相对路径 `../../../tests/parity/slot-fixtures`）。
- Produces: 命中 fixture 的 entry 额外渲染具名 slot 内容；未命中时渲染不变。

- [ ] **Step 1: 加 fixture 查表 computed**

在 `<script setup>` 内（`componentAttrs` 之后）新增：
```ts
import { SLOT_FIXTURES } from '../../../tests/parity/slot-fixtures'

const slotFixture = computed(() => {
  const cc = entry.value?.codeComponent
  return cc && cc in SLOT_FIXTURES ? SLOT_FIXTURES[cc as keyof typeof SLOT_FIXTURES].slots : null
})
```

- [ ] **Step 2: 模板注入具名 slot**

把 L91-94 的 `<component>` 改为：
```vue
<component :is="resolvedComponent" v-bind="componentAttrs">
  <template v-if="slotFixture" v-for="(text, name) in slotFixture" :key="name" #[name]>
    {{ text }}
  </template>
</component>
```
> default slot 名 `'default'` 用 `#default` 生效（Vue 具名 default slot 合法）。

- [ ] **Step 2b: parity-logo A-narrow 渲染路径（Vue）**

`RenderHarnessPage.vue` 特判 `manifestId === 'parity-logo'`：渲染 canonical `<Logo type="tvu" :size="32" data-manifest-id="parity-logo" />`（`@/src/canonical/Logo.vue`）。canonical Logo `inheritAttrs:false` 会把 `data-manifest-id` 透传到 BaseLogo 的 `<span class="logo">` → A-narrow test 用 `[data-manifest-id="parity-logo"] .logo`（或直接 `.logo`）定位测 height。**实测确认 `data-manifest-id` 真落到 `.logo` span 上**（若 canonical 有中间 wrapper 吞了 attr，改为外层 `<div data-manifest-id="parity-logo">` 包 `<Logo>`，test selector 相应调整）。此路径不经 manifest，故不影响现有 render-verification。

- [ ] **Step 3: 验证默认路径不变（现有 gate）**

Run: `pnpm test:render-verification 2>&1 | tail -5`
Expected: 930 entries 仍 PASS（无 fixture 的 entry 无 slot children，行为不变）。

- [ ] **Step 4: 手工冒烟 fixture 命中**

```bash
pnpm dev &  # 5173
# 访问一条 TopBar entry 的 renderRoute，确认 PARITY_LOGO/MENU/RIGHT 可见
```
Expected: Vue 侧 TopBar 显示注入 token（Vue SFC slot 正常）。

- [ ] **Step 5: 报 diff + stdout（不 commit）**

---

## Task 5: React harness slot 注入（`RenderHarness.tsx`）

**Files:**
- Modify: `react-pilot/harness/RenderHarness.tsx`（`renderComponent` L116-153；risk-set 分支 TopBar L126 / FormItem L120 / PillStatus L122）

**Interfaces:**
- Consumes: `SLOT_FIXTURES` from `../../tests/parity/slot-fixtures`（核实 harness tsconfig/vite 是否允许越出 react-pilot import 仓库根 `tests/`；不行则 fixture 放两端都可 import 的位置，见 spec §10.1，Task 0 后定）。
- Produces: risk-set 组件渲染时带 `slot="..."` children；其余不变。

- [ ] **Step 1: 加 slot children 构造 helper**

在 import 区加 `import { SLOT_FIXTURES } from '../../tests/parity/slot-fixtures'`，并在 `renderComponent` 上方加：
```tsx
function slotChildren(codeComponent: string) {
  const fx = (SLOT_FIXTURES as Record<string, { slots: Record<string, string> }>)[codeComponent]
  if (!fx) return null
  return Object.entries(fx.slots).map(([name, text]) =>
    name === 'default'
      ? <span key={name} data-parity-slot={name}>{text}</span>
      // @ts-expect-error slot attr on span (same pattern as renderF54Case)
      : <span key={name} slot={name} data-parity-slot={name}>{text}</span>,
  )
}
```

- [ ] **Step 2: 在 risk-set 分支注入 children**

改 `renderComponent` 的三分支（沿用 `renderF54Case` 已验证的 `slot` children 模式）：
```tsx
if (entry.codeComponent === 'FormItem') return <FormItem {...props}>{slotChildren('FormItem')}</FormItem>
if (entry.codeComponent === 'PillStatus') return <PillStatus {...props}>{slotChildren('PillStatus')}</PillStatus>
if (entry.codeComponent === 'TopBar') return <TopBar {...props}>{slotChildren('TopBar')}</TopBar>
```
> 其余分支不变。`slotChildren` 返回 `null` 时（非 risk-set）等价无 children。

- [ ] **Step 2b: parity-logo A-narrow 渲染路径（React）**

`RenderHarness.tsx` 特判 `manifestId === 'parity-logo'`：渲染 `<div data-manifest-id="parity-logo" style={{display:'inline-block'}}><Logo type="tvu" size={32} /></div>`（`../src/wrappers/Logo`，demo 已 import 过）。React `<Logo>` wrapper 渲 `<tvu-logo>` host（**只设 type/size property，不转发 attr** → 故 data-manifest-id 圈在外层 div，A-narrow test 用 `[data-manifest-id="parity-logo"] tvu-logo` 定位 host 测 height）。#5 的 `display:inline` 基线间隙就在这个 `<tvu-logo>` host 上：pre-fix ≈35 / post-fix ≈32。此路径不经 manifest，不影响现有 gate。

- [ ] **Step 3: 验证默认路径不变（现有 React gate）**

Run: `pnpm test:render-verification-react 2>&1 | tail -8`
Expected: 930 entries + F54 6 cases 仍 PASS（非 risk-set 无 children；risk-set 现在带 slot，但 collectActual 只读样式，不因多 children FAIL——如个别 entry 因此漂移，记录留 Task 7 判定）。

- [ ] **Step 4: 手工冒烟**

```bash
pnpm --dir react-pilot exec vite --config harness/vite.harness.config.ts --port 5174 &
# 访问 /?manifestId=<TopBar entry>&theme=dark
```
Expected: post-fix 态 React TopBar 显示 PARITY_LOGO/MENU/RIGHT（若仍缺 = 修复未生效或 slot 名不符，回 Task 0/3 核）。

- [ ] **Step 5: 报 diff + stdout（不 commit）**

---

## Task 6: parity Playwright config + spec + package script

**Files:**
- Create: `playwright.framework-parity.config.ts`
- Create: `tests/parity/framework-parity.spec.ts`
- Modify: `package.json`（scripts，L72-79 附近）

**Interfaces:**
- Consumes: `SLOT_FIXTURES`、`collectActual` + `ManifestEntry`（from drift-compare-core）、`collectVisibleText`/`collectElementOutline`（Task 1）、`compareVisibleText`/`assertSlotTokensPresent`/`compareComputedStyle`（Task 2）、manifest JSON。
- Produces: `pnpm test:framework-parity` gate。

- [ ] **Step 1: 双 webServer config**

```ts
// playwright.framework-parity.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: 'tests/parity',
  testMatch: '**/*.spec.ts',
  use: { viewport: { width: 320, height: 160 } },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: [
    { command: 'pnpm dev', url: 'http://localhost:5173', reuseExistingServer: true, timeout: 120_000 },
    { command: 'pnpm --dir react-pilot exec vite --config harness/vite.harness.config.ts --port 5174', url: 'http://localhost:5174', reuseExistingServer: true, timeout: 120_000 },
  ],
})
```

- [ ] **Step 2: 写 parity spec（C gate + A survey）**

```ts
// tests/parity/framework-parity.spec.ts
import { test, expect } from '@playwright/test'
import manifestData from '../../figma-data/render-verification-manifest.json'
import type { ManifestEntry } from '../visual-verify/lib/drift-compare-core'
import { collectActual } from '../visual-verify/lib/drift-compare-core'
import { collectVisibleText, collectElementOutline } from './lib/collect-visible'
import { SLOT_FIXTURES } from './slot-fixtures'
import { compareComputedStyle, assertSlotTokensPresent, compareVisibleText } from './lib/parity-compare'

const VUE = 'http://localhost:5173'
const REACT = 'http://localhost:5174'
const manifest = manifestData as unknown as ManifestEntry[]
const RISK_SET = Object.keys(SLOT_FIXTURES)

async function loadVue(page, entry: ManifestEntry) {
  await page.goto(`${VUE}${entry.renderRoute}`, { waitUntil: 'networkidle' })
  await page.locator(`[data-manifest-id="${entry.manifestId}"]`).waitFor({ state: 'attached' })
  await page.waitForTimeout(150)
}
async function loadReact(page, entry: ManifestEntry) {
  await page.goto(`${REACT}/?manifestId=${encodeURIComponent(entry.manifestId)}&theme=${entry.theme ?? 'dark'}`, { waitUntil: 'networkidle' })
  await page.locator(`[data-manifest-id="${entry.manifestId}"]`).waitFor({ state: 'attached' })
  await page.waitForTimeout(150)
}

// ---- C gate: 具名插槽风险集，两侧 slot token 都须在 + 两侧可见文本互等 ----
const riskEntries = manifest.filter(e => RISK_SET.includes(e.codeComponent))
for (const entry of riskEntries) {
  test(`C parity [${entry.codeComponent}] ${entry.manifestId} @${entry.theme ?? 'dark'}`, async ({ page }) => {
    const expectTokens = SLOT_FIXTURES[entry.codeComponent].expectTokens

    await loadVue(page, entry)
    const vueText = await page.evaluate(collectVisibleText)

    await loadReact(page, entry)
    const reactText = await page.evaluate(collectVisibleText)

    // 两侧都必须渲染出 slot token（抓 slot-drop）
    expect(assertSlotTokensPresent(vueText, expectTokens).missing, `Vue missing slot tokens — ${vueText}`).toEqual([])
    expect(assertSlotTokensPresent(reactText, expectTokens).missing, `React missing slot tokens — ${reactText}`).toEqual([])

    // slot token 层面两侧互等（结构 parity）
    const diff = compareVisibleText(vueText, reactText)
    const tokenDrops = [...diff.vueOnly, ...diff.reactOnly].filter(t => expectTokens.includes(t))
    expect(tokenDrops, `slot token asymmetry vueOnly=${diff.vueOnly} reactOnly=${diff.reactOnly}`).toEqual([])
  })
}

// ---- A-narrow gate: 独立 Logo host height 互等（DAY-ONE 阻塞；self-証靶）----
// parity-logo 不在 manifest：两 harness 特判 manifestId==='parity-logo' 渲 <Logo type=tvu size=32>。
// 这是 bespoke 测试（非通用 loop），per-side 不同 target selector：Vue 测 .logo（BaseLogo span），
// React 测 tvu-logo（CE host —— #5 的 display:inline 基线间隙就在这个 host 上）。
test('A-narrow [Logo] host height parity @light', async ({ page }) => {
  // Vue: canonical <Logo> → BaseLogo <span class="logo"> (inline-flex, 显式 height) ≈ 32
  await page.goto(`${VUE}/internal/render-harness?manifestId=parity-logo&theme=light`, { waitUntil: 'networkidle' })
  const vueEl = page.locator('[data-manifest-id="parity-logo"] .logo').first()
  await vueEl.waitFor({ state: 'visible' })
  const vueH = (await vueEl.boundingBox())!.height
  // React: <Logo> wrapper → <tvu-logo> host。pre-fix inline≈35 / post-fix inline-flex≈32
  await page.goto(`${REACT}/?manifestId=parity-logo&theme=light`, { waitUntil: 'networkidle' })
  const reactEl = page.locator('[data-manifest-id="parity-logo"] tvu-logo').first()
  await reactEl.waitFor({ state: 'attached' })
  const reactH = (await reactEl.boundingBox())!.height
  // 表示无关字段：两侧该 1:1（±1px）。回滚 Logo.vue :host → reactH≈35 vs vueH≈32 → FAIL（Task 7）。
  expect(Math.abs(vueH - reactH), `Logo host height parity: vue=${vueH} react=${reactH}`).toBeLessThanOrEqual(1)
})
// ⚠️ 上面 vue renderRoute/selector 待 Task 4 实测确定（parity-logo entry 的 renderRoute + .logo 是否被
//    canonical inheritAttrs 透传到）。若 .logo 未被 data-manifest-id 圈住，改为在 harness 外层 div 上圈
//    data-manifest-id + 内层 selector 定位。**Task 7 三态（post PASS/pre FAIL/恢复 PASS）是本 test 的验收**：
//    若回滚 :host 后没 FAIL，说明测错了元素（如测到 wrapper div 而非 host），换 selector 直到三态成立。

// ---- A-broad survey: 计算样式互比，全 risk-set（起步；non-gating，soft-fail 记录）----
for (const entry of riskEntries) {
  test(`A-broad survey [${entry.codeComponent}] ${entry.manifestId} @${entry.theme ?? 'dark'}`, async ({ page }, testInfo) => {
    await loadVue(page, entry)
    const vueActual = await collectActual(page, entry)
    await loadReact(page, entry)
    const reactActual = await collectActual(page, entry)
    const diffs = compareComputedStyle(vueActual as any, reactActual as any)
    await testInfo.attach('style-diffs', { body: JSON.stringify({ manifestId: entry.manifestId, diffs }, null, 2), contentType: 'application/json' })
    test.info().annotations.push({ type: 'style-parity', description: `${entry.manifestId}: ${diffs.length} diffs` })
    // A-broad 是 baseline survey：先不硬断言（见 spec §4.2）。漂移量供 triage（Task 8）。
  })
}
```
> A-narrow = day-one 阻塞（承 self-証）；A-broad = non-gating survey（Task 8 triage 后再议升 gate + allowlist）。C 的 riskEntries 若过多（FormItem 64 条）可对每 codeComponent 抽代表 variant——Task 0 后按实测条数决定是否 sample，避免跑太久。

- [ ] **Step 3: 加 package.json script**

在 scripts 内 `test:render-verification-react` 附近加：
```json
"test:framework-parity": "node scripts/generate-render-verification-manifest.mjs && playwright test --config playwright.framework-parity.config.ts"
```

- [ ] **Step 4: 跑 parity gate（当前 HEAD）**

Run: `pnpm test:framework-parity 2>&1 | tail -20`
Expected: **C parity 全 PASS**（TopBar/FormItem/PillStatus 两侧 slot token 都在）；**A-narrow [Logo] PASS**（两侧 host height 互等 ≈32）；A-broad survey 全"通过"（non-gating）并产 style-diffs 附件。**A-narrow PASS 是 Task 7 self-証的 post-fix 态**。

- [ ] **Step 5: 报 diff + stdout（不 commit）**

---

## Task 7: 自证 gate 能抓 bug（FP-critical 证据）— 用 logo-host #5，走 A-narrow gate

**Files:** 无新代码；仅临时改 `src/canonical/Logo.vue`（自证后恢复）；证据记入 SDD ledger。
**原理**（spec §6）：slot-drop 不存在 → 不用它自证。改用 #5 logo-host 真实差异：`Logo.vue :host{display:inline-flex}` 是 post-fix，回滚它 → React `<tvu-logo>` host `display:inline` 基线间隙 → host ~35px vs Vue 32px → A-narrow gate（Logo host height 互等 ±1px）**FAIL**。

- [ ] **Step 1: post-fix（HEAD）A-narrow gate PASS（已在 Task 6 得）**
记录 `A-narrow [Logo]` 断言 PASS 的 stdout（两侧 host height 互等 ≈32px）。

- [ ] **Step 2: 回滚 Logo.vue :host（工作树内，勿 commit）**
临时删除 `src/canonical/Logo.vue` 的 `<style>` 里 `:host{ display:inline-flex; vertical-align:middle }` 块（4 行）。**重建 CE**：`pnpm build:wc`（React harness 读 `dist-wc`，必须重建才生效）。

- [ ] **Step 3: pre-fix 态跑 A-narrow gate → 必 FAIL**
Run: `pnpm test:framework-parity 2>&1 | grep -A3 "A-narrow \[Logo\]"`
Expected: **FAIL** — Vue host≈32 vs React host≈35（差 ~3px > ±1px），证明 gate 真能抓真实框架间表示差异。若**没 FAIL**：说明 A-narrow 没真断言到 host height 或重建没生效 → STOP 排查（gate 无效比漏建更危险）。

- [ ] **Step 4: 恢复 Logo.vue + 重建 + 复跑 → 回 PASS**
```bash
git checkout -- src/canonical/Logo.vue
pnpm build:wc
pnpm test:framework-parity 2>&1 | grep -A3 "A-narrow \[Logo\]"   # 期望回 PASS
```
把三态（post PASS / pre FAIL / 恢复 PASS）stdout 摘录写入 SDD ledger 作 gate 有效性证据。**收尾工作树必须干净**（Logo.vue 已恢复，dist-wc 是 gitignored 产物）。

- [ ] **Step 5: 报证据（不 commit）**

---

## Task 8: A survey baseline + triage（决定是否升 gate；可作 follow-up）

**Files:**
- Create: `docs/internal/_reports/vue-react-style-parity-survey-2026-07-13.md`（漂移量 + 三元分类 + 建议 allowlist）

- [ ] **Step 1: 跑 A survey 收全部 style-diffs**

Run: `pnpm test:framework-parity 2>&1 | tee /tmp/parity-survey.log`；从 test-results 附件汇总每 entry 的 diffs。

- [ ] **Step 2: 三元分类**

逐 diff 分类：① 真 drift（两框架实现不一致，应修）② 良性表示差（SFC light-DOM vs CE shadow-DOM 的 box model/默认值，accept）③ 需 owner 拍。写进 survey 报告。

- [ ] **Step 3: 出结论**

- 若良性 drift 少且可枚举 → 建议给 `compareComputedStyle` 加 accepted-diff allowlist、把 A 升为 gate（沿用 contrast accept-型收口范式 [[feedback_audit-whitelist-vs-sot-refactor]]）。
- 若量大/需设计判断 → A 维持 survey，report 交 owner；不硬上 gate（[[feedback_baseline-before-plan]]）。

- [ ] **Step 4: 报报告（不 commit）**

---

## 收尾（controller，全 task 过 task-reviewer 后）

- [ ] 全绿复跑：`pnpm test:render-verification`、`pnpm test:render-verification-react`、`pnpm test:framework-parity`、`pnpm vitest run tests/parity`。
- [ ] controller commit（implementer 未 commit）：本 plan 全部新文件 + 两 harness 改 + package.json + spec/plan，与并行 session 那批协调后**一起推 origin**（Gitea+GitHub）。
- [ ] 更新 STATUS.md「Last updated」+ open 项（parity 工具 shipped，v0.11.0 发版前置解除）→ 交 owner ack 发版。

---

## Self-Review

**Spec coverage**（对 spec 逐节核）：
- §4.1 gate 非报告 → Task 6（Playwright gate，无 HTML 报告）✅
- §4.2 C=day-one gate / A=baseline-first → Task 6（C 硬断言 / A non-gating survey）+ Task 8（triage 升 gate）✅
- §4.3 专用对比器不复用 buildChecks → Task 2 ✅
- §4.4 风险集 TopBar/FormItem/PillStatus + MenuList/UserMenu 搭 TopBar + DateTime 缺口 → Task 3/6（riskEntries）；MenuList/UserMenu 随 TopBar menu slot；DateTime 无 manifest 条目自然不入 riskEntries ✅
- §4.5 独立 slot fixture 不污染 manifest + 两 harness 注入 → Task 3/4/5 ✅
- §5 两 webServer + data-manifest-id 前置 → Task 6 config（两 side 都有 data-manifest-id：Vue L71 / React L237）✅
- §6 自证 pre-fix FAIL/post-fix PASS → Task 7 ✅
- §10.1 fixture import 边界 → Task 5 Step 1 显式核 + fallback ✅

**Placeholder scan**：无 TBD/TODO。`⚠️ re-anchor` 注记均指向 Task 0 落地后的确定性核实（并行 churn 使然），非空洞占位。

**Type consistency**：`SlotFixture`/`SLOT_FIXTURES` 键=codeComponent，Task 3/4/5/6 一致；`collectVisibleText`/`collectElementOutline` 签名 Task 1↔6 一致；`compareVisibleText`/`assertSlotTokensPresent`/`compareComputedStyle` 签名 Task 2↔6 一致。
