You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
264 lines
11 KiB
264 lines
11 KiB
/**
|
|
* 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<string, unknown>, 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<string, unknown>, path);
|
|
} else {
|
|
out.push(path);
|
|
}
|
|
}
|
|
};
|
|
walk(commonLangs as Record<string, unknown>, '');
|
|
return out;
|
|
}
|
|
|
|
export function generateVocabulariesDoc(): string {
|
|
const L: string[] = [];
|
|
const desc = ARCHETYPE_DESCRIPTIONS as Record<string, string>;
|
|
const holdMs = (label: string) =>
|
|
label in SEMA_DURATIONS ? `${(SEMA_DURATIONS as Record<string, number>)[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<string, { hold: string; persistence: string }>;
|
|
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('(`<X color="teal">`). 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}`);
|
|
}
|