# Form Validation Engine (APID-02) 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:** Add a framework-neutral form-validation engine + a `Form` container + `FormItem` extensions so consumers can declaratively validate forms (el-form mental model), with the result auto-filling FormItem's existing `error` / `status` props.

**Architecture:** Three decoupled layers. (1) A zero-framework TS core — pure `validators.ts` + a `createFormEngine` with an explicit `subscribe()` pub/sub. (2) A CE-safe accessor `useFormContext.ts` that lets a `FormItem` find its parent `Form`'s engine across the `defineCustomElement` boundary (SFC → `inject`; CE → `getCurrentInstance().ce` host + `host.closest('tvu-form')`). (3) Thin Vue (`Form.vue` + `FormItem` extension) and React (react-pilot binding) adapters over the same core. The engine does NOT depend on Vue reactivity to cross the CE boundary — it uses explicit `subscribe()` because Vue's reactive forwarding chain is documented to arrive too late under CE (see `useHasSlot.ts`).

**Tech Stack:** TypeScript, Vue 3.5 (`defineCustomElement`), Vitest (unit + SFC mount via `@vue/test-utils`), Playwright (CE cross-boundary verification), pnpm.

## Global Constraints

- **Package name (verbatim):** `@nancyzeng0210/tvu-design-system`. Changeset bump = **`minor`** (new component + additive FormItem props; backward-compatible).
- **Zero new Figma source.** MVP is two-value pass/fail only: a failing field writes the existing `error` (string) + `status='Error'` props on FormItem, whose Error visual is already Figma-verified. Register exactly ONE divergence (`form-container-topology`). Do NOT invent warning/success/validating visual states.
- **Engine core = zero framework dependency.** `src/canonical/composables/form/validators.ts` and `formEngine.ts` must not import Vue or any component. Vue appears only in `useFormContext.ts`, `Form.vue`, and the `FormItem` extension. This is what lets the core be TDD'd headless and reused by the React binding (硬规则 #8 dual-framework parity).
- **Auto-generated files — NEVER hand-edit:** `src/web-components/register.ts`, `react-pilot/src/wrappers/*.tsx`, `react-pilot/src/wrappers/types.ts`. Their SoT is `src/web-components/components.config.ts`; regenerate all three with `pnpm gen:react-bindings`.
- **`.vue` visual gate:** any commit that touches a `.vue` file (`Form.vue`, `FormItem.vue`, `components/FormItem/FormItem.vue`, `components/Form/…`, docs pages) requires owner approval and a `VISUAL_COMMIT_APPROVED` marker. Execution must STOP for owner sign-off before committing `.vue` changes — do NOT self-commit `.vue` (see AGENTS §标准闭环 + feedback_executor-no-self-commit). Engine-core / test-only / JSON-config commits are not gated.
- **Two-value only.** No async/remote validators, no cross-field linkage, no dynamic field add/remove (FormList) — those are explicitly out of MVP scope (spec §1).
- Verify with real evidence before claiming pass. Run the exact commands shown; paste real output. Frequent commits, DRY, YAGNI, TDD.

---

## File Structure

**Engine core (zero framework deps):**
- `src/canonical/composables/form/validators.ts` — types (`FormRule`, `FormTrigger`, `FormRules`) + pure validators + `runRules()` + `normalizeRules()`. One responsibility: value + rule(s) → error string.
- `src/canonical/composables/form/formEngine.ts` — `createFormEngine()`: field registry, error map, `validate/validateField/resetFields/clearValidate`, `subscribe`, `getFieldValue`, `getError`. Depends only on `validators.ts`.

**CE-safe accessor:**
- `src/canonical/composables/useFormContext.ts` — `FormContextKey` symbol + `useFormEngine()` (three-branch discovery mirroring `useHasSlot.ts`).

**Vue adapters:**
- `src/canonical/Form.vue` — container; creates engine; provides (SFC) + sets `host._tvuFormEngine` (CE); `defineExpose` the 4 methods.
- `src/components/Form/Form.vue` — (only if a presentational base is needed; MVP keeps layout in canonical `Form.vue`, so this file is NOT created — the canonical Form owns the thin `.tvu-form` wrapper. Documented here so no task looks for it.)
- `src/canonical/FormItem.vue` — extend: add `prop?` / `rules?`; register with engine; `subscribe` → local error ref; bind effective `error`/`status` to base.
- `src/components/FormItem/FormItem.vue` — unchanged (base presentational). No edits needed; canonical drives error/status via existing props.

**CE registration (regenerate, don't hand-edit):**
- `src/web-components/components.config.ts` — add one `Form` `ComponentConfig`.

**React binding + demo:**
- `react-pilot/src/wrappers/Form.tsx` — generated.
- `react-pilot/src/demos/Form.tsx` (+ `form-demo.css`) — hand-written demo, registered in `react-pilot/src/App.tsx`.
- `react-pilot/ds-sync/entry.tsx` — add `export * from '../src/wrappers/Form'`.

**Docs:**
- `playground/docs/pages/FormPage.vue` — new page.
- `playground/docs/navigation.ts` — `CanonicalPageId` union + nav entry.
- `playground/docs/DocsShell.vue` — 3 arrays (`registeredComponentPageIds`, `pageLoaders`, `pageComponents`).
- `docs/site-review-manifest.json` — page entry.

**Translation / affordance / changeset:**
- `src/design-system/translation/divergences-decisions.json` — add `form-container-topology`.
- `docs/internal/component-affordances.json` — add `Form`, update `FormItem`; regenerate `.md`.
- `.changeset/form-validation-engine.md` — minor.

**Tests:**
- `tests/spike/` — Stage 0 CE-accessor spike (harness + playwright spec + config).
- `tests/FormValidators.test.ts` — validators unit.
- `tests/FormEngine.test.ts` — engine unit.
- `tests/Form.test.ts` — Form + FormItem SFC integration (`@vue/test-utils`).
- `tests/render-verification-react/form-ce-validation.spec.ts` — CE cross-boundary error propagation (permanent regression guard, promoted from the Stage 0 spike).
- All non-spike tests live in the flat `tests/` root; the runner is `pnpm test` (`vitest run`), include glob `tests/**/*.{test,spec}.{ts,mts}`.

---

## Stage 0 — CE-accessor spike (DECISION GATE)

**Purpose:** Before investing in the full engine, prove the single highest-risk mechanism in a real browser: a parent `defineCustomElement` component can hand a child `defineCustomElement` component a shared object across the CE boundary via `host.closest(...)` + a host property, and an explicit `subscribe()` callback fired by the parent re-renders the child. `useHasSlot.ts` already proves `getCurrentInstance().ce` + host DOM query work under CE; what is UNPROVEN and load-bearing here is (a) `childHost.closest('tvu-...')` resolves the parent host, (b) a property set on the parent host in `setup()` is readable when the child upgrades, and (c) a `subscribe` callback crosses the boundary and updates the child DOM.

**Why not vitest:** jsdom/happy-dom cannot faithfully run `defineCustomElement` shadow projection / cross-host `closest` — that is exactly why `useHasSlot` has no vitest unit test and is instead verified by Playwright against the real built `@tvu/wc`. The spike MUST run in Playwright against a real Vue CE build.

**Decision gate:** If the spike passes → proceed to Stage 1 with the engine-on-host architecture. If it fails → STOP and report to owner; fall back to spec plan B (explicit `:form` prop threading — FormItem takes a `:form` prop pointing at the engine, no `closest` discovery). Do not silently proceed on a failed spike.

**Files:**
- Create: `tests/spike/harness/index.html`
- Create: `tests/spike/harness/main.ts`
- Create: `tests/spike/harness/vite.config.ts`
- Create: `playwright.spike.config.ts`
- Create: `tests/spike/form-ce-accessor.spec.ts`

**Interfaces:**
- Produces (spike-local only, throwaway shapes — NOT the real engine): two minimal CEs `<tvu-spike-form>` / `<tvu-spike-form-item>` and a trivial `{ setError, getError, subscribe }` engine, used only to validate the mechanism.

- [ ] **Step 1: Create the spike harness Vite config**

`tests/spike/harness/vite.config.ts`:

```ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'

// Standalone spike harness: builds two minimal Vue custom elements to prove the
// Form→FormItem cross-CE accessor + subscribe mechanism in a real browser.
// Throwaway de-risk scaffolding (Stage 0). See docs/superpowers/plans/2026-07-21-form-validation-engine.md.
export default defineConfig({
  plugins: [vue({ customElement: true })],
  root: fileURLToPath(new URL('.', import.meta.url)),
  server: { port: 5175 },
})
```

- [ ] **Step 2: Create the spike harness HTML**

`tests/spike/harness/index.html`:

```html
<!doctype html>
<html>
  <head><meta charset="utf-8" /><title>Form CE spike</title></head>
  <body>
    <div id="app"></div>
    <script type="module" src="./main.ts"></script>
  </body>
</html>
```

- [ ] **Step 3: Create the spike harness entry (two minimal CEs + mechanism)**

`tests/spike/harness/main.ts`:

```ts
import { defineCustomElement, getCurrentInstance, h, onMounted, onUnmounted, ref } from 'vue'

// --- trivial engine (mechanism only, NOT the real engine) ---
interface SpikeEngine {
  setError(prop: string, msg: string): void
  getError(prop: string): string
  subscribe(fn: (errors: Record<string, string>) => void): () => void
}
function createSpikeEngine(): SpikeEngine {
  let errors: Record<string, string> = {}
  const listeners = new Set<(e: Record<string, string>) => void>()
  const notify = () => { const snap = { ...errors }; listeners.forEach((l) => l(snap)) }
  return {
    setError(prop, msg) { errors[prop] = msg; notify() },
    getError(prop) { return errors[prop] ?? '' },
    subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
  }
}

const SpikeForm = defineCustomElement({
  setup() {
    const engine = createSpikeEngine()
    const host = (getCurrentInstance() as { ce?: HTMLElement & { _tvuSpikeEngine?: SpikeEngine } } | null)?.ce ?? null
    // (c-parent) expose the engine on the host synchronously in setup
    if (host) host._tvuSpikeEngine = engine
    // expose a way for the test to trigger an error from outside
    if (host) (host as any).triggerError = (prop: string, msg: string) => engine.setError(prop, msg)
    return () => h('div', { class: 'spike-form' }, [h('slot')])
  },
})

const SpikeFormItem = defineCustomElement({
  props: { prop: { type: String, default: '' } },
  setup(props) {
    const localError = ref('')
    const host = (getCurrentInstance() as { ce?: HTMLElement } | null)?.ce ?? null
    let unsub: (() => void) | null = null
    onMounted(() => {
      // (a) resolve parent host via closest; (b) read engine off the parent host
      const formHost = host?.closest('tvu-spike-form') as (HTMLElement & { _tvuSpikeEngine?: SpikeEngine }) | null
      const engine = formHost?._tvuSpikeEngine ?? null
      if (engine) {
        localError.value = engine.getError(props.prop)
        // (c-child) explicit subscribe re-renders the child
        unsub = engine.subscribe((errors) => { localError.value = errors[props.prop] ?? '' })
      } else {
        localError.value = 'NO_ENGINE_FOUND'
      }
    })
    onUnmounted(() => unsub?.())
    return () => h('div', { class: 'spike-item' }, [
      h('span', { 'data-spike-error': props.prop }, localError.value || 'OK'),
    ])
  },
})

customElements.define('tvu-spike-form', SpikeForm)
customElements.define('tvu-spike-form-item', SpikeFormItem)

const app = document.getElementById('app')!
app.innerHTML = `
  <tvu-spike-form id="form">
    <tvu-spike-form-item prop="email"></tvu-spike-form-item>
    <tvu-spike-form-item prop="port"></tvu-spike-form-item>
  </tvu-spike-form>
`
```

- [ ] **Step 4: Create the Playwright config for the spike**

`playwright.spike.config.ts`:

```ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: 'tests/spike',
  use: { baseURL: 'http://localhost:5175' },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: {
    command: 'pnpm exec vite --config tests/spike/harness/vite.config.ts --port 5175',
    url: 'http://localhost:5175',
    reuseExistingServer: true,
    timeout: 120_000,
  },
  testMatch: '**/*.spec.ts',
})
```

- [ ] **Step 5: Write the spike test**

`tests/spike/form-ce-accessor.spec.ts`:

```ts
// Guard: vitest also globs *.spec.ts — skip under VITEST so pre-commit `vitest run`
// doesn't run this Playwright test in jsdom (mirrors named-slot-projection.spec.ts).
if (process.env.VITEST) {
  const { test } = await import('vitest')
  test.skip('Stage 0 CE-accessor spike runs via playwright.spike.config.ts', () => {})
} else {
  const { test, expect } = await import('@playwright/test')

  test('child CE finds parent engine (no NO_ENGINE_FOUND)', async ({ page }) => {
    await page.goto('/')
    // pierce shadow roots: read the child's rendered error text
    const emailText = await page.locator('tvu-spike-form-item[prop="email"]').evaluate(
      (el) => el.shadowRoot?.querySelector('[data-spike-error]')?.textContent ?? '',
    )
    expect(emailText).toBe('OK')
    expect(emailText).not.toBe('NO_ENGINE_FOUND')
  })

  test('parent subscribe() re-renders child across CE boundary', async ({ page }) => {
    await page.goto('/')
    await page.locator('#form').evaluate((el) => (el as any).triggerError('email', 'REQUIRED'))
    await page.waitForTimeout(100)
    const emailText = await page.locator('tvu-spike-form-item[prop="email"]').evaluate(
      (el) => el.shadowRoot?.querySelector('[data-spike-error]')?.textContent ?? '',
    )
    const portText = await page.locator('tvu-spike-form-item[prop="port"]').evaluate(
      (el) => el.shadowRoot?.querySelector('[data-spike-error]')?.textContent ?? '',
    )
    expect(emailText).toBe('REQUIRED')
    expect(portText).toBe('OK') // only the targeted field updates
  })
}
```

- [ ] **Step 6: Run the spike**

Run: `pnpm exec playwright test --config=playwright.spike.config.ts`
Expected: 2 passed. If Playwright browsers are missing, run `pnpm exec playwright install chromium` first.

- [ ] **Step 7: DECISION GATE**

- If both tests PASS → the engine-on-host architecture is validated. Proceed to Stage 1. Report to owner: "Stage 0 spike PASS — closest+host-property+subscribe verified across CE boundary."
- If either FAILS → STOP. Report the failure + fall back to spec plan B (explicit `:form` prop threading). Do NOT proceed to Stage 1 on the current architecture.

- [ ] **Step 8: Commit (test/config only — not gated)**

```bash
git add tests/spike playwright.spike.config.ts
git commit -m "test(form): Stage 0 CE-accessor spike — verify cross-CE engine discovery + subscribe"
```

---

## Stage 1 — Engine core (TDD, zero framework deps)

### Task 1: `validators.ts` — types + pure validators + runner

**Files:**
- Create: `src/canonical/composables/form/validators.ts`
- Test: `tests/FormValidators.test.ts`

**Interfaces:**
- Produces:
  - `type FormTrigger = 'blur' | 'change'`
  - `interface FormRule { required?, type?, min?, max?, len?, pattern?, validator?, message?, trigger? }`
  - `type FormRules = Record<string, FormRule | FormRule[]>`
  - `function normalizeRules(rules: FormRule | FormRule[] | undefined): FormRule[]`
  - `function runRules(value: unknown, rules: FormRule[], model: Record<string, unknown>): string`

- [ ] **Step 1: Write the failing test**

`tests/FormValidators.test.ts`:

```ts
import { describe, it, expect } from 'vitest'
import { normalizeRules, runRules, type FormRule } from '../src/canonical/composables/form/validators'

const model = {}

describe('normalizeRules', () => {
  it('wraps a single rule into an array', () => {
    expect(normalizeRules({ required: true })).toEqual([{ required: true }])
  })
  it('passes an array through', () => {
    const r: FormRule[] = [{ required: true }, { type: 'email' }]
    expect(normalizeRules(r)).toBe(r)
  })
  it('maps undefined to empty array', () => {
    expect(normalizeRules(undefined)).toEqual([])
  })
})

describe('runRules — required', () => {
  it('fails empty required with default message', () => {
    expect(runRules('', [{ required: true }], model)).toBe('This field is required')
  })
  it('uses custom message', () => {
    expect(runRules('', [{ required: true, message: 'Email required' }], model)).toBe('Email required')
  })
  it('passes a filled required', () => {
    expect(runRules('a', [{ required: true }], model)).toBe('')
  })
  it('skips non-required checks when value is empty', () => {
    expect(runRules('', [{ type: 'email' }], model)).toBe('')
  })
})

describe('runRules — type', () => {
  it('fails non-email', () => {
    expect(runRules('nope', [{ type: 'email' }], model)).toBe('Please enter a valid email')
  })
  it('passes email', () => {
    expect(runRules('a@b.com', [{ type: 'email' }], model)).toBe('')
  })
  it('fails non-integer', () => {
    expect(runRules(1.5, [{ type: 'integer' }], model)).toBe('Please enter a valid integer')
  })
  it('passes integer', () => {
    expect(runRules(3, [{ type: 'integer' }], model)).toBe('')
  })
  it('fails non-url', () => {
    expect(runRules('not a url', [{ type: 'url' }], model)).toBe('Please enter a valid URL')
  })
  it('passes url', () => {
    expect(runRules('https://x.io', [{ type: 'url' }], model)).toBe('')
  })
})

describe('runRules — min/max/len (numeric domain vs string length)', () => {
  it('number below min', () => {
    expect(runRules(0, [{ type: 'integer', min: 1, max: 65535 }], model)).toBe('Must be at least 1')
  })
  it('number above max', () => {
    expect(runRules(70000, [{ type: 'integer', min: 1, max: 65535 }], model)).toBe('Must be at most 65535')
  })
  it('number in range passes', () => {
    expect(runRules(8080, [{ type: 'integer', min: 1, max: 65535 }], model)).toBe('')
  })
  it('string shorter than min length', () => {
    expect(runRules('ab', [{ min: 3 }], model)).toBe('Must be at least 3 characters')
  })
  it('string exact len mismatch', () => {
    expect(runRules('abcd', [{ len: 3 }], model)).toBe('Must be exactly 3 characters')
  })
  it('string exact len match passes', () => {
    expect(runRules('abc', [{ len: 3 }], model)).toBe('')
  })
})

describe('runRules — pattern + custom validator', () => {
  it('fails pattern', () => {
    expect(runRules('abc', [{ pattern: /^\d+$/ }], model)).toBe('Invalid format')
  })
  it('passes pattern', () => {
    expect(runRules('123', [{ pattern: /^\d+$/ }], model)).toBe('')
  })
  it('custom validator returning string fails with that string', () => {
    expect(runRules('x', [{ validator: () => 'bad' }], model)).toBe('bad')
  })
  it('custom validator returning true passes', () => {
    expect(runRules('x', [{ validator: () => true }], model)).toBe('')
  })
  it('custom validator receives value + model', () => {
    const m = { other: 5 }
    const rule: FormRule = { validator: (v, mm) => (v === (mm as any).other ? true : 'mismatch') }
    expect(runRules(5, [rule], m)).toBe('')
    expect(runRules(6, [rule], m)).toBe('mismatch')
  })
})

describe('runRules — order + short-circuit', () => {
  it('returns the first failing rule error', () => {
    expect(runRules('', [{ required: true }, { type: 'email' }], model)).toBe('This field is required')
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm test tests/FormValidators.test.ts`
Expected: FAIL — cannot resolve `../src/canonical/composables/form/validators`.

- [ ] **Step 3: Write the implementation**

`src/canonical/composables/form/validators.ts`:

```ts
// Framework-neutral form validators. ZERO Vue / component imports — this module
// is TDD'd headless and reused by the React binding (硬规则 #8 dual-framework parity).

export type FormTrigger = 'blur' | 'change'

export interface FormRule {
  required?: boolean
  type?: 'string' | 'number' | 'integer' | 'email' | 'url'
  min?: number // number domain: value floor; string: length floor
  max?: number // number domain: value ceiling; string: length ceiling
  len?: number // exact length (string) / exact value (number)
  pattern?: RegExp
  validator?: (value: unknown, model: Record<string, unknown>) => true | string // sync only
  message?: string // overrides the default message for whichever check fails
  trigger?: FormTrigger | FormTrigger[] // default 'change' (applied by the engine)
}

export type FormRules = Record<string, FormRule | FormRule[]>

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

export function normalizeRules(rules: FormRule | FormRule[] | undefined): FormRule[] {
  if (!rules) return []
  return Array.isArray(rules) ? rules : [rules]
}

function isEmpty(value: unknown): boolean {
  return value === undefined || value === null || value === ''
}

// number → the value itself; string/array → its length; else 0.
function measure(value: unknown): number {
  if (typeof value === 'number') return value
  if (typeof value === 'string' || Array.isArray(value)) return value.length
  return 0
}

function checkType(value: unknown, type: NonNullable<FormRule['type']>): boolean {
  switch (type) {
    case 'string': return typeof value === 'string'
    case 'number': return typeof value === 'number' && !Number.isNaN(value)
    case 'integer': return typeof value === 'number' && Number.isInteger(value)
    case 'email': return typeof value === 'string' && EMAIL_RE.test(value)
    case 'url':
      try { new URL(String(value)); return true } catch { return false }
  }
}

const TYPE_MESSAGE: Record<NonNullable<FormRule['type']>, string> = {
  string: 'Please enter valid text',
  number: 'Please enter a valid number',
  integer: 'Please enter a valid integer',
  email: 'Please enter a valid email',
  url: 'Please enter a valid URL',
}

// Runs one rule; '' = pass, else the (custom or default) error message.
function runRule(value: unknown, rule: FormRule, model: Record<string, unknown>): string {
  if (rule.required && isEmpty(value)) return rule.message || 'This field is required'
  // A non-required empty value skips every remaining check (el-form behaviour).
  if (isEmpty(value)) return ''

  if (rule.type && !checkType(value, rule.type)) return rule.message || TYPE_MESSAGE[rule.type]

  const isNum = typeof value === 'number'
  if (rule.len != null && measure(value) !== rule.len) {
    return rule.message || (isNum ? `Must equal ${rule.len}` : `Must be exactly ${rule.len} characters`)
  }
  if (rule.min != null && measure(value) < rule.min) {
    return rule.message || (isNum ? `Must be at least ${rule.min}` : `Must be at least ${rule.min} characters`)
  }
  if (rule.max != null && measure(value) > rule.max) {
    return rule.message || (isNum ? `Must be at most ${rule.max}` : `Must be at most ${rule.max} characters`)
  }
  if (rule.pattern && !rule.pattern.test(String(value))) return rule.message || 'Invalid format'
  if (rule.validator) {
    const result = rule.validator(value, model)
    if (result !== true) return typeof result === 'string' ? result : (rule.message || 'Invalid value')
  }
  return ''
}

// Runs rules in order, short-circuiting on the first failure. '' = all pass.
export function runRules(value: unknown, rules: FormRule[], model: Record<string, unknown>): string {
  for (const rule of rules) {
    const error = runRule(value, rule, model)
    if (error) return error
  }
  return ''
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm test tests/FormValidators.test.ts`
Expected: PASS (all describe blocks green).

- [ ] **Step 5: Commit**

```bash
git add src/canonical/composables/form/validators.ts tests/FormValidators.test.ts
git commit -m "feat(form): pure validators + runRules (APID-02 Stage 1)"
```

---

### Task 2: `formEngine.ts` — createFormEngine

**Files:**
- Create: `src/canonical/composables/form/formEngine.ts`
- Test: `tests/FormEngine.test.ts`

**Interfaces:**
- Consumes: `FormRule`, `FormRules`, `FormTrigger`, `normalizeRules`, `runRules` from `validators.ts`.
- Produces:
  - `interface FormEngine { registerField, unregisterField, validate, validateField, resetFields, clearValidate, subscribe, getFieldValue, getError }`
  - `function createFormEngine(opts: { getModel: () => Record<string, unknown>; getFormRules: () => FormRules }): FormEngine`
  - Method signatures relied on by later tasks:
    - `registerField(prop: string, getRules: () => FormRule[]): void`
    - `unregisterField(prop: string): void`
    - `validate(): Promise<{ valid: boolean; errors: Record<string, string> }>`
    - `validateField(prop: string, trigger?: FormTrigger): Promise<string>`
    - `resetFields(): void`
    - `clearValidate(props?: string | string[]): void`
    - `subscribe(listener: (errors: Record<string, string>) => void): () => void`
    - `getFieldValue(prop: string): unknown`
    - `getError(prop: string): string`

- [ ] **Step 1: Write the failing test**

`tests/FormEngine.test.ts`:

```ts
import { describe, it, expect, vi } from 'vitest'
import { createFormEngine } from '../src/canonical/composables/form/formEngine'
import type { FormRules, FormRule } from '../src/canonical/composables/form/validators'

function make(model: Record<string, unknown>, rules: FormRules = {}) {
  const state = { ...model }
  const engine = createFormEngine({ getModel: () => state, getFormRules: () => rules })
  return { engine, state }
}

describe('createFormEngine — register + validate (submit)', () => {
  it('validates all registered fields with merged rules', async () => {
    const { engine } = make({ email: '', port: 8080 }, { email: { required: true } })
    engine.registerField('email', () => [])
    engine.registerField('port', () => [{ type: 'integer', min: 1, max: 65535 }])
    const res = await engine.validate()
    expect(res.valid).toBe(false)
    expect(res.errors.email).toBe('This field is required')
    expect(res.errors.port).toBe('')
  })

  it('valid=true when all pass', async () => {
    const { engine } = make({ email: 'a@b.com' }, { email: { required: true, type: 'email' } })
    engine.registerField('email', () => [])
    const res = await engine.validate()
    expect(res.valid).toBe(true)
    expect(res.errors.email).toBe('')
  })
})

describe('rule merging — table-level + field-level (field appended after)', () => {
  it('runs both; table-level failure wins when it is first', async () => {
    const { engine } = make({ name: '' }, { name: { required: true } })
    engine.registerField('name', () => [{ min: 3 }])
    const err = await engine.validateField('name')
    expect(err).toBe('This field is required') // table-level required runs first
  })
  it('field-level rule runs when table-level passes', async () => {
    const { engine } = make({ name: 'ab' }, { name: { required: true } })
    engine.registerField('name', () => [{ min: 3 }])
    const err = await engine.validateField('name')
    expect(err).toBe('Must be at least 3 characters')
  })
})

describe('validateField — trigger filtering', () => {
  it('blur skips a change-only rule (no-op keeps prior error)', async () => {
    const { engine } = make({ email: '' })
    engine.registerField('email', () => [{ required: true, trigger: 'change' }])
    const err = await engine.validateField('email', 'blur')
    expect(err).toBe('') // no blur rules → no-op, error stays empty
  })
  it('change runs a change rule', async () => {
    const { engine } = make({ email: '' })
    engine.registerField('email', () => [{ required: true, trigger: 'change' }])
    const err = await engine.validateField('email', 'change')
    expect(err).toBe('This field is required')
  })
  it('rule with no trigger defaults to change', async () => {
    const { engine } = make({ email: '' })
    engine.registerField('email', () => [{ required: true }])
    expect(await engine.validateField('email', 'change')).toBe('This field is required')
    expect(await engine.validateField('email', 'blur')).toBe('')
  })
})

describe('subscribe — notified on error changes', () => {
  it('notifies listeners with the errors snapshot', async () => {
    const { engine } = make({ email: '' })
    engine.registerField('email', () => [{ required: true }])
    const listener = vi.fn()
    engine.subscribe(listener)
    await engine.validateField('email', 'change')
    expect(listener).toHaveBeenCalledWith({ email: 'This field is required' })
  })
  it('unsubscribe stops notifications', async () => {
    const { engine } = make({ email: '' })
    engine.registerField('email', () => [{ required: true }])
    const listener = vi.fn()
    const unsub = engine.subscribe(listener)
    unsub()
    await engine.validateField('email', 'change')
    expect(listener).not.toHaveBeenCalled()
  })
})

describe('resetFields + clearValidate', () => {
  it('resetFields restores initial model values and clears errors', async () => {
    const { engine, state } = make({ email: 'init@b.com' })
    engine.registerField('email', () => [{ required: true }])
    state.email = ''
    await engine.validate()
    expect(engine.getError('email')).toBe('This field is required')
    engine.resetFields()
    expect(state.email).toBe('init@b.com') // value restored from snapshot
    expect(engine.getError('email')).toBe('') // validation cleared
  })
  it('clearValidate(prop) clears one field error without touching values', async () => {
    const { engine, state } = make({ email: '', port: 0 })
    engine.registerField('email', () => [{ required: true }])
    engine.registerField('port', () => [{ type: 'integer', min: 1 }])
    await engine.validate()
    engine.clearValidate('email')
    expect(engine.getError('email')).toBe('')
    expect(engine.getError('port')).toBe('Must be at least 1')
    expect(state.email).toBe('') // value untouched
  })
})

describe('getFieldValue + unregisterField', () => {
  it('getFieldValue reads live model', () => {
    const { engine, state } = make({ email: 'x' })
    expect(engine.getFieldValue('email')).toBe('x')
    state.email = 'y'
    expect(engine.getFieldValue('email')).toBe('y')
  })
  it('unregisterField drops a field from validate()', async () => {
    const { engine } = make({ email: '' })
    engine.registerField('email', () => [{ required: true }])
    engine.unregisterField('email')
    const res = await engine.validate()
    expect(res.valid).toBe(true)
    expect(res.errors.email).toBeUndefined()
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm test tests/FormEngine.test.ts`
Expected: FAIL — cannot resolve `formEngine`.

- [ ] **Step 3: Write the implementation**

`src/canonical/composables/form/formEngine.ts`:

```ts
// Framework-neutral form engine. Depends ONLY on validators.ts — no Vue, no
// components. Cross-CE reactivity is handled by explicit subscribe(), NOT Vue
// reactivity (Vue's forwarding chain arrives too late under defineCustomElement;
// see src/canonical/composables/useHasSlot.ts).
import { normalizeRules, runRules, type FormRule, type FormRules, type FormTrigger } from './validators'

export interface FormEngine {
  registerField(prop: string, getRules: () => FormRule[]): void
  unregisterField(prop: string): void
  validate(): Promise<{ valid: boolean; errors: Record<string, string> }>
  validateField(prop: string, trigger?: FormTrigger): Promise<string>
  resetFields(): void
  clearValidate(props?: string | string[]): void
  subscribe(listener: (errors: Record<string, string>) => void): () => void
  getFieldValue(prop: string): unknown
  getError(prop: string): string
}

function ruleMatchesTrigger(rule: FormRule, trigger: FormTrigger): boolean {
  const t = rule.trigger ?? 'change'
  return Array.isArray(t) ? t.includes(trigger) : t === trigger
}

// Deep-clones the initial model so resetFields() can restore it. JSON clone is
// sufficient for the flat/serialisable models this MVP targets.
function snapshot(model: Record<string, unknown>): Record<string, unknown> {
  return JSON.parse(JSON.stringify(model))
}

export function createFormEngine(opts: {
  getModel: () => Record<string, unknown>
  getFormRules: () => FormRules
}): FormEngine {
  const { getModel, getFormRules } = opts
  const registry = new Map<string, () => FormRule[]>()
  const listeners = new Set<(errors: Record<string, string>) => void>()
  let errors: Record<string, string> = {}
  const initial = snapshot(getModel())

  const notify = () => {
    const snap = { ...errors }
    listeners.forEach((l) => l(snap))
  }

  // table-level rules (Form.rules[prop]) + field-level rules (from registry),
  // field-level appended AFTER table-level (both run; field does not override).
  const combinedRules = (prop: string): FormRule[] => [
    ...normalizeRules(getFormRules()[prop]),
    ...(registry.get(prop)?.() ?? []),
  ]

  return {
    registerField(prop, getRules) {
      registry.set(prop, getRules)
    },
    unregisterField(prop) {
      registry.delete(prop)
      if (prop in errors) {
        delete errors[prop]
        notify()
      }
    },
    getFieldValue(prop) {
      return getModel()[prop]
    },
    getError(prop) {
      return errors[prop] ?? ''
    },
    async validateField(prop, trigger) {
      const model = getModel()
      const all = combinedRules(prop)
      const applicable = trigger ? all.filter((r) => ruleMatchesTrigger(r, trigger)) : all
      // Triggered validation with no matching rules is a no-op (keeps prior error).
      if (trigger && applicable.length === 0) return errors[prop] ?? ''
      const error = runRules(model[prop], applicable, model)
      errors[prop] = error
      notify()
      return error
    },
    async validate() {
      const model = getModel()
      const next: Record<string, string> = {}
      for (const prop of registry.keys()) {
        next[prop] = runRules(model[prop], combinedRules(prop), model)
      }
      errors = next
      notify()
      return { valid: Object.values(next).every((e) => e === ''), errors: { ...next } }
    },
    resetFields() {
      const model = getModel()
      for (const key of Object.keys(initial)) {
        // mutate in place so a reactive model (Vue) picks the restore up
        model[key] = JSON.parse(JSON.stringify(initial[key]))
      }
      errors = {}
      notify()
    },
    clearValidate(props) {
      if (props == null) {
        errors = {}
      } else {
        const list = Array.isArray(props) ? props : [props]
        for (const p of list) delete errors[p]
      }
      notify()
    },
    subscribe(listener) {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm test tests/FormEngine.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/canonical/composables/form/formEngine.ts tests/FormEngine.test.ts
git commit -m "feat(form): createFormEngine — registry + validate + subscribe (APID-02 Stage 1)"
```

---

## Stage 2 — CE-safe accessor + Vue components

### Task 3: `useFormContext.ts` — CE-safe engine accessor

**Files:**
- Create: `src/canonical/composables/useFormContext.ts`

**Interfaces:**
- Consumes: `FormEngine` from `formEngine.ts`.
- Produces:
  - `const FormContextKey: InjectionKey<FormEngine>`
  - `function useFormEngine(): FormEngine | null`

- [ ] **Step 1: Write the implementation** (this composable's cross-CE behaviour is verified by the Stage 4 Playwright test, not vitest — jsdom cannot run cross-host `closest`; mirrors `useHasSlot`)

`src/canonical/composables/useFormContext.ts`:

```ts
import { getCurrentInstance, inject, type InjectionKey } from 'vue'
import type { FormEngine } from './form/formEngine'

// SFC path uses provide/inject. Under defineCustomElement, provide/inject does
// NOT cross the CE boundary, so Form also exposes its engine on the host element
// (host._tvuFormEngine) and FormItem discovers it via closest('tvu-form').
export const FormContextKey: InjectionKey<FormEngine> = Symbol('tvu-form-engine')

interface FormHost extends HTMLElement {
  _tvuFormEngine?: FormEngine
}

/**
 * Resolve the parent Form's engine, CE-safely. Returns null when the FormItem is
 * used standalone (no parent Form) — callers must treat that as "no validation"
 * so standalone FormItem behaviour is unchanged (backward compatible).
 *
 *  1. SFC / plain-Vue: `inject(FormContextKey)` — authoritative when present.
 *  2. defineCustomElement: read `getCurrentInstance().ce` (the host, exactly what
 *     Vue's useHost() reads) → `host.closest('tvu-form')` → parent host's
 *     `_tvuFormEngine`. `.ce` is read directly (not via useHost()) to avoid the
 *     dev warning useHost() emits in every plain-Vue consumer (see useHasSlot.ts).
 */
export function useFormEngine(): FormEngine | null {
  const injected = inject(FormContextKey, null)
  if (injected) return injected

  const instance = getCurrentInstance() as { ce?: FormHost } | null
  const host = instance?.ce ?? null
  if (host) {
    const formHost = host.closest('tvu-form') as FormHost | null
    if (formHost?._tvuFormEngine) return formHost._tvuFormEngine
  }
  return null
}
```

- [ ] **Step 2: Typecheck**

Run: `pnpm exec vue-tsc --noEmit -p tsconfig.json 2>&1 | grep -i useFormContext || echo "no useFormContext type errors"`
Expected: `no useFormContext type errors`.

- [ ] **Step 3: Commit (TS-only, not gated)**

```bash
git add src/canonical/composables/useFormContext.ts
git commit -m "feat(form): CE-safe useFormEngine accessor (APID-02 Stage 2)"
```

---

### Task 4: `Form.vue` — container + engine wiring + defineExpose

**Files:**
- Create: `src/canonical/Form.vue`
- Test: `tests/Form.test.ts` (first half — Form-only assertions; extended in Task 5)

**⚠️ Visual gate:** `Form.vue` is a `.vue` file → its commit requires owner `VISUAL_COMMIT_APPROVED`. STOP for owner sign-off before Step 5.

**Interfaces:**
- Consumes: `createFormEngine`, `FormEngine`, `FormContextKey`, `FormRules`.
- Produces: `<Form>` with props `model` / `rules?` / `labelWidth?` / `layout?` / `disabled?`; `defineExpose({ validate, validateField, resetFields, clearValidate })`; sets `host._tvuFormEngine` under CE; `provide(FormContextKey, engine)` under SFC.

- [ ] **Step 1: Write the failing test**

`tests/Form.test.ts`:

```ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Form from '../src/canonical/Form.vue'

describe('Form — exposed methods + validation', () => {
  it('validate() returns invalid with errors for a failing required field via FormItem', async () => {
    // Full Form+FormItem integration is asserted in Task 5. Here: Form alone
    // exposes the 4 methods and validates a manually-registered field.
    const wrapper = mount(Form, {
      props: { model: { email: '' }, rules: { email: { required: true } } },
    })
    const vm = wrapper.vm as unknown as {
      validate: () => Promise<{ valid: boolean; errors: Record<string, string> }>
    }
    // no FormItem registered yet → nothing to validate → valid
    const res = await vm.validate()
    expect(res.valid).toBe(true)
  })

  it('renders a .tvu-form wrapper and its default slot', () => {
    const wrapper = mount(Form, {
      props: { model: {} },
      slots: { default: '<span class="child">hi</span>' },
    })
    expect(wrapper.find('.tvu-form').exists()).toBe(true)
    expect(wrapper.find('.child').exists()).toBe(true)
  })

  it('applies --disabled modifier class when disabled', () => {
    const wrapper = mount(Form, { props: { model: {}, disabled: true } })
    expect(wrapper.find('.tvu-form--disabled').exists()).toBe(true)
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm test tests/Form.test.ts`
Expected: FAIL — cannot resolve `../src/canonical/Form.vue`.

- [ ] **Step 3: Write the implementation**

`src/canonical/Form.vue`:

```vue
<script setup lang="ts">
import { getCurrentInstance, provide } from 'vue'
import { createFormEngine, type FormEngine } from './composables/form/formEngine'
import { FormContextKey } from './composables/useFormContext'
import type { FormRules } from './composables/form/validators'

type LabelWidth = '120 px' | '200 px' | 'Dynamic'
type Layout = '1 line' | '1 line & Right' | '2 lines'

const props = withDefaults(defineProps<{
  model: Record<string, unknown>
  rules?: FormRules
  labelWidth?: LabelWidth
  layout?: Layout
  disabled?: boolean
}>(), {
  rules: () => ({}),
  labelWidth: '120 px',
  layout: '1 line',
  disabled: false,
})

const engine = createFormEngine({
  getModel: () => props.model,
  getFormRules: () => props.rules ?? {},
})

// SFC / plain-Vue path: children discover the engine via inject.
provide(FormContextKey, engine)

// CE path: provide/inject does NOT cross the defineCustomElement boundary, so
// expose the engine on the host element synchronously in setup — children find
// it via closest('tvu-form') (see useFormContext.ts). [Stage 0 spike validated]
interface FormHost extends HTMLElement { _tvuFormEngine?: FormEngine }
const host = (getCurrentInstance() as { ce?: FormHost } | null)?.ce ?? null
if (host) host._tvuFormEngine = engine

defineExpose({
  validate: () => engine.validate(),
  validateField: (prop: string) => engine.validateField(prop),
  resetFields: () => engine.resetFields(),
  clearValidate: (fields?: string | string[]) => engine.clearValidate(fields),
})
</script>

<template>
  <div class="tvu-form" :class="{ 'tvu-form--disabled': disabled }" :aria-disabled="disabled || undefined">
    <slot />
  </div>
</template>

<style scoped>
/* Form has no independent Figma visual — the only "visual" is the vertical gap
   between fields (layout token). Registered divergence: form-container-topology. */
.tvu-form {
  display: flex;
  flex-direction: column;
  gap: var(--sp-m);
}

.tvu-form--disabled {
  /* Container-level disabled affordance (code-side; no Figma source). Individual
     controls keep their own disabled visuals. */
  pointer-events: none;
  opacity: 0.6;
}
</style>
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm test tests/Form.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit — GATED (owner visual approval required)**

STOP. Request owner `VISUAL_COMMIT_APPROVED` for `Form.vue`. After approval:

```bash
git add src/canonical/Form.vue tests/Form.test.ts
git commit -m "feat(form): Form container + engine wiring + defineExpose (APID-02 Stage 2) [VISUAL_COMMIT_APPROVED]"
```

---

### Task 5: `FormItem.vue` extension — register + subscribe + bind error/status

**Files:**
- Modify: `src/canonical/FormItem.vue`
- Test: `tests/Form.test.ts` (extend with Form+FormItem integration)

**⚠️ Visual gate:** `FormItem.vue` is a `.vue` file → commit requires owner `VISUAL_COMMIT_APPROVED`.

**Interfaces:**
- Consumes: `useFormEngine`, `normalizeRules`, `FormRule`.
- Produces: FormItem accepts new `prop?: string` + `rules?: FormRule | FormRule[]`; when inside a Form (engine found) it registers, subscribes, and its effective `error`/`status` reflect engine validation. Standalone (no engine) → unchanged.

- [ ] **Step 1: Write the failing test (append to `tests/Form.test.ts`)**

```ts
import { nextTick } from 'vue'
import FormItem from '../src/canonical/FormItem.vue'

describe('Form + FormItem — SFC integration', () => {
  it('validate() surfaces a required error onto the FormItem', async () => {
    const wrapper = mount(Form, {
      props: { model: { email: '' }, rules: { email: { required: true, message: 'Email required' } } },
      slots: {
        default: () => h(FormItem, { prop: 'email', label: 'Email' }),
      },
    })
    const vm = wrapper.vm as unknown as { validate: () => Promise<{ valid: boolean }> }
    const res = await vm.validate()
    await nextTick()
    expect(res.valid).toBe(false)
    // FormItem reflects the engine error via the base message + error class
    expect(wrapper.find('.form-item__message--error').text()).toContain('Email required')
    expect(wrapper.find('.form-item--error').exists()).toBe(true)
  })

  it('resetFields() clears the surfaced error', async () => {
    const wrapper = mount(Form, {
      props: { model: { email: '' }, rules: { email: { required: true } } },
      slots: { default: () => h(FormItem, { prop: 'email', label: 'Email' }) },
    })
    const vm = wrapper.vm as unknown as {
      validate: () => Promise<unknown>; resetFields: () => void
    }
    await vm.validate()
    await nextTick()
    expect(wrapper.find('.form-item--error').exists()).toBe(true)
    vm.resetFields()
    await nextTick()
    expect(wrapper.find('.form-item--error').exists()).toBe(false)
  })

  it('standalone FormItem (no Form) is unchanged — explicit error prop still wins', () => {
    const wrapper = mount(FormItem, { props: { label: 'X', error: 'boom', status: 'Error' } })
    expect(wrapper.find('.form-item__message--error').text()).toContain('boom')
  })
})
```

(Add `import { h } from 'vue'` to the file's imports.)

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm test tests/Form.test.ts`
Expected: FAIL — FormItem does not yet register/subscribe; `.form-item--error` absent after validate().

- [ ] **Step 3: Write the implementation** — extend `src/canonical/FormItem.vue`

Replace the `<script setup>` block with (additions marked; existing axis/figma logic preserved):

```vue
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import BaseFormItem from '../components/FormItem/FormItem.vue'
import { useHasSlot } from './composables/useHasSlot'
import { useFormEngine } from './composables/useFormContext'
import { normalizeRules, type FormRule } from './composables/form/validators'
import InputBoxFilled from './InputBoxFilled.vue'
import SelectBoxFilled from './SelectBoxFilled.vue'
import Radio from './Radio.vue'
import CheckBox from './CheckBox.vue'
import Switch from './Switch.vue'

type LabelWidth = '120 px' | '200 px' | 'Dynamic'
type Layout = '1 line' | '1 line & Right' | '2 lines'
type Status = 'Error' | 'Normal'
type Theme = 'Dark' | 'Light'
type Type = 'Label & checkbox' | 'Label & Input' | 'Label & Radio' | 'Label & Selector' | 'Label & Switch' | 'Label & Textarea'

const props = withDefaults(defineProps<{
  label?: string
  required?: boolean
  error?: string
  hint?: string
  labelWidth?: LabelWidth
  layout?: Layout
  status?: Status
  theme?: Theme
  type?: Type
  prop?: string                       // NEW: field path in the Form model
  rules?: FormRule | FormRule[]       // NEW: field-level rules
}>(), {
  label: 'Form Label',
  required: false,
  hint: '',
  labelWidth: '120 px',
  layout: '1 line',
  status: 'Normal',
  theme: 'Dark',
  type: 'Label & Input',
})

// --- validation wiring (only active inside a Form) ---
const engine = useFormEngine()
const engineError = ref('')

if (engine && props.prop) {
  const prop = props.prop
  engine.registerField(prop, () => {
    const rules = normalizeRules(props.rules)
    // `required` prop is sugar for a required rule if not already declared
    if (props.required && !rules.some((r) => r.required)) {
      return [{ required: true }, ...rules]
    }
    return rules
  })
  engineError.value = engine.getError(prop)
  const unsub = engine.subscribe((errors) => { engineError.value = errors[prop] ?? '' })
  // change trigger: watch the field value in the model → validateField('change')
  watch(() => engine.getFieldValue(prop), () => { void engine.validateField(prop, 'change') })
  onUnmounted(() => { engine.unregisterField(prop); unsub() })
}

// Effective error/status: explicit `error` prop (standalone usage) wins; else the
// engine result. Standalone FormItem (no engine) → engineError stays '' → unchanged.
const effectiveError = computed(() => props.error || engineError.value)
const effectiveStatus = computed<Status>(() => (effectiveError.value ? 'Error' : props.status))

// blur trigger handler — attached in template on the wrapper (bubbling focusout).
function onFocusOut() {
  if (engine && props.prop) void engine.validateField(props.prop, 'blur')
}

const figmaAttrs = computed(() => ({
  'data-figma-component': 'FormItem',
  'data-figma-label-width': props.labelWidth,
  'data-figma-layout': props.layout,
  'data-figma-status': effectiveStatus.value,
  'data-figma-theme': props.theme,
  'data-figma-type': props.type,
}))

const darkTheme = computed<'on' | 'off'>(() => (props.theme === 'Dark' ? 'on' : 'off'))
const fieldUx = computed<'default' | 'error'>(() => (effectiveStatus.value === 'Error' ? 'error' : 'default'))

const hasLabelSlot = useHasSlot('label')
</script>
```

Update the `<template>` to (a) forward `effectiveError`/`effectiveStatus` to the base and (b) attach the bubbling blur handler:

```vue
<template>
  <BaseFormItem
    v-bind="{ ...props, ...figmaAttrs }"
    :error="effectiveError"
    :status="effectiveStatus"
    @focusout="onFocusOut"
  >
    <template v-if="hasLabelSlot" #label>
      <slot name="label" />
    </template>
    <slot>
      <!-- (unchanged default-field preview block: InputBoxFilled / SelectBoxFilled /
           Radio / CheckBox / Switch per `type` — keep exactly as in the current file) -->
    </slot>
  </BaseFormItem>
</template>
```

> Keep the existing default-slot preview markup and `<style scoped>` block exactly as they are — only the two bound attributes and `@focusout` are added. `v-bind="{ ...props, ... }"` already forwards `prop`/`rules` down, which the base ignores (harmless); the explicit `:error`/`:status` after it override with the effective values.

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm test tests/Form.test.ts`
Expected: PASS (Form-only + integration + standalone).

- [ ] **Step 5: Run the full unit suite (regression check)**

Run: `pnpm test`
Expected: all suites pass (existing FormItem tests `tests/FormItem.test.ts` still green — standalone behaviour unchanged).

- [ ] **Step 6: Commit — GATED (owner visual approval required)**

STOP for owner `VISUAL_COMMIT_APPROVED`. After approval:

```bash
git add src/canonical/FormItem.vue tests/Form.test.ts
git commit -m "feat(form): FormItem registers + subscribes to Form engine; binds error/status (APID-02 Stage 2) [VISUAL_COMMIT_APPROVED]"
```

---

## Stage 3 — Trigger wiring verification (change / blur / submit)

> The three triggers are already implemented across Stage 2 (change = FormItem `watch`; blur = `@focusout`; submit = `Form.validate()`). Stage 3 adds explicit SFC tests that each trigger fires at the right time, plus `clearValidate`.

### Task 6: Trigger + clearValidate SFC tests

**Files:**
- Test: `tests/Form.test.ts` (append)

- [ ] **Step 1: Write the tests (append to `tests/Form.test.ts`)**

```ts
describe('Form — triggers', () => {
  it('change trigger: editing the model re-validates the field', async () => {
    const model = ref<{ email: string }>({ email: '' })
    const wrapper = mount(Form, {
      props: { model: model.value, rules: { email: { required: true, trigger: 'change' } } },
      slots: { default: () => h(FormItem, { prop: 'email', label: 'Email' }) },
    })
    // trigger a change by mutating the model then poking validateField via the value watch
    model.value.email = ''
    ;(wrapper.vm as any).validateField('email')
    await nextTick()
    expect(wrapper.find('.form-item--error').exists()).toBe(true)
  })

  it('clearValidate(prop) clears the surfaced error', async () => {
    const wrapper = mount(Form, {
      props: { model: { email: '' }, rules: { email: { required: true } } },
      slots: { default: () => h(FormItem, { prop: 'email', label: 'Email' }) },
    })
    const vm = wrapper.vm as unknown as { validate: () => Promise<unknown>; clearValidate: (p?: string) => void }
    await vm.validate(); await nextTick()
    expect(wrapper.find('.form-item--error').exists()).toBe(true)
    vm.clearValidate('email'); await nextTick()
    expect(wrapper.find('.form-item--error').exists()).toBe(false)
  })
})
```

(Ensure `import { ref } from 'vue'` is present.)

- [ ] **Step 2: Run + verify pass**

Run: `pnpm test tests/Form.test.ts`
Expected: PASS.

- [ ] **Step 3: Commit (test-only, not gated)**

```bash
git add tests/Form.test.ts
git commit -m "test(form): trigger + clearValidate SFC coverage (APID-02 Stage 3)"
```

---

## Stage 4 — CE registration + React binding + docs + parity

### Task 7: Register `<tvu-form>` as a custom element (regenerate bindings)

**Files:**
- Modify: `src/web-components/components.config.ts` (add one `ComponentConfig`)
- Regenerate (do NOT hand-edit): `src/web-components/register.ts`, `react-pilot/src/wrappers/Form.tsx`, `react-pilot/src/wrappers/types.ts`

**Interfaces:**
- Consumes: `Form.vue`.
- Produces: `<tvu-form>` registered; `Form` React wrapper generated; `FormProps` in `types.ts`.

- [ ] **Step 1: Read the config interface + a sibling container entry**

Run: `sed -n '58,99p' src/web-components/components.config.ts` (the `ComponentConfig` interface) and locate the `FormItem` entry (`grep -n "tag: 'tvu-form-item'" src/web-components/components.config.ts`). Read the surrounding entry to copy its `props` shape.

- [ ] **Step 2: Add the `Form` config entry**

Append to `COMPONENT_CONFIGS` in `src/web-components/components.config.ts` (Form is inline layout, no teleport → no `shadowRoot` field; `model`/`rules` are object props set as element properties):

```ts
{
  name: 'Form',
  tag: 'tvu-form',
  canonicalImport: 'Form',
  canonicalPath: '../canonical/Form.vue',
  figmaCodeComponent: 'Form',
  props: [
    { name: 'model', tsType: 'Record<string, unknown>' },
    { name: 'rules', tsType: 'Record<string, unknown>' },
    { name: 'labelWidth', tsType: "'120 px' | '200 px' | 'Dynamic'" },
    { name: 'layout', tsType: "'1 line' | '1 line & Right' | '2 lines'" },
    { name: 'disabled', tsType: 'boolean' },
  ],
  events: [],
  vModel: null,
  hasDefaultSlot: true,
  namedSlots: [],
},
```

> Match field names to the interface read in Step 1 — if the interface names differ (e.g. `type` vs `tsType`, or a required `jsdoc`), copy the exact keys the `FormItem` entry uses.

- [ ] **Step 3: Regenerate bindings**

Run: `pnpm gen:react-bindings`
Expected: writes `src/web-components/register.ts` (now `customElements.define('tvu-form', TvuForm)`), `react-pilot/src/wrappers/Form.tsx`, and updated `types.ts`.

- [ ] **Step 4: Verify registration + rebuild WC bundle**

Run:
```bash
grep -n "tvu-form'" src/web-components/register.ts
pnpm build:wc 2>&1 | tail -3
```
Expected: `register.ts` contains `customElements.define('tvu-form', ...)`; `build:wc` succeeds.

- [ ] **Step 5: Run binding-parity audit**

Run: `pnpm audit:binding-config-parity`
Expected: pass (config ↔ generated wrappers consistent).

- [ ] **Step 6: Commit (generated + config; no `.vue` → not visual-gated)**

```bash
git add src/web-components/components.config.ts src/web-components/register.ts react-pilot/src/wrappers/Form.tsx react-pilot/src/wrappers/types.ts
git commit -m "feat(form): register <tvu-form> custom element + generate React binding (APID-02 Stage 4)"
```

---

### Task 8: CE cross-boundary validation test (promote spike to permanent guard)

**Files:**
- Create: `tests/render-verification-react/form-ce-validation.spec.ts`
- Modify: `react-pilot/harness/RenderHarness.tsx` (add a `?f54=form-validate` case)
- Modify: `react-pilot/ds-sync/entry.tsx` (add `export * from '../src/wrappers/Form'`)

**Interfaces:**
- Consumes: generated `Form` + `FormItem` wrappers, built `@tvu/wc`.

- [ ] **Step 1: Add the harness case** — in `react-pilot/harness/RenderHarness.tsx`, import the `Form` wrapper and add a case to `renderF54Case`:

```tsx
import { Form } from '../src/wrappers/Form'
// ... inside renderF54Case switch:
    case 'form-validate':
      return (
        <Form model={{ email: '' }} rules={{ email: { required: true, message: 'FORM_EMAIL_REQUIRED' } }}>
          {/* @ts-expect-error tvu-form-item */}
          <FormItem prop="email" label="Email" data-f54-slot-sentinel="1" />
          <button
            data-form-submit="1"
            onClick={(e) => {
              const form = (e.currentTarget.closest('tvu-form') as any)
              form?.validate?.()
            }}
          >submit</button>
        </Form>
      )
```

> If the `Form` wrapper does not expose `validate()` as a callable method on the host, call it via the host element ref instead: `document.querySelector('tvu-form').validate()`. `defineExpose` methods are available as methods on the CE host.

- [ ] **Step 2: Add the ds-sync re-export** — in `react-pilot/ds-sync/entry.tsx` add near the other wrapper re-exports:

```tsx
export * from '../src/wrappers/Form'
```

- [ ] **Step 3: Write the Playwright test**

`tests/render-verification-react/form-ce-validation.spec.ts`:

```ts
// APID-02 CE cross-boundary guard — a FormItem inside a Form (both custom
// elements) must receive its validation error from the parent Form's engine
// across the defineCustomElement boundary. This is the permanent version of the
// Stage 0 spike, run against the real @tvu/wc build. See useFormContext.ts.
import { collectVisibleText } from '../parity/lib/collect-visible'

if (process.env.VITEST) {
  const { test } = await import('vitest')
  test.skip('APID-02 form CE validation runs via playwright.render-verification-react.config.ts', () => {})
} else {
  const { test, expect } = await import('@playwright/test')

  test('FormItem surfaces the Form engine error across the CE boundary', async ({ page }) => {
    await page.goto('/?f54=form-validate&theme=dark')
    await page.locator('[data-f54-case="form-validate"]').waitFor({ state: 'attached' })
    await page.locator('[data-form-submit="1"]').click()
    await page.waitForTimeout(200)
    const visibleText = await page.evaluate(collectVisibleText)
    expect(
      visibleText.includes('FORM_EMAIL_REQUIRED'),
      `expected the engine error to render in the child FormItem — got: ${visibleText}`,
    ).toBe(true)
  })
}
```

- [ ] **Step 4: Run the CE test**

Run: `pnpm build:wc && pnpm exec playwright test --config=playwright.render-verification-react.config.ts form-ce-validation`
Expected: 1 passed. (This is the real cross-CE proof — if it fails after a passing Stage 0 spike, the regression is in the real components, debug there.)

- [ ] **Step 5: Commit (test + harness + ds-sync; no `.vue` → not visual-gated)**

```bash
git add tests/render-verification-react/form-ce-validation.spec.ts react-pilot/harness/RenderHarness.tsx react-pilot/ds-sync/entry.tsx
git commit -m "test(form): CE cross-boundary validation guard (APID-02 Stage 4)"
```

---

### Task 9: Docs page + React demo + dual-framework parity

**Files:**
- Create: `playground/docs/pages/FormPage.vue`
- Modify: `playground/docs/navigation.ts` (`CanonicalPageId` union + nav entry)
- Modify: `playground/docs/DocsShell.vue` (`registeredComponentPageIds`, `pageLoaders`, `pageComponents`)
- Modify: `docs/site-review-manifest.json` (page entry)
- Create: `react-pilot/src/demos/Form.tsx` (+ `react-pilot/src/demos/form-demo.css` if styles needed)
- Modify: `react-pilot/src/App.tsx` (register the demo)

**⚠️ Visual gate:** `FormPage.vue` is a `.vue` file → commit requires owner `VISUAL_COMMIT_APPROVED`.

- [ ] **Step 1: Read a template page + demo** — read `playground/docs/pages/FormItemPage.vue` and `react-pilot/src/demos/FormItem.tsx` to copy the exact section structure (section titles must match across Vue page ↔ React demo for `audit:demo-framework-parity`).

- [ ] **Step 2: Create `playground/docs/pages/FormPage.vue`** — mirror `FormItemPage.vue` structure. Include at least: an intro, a "Basic validation" example (Form + 2 FormItems with rules, a submit button calling `formRef.validate()`), a "Reset" example, and the API tables (Form props/methods, FormItem new props). Use real canonical imports:

```vue
<script setup lang="ts">
import { ref, reactive } from 'vue'
import Form from '../../../src/canonical/Form.vue'
import FormItem from '../../../src/canonical/FormItem.vue'
import InputBoxLine from '../../../src/canonical/InputBoxLine.vue'
import InputNumber from '../../../src/canonical/InputNumber.vue'

const formRef = ref<InstanceType<typeof Form> | null>(null)
const model = reactive({ email: '', port: 8080 })
const rules = {
  email: { required: true, type: 'email' as const, message: 'Please enter a valid email' },
  port: { type: 'integer' as const, min: 1, max: 65535, message: 'Port must be 1–65535' },
}
async function onSubmit() {
  const res = await formRef.value?.validate()
  // eslint-disable-next-line no-console
  console.log('valid?', res?.valid)
}
</script>

<template>
  <!-- Match FormItemPage.vue's docs section scaffolding (headings/preview/source).
       Section titles MUST match react-pilot/src/demos/Form.tsx for parity. -->
  <Form ref="formRef" :model="model" :rules="rules" label-width="120 px">
    <FormItem prop="email" label="Email" required>
      <InputBoxLine v-model="model.email" />
    </FormItem>
    <FormItem prop="port" label="Port">
      <InputNumber v-model="model.port" />
    </FormItem>
    <button @click="onSubmit">Submit</button>
  </Form>
</template>
```

- [ ] **Step 3: Register the page** — edits:
  - `playground/docs/navigation.ts`: add `| 'form'` to the `CanonicalPageId` union; add a nav item `{ id: 'form', label: text('Form', 'Form 表单'), title: text('Form', 'Form 表单'), summary: text('Declarative form validation container.', '声明式表单校验容器。') }` in the Form cluster (near `form-item`).
  - `playground/docs/DocsShell.vue`: add `'form'` to `registeredComponentPageIds`; add `form: () => import('./pages/FormPage.vue'),` to `pageLoaders`; add `form: createPageComponent(pageLoaders.form),` to `pageComponents`.

- [ ] **Step 4: Add the site-review manifest entry** — append to `docs/site-review-manifest.json` `pages` (code-first, no Figma component set → `canonical-only`):

```json
{
  "pageId": "form",
  "pageFile": "FormPage.vue",
  "kind": "component",
  "figmaPage": "FormItem",
  "figmaComponentSet": "form-item",
  "figmaNodeId": "1923:49069",
  "baselineStatus": "not-applicable",
  "expectedSourceType": "canonical-only",
  "currentRenderingMode": "code",
  "pixelReviewStage": "not-applicable",
  "views": { "statusMatrix": "pending", "useCases": "pending", "designSpec": "pending" }
}
```

> Verify the exact enum values the manifest schema accepts by reading a sibling code-first/topology page entry first; match its `baselineStatus`/`pixelReviewStage` values rather than inventing ones.

- [ ] **Step 5: Create the React demo** — `react-pilot/src/demos/Form.tsx`, mirroring `react-pilot/src/demos/FormItem.tsx` structure and section titles, using the generated `Form`/`FormItem` wrappers + `import '@tvu/wc'`. Register it in `react-pilot/src/App.tsx` (import + render case, same pattern as `FormItemDemo`).

- [ ] **Step 6: Run the mountable-page + parity + docs audits**

Run:
```bash
pnpm test tests/RemainingCanonicalPages.test.ts tests/FormControlPages.test.ts
pnpm audit:demo-framework-parity
pnpm audit:docs-site
```
Expected: all pass. Fix section-title mismatches until `audit:demo-framework-parity` is green.

- [ ] **Step 7: Commit — GATED for the `.vue` page (owner approval); non-`.vue` can commit first**

Split: commit non-`.vue` first (nav/shell TS, manifest JSON, React demo, App.tsx), then STOP for owner `VISUAL_COMMIT_APPROVED` on `FormPage.vue`:

```bash
# non-gated
git add playground/docs/navigation.ts playground/docs/DocsShell.vue docs/site-review-manifest.json react-pilot/src/demos/Form.tsx react-pilot/src/demos/form-demo.css react-pilot/src/App.tsx
git commit -m "feat(form): docs nav/shell + React demo + site-review manifest (APID-02 Stage 4)"
# gated — after owner VISUAL_COMMIT_APPROVED
git add playground/docs/pages/FormPage.vue
git commit -m "feat(form): FormPage docs page (APID-02 Stage 4) [VISUAL_COMMIT_APPROVED]"
```

---

## Stage 5 — Delivery (divergence + affordance + exports + changeset + audits)

### Task 10: Register `form-container-topology` divergence

**Files:**
- Modify: `src/design-system/translation/divergences-decisions.json`

- [ ] **Step 1: Append the decision** to the `decisions` array (matches the `steps-item-container-topology` shape; `category: "component-level-translation"`):

```json
{
  "id": "form-container-topology",
  "category": "component-level-translation",
  "component": "Form",
  "components": ["Form", "FormItem"],
  "subject": "Form ↔ Form/Item container aggregation",
  "figmaSide": "Figma publishes FormItem (Form Item) but no Form container.",
  "codeSide": "Code adds a Form container (validation engine + vertical-gap layout) wrapping FormItem, which corresponds to the Figma item.",
  "status": null,
  "reason": "Container + item is the runtime topology; the validation engine is a code-side composition layer with no Figma source.",
  "resolvedAt": null,
  "resolutionRef": null,
  "phase": null,
  "verifyHint": null,
  "notes": "Same pattern as Breadcrumb / Steps."
}
```

- [ ] **Step 2: Validate**

Run: `pnpm audit:translation-completeness`
Expected: pass (schema-valid, Form no longer an unregistered divergence).

- [ ] **Step 3: Commit (JSON-only, not gated)**

```bash
git add src/design-system/translation/divergences-decisions.json
git commit -m "chore(form): register form-container-topology divergence (APID-02 Stage 5)"
```

---

### Task 11: component-affordances — add Form, update FormItem, regenerate

**Files:**
- Modify: `docs/internal/component-affordances.json`
- Regenerate: `docs/internal/component-affordances.md`

- [ ] **Step 1: Add the `Form` component object** to `components` (category `form-input`), e.g.:

```json
{
  "code_name": "Form",
  "npm_export": "Form",
  "figma_name": "Form",
  "category": "form-input",
  "summary": "表单容器 —— 声明式校验引擎（model + rules）+ 字段编排 + 纵向布局；结果自动回填各 FormItem 的 error/status。",
  "synonyms_en": ["form", "form container", "validation", "form validation"],
  "synonyms_zh": ["表单", "表单容器", "校验", "表单校验"],
  "when_to_use": "需要对一组字段做声明式校验（required / type / min-max-len / pattern / 自定义）时，用 Form 包 FormItem，字段路径写 prop，规则写 rules，提交时调 formRef.validate()。",
  "built_in_features": [
    { "feature": "字段校验引擎", "controlled_by": "prop:model / prop:rules + FormItem prop/rules" },
    { "feature": "触发时机 blur/change/submit", "controlled_by": "rule.trigger（默认 change）" },
    { "feature": "校验方法", "controlled_by": "expose:validate / validateField / resetFields / clearValidate" }
  ],
  "do_not_hand_compose": "别自己写 watch + if 手动校验并手动塞 error 文案。用 Form + rules 声明校验，结果自动回填 FormItem。",
  "composition": { "contained_by": [], "contains": ["FormItem"] },
  "code_props": [
    { "name": "model", "type": "Record<string, unknown>" },
    { "name": "rules", "type": "FormRules" },
    { "name": "labelWidth", "type": "120 px | 200 px | Dynamic", "default": "120 px" },
    { "name": "layout", "type": "1 line | 1 line & Right | 2 lines", "default": "1 line" },
    { "name": "disabled", "type": "boolean", "default": "false" }
  ],
  "key_events": ["validate", "validateField", "resetFields", "clearValidate"],
  "code_import": "import { Form } from '@nancyzeng0210/tvu-design-system'",
  "related": ["FormItem", "InputBoxLine", "SelectBoxLine", "InputNumber"],
  "status": "verified"
}
```

- [ ] **Step 2: Update the `FormItem` entry** — set `composition.contained_by` from `[]` to `["Form"]`; add the two new props to `code_props` (`{ "name": "prop", "type": "string" }`, `{ "name": "rules", "type": "FormRule | FormRule[]" }`); set `key_events` from `[]` to `["blur", "change"]`.

- [ ] **Step 3: Regenerate the `.md`**

Run: `pnpm generate:component-affordances`
Expected: `docs/internal/component-affordances.md` updated (Form appears in form-input group; FormItem shows contained_by Form).

- [ ] **Step 4: Validate affordance + composition exports**

Run:
```bash
pnpm audit:component-affordances
pnpm audit:composition-exports
```
Expected: both pass (composition exports absorb the new `contains`/`contained_by`).

- [ ] **Step 5: Commit (JSON/MD, not gated)**

```bash
git add docs/internal/component-affordances.json docs/internal/component-affordances.md
git commit -m "docs(form): affordances — add Form, link FormItem contained_by Form (APID-02 Stage 5)"
```

---

### Task 12: Export Form + changeset

**Files:**
- Modify: `src/index.ts` (export `Form`)
- Create: `.changeset/form-validation-engine.md`

- [ ] **Step 1: Export Form** — add to `src/index.ts` next to the existing `FormItem` canonical export (read the file to match the exact export idiom, e.g. `export { default as Form } from './canonical/Form.vue'`). Also export the public rule types if the barrel exports types: `export type { FormRule, FormRules, FormTrigger } from './canonical/composables/form/validators'`.

- [ ] **Step 2: Verify the export resolves**

Run: `pnpm test tests/exports/package-exports.test.ts`
Expected: pass (add a `Form` assertion there if the suite enumerates exports; read it first).

- [ ] **Step 3: Write the changeset** — `.changeset/form-validation-engine.md`:

```md
---
"@nancyzeng0210/tvu-design-system": minor
---

Add `Form` validation engine (APID-02). New `Form` container with `model` / `rules`
and a framework-neutral validation engine; `FormItem` gains additive `prop` / `rules`
props. Built-in validators: `required` / `type` (string·number·integer·email·url) /
`min` · `max` · `len` / `pattern` / sync custom `validator`. Triggers `blur` / `change`
/ submit (`trigger`, default `change`). Exposes `validate` / `validateField` /
`resetFields` / `clearValidate`. Validation results auto-fill each FormItem's existing
`error` + `status` (two-value pass/fail; zero new Figma source). Standalone FormItem
behaviour is unchanged (backward compatible).
```

- [ ] **Step 4: Commit — Form export touches `src/index.ts` (not `.vue`, not gated)**

```bash
git add src/index.ts .changeset/form-validation-engine.md
git commit -m "feat(form): export Form + minor changeset (APID-02 Stage 5)"
```

---

### Task 13: Full sprint-close audit gate

**Files:** none (verification only).

- [ ] **Step 1: Run the unit suite**

Run: `pnpm test`
Expected: all suites pass (674+ prior + new Form/engine/validators tests).

- [ ] **Step 2: Run the CE + parity gates**

Run:
```bash
pnpm build:wc
pnpm exec playwright test --config=playwright.render-verification-react.config.ts
pnpm test:framework-parity
```
Expected: all pass (includes the new `form-ce-validation` guard + named-slot guard).

- [ ] **Step 3: Run the sprint-close audits** (the spec §6 收尾 set + the audits that consume the touched data)

Run:
```bash
pnpm audit:translation-completeness
pnpm audit:component-affordances
pnpm audit:composition-exports
pnpm audit:docs-site
pnpm audit:demo-framework-parity
pnpm audit:binding-config-parity
pnpm audit:demo-slot-boolean
pnpm audit:render-drift-gate
```
Expected: all pass. (`audit:demo-slot-boolean` needs a live-toggle boolean in the Form demo — `disabled` — added in Task 9.) If any command name differs, list scripts with `node -e "console.log(Object.keys(require('./package.json').scripts).filter(s=>s.startsWith('audit')).join('\n'))"` and run the matching ones.

- [ ] **Step 4: Self-audit sweep** (per AGENTS §Sprint 收尾 Self-Audit): scan for (a) spec gaps — is every MVP item from spec §1 implemented? (b) implementation bugs; (c) doc lag. Report findings; do not silently skip.

- [ ] **Step 5: Report completion with evidence** — paste the real output of Steps 1–3. Do NOT claim pass without pasted command output (superpowers:verification-before-completion).

---

## Self-Review (against spec)

**Spec coverage check:**
- MVP §1 `Form` model+rules → Task 4. FormItem prop+rules → Task 5. ✓
- Validators required/type/min·max·len/pattern/custom → Task 1. ✓
- Triggers blur/change/submit + rule.trigger default change → Stage 2 (impl) + Task 6 (tests). ✓
- Methods validate/validateField/resetFields/clearValidate → Task 2 (engine) + Task 4 (expose). ✓
- Result auto-fills FormItem error+status → Task 5. ✓
- Two-value pass/fail, zero new Figma source → global constraints + Task 10 divergence. ✓
- Architecture 3-layer (validators / engine / accessor / Form / FormItem / react binding) → Tasks 1–5, 7. ✓
- Explicit subscribe not Vue reactivity → Task 2 + Task 3 rationale. ✓
- API §4 el-form shape + rule merging (field appended after table) → Task 2 (merge) + Task 1 (types). ✓
- Data flow §5 (register/change-watch/blur-focusout/submit/notify) → Stage 2 + Stage 3. ✓
- Test strategy §6 (validators unit / engine unit / accessor CE 3-state / component SFC + CE spike / dual-framework parity / sprint audits) → Stage 0 spike, Tasks 1,2,5,8,9, Task 13. ✓
- Delivery §7 (changeset minor / divergence / affordances / docs / index export / visual gate) → Tasks 10–12 + gating notes. ✓
- Stage 0 de-risk + plan-B fallback → Stage 0 decision gate. ✓

**Placeholder scan:** engine/validators/accessor/Form/FormItem tasks carry complete code. Integration tasks (7, 9) quote exact entries; where a repo-specific enum/field shape must be confirmed at execution (config interface field names, manifest enum values, index export idiom), the step says "read the sibling first and match" rather than guessing — this is a deliberate verification instruction, not a placeholder.

**Type consistency:** `FormRule`/`FormRules`/`FormTrigger` defined in Task 1, consumed unchanged in Tasks 2/3/5/12. `createFormEngine({ getModel, getFormRules })` + method names (`validate`/`validateField`/`resetFields`/`clearValidate`/`subscribe`/`registerField`/`unregisterField`/`getFieldValue`/`getError`) consistent across Tasks 2, 4, 5. `FormContextKey` + `useFormEngine()` consistent Tasks 3–5. `host._tvuFormEngine` consistent Tasks 3, 4 (and spike uses the isolated `_tvuSpikeEngine`).
