# APID-01 Table 数据表状态视觉规范 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:** 给 `Table` 加**纯呈现**的数据表状态（loading / 空态 / 选中行高亮 / 排序表头指示）+ AI 机读组合配方，code-first，无任何运行时行为（不排序/不管理选中/不切片分页/无 emits/无 v-model）。

**Architecture:** 在既有 Figma 保真 `Table`（canonical + base）上加 additive/opt-in props。默认渲染完全不变（现有 12 render-verification entries 保持有效）。全部 inline（无 overlay/teleport）→ 保留默认 shadow DOM。新非-Figma 层走 divergence 登记（**不进 canonical-exempt**——Table 有 Figma 覆盖）。React 侧经 `gen:react-bindings` 自动生成，demo 是手写镜像（非 descriptor）。

**Tech Stack:** Vue 3 `<script setup>` + TS · CE 经 `components.config.ts`→`gen:react-bindings` · vitest 组件测试 · docs 站 + Playwright 视觉门。

## Global Constraints

- **零运行时行为**：属性只把 app 传入状态映射成 TVU 视觉；组件不维护可变状态、不做数据变换。违者出界。（owner 2026-07-22 裁剪线）
- **全 additive / opt-in**：不删改现有 `columns`/`data`/`striped`/`align`/`type` 及默认渲染（v1.0 API 锁定，API_STABILITY.md）。
- **选中 = 仅行高亮**，无勾选列（owner 2026-07-22）。
- **CE-safe 槽判定用 `useHasSlot`，禁 `$slots`/`useSlots`**（`audit:canonical-slot-guard` 拦）。
- **图标复用注册表** `action/sorting`（canonical 名，禁短别名 / 禁内联 SVG）。
- **Table 不进 `canonical-exempt.json`**（有 Figma 覆盖）；新层走 **divergence**。
- **原子 commit**：`.vue`/`.tsx` 改动一次到位（完整接线），视觉门 owner `VISUAL_COMMIT_APPROVED` 后 commit；执行期可 per-task `--no-verify` WIP，视觉门通过后 `git reset --soft` 挤成单一原子 commit。
- **locale**：空态默认文案走 `TvuLocale.tableEmpty`（新增 key，默认 `"No data"`），不写死。
- 命令前置：本地跑测试/harness 前 `pnpm build:wc`（dist-wc 不由主 build 重建）。

---

### Task 1: base + canonical Table 呈现层渲染（核心，可测）

**Files:**
- Modify: `src/components/Table/Table.vue`（base：新增渲染 + `TableColumn.sortOrder` + `#empty` slot）
- Modify: `src/canonical/Table.vue`（转发新 props + `#empty` slot）
- Test: `tests/Canonical.test.ts`（追加 Table 状态渲染断言，沿用现有 Canonical wrapper 测试范式）

**Interfaces:**
- Produces（base `TableColumn` 扩展 + base/canonical props）：
  - `TableColumn` 加 `sortOrder?: 'asc' | 'desc' | null`
  - props 加 `loading?: boolean`（默认 false）· `rowKey?: string` · `selectedKeys?: (string | number)[]`（默认 `[]`）
  - named slot `empty`
- Consumes: 现有 `Icon`（`src/components/Icon/Icon.vue`）· `useHasSlot`（CE-safe 槽判定，先确认其 import 路径见 Step 1）· `useLocale`（Task 3 后接空态文案；Task 1 先用硬编码 fallback `'No data'`，Task 3 替换为 locale）

- [ ] **Step 1: 确认 CE-safe 槽判定 helper 的确切 import**

Run: `grep -rn "useHasSlot" src/ | head` 找到 helper 路径与签名（若不存在则改用现有其它 canonical 组件的同款 CE 安全判定方式——照最近有具名槽的 canonical 组件，如 `src/canonical/UserMenu.vue` 的 `#panel-top` 槽判定实现）。
Expected: 得到 `useHasSlot` 的确切模块路径（记下供 Step 3/4 用）。

- [ ] **Step 2: 写失败测试（base Table 四状态渲染）**

在 `tests/Canonical.test.ts` 追加（沿用文件顶部既有 `mount` + canonical import 风格；Table canonical import 已可能存在，若无则 `import Table from '../src/canonical/Table.vue'`）：

```ts
describe('Table presentational states (APID-01)', () => {
  const columns = [{ key: 'name', title: 'Name' }, { key: 'role', title: 'Role', sortOrder: 'asc' as const }]
  const data = [{ id: 1, name: 'Alice', role: 'Admin' }, { id: 2, name: 'Bob', role: 'Editor' }]

  it('renders empty slot fallback when data is empty and not loading', () => {
    const w = mount(Table, { props: { columns, data: [] } })
    expect(w.text()).toContain('No data')
    expect(w.find('.tbl-empty').exists()).toBe(true)
  })

  it('renders loading overlay when loading', () => {
    const w = mount(Table, { props: { columns, data, loading: true } })
    expect(w.find('.tbl-loading').exists()).toBe(true)
  })

  it('marks selected rows via rowKey + selectedKeys', () => {
    const w = mount(Table, { props: { columns, data, rowKey: 'id', selectedKeys: [2] } })
    const selected = w.findAll('.tbl-row--selected')
    expect(selected.length).toBe(1)
  })

  it('renders a sort indicator on a column with sortOrder', () => {
    const w = mount(Table, { props: { columns, data } })
    expect(w.find('.tbl-sort-indicator').exists()).toBe(true)
  })

  it('default render (no new props) is unchanged — no empty/loading/selected/sort nodes', () => {
    const w = mount(Table, { props: { columns, data } })
    expect(w.find('.tbl-empty').exists()).toBe(false)
    expect(w.find('.tbl-loading').exists()).toBe(false)
    expect(w.find('.tbl-row--selected').exists()).toBe(false)
  })
})
```

- [ ] **Step 3: 运行测试确认失败**

Run: `pnpm build:wc && pnpm test -- tests/Canonical.test.ts`
Expected: FAIL（`.tbl-empty` / `.tbl-loading` / `.tbl-row--selected` / `.tbl-sort-indicator` 不存在）

- [ ] **Step 4: 实现 base `src/components/Table/Table.vue`**

`<script setup>` 内：
- `TableColumn` 接口加 `sortOrder?: 'asc' | 'desc' | null`。
- `defineProps` 加 `loading?: boolean`（默认 false）、`rowKey?: string`、`selectedKeys?: (string | number)[]`（默认 `() => []`）。
- 用 Step 1 的 `useHasSlot('empty')` 判定是否有自定义空态内容。
- 计算 `isEmpty = computed(() => (props.data?.length ?? 0) === 0 && !props.loading)`。
- 选中判定：`isRowSelected(row) => props.rowKey != null && props.selectedKeys?.includes(row[props.rowKey])`。

`<template>`：
- 外层包 `<div class="tbl-wrap">`（`position: relative`），内含 `<table class="tbl">`。
- 表头单元格：若 `col.sortOrder`，在内容后加 `<Icon name="action/sorting" class="tbl-sort-indicator" :data-order="col.sortOrder" />`（asc/desc 由 CSS 旋转或 data 属性区分；用注册表 `action/sorting`）。
- tbody 行：`:class="['tbl-row', { 'tbl-row--selected': isRowSelected(row) }]"`。
- 空态：`v-if="isEmpty"` 渲染一行 `<tr class="tbl-empty-row"><td :colspan="columns.length" class="tbl-empty"><slot name="empty">{{ 'No data' }}</slot></td></tr>`（Task 3 把 `'No data'` 换成 locale）。
- 加载态：`v-if="loading"` 在 `.tbl-wrap` 内加 `<div class="tbl-loading"><span class="tbl-spinner" /></div>`（绝对定位覆盖层 + CSS spinner）。

`<style scoped>` 追加 `.tbl-wrap`（relative）、`.tbl-row--selected`（`background: var(--bg-selected 或 --brand-fade`——用现有选中态 token，Step 先 `grep -i "selected\|--brand" src/**/*.vue` 找项目既有选中底色 token，禁硬编码）、`.tbl-loading`（absolute inset-0 flex center，半透明 `var(--bg-layer2)` 底）、`.tbl-spinner`（CSS `@keyframes` 旋转，border token 色）、`.tbl-empty`（居中 `var(--text-tips)`）、`.tbl-sort-indicator`（16px；`[data-order=desc]` 旋转 180deg）。

- [ ] **Step 5: 实现 canonical `src/canonical/Table.vue` 转发**

`defineProps` 加 `loading` / `rowKey` / `selectedKeys`（透传给 BaseTable）；`normalizedColumns` 保留 `sortOrder`（`...column` 已含）。`<template>` BaseTable 上绑新 props，并转发 `#empty`：`<template #empty><slot name="empty" /></template>`（仅当有 slot 时——用 `useHasSlot` 条件转发，避免空槽覆盖 base 默认文案）。

- [ ] **Step 6: 运行测试确认通过**

Run: `pnpm test -- tests/Canonical.test.ts`
Expected: PASS（5 断言全绿）

- [ ] **Step 7: WIP commit（--no-verify，视觉门后再挤压）**

```bash
git add src/components/Table/Table.vue src/canonical/Table.vue tests/Canonical.test.ts
git commit --no-verify -m "WIP(table): presentational state rendering (loading/empty/selected/sort)"
```

---

### Task 2: CE 绑定配置 + React 生成 + parity 闸

**Files:**
- Modify: `src/web-components/components.config.ts`（Table 条目 L1154-1171）
- Regenerate: `react-pilot/src/wrappers/Table.tsx` · `react-pilot/src/wrappers/types.ts` · `src/web-components/register.ts`（由 `gen:react-bindings` 写）

**Interfaces:**
- Consumes: Task 1 的 `defineProps` 形态（1:1 镜像）
- Produces: `tvu-table` CE + React `<Table>` wrapper 带新 props

- [ ] **Step 1: 更新 components.config Table 条目**

在 `props: []` 追加（顺序/类型 1:1 镜像 canonical `defineProps`）：
```ts
{ name: 'loading',      tsType: 'boolean' },
{ name: 'rowKey',       tsType: 'string' },
{ name: 'selectedKeys', tsType: '(string | number)[]' },
```
并把 `columns` 的 tsType 内联对象加 `sortOrder?: 'asc' | 'desc' | null`。`namedSlots: []` → `namedSlots: ['empty']`。`hasDefaultSlot`/`events`/`vModel` 不变。

- [ ] **Step 2: 重生 React 绑定**

Run: `pnpm gen:react-bindings`
Expected: `react-pilot/src/wrappers/Table.tsx` / `types.ts` / `register.ts` 更新，含新 props；不手改生成物。

- [ ] **Step 3: 跑 parity + 命名闸**

Run: `pnpm audit:binding-config-parity && pnpm run audit:prop-naming`
Expected: 两者 PASS（config 与 SFC 1:1）。

- [ ] **Step 4: WIP commit**

```bash
git add src/web-components/components.config.ts react-pilot/src/wrappers/Table.tsx react-pilot/src/wrappers/types.ts src/web-components/register.ts
git commit --no-verify -m "WIP(table): components.config props + regen react bindings"
```

---

### Task 3: TvuLocale `tableEmpty` key + 接入空态

**Files:**
- Modify: `src/locale/index.ts`（`interface TvuLocale` + `defaultLocale`）
- Modify: `src/components/Table/Table.vue`（空态 fallback 用 `useLocale`）

- [ ] **Step 1: 加 locale key**

`src/locale/index.ts`：`interface TvuLocale` 在 `cancel` 之后加 `tableEmpty: string`；`defaultLocale` 对象同步加 `tableEmpty: 'No data'`（interface ⟷ defaultLocale 必须同步，否则 TS 报错）。

- [ ] **Step 2: base Table 空态用 locale**

`src/components/Table/Table.vue` import `useLocale`（照现有用 locale 的组件，如 Pagination——`grep -rn "useLocale" src/components | head`），空态 slot 默认内容 `{{ locale.tableEmpty }}` 替换 Task 1 的硬编码 `'No data'`。

- [ ] **Step 3: 更新测试期望不变 + 跑测试**

Run: `pnpm test -- tests/Canonical.test.ts`
Expected: PASS（`'No data'` 仍是默认 locale 值，断言 `toContain('No data')` 仍成立）。

- [ ] **Step 4: WIP commit**

```bash
git add src/locale/index.ts src/components/Table/Table.vue
git commit --no-verify -m "WIP(table): tableEmpty locale key + empty-state wiring"
```

---

### Task 4: 溯源/规则接线（divergence + affordances + CLAUDE_DESIGN_RULES + 机读 recipe）

**Files:**
- Modify: `src/design-system/translation/divergences-decisions.json`（新 code-first 条目）
- Modify: `docs/internal/component-affordances.json`（Table 条目加 props/features）
- Regenerate: `docs/internal/component-affordances.md`
- Modify: `docs/CLAUDE_DESIGN_RULES.md`（§3 Table bullet）
- Modify/Create: 机读 recipe（先勘察 `component-affordances.json` 是否容纳 recipe 段；否则新建 `figma-data/page-recipes.json`）

- [ ] **Step 1: 加 divergence 条目**

在 `divergences-decisions.json` `decisions[]` 追加（字段见 spec §K 模板，`id: "table-presentational-props-code-first-2026-07-22"`，reason 必含 `Owner approved code-first 2026-07-22`，notes 明确"NOT added to canonical-exempt.json — has Figma component"）。

- [ ] **Step 2: 更新 affordances JSON + 重生 md**

`component-affordances.json` Table 条目（~L499-526）`code_props` 加 `loading`/`rowKey`/`selectedKeys` + 列 `sortOrder`；`features` 加 `{feature:'空态',controlled_by:'slot:empty'}`/`{feature:'加载态',controlled_by:'prop:loading'}`/`{feature:'选中行高亮',controlled_by:'prop:selectedKeys+rowKey'}`/`{feature:'排序指示',controlled_by:'column.sortOrder'}`。
Run: `pnpm generate:component-affordances && pnpm run audit:component-affordances`
Expected: md 重生、drift 闸 PASS。

- [ ] **Step 3: 机读 recipe（AI-consumable 组合配方）**

先 Run: `grep -n "recipe\|blueprint\|_meta\|composition" docs/internal/component-affordances.json | head` 判定现有 schema 能否容纳「页面级 recipe」段。
- 若能：在 `component-affordances.json` 加 `page_recipes`（或既有等价段）一条 `data-table`：槽位组合（table + 可选 toolbar + 下方 `Pagination`）+ 状态清单（empty/loading/selected/sorted 各由哪个 prop/slot 驱动）+ "排序/选择/翻页**行为**由消费方 app 实现" 的明示 + 布局间距 token。
- 若不能：新建 `figma-data/page-recipes.json`（顶层 `recipes: [{ id:'data-table-page', slots:[...], states:[...], behaviorOwnedByApp:true, layoutTokens:{...} }]`），并在 `docs/PROJECT_MAP.md` 生成链登记其来源（避免孤儿文件被 audit 报）。
> 判定与落点在执行时据实定，二者皆满足"AI 可读 + 不进产品语义"。

- [ ] **Step 4: CLAUDE_DESIGN_RULES §3 Table 收口**

`docs/CLAUDE_DESIGN_RULES.md` §3「组件（用库,不自搓）」的 Table bullet 扩为：数据表格用 Table；空态走 `#empty` 槽、加载/选择/排序用 `loading`/`selectedKeys`+`rowKey`/列 `sortOrder`，别手搓 loading spinner/勾选列/排序箭头（APID-01 code-first, divergence `table-presentational-props-code-first-2026-07-22`）。

- [ ] **Step 5: WIP commit**

```bash
git add src/design-system/translation/divergences-decisions.json docs/internal/component-affordances.json docs/internal/component-affordances.md docs/CLAUDE_DESIGN_RULES.md figma-data/page-recipes.json docs/PROJECT_MAP.md 2>/dev/null
git commit --no-verify -m "WIP(table): divergence + affordances + design-rules + machine-readable recipe"
```

---

### Task 5: docs 状态段（Vue）+ React 手写镜像 demo + slot-boolean/parity 闸

**Files:**
- Modify: `playground/docs/pages/TablePage.vue`（新增状态段）
- Modify: `react-pilot/src/demos/Table.tsx`（手写镜像，段标题与 Vue 一致）
- Modify（可选样式）: `react-pilot/src/demos/table-demo.css`

**Interfaces:**
- Consumes: Task 1-3 的 props + `#empty` slot
- 段标题（English，两侧 1:1，供 `audit:demo-framework-parity`）建议：`Empty state` · `Loading state` · `Row selection` · `Sorted column` · `Table + Pagination`

- [ ] **Step 1: Vue docs 状态段**

`TablePage.vue` `FrameworkDemoRegion` 内新增 5 段，静态展示各状态；其中 **`loading` 用 boolean live 切换（Switch 绑 ref）**、**`#empty` slot 投影自定义内容**（满足硬规则 #8 demo-slot-boolean）。Row selection 段用固定 `:selectedKeys` + `rowKey` 展示高亮。Sorted 段用列 `sortOrder` 展示指示。Table+Pagination 段展示推荐布局（表格 + 下方 `Pagination`）。

- [ ] **Step 2: React 镜像**

`react-pilot/src/demos/Table.tsx` 对应新增同标题 5 段，`loading` 用 `useState` live 切换、`#empty` 投影内容（React children/slot 承载），与 Vue 侧行为对等。

- [ ] **Step 3: 跑 slot-boolean + parity 闸**

Run: `pnpm run audit:demo-slot-boolean && pnpm run audit:demo-framework-parity`
Expected: 两者 PASS（`#empty` 投影 + `loading` live 双框架；段标题 parity）。

- [ ] **Step 4: WIP commit**

```bash
git add playground/docs/pages/TablePage.vue react-pilot/src/demos/Table.tsx react-pilot/src/demos/table-demo.css 2>/dev/null
git commit --no-verify -m "WIP(table): docs state sections + React mirror demo"
```

---

### Task 6: 视觉门 + 全闸复核 + 原子 squash commit + changeset

- [ ] **Step 1: 全量本地闸复核**

Run（按序）: `pnpm build:wc && pnpm gen:react-bindings && pnpm generate:component-affordances && pnpm audit:binding-config-parity && pnpm run audit:prop-naming && pnpm run audit:component-affordances && pnpm run audit:demo-slot-boolean && pnpm run audit:demo-framework-parity && pnpm test && pnpm exec vue-tsc --noEmit`
Expected: 全 PASS。任一红 → 修到绿，不 `--no-verify` 掩盖真闸。

- [ ] **Step 2: 起 docs 站 + Playwright 截各状态真机图**

起 docs dev server（先 `lsof` 核实无并发同 config server 污染 .vite 缓存，见 memory `reference_docs-dev-server-quirks`），Playwright 截 Empty / Loading / Row selection / Sorted / Table+Pagination 五状态 Vue+React 双框架真机图，落项目内 scratch 路径。核实产物真实存在（`ls`/`file`）。

- [ ] **Step 3: 交 owner 视觉审 → 拿 `VISUAL_COMMIT_APPROVED`**

把截图交 owner；owner ack 后才继续。executor 不自 commit。

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

Create `.changeset/table-presentational-props.md`（内容见 spec §N，`minor`）。

- [ ] **Step 5: 挤压成单一原子 commit**

```bash
# 把 Task1-5 的 WIP + changeset 挤成一个
git reset --soft <Task1 WIP 之前的 HEAD, 即 90514185 之后的基点>
git add -A
VISUAL_COMMIT_APPROVED=1 git commit -F <commit-msg 文件>
```
commit message 描述 additive 呈现层 + code-first divergence + 机读 recipe；带 Co-Authored-By trailer。pre-commit 全闸此时真实跑一遍（含视觉门放行）。

- [ ] **Step 6: 验证 commit 落地**

Run: `git log --oneline -1 && git status --short`
Expected: 单一 feat commit，clean tree。

---

### Task 7: 收尾（backlog/STATUS/push）

- [ ] **Step 1: backlog 标 APID-01 shipped**

`docs/internal/backlog.md` L1 块 APID-01 条目从 `in-flight` 改 `✅ shipped 2026-07-22`（并修正早先"走 divergence + canonical-exempt"一句为"仅 divergence，Table 保留 render-verif 覆盖"）。

- [ ] **Step 2: STATUS 更新**

`docs/STATUS.md` 顶部 "Last updated" 改今天 + 当日摘要（旧摘要 prepend 进 STATUS-CHANGELOG.md）。⚠️ 若并行 session 仍占 STATUS dirty，协调后再改，避免冲突。

- [ ] **Step 3: docs commit**

```bash
git add docs/internal/backlog.md docs/STATUS.md
git commit -m "docs(status): APID-01 Table state-visual spec shipped"
```

- [ ] **Step 4: FF push master（owner fast-lane）+ 双 remote 核实**

确认 worktree 分支基于当前 origin/master（`git fetch origin master && git log HEAD..origin/master` 应空或可 FF）→ `git push origin HEAD:master`（+ github mirror 若配）。
Run: `git ls-remote origin master` 双 remote 核实 ref 已更新（GitHub 瞬时 remote-rejected = 假警，以 ls-remote 为准）。

- [ ] **Step 5: Claude Design 文件包更新（若 CLAUDE_DESIGN_RULES 变）**

Run: `pnpm build && pnpm export:claude-design-bundle`（产出可上传 zip；只报变更散文件）。收尾 `git checkout HEAD -- playground-dist react-pilot/dist && git clean -fdq` 还原构建产物 churn。

---

## Self-Review

**Spec coverage**：spec §3.1 呈现层 props → Task 1-3；§3.2 docs 状态页 → Task 5；§3.3 机读 recipe → Task 4 Step 3；§2 divergence/不进 canonical-exempt → Task 4 Step 1；接线清单 §7 → Task 2/4/5 逐项；视觉门 → Task 6。全覆盖。
**Placeholder scan**：Task 1 Step 4 的选中底色 token 与 Task 3 locale import 路径标了"执行时 grep 确认"——这是 codebase 探测点非占位（给了确切 grep 命令）；Task 4 Step 3 recipe 落点二选一给了明确判定命令 + 两条落地路径。无 TBD/TODO。
**Type consistency**：`loading:boolean` / `rowKey:string` / `selectedKeys:(string|number)[]` / 列 `sortOrder:'asc'|'desc'|null` 在 Task1(SFC)、Task2(config)、Task4(affordances)、spec §K(divergence) 全一致；`tableEmpty` key 在 Task3 interface+defaultLocale+base 消费一致；CSS class `.tbl-empty`/`.tbl-loading`/`.tbl-row--selected`/`.tbl-sort-indicator` 在 Task1 测试与实现一致。
