# INFRA-F45 — Slot+Boolean Demo Enforcement 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:** 把硬规则 #8（组件 demo 必演示 slot 投影 + boolean live 切换，Vue+React 双侧）从 L1 升到 L4 gate——全量回填 + 确定性静态 audit + pre-commit gate。

**Architecture:** 先写 report-only audit（从 `components.config.ts` 派生 in-scope 清单，用 `@vue/compiler-sfc` 解析 Vue 页、`typescript` compiler 解析 React `App.tsx`），跑出确定性红名单当 baseline。再按红名单回填 React 单文件 + Vue 各页（复用统一 Interactive-props 控件范式），重建 committed `react-pilot/dist`。全绿后把 audit 翻成 blocking + 挂 pre-commit 条件 gate。

**Tech Stack:** Node ≥22（native TS type-stripping，import `.ts` config 直接用）、`@vue/compiler-sfc`（Vue SFC parse）、`typescript`（TSX JSX AST）、vitest（audit 单测）、Vue 3.5 canonical + React wrappers（既有，不改）。

**设计真源：** [`docs/superpowers/specs/2026-07-03-infra-f45-slot-boolean-demo-enforcement-design.md`](../specs/2026-07-03-infra-f45-slot-boolean-demo-enforcement-design.md)

## Global Constraints

- **不改任何 canonical/base SFC 的 props/slots**——本 sprint 只改 demo（`react-pilot/src/App.tsx` + `playground/docs/pages/*.vue`）+ 新增 audit + hook。例外：若回填暴露某 React wrapper 未透传具名 slot（TopBar `menu` 疑似），才触发 `pnpm build:wc` + 重生成 bindings，且另记（不静默扩 scope）。
- **In-scope SoT = `src/web-components/components.config.ts`**：组件 in-scope ⟺ `hasDefaultSlot===true` ∨ `namedSlots.length>0` ∨ 存在 `props[].tsType.trim()==='boolean'`。audit 运行时派生，禁写死清单。
- **当前 in-scope = 28 个**（排除既无 slot 又无 boolean 的 7 个：Switch/Pagination/DropDownListSelect/Chart/Logo/MenuList/UserMenu）。
- **Demo 标准 A1（严格字面，两侧各要）**：有 slot → 投影真实非空内容；有 boolean → ≥1 个 boolean prop 的 **live 切换**（绑到可写的 ref/useState 变量 + 有翻转它的控件），非静态字面量。
- **统一 Interactive-props 控件范式**（UX 硬约束，见 Task 2）：控件用 DS `Switch`/`CheckBox`，带标签+当前值可见；Vue 标签走 `t(en,zh)` 双语，React 英文即可；固定位置。
- **React=证明面 / Vue=主 docs**：React pilot 是单文件 gallery 回归证明面，toggle 做到"证明绑定活着"即可；UX 打磨投 Vue 页。
- **audit report-only → blocking 分两 commit**：回填全绿前 audit 恒 exit 0（打印 FAIL 不阻塞）；全绿实测后单独 commit 翻 blocking + 挂 hook。
- **Node ≥22 必须**（audit import `.ts`）；`.mjs` 头部照 `scripts/audit-binding-config-parity.mjs` 加 Node 版本 guard。
- **Commit 纪律**：owner 直 commit master + 三端 push（`git push origin master` 同推 Gitea+GitHub）；React demo 视觉改动 commit 需 `VISUAL_COMMIT_APPROVED=1`；用 `git commit -F <file>`（本机 shell quirk）。executor 不自 commit——由 plan owner + owner ack 后 commit。
- **per-component boolean toggle prop 选"视觉差异最明显"的**（Slider=`showValue`、Tooltip=`open`、Progress=`showLabel`……见 Task 4 param 表）；audit 只要求"该组件 ≥1 boolean live 绑定"，不锁具体哪个。

---

## File Structure

- **Create** `scripts/audit-demo-slot-boolean-coverage.mjs` — 确定性 audit（派生 in-scope + 解析双侧 demo + 断言 + 报表 + exit）。
- **Create** `tests/audit-demo-slot-boolean.test.ts` — audit 检测函数的 fixture 单测（TDD）。
- **Modify** `package.json` — 加 `"audit:demo-slot-boolean"` script + 纳入 `audit:self-audit-phase2` 链（若合适）。
- **Modify** `react-pilot/src/App.tsx` — React 侧回填（slot + live toggle）。
- **Rebuild** `react-pilot/dist/**`（git-tracked 部署实物）— `pnpm build:react-pilot`。
- **Modify** `playground/docs/pages/*.vue`（约 18 页）— Vue 侧回填。
- **Modify** `.husky/pre-commit` — 加 F45 条件 gate。
- **Modify** `docs/STATUS.md` + `docs/internal/backlog.md` — 收口（F45 移出 Active）。

---

## Task 1: Audit 检测函数 + 单测（report-only 核心）

**Files:**
- Create: `scripts/audit-demo-slot-boolean-coverage.mjs`
- Test: `tests/audit-demo-slot-boolean.test.ts`
- Modify: `package.json`（scripts 段）

**Interfaces:**
- Produces:
  - `deriveInScope(configs)` → `Array<{ name, canonicalImport, hasSlot, namedSlots: string[], hasDefaultSlot: boolean, boolProps: string[] }>`（仅 in-scope）
  - `checkReactDemo(sourceText, comp)` → `{ used, slotOk, boolLiveOk }`
  - `checkVueDemo(sfcText, comp)` → `{ used, slotOk, boolLiveOk }`
  - default: CLI 跑全量、打印表、`REPORT_ONLY` 常量控 exit。

- [ ] **Step 1: 写检测函数的失败单测**

Create `tests/audit-demo-slot-boolean.test.ts`：

```ts
import { describe, it, expect } from 'vitest'
import { checkReactDemo, checkVueDemo, deriveInScope } from '../scripts/audit-demo-slot-boolean-coverage.mjs'

const POPUPBOX = { name: 'PopupBox', canonicalImport: 'PopupBox', hasDefaultSlot: true, namedSlots: ['footer'], hasSlot: true, boolProps: ['closable', 'showFooter'] }
const NOTIF = { name: 'Notification', canonicalImport: 'Notification', hasDefaultSlot: false, namedSlots: [], hasSlot: false, boolProps: ['closable'] }
const BUTTON = { name: 'Button', canonicalImport: 'ButtonBridge', hasDefaultSlot: true, namedSlots: [], hasSlot: true, boolProps: [] }

describe('deriveInScope', () => {
  it('includes slot-only and boolean-only, excludes neither', () => {
    const cfgs = [
      { name: 'Button', canonicalImport: 'ButtonBridge', hasDefaultSlot: true, namedSlots: [], props: [{ name: 'size', tsType: `'M'|'L'` }] },
      { name: 'Notification', canonicalImport: 'Notification', hasDefaultSlot: false, namedSlots: [], props: [{ name: 'closable', tsType: 'boolean' }] },
      { name: 'Switch', canonicalImport: 'Switch', hasDefaultSlot: false, namedSlots: [], props: [{ name: 'enable', tsType: `'yes'|'no'` }] },
    ]
    const inScope = deriveInScope(cfgs).map(c => c.name)
    expect(inScope).toContain('Button')
    expect(inScope).toContain('Notification')
    expect(inScope).not.toContain('Switch')
  })
})

describe('checkReactDemo', () => {
  it('PASS: boolean bound to writable state + slot children', () => {
    const src = `
      function App(){ const [c,setC]=useState(true); const onx=()=>setC(v=>!v)
        return <><CheckBox onStatusChange={onx}/><PopupBox closable={c}><FormItem label="x"/></PopupBox></> }`
    const r = checkReactDemo(src, POPUPBOX)
    expect(r).toEqual({ used: true, slotOk: true, boolLiveOk: true })
  })
  it('FAIL: static boolean literal (closable) is not live', () => {
    const src = `function App(){ return <Notification closable/> }`
    const r = checkReactDemo(src, NOTIF)
    expect(r.used).toBe(true)
    expect(r.boolLiveOk).toBe(false)
  })
  it('slot N/A component (no slot) reports slotOk true', () => {
    const src = `function App(){ const [c,setC]=useState(true); return <Notification closable={c}/>; setC }`
    expect(checkReactDemo(src, NOTIF).slotOk).toBe(true)
  })
  it('boolean N/A component (Button) reports boolLiveOk true when slot present', () => {
    const src = `function App(){ return <Button size="M">Save</Button> }`
    expect(checkReactDemo(src, BUTTON)).toEqual({ used: true, slotOk: true, boolLiveOk: true })
  })
})

describe('checkVueDemo', () => {
  it('PASS: :closable bound to writable ref + default slot', () => {
    const sfc = `<script setup>import {ref} from 'vue'; const shut=ref(true); function f(){shut.value=!shut.value}</script>
      <template><Switch @click="f"/><PopupBox :closable="shut"><div>body</div></PopupBox></template>`
    expect(checkVueDemo(sfc, POPUPBOX)).toEqual({ used: true, slotOk: true, boolLiveOk: true })
  })
  it('FAIL: :closable="true" literal is not live', () => {
    const sfc = `<script setup></script><template><Notification :closable="true"/></template>`
    expect(checkVueDemo(sfc, NOTIF).boolLiveOk).toBe(false)
  })
})
```

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

Run: `pnpm vitest run tests/audit-demo-slot-boolean.test.ts`
Expected: FAIL —— cannot import from non-existent `scripts/audit-demo-slot-boolean-coverage.mjs`。

- [ ] **Step 3: 写 audit 脚本（in-scope 派生 + 双侧解析器 + 报表）**

Create `scripts/audit-demo-slot-boolean-coverage.mjs`。头部照 `audit-binding-config-parity.mjs` 加 shebang + Node≥22 guard，然后：

```js
#!/usr/bin/env node
// scripts/audit-demo-slot-boolean-coverage.mjs
// INFRA-F45 — 确定性断言每个 in-scope 组件在 React(App.tsx) + Vue(playground pages)
// 两侧 demo 都演示了 slot 投影 + boolean live 切换。in-scope 从 components.config.ts 派生。
// Exit: REPORT_ONLY=true → 恒 0（打印 FAIL 不阻塞，回填期用）; false → 任一 FAIL exit 1。
import { readFileSync, existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import ts from 'typescript'
import { parse as parseSfc } from '@vue/compiler-sfc'

const nodeMajor = parseInt(process.versions.node.split('.')[0], 10)
if (nodeMajor < 22) { console.error('[audit-demo-slot-boolean] requires Node >= 22'); process.exit(1) }

import { COMPONENT_CONFIGS } from '../src/web-components/components.config.ts'

const __dirname = dirname(fileURLToPath(import.meta.url))
const REACT_APP = resolve(__dirname, '..', 'react-pilot', 'src', 'App.tsx')
const VUE_PAGES_DIR = resolve(__dirname, '..', 'playground', 'docs', 'pages')

// Report-only until backfill complete (Global Constraints). CLI flag override.
export const REPORT_ONLY = !process.argv.includes('--blocking')

// ---- in-scope 派生 ----
export function deriveInScope(configs) {
  return configs.map(c => {
    const namedSlots = c.namedSlots || []
    const hasDefaultSlot = !!c.hasDefaultSlot
    const hasSlot = hasDefaultSlot || namedSlots.length > 0
    const boolProps = (c.props || []).filter(p => String(p.tsType).trim() === 'boolean').map(p => p.name)
    return { name: c.name, canonicalImport: c.canonicalImport, hasDefaultSlot, namedSlots, hasSlot, boolProps }
  }).filter(c => c.hasSlot || c.boolProps.length > 0)
}

// kebab-case helper (showLabel -> show-label) for Vue attr matching.
const kebab = s => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()

// ---- React (TSX) 解析 ----
export function checkReactDemo(sourceText, comp) {
  const sf = ts.createSourceFile('App.tsx', sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
  // useState 变量名集合（有 setter → 视为可写 live 源）
  const stateVars = new Set()
  const walkState = n => {
    if (ts.isVariableDeclaration(n) && n.initializer && ts.isCallExpression(n.initializer) &&
        n.initializer.expression.getText(sf) === 'useState' && ts.isArrayBindingPattern(n.name)) {
      const first = n.name.elements[0]
      if (first && ts.isBindingElement(first) && ts.isIdentifier(first.name)) stateVars.add(first.name.getText(sf))
    }
    ts.forEachChild(n, walkState)
  }
  walkState(sf)

  let used = false, slotOk = !comp.hasSlot, boolLiveOk = comp.boolProps.length === 0
  const boolSet = new Set(comp.boolProps)
  const walkJsx = n => {
    const isEl = ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n)
    if (isEl) {
      const open = ts.isJsxElement(n) ? n.openingElement : n
      if (open.tagName.getText(sf) === comp.name) {
        used = true
        // slot: JsxElement with non-whitespace children, OR named-slot props (React prop for a named slot)
        if (comp.hasSlot) {
          if (ts.isJsxElement(n) && n.children.some(ch => !(ts.isJsxText(ch) && ch.text.trim() === ''))) slotOk = true
          // named slot props (camelCased) present with a value → also counts
          const namedProps = comp.namedSlots.map(s => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()))
          for (const attr of open.attributes.properties) {
            if (ts.isJsxAttribute(attr) && namedProps.includes(attr.name.getText(sf)) && attr.initializer) slotOk = true
          }
        }
        // boolean live: attr whose name ∈ boolProps AND value is {identifier} where identifier ∈ stateVars
        for (const attr of open.attributes.properties) {
          if (!ts.isJsxAttribute(attr)) continue
          const an = attr.name.getText(sf)
          if (!boolSet.has(an)) continue
          const init = attr.initializer
          if (init && ts.isJsxExpression(init) && init.expression && ts.isIdentifier(init.expression) &&
              stateVars.has(init.expression.getText(sf))) boolLiveOk = true
          // {someState ? 'x':'y'} style also acceptable if it references a state var
          else if (init && ts.isJsxExpression(init) && init.expression &&
                   [...stateVars].some(v => init.expression.getText(sf).includes(v))) boolLiveOk = true
        }
      }
    }
    ts.forEachChild(n, walkJsx)
  }
  walkJsx(sf)
  return { used, slotOk, boolLiveOk }
}

// ---- Vue (SFC) 解析 ----
export function checkVueDemo(sfcText, comp) {
  const { descriptor } = parseSfc(sfcText)
  const tpl = descriptor.template ? descriptor.template.content : ''
  const script = (descriptor.scriptSetup ? descriptor.scriptSetup.content : '') + (descriptor.script ? descriptor.script.content : '')
  const tag = comp.canonicalImport // Vue pages import canonical name
  const usedRe = new RegExp(`<${tag}[\\s>/]`)
  const used = usedRe.test(tpl)
  let slotOk = !comp.hasSlot, boolLiveOk = comp.boolProps.length === 0
  if (!used) return { used, slotOk, boolLiveOk }

  // ref names declared in script setup
  const refNames = new Set()
  for (const m of script.matchAll(/const\s+([A-Za-z0-9_]+)\s*=\s*(?:ref|computed|reactive)\s*\(/g)) refNames.add(m[1])
  // reactive object props: `const settings = ref({...})` → settings.foo also live; keep the base name.

  // Scan each occurrence of the component's opening tag.
  const tagBlockRe = new RegExp(`<${tag}\\b([\\s\\S]*?)/?>`, 'g')
  for (const m of tpl.matchAll(tagBlockRe)) {
    const attrs = m[1]
    // slot: element is NOT self-closing → has children between <tag ...> and </tag>
    if (comp.hasSlot) {
      const fullRe = new RegExp(`<${tag}\\b[\\s\\S]*?>([\\s\\S]*?)</${tag}>`)
      const fm = tpl.match(fullRe)
      if (fm && fm[1].trim().length > 0) slotOk = true
      // named slot: <template #name> anywhere inside; or named-slot via child template
      if (comp.namedSlots.some(s => new RegExp(`#${s}\\b|v-slot:${s}\\b`).test(tpl))) slotOk = true
    }
    // boolean live: :prop="ident" (camel or kebab) where ident (or its base before .) ∈ refNames, and NOT a literal
    for (const bp of comp.boolProps) {
      const names = [bp, kebab(bp)]
      for (const nm of names) {
        const bindRe = new RegExp(`:${nm}="([^"]+)"`, 'g')
        for (const bm of attrs.matchAll(bindRe)) {
          const expr = bm[1].trim()
          if (expr === 'true' || expr === 'false') continue
          const base = expr.split('.')[0].replace(/[^A-Za-z0-9_].*$/, '')
          if (refNames.has(base)) boolLiveOk = true
        }
      }
    }
  }
  return { used, slotOk, boolLiveOk }
}

// ---- CLI ----
function main() {
  const inScope = deriveInScope(COMPONENT_CONFIGS)
  const reactSrc = readFileSync(REACT_APP, 'utf8')
  // component -> which page file(s) to scan (fallback: scan all pages)
  const allPages = existsSync(VUE_PAGES_DIR)
    ? readFileSync // placeholder — replaced below
    : null
  const fs = require('node:fs')
  const pageFiles = fs.readdirSync(VUE_PAGES_DIR).filter(f => f.endsWith('.vue'))
  const pageTexts = pageFiles.map(f => readFileSync(resolve(VUE_PAGES_DIR, f), 'utf8'))

  const rows = []
  for (const comp of inScope) {
    const react = checkReactDemo(reactSrc, comp)
    // Vue: try each page until the component is found used
    let vue = { used: false, slotOk: !comp.hasSlot, boolLiveOk: comp.boolProps.length === 0 }
    for (const txt of pageTexts) {
      const r = checkVueDemo(txt, comp)
      if (r.used) { vue = r; break }
    }
    rows.push({ comp, react, vue })
  }

  let fails = 0
  const fail = (side, r, comp) => {
    const probs = []
    if (!r.used) probs.push('not-used')
    else {
      if (comp.hasSlot && !r.slotOk) probs.push('missing-slot')
      if (comp.boolProps.length && !r.boolLiveOk) probs.push('missing-boolean-live')
    }
    return probs
  }
  console.log('# INFRA-F45 slot+boolean demo coverage\n')
  console.log('component'.padEnd(18), 'React'.padEnd(28), 'Vue')
  for (const { comp, react, vue } of rows) {
    const rp = fail('react', react, comp), vp = fail('vue', vue, comp)
    if (rp.length) fails++
    if (vp.length) fails++
    const fmt = p => p.length ? `FAIL(${p.join(',')})` : 'PASS'
    console.log(comp.name.padEnd(18), fmt(rp).padEnd(28), fmt(vp))
  }
  console.log(`\nin-scope=${rows.length}  side-failures=${fails}  mode=${REPORT_ONLY ? 'REPORT-ONLY' : 'BLOCKING'}`)
  if (!REPORT_ONLY && fails > 0) process.exit(1)
}

// run only as CLI, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) main()
```

> **实现注意**：`checkReactDemo`/`checkVueDemo` 是纯函数（导出给单测）；main() 里 `require('node:fs')` 那段替换成顶部已 import 的 `readFileSync`/`readdirSync`（把 `readdirSync` 加进顶部 import）。上面 CLI 段有个占位 `allPages` 变量删掉——正式用 `readdirSync`。executor 清理这些、让 `pnpm vitest run tests/audit-demo-slot-boolean.test.ts` 全绿。

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

Run: `pnpm vitest run tests/audit-demo-slot-boolean.test.ts`
Expected: PASS（8+ 断言全绿）。若某断言不过，修检测函数而非改断言。

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

在 `package.json` scripts 段加：
```json
"audit:demo-slot-boolean": "node scripts/audit-demo-slot-boolean-coverage.mjs",
```

- [ ] **Step 6: 跑全量 audit 产出 baseline 红名单**

Run: `pnpm audit:demo-slot-boolean`
Expected: exit 0（REPORT-ONLY），打印 28 行表；预期大量 `FAIL(missing-boolean-live)`（~20 组件双侧）+ TopBar/Badge slot 相关 FAIL。**把这份输出存为 baseline 红名单**（贴进 commit message 或 scratchpad），后续回填逐行转绿。

- [ ] **Step 7: Commit**

```bash
git add scripts/audit-demo-slot-boolean-coverage.mjs tests/audit-demo-slot-boolean.test.ts package.json
git commit -F <msg-file>   # feat(audit): INFRA-F45 slot+boolean demo coverage audit (report-only) + unit tests
git push origin master
```
msg body 附 baseline 红名单。

---

## Task 2: 定义统一 Interactive-props 控件范式（回填模板）

**Files:** 无代码改动——本 task 产出后续所有回填 task 复用的模板片段（写进本 plan 供引用，避免各页发明不同写法）。

**Interfaces:** Produces —— Vue 模板片段 `INTERACTIVE_VUE` + React 片段 `INTERACTIVE_REACT`（下方），Task 3/4 引用。

- [ ] **Step 1: 锁定 React 片段模板（App.tsx 内每个需 boolean 的 Card 套用）**

```tsx
// 在 App() 顶部按组件加一个 state：const [xReadonly, setXReadonly] = useState(false)
// Card 内：demo 控件 + 组件本体绑 state
<Card label="Rating (readonly toggle)">
  <label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
    <CheckBox darkTheme={theme === 'dark' ? 'on' : 'off'} enable="yes"
      status={ratingReadonly ? 'on' : 'off'} onStatusChange={(s) => setRatingReadonly(s === 'on')}>readonly</CheckBox>
  </label>
  <Rating value={'3'} readonly={ratingReadonly} />
</Card>
```
要点：`status={bool ? 'on':'off'}` + `onStatusChange` 翻转 state；组件 prop 绑 `{state}`。slot 组件在 children 放真实内容。

- [ ] **Step 2: 锁定 Vue 片段模板（各 *Page.vue 内套用）**

```vue
<!-- script setup: 每个 boolean 一个 ref -->
const ratingReadonly = ref(false)

<!-- template: Interactive-props 控件条 + 组件本体 -->
<div class="sb-controls">
  <label class="sb-control">
    <Switch :dark-theme="fieldDarkTheme" :status="ratingReadonly ? 'on' : 'off'"
      @update:status="(s) => (ratingReadonly = s === 'on')" enable="yes" loading="no" />
    <span>{{ t('readonly', '只读') }}: {{ ratingReadonly }}</span>
  </label>
</div>
<Rating :readonly="ratingReadonly" v-model="ratingValue" />
```
`fieldDarkTheme` = `computed(() => docsTheme.value==='dark' ? 'on':'off')`（多数页已有；无则加）。slot 组件在标签间放真实内容 / `<template #name>`。

- [ ] **Step 3: 锁定统一 `.sb-controls` 样式（各 Vue 页 `<style scoped>` 复用同定义）**

```css
.sb-controls { display: flex; flex-wrap: wrap; gap: var(--sp-s); margin-bottom: var(--sp-m); }
.sb-control { display: inline-flex; align-items: center; gap: var(--sp-xs); font: var(--text-style-tips); color: var(--text-tips); }
```

（本 task 无 commit——模板随 Task 3/4 落地。）

---

## Task 3: React App.tsx 全量回填 + 重建 dist

**Files:**
- Modify: `react-pilot/src/App.tsx`
- Rebuild: `react-pilot/dist/**`

**Interfaces:** Consumes —— Task 2 `INTERACTIVE_REACT` 模板；Task 1 audit（判定转绿）。

- [ ] **Step 1: 按 baseline 红名单逐组件加 state + toggle + slot**

对每个 React 侧 FAIL 的 in-scope 组件，在 `App()` 加 `useState` + 在其 Card 套 Task 2 Step1 模板。参数表（组件 → 选定 boolean → slot 内容）：

| 组件 | boolean toggle | slot 内容（有 slot 才需）|
|---|---|---|
| Input | readonly | — |
| FormItem | required | 默认 slot 放 `<InputBoxFilled/>`（已在 PopupBox 段有范例）|
| PillStatus | active | 默认 slot 放文本 / `#count` |
| Progress | showLabel | — |
| Rating | readonly | — |
| BreadcrumbItem | showSeparator | children 文本（已有）|
| TopBar | showMenu | **具名 slot `menu`** 放真实内容（见 Step 3 风险）|
| CheckBox | readonly | children label（已有）|
| InputBoxFilled | readonly | — |
| InputNumber | disabled | — |
| Slider | showValue | — |
| TabItem | disabled | children label |
| StepItem | showLeadingConnector | — |
| SelectBoxLine | multiple | — |
| SelectBoxFilled | multiple | — |
| Tooltip | open | 默认 slot trigger（已有）+ `#content` |
| Notification | closable | — |
| Message | closable | — |
| Table | striped | — |
| Badge | —（无 boolean）| **children 放真实文本**（当前缺）|

> Badge：给一个 Card `<Badge type="Rectangle" color="Blue" tag="Filled">Live</Badge>` 演示 slot 投影（覆盖 prop 兜底内容）。

- [ ] **Step 2: 本地跑 audit 看 React 列转绿**

Run: `pnpm audit:demo-slot-boolean`
Expected: React 列全部 `PASS`（Vue 列仍多 FAIL——下个 task 处理）。若某 React 行仍 FAIL，按原因（missing-boolean-live=没绑 state / missing-slot=没 children）修。

- [ ] **Step 3: 处理 TopBar 具名 slot 风险（若 wrapper 不透传则触发 build:wc）**

TopBar React demo 加 `menu` 具名 slot 内容（`menu={<MenuList .../>}` 或 `<div slot="menu">…`）。跑 audit + 本地起 `react-pilot` 目视：若菜单仍空 → React wrapper 未透传具名 slot（Global Constraints 例外）：
```bash
pnpm build:wc            # 重生成 wrapper（含 named-slot props）
```
确认 `react-pilot/src/wrappers/TopBar.tsx` 有 `menu` prop 后再回填。**若触发 build:wc，另记一行到 backlog（wrapper 缺具名 slot 已修）。**

- [ ] **Step 4: 重建 committed dist**

Run: `pnpm build:react-pilot`
Expected: 成功，`react-pilot/dist/**` 更新。`git status` 应见 dist 变更。

- [ ] **Step 5: 目视核实（截图）**

起静态服务截图（照本 session subagent 手法：`python3 -m http.server` on `react-pilot/dist` + playwright），确认新 toggle 可见、TopBar menu 有内容、PopupBox 不回归。截图存 scratchpad + `Read` 核实。

- [ ] **Step 6: Commit（视觉改动需 approval flag）**

```bash
git add react-pilot/src/App.tsx react-pilot/dist
VISUAL_COMMIT_APPROVED=1 git commit -F <msg-file>   # feat(react-pilot): INFRA-F45 slot+boolean live demos + rebuild dist
git push origin master
```

---

## Task 4: Vue playground 各页全量回填（可并行）

> **执行方式**：各 `*Page.vue` 相互独立 → subagent-driven 每页/每组一个 subagent 并行；主线复审 diff。每个子任务套 Task 2 模板，收尾跑 audit 看对应组件 Vue 列转绿。**executor 不自 commit**，报 diff 给主线。

**Files（按页分组，每组一个子任务）：**
- Modify: `playground/docs/pages/InputPage.vue`（Input + InputBoxFilled：readonly）
- Modify: `playground/docs/pages/FormItemPage.vue`（FormItem：required + slot）
- Modify: `playground/docs/pages/PillPage.vue`（PillStatus：active + slot）
- Modify: `playground/docs/pages/ProgressPage.vue`（Progress：showLabel）
- Modify: `playground/docs/pages/RatingPage.vue`（Rating：readonly）
- Modify: `playground/docs/pages/BreadcrumbPage.vue`（BreadcrumbItem：showSeparator + slot）
- Modify: `playground/docs/pages/TopBarPage.vue`（TopBar：showMenu + 具名 slot menu）
- Modify: `playground/docs/pages/CheckboxPage.vue`（CheckBox：readonly）
- Modify: `playground/docs/pages/InputNumberPage.vue`（InputNumber：disabled）
- Modify: `playground/docs/pages/SliderPage.vue`（Slider：showValue）
- Modify: `playground/docs/pages/TabsPage.vue`（TabItem：disabled）
- Modify: `playground/docs/pages/StepsPage.vue`（StepItem：showLeadingConnector）
- Modify: `playground/docs/pages/SelectPage.vue`（SelectBoxLine/Filled：multiple）
- Modify: `playground/docs/pages/TooltipPage.vue`（Tooltip：open）
- Modify: `playground/docs/pages/NotificationPage.vue`（Notification：closable）
- Modify: `playground/docs/pages/MessagePage.vue`（Message：closable）
- Modify: `playground/docs/pages/TablePage.vue`（Table：striped）
- Modify: `playground/docs/pages/PopupBoxPage.vue`（PopupBox：**补 closable live 切换** — exemplar Vue 缺口）
- Modify: `playground/docs/pages/BadgePage.vue`（Badge：slot 投影真文本）

> slot-only 无 boolean 的组件（Button/Radio/PillCounter/Breadcrumb/Tab/TabList/Steps）baseline 已 slot DONE → 通常无需改；audit 若报 FAIL 才补 slot children。

**Interfaces:** Consumes —— Task 2 `INTERACTIVE_VUE` + `.sb-controls` 样式；audit 判定。

- [ ] **Step 1（每子任务）: 该页加 boolean ref + Interactive 控件条 + 确保 slot 真内容**

套 Task 2 Step2/Step3 模板。boolean prop 用上表选定项。若组件用 `v-model`（如 Rating/Select/Input），保留 v-model，另加 boolean ref + 控件。TopBarPage `#menu` 放真实内容（如 MenuList / 链接组）。BadgePage 给一处 `<Badge>Live</Badge>` 投影文本。PopupBoxPage 给现有 `<PopupBox>` 加 `:closable="popupClosable"` + 控件条。

- [ ] **Step 2（每子任务）: 跑 audit 看该组件 Vue 列转绿**

Run: `pnpm audit:demo-slot-boolean`
Expected: 该组件 Vue 列 `PASS`。

- [ ] **Step 3（每子任务）: 跑 vue-tsc + 相关页测试**

Run: `pnpm exec vue-tsc --noEmit`（不新增类型错）；`pnpm vitest run`（page smoke 测试不回归，如 `FormControlPages.test.ts`/`StateControlPages.test.ts`）。
Expected: PASS。

- [ ] **Step 4: 全部页回填后 主线复审所有 diff + 跑全量 audit（仍 report-only）**

Run: `pnpm audit:demo-slot-boolean`
Expected: 28 行 **双侧全 PASS**，`side-failures=0`。若有残留 FAIL，定位补齐。

- [ ] **Step 5: Commit（Vue 页视觉改动需 approval flag）**

```bash
git add playground/docs/pages/*.vue
VISUAL_COMMIT_APPROVED=1 git commit -F <msg-file>   # feat(docs): INFRA-F45 slot+boolean live demos across component pages
git push origin master
```

---

## Task 5: 升 gate（audit → blocking + pre-commit 挂钩）

**Files:**
- Modify: `scripts/audit-demo-slot-boolean-coverage.mjs`（默认转 blocking）
- Modify: `.husky/pre-commit`
- Modify: `package.json`（若纳入 self-audit 链）

**Interfaces:** Consumes —— Task 4 全绿前提。

- [ ] **Step 1: audit 默认改 blocking**

改 `REPORT_ONLY`：默认 blocking，保留 `--report-only` opt-out：
```js
export const REPORT_ONLY = process.argv.includes('--report-only')
```
更新头部注释 + package.json script（可保留 `audit:demo-slot-boolean` 为 blocking）。

- [ ] **Step 2: 确认全量 audit blocking 下 exit 0**

Run: `pnpm audit:demo-slot-boolean`
Expected: 全 PASS，`mode=BLOCKING`，exit 0（`echo $?` = 0）。

- [ ] **Step 3: 挂 pre-commit 条件 gate**

在 `.husky/pre-commit` 末尾（其它条件 gate 段之后）加：
```bash
echo "▶ pre-commit: demo slot+boolean coverage (INFRA-F45 硬规则 #8)"
# Run when the demo surfaces or the in-scope SoT / audit itself changes.
if git diff --cached --name-only --diff-filter=AM | grep -qE '(react-pilot/src/App\.tsx|playground/docs/pages/.*\.vue|src/web-components/components\.config\.ts|scripts/audit-demo-slot-boolean-coverage\.mjs)'; then
  node scripts/audit-demo-slot-boolean-coverage.mjs
else
  echo "   ✓ no demo-slot-boolean-relevant files staged, skipped"
fi
```

- [ ] **Step 4: 验证 hook 真拦截**

临时把某页一个 `:closable="ref"` 改成 `:closable="true"`，`git add` 该页，跑 `.husky/pre-commit`（或 `git commit` dry）→ 预期 audit exit 1 阻断。恢复改动。

- [ ] **Step 5: Commit**

```bash
git add scripts/audit-demo-slot-boolean-coverage.mjs .husky/pre-commit package.json
git commit -F <msg-file>   # feat(gate): INFRA-F45 升 blocking + pre-commit 条件 gate
git push origin master
```

---

## Task 6: Sprint 收尾 self-audit + 文档收口

**Files:**
- Modify: `docs/STATUS.md`（Last updated + F45 移出开放项）
- Modify: `docs/internal/backlog.md`（删 INFRA-F45 entry）

- [ ] **Step 1: 跑 sprint 收尾 self-audit D/E/F/G**

Run: `pnpm audit:self-audit-phase2`（或分别 `audit:canonical-compliance` / `audit:render-verification-coverage` / `audit:hard-rule-compliance` / `audit:export-coverage`）。
Expected: exit 0。任一 FAIL → 立 backlog entry 后才收口。

- [ ] **Step 2: 全量回归**

Run: `pnpm exec vue-tsc --noEmit && pnpm vitest run`
Expected: 全绿（含新 `tests/audit-demo-slot-boolean.test.ts`）。

- [ ] **Step 3: 更新 STATUS + backlog**

- STATUS.md：`Last updated` 改今天 + 摘要"INFRA-F45 shipped（全量回填 + audit blocking gate + pre-commit）"；旧摘要 prepend 到 STATUS-CHANGELOG.md。
- backlog.md：删除 `### INFRA-F45` 整段（完成直接删，改动留 git）；规则 #8 Enforcement 层级从 "L1→目标 L4/L5" 更新为 "L4（pre-commit 条件 gate）"（改 AGENTS.md 硬规则 #8 表格该行）。

- [ ] **Step 4: Commit**

```bash
git add docs/STATUS.md docs/internal/backlog.md docs/internal/STATUS-CHANGELOG.md AGENTS.md
git commit -F <msg-file>   # docs: INFRA-F45 收口（STATUS/backlog/AGENTS 硬规则 #8 升 L4）
git push origin master
```

---

## Self-Review（对 spec 核对）

- **§2 in-scope 派生** → Task 1 Step3 `deriveInScope` + Step1 单测 ✓
- **§3 A1 标准（slot + boolean live 双侧）** → Task 3（React）+ Task 4（Vue）逐组件参数表 ✓
- **§3.1 统一控件范式** → Task 2 模板 + Task 4 复用 ✓
- **§3.1 PopupBox Vue 缺口** → Task 4 PopupBoxPage 补 closable ✓
- **§3.1 React=证明面/Vue=主 docs** → Task 3 vs Task 4 投入差异（Task 3 单文件、Task 4 逐页双语）✓
- **§4 audit（@vue/compiler-sfc + ts）+ report-only→blocking** → Task 1（report-only）+ Task 5（blocking）✓
- **§5 pre-commit 条件 gate** → Task 5 Step3（照 icon-canonical-names 范式）✓
- **§6 执行策略（audit 先行 baseline / React 主线 / Vue 并行 subagent / 重建 dist）** → Task 1 Step6 / Task 3 / Task 4 / Task 3 Step4 ✓
- **§6 canonical 不改例外（TopBar wrapper）** → Task 3 Step3 ✓
- **§7 gates** → Task 6 ✓
- **AGENTS 硬规则 #8 Enforcement 层级更新** → Task 6 Step3 ✓

**Placeholder scan**：Task 1 Step3 CLI 段有明确标注的待清理占位（`allPages`/`require`）——已在 Step3 注记要求 executor 清理，非隐藏占位。其余无 TBD。

**Type consistency**：`checkReactDemo`/`checkVueDemo`/`deriveInScope` 返回形状在单测（Task 1 Step1）与实现（Step3）一致（`{used, slotOk, boolLiveOk}` / in-scope 对象含 `boolProps`/`hasSlot`/`namedSlots`/`canonicalImport`）✓
