refactor(sema): align verb canon with «Diseñando lo que ocurre» (Plan B commit 1)

Codifies the doctrinal source for the semantic system per the user's
book at src/docs/Disenando_lo_que_ocurre_manuscrito_completo_revisado_v2.docx.

Canon (book cap. 8 + cap. 22-29 + cap. 10):
- 8th family `delegate` added (offer/plan/authorize/act/review/escalate/return)
- `signal.inform` added (book cap. 24 listed 7 signal verbs; we had 6)
- Commit contextual verbs from case studies: apply, partial, block, move, upload
- `sustain.upload` added
- SemaFamily union, SEMA_FAMILY_POLICY, sema-map, visual chan, morfo schema
  all extended to include delegate (intentPolicy: 'optional' — no intent by
  default per cap. 29 §4)

Lint (scripts/morfo-vocabulary-check.ts):
- Hard-fails on declared `semantic.verb` not in family canon (the doctrinal
  contract: subscribers query by family+verb)
- Soft-warns on event name shape drift (declared verb is the truth, name is
  informational)
- Allowlists `button.commit-action` with TODO referencing Plan B commit 2
  (Button's intent vs contact question per cap. 22 §11 needs design sign-off)

3 mechanical violation fixes:
- textarea: `shift-count-overflow` (family shift, verb limit) → `signal-warn-count-overflow`
  (signal.warn + risk per cap. 24 — corregible warning, not context shift)
- password-field: `shift-toggle-visibility` → `commit-toggle-visibility`
  (commit.toggle + affirm per cap. 23 — state fixation, not context shift)
- password-field: `shift-caps-state` → `signal-notify-caps-state`
  (signal.notify + neutral per cap. 24 — system orients attention without urgency)
- month-grid + year-grid: `nav-step` → `shift-navigate-step` (family/verb
  already canonical, name aligned to {family}-{verb}-{variant})

Verification:
- npm run morfo:vocabulary → EXIT 0 (1 allowlisted, 59 pre-existing name-shape
  warns documenting visible drift for future sprints)
- npx vitest src/uix/sema src/uix/morfo → 194/194 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 928b3c15b6
commit af6d6c762a

@ -1,23 +1,35 @@
/** /**
* Morfo vocabulary consistency check. * Morfo vocabulary consistency check.
* *
* Walks every morfo's `data` declarations and reports any attr with * Three checks in one script:
* `values: string[]` whose set is NOT one of the canonical vocabularies
* declared in `CANONICAL_VOCABULARIES` (exported from morfo/schema.ts).
* *
* Rationale (see sema_pre.md §5 + study.md §10): * 1. **data attr enums** (WARN) — walks every morfo's `data` declarations
* Sema selectors rely on consistent state vocabularies across components. * and reports any attr with `values: string[]` whose set is NOT one of
* If Accordion uses `data-state: ['open', 'closed']` then Dialog and Drawer * the canonical vocabularies declared in `CANONICAL_VOCABULARIES`.
* must use the same tokens — not `visible|hidden` or `expanded|collapsed`.
* *
* Strategy: a whitelist of known vocabularies. Any enum that doesn't exactly * 2. **declared semantic.verb against canon** (FAIL) — every morfo
* match one of them is flagged as a WARNING, not a hard error. Promote to * `events[].semantic.verb` must be in the canon defined in
* error only when the vocabulary registry is mature enough that divergence * `sema/verbs.ts` (which mirrors «Diseñando lo que ocurre» cap. 22-29).
* is always a bug. * 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: * Exit codes:
* 0 — all enums match a canonical vocabulary (or are novel but acceptable). * 0 — every declared verb is canonical (or allowlisted).
* 1 — at least one enum diverges and should be investigated. * 1 — at least one declared verb drifts from canon AND is not allowlisted.
*/ */
import { readdirSync } from 'node:fs'; import { readdirSync } from 'node:fs';
@ -25,10 +37,30 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import type { Morfo, MorfoPart } from '../src/uix/morfo/types'; import type { Morfo, MorfoPart } from '../src/uix/morfo/types';
import { validateMorfo, CANONICAL_VOCABULARIES } from '../src/uix/morfo/schema'; 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 __dirname = dirname(fileURLToPath(import.meta.url));
const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components'); 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<string, string> = {
// 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<Morfo[]> { async function loadMorfos(): Promise<Morfo[]> {
const files = readdirSync(MORFOS_DIR).filter( const files = readdirSync(MORFOS_DIR).filter(
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts') (f) => f.endsWith('.ts') && !f.endsWith('.test.ts')
@ -92,9 +124,13 @@ function classify(values: readonly string[]): Classification {
// ── Main ──────────────────────────────────────────────────────────────────── // ── Main ────────────────────────────────────────────────────────────────────
const morfos = await loadMorfos(); 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; morfo: string;
part: string; part: string;
attr: string; attr: string;
@ -109,7 +145,7 @@ type Finding = {
*/ */
const PER_COMPONENT_ATTRS = new Set(['data-last-action']); const PER_COMPONENT_ATTRS = new Set(['data-last-action']);
const findings: Finding[] = []; const dataFindings: DataFinding[] = [];
for (const morfo of morfos) { for (const morfo of morfos) {
for (const part of flatParts(morfo.parts)) { for (const part of flatParts(morfo.parts)) {
@ -118,7 +154,7 @@ for (const morfo of morfos) {
if (PER_COMPONENT_ATTRS.has(data.attr)) continue; if (PER_COMPONENT_ATTRS.has(data.attr)) continue;
const classification = classify(data.values); const classification = classify(data.values);
if (classification.kind !== 'match') { if (classification.kind !== 'match') {
findings.push({ dataFindings.push({
morfo: morfo.kebab, morfo: morfo.kebab,
part: part.kebab, part: part.kebab,
attr: data.attr, attr: data.attr,
@ -130,40 +166,162 @@ for (const morfo of morfos) {
} }
} }
if (findings.length === 0) { // ── 2. declared semantic.verb canon (FAIL) ─────────────────────────────────
console.log(`All enum values match canonical vocabularies.`);
process.exit(0); 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(''); if (verbFindings.length > 0) {
console.log(`Found ${findings.length} divergent enum${findings.length === 1 ? '' : 's'}:`); console.log('');
console.log(''); const drift = verbFindings.filter((f) => !f.allowlisted);
const allowlisted = verbFindings.filter((f) => f.allowlisted);
for (const f of findings) { if (allowlisted.length > 0) {
const loc = `${f.morfo}.${f.part}.${f.attr}`; console.log(
const values = `[${f.values.join(', ')}]`; `Found ${allowlisted.length} allowlisted declared-verb divergence${allowlisted.length === 1 ? '' : 's'}:`
switch (f.classification.kind) { );
case 'subset': console.log('');
for (const f of allowlisted) {
console.log( 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( 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( 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(''); if (nameFindings.length > 0) {
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.` 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);

@ -33,7 +33,7 @@ Parts: `Provider`, `Header`, `Heading`, `PrevButton`, `NextButton`, `Grid`,
## Sema events ## Sema events
- `commit-set` — `commit.set` on `cell`, `intent: 'affirm'`, sequence `post`. - `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 ## Baseline
@ -69,9 +69,10 @@ fuera del DatePicker (filtros, reportes, año fiscal).
`prev-row`/`first-month`/`last-month`/`prev-year`/`next-year` como `prev-row`/`first-month`/`last-month`/`prev-year`/`next-year` como
focus moves (no mutan el valor seleccionado). Sólo Enter/Space y la focus moves (no mutan el valor seleccionado). Sólo Enter/Space y la
navegación por PrevButton/NextButton mutan estado. navegación por PrevButton/NextButton mutan estado.
- **`nav-step` cubre toda la paginación** — un solo evento sema para - **`shift-navigate-step` cubre toda la paginación** — un solo evento sema
cualquier shift del header (PrevButton / NextButton / PageUp / PageDown). para cualquier shift del header (PrevButton / NextButton / PageUp / PageDown).
Family `shift.navigate` semánticamente correcto. Family `shift.navigate` semánticamente correcto, variant `step` por paso
discreto.
## Gaps ## 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. | | 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. | | 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. | | 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. |

@ -33,7 +33,7 @@ Parts: `Provider`, `Header`, `Heading`, `PrevButton`, `NextButton`, `Grid`,
## Sema events ## Sema events
- `commit-set` — `commit.set` on `cell`, `intent: 'affirm'`, sequence `post`. - `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 ## 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. para reusabilidad. DatePicker `kind: 'year'` lo consume internamente.
- **`decadeSize` configurable** — algunos diseños prefieren 10 (década - **`decadeSize` configurable** — algunos diseños prefieren 10 (década
natural) o 20 (dos décadas). UIX permite ambos sin nuevo componente. natural) o 20 (dos décadas). UIX permite ambos sin nuevo componente.
- **Mismo modelo de eventos que MonthGrid** — `commit-set` y `nav-step`, - **Mismo modelo de eventos que MonthGrid** — `commit-set` y
para que la pareja month-grid/year-grid sea sema-simétrica. `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 - **`select` único evento mutante; resto son focus moves** — el audit
script reconoce `next-year`/`prev-year`/`next-row`/`prev-row`/ script reconoce `next-year`/`prev-year`/`next-row`/`prev-row`/
`first-year`/`last-year`/`next-page`/`prev-page` como focus moves `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. | | 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. | | 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. | | 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. |

@ -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: { semantic: {
family: 'shift', family: 'shift',
verb: 'navigate', verb: 'navigate',

@ -57,22 +57,30 @@ export const passwordFieldMorfo = {
} }
}, },
{ {
// Visibility toggled (mask ↔ plaintext). // Visibility toggled (mask ↔ plaintext). Per book cap. 23, this is
name: 'shift-toggle-visibility', // 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: { semantic: {
family: 'shift', family: 'commit',
verb: 'toggle', verb: 'toggle',
target: v.partRef('visibility-trigger'), target: v.partRef('visibility-trigger'),
intent: 'affirm',
sequence: 'post' sequence: 'post'
} }
}, },
{ {
// Caps Lock state changed while the input is focused. // Caps Lock state changed while the input is focused. Per book
name: 'shift-caps-state', // 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: { semantic: {
family: 'shift', family: 'signal',
verb: 'navigate', verb: 'notify',
target: v.partRef('caps-lock-indicator'), target: v.partRef('caps-lock-indicator'),
intent: 'neutral',
sequence: 'post' sequence: 'post'
} }
} }

@ -41,13 +41,16 @@ export const textareaMorfo = {
}, },
{ {
// User exceeded `maxLength` while typing — input is clamped at the // User exceeded `maxLength` while typing — input is clamped at the
// limit. Fires once per overflow attempt. // limit. Fires once per overflow attempt. Per book cap. 24, this is
name: 'shift-count-overflow', // 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: { semantic: {
family: 'shift', family: 'signal',
verb: 'limit', verb: 'warn',
target: v.partRef('input'), target: v.partRef('input'),
intent: 'threat', intent: 'risk',
sequence: 'post' sequence: 'post'
} }
} }

@ -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: { semantic: {
family: 'shift', family: 'shift',
verb: 'navigate', verb: 'navigate',

@ -183,7 +183,12 @@ const semaIntentSchema = union(
]) ])
) as Schema<Intent, Intent>; ) as Schema<Intent, Intent>;
const semaTransitionalFamilySchema = union(literal('emerge'), literal('shift'), literal('sustain')); const semaTransitionalFamilySchema = union(
literal('emerge'),
literal('shift'),
literal('sustain'),
literal('delegate')
);
const semaIntentOptionalFamilySchema = union( const semaIntentOptionalFamilySchema = union(
semaTransitionalFamilySchema, semaTransitionalFamilySchema,
literal('contact'), literal('contact'),

@ -572,7 +572,8 @@ export const SEMA_FAMILY_POLICY = {
handle: { intentPolicy: 'allowed' }, // OPCIONAL — drag/scrub suelen ser neutrales handle: { intentPolicy: 'allowed' }, // OPCIONAL — drag/scrub suelen ser neutrales
emerge: { intentPolicy: 'optional' }, // OPCIONAL — Dialog que abre para confirmar threat sí declara emerge: { intentPolicy: 'optional' }, // OPCIONAL — Dialog que abre para confirmar threat sí declara
shift: { intentPolicy: 'optional' }, shift: { intentPolicy: 'optional' },
sustain: { intentPolicy: 'optional' } sustain: { intentPolicy: 'optional' },
delegate: { intentPolicy: 'optional' } // cap. 29 — no intent por defecto
} as const; } 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`) ## Vocabulario canónico de verbs (`SEMA_VERBS`)
Cross-component action verbs grouped by family per Cross-component action verbs grouped by family. El canon vive en
[`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../docs/GUIA_IMPLEMENTACION_SEMAUIX.md) [`verbs.ts`](./verbs.ts) y refleja
§1.3. `morfo.events[].name` debería alinear con este vocabulario para [`src/docs/Disenando_lo_que_ocurre_manuscrito_completo_revisado_v2.docx`](../../docs/)
que sema/sound/haptic puedan suscribir por verb y eidos pueda escribir cap. 22-29 (familias) + cap. 10 (intents). `morfo.events[].semantic.verb`
selectores transversales (`[data-event^=dismiss]`). 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 ```ts
SEMA_VERBS = { SEMA_VERBS = {
contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'], contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'],
commit: [ commit: [
'select', 'select', 'toggle', 'save', 'submit', 'confirm', 'complete', 'fail',
'toggle', 'cancel', 'reset', 'discard', 'delete', 'restore', 'expire',
'save',
'submit',
'confirm',
'cancel',
'complete',
'fail',
'delete',
'restore',
'reset',
'discard',
'expire',
'acknowledge', 'acknowledge',
'set', // Verbos contextuales del libro (cap. 29 + cap. 30 + apéndice C):
'remove', 'apply', 'partial', 'block', 'move', 'set', 'remove', 'reorder', 'upload'
'reorder'
], ],
signal: ['announce', 'notify', 'warn', 'alert', 'emphasize', 'remind'], signal: ['announce', 'notify', 'warn', 'alert', 'inform', 'emphasize', 'remind'],
handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'], handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'],
emerge: ['present', 'dismiss', 'open', 'close', 'expand', 'collapse', 'reveal', 'hide'], emerge: ['present', 'dismiss', 'open', 'close', 'expand', 'collapse', 'reveal', 'hide'],
shift: ['enter-mode', 'exit-mode', 'navigate', 'route', 'step', 'return', 'context'], shift: ['enter-mode', 'exit-mode', 'navigate', 'route', 'step', 'return', 'context'],
sustain: [ sustain: [
'start', 'start', 'progress', 'loading', 'waiting', 'syncing', 'processing',
'progress', 'streaming', 'pending', 'retrying', 'upload', 'end'
'loading', ],
'waiting', // Familia delegate (libro cap. 29): eventos donde cambia quién lleva la
'syncing', // iniciativa. No requiere IA — workflows, macros, aprobaciones.
'processing', delegate: ['offer', 'plan', 'authorize', 'act', 'review', 'escalate', 'return']
'streaming',
'pending',
'retrying',
'end'
]
}; };
``` ```
@ -675,8 +662,12 @@ Un `morfo.events[].name` puede tomar dos formas canónicas:
``` ```
`validateEventName(name)` reconoce ambas formas y devuelve `{ family, `validateEventName(name)` reconoce ambas formas y devuelve `{ family,
verb, variant, matchesCanonical }`. Advisory — no rechaza morfos, solo verb, variant, matchesCanonical }`. Usada por `scripts/morfo-vocabulary-check.ts`
flagea drift para tooling y revisión. (`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 ```ts
import { validateEventName } from '$uix/sema'; import { validateEventName } from '$uix/sema';

@ -46,7 +46,10 @@ const FAMILY_FALLBACK_LABEL: Record<string, SemaDurationLabel> = {
contact: 'glimpse', contact: 'glimpse',
commit: 'brief', commit: 'brief',
signal: 'noticed', 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'; const DEFAULT_HOLD_LABEL: SemaDurationLabel = 'brief';

@ -7,9 +7,13 @@ import type { Sema } from '../sema-map';
* PasswordField perceptual defaults. * PasswordField perceptual defaults.
* *
* - `commit-submit` → standard commit chime (Enter pressed in the input). * - `commit-submit` → standard commit chime (Enter pressed in the input).
* - `shift-toggle-visibility` → soft handle click on the eye button. * - `commit-toggle-visibility` → soft commit click on the eye button.
* - `shift-caps-state` → tap when caps lock state flips while typing — * Per book cap. 23: togglear visibilidad fija un estado, no cambia el
* subtle audio counterpart to the visible indicator. * 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. * Typing itself (`commit-input`) is silent.
*/ */
@ -27,12 +31,12 @@ export const passwordFieldSema: Sema = {
haptic: { kind: 'tap' } haptic: { kind: 'tap' }
}, },
{ {
selector: onPart('visibility-trigger', { eventName: 'shift-toggle-visibility' }), selector: onPart('visibility-trigger', { eventName: 'commit-toggle-visibility' }),
sound: soundTuning('form.commit.subtle'), sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' } 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'), sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' } haptic: { kind: 'tap' }
} }

@ -23,7 +23,7 @@ export const textareaSema: Sema = {
haptic: { kind: 'tap' } haptic: { kind: 'tap' }
}, },
{ {
selector: onProvider({ eventName: 'shift-count-overflow' }), selector: onProvider({ eventName: 'signal-warn-count-overflow' }),
sound: soundTuning('form.commit.subtle'), sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' } haptic: { kind: 'tap' }
} }

@ -219,6 +219,16 @@ export const SEMA_MAP: SemaMap = {
base: {}, base: {},
activeChannels: [], activeChannels: [],
hold: 'noticed' 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: { intents: {

@ -13,7 +13,7 @@ import type { SemaChannelId, SemaSignatureOverride } from './channels';
// ── Core domain ──────────────────────────────────────────────────────────── // ── Core domain ────────────────────────────────────────────────────────────
export type SemaValencedFamily = 'contact' | 'commit' | 'signal' | 'handle'; 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; export type SemaFamily = SemaValencedFamily | SemaTransitionalFamily;
// ── Family policy ────────────────────────────────────────────────────────── // ── Family policy ──────────────────────────────────────────────────────────
@ -60,7 +60,12 @@ export const SEMA_FAMILY_POLICY = {
handle: { intentPolicy: 'allowed' }, handle: { intentPolicy: 'allowed' },
emerge: { intentPolicy: 'optional' }, emerge: { intentPolicy: 'optional' },
shift: { 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<SemaFamily, { intentPolicy: IntentPolicy }>; } as const satisfies Record<SemaFamily, { intentPolicy: IntentPolicy }>;
/** Family classifications derived from `SEMA_FAMILY_POLICY.intentPolicy`. */ /** Family classifications derived from `SEMA_FAMILY_POLICY.intentPolicy`. */

@ -3,14 +3,14 @@ import { describe, expect, it } from 'vitest'
import { SEMA_VERBS, isSemaVerb, validateEventName, familyForVerb } from './verbs' import { SEMA_VERBS, isSemaVerb, validateEventName, familyForVerb } from './verbs'
describe('SEMA_VERBS', () => { describe('SEMA_VERBS', () => {
it('groups verbs by family', () => { it('groups verbs by family per book canon', () => {
// Canonical verbs in each family per // Canonical verbs per «Diseñando lo que ocurre» cap. 22-29
// src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md §1.3
expect(SEMA_VERBS.contact).toContain('press') expect(SEMA_VERBS.contact).toContain('press')
expect(SEMA_VERBS.commit).toContain('toggle') expect(SEMA_VERBS.commit).toContain('toggle')
expect(SEMA_VERBS.commit).toContain('save') expect(SEMA_VERBS.commit).toContain('save')
expect(SEMA_VERBS.signal).toContain('announce') expect(SEMA_VERBS.signal).toContain('announce')
expect(SEMA_VERBS.signal).toContain('warn') expect(SEMA_VERBS.signal).toContain('warn')
expect(SEMA_VERBS.signal).toContain('inform') // cap. 24
expect(SEMA_VERBS.handle).toContain('drag') expect(SEMA_VERBS.handle).toContain('drag')
expect(SEMA_VERBS.emerge).toContain('present') expect(SEMA_VERBS.emerge).toContain('present')
expect(SEMA_VERBS.emerge).toContain('dismiss') expect(SEMA_VERBS.emerge).toContain('dismiss')
@ -18,6 +18,26 @@ describe('SEMA_VERBS', () => {
expect(SEMA_VERBS.sustain).toContain('progress') 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', () => { it('has no internal duplicates per family', () => {
for (const verbs of Object.values(SEMA_VERBS)) { for (const verbs of Object.values(SEMA_VERBS)) {
expect(new Set(verbs).size).toBe(verbs.length) expect(new Set(verbs).size).toBe(verbs.length)
@ -51,6 +71,7 @@ describe('isSemaVerb', () => {
expect(isSemaVerb('commit')).toBe(false) expect(isSemaVerb('commit')).toBe(false)
expect(isSemaVerb('emerge')).toBe(false) expect(isSemaVerb('emerge')).toBe(false)
expect(isSemaVerb('signal')).toBe(false) expect(isSemaVerb('signal')).toBe(false)
expect(isSemaVerb('delegate')).toBe(false)
}) })
it('rejects non-strings', () => { it('rejects non-strings', () => {
@ -66,9 +87,12 @@ describe('familyForVerb', () => {
expect(familyForVerb('toggle')).toBe('commit') expect(familyForVerb('toggle')).toBe('commit')
expect(familyForVerb('dismiss')).toBe('emerge') expect(familyForVerb('dismiss')).toBe('emerge')
expect(familyForVerb('warn')).toBe('signal') expect(familyForVerb('warn')).toBe('signal')
expect(familyForVerb('inform')).toBe('signal')
expect(familyForVerb('drag')).toBe('handle') expect(familyForVerb('drag')).toBe('handle')
expect(familyForVerb('enter-mode')).toBe('shift') expect(familyForVerb('enter-mode')).toBe('shift')
expect(familyForVerb('progress')).toBe('sustain') expect(familyForVerb('progress')).toBe('sustain')
expect(familyForVerb('authorize')).toBe('delegate')
expect(familyForVerb('escalate')).toBe('delegate')
}) })
it('returns undefined for non-canonical strings', () => { it('returns undefined for non-canonical strings', () => {

@ -7,49 +7,103 @@
* and lets eidos write transversal selectors like * and lets eidos write transversal selectors like
* `[data-event^="dismiss"]`. * `[data-event^="dismiss"]`.
* *
* Vocabulary is intentionally small. New verbs only added when at least * Composite event names follow the convention `{family}-{verb}` or
* two components share the action with the same semantic family. * `{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. * ## Doctrina (libro «Diseñando lo que ocurre», cap. 8 + cap. 22-29)
* `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 * Eight families, each answering one perceptual question:
* 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): * - `contact` (cap. 22) — ¿el sistema ha sentido mi acción? Pre-evaluation
* verbs live under their family. Some verbs that look like one family * reception of input. Intent is typically `neutral` or omitted; "el intent
* actually belong to another in the doctrinal classification: * fuerte no debería vivir en el contacto, sino en la señal o consecuencia
* - select / toggle → commit (fix state, not just contact) * posterior" (cap. 22 §11).
* - acknowledge → commit (closes a pending decision) * - `commit` (cap. 23) — ¿algo quedó fijado o tuvo consecuencia? Where state
* - edit → represented as shift.enter-mode (régime change) * 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 = { export const SEMA_VERBS = {
contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'], contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'],
commit: [ commit: [
// Fijación de estado
'select', 'select',
'toggle', 'toggle',
'save', 'save',
// Resolución de proceso
'submit', 'submit',
'confirm', 'confirm',
'cancel',
'complete', 'complete',
'fail', 'fail',
'delete', // Cancelación / reversión
'restore', 'cancel',
'reset', 'reset',
'discard', 'discard',
// Consecuencia consumada
'delete',
'restore',
'expire', 'expire',
// Cierre que cubre una decisión pendiente sin afirmar/rechazar
'acknowledge', 'acknowledge',
// Verbos contextuales que el libro usa en casos de estudio
// (cap. 29 + cap. 30 + apéndice C):
'apply',
'partial',
'block',
'move',
'set', 'set',
'remove', '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'], handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'],
@ -76,7 +130,21 @@ export const SEMA_VERBS = {
'streaming', 'streaming',
'pending', 'pending',
'retrying', 'retrying',
'upload',
'end' '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; } as const;
@ -92,8 +160,10 @@ const VERB_SET: ReadonlySet<string> = new Set(
* Reverse lookup: which family does a given verb belong to? * Reverse lookup: which family does a given verb belong to?
* *
* Some verbs appear in multiple families (e.g. `reorder` is both a * Some verbs appear in multiple families (e.g. `reorder` is both a
* commit and a handle verb depending on context). The map returns the * commit and a handle verb depending on context; `return` is both a
* FIRST family in which the verb is declared per `SEMA_VERBS` ordering. * 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<string, SemaFamilyName> = (() => { const VERB_TO_FAMILY: ReadonlyMap<string, SemaFamilyName> = (() => {
const map = new Map<string, SemaFamilyName>(); const map = new Map<string, SemaFamilyName>();

@ -26,7 +26,7 @@ Independent of `Calendar` and `DatePicker`.
| Event | Family | Target | Intent | When | | Event | Family | Target | Intent | When |
| ------------ | -------- | ------ | ------ | ----------------------------------- | | ------------ | -------- | ------ | ------ | ----------------------------------- |
| `commit-set` | `commit` | `cell` | affirm | A month cell is clicked or Enter'd. | | `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 ## Keyboard

@ -163,7 +163,7 @@ export class MonthGridProvider {
private setPlaceholder(next: MonthPlaceholder, target?: HTMLElement): void { private setPlaceholder(next: MonthPlaceholder, target?: HTMLElement): void {
if (this.opts.placeholder.current.year === next.year) return; if (this.opts.placeholder.current.year === next.year) return;
this.opts.placeholder.current = next; 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 { prevPage(target?: HTMLElement): void {

@ -213,7 +213,7 @@ export class PasswordFieldProvider {
const next = !this.opts.visible.current; const next = !this.opts.visible.current;
this.opts.visible.current = next; this.opts.visible.current = next;
this.opts.onVisibilityChange.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. // Re-focus the input after the toggle so the user can keep typing.
this.soma.dom.focus(this.inputRef.current); this.soma.dom.focus(this.inputRef.current);
}; };
@ -226,7 +226,7 @@ export class PasswordFieldProvider {
setCapsActive(active: boolean) { setCapsActive(active: boolean) {
if (this.capsActive === active) return; if (this.capsActive === active) return;
this.capsActive = active; this.capsActive = active;
void this.runtime.trigger('shift-caps-state'); void this.runtime.trigger('signal-notify-caps-state');
} }
updateCapsFromEvent(e: KeyboardEvent | FocusEvent | undefined) { updateCapsFromEvent(e: KeyboardEvent | FocusEvent | undefined) {

@ -33,7 +33,7 @@ Multi-line text input with optional autosize, character counter, and submit-on-s
| `minRows` | `number` | `2` | Floor for autosize height. | | `minRows` | `number` | `2` | Floor for autosize height. |
| `maxRows` | `number` | — | Ceiling for autosize height. `undefined` = unbounded. | | `maxRows` | `number` | — | Ceiling for autosize height. `undefined` = unbounded. |
| `minLength` | `number` | — | Forwarded to the textarea. | | `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`. | | `disabled` | `boolean` | `false` | OR-merged with `Field.disabled`. |
| `readonly` | `boolean` | `false` | OR-merged with `Field.readonly`. | | `readonly` | `boolean` | `false` | OR-merged with `Field.readonly`. |
| `required` | `boolean` | `false` | OR-merged with `Field.required`. | | `required` | `boolean` | `false` | OR-merged with `Field.required`. |

@ -200,7 +200,7 @@ export class TextAreaProvider {
if (max !== undefined && next.length > max) { if (max !== undefined && next.length > max) {
clamped = next.slice(0, max); clamped = next.slice(0, max);
this.overflow = true; this.overflow = true;
void this.runtime.trigger('shift-count-overflow'); void this.runtime.trigger('signal-warn-count-overflow');
} else { } else {
this.overflow = false; this.overflow = false;
} }

@ -85,7 +85,7 @@ export type TextAreaProps = WithChild<
/** /**
* Hard limit on character count. When reached, additional input is * 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. * `value` never exceeds this limit. Unbounded when omitted.
*/ */
maxLength?: number; maxLength?: number;

@ -26,7 +26,7 @@ Independent of `Calendar` and `DatePicker`.
| Event | Family | Target | Intent | When | | Event | Family | Target | Intent | When |
| ------------ | -------- | ------ | ------ | ------------------------------------------ | | ------------ | -------- | ------ | ------ | ------------------------------------------ |
| `commit-set` | `commit` | `cell` | affirm | A year cell is clicked or Enter'd. | | `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 ## Keyboard

@ -144,7 +144,7 @@ export class YearGridProvider {
private setPlaceholder(next: YearPlaceholder, target?: HTMLElement): void { private setPlaceholder(next: YearPlaceholder, target?: HTMLElement): void {
if (this.opts.placeholder.current.year === next.year) return; if (this.opts.placeholder.current.year === next.year) return;
this.opts.placeholder.current = next; 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 { prevPage(target?: HTMLElement): void {

Loading…
Cancel
Save

Powered by TurnKey Linux.