/** * 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"]`. * * Composite event names follow the convention `{family}-{verb}` or * `{family}-{verb}-{variant}` (e.g. `commit-save`, `commit-toggle-visibility`, * `signal-warn-count-overflow`). `validateEventName` extracts the head, checks * it against the canon, and reports drift. * * ## Doctrina (libro «Diseñando lo que ocurre», cap. 8 + cap. 22-29) * * Eight families, each answering one perceptual question: * * - `contact` (cap. 22) — ¿el sistema ha sentido mi acción? Pre-evaluation * reception of input. Intent is typically `neutral` or omitted; "el intent * fuerte no debería vivir en el contacto, sino en la señal o consecuencia * posterior" (cap. 22 §11). * - `commit` (cap. 23) — ¿algo quedó fijado o tuvo consecuencia? Where state * or outcome lands. Accepts intent fully. * - `signal` (cap. 24) — ¿algo reclama mi atención? System-initiated attention * orientation. Accepts intent fully. * - `handle` (cap. 25) — ¿estoy controlando directamente este objeto? Direct * manipulation. Intent concentrates on `drop`; `carry` stays neutral. * - `emerge` (cap. 26) — ¿algo entró o salió del campo perceptivo? Presence * transitions. Normally does NOT accept intent — what appears may carry * it, the appearance itself doesn't. * - `shift` (cap. 27) — ¿cambió el contexto? Context/regime change. Normally * does NOT accept intent. * - `sustain` (cap. 28) — ¿esto sigue ocurriendo? Operational continuity. * Normally does NOT accept intent — pair with signal/commit when needed. * - `delegate` (cap. 29) — ¿quién actúa ahora? Reparto de iniciativa entre * usuario y sistema (workflows, IA, macros, aprobaciones). No intent by * default; intent appears in the resulting signal/commit. * * ## Six intents (cap. 10): neutral, affirm, fulfill, risk, threat, loss * * threat ≠ loss: threat lives ANTES de la consecuencia (convoca acción), * loss lives DESPUÉS (registra consecuencia consumada). * * ## Composition rules (cap. 30) * * 1. contact precede al resultado cuando hay acción directa * 2. sustain precede a commit cuando hay espera * 3. threat precede a loss (nunca `commit.delete + threat`) * 4. emerge no absorbe intent del contenido * 5. shift debe orientar (foco + título + landmark) * 6. handle concentra evaluación en el drop, no en el carry * 7. todo proceso abierto necesita salida (commit/cancel/fail) * * ## Extending the canon * * Adding a verb is justified when at least two components share the action * with the same semantic family. The book (cap. 8 §1) authorises verbs as a * level distinct from families: "no todo debe ser familia. Algunas diferencias * pertenecen al verbo, a la fase, al intent o a la realización." */ export const SEMA_VERBS = { contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'], commit: [ // Fijación de estado de selección — par natural canónico. // `select` (libro Cap 23) + `unselect` (extensión del autor 2026-05-25): // resultados aplicados sobre el estado de selección de un elemento. // "este item queda seleccionado" / "este item deja de estar seleccionado". // No es `remove` (no se elimina ni se saca de colección, solo cambia // estado de selección). No es `toggle` (toggle describe inversión // binaria genérica, no el resultado semántico de selección). 'select', 'unselect', // Inversión binaria genérica (switch, checkbox, control on/off). 'toggle', // Otros fijadores de estado. 'save', // Resolución de proceso 'submit', 'confirm', 'complete', 'fail', // Cancelación / reversión 'cancel', 'reset', 'discard', // Consecuencia consumada 'delete', 'restore', 'expire', // Cierre que cubre una decisión pendiente sin afirmar/rechazar 'acknowledge', // Verbos contextuales que el libro usa en casos de estudio // (cap. 29 + cap. 30 + apéndice C): 'apply', 'partial', 'block', 'move', 'set', 'remove', 'reorder', 'upload' ], signal: [ 'announce', 'notify', 'warn', 'alert', 'inform', 'emphasize', 'remind' ], // `zoom` (extensión del autor 2026-06-10): manipulación directa de la escala // del contenido (rueda / pinch), junto a drag / resize / rotate / scroll. handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll', 'zoom'], 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', 'upload', 'end' ], // Familia delegate (libro cap. 29). Eventos donde cambia quién lleva la // iniciativa o ejecuta parte de la acción. No requiere IA — workflows, // macros, reglas automáticas y aprobaciones también producen delegate. delegate: [ 'offer', // el sistema ofrece encargarse 'plan', // el sistema propone un curso 'authorize', // el usuario concede permiso 'act', // el sistema actúa por el usuario 'review', // el sistema espera revisión 'escalate', // el sistema pide intervención humana 'return' // el control vuelve al usuario ] } as const; export type SemaFamilyName = keyof typeof SEMA_VERBS; export type SemaVerb = (typeof SEMA_VERBS)[SemaFamilyName][number]; const VERB_SET: ReadonlySet = new Set( Object.values(SEMA_VERBS).flatMap((verbs) => [...verbs]) ); /** * Find the longest canonical verb that is a prefix of `text` (followed * by end-of-string or a dash). Required because the canon includes * multi-word verbs (`enter-mode`, `exit-mode`); a naive "split at first * dash" would only see `enter` / `exit` and miss the actual verb. * * Returns the matched verb plus whatever remains of `text` after it * (with the separating dash consumed), or `undefined` if no verb * prefixes `text`. Sorts by length descending so `enter-mode` beats * `enter` when both could match. */ function matchLongestVerb( text: string, verbs: Iterable ): { verb: string; tail: string } | undefined { const sorted = [...verbs].sort((a, b) => b.length - a.length); for (const verb of sorted) { if (text === verb) return { verb, tail: '' }; if (text.startsWith(verb + '-')) return { verb, tail: text.slice(verb.length + 1) }; } return undefined; } /** * 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; `return` is both a * shift and a delegate verb; `upload` is both a commit outcome and a * sustain phase). The map returns the FIRST family in which the verb is * declared per `SEMA_VERBS` ordering. */ const VERB_TO_FAMILY: ReadonlyMap = (() => { const map = new Map(); 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: name starts with a canonical verb (bare verb or verb-variant // shape). Longest-prefix match against ALL canonical verbs so // multi-word verbs like `enter-mode` win over `enter`. const verbHeadMatch = matchLongestVerb(name, VERB_SET); if (verbHeadMatch) { return { name, head, matchesCanonical: true, variant, family: VERB_TO_FAMILY.get(verbHeadMatch.verb), verb: verbHeadMatch.verb }; } // Case 2: head is a family name → longest-prefix match against the // family's verbs (covers multi-word verbs like `shift.enter-mode`). if (head in SEMA_VERBS) { const family = head as SemaFamilyName; const familyVerbs = SEMA_VERBS[family] as readonly string[]; const verbMatch = matchLongestVerb(tail, familyVerbs); return { name, head, matchesCanonical: verbMatch !== undefined, variant, family, verb: verbMatch?.verb }; } // 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); }