From 9ca8b4e30b5edd13374485611aaf29b63de6263e Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 26 May 2026 19:43:30 +0200 Subject: [PATCH] docs(claude/libro): document persistence + a11ySemantic + polymorphic close rollout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LIBRO_VARIACIONES_Y_EXTENSIONES.md: - New section D.9 (persistence + holds-by-intent — book §6.1). - New section D.10 (a11ySemantic per event — book §9.1). - New section D.11 (polymorphic events — book §5.3) with the full rollout state across overlays + picker family. CLAUDE.md hand-offs: - 2026-05-27: initial sprint (persistence + a11ySemantic + polymorphic types). - 2026-05-27 #2: caller messages + persistence extension + Dialog refactor. - 2026-05-27 #3: Drawer + Popover polymorphic refactor. - 2026-05-27 #4: Picker family polymorphic refactor + test fixtures decoupling. Each hand-off records: scope, files touched, doctrinal decisions, and pending follow-ups. The whole rollout was verified on 680/680 tests across sema + morfo + soma + adom scopes. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 158 ++++++++++++++ src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md | 228 ++++++++++++++++++++ 2 files changed, 386 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 885dff27a..6677a195f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,164 @@ remaining surface (plain CSS recipes) until those gain a builder of their own. Hand-written morfo-targeting selector strings in TypeScript files are an architecture violation, not a lint warning. +## Session hand-off — 2026-05-27 (persistence + a11ySemantic + polymorphic) + +Sprint cerrando los 3 ítems diferidos del hand-off 2026-05-08: persistence + holds-by-intent (libro §6), a11ySemantic per event (libro §9), polymorphic events (libro §5.3). Detalle completo en `src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md` D.9 / D.10 / D.11. + +**Persistence (libro §6.1)** — separa `hold` (duración perceptible mínima) de `persistence` (lifecycle real): + +- Nuevo tipo `SignalPersistence = 'transient' | 'untilAction' | 'untilFix' | 'stateBound'` en `src/uix/sema/types.ts`, propagado a `SemanticSignal`, `MorfoEventSemantic` y `TriggerOptions`. +- Tabla canónica `SEMA_HOLDS_BY_INTENT` en `src/uix/sema/holds.ts` (referencia, NO auto-aplicada — default conservador `'transient'`). +- `EngineSemantic.emit()` ahora devuelve `Promise` (el id). Para `persistence !== 'transient'` mantiene la proyección viva pasado el hold; expone `engine.clear(id)`, `engine.clearTarget(target)`, `engine.hasActive(id)`. +- `SomaRuntime.trigger()` devuelve `TriggerResult { id?, persistence? }`. Expone `runtime.clearSignal(id)`, `runtime.clearTarget(target)`, `runtime.partRef(part)` (helper que devuelve el HTMLElement registrado de un part — útil para que providers limpien sin trackear ids). +- 6 morfos declaran `persistence` explícita: `announce.signal-alert` (untilAction), `dialog/drawer.close-after-fail` (transient explícito), `file-upload.signal-warn-reject` + `form.signal-warn-invalid` (untilFix), `password-field.signal-notify-caps-state` (stateBound). +- 3 providers cabledados con `clearTarget(provider)`: form (limpia al validar / reset), file-upload (limpia antes de cada nueva ronda de accept/reject + en remove / clear), password-field (limpia indicator al apagarse caps lock). + +**a11ySemantic (libro §9.1)**: + +- Nuevo `MorfoA11ySemantic` interface en `src/uix/morfo/types.ts`: `requiresPersistentTrace?`, `requiresLiveRegion?`, `requiresFocusMove?`, `keyboardEquivalent?`, `reducedMotionFallback?: 'state' | 'text' | 'focus' | 'none'`. Añadido como `a11ySemantic?` opcional en `MorfoEvent`. Pasa por compile a `ActionPlan.a11ySemantic`. +- Helper de reduced-motion en `src/arts/adom/reduced-motion.svelte.ts` (paralelo a `viewport.svelte.ts`): `ReducedMotionTracker` reactivo del media query con SSR-safe fallback. Expuesto en `ActiveDom.prefersReducedMotion.matches`. +- `ActiveUix.announce(message, priority?, timeout?)`: live region compartida lazy-creada vía `dom.writeNode`. No depende de soma. Polite/assertive son regiones separadas, cleanup en `dispose()`. +- `Soma.runtime()` auto-wires `sources.announce = (m, p) => uix.announce(m, p)`. Tests usan fake `announce`. +- `SomaRuntime.trigger()` honra a11ySemantic después del emit: live region (con `opts.message`), focus move, reduced-motion fallback (incluye forzar `channels: []` cuando fallback = 'state'). +- 6 morfos anotados (los mismos consumidores). + +**Polymorphic events (libro §5.3)** — ADITIVO sobre el shape concreto: + +- Morfo declara `family` + `intent` + `verb` como default. Si añade `allowedFamilies: readonly SemaFamily[]`, los providers pueden override la family en `runtime.trigger(name, { semantic: { family, intent?, verb? } })`. El default family del morfo es IMPLÍCITAMENTE allowed. +- `isPolymorphicSemantic(semantic)` helper exportado de `$uix/morfo`. +- `SomaRuntimePolymorphicError` cuando override no está en allowedFamilies. +- Diseño aditivo (no variante separada con `defaultSemantic`) elegido para mantener backwards-compat con 29 morfos existentes y demos que acceden `event.semantic.family` directamente. + +**Tests**: 671/671 pasan en `src/uix/sema + morfo + soma + src/arts/adom/test`. 4 fallas pre-existentes en `src/uix/contracts.test.ts` (words component translation keys) — confirmadas en baseline antes de mis cambios via `git stash`. + +**`npm run check`**: 1 error pre-existente en `src/uix/soma/components/command/command-provider.svelte.ts:384` (`Cannot find name 'attrs'`) que ya estaba en main. + +**Pendientes deliberadamente fuera del sprint** (resueltos en la segunda mitad — ver hand-off 2026-05-27 #2 abajo): + +- ~~Extender persistence a tags-input + textarea~~ — DONE +- ~~Caller messages para announce~~ — DONE (form / file-upload / password-field / dialog / drawer) +- ~~Demo polymorphic real~~ — DONE (Dialog refactorizado a polymorphic close) + +## Session hand-off — 2026-05-27 #2 (messages + persistence ext + Dialog polymorphic) + +Segunda mitad del sprint cerrando el backlog del hand-off anterior. 3 grupos de cambios. + +**M — Caller messages para live region**: + +Cada provider que dispara un evento con `a11ySemantic.requiresLiveRegion` ahora pasa `opts.message` con texto localizado. La live region (ya cabledada via `Soma.runtime() → sources.announce`) finalmente anuncia algo: + +- `form-provider`: `signal-warn-invalid` → "1 form error..." / "{N} form errors..." (count = `Object.keys(form.issues).length`). Usa `FORM_LANGS.ERROR_SUMMARY_*`. +- `file-upload-provider`: `signal-warn-reject` → "1 file was rejected" / "{N} files were rejected". Nuevas entradas `FILE_UPLOAD_LANGS.REJECT_SUMMARY_SINGLE/MULTI` + langs catalog. +- `password-field-provider`: `signal-notify-caps-state` → "Caps Lock is on" (usa `PASSWORD_FIELD_LANGS.CAPS_WARNING` que ya existía). +- `dialog-provider.dismissWith(action, { message })`: signatura aceptando `message` opcional, forwarded al trigger. +- `drawer-provider.dismissWith(action, { message })`: igual que dialog. + +**P — Persistence extendido a tags-input + textarea**: + +Misma doctrina §6.2 — signal+risk = untilFix: + +- `tags-input.signal-warn-reject` → persistence: `untilFix` + a11ySemantic + provider clear via `clearTarget(input)`. Nueva entrada `tags-input.reject-warning` en langs catalog. Provider gana método privado `emitWarnReject(target)` que centraliza clear + emit + message. Successful add también clearea (la adición VÁLIDA es el "fix" de un reject previo). +- `textarea.signal-warn-count-overflow` → persistence: `untilFix` + a11ySemantic + provider clear en transición OUT-of-overflow. Nueva entrada `textarea.overflow-warning` en langs catalog. Provider emite SOLO en transición INTO overflow (no en cada keystroke al cap) — el `untilFix` proyecta persistente, no necesita re-anuncio. + +**POLY — Dialog refactor a polymorphic close**: + +Caso real de uso del feature de eventos polymorphic (§5.3). 5 eventos `close-*` colapsados en 1 evento `close`: + +- **Morfo** (`dialog.ts`): un evento `close` con `family: 'emerge', verb: 'close', allowedFamilies: ['emerge', 'commit', 'signal']`. SIN `prewrite` (el provider escribe `data-last-action` imperativamente). +- **Validador relajado**: el invariante "cada `values[]` debe ser prewritten por algún event" cayó al sentido único "cada prewrite con value debe estar en `values[]`". Razón: con polymorphism los values pueden ser escritos imperativamente. El comentario en `schema.ts` explica. +- **Provider** (`dialog-provider.svelte.ts`): `DISMISS_CAUSES` mapa de acciones a `{ lastAction, semantic }`. `dismissWith(action, opts)` traduce a `dom.apply` + `runtime.trigger('close', { semantic, ... })`. `triggerClose` es privado ahora. +- **Cascade sema** (`sema/components/dialog.ts`): selectores `eventName: 'close-dismiss-outside'` → `eventName: 'close', state: { attr: 'data-last-action', value: 'dismissed-outside' }`. `eventNamePrefix: 'close-'` → `eventName: 'close', eventFamily: 'emerge'`. +- **Eidos CSS** (`dialog.css`): NO cambió — ya leía `data-last-action` para tintar, no nombres de evento. +- **API pública preservada**: consumidores externos no notan diferencia (`dismissWith` mantiene firma + comportamiento observable). +- **Drawer / Popover**: NO refactorizados. Mismo patrón aplicable; defer porque cada uno requiere reescribir su cascade + decisión separada de timing. + +**Tests**: + +- `npx vitest run src/uix/sema src/uix/morfo src/uix/soma src/arts/adom/test`: **679/679 pass**. +- `npm run check`: 1 error pre-existente (`command-provider.svelte.ts:384` — ya en baseline). +- 4 fallas pre-existentes en `src/uix/contracts.test.ts` (words translations) — no relacionadas. + +**Pendientes a futuro** (cubierto en hand-off 2026-05-27 #3 abajo): + +- ~~Refactor analógico de Drawer + Popover a polymorphic close~~ — DONE +- Otros `signal.warn-*` no incluidos: hay variantes con `verb: 'warn'` que podrían también beneficiarse de persistence untilFix (revisar caso por caso). + +## Session hand-off — 2026-05-27 #3 (Drawer + Popover polymorphic close) + +Tercera tanda del sprint — completa el rollout del patrón polymorphic close (book §5.3) a los dos componentes hermanos de Dialog. Mismo refactor aplicado de forma sistemática. + +**Drawer**: +- Morfo: 5 close-* → 1 polymorphic `close` con `family: 'emerge', allowedFamilies: ['emerge', 'commit', 'signal']`, sin prewrite. Drag events (drag-start / drag-progress / drag-end / resize) intactos. +- Provider: `DISMISS_CAUSES` map + `dismissWith(action, opts?)` traduce a `dom.apply(data-last-action)` + `runtime.trigger('close', { semantic })`. `triggerClose` privado. 4 callsites internos migrados a `dismissWith` (Content escape, Content keydown escape, Overlay click-outside, Close button). +- Sema cascade (`sema/components/drawer.ts`): selector `eventNamePrefix: 'close-'` → `eventName: 'close', eventFamily: 'emerge'`. +- Test (`drawer-provider.svelte.test.ts`): `triggerClose('close-dismiss', ...)` → `dismissWith('dismiss')`; expect name='close'. + +**Popover**: +- Morfo: igual refactor. 5 close-* → 1 polymorphic `close`. +- Provider: igual patrón con DISMISS_CAUSES + dismissWith. Internal callsites (5 lugares: scheduleHoverClose, trigger toggle click, trigger toggle keydown, escape, outside) migrados a `dismissWith`. Close button: usa el prop `action` traducido a dismissWith. +- Sema cascade (`sema/components/popover.ts`): mismo patrón — `eventName: 'close'` + `state: { attr: 'data-last-action', value: 'dismissed-outside' }` para la regla de dismiss-outside passive. +- Test (`popover-provider.svelte.test.ts`): no había refs a close-* directos. Ningún cambio necesario. + +**Test fixtures migrados antes del refactor**: +- `compile.test.ts`: prewrite test usaba `drawerMorfo` → ahora usa `colorPickerMorfo`. +- `runtime.svelte.test.ts`: prewrite test usaba `drawerMorfo` → ahora usa `colorPickerMorfo`. + +**Resultado**: +- 3 componentes overlay (Dialog / Drawer / Popover) usan el patrón polymorphic close de forma consistente. +- 5 picker components (color-picker / date-picker / date-range-picker / time-picker / time-range-picker) mantienen el shape per-event. Son la canonical reference para `prewrite` declarativo + sirven como fixtures de tests. +- API pública intacta — `dismissWith` mantiene signatura en los tres overlays. +- Eidos CSS no tocó (ya leía `data-last-action`). + +**Tests**: 680/680 pass en `src/uix/sema + morfo + soma + src/arts/adom/test`. `npm run check`: 1 error pre-existente. + +**Pendientes a futuro** (picker family cubierto en hand-off 2026-05-27 #4): +- ~~Picker family refactor~~ — DONE +- Otros `signal-warn-*` no cubiertos (textarea / tags-input ya hechos; revisar caso por caso si emergen más). + +## Session hand-off — 2026-05-27 #4 (Picker family polymorphic close) + +Cuarta tanda — completa el rollout polymorphic close al picker family (5 componentes). Más decoupling de tests. + +**Refactor de los 5 pickers**: +- `color-picker`: 4 close-* → 1 polymorphic `close` +- `date-picker`: 4 close-* → 1 polymorphic `close` +- `date-range-picker`: 4 close-* (incl. `close-range-commit`) → 1 polymorphic `close` +- `time-picker`: 4 close-* → 1 polymorphic `close` +- `time-range-picker`: 4 close-* → 1 polymorphic `close` + +Cada morfo: `family: 'emerge', verb: 'close', target: v.partRef('calendar'|'clock'|'content'), persistence: 'transient', allowedFamilies: ['emerge', 'commit', 'signal']`. + +**Hallazgo importante**: los picker providers NO disparan los close events. Solo togglean `opts.open = false`. Los eventos estaban declarados pero **inertes** — su único consumidor era el schema validator y los compiler tests. El refactor es alineación doctrinal, no de comportamiento. + +**Sema cascade**: solo `color-picker` tiene un sema pack y no referenciaba close-* (solo handle-*). Cero updates necesarios en cascades. + +**Test fixtures decoupling**: +- Nuevo `src/uix/morfo/test-fixtures.ts` con `prewriteFixtureMorfo` sintético. +- `compile.test.ts`: migrado de `colorPickerMorfo` → `prewriteFixtureMorfo`. +- `runtime.svelte.test.ts`: igual migración. +- Beneficio: los tests validan el contrato del compiler/runtime sin depender del catálogo de componentes. Futuros refactors del catálogo no rompen estos tests. + +**Estado final del rollout polymorphic** (3 sprints combinados): + +| Componente | Polymorphic morfo | Provider cabledado | +|---|---|---| +| Dialog | ✓ | ✓ (`dismissWith` → `runtime.trigger`) | +| Drawer | ✓ | ✓ | +| Popover | ✓ | ✓ | +| color-picker | ✓ | ✗ (toggle opts.open) | +| date-picker | ✓ | ✗ | +| date-range-picker | ✓ | ✗ | +| time-picker | ✓ | ✗ | +| time-range-picker | ✓ | ✗ | + +Único morfo con shape pre-polymorphic restante: el fixture sintético `prewriteFixtureMorfo` — vivo sólo para tests. + +**Tests**: 680/680 pass. `npm run check`: 1 error pre-existente. + +**Pendiente a futuro**: +- Cabledar `runtime.trigger('close', { semantic })` en los providers picker cuando justifique disparar el evento (telemetría, sound on commit picker, etc.). + ## Session hand-off — 2026-05-09 (selector discipline + lint reframing) - **Typed selector builder applied** in diff --git a/src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md b/src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md index 3e99fba4c..61edd4f58 100644 --- a/src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md +++ b/src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md @@ -440,6 +440,234 @@ E implementar un `Channel` con `prepare(signal, target)` + `play(effective)`. El --- +### D.9 Persistence: separar hold expresivo de lifecycle de la señal + +- **Status**: **PROJECT_CANON** (codifica libro Cap 24 §6.1 — primera implementación operativa) +- **Origen**: libro Cap 24 §6.1 distingue `hold` (duración mínima perceptible) de `persistence` (cuánto tiempo dura realmente la señal). La implementación trataba todo como transient con un hold numérico — regresión: un `signal.warn + risk` de validación desaparecía a los 240 ms aunque el formulario siguiera inválido. + +**Tipos** (`src/uix/sema/types.ts`): + +```ts +export type SignalPersistence = 'transient' | 'untilAction' | 'untilFix' | 'stateBound'; +``` + +| Valor | Lifecycle | Caso típico | +|---|---|---| +| `transient` | Engine auto-limpia tras `hold`. Default. | contact.press, commit.save, emerge.open | +| `untilAction` | Persiste hasta acción del usuario. | signal.alert + threat (banner crítico) | +| `untilFix` | Persiste hasta corrección. | signal.warn + risk (validación de campo) | +| `stateBound` | Lifecycle = duración del estado. | sustain.progress, caps-lock indicator | + +**Tabla canónica `SEMA_HOLDS_BY_INTENT`** (`src/uix/sema/holds.ts`) — codifica el libro §6.2 separando los dos ejes: + +```ts +{ + contact: { _default: { hold: 'glimpse', persistence: 'transient' } }, + emerge: { _default: { hold: 'brief', persistence: 'transient' } }, + shift: { _default: { hold: 'noticed', persistence: 'transient' } }, + commit: { + _default: { hold: 'brief', persistence: 'transient' }, + fulfill: { hold: 'noticed', persistence: 'transient' } + }, + signal: { + _default: { hold: 'brief', persistence: 'transient' }, + risk: { hold: 'brief', persistence: 'untilFix' }, + threat: { hold: 'brief', persistence: 'untilAction' }, + loss: { hold: 'noticed', persistence: 'transient' } + }, + handle: { _default: { hold: 'brief', persistence: 'transient' } }, + sustain: { _default: { hold: 'noticed', persistence: 'stateBound' } }, + delegate: { _default: { hold: 'noticed', persistence: 'transient' } } +} +``` + +**Implementación**: + +- `EngineSemantic.emit()` ahora devuelve el `id` del signal. Para `persistence !== 'transient'` mantiene la proyección viva pasado el hold; el caller posee el cleanup vía `engine.clear(id)` o `engine.clearTarget(target)`. +- `SomaRuntime.trigger()` devuelve `TriggerResult { id?, persistence? }`. Expone `runtime.clearSignal(id)` y `runtime.clearTarget(target)` para que providers cierren el ciclo. +- Default conservador: cuando un morfo NO declara `persistence`, el runtime asume `'transient'`. La tabla canónica es REFERENCIA — los autores la declaran explícitamente en cada morfo. No se aplica de oficio para no introducir regresiones silenciosas. + +**Morfos actualizados** (consumidores reales): + +| Morfo / evento | persistence | Por qué | +|---|---|---| +| `announce.signal-alert` | `untilAction` | El banner crítico debe esperar gesto del usuario | +| `dialog.close-after-fail` | `transient` (explícito) | El dialog se desmonta; la persistencia del fallo vive en Toast/Announce externo | +| `drawer.close-after-fail` | `transient` (explícito) | Misma razón | +| `file-upload.signal-warn-reject` | `untilFix` | El archivo rechazado sigue presente hasta que el usuario lo quita | +| `form.signal-warn-invalid` | `untilFix` | El warning persiste hasta que la validación pase | +| `password-field.signal-notify-caps-state` | `stateBound` | El indicator vive mientras caps lock esté on | + +**Providers cabledados** (form / file-upload / password-field): cada uno llama `runtime.clearTarget(provider)` antes de re-emitir, o sobre la transición a estado "fix aplicado". Ver providers de los tres componentes. + +**Texto doctrinal para el libro**: + +> Cada señal perceptiva tiene dos duraciones independientes: un `hold` que es la duración mínima necesaria para que el usuario la registre como evento, y una `persistence` que dice cuánto tiempo permanece visible una vez registrada. Para la mayoría de eventos coinciden: la señal aparece, dura `hold` ms, y desaparece. Pero para `signal.warn + risk` y `signal.alert + threat`, la duración real depende del estado del sistema o de la acción del usuario, no de un cronómetro: una advertencia de validación debe seguir visible mientras el problema exista, y una alerta crítica debe seguir visible hasta que el usuario reconozca la situación. + +--- + +### D.10 Accesibilidad semántica por evento (`a11ySemantic`) + +- **Status**: **PROJECT_CANON** (codifica libro Cap 24 §9 — primera implementación operativa) +- **Origen**: el libro §9 define un contrato a11y por evento (live region, focus move, persistent trace, reduced-motion fallback). La implementación lo respetaba ad-hoc en cada provider. Ahora se declara en el morfo y el runtime lo honra centralizadamente. + +**Tipo** (`src/uix/morfo/types.ts`): + +```ts +export interface MorfoA11ySemantic { + requiresPersistentTrace?: boolean; // app debe mostrar trace externo + requiresLiveRegion?: boolean; // runtime pushes message a aria-live + requiresFocusMove?: boolean; // runtime mueve foco al target + keyboardEquivalent?: boolean; // contrato — handle.* drag etc. + reducedMotionFallback?: 'state' | 'text' | 'focus' | 'none'; +} +``` + +Y en `MorfoEvent`: +```ts +{ name: 'signal-warn-invalid', semantic: {...}, a11ySemantic: { requiresPersistentTrace: true, requiresFocusMove: true, reducedMotionFallback: 'text' } } +``` + +**Infraestructura nueva**: + +- `ActiveDom.prefersReducedMotion`: tracker reactivo del media query, vive en `src/arts/adom/reduced-motion.svelte.ts`. SSR-safe (devuelve `false` cuando no hay `matchMedia`). +- `ActiveUix.announce(message, priority?, timeout?)`: live region compartida lazy-creada en document body. NO depende de soma — usa `dom.writeNode` directo. Lower-level que `` soma (que ofrece snippet props y A/B alternation para repetidos). +- `SomaRuntime.trigger` honra `a11ySemantic` después del emit: + - `requiresLiveRegion` ∧ `opts.message` ∧ `sources.announce` → llama `announce(message, priority)` donde la prioridad se deriva de family/intent (`signal + threat/loss → assertive`, resto polite). + - `requiresFocusMove` ∧ target → `dom.focus(target)`. + - `reducedMotionFallback === 'state'` ∧ user prefers reduced → fuerza `channels: []` (silencia toda la señal perceptiva; sólo state attrs). + - `reducedMotionFallback === 'text'` ∧ user prefers reduced → llama announce aunque `requiresLiveRegion` no esté seteado. + - `reducedMotionFallback === 'focus'` ∧ user prefers reduced → focusea target aunque `requiresFocusMove` no esté seteado. + - `reducedMotionFallback === 'none'` → no hace nada (el motion era incidental). + +**Morfos anotados** (mismos 6 consumidores que persistence): + +| Morfo / evento | `a11ySemantic` | +|---|---| +| `announce.signal-alert` | `{ requiresPersistentTrace, requiresLiveRegion }` | +| `dialog.close-after-fail` | `{ requiresPersistentTrace, requiresLiveRegion, reducedMotionFallback: 'text' }` | +| `drawer.close-after-fail` | idem dialog | +| `form.signal-warn-invalid` | `{ requiresPersistentTrace, requiresFocusMove, reducedMotionFallback: 'text' }` | +| `file-upload.signal-warn-reject` | `{ requiresPersistentTrace, reducedMotionFallback: 'text' }` | +| `password-field.signal-notify-caps-state` | `{ requiresLiveRegion, reducedMotionFallback: 'text' }` | + +**Mensaje del live region**: el caller pasa `opts.message` en `runtime.trigger`. El runtime NO infiere texto del nombre del evento — los nombres son técnicos (`signal-warn-invalid`), no user-facing. Esto deja el control de fraseo en el provider (que sabe en qué idioma y con qué contexto). + +**`requiresPersistentTrace` no es ejecutable por el runtime**: declara un contrato que el provider/app debe cumplir mostrando un afford persistente (banner, inline error, undo toast). Es una nota declarativa que lint/docs/audit pueden chequear, pero no algo que el runtime pueda forzar — el "trace" vive en código de aplicación. + +--- + +### D.11 Eventos polimórficos (`allowedFamilies`) + +- **Status**: **PROJECT_CANON** (codifica libro Cap 5 §3 — implementación operativa diferida) +- **Origen**: libro §5.3 documenta que un morfo puede declarar CAPACIDAD para varios shapes semánticos en lugar de comprometerse a uno. Hasta ahora la implementación sólo soportaba shape fijo. No había consumidor concreto, pero el tipo + runtime quedan disponibles para futuras decisiones (Dialog `close` con/sin cambios sin guardar, etc.). + +**Shape**: ADITIVO sobre el shape concreto, no variante separada. El morfo declara su `family` + `intent` + `verb` como default; añade `allowedFamilies` para autorizar overrides: + +```ts +{ + name: 'close', + semantic: { + family: 'shift', // default + verb: 'exit-mode', + target: v.partRef('content'), + allowedFamilies: ['shift', 'commit', 'emerge'] // polymorphic capacity + } +} +``` + +Trigger: +```ts +runtime.trigger('close'); // emite { family: 'shift', verb: 'exit-mode' } +runtime.trigger('close', { semantic: { family: 'commit', verb: 'discard', intent: 'loss' } }); +runtime.trigger('close', { semantic: { family: 'signal', verb: 'alert' } }); // → throws (signal no en allowedFamilies) +``` + +**Reglas**: + +- El `family` declarado en el morfo es IMPLÍCITAMENTE allowed; no hace falta repetirlo en `allowedFamilies` (que enumera SOLO las alternativas). +- El override falla con `SomaRuntimePolymorphicError` si la family no está en allowedFamilies y no es el default. +- `TriggerOptions.semantic` opcional. Sin él, la trigger usa el shape canónico del morfo (comportamiento original). + +**Por qué no la forma del libro literal** (`{ allowedFamilies, defaultSemantic: { family, verb, intent } }`): + +- Backwards-compat: los 29 morfos existentes acceden a `event.semantic.family`/`intent`/`verb`/`sequence`. Una variante separada `{ defaultSemantic: { family: ... } }` rompía cientos de demos que hacen visualizaciones de la morfo en tablas. +- Equivalencia funcional: declarar `family: 'shift', verb: 'exit-mode'` Y `allowedFamilies: ['shift', 'commit', 'emerge']` cumple la misma intención que el shape del libro con menos anidamiento. +- Cero overhead para no-polymorphic: morfos sin `allowedFamilies` no pagan ningún coste de tipos ni runtime. + +**Texto doctrinal para el libro** (si se canoniza): + +> Un evento puede declarar no sólo qué es, sino qué podría ser. La forma canónica (`family`, `verb`, `intent`) describe el caso por defecto, pero un campo opcional `allowedFamilies` puede enumerar las alternativas que el provider está autorizado a emitir según contexto. Por ejemplo, el `close` de un diálogo es típicamente `shift.exit-mode`, pero si hay cambios sin guardar puede convertirse en `commit.discard + loss`, o si simplemente se descarta sin acción, en `emerge.close`. El provider decide la family concreta en tiempo de ejecución; el morfo establece el catálogo de las shapes válidas. + +**Aplicación real — Dialog (sprint 2026-05-27 / segunda mitad)**: + +Dialog cabledaba 5 eventos `close-*` distintos (close-save / close-cancel / close-dismiss / close-dismiss-outside / close-after-fail), cada uno con su propio prewrite de `data-last-action` y su semantic concreta. La refactorización los colapsó en UN evento polymorphic `close`: + +```ts +{ + name: 'close', + semantic: { + family: 'emerge', // default + verb: 'close', + target: v.partRef('content'), + sequence: 'pre', + persistence: 'transient', + allowedFamilies: ['emerge', 'commit', 'signal'] + }, + regime: 'lock', + commits: { part: v.partRef('content'), attr: 'data-state', value: 'closed' } + // NO prewrite — el provider escribe data-last-action imperativamente +} +``` + +El `DialogProvider.dismissWith(action, opts?)` traduce la acción al shape correcto: + +```ts +const DISMISS_CAUSES = { + save: { lastAction: 'saved', semantic: { family: 'commit', verb: 'save', intent: 'fulfill' } }, + cancel: { lastAction: 'cancelled', semantic: { family: 'emerge', verb: 'close' } }, + dismiss: { lastAction: 'dismissed', semantic: { family: 'emerge', verb: 'dismiss' } }, + 'dismiss-outside': { lastAction: 'dismissed-outside', semantic: { family: 'emerge', verb: 'dismiss' } }, + fail: { lastAction: 'failed', semantic: { family: 'signal', verb: 'alert', intent: 'threat' } } +}; +``` + +El provider hace `dom.apply({ target, attrs: { 'data-last-action': cause.lastAction } })` antes de `runtime.trigger('close', { semantic: cause.semantic, ... })`. + +**Cambios colaterales necesarios**: + +- **Validador del morfo** (`src/uix/morfo/schema.ts`): se relajó el invariante "cada `data-last-action.values[]` debe ser prewritten por algún event". El otro sentido sigue estricto (un prewrite con valor fuera de `values[]` falla). Razón: con polymorphism, el provider escribe imperativamente — la sincronía bidireccional dejaba de tener sentido. +- **Cascade sema de Dialog** (`src/uix/sema/components/dialog.ts`): los selectores que matcheaban `eventName: 'close-dismiss-outside'` o `eventNamePrefix: 'close-'` se reescribieron para usar `eventName: 'close'` + matchers adicionales (`state: { attr: 'data-last-action', value: 'dismissed-outside' }` y `eventFamily: 'emerge'`). El helper `semaSelector` soporta el matcher `state` nativamente. +- **Eidos CSS** (`dialog.css`): NO requirió cambios — los selectores ya leen `data-last-action` para tintar la animación de salida, no los nombres de evento. +- **Tests del morfo/runtime**: actualizados para esperar `close` en lugar de `close-cancel`/etc. El test de prewrite del compiler se movió a `drawerMorfo` (que mantiene su shape per-event). + +**Drawer y Popover**: refactorizados con el mismo patrón en sprint 2026-05-27 #3. Cada uno: +- 5 close-* events → 1 polymorphic `close` event en su morfo +- DISMISS_CAUSES + dismissWith adaptado en su provider +- Cascade sema reescrito (`eventName: 'close'` + `state` matchers en `data-last-action`) +- Internal callsites (escape, outside-click, close button) migrados a `dismissWith` +- Tests actualizados +- Eidos CSS sin tocar (ya leía `data-last-action`) + +**Picker family** (color-picker, date-picker, date-range-picker, time-picker, time-range-picker): refactorizados al patrón polymorphic close en sprint 2026-05-27 #4. + +Detalle del refactor: +- Cada picker tenía 4–5 eventos `close-*` (close-commit / close-cancel / close-dismiss / close-dismiss-outside, + variantes como `close-range-commit` en date-range-picker), todos con prewrite individual de `data-last-action`. +- Colapsados a 1 evento `close` polymorphic con `family: 'emerge', allowedFamilies: ['emerge', 'commit', 'signal']`, sin prewrite. +- **Hallazgo**: los providers de los pickers NO disparan los close events vía `runtime.trigger`. Sólo togglean `opts.open = false`. Los eventos estaban declarados pero **inertes** — su único consumidor era el schema validator y el compiler tests. El refactor es alineación doctrinal, no de comportamiento. +- Sema cascade: solo `color-picker` tiene un sema pack y NO referenciaba close-* (sólo handle-*). Nada que actualizar. + +**Test fixtures decoupling**: antes de tocar los pickers, los tests `compile.test.ts` + `runtime.svelte.test.ts` se migraron a un fixture sintético `prewriteFixtureMorfo` (en `src/uix/morfo/test-fixtures.ts`). Esto desacopla los tests de las decisiones del catálogo de componentes — los tests validan el contrato del compiler / runtime, no qué morfos lo usan. + +**Resultado del rollout polymorphic completo**: +- **3 overlays** (Dialog / Drawer / Popover): polymorphic close cabledado al runtime (providers disparan vía `dismissWith`). +- **5 pickers** (color / date / date-range / time / time-range): polymorphic close declarado en morfos, providers aún no cabledados (toggle de `opts.open` directo, sin trigger). Migración futura del provider para que use `runtime.trigger('close', { semantic })` queda abierta. +- Único morfo con shape pre-polymorphic restante: el fixture sintético `prewriteFixtureMorfo` — vivo sólo para tests. + +**API pública preservada** en todos: ningún cambio observable para el consumidor de los componentes. + +--- + ## E. Resumen ejecutivo **Familias** (libro Cap 8): 8 — sin cambios. (`contact, commit, signal, handle, emerge, shift, sustain, delegate`)