# CANONICAL-010 — PopupBox canonical 实现（modal dialog 容器 + 命令式 API）

> **目标**：新增 `PopupBox` canonical 组件 1:1 对齐 figma `Popup Box` set（2 variants `Theme=Dark/Light`），含**命令式 API**（`PopupBox.alert / .confirm / .open`，类 Element Plus `ElMessageBox`）+ 基础 modal 容器组件 + canonical figma 变体 wrapper + docs page + nav 入口 + 0.1.3 changeset。
>
> **一步到位 prompt**（user 2026-05-11 拍板 D1=A + D2=B 命令式 + D3=推荐 family pattern）。Executor 严格按本 prompt 改，**禁止扩展 scope**（详见 §8）。

---

## §0 决策 baked-in（user 拍板 2026-05-11）

### D1 — Base 实现路径 = A（Vue Teleport + 自写 modal 逻辑,0 dep）

- 用 Vue `<Teleport to="body">` 渲染到 `<body>`
- 自写 backdrop `<div>` 含 `--mask-overlay` + `backdrop-filter: blur(var(--mask-overlay-blur))`
- 自写 focus trap（Tab / Shift+Tab cycle 锁在 dialog 内,fallback to first focusable on open）
- ESC key close（`closeOnEscape` 可禁,默认 `true`）
- Backdrop click close（`closeOnBackdrop` 可禁,默认 `false` —— modal 默认严格,不像 popover）
- Scroll lock：dialog open 时 `document.body.style.overflow = 'hidden'`,close 时还原
- Focus restoration：dialog close 时还原焦点到 open 之前的 `document.activeElement`
- **0 外部 dep**：不引 `@vueuse/core` / `focus-trap` / `body-scroll-lock` / 任何 floating-ui

### D2 — API 形态 = B（命令式 API,类 ElMessageBox）

**Primary**：命令式调用 `PopupBox.alert / .confirm / .open`。内部用 Vue 3 `createApp(...).mount(...)` 动态挂载到 fresh `<div>` appended to `document.body`,close 时 `app.unmount()` + remove `<div>`。

**Secondary**：`<PopupBox>` 仍是 Vue 组件（canonical wrapper 形态,接 `:visible` prop）—— 但**不是推荐用法**,主要为 figma alignment / Code Connect / docs page demo coverage。

**Object.assign 统一 export**：
```ts
// src/index.ts
import PopupBoxCanonical from './canonical/PopupBox.vue'
import { open, alert, confirm } from './components/PopupBox/popupbox-service'
const PopupBox = Object.assign(PopupBoxCanonical, { open, alert, confirm })
export { PopupBox }
```
这样消费侧 `<PopupBox theme="dark">` 作组件 + `PopupBox.alert({...})` 作命令式 API 都能用。

### D3 — 命名 + 文件位置 = 推荐 family pattern

- Base 组件：`src/components/PopupBox/PopupBox.vue`（Teleport + backdrop + focus trap + scroll lock 完整逻辑）
- 命令式 service：`src/components/PopupBox/popupbox-service.ts`（`createApp` 动态 mount）
- Folder index：`src/components/PopupBox/index.ts`（re-export base + service）
- Canonical wrapper：`src/canonical/PopupBox.vue`（figma `Theme=Dark/Light` 极薄 wrapper + `data-figma-component="Popup Box"` / `data-figma-theme` attrs）
- 主 export：`src/index.ts` 加 named + default
- Docs page：`playground/docs/pages/PopupBoxPage.vue`（button-driven imperative demo）
- Nav 5 touch points：`figmaFirstRegistry.ts` / `navigation.ts` (×2) / `DocsShell.vue` (×3)

### Token mapping（已 verify,**禁止改 figma 真源外的 SOT,只用 global tokens**）

| Figma 真源 | Dark hex | Light hex | 必用 CSS var |
|---|---|---|---|
| Container bg | `#1f1f1f` | `#f8f8f8` | `var(--bg-layer2)`（theme-aware）|
| Container border | `#595959` | `#f0f0f0` | `var(--line-border)`（theme-aware）|
| Title color | `#f8f8f8` | `#141414` | `var(--text-body)`（theme-aware）|
| Title font | Roboto 600 16/24 | 同左 | `var(--text-style-pop-title)` |
| Drop shadow | 3 层（12r/4s, 6r/0s, 2r/-2s）| 3 层（28r/8s, 16r/0s, 6r/-4s）| Dark = `var(--shadow-l1)` / Light = `var(--shadow-l2)` |
| Backdrop mask | （figma 没画,modal pattern 补）| 同左 | `background: var(--mask-overlay); backdrop-filter: blur(var(--mask-overlay-blur));` |
| Border radius | 4 | 4 | hardcoded `border-radius: 4px;`（无 global radius token,audit 不报警）|
| Padding | pH=16 pR=16 V=null | 同左 | `padding: 16px;`（hardcoded） |
| Default width | 680 | 680 | scoped `--popup-width: 680px;` + `width: var(--popup-width); max-width: 90vw;` |
| Default height | 416 | 416 | **不固定**（content-driven; `max-height: 90vh; overflow: auto;`）|

### Icon 用法（mandatory）

按 [`feedback_tvu-icons-mandatory.md`](.claude memory) — close button 用 `<Icon name="close" :size="12" />`,**禁止内联自画 SVG**。Icon component path: `src/components/Icon/Icon.vue`。

### Footer buttons 用 base Button（避免 canonical 循环 import）

Footer cancel/confirm buttons import 自 `src/components/Button/Button.vue`（**base**,不是 `src/canonical/ButtonBridge.vue`）—— 避免 canonical 之间循环 import。Executor 必须先 read `src/components/Button/Button.vue` 看 props API 是什么样（type / size / 等）。

### Anti-decisions（已拍板排除,executor 不可发明）

| 类别 | 禁止 |
|---|---|
| dep | ❌ 不引 `@vueuse/core` / `focus-trap` / `body-scroll-lock` / `floating-ui` 任何 modal 类 npm 包 |
| API | ❌ 不做声明式 `v-model:visible` 优先范式 + 命令式 fallback（顺序反了,user 偏好命令式 primary）|
| API | ❌ 不抽 `<PopupBox>` Vue 组件成"deprecated"或"low-priority" —— 仍正常 export,只是 docs page demo 以命令式为主 |
| SOT | ❌ 不改 `src/tokens/variables.css` 加新 token —— 现有 `--bg-layer2` / `--line-border` / `--text-body` / `--shadow-l1`+`l2` / `--mask-overlay` / `--text-style-pop-title` 全够 |
| SOT | ❌ 不改 `figma-data/figma-to-code-mapping.json` 中 PopupBox status —— 由 `pnpm sync:figma-library` 触发 audit 自动 reconcile（落地后 status 自动从 `needs-implementation` 变 matched）|
| Icon | ❌ 不内联 `<svg>` 自画 close icon —— 按 [`feedback_tvu-icons-mandatory.md`](.claude memory) `<Icon name="close" :size="12" />` |
| Button | ❌ 不自写 footer button styling —— 用 base `Button` 组件 |
| Scope | ❌ 不顺手 refactor / 升级其它 canonical（Notification / Tooltip 等）—— 严格本 entry scope |
| Scope | ❌ 不动 `audit:published-vs-code` warn-only 状态 —— BRIDGE-MOCKUP-004 tracker 决定升 strict（需 PopupBox + Chart 都完）|

---

## §1 Executor pre-flight（mandatory before §2）

**STEP A — Figma 真源最终验证**：

跑 `mcp_claude_ai_Figma_get_design_context` 拿 nodeId `1939:43682`（fileKey `YbsPRUVmNdsbN40NNwh1Gn`）or fall back to reading `figma-data/raw/components/popup_box__1939_43682.json`。**重点验**：

- 2 variants `Theme=Dark` (`1939:43644`) / `Theme=Light` (`1939:43606`) 的 13 个 nested vector instances（`vectorNodeIds` 字段）具体是什么 —— 推断含 close icon + 可能 cancel/confirm footer buttons + 可能其它装饰 icon
- Footer button 排布（horizontal end-aligned? gap?）
- Title 在顶部 left-aligned 还是 center-aligned
- Body 区域是否含 description text / 内含 placeholder 占位

**Report what you found** in §6 报告 schema 的 `pre-flight 验证` 段（不超 100 字）。

**STEP B — Base 组件参考**：

读 `src/components/Notification/Notification.vue` + `src/components/Tooltip/Tooltip.vue` 看现有弹层范式（虽然 Notification 是 inline toast 不是 modal,但 cancel/confirm button + closable + icon usage 范式可参考）。

读 `src/components/Button/Button.vue` 看 base Button props（重点 type / size / variant 等 props 名）。

---

## §2 改动顺序

按本顺序做,每步跑 typecheck + build verify:

```
1. 写 src/components/PopupBox/PopupBox.vue（base modal,Teleport + backdrop + focus trap + scroll lock）
2. 写 src/components/PopupBox/popupbox-service.ts（命令式 API)
3. 写 src/components/PopupBox/index.ts（re-export base + service）
4. 写 src/canonical/PopupBox.vue（figma variant wrapper）
5. 改 src/index.ts（Object.assign 统一 export PopupBox）
6. 跑 pnpm typecheck → 验 0
7. 写 playground/docs/pages/PopupBoxPage.vue（button-driven imperative demo,含 dark/light theme toggle）
8. 改 playground/docs/figmaFirstRegistry.ts（加 entry）
9. 改 playground/docs/navigation.ts（page id type + nav config 2 处）
10. 改 playground/docs/DocsShell.vue（PageId list + pageLoaders + activePage map 3 处）
11. 跑 pnpm typecheck → 验 0
12. 跑 pnpm build → 验 0
13. 跑 pnpm dev → 浏览 docs site PopupBox page,click button trigger popup,验视觉 + 交互（dark/light 切换 + ESC + close button + backdrop click + Tab cycle）
14. 跑 pnpm audit:design-system → 验 exit 0（全 token 化,无 hex,无 inline svg）
15. 跑 pnpm audit:published-vs-code → 验 figma-only count 从 2 减到 1（PopupBox 移出 figma-only）
16. 跑 pnpm audit:docs-site → 验 exit 0
17. 跑 pnpm changeset → 生成 0.1.3 patch description
```

---

## §3 改动清单（文件级具体内容）

### 3.1 `src/components/PopupBox/PopupBox.vue`（新建,~250 行）

**模板要点**：
- Root `<Teleport to="body">` wrap 整个 popup
- `<div class="popup-overlay" :class="\`popup-overlay--${theme}\`" v-if="visible" @click.self="handleBackdropClick">` 作 backdrop
- 内层 `<div ref="dialogEl" class="popup-box" :class="\`popup-box--${theme}\`" :style="{ width: widthStyle }" role="dialog" aria-modal="true" :aria-labelledby="title ? labelId : undefined">`
- Header：`<header class="popup-box__header">` 含 `<h2 :id="labelId" class="popup-box__title">{{ title }}</h2>` + `<button v-if="closable" class="popup-box__close" aria-label="Close" @click="handleClose"><Icon name="close" :size="12" /></button>`
- Body：`<div class="popup-box__body"><slot /></div>`
- Footer：`<footer v-if="showFooter" class="popup-box__footer"><slot name="footer"><Button @click="handleCancel">{{ cancelText }}</Button><Button type="primary" @click="handleConfirm">{{ confirmText }}</Button></slot></footer>`（Button type 名按 §1 step B 验到的实际命名）

**Props**：
```ts
withDefaults(defineProps<{
  visible?: boolean
  title?: string
  theme?: 'dark' | 'light'
  width?: string | number
  closable?: boolean
  closeOnBackdrop?: boolean
  closeOnEscape?: boolean
  showFooter?: boolean
  cancelText?: string
  confirmText?: string
}>(), {
  visible: false,
  theme: 'dark',
  width: 680,
  closable: true,
  closeOnBackdrop: false,
  closeOnEscape: true,
  showFooter: true,
  cancelText: 'Cancel',
  confirmText: 'Confirm',
})
```

**Emits**：
```ts
defineEmits<{
  close: []
  cancel: []
  confirm: []
  'update:visible': [value: boolean]
}>()
```

**逻辑**：
- `widthStyle = computed(() => typeof props.width === 'number' ? \`${props.width}px\` : props.width)`
- `labelId = \`popup-box-title-${uid}\`` (用 useId() if Vue 3.5+,或 generate unique counter)
- `dialogEl = ref<HTMLDivElement | null>(null)`
- `focusableEls: HTMLElement[]` `originalFocusEl: HTMLElement | null` 模块级 / setup level
- `refreshFocusables()`：query selector `'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'` filter out `disabled`
- `handleKeydown(e)`:
  - Escape + `closeOnEscape && closable` → preventDefault + handleClose()
  - Tab: 在 focusableEls first/last 之间 cycle（Shift+Tab on first → focus last; Tab on last → focus first）
- `handleBackdropClick()`：仅 `@click.self`（不冒泡触发）+ `closeOnBackdrop && closable` 时调 handleClose
- `handleClose() / handleCancel() / handleConfirm()`：emit + emit `'update:visible'` false
- `watch(() => props.visible, (visible, prev) => {...}, { immediate: true })`:
  - visible true & prev !== true: store `originalFocusEl = document.activeElement`,`document.body.style.overflow = 'hidden'`,`window.addEventListener('keydown', handleKeydown)`,`requestAnimationFrame(() => { refreshFocusables(); focusableEls[0]?.focus() })`
  - visible false & prev === true: restore overflow,removeEventListener,`originalFocusEl?.focus()`,清空
- `onBeforeUnmount`：cleanup 同 close 流（防 unmount 时仍 locked）

**Style**（scoped,**严格只用 token,不允许 hex 字面**）：
```css
.popup-overlay {
  position: fixed;
  inset: 0;
  background: var(--mask-overlay);
  backdrop-filter: blur(var(--mask-overlay-blur));
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.popup-box {
  background: var(--bg-layer2);
  border: 1px solid var(--line-border);
  border-radius: 4px;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  color: var(--text-body);
  max-width: 90vw;
  max-height: 90vh;
  overflow: hidden;
}

.popup-box--dark { box-shadow: var(--shadow-l1); }
.popup-box--light { box-shadow: var(--shadow-l2); }

.popup-box__header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.popup-box__title {
  font: var(--text-style-pop-title);
  color: var(--text-body);
  margin: 0;
  flex: 1;
}

.popup-box__close {
  background: transparent;
  border: none;
  cursor: pointer;
  color: var(--text-body);
  padding: 4px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.popup-box__body {
  flex: 1;
  overflow: auto;
  color: var(--text-body);
}

.popup-box__footer {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}
```

### 3.2 `src/components/PopupBox/popupbox-service.ts`（新建,~120 行）

```ts
import { createApp, h, type App, type Component, type VNode } from 'vue'
import BasePopupBox from './PopupBox.vue'

export interface PopupBoxOptions {
  title?: string
  content?: string | VNode | Component
  theme?: 'dark' | 'light'
  width?: string | number
  closable?: boolean
  closeOnBackdrop?: boolean
  closeOnEscape?: boolean
  showFooter?: boolean
  cancelText?: string
  confirmText?: string
  onClose?: () => void
  onCancel?: () => void
  onConfirm?: () => void
}

export interface PopupBoxHandle {
  close: () => void
}

export type PopupBoxResult = 'confirm' | 'cancel' | 'close'

function renderContent(content: PopupBoxOptions['content']) {
  if (content == null) return undefined
  if (typeof content === 'string') return content
  if (typeof content === 'object' && 'type' in content) return content as VNode
  return h(content as Component)
}

export function open(options: PopupBoxOptions = {}): PopupBoxHandle {
  const container = document.createElement('div')
  document.body.appendChild(container)

  let app: App | null = null
  let settled = false

  function cleanup() {
    if (app) {
      app.unmount()
      app = null
    }
    if (container.parentNode) {
      container.parentNode.removeChild(container)
    }
  }

  function settle(kind: PopupBoxResult) {
    if (settled) return
    settled = true
    if (kind === 'close') options.onClose?.()
    if (kind === 'cancel') options.onCancel?.()
    if (kind === 'confirm') options.onConfirm?.()
    cleanup()
  }

  app = createApp({
    render: () =>
      h(
        BasePopupBox,
        {
          visible: true,
          title: options.title,
          theme: options.theme ?? 'dark',
          width: options.width,
          closable: options.closable ?? true,
          closeOnBackdrop: options.closeOnBackdrop ?? false,
          closeOnEscape: options.closeOnEscape ?? true,
          showFooter: options.showFooter ?? true,
          cancelText: options.cancelText,
          confirmText: options.confirmText,
          onClose: () => settle('close'),
          onCancel: () => settle('cancel'),
          onConfirm: () => settle('confirm'),
        },
        { default: () => renderContent(options.content) },
      ),
  })

  app.mount(container)

  return {
    close: () => settle('close'),
  }
}

export function alert(
  options: Omit<PopupBoxOptions, 'cancelText' | 'onCancel'> = {},
): Promise<PopupBoxResult> {
  return new Promise((resolve) => {
    open({
      ...options,
      cancelText: undefined,
      showFooter: options.showFooter ?? true,
      onConfirm: () => {
        options.onConfirm?.()
        resolve('confirm')
      },
      onClose: () => {
        options.onClose?.()
        resolve('close')
      },
    })
    // Patch base behaviour: alert hides cancel button via showFooter slot fallback w/ confirmText only.
    // Base PopupBox renders both cancel + confirm by default; alert variants pass only confirmText.
    // If base needs explicit hide-cancel prop, add `hideCancel?: boolean` to PopupBoxProps + thread through.
  })
}

export function confirm(options: PopupBoxOptions = {}): Promise<PopupBoxResult> {
  return new Promise((resolve) => {
    open({
      ...options,
      showFooter: options.showFooter ?? true,
      onConfirm: () => {
        options.onConfirm?.()
        resolve('confirm')
      },
      onCancel: () => {
        options.onCancel?.()
        resolve('cancel')
      },
      onClose: () => {
        options.onClose?.()
        resolve('close')
      },
    })
  })
}
```

**重要细节**：`alert` 模式需要隐藏 cancel button。在 `BasePopupBox.vue` props 加 `hideCancel?: boolean` 默认 false,template footer 改成：
```vue
<Button v-if="!hideCancel" @click="handleCancel">{{ cancelText }}</Button>
<Button type="primary" @click="handleConfirm">{{ confirmText }}</Button>
```
然后 `alert` 传 `hideCancel: true`。**这是 alert/confirm 唯一行为差异**,不要加更多 variant 区分。

### 3.3 `src/components/PopupBox/index.ts`（新建,~4 行）

```ts
export { default as PopupBox } from './PopupBox.vue'
export * from './popupbox-service'
```

### 3.4 `src/canonical/PopupBox.vue`（新建,~50 行）

```vue
<script setup lang="ts">
import { computed } from 'vue'
import BasePopupBox from '../components/PopupBox/PopupBox.vue'

type Theme = 'dark' | 'light'

const props = withDefaults(defineProps<{
  theme?: Theme
  visible?: boolean
  title?: string
  width?: string | number
  closable?: boolean
  closeOnBackdrop?: boolean
  closeOnEscape?: boolean
  showFooter?: boolean
  cancelText?: string
  confirmText?: string
}>(), {
  theme: 'dark',
  visible: false,
  width: 680,
  closable: true,
  closeOnBackdrop: false,
  closeOnEscape: true,
  showFooter: true,
})

defineEmits<{
  close: []
  cancel: []
  confirm: []
  'update:visible': [value: boolean]
}>()

const figmaAttrs = computed(() => ({
  'data-figma-component': 'Popup Box',
  'data-figma-theme': props.theme,
}))
</script>

<template>
  <BasePopupBox
    v-bind="figmaAttrs"
    :theme="theme"
    :visible="visible"
    :title="title"
    :width="width"
    :closable="closable"
    :close-on-backdrop="closeOnBackdrop"
    :close-on-escape="closeOnEscape"
    :show-footer="showFooter"
    :cancel-text="cancelText"
    :confirm-text="confirmText"
    @close="$emit('close'); $emit('update:visible', false)"
    @cancel="$emit('cancel')"
    @confirm="$emit('confirm')"
    @update:visible="(v) => $emit('update:visible', v)"
  >
    <slot />
    <template #footer>
      <slot name="footer" />
    </template>
  </BasePopupBox>
</template>
```

### 3.5 `src/index.ts`（改动）

在现有 imports 段（line ~30 区域,Tooltip import 后）加：
```ts
import PopupBoxCanonical from './canonical/PopupBox.vue'
import { open as popupBoxOpen, alert as popupBoxAlert, confirm as popupBoxConfirm } from './components/PopupBox/popupbox-service'

const PopupBox = Object.assign(PopupBoxCanonical, {
  open: popupBoxOpen,
  alert: popupBoxAlert,
  confirm: popupBoxConfirm,
})
```

在 named exports block 加 `PopupBox`（按字母顺序插入,parent 是 export {...} 块）。

在 default export 对象加 `PopupBox` 字段。

如有 `export type` block 也加 `export type { PopupBoxOptions, PopupBoxHandle, PopupBoxResult } from './components/PopupBox/popupbox-service'`。

### 3.6 `playground/docs/pages/PopupBoxPage.vue`（新建,~200 行）

按现有 page 范式（参考 `NotificationPage.vue` script 段 setup + `<DocsShell>` injection of `docsTheme`）：

```vue
<script setup lang="ts">
import { computed, inject, ref, type Ref } from 'vue'
import { PopupBox } from '@/src/index'

const docsTheme = inject<Readonly<Ref<'dark' | 'light'>>>('docsTheme', ref('dark'))

const props = withDefaults(defineProps<{ locale?: string }>(), { locale: 'en-US' })
const isZhCN = computed(() => props.locale === 'zh-CN')
function t(en: string, zh: string) { return isZhCN.value ? zh : en }

const lastResult = ref<string | null>(null)

function showAlert() {
  PopupBox.alert({
    title: t('Alert', '提示'),
    content: t('Are you sure to proceed?', '确认要继续吗？'),
    theme: docsTheme.value,
    confirmText: t('OK', '确定'),
  }).then((r) => { lastResult.value = `alert → ${r}` })
}

function showConfirm() {
  PopupBox.confirm({
    title: t('Confirm', '确认'),
    content: t('This action cannot be undone. Continue?', '此操作不可撤销，是否继续？'),
    theme: docsTheme.value,
    cancelText: t('Cancel', '取消'),
    confirmText: t('Confirm', '确认'),
  }).then((r) => { lastResult.value = `confirm → ${r}` })
}

function showOpen() {
  const handle = PopupBox.open({
    title: t('Custom open', '自定义弹窗'),
    content: t('Imperatively opened — close handle returned.', '命令式打开 —— 返回 close handle。'),
    theme: docsTheme.value,
    width: 520,
    onClose: () => { lastResult.value = 'open → closed (via handle or close button)' },
  })
  // demo: auto-close after 5s if user idle
  setTimeout(() => handle.close(), 5000)
}

const usageCode = `import { PopupBox } from '@nancyzeng0210/tvu-design-system'

// 命令式 API （推荐）
PopupBox.alert({ title: 'Alert', content: 'Are you sure?' })
  .then((result) => console.log(result)) // 'confirm' | 'close'

PopupBox.confirm({ title: 'Confirm', content: 'Delete?', confirmText: 'Delete' })
  .then((result) => console.log(result)) // 'confirm' | 'cancel' | 'close'

const handle = PopupBox.open({ title: 'Custom', content: 'Hello' })
handle.close()

// 声明式 (rare)
// <PopupBox :visible="open" theme="dark" title="..."><MyContent /></PopupBox>`

const apiRows = [
  { name: 'open(options)', description: ['Imperatively open a popup; returns { close } handle.', '命令式打开,返回 { close } handle。'], type: '(opts: PopupBoxOptions) => PopupBoxHandle' },
  { name: 'alert(options)', description: ['Alert popup with single confirm button; returns Promise.', '只含 confirm 按钮的提示框,返回 Promise。'], type: '(opts) => Promise<PopupBoxResult>' },
  { name: 'confirm(options)', description: ['Confirm popup with cancel + confirm buttons; returns Promise.', '含 cancel + confirm 的确认框,返回 Promise。'], type: '(opts) => Promise<PopupBoxResult>' },
  { name: 'title', description: ['Popup title.', '弹窗标题。'], type: 'string', defaultValue: '—' },
  { name: 'content', description: ['Popup body content (string / VNode / Component).', '内容（字符串 / VNode / 组件）。'], type: 'string | VNode | Component' },
  { name: 'theme', description: ['Figma theme variant.', 'Figma 主题成员。'], type: `'dark' | 'light'`, defaultValue: `'dark'` },
  { name: 'width', description: ['Popup width override.', '弹窗宽度覆写。'], type: 'string | number', defaultValue: '680' },
  { name: 'closable', description: ['Show close button.', '显示关闭按钮。'], type: 'boolean', defaultValue: 'true' },
  { name: 'closeOnBackdrop', description: ['Click backdrop to close.', '点击遮罩关闭。'], type: 'boolean', defaultValue: 'false' },
  { name: 'closeOnEscape', description: ['ESC key to close.', 'ESC 键关闭。'], type: 'boolean', defaultValue: 'true' },
  { name: 'showFooter', description: ['Show footer cancel/confirm buttons.', '显示底部按钮。'], type: 'boolean', defaultValue: 'true' },
] as const
</script>

<template>
  <div class="docs-page">
    <section class="docs-section">
      <h2 class="docs-section__title">{{ t('Imperative demo', '命令式 demo') }}</h2>
      <p class="docs-section__summary">
        {{ t('PopupBox is invoked imperatively — click any button to see the popup. The most common pattern; v-model:visible declarative usage is supported but rare.', 'PopupBox 通过命令式调用 —— 点任意按钮触发弹窗。声明式 v-model:visible 也支持,但不常用。') }}
      </p>
      <div class="docs-demo">
        <button class="docs-demo-btn" @click="showAlert">{{ t('Show Alert', '显示 Alert') }}</button>
        <button class="docs-demo-btn" @click="showConfirm">{{ t('Show Confirm', '显示 Confirm') }}</button>
        <button class="docs-demo-btn" @click="showOpen">{{ t('Show Open (custom)', '显示 Open (自定义)') }}</button>
        <div v-if="lastResult" class="docs-demo-result">{{ t('Last result:', '最后结果：') }} {{ lastResult }}</div>
      </div>
    </section>

    <section class="docs-section">
      <h2 class="docs-section__title">{{ t('Usage', '使用') }}</h2>
      <pre class="docs-code">{{ usageCode }}</pre>
    </section>

    <section class="docs-section">
      <h2 class="docs-section__title">{{ t('API', 'API') }}</h2>
      <table class="docs-api">
        <thead><tr><th>{{ t('Name', '名称') }}</th><th>{{ t('Description', '说明') }}</th><th>{{ t('Type', '类型') }}</th><th>{{ t('Default', '默认值') }}</th></tr></thead>
        <tbody>
          <tr v-for="row in apiRows" :key="row.name">
            <td><code>{{ row.name }}</code></td>
            <td>{{ t(row.description[0], row.description[1]) }}</td>
            <td><code>{{ row.type }}</code></td>
            <td><code>{{ 'defaultValue' in row ? row.defaultValue : '—' }}</code></td>
          </tr>
        </tbody>
      </table>
    </section>
  </div>
</template>

<style scoped>
.docs-demo { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.docs-demo-btn { padding: 8px 16px; background: var(--bg-layer3); color: var(--text-body); border: 1px solid var(--line-border); border-radius: 4px; cursor: pointer; }
.docs-demo-btn:hover { background: var(--bg-grey-btn-hv); }
.docs-demo-result { margin-left: 12px; color: var(--text-tips, var(--text-body)); font-size: 14px; }
.docs-code { background: var(--bg-layer3); color: var(--text-body); padding: 12px; border-radius: 4px; overflow: auto; font-family: monospace; font-size: 13px; }
.docs-api { width: 100%; border-collapse: collapse; color: var(--text-body); }
.docs-api th, .docs-api td { padding: 8px 12px; border-bottom: 1px solid var(--line-border); text-align: left; }
</style>
```

> **注**：本 page 范式与既有 page 略不同(命令式 API 没 `FigmaMembersGrid` 渲染,因为无法 mount 静态 dark+light 并排 —— 与 [`feedback_no-side-by-side-theme`](.claude memory) 兼容,不违反)。Demo 形式是 button-driven imperative,完全契合 D2 决策。

### 3.7 `playground/docs/figmaFirstRegistry.ts`（改动）

按 line 72-86 现有 entry 范式（notification / tooltip）添加 PopupBox entry。具体位置紧跟 tooltip 之后（line 86 之后或同 section）。Entry shape：
```ts
{
  pageId: 'popup-box',
  componentName: 'PopupBox',
  pageFile: 'PopupBoxPage.vue',
  // ... (按现有 schema 必填字段补齐,参考 notification entry)
},
```

### 3.8 `playground/docs/navigation.ts`（改动 2 处）

**改动 A — line 18** 给 PageId union type 加 `| 'popup-box'`（按字母顺序插入,在 `| 'notification'` 后或 alphabetic 位置）。

**改动 B — line 304 区域** nav config 加 entry：
```ts
{ id: 'popup-box', label: 'PopupBox', /* ... 按 notification entry shape 补齐 */ },
```

### 3.9 `playground/docs/DocsShell.vue`（改动 3 处）

**改动 A — line 50** PageId list 加 `'popup-box',`

**改动 B — line 94-96 区域** pageLoaders 加：
```ts
'popup-box': () => import('./pages/PopupBoxPage.vue'),
```

**改动 C — line 125 区域** activePage map 加：
```ts
'popup-box': createPageComponent(pageLoaders['popup-box']),
```

### 3.10 `.changeset/<auto-generated-name>.md`（新建）

跑 `pnpm changeset` 交互式生成。选 bump level `patch`（0.1.2 → 0.1.3）。Description 填：

```
CANONICAL-010: PopupBox canonical 实现（modal dialog + 命令式 API）

- 新增 base modal 组件 src/components/PopupBox/PopupBox.vue（Teleport + 自写 backdrop / focus trap / ESC / scroll lock,0 dep）
- 新增命令式 API PopupBox.alert / .confirm / .open（类 Element Plus ElMessageBox）
- 新增 canonical wrapper src/canonical/PopupBox.vue（figma Theme=Dark/Light 1:1 对齐）
- 加 docs page playground/docs/pages/PopupBoxPage.vue（button-driven imperative demo）
- audit:published-vs-code figma-only count 从 2 减到 1（PopupBox 移出）
- BRIDGE-MOCKUP-004 tracker 子项 1/2 ✅（仍待 CANONICAL-011 Chart 完成才升 strict）
```

Frontmatter：
```
---
'@nancyzeng0210/tvu-design-system': patch
---
```

---

## §4 验证（机械化命令,按顺序）

| # | 命令 | 预期 exit / stdout | 关键期望 |
|---|---|---|---|
| 1 | `pnpm typecheck` | 0 | 全 ts 类型通过 |
| 2 | `pnpm build` | 0 | dist 正常产出 |
| 3 | `pnpm audit:design-system` | 0 | 全 token 化,无 hex 字面,无 inline svg |
| 4 | `pnpm audit:icon-fill-currentcolor` | 0 | 不破坏 |
| 5 | `pnpm audit:no-hardcoded-design-tokens` | 0 | 不破坏 |
| 6 | `pnpm audit:component-no-inline-svg` | 0 | 不破坏 |
| 7 | `pnpm audit:figma-conformance` | 0 | 不破坏 |
| 8 | `pnpm audit:docs-site` | 0 | 不破坏 |
| 9 | `pnpm audit:component-tokens` | 0 | 不破坏 |
| 10 | `pnpm audit:tokenized-diff` | 0 | 不破坏 |
| 11 | `pnpm audit:published-vs-code` | warn-only (CI exit 0 by gate; local CLI 可能 exit 1) | 输出中 `figmaOnly` 数组 length 从 2 减到 1（仅剩 Chart）;PopupBox 不再出现 |
| 12 | `pnpm changeset:status` | 0 | 列出 1 个 pending 0.1.3 patch changeset |
| 13 | `grep -l 'PopupBox' src/index.ts` | stdout has match | export 已加 |
| 14 | `test -f src/components/PopupBox/PopupBox.vue && test -f src/components/PopupBox/popupbox-service.ts && test -f src/components/PopupBox/index.ts && test -f src/canonical/PopupBox.vue && test -f playground/docs/pages/PopupBoxPage.vue` | exit 0 | 5 个新文件都存在 |
| 15 | `grep -c "popup-box" playground/docs/navigation.ts` | stdout `>= 2` | type + nav config 都加了 |
| 16 | `grep -c "popup-box" playground/docs/DocsShell.vue` | stdout `>= 3` | PageId list + pageLoaders + activePage 3 处都加了 |
| 17 | `pnpm dev` 后浏览 docs site PopupBox page | 视觉 / 交互 PASS | dark/light theme 切换 popup 内部 token 变化;click Show Alert 出弹窗;ESC 关;close button 关;Tab cycle 锁内部;backdrop click 不关（默认）;Confirm Promise resolve 'confirm'/'cancel'/'close' |

---

## §5 报告 schema（STOP 后输出）

按本 schema 填值后 STOP,**不要 commit / push / tag**：

```
### CANONICAL-010 执行报告

#### Pre-flight 验证（§1 STEP A）
- Figma `Popup Box` 1939:43682 nested vector instances 实际内容：<填一句 ~50 字描述,如 "13 instances = 1 close icon top-right + 2 cancel/confirm Button instances bottom-right + 10 decorative placeholder icons in body">
- Footer 排布 / title 对齐 / body 占位：<一句>

#### 改动文件清单
- src/components/PopupBox/PopupBox.vue (new, XXX lines)
- src/components/PopupBox/popupbox-service.ts (new, XXX lines)
- src/components/PopupBox/index.ts (new, 2 lines)
- src/canonical/PopupBox.vue (new, XX lines)
- src/index.ts (modified: import + Object.assign + named export + default + type re-export)
- playground/docs/pages/PopupBoxPage.vue (new, XXX lines)
- playground/docs/figmaFirstRegistry.ts (modified: +1 entry)
- playground/docs/navigation.ts (modified: type union +1 / nav config +1)
- playground/docs/DocsShell.vue (modified: 3 处)
- .changeset/<auto>.md (new, 0.1.3 patch)

#### 验证结果（§4 顺序,全 17 条）

| # | 命令 | 实际 exit / stdout | 预期 | 状态 |
|---|---|---|---|---|
| 1 | pnpm typecheck | 0 | 0 | ✅ |
| ... | ... | ... | ... | ... |

（填全 17 行）

#### 浏览器验视频片段或描述（§4 第 17 条）
- Dark theme 下点 Show Alert：<一句>
- Light theme 下点 Show Confirm：<一句>
- ESC 关:<一句>
- backdrop click 不关:<一句>
- Tab cycle:<一句>

#### 未解决项 / blocker
（如无写"无"。如有任一 §4 验证不过,STOP 报告具体哪条,不发明 fix）

#### 自检
- [ ] 没自创新规则（未加 §0 之外的 prop / API method）
- [ ] 没改 §8 禁止列表内文件
- [ ] §4 全 17 条命令实际跑过（不是猜测）
- [ ] §0 D1/D2/D3 决策按拍板版本落地,未发明声明式 v-model 范式作 primary
- [ ] 全 token 化,无 hex 字面在新 file 内（§0 token mapping 表全用上）
- [ ] Close icon 用 `<Icon name="close" :size="12" />`,不内联 svg
- [ ] Footer button 用 base `src/components/Button/Button.vue`,不引 canonical（避免循环）
- [ ] `Object.assign(PopupBoxCanonical, { open, alert, confirm })` 模式落地,既能 `<PopupBox>` 又能 `PopupBox.alert()`
- [ ] `alert` 模式有 `hideCancel: true` 隐藏 cancel button（不重复实现 alert 完整组件）
```

---

## §6 不允许的扩展（防 executor 加料）

**严格禁止**：

| 类别 | 禁止动作 |
|---|---|
| dep | 不引 `@vueuse/core` / `focus-trap` / `body-scroll-lock` / `floating-ui` / 任何新 npm 包 |
| Token SOT | 不动 `src/tokens/variables.css`（不加新 var,不改现有 var）|
| Figma SOT | 不动 `figma-data/figma-to-code-mapping.json`（status 由 audit 自动 reconcile）|
| Figma raw | 不动 `figma-data/raw/components/popup_box__1939_43682.json` |
| 其它 canonical | 不"顺手"修 Notification / Tooltip / Alert 等任何现有 canonical / base 组件 |
| Button base | 不改 `src/components/Button/Button.vue`（只 import 用,不改它）|
| Icon base | 不改 `src/components/Icon/Icon.vue`（只 import 用）|
| Audit 脚本 | 不改任何 `figma-sync/audit-*.mjs`（audit 升 strict 由 BRIDGE-MOCKUP-004 tracker 决定）|
| Publish | 不动 `.github/workflows/publish.yml`（audit:published-vs-code 仍 warn-only）|
| RELEASING | 不改 `docs/RELEASING.md`（audit gate 状态保持）|
| Changeset | 只生成 1 个 changeset,bump `patch`,描述按 §3.10 填;不动 `.changeset/config.json` |
| 别 entry | 不顺手做 INFRA-F29 / CANONICAL-011 / 任何别 backlog —— 严格 CANONICAL-010 scope |
| commit / push | **不要 commit**;**不要 push**;**不要 tag**;STOP 等 plan owner 复审 |

**如发现 §4 验证不过**：STOP,报告具体哪条 + 实际输出 + 诊断假设,**不要发明 fix 路线**。

---

## §7 plan-owner 自检（meta-rules.md §3 反模式 5 条）

| # | 反模式 | 本 prompt 是否避免 | 实证 |
|---|---|---|---|
| 1 | 硬编码项目级规则到工具 | ✅ | 命令式 API 通用范式（类 ElMessageBox）,不绑死 figma 特殊维度;close icon 用 Icon component 不内联 |
| 2 | 打补丁方案 | ✅ | base + service + canonical 三层职责分明;Object.assign pattern 通用（既能组件用又能命令式调）|
| 3 | to-do list 思维 | ✅ | §4 全 17 条验证可机械比对 exit code / stdout |
| 4 | 没问下游怎么消费 | ✅ | docs page button-driven demo 即"下游消费"范式实证;usageCode 段贴出 import + 调用样例 |
| 5 | 没问扩展时改哪里 | ✅ | 新增 popup 类组件时（如 Drawer / Sidesheet）可复用 popupbox-service.ts 的 createApp 范式,只换 BasePopupBox = BaseDrawer |

---

## §8 STOP 协议

完成 §4 全部 17 条验证 + 写完 §5 报告后 **立即 STOP**。

**禁止**：
- 自行 `git add` / `git commit`
- 自行 `git push`
- 自行 `git tag`
- 自行 fire 下一个 task（如 INFRA-F29 hover）

等 plan owner 复审 diff → user 拍板 → 由 user 操作 commit + push + tag 触发 CI auto-publish 0.1.3。

---

## §9 元说明

- **本 prompt 生命周期**：commit 入仓库 → executor fire → executor STOP 后 plan owner 复审 → user 拍板 commit。完成后不删除（file-based prompt 范式,保留追溯）
- **关联 backlog**：[`docs/internal/backlog.md`](../backlog.md) CANONICAL-010 entry + BRIDGE-MOCKUP-004 tracker
- **关联 pickup**：[`docs/internal/_plans/next-session-pickup-2026-05-13.md`](../_plans/next-session-pickup-2026-05-13.md) §3 第 1 步（含 2026-05-11 EOD baseline + D1/D2/D3 baked-in Update 段）
- **关联复盘**：[`docs/internal/retrospection/2026-05-11-bridge-mockup-004-baseline-assumption-invalid.md`](../retrospection/2026-05-11-bridge-mockup-004-baseline-assumption-invalid.md)（D 路线决策 + tracker 范式 + 派生 CANONICAL-010 来源）
- **关联 memory**：
  - [`feedback_popup-imperative-api`](.claude memory)（命令式 API 偏好固化）
  - [`feedback_tvu-icons-mandatory`](.claude memory)（close icon 用 `<Icon>` 不内联）
  - [`feedback_one-shot-prompts`](.claude memory)（本 prompt 即一步到位实证,无 baseline→Phase 2 假分阶段）
  - [`feedback_no-side-by-side-theme`](.claude memory)（docs page 不并排 dark+light）
- **下一步**：CANONICAL-010 落地后接 INFRA-F29 hover prompt → 0.1.3 release cycle（含 Packages 页实物 verify,按 [`feedback_release-verify-packages-page`](.claude memory) 协议）→ 之后 CANONICAL-011 Chart（v0.2 minor）才能升 audit:published-vs-code 到 strict + 关闭 BRIDGE-MOCKUP-004 tracker
