/** * 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, * directions, 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 { SOUNDS } from '../src/uix/sema/sound-names'; import { SEMA_DURATIONS } from '../src/uix/sema/durations'; import { SEMA_DIRECTIONS } from '../src/uix/sema/types'; 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 catalogue const soundNames = Object.keys(SOUNDS); L.push(`## Sounds (${soundNames.length})`); L.push(''); L.push('THE catalogue (`SOUNDS`, `src/uix/sema/sound-names.ts`). A component names'); L.push('one of these and writes nothing else: the type of a pack rule accepts a name'); L.push('or `SILENT`, and nothing more. Whether a name resolves to a synthesised'); L.push('recipe or to a `.wav` is decided here, never at the component.'); L.push(''); L.push('A name is applied over the family base and BEFORE the intent deltas, so the'); L.push('evaluative profile always survives it — which is what makes the old D.7 / S-07'); L.push('class of defect impossible rather than merely forbidden.'); L.push(''); L.push('Silence is not a name: it is `SILENT`, one canonical value for the whole'); L.push('system (`$uix/sema`). Declared on a channel slice, the resolver honours it by'); L.push('dropping that channel.'); L.push(''); L.push(soundNames.map((n) => `\`${n}\``).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(''); // Directions L.push(`## Directions (${SEMA_DIRECTIONS.length})`); L.push(''); L.push('The sense of a traversal (`SEMA_DIRECTIONS`, `src/uix/sema/types.ts`),'); L.push('projected as `data-event-direction`. Per emission like the intent, and'); L.push('unlike it undeclarable on the EVENT: one `shift-navigate` is the previous'); L.push('month and the next one is the following month, so only the caller knows'); L.push('(`TriggerOptions.direction`). Optional — most occurrences have no sense to'); L.push('declare, and an invented one is worse than none.'); L.push(''); L.push('A SENSE, never an axis: eidos maps `forward` onto the inline end and'); L.push('`backward` onto the inline start, so RTL flips through `:dir(rtl)` and'); L.push('nothing upstream knows about it. Distinct from `SoundContour`'); L.push('(`ascending` / `descending`, `src/uix/sema/sounds.ts`), which shapes a pitch,'); L.push('and from `Morfo.direction`, which names the parts that carry the `dir` stamp'); L.push('(the RTL contract — see `docs/canon/direction-contract.md`).'); L.push(''); L.push(SEMA_DIRECTIONS.map((d) => `\`${d}\``).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}`); }