sema: canonical action verb vocabulary (SEMA_VERBS)

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.
morfo-runtime
dev 6 months ago
parent aba18117ba
commit 2e062f216b

@ -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'

@ -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)
}
})
})

@ -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<string> = 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);
}
Loading…
Cancel
Save

Powered by TurnKey Linux.