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.
205 lines
5.9 KiB
205 lines
5.9 KiB
/**
|
|
* Canonical action verb vocabulary, grouped by family.
|
|
*
|
|
* Cross-component verb names that morfo `events[].semantic.verb` (and the
|
|
* `events[].name` head) should preferably align with. Lets sema/sound/haptic
|
|
* 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.
|
|
*
|
|
* Doctrina (per src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md §1.3):
|
|
* verbs live under their family. Some verbs that look like one family
|
|
* actually belong to another in the doctrinal classification:
|
|
* - select / toggle → commit (fix state, not just contact)
|
|
* - acknowledge → commit (closes a pending decision)
|
|
* - edit → represented as shift.enter-mode (régime change)
|
|
*/
|
|
|
|
export const SEMA_VERBS = {
|
|
contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'],
|
|
|
|
commit: [
|
|
'select',
|
|
'toggle',
|
|
'save',
|
|
'submit',
|
|
'confirm',
|
|
'cancel',
|
|
'complete',
|
|
'fail',
|
|
'delete',
|
|
'restore',
|
|
'reset',
|
|
'discard',
|
|
'expire',
|
|
'acknowledge',
|
|
'set',
|
|
'remove',
|
|
'reorder'
|
|
],
|
|
|
|
signal: ['announce', 'notify', 'warn', 'alert', 'emphasize', 'remind'],
|
|
|
|
handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'],
|
|
|
|
emerge: [
|
|
'present',
|
|
'dismiss',
|
|
'open',
|
|
'close',
|
|
'expand',
|
|
'collapse',
|
|
'reveal',
|
|
'hide'
|
|
],
|
|
|
|
shift: ['enter-mode', 'exit-mode', 'navigate', 'route', 'step', 'return', 'context'],
|
|
|
|
sustain: [
|
|
'start',
|
|
'progress',
|
|
'loading',
|
|
'waiting',
|
|
'syncing',
|
|
'processing',
|
|
'streaming',
|
|
'pending',
|
|
'retrying',
|
|
'end'
|
|
]
|
|
} as const;
|
|
|
|
export type SemaFamilyName = keyof typeof SEMA_VERBS;
|
|
|
|
export type SemaVerb = (typeof SEMA_VERBS)[SemaFamilyName][number];
|
|
|
|
const VERB_SET: ReadonlySet<string> = new Set(
|
|
Object.values(SEMA_VERBS).flatMap((verbs) => [...verbs])
|
|
);
|
|
|
|
/**
|
|
* Reverse lookup: which family does a given verb belong to?
|
|
*
|
|
* Some verbs appear in multiple families (e.g. `reorder` is both a
|
|
* commit and a handle verb depending on context). The map returns the
|
|
* FIRST family in which the verb is declared per `SEMA_VERBS` ordering.
|
|
*/
|
|
const VERB_TO_FAMILY: ReadonlyMap<string, SemaFamilyName> = (() => {
|
|
const map = new Map<string, SemaFamilyName>();
|
|
for (const [family, verbs] of Object.entries(SEMA_VERBS) as [
|
|
SemaFamilyName,
|
|
readonly string[]
|
|
][]) {
|
|
for (const verb of verbs) {
|
|
if (!map.has(verb)) map.set(verb, family);
|
|
}
|
|
}
|
|
return map;
|
|
})();
|
|
|
|
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 parsed head OR (family + first-tail-segment) is canonical. */
|
|
matchesCanonical: boolean;
|
|
/** Tail after the first `-`, if any. e.g. `'commit-save'` → `'save'`. */
|
|
variant: string | undefined;
|
|
/**
|
|
* The family resolved from the name. Three sources, in order:
|
|
* 1. head is a verb → family from `VERB_TO_FAMILY[head]`
|
|
* 2. head is a family name → that family
|
|
* 3. neither → `undefined`
|
|
*/
|
|
family: SemaFamilyName | undefined;
|
|
/**
|
|
* The canonical verb resolved from the name, when one can be
|
|
* determined. For `'commit-toggle'` (family-verb shape) this is
|
|
* `'toggle'`; for `'dismiss-outside'` (verb-variant shape) this is
|
|
* `'dismiss'`; for bare `'present'` this is `'present'`.
|
|
*/
|
|
verb: string | undefined;
|
|
}
|
|
|
|
/**
|
|
* Inspect a morfo `events[].name` against the canonical vocabulary.
|
|
*
|
|
* Recognises two naming shapes:
|
|
* - `{verb}` or `{verb}-{variant}` — head is the verb (e.g.
|
|
* `present`, `dismiss-outside`, `close-cancel`).
|
|
* - `{family}-{verb}` or `{family}-{verb}-{variant}` — head is a
|
|
* family name (e.g. `commit-toggle`, `commit-save`).
|
|
*
|
|
* Returns `matchesCanonical: true` for either shape. Used for auditing
|
|
* morfos 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 tail = dashIdx === -1 ? '' : name.slice(dashIdx + 1);
|
|
const variant = dashIdx === -1 ? undefined : tail;
|
|
|
|
// Case 1: head is a canonical verb → family from VERB_TO_FAMILY.
|
|
if (VERB_SET.has(head)) {
|
|
return {
|
|
name,
|
|
head,
|
|
matchesCanonical: true,
|
|
variant,
|
|
family: VERB_TO_FAMILY.get(head),
|
|
verb: head
|
|
};
|
|
}
|
|
|
|
// Case 2: head is a family name → tail's first segment should be a
|
|
// verb in that family.
|
|
if (head in SEMA_VERBS) {
|
|
const family = head as SemaFamilyName;
|
|
const tailFirstDash = tail.indexOf('-');
|
|
const verbCandidate = tailFirstDash === -1 ? tail : tail.slice(0, tailFirstDash);
|
|
const familyVerbs = SEMA_VERBS[family] as readonly string[];
|
|
const verbCanonical = verbCandidate.length > 0 && familyVerbs.includes(verbCandidate);
|
|
return {
|
|
name,
|
|
head,
|
|
matchesCanonical: verbCanonical,
|
|
variant,
|
|
family,
|
|
verb: verbCanonical ? verbCandidate : undefined
|
|
};
|
|
}
|
|
|
|
// Case 3: neither verb nor family.
|
|
return {
|
|
name,
|
|
head,
|
|
matchesCanonical: false,
|
|
variant,
|
|
family: undefined,
|
|
verb: undefined
|
|
};
|
|
}
|
|
|
|
export function isSemaVerb(value: unknown): value is SemaVerb {
|
|
return typeof value === 'string' && VERB_SET.has(value);
|
|
}
|
|
|
|
/**
|
|
* Family the canonical verb belongs to, or `undefined` if not canonical.
|
|
*/
|
|
export function familyForVerb(verb: string): SemaFamilyName | undefined {
|
|
return VERB_TO_FAMILY.get(verb);
|
|
}
|