# INFRA-F46 — docs 全局 Vue↔React 框架开关（逐组件 React 视图 · React islands）设计

> **状态**：设计已 owner 批准（2026-07-06）。
> **backlog**：[`docs/internal/backlog.md`](../../internal/backlog.md) INFRA-F46。
> **上游方向**：[`2026-07-03-infra-f45-slot-boolean-demo-enforcement-design.md`](./2026-07-03-infra-f45-slot-boolean-demo-enforcement-design.md) §3.1（owner 2026-07-03 立项 F46）。
> **规则关联**：AGENTS.md 硬规则 #8（F45 slot+boolean demo gate，L4 blocking）——本项目**必须保持其连续性**（见 §6）。

---

## 0. 决策速览（本 session brainstorm 拍板）

| # | 决策点 | 结论 | 拍板人 |
|---|--------|------|--------|
| 1 | 渲染桥架构 fork | **React islands**（Vue 页内 mount 小 React root），非 scoped iframe | owner 2026-07-06 |
| 2 | React 逐组件 demo 保真度 | **Full parity**（镜像 Vue 页全部示例，方便两框架页面效果作对比） | owner 2026-07-06 |
| 3 | 防漂移策略 | **手写 React demo 模块 + 确定性 parity audit**（非共享 data-driven DSL） | owner 委托，plan owner 定夺 2026-07-06 |
| 4 | 首增量范围 | **Pilot 3-4 组件先行**（bridge+toggle+build 基建 + 4 代表组件），验收后再 fan-out | owner 2026-07-06 |

**islands 胜 iframe 的实测理由**（已 baseline 核实）：
1. **主题/双语零管线**：island 与 Vue 同一 `document`，直接继承 `documentElement[data-theme]` 与 locale；iframe 要 postMessage/query 跨界同步，theme toggle 会闪。
2. **overlay 不被裁切**：PopupBox / Notification dialog / Tooltip / Select 都 teleport 到 `document.body`——island 里居中于整个 docs 视口（同 Vue PopupBoxPage）；iframe 里困在小盒内、模态框局促裁切。而这几个恰是 F45 slot+boolean 最关键的组件。
3. **无 sizing 摩擦**：island demo 内联、高度自然贴合；iframe 要 ResizeObserver+postMessage 动态量高。

**full parity 的诚实代价**（owner 已知情接受）：每个组件 demo 维护两份完整示例集（Vue 页 + React 模块），与 F45 §3.1「React=回归证明面、轻投入」的旧定位冲突——F46 明确把 React 逐组件 demo 升级为用户可见文档，代价由 §5 的 parity audit 兜住漂移。

---

## 1. 目标

在**同一套 Vue docs 外壳**顶部加一个**全局 framework 开关**（与现有 theme toggle 并列），拨到 React 时**所有 in-scope 组件页的 demo 区**渲染对应组件的 **full-parity React 版本**（React islands 内联挂载），镜像该 Vue 页的全部示例。导航 / TOC / API 表 / 双语 / 主题**全部复用**，不建独立 React 文档站、不重写 Vue 页。

**非目标（YAGNI）**：
- 不建独立 React docs app（重写整个 docs 不划算）。
- 不重写既有 Vue 组件页。
- pilot 不做 API 表 prop 名随框架切换（见 §7 增强项）。
- 不碰 `src/canonical/*` / `src/components/*`（base SFC）；不改 web component 层。
- 不移除现有 iframe 版 `DualFrameworkPilotPage`（它并存，见 §8）。

---

## 2. Baseline（已实测，非假设）

### 2.1 React 侧
- [`react-pilot/src/App.tsx`](../../../react-pilot/src/App.tsx) = **375 行单文件 gallery**，35 组件按 7 个 Section 铺一页，每组件一个 `<Card>`。
- **无任何路由**（无 react-router、无 `?component=X`），整页一次性渲染，自带 `theme` state 写 `documentElement[data-theme]`。
- 逐组件 React **wrapper 已存在**（`react-pilot/src/wrappers/*`，35 个齐全）→ F46 是"抽 demo 成模块"，不是造 wrapper。
- 经 `import '@tvu/wc'`（side-effect，alias→`dist-wc/tvu-web-components.js`）注册 CE，wrapper 实例化 CE。
- 构建产物 `react-pilot/dist` = 单个 ~2.2MB bundle（git-tracked 部署实物）。
- react-pilot 是**独立 vite 工程**（自己的 `package.json`：react@19 + react-dom@19 + `@vitejs/plugin-react`，自己的 node_modules）。

### 2.2 Vue docs 侧
- [`playground/docs/DocsShell.vue`](../../../playground/docs/DocsShell.vue)：hash 路由（`#/component/xxx`），30 组件页 + 5 foundation 页 + dual-framework-pilot + a11y。
- **theme 范式（F46 照抄）**：`isDark` ref → `provide('docsTheme', readonly(computed))`（L24-26）+ watch 写 `documentElement[data-theme]`（L502-508）。toolbar 已有 theme toggle（L618-621）+ locale switch（L605-616）。
- 页面组件由 `pageComponents` 表懒加载，接 `:locale` + `:is-dark` props，inject `docsTheme`。
- 组件页用 canonical **Vue SFC**（`@/src/canonical/*`），**不是** web component；React 侧才用 WC+wrapper。两者 tag 名不冲突可共存。
- TOC 自动采集：`collectContentSections()` 用 MutationObserver 扫 `.docs-page .docs-section__title` 生成右栏目录（L333-383, L474-483）——**任何后续注入这套 markup 的内容（含 React island）都会被自动纳入 TOC**。
- 现有 React 嵌入 = [`DualFrameworkPilotPage.vue`](../../../playground/docs/pages/DualFrameworkPilotPage.vue) 里一个 iframe 指向 `react-pilot-demo/index.html`。

### 2.3 构建链
- docs 站由根 [`vite.playground.config.ts`](../../../vite.playground.config.ts) 构建：只挂 `@vitejs/plugin-vue`，`root: 'playground'`，alias `@`→repo 根，outDir `playground-dist`。
- **根仓库 `package.json` 无 react 依赖**。
- `build:playground` = `(build:react-pilot || 回退 committed dist) && vite build --config vite.playground.config.ts`；`copyReactPilotDistPlugin` closeBundle 时拷 `react-pilot/dist`→`playground-dist/react-pilot-demo/`。

---

## 3. 架构：React islands 渲染桥

### 3.1 单元划分（各有单一职责、可独立理解/测试）

| 单元 | 位置 | 职责 | 依赖 |
|------|------|------|------|
| **ReactIsland.vue** | `playground/docs/components/ReactIsland.vue` | 把一个 React demo 模块 mount 进宿主 `<div>`；theme/locale 变→重渲染；卸载→unmount | 懒 `react-dom/client`、传入的 `loader` |
| **React demo 模块** | `react-pilot/src/demos/<Component>.tsx` | 渲染该组件的**全部示例**（镜像 Vue 页），用同款 `.docs-section__title` markup + 双语 | `react-pilot/src/wrappers/*`、`@tvu/wc` |
| **framework 状态** | `playground/docs/DocsShell.vue` | 全局 `framework` ref + provide + toolbar 开关 + localStorage 持久化 | 无 |
| **组件页接入** | `playground/docs/pages/*Page.vue` | inject `docsFramework`；'react' 时以 `<ReactIsland>` 替换 Vue 示例体 | ReactIsland、demo loader |
| **parity audit** | `scripts/audit-demo-framework-parity.mjs` | 断言两侧 example 集对齐 | @vue/compiler-sfc、typescript |

### 3.2 ReactIsland.vue 契约

```
props:
  loader:  () => Promise<{ default: (props) => ReactElement }>   // 懒加载 demo 模块
  theme:   'dark' | 'light'                                      // 来自 docsTheme
  locale:  'en-US' | 'zh-CN'                                     // 来自 shell locale
行为:
  onMounted  → 懒 import('react-dom/client') + loader() → createRoot(host).render(<Demo theme locale/>)
  watch(theme, locale) → root.render(<Demo .../>)  // 重渲染传新 props
  onBeforeUnmount → root.unmount()
```

- **懒加载即代码分包**：`import('react-dom/client')` 与 demo 模块都是动态 import → Vite 自动切出独立 chunk，**只在用户拨到 React 时首次拉取**；默认 Vue-only 访问不加载任何 React。
- `document` 共享：island 内 CE 的 CSS var 直接解析自 `documentElement[data-theme]`，overlay teleport 到真 `document.body`（islands 相对 iframe 的核心优势）。

### 3.3 一份源，两个消费者

React demo 模块 `react-pilot/src/demos/<Component>.tsx` 是**唯一** React demo 真源：
1. **独立 react-pilot App.tsx** 组合渲染这些模块 → 保住单页 gallery + 维持 F45 audit 面（见 §6）。
2. **docs islands** 经 vite alias（`@demos`）import 同一模块。

→ 不产生"gallery 一套 + docs 一套"的第三份 drift。

### 3.4 TOC / API 表 / 双语 / 主题复用

- **TOC**：demo 模块输出与 Vue 页相同的 `.docs-section__title` heading → shell 的 MutationObserver 采集自动生效（无需改 shell TOC 逻辑）。
- **API 表 / 页 header / pager**：这些是 shell 级或 Vue 页级、框架无关的静态内容——framework 开关只切 demo 区，它们原样保留（pilot 不做 API 表 prop 名切换，见 §7）。
- **双语**：demo 模块接 `locale`，内部 `t(en, zh)`（同 DualFrameworkPilotPage 范式）。full parity 要求 React demo 标签也双语——属 authoring 成本。
- **主题**：island 接 `theme`（= `docsTheme`），wrapper 内部按 `theme==='dark'?'on':'off'` 派生（同 App.tsx 现有写法）。

---

## 4. DocsShell framework 开关

- 加 `const framework = ref<'vue'|'react'>(getInitialFramework())`，`provide('docsFramework', readonly(framework))`。
- 初始值/持久化：localStorage key `tvu-docs-framework`（照 `tvu-docs-locale` 范式）。
- toolbar 加一个开关按钮，紧邻 theme toggle（[DocsShell.vue:618](../../../playground/docs/DocsShell.vue#L618)）；双语标签走 `shellCopy`。
- foundation 页（`color/icon/typography/border/effect`，见 `foundationPages` set L152）**忽略** framework——见 §7 no-op 处理。

---

## 5. 防漂移 parity audit（`scripts/audit-demo-framework-parity.mjs`）

**目的**：full parity 下两份手写 demo 不 drift 的确定性保证。

- **输入**：in-scope 组件清单（复用/对齐 F45 的 `components.config.ts` 派生逻辑）；Vue 页 `playground/docs/pages/*.vue`；React demo 模块 `react-pilot/src/demos/*.tsx`。
- **断言**：逐组件，React demo 模块的 **example 集**（section 标题 / example label 集合）⊇/≡ Vue 页的 example 集。任一侧多/缺一个 → FAIL（原因：`react-missing-example` / `vue-missing-example` / `module-not-found`）。
- **解析**：确定性 AST——Vue 用 `@vue/compiler-sfc`、TSX 用 TypeScript compiler API（复用 F45 `audit-demo-slot-boolean-coverage.mjs` 的解析范式，禁脆弱 regex）。
- **输出**：逐组件 PASS/FAIL 表 + 缺口原因 + 汇总。
- **exit code**：pilot 期 **report-only**（exit 0）；fan-out 全绿后转 **blocking**（exit 1 on FAIL）+ 挂 pre-commit 条件 gate（staged 命中 demo 面/audit 自身时触发，照 F45/icon-canonical-names 范式）。
- `package.json` 加 `"audit:demo-framework-parity": "node scripts/audit-demo-framework-parity.mjs"`。

**example 集的锚点约定**（parity 的判定基准）：两侧以**同一组 example label/section 标题**作为可比对的锚——这也是 TOC 自动对齐的副产品。pilot 期实现时确定 label 抽取规则（Vue 侧从 `DocsExampleBlock` 的 `title` prop / `.docs-section__title` 文本；React 侧从 demo 模块的 section 标题）并在 audit 内文档化。

---

## 6. F45 audit 连续性（硬约束，commit 前必验）

F45 的 `scripts/audit-demo-slot-boolean-coverage.mjs` 把 `react-pilot/src/App.tsx` 当 React demo 面**静态解析**（AGENTS 硬规则 #8，**L4 blocking pre-commit gate**）。把 App.tsx 拆成 demo 模块**绝不能破它**。

**策略（二选一，pilot 期实测定）**：
- **(推荐) App.tsx 继续组合并渲染 demo 模块**，使每个 wrapper 的使用（slot 投影 + boolean live 绑定）在 App.tsx 静态可达 → F45 audit 无需改即绿；或
- **同步更新 F45 audit** 让它解析 `react-pilot/src/demos/*.tsx`（若拆分后 wrapper 用法迁进模块、App.tsx 只剩 import 组合）。

**验收门**：任何 commit 前 `pnpm audit:demo-slot-boolean` 必须仍 exit 0（blocking 模式）。这是 F46 不得回归 F45 的硬红线。

---

## 7. 构建 / 打包 + foundation no-op

### 7.1 构建
- `vite.playground.config.ts` plugins 加 `@vitejs/plugin-react`（demo 是 TSX）。
- 根 `package.json` 加 `react` / `react-dom` / `@vitejs/plugin-react` 依赖（版本对齐 react-pilot 的 react@19）。
- alias 加 `@demos` → `react-pilot/src/demos`，并确保 `react-pilot/src/wrappers/*` 与 `@tvu/wc`（`dist-wc/tvu-web-components.js`）在 docs 构建上下文可解析。
- React runtime + demo 模块经 §3.2 动态 import 落**懒 chunk**，默认 Vue-only 包不含 React。
- 现有 `copyReactPilotDistPlugin` + iframe 版 DualFrameworkPilotPage **不动**并存（F46 加内联 island 路径，与 iframe 路径正交；两者是否长期都留由 fan-out 后 owner 决定，本 spec 不删）。

### 7.2 foundation 页 no-op
- Color / Icon / Typography / Border / Effect 框架无关 → framework 开关在这些页**不产生 React 视图**。
- 呈现：开关在 foundation 页禁用/隐藏（affordance 明确，避免"拨了没反应"的困惑）——pilot 期确认一个 foundation 页（如 Color）正确 no-op。

### 7.3 API 表 prop 名切换（增强项，pilot 不做）
- backlog 标"理想随框架切 prop 命名（Vue kebab ↔ React camel）"为**增强项**。pilot 保 Vue 命名的 API 表，记为 fan-out 后可选增强，不进 pilot scope。

---

## 8. Pilot 范围（本 spec + 首个 plan 覆盖）

**基建（§3/§4/§7.1）**：ReactIsland.vue + DocsShell framework 开关 + 构建打包接入。

**4 个代表性组件的 full-parity React demo 模块**（覆盖难度光谱）：
| 组件 | 代表性 | 验证什么 |
|------|--------|----------|
| **Button** | 简单、无 overlay | islands 基础渲染 + 主题/双语 + parity 锚点 |
| **PopupBox** | overlay / teleport（最难） | 证 island teleport 到真 body、模态居中整页（islands 胜 iframe 的关键证据） |
| **Table** | 复杂数据 / slot | 数据类组件 + slot 投影在 island 内正确 |
| **Chart** | SVG / ECharts | 第三方 SVG 渲染器在 island 内正常 |

- 外加确认 **1 个 foundation 页**（Color）正确 no-op（§7.2）。
- parity audit（§5，report-only）+ F45 audit 连续性（§6）全绿。

**验收后**：owner review pilot 页效果（两框架 × 双主题）→ 另开 **Plan v2** 派并行 subagent 铺剩余 ~26 个 in-scope 组件（每 subagent 一组、互不重叠，主线复审 diff；照 F45 fan-out 范式）。

---

## 9. 验证（gates）

- `pnpm exec vue-tsc --noEmit` 不回归（新 Vue 组件/页改动）。
- `pnpm vitest run` 不回归。
- `pnpm audit:demo-slot-boolean`（F45）**exit 0**（§6 硬红线）。
- `pnpm audit:demo-framework-parity`（新，report-only）跑出确定性 baseline。
- `pnpm build:playground` 成功，产出可用的**内联 React island**（非 iframe 路径）。
- playwright 截图核实 4 个 pilot 页 × {Vue, React} × {dark, light} 渲染完整（尤其 PopupBox 模态居中整页、overlay 不裁切）。
- react-pilot 独立 `pnpm build:react-pilot` 仍成功（App.tsx 拆分后 gallery 不破）。
- sprint 收尾 self-audit D/E/F/G。

---

## 10. 风险 / 注意

- **F45 回归**（最高风险）：拆 App.tsx 破 F45 audit → §6 硬门兜；commit 前必跑。
- **React 进 docs 包体积**：懒 chunk 化（§3.2/§7.1）确保默认 Vue 访问零 React 开销；pilot 后量一次 chunk 大小。
- **CE 全局注册时序**：docs 里 `@tvu/wc` 注册需在 island mount 前完成——demo 模块 `import '@tvu/wc'` side-effect 保证（同 App.tsx L1）。
- **island 重渲染性能**：theme/locale 频繁切时 root.render 重跑——pilot 期观察，必要时 memo。
- **parity 锚点稳定性**：example label 抽取规则要两侧一致且稳定（§5）——pilot 期定死并在 audit 内文档化，避免 fan-out 期误报。
- **build:react-pilot 环境**：含 `pnpm install --frozen-lockfile`，部署侧 best-effort 回退 committed dist（META-02 已处理）——F46 内联 island 走 docs 构建，与该回退正交。

---

## 11. 执行策略（衔接 writing-plans）

1. **基建先行**：ReactIsland.vue → DocsShell framework 开关 → vite/pkg 构建接入（本地 dev 能拨开关看到一个占位 React island）。
2. **拆 App.tsx**：抽 4 个 pilot 组件的卡片成 `react-pilot/src/demos/*.tsx`，App.tsx 改为组合渲染；**立即验 F45 audit 仍绿**（§6）。
3. **补 full parity**：4 个 demo 模块补齐镜像 Vue 页的完整 example 集 + 双语。
4. **parity audit**：写 `audit-demo-framework-parity.mjs`（report-only），跑出 4 组件绿。
5. **组件页接入**：4 个 Vue 页 inject `docsFramework` + `<ReactIsland>` 替换路径；foundation no-op 确认。
6. **验证**：§9 全 gates + playwright 截图。
7. **收尾**：owner review pilot（executor 不自 commit；plan owner 复审 + owner ack 后 commit）→ 更新 STATUS/backlog → commit（含三端 push；React demo 视觉改动需 `VISUAL_COMMIT_APPROVED=1`；commit 用 `git commit -F`）→ 另开 Plan v2 fan-out。
