From 2e062f216b316daa10b6a6740877f7aa61c81a1b Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 26 Apr 2026 15:46:44 +0200 Subject: [PATCH] sema: canonical action verb vocabulary (SEMA_VERBS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-component verb names that morfo `events[].name` should preferably align with. Lets sema / sound / vibra engines subscribe by verb instead of by component-specific event name, and lets eidos write transversal selectors like `[data-event^="dismiss"]`. Vocabulary (24 verbs, grouped by family alignment) - emerge: present, dismiss, open, close, expand, collapse - commit: commit, cancel, confirm, submit, reset, fail - alert: announce, alert - contact: activate, select, toggle - handle: acknowledge, edit, drag, resize - sustain: tick, progress Composite event names follow `{verb}-{variant}` (e.g. `commit-save`, `dismiss-outside`). The verb is the head; the rest is component-specific nuance the validator extracts and reports separately. `validateEventName(name)` returns `{ name, head, matchesCanonical, variant }`. Advisory — doesn't reject morfos, just surfaces drift for tooling / review (a future `sema:vocabulary` script can use it to flag composite event names whose head isn't canonical). Existing event names already align well - Toast: `present`, `announce`, `dismiss` — all canonical bare verbs. - Dialog: `open`, `close-save`, `close-cancel`, `close-dismiss`, `close-dismiss-outside`, `close-after-fail` — all canonical heads (`open`, `close`) with variants. Tests: 10 unit tests covering vocabulary integrity, isSemaVerb guard, validateEventName splitting/matching, and a snapshot of current dialog + toast event names against the catalog (all match). This is the second extension justified under the "2-of-3 rule": verbs are consumed by sema (subscription routing) AND eidos (selector generation), with the original morfo authoring as the third producer. --- src/uix/sema/exports.ts | 8 ++++ src/uix/sema/verbs.test.ts | 93 +++++++++++++++++++++++++++++++++++++ src/uix/sema/verbs.ts | 94 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 src/uix/sema/verbs.test.ts create mode 100644 src/uix/sema/verbs.ts diff --git a/src/uix/sema/exports.ts b/src/uix/sema/exports.ts index 702fb9207..81eee7888 100644 --- a/src/uix/sema/exports.ts +++ b/src/uix/sema/exports.ts @@ -46,4 +46,12 @@ export { SemanticEngine } from './engine' export type { SemanticSignal } from './signal' +export { + SEMA_VERBS, + validateEventName, + isSemaVerb, + type SemaVerb, + type VerbValidation +} from './verbs' + export { SemaInvariantError, validateSemaEvent, validateSemaIntentBinding } from './validation' diff --git a/src/uix/sema/verbs.test.ts b/src/uix/sema/verbs.test.ts new file mode 100644 index 000000000..68246a558 --- /dev/null +++ b/src/uix/sema/verbs.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' + +import { SEMA_VERBS, isSemaVerb, validateEventName } from './verbs' + +describe('SEMA_VERBS', () => { + it('contains the basic emerge / commit / alert verbs', () => { + expect(SEMA_VERBS).toContain('present') + expect(SEMA_VERBS).toContain('dismiss') + expect(SEMA_VERBS).toContain('commit') + expect(SEMA_VERBS).toContain('cancel') + expect(SEMA_VERBS).toContain('announce') + }) + + it('has no duplicates', () => { + expect(new Set(SEMA_VERBS).size).toBe(SEMA_VERBS.length) + }) +}) + +describe('isSemaVerb', () => { + it('accepts canonical verbs', () => { + expect(isSemaVerb('present')).toBe(true) + expect(isSemaVerb('commit')).toBe(true) + }) + + it('rejects non-canonical strings', () => { + expect(isSemaVerb('frob')).toBe(false) + expect(isSemaVerb('commit-save')).toBe(false) // composite, not a verb + }) + + it('rejects non-strings', () => { + expect(isSemaVerb(undefined)).toBe(false) + expect(isSemaVerb(42)).toBe(false) + expect(isSemaVerb(null)).toBe(false) + }) +}) + +describe('validateEventName', () => { + it('matches a bare canonical verb', () => { + expect(validateEventName('dismiss')).toEqual({ + name: 'dismiss', + head: 'dismiss', + matchesCanonical: true, + variant: undefined + }) + }) + + it('extracts head + variant from composite names', () => { + expect(validateEventName('commit-save')).toEqual({ + name: 'commit-save', + head: 'commit', + matchesCanonical: true, + variant: 'save' + }) + expect(validateEventName('dismiss-outside')).toEqual({ + name: 'dismiss-outside', + head: 'dismiss', + matchesCanonical: true, + variant: 'outside' + }) + }) + + it('flags drift when the head is not a canonical verb', () => { + expect(validateEventName('frob-glob')).toEqual({ + name: 'frob-glob', + head: 'frob', + matchesCanonical: false, + variant: 'glob' + }) + }) + + it('reports the existing dialog event names against the catalog', () => { + // Documents current state: most dialog events use canonical heads, + // `close-*` family uses `close` (canonical). + const dialogEvents = [ + 'open', + 'close-save', + 'close-cancel', + 'close-dismiss', + 'close-dismiss-outside', + 'close-after-fail' + ] + for (const name of dialogEvents) { + const v = validateEventName(name) + expect(v.matchesCanonical).toBe(true) + } + }) + + it('reports the existing toast event names against the catalog', () => { + for (const name of ['present', 'announce', 'dismiss']) { + expect(validateEventName(name).matchesCanonical).toBe(true) + } + }) +}) diff --git a/src/uix/sema/verbs.ts b/src/uix/sema/verbs.ts new file mode 100644 index 000000000..94f3ec93f --- /dev/null +++ b/src/uix/sema/verbs.ts @@ -0,0 +1,94 @@ +/** + * Canonical action verb vocabulary. + * + * Cross-component verb names that morfo `events[].name` should preferably + * align with. Lets sema/sound/vibra engines subscribe by verb instead of + * by component-specific event name, and lets eidos write transversal + * selectors like `[data-event^="dismiss"]`. + * + * Vocabulary is intentionally small. New verbs only added when at least + * two components share the action with the same semantic family. + * + * Composite event names follow the convention `{verb}-{variant}`, e.g. + * `commit-save` / `commit-cancel` / `dismiss-outside`. The verb is the + * head; everything after the first `-` is component-specific nuance. + * + * The validator (`validateEventName`) extracts the head and reports + * whether it matches a canonical verb. Advisory by design — it doesn't + * reject morfos, just flags drift for review. + */ + +export const SEMA_VERBS = [ + // emerge family — something appears or disappears transitionally + 'present', + 'dismiss', + 'open', + 'close', + 'expand', + 'collapse', + + // commit family — user-initiated commit (valenced by intent) + 'commit', + 'cancel', + 'confirm', + 'submit', + 'reset', + 'fail', + + // alert family — system signals attention (valenced by intent) + 'announce', + 'alert', + + // contact family — first / one-shot interaction (valenced by intent) + 'activate', + 'select', + 'toggle', + + // handle family — sustained interaction (valenced by intent) + 'acknowledge', + 'edit', + 'drag', + 'resize', + + // sustain family — ongoing periodic + 'tick', + 'progress' +] as const; + +export type SemaVerb = (typeof SEMA_VERBS)[number]; + +const VERB_SET: ReadonlySet = new Set(SEMA_VERBS); + +export interface VerbValidation { + /** The morfo event name as authored. */ + name: string; + /** The verb head — the part before the first `-`, or the whole name. */ + head: string; + /** Whether the head matches a canonical verb in `SEMA_VERBS`. */ + matchesCanonical: boolean; + /** Tail after the first `-`, if any. e.g. `'commit-save'` → `'save'`. */ + variant: string | undefined; +} + +/** + * Inspect a morfo `events[].name` against the canonical vocabulary. + * + * Splits on the first `-` to extract the verb head; reports whether the + * head appears in `SEMA_VERBS`. Doesn't throw — the result is informational + * for tooling and for `morfo:vocabulary`-style scripts to surface drift. + */ +export function validateEventName(name: string): VerbValidation { + const dashIdx = name.indexOf('-'); + const head = dashIdx === -1 ? name : name.slice(0, dashIdx); + const variant = dashIdx === -1 ? undefined : name.slice(dashIdx + 1); + return { + name, + head, + matchesCanonical: VERB_SET.has(head), + variant + }; +} + +export function isSemaVerb(value: unknown): value is SemaVerb { + return typeof value === 'string' && VERB_SET.has(value); +}