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
parent
aba18117ba
commit
2e062f216b
@ -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…
Reference in new issue