--- title: Registro de desviaciones entre implementación y canon editorial type: decision-log audience: human + agent authority: authoritative registry — where the implementation deviates from the book canon, with per-entry status status: current source: migrated verbatim from src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md (2026-07-02, docs-book F7.6; kept in Spanish — it is a logbook of literal author decisions and proposed doctrinal text for the Spanish book) --- # Registro de desviaciones entre implementación y canon editorial > **Este documento no forma parte del libro.** Es un registro interno de decisiones de implementación. Su objetivo es separar qué pertenece al canon editorial («Diseñando lo que ocurre»), qué pertenece al canon del proyecto UIX, y qué queda como extensión local o candidato pendiente. > > **Principio rector**: solo entra al libro lo que mejora la teoría general. Lo demás puede vivir como extensión del proyecto. El libro debe conservar un núcleo estable; la implementación puede tener vocabulario más rico. > > **No todo lo que aparece en implementación debe volver al libro.** --- ## Clasificación de status | Status | Significado | |---|---| | **BOOK_CANON** | Ya debería pasar al libro como ampliación canónica. Aceptado por el autor. | | **PROJECT_CANON** | Canon válido del proyecto, pero no necesariamente del libro. | | **CANDIDATE** | Caso plausible, pero falta doctrina o uso real para canonizar. | | **LOCAL_EXTENSION** | Necesario para este proyecto, pero no escalable al libro. | | **DEPRECATED** | Se mantiene temporalmente, pero debería eliminarse. | | **ALIAS** | Nombre aceptado como comodidad técnica, mapea a otro verbo doctrinal. | | **IMPLEMENTATION_CONTRACT** | Existe en morfos/runtime, pero no necesariamente como doctrina editorial. | Cada entrada lleva su status. Las entradas con `BOOK_CANON` incluyen el texto doctrinal recomendado para el libro, en bloques citados. --- ## A. Verbos añadidos al canon de implementación Estos verbos están en `src/uix/sema/verbs.ts:SEMA_VERBS`. Su status doctrinal para el libro varía. ### A.1 `handle.scroll` - **Status**: **BOOK_CANON** - **Libro Cap 25 §1** lista 7 verbos: pick, carry, drop, drag, resize, rotate, reorder. `scroll` no aparece literalmente. - **Decisión del autor**: aceptar como ampliación de `handle`. Cap 25 §1 ("manipulación directa de un objeto") cubre la lectura: el usuario desplaza directamente un viewport mediante gesto continuo. - **Distinción doctrinal**: - Scroll gestual del usuario → `handle.scroll` - Scroll programático del sistema (scrollToIndex / scrollToCell / scrollIntoView) → `shift.navigate` - **Texto doctrinal para el libro**: > `handle.scroll` cubre los casos en los que el usuario desplaza directamente un viewport, lista, panel o superficie desplazable. No equivale a navegación programática: cuando el sistema mueve al usuario a una posición concreta sin control directo, el evento pertenece mejor a `shift.navigate`. ### A.2 `commit.acknowledge` - **Status**: **CANDIDATE** - **Libro Cap 23 §5** lista 12 verbos de commit. `acknowledge` no aparece. - **Decisión del autor**: NO pasar al libro todavía. Mantener en canon de implementación pendiente de casos fuertes. - **Solapamiento problemático**: con `commit.confirm`, `commit.cancel`, `commit.submit`, `signal.dismiss`. Si el usuario solo cierra un aviso, quizá no hay commit; quizá solo hay retirada de señal (`signal.notify + neutral → emerge.close`). - **Cuándo sí cambiar a BOOK_CANON**: si hay obligación explícita de reconocimiento (registrado en el sistema): - "He leído y entiendo esta advertencia" - "Entiendo que esta acción no se puede deshacer" - "Acepto las condiciones" ### A.3 `commit.remove` vs `commit.delete` vs `commit.unselect` - **Status de `remove`**: **BOOK_CANON** - **Libro Cap 23 §5** lista `delete` (destrucción consumada). `remove` no aparece. - **Decisión del autor**: distinción de tres verbos sobre operaciones de retirada/eliminación. - **Texto doctrinal para el libro**: > `remove` no significa destruir. Significa retirar un elemento de una colección, relación o conjunto operativo. Si el elemento deja de existir o deja de estar disponible, corresponde `delete`. Si solo deja de estar seleccionado, corresponde `unselect`. ### A.4 `commit.confirm` - **Status**: **CANDIDATE** - **Libro Cap 23 §5** lista `submit`, `complete`. `confirm` no aparece. - **Decisión del autor**: NO canonizar todavía. - **Problema**: "confirmar" muchas veces no es el resultado final, sino un paso previo. El botón dice "Confirmar" pero el evento real puede ser `delete`, `submit`, `apply`, `authorize` o `acknowledge`. - **Cuándo sí cambiar a BOOK_CANON**: si hay casos donde el resultado aplicado sea literalmente "confirmación registrada", no acción posterior (confirmar asistencia, confirmar lectura, confirmar email). ### A.5 `commit.set` - **Status**: **BOOK_CANON** - **Libro**: Cap 25 ejemplo slider menciona `commit.set + affirm` pero no figura en lista canónica de Cap 23. - **Decisión del autor**: formalizar en Cap 23 §5. - **Casos canónicos**: slider, sort, criterio de filtro, valor de un picker, tamaño de página, zoom, preferencia local, criterio de ordenación, valor numérico. - **Texto doctrinal para el libro**: > `commit.set` comunica que un valor, criterio o parámetro ha quedado aplicado. Diferente de `save` (persistir globalmente), `submit` (enviar formulario), `complete` (culminar flujo), `select` (elegir un item). ### A.6 `commit.apply` - **Status**: **BOOK_CANON** - **Libro Cap 29** (delegate) usa `commit.apply` en ejemplos. Cap 23 §5 no lo lista. - **Decisión del autor**: formalizar en Cap 23 §5. - **Texto doctrinal para el libro**: > `commit.apply` comunica que un conjunto de cambios o una operación se ha aplicado. Diferente de `save` (guardar) — apply puede aplicar sin persistir; save persiste. ### A.7 `commit.move` - **Status**: **BOOK_CANON** (con distinción de `reorder`) - **Decisión del autor**: aceptar si se distingue: - `commit.move` → un elemento cambia de lugar - `commit.reorder` → una colección cambia de orden - **Caso típico**: `handle.drop → commit.move + affirm` o `handle.drop → commit.reorder + affirm` según si lo que cambió fue la posición de UN item o el orden de la colección. ### A.8 `commit.upload` - **Status**: **CANDIDATE** - **Decisión del autor**: dudoso como commit. Subir un archivo es proceso (`sustain.uploading`) que termina en `commit.complete` (éxito) o `commit.fail` (error). Como verbo de resultado quizá es redundante con `commit.complete`. - **Mantener** como candidato hasta que haya caso donde "upload" sea el resultado final no reducible a complete/attach/add/apply. ### A.9 `commit.partial` - **Status**: **CANDIDATE / revisar** - **Decisión del autor**: probablemente NO es verbo. "Partial" suena a estado o resultado incompleto, no a acción. - **Alternativas mejores**: - `commit.complete` + variant `partial` - `commit.fail + risk` - `sustain.partial` (estado, no acción) - **A revisar antes de canonizar**. ### A.10 `commit.block` - **Status**: **CANDIDATE / revisar** - **Decisión del autor**: probablemente NO es verbo de commit. "Blocked" suele ser estado, no resultado aplicado por el usuario. - **Alternativas mejores**: - `signal.alert + threat` - `commit.fail + risk` - `sustain.blocked + risk` (estado) - **A revisar antes de canonizar**. ### A.11 `commit.unselect` - **Status**: **BOOK_CANON** - **Libro Cap 23 §5** lista `select` pero no `unselect`. Pasa el par natural al canon. - **Decisión del autor (transcrita literal)**: > Seleccionar y deseleccionar son resultados aplicados sobre el estado de selección de un elemento. Eso es `commit`, porque el resultado queda aplicado. > - No es `remove`: no estás eliminando el item ni sacándolo de una colección funcional; solo estás cambiando su estado de selección. > - No es necesariamente `toggle`: `toggle` describe mejor el mecanismo binario o el control, pero no expresa tan bien el resultado semántico concreto. > > Regla final: > - `select` / `unselect` → resultados sobre estado de selección > - `toggle` → inversión binaria genérica > - `remove` → retirada, eliminación o salida de colección - **Aplicado en**: calendar, combobox, grid-list, listbox, select, tag-group. --- ## B. Adopciones interpretativas (no literales en el libro) ### B.1 Cancelación de drag = `commit.cancel` - **Status**: **BOOK_CANON** (composición canónica) - **Libro Cap 25 §4** cubre fases pick → carry → drop. No aborda explícitamente Escape durante carry. - **Decisión del autor**: la cancelación de un drag es composición `handle + commit.cancel`. No hay drop, no hay resultado aplicado, la manipulación se aborta. - **Para el libro**: añadir al capítulo de handle como composición canónica. ### B.2 Scroll programático = `shift.navigate` - **Status**: **BOOK_CANON** (con matiz) - **Libro Cap 27 §5** lista `shift.navigate` con ejemplos a escala "Lista → detalle. Página A → página B". - **Decisión del autor**: aclarar que `shift.navigate` puede operar a varias escalas: - entre páginas - entre vistas - entre pasos - dentro de una lista (programáticamente) - hasta una celda - hasta un índice - **Distinción**: usuario desplaza manualmente → `handle.scroll`; sistema mueve viewport → `shift.navigate`. **No** crear `shift.scrollto` todavía. ### B.3 Cambio de tamaño del modelo de datos - **Status**: **LOCAL_EXTENSION / CANDIDATE** - **Decisión del autor**: NO regla general del libro todavía. Caso por caso. - **Regla doctrinal aplicable** (ya en el libro Cap 4 §1, recordatorio): > Un cambio interno de datos solo se convierte en evento cuando se vuelve perceptible o cambia lo que el usuario puede hacer, debe atender o necesita interpretar. - **Si el cambio merece evento**, el verbo correcto depende del caso: - `signal.notify + neutral` — si avisa de nuevos items - `emerge.reveal` — si aparecen elementos - `commit.set + neutral` — si el sistema aplica un nuevo valor operativo visible (tamaño de dataset, criterio) ### B.4 Sort de tabla = `commit.set` - **Status**: **BOOK_CANON** (como ejemplo en commit.set) - **Decisión del autor**: ordenar por columna no es reordenar manualmente items; es fijar un criterio. - **Distinción**: - sort by criterion → `commit.set` - manual reorder → `handle.reorder` → `commit.reorder` - **Para el libro**: añadir como ejemplo de `commit.set` cuando se introduzca ese verbo. ### B.5 Eventos no perceptibles no son eventos - **Status**: **BOOK_CANON** (regla doctrinal) - **Libro Cap 4 §1** ya tiene la regla pero conviene reforzar. - **Texto doctrinal para el libro** (añadir como nota explícita en Cap 4): > No todo cambio de estado necesita evento. Un estado puede volver automáticamente a su forma base sin producir un evento semántico si el usuario no necesita interpretarlo como cambio relevante. - **Ejemplo de aplicación**: timer interno que revierte `copied=false` tras N ms en clipboard. Es estado (`data-copied`), no evento sema. ### B.6 Toggle con dos eventos direccionales - **Status**: **BOOK_CANON** (posibilidad direccional, NO canonizar intents) - **Libro Cap 22 §10** ejemplo: "Toggle: contact.press → commit.toggle + affirm". UN evento. - **Decisión del autor**: aceptar la POSIBILIDAD de dos eventos direccionales, pero NO fijar intents por defecto. - **Texto doctrinal para el libro**: > Un toggle puede modelarse como un solo evento de inversión o como dos eventos direccionales si la diferencia entre activar y desactivar importa para el usuario. El intent de cada dirección depende del contexto. - **Ejemplos del autor**: - Activar notificaciones: check → affirm, uncheck → neutral - Desactivar tracking: uncheck → affirm - Desmarcar consentimiento obligatorio: uncheck → risk - **Nota para la implementación**: el checkbox actual del proyecto fija `check=affirm` / `uncheck=neutral` como defaults razonables; los consumidores pueden override per-instance. ### B.7 Intent en `contact` como anticipación visual únicamente - **Status**: **BOOK_CANON** (regla de buena práctica) - **Libro Cap 22 §11** ya tiene la doctrina ("el intent fuerte no debería vivir en el contacto"). - **Decisión del autor**: el evento `contact.*` no debe cargar intent fuerte; el `intent` prop del componente puede afectar visualmente (data-color, forma) pero el evento sema se mantiene neutro. - **Aplicado en**: Button (`contact.activate` sin intent en el evento; `intent` prop drives data-color). - **Conecta con D.3**: la family policy actual `'allowed'` no comunica "discouraged" — requiere refinamiento. --- ## C. Cluster decisions ### C.1 `clear` field — RESUELTO - **Status**: **BOOK_CANON** (mapeo a verbo existente) - **Decisión del autor**: `commit.reset`. 10 morfos actualizados. - **Para el libro**: añadir como ejemplo de `commit.reset` cuando se introduzca `commit.set`/`commit.reset` al libro. ### C.2 `unselect` — RESUELTO - Ver A.11 — promovido a verbo BOOK_CANON. ### C.3.a Renames de forma (mecánicos) - **Status**: PROJECT_CANON (no requieren cambio en el libro) - 11 events renombrados a `{family}-{verb}-{variant}` para alinearse con la convención de naming: - `carousel.shift-slide` → `shift-navigate-slide` - `feed.shift-focus-item` → `shift-navigate-focus-item` - `feed.commit-load-more` → `commit-submit-load-more` - `file-upload.commit-add` → `commit-set-add` - `file-upload.signal-reject` → `signal-warn-reject` - `form.signal-invalid` → `signal-warn-invalid` - `number-field.handle-scrub` → `handle-drag-scrub` - `range-calendar.commit-start` → `commit-select-start` - `range-calendar.commit-range` → `commit-select-range` - `tags-input.commit-add` → `commit-set-add` - `tags-input.signal-reject` → `signal-warn-reject` ### C.3.b Correcciones doctrinales - **Status**: BOOK_CANON (precedent Button) - `command.commit-invoke` (declared `submit + fulfill`) → `commit-submit-invoke + submit + affirm`. Cambio de intent (`fulfill` → `affirm`) por Cap 22 §8 (celebrate-before-time es antipatrón). Mismo precedent que Button. --- ## D. Decisiones arquitecturales ### D.1 Cada actor declara sus eventos - **Status**: **BOOK_CANON** - **Texto doctrinal para el libro** (capítulo de composición o apéndice técnico): > Un componente no debe declarar eventos que no produce. En una composición, cada actor declara su parte del evento. - **Ejemplo**: un Button no declara `commit.delete + loss` si solo registra el click; declara `contact.activate`. El flujo, diálogo o acción que realmente elimina declara `commit.delete + loss`. Esto evita sobrecargar el botón y explica por qué la gramática necesita composición. ### D.2 Eventos del contrato que no se disparan - **Status**: **IMPLEMENTATION_CONTRACT** (no doctrina del libro) - **Decisión del autor**: doctrinalmente peligroso si se presenta mal (parece que el sistema promete eventos inexistentes). NO pasarlo al cuerpo del libro. - **Tratamiento**: apéndice técnico con metadata explícita: ```ts emission: 'runtime' | 'host' | 'external' | 'declared-only' // o: implemented: true | false ``` - **Acción del proyecto**: añadir flag `emission` al MorfoEvent type en una iteración futura para hacer explícito qué eventos se emiten en runtime vs cuáles son declarados sin emisor (contract surface para testing, analytics, accesibilidad externa). ### D.4 Campo `expression` en el morfo: cómo se rellena la firma sema - **Status**: **IMPLEMENTATION_CONTRACT** (no doctrina del libro) - **Decisión del autor (Lectura C)**: un morfo con eventos doctrinales declara su modo de expresión perceptual: ```ts type SemaExpressionMode = 'pack' | 'family-default' | 'delegated' | 'none'; ``` - **`pack`**: el morfo tiene un archivo `src/uix/sema/components/{kebab}.ts` con cascade rules específicas (sonido, háptica, prioridad). - **`family-default`**: el morfo descansa en `SEMA_MAP.families[family].base` sin tuning per-componente. Apropiado cuando el componente no necesita firma diferenciada. - **`delegated`**: el componente compone otros morfos que sí emiten (p. ej. picker emite via calendar/time-field/color-area que tienen sus propios eventos). - **`none`**: el morfo declara contrato pero no participa en sema runtime (reservado para utilidades no perceptuales). - **Lint**: `npm run morfo:vocabulary` valida que todo morfo con `events.length > 0` cumpla: 1. `scope` incluye `'sema'` (FAIL). 2. Existe un pack en `src/uix/sema/components/{kebab}.ts` **o** `expression !== undefined` (WARN si falla). - **Cuándo crear pack vs `family-default`** (criterios para D.4): - **Crear pack** si: eventos de alta frecuencia + riesgo de fatiga (toggles, form controls), o el componente necesita una firma sobria distinta del family base, o introduce cascades con prioridad (a11y override). - **`family-default`** si: el family base ya es la firma correcta y el componente no compite con otros del mismo family por intensidad perceptual. ### D.5 Packs para componentes de alta frecuencia (toggles) - **Status**: **PROJECT_CANON** (decisión arquitectural sin necesidad de pasar al libro) - **Caso**: `switch`, `toggle`, `toggle-group` emiten `commit-toggle` en bucle (settings panels, toolbars, segmented controls). Sin tuning, heredan el `family.commit.base.gain = 0.3` que es demasiado para una sesión sostenida. - **Solución (2026-05-26, DEROGADA — ver la nota del 2026-08-06 al final)**: tuning `form.toggle.silent = { gain: { op: 'add', value: -0.3 } }` en `src/uix/sema/sounds.ts`. Cancelaba exactamente el `gain` del family base, dejando el default en 0 (silencio). Los intent.deltas que añaden gain (`threat: +0.1`, `fulfill: +0.05`) seguían surgiendo, así un toggle destructivo sí emitía señal audible. - **Haptic**: tap leve (`intensity: 0.3, duration: 12, delay: 0`) sustituye el tap medio del family. Replaza el `kind` para que la háptica no oscile con el intent — la carga evaluativa de un toggle se lee en `data-color` + (selectivamente) sonido, no en háptica fluctuante. - **Doctrina**: el libro habla de "componentes de baja intensidad" (cap. 22) — esta es la materialización runtime. NO ir al libro: es decisión de tuning, no de gramática. - **Packs concretos**: `src/uix/sema/components/{switch,toggle,toggle-group}.ts` — los tres comparten la misma firma porque comparten rol UX (un press → un flip). - **REVERTIDO 2026-08-05 — los toggles recuperan voz (directiva de autor)**: el silencio de arriba se retira para `switch`, `toggle` y `toggle-group`, que pasan a **`commit.medium` (gain 0.1)** — audible, y un tercio de una pulsación de botón (`contact` base 0.25) para que un flip nunca pese más que una activación. Los deltas de intent siguen montando encima (`threat` +0.1, `fulfill` +0.05). Razón: un silencio por defecto se percibe como componente roto, no como sobriedad; la fatiga se combate bajando el nivel, no anulándolo. El háptico ligero de abajo NO cambia. Se conserva el silencio, con su justificación intacta, sólo donde el silencio ES la firma: `tooltip` (revela al pasar el ratón — sonaría en cada cruce) y `media-player` (su propia salida ES audio, D-AP2.7). Además, **emitir silencio dejó de costar**: `SoundChannel.handle` corta antes de sintetizar cuando la ganancia resuelta es ≤ 0 (`chans/sound.ts`), así que un `{family}.silent` ya no levanta dos osciladores, un filtro y una envolvente para no sonar. Fijado por test, verificado en rojo. - **Renombrado 2026-08-05 — el prefijo `form.` muere**: este tuning nació como `form.toggle.silent` y pasó a llamarse `commit.silent` — una clave que vivió UN DÍA: la nota siguiente la retira del catálogo entera. El prefijo `form.` entró el 2026-05-19 (`09e261878`) nombrando con verdad a siete controles de formulario, pero ESTE MISMO commit (`2d562f378`) lo extendió a menús y árboles vía `form.commit.subtle` — el D.6 de abajo llegó a escribir el nombre correcto (`commit.select`) en la misma línea que usaba la clave incorrecta. Llegó a 50 packs, de los que exactamente uno era el componente Form. La ley queda escrita en [`sema.md` § Tuning naming shapes](../architecture/sema.md#tuning-naming-shapes) — **el primer segmento es la familia cuya base modifica el tuning, nunca un componente** — y la vigila `src/uix/sema/sounds-grammar.test.ts`. Renombres del mismo barrido: `form.commit.soft` → `commit.soft`, `form.commit.subtle` → `commit.subtle`, `tooltip.silent` → `emerge.silent` (ambas retiradas al día siguiente), `tabs.select.soft` → `commit.select.soft`. Sin cambio de comportamiento: mismos deltas, mismas ganancias. **Claves vivas hoy: sólo `commit.soft`, `commit.subtle`, `commit.select.soft`, `commit.medium` y las `emerge.*`** — el enumerado generado está en [`canon/vocabularies.md`](../canon/vocabularies.md#sound-tunings). - **EL SILENCIO DEJA DE SER ARITMÉTICA — 2026-08-06 (directiva de autor)**: «el silencio no admite verbos ni intents, es universal, por lo tanto es un valor canónico que cuando se le envía al canal semántico éste simplemente lo ignora y no envía nada al soundengine». Las tres claves silenciadoras (`commit.silent`, `emerge.silent`, `contact.silent`) **desaparecen del catálogo**; en su lugar hay **`SILENT`**, un único valor exportado por `$uix/sema` que se declara en el slice del canal y que el resolver honra **retirando ese canal** de la firma — la forma que el propio motor ya prescribía («muting DROPS the channel rather than scaling to zero, because a `0` still buzzes»). Un delta de intent ya no puede resucitarlo, porque no queda ningún número que mover. MOTIVO MEDIDO, y desmiente la promesa de la «Solución» de arriba: el cero no era silencio. Los deltas de intent suman **rugosidad** (`risk` +0.2, `threat` +0.4), lo que cruza el umbral del AM del motor, y como el modulador se conecta al `AudioParam` de la envolvente —y conectar a un `AudioParam` SUMA, no multiplica— el trémolo pasaba a ser la señal entera. Renderizado offline con el grafo real: `risk` **−13,9 dBFS** y `threat` **−6,7 dBFS** desde una firma declarada a `gain 0`, frente a **−9,3 dBFS** de un botón normal. El «silencio» era lo más fuerte de la interfaz, y lo que sonaba era un zumbido sin ataque ni caída cortado en seco. Fijado por tests en `resolver.test.ts` (universal para toda familia e intent, y liftable sólo por REEMPLAZO) y por `sounds-grammar.test.ts`, que ahora prohíbe tanto una clave `*.silent` como cualquier afinado que deje su familia en `gain <= 0`. ### D.6 Packs para superficies de menú y árboles - **Status**: **PROJECT_CANON** - **Caso**: `menubar`, `navigation-menu`, `context-menu`, `dropdown-menu`, `tree-view`, `tree-grid` — superficies navegacionales de alta frecuencia. El usuario abre/cierra menús y expande/contrae nodos decenas de veces por sesión. Sin tuning, heredan `family.emerge.base.gain = 0.2` y `family.commit.base.gain = 0.3` — demasiado prominente. - **Solución**: packs por componente que combinan tuning emerge soft + commit subtle: - **emerge.open (menús, expand)**: `emerge.soft` (gain 0.08) — el contenido se revela sin competir con la superficie que lo invoca. - **emerge.close (menús, collapse)**: `emerge.exit.soft` (gain 0.05, descending) — disciplina de dirección compartida con dialog/drawer/popover. - **commit.select (item de menú, nodo de árbol)**: `commit.subtle` (gain 0.03) + `tap` leve. Más sobrio que radio-group porque la cascada típica es "menú cierra + item commit + nueva superficie aparece" — tres señales en milisegundos, hay que repartir intensidad. - **Coherencia**: dropdown-menu y context-menu comparten firma idéntica (el usuario no debe aprender dos "sonidos de menú"). Tree-view y tree-grid idem. Menubar y navigation-menu se quedan en solo `commit.subtle` porque no tienen evento de open/close declarado en el morfo (la apertura es de un dropdown adyacente). - **Caveat (`tree-view` / `tree-grid`) — RESUELTO (verificado 2026-07-12, SEM-4)**: la emisión aterriza vía `targetOverride` en el elemento real (`branchEl` / `rowEl`) y los packs construyen sus selectores con `onBranch` / `onRow` (`semaSelector(morfo, 'branch'|'row')`) — emisión y cascada casan de punta a punta. - **Caveat (emisión soma) — RESUELTO (verificado 2026-07-12, SEM-4)**: los 6 providers emiten vía `runtime.trigger` (dropdown/context: `open`/`close`/`commit-select`; menubar/nav-menu: `commit-select`; trees: `emerge-expand`/`emerge-collapse` + `commit-select`). Verificado en vivo: stamps `open · emerge · active` y `commit-select · commit · affirm` en el navegador. Este párrafo quedó STALE varias semanas y una auditoría clean-room (2026-07-10) lo citó como evidencia de dormancia — lección: el registro se actualiza EN EL MISMO PASE que el cableado. ### D.7 Sonido canónico y samples — **RETIRADA el 2026-08-06, por disolución** - **Status**: **RETIRADA**. No derogada por cambio de opinión: **disuelta por un cambio de arquitectura que hace imposible el defecto que prohibía.** La doctrina del autor que la originó sigue siendo cierta, pero ahora la sostiene el orden de resolución en vez de la disciplina de quien escribe un pack. - **Qué prohibía y por qué.** «Los samples no deben sustituir la firma semántica base cuando esa sustitución impide la modulación por intent.» El mecanismo era real: `sound()` devolvía una firma COMPLETA y la cascada la aplicaba en modo `replace` DESPUÉS de los deltas de intent, así que canonizar un sample sobre un evento `commit` borraba el perfil evaluativo — un `fulfill` perdía su +300 Hz ascendente. La regla se enunció como lista de familias prohibidas, se le encontró un infractor (`proof-of-human`), se enmendó para sacar a `handle` / `sustain` / `delegate` (familias sin `base.sound`: donde no hay base no hay nada que aplastar) y se le añadió un guard. - **Por qué ya no hace falta.** Desde el 2026-08-06 el sonido no se resuelve por capas: se ELIGE, en dos búsquedas. ``` nombre = per-emit ?? cascada ?? pack ?? morfo ?? familia[verbo] ?? familia.default sonido = pack[`${nombre}.${intent}`] ?? pack[nombre] ?? nada ``` El intent **selecciona un sonido entero** (`tick.threat` no es un `tick` deformado) y, si el pack no trae variante para ese intent, se desprecia el intent y suena la base. No queda nada que aplastar porque no queda aritmética: un nombre sustituye a un nombre. El tipo de una regla acepta un `SoundName` o `SILENT`, y ni el producto ni un tema ni la app pueden escribir un parámetro. - **Lo que se conserva de la doctrina, y dónde vive ahora:** - «El silencio es una firma válida» → `SILENT`, valor canónico honrado por el resolver retirando el canal (D.5 + `sounds-grammar.test.ts`). - «Los packs sólo se crean cuando corrigen una diferencia perceptiva real» → sigue vigente como criterio de autor, y hoy es barato cumplirlo: un pack que no tiene nada que decir no tiene nada que escribir. - «Los samples son recursos de producto, no canon por defecto» → el catálogo los admite con nombre (`ping`, `error`) y una app puede autorar los suyos por `overrides.cascade`, que sigue abierto a propósito. - **⚠️ EL LÍMITE FÍSICO, medido — lo único de esta entrada que hay que seguir sabiendo.** `playSample` lee **exactamente dos campos** de la firma: `sampleUrl` y `gain`. No hay `playbackRate` ni `detune`. Luego **sobre una grabación el intent sólo puede mover el volumen**. Un nombre con fichero que deba llevar carga evaluativa **necesita un fichero por intent** (`tick.mp3` Y `tick.threat.mp3`), no uno solo modificado. Con síntesis el problema no existe porque cada variante se diseña entera. Es física del audio, no política: no se diseña alrededor de ella. Este límite dejó de ser una excepción y pasó a ser **la forma general del sistema**: un intent siempre selecciona una entrada distinta, venga de un oscilador o de un fichero. Lo que antes era el caso raro de `signal` es hoy cómo funciona todo. - **Lo que sigue descartado permanentemente**: `sampleOverlay` (sample como capa adicional sobre el synth). Sobreingeniería — añade mixing en WebAudio, capa de resolver y mandos extra al diseñador, para casos raros. ### S-07 — **DISUELTA junto con D.7** El hallazgo S-07 de [`AUDIT-sema-2026-08-05`](../process/AUDIT-sema-2026-08-05.md) decía que las 14 claves de escalera de `SOUND_TUNINGS` fijaban `gain` como número DESNUDO, y un número desnudo se aplica en `replace` igual que un sample: cambiar un sample por `soundTuning('commit.soft')` dejaba el intent igual de aplastado. Era el mismo defecto que D.7 una capa más abajo, y quedó ABIERTO por decisión del autor («se trata más detenidamente»). Ya no existe: **`SOUND_TUNINGS` fue retirado el 2026-08-06**, y con él la aritmética entera. No hay `gain` que fijar ni delta que borrar: un evento y su intent eligen una entrada del pack. Los ~150 usos que el hallazgo contaba son hoy nombres, y 135 de las reglas que los llevaban se borraron porque no decían más que el defecto de su familia. ### D.3 Family policy: separar requirement de guidance - **Status**: **BOOK_CANON** (concepto) + cambio inmediato en el proyecto - **Decisión del autor**: la policy actual `'allowed' | 'expected' | 'optional'` mezcla dos cosas: requisito de tipo y guía doctrinal. Separar: ```ts intentRequirement: 'required' | 'optional' | 'forbidden' intentGuidance: 'expected' | 'contextual' | 'discouraged' ``` - **Policy propuesta**: | Family | intentRequirement | intentGuidance | |---|---|---| | `contact` | optional | discouraged | | `commit` | required | expected | | `signal` | required | expected | | `handle` | optional | contextual | | `emerge` | optional | contextual | | `shift` | optional | contextual | | `sustain` | optional | contextual | | `delegate` | optional | contextual | - **Implementación**: aplicar en `src/uix/sema/types.ts:SEMA_FAMILY_POLICY`. TypeScript deriva `IntentExpectedFamily` desde `intentRequirement === 'required'`. `intentGuidance` queda como campo doctrinal de documentación + posible lint en el futuro. ### D.8 Channels: qué es canal y qué no - **Status**: **PROJECT_CANON** (regla arquitectural de límites) — con dos puntos REESCRITOS el 2026-08-06 tras la lectura completa del corpus de sema y del motor de sonido, firmados por el autor. - **Origen**: al revisar la sección D.7 emergió la tentación de promocionar ARIA a "canal a11y". Análisis honesto: era confusión categorial. Esta sección fija los límites para que nadie reincida. - **Announce SÍ es canal desde el 2026-07-04 — y esta entrada lo había previsto.** Su propio cierre decía: «si en el futuro `Announce` necesita ser pluggable … entonces vale convertirlo en canal formal. Hoy no». Ese futuro llegó: el `AnnounceChannel` es built-in, opt-in y exportado (`src/uix/sema/chans/announce.ts`), y [`sema.md` §Announce channel](../architecture/sema.md) lo declara supersedente. No fue una deriva: fue la puerta que esta entrada dejó abierta, cruzada sin volver a escribirlo aquí. Canales runtime reales: 4 ([`channels.md`](../theming/channels.md)) — `visual`/`sound`/`haptic` EXPRESAN, `announce` SUSTITUYE. **Canales runtime declarables en sema (built-in del framework):** ``` sound — SoundSignature (synth/sample, modulable por intent.deltas) haptic — HapticSignature (vibration, modulable por intent.deltas) ``` **Lo que NO es canal (y no debe convertirse en canal):** | Cosa | Dónde vive | Razón | |---|---|---| | ARIA estructural (`aria-label`, `aria-expanded`, `role`, ...) | `Morfo.parts[].aria` + `.role` | Declarativo. Resuelto desde props/states. Promocionarlo a canal sería convertir lo declarativo en post-hoc DOM manipulation. | | ARIA dinámico (live regions `aria-live`) | Soma escribe directo en el live region DOM | Sólo un morfo lo necesita (`Announce`). Hacer canal añadiría engine surface sin caso plural. | | Visual — motion (animaciones, transiciones) | Eidos CSS `@keyframes` reaccionando a `data-event-*` stampeado por `VisualChannel` | El stamp es la única responsabilidad de sema; el output visual es CSS. | | Visual — color/intent (data-color, data-event-intent overlays) | Eidos recipes + design tokens | Idem. | | Visual — presence (z-index, opacity, layout) | Eidos CSS | Idem. | **Channel activation por familia** (en `SEMA_MAP`): ``` contact sound + haptic commit sound + haptic signal sound + haptic emerge sound (sin haptic — apariciones no son táctiles) shift sound (sin haptic — cambio de marco) handle sound + haptic (el trinquete: `step` por emisión, 2026-08-06) sustain ninguno (puramente visual) delegate ninguno (puramente estructural / visual) ``` **`activeChannels` es el DEFAULT perceptual de la familia, no una prohibición** (ley reescrita y FIRMADA el 2026-08-06; la redacción anterior decía «los packs DEBEN respetar el `activeChannels` del family … añadir `sound` a `handle` también [es incoherente]», y el framework hacía justo eso, prescrito): - **`channels` es la palanca declarativa por evento o por regla, y ajusta en las DOS direcciones.** Restringir: el morfo del dialog usa `channels: ['haptic']` en `close-after-fail` para no competir con la live region; el transporte del player hace lo mismo (D-AP2.7 v2); `VirtualList` usa `channels: []`. Ampliar: [`sema.md` §componentes continuos](../architecture/sema.md) MANDA que el pack del slider ponga `channels: ['sound', 'haptic']` sobre la familia `handle`. - **Ampliar exige justificación perceptual escrita en el sitio.** El ejemplo canónico era el slider, cuyo arrastre se resolvía con un payload CALCULADO por emisión (pitch/gain/contour desde posición y velocidad), sostenido por tres resolvers dedicados. **Eso murió el 2026-08-06**: un gesto continuo suena por REPETICIÓN — `step` (18 ms) por emisión, a la cadencia del propio gesto, como una rueda dentada — y los tres resolvers se borraron con él. `handle` declara hoy `sounds: { default: 'step' }` y activa los dos canales, y queda EXENTA de la memoria de frecuencia, que si no estrangularía el trinquete al primer arrastre. Contrapartida firmada: el ritmo lleva la velocidad, pero la posición ya no mapea a altura. (Nota 2026-08-12: «los tres resolvers se borraron con él» se adelantó a los hechos — `git log -S` demostró que el borrado nunca se ejecutó; una retirada intermedia del mismo 2026-08-12 se revirtió por proceso, y la definitiva la hizo la sesión del eje ese día, firmada. Ledger [`AUDIT-docs-code-ledger.md` §D10](../process/AUDIT-docs-code-ledger.md). Hoy la frase es verdad.) - **LO PROHIBIDO — y es lo que produce el defecto real: declarar una firma de canal que la activación resultante no incluye.** Eso deja reglas INERTES, mudas y muertas, que se leen como si hicieran algo. Es mecánicamente comprobable y merece guard. Infractoras detectadas por la auditoría 2026-08-05: el pack de `dialog` declara háptico sobre eventos de familia `emerge` (dos reglas), más otras tres del mismo tipo en el catálogo. Cada una o justifica su ampliación con `channels`, o se borra. El espíritu de la redacción vieja se conserva —no pongas háptico donde no hay tacto— pero era una prohibición sobre el mecanismo equivocado: prohibía ajustar en vez de prohibir la incoherencia. **Regla operativa**: algo es canal sólo si cumple las tres: ``` (a) recibe SemanticSignal y emite output perceptual, (b) acepta modulación por intent.deltas (perceptual loading), (c) tiene signature paramétrica análoga a SoundSignature / HapticSignature. Si falla (b), no es canal — es declarativo o ad-hoc. ``` ARIA dinámico falla (b): `signal-announce + threat` produce el MISMO texto + el MISMO ARIA. La carga evaluativa del intent vive en sound + visual, no en el texto del anuncio. Por eso no es canal. **Channels extensibles (no built-in, opt-in por la app):** El registry `SemaChannelSignatures` es OPEN vía declaration merging. Una app puede: ```ts declare module '$uix/sema' { interface SemaChannelSignatures { voice: { phrase: string; rate?: number; ... }; // TTS a11y: { ariaPayload: string; politeness: 'polite' | 'assertive' }; // si la app lo quiere formal } } ``` E implementar un `Channel` con `prepare(signal, target)` + `play(effective)`. El framework no envía ninguno de éstos — son extensión de producto. **Pendiente sin urgencia**: si en el futuro `Announce` necesita ser pluggable (apps que quieran enviar a logger, telemetría, voice UI), entonces vale convertirlo en canal formal. Hoy no. --- ### 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`** — vive en [`src/uix/sema/holds.ts`](../../src/uix/sema/holds.ts) y se enumera generada en [`docs/canon/vocabularies.md`](../canon/vocabularies.md). Codifica el libro §6.2 separando los dos ejes (hold expresivo · persistencia de la señal). > **Aquí había una transcripción literal de esa tabla, y se pudrió.** D.12 > corrigió dos valores el 2026-07-06 —`commit.fulfill` de `noticed` a `settled` > y `signal.loss` de `noticed` a `brief`, ambos contra el texto del libro— y la > copia de esta entrada siguió afirmando los viejos durante un mes. La ley del > corpus es enlazar, no copiar (`docs/authoring.md` §1); esta entrada la > incumplía. Retirada el 2026-08-06. **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 morfos existentes —29 en aquel momento— 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) — **CERRADO 2026-07-12 (SEM-4, `b55ca6ee`)**: tras la reconciliación de-dialoged (2026-06-27) sus morfos ya no declaran `close` propio (`expression: 'delegated'` — la firma pertenece al Popover compuesto); el cierre programático (commit/cancel/select-close) ahora enruta por causa vía `PickerShellHandle.setPopoverDismiss` → `popover.dismissWith('save'|'cancel')` (delegado inyectado por el eidos PickerShell root; fallback raw para composiciones headless). Verificado en vivo: Done → `close · commit · fulfill` · Cancel → `close · emerge`. - Ú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. ### D.12 Holds/duraciones: materialización numérica de las regiones cualitativas del libro (+ "el hold es suelo, no tijera") - **Status**: **PROJECT_CANON** (materialización propia; el libro rehúsa dar números a propósito) - **Origen**: auditoría clean-room 2026-07-06. La cabecera de `holds.ts` citaba un "cap. 24 §6.2 (Holds por familia e intent)" **que no existe** (el cap. 24 §6 real es "Canales, texto e intent") y reclamaba tablas "verbatim" con números ("commit.fulfill: 280") **que el libro jamás da**. La doctrina temporal real del libro es CUALITATIVA: cap. 4 §13 (duración expresiva · estado · resultado · huella — "un evento no termina siempre cuando acaba su animación") · cap. 12 §4-§9 (duración/persistencia/huella/caducidad; "si un evento importante solo existe durante un instante, muchos usuarios no lo recibirán") · cap. 32 TABLAS 32.1/32.2 ("regiones de diseño, no números sagrados": affirm "breve" · fulfill "breve-media, más resolutivo" · risk "hasta corrección" · threat "entrada rápida + persistencia hasta acción" · loss "breve + huella"). **Decisiones (usuario, 2026-07-06):** 1. **Los ms son autoría del framework** (materialización de las regiones sobre `SEMA_DURATIONS`), nunca "transcripción del libro". Procedencia corregida en `holds.ts`/`durations.ts`. 2. **Peldaño nuevo `settled` (400 ms)** — la escala no tenía paso entre `brief` (240) y `noticed` (600) y la región "breve-media" lo exigía. `commit.fulfill` 600→400 (`settled`); `signal.loss` 600→240 (`brief` — la huella es del caller: undo/estado, no señal más larga). 3. **Tabla única**: `SEMA_HOLDS_BY_INTENT` (familia+intent) es LA fuente de holds; el `hold` por-familia duplicado de `SEMA_MAP` se eliminó (había derivado: signal 600 contra la región "breve o contextual" → vuelve a 240). El resolver compone `signal.hold ?? resolveHoldsByIntent(family, intent)`. 4. **"El hold es suelo, no tijera"** — el des-estampado ya no amputa la expresión: tras el hold (mínimo de registro), el canal visual espera el `finished` de las animaciones activas del target (`VisualChannel.awaitExpression`), con tope ABSOLUTO `MAX_EXPRESSION_WAIT_MS = 1500` (constante de ingeniería — derivarlo del hold re-acoplaría los presupuestos que el cap. 4 §13 separa; el corte era el antipatrón del cap. 32 §1: "una señal necesaria, por desaparecer demasiado pronto"). La persistencia sigue siendo declaración del morfo (la huella SE DECLARA), nunca default del runtime. 5. **Firmas re-materializadas a las regiones**: announce neutral/affirm `deliberate(600)`→`moderate(240)` ("breve"); fulfill →`slower(400)` ("breve-media"); risk `emphatic(800)` / threat `sustained(1000)` conservan la escalación que el libro sí quiere saliente/persistente. 6. **Triple guarda**: suelo (attrs viven ≥ hold), espera de expresión (test del canal), y lint de diseño (ninguna firma transitoria > tope; subirla exige subir la constante conscientemente). `vocabularies.md` (generado de `holds.ts`) queda veraz sin tocarlo. **Candidato editorial** (si el autor lo quiere para el ApD, "Lo que la práctica corrigió"): esta es la historia inversa a las demás — aquí **el libro corrigió a la práctica**: el runtime había convertido el suelo en tijera y la tabla derivada en canon; releer la fuente restauró ambos. --- ### D.13 El diálogo declara `emerge.open`, no `shift.enter-mode` (la desviación emerge/shift del diálogo) - **Status**: **DESVIACIÓN REGISTRADA Y BENDECIDA POR EL LIBRO** (edición FINAL, Apéndice D, ancla **BK-D11**; pass de verbos C2, checkpoint 2026-07-07) - **Origen**: el libro doctrina el modal bloqueante como `shift.enter-mode` (cap. 8 §5 lo usa así; cap. 26 §8: "el dropdown es emerge.open y el modal es shift.enter-mode"; cap. 27 §1: "abrir un modal… todo eso es shift"). El contrato real de `dialog` declara `emerge-open`/`emerge-close`, y su cierre polimórfico admite emerge/commit/signal — shift no está en la lista. Esta entrada es el registro que el propio Apéndice D exige ("registrada en el cuaderno de desviaciones del proyecto, con su razón"). **La razón (del propio Apéndice D del libro):** > "En el componente genérico pesó más la aparición que el cruce de marco. Y el > cruce quedó reservado a los usos que de verdad bloquean el fondo y capturan > el foco — el mismo componente puede ser una cosa u otra según cómo se use. > La doctrina del libro no cambia: el modal pesado sigue siendo shift. Lo que > la práctica enseñó es que **la frontera emerge/shift no pasa entre > componentes, sino por dentro de ellos**." Y la regla de convivencia (pág. 411): *"Ambas lecturas son defendibles… La gramática no exige que todas las implementaciones lean igual el caso frontera; exige que cada una **elija, declare y sea consecuente**. El desacuerdo, mientras esté declarado, es información."* **Consecuencias operativas:** 1. `dialog` (y `alert-dialog`, que delega en sus eventos) CONSERVA `emerge-open`/`emerge-close`. No hay migración a shift. 2. Un uso que de verdad cambie el régimen (bloquea el fondo, captura el foco, exige reorientación como MODO) puede componer `shift.enter-mode` a nivel de aplicación — la frontera se decide POR USO, no por componente. 3. El caso queda como "lo que sigue abierto" nº1 del propio libro: señala dónde la frontera emerge/shift necesita más trabajo teórico. Si el libro la redefine en una edición futura, esta entrada se revisa. --- ## E. Resumen ejecutivo **Familias** (libro Cap 8): 8 — sin cambios. (`contact, commit, signal, handle, emerge, shift, sustain, delegate`) **Intents** (libro Cap 10): 6 — sin cambios. (`neutral, affirm, fulfill, risk, threat, loss`) ### Verbos / casos que pasan al libro (BOOK_CANON) - `handle.scroll` (A.1) - `commit.remove` (A.3, con distinción remove/delete/unselect) - `commit.set` (A.5) - `commit.apply` (A.6) - `commit.move` (A.7, distinto de reorder) - `commit.unselect` (A.11) - Cancelación de drag = `commit.cancel` (B.1) - Scroll programático = `shift.navigate` (B.2) - Sort = `commit.set` (B.4) - Eventos no perceptibles no son eventos (B.5) - Toggle con dos eventos direccionales (B.6, sin canonizar intents) - Intent en contact visual-only (B.7) - Cada actor declara sus eventos (D.1) - Family policy `intentRequirement` + `intentGuidance` (D.3) - `clear` = `commit.reset` (C.1) ### Pendientes — CANDIDATE (mantener en canon de implementación, NO al libro todavía) - `commit.acknowledge` (A.2) — necesita casos fuertes - `commit.confirm` (A.4) — solapa con submit/apply/acknowledge - `commit.upload` (A.8) — probable redundancia con complete - `commit.partial` (A.9) — probable estado, no verbo - `commit.block` (A.10) — probable estado/señal, no commit - Data-size auto-change (B.3) — caso por caso ### IMPLEMENTATION_CONTRACT (no doctrina) - Eventos declarados pero no emitidos (D.2) - Campo `expression` en el morfo (D.4) - Packs sema soft-tuned para alta frecuencia (D.5, toggles) - Packs sema para superficies de menú y árboles (D.6) - Doctrina sonido canónico vs samples (D.7) + packs tooltip / collapsible - Channel scope: qué es canal y qué no (D.8) --- ## F. Cómo proceder Para cada entrada el autor decidió un status. La implementación: 1. **BOOK_CANON aceptados**: ya están en `SEMA_VERBS` del proyecto. La próxima edición del libro puede formalizarlos. Los textos doctrinales recomendados están en bloques citados arriba. 2. **CANDIDATE**: mantener en `SEMA_VERBS` pero no promover al libro hasta acumular casos. 3. **IMPLEMENTATION_CONTRACT**: requiere cambios en types (flag `emission`) en una iteración futura. 4. **Cambio inmediato pendiente en proyecto**: split de `intentPolicy` en `intentRequirement` + `intentGuidance` (D.3) — commit separado. Hasta que el libro se actualice, este documento es la fuente de verdad sobre dónde el proyecto se ha desviado del libro literal y con qué status. --- ## G. Anti-mezclas Este documento mezclaría planos peligrosamente si no se mantiene la disciplina de: - **No copiar el contenido de este documento al libro tal cual.** El libro necesita gramática estable; este documento es bitácora. - **No promover automáticamente `PROJECT_CANON` a `BOOK_CANON`.** Solo entra al libro lo que mejora la teoría general. - **No usar lenguaje interno del proyecto** ("Capa 2", "Cluster 6", "Plan B commit X") en texto editorial. - **Versionar el documento** cuando cambie un veredicto del autor. Las familias son el núcleo estable. Los verbos son extensibles bajo criterios. La implementación puede tener aliases y extensiones locales. Solo las extensiones que revelan una diferencia recurrente y general deben pasar al libro. ## Backlog · actualizaciones posteriores Registro de lo que la implementación cambió DESPUÉS de firmar cada deviación. El cuerpo de arriba se deja intacto a propósito: es el acta de lo que se firmó, no el estado del código. Cada entrada nombra la sección que corrige. ### 2026-08-13 — D.7: el eje de intent implementa TRES cubos, y `IntentExpectedFamily` ya no existe Corrige **§D.7 · Implementación** («TypeScript deriva `IntentExpectedFamily` desde `intentRequirement === 'required'`»). - El alias `IntentExpectedFamily` se borró en `13246a2c2` (M6, «un nombre por concepto»). El nombre vivo es **`IntentRequiredFamily`**. - La derivación ya no son dos cubos. `1a174d5a6` (S-33) la hizo **positiva y triple** sobre `intentRequirement`: `IntentRequiredFamily` (`'required'`), `IntentOptionalFamily` (`'optional'`) e `IntentForbiddenFamily` (`'forbidden'`). Derivar el segundo cubo con `Exclude<…, Required>` era una derivación de mundo abierto: cualquier familia nueva caía dentro por omisión, sin que nadie hubiera decidido su política. - `intentGuidance` sigue siendo campo doctrinal, tal como se firmó. ### 2026-08-13 — la emisión anclada ya no pasa por `targetOverride` Corrige el **Caveat (`tree-view` / `tree-grid`) — RESUELTO (SEM-4)**, que nombra `targetOverride` como el vehículo del anclaje. La opción de elemento crudo `targetOverride` se **borró** en `b8aa333fd`, una vez el censo F3 llegó a cero: el estado ilegal del eje quedó inexpresable. El anclaje se hace hoy por identidad contra el registro — `runtime.partInstance('branch'|'row', el).trigger` en esos dos providers—, declarado en el morfo con `semantic.allowedTargets` ([`architecture/morfo.md`](../architecture/morfo.md)). Lo demás del caveat sigue en pie: los packs construyen sus selectores con `onBranch` / `onRow` y emisión y cascada casan de punta a punta.