# 两端 demo 单一来源（D2）— 设计 spec

- **日期**：2026-07-14
- **状态**：设计定稿（owner 已批准三大 fork）→ writing-plans
- **前置**：D1（Vue↔React harness parity gate）已 ship（master `90d1e987`）。本 spec 是 D1 拆出的第二有序交付物，见 [`2026-07-13-vue-react-usage-parity-tool-design.md`](./2026-07-13-vue-react-usage-parity-tool-design.md) §0。
- **owner 定位**：owner 在 docs 站切 React 走查时发现 demo 与 Vue 有结构差异（缺真 Logo / 九宫格 / search）。根因是**两端 demo 是手工孪生**——同样的 section/card/CSS class 靠人肉同步。D2 让两端从**一份框架中立 descriptor** 渲染，使结构漂移物理上不可能。

---

## 0. 范围：D2 = 两端 demo 单一来源 · 与 D1 / Figma-conformance 的分工

三个 gate 各管一类失败，不可混淆（真源见 D1 spec §4.6）：

| gate | 比什么 | 抓的失败类 |
|---|---|---|
| **parity（D1，已 ship）** | Vue-actual ↔ React-actual（两 harness 互比） | 框架间使用级分歧（一侧有另一侧没有 / 样式偏离） |
| **Figma-conformance（现有 render-verification）** | 各侧 actual vs Figma expected | 两框架**一样**偏离 Figma（parity 会互相匹配 → 假阴） |
| **demo 单一来源（D2，本 spec）** | 两端 demo 从**同一 descriptor** 生成 | **docs demo 结构漂移**（harness 注同 fixture 抓不到——D1 构造上抓不到 demo 漂移，这正是 D2 存在的理由） |

D1 的 harness gate 两侧注同一 fixture，**构造上不可能抓到 demo 漂移**。D2 是治本：不是"再加一个比对器去追漂移"，而是**消灭漂移的可能性**——结构只存在于一份 descriptor。

---

## 1. 背景与根因（live 证据）

owner 的痛点（React demo 缺整块内容）是"结构在两份手写文件里各活各的"的必然结果。本 session live 读取当前源码，三条证据：

1. **Progress 硬编码漂移向量**（当前存在）：Vue 侧 "Figma Coverage" / "Status Matrix" 用 `<FigmaMembersGrid>` / `<StatusMatrix>` 从 `figma-data/normalized/docs-figma-members/progress` **数据驱动**；React 侧却把 `figmaAxes` + 16 条 `statusVariants`（含 variantId）**手抄成硬编码常量**（[`Progress.tsx`](../../../react-pilot/src/demos/Progress.tsx) L22-56）。Figma 成员一变，Vue 自动跟、React 得手改 → 必漂。
2. **TopBar 真分叉**（当前存在，收窄后）：section 标题两端已对齐（故现有 parity 闸通过），但卡内实现两处分叉——(a) 九宫格图标 React 内联 raw SVG（从 `src/icons/raw.ts` 手抄），Vue 用 `<Icon name="navigation/app-launcher">`；(b) showMenu/showSearchBox toggle React 用原生 `<input type=checkbox>`，Vue 用 DS `<Switch>`。
3. **owner 的 #1/#2/#3（缺 Logo/九宫格/search）已被手工补齐**（当前 TopBar 文件三者 React 侧都在）——**这不削弱 D2，反而印证根因**：手工补的东西天生会再漂，因为没有机制保证。

> ⚠️ 纪律：证据 2/3 由 subagent live 读当前文件得出，与旧 pickup 措辞不同（pickup 说 React 缺三者）。以 live 源为准；实现 TopBar 前会**亲核** Icon-inline / Logo 现状再动手（[[feedback_verify-subagent-artifacts-exist]] / stale 不当事实）。

---

## 2. 目标

让每个双框架组件的 docs demo（`FrameworkDemoRegion` 内的 section 内容）**结构只存在于一份框架中立 descriptor**：

- Vue 文档页 + react-pilot demo 各是一个**薄委托器**，读同一 descriptor 渲染。
- 两端消费**同一 DS 组件**（canonical Vue SFC / React wrapper→CE），**视觉严格 1:1 Figma**（D2 重构 demo 结构，不碰组件视觉——Figma SoT 不动）。
- 结构漂移物理上不可能：新增/删改 section 或 card 只能改 descriptor，两端同时生效。

**非目标**：API 表（Attributes/Slots/Events）是 Vue-only、在 `FrameworkDemoRegion` 之外、只有一份，无漂移风险 → **不纳入 D2**。

---

## 3. 关键约束（决定架构）

1. **表示层非对称是真实用法、不是 bug**：Vue = canonical SFC（light DOM）；React = wrapper → `<tvu-*>` CE（shadow DOM）。descriptor 描述**结构**，两端各自把结构解析成本框架的 DS 组件。这是"单一来源结构 + per-framework 解析"的核心。
2. **descriptor 必须是纯 TS（无 JSX）**：`@demos` alias 已跨 Vue/React 构建边界（Vue 现在静态 import `@demos/topbar-demo.css`）。纯数据 descriptor 可被两端构建图安全静态 import；**任何 JSX / 框架特定内容不得进 descriptor**——逃生舱内容留在各框架 renderer 侧（React 逃生舱走动态 import 路径，不进 Vue 运行时 bundle）。
3. **双语文案**：所有 demo 文案是 `t(en, zh)` 二元组 → descriptor 文本字段统一建模为 `[string, string]` tuple。
4. **渲染契约 = `DemoProps { theme, locale }`**：renderer 签名对齐现有 React island 契约，Vue 侧从 `docsTheme` inject + `locale` prop 取。
5. **Figma 真源不可越界**：demo 里组件实例的视觉 1:1 Figma；descriptor 只声明"用哪个组件 + 什么 props + 什么 slot 内容"，不声明任何视觉数值。

---

## 4. 关键决策（owner 已批准）

| # | fork | 决策 | 理由 |
|---|---|---|---|
| D-1 | 核心架构 | **Runtime descriptor interpreter** | 一份 descriptor 对象被两端 import，结构不可能漂；机器量最小、无 build 步、可 live 编辑。codegen 产物可被手改、反需额外反漂移 audit（无消费者受益）；只抽常量不解决结构漂移。 |
| D-2 | Icon 前置 | **Demo 层解析 + backlog CE** | descriptor 按 name 引 icon，Vue renderer → `<Icon>`，React renderer → demo-local `resolveIcon(name)` 读 `src/icons/raw.ts`。不动发布包，结构仍单一来源。`<tvu-icon>` CE + React wrapper 是**真实产品缺口**（React consumer 现无法用 Icon），单独 backlog、日后走 Figma-conformance 专门做，不被 demo 需求裹挟仓促上（[[feedback_lead-with-robust-not-cheapest]] 指主解稳健，非最大化 scope；[[feedback_audit-whitelist-vs-sot-refactor]] 不为工具改发布包）。 |
| D-3 | D2 本轮范围 | **机制 + 3 PoC（Progress→Button→TopBar）+ audit 升级，然后停下复盘** | 增量先行、SDD 自然 checkpoint（[[feedback_baseline-before-plan]]）。其余 22 按批 follow-up（backlog 化），不阻塞。发版仍 owner-gated、与 D2 范围解耦。 |
| D-4 | 交互声明式边界 | **混合模型**：简单/中等交互（19/25 组件，≤4 state）走 descriptor 声明式 `interactive`/`hoverPreview` schema；复杂态（6/25：InputNumber/Message/PopupBox/Select/Slider/FormItem，≥5 state）留**具名逃生舱**（`custom` card → 两端各一同名手写模块，renderer 挂载）。**逃生舱在 D2 只设计不实现**——3 个 PoC 全属简单/中等档，无复杂态可测；逃生舱首次真实现留给首个迁移的复杂组件（YAGNI，不建未测机器）。 |

---

## 5. 架构

### 5.1 组成单元（每个职责单一、接口清晰）

```
react-pilot/src/demos/descriptors/<component>.ts   ← 框架中立 descriptor（纯 TS 数据，两端 import）
      │  import
      ├──────────────► playground/docs/components/DemoRenderer.vue   ← Vue 通用 renderer
      │                     + Vue 组件注册表（name → canonical SFC / DS 组件）
      │                     + resolveIcon → <Icon name>
      └──────────────► react-pilot/src/demos/DemoRenderer.tsx        ← React 通用 renderer
                            + React 组件注册表（name → wrapper）
                            + resolveIcon(name) 读 src/icons/raw.ts
```

- **descriptor**：一份纯 TS 对象。做什么 = 声明一个组件 demo 的全部 section/card 结构。依赖 = 只依赖框架中立的 schema 类型 + 可选 `figma-data` 数据引用。可独立理解、可独立校验（audit 校 schema）。
- **DemoRenderer（×2）**：各框架一个薄组件。做什么 = 遍历 descriptor 渲染。依赖 = descriptor + 本框架组件注册表 + DemoProps。改 renderer 内部不影响 descriptor 契约。
- **组件注册表（×2）**：`{ Progress, Switch, Logo, ... }` name → 本框架 DS 组件。这是"框架非对称"的唯一落点——descriptor 说 `component:'Progress'`，Vue 查表得 `canonical/Progress.vue`，React 查表得 `wrappers/Progress`。
- **每组件的两个 demo 文件收缩为薄委托器**：
  - `ProgressPage.vue`：`<FrameworkDemoRegion :loader><DemoRenderer :descriptor="progressDescriptor"/></FrameworkDemoRegion>` + API 表（region 外，保持 Vue-only 手写）。
  - `Progress.tsx`：`export default (props) => <DemoRenderer descriptor={progressDescriptor} {...props}/>`。
- **挂载机制不变**：`FrameworkDemoRegion` 仍按 `docsFramework` 二选一——Vue 走默认 slot（现在 slot 里是 `<DemoRenderer>`）、React 走 `ReactIsland` 动态 `import('@demos/Progress')`（现在 Progress.tsx 是薄委托器）。`loader` 指向不变。

### 5.2 存放 + 零新配置

descriptor 放 `react-pilot/src/demos/descriptors/`：Vue 侧用现成 `@demos/descriptors/<x>` alias（零新配置），React 侧相对路径 `./descriptors/<x>`。Vue renderer 放 `playground/docs/components/DemoRenderer.vue`（与 FrameworkDemoRegion 同目录），React renderer 放 `react-pilot/src/demos/DemoRenderer.tsx`。

### 5.3 数据流（单组件单 theme）

```
descriptor（结构） + DemoProps{theme,locale}（运行态）
  → DemoRenderer 遍历 sections → 每 section 遍历 cards
    → 按 card.type 分派：
        figma-coverage   → 从 figma-data 读 membersRef 渲染 axes chips（两端读同一 figma-data，消除 Progress 硬编码漂移）
        component-matrix → rows×cols 网格，每 cell 用注册表解析 component + props（支持 $theme/$row/$col 占位符 + 可选 hoverPreview）
        instance-list    → items[] 各渲一个组件实例 + label
        slotted-instance → 组件实例 + 具名 slot（slot 内容 = content-tree 节点）
        code-block       → <pre><code>{code}</code></pre>
        prose            → 标题/摘要文字卡
        interactive      → 声明 state + controls（range/switch）+ target 实例（$var 绑定）
        custom           → 逃生舱：按 module 名从本框架 custom-registry 取组件挂载（D2 只留 seam）
```

### 5.4 content-tree（slot 内容 / 嵌套内容的框架中立表示）

slot 内容（如 TopBar 的 logo/menu/right-content/search）是 mini 渲染树。content-tree 节点是纯数据 union：
- `text: [en, zh]`
- `{ component, props, slots?, children? }`（DS 组件实例）
- `{ icon: name, size }`（图标，per-framework 解析）
- `{ el: tag, class?, children? }`（原生元素，如 `<nav>` / `<input>` 包装）

两端 renderer 把 content-tree 递归渲成本框架 VDOM。这让 TopBar 的具名 slot（Vue `<template #logo>` ↔ React `logo={...}` prop）从**同一 content-tree** 生成。

---

## 6. Descriptor schema（PoC 所需 card 类型 + 增量增长）

schema 只定义 3 个 PoC 组件用到的 card 类型；**按 YAGNI 增量增长**——后续批次遇到新 card 形态再扩，不为 25 个组件预先设计全部类型。

PoC 覆盖的 card 类型（附来源组件）：

| card type | 字段（要点） | 来自 |
|---|---|---|
| `figma-coverage` | `membersRef`（figma-data key） | Progress/Button/TopBar |
| `component-matrix` | `component`, `rows`, `cols`, `cellProps`（含 `$theme/$row/$col`）, `hoverPreview?:{prop,value}` | Progress family/status matrix · Button 9 axis 矩阵（hoverPreview 治 Button hover 态） |
| `instance-list` | `component`, `items:[{label:[en,zh], props}]` | Progress value-grid |
| `slotted-instance` | `component`, `props`, `slots:{name: contentTree}` | TopBar 主实例（logo/menu/right-content/search） |
| `code-block` | `code`（string） | Progress/Button usage |
| `prose` | `title?:[en,zh]`, `summary:[en,zh]` | Button 信息卡 |
| `interactive` | `state:{var:init}`, `controls:[{type:'range'\|'switch', boundTo, label:[en,zh], min?, max?}]`, `target:{component, props(含 $var)}`, `readout?` | Progress Try-it/showLabel · TopBar showMenu/showSearchBox |
| `custom` | `module`（名）— **seam only，D2 不实现** | 复杂档组件（follow-up） |

section 结构：`{ title:[en,zh], summary?:[en,zh], cards: Card[] }`；descriptor 顶层 `{ component:string, sections: Section[] }`。

**D2 顺带治好的分叉**（无需额外工作）：
- Icon 分叉：TopBar slot content-tree 用 `{icon:'navigation/app-launcher', size:28}` → 两端解析成 DS Icon（React 不再手抄 SVG）。
- toggle 分叉：`interactive.control.type='switch'` → 两端都渲 DS `<Switch>`（React 不再用原生 checkbox）。
- Progress 硬编码：`figma-coverage` + `component-matrix` 两端都读 figma-data（React 不再硬编码 variantId）。

**2026-07-14 canonical 结构精化（owner 拍板）**：写 plan 时对着源码逐段核 Progress，发现 §6 曾低估的真漂移——Progress 六段里有**两段两端结构已在漂**，且 Vue 更丰富：
- ①Figma Coverage：Vue 用 `<FigmaMembersGrid>`（轴 chips **+ 16 个变体 live 预览**），React 手写 `coverage-grid`（**只轴 chips 无预览**）。
- ⑤Status Matrix：Vue 用 `<StatusMatrix>`（状态轴 chips + held-constant note + live 预览），React 手写 `status-matrix-grid`（cells + meta，无轴 chips / 无 held-constant）。

owner 决策：**canonical = Vue 的丰富版**（把 React 拉上来，根治"React demo 缺东西"）。落地：`figma-coverage` / `status-matrix` 是独立 card 类型；两个共享组件（FigmaMembersGrid/StatusMatrix）的 `<style scoped>` **单一来源到共享 demo CSS**（两框架都加载），Vue renderer 直接复用这两个现成组件（另 24 页无回归）、React renderer 复现其 DOM。因此 canonical 结构决策落进 plan Task 3（CSS 单一来源）+ Task 4/5（两端 renderer）。

---

## 7. PoC 序（每个验证一层机制）

1. **Progress**（先建机制）：验 `figma-coverage`/`component-matrix`/`instance-list`/`code-block`/`interactive` 五类 + 组件注册表 + $占位符。中等档（2 state：value range + showLabel switch）。**这一步同时建成 DemoRenderer×2 + 注册表 + schema 类型基座。**
2. **Button**（props 矩阵 + hover）：验 `component-matrix` 的 hoverPreview + `prose` 信息卡 + 默认文字 slot（content-tree text）。简单档（1 state：hover key）。
3. **TopBar**（打穿治本三难点）：验 `slotted-instance` + content-tree（具名 slot logo/menu/right-content/search）+ icon-by-name 解析（D-2 demo 层）+ toggle 统一（Switch 两侧）+ 多 var `interactive`。中等档（4 state）。**这是漂移原点，验证"治本"。**

3 个 PoC 全属简单/中等档 → 逃生舱不被 PoC 触发（符合 D-4：只设计不实现）。

---

## 8. Audit 升级（migration-aware）

`audit:demo-framework-parity` 当前只比 section h2 标题集（`scripts/audit-demo-framework-parity.mjs`，PILOT_MAP 25 组件，blocking）。升级为**迁移感知双模**：

- **已迁移组件**（两端 demo 文件都委托 `<DemoRenderer>` + 同一 descriptor 模块）：断言 (1) 两端文件都是薄委托器、无手写 `<section class="docs-section">` 内容；(2) 两端 import 同一 descriptor 路径；(3) descriptor schema 校验通过（section/card 结构合法）。**这比"标题集比对"严格得多**——结构单一来源本身就保证标题一致，audit 转而守"没人偷偷手写结构绕过 descriptor"。
- **未迁移组件**：保留现有标题集双向 diff（22 个仍受旧检查保护、零回归）。
- **迁移进度可见**：audit 输出"已迁移 X / 25"，未迁移标为**已知债**（不阻塞，符合 D-3；但 audit 存在本身制造收口压力）。

hook 点：`main()` per-component 循环 + gap 构建块（现 L638 / L657-668）——先探测组件是否已迁移，分派到新委托契约检查或旧标题检查。

---

## 9. Icon 处理（D-2 落地细节）

- descriptor 用 content-tree 节点 `{icon: name, size}`（name = registry key，如 `navigation/app-launcher`）。
- Vue renderer：解析成 `<Icon :name :size>`（现成 DS 组件，`src/index` 导出）。
- React renderer：`resolveIcon(name, size)` helper——读 `src/icons/raw.ts` 的 name→SVG 映射，返回 `<svg>`。此 helper 是 demo 层工具（不进发布包）。
- **backlog**：`<tvu-icon>` CE + React `<Icon>` wrapper 进 `components.config.ts`——真实产品缺口（能力 1：React consumer npm install 后无法用 Icon）。单独 entry、走 Figma-conformance + 测试，日后专门做。D2 完成后新建该 backlog entry（先 grep 全部已用 ID 挑最大 +1 — [[feedback_backlog-id-collision]]）。

---

## 10. 非目标（YAGNI）

- 不实现逃生舱（`custom` 只留 schema seam）——首个复杂档组件迁移时再做。
- 不迁移 PoC 之外的 22 个组件（follow-up 批次，backlog）。
- 不建 `<tvu-icon>` CE / React Icon wrapper（backlog）。
- 不纳入 API 表单一来源（Vue-only、无漂移风险）。
- 不改任何组件视觉 / Figma 数值（Figma SoT 不动）。
- 不做 codegen（D-1 决策否掉）。
- 不动 D1 的 harness parity gate（`tests/parity/*`）——D2 是 demo 层，与 harness 正交。

---

## 11. 验证策略（怎么证明"治本"）

1. **视觉门（owner 亲审）**：迁移后 owner 在 docs 站切 Vue / React 两态走查 3 个 PoC 组件，确认视觉与迁移前一致（尤其 TopBar 的 Icon / Logo / toggle）。**视觉 diff 只有 owner 亲看效果 approve 后**才允许带 `VISUAL_COMMIT_APPROVED=1` 提交（D2 改的是真 docs demo，用户可见——该门初衷正在此，不滥用）。
2. **结构不可漂的机器证据**：升级后的 `audit:demo-framework-parity` 对 3 个 PoC 报"已迁移 + 委托契约通过"。
3. **回归零影响**：现有 gate 全绿——`pnpm audit:demo-framework-parity`（22 未迁移仍标题闸绿）+ Vue render-verification + D1 framework-parity + vitest。
4. **negative test（可选，plan 定）**：临时在某 PoC 的 Vue 委托器里手写一个 `<section>` 绕过 descriptor → audit 应 FAIL（证 audit 真能抓"偷写结构"）→ 恢复。仿 D1 self-証范式（[[feedback_verify-plan-shipped-state-per-item]]）。

---

## 12. 风险

| 风险 | 缓解 |
|---|---|
| descriptor schema 表达力不足（PoC 中途发现 card 类型不够） | PoC 序 Progress 先建基座，遇到表达不了的即 STOP 扩 schema；YAGNI 增量、不预先过度设计 |
| React renderer 的 content-tree 递归 + 动态组件解析在 React island 里出错 | Progress PoC 就跑通 ReactIsland 挂载路径；先小后大 |
| `figma-data/normalized/docs-figma-members/*` 是否可被 React 构建图安全 import（Vue 侧已 import） | plan 首步核实（纯数据 .ts 应可）；不可则 descriptor 内联必要数据 |
| Vue `<component :is>` 动态组件 + content-tree 渲染的 SSR/类型复杂度 | Vue renderer 用 `h()` 渲染函数而非模板，绕过模板动态限制 |
| hoverPreview 声明式覆盖不了 Button 真实 hover 逻辑 | Button PoC 验证；覆盖不了则降级为该卡走 `custom`（记录，不硬塞 schema） |
| 迁移后视觉细微漂移（class 名/DOM 层级变化影响 `*-demo.css`） | CSS 已是单一来源（两端引同一 `*-demo.css`）；renderer 输出的 DOM class 必须与现有 demo 一致，plan 逐 class 核对 |
| subagent/executor 报 PASS 不实 | controller 亲核产物 + 亲跑 gate（[[feedback_verify-subagent-artifacts-exist]]）；executor 不自 commit（[[feedback_executor-no-self-commit]]） |

---

## 13. 待 plan 解决的开放项

1. schema 类型文件位置（`descriptors/schema.ts`？）+ 两端 renderer 如何共享类型（纯 TS type 可两端 import）。
2. `figma-data` 可否被 React 构建图 import 的核实结果 + 不可时的回退。
3. Vue renderer 用 `h()` 渲染函数 vs 模板 + `<component :is>` 的取舍（倾向 `h()`）。
4. content-tree 的 `{el: tag}` 原生元素白名单（nav/input/span/div 等）+ class 透传规则。
5. `$占位符`（`$theme/$row/$col/$var`）的解析实现（简单字符串替换 vs 求值）。
6. audit 迁移探测的判定信号（import 了 DemoRenderer + descriptor？还是显式标记？）。
7. negative test 是否纳入 + 落点。
8. 提交粒度：3 个 PoC 是一次提交还是分 3 次（倾向分 PoC 提交，每个 owner 可复盘）。
9. `restore` 纪律：跑 gate 会再生 `figma-data/normalized/*.report.json` + `docs/internal/*-report.md` 4 产物，提交前 `git checkout --` restore、只 `git add` 具体文件不 `-A`。

---

## 14. 车道与执行方式

- **本 spec 拥有**：`react-pilot/src/demos/descriptors/*` + 两个 `DemoRenderer` + 3 个 PoC 的两端 demo 文件薄改 + `audit-demo-framework-parity.mjs` 升级 + 本 spec/plan。
- **执行方式**：in-session SDD subagent（横切两端 + 视觉敏感，需 controller 紧迭代 + task-reviewer 复核）；implementer 不自 commit，只报 diff+stdout，controller 复审 + gate 绿 + owner 亲审视觉后才提交。
- **发版 owner-gated**：D1+D2（本轮 = 机制 + 3 PoC）全完成 + owner 查看效果后才定 v0.11.0，不急。
