CLAUDE.md 981 -> 350 lines. The 12 session hand-offs (2026-05-08 ->
2026-06-05, ~513 L) moved verbatim to docs/process/handoffs-claude-md.md
(chronicle frontmatter). Reference Documents now point into the book
tree, and the long-stale authority claim is fixed: the semantic
vocabulary source is docs/CANON.md (+ decisions/book-deviations.md),
with the Spanish guide correctly framed as a historical seed at its new
decisions/ home — the correction the guia's own banner had been
announcing since Fase 6. The four sema/eidos doctrine sections (~170 L
of copied tables, cascade diagrams and examples — the copy class
docs:check bans in the corpus) compact to the operational MUSTs
(semaSelector mandatory, packs never replace intent primitives,
channels split by owner, types-over-lint) each with its pointer to
CANON.md / architecture/sema.md / theming/channels.md /
architecture/eidos.md. Two head refs swept (eidos-motion ->
theming/motion.md, COMPONENT_GUIDE s4 -> guides/component-guide.md).
Approved by the user after diff review. docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- **`intentRequirement`** (`'required' | 'optional' | 'forbidden'`) — compile-time gate. `required` → `intent` REQUIRED in `MorfoEventSemantic` (discriminated union). `forbidden` is reserved for future use (no family uses it today).
- **`intentGuidance`** (`'expected' | 'contextual' | 'discouraged'`) — doctrinal hint, not type-enforced. Drives lint warnings and editor tooltips.
Type derivation: `SemaEvent` and `MorfoEventSemantic` discriminate over `intentRequirement`. Editing the const reshapes the discriminated union.
Runtime enforcement: `validateSemaEvent` throws when an `intentRequirement: 'required'` family declares an event without intent. Optional families pass silently.
**`delegate`** is the 8th family per book cap. 29 — reparto de iniciativa entre usuario y sistema. Structural; carries no intent of its own. Active channels empty (delegate composes with sustain / signal / commit for perceptual layering, doesn't own a base signature).
The earlier flat `intentPolicy: 'allowed' | 'expected' | 'optional'` mixed type-requirement with doctrinal guidance. The split was applied in commit `00f0b740`.
## Sema: open channel registry + flat CSS-style cascade
The perceptual layer is **NOT closed**. The book defines **8 expression
`semaSelector(morfo, partKebab, matchers?)` from `$uix/morfo`. Renames
in morfo break the cascade at type-check time. See "Sema cascade
selectors must use the typed builder" above.
- **Eidos plain `.css` recipes** are still raw CSS today — there is no
CSS-side typed builder. For those, `scripts/eidos-lint.ts` remains as
an **opt-in safety net** that classifies each `[data-*]` selector as
`morfo-backed` / `eidos-only` / `invalid`. It is not the architectural
contract; the contract is the morfo declaration.
Rule: when a layer can consume the morfo via TypeScript (anything in
`.ts` / `.svelte`), it MUST use the typed builder. Lint is for the
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-06-05 (sistema de color — cierres + theme builder runtime + wide-gamut + a11y)
Sprint dando "un giro de vueltas" al sistema de color de Eidos hasta reference-grade. 6 commits. El motor `uix.color` (`$color`, art puro isomórfico: OKLCH↔sRGB, APCA, alpha compositing-inverse, `deriveScheme`/`generateScale`/`temper`) ya existía; este sprint lo CONSUME end-to-end + cierra los ítems de calidad de `THEMING_AUDIT`.
**Cierres rápidos** (`cc37bdce`):
- `themes/base.ts`: `loss` era `purple` ≡ primary → mapeado a `plum` (escala canónica). Última colisión de roles del tema base cerrada (tras tertiary→indigo).
- `temas/grafito`: la sección "override por componente" pasaba nombres de escala (teal/amber/…) a `Button.color`, que solo acepta el override jerárquico (`primary|secondary|neutral`) — type error + inerte. Reescrita a los 2 ejes reales: `color` (jerarquía) + `intent` (paleta evaluativa). Cierra el último error de `svelte-check` → **0 errores**.
**API runtime — theme builder** (`c4d2e34d`):
- Nuevo `src/uix/eidos/lib/build-scheme.ts` (PURO): `buildScheme(seed, opts)` compone `deriveScheme` + `generateScale` + APCA on-solid + alpha en el mapa de override `--primitive-{role}-*` (+ `--color-{role}-contrast`). `seed → { variables, roles }`. Sin DOM.
- `ActiveEidos.applyColorScheme(seed, opts)` / `clearColorScheme()`: resuelve escalas-donantes + background del tema activo, escribe el bloque `uix-eidos-scheme` DESPUÉS del de tema (gana cascada), RE-DERIVA al cambiar de modo (sigue light/dark). Devuelve `BuildSchemeResult`. `opts`: `variant` (tonal|vibrant|monochrome) + `temper` (cohesión de intents, mantiene hue) + `overrides` per-rol + `selector`. Exportado de `$uix/eidos`. Demo `/temas/color` dogfooda `buildScheme`.
- Output OKLCH-nativo default-on (RFC §7 estrategia A): `render-css > appendColorScaleDeclarations` emite por cada paso de paleta el hex (fallback) + un hermano `oklch()` que gana donde se soporta. SIN flag.
- Generador wide-gamut-TRUE: `buildScheme`/`applyColorScheme` retienen el OKLCH raw de `generateScale` (sin clamp) → `result.wideGamut` + `result.roles[].stepsOklch`. Nuevo `schemeDeclarations(result, { fallback })` apila hex+oklch (default) u oklch-only (inline).
- **HONESTIDAD**: la paleta Radix shipped es hex sRGB → sus `oklch()` son sRGB-equivalentes (idéntico hoy). El wide-gamut REAL vive en el generador: seed con croma > sRGB sale más saturado en P3. Demo `/temas/color`: slider **vivacidad P3** + badge «fuera de sRGB → P3» (`isInSrgbGamut`). Verificado: ×1.70 → croma primary-9 0.18→0.31. La paleta autorada NO se migró a semillas (regresaría los valores exactos de Radix sin añadir wide-gamut visible).
**P3-2 forced-colors + P3-3 ramp de bordes** (`4eab3306`):
- **forced-colors (Windows HCM)**: el navegador auto-mapea bordes/texto/fondos (`forced-color-adjust: auto`) pero ELIMINA `box-shadow` → el focus ring (`--focus-ring`, box-shadow) desaparecía. Fix: foundation emite siempre `renderForcedColorsBlock` → `@media (forced-colors: active) { :focus-visible { outline: 2px solid Highlight } }`. Componentes con outline propio (Button) lo conservan por especificidad. Pendiente: `prefers-contrast: more`.
- **ramp de bordes**: slot de rol `border` 6 (separador sutil) → **7** (UI element border de Radix). `DEFAULT_COLOR_ROLE_SLOT_STEPS`. element/hover/active=3/4/5 se quedan. Verificado: `--color-{role}-border` → primitive-7 (checkbox OK).
**Verificación**: `check` 0 errores. Suite eidos 155/158 (3 fallos pre-existentes del track `words`, confirmados con `git stash` baseline). `build-scheme` 8 + scheme 4 + forced-colors 1. `generated/base.css` regenerado y en sync.
- **Paleta autorada = exacta-Radix-sRGB** (sin regresión). Wide-gamut VISIBLE de la paleta = Fase 3 futura (autorar/generar en OKLCH).
- **forced-colors**: el box-shadow muere en HCM → el foco debe ser `outline`. Migrar los componentes que aún usan `var(--focus-ring)` (box-shadow) a `outline` propio es trabajo futuro per-componente (hoy dependen del fallback global).
- **slot `border` = step 7** (no 6).
**Pendiente (solo higiene del engine, NO calidad de color)** — _todo resuelto-o-decidido en la continuación, abajo_: `THEMING_AUDIT` P3-4/5/6/7/8/9/11 + mitades P2.
**Continuación (mismo día) — fix de UX + cierre del backlog**:
- **Checkbox lag — NO era color, era timing del sema** (`e6fd014d` + `cd834384`). El usuario reportó el check "muy lento". Medido frame-a-frame: el `data-state` tardaba **244ms** en cambiar tras el click. Causa: el provider del checkbox fija el estado en el HANDLER del `runtime.trigger`, y el morfo declaraba `sequence: 'pre'` → el runtime hace `await runEmit()` (que **espera el hold del canal visual ~240ms**, `engine.emit()` → `await visualChannel.handle`) ANTES del handler. Fix: `commit-toggle-check`/`commit-toggle-uncheck` → `sequence: 'post'` (handler primero, pulso después). 244ms → 46ms. Secundario: trazo del checkmark `stroke-duration` 220ms hardcoded → `var(--duration-fast)`. **Verificado que radio-group (47ms) y tabs (31ms) NO laggean** aunque son 'pre' — fijan estado en el call-site, no en el handler; no se tocaron. Toggle/Switch ya eran 'post'.
- **DOCTRINA NUEVA**: un control cuyo estado se fija en el HANDLER del trigger DEBE usar `sequence: 'post'`; con `'pre'` el hold perceptual del emit bloquea el cambio funcional. Los que fijan estado en el call-site toleran 'pre' sin lag.
- **P3-11 surface ladder** (`76e18772`). Light `overlay` era `neutral-3` == `muted` (popovers indistinguibles de paneles muted en light); dark ya tenía overlay=4. Light overlay → `neutral-4` → ladder consistente en ambos modos: `default(1) < raised(2) < muted(3) < overlay(4)`. Verificado (popover light: overlay L93% ≠ muted L95.5%).
- **prefers-contrast: more** (`21b2329a`). Completa el a11y de color junto a forced-colors. `renderPrefersContrastBlock` → `@media (prefers-contrast: more) { :root:root { … } }` refuerza bordes (neutral 7/8/9) + texto de-enfatizado (12/11). `:root:root` (0,2,0) gana al `:root` del tema; aditivo, gated, estrictamente más fuerte. THEMING §28.
- **Cierre del backlog del engine** (`afa15aea`). `THEMING_AUDIT` P3 todo fixed-or-decided:
- ✅ **P3-6** confirmado ya resuelto (dispose vía `dom`, sin `document` directo).
- ✅ **P3-8** index ya NO re-exporta los render-fns crudos (API pública = clase `ActiveEidos`; `./lib/render-css` para uso interno). Consumidor de test redirigido al módulo.
- ⏸️ **P3-4** deferido (densidad gana por orden de fuente determinista, estable; restructure `:where(:root)` = coste alto por nit teórico).
- ⏸️ **P3-7** deferido (reactividad callback-driven vía `apply()` POR DISEÑO; runes = refactor riesgoso sin bug que lo justifique).
- ⏸️ **P3-9** deferido (forwarders huérfanos `_accent` — cirugía de recipe con riesgo de cascada, valor bajo).
- Único pendiente real: 2 mitades **P2** (poda de contrato + validación de identificador-color pelado) — edge-case, deferidas por bajo valor.
**Estado final del color/theming**: apariencia + a11y (forced-colors + prefers-contrast) + wide-gamut + theme builder runtime + backlog del engine — todo resuelto-o-decidido, cero ítems colgando. `check` 0 errores. Suite eidos 156/159 (3 fallos de words, pre-existentes, confirmados con baseline). Docs extra: `THEMING.md` §28 (prefers-contrast), `THEMING_AUDIT_2026-06-01.md` scorecard P3 completo.
- 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<string>` (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).
- 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()`.
- `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
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).
- `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.
- **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).
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.
- 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.
**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):
Ú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.).
Cierra la migración universal del Token Scope Contract. El usuario rechazó el framing "excepciones arquitectónicas" para los 3 componentes que vivían fuera de TSC v2.1 (`select`, `avatar`, `toggle-group`) — la respuesta fue extender TSC con dos features nuevas (`parts` + `composition`) y migrar los 3.
- **Multi-part scope** — `RecipeTokenMultiDeclaration.parts?: readonly string[]`. Cuando un token tiene `parts: ['x', 'y']` el generador emite selectores comma-separados (`[data-{c}-x][...], [data-{c}-y][...]`) en lugar del default `[data-{c}][...]`. Cubre el caso del select donde `data-color` vive en Trigger + Content (Content portaliza fuera del árbol del Trigger). Único consumer hoy.
- **Cross-recipe composition** — nuevo sibling field `composition?: { foreignComponent: { targetSelector, tokens } }`. Permite que un recipe declare overrides de tokens de OTRO recipe scoped a su propia cascade, con un selector de descendant para alcanzar la part foránea. Genera `{host-scope} {targetSelector} { --{foreign}-{tokenName}: ... }`. Validator rechaza root-scoped composition (un override no-scoped pertenece al foreign recipe). Único consumer hoy: `toggle-group` modifica `--toggle-palette-*` en sus items.
Validador (`config.ts`) extendido con `validateRecipeComposition`. Generator (`render-css.ts`) gana `stripCompositionKey` + `emitComposition`. Contract builder (`contract.ts`) skip-list de la reserved key `composition` para que no aparezca como `--{c}-composition` knob en el contrato público. Test helpers (`tokenKeys`/`tokenEntries` en `recipe-css-contract.test.ts`) filtran `composition` en todos los iteradores.
**Migraciones de los 3 componentes restantes**:
- `select`: 4 → 3 private `_accent-*` con `parts: ['trigger', 'content']`. (`_accent-solid` no se migró porque la CSS nunca lo consumía — era orphan; se removió de la recipe.)
- `avatar`: 6 tokens (`_bg`/`_fg`/`_border` + `_badge-bg`/`_badge-fg`/`_badge-border`) con declarations[] generadas por un helper inline `matrix()` que recorre los 8 colores × 3 variants. `_badge-*` usa `parts: ['badge']` para retargetear a `[data-avatar-badge]`. Reemplaza ~150 declarations CSS por las mismas declarations, generadas desde TS.
**Bug post-migración del toggle-group (toggle-group color cascade)** — encontrado y arreglado:
La composition correctamente override `--toggle-palette-*` en `[data-toggle-group-item]`, pero la cascade se rompía aguas abajo. Después de la migración TSC v2 original de toggle, los tokens DERIVADOS (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es SIBLING (no descendant) de `[data-toggle]`, así que `var(--toggle-solid-on-bg)` resolvía a undefined en el item.
Fix (en `toggle-group.css`): **inlined las derivation expressions** que leen palette directamente en `[data-toggle-group-item]` y sus variant cascades. Las expresiones reflejan las de `recipes/base.ts > toggle.{solid,outline,ghost}-*`. Duplicación documentada en el header. Alternativa estructural (hacer el item carrier de `[data-toggle]` + propagar `data-color` morfo/soma) no se aplicó — change too big.
**Documentación**:
- `THEMING.md` §18 reescrito como "Cobertura universal de TSC" (sin excepciones). §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" con ejemplos completos. TOC actualizado.
- `eidos/README.md` tabla de referencia ampliada con entradas para TSC v2.2 + §18.
**Tests**: 786/786 pass en `src/uix/{eidos,morfo,soma,sema}`. `npm run check`: los mismos 6 errores pre-existentes en `lib/_demo`/`soma/components/internal`/`web/routes/active` (no relacionados).
**Pendiente a futuro**:
- Si emergen patrones similares al toggle-group (otros wrappers compositivos como button-group, nav-menu), la composition TSC v2.2 los cubre — no requiere más extensiones.
- Los `inlined derivation expressions` en `toggle-group.css` son la única duplicación entre recipes/base.ts y CSS. Si Toggle's derivations cambian, hay que actualizar ambos.
Pregunta arquitectónica del usuario: "si activeUIX quiere ser referencia como framework, ¿qué es lo lógicamente coherente respecto a la extensibilidad de variants?". Respuesta firme: **variants son canon del eidos, NO del theme** — paralelo a las 8 sema families del libro.
**Cambios**:
- `src/uix/eidos/lib/types.ts`: nueva constante `EIDOS_VARIANTS` (5 archetypes: `control`/`selection`/`chip`/`marker`/`tabs`) como single source of truth. Los 5 union types se derivan via `[number]` indexed access — valor y tipo no pueden desincronizarse. Nueva constante `EIDOS_VARIANT_VALUES` (Set flat de todos los valores canónicos + utilidades cross-component como `'plain'` y `'subtle'`).
- `src/uix/eidos/recipe-css-contract.test.ts`: nuevo test "variant CSS selectors per component match the declared type union". Por cada componente: extrae el union type de `components/{c}/types.ts` via regex (literal-union + archetype-alias patterns soportados, Extract<>/conditional types caen a advisory mode); compara con los `[data-{c}][data-variant='X']` selectores en `{c}.css`; reporta typos y unauthorized extensions bidireccionalmente. 101/101 tests pasan.
- `src/uix/eidos/THEMING.md` §19: nueva sección "Variants son canon del eidos, NO del theme" con argumentación (portabilidad, type safety, archetypes perceptuales), tabla de las 3 capas de la cebolla (sema → variants → palette), referencia a `EIDOS_VARIANTS`, comparación con Radix Themes/Mantine/Chakra v3/Ark UI/shadcn. TOC actualizado.
- `src/uix/eidos/README.md`: tabla de referencia ampliada con §19.
**Doctrina sostenida**: Theme = retintar lo perceptualmente fijo. Cambia QUÉ color es `affirm`, no QUÉ significa `outline`. Si una app necesita un look brandeado, hace override de tokens en `EidosConfig.recipes` o crea un wrapper composicional — NO inventa un nuevo variant.
**Variants component-specific permitidos** (Banner `inline`/`overlay`/`persistent`, Spinner `bars`/`dots`/`ring`, Button `'plain'`): viven en cada `components/{c}/types.ts` y el lint los valida contra la CSS del componente.
**Tests**: 101/101 pass en `src/uix/eidos`. `npm run check`: los mismos 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active).
Cierra la deuda dejada explícitamente abierta en el hand-off 2026-05-27 #5 ("Los `inlined derivation expressions` en `toggle-group.css` son la única duplicación entre recipes/base.ts y CSS. Si Toggle's derivations cambian, hay que actualizar ambos."). Ahora son cero.
**Pregunta arquitectónica del usuario** tras verificar el fix EXT-FIX (color cascade): "¿qué es lo recomendado en referencia al ecosistema y su diseño y arquitectura?". Respuesta firme: **resolver la duplicación estructuralmente, no con un test de drift**. El item de toggle-group ES un toggle (mismo press, mismo variant/color/size, misma máquina on/off) — la doctrina "morfo declara DNA" pide declararlo.
**Cambios**:
- `src/uix/morfo/components/toggle-group.ts`: el part `Item` declara `{ attr: 'data-toggle', value: v.literal(''), severity: 'required' }`. Cada item proyecta `data-toggle=""` como atributo de presencia. Cero overhead, captura la identidad estructural.
- `src/uix/eidos/components/toggle-group/context.ts` (nuevo): contexto Svelte tipado (`ToggleGroupEidosCtx` con getters reactivos para `variant` y `size`) — propaga las dos perillas eidos-only del root a cada item.
- `src/uix/eidos/components/toggle-group/toggle-group.svelte`: setea el contexto en el root. Getters mantienen reactividad cuando el prop cambia.
- `src/uix/eidos/components/toggle-group/toggle-group-item.svelte`: lee el contexto y escribe `data-variant={ctx?.variant}` + `data-size={ctx?.size}` en el button. Combinado con `data-toggle` del morfo, el elemento es DOM-equivalente a un `<Toggle>` standalone.
- `src/uix/eidos/components/toggle-group/toggle-group.css`: borradas ~150 líneas (todas las derivaciones base + variant cascade + size cascade + focus-visible / disabled / icon-only duplicados). El CSS conserva SOLO grouping concerns (flex layout, orientation, attached con first/last/border-radius, block, focus z-index, group-level disabled). El header comment se rescribe documentando la nueva división de responsabilidades.
- `src/uix/eidos/components/toggle-group/README.md`: sección "Recipe" + "Decisiones" actualizadas — la entrada "Structural identity" documenta el patrón.
**Por qué TSC v2.2 composition se queda en la mezcla**: la composition sigue siendo necesaria para overridear `--toggle-palette-*` en el item bajo `[data-toggle-group][data-color='X'] [data-toggle-group-item]`. El color NO se propaga via contexto porque la composition ya hace el trabajo a nivel CSS, sin overhead reactivo. Variant/size sí se propagan porque tienen muchos derivados (height, padding, gap, font, radius × 5 sizes; bg, fg, border, hover, on, on-hover × 3 variants) que solo Toggle's recipe ya emite — no había ningún beneficio en mantener cascadas paralelas.
**Verificación**: probe DOM en `/uix/components/toggle-group` capturando computed values en las 12 combinaciones (4 colores × 3 variants) × 2 estados (on/off) — match bit-a-bit con baseline pre-refactor. Ejemplos:
- `affirm/solid` on: `rgb(18,165,148)` = `#12a594` (palette-solid affirm)
- `threat/ghost` on: `rgb(25,17,17)` = `#191111` (palette-track threat)
**Tests**: 163/163 pass en `src/uix/morfo` + `src/uix/eidos`. 4/4 pass en `src/uix/soma/components/toggle-group`. `npm run check`: 16 errores pre-existentes (los mismos del sprint Words), CERO añadidos por esta migración.
**Coste real vs estimado**: el hand-off #5 estimó el cambio como "too big" — incorrecto. Total = 1 entrada en morfo + 1 archivo de contexto (~30 líneas) + 2 ediciones puntuales en wrappers (~5 líneas cada) + 1 rewrite de CSS reduciendo ~150 líneas a ~85. La parte engañosa era pensar que requería tocar Toggle's CSS — no lo hace.
**Doctrina reforzada**: cuando un wrapper componente reusa visualmente otro, la respuesta canónica NO es duplicar el cascade ni extender TSC con un tercer feature. Es declarar la identidad estructural en el morfo del wrapper. Patrón aplicable si emerge button-group, link-group, etc.
Segunda mitad de la sesión (2026-05-28) dedicada a fixes del editor Words por feedback iterativo del usuario. Seis sprints EV-* + cleanup del check.
**Cleanup pre-sprint — `npm run check` 16 → 0**:
- 4 errores `orientation` drift en `soma/components/words-toolbar*` (deuda mía del sprint anterior — removí orientation del type pero no del soma component). Quitado el prop + create() call.
- 12 errores en el sprint Words activo (post-2026-05 F2/F3/COLOR): `onUploadImage` threading en soma `Words.Provider`, `'insert-image'` añadido a 2 Records `WordsToolbarButtonCommandName`, `leafItem` snippet hoisted FUERA de `<SomaWords.Provider>` (snippets dentro de un component element son props en Svelte 5; el snippet era helper local), 5 arrays inline en `words-drawer.svelte` extraídos a constantes typed `as const satisfies readonly { id: WordsCommandName | WordsMark; ... }[]`.
**EV-A — spam de eventos del canvas**:
- `isInsideWordsTool` extendido a los 5 overlays añadidos post-DRAWER: `data-words-drawer`, `data-words-block-handle`, `data-words-block-handle-menu`, `data-words-block-inserter`, `data-words-image-float-bar`. Antes, cualquier click sobre estos overlays se interpretaba como blur EXTERNO → fire `commit-save-content` + re-focus → fire `contact-focus` = 2 sonidos por interacción.
- `contact-focus` target movido de `content` a `provider` en morfo + sema cascade (doctrina: focus es evento de componente, no de body).
**EV-B — toolbar slim**:
- Presets demo (`minimal`/`formatting`/`full` + custom) reducidos a acciones GLOBALES: history (undo/redo) + insert + link + tools + find-replace. Text/block/list/align/table OUT porque el drawer ya los cubre por scope.
**EV-C — drag handle UX**:
- Borrado `e.dataTransfer.setDragImage(hoverBlockEl, 12, 12)`. El browser usa su snapshot por defecto (= el grip button) como ghost — el ghost viaja con el cursor mientras el bar en la gutter queda fijo como ancla visual. Nuevo `data-dragging` + CSS fade del ancla a 0.35 opacity.
**EV-D — inserter al borde inferior**:
- `seam.y` para seams entre bloques cambia de `(a.bottom + b.top) / 2` (midpoint) a `a.bottom` (borde inferior del bloque anterior). Half-open interval `[top, bottom)` para que el píxel exacto del bottom pertenezca al seam, no al bloque.
**EV-E — scroll interno del content**:
- Nuevo token `content-max-block-size-sm/md/lg` (50/60/70vh) en el recipe. `[data-words-content]` gana `max-block-size: var(--_words-content-max-block-size)` + `overflow-y: auto`. El min-block-size baseline queda como starting height para editores vacíos.
- block-handle / block-inserter / image-float-bar: scroll listeners migrados de `window.addEventListener('scroll')` a `document.addEventListener('scroll', { capture: true })`. Razón: scroll events NO burbujean — la versión anterior solo captaba scroll del root document; con la rail/handle/inserter ahora dentro de un content scrollable, había que capturar también scroll DENTRO del content para que las overlays se re-midieran.
**EV-F — paquete de 6 fixes en uno**:
1. Cascade sema de `contact-focus` REMOVIDA → canvas mudo en focus (EV-A fixaba solo el target; la cascade seguía sonando).
2. Rail bg → flat silver (`#d4d4d4`) + borde derecho `#9a9a9a`, sin dot pattern. Tokens `words.rail-bg / rail-border` en el recipe. **Fixed-tone, NOT theme-aware** — la intención es emular un margen físico de cuaderno, debe verse igual en dark/light theme.
4. Family-panel base 12.5 → 16rem · tools 17rem · link 20rem. Sin scroll horizontal.
5. Link popover sin scroll vertical (consecuencia del #4).
6. Engine `insertParagraph` con guarda explícita: paragraph vacío + Enter = no-op; heading vacío + Enter = demote a paragraph (canonical Notion UX).
**EV-G — último pass (parcial)**:
- `runtime.trigger('contact-focus')` comentado en soma. Causa real del sonido residual: la family `contact` en `SEMA_MAP` tiene BASE signature de sound (pitch 800, gain 0.25) que suena aunque no haya cascade per-component. Solución: no disparar el evento. Telemetry vacía para focus, aceptado.
- Drag handle + inserter ahora cubren `data-words-node="list"` y `data-words-node="table"`, no solo `"block"`. Los selectores en `findBlockElement`, `blockUnderCursorY`, `listBlockBoundaries` aceptan los tres tipos para top-level blocks. Listas y tablas tienen drag handle + inserter seam.
- `:hover` rules eliminadas en `[data-words-block-inserter-button]` y `[data-words-block-handle]`. Las overlays son AMBIENT — estado visual cambia solo en data attrs (`data-open`, `data-grabbed`, `data-dragging`). No se iluminan en hover.
**Decisiones arquitectónicas con efecto duradero**:
- **Canvas perceptualmente silencioso** (Words): `contact-focus` no se emite. Si en el futuro un consumidor necesita el evento, descomentar el `runtime.trigger` en `words-provider.svelte.ts > onfocus`.
- **Tokens FIXED-TONE para concepto físico** (Words rail): `--words-rail-bg/border` son hex literal en el recipe, no `var(--color-*)`. Justificación: el concepto visual es "margen de papel" — debe verse igual en todos los themes.
- **Drag-handle predicate cubre 3 tipos de node**: `block`/`list`/`table`. No solo `block`. Cualquier nuevo top-level node type tiene que entrar en la lista.
- **Overlays ambient, no interactive buttons**: gutter overlays (block-handle, block-inserter button) no tienen `:hover`. Estado visual via data attrs solamente.
- **Engine guard sobre bloque vacío**: `insertParagraph` ya no duplica párrafos vacíos. Convierte heading→paragraph en vacío.
**Pendientes documentados en CONTINUE.md**:
- P1 — Drag handle aparece FUERA del rail sobre code blocks (no diagnosticado).
- P1 — Heading inline level change (h1↔h2↔h3) — la UI no permite cambiar nivel inline; el drawer Block panel lo tendría que ofrecer cuando `currentBlock === 'heading'`.
Sprint completing the alignment of the project with the book *Diseñando lo que ocurre*. Nine commits closed P1 #3, P1 #4 and P1 #5 of the audit-codex:
```
504780ad expression field + sema coverage check
2d562f37 11 sema packs + doctrina samples (D.5/D.6/D.7)
826ca2bf D.8 channels scope doctrine
43b15372 picker-shell INTERNAL (P1 #5 closed)
66318c47 tree-view target → branch for emerge events
adf89a08 batch A — soma emission for 7 packs-shipped components
0784cae5 batch B — 8 components with sema scope, no pack
cbf76b66 batch C — drag-drop + virtual-list/grid
7afa7057 23 component READMEs with Sema events sections
```
**Architectural deltas from this sprint:**
- **`Morfo.expression`** new field (`'pack' | 'family-default' | 'delegated' | 'none'`) declares how each morfo with declared events materialises its perceptual signature. Schema-validated; lint enforces coverage. See `src/uix/morfo/types.ts` + `LIBRO_VARIACIONES_Y_EXTENSIONES.md` D.4.
- **`SemaFamily` is now 8** (added `delegate` per book cap. 29). `SEMA_FAMILY_POLICY` carries two axes (`intentRequirement` + `intentGuidance`) — see updated section above.
- **Soma emission cabled** in 18 components (toggle-group, menubar, navigation-menu, dropdown-menu, context-menu, tree-view, tree-grid, listbox, grid-list, table, feed, command, carousel, announce, clipboard, drag-drop, virtual-list, virtual-grid). Pattern: emit from the central state-mutator with `fallbackTarget` resolving the specific element so the cascade matches the right instance.
- **`handle-scroll*` intentionally NOT emitted** in virtual-list / virtual-grid — family.handle activates haptic only; emitting on every pixel of scroll would buzz the device nonstop. Apps that want scroll-feedback wire their own throttled emit. Documented in both READMEs.
- **picker-shell relocated** from `src/uix/morfo/components/` to `src/uix/morfo/internal/` — declares its INTERNAL status by file location. Apps never write `<PickerShell>`; the five composite pickers (date / date-range / time / time-range / color) re-export the shell parts under their own namespace. README added at `src/uix/eidos/components/picker-shell/README.md`.
- **`LIBRO_VARIACIONES_Y_EXTENSIONES.md`** is the authoritative registry for project decisions vs the book canon. Sections D.4 (`expression` field), D.5 (toggles soft-tuned packs), D.6 (menus/trees packs), D.7 (samples doctrine — `SOUND_LIBRARY` = resources, `SOUND_TUNINGS` = canon, packs compose tunings never samples; `sampleOverlay` rejected permanently), D.8 (channels scope — `sound` + `haptic` are the only canonical runtime channels; ARIA structural lives in morfo, ARIA dynamic in soma, visual in eidos — never canonized as channels).
- **23 soma component READMEs** now include a `## Sema events` table with event / family / verb / target / intent / when + pack reference + doctrinal notes for the verb-rename corrections applied across the sprint.
**Verification at hand-off**: `npm run morfo:vocabulary` EXIT 0 (clean). `npx vitest run src/uix/sema src/uix/morfo` 195/195. `npx vitest run src/uix/soma` 419/419. Pre-existing words/* type errors unchanged (separate dev track).
**What's left from the audit-codex**: P2 (34 demos NEEDS-WORK) — explicitly out of scope (full refactor of demos + web routes pending separately).
When a layer can consume the morfo via TypeScript (anything in `.ts` /
`.svelte`), it MUST use the typed builder (`semaSelector`); hand-written
morfo-targeting selector strings in TS are an architecture violation, not a
lint warning. Plain `.css` recipes have no builder yet — for those,
`scripts/eidos-lint.ts` is the opt-in safety net (classifies each `[data-*]`
selector as morfo-backed / eidos-only / invalid). The architectural contract
is the morfo declaration. (Detail: [`docs/architecture/eidos.md`](docs/architecture/eidos.md).)
> Extracted from CLAUDE.md so the agent-instructions file reads operational
> and timeless. Chronicle — recorded history, not current truth: where a
> hand-off names a doc, the docs corpus (docs/README.md) is the living home.
## Session hand-off — 2026-06-05 (sistema de color — cierres + theme builder runtime + wide-gamut + a11y)
Sprint dando "un giro de vueltas" al sistema de color de Eidos hasta reference-grade. 6 commits. El motor `uix.color` (`$color`, art puro isomórfico: OKLCH↔sRGB, APCA, alpha compositing-inverse, `deriveScheme`/`generateScale`/`temper`) ya existía; este sprint lo CONSUME end-to-end + cierra los ítems de calidad de `THEMING_AUDIT`.
**Cierres rápidos** (`cc37bdce`):
- `themes/base.ts`: `loss` era `purple` ≡ primary → mapeado a `plum` (escala canónica). Última colisión de roles del tema base cerrada (tras tertiary→indigo).
- `temas/grafito`: la sección "override por componente" pasaba nombres de escala (teal/amber/…) a `Button.color`, que solo acepta el override jerárquico (`primary|secondary|neutral`) — type error + inerte. Reescrita a los 2 ejes reales: `color` (jerarquía) + `intent` (paleta evaluativa). Cierra el último error de `svelte-check` → **0 errores**.
**API runtime — theme builder** (`c4d2e34d`):
- Nuevo `src/uix/eidos/lib/build-scheme.ts` (PURO): `buildScheme(seed, opts)` compone `deriveScheme` + `generateScale` + APCA on-solid + alpha en el mapa de override `--primitive-{role}-*` (+ `--color-{role}-contrast`). `seed → { variables, roles }`. Sin DOM.
- `ActiveEidos.applyColorScheme(seed, opts)` / `clearColorScheme()`: resuelve escalas-donantes + background del tema activo, escribe el bloque `uix-eidos-scheme` DESPUÉS del de tema (gana cascada), RE-DERIVA al cambiar de modo (sigue light/dark). Devuelve `BuildSchemeResult`. `opts`: `variant` (tonal|vibrant|monochrome) + `temper` (cohesión de intents, mantiene hue) + `overrides` per-rol + `selector`. Exportado de `$uix/eidos`. Demo `/temas/color` dogfooda `buildScheme`.
- Output OKLCH-nativo default-on (RFC §7 estrategia A): `render-css > appendColorScaleDeclarations` emite por cada paso de paleta el hex (fallback) + un hermano `oklch()` que gana donde se soporta. SIN flag.
- Generador wide-gamut-TRUE: `buildScheme`/`applyColorScheme` retienen el OKLCH raw de `generateScale` (sin clamp) → `result.wideGamut` + `result.roles[].stepsOklch`. Nuevo `schemeDeclarations(result, { fallback })` apila hex+oklch (default) u oklch-only (inline).
- **HONESTIDAD**: la paleta Radix shipped es hex sRGB → sus `oklch()` son sRGB-equivalentes (idéntico hoy). El wide-gamut REAL vive en el generador: seed con croma > sRGB sale más saturado en P3. Demo `/temas/color`: slider **vivacidad P3** + badge «fuera de sRGB → P3» (`isInSrgbGamut`). Verificado: ×1.70 → croma primary-9 0.18→0.31. La paleta autorada NO se migró a semillas (regresaría los valores exactos de Radix sin añadir wide-gamut visible).
**P3-2 forced-colors + P3-3 ramp de bordes** (`4eab3306`):
- **forced-colors (Windows HCM)**: el navegador auto-mapea bordes/texto/fondos (`forced-color-adjust: auto`) pero ELIMINA `box-shadow` → el focus ring (`--focus-ring`, box-shadow) desaparecía. Fix: foundation emite siempre `renderForcedColorsBlock` → `@media (forced-colors: active) { :focus-visible { outline: 2px solid Highlight } }`. Componentes con outline propio (Button) lo conservan por especificidad. Pendiente: `prefers-contrast: more`.
- **ramp de bordes**: slot de rol `border` 6 (separador sutil) → **7** (UI element border de Radix). `DEFAULT_COLOR_ROLE_SLOT_STEPS`. element/hover/active=3/4/5 se quedan. Verificado: `--color-{role}-border` → primitive-7 (checkbox OK).
**Verificación**: `check` 0 errores. Suite eidos 155/158 (3 fallos pre-existentes del track `words`, confirmados con `git stash` baseline). `build-scheme` 8 + scheme 4 + forced-colors 1. `generated/base.css` regenerado y en sync.
- **Paleta autorada = exacta-Radix-sRGB** (sin regresión). Wide-gamut VISIBLE de la paleta = Fase 3 futura (autorar/generar en OKLCH).
- **forced-colors**: el box-shadow muere en HCM → el foco debe ser `outline`. Migrar los componentes que aún usan `var(--focus-ring)` (box-shadow) a `outline` propio es trabajo futuro per-componente (hoy dependen del fallback global).
- **slot `border` = step 7** (no 6).
**Pendiente (solo higiene del engine, NO calidad de color)** — _todo resuelto-o-decidido en la continuación, abajo_: `THEMING_AUDIT` P3-4/5/6/7/8/9/11 + mitades P2.
**Continuación (mismo día) — fix de UX + cierre del backlog**:
- **Checkbox lag — NO era color, era timing del sema** (`e6fd014d` + `cd834384`). El usuario reportó el check "muy lento". Medido frame-a-frame: el `data-state` tardaba **244ms** en cambiar tras el click. Causa: el provider del checkbox fija el estado en el HANDLER del `runtime.trigger`, y el morfo declaraba `sequence: 'pre'` → el runtime hace `await runEmit()` (que **espera el hold del canal visual ~240ms**, `engine.emit()` → `await visualChannel.handle`) ANTES del handler. Fix: `commit-toggle-check`/`commit-toggle-uncheck` → `sequence: 'post'` (handler primero, pulso después). 244ms → 46ms. Secundario: trazo del checkmark `stroke-duration` 220ms hardcoded → `var(--duration-fast)`. **Verificado que radio-group (47ms) y tabs (31ms) NO laggean** aunque son 'pre' — fijan estado en el call-site, no en el handler; no se tocaron. Toggle/Switch ya eran 'post'.
- **DOCTRINA NUEVA**: un control cuyo estado se fija en el HANDLER del trigger DEBE usar `sequence: 'post'`; con `'pre'` el hold perceptual del emit bloquea el cambio funcional. Los que fijan estado en el call-site toleran 'pre' sin lag.
- **P3-11 surface ladder** (`76e18772`). Light `overlay` era `neutral-3` == `muted` (popovers indistinguibles de paneles muted en light); dark ya tenía overlay=4. Light overlay → `neutral-4` → ladder consistente en ambos modos: `default(1) < raised(2) < muted(3) < overlay(4)`. Verificado (popover light: overlay L93% ≠ muted L95.5%).
- **prefers-contrast: more** (`21b2329a`). Completa el a11y de color junto a forced-colors. `renderPrefersContrastBlock` → `@media (prefers-contrast: more) { :root:root { … } }` refuerza bordes (neutral 7/8/9) + texto de-enfatizado (12/11). `:root:root` (0,2,0) gana al `:root` del tema; aditivo, gated, estrictamente más fuerte. THEMING §28.
- **Cierre del backlog del engine** (`afa15aea`). `THEMING_AUDIT` P3 todo fixed-or-decided:
- ✅ **P3-6** confirmado ya resuelto (dispose vía `dom`, sin `document` directo).
- ✅ **P3-8** index ya NO re-exporta los render-fns crudos (API pública = clase `ActiveEidos`; `./lib/render-css` para uso interno). Consumidor de test redirigido al módulo.
- ⏸️ **P3-4** deferido (densidad gana por orden de fuente determinista, estable; restructure `:where(:root)` = coste alto por nit teórico).
- ⏸️ **P3-7** deferido (reactividad callback-driven vía `apply()` POR DISEÑO; runes = refactor riesgoso sin bug que lo justifique).
- ⏸️ **P3-9** deferido (forwarders huérfanos `_accent` — cirugía de recipe con riesgo de cascada, valor bajo).
- Único pendiente real: 2 mitades **P2** (poda de contrato + validación de identificador-color pelado) — edge-case, deferidas por bajo valor.
**Estado final del color/theming**: apariencia + a11y (forced-colors + prefers-contrast) + wide-gamut + theme builder runtime + backlog del engine — todo resuelto-o-decidido, cero ítems colgando. `check` 0 errores. Suite eidos 156/159 (3 fallos de words, pre-existentes, confirmados con baseline). Docs extra: `THEMING.md` §28 (prefers-contrast), `THEMING_AUDIT_2026-06-01.md` scorecard P3 completo.
- 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<string>` (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).
- 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()`.
- `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
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).
- `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.
- **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).
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.
- 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.
**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):
Ú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.).
Cierra la migración universal del Token Scope Contract. El usuario rechazó el framing "excepciones arquitectónicas" para los 3 componentes que vivían fuera de TSC v2.1 (`select`, `avatar`, `toggle-group`) — la respuesta fue extender TSC con dos features nuevas (`parts` + `composition`) y migrar los 3.
- **Multi-part scope** — `RecipeTokenMultiDeclaration.parts?: readonly string[]`. Cuando un token tiene `parts: ['x', 'y']` el generador emite selectores comma-separados (`[data-{c}-x][...], [data-{c}-y][...]`) en lugar del default `[data-{c}][...]`. Cubre el caso del select donde `data-color` vive en Trigger + Content (Content portaliza fuera del árbol del Trigger). Único consumer hoy.
- **Cross-recipe composition** — nuevo sibling field `composition?: { foreignComponent: { targetSelector, tokens } }`. Permite que un recipe declare overrides de tokens de OTRO recipe scoped a su propia cascade, con un selector de descendant para alcanzar la part foránea. Genera `{host-scope} {targetSelector} { --{foreign}-{tokenName}: ... }`. Validator rechaza root-scoped composition (un override no-scoped pertenece al foreign recipe). Único consumer hoy: `toggle-group` modifica `--toggle-palette-*` en sus items.
Validador (`config.ts`) extendido con `validateRecipeComposition`. Generator (`render-css.ts`) gana `stripCompositionKey` + `emitComposition`. Contract builder (`contract.ts`) skip-list de la reserved key `composition` para que no aparezca como `--{c}-composition` knob en el contrato público. Test helpers (`tokenKeys`/`tokenEntries` en `recipe-css-contract.test.ts`) filtran `composition` en todos los iteradores.
**Migraciones de los 3 componentes restantes**:
- `select`: 4 → 3 private `_accent-*` con `parts: ['trigger', 'content']`. (`_accent-solid` no se migró porque la CSS nunca lo consumía — era orphan; se removió de la recipe.)
- `avatar`: 6 tokens (`_bg`/`_fg`/`_border` + `_badge-bg`/`_badge-fg`/`_badge-border`) con declarations[] generadas por un helper inline `matrix()` que recorre los 8 colores × 3 variants. `_badge-*` usa `parts: ['badge']` para retargetear a `[data-avatar-badge]`. Reemplaza ~150 declarations CSS por las mismas declarations, generadas desde TS.
**Bug post-migración del toggle-group (toggle-group color cascade)** — encontrado y arreglado:
La composition correctamente override `--toggle-palette-*` en `[data-toggle-group-item]`, pero la cascade se rompía aguas abajo. Después de la migración TSC v2 original de toggle, los tokens DERIVADOS (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es SIBLING (no descendant) de `[data-toggle]`, así que `var(--toggle-solid-on-bg)` resolvía a undefined en el item.
Fix (en `toggle-group.css`): **inlined las derivation expressions** que leen palette directamente en `[data-toggle-group-item]` y sus variant cascades. Las expresiones reflejan las de `recipes/base.ts > toggle.{solid,outline,ghost}-*`. Duplicación documentada en el header. Alternativa estructural (hacer el item carrier de `[data-toggle]` + propagar `data-color` morfo/soma) no se aplicó — change too big.
**Documentación**:
- `THEMING.md` §18 reescrito como "Cobertura universal de TSC" (sin excepciones). §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" con ejemplos completos. TOC actualizado.
- `eidos/README.md` tabla de referencia ampliada con entradas para TSC v2.2 + §18.
**Tests**: 786/786 pass en `src/uix/{eidos,morfo,soma,sema}`. `npm run check`: los mismos 6 errores pre-existentes en `lib/_demo`/`soma/components/internal`/`web/routes/active` (no relacionados).
**Pendiente a futuro**:
- Si emergen patrones similares al toggle-group (otros wrappers compositivos como button-group, nav-menu), la composition TSC v2.2 los cubre — no requiere más extensiones.
- Los `inlined derivation expressions` en `toggle-group.css` son la única duplicación entre recipes/base.ts y CSS. Si Toggle's derivations cambian, hay que actualizar ambos.
Pregunta arquitectónica del usuario: "si activeUIX quiere ser referencia como framework, ¿qué es lo lógicamente coherente respecto a la extensibilidad de variants?". Respuesta firme: **variants son canon del eidos, NO del theme** — paralelo a las 8 sema families del libro.
**Cambios**:
- `src/uix/eidos/lib/types.ts`: nueva constante `EIDOS_VARIANTS` (5 archetypes: `control`/`selection`/`chip`/`marker`/`tabs`) como single source of truth. Los 5 union types se derivan via `[number]` indexed access — valor y tipo no pueden desincronizarse. Nueva constante `EIDOS_VARIANT_VALUES` (Set flat de todos los valores canónicos + utilidades cross-component como `'plain'` y `'subtle'`).
- `src/uix/eidos/recipe-css-contract.test.ts`: nuevo test "variant CSS selectors per component match the declared type union". Por cada componente: extrae el union type de `components/{c}/types.ts` via regex (literal-union + archetype-alias patterns soportados, Extract<>/conditional types caen a advisory mode); compara con los `[data-{c}][data-variant='X']` selectores en `{c}.css`; reporta typos y unauthorized extensions bidireccionalmente. 101/101 tests pasan.
- `src/uix/eidos/THEMING.md` §19: nueva sección "Variants son canon del eidos, NO del theme" con argumentación (portabilidad, type safety, archetypes perceptuales), tabla de las 3 capas de la cebolla (sema → variants → palette), referencia a `EIDOS_VARIANTS`, comparación con Radix Themes/Mantine/Chakra v3/Ark UI/shadcn. TOC actualizado.
- `src/uix/eidos/README.md`: tabla de referencia ampliada con §19.
**Doctrina sostenida**: Theme = retintar lo perceptualmente fijo. Cambia QUÉ color es `affirm`, no QUÉ significa `outline`. Si una app necesita un look brandeado, hace override de tokens en `EidosConfig.recipes` o crea un wrapper composicional — NO inventa un nuevo variant.
**Variants component-specific permitidos** (Banner `inline`/`overlay`/`persistent`, Spinner `bars`/`dots`/`ring`, Button `'plain'`): viven en cada `components/{c}/types.ts` y el lint los valida contra la CSS del componente.
**Tests**: 101/101 pass en `src/uix/eidos`. `npm run check`: los mismos 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active).
Cierra la deuda dejada explícitamente abierta en el hand-off 2026-05-27 #5 ("Los `inlined derivation expressions` en `toggle-group.css` son la única duplicación entre recipes/base.ts y CSS. Si Toggle's derivations cambian, hay que actualizar ambos."). Ahora son cero.
**Pregunta arquitectónica del usuario** tras verificar el fix EXT-FIX (color cascade): "¿qué es lo recomendado en referencia al ecosistema y su diseño y arquitectura?". Respuesta firme: **resolver la duplicación estructuralmente, no con un test de drift**. El item de toggle-group ES un toggle (mismo press, mismo variant/color/size, misma máquina on/off) — la doctrina "morfo declara DNA" pide declararlo.
**Cambios**:
- `src/uix/morfo/components/toggle-group.ts`: el part `Item` declara `{ attr: 'data-toggle', value: v.literal(''), severity: 'required' }`. Cada item proyecta `data-toggle=""` como atributo de presencia. Cero overhead, captura la identidad estructural.
- `src/uix/eidos/components/toggle-group/context.ts` (nuevo): contexto Svelte tipado (`ToggleGroupEidosCtx` con getters reactivos para `variant` y `size`) — propaga las dos perillas eidos-only del root a cada item.
- `src/uix/eidos/components/toggle-group/toggle-group.svelte`: setea el contexto en el root. Getters mantienen reactividad cuando el prop cambia.
- `src/uix/eidos/components/toggle-group/toggle-group-item.svelte`: lee el contexto y escribe `data-variant={ctx?.variant}` + `data-size={ctx?.size}` en el button. Combinado con `data-toggle` del morfo, el elemento es DOM-equivalente a un `<Toggle>` standalone.
- `src/uix/eidos/components/toggle-group/toggle-group.css`: borradas ~150 líneas (todas las derivaciones base + variant cascade + size cascade + focus-visible / disabled / icon-only duplicados). El CSS conserva SOLO grouping concerns (flex layout, orientation, attached con first/last/border-radius, block, focus z-index, group-level disabled). El header comment se rescribe documentando la nueva división de responsabilidades.
- `src/uix/eidos/components/toggle-group/README.md`: sección "Recipe" + "Decisiones" actualizadas — la entrada "Structural identity" documenta el patrón.
**Por qué TSC v2.2 composition se queda en la mezcla**: la composition sigue siendo necesaria para overridear `--toggle-palette-*` en el item bajo `[data-toggle-group][data-color='X'] [data-toggle-group-item]`. El color NO se propaga via contexto porque la composition ya hace el trabajo a nivel CSS, sin overhead reactivo. Variant/size sí se propagan porque tienen muchos derivados (height, padding, gap, font, radius × 5 sizes; bg, fg, border, hover, on, on-hover × 3 variants) que solo Toggle's recipe ya emite — no había ningún beneficio en mantener cascadas paralelas.
**Verificación**: probe DOM en `/uix/components/toggle-group` capturando computed values en las 12 combinaciones (4 colores × 3 variants) × 2 estados (on/off) — match bit-a-bit con baseline pre-refactor. Ejemplos:
- `affirm/solid` on: `rgb(18,165,148)` = `#12a594` (palette-solid affirm)
- `threat/ghost` on: `rgb(25,17,17)` = `#191111` (palette-track threat)
**Tests**: 163/163 pass en `src/uix/morfo` + `src/uix/eidos`. 4/4 pass en `src/uix/soma/components/toggle-group`. `npm run check`: 16 errores pre-existentes (los mismos del sprint Words), CERO añadidos por esta migración.
**Coste real vs estimado**: el hand-off #5 estimó el cambio como "too big" — incorrecto. Total = 1 entrada en morfo + 1 archivo de contexto (~30 líneas) + 2 ediciones puntuales en wrappers (~5 líneas cada) + 1 rewrite de CSS reduciendo ~150 líneas a ~85. La parte engañosa era pensar que requería tocar Toggle's CSS — no lo hace.
**Doctrina reforzada**: cuando un wrapper componente reusa visualmente otro, la respuesta canónica NO es duplicar el cascade ni extender TSC con un tercer feature. Es declarar la identidad estructural en el morfo del wrapper. Patrón aplicable si emerge button-group, link-group, etc.
Segunda mitad de la sesión (2026-05-28) dedicada a fixes del editor Words por feedback iterativo del usuario. Seis sprints EV-* + cleanup del check.
**Cleanup pre-sprint — `npm run check` 16 → 0**:
- 4 errores `orientation` drift en `soma/components/words-toolbar*` (deuda mía del sprint anterior — removí orientation del type pero no del soma component). Quitado el prop + create() call.
- 12 errores en el sprint Words activo (post-2026-05 F2/F3/COLOR): `onUploadImage` threading en soma `Words.Provider`, `'insert-image'` añadido a 2 Records `WordsToolbarButtonCommandName`, `leafItem` snippet hoisted FUERA de `<SomaWords.Provider>` (snippets dentro de un component element son props en Svelte 5; el snippet era helper local), 5 arrays inline en `words-drawer.svelte` extraídos a constantes typed `as const satisfies readonly { id: WordsCommandName | WordsMark; ... }[]`.
**EV-A — spam de eventos del canvas**:
- `isInsideWordsTool` extendido a los 5 overlays añadidos post-DRAWER: `data-words-drawer`, `data-words-block-handle`, `data-words-block-handle-menu`, `data-words-block-inserter`, `data-words-image-float-bar`. Antes, cualquier click sobre estos overlays se interpretaba como blur EXTERNO → fire `commit-save-content` + re-focus → fire `contact-focus` = 2 sonidos por interacción.
- `contact-focus` target movido de `content` a `provider` en morfo + sema cascade (doctrina: focus es evento de componente, no de body).
**EV-B — toolbar slim**:
- Presets demo (`minimal`/`formatting`/`full` + custom) reducidos a acciones GLOBALES: history (undo/redo) + insert + link + tools + find-replace. Text/block/list/align/table OUT porque el drawer ya los cubre por scope.
**EV-C — drag handle UX**:
- Borrado `e.dataTransfer.setDragImage(hoverBlockEl, 12, 12)`. El browser usa su snapshot por defecto (= el grip button) como ghost — el ghost viaja con el cursor mientras el bar en la gutter queda fijo como ancla visual. Nuevo `data-dragging` + CSS fade del ancla a 0.35 opacity.
**EV-D — inserter al borde inferior**:
- `seam.y` para seams entre bloques cambia de `(a.bottom + b.top) / 2` (midpoint) a `a.bottom` (borde inferior del bloque anterior). Half-open interval `[top, bottom)` para que el píxel exacto del bottom pertenezca al seam, no al bloque.
**EV-E — scroll interno del content**:
- Nuevo token `content-max-block-size-sm/md/lg` (50/60/70vh) en el recipe. `[data-words-content]` gana `max-block-size: var(--_words-content-max-block-size)` + `overflow-y: auto`. El min-block-size baseline queda como starting height para editores vacíos.
- block-handle / block-inserter / image-float-bar: scroll listeners migrados de `window.addEventListener('scroll')` a `document.addEventListener('scroll', { capture: true })`. Razón: scroll events NO burbujean — la versión anterior solo captaba scroll del root document; con la rail/handle/inserter ahora dentro de un content scrollable, había que capturar también scroll DENTRO del content para que las overlays se re-midieran.
**EV-F — paquete de 6 fixes en uno**:
1. Cascade sema de `contact-focus` REMOVIDA → canvas mudo en focus (EV-A fixaba solo el target; la cascade seguía sonando).
2. Rail bg → flat silver (`#d4d4d4`) + borde derecho `#9a9a9a`, sin dot pattern. Tokens `words.rail-bg / rail-border` en el recipe. **Fixed-tone, NOT theme-aware** — la intención es emular un margen físico de cuaderno, debe verse igual en dark/light theme.
4. Family-panel base 12.5 → 16rem · tools 17rem · link 20rem. Sin scroll horizontal.
5. Link popover sin scroll vertical (consecuencia del #4).
6. Engine `insertParagraph` con guarda explícita: paragraph vacío + Enter = no-op; heading vacío + Enter = demote a paragraph (canonical Notion UX).
**EV-G — último pass (parcial)**:
- `runtime.trigger('contact-focus')` comentado en soma. Causa real del sonido residual: la family `contact` en `SEMA_MAP` tiene BASE signature de sound (pitch 800, gain 0.25) que suena aunque no haya cascade per-component. Solución: no disparar el evento. Telemetry vacía para focus, aceptado.
- Drag handle + inserter ahora cubren `data-words-node="list"` y `data-words-node="table"`, no solo `"block"`. Los selectores en `findBlockElement`, `blockUnderCursorY`, `listBlockBoundaries` aceptan los tres tipos para top-level blocks. Listas y tablas tienen drag handle + inserter seam.
- `:hover` rules eliminadas en `[data-words-block-inserter-button]` y `[data-words-block-handle]`. Las overlays son AMBIENT — estado visual cambia solo en data attrs (`data-open`, `data-grabbed`, `data-dragging`). No se iluminan en hover.
**Decisiones arquitectónicas con efecto duradero**:
- **Canvas perceptualmente silencioso** (Words): `contact-focus` no se emite. Si en el futuro un consumidor necesita el evento, descomentar el `runtime.trigger` en `words-provider.svelte.ts > onfocus`.
- **Tokens FIXED-TONE para concepto físico** (Words rail): `--words-rail-bg/border` son hex literal en el recipe, no `var(--color-*)`. Justificación: el concepto visual es "margen de papel" — debe verse igual en todos los themes.
- **Drag-handle predicate cubre 3 tipos de node**: `block`/`list`/`table`. No solo `block`. Cualquier nuevo top-level node type tiene que entrar en la lista.
- **Overlays ambient, no interactive buttons**: gutter overlays (block-handle, block-inserter button) no tienen `:hover`. Estado visual via data attrs solamente.
- **Engine guard sobre bloque vacío**: `insertParagraph` ya no duplica párrafos vacíos. Convierte heading→paragraph en vacío.
**Pendientes documentados en CONTINUE.md**:
- P1 — Drag handle aparece FUERA del rail sobre code blocks (no diagnosticado).
- P1 — Heading inline level change (h1↔h2↔h3) — la UI no permite cambiar nivel inline; el drawer Block panel lo tendría que ofrecer cuando `currentBlock === 'heading'`.
Sprint completing the alignment of the project with the book *Diseñando lo que ocurre*. Nine commits closed P1 #3, P1 #4 and P1 #5 of the audit-codex:
```
504780ad expression field + sema coverage check
2d562f37 11 sema packs + doctrina samples (D.5/D.6/D.7)
826ca2bf D.8 channels scope doctrine
43b15372 picker-shell INTERNAL (P1 #5 closed)
66318c47 tree-view target → branch for emerge events
adf89a08 batch A — soma emission for 7 packs-shipped components
0784cae5 batch B — 8 components with sema scope, no pack
cbf76b66 batch C — drag-drop + virtual-list/grid
7afa7057 23 component READMEs with Sema events sections
```
**Architectural deltas from this sprint:**
- **`Morfo.expression`** new field (`'pack' | 'family-default' | 'delegated' | 'none'`) declares how each morfo with declared events materialises its perceptual signature. Schema-validated; lint enforces coverage. See `src/uix/morfo/types.ts` + `LIBRO_VARIACIONES_Y_EXTENSIONES.md` D.4.
- **`SemaFamily` is now 8** (added `delegate` per book cap. 29). `SEMA_FAMILY_POLICY` carries two axes (`intentRequirement` + `intentGuidance`) — see updated section above.
- **Soma emission cabled** in 18 components (toggle-group, menubar, navigation-menu, dropdown-menu, context-menu, tree-view, tree-grid, listbox, grid-list, table, feed, command, carousel, announce, clipboard, drag-drop, virtual-list, virtual-grid). Pattern: emit from the central state-mutator with `fallbackTarget` resolving the specific element so the cascade matches the right instance.
- **`handle-scroll*` intentionally NOT emitted** in virtual-list / virtual-grid — family.handle activates haptic only; emitting on every pixel of scroll would buzz the device nonstop. Apps that want scroll-feedback wire their own throttled emit. Documented in both READMEs.
- **picker-shell relocated** from `src/uix/morfo/components/` to `src/uix/morfo/internal/` — declares its INTERNAL status by file location. Apps never write `<PickerShell>`; the five composite pickers (date / date-range / time / time-range / color) re-export the shell parts under their own namespace. README added at `src/uix/eidos/components/picker-shell/README.md`.
- **`LIBRO_VARIACIONES_Y_EXTENSIONES.md`** is the authoritative registry for project decisions vs the book canon. Sections D.4 (`expression` field), D.5 (toggles soft-tuned packs), D.6 (menus/trees packs), D.7 (samples doctrine — `SOUND_LIBRARY` = resources, `SOUND_TUNINGS` = canon, packs compose tunings never samples; `sampleOverlay` rejected permanently), D.8 (channels scope — `sound` + `haptic` are the only canonical runtime channels; ARIA structural lives in morfo, ARIA dynamic in soma, visual in eidos — never canonized as channels).
- **23 soma component READMEs** now include a `## Sema events` table with event / family / verb / target / intent / when + pack reference + doctrinal notes for the verb-rename corrections applied across the sprint.
**Verification at hand-off**: `npm run morfo:vocabulary` EXIT 0 (clean). `npx vitest run src/uix/sema src/uix/morfo` 195/195. `npx vitest run src/uix/soma` 419/419. Pre-existing words/* type errors unchanged (separate dev track).
**What's left from the audit-codex**: P2 (34 demos NEEDS-WORK) — explicitly out of scope (full refactor of demos + web routes pending separately).