/** * docs:vocabularies — generate the canonical-vocabulary appendix from the code * consts, so agents building from the docs-book can SEE the closed sets they * must draw from (archetypes, sema families + holds + verbs, intents, haptic * kinds, palette scales, sizes, variants, shared strings) without any * copy-the-list drift objection. Generated = the ONE sanctioned place these * lists are spelled out; every other doc links here. * * Closes STUMBLES #1 (invisible canonical vocabularies — an agent could not * assign archetypes / holds from the docs alone). * * Usage: npm run docs:vocabularies (writes the file) * imported by scripts/docs-check.ts (freshness guard: the committed * file must equal the generator) */ import { readFileSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ARCHETYPE_VOCABULARY, ARCHETYPE_DESCRIPTIONS } from '../src/uix/morfo/types'; import { PALETTE_SCALES, SIZES, SIZE_PRIMITIVE_KEYS, EIDOS_VARIANTS } from '../src/uix/eidos/lib/types'; import { SEMA_HOLDS_BY_INTENT } from '../src/uix/sema/holds'; import { SEMA_VERBS } from '../src/uix/sema/verbs'; import { SOUND_TUNINGS } from '../src/uix/sema/sounds'; import { SEMA_DURATIONS } from '../src/uix/sema/durations'; import { INTENTS } from '../src/uix/intent'; import { commonLangs } from '../src/uix/langs/common'; const REPO = join(dirname(fileURLToPath(import.meta.url)), '..'); const OUT = join(REPO, 'docs/canon/vocabularies.md'); /** Parse the `kind: 'a' | 'b' | …` union off the HapticSignature. */ function hapticKinds(): string[] { const src = readFileSync(join(REPO, 'src/uix/sema/channels.ts'), 'utf8'); const m = src.match(/kind:\s*((?:'[\w-]+'\s*\|\s*)*'[\w-]+')\s*;/); if (!m) throw new Error('cannot find HapticSignature kind union in channels.ts'); return [...m[1].matchAll(/'([\w-]+)'/g)].map((x) => x[1]); } /** Collect the dotted leaf keys of commonLangs (e.g. `buttons.close`). */ function commonKeys(): string[] { const out: string[] = []; const walk = (node: Record, prefix: string) => { for (const [k, v] of Object.entries(node)) { const path = prefix ? `${prefix}.${k}` : k; if (v && typeof v === 'object' && !('es' in v) && !('en' in v)) { walk(v as Record, path); } else { out.push(path); } } }; walk(commonLangs as Record, ''); return out; } export function generateVocabulariesDoc(): string { const L: string[] = []; const desc = ARCHETYPE_DESCRIPTIONS as Record; const holdMs = (label: string) => label in SEMA_DURATIONS ? `${(SEMA_DURATIONS as Record)[label]}ms` : label; L.push('---'); L.push('title: Canonical vocabularies (generated)'); L.push('type: canon'); L.push('audience: human + agent'); L.push('authority: canonical — the closed sets, generated from the code consts'); L.push('status: current'); L.push('generated: npm run docs:vocabularies (do NOT edit by hand)'); L.push('---'); L.push(''); L.push('# Canonical vocabularies'); L.push(''); L.push('> **Generated from the code — do not edit.** Run `npm run docs:vocabularies`'); L.push('> to regenerate; `npm run docs:check` fails if this file drifts from the'); L.push('> consts. This is the ONE place the closed sets are spelled out (the reason'); L.push("> every other doc links here instead of copying a list that would go stale)."); L.push('> When building a component you draw part archetypes, event families/verbs,'); L.push('> intents and holds from exactly these sets (STUMBLES #1).'); L.push(''); // Archetypes L.push(`## Part archetypes (${ARCHETYPE_VOCABULARY.length})`); L.push(''); L.push('The cross-component classification a `part` may declare'); L.push('(`ARCHETYPE_VOCABULARY`, `src/uix/morfo/types.ts`). Omit it for a plain'); L.push('display part that pulls no shared styling.'); L.push(''); L.push('| Archetype | Role |'); L.push('| --- | --- |'); for (const a of ARCHETYPE_VOCABULARY) L.push(`| \`${a}\` | ${desc[a] || '—'} |`); L.push(''); // Sema families + holds const families = Object.keys(SEMA_HOLDS_BY_INTENT); L.push(`## Sema families (${families.length}) — default hold + persistence`); L.push(''); L.push('Each event declares a `semantic.family`; the default perceptual hold and'); L.push('persistence come from `SEMA_HOLDS_BY_INTENT` (`src/uix/sema/holds.ts`),'); L.push('overridable per event. Named holds map to `SEMA_DURATIONS`.'); L.push(''); L.push('| Family | Default hold | Persistence | Per-intent overrides |'); L.push('| --- | --- | --- | --- |'); for (const [family, intents] of Object.entries(SEMA_HOLDS_BY_INTENT)) { const table = intents as Record; const def = table._default; const overrides = Object.entries(table) .filter(([k]) => k !== '_default') .map(([k, v]) => `${k}: ${holdMs(v.hold)}/${v.persistence}`) .join('; '); L.push( `| \`${family}\` | ${holdMs(def.hold)} (\`${def.hold}\`) | ${def.persistence} | ${overrides || '—'} |` ); } L.push(''); // Verbs per family L.push('## Sema verbs, by family'); L.push(''); L.push('The canonical verb set an event may use for each family (`SEMA_VERBS`,'); L.push('`src/uix/sema/verbs.ts`). A `family.verb` pairing outside this is drift.'); L.push(''); L.push('| Family | Verbs |'); L.push('| --- | --- |'); for (const [family, verbs] of Object.entries(SEMA_VERBS)) { L.push(`| \`${family}\` | ${(verbs as string[]).map((v) => `\`${v}\``).join(' · ')} |`); } L.push(''); // Sound tunings const tuningKeys = Object.keys(SOUND_TUNINGS); L.push(`## Sound tunings (${tuningKeys.length})`); L.push(''); L.push('Parametric deltas over a family base signature (`SOUND_TUNINGS`,'); L.push('`src/uix/sema/sounds.ts`). A key is `{family}.{tail}`: the FIRST segment is'); L.push('the sema family whose base the tuning modifies — never a component — and the'); L.push('tail describes the resulting signature, not the triggering event.'); L.push(''); L.push('Silence is NOT here: it is `SILENT`, one canonical value for the whole system'); L.push('(`$uix/sema`). Declared on a channel slice, the resolver honours it by dropping'); L.push('that channel — no family, no verb, no intent, and no number a later delta could'); L.push('move. A tuning that lands its family at `gain <= 0` is rejected by'); L.push('`src/uix/sema/sounds-grammar.test.ts`, which also enforces the shape above.'); L.push(''); L.push('| Family | Tunings |'); L.push('| --- | --- |'); for (const family of families) { const owned = tuningKeys.filter((k) => k.split('.')[0] === family); if (owned.length > 0) L.push(`| \`${family}\` | ${owned.map((k) => `\`${k}\``).join(' · ')} |`); } L.push(''); // Intents L.push(`## Intents (${INTENTS.length})`); L.push(''); L.push('The evaluative axis (`INTENTS`, `src/uix/intent.ts`). `commit` and `signal`'); L.push('require one; the intent policy per family is `SEMA_FAMILY_POLICY`.'); L.push(''); L.push(INTENTS.map((i) => `\`${i}\``).join(' · ')); L.push(''); // Hold durations L.push('## Hold / perceptual durations'); L.push(''); L.push('The named perceptual scale (`SEMA_DURATIONS`, `src/uix/sema/durations.ts`);'); L.push('an event `hold` is a label from here or a raw ms number.'); L.push(''); L.push('| Label | ms |'); L.push('| --- | --- |'); for (const [label, ms] of Object.entries(SEMA_DURATIONS)) L.push(`| \`${label}\` | ${ms} |`); L.push(''); // Haptic kinds const kinds = hapticKinds(); L.push(`## Haptic kinds (${kinds.length})`); L.push(''); L.push('The `kind` a `haptic` channel signature may use (`HapticSignature`,'); L.push('`src/uix/sema/channels.ts`). Haptic is opt-in (`new EngineSemantic({ haptic: true })`).'); L.push(''); L.push(kinds.map((k) => `\`${k}\``).join(' · ')); L.push(''); // Palette scales L.push(`## Palette scales (${PALETTE_SCALES.length})`); L.push(''); L.push('The donor scales a component `color` prop accepts under `intent="neutral"`'); L.push('(`PALETTE_SCALES`, `src/uix/eidos/lib/types.ts`) — the per-instance override'); L.push('(``). NEVER hand-count this list.'); L.push(''); L.push(PALETTE_SCALES.map((s) => `\`${s}\``).join(' · ')); L.push(''); // Sizes L.push(`## Sizes (${SIZES.length})`); L.push(''); L.push('The size scale (`SIZES`; the physical primitives are `SIZE_PRIMITIVE_KEYS`,'); L.push('`src/uix/eidos/lib/types.ts`). `full` is a layout semantic, not a physical'); L.push('tier. Each component exposes the subset its recipe supports.'); L.push(''); L.push( `Physical: ${SIZE_PRIMITIVE_KEYS.map((s) => `\`${s}\``).join(' · ')} · layout: \`full\`` ); L.push(''); // Variants L.push('## Variant archetypes'); L.push(''); L.push('The canonical variant sets per archetype (`EIDOS_VARIANTS`,'); L.push('`src/uix/eidos/lib/types.ts`). A component narrows to one set; component-only'); L.push('values live in its own `types.ts`.'); L.push(''); L.push('| Archetype | Variants |'); L.push('| --- | --- |'); for (const [archetype, values] of Object.entries(EIDOS_VARIANTS)) { L.push(`| \`${archetype}\` | ${(values as readonly string[]).map((v) => `\`${v}\``).join(' · ')} |`); } L.push(''); // Common strings const keys = commonKeys(); L.push(`## Shared strings (\`common.*\`, ${keys.length})`); L.push(''); L.push('The shared idlangref leaves (`commonLangs`, `src/uix/langs/common.ts`),'); L.push('reachable via `v.commonRef(...)` or `#?common.{path}|Fallback` — reuse these'); L.push('instead of re-declaring a close/cancel/clear label per component.'); L.push(''); L.push(keys.map((k) => `\`${k}\``).join(' · ')); L.push(''); return L.join('\n') + '\n'; } // Direct run → write the file. if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('docs-vocabularies.ts')) { writeFileSync(OUT, generateVocabulariesDoc(), 'utf8'); console.log(`docs:vocabularies → ${OUT}`); }