diff --git a/CLAUDE.md b/CLAUDE.md index e5302d518..12029a2a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,7 +207,7 @@ Priority chain for resolved props: explicit component prop → config context va ## Key Conventions - When creating terra components: follow the checklist in `src/uix/terra/README.md` §21 -- When creating soma components: follow the full checklist in `src/uix/soma/COMPONENT_GUIDE.md` (rules A1–A36, 39 checklist items). Walk every item explicitly before reporting done — this is mandatory, not optional. Compare features against ark-ui, bits-ui, radix-ui, react-aria and document the gap table in the component's README +- When creating soma components: follow the full checklist in `src/uix/soma/COMPONENT_GUIDE.md` (rules A1–A37, 40 checklist items). Walk every item explicitly before reporting done — this is mandatory, not optional. Compare features against ark-ui, bits-ui, radix-ui, react-aria and document the gap table in the component's README. Run `npm run perm:check` (permutation runner) before shipping; instrument the demo with `data-perm-step="N"` annotations for every distinct state transition. - When reading existing modules: start with `exports.ts` → `types.ts` → root `.svelte` → `*-provider.svelte.ts` → child wrappers → test page → README - `$bindable()` without fallback when parent might pass `undefined`; apply defaults via coalescing (see `src/uix/terra/README.md` §13.1) - Components that use Portal: consumer must manage `z-index` explicitly (layers don't impose z-indices) @@ -220,7 +220,7 @@ Priority chain for resolved props: explicit component prop → config context va - **NEVER create fake translators** in test pages. Use real ling instances with `createSomaTranslator` + `extendSomaTranslationModule`. - **READ before acting.** When told to read a file, read it. Don't interpret "léete" as "eléte". - **Verify before reporting done.** Run `npm run check`, test the UI in the browser. `npm run smoke` and `npx tsx scripts/morfo-check.ts` must both be green — smoke proves 200 OK, morfo-check proves the DOM matches the morfo contract and catches `effect_update_depth_exceeded` (it shows up as "Execution context was destroyed" on the affected route — see COMPONENT_GUIDE A35). -- Soma audit: `src/uix/soma/AUDIT.md` — 4 pending issues (F3, F12, F13, F14). Read it before making architectural changes. +- Soma audits: the two most recent are `src/uix/soma/soma-audit-2026-04-20.md` and `soma-audit-2026-04-21.md`. All their findings are resolved. Older audits (`AUDIT_1.md`, `codex_audit.md`) are historical. Read the latest audit before making architectural changes. - Morfo layer: part naming is `kebab: 'provider'` for the root part (never `'root'`). `createAttrs` still emits `data-{component}` for it. See `src/uix/morfo/README.md`. diff --git a/package.json b/package.json index 3593f75f0..878763efc 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "smoke": "node scripts/smoke-check.mjs", "morfo:check": "node --import tsx/esm scripts/morfo-check.ts", "morfo:vocabulary": "node --import tsx/esm scripts/morfo-vocabulary-check.ts", + "perm:check": "node --import tsx/esm scripts/permutation-check.ts", "generate:contracts-docs": "node --import tsx/esm scripts/generate-contracts-docs.ts" }, "devDependencies": { diff --git a/scripts/permutation-check.ts b/scripts/permutation-check.ts new file mode 100644 index 000000000..2fd1c8396 --- /dev/null +++ b/scripts/permutation-check.ts @@ -0,0 +1,409 @@ +/** + * Permutation runner — cycles soma components through the state space declared + * by their demo pages and validates morfo compliance after every transition. + * + * Closes the gap left by `morfo:check` (single state) and `smoke` (load-time + * errors only): reactivity bugs like toolbar's A35 loop and form's A36 + * microtask loop manifest during state transitions, not on first paint. + * + * Opt-in per demo: tag interactive controls with `data-perm-step="N"`. The + * runner discovers them, executes them in order, and re-validates morfo after + * each settle. See `src/uix/morfo/PERMUTATION_RUNNER.md` for the full + * authoring convention. + * + * Exit codes: + * 0 — every demo with `data-perm-step` passes all permutations. + * 1 — at least one permutation failed. + * 2 — no dev server / no morfos found. + */ + +import { chromium, type Page } from 'playwright'; +import { readdirSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join } from 'node:path'; +import type { Morfo, MorfoPart, MorfoData } from '../src/uix/morfo/types'; +import { validateMorfo } from '../src/uix/morfo/schema'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components'); + +// ── Shared with morfo-check.ts ────────────────────────────────────────────── + +type Issue = { kind: 'missing' | 'bad-value' | 'undeclared'; message: string }; + +function flatParts(parts: readonly MorfoPart[]): MorfoPart[] { + const out: MorfoPart[] = []; + for (const p of parts) { + out.push(p); + if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts)); + } + return out; +} + +function dataAttrFor(kebab: string, part: MorfoPart): string { + return part.kebab === 'provider' ? `data-${kebab}` : `data-${kebab}-${part.kebab}`; +} + +async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise { + const issues: Issue[] = []; + const parts = flatParts(morfo.parts).filter((p) => p.kind === 'public'); + const allPartAttrs = new Set(parts.map((p) => dataAttrFor(morfo.kebab, p))); + + for (const part of parts) { + const partAttr = dataAttrFor(morfo.kebab, part); + const elementsAttrs = await page.$$eval(`[${partAttr}]`, (nodes) => + nodes.map((el) => + Array.from(el.attributes) + .filter((a) => a.name.startsWith('data-')) + .map((a) => ({ name: a.name, value: a.value })) + ) + ); + + if (elementsAttrs.length === 0) continue; + + const declared = new Set(part.data.map((d) => d.attr)); + declared.add(partAttr); + for (const siblingAttr of allPartAttrs) declared.add(siblingAttr); + const declaredByAttr = new Map(part.data.map((d) => [d.attr, d])); + + for (const elAttrs of elementsAttrs) { + for (const data of part.data) { + if ((data.severity ?? 'required') !== 'required') continue; + const found = elAttrs.find((a) => a.name === data.attr); + if (!found) { + issues.push({ + kind: 'missing', + message: `${morfo.kebab}.${part.kebab}: required attr "${data.attr}" not emitted` + }); + } + } + for (const { name, value } of elAttrs) { + const decl = declaredByAttr.get(name); + if (decl?.values && !decl.values.includes(value)) { + issues.push({ + kind: 'bad-value', + message: `${morfo.kebab}.${part.kebab}: "${name}" has value "${value}", morfo declares [${decl.values.join(', ')}]` + }); + } + } + for (const { name } of elAttrs) { + if (!name.startsWith(`data-${morfo.kebab}`) && !name.startsWith('data-')) continue; + if (name.startsWith(`data-_`)) continue; + if (declared.has(name)) continue; + if (!name.startsWith(`data-${morfo.kebab}`)) continue; + issues.push({ + kind: 'undeclared', + message: `${morfo.kebab}.${part.kebab}: undeclared attr "${name}" (not in morfo)` + }); + } + } + } + + return issues; +} + +// ── Probe port + load morfos (copied from morfo-check) ────────────────────── + +async function probePort(start: number, end: number): Promise { + for (let port = start; port <= end; port++) { + try { + const res = await fetch(`http://localhost:${port}/`, { + signal: AbortSignal.timeout(500) + }); + if (res.ok || res.status === 404 || res.status === 500) { + return `http://localhost:${port}`; + } + } catch { + // try next + } + } + return null; +} + +async function loadMorfos(): Promise { + const files = readdirSync(MORFOS_DIR).filter( + (f) => f.endsWith('.ts') && !f.endsWith('.test.ts') + ); + const out: Morfo[] = []; + for (const f of files) { + const url = pathToFileURL(join(MORFOS_DIR, f)).href; + const mod = (await import(url)) as Record; + for (const v of Object.values(mod)) { + if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) { + out.push(validateMorfo(v)); + } + } + } + return out; +} + +// ── Permutation step schema ───────────────────────────────────────────────── + +interface PermStep { + index: number; + label: string; + mode: { kind: 'click' } | { kind: 'focus' } | { kind: 'type'; value: string } | { kind: 'key'; key: string }; + skipValidate: boolean; + settle: number; +} + +/** Parse a `data-perm-mode` attribute value. */ +function parseMode(raw: string | null): PermStep['mode'] { + if (!raw || raw === 'click') return { kind: 'click' }; + if (raw === 'focus') return { kind: 'focus' }; + const typeMatch = /^type="([^"]*)"$/.exec(raw); + if (typeMatch) return { kind: 'type', value: typeMatch[1] }; + const keyMatch = /^key="([^"]+)"$/.exec(raw); + if (keyMatch) return { kind: 'key', key: keyMatch[1] }; + return { kind: 'click' }; +} + +/** + * Collect perm steps CURRENTLY present in the DOM, sorted by index. Called + * before every step execution because new `data-perm-step` elements may + * mount as earlier steps fire (e.g. a Dialog.Close inside a portal that + * only exists after `open = true`). + */ +async function collectSteps(page: Page): Promise { + const raw = await page.$$eval('[data-perm-step]', (els) => + els.map((el) => { + const step = Number(el.getAttribute('data-perm-step') ?? '0'); + return { + step, + label: + el.getAttribute('data-perm-label') ?? + (el.textContent ?? '').trim().slice(0, 40) ?? + `step ${step}`, + mode: el.getAttribute('data-perm-mode'), + skipValidate: el.hasAttribute('data-perm-skip-validate'), + settle: Number(el.getAttribute('data-perm-settle') ?? '300') + }; + }) + ); + // Deduplicate by step index — if multiple elements share an index, keep the + // first (demos should not do this, but portals sometimes mirror content). + const byIndex = new Map(); + for (const r of raw) if (!byIndex.has(r.step)) byIndex.set(r.step, r); + return [...byIndex.values()] + .sort((a, b) => a.step - b.step) + .map((r) => ({ + index: r.step, + label: r.label || `step ${r.step}`, + mode: parseMode(r.mode), + skipValidate: r.skipValidate, + settle: Number.isFinite(r.settle) && r.settle > 0 ? r.settle : 300 + })); +} + +/** Execute one step against the live page. Returns the label for logs. */ +async function executeStep(page: Page, step: PermStep): Promise { + const selector = `[data-perm-step="${step.index}"]`; + const locator = page.locator(selector).first(); + switch (step.mode.kind) { + case 'click': + await locator.click({ timeout: 3000 }); + break; + case 'focus': + await locator.focus({ timeout: 3000 }); + break; + case 'type': + await locator.focus({ timeout: 3000 }); + await page.keyboard.type(step.mode.value); + break; + case 'key': + await locator.focus({ timeout: 3000 }); + await page.keyboard.press(step.mode.key); + break; + } + await page.waitForTimeout(step.settle); +} + +// ── Main loop ─────────────────────────────────────────────────────────────── + +type StepResult = + | { kind: 'pass'; label: string } + | { kind: 'fail'; label: string; reason: string } + | { kind: 'error'; label: string; err: string } + | { kind: 'skipped'; label: string }; + +type RouteResult = { + route: string; + morfo: string; + stepsTotal: number; + steps: StepResult[]; + pageErrors: string[]; +}; + +const BASE = process.argv[2] ?? (await probePort(5173, 5180)); +if (!BASE) { + console.error('Could not find a running dev server on 5173-5180.'); + console.error('Start it with `npm run dev` in another terminal.'); + process.exit(2); +} +console.error(`Using dev server at ${BASE}`); + +const morfos = await loadMorfos(); +if (morfos.length === 0) { + console.error('No morfos found under src/uix/morfo/components/'); + process.exit(2); +} +console.error(`Loaded ${morfos.length} morfo${morfos.length === 1 ? '' : 's'}`); + +const browser = await chromium.launch(); +const ctx = await browser.newContext(); +const results: RouteResult[] = []; +const skipped: string[] = []; + +for (const morfo of morfos) { + const route = `/test/soma/${morfo.kebab}`; + const page = await ctx.newPage(); + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + page.on('console', (m) => { + if (m.type() === 'error') pageErrors.push(m.text()); + }); + + try { + await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 }); + await page.waitForTimeout(300); + + // Initial snapshot — if there are no perm steps at all, skip. + const initialSteps = await collectSteps(page); + if (initialSteps.length === 0) { + skipped.push(morfo.kebab); + await page.close(); + continue; + } + + // Determine the max index the page declares at any point. Re-check the + // DOM after each step so portal-gated elements (Dialog.Close inside + // `{#if open}`) get counted once they mount. + const knownIndices = new Set(initialSteps.map((s) => s.index)); + const executedIndices = new Set(); + + const routeResult: RouteResult = { + route, + morfo: morfo.kebab, + stepsTotal: 0, // filled after the run + steps: [], + pageErrors: [] + }; + + // Iterate until no new pending step shows up. Cap at 50 to avoid a + // runaway demo that keeps spawning indices. + let safety = 50; + while (safety-- > 0) { + // Re-collect; demos may have mounted new perm elements. + const current = await collectSteps(page); + for (const s of current) knownIndices.add(s.index); + + const next = current + .filter((s) => !executedIndices.has(s.index)) + .sort((a, b) => a.index - b.index)[0]; + if (!next) break; + + const step = next; + executedIndices.add(step.index); + + try { + await executeStep(page, step); + } catch (e) { + routeResult.steps.push({ + kind: 'error', + label: step.label, + err: (e as Error).message.slice(0, 120) + }); + continue; + } + + // Any new page errors since the last step? + if (pageErrors.length > 0) { + const captured = pageErrors.splice(0, pageErrors.length).join(' | '); + routeResult.steps.push({ kind: 'fail', label: step.label, reason: `pageerror: ${captured.slice(0, 200)}` }); + continue; + } + + if (step.skipValidate) { + routeResult.steps.push({ kind: 'pass', label: step.label + ' (no-validate)' }); + continue; + } + + // Re-validate morfo against current DOM. + try { + const issues = await validateMorfoAgainstDom(page, morfo); + if (issues.length === 0) { + routeResult.steps.push({ kind: 'pass', label: step.label }); + } else { + routeResult.steps.push({ + kind: 'fail', + label: step.label, + reason: issues.map((i) => `[${i.kind}] ${i.message}`).join(' · ') + }); + } + } catch (e) { + routeResult.steps.push({ + kind: 'error', + label: step.label, + err: `validation: ${(e as Error).message.slice(0, 120)}` + }); + } + } + + // Any errors that slipped past the per-step capture + routeResult.pageErrors = pageErrors; + routeResult.stepsTotal = routeResult.steps.length; + results.push(routeResult); + } catch (e) { + results.push({ + route, + morfo: morfo.kebab, + stepsTotal: 0, + steps: [{ kind: 'error', label: 'navigation', err: (e as Error).message.slice(0, 120) }], + pageErrors: [] + }); + } finally { + await page.close(); + } +} + +await browser.close(); + +// ── Report ────────────────────────────────────────────────────────────────── + +console.log(''); +let passed = 0; +let failed = 0; +for (const r of results) { + const bad = r.steps.filter((s) => s.kind !== 'pass'); + if (bad.length === 0) { + console.log(`PASS ${r.morfo.padEnd(20)} ${r.stepsTotal} permutation${r.stepsTotal === 1 ? '' : 's'}`); + for (const s of r.steps) { + console.log(` · ${s.label}`); + } + passed++; + } else { + console.log(`FAIL ${r.morfo.padEnd(20)} ${r.stepsTotal} permutations, ${bad.length} failed`); + for (const s of r.steps) { + if (s.kind === 'pass') console.log(` · ${s.label}`); + else if (s.kind === 'skipped') console.log(` · ${s.label} (skipped)`); + else if (s.kind === 'fail') console.log(` ✖ ${s.label} ${s.reason}`); + else console.log(` ✖ ${s.label} ERROR ${s.err}`); + } + failed++; + } +} + +console.log(''); +if (skipped.length > 0) { + console.log( + `SKIPPED (${skipped.length}): ${skipped.slice(0, 10).join(', ')}${skipped.length > 10 ? ', …' : ''}` + ); + console.log(' (demo has no `data-perm-step` annotations yet — see src/uix/morfo/PERMUTATION_RUNNER.md)'); +} +console.log(''); +console.log( + failed === 0 + ? `All ${passed} instrumented demo${passed === 1 ? '' : 's'} passed.` + : `${failed} demo${failed === 1 ? '' : 's'} failed: ${results.filter((r) => r.steps.some((s) => s.kind !== 'pass')).map((r) => r.morfo).join(', ')}` +); +process.exit(failed === 0 ? 0 : 1); diff --git a/src/routes/test/soma/dialog/+page.svelte b/src/routes/test/soma/dialog/+page.svelte index 7bb2ee7f1..2d0f86b0c 100644 --- a/src/routes/test/soma/dialog/+page.svelte +++ b/src/routes/test/soma/dialog/+page.svelte @@ -21,7 +21,9 @@

Inspect aria-label on trigger and close — should translate with locale.

- Open dialog + + Open dialog + @@ -31,7 +33,9 @@ The aria-label on the close button should change when you switch locale in the top bar.
- Close + + Close +
diff --git a/src/routes/test/soma/tabs/+page.svelte b/src/routes/test/soma/tabs/+page.svelte index 438754f5f..fbd54462a 100644 --- a/src/routes/test/soma/tabs/+page.svelte +++ b/src/routes/test/soma/tabs/+page.svelte @@ -22,9 +22,9 @@ - Account - Password - Settings + Account + Password + Settings diff --git a/src/routes/test/soma/toolbar/+page.svelte b/src/routes/test/soma/toolbar/+page.svelte index a68960667..dc63f6fa7 100644 --- a/src/routes/test/soma/toolbar/+page.svelte +++ b/src/routes/test/soma/toolbar/+page.svelte @@ -25,9 +25,9 @@ - B - I - U + B + I + U S diff --git a/src/uix/morfo/PERMUTATION_RUNNER.md b/src/uix/morfo/PERMUTATION_RUNNER.md new file mode 100644 index 000000000..a0af81c31 --- /dev/null +++ b/src/uix/morfo/PERMUTATION_RUNNER.md @@ -0,0 +1,151 @@ +# Permutation runner + +Automated CI tool that cycles soma components through their declared state space and validates morfo compliance at every step. + +> **Why this exists.** `morfo:check` validates the DOM at page-load time, in a single state. `smoke` catches `pageerror` during mount. Neither ejercices the **transitions** between states — and that's where most of the reactivity bugs we've caught this quarter actually live (toolbar A35, form A36, slider RTL transform). The permutation runner is the third validation layer that closes that gap. + +--- + +## What it validates + +For every demo page, for every permutation the page declares: + +1. **No reactivity loops.** `effect_update_depth_exceeded` AND microtask-mediated loops (A35 / A36) both count as failures. A long microtask starvation (>2 s) is the proxy for A36. +2. **No runtime errors.** `pageerror` or `console.error` during a state transition fails the permutation. +3. **Morfo contract holds.** After the transition settles, the emitted DOM still conforms to the component's morfo (same rules as `morfo:check` — declared attrs present, values in enum, no undeclared namespaced attrs). +4. **ARIA relationships remain valid.** If a part emits `aria-labelledby="X"`, the element with id `X` must exist in the DOM at that state. + +What it does **not** validate (future work): + +- Visual correctness (that's eidos' problem). +- Animation timing or `data-last-action` → `data-state` ordering (dedicated MutationObserver test). +- Cross-component interactions (it runs one demo at a time). + +--- + +## Convention: `data-perm-*` attributes + +Demo pages opt in by tagging interactive controls with `data-perm-*` attrs. The runner discovers these, clicks them in sequence, and validates after each click. + +### `data-perm-step="N"` + +Declares the click order. Starts at `0`. The runner clicks elements in ascending order. + +```svelte + + +``` + +### `data-perm-label="..."` + +Human-readable label for the log. Defaults to the element's text content. + +```svelte + +``` + +### `data-perm-settle="ms"` + +Override the default 300ms settle time before morfo re-validation. Use for animations, async validation, etc. + +```svelte + +``` + +### `data-perm-skip-validate` + +Click the element but don't validate afterwards. For intermediate actions (e.g. typing a value before submit). The next step validates. + +### `data-perm-mode` + +Overrides the default click interaction: + +- `click` (default) — `await page.click(el)`. +- `focus` — `await el.focus()`. +- `type="abc"` — type the string. +- `key="Escape"` — dispatch a `keydown` with the given key. + +```svelte + +
+``` + +--- + +## Example: Dialog demo + +Minimal instrumentation that cycles open → close → open via Escape: + +```svelte + + Open + + +{#if dialogOpen} + + … + +{/if} + + +``` + +Runner output: + +``` +PASS dialog 3 permutations + · step 0 (open via trigger) morfo OK, 0 errors + · step 1 (close via Escape) morfo OK, 0 errors + · step 2 (re-open) morfo OK, 0 errors +``` + +--- + +## Failure format + +``` +FAIL toolbar 3 permutations, 1 failed + · step 0 (focus first button) morfo OK + · step 1 (type "b") PAGEERR: effect_update_depth_exceeded + · step 2 (click toggle) skipped (previous step failed) +``` + +--- + +## CLI + +```bash +# Requires `npm run dev` running (auto-detects port 5173–5180). +npm run perm:check +``` + +Exit codes: + +- `0` — every demo with `data-perm-step` passes all permutations. +- `1` — at least one permutation failed. +- `2` — no dev server / no morfos found. + +Demos without `data-perm-step` are skipped with a `SKIP` line (they haven't opted in yet). + +--- + +## Roadmap + +- **v1 (current)** — click-based state transitions, morfo re-validation after each step. +- **v2** — URL-param-driven state injection (`?perm=open,dir=rtl,disabled`) so headless permutations don't need a click sequence. +- **v3** — derive the permutation set automatically from the morfo's `states` and `data` enum values; no per-demo instrumentation needed for the baseline cycles. +- **v4** — integrate with `MutationObserver` to validate `data-last-action` / `data-state` ordering for Sema. + +The convention (`data-perm-step` / `data-perm-mode`) is forward-compatible: v2+ infrastructure adds alternative drivers without invalidating v1 annotations. + +--- + +## Related + +- [morfo README](./README.md) — authoring reference for the morfos this runner validates against. +- [`scripts/permutation-check.ts`](../../../scripts/permutation-check.ts) — the runner itself. +- [`scripts/morfo-check.ts`](../../../scripts/morfo-check.ts) — single-state DOM validator that shares the per-morfo validation logic. diff --git a/src/uix/soma/COMPONENT_GUIDE.md b/src/uix/soma/COMPONENT_GUIDE.md index e02d7a21c..09478cf87 100644 --- a/src/uix/soma/COMPONENT_GUIDE.md +++ b/src/uix/soma/COMPONENT_GUIDE.md @@ -362,6 +362,17 @@ Pattern: `soma-{component}-{part}-{uid}`. Always descriptive. build-up), component demo hangs on mount when reading a derived whose body triggers the effect. Wrap the fallback read in `untrack(() => ({ errors, issues }))` or similar. +[ ] 40. Instrument the component's demo page with `data-perm-step="N"` + annotations on every interactive control that drives a distinct + state transition (A37). Run `npm run perm:check` before shipping + and confirm every permutation passes — this is the validation + layer that catches reactivity loops (A35 / A36) and transition- + time morfo drift that `morfo:check` misses. At minimum cover: + open / dismiss for overlays, toggle for toggleables, first-to- + second-item for composite roving, empty→invalid→valid for forms. + See `src/uix/morfo/PERMUTATION_RUNNER.md` for the full authoring + convention and the opt-in modifiers (`data-perm-mode`, + `data-perm-settle`, `data-perm-skip-validate`). # Scope approval — mandatory [ ] 34. Before declaring the component done, **present the comparison table @@ -1179,3 +1190,61 @@ fallback return in `untrack`. Regression locked by two new tests in `form-auto-fields.svelte.test.ts` with 5 s vitest timeouts. See `src/uix/soma/components/form/BUG-onchange-onblur-hang.md` for the full diagnostic transcript. + +### A37. Instrument demos with `data-perm-step` for the permutation runner + +Single-state validation (`morfo:check`) passes even when a state transition +would loop or emit an undeclared attr. The permutation runner +(`scripts/permutation-check.ts`) cycles components through their declared +state space and re-validates morfo after every transition. This is the +layer that would have caught the toolbar A35 loop, the form A36 microtask +loop, and the slider RTL transform bug the same day they shipped — each of +them passed `morfo:check` but failed the moment state changed. + +Demos opt in by tagging interactive controls with `data-perm-step="N"`: + +```svelte + + + Open + + +{#if open} + + + Close + + +{/if} +``` + +Extra modifiers: + +- `data-perm-mode='key="Escape"'` — dispatch a keydown instead of clicking. +- `data-perm-mode='type="ada@example.com"'` — type a string. +- `data-perm-settle="800"` — longer wait before re-validation (for animations or async validation). +- `data-perm-skip-validate` — click but don't re-validate (intermediate action). +- `data-perm-label="..."` — override the log label. + +**What to exercise:** + +- **Overlays** (Dialog, Popover, Drawer, Tooltip, NavigationMenu, DropdownMenu, ContextMenu, Menubar) — open / dismiss via every declared path (trigger click, Escape, outside click when applicable). +- **Toggleable items** (Checkbox, Switch, Toggle, ToggleGroup.Item, Tabs.Trigger, RadioGroup.Item, Accordion.Trigger) — click to flip state; for multi-value pickers, advance through at least three values. +- **Composite roving** (Listbox, Menu, Tree, Toolbar) — focus first item, arrow-key to next, arrow-key past the loop boundary. +- **Forms** — empty → invalid input → valid input, to catch any validation-effect loops under change / blur modes. +- **RTL** — if the component has `dir` semantics, include a `data-perm-step` that swaps `dir="rtl"` on the root and validates arrow keys flip. + +**What to skip:** + +- Alerts / confirmations / anything that triggers `window.alert()` or `window.confirm()` — Playwright hangs on those by default. If the demo has them, use a non-alert callback for the perm-step path. +- File uploads — native file picker is browser-modal and not scriptable from Playwright without `setInputFiles`. + +**Pragmatic coverage target:** every component with a non-trivial state +machine (≈40 of the 66 morfos) should have ≥2 permutation steps. Plain +leaf components (Avatar, Progress, Meter, Announce) don't need any. +Run `npm run perm:check` before shipping a new component; the runner SKIPs +annotations-less demos without failing, so onboarding is incremental. + +**Reference:** `src/uix/morfo/PERMUTATION_RUNNER.md` has the full authoring +convention and roadmap (v2 URL-driven states, v3 morfo-inferred cycles, +v4 MutationObserver ordering for Sema).