# v1.x 下一批 — AI 可消费页面层 + 能力 1 收口 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:** 把 v1.x 下一批锁定为「能力 4 的 AI 可消费页面层（page-recipes 上闸 + 补两个配方 + layout 原语交 owner 决策）+ 能力 1 从 🟡 partial 收口（React npm 路径 / Select combobox ARIA / i18n wiring）」，并让 `1.1.2` 作为 tag→CI 发布链的首跑小载荷先把链验通。

**Architecture:** 两次发版夹三条主线。先用**已存在的 1 个 patch changeset**（registry 改 https，纯文档/元数据）走 `tag push → .gitea/workflows/publish.yml` 的**历史首跑**——载荷最小、爆炸半径最小；链验通后，主线改动聚成 `v1.2.0`（全 additive minor）再走同一条已验证的链。主线本身分两支：**能力 4 支**先把 `figma-data/page-recipes.json` 从「只 JSON-parse」升成带 schema 的机械闸（自动化前置基础设施在主线前），再补 `entity-form-page` / `master-detail-page` 两个配方，layout 原语（DG-1）只产出 spec + 三选项交 owner，**不擅自造 breakpoint 数值**；**能力 1 支**修 React/CE 无 npm 消费路径、补 Select trigger 的 combobox ARIA 语义、把 5 个已 land 的 locale key 真接到 4 个消费点并配防回归闸。

**Tech Stack:** Node 22 + ESM stdlib-only 脚本（`audit:scripts-stdlib` 闸）· Ajv 2020（`ajv@^8.20.0` devDep，先例 `figma-sync/audit-translation-completeness.mjs:1`）· vitest（纯函数单测）· Playwright（`playwright.select-keyboard.config.ts` 已存在）· changesets + `.gitea/workflows/publish.yml`

---

## Global Constraints

以下每条对**每个 Task 都隐式生效**，值逐字来自仓库真源，不要改写：

- **Figma 是真源**（`docs/FIGMA_AS_SOURCE_OF_TRUTH.md`）：任何视觉值不得凭"行业惯例"造。本批**所有新 token / 新配方均为 code-first**，必须在 `src/design-system/translation/divergences-decisions.json` 显式登记，登记类别 = "用户显式 code-first 拍板"，注释含 `user approved code-first 2026-07-30`。
- **颜色硬编码 = bug**（AGENTS 硬规则 #4，L5 `audit:no-hardcoded-design-tokens`）。
- **canonical 是 SoT**（硬规则 #6）：组件改动只改 `src/canonical/*`，不改 `src/components/*` 的公开 API 形态；`src/components/*` 仅作为 base 实现层被 canonical 包。
- **图标只能从库取**（硬规则 #4 / `feedback_tvu-icons-mandatory`）：`dist/icons/svg/`，禁内联自画 SVG。
- **gate 平权**（backlog INFRA-F61）：本批新增/改动的任何 gate，必须**同时**挂 pre-commit（L4，`.husky/pre-commit` 条件块）与 `.gitea/workflows/pr-checks.yml`（L5，该 workflow 的 `on:` 同时含 `pull_request` 与 `push: branches:[master]`）。只挂一处 = 未完成。
- **新增/改动 gate 必须造故障注入证「不修就会 FAIL」**：每个 gate 任务都含显式 fault-injection 步骤（先造故障 → 断言 exit 1 + 命中行号 → 复原 → 断言 exit 0）。**只跑正常态得到 exit 0 不算验过**（`feedback_regression-pass-needs-fault-proof`）。
- **脚本 stdlib 稳定性**：`scripts/` / `figma-sync/` 下禁用 `globSync`（Node 20 无导出）等 API，见 `scripts/audit-scripts-stdlib-stability.mjs` 的 `BANNED` 表；用 `readdirSync + filter`。
- **`src/tokens/variables.css` 分组纪律**（`audit:sort-tokens` I1/I2）：新 token 必须落在**分组标题注释之下**，且同一块内同名分组标题不得重复。
- **`variables.css` 里 Figma-synced 的 54 个变量不可手改**（`audit:token-contract` L5）；本批新加的是 **code-authored token**（无 Figma Variable 上游，同 `--sp-*` 性质），不受该闸校验但仍受 `audit:sort-tokens` 约束。
- **Commit 纪律**：`git commit -- <显式路径>`（不用 `-a`、不用裸 `git add .`），提交后 `git status --short` 必须无 `??`。**本仓库 commit 默认含 push**（`feedback_commit-includes-push`），push 落地以 `git ls-remote` 对 `origin`（Gitea）与 `github` 双 remote 验证，不信 push 的 stdout。
- **executor 不准自 commit**：若本计划交 executor 执行，executor 只报 diff + stdout，由 plan owner 复审 + owner ack 后才 commit（AGENTS §标准闭环 step 5-7 / `feedback_executor-no-self-commit`）。
- **本机 Bash 陷阱**：`IFS` 含 NUL → `for x in $VAR` 会把整串当一个词，循环前先 `IFS=$' \t\n'` 或用 `while read`；`perl -pi -e` 里 `\Q$VAR\E` 仍会插值，改文件后必须 `grep` 复验命中数。
- **视觉门**：任何触碰 `.vue` / `.css` / `.svg` 的 staged 改动会被 `.husky/pre-commit` 的视觉门拦，需 `VISUAL_COMMIT_APPROVED=1`；**该环境变量只在 owner 看过截图之后才允许设**，AI 不得为图省事自行设置。

---

## 排期依据（决策留痕 · 供 review 反驳）

**总数字**：backlog Active **18 条**；INFRA-F68 umbrella = 2026-07-16 的 105 findings + 2026-07-17 补测的 62 findings（七层框架，看板 Artifact）。

**排期原则**（`design-spec-canonical-alignment-tracker.md` §排期原则，权重降序）：① 项目目标对齐 ② 依赖解锁 ③ 自动化/标准化 ④ 快速可交付独立项（顺路填空、**不挤主线**）。**禁按耗时/复杂度/成本最低排。**

**本批选中项与权重归属**：

| # | Workstream | backlog 出处 | 权重 | 对齐能力 |
|---|---|---|---|---|
| W0 | `1.1.2` 走 tag→CI 首跑 | INFRA-F77 | ②③ | 能力 1（分发链可靠性） |
| W1 | page-recipes schema 闸 + 2 个新配方 + DG-1 spec | F68 PAT-01 / AIC-01 / DG-1 + APID-01 遗留 follow-up | **①** | **能力 4**（page-level 生成）+ 能力 1 |
| W2 | React/CE npm 路径 + Select combobox ARIA + i18n wiring | F68 reexp-ce-runtime / STATUS v1.x「Select ARIA combobox」/ F68 I18N-01 | **①** | **能力 1**（从 🟡 partial 收口） |
| W3 | z-index/elevation token + 扩硬编码闸 | F68 TKN-02 | ③④ | 能力 1（消费方浮层可控） |

**推荐自审 4 问（meta-rules 触发器 F §推荐自审）—— 含两处自我订正**：

1. **理由经得起反驳吗？**
   - ✅ **W0「小载荷先验链」是真依赖**：`.gitea/workflows/publish.yml` 的 publish job **一次都没干跑过**（F77；Gitea 1.22.6 无 `workflow_dispatch` 且写进 `on:` 会连坐压死 `push`），而 registry/secret 接线只有真发那一刻才被触碰。先用纯文档 patch 验链、再让含 code 的 minor 走，爆炸半径严格更小。
   - ❌ **自我订正 1**：初稿写「DG-1 layout 原语 unblock PAT-01 页面配方」——**这个依赖是半编的**。`figma-data/page-recipes.json` 的 `layoutTokens` 字段实测已在引用既有 `--sp-*`，出第二/第三个配方**不需要** breakpoint/container token。DG-1 只在"响应式断点"这一维上才是前置。故本计划把两者**并列**、不把 DG-1 说成 PAT-01 的关键路径。
   - ❌ **自我订正 2**：初稿写「React npm 路径解锁 F76 MicroApps 迁移」——**假的**。MicroApps 是 vue-app，不消费 React 路径。W2① 的真理由只有一条：STATUS §能力×成熟度表把能力 1 标 🟡 partial，点名理由就是「**React 无 published npm 路径**」。
2. **是否把最省事的包装成 #1？** 本批最省事的三项是 i18n wiring（4 处字符串）、z-index（5 处）、Select trigger ARIA（几个属性）。**它们一律排在 W2/W3，不当 #1**；#1 是 weight-1 的能力 4 页面层（W1）。
3. **有没有更稳健但被压成脚注的选项？** 有两条，已提到台面：
   - **先上 schema 闸再加配方**（不是先堆配方后补闸）—— APID-01 自己留的 follow-up 原文即「多实例前补 validator + pre-commit hook」；先加配方后补闸会导致回改数据。故 W1 的 Task 2 排在 Task 3/4 前。
   - **DG-1 不由 AI 落值**：backlog 明写 DG-1 是「Figma-source + owner」。本计划只产 spec + 三选项 + 推荐（Task 5），**不写任何 breakpoint 数值进 token**。
4. **该我拍还是 owner 拍？** 显式交回 owner 的 4 个门：**(a)** Task 1 的 `git tag` push（不可逆 + 外部可见）；**(b)** Task 5 DG-1 的 Figma-first vs code-first 路线；**(c)** Task 6 把 `dist-wc/` 变成**发布面**——这会**反转** INFRA-F69 子项③ 当时「dist-wc 不是发布面故不改 build」的结论，且 v1.0 已锁 API（`docs/API_STABILITY.md`），新增 export subpath 是 additive 但产生新的稳定性承诺；**(d)** Task 10 的 v1.2.0 发版时机。

**明确不做（本批外，理由逐条给）**：

| 不做项 | 理由 |
|---|---|
| **RTL-01**（F68，40 处物理 L/R × 11 文件 + 方向图标镜像） | 独立一批的量级 + 双主题视觉门重；不是因为"大"而推后，而是它自成一批、塞进本批会挤掉 W1 |
| **ds-index-barrel tree-shaking**（F68） | **breaking**，需 changeset + owner 拍板 |
| **COV-02 系统级缺组件**（Drawer/DatePicker/Skeleton/Empty） | Figma-first + owner；AI 不得自造组件设计 |
| **TKN-01 算法主题** | 大改造；且与 DG-1 同属"要不要在 Figma 建变量"的同一个 owner 决策簇，等 Task 5 决策后再排 |
| **INFRA-F61 consumer 冒烟 CI** | 需选定 consumer 仓库，靶子事实上是 MicroApps → 被 F76 前置；F76 属别的仓库 |
| **INFRA-F76 MicroApps 迁移** | **属 MicroApps 仓库，不在本 repo scope**（backlog 原文）；`.npmrc` 两行形态已在 F76 entry 里定案，届时照抄 |
| **INFRA-F58 规则三层化** | backlog 原文归 F55 支柱①「需专门 plan」 |
| **INFRA-F60 厚路线 / F62 变体 B / F63 元层** | 全部 owner-gated 或依赖 F60/F61 契约就绪 |
| **INFRA-F67 (c-1) 遮挡谓词** | 已在 F67 下有明确修法 + 探针脚本，属 docs 站可用性独立小项，不与本批两条主线争位 |

---

## File Structure

**新建**

| 文件 | 责任 |
|---|---|
| `figma-data/page-recipes.schema.json` | page-recipes 的 JSON Schema（2020-12），单一结构真源 |
| `scripts/audit-page-recipes.mjs` | 闸：schema 校验 + 跨引用校验（component 必须是真 canonical / layoutTokens 必须是 variables.css 真 token）。纯函数导出供 vitest |
| `tests/audit-page-recipes.test.ts` | 上述纯函数的单测（must-fire / must-not-fire） |
| `scripts/audit-no-hardcoded-ui-strings.mjs` | 闸：canonical/components 模板里的可见英文字面量必须走 `useLocale()`，纯函数导出 |
| `tests/audit-no-hardcoded-ui-strings.test.ts` | 同上单测 |
| `docs/superpowers/specs/2026-07-30-dg1-layout-primitives-design.md` | DG-1 layout 原语 spec + 三选项 + 推荐（**交 owner 决策，不落值**） |

**修改**

| 文件 | 改什么 |
|---|---|
| `figma-data/page-recipes.json` | 加 `entity-form-page` / `master-detail-page` 两条 recipe |
| `package.json` | 加 `audit:page-recipes` / `audit:no-hardcoded-ui-strings` script；`files[]` 加 `dist-wc`；`exports` 加 `./web-components`；`build` 加 `build:wc`；`prepublishOnly` 串上两个新闸 |
| `.husky/pre-commit` | 两个新闸的条件块（L4） |
| `.gitea/workflows/pr-checks.yml` | 两个新闸的 step（L5，gate 平权） |
| `src/canonical/SelectBoxBase.vue` | trigger 加 `role="combobox"` / `aria-controls` / `aria-haspopup="listbox"`；`'Select...'` 字面量改读 locale |
| `src/canonical/DropDownListSelect.vue` | listbox 容器接受并回填稳定 `id`（供 trigger `aria-controls` 指向） |
| `tests/select-keyboard/keyboard.spec.ts` | 加 combobox ARIA 断言 |
| `src/components/Pagination/Pagination.vue` | `of`（:163）+ `Go to`（:228）改读 locale |
| `src/components/PopupBox/PopupBox.vue` | `cancelText`/`confirmText` 默认值（:32-33）改读 locale |
| `src/tokens/variables.css` | 新增 `── Z-Index / Elevation ──` 分组 + `--z-*` 家族 |
| `src/components/Tooltip/Tooltip.vue`(:81) · `src/canonical/SelectBoxBase.vue`(:545) · `src/components/PopupBox/PopupBox.vue`(:272) · `src/components/UserMenu/UserMenu.vue`(:208) | 硬编码 `z-index` 改 `var(--z-*)` |
| `figma-sync/audit-no-hardcoded-design-tokens.mjs` | 加 `zIndex` category（exactMatch blocking / noMatch report-only，沿用既有分档） |
| `src/design-system/translation/divergences-decisions.json` | 登记本批 code-first 决策（page-recipes 新配方 / `--z-*` token / `./web-components` 出口） |
| `docs/STATUS.md` · `docs/internal/backlog.md` · tracker | wrap-up 同步 |

---

## Baseline（**已实跑，非估值** · 2026-07-30 12:47–12:55 CST）

执行者不必重跑这些即可信任，但**任何与下表矛盾的观察都要以当场实测为准**（硬规则 #10）。

| 事实 | 实测值 | 取法 |
|---|---|---|
| 本地 / `origin` / `github` HEAD | 三者同为 `0330d2ab`，`rev-list --left-right --count` = `0 0` | `git fetch --all` + `git log --oneline` |
| `package.json` version | `1.1.1` | `grep -m1 '"version"'` |
| `.changeset/` | **非空** — `registry-https-and-root-url.md`，`patch` | `ls .changeset/` + `cat` |
| `changeset:status` | `Packages to be bumped at patch: @ux-team/tvu-design-system`；minor/major 皆 NO → **下一版 = `1.1.2`** | `pnpm changeset:status` |
| breakpoint / container / grid token | **0 个**（`grep 'breakpoint\|--container\|--grid-' src/tokens/variables.css` 无输出） | grep |
| `src/` 内 `@media` | **0 处** | `grep -rln '@media' src/` 无输出 |
| `--z-*` / `--elevation` token | **不存在** | grep |
| 硬编码 `z-index` | **5 处**：`InputBoxBase.vue:154` (`1`，局部层叠上下文非层级) · `Tooltip.vue:81` (`1000`) · `SelectBoxBase.vue:545` (`1100`) · `PopupBox.vue:272` (`1000`) · `UserMenu.vue:208` (`1000`) | grep |
| `audit:no-hardcoded-design-tokens` 覆盖域 | 只有 `color` / `spacing` / `radius` 三类（源码 `totals = { color, spacing, radius }`），**无 zIndex** | 读源码 |
| `TvuLocale` 契约 | `src/locale/index.ts` 已含 `paginationOf`/`paginationJumpTo`/`selectPlaceholder`/`confirm`/`cancel`/`tableEmpty`，注释明写 "wiring is a follow-up" | 读文件 |
| i18n 未 wire 的可见字符串 | **4 处**：`Pagination.vue:163` 模板里裸 `of` · `Pagination.vue:228` `<span class="pg-jumper__label">Go to</span>` · `SelectBoxBase.vue:256` `\|\| 'Select...'` · `PopupBox.vue:32-33` `cancelText: 'Cancel'` / `confirmText: 'Confirm'` | grep + sed |
| 已 wire 的 | `Table.vue:116` 用 `locale.tableEmpty`；`Pagination`/`PopupBox`/`SelectBoxBase`/`InputBoxBase`/`Notification`/`Message`/`InputNumber` 均已 `import { useLocale }` | grep |
| Select 键盘操作 | **已 ship 且有测**：`tests/select-keyboard/keyboard.spec.ts` 覆盖 打开/焦点进 listbox/ArrowDown+Enter 选中/Escape 关闭并回焦；`DropDownListSelect.vue` 处理 `ArrowDown`/`ArrowUp`/`Home`/`End`/`Enter`/`' '`/`Escape` | 读文件 |
| Select ARIA **缺口（本批真 scope）** | `SelectBoxBase.vue:228` trigger **只有** `aria-expanded`；**无** `role="combobox"` / `aria-controls` / `aria-haspopup`。listbox 侧 `DropDownListSelect.vue:124-136` 已有 `role="listbox"` / `role="option"` / `aria-activedescendant` | grep |
| `dist-wc` 发布面 | `files[] = ["dist","llms.txt","eslint-plugin","scripts","templates","docs/CONSUMER_AUDIT_SETUP.md","docs/MIGRATION_TO_V1.md","docs/API_STABILITY.md"]` — **无 dist-wc**；`exports` 13 个 key 无 `./web-components`；但 `prepare` **已含** `pnpm build:wc`，`build` **不含** | 读 package.json |
| `page-recipes.json` 闸 | 只有 1 条 recipe（`data-table-page`），**无 schema、无 validator script** | `ls scripts/ \| grep -i recipe` 空 |
| Ajv | `devDependencies.ajv = ^8.20.0`，先例 `figma-sync/audit-translation-completeness.mjs:1` 用 `ajv/dist/2020.js` | 读 package.json |
| 并行 session | `figma-sync/extract.mjs`(M, +79/-7) 与 `scripts/audit-figma-variables-freshness.mjs`(??) 的 mtime = **12:48 / 12:50**（现在 12:52）→ 有 session 正在写「Figma 变量层 stale 告警」。**本批一律不碰这两个文件** | `stat` + `git diff --stat` |

---

## Task 1: ~~`1.1.2` 经 tag→CI 首跑（INFRA-F77）~~ —— ❌ **已作废（2026-07-31），不是「暂缓」**

> **判据不是「owner 说不着急发版」，是这个 task 的前提已经不存在了**，即使 owner 现在说发也不能按它走：
>
> 1. **算不出 `1.1.2` 这个版本号**。`pnpm changeset:status` 实测 = `NO packages to be bumped at patch` / `minor: @ux-team/tvu-design-system`（`.changeset/` 里 7 份，4 份是 minor）→ changesets 只会算出 **`1.2.0`**。changesets 一次消化全部待发 changeset，没有「只发那一份 patch」的形态。
> 2. **「纯文档 / 元数据最小载荷」这个性质已经没了**。Task 6–9 的 code 早已在 master：`--z-*` 在 `src/tokens/variables.css`、`locale.paginationOf` 在 `src/components/Pagination/Pagination.vue`、`./web-components` 在 `exports`、`dist-wc` 在 `files[]`。现在切任何 tag，tarball 里都带着这批 code —— 若还按 Task 1 发成 `1.1.2`，就是**用 patch 标签 + 只写「registry 改 https」的 CHANGELOG 把一批 minor 内容发出去**，属错标发布，比不发更糟。
>
> **所以 F77 的 tag→CI 首跑载荷必然是含 code 的 minor，只剩一次 tag push = Task 10 的 `v1.2.0`。** 首跑该有的谨慎不再靠「缩小载荷」获得，改为靠 **Task 10 Step 2 跑满全部闸（含单独跑 render-gate）+ Step 9 逐段读 CI 日志**（原 Task 1 Step 9 那三段 pre-flight 观察点搬去 Task 10 执行）。
>
> 前身：2026-07-30 session D 已实测出「`.changeset/` 4 个不是计划写的 1 个 → Task 1 前提不存在」（见 STATUS-CHANGELOG），但当时只记在归档里，计划与 STATUS 仍写「两次 tag push」；本次把作废落到计划本体。

**载荷说明（已作废，仅留作对照）**：这次发的是**已存在**的那 1 个 patch changeset（registry 改 https + `repository` 元数据 + consumer `.npmrc` 说明），**没有** code 面改动 —— 正是首跑该有的最小爆炸半径。**不为跑通链条凭空造版本号**（owner 07-29 那条纪律未被推翻）。

**Files:**
- Modify: `package.json`（version → `1.1.2`，由 `changeset version` 自动改）
- Modify: `CHANGELOG.md`（由 `changeset version` 自动改）
- Delete: `.changeset/registry-https-and-root-url.md`（由 `changeset version` 消化）
- Modify: `docs/STATUS.md` §当前版本

**Interfaces:**
- Consumes: 无（本批第一个 task）
- Produces: registry 上存在 `@ux-team/tvu-design-system@1.1.2`；**证明 `publish.yml` 的 registry/secret 接线可用** → Task 10 才敢让 v1.2.0 走同一条链

- [ ] **Step 1: 起手对活源核实三件事**（硬规则 #10；别信本文档的 baseline 表）

```bash
git fetch --all --prune
git rev-list --left-right --count origin/master...HEAD   # 期望 0<TAB>0
pnpm changeset:status                                     # 期望 patch: @ux-team/tvu-design-system
grep -m1 '"version"' package.json                         # 期望 1.1.1
```

- [ ] **Step 2: 本地跑满发版前闸（含 render-gate —— 它不在 prepublishOnly 里）**

```bash
pnpm exec vue-tsc --noEmit
pnpm test
pnpm run prepublishOnly
pnpm run test:render-verification && pnpm run audit:render-drift-gate
```

Expected：全 exit 0；render-gate 报 `A_TRUE_DRIFT_CANDIDATE=0`、936 entries。
⚠️ **跑 render-verification 前先确认 5173 没有孤儿 dev server**（`playwright.render-verification.config.ts` 是 `reuseExistingServer: true`，会去蹭旧进程导致测量不可信）：

```bash
lsof -nP -iTCP:5173 -sTCP:LISTEN   # 有输出就 kill 掉，让 playwright 起干净的
```

- [ ] **Step 3: 消化 changeset 并复核 diff**

```bash
pnpm changeset:version
git --no-pager diff --stat
grep -m1 '"version"' package.json    # 期望 1.1.2
ls .changeset/                       # 期望只剩 README.md + config.json
```

- [ ] **Step 4: 同步 STATUS §当前版本的字面版本号**

`docs/STATUS.md` §SoT 归属表规定「npm 包当前版本号」的字面值**只此一处**，且 `audit:doc-de-mirror` 的 `no-version` mode 会拦别处写字面版本。把 §当前版本标题里的 `v1.1.1` 改 `v1.1.2`，并注明本次是 **tag→CI 首跑**。

- [ ] **Step 5: commit（显式路径）+ 复核无残留**

```bash
git commit -- package.json CHANGELOG.md docs/STATUS.md .changeset/registry-https-and-root-url.md \
  -m "chore(release): 1.1.2 —— registry 改 https 的 consumer 说明随版本发出

首次走 tag→CI（INFRA-F77）：publish.yml 的 publish job 历史上从未干跑，
本次刻意用纯文档/元数据 patch 作最小载荷验证 registry/secret 接线。"
git status --short          # 期望：只剩别的 session 那两个文件，无本 task 的 ??
```

- [ ] **Step 6: push 并以 `ls-remote` 双 remote 验证落地**（不信 push 的 stdout —— GitHub 曾瞬时 remote-rejected 而 ref 实际已更新）

```bash
git push origin HEAD:master && git push github HEAD:master
LOCAL=$(git rev-parse HEAD)
for R in origin github; do echo "$R -> $(git ls-remote $R refs/heads/master | cut -f1)"; done
echo "local -> $LOCAL"
```

- [ ] **Step 7: 🛑 OWNER GATE —— tag push 前停下要 ack**

tag push 会触发**不可逆的对外发布**，且这是该链的首跑。把 Step 2 的闸结果 + 待发版本号 + 「首跑」性质摆给 owner，**得到明确 ack 才继续**。

- [ ] **Step 8: 切 tag 并推（触发 CI）**

```bash
pnpm run release          # scripts/release.mjs：切 tag 时强制跑 render-gate
git ls-remote origin refs/tags/v1.1.2   # 期望有 sha
```

- [ ] **Step 9: 盯 CI 首跑，逐段读日志（不只看绿灯）**

必须亲眼确认 3 段：① F73 新加的 **pre-flight 真写探针**（列表 200 → generic PUT 201 → DELETE 后重拉列表亲验）；② `actions/checkout@v4` success；③ publish 步骤把包推上去。
**若 pre-flight 红**：按 `docs/RELEASING.md` §排障读码 —— **`401` = 根本不是凭据 / `403` = 是凭据但 scope 不够**；secret 名是 `TVU_GITEA_PACKAGES_TOKEN`（`GITEA_`/`GITHUB_` 是 Gitea 保留前缀，建不出来）。

- [ ] **Step 10: 三路核验（API 绿不免除人眼那一路）**

```bash
# ①②机器两路
curl -s -H "Authorization: token $(cat ~/.config/tvu/gitea-token)" \
  https://product-demo.tvustream.com/gitea/api/v1/packages/ux-team | grep -o '1\.1\.2'
npm view @ux-team/tvu-design-system version \
  --registry=https://product-demo.tvustream.com/gitea/api/packages/ux-team/npm/
```

③ **owner 人眼**看 `https://product-demo.tvustream.com/gitea/ux-team/-/packages` 列表里有 `1.1.2`（硬纪律，AI 代不了；v0.1.0/v0.1.1 曾 silent fail 1-2 周）。

- [ ] **Step 11: 把首跑结论写回 backlog INFRA-F77**

链通了就把 F77 从「从未干跑」改成「首跑已过，证据 = CI run id + 三路核验」；若某段红，把**红在哪一段**写进 entry（这比"过了"更有价值）。

```bash
git commit -- docs/internal/backlog.md docs/STATUS.md -m "docs(backlog): INFRA-F77 tag→CI 首跑结果留档"
git push origin HEAD:master && git push github HEAD:master
```

---

## Task 2: page-recipes 上机械闸（schema + validator + 双挂）

> ✅ **已 ship 2026-07-30**（`611b05c2` + S2 分母修复 `bf0048e1`）。checkbox 于 2026-07-30 补勾 —— 此前 13 个 Step 全是空框，会让接手 session 误判需重做。

**为什么排在补配方之前**：APID-01 自己留的 follow-up 原文 = 「`page-recipes.json` 暂无专用 schema 校验闸（仅 JSON-parse），**多实例前**补 validator + pre-commit hook」。先加配方后补闸 = 要回改数据。

**Files:**
- Create: `figma-data/page-recipes.schema.json`
- Create: `scripts/audit-page-recipes.mjs`
- Create: `tests/audit-page-recipes.test.ts`
- Modify: `package.json`（scripts + prepublishOnly）
- Modify: `.husky/pre-commit`
- Modify: `.gitea/workflows/pr-checks.yml`

**Interfaces:**
- Consumes: `figma-data/page-recipes.json` 现有 `data-table-page` 条目的实际形状（`_meta.schema_note` 已把字段语义写死：`id` / `summary` / `slots[]` / `states[]` / `cellRendering?` / `behaviorOwnedByApp` / `layoutTokens`）
- Produces:
  - `figma-data/page-recipes.schema.json`（2020-12 draft）
  - `scripts/audit-page-recipes.mjs` 导出 3 个纯函数：
    - `validateShape(data, schema) -> {ok: boolean, errors: string[]}`
    - `validateComponentRefs(recipes, canonicalNames: string[]) -> {ok: boolean, errors: string[]}`
    - `validateLayoutTokens(recipes, definedTokens: string[]) -> {ok: boolean, errors: string[]}`
  - npm script `audit:page-recipes`
- Task 3 / Task 4 加配方时靠这个闸兜底

- [x] **Step 1: 读现有那条 recipe 的真实形状**（别照 `_meta.schema_note` 的散文猜字段）

```bash
node -e "
const d=require('./figma-data/page-recipes.json');
console.log(JSON.stringify(d.recipes[0],null,2));
console.log('top keys:', Object.keys(d));
"
```

- [x] **Step 2: 写 schema**

`figma-data/page-recipes.schema.json`：

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tvu.internal/schemas/page-recipes.schema.json",
  "title": "Page Recipes — AI 机读页面级组合配方",
  "type": "object",
  "required": ["_meta", "recipes"],
  "additionalProperties": false,
  "properties": {
    "_meta": { "type": "object" },
    "recipes": {
      "type": "array",
      "minItems": 1,
      "items": { "$ref": "#/$defs/recipe" }
    }
  },
  "$defs": {
    "recipe": {
      "type": "object",
      "required": ["id", "summary", "slots", "states", "behaviorOwnedByApp", "layoutTokens"],
      "additionalProperties": false,
      "properties": {
        "id": { "type": "string", "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" },
        "summary": { "type": "string", "minLength": 20 },
        "slots": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/slot" } },
        "states": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/state" } },
        "cellRendering": { "type": "object" },
        "behaviorOwnedByApp": { "type": "boolean" },
        "layoutTokens": {
          "type": "array",
          "items": { "type": "string", "pattern": "^--[a-z0-9-]+$" }
        }
      }
    },
    "slot": {
      "type": "object",
      "required": ["slot", "required", "component", "position", "description"],
      "additionalProperties": false,
      "properties": {
        "slot": { "type": "string", "minLength": 1 },
        "required": { "type": "boolean" },
        "component": { "type": ["string", "null"] },
        "position": { "type": "string", "minLength": 1 },
        "description": { "type": "string", "minLength": 10 }
      }
    },
    "state": {
      "type": "object",
      "required": ["state", "drivenBy", "description"],
      "additionalProperties": false,
      "properties": {
        "state": { "type": "string", "minLength": 1 },
        "drivenBy": { "type": "string", "minLength": 1 },
        "description": { "type": "string", "minLength": 10 }
      }
    }
  }
}
```

⚠️ **Step 2 的现实检查**：若 Step 1 打出的实际 `states[]` 字段名不是 `state`/`drivenBy`/`description`，**以实际文件为准改 schema**，不要反过来改数据（那是 tail-wagging-the-dog，见 `feedback_audit-whitelist-vs-sot-refactor`）。

- [x] **Step 3: 写失败的单测**

`tests/audit-page-recipes.test.ts`：

```ts
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import {
  validateShape,
  validateComponentRefs,
  validateLayoutTokens,
} from '../scripts/audit-page-recipes.mjs'

const schema = JSON.parse(
  readFileSync(resolve(__dirname, '../figma-data/page-recipes.schema.json'), 'utf8'),
)
const real = JSON.parse(
  readFileSync(resolve(__dirname, '../figma-data/page-recipes.json'), 'utf8'),
)

describe('validateShape', () => {
  it('passes on the real file (must-not-fire)', () => {
    expect(validateShape(real, schema).ok).toBe(true)
  })

  it('fires on an unknown top-level recipe key', () => {
    const bad = structuredClone(real)
    bad.recipes[0].behaviourOwnedByApp = true // 拼错的字段名
    const r = validateShape(bad, schema)
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/behaviourOwnedByApp|additionalProperties/)
  })

  it('fires on a non-kebab recipe id', () => {
    const bad = structuredClone(real)
    bad.recipes[0].id = 'dataTablePage'
    expect(validateShape(bad, schema).ok).toBe(false)
  })
})

describe('validateComponentRefs', () => {
  it('passes when every non-null component exists in canonical (must-not-fire)', () => {
    expect(validateComponentRefs(real.recipes, ['Table', 'Pagination']).ok).toBe(true)
  })

  it('fires when a slot names a component that is not canonical', () => {
    const bad = structuredClone(real)
    bad.recipes[0].slots[1].component = 'DataGrid'
    const r = validateComponentRefs(bad.recipes, ['Table', 'Pagination'])
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/DataGrid/)
  })

  it('accepts null component (pure layout placeholder)', () => {
    const ok = structuredClone(real)
    ok.recipes[0].slots[0].component = null
    expect(validateComponentRefs(ok.recipes, ['Table', 'Pagination']).ok).toBe(true)
  })
})

describe('validateLayoutTokens', () => {
  it('passes when every token is defined in variables.css (must-not-fire)', () => {
    const defined = real.recipes.flatMap((r: { layoutTokens: string[] }) => r.layoutTokens)
    expect(validateLayoutTokens(real.recipes, defined).ok).toBe(true)
  })

  it('fires on an invented token', () => {
    const bad = structuredClone(real)
    bad.recipes[0].layoutTokens.push('--sp-invented')
    const r = validateLayoutTokens(bad.recipes, ['--sp-l'])
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/--sp-invented/)
  })
})
```

- [x] **Step 4: 跑单测确认它红**

```bash
pnpm exec vitest run tests/audit-page-recipes.test.ts
```

Expected: FAIL — `Cannot find module '../scripts/audit-page-recipes.mjs'`

- [x] **Step 5: 写 validator**

`scripts/audit-page-recipes.mjs`（stdlib + ajv；`readdirSync` 不用 `globSync`；纯函数导出 + `IS_MAIN` guard 防 import 副作用，范式同 `scripts/audit-product-code.mjs`）：

```js
#!/usr/bin/env node
// audit-page-recipes.mjs — figma-data/page-recipes.json 的结构 + 跨引用闸。
// 缘起：APID-01 (2026-07-22) ship 首个页面配方时留的 follow-up 原文 ——
// 「page-recipes.json 暂无专用 schema 校验闸（仅 JSON-parse），多实例前补
//   validator + pre-commit hook」。本文件就是那个闸。
// 三层判据：
//   S1 schema  — 结构合规（additionalProperties:false，拼错字段名会红）
//   S2 refs    — slots[].component 非 null 时必须是真实的 src/canonical/*.vue
//   S3 tokens  — layoutTokens[] 必须是 src/tokens/variables.css 里真定义过的 token
// gate 平权（INFRA-F61）：pre-commit(L4) + pr-checks.yml(L5) + prepublishOnly 都挂。
import { readFileSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve, basename } from 'node:path'
import Ajv2020 from 'ajv/dist/2020.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = resolve(__dirname, '..')

// ───────────────────────── 纯函数（vitest 直接 import） ─────────────────────────

export function validateShape(data, schema) {
  const ajv = new Ajv2020({ allErrors: true, strict: false })
  const validate = ajv.compile(schema)
  if (validate(data)) return { ok: true, errors: [] }
  return {
    ok: false,
    errors: (validate.errors || []).map(e => `${e.instancePath || '/'} ${e.message} ${JSON.stringify(e.params)}`),
  }
}

export function validateComponentRefs(recipes, canonicalNames) {
  const known = new Set(canonicalNames)
  const errors = []
  for (const recipe of recipes) {
    for (const slot of recipe.slots) {
      if (slot.component === null) continue
      if (!known.has(slot.component)) {
        errors.push(`recipe "${recipe.id}" slot "${slot.slot}": component "${slot.component}" 不是 src/canonical/ 下的组件`)
      }
    }
  }
  return { ok: errors.length === 0, errors }
}

export function validateLayoutTokens(recipes, definedTokens) {
  const known = new Set(definedTokens)
  const errors = []
  for (const recipe of recipes) {
    for (const token of recipe.layoutTokens) {
      if (!known.has(token)) {
        errors.push(`recipe "${recipe.id}": layoutTokens 引用了未定义的 token "${token}"`)
      }
    }
  }
  return { ok: errors.length === 0, errors }
}

export function readCanonicalNames(root = REPO_ROOT) {
  return readdirSync(resolve(root, 'src/canonical'))
    .filter(f => f.endsWith('.vue'))
    .map(f => basename(f, '.vue'))
}

export function readDefinedTokens(root = REPO_ROOT) {
  const css = readFileSync(resolve(root, 'src/tokens/variables.css'), 'utf8')
  return [...css.matchAll(/^\s*(--[A-Za-z0-9-]+)\s*:/gm)].map(m => m[1])
}

// ───────────────────────────────── CLI ─────────────────────────────────

const IS_MAIN = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))

if (IS_MAIN) {
  const data = JSON.parse(readFileSync(resolve(REPO_ROOT, 'figma-data/page-recipes.json'), 'utf8'))
  const schema = JSON.parse(readFileSync(resolve(REPO_ROOT, 'figma-data/page-recipes.schema.json'), 'utf8'))

  const results = [
    ['S1 schema', validateShape(data, schema)],
    ['S2 component refs', validateComponentRefs(data.recipes, readCanonicalNames())],
    ['S3 layout tokens', validateLayoutTokens(data.recipes, readDefinedTokens())],
  ]

  let failed = false
  for (const [label, r] of results) {
    if (r.ok) {
      console.log(`✓ ${label}`)
    } else {
      failed = true
      console.error(`✗ ${label}`)
      for (const e of r.errors) console.error(`    ${e}`)
    }
  }
  console.log(`\n${data.recipes.length} recipe(s) checked: ${data.recipes.map(r => r.id).join(', ')}`)
  process.exit(failed ? 1 : 0)
}
```

- [x] **Step 6: 跑单测确认它绿**

```bash
pnpm exec vitest run tests/audit-page-recipes.test.ts
```

Expected: PASS（全部 case）

- [x] **Step 7: 接进 package.json**

在 `scripts` 里加：

```json
"audit:page-recipes": "node scripts/audit-page-recipes.mjs",
```

并把 ` && pnpm run audit:page-recipes` 追加到 `prepublishOnly` 串尾。

- [x] **Step 8: 正常态实跑**

```bash
pnpm run audit:page-recipes; echo "exit=$?"
```

Expected: `✓ S1/S2/S3` + `1 recipe(s) checked: data-table-page` + `exit=0`

- [x] **Step 9: 🔴 故障注入 —— 四组，逐组证「不修就会 FAIL」**

**每组都是「改 → 跑 → 断言红且报对了行 → 复原 → 跑 → 断言绿」**。`git stash` / `git checkout --` 复原，复原后**必须 grep 复验文件已回到原状**。

```bash
cp figma-data/page-recipes.json /tmp/pr-backup.json

# F1 拼错字段名（additionalProperties:false 该抓住）
node -e "const f='figma-data/page-recipes.json';const d=require('./'+f);d.recipes[0].behaviourOwnedByApp=true;require('fs').writeFileSync(f,JSON.stringify(d,null,2))"
pnpm run audit:page-recipes; echo "F1 exit=$? (期望 1，且报 behaviourOwnedByApp)"
cp /tmp/pr-backup.json figma-data/page-recipes.json

# F2 recipe id 非 kebab
node -e "const f='figma-data/page-recipes.json';const d=require('./'+f);d.recipes[0].id='dataTablePage';require('fs').writeFileSync(f,JSON.stringify(d,null,2))"
pnpm run audit:page-recipes; echo "F2 exit=$? (期望 1)"
cp /tmp/pr-backup.json figma-data/page-recipes.json

# F3 slot 指向不存在的组件
node -e "const f='figma-data/page-recipes.json';const d=require('./'+f);d.recipes[0].slots.find(s=>s.component).component='DataGrid';require('fs').writeFileSync(f,JSON.stringify(d,null,2))"
pnpm run audit:page-recipes; echo "F3 exit=$? (期望 1，且报 DataGrid)"
cp /tmp/pr-backup.json figma-data/page-recipes.json

# F4 编造 layout token
node -e "const f='figma-data/page-recipes.json';const d=require('./'+f);d.recipes[0].layoutTokens.push('--sp-invented');require('fs').writeFileSync(f,JSON.stringify(d,null,2))"
pnpm run audit:page-recipes; echo "F4 exit=$? (期望 1，且报 --sp-invented)"
cp /tmp/pr-backup.json figma-data/page-recipes.json

# 复原验证
pnpm run audit:page-recipes; echo "restored exit=$? (期望 0)"
git --no-pager diff --stat figma-data/page-recipes.json   # 期望：无输出
rm /tmp/pr-backup.json
```

**四组必须都红。任一组没红 = 那条判据是假闸，回去修 validator 再重跑整组。**

- [x] **Step 10: 挂 pre-commit（L4）**

在 `.husky/pre-commit` 末尾按既有条件块范式（参考 `audit:reference-numbering` 那块）追加：

```sh
# page-recipes 结构 + 跨引用闸（APID-01 遗留 follow-up；多实例前必须有 validator）。
# 触发面：配方数据 / schema / 闸脚本 / canonical 组件增删（S2 的分母来自 src/canonical/*.vue）
# / variables.css（S3 的分母）任一变更即跑。
if git diff --cached --name-only --diff-filter=AMD | grep -qE '(figma-data/page-recipes\.(json|schema\.json)|scripts/audit-page-recipes\.mjs|src/canonical/[^/]+\.vue|src/tokens/variables\.css)'; then
  pnpm run audit:page-recipes
else
  echo "   ✓ no page-recipes-relevant files staged, skipped"
fi
```

⚠️ `--diff-filter=AMD` 含 `D`：删掉一个 canonical 组件会让 S2 的分母变小，那正是该跑闸的时刻。

- [x] **Step 11: 挂 pr-checks.yml（L5，gate 平权）**

在 `.gitea/workflows/pr-checks.yml` 里，紧跟 `audit:reference-numbering` 那一步之后加：

```yaml
      - name: Page-recipes schema + 跨引用 gate (F68 PAT-01 / AIC-01)
        # 与 pre-commit 同一条闸 —— INFRA-F61 gate 平权：拦 PR 的也拦 push:master
        # （本 workflow 的 on: 同时含 pull_request 与 push.branches:[master]）。
        run: pnpm audit:page-recipes
```

- [x] **Step 12: 验 pre-commit 条件块真会触发**（"声明 ≠ 被消费"，meta-rules 反模式 #7）

```bash
touch figma-data/page-recipes.json && git add figma-data/page-recipes.json
git diff --cached --name-only --diff-filter=AMD | grep -E 'figma-data/page-recipes\.' && echo "条件块会触发 ✓"
git restore --staged figma-data/page-recipes.json
```

- [x] **Step 13: Commit**

```bash
git commit -- figma-data/page-recipes.schema.json scripts/audit-page-recipes.mjs \
  tests/audit-page-recipes.test.ts package.json .husky/pre-commit .gitea/workflows/pr-checks.yml \
  -m "feat(gate): page-recipes 从只 JSON-parse 升成 schema + 跨引用机械闸

APID-01 ship 首个页面配方时留的 follow-by 原文 = 「多实例前补 validator +
pre-commit hook」。三层判据：S1 schema（additionalProperties:false 抓拼错字段）
/ S2 slots[].component 必须是真 canonical / S3 layoutTokens 必须在 variables.css
里真定义过。四组故障注入逐条实跑证失败态成立（拼错字段名 / 非 kebab id /
不存在的组件 / 编造 token），复原后 exit 0。
L4 pre-commit + L5 pr-checks 双挂（INFRA-F61 gate 平权）。"
git status --short
git push origin HEAD:master && git push github HEAD:master
for R in origin github; do echo "$R -> $(git ls-remote $R refs/heads/master | cut -f1)"; done
```

---

## Task 3: 补 `entity-form-page` 配方（PAT-01 第二个实例）

> ✅ **已 ship 2026-07-30**（`cd6040bd`）。checkbox 同上于 2026-07-30 补勾。

**为什么是表单页**：`data-table-page` 已覆盖列表页；APID-02 Form 校验引擎已随 v1.1.0 ship（`src/canonical/Form.vue` + `FormItem.vue`），表单页是**当下 code 已具备、无需 owner 新决策**的下一个页面骨架。

**Files:**
- Modify: `figma-data/page-recipes.json`
- Modify: `src/design-system/translation/divergences-decisions.json`
- Test: `pnpm run audit:page-recipes`（Task 2 的闸即本 task 的测试）

**Interfaces:**
- Consumes: Task 2 的 `audit:page-recipes`（S1/S2/S3 三判据）
- Produces: `recipes[]` 里 id = `entity-form-page` 的条目；Task 4 照同一形状加第三条

- [x] **Step 1: 从活源读 Form / FormItem 的真实 prop / slot 面**（不凭印象写配方）

```bash
grep -n "defineProps\|withDefaults\|defineSlots\|<slot" src/canonical/Form.vue src/canonical/FormItem.vue | head -40
grep -n '"Form"\|"FormItem"' docs/internal/component-affordances.json | head
```

- [x] **Step 2: 读 `data-table-page` 里 `states[]` 的真实字段名**（Task 2 已确认过一次，这里再确认，避免照 schema 散文写）

```bash
node -e "console.log(JSON.stringify(require('./figma-data/page-recipes.json').recipes[0].states,null,2))"
```

- [x] **Step 3: 追加配方**

在 `figma-data/page-recipes.json` 的 `recipes` 数组尾部追加（**字段名以 Step 2 实测为准**；`component` 只填 Step 1 实测存在的 canonical 名；`layoutTokens` 只填 `variables.css` 里真有的）：

```json
{
  "id": "entity-form-page",
  "summary": "实体表单页骨架：可选页头 + Form 容器包 N 个 FormItem + 底部主/次操作按钮 + 提交结果反馈。校验触发（blur/change/submit）与校验规则由 Form 引擎承担；提交请求、成功后跳转、服务端错误映射到字段完全由消费方 app 实现。",
  "slots": [
    {
      "slot": "header",
      "required": false,
      "component": null,
      "position": "above form",
      "description": "app 自定义页头区域（标题、面包屑、说明文字）。本 DS 无对应 canonical 组件，纯布局占位。"
    },
    {
      "slot": "form",
      "required": true,
      "component": "Form",
      "position": "core",
      "description": "canonical Form：model + rules 驱动校验；暴露 validate / validateField / resetFields / clearValidate。校验为二值 pass/fail，不含异步/远程校验（owner 2026-07-22 裁剪：属 app 层逻辑）。"
    },
    {
      "slot": "field",
      "required": true,
      "component": "FormItem",
      "position": "inside form, repeated",
      "description": "canonical FormItem 逐字段重复；label 走 prop 或 #label 具名槽，控件放默认槽（Input / Select / CheckBox / Radio / InputNumber 等 canonical 控件）。"
    },
    {
      "slot": "actions",
      "required": true,
      "component": "ButtonBridge",
      "position": "below form",
      "description": "提交 / 取消操作区。主操作 filling+green、次操作 ghost+gray 1（与 PopupBox footer 同一 canonical Button API，禁用已删除的 variant/size 死 prop）。"
    },
    {
      "slot": "feedback",
      "required": false,
      "component": "Message",
      "position": "page-level, above form or floating",
      "description": "提交结果反馈。canonical Message 承担视觉与文案，何时显示 / 显示什么由 app 决定。"
    }
  ],
  "states": [
    {
      "state": "pristine",
      "when": "Form model 为初值且未触发过任何校验",
      "driven_by": "prop:model",
      "note": "字段无错误态、无红边框；FormItem 不显示 error message。"
    },
    {
      "state": "field-invalid",
      "when": "Form 引擎对某字段校验失败",
      "driven_by": "prop:rules",
      "note": "FormItem 红边框 + 独立 error message 行；label 保持灰（不染红 —— CANONICAL-019 曾误把 label 染红，已被 live Figma 1923:49069 实证推翻并 revert）。"
    },
    {
      "state": "submitting",
      "when": "app 已发起提交请求、尚未返回",
      "driven_by": "prop:loading",
      "note": "主操作按钮进 loading 呈现态。DS 不管理请求生命周期，只提供该呈现态。"
    },
    {
      "state": "submit-result",
      "when": "提交已返回成功或失败",
      "driven_by": "slot:feedback",
      "note": "成功/失败反馈用 Message 的语义色 + 文案传达；spinner/图标动效交 dev 实现。"
    }
  ],
  "behaviorOwnedByApp": true,
  "behaviorNote": "本配方与 Form/FormItem 组件都只负责『此刻是什么状态、由哪个 prop/slot 驱动其视觉』。提交请求、成功后跳转、服务端错误映射到字段、异步/远程校验一律由消费方 app 实现（owner 2026-07-22 已裁剪运行时余项）。",
  "layoutTokens": {
    "headerToFormGap": "var(--sp-l)",
    "fieldToFieldGap": "var(--sp-m)",
    "formToActionsGap": "var(--sp-l)"
  }
}
```

⚠️ **形状以 Task 2 落地的 schema 为准**（`figma-data/page-recipes.schema.json`，2026-07-30 `611b05c2`）—— 上面这段已按它改过：`states[]` 是 `{state, when, driven_by, note}`（**不是** `drivenBy`/`description`），`driven_by` 只收 `prop:name[+name]` / `slot:name` / `Component.field` 三种形态（写散文会红），`layoutTokens` 是**对象** `{语义位置名: "var(--token)"}`（**不是** token 名数组，数值字面量会红）。

⚠️ **三个 token 必须先核在 `variables.css` 存在**，否则 S3 会红：

```bash
grep -nE "^\s*--sp-(l|m)\s*:" src/tokens/variables.css
pnpm run audit:page-recipes    # 加完配方立刻跑，S1/S2/S3 三条都要绿
```

- [x] **Step 4: 跑闸**

```bash
pnpm run audit:page-recipes; echo "exit=$?"
```

Expected: `exit=0` + `2 recipe(s) checked: data-table-page, entity-form-page`

- [x] **Step 5: ❌ 不登记 divergence（本步已作废 —— 2026-07-30 执行时实测证伪）**

原计划写「追加一条 `page-recipes-entity-form-page-code-first-2026-07-30`」。**实测推翻，不要做**：

```bash
# ① 第一个配方 data-table-page 本身就没有 divergence 条目（40 条里零命中）
node -e "const d=require('./src/design-system/translation/divergences-decisions.json');
const a=d.decisions||d;
console.log('提到 page-recipes 的条目:', a.filter(x=>JSON.stringify(x).includes('page-recipe')).map(x=>x.id));
console.log('总条目数:', a.length)"
# ② 配方引用的组件其 divergence 早已各自登记：
#    Form/FormItem → form-container-topology · Table → 两条 code-first · Message → 四条
```

理由：`divergences-decisions.json` 登记的是**硬规则 #5 意义上的「Figma↔Code 不一致」**。页面配方
不新增任何代码 API 面、不产生新的 Figma↔code 差异 —— 它只引用已各自登记过的组件。给它开条目是
category error，且会与第一个配方的处理方式不一致。

- [x] **Step 6: 跑 translation 闸确认无需新条目**

```bash
pnpm run audit:translation-completeness; echo "exit=$?"
```

Expected: exit 0（**不加任何新条目**即绿 —— 这就是 Step 5 作废的证据）

- [x] **Step 7: Commit**

```bash
git commit -F <msgfile> -- figma-data/page-recipes.json \
  -m "feat(recipes): 加 entity-form-page 页面配方（F68 PAT-01 第二个实例）

表单页骨架 = header? + Form(model/rules) + FormItem×N + actions + feedback?，
四个呈现态（pristine / field-invalid / submitting / submit-result）逐条标明由哪个
prop/槽驱动；提交请求、成功跳转、服务端错误映射全部标为 app 层。
field-invalid 显式写明 label 保持灰 —— CANONICAL-019 那次把 label 染红已被
live Figma 1923:49069 实证推翻，配方里写清防再犯。
audit:page-recipes 2 recipes 全绿。"
git status --short
git push origin HEAD:master && git push github HEAD:master
for R in origin github; do echo "$R -> $(git ls-remote $R refs/heads/master | cut -f1)"; done
```

---

## Task 4: 补 `master-detail-page` 配方（PAT-01 第三个实例）

**Files:**
- Modify: `figma-data/page-recipes.json`
- Modify: `src/design-system/translation/divergences-decisions.json`
- Test: `pnpm run audit:page-recipes`

**Interfaces:**
- Consumes: Task 2 的闸 + Task 3 确立的条目形状
- Produces: id = `master-detail-page` 的条目 → PAT-01「列表/详情/表单页配方」三件套齐

- [x] **Step 1: 核实要引用的组件真能 import + 逐个核 `driven_by` 引用的 prop 真存在**（✅ 2026-07-30 已执行）

```bash
sed -n '/^export {/,/^}/p' src/index.ts | tr -d ' ' | tr ',' '\n' | grep -xE "Breadcrumb|Table|Tab|TabList|TabItem"
grep -n "defineProps\|withDefaults\|<slot" src/canonical/Table.vue src/canonical/Tab.vue src/canonical/Breadcrumb.vue
```

⚠️ **不能只 `ls src/canonical/*.vue`** —— S2 的分母是**两个 barrel 的导出并集**，不是文件名（`bf0048e1` 的教训）。

**实测结果 —— 草稿 4 条具体断言里 3 条要改**（同 Task 3 的命中率，[[feedback: 计划草稿具体值不可信]]）：

| 草稿写的 | 活源实测 | 处置 |
|---|---|---|
| Table `rowKey` / `selectedKeys` / `loading` | ✅ 全真（`Table.vue:16-18`），`#empty` 槽也在 | 保留 |
| `Breadcrumb` 表达层级位置 | ⚠️ **Breadcrumb 本体零 props**（只转发 attrs + 默认槽）；层级信息全在 `BreadcrumbItem`（`href` / `state` = `current\|Default\|disabled\|hover` / `separatorType` = `arrow\|slash` / `showSeparator`） | 改描述 |
| 「详情内分段用 Tab / TabList / TabItem」 | ⚠️ **Tab 与 TabList 是两种用法二选一**（Tab 走槽位组合放 TabItem；TabList 走 `items[]` 数据驱动），草稿混着列成三件套；且草稿没提**当前段由 `modelValue` 走 v-model 驱动** | 改描述 |
| 只 4 个态 / 只 2 个 layoutToken | ⚠️ 列了 `master-loading` 却没列 `master-empty`，而 Table 明明有 `#empty` 槽 → 状态集对 AI 不对称；另实测 **Tab base 零 margin**（`gap: var(--sp-l)` 只是横向 itemSpacing，非到内容的纵向间距）→ 分段到内容的 gap 确实该由 app 给、不会叠 | 加 `master-empty` 态 + `detailSectionsToContentGap` |

- [x] **Step 2: 追加配方**（✅ 已落 `figma-data/page-recipes.json` `recipes[2]`）

**真源 = 数据文件本身**，此处不再留第二份副本（会 drift）。落地形状 = 5 态（`master-loading` / `master-empty` / `nothing-selected` / `selected` / `detail-empty`）+ 4 槽（`breadcrumb?` / `master` / `detail`(null 占位) / `detail-sections?`）+ 3 个 layoutToken。以下为**已作废的初稿**，仅留作上表「草稿 vs 实测」的对照：

```json
{
  "id": "master-detail-page",
  "summary": "主从详情页骨架：面包屑 + 左侧主列表（或上方 Tab 分组）+ 右侧/下方详情区，详情区内按 Tab 分段。选中哪一项、详情数据怎么取、路由怎么变全部由消费方 app 实现；本配方只定槽位组合与四个呈现态。",
  "slots": [
    {
      "slot": "breadcrumb",
      "required": false,
      "component": "Breadcrumb",
      "position": "top",
      "description": "canonical Breadcrumb + BreadcrumbItem 表达层级位置；点击跳转由 app 接路由。"
    },
    {
      "slot": "master",
      "required": true,
      "component": "Table",
      "position": "left (or top)",
      "description": "主列表。用 canonical Table 的 rowKey + selectedKeys 表达当前选中行的呈现态（高亮），选中集变更由 app 持有。"
    },
    {
      "slot": "detail",
      "required": true,
      "component": null,
      "position": "right (or below)",
      "description": "详情区容器，纯布局占位 —— 内部内容由 app 用 canonical 控件自由组合（DS 不规定详情字段）。"
    },
    {
      "slot": "detail-sections",
      "required": false,
      "component": "Tab",
      "position": "inside detail",
      "description": "详情内分段用 canonical Tab / TabList / TabItem；change 事件跨 CE 边界已在 v1.1.0 ship，切段后加载哪份数据由 app 决定。"
    }
  ],
  "states": [
    {
      "state": "nothing-selected",
      "when": "app 尚未给出任何选中行",
      "driven_by": "prop:selectedKeys",
      "note": "详情区显示引导性空态（用 Message 或 app 自定义文案），主列表无高亮行。"
    },
    {
      "state": "master-loading",
      "when": "主列表数据正在请求中",
      "driven_by": "prop:loading",
      "note": "主列表进 loading 呈现态，详情区保持上一次内容或空态（由 app 决定）。"
    },
    {
      "state": "selected",
      "when": "app 记录了当前被选中的行",
      "driven_by": "prop:selectedKeys+rowKey",
      "note": "被选中行高亮，详情区渲染对应内容。高亮是呈现层 prop 映射，不含选中行为。"
    },
    {
      "state": "detail-empty",
      "when": "选中项没有详情数据",
      "driven_by": "slot:detail",
      "note": "空态由 app 在 detail 槽内自己渲染 —— DS 不提供 detail 级 #empty 槽（detail 是纯布局占位）。"
    }
  ],
  "behaviorOwnedByApp": true,
  "behaviorNote": "选中集变更、详情数据获取、路由同步全部由消费方 app 实现；Table 只按 rowKey/selectedKeys 做行高亮呈现，不维护内部选中状态。",
  "layoutTokens": {
    "breadcrumbToBodyGap": "var(--sp-m)",
    "masterToDetailGap": "var(--sp-l)"
  }
}
```

⚠️ 形状同 Task 3 的提醒：`{state, when, driven_by, note}` + `driven_by` 三种合法形态 + `layoutTokens` 是对象。加完立刻 `pnpm run audit:page-recipes`。

- [x] **Step 3: 跑闸 + 对新配方做故障注入**（✅ 实跑）

```bash
pnpm run audit:page-recipes; echo "exit=$?"
```

实测：`✓ S1/S2/S3` + `3 recipe(s) checked: data-table-page, entity-form-page, master-detail-page` + `分母（活源现算）：41 个可消费组件名 · 293 token 声明` + `exit=0`。

**⚠️ 「闸绿」只证闸没红，不证新配方真被逐条校**（可能静默跳过）。故对 `recipes[2]` 做三组精确注入，验**各只红对应那一条、其余保持 ✓**：

| 注入 | 结果 |
|---|---|
| `states[3].driven_by` 改成散文「由 app 决定」 | exit 1，**只 S1 红**，报 `/recipes/2/states/3/driven_by must match pattern`（路径精确指向新配方）|
| `slots[3].component` 改 `TabPane`（未导出）| exit 1，**只 S2 红**，报 `recipe "master-detail-page" slot "detail-sections": component "TabPane" 不是可消费的组件名` |
| `layoutTokens.masterToDetailGap` 改 `var(--sp-huge)` | exit 1，**只 S3 红**，报 `token "--sp-huge" 在 src/tokens/variables.css 里没有定义` |

复原后 `exit=0`，`git diff --stat` 只剩本 task 的 73 行新增（无注入残留）。
另跑 `pnpm exec vitest run tests/audit-page-recipes.test.ts` → **23 passed** —— 证明 Task 3 修过的「窄分母只喂被测那一条」纪律在第三条配方进来后依然成立（复盘 §3 的脆性没复发）。

- [x] **Step 4: ❌ 不登记 divergence**（同 Task 3 Step 5 —— 那步已在 2026-07-30 执行时实测作废：页面配方不产生 Figma↔code 不一致，别开条目）

- [x] **Step 5: 跑 translation 闸确认无需新条目** → 实测 `exit=0`（未加任何条目即绿，即 Step 4 作废的证据）

```bash
pnpm run audit:translation-completeness; echo "exit=$?"
```

- [x] **Step 6: Commit**

```bash
git commit -F <msgfile> -- figma-data/page-recipes.json \
  -m "feat(recipes): 加 master-detail-page 页面配方 —— PAT-01 列表/详情/表单三件套齐

主从页骨架 = breadcrumb? + master(Table, rowKey+selectedKeys 表达选中呈现态)
+ detail(纯布局占位) + detail-sections?(Tab)。四态：nothing-selected /
master-loading / selected / detail-empty。选中集、数据获取、路由全标 app 层。
audit:page-recipes 3 recipes 全绿。"
git status --short
git push origin HEAD:master && git push github HEAD:master
for R in origin github; do echo "$R -> $(git ls-remote $R refs/heads/master | cut -f1)"; done
```

---

## Task 5: DG-1 layout 原语 —— 出 spec + 三选项交 owner（**不落值**）

**边界（非常重要）**：backlog F68 把 DG-1 标为「**Figma-source + owner**」。本 task 的**唯一产出是决策材料**：现状实测 + 三条路线 + 推荐 + 每条路线的验收方式。**禁止**在本 task 里往 `variables.css` 写任何 breakpoint / container 数值 —— 那是 owner 拍完路线之后的事。

**Files:**
- Create: `docs/superpowers/specs/2026-07-30-dg1-layout-primitives-design.md`
- Modify: `docs/internal/backlog.md`（DG-1 子项补「spec 已出，待 owner 选路线」）

**Interfaces:**
- Consumes: 无 code 依赖
- Produces: spec 文件 + 3 个具名选项（`figma-first-variables` / `code-first-tokens` / `recipe-only-no-tokens`）供 owner 单选

> ✅ **2026-07-30 已执行完（Task 5 全部 Step）。产出 = [`specs/2026-07-30-dg1-layout-primitives-design.md`](../specs/2026-07-30-dg1-layout-primitives-design.md)。**
>
> ⚠️ **下面 Step 4 写的「推荐 = A，fallback = B」已被实测推翻，别照它读** —— 见 spec §0/§1⑥：选项 A 的落地机制依赖「designer 建变量 → `pnpm generate` 按名同步」，而**那条管道当前是断的**（变量层 REST 403，`file_variables:read` = Enterprise-only；`raw/variables.json` 冻结在 2026-04-22 `9a4a52ca`）→ designer 建了代码也拉不到。**推荐已改为「B 当下 + A 终点」**（迁 A 只换真源方向、不改 token 名）。
> 🔴 **2026-09-01 订正上面括号里那个前提（结论不变，⛔ 别据此重开 A）**：变量层刷新管线当日已建成（走 `use_figma` = Plugin API 执行面，绕开那个 Enterprise-only scope），`raw/variables.json` 已刷到 live 98 条 —— **管道不再是断的**。挡住 A 的现在是另外两条：① owner 2026-07-31 **显式拍定** layout 断点/容器宽 code-first（`divergences-decisions.json`）② Figma 的 `Module Width/*`（200–1200）与 `--container-*`（720/840/1600/1800）**零值重合**。逐条见 spec §0/§4 的同日订正。
> 另一处推翻：起初把 `figma-data/` 里 484 处 `layoutGrid` 关键词读成「designer 已有栅格可直接采值」，`pattern` 分布实测证伪 —— 219 个全是 `pattern:GRID / sectionSize:10` 的 **10px 对齐辅助网格**，`pattern:COLUMNS` 命中 **0**，**响应式栅格在设计侧也不存在**。

- [x] **Step 1: 把现状测成数字（spec 的证据段，不能是散文）**

```bash
echo "--- breakpoint/container/grid token ---"; grep -cE "breakpoint|--container|--grid-" src/tokens/variables.css || echo 0
echo "--- src 内 @media ---"; grep -rl "@media" src/ | wc -l
echo "--- 是否有 Layout/Grid/Container canonical 组件 ---"; ls src/canonical/ | grep -iE "layout|grid|container|row|col|space" || echo "(none)"
echo "--- docs 站自己用什么做响应式（对照物）---"; grep -rn "@media" playground/ --include=*.css | head -10
echo "--- Figma 端是否已有 grid style ---"; node -e "
const s=require('./figma-data/normalized/figma-styles.json');
const grids=Object.entries(s).filter(([k,v])=>/grid/i.test(k)||/grid/i.test(v?.styleType||''));
console.log('grid styles:', grids.length, JSON.stringify(grids.slice(0,5),null,1));
"
```

⚠️ 最后一条若报错或键名不对，**用 `head -40 figma-data/normalized/figma-styles.json` 看真实形状再改命令**；不要把「命令没跑通」写成「Figma 里没有 grid」（`feedback_name-search-absent-fallacy`）。

- [x] **Step 2: 写 spec**

`docs/superpowers/specs/2026-07-30-dg1-layout-primitives-design.md`，结构：

1. **§1 现状（Step 1 的原始输出逐条贴，含命令）** —— breakpoint token 0 个 / `src/` 内 `@media` 0 处 / 无 Layout|Grid|Container canonical 组件 / Figma 端 grid style 实测数。
2. **§2 为什么这是能力 4 的瓶颈** —— PROJECT_GOAL 能力 4 要求「AI 拿文字/截图 → 生成符合本规范的 UX 效果图 + 可运行代码网页」。页面级合成缺可组合的布局契约时，AI 只能拼组件、无法表达"两列在 ≥1280 断点下如何变一列"。**同时诚实写明**：page-recipes 的 `layoutTokens` 现在引用 `--sp-*` 已够表达间距，**断点维度才是真缺口** —— 不要把 DG-1 说成整个 pattern 层的前置。
3. **§3 三选项**（每条含：改哪些文件 / 谁是真源 / 破坏性 / 验收方式 / 谁能落地）：
   - **A `figma-first-variables`** — 请 designer 在 Figma 建 breakpoint/container 变量 → `pnpm generate` 按名同步 → `audit:token-contract` 自动纳管。**最符合硬规则 #1 + Figma 真源原则**；代价 = 依赖 designer 排期，且 Figma Variable 是否适合表达断点需 designer 判断。
   - **B `code-first-tokens`** — 按 `--sp-*` 的先例做 code-authored token（`--bp-*` / `--container-*`），走 divergences 登记 code-first。**能立刻落地**；代价 = 布局真源落在 code 侧，与「Figma 是真源」的默认相反，需 owner 显式拍板（`docs/FIGMA_AS_SOURCE_OF_TRUTH.md` §合法差异第 6 条「用户显式 code-first 拍板」是唯一出口）。
   - **C `recipe-only-no-tokens`** — 不建 token，只在 page-recipes 里用**语义档位名**（`compact` / `regular` / `wide`）描述断点意图，具体像素交消费方 app。代价 = AI 生成的页面在不同 app 里断点不一致，等于没解决能力 4 的一致性。
4. **§4 推荐 = A，fallback = B** —— 理由三条：① 硬规则 #1 与 Figma 真源原则的默认方向就是 Figma-first，走 B 需要显式推翻默认；② A 落地后 `audit:token-contract` 自动纳管、零额外闸开发；③ 若 designer 排期不确定，B 可作过渡且**迁 A 时是纯改真源方向、不改 token 名**（把 `--bp-*` 从 code-authored 转成 Figma-synced）。**C 不推荐**：它把不一致外包给消费方，与能力 4 的目标相反。
5. **§5 明确不在本 spec 范围** —— 不含 Layout/Grid/Container **组件**（那是 COV-02 系统级缺件，Figma-first + owner）；不含 TKN-01 算法主题（同一 owner 决策簇，等本条拍完再排）。
6. **§6 owner 需要回答的一句话** —— 「A / B / C 选哪个」，以及若选 A，是否由 owner 去跟 designer 排期。

- [x] **Step 3: backlog 同步**

在 `docs/internal/backlog.md` 的 INFRA-F68 「[P1/大] DG-1 无 layout 原语」那一行后追加：spec 路径 + 三选项名 + 「待 owner 选路线，AI 未落任何数值（Figma-source + owner）」。

- [x] **Step 4: 确认真的没往 token 文件写东西**

```bash
git --no-pager diff --stat src/tokens/variables.css   # 期望：无输出
```

- [x] **Step 5: Commit**

```bash
git commit -- docs/superpowers/specs/2026-07-30-dg1-layout-primitives-design.md docs/internal/backlog.md \
  -m "docs(spec): DG-1 layout 原语 —— 现状实测 + 三选项 + 推荐，交 owner 选路线

实测：breakpoint/container/grid token 0 个、src/ 内 @media 0 处、无 Layout|Grid|
Container canonical 组件。三选项 A figma-first-variables / B code-first-tokens /
C recipe-only-no-tokens，推荐 A、fallback B、不推荐 C。
刻意不落任何数值 —— backlog 明写 DG-1 是 Figma-source + owner。
并订正一处自己的说法：page-recipes 的 layoutTokens 已够表达间距，DG-1 只在
断点维度上是前置，不是整个 pattern 层的关键路径。"
git status --short
git push origin HEAD:master && git push github HEAD:master
for R in origin github; do echo "$R -> $(git ls-remote $R refs/heads/master | cut -f1)"; done
```

---

## Task 6: React/CE 的 npm 消费路径（能力 1 标 🟡 partial 的点名理由）

**🛑 OWNER GATE（做之前先问）**：本 task 把 `dist-wc/` 从「纯内部工具产物」变成**发布面**。这**反转** INFRA-F69 子项③ 当时的结论（「dist-wc 不发布到 npm，故不改 build 脚本，非 shipping bug」）—— 那条结论在"当前设计"下成立，本 task 改的正是那个设计。且 v1.0 已锁 API（`docs/API_STABILITY.md`），新增 export subpath 虽 additive 但产生新的稳定性承诺。**先把这段摆给 owner 拿 ack，再动手。**

**Files:**
- Modify: `package.json`（`files[]` + `exports` + `build`）
- Modify: `docs/API_STABILITY.md`（登记新 export subpath）
- Modify: `docs/GETTING_STARTED.md`（React/CE 消费方式）
- Modify: `docs/internal/backlog.md`（F68 reexp-ce-runtime + F69 子项③ 的结论订正）
- Modify: `src/design-system/translation/divergences-decisions.json`
- Test: 真 `npm pack` + 真 fresh install 验证（不是读 package.json 自证）

**Interfaces:**
- Consumes: 现有 `build:wc`（`vite build --config vite.web-components.config.ts`）与 `prepare`（**已含** `build:wc`）
- Produces: `exports["./web-components"]` 指向 CE register runtime；`files[]` 含 `dist-wc`

- [ ] **Step 1: 实测 `dist-wc` 现在长什么样、入口文件叫什么**

```bash
pnpm run build:wc
find dist-wc -maxdepth 2 -type f | head -20
node -e "console.log(require('fs').readFileSync('vite.web-components.config.ts','utf8'))" | grep -nE "entry|fileName|formats|outDir"
```

**入口名以实测为准**，不要照下面示例里的路径硬填。

- [ ] **Step 2: 改 `package.json` 三处**

① `files[]` 加 `"dist-wc"`；② `exports` 加（**路径以 Step 1 实测为准**；已知 `vite.config.ts:61` 的 `@tvu/wc` alias 指向 `./dist-wc/tvu-web-components.js`，即入口文件名是 `tvu-web-components.js` 而非包名）：

```json
"./web-components": {
  "types": "./dist-wc/index.d.ts",
  "import": "./dist-wc/tvu-web-components.js"
},
```

⚠️ `types` 那行的 `.d.ts` 是否真存在要用 Step 1 的 `find dist-wc` 结果确认 —— INFRA-F69 子项① 提过 dist-wc 有 orphan `.d.ts` 问题；若无 `index.d.ts` 就先只给 `import`，types 缺口单独处理，**不要写一个指向不存在文件的 types 字段**。

③ `build` 串尾加 ` && pnpm build:wc`（`prepare` 已有，`build` 缺 → 二者对齐，避免"CI 建了本地没建"的 footgun）。

> ⛔ **2026-08-04 就地标注：上面这句「串尾加」是错的，别照抄。** `build` 的最后一环就是 `build:playground`，串在尾巴上 = 打包完才重建 `dist-wc`，症状原封不动。正确形态是 **`build:wc` 必须插在 `build:playground` 之前** —— 这条约束的**唯一真源 = `scripts/check-dist-wc-freshness.mjs` 头注释**（本行只是把已完成 plan 里的错误指令中和掉，不复述理由）。原先唯一警告它的地方是 STATUS §三 18，那条已于 2026-08-04 随 [[INFRA-F95]] 待做② 删档，所以警告搬到这里。

- [ ] **Step 3: 真 pack，验产物在 tarball 里**（不读 package.json 自证 —— `feedback_release-verify-packages-page` 同型纪律）

```bash
pnpm pack --pack-destination /tmp
tar -tzf /tmp/ux-team-tvu-design-system-*.tgz | grep -c "^package/dist-wc/"   # 期望 > 0
tar -tzf /tmp/ux-team-tvu-design-system-*.tgz | grep "^package/dist-wc/" | head -5
```

- [ ] **Step 4: 真 fresh install + 真 import 验证**

```bash
WORK=$(mktemp -d) && cd "$WORK"
npm init -y >/dev/null
npm i /tmp/ux-team-tvu-design-system-*.tgz >/dev/null 2>&1
node -e "
const p = require.resolve('@ux-team/tvu-design-system/web-components');
console.log('resolved ->', p);
"
cd - >/dev/null
```

Expected：打出真实路径。**若 `ERR_PACKAGE_PATH_NOT_EXPORTED` → exports 写错了**，回 Step 2。

- [ ] **Step 5: 跑 export 面的既有闸**

```bash
pnpm run audit:export-coverage; echo "export-coverage exit=$?"
pnpm run audit:consumer-contract; echo "consumer-contract exit=$?"
pnpm run audit:style-contract; echo "style-contract exit=$?"
```

三者任一红 → 读它的输出决定是扩闸的分母（新 export 面该被纳管）还是修 exports。**不要为凑绿把闸的判据删掉。**

- [ ] **Step 6: 🔴 故障注入 —— 证「dist-wc 掉出 files 会被抓到」**

若 Step 5 里没有任何闸覆盖 `dist-wc` 是否真在 tarball 里，说明这条新发布面**无闸**，必须补进 `audit:consumer-contract`（该闸本就是消费契约的落点）。补完后注入：

```bash
node -e "const f='package.json';const p=require('./'+f);p.files=p.files.filter(x=>x!=='dist-wc');require('fs').writeFileSync(f,JSON.stringify(p,null,2)+'\n')"
pnpm run audit:consumer-contract; echo "F-inject exit=$? (期望 1)"
git checkout -- package.json
grep -c '"dist-wc"' package.json    # 期望 1（复原验证）
pnpm run audit:consumer-contract; echo "restored exit=$? (期望 0)"
```

- [ ] **Step 7: 文档三处同步**

① `docs/API_STABILITY.md` 登记 `./web-components` 为 v1.2.0 起的公开 export subpath；② `docs/GETTING_STARTED.md` 写 React/CE consumer 的 import 方式（含"必须 import 一次 register runtime 才能用 `<tvu-*>`"）；③ backlog：F68 `reexp-ce-runtime` 标 shipped，**并在 F69 子项③ 下补一行说明结论为何被反转**（原结论基于"dist-wc 不是发布面"这个前提，本次改的正是前提，owner ack 于 2026-07-30）。

- [ ] **Step 8: 写 changeset**

```bash
cat > .changeset/react-ce-npm-consumption-path.md <<'EOF'
---
"@ux-team/tvu-design-system": minor
---

新增 `./web-components` 出口 —— React / 原生 CE consumer 第一次有了 npm 消费路径。

此前 `dist-wc/`（`<tvu-*>` 自定义元素的 register runtime）只在本地/CI 构建，从不进 npm tarball，也没有 export subpath：React consumer 只能反抄源码或走内部 harness。本版把 `dist-wc` 加进 `files`、加 `./web-components` export，并让 `build` 与 `prepare` 一样含 `build:wc`。

```js
import '@ux-team/tvu-design-system/web-components'  // 注册全部 <tvu-*> 元素
import '@ux-team/tvu-design-system/style.css'
```

纯 additive：Vue consumer 的 import 路径、组件 API、样式、token 均无变化。
EOF
```

- [ ] **Step 9: Commit**

```bash
git commit -- package.json docs/API_STABILITY.md docs/GETTING_STARTED.md \
  docs/internal/backlog.md src/design-system/translation/divergences-decisions.json \
  scripts/audit-consumer-contract.mjs .changeset/react-ce-npm-consumption-path.md \
  -m "feat(exports): 加 ./web-components 出口 —— React/CE 首次有 npm 消费路径

STATUS §能力×成熟度表把能力 1 标 🟡 partial，点名理由就是「React 无 published
npm 路径」。修法（F68 Option A，additive）：files[] 加 dist-wc + exports 加
./web-components + build 串尾补 build:wc（prepare 本就有，二者原先不对齐）。
验证不靠读 package.json：真 pnpm pack 查 tarball 内 dist-wc/ 存在 + 真 fresh
install 后 require.resolve 出真实路径。
⚠️ 本改动反转 INFRA-F69 子项③ 的结论（原结论前提 = dist-wc 不是发布面），
owner 2026-07-30 ack；已在 F69 entry 下写明前提变化。"
git status --short
git push origin HEAD:master && git push github HEAD:master
for R in origin github; do echo "$R -> $(git ls-remote $R refs/heads/master | cut -f1)"; done
```

---

## Task 7: Select trigger 的 combobox ARIA 语义

**Scope 澄清（baseline 实测已收窄）**：**键盘操作已经 ship 且有测** —— `tests/select-keyboard/keyboard.spec.ts` 覆盖 打开 / 焦点进 listbox / ArrowDown+Enter 选中 / Escape 关闭并回焦，`DropDownListSelect.vue` 处理 7 个键。STATUS v1.x roadmap 里剩的那条叫「Select **ARIA combobox role**」，实测缺口精确到：`SelectBoxBase.vue:228` 的 trigger **只有** `aria-expanded`，缺 `role="combobox"` / `aria-controls` / `aria-haspopup="listbox"`，导致屏幕阅读器读不出"这是个组合框、它控制着那个 listbox"。

**Files:**
- Modify: `src/canonical/SelectBoxBase.vue`（trigger ARIA + listbox id 传递）
- Modify: `src/canonical/DropDownListSelect.vue`（listbox 容器接受外部 id）
- Modify: `tests/select-keyboard/keyboard.spec.ts`（加 ARIA 断言）

**Interfaces:**
- Consumes: `DropDownListSelect.vue:124` 的 `role="listbox"` 容器 · `SelectBoxBase.vue:223-232` 的 trigger `<button>`
- Produces: `DropDownListSelect` 新增可选 prop `listboxId?: string`（透传到 listbox 容器的 `id`）；trigger 的 `aria-controls` 指向同一值

- [ ] **Step 1: 先读两个文件的相关段（活源，别照本计划的行号）**

```bash
sed -n '218,240p' src/canonical/SelectBoxBase.vue
sed -n '118,142p' src/canonical/DropDownListSelect.vue
grep -n "defineProps\|withDefaults" src/canonical/DropDownListSelect.vue
```

- [ ] **Step 2: 写失败的断言（加到既有 Playwright 套件里）**

在 `tests/select-keyboard/keyboard.spec.ts` 里加：

```ts
test('trigger carries combobox semantics wired to the listbox', async ({ page }: { page: Page }) => {
  await page.goto('/internal/select-keyboard-harness')

  const t = trigger(page)
  await expect(t).toHaveAttribute('role', 'combobox')
  await expect(t).toHaveAttribute('aria-haspopup', 'listbox')
  await expect(t).toHaveAttribute('aria-expanded', 'false')

  // 打开后 aria-controls 必须指向一个真实存在、role=listbox 的元素
  await t.press('Enter')
  await expect(t).toHaveAttribute('aria-expanded', 'true')

  const controls = await t.getAttribute('aria-controls')
  expect(controls, 'trigger 必须声明 aria-controls').toBeTruthy()
  const target = page.locator(`#${controls}`)
  await expect(target).toHaveCount(1)
  await expect(target).toHaveAttribute('role', 'listbox')
})
```

- [ ] **Step 3: 跑测确认它红**

```bash
lsof -nP -iTCP:5173 -sTCP:LISTEN     # 有孤儿 dev server 先 kill
pnpm run test:select-keyboard
```

Expected: 新 case FAIL（`role` 属性不存在）；**旧 4 个 case 仍 PASS**（证明探针没把套件搞坏）。

- [ ] **Step 4: 改 `DropDownListSelect.vue` 接受外部 id**

props 加 `listboxId?: string`，模板里 listbox 容器加 `:id="listboxId"`。（容器已有 `role="listbox"`，只加 id。）

- [ ] **Step 5: 改 `SelectBoxBase.vue` 的 trigger**

在 `<button>` 上加（`useId()` 是 Vue 3.5+ 内置，仓库 vue 版本 ≥3.5.35，可用；若不可用改用模块级计数器）：

```
role="combobox"
aria-haspopup="listbox"
:aria-controls="isOpen ? listboxId : undefined"
```

并给 `<DropDownListSelect>` 传 `:listbox-id="listboxId"`，其中：

```js
const listboxId = `tvu-select-listbox-${useId()}`
```

⚠️ `aria-controls` 只在 open 时给值（关闭时目标元素不在 DOM 里，指向不存在的 id 是新的 a11y 问题）。

- [ ] **Step 6: 跑测确认全绿**

```bash
pnpm run test:select-keyboard
```

Expected: 5 个 case 全 PASS

- [ ] **Step 7: 跑视觉/保真闸确认没碰坏渲染**

```bash
pnpm exec vue-tsc --noEmit
pnpm test
pnpm run test:render-verification && pnpm run audit:render-drift-gate
pnpm run audit:demo-framework-parity
```

Expected: `A_TRUE_DRIFT_CANDIDATE=0`（ARIA 属性不进 render manifest 的 A-field，数字应不动）

- [ ] **Step 8: 跑 axe 扫描看有无新增/减少**

```bash
pnpm run test:a11y
```

- [ ] **Step 9: 写 changeset**

```bash
cat > .changeset/select-combobox-aria.md <<'EOF'
---
"@ux-team/tvu-design-system": patch
---

Select 的触发器补齐 combobox ARIA 语义（`role="combobox"` / `aria-haspopup="listbox"` / open 时 `aria-controls` 指向真实 listbox）。

此前触发器只有 `aria-expanded`：键盘操作本身是好的（打开、上下移动、Enter 选中、Escape 回焦都已实现并有 e2e 覆盖），但屏幕阅读器读不出"这是组合框、它控制着哪个列表"。纯 ARIA 属性补齐，无视觉、无 API 变化。
EOF
```

- [ ] **Step 10: 🛑 视觉门 + Commit**

`.vue` 改动会触发 pre-commit 视觉门。本改动纯 ARIA 属性、理论零视觉影响，但**仍需 owner 看一次截图后才允许设 `VISUAL_COMMIT_APPROVED=1`**（AI 不得自行设）。

```bash
VISUAL_COMMIT_APPROVED=1 git commit -- src/canonical/SelectBoxBase.vue \
  src/canonical/DropDownListSelect.vue tests/select-keyboard/keyboard.spec.ts \
  .changeset/select-combobox-aria.md \
  -m "fix(a11y): Select trigger 补 combobox ARIA 语义（STATUS v1.x roadmap 残余）

实测收窄 scope：键盘操作早已 ship 且有 e2e（打开/上下/Enter/Escape 回焦 4 case），
真缺口只在 trigger 的 ARIA 语义 —— 原先只有 aria-expanded。
补 role=combobox + aria-haspopup=listbox + open 时 aria-controls 指向
DropDownListSelect 的 listbox（新增可选 listboxId prop 透传 id；关闭时刻意不给
aria-controls，避免指向不存在的元素）。
先加断言证其红（旧 4 case 仍绿 = 探针没搞坏套件）再改实现，5 case 全绿。
render-drift-gate A=0 不动。"
git status --short
git push origin HEAD:master && git push github HEAD:master
for R in origin github; do echo "$R -> $(git ls-remote $R refs/heads/master | cut -f1)"; done
```

---

## Task 8: I18N-01 wiring 4 处 + 防回归闸

**Files:**
- Modify: `src/components/Pagination/Pagination.vue`（:163 `of` · :228 `Go to`）
- Modify: `src/canonical/SelectBoxBase.vue`（:256 `'Select...'`）
- Modify: `src/components/PopupBox/PopupBox.vue`（:32-33 `cancelText`/`confirmText` 默认值）
- Create: `scripts/audit-no-hardcoded-ui-strings.mjs`
- Create: `tests/audit-no-hardcoded-ui-strings.test.ts`
- Modify: `package.json` · `.husky/pre-commit` · `.gitea/workflows/pr-checks.yml`

**Interfaces:**
- Consumes: `src/locale/index.ts` 的 `useLocale()` 与 `defaultLocale` 的 5 个 key（`paginationOf` / `paginationJumpTo` / `selectPlaceholder` / `confirm` / `cancel`），**默认值与被替换的字面量逐字相等**（`defaultLocale` 注释原文：byte-equal to the previously-hardcoded strings）
- Produces: `scripts/audit-no-hardcoded-ui-strings.mjs` 导出 `findHardcodedStrings(source, filePath, dictionary) -> Array<{line, text, suggestedKey}>`；npm script `audit:no-hardcoded-ui-strings`

- [ ] **Step 1: 确认 5 个默认值与现字面量逐字相等**（相等才能保证零视觉变化）

```bash
node -e "
const src=require('fs').readFileSync('src/locale/index.ts','utf8');
['paginationOf','paginationJumpTo','selectPlaceholder','confirm','cancel'].forEach(k=>{
  const m=src.match(new RegExp(k+\":\\\\s*'([^']*)'\"));
  console.log(k,'=',JSON.stringify(m&&m[1]));
});
"
```

Expected: `of` / `Go to` / `Select...` / `Confirm` / `Cancel`

- [ ] **Step 2: 改 Pagination 两处**

`:163`：

```
{{ visibleRangeStart }}-{{ visibleRangeEnd }} {{ locale.paginationOf }} {{ total.toLocaleString() }}
```

`:228`：

```
<span class="pg-jumper__label">{{ locale.paginationJumpTo }}</span>
```

（`const locale = useLocale()` 已在 `:42`，无需新 import。）

- [ ] **Step 3: 改 SelectBoxBase**

`:256`：

```
{{ displayLabel || placeholder || locale.selectPlaceholder }}
```

- [ ] **Step 4: 改 PopupBox 的两个默认值**

`withDefaults` 里的字面量默认值不能读 `locale`（defaults 求值时机在 setup 内但要拿到 `locale` 的 computed 值 → 改成 `undefined` 默认 + 模板/computed 兜底）：

```
cancelText: undefined,
confirmText: undefined,
```

并加两个 computed：

```js
const resolvedCancelText = computed(() => props.cancelText ?? locale.value.cancel)
const resolvedConfirmText = computed(() => props.confirmText ?? locale.value.confirm)
```

模板里两个按钮的文案改用 `resolvedCancelText` / `resolvedConfirmText`。
⚠️ **`?? ` 不是 `||`** —— 消费方显式传空串时应尊重空串，不该回落成 `Cancel`。

- [ ] **Step 5: 跑组件测 + 保真闸，确认零视觉变化**

```bash
pnpm exec vue-tsc --noEmit
pnpm test
pnpm run test:render-verification && pnpm run audit:render-drift-gate
```

Expected: `A_TRUE_DRIFT_CANDIDATE=0`（默认值逐字相等 → 渲染字符串不变）

- [ ] **Step 6: 写闸的失败单测**

`tests/audit-no-hardcoded-ui-strings.test.ts`：

```ts
import { describe, it, expect } from 'vitest'
import { findHardcodedStrings } from '../scripts/audit-no-hardcoded-ui-strings.mjs'

const DICT = { paginationOf: 'of', selectPlaceholder: 'Select...', cancel: 'Cancel' }

describe('findHardcodedStrings', () => {
  it('fires on a locale-covered literal inside a template text node', () => {
    const src = `<template>\n  <span>Select...</span>\n</template>`
    const hits = findHardcodedStrings(src, 'src/canonical/Foo.vue', DICT)
    expect(hits).toHaveLength(1)
    expect(hits[0].line).toBe(2)
    expect(hits[0].suggestedKey).toBe('selectPlaceholder')
  })

  it('fires on a locale-covered literal used as a prop default', () => {
    const src = `<script setup>\nconst p = withDefaults(defineProps(), { cancelText: 'Cancel' })\n</script>`
    const hits = findHardcodedStrings(src, 'src/components/Bar/Bar.vue', DICT)
    expect(hits.map(h => h.suggestedKey)).toContain('cancel')
  })

  it('does NOT fire when the string already goes through locale (must-not-fire)', () => {
    const src = `<template>\n  <span>{{ locale.selectPlaceholder }}</span>\n</template>`
    expect(findHardcodedStrings(src, 'src/canonical/Foo.vue', DICT)).toHaveLength(0)
  })

  it('does NOT fire on class names, attribute values or CSS (must-not-fire)', () => {
    const src = [
      '<template>',
      '  <span class="of-something" data-x="Cancel" aria-hidden="true"></span>',
      '</template>',
      '<style scoped>',
      '.x { content: "Cancel"; }',
      '</style>',
    ].join('\n')
    expect(findHardcodedStrings(src, 'src/canonical/Foo.vue', DICT)).toHaveLength(0)
  })

  it('does NOT fire on a literal that no locale key covers (must-not-fire)', () => {
    const src = `<template>\n  <span>Totally bespoke product copy</span>\n</template>`
    expect(findHardcodedStrings(src, 'src/canonical/Foo.vue', DICT)).toHaveLength(0)
  })
})
```

- [ ] **Step 7: 跑单测确认红**

```bash
pnpm exec vitest run tests/audit-no-hardcoded-ui-strings.test.ts
```

Expected: FAIL — `Cannot find module '../scripts/audit-no-hardcoded-ui-strings.mjs'`

- [ ] **Step 8: 写闸**

`scripts/audit-no-hardcoded-ui-strings.mjs`：

```js
#!/usr/bin/env node
// audit-no-hardcoded-ui-strings.mjs — I18N-01 的防回归闸。
// 判据（刻意窄，为把假阳压到 0）：只有「defaultLocale 里已经有对应 key 的那几个
// 字符串」出现在 .vue 的可见文案位置时才红。产品自由文案、class 名、属性值、CSS
// 一律不管 —— 这不是通用 i18n 扫描器，是"契约已经给了 key 却没用"的检测器。
// 分母 = src/locale/index.ts 的 defaultLocale（改契约自动扩覆盖，反模式 #5：扩展只改一处）。
// gate 平权（INFRA-F61）：pre-commit(L4) + pr-checks.yml(L5) + prepublishOnly。
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve, join, relative } from 'node:path'

const __dirname = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = resolve(__dirname, '..')
const SCAN_DIRS = ['src/canonical', 'src/components']

// ───────────────────────── 纯函数（vitest 直接 import） ─────────────────────────

/** 读 src/locale/index.ts 的 defaultLocale → { key: literal }。 */
export function readLocaleDictionary(root = REPO_ROOT) {
  const src = readFileSync(resolve(root, 'src/locale/index.ts'), 'utf8')
  const block = src.slice(src.indexOf('defaultLocale'))
  const dict = {}
  for (const m of block.matchAll(/^\s*([A-Za-z][A-Za-z0-9]*):\s*'([^']*)',/gm)) {
    dict[m[1]] = m[2]
  }
  return dict
}

/**
 * 在一份 .vue 源码里找「该走 locale 却写死」的字面量。
 * 两个命中形态：
 *   T1 template 文本节点：>Select...<
 *   T2 prop 默认值：cancelText: 'Cancel'
 * 三个显式豁免：已经是 {{ locale.x }} / 在 class|attr 值里 / 在 <style> 块里。
 */
export function findHardcodedStrings(source, filePath, dictionary) {
  const styleStripped = source.replace(/<style[\s\S]*?<\/style>/g, m => m.replace(/[^\n]/g, ' '))
  const lines = styleStripped.split('\n')
  const hits = []

  const byLiteral = new Map()
  for (const [key, literal] of Object.entries(dictionary)) {
    if (literal && !byLiteral.has(literal)) byLiteral.set(literal, key)
  }

  lines.forEach((line, i) => {
    if (line.includes('locale.')) return // 已走契约
    for (const [literal, key] of byLiteral) {
      const esc = literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
      // T1：作为 template 文本节点出现（前后是 > < 或行首/尾的纯文本）
      const t1 = new RegExp(`>\\s*${esc}\\s*<`)
      // T2：作为对象字面量的值出现（prop 默认值 / options 常量）
      const t2 = new RegExp(`:\\s*'${esc}'`)
      if (t1.test(line) || t2.test(line)) {
        hits.push({ line: i + 1, text: literal, suggestedKey: key, file: filePath })
      }
    }
  })
  return hits
}

export function listVueFiles(root = REPO_ROOT, dirs = SCAN_DIRS) {
  const out = []
  const walk = dir => {
    for (const name of readdirSync(dir)) {
      const full = join(dir, name)
      if (statSync(full).isDirectory()) walk(full)
      else if (name.endsWith('.vue')) out.push(full)
    }
  }
  for (const d of dirs) walk(resolve(root, d))
  return out
}

// ───────────────────────────────── CLI ─────────────────────────────────

const IS_MAIN = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))

if (IS_MAIN) {
  const dict = readLocaleDictionary()
  const files = listVueFiles()
  const all = []
  for (const f of files) {
    all.push(...findHardcodedStrings(readFileSync(f, 'utf8'), relative(REPO_ROOT, f), dict))
  }
  if (all.length === 0) {
    console.log(`✓ no hardcoded locale-covered strings (${files.length} .vue scanned, ${Object.keys(dict).length} locale keys)`)
    process.exit(0)
  }
  console.error(`✗ ${all.length} hardcoded string(s) that TvuLocale already has a key for:\n`)
  for (const h of all) {
    console.error(`   ${h.file}:${h.line}  "${h.text}"  → 改用 locale.${h.suggestedKey}`)
  }
  console.error('\n   契约在 src/locale/index.ts；组件里 const locale = useLocale() 后读 locale.<key>。')
  process.exit(1)
}
```

- [ ] **Step 9: 跑单测确认绿**

```bash
pnpm exec vitest run tests/audit-no-hardcoded-ui-strings.test.ts
```

- [ ] **Step 10: 接进 package.json + 正常态实跑**

`scripts` 加 `"audit:no-hardcoded-ui-strings": "node scripts/audit-no-hardcoded-ui-strings.mjs"`，并追加到 `prepublishOnly` 串尾。

```bash
pnpm run audit:no-hardcoded-ui-strings; echo "exit=$?"
```

Expected: `exit=0`（Step 2-4 已把 4 处改完）

- [ ] **Step 11: 🔴 故障注入 —— 三组**

```bash
# F1 把 Pagination 的 of 改回硬编码
perl -pi -e "s/\Q{{ locale.paginationOf }}\E/of/" src/components/Pagination/Pagination.vue
grep -c "locale.paginationOf" src/components/Pagination/Pagination.vue   # 期望 0（复验 perl 真改了）
pnpm run audit:no-hardcoded-ui-strings; echo "F1 exit=$? (期望 1，且报 Pagination.vue 行号)"
git checkout -- src/components/Pagination/Pagination.vue
grep -c "locale.paginationOf" src/components/Pagination/Pagination.vue   # 期望 1

# F2 把 PopupBox 默认值改回字面量
perl -pi -e "s/cancelText: undefined,/cancelText: 'Cancel',/" src/components/PopupBox/PopupBox.vue
grep -c "cancelText: 'Cancel'" src/components/PopupBox/PopupBox.vue      # 期望 1
pnpm run audit:no-hardcoded-ui-strings; echo "F2 exit=$? (期望 1，且报 cancel)"
git checkout -- src/components/PopupBox/PopupBox.vue

# F3 新增一个 locale key 后，原本合法的写法应该开始被管（证明分母跟着契约走）
#    —— 只读验证，不改文件：给 dictionary 里塞一个新 key 看纯函数行为
node -e "
const { findHardcodedStrings } = await import('./scripts/audit-no-hardcoded-ui-strings.mjs');
const src = '<template>\n  <span>Reset</span>\n</template>';
console.log('无 key 时:', findHardcodedStrings(src,'x.vue',{}).length, '(期望 0)');
console.log('有 key 时:', findHardcodedStrings(src,'x.vue',{reset:'Reset'}).length, '(期望 1)');
" --input-type=module

# 复原验证
pnpm run audit:no-hardcoded-ui-strings; echo "restored exit=$? (期望 0)"
git status --short src/   # 期望：只有本 task 有意的改动
```

**三组必须都按期望。** 特别是 F3 —— 它证明「扩契约 = 自动扩覆盖」，反模式 #5（扩展只改一处）成立。

- [ ] **Step 12: 双挂 gate**

`.husky/pre-commit` 追加：

```sh
# I18N-01 防回归：契约已给了 key 的可见文案不得再写死。
# 触发面：canonical/components 的 .vue，或契约文件本身（改契约会扩大分母）。
if git diff --cached --name-only --diff-filter=AM | grep -qE '(src/(canonical|components)/.*\.vue|src/locale/index\.ts)'; then
  pnpm run audit:no-hardcoded-ui-strings
else
  echo "   ✓ no ui-string-relevant files staged, skipped"
fi
```

`.gitea/workflows/pr-checks.yml` 追加：

```yaml
      - name: 组件可见文案必须走 TvuLocale (F68 I18N-01)
        # 与 pre-commit 同一条闸 —— INFRA-F61 gate 平权。
        run: pnpm audit:no-hardcoded-ui-strings
```

- [ ] **Step 13: 写 changeset**

```bash
cat > .changeset/i18n-wiring-visible-strings.md <<'EOF'
---
"@ux-team/tvu-design-system": patch
---

Pagination / Select / PopupBox 的 4 处可见英文文案接上 `TvuLocale` 契约。

`TvuLocale` 早在 2026-07-17 就加了 `paginationOf` / `paginationJumpTo` / `selectPlaceholder` / `confirm` / `cancel` 这几个 key，但组件里仍写死英文——契约给了、没人用。本版把它们接上：

- `Pagination` 的范围分隔词与页码跳转标签
- `Select` 的兜底 placeholder
- `PopupBox` 的确认 / 取消按钮默认文案（显式传 `confirmText` / `cancelText` 时仍优先用传入值，**空串会被尊重**）

英文默认值与原字面量逐字相同，**不改任何默认显示**；配套新增 `audit:no-hardcoded-ui-strings` 闸防回归。
EOF
```

- [ ] **Step 14: 🛑 视觉门 + Commit**

```bash
VISUAL_COMMIT_APPROVED=1 git commit -- src/components/Pagination/Pagination.vue \
  src/canonical/SelectBoxBase.vue src/components/PopupBox/PopupBox.vue \
  scripts/audit-no-hardcoded-ui-strings.mjs tests/audit-no-hardcoded-ui-strings.test.ts \
  package.json .husky/pre-commit .gitea/workflows/pr-checks.yml \
  .changeset/i18n-wiring-visible-strings.md \
  -m "feat(i18n): I18N-01 wiring 4 处 + 防回归闸（契约 land 两周后终于被消费）

契约 2026-07-17 就加了 5 个 key、组件里仍写死英文 —— 典型「声明 ≠ 被消费」
（meta-rules 反模式 #7）。接上：Pagination of/Go to · Select 兜底 placeholder ·
PopupBox 确认/取消默认文案（用 ?? 不用 || ，消费方传空串要被尊重）。
默认值与原字面量逐字相等 → render-drift-gate A=0 不动。
新闸 audit:no-hardcoded-ui-strings 判据刻意窄（只管 defaultLocale 已有 key 的
字面量，产品自由文案/class/attr/CSS 全豁免），分母跟着契约走 —— 扩 key 自动
扩覆盖。三组故障注入实跑：改回硬编码文本红 / 改回 prop 默认值红 / 新增 key
让原本合法写法开始被管，复原后 exit 0。L4+L5 双挂。"
git status --short
git push origin HEAD:master && git push github HEAD:master
for R in origin github; do echo "$R -> $(git ls-remote $R refs/heads/master | cut -f1)"; done
```

---

## Task 9: z-index / elevation token 标度（TKN-02）+ 扩硬编码闸

**Files:**
- Modify: `src/tokens/variables.css`（新分组 + `--z-*`）
- Modify: `src/components/Tooltip/Tooltip.vue:81` · `src/canonical/SelectBoxBase.vue:545` · `src/components/PopupBox/PopupBox.vue:272` · `src/components/UserMenu/UserMenu.vue:208`
- Modify: `figma-sync/audit-no-hardcoded-design-tokens.mjs`（加 `zIndex` category）
- Modify: `src/design-system/translation/divergences-decisions.json`

**Interfaces:**
- Consumes: 该闸现有的 `expandFindingsByDecl(re, source, blockStart, blockContent, category, valueDictLookup)` 与 `totals = { color, spacing, radius, ... }` 结构（**先读源码确认签名再改**）
- Produces: `--z-dropdown` / `--z-popover` / `--z-modal` / `--z-toast` 四个 code-authored token；闸的 `zIndex` category

- [ ] **Step 1: 实测现有 5 处 z-index 与它们的语义层级**

```bash
grep -rn "z-index" src/ | grep -v "var(--"
```

Expected（baseline）：`InputBoxBase.vue:154` = `1` · `Tooltip.vue:81` = `1000` · `SelectBoxBase.vue:545` = `1100` · `PopupBox.vue:272` = `1000` · `UserMenu.vue:208` = `1000`

**层级判断（依据实测值，不发明新层）**：`1100`（Select 下拉，要浮在 modal 之上是因为 modal 内也有 select）> `1000`（Tooltip / PopupBox / UserMenu 同层）。`InputBoxBase.vue:154` 的 `1` 是**局部层叠上下文**（不是全局层级），**不改、加豁免注释**。

- [ ] **Step 2: 读 `variables.css` 的分组注释格式**（`audit:sort-tokens` I1/I2 要求每个 token 之上有分组标签）

```bash
grep -nE "^\s*/\*.*─" src/tokens/variables.css | head -10
```

- [ ] **Step 3: 加 token**

在 `:root` 块内、按 Step 2 实测的注释装饰风格加一个**新分组**（分组名不得与块内已有标题重复 —— I1）：

```css
  /* ── Z-Index / Elevation（code-authored；Figma 无对应 Variable，见 divergences） ── */
  --z-dropdown: 1000;
  --z-popover: 1000;
  --z-modal: 1000;
  --z-toast: 1100;
  --z-select-portal: 1100;
```

⚠️ **值必须等于 Step 1 实测的现值**，这样是纯重命名、零视觉变化。**不要"顺手规整"成 100/200/300** —— 那会改变实际层叠顺序，是视觉回归。若 owner 想要整齐的标度，那是独立一项（本 task 只做"把魔法数字变成有名字的同值 token"）。

- [ ] **Step 4: 替换 4 处（第 5 处加豁免注释）**

```
Tooltip.vue:81        z-index: var(--z-popover);
SelectBoxBase.vue:545 z-index: var(--z-select-portal);
PopupBox.vue:272      z-index: var(--z-modal);
UserMenu.vue:208      z-index: var(--z-dropdown);
```

`InputBoxBase.vue:154` 之上加：

```css
  /* audit-no-hardcoded-design-tokens: allow zIndex — 局部层叠上下文（把图标抬到
     input 背景之上），不是全局浮层层级，不该占用 --z-* 标度。 */
  z-index: 1;
```

- [ ] **Step 5: 跑 sort-tokens 闸**

```bash
pnpm run audit:sort-tokens; echo "exit=$?"
pnpm run audit:token-contract; echo "token-contract exit=$? (期望 0 —— 新 token 无 Figma 上游，不受该闸管)"
```

- [ ] **Step 6: 视觉验证（值未变 → 应零变化）**

```bash
pnpm exec vue-tsc --noEmit
pnpm test
pnpm run test:visual        # 58+ baseline；期望零 diff
pnpm run test:render-verification && pnpm run audit:render-drift-gate
```

Expected: 视觉 baseline 零 diff（值相同）；`A_TRUE_DRIFT_CANDIDATE=0`。**若视觉 baseline 出 diff → 说明某处值判错了，回 Step 1 重测，不要 `--update-snapshots` 盖过去。**

- [ ] **Step 7: 读闸源码确认扩展点**

```bash
grep -n "RADIUS_RE\|SPACING_RE\|expandFindingsByDecl\|totals =\|spacingByValue\|radiusByValue" figma-sync/audit-no-hardcoded-design-tokens.mjs
```

- [ ] **Step 8: 加 `zIndex` category**

沿用既有形状（三处对称加）：

```js
const ZINDEX_RE = /(^|[\s;])(z-index)\s*:\s*([^;}\n]+)/gi
```

值字典里加 `zIndexByValue`（收集 `--z-` 前缀 token）：

```js
      if (name.startsWith('--z-') && !zIndexByValue.has(literal)) zIndexByValue.set(literal, name)
```

`expandFindingsByDecl` 的 dict 选择分支加 `zIndex`；`totals` 加 `zIndex: 0`；主扫描处加：

```js
    findings.push(...expandFindingsByDecl(ZINDEX_RE, source, block.startOffset, block.content, 'zIndex', dict))
```

**分档沿用既有约定**：exactMatch（值在 `--z-*` 里有精确对应）→ **blocking**；noMatch（如 `1`、`auto`、`-1`）→ **report-only**。这样 `InputBoxBase` 的 `z-index: 1` 天然落 report-only（`1` 不在 `--z-*` 值里），豁免注释是给读代码的人看的第二层说明。

- [ ] **Step 9: 正常态实跑**

```bash
pnpm run audit:no-hardcoded-design-tokens; echo "exit=$?"
```

Expected: exit 0，输出里 `zIndex` 的 exactMatch = 0

- [ ] **Step 10: 🔴 故障注入 —— 两组**

```bash
# F1 把一处改回硬编码（该有 exactMatch → blocking）
perl -pi -e "s/z-index: var\(--z-modal\);/z-index: 1000;/" src/components/PopupBox/PopupBox.vue
grep -c "z-index: 1000;" src/components/PopupBox/PopupBox.vue     # 期望 1（复验 perl 真改了）
pnpm run audit:no-hardcoded-design-tokens; echo "F1 exit=$? (期望 1，且 category=zIndex 报 PopupBox 行号)"
git checkout -- src/components/PopupBox/PopupBox.vue
grep -c "var(--z-modal)" src/components/PopupBox/PopupBox.vue     # 期望 1

# F2 精确注入：证明它只红「有精确 token 对应」的那种，不无脑全红
#    在一个测试文件里写 z-index: 3（无对应 token）→ 该 report-only 不该 blocking
cp src/components/Tooltip/Tooltip.vue /tmp/tt.bak
perl -pi -e "s/z-index: var\(--z-popover\);/z-index: 3;/" src/components/Tooltip/Tooltip.vue
pnpm run audit:no-hardcoded-design-tokens; echo "F2 exit=$? (期望 0 —— 3 无对应 token，落 noMatch report-only)"
cp /tmp/tt.bak src/components/Tooltip/Tooltip.vue && rm /tmp/tt.bak
grep -c "var(--z-popover)" src/components/Tooltip/Tooltip.vue     # 期望 1

pnpm run audit:no-hardcoded-design-tokens; echo "restored exit=$? (期望 0)"
```

**F2 是精确注入**，证明闸不是"看见 z-index 就红"（那种闸会逼人写豁免注释绕过它，最后没人当真）。**若 F2 也红了 → 分档实现错了，回 Step 8。**

- [ ] **Step 11: 登记 code-first divergence**

id = `z-index-scale-code-first-2026-07-30`；notes 写明：Figma 端无 z-index 概念（Figma 层级是画布顺序，不是 CSS 层叠），故这层 token 只能 code-authored；值等于替换前的实测现值，本次是纯命名化、非层级重排。

- [ ] **Step 12: 写 changeset**

```bash
cat > .changeset/z-index-scale.md <<'EOF'
---
"@ux-team/tvu-design-system": patch
---

浮层层级从魔法数字变成有名字的 token（`--z-dropdown` / `--z-popover` / `--z-modal` / `--z-toast` / `--z-select-portal`）。

此前 Tooltip / PopupBox / UserMenu / Select 下拉各自写死 `1000` / `1100`，消费方想把自己的浮层插进这套层级只能猜数字——媒体类 dashboard 上很容易互相遮挡。**token 值与替换前逐字相同，本版不改任何层叠顺序**，只是给它们名字；`audit:no-hardcoded-design-tokens` 同步新增 `zIndex` 检查防回归。
EOF
```

- [ ] **Step 13: 🛑 视觉门 + Commit**

```bash
VISUAL_COMMIT_APPROVED=1 git commit -- src/tokens/variables.css \
  src/components/Tooltip/Tooltip.vue src/canonical/SelectBoxBase.vue \
  src/components/PopupBox/PopupBox.vue src/components/UserMenu/UserMenu.vue \
  src/canonical/InputBoxBase.vue figma-sync/audit-no-hardcoded-design-tokens.mjs \
  src/design-system/translation/divergences-decisions.json .changeset/z-index-scale.md \
  -m "feat(tokens): z-index/elevation 标度 token 化（F68 TKN-02）+ 闸加 zIndex category

5 处硬编码实测：Tooltip/PopupBox/UserMenu 1000 · Select portal 1100 ·
InputBoxBase 1（局部层叠上下文，不是全局层级 → 不改，加豁免注释）。
token 值一律等于替换前现值 —— 纯命名化、零层叠顺序改动（视觉 baseline 零 diff
是这条的证据）。刻意不把它们规整成 100/200/300：那会改层叠顺序 = 视觉回归，
若 owner 要整齐标度另立一项。
闸扩 zIndex category 沿用既有分档（exactMatch blocking / noMatch report-only）。
两组故障注入：改回 1000 变红 / 写 z-index:3（无对应 token）保持绿 —— 后者是
精确注入，证明它不是「见 z-index 就红」的假闸。"
git status --short
git push origin HEAD:master && git push github HEAD:master
for R in origin github; do echo "$R -> $(git ls-remote $R refs/heads/master | cut -f1)"; done
```

---

## Task 10: 发 `v1.2.0`（**它自己就是 tag→CI 链的历史首跑** —— Task 1 已作废）

> ⚠️ **2026-07-31 修正**：原标题写「走 Task 1 已验证的链」，而 Task 1 已作废（见其标题下的作废判据）→ **这条链在本 task 之前一次都没干跑过**（INFRA-F77）。因此本 task 多两件事：**(a)** Step 9 必须逐段读 CI 日志、亲眼确认三段（F73 新加的 pre-flight 真写探针：列表 200 → generic PUT 201 → DELETE 后重拉列表亲验 · `actions/checkout@v4` success · publish 步骤真把包推上去）；**(b)** pre-flight 若红，按 `docs/RELEASING.md` §排障读码 —— **`401` = 根本不是凭据 / `403` = 是凭据但 scope 不够**，secret 名是 `TVU_GITEA_PACKAGES_TOKEN`（`GITEA_`/`GITHUB_` 是 Gitea 保留前缀，建不出来）。

**Files:**
- Modify: `package.json` / `CHANGELOG.md`（由 `changeset version` 自动改）
- Delete: `.changeset/react-ce-npm-consumption-path.md` · `select-combobox-aria.md` · `i18n-wiring-visible-strings.md` · `z-index-scale.md`
- Modify: `docs/STATUS.md` · `docs/internal/backlog.md` · tracker

**Interfaces:**
- Consumes: `.changeset/` 里全部待发条目（**实测 7 份 / 4 份 minor → `changeset:status` 打 minor = 1.2.0**；接手自己跑，别信这个数字）。⚠️ **不 Consumes「Task 1 证明过的链」—— 那个前置已不存在**
- Produces: registry 上的 `@ux-team/tvu-design-system@1.2.0`

- [ ] **Step 1: 确认版本号是 changeset 算出来的、不是我定的**

```bash
pnpm changeset:status
```

Expected: `Packages to be bumped at minor`（Task 6 是 minor）→ 下一版 `1.2.0`。
**若打出的是 patch → 说明 Task 6 的 changeset 没写成 minor 或没落盘，回去查，别手改版本号。**

- [ ] **Step 2: 跑满全部闸**

```bash
pnpm exec vue-tsc --noEmit
pnpm test
pnpm run prepublishOnly
lsof -nP -iTCP:5173 -sTCP:LISTEN     # 有孤儿先 kill
pnpm run test:render-verification && pnpm run audit:render-drift-gate
pnpm run test:visual
pnpm run test:select-keyboard
pnpm run test:a11y
pnpm run audit:demo-framework-parity
pnpm run audit:page-recipes
pnpm run audit:no-hardcoded-ui-strings
```

Expected: 全 exit 0；render-gate `A_TRUE_DRIFT_CANDIDATE=0`。
⚠️ **render-gate 不在 `prepublishOnly` 里**（`feedback_render-gate-not-in-prepublishonly`：v1.1.0 曾以为 prepublishOnly 绿就 ready）——必须单独跑，本版有真实 `.vue` 改动，不适用"render 面无变化所以免跑"。

- [ ] **Step 3: 消化 changeset + 核 CHANGELOG**

```bash
pnpm changeset:version
grep -m1 '"version"' package.json     # 期望 1.2.0
sed -n '1,60p' CHANGELOG.md           # 逐条核 4 项都在，措辞是 consumer 读得懂的
ls .changeset/                        # 期望只剩 README.md + config.json
```

- [ ] **Step 4: 同步三份文档**

① `docs/STATUS.md` §当前版本 → `v1.2.0` + 本版内容 + 三路核验证据位；② `docs/internal/backlog.md`：F68 的 `reexp-ce-runtime` / `I18N-01` / `TKN-02` / `PAT-01`(部分) 标 shipped，STATUS §Active 计数行同步（`audit:status-consistency` C4 会核）；③ tracker 追加一行 `| v1.2.0 | <内容> | 2026-07-30 | ~Xh |`（**耗时写实跑值，不估**）。

- [ ] **Step 5: commit + push + 双 remote 验证**

```bash
git commit -- package.json CHANGELOG.md docs/STATUS.md docs/internal/backlog.md \
  docs/internal/retrospection/design-spec-canonical-alignment-tracker.md \
  -m "chore(release): 1.2.0 —— React/CE npm 路径 + Select combobox ARIA + i18n wiring + z-index token"
git status --short
git push origin HEAD:master && git push github HEAD:master
for R in origin github; do echo "$R -> $(git ls-remote $R refs/heads/master | cut -f1)"; done
```

- [ ] **Step 6: 🛑 OWNER GATE —— tag push 前要 ack**

- [ ] **Step 7: 切 tag → CI**

```bash
pnpm run release
git ls-remote origin refs/tags/v1.2.0
```

- [ ] **Step 8: 三路核验（同 Task 1 Step 10）+ 一层「元数据在但 blob 取不回」核验**

```bash
npm view @ux-team/tvu-design-system version \
  --registry=https://product-demo.tvustream.com/gitea/api/packages/ux-team/npm/
npm pack @ux-team/tvu-design-system@1.2.0 \
  --registry=https://product-demo.tvustream.com/gitea/api/packages/ux-team/npm/ --pack-destination /tmp
tar -tzf /tmp/ux-team-tvu-design-system-1.2.0.tgz | grep -c "^package/dist-wc/"   # 期望 > 0
```

③ **owner 人眼**看 Packages 页有 `1.2.0`。

- [ ] **Step 9: docs 站同步**（`docs/RELEASING.md` Step 4）

```bash
curl -sI https://product-demo.tvustream.com/<docs-path>/ | grep -i last-modified
```

⚠️ **必须走 https** —— 站点 `http` 一律 301 跳 https，用 `http` 只会拿到 301、`grep last-modified` 空手而归，容易误判成"线上没更新"。

- [ ] **Step 10: Wrap-up**

`docs/STATUS.md` 顶部 `Last updated` 改到今天 + 当日摘要（旧摘要 prepend 进 `docs/internal/STATUS-CHANGELOG.md`）；跑 `pnpm run audit:status-consistency` 确认 C4 计数对上；按 `docs/WRAP-UP.md` 判断是否触发 retrospect。

---

## 本批之后的下一批候选（不在本计划范围，供下个 session 排期起点）

按排期原则预排，**下次仍要重跑推荐自审 4 问**：

1. **DG-1 落地**（等 Task 5 的 owner 选路线；若选 A 则要 designer 在 Figma 建变量）+ **TKN-01 算法主题**（同一决策簇）。
2. **RTL-01**（40 处物理 L/R × 11 文件 + 方向图标镜像 + 双主题视觉门）—— 自成一批。
3. **F68 FID-04 M1 role-map 全量铺开**（pilot 只覆盖 Badge+SelectBox 3 role，余 ~2700 深层节点）—— 能力 2/5 加固。
4. **INFRA-F67 (c-1)** 遮挡谓词上闸（修法 + 探针脚本已在 entry 里写死）。
5. **F61 consumer 冒烟 CI**（前置 = owner 决定 F76 MicroApps 何时迁，属该仓库）。
6. **ds-index-barrel**（breaking，需 owner 拍板 + 迁移指南）。

---

## 自审记录（writing-plans §Self-Review，本计划已过）

- **spec 覆盖**：用户点名的候选逐条有归属 —— layout 原语 → Task 5（spec+owner 门）· pattern 层 → Task 2/3/4 · Select ARIA combobox → Task 7 · TKN-* → Task 9（TKN-02；TKN-01 显式列入"不做"并给理由）· i18n wiring → Task 8 · RTL → 显式"不做"+理由 · F60/F61/F62/F63/F58 → 显式"不做"+逐条理由 · F76 → 显式"不做"（属别仓，`.npmrc` 形态已在 entry 定案）· F77 → Task 1（1.1.2 作首跑载荷）。
- **占位符扫描**：无 TBD / "适当处理" / "类似 Task N" / "为上面写测试"；每个 gate 任务都有具体故障注入命令与期望 exit code。
- **类型/命名一致性**：`validateShape` / `validateComponentRefs` / `validateLayoutTokens` / `readCanonicalNames` / `readDefinedTokens`（Task 2）· `findHardcodedStrings` / `readLocaleDictionary` / `listVueFiles`（Task 8）· `listboxId` prop（Task 7 跨两个 .vue）· `--z-dropdown|popover|modal|toast|select-portal`（Task 9 跨 token 与 4 个组件）—— 各处用名一致。
- **已知薄弱处（诚实登记，不假装没有）**：
  - Task 2 的 schema 字段名（`states[].state` / `drivenBy`）是从 `_meta.schema_note` 的散文推的，**Step 1/Step 2 都要求先读实际 JSON 再定稿**；若不符以数据为准改 schema，不改数据。
  - Task 6 Step 2 的 `dist-wc` 入口文件名是占位示例，**Step 1 明确要求以 `find dist-wc` 实测为准**。
  - Task 7 用 `useId()`（Vue 3.5+）；若 `vue-tsc` 报不存在，改模块级计数器，行为不变。
  - Task 9 F2 那组精确注入依赖「`3` 不在 `--z-*` 值里」，若有人后来加了 `--z-*: 3`，该组注入会失效 —— 届时换一个没被占用的值。
