diff --git a/scripts/morfo-vocabulary-check.ts b/scripts/morfo-vocabulary-check.ts index 57ec1c144..82d831277 100644 --- a/scripts/morfo-vocabulary-check.ts +++ b/scripts/morfo-vocabulary-check.ts @@ -1,23 +1,35 @@ /** * Morfo vocabulary consistency check. * - * Walks every morfo's `data` declarations and reports any attr with - * `values: string[]` whose set is NOT one of the canonical vocabularies - * declared in `CANONICAL_VOCABULARIES` (exported from morfo/schema.ts). + * Three checks in one script: * - * Rationale (see sema_pre.md §5 + study.md §10): - * Sema selectors rely on consistent state vocabularies across components. - * If Accordion uses `data-state: ['open', 'closed']` then Dialog and Drawer - * must use the same tokens — not `visible|hidden` or `expanded|collapsed`. + * 1. **data attr enums** (WARN) — walks every morfo's `data` declarations + * and reports any attr with `values: string[]` whose set is NOT one of + * the canonical vocabularies declared in `CANONICAL_VOCABULARIES`. * - * Strategy: a whitelist of known vocabularies. Any enum that doesn't exactly - * match one of them is flagged as a WARNING, not a hard error. Promote to - * error only when the vocabulary registry is mature enough that divergence - * is always a bug. + * 2. **declared semantic.verb against canon** (FAIL) — every morfo + * `events[].semantic.verb` must be in the canon defined in + * `sema/verbs.ts` (which mirrors «Diseñando lo que ocurre» cap. 22-29). + * This is the doctrinal contract: cross-family selectors and sema + * cascades subscribe by family+verb, so a non-canonical verb is a + * real semantic drift, not just a naming choice. Fails unless the + * `{morfo}:{eventName}` pair is in `EVENT_NAME_ALLOWLIST`. + * + * 3. **event name shape** (WARN) — checks each `events[].name` against + * the convention `{family}-{verb}[-{variant}]` or `{verb}[-{variant}]`. + * Names that don't parse are WARN: subscribers grep by name, so drift + * here hurts discoverability, but the canonical truth lives in + * `semantic.verb` (check #2). The book authorises verb-level variation + * (cap. 8 §1: "Algunas diferencias pertenecen al verbo, a la fase, al + * intent o a la realización"), so a domain-specific label like + * `commit-clear` whose declared verb is `reset` is acceptable. + * + * Allowlisted entries are tracked deliberate exceptions, each with a + * TODO pointing at where they will be resolved. * * Exit codes: - * 0 — all enums match a canonical vocabulary (or are novel but acceptable). - * 1 — at least one enum diverges and should be investigated. + * 0 — every declared verb is canonical (or allowlisted). + * 1 — at least one declared verb drifts from canon AND is not allowlisted. */ import { readdirSync } from 'node:fs'; @@ -25,10 +37,30 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; import type { Morfo, MorfoPart } from '../src/uix/morfo/types'; import { validateMorfo, CANONICAL_VOCABULARIES } from '../src/uix/morfo/schema'; +import { validateEventName, SEMA_VERBS } from '../src/uix/sema/verbs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components'); +/** + * Event names that knowingly diverge from the canonical vocabulary, + * tracked as explicit technical debt. Each entry must reference WHERE + * the divergence is resolved or formally accepted. + * + * Add new entries only after a documented design discussion — the point + * of this allowlist is to make the divergence visible, not to dodge the + * canon. Remove an entry the moment the underlying morfo is updated. + */ +const EVENT_NAME_ALLOWLIST: Record = { + // TODO(Button): `commit-action` uses verb `action`, not in canon. + // Per book cap. 22 §11 "el intent fuerte no debería vivir en el contacto, + // sino en la señal o consecuencia posterior." Deciding between + // (a) `contact-activate` + intent stays as visual chip variant only + // (b) keep `commit` family but pick a canonical verb (e.g. acknowledge) + // is an API decision that needs design sign-off — see Plan B commit 2. + 'button:commit-action': 'Pending Button intent/family design decision (Plan B commit 2)' +}; + async function loadMorfos(): Promise { const files = readdirSync(MORFOS_DIR).filter( (f) => f.endsWith('.ts') && !f.endsWith('.test.ts') @@ -92,9 +124,13 @@ function classify(values: readonly string[]): Classification { // ── Main ──────────────────────────────────────────────────────────────────── const morfos = await loadMorfos(); -console.error(`Scanning ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} for vocabulary divergence...`); +console.error( + `Scanning ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} for vocabulary divergence...` +); + +// ── 1. data attr enums (WARN only) ───────────────────────────────────────── -type Finding = { +type DataFinding = { morfo: string; part: string; attr: string; @@ -109,7 +145,7 @@ type Finding = { */ const PER_COMPONENT_ATTRS = new Set(['data-last-action']); -const findings: Finding[] = []; +const dataFindings: DataFinding[] = []; for (const morfo of morfos) { for (const part of flatParts(morfo.parts)) { @@ -118,7 +154,7 @@ for (const morfo of morfos) { if (PER_COMPONENT_ATTRS.has(data.attr)) continue; const classification = classify(data.values); if (classification.kind !== 'match') { - findings.push({ + dataFindings.push({ morfo: morfo.kebab, part: part.kebab, attr: data.attr, @@ -130,40 +166,162 @@ for (const morfo of morfos) { } } -if (findings.length === 0) { - console.log(`All enum values match canonical vocabularies.`); - process.exit(0); +// ── 2. declared semantic.verb canon (FAIL) ───────────────────────────────── + +type VerbFinding = { + morfo: string; + eventName: string; + declaredFamily: string; + declaredVerb: string; + allowlisted: boolean; + allowlistReason?: string; +}; + +const verbFindings: VerbFinding[] = []; + +// ── 3. event name shape (WARN) ───────────────────────────────────────────── + +type NameFinding = { + morfo: string; + eventName: string; + parsedFamily: string | undefined; + parsedVerb: string | undefined; +}; + +const nameFindings: NameFinding[] = []; + +for (const morfo of morfos) { + for (const evt of morfo.events ?? []) { + const declaredFamily = evt.semantic?.family; + const declaredVerb = evt.semantic?.verb; + + // Check #2 — declared verb must be in the family's canon. + if (declaredFamily && declaredVerb) { + const familyVerbs = SEMA_VERBS[declaredFamily as keyof typeof SEMA_VERBS] as + | readonly string[] + | undefined; + if (familyVerbs && !familyVerbs.includes(declaredVerb)) { + const allowlistKey = `${morfo.kebab}:${evt.name}`; + const allowlistReason = EVENT_NAME_ALLOWLIST[allowlistKey]; + verbFindings.push({ + morfo: morfo.kebab, + eventName: evt.name, + declaredFamily, + declaredVerb, + allowlisted: allowlistReason !== undefined, + allowlistReason + }); + } + } + + // Check #3 — event name shape (WARN only). + const parsed = validateEventName(evt.name); + if (!parsed.matchesCanonical) { + nameFindings.push({ + morfo: morfo.kebab, + eventName: evt.name, + parsedFamily: parsed.family, + parsedVerb: parsed.verb + }); + } + } +} + +// ── Reporting ────────────────────────────────────────────────────────────── + +let hadHardErrors = false; + +if (dataFindings.length > 0) { + console.log(''); + console.log( + `Found ${dataFindings.length} divergent data attr enum${dataFindings.length === 1 ? '' : 's'} (warn):` + ); + console.log(''); + for (const f of dataFindings) { + const loc = `${f.morfo}.${f.part}.${f.attr}`; + const values = `[${f.values.join(', ')}]`; + switch (f.classification.kind) { + case 'subset': + console.log( + `WARN ${loc} ${values} extends "${f.classification.vocabulary}" with: ${f.classification.extra.join(', ')}` + ); + break; + case 'superset': + console.log( + `WARN ${loc} ${values} shrinks "${f.classification.vocabulary}" missing: ${f.classification.missing.join(', ')}` + ); + break; + case 'unknown': + console.log( + `WARN ${loc} ${values} no canonical vocabulary matches. Consider if this should use one of: ${Object.keys(CANONICAL_VOCABULARIES).join(', ')}` + ); + break; + } + } + console.log(''); + console.log( + `These are WARNINGS — novel vocabularies may be legitimate. If the value set SHOULD match a canonical vocabulary, align it. If the component introduces a NEW canonical vocabulary, add it to CANONICAL_VOCABULARIES in src/uix/morfo/schema.ts.` + ); } -console.log(''); -console.log(`Found ${findings.length} divergent enum${findings.length === 1 ? '' : 's'}:`); -console.log(''); +if (verbFindings.length > 0) { + console.log(''); + const drift = verbFindings.filter((f) => !f.allowlisted); + const allowlisted = verbFindings.filter((f) => f.allowlisted); -for (const f of findings) { - const loc = `${f.morfo}.${f.part}.${f.attr}`; - const values = `[${f.values.join(', ')}]`; - switch (f.classification.kind) { - case 'subset': + if (allowlisted.length > 0) { + console.log( + `Found ${allowlisted.length} allowlisted declared-verb divergence${allowlisted.length === 1 ? '' : 's'}:` + ); + console.log(''); + for (const f of allowlisted) { console.log( - `WARN ${loc} ${values} extends "${f.classification.vocabulary}" with: ${f.classification.extra.join(', ')}` + `ALLOW ${f.morfo}.${f.eventName} family=${f.declaredFamily} verb=${f.declaredVerb} → ${f.allowlistReason}` ); - break; - case 'superset': + } + } + + if (drift.length > 0) { + console.log(''); + console.log( + `Found ${drift.length} non-canonical declared verb${drift.length === 1 ? '' : 's'} (FAIL):` + ); + console.log(''); + for (const f of drift) { console.log( - `WARN ${loc} ${values} shrinks "${f.classification.vocabulary}" missing: ${f.classification.missing.join(', ')}` + `FAIL ${f.morfo}.${f.eventName} family=${f.declaredFamily} verb=${f.declaredVerb}` ); - break; - case 'unknown': console.log( - `WARN ${loc} ${values} no canonical vocabulary matches. Consider if this should use one of: ${Object.keys(CANONICAL_VOCABULARIES).join(', ')}` + ` Verb "${f.declaredVerb}" is not in SEMA_VERBS.${f.declaredFamily} (see src/uix/sema/verbs.ts).` ); - break; + } + console.log(''); + console.log( + `Declared semantic.verb must be in the canon in sema/verbs.ts (which mirrors «Diseñando lo que ocurre» cap. 22-29). If a new verb is genuinely needed in the canon, add it after a design discussion and ensure at least two components share it. If the divergence is deliberate and time-bounded, add it to EVENT_NAME_ALLOWLIST in this script with a TODO.` + ); + hadHardErrors = true; } } -console.log(''); -console.log( - `These are WARNINGS — novel vocabularies may be legitimate. Review each above: if the value set SHOULD match a canonical vocabulary, align it. If the component introduces a NEW canonical vocabulary, add it to CANONICAL_VOCABULARIES in src/uix/morfo/schema.ts.` -); +if (nameFindings.length > 0) { + console.log(''); + console.log( + `Found ${nameFindings.length} event name${nameFindings.length === 1 ? '' : 's'} that don't follow the canonical shape (warn):` + ); + console.log(''); + for (const f of nameFindings) { + console.log(`WARN ${f.morfo}.${f.eventName} does not parse to {family}-{verb}[-{variant}]`); + } + console.log(''); + console.log( + `These are WARNINGS — the declared semantic.verb is canonical, but the event name doesn't surface the family+verb (which makes \`[data-event^="..."]\` selectors and verb-grouped sema cascades harder to author). Consider renaming to \`{family}-{verb}-{domain-label}\`.` + ); +} + +if (dataFindings.length === 0 && verbFindings.length === 0 && nameFindings.length === 0) { + console.log( + 'All enum values match canonical vocabularies and all event names are canonical.' + ); +} -process.exit(0); // WARN only — don't fail CI yet (until vocabulary registry is mature) +process.exit(hadHardErrors ? 1 : 0); diff --git a/src/uix/eidos/components/month-grid/README.md b/src/uix/eidos/components/month-grid/README.md index 3960a4191..dcb99a2c9 100644 --- a/src/uix/eidos/components/month-grid/README.md +++ b/src/uix/eidos/components/month-grid/README.md @@ -33,7 +33,7 @@ Parts: `Provider`, `Header`, `Heading`, `PrevButton`, `NextButton`, `Grid`, ## Sema events - `commit-set` — `commit.set` on `cell`, `intent: 'affirm'`, sequence `post`. -- `nav-step` — `shift.navigate` on `provider`, sequence `post`. +- `shift-navigate-step` — `shift.navigate` on `provider`, sequence `post`. ## Baseline @@ -69,9 +69,10 @@ fuera del DatePicker (filtros, reportes, año fiscal). `prev-row`/`first-month`/`last-month`/`prev-year`/`next-year` como focus moves (no mutan el valor seleccionado). Sólo Enter/Space y la navegación por PrevButton/NextButton mutan estado. -- **`nav-step` cubre toda la paginación** — un solo evento sema para - cualquier shift del header (PrevButton / NextButton / PageUp / PageDown). - Family `shift.navigate` semánticamente correcto. +- **`shift-navigate-step` cubre toda la paginación** — un solo evento sema + para cualquier shift del header (PrevButton / NextButton / PageUp / PageDown). + Family `shift.navigate` semánticamente correcto, variant `step` por paso + discreto. ## Gaps @@ -80,4 +81,4 @@ fuera del DatePicker (filtros, reportes, año fiscal). | Multi-año selection (range) | **diferir** | Caso de uso emergente; resolver con un MonthRangePicker dedicado cuando llegue demanda real. | | Quick-jump dropdown del año | **diferir** | Útil para escalar años pero el patrón actual (PrevButton/NextButton + PageUp/Down) cubre. | | Localized month names en el cell | **implementar** | Hoy se renderiza el número/short label desde el snippet; las locales que necesitan formatos mes-largo no se aplican automáticamente. Backlog i18n. | -| Renombrar `nav-step` a `shift-step` o `navigate-page` | **diferir** | El warning A-3.6 sugiere {verb}-X. Cambiar requiere actualizar consumidores; bajo prioridad. | +| Renombrar `nav-step` a forma canónica | **resuelto** | Renombrado a `shift-navigate-step` (verb `navigate` + variant `step`) per book cap. 27 §5. | diff --git a/src/uix/eidos/components/year-grid/README.md b/src/uix/eidos/components/year-grid/README.md index 5f5c6fb56..7ec02761e 100644 --- a/src/uix/eidos/components/year-grid/README.md +++ b/src/uix/eidos/components/year-grid/README.md @@ -33,7 +33,7 @@ Parts: `Provider`, `Header`, `Heading`, `PrevButton`, `NextButton`, `Grid`, ## Sema events - `commit-set` — `commit.set` on `cell`, `intent: 'affirm'`, sequence `post`. -- `nav-step` — `shift.navigate` on `provider`, sequence `post`. +- `shift-navigate-step` — `shift.navigate` on `provider`, sequence `post`. ## Baseline @@ -63,8 +63,9 @@ nuevo en UIX; sirve para escenarios de selección de año aislada para reusabilidad. DatePicker `kind: 'year'` lo consume internamente. - **`decadeSize` configurable** — algunos diseños prefieren 10 (década natural) o 20 (dos décadas). UIX permite ambos sin nuevo componente. -- **Mismo modelo de eventos que MonthGrid** — `commit-set` y `nav-step`, - para que la pareja month-grid/year-grid sea sema-simétrica. +- **Mismo modelo de eventos que MonthGrid** — `commit-set` y + `shift-navigate-step`, para que la pareja month-grid/year-grid sea + sema-simétrica. - **`select` único evento mutante; resto son focus moves** — el audit script reconoce `next-year`/`prev-year`/`next-row`/`prev-row`/ `first-year`/`last-year`/`next-page`/`prev-page` como focus moves @@ -77,5 +78,5 @@ nuevo en UIX; sirve para escenarios de selección de año aislada | --- | --- | --- | | Multi-año selection (range) | **diferir** | Resolver con YearRangePicker cuando llegue caso real. | | Quick-jump a una década específica (input + go) | **diferir** | Útil para escalar 100+ años; la paginación cubre el caso común. | -| Renombrar `nav-step` a `shift-step` o `navigate-page` | **diferir** | El warning A-3.6 sugiere {verb}-X. Cambiar requiere actualizar consumidores; bajo prioridad. | +| Renombrar `nav-step` a forma canónica | **resuelto** | Renombrado a `shift-navigate-step` (verb `navigate` + variant `step`) per book cap. 27 §5. | | Highlight visual de la década actual (extra al año) | **diferir** | Hoy se marca sólo el año actual con `data-current`. La década podría marcarse en el heading. | diff --git a/src/uix/morfo/components/month-grid.ts b/src/uix/morfo/components/month-grid.ts index 31b54540c..fbf2a16be 100644 --- a/src/uix/morfo/components/month-grid.ts +++ b/src/uix/morfo/components/month-grid.ts @@ -23,7 +23,11 @@ export const monthGridMorfo = { } }, { - name: 'nav-step', + // Paginar al año anterior/siguiente. Per book cap. 27 §5, + // `shift.navigate` cubre "Lista → detalle. Página A → página B" — + // el usuario está en otro tramo del eje temporal. La variante + // `step` mantiene la huella semántica de paso discreto. + name: 'shift-navigate-step', semantic: { family: 'shift', verb: 'navigate', diff --git a/src/uix/morfo/components/password-field.ts b/src/uix/morfo/components/password-field.ts index 972ef0e95..ad349c64f 100644 --- a/src/uix/morfo/components/password-field.ts +++ b/src/uix/morfo/components/password-field.ts @@ -57,22 +57,30 @@ export const passwordFieldMorfo = { } }, { - // Visibility toggled (mask ↔ plaintext). - name: 'shift-toggle-visibility', + // Visibility toggled (mask ↔ plaintext). Per book cap. 23, this is + // a state fixation (`commit.toggle`), NOT a context shift: the + // visible/masked attribute flips, the operational regime doesn't + // change. Intent `affirm` — confirmation suave de un cambio menor. + name: 'commit-toggle-visibility', semantic: { - family: 'shift', + family: 'commit', verb: 'toggle', target: v.partRef('visibility-trigger'), + intent: 'affirm', sequence: 'post' } }, { - // Caps Lock state changed while the input is focused. - name: 'shift-caps-state', + // Caps Lock state changed while the input is focused. Per book + // cap. 24, this is the system orienting attention to a relevant + // condition without urgency (`signal.notify + neutral`). Not a + // context shift; not a commit (the user didn't decide anything). + name: 'signal-notify-caps-state', semantic: { - family: 'shift', - verb: 'navigate', + family: 'signal', + verb: 'notify', target: v.partRef('caps-lock-indicator'), + intent: 'neutral', sequence: 'post' } } diff --git a/src/uix/morfo/components/textarea.ts b/src/uix/morfo/components/textarea.ts index 221d5fbd8..c9db5e142 100644 --- a/src/uix/morfo/components/textarea.ts +++ b/src/uix/morfo/components/textarea.ts @@ -41,13 +41,16 @@ export const textareaMorfo = { }, { // User exceeded `maxLength` while typing — input is clamped at the - // limit. Fires once per overflow attempt. - name: 'shift-count-overflow', + // limit. Fires once per overflow attempt. Per book cap. 24, this is + // a corregible warning (signal.warn + risk), NOT a context shift: + // the limit was reached, the user can correct by deleting; nothing + // in the operational regime changed. + name: 'signal-warn-count-overflow', semantic: { - family: 'shift', - verb: 'limit', + family: 'signal', + verb: 'warn', target: v.partRef('input'), - intent: 'threat', + intent: 'risk', sequence: 'post' } } diff --git a/src/uix/morfo/components/year-grid.ts b/src/uix/morfo/components/year-grid.ts index fdd2cb80f..b149b5115 100644 --- a/src/uix/morfo/components/year-grid.ts +++ b/src/uix/morfo/components/year-grid.ts @@ -23,7 +23,10 @@ export const yearGridMorfo = { } }, { - name: 'nav-step', + // Paginar al rango anterior/siguiente de años. Per book cap. 27 §5, + // `shift.navigate` cubre "Página A → página B" — el usuario cambia + // de tramo del eje temporal. Variante `step` para paso discreto. + name: 'shift-navigate-step', semantic: { family: 'shift', verb: 'navigate', diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index 791c7af8e..ae0220361 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -183,7 +183,12 @@ const semaIntentSchema = union( ]) ) as Schema; -const semaTransitionalFamilySchema = union(literal('emerge'), literal('shift'), literal('sustain')); +const semaTransitionalFamilySchema = union( + literal('emerge'), + literal('shift'), + literal('sustain'), + literal('delegate') +); const semaIntentOptionalFamilySchema = union( semaTransitionalFamilySchema, literal('contact'), diff --git a/src/uix/sema/README.md b/src/uix/sema/README.md index 091995dbe..8aba605ea 100644 --- a/src/uix/sema/README.md +++ b/src/uix/sema/README.md @@ -572,7 +572,8 @@ export const SEMA_FAMILY_POLICY = { handle: { intentPolicy: 'allowed' }, // OPCIONAL — drag/scrub suelen ser neutrales emerge: { intentPolicy: 'optional' }, // OPCIONAL — Dialog que abre para confirmar threat sí declara shift: { intentPolicy: 'optional' }, - sustain: { intentPolicy: 'optional' } + sustain: { intentPolicy: 'optional' }, + delegate: { intentPolicy: 'optional' } // cap. 29 — no intent por defecto } as const; ``` @@ -605,50 +606,36 @@ pero ya no dicta las reglas de intent — eso lo hace la política. ## Vocabulario canónico de verbs (`SEMA_VERBS`) -Cross-component action verbs grouped by family per -[`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../docs/GUIA_IMPLEMENTACION_SEMAUIX.md) -§1.3. `morfo.events[].name` debería alinear con este vocabulario para -que sema/sound/haptic puedan suscribir por verb y eidos pueda escribir -selectores transversales (`[data-event^=dismiss]`). +Cross-component action verbs grouped by family. El canon vive en +[`verbs.ts`](./verbs.ts) y refleja +[`src/docs/Disenando_lo_que_ocurre_manuscrito_completo_revisado_v2.docx`](../../docs/) +cap. 22-29 (familias) + cap. 10 (intents). `morfo.events[].semantic.verb` +DEBE estar en este vocabulario; `morfo.events[].name` debería seguir la +forma `{family}-{verb}[-{variant}]` para que sema/sound/haptic puedan +suscribir por verb y eidos pueda escribir selectores transversales +(`[data-event^="dismiss"]`). ```ts SEMA_VERBS = { contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'], commit: [ - 'select', - 'toggle', - 'save', - 'submit', - 'confirm', - 'cancel', - 'complete', - 'fail', - 'delete', - 'restore', - 'reset', - 'discard', - 'expire', + 'select', 'toggle', 'save', 'submit', 'confirm', 'complete', 'fail', + 'cancel', 'reset', 'discard', 'delete', 'restore', 'expire', 'acknowledge', - 'set', - 'remove', - 'reorder' + // Verbos contextuales del libro (cap. 29 + cap. 30 + apéndice C): + 'apply', 'partial', 'block', 'move', 'set', 'remove', 'reorder', 'upload' ], - signal: ['announce', 'notify', 'warn', 'alert', 'emphasize', 'remind'], + signal: ['announce', 'notify', 'warn', 'alert', 'inform', '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' - ] + 'start', 'progress', 'loading', 'waiting', 'syncing', 'processing', + 'streaming', 'pending', 'retrying', 'upload', 'end' + ], + // Familia delegate (libro cap. 29): eventos donde cambia quién lleva la + // iniciativa. No requiere IA — workflows, macros, aprobaciones. + delegate: ['offer', 'plan', 'authorize', 'act', 'review', 'escalate', 'return'] }; ``` @@ -675,8 +662,12 @@ Un `morfo.events[].name` puede tomar dos formas canónicas: ``` `validateEventName(name)` reconoce ambas formas y devuelve `{ family, -verb, variant, matchesCanonical }`. Advisory — no rechaza morfos, solo -flagea drift para tooling y revisión. +verb, variant, matchesCanonical }`. Usada por `scripts/morfo-vocabulary-check.ts` +(`npm run morfo:vocabulary`), que hard-falla cuando el `semantic.verb` +declarado en un morfo no está en el canon de su familia, y soft-warns +cuando la NAME del evento no calza con `{family}-{verb}[-{variant}]` +aun siendo el verb canónico. Allowlist temporal en el script para +divergencias deliberadas. ```ts import { validateEventName } from '$uix/sema'; diff --git a/src/uix/sema/chans/visual.ts b/src/uix/sema/chans/visual.ts index 1a6727a01..aa8982c7e 100644 --- a/src/uix/sema/chans/visual.ts +++ b/src/uix/sema/chans/visual.ts @@ -46,7 +46,10 @@ const FAMILY_FALLBACK_LABEL: Record = { contact: 'glimpse', commit: 'brief', signal: 'noticed', - handle: 'brief' + handle: 'brief', + // Delegate (book cap. 29): structural, similar perceptual weight to + // sustain — the visible signal is presence + text, not a brief pulse. + delegate: 'noticed' }; const DEFAULT_HOLD_LABEL: SemaDurationLabel = 'brief'; diff --git a/src/uix/sema/components/password-field.ts b/src/uix/sema/components/password-field.ts index 0cc4ed475..0d50920f2 100644 --- a/src/uix/sema/components/password-field.ts +++ b/src/uix/sema/components/password-field.ts @@ -7,9 +7,13 @@ import type { Sema } from '../sema-map'; * PasswordField perceptual defaults. * * - `commit-submit` → standard commit chime (Enter pressed in the input). - * - `shift-toggle-visibility` → soft handle click on the eye button. - * - `shift-caps-state` → tap when caps lock state flips while typing — - * subtle audio counterpart to the visible indicator. + * - `commit-toggle-visibility` → soft commit click on the eye button. + * Per book cap. 23: togglear visibilidad fija un estado, no cambia el + * contexto operativo. + * - `signal-notify-caps-state` → tap when caps lock state flips while + * typing — subtle audio counterpart to the visible indicator. Per book + * cap. 24: signal.notify + neutral (the system orients attention without + * urgency). * Typing itself (`commit-input`) is silent. */ @@ -27,12 +31,12 @@ export const passwordFieldSema: Sema = { haptic: { kind: 'tap' } }, { - selector: onPart('visibility-trigger', { eventName: 'shift-toggle-visibility' }), + selector: onPart('visibility-trigger', { eventName: 'commit-toggle-visibility' }), sound: soundTuning('form.commit.subtle'), haptic: { kind: 'tap' } }, { - selector: onPart('caps-lock-indicator', { eventName: 'shift-caps-state' }), + selector: onPart('caps-lock-indicator', { eventName: 'signal-notify-caps-state' }), sound: soundTuning('form.commit.subtle'), haptic: { kind: 'tap' } } diff --git a/src/uix/sema/components/textarea.ts b/src/uix/sema/components/textarea.ts index 39b750887..01040e19b 100644 --- a/src/uix/sema/components/textarea.ts +++ b/src/uix/sema/components/textarea.ts @@ -23,7 +23,7 @@ export const textareaSema: Sema = { haptic: { kind: 'tap' } }, { - selector: onProvider({ eventName: 'shift-count-overflow' }), + selector: onProvider({ eventName: 'signal-warn-count-overflow' }), sound: soundTuning('form.commit.subtle'), haptic: { kind: 'tap' } } diff --git a/src/uix/sema/sema-map.ts b/src/uix/sema/sema-map.ts index 1a3cc9d00..ce80bc890 100644 --- a/src/uix/sema/sema-map.ts +++ b/src/uix/sema/sema-map.ts @@ -219,6 +219,16 @@ export const SEMA_MAP: SemaMap = { base: {}, activeChannels: [], hold: 'noticed' + }, + // Delegate = reparto de iniciativa entre usuario y sistema (libro + // cap. 29). Estructural; no carga intent por sí mismo. Mantiene perfil + // bajo en sonido/háptica — la lectura se apoya en presencia + texto + // + sustain. Los canales fuertes aparecen cuando delegate se compone + // con signal (revisión, alerta) o commit (apply). + delegate: { + base: {}, + activeChannels: [], + hold: 'noticed' } }, intents: { diff --git a/src/uix/sema/types.ts b/src/uix/sema/types.ts index c52e7ee10..6cf0e37ac 100644 --- a/src/uix/sema/types.ts +++ b/src/uix/sema/types.ts @@ -13,7 +13,7 @@ import type { SemaChannelId, SemaSignatureOverride } from './channels'; // ── Core domain ──────────────────────────────────────────────────────────── export type SemaValencedFamily = 'contact' | 'commit' | 'signal' | 'handle'; -export type SemaTransitionalFamily = 'emerge' | 'shift' | 'sustain'; +export type SemaTransitionalFamily = 'emerge' | 'shift' | 'sustain' | 'delegate'; export type SemaFamily = SemaValencedFamily | SemaTransitionalFamily; // ── Family policy ────────────────────────────────────────────────────────── @@ -60,7 +60,12 @@ export const SEMA_FAMILY_POLICY = { handle: { intentPolicy: 'allowed' }, emerge: { intentPolicy: 'optional' }, shift: { intentPolicy: 'optional' }, - sustain: { intentPolicy: 'optional' } + sustain: { intentPolicy: 'optional' }, + // Book cap. 29 §4: "Delegate no tiene intent por defecto. Que el sistema + // actúe por el usuario no significa automáticamente que algo sea positivo, + // negativo, urgente o perdido." Intent appears when the delegated action + // produces a downstream signal/commit. + delegate: { intentPolicy: 'optional' } } as const satisfies Record; /** Family classifications derived from `SEMA_FAMILY_POLICY.intentPolicy`. */ diff --git a/src/uix/sema/verbs.test.ts b/src/uix/sema/verbs.test.ts index 0e339d5a8..dc606ecc8 100644 --- a/src/uix/sema/verbs.test.ts +++ b/src/uix/sema/verbs.test.ts @@ -3,14 +3,14 @@ import { describe, expect, it } from 'vitest' import { SEMA_VERBS, isSemaVerb, validateEventName, familyForVerb } from './verbs' describe('SEMA_VERBS', () => { - it('groups verbs by family', () => { - // Canonical verbs in each family per - // src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md §1.3 + it('groups verbs by family per book canon', () => { + // Canonical verbs per «Diseñando lo que ocurre» cap. 22-29 expect(SEMA_VERBS.contact).toContain('press') expect(SEMA_VERBS.commit).toContain('toggle') expect(SEMA_VERBS.commit).toContain('save') expect(SEMA_VERBS.signal).toContain('announce') expect(SEMA_VERBS.signal).toContain('warn') + expect(SEMA_VERBS.signal).toContain('inform') // cap. 24 expect(SEMA_VERBS.handle).toContain('drag') expect(SEMA_VERBS.emerge).toContain('present') expect(SEMA_VERBS.emerge).toContain('dismiss') @@ -18,6 +18,26 @@ describe('SEMA_VERBS', () => { expect(SEMA_VERBS.sustain).toContain('progress') }) + it('exposes the delegate family (book cap. 29)', () => { + // Eighth family: who acts. Required for workflows, IA, macros, + // rule-based automation. Per book Apéndice C case 3. + expect(SEMA_VERBS.delegate).toContain('offer') + expect(SEMA_VERBS.delegate).toContain('plan') + expect(SEMA_VERBS.delegate).toContain('authorize') + expect(SEMA_VERBS.delegate).toContain('act') + expect(SEMA_VERBS.delegate).toContain('review') + expect(SEMA_VERBS.delegate).toContain('escalate') + expect(SEMA_VERBS.delegate).toContain('return') + }) + + it('exposes contextual commit verbs used in the book case studies', () => { + // cap. 29 + cap. 30 + apéndice C + expect(SEMA_VERBS.commit).toContain('apply') + expect(SEMA_VERBS.commit).toContain('partial') + expect(SEMA_VERBS.commit).toContain('block') + expect(SEMA_VERBS.commit).toContain('upload') + }) + it('has no internal duplicates per family', () => { for (const verbs of Object.values(SEMA_VERBS)) { expect(new Set(verbs).size).toBe(verbs.length) @@ -51,6 +71,7 @@ describe('isSemaVerb', () => { expect(isSemaVerb('commit')).toBe(false) expect(isSemaVerb('emerge')).toBe(false) expect(isSemaVerb('signal')).toBe(false) + expect(isSemaVerb('delegate')).toBe(false) }) it('rejects non-strings', () => { @@ -66,9 +87,12 @@ describe('familyForVerb', () => { expect(familyForVerb('toggle')).toBe('commit') expect(familyForVerb('dismiss')).toBe('emerge') expect(familyForVerb('warn')).toBe('signal') + expect(familyForVerb('inform')).toBe('signal') expect(familyForVerb('drag')).toBe('handle') expect(familyForVerb('enter-mode')).toBe('shift') expect(familyForVerb('progress')).toBe('sustain') + expect(familyForVerb('authorize')).toBe('delegate') + expect(familyForVerb('escalate')).toBe('delegate') }) it('returns undefined for non-canonical strings', () => { diff --git a/src/uix/sema/verbs.ts b/src/uix/sema/verbs.ts index d6a77b995..ddf082ead 100644 --- a/src/uix/sema/verbs.ts +++ b/src/uix/sema/verbs.ts @@ -7,49 +7,103 @@ * 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 `{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. * - * 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. + * ## Doctrina (libro «Diseñando lo que ocurre», cap. 8 + cap. 22-29) * - * 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. + * Eight families, each answering one perceptual question: * - * 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) + * - `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 'select', 'toggle', 'save', + // Resolución de proceso 'submit', 'confirm', - 'cancel', 'complete', 'fail', - 'delete', - 'restore', + // 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' + 'reorder', + 'upload' ], - signal: ['announce', 'notify', 'warn', 'alert', 'emphasize', 'remind'], + signal: [ + 'announce', + 'notify', + 'warn', + 'alert', + 'inform', + 'emphasize', + 'remind' + ], handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'], @@ -76,7 +130,21 @@ export const SEMA_VERBS = { '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; @@ -92,8 +160,10 @@ const VERB_SET: ReadonlySet = new Set( * 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. + * 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(); diff --git a/src/uix/soma/components/month-grid/README.md b/src/uix/soma/components/month-grid/README.md index a8df6d843..2992c9883 100644 --- a/src/uix/soma/components/month-grid/README.md +++ b/src/uix/soma/components/month-grid/README.md @@ -26,7 +26,7 @@ Independent of `Calendar` and `DatePicker`. | Event | Family | Target | Intent | When | | ------------ | -------- | ------ | ------ | ----------------------------------- | | `commit-set` | `commit` | `cell` | affirm | A month cell is clicked or Enter'd. | -| `nav-step` | `shift` | `provider` | - | Prev/Next year or keyboard PageUp/PageDown. | +| `shift-navigate-step` | `shift` | `provider` | - | Prev/Next year or keyboard PageUp/PageDown. | ## Keyboard diff --git a/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts b/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts index 103b5f94b..3134cc72d 100644 --- a/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts +++ b/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts @@ -163,7 +163,7 @@ export class MonthGridProvider { private setPlaceholder(next: MonthPlaceholder, target?: HTMLElement): void { if (this.opts.placeholder.current.year === next.year) return; this.opts.placeholder.current = next; - void this.runtime.trigger('nav-step', target ? { fallbackTarget: target } : undefined); + void this.runtime.trigger('shift-navigate-step', target ? { fallbackTarget: target } : undefined); } prevPage(target?: HTMLElement): void { diff --git a/src/uix/soma/components/password-field/password-field-provider.svelte.ts b/src/uix/soma/components/password-field/password-field-provider.svelte.ts index caf68106f..b3d8f6813 100644 --- a/src/uix/soma/components/password-field/password-field-provider.svelte.ts +++ b/src/uix/soma/components/password-field/password-field-provider.svelte.ts @@ -213,7 +213,7 @@ export class PasswordFieldProvider { const next = !this.opts.visible.current; this.opts.visible.current = next; this.opts.onVisibilityChange.current?.(next); - void this.runtime.trigger('shift-toggle-visibility'); + void this.runtime.trigger('commit-toggle-visibility'); // Re-focus the input after the toggle so the user can keep typing. this.soma.dom.focus(this.inputRef.current); }; @@ -226,7 +226,7 @@ export class PasswordFieldProvider { setCapsActive(active: boolean) { if (this.capsActive === active) return; this.capsActive = active; - void this.runtime.trigger('shift-caps-state'); + void this.runtime.trigger('signal-notify-caps-state'); } updateCapsFromEvent(e: KeyboardEvent | FocusEvent | undefined) { diff --git a/src/uix/soma/components/textarea/README.md b/src/uix/soma/components/textarea/README.md index ac263d32f..b14928082 100644 --- a/src/uix/soma/components/textarea/README.md +++ b/src/uix/soma/components/textarea/README.md @@ -33,7 +33,7 @@ Multi-line text input with optional autosize, character counter, and submit-on-s | `minRows` | `number` | `2` | Floor for autosize height. | | `maxRows` | `number` | — | Ceiling for autosize height. `undefined` = unbounded. | | `minLength` | `number` | — | Forwarded to the textarea. | -| `maxLength` | `number` | — | Clamps the value AND surfaces `shift-count-overflow` events. | +| `maxLength` | `number` | — | Clamps the value AND surfaces `signal-warn-count-overflow` events. | | `disabled` | `boolean` | `false` | OR-merged with `Field.disabled`. | | `readonly` | `boolean` | `false` | OR-merged with `Field.readonly`. | | `required` | `boolean` | `false` | OR-merged with `Field.required`. | diff --git a/src/uix/soma/components/textarea/textarea-provider.svelte.ts b/src/uix/soma/components/textarea/textarea-provider.svelte.ts index 3456a92c2..ccb6d9103 100644 --- a/src/uix/soma/components/textarea/textarea-provider.svelte.ts +++ b/src/uix/soma/components/textarea/textarea-provider.svelte.ts @@ -200,7 +200,7 @@ export class TextAreaProvider { if (max !== undefined && next.length > max) { clamped = next.slice(0, max); this.overflow = true; - void this.runtime.trigger('shift-count-overflow'); + void this.runtime.trigger('signal-warn-count-overflow'); } else { this.overflow = false; } diff --git a/src/uix/soma/components/textarea/types.ts b/src/uix/soma/components/textarea/types.ts index 3414a79df..10302f664 100644 --- a/src/uix/soma/components/textarea/types.ts +++ b/src/uix/soma/components/textarea/types.ts @@ -85,7 +85,7 @@ export type TextAreaProps = WithChild< /** * Hard limit on character count. When reached, additional input is - * dropped and the morfo emits `shift-count-overflow`. The bindable + * dropped and the morfo emits `signal-warn-count-overflow`. The bindable * `value` never exceeds this limit. Unbounded when omitted. */ maxLength?: number; diff --git a/src/uix/soma/components/year-grid/README.md b/src/uix/soma/components/year-grid/README.md index 75019a79b..a5f6b50f6 100644 --- a/src/uix/soma/components/year-grid/README.md +++ b/src/uix/soma/components/year-grid/README.md @@ -26,7 +26,7 @@ Independent of `Calendar` and `DatePicker`. | Event | Family | Target | Intent | When | | ------------ | -------- | ------ | ------ | ------------------------------------------ | | `commit-set` | `commit` | `cell` | affirm | A year cell is clicked or Enter'd. | -| `nav-step` | `shift` | `provider` | - | Prev/Next page or keyboard PageUp/PageDown. | +| `shift-navigate-step` | `shift` | `provider` | - | Prev/Next page or keyboard PageUp/PageDown. | ## Keyboard diff --git a/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts b/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts index 9d73f3507..7fdd0de15 100644 --- a/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts +++ b/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts @@ -144,7 +144,7 @@ export class YearGridProvider { private setPlaceholder(next: YearPlaceholder, target?: HTMLElement): void { if (this.opts.placeholder.current.year === next.year) return; this.opts.placeholder.current = next; - void this.runtime.trigger('nav-step', target ? { fallbackTarget: target } : undefined); + void this.runtime.trigger('shift-navigate-step', target ? { fallbackTarget: target } : undefined); } prevPage(target?: HTMLElement): void {