69 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Branch note (
active-uix): the legacy layerssrc/lib/,src/uix/terra/,src/uix/air/and the oldsrc/routes/test tree were removed during the UIX refactor. The new ecosystem lives insrc/arts/,src/libs/,src/svrs/, andsrc/uix/{active-uix,soma,sema,eidos,morfo}. Demos are being re-authored underweb/routes/from scratch.
Build / Test Commands
npm run dev # Start dev server (routes resolve from web/routes)
npm run build # Production build (static via adapter-static)
npm run test # Run all tests once
npm run test:unit # Run tests in watch mode
npx vitest run src/uix/morfo/compile.test.ts # Run a single test file
npx vitest run -t "describe name" # Run tests matching a pattern
npm run check # Type check with svelte-check
npm run lint # Check formatting (Prettier)
npm run format # Auto-format
Code Style
- Tabs for indentation, single quotes, no trailing commas, 100 char print width (Prettier).
- Comments in English. (Some legacy Spanish comments remain — don't add new ones.)
- Svelte 5 runes mode is enforced globally (
svelte.config.jsdynamicCompileOptions). - DOM event handlers: lowercase (
onclick,onfocus). User callbacks: camelCase (onComplete,onInteractOutside). - State:
$state()without#. Derived:readonly x = $derived.by(...).
Path Aliases
Aliases are kept in sync between svelte.config.js and vite.config.ts. Source
of truth is vite.config.ts (single aliases const reused by both top-level
resolve and the server-test project).
| Alias | Target |
|---|---|
@ |
src/ |
$active-app, $adom, $auth, $bus, $cache, $clipboard, $color, $connection, $format, $http, $langs, $logger, $motion, $orca, $perm, $prefs, $session, $sium, $storage, $timer |
src/arts/{name} |
$libs, $locale, $reactive |
src/libs, src/libs/locale, src/libs/reactive |
$svrs |
src/svrs/ |
$uix, $active-uix, $soma |
src/uix, src/uix/active-uix, src/uix/soma |
Vitest Two-Project Structure
vite.config.ts defines two test projects:
- client: Browser tests via Playwright for
*.svelte.{test,spec}.{js,ts}. - server: Node environment for
*.{test,spec}.{js,ts}(excludes svelte tests).
Architecture
The UIX is layered around a declarative contract (morfo) that the other layers
consume. Each consumer reads the morfo via compileMorfo(morfo), never by
walking the raw declaration.
arts/ → runtime artifacts: active-app, adom, motion, perm, prefs, ...
libs/ → pure helpers (zero-dep): reactive, days, dom, locale, ...
svrs/ → server-authoritative engines
uix/morfo → declarative contract: parts, data-attrs, ARIA, keyboard,
events with semantic family/intent. Compiler at compile.ts
emits CompiledMorfo (cached by WeakMap).
uix/sema → semantic engine + channels (visual / sound / vibra).
The visual channel writes data-event* during a hold window.
State attrs (data-state, data-disabled, ...) are NEVER
touched by sema — they belong to the morfo runtime.
uix/soma → headless component layer. Each provider consumes morfo +
sema via `runtime.trigger(eventName)` which sequences:
prewrite → semantic.emit → handler → effect-driven state attrs.
uix/eidos → CSS layer that selects against the data-attrs the morfo
contract promises. lint.ts validates a CSS file against
compileMorfo(morfo).contracts.cssSelectors.
uix/active-uix → composition root. Two boot modes:
- createActiveUix(options) — standalone (owns services)
- attachActiveUix(activeApp) — attach to external app
Motion lives in arts/motion (EngineMotion, alias $motion), exposed as
uix.motion and consumed by BOTH soma (Presence.motion → motion.run) and
eidos (CSS generation + preset registration). It's an art — not part of eidos —
so soma can drive JS motion (spring) without a soma→eidos dependency. The DOM
arrives injected via a structural MotionDom port (adom satisfies it).
Two-moment model + F1–F7: src/arts/motion/README.md +
src/uix/eidos/eidos-motion.md.
morfo (src/uix/morfo/)
Single source of truth for a component. A Morfo declares:
parts[]— kebab + archetype +data+aria+ optionalkeyboardevents[]— name +targetpartRef +semantic+ optionalprewrite+commitsfocus— initial / trap / return / restore (for overlay components)
compileMorfo(morfo) returns a CompiledMorfo with pre-resolved AttrPlan[],
KeyboardPlan[], ActionPlan[], and contracts.cssSelectors (the closed set
of selectors eidos is allowed to use). Cached by morfo identity.
eidos linter
scripts/eidos-lint.ts <component> and scripts/eidos-lint-all.ts classify every
[data-*] selector in eidos CSS as morfo-backed / eidos-only / invalid. Use
this to detect drift between the morfo contract and the eidos rules.
Key Conventions
- Never modify the morfo declaration files without updating the eidos selectors and provider sources that depend on them.
- Read before acting. When told to read a file, read it. Don't paraphrase.
- Verify before reporting done. Run
npm run checkand the relevant vitest scope. UI claims need a browser check; if you can't run a browser, say so. - Compose existing components; flag gaps. When building a component (or its
demo, or any UI), compose the framework's existing soma/eidos components
(
Button,Field,Popover,Icon, …) — never re-implement a primitive inline. If a needed component is missing, flag the gap so it gets built as a reusable component, never reinvented ad-hoc. Detail:soma/COMPONENT_GUIDE.md§4. - No backward-compat shims when relocating code. Update consumers and delete the old path; don't leave a re-export.
- No re-export façades between layers (e.g. soma must not re-export dias —
consume
$libs/daysdirectly). - Provider naming: child providers reference the parent as
provider, neverroot. The exported component is<Xxx.Provider>.createAttrsstill uses the part kebab'provider'internally. - Data-attr naming:
data-{component}(provider) anddata-{component}-{kebab}(sub-parts). Neverdata-soma-*. The compiler emits these viacompiled.parts.attrs; runtime queries match exactly. - CSS token naming (eidos recipes): bare-prefixed by component, never
by layer. Public tokens:
--{component}-…(e.g.--dialog-content-bg,--toggle-radius-md). Internal recipe tokens:--_{component}-…. Never--eidos-…,--air-…,--terra-…,--soma-…. Migratingair-baselines means stripping theair-prefix, not replacing it. - DOM helpers:
$adomis the single public surface for DOM-related functionality at component / soma level. It re-exports all of$libs/domwholesale, plus the reactivecreateActiveDomruntime and stateful primitives (BodyScrollLock,DOMContext,RovingFocusGroup). Components and soma NEVER import from$libs/domdirectly — consume$adom. Onlyarts/adom/*(the implementation),arts/frontend(peer service fallback), and tests of$libs/dommay reach into it. This includes responsive types (ResponsiveProp<T>,Breakpoint,Breakpoints) — they travel with the runtime, so they live in$adom. - Iframe / popup correctness: when you have a
uix.dom, preferuix.dom.getDocument(node?)anduix.dom.getWindow(node?)over the bare functions of the same name. The instance methods respect the active-dom'stargetWindow; the bare functions fall back to the globaldocument/window, which is wrong in iframe / popup / happy-dom-test contexts.
Critical Rules
- NEVER delete files without explicit instruction ("delete", "remove", "borra", "elimina"). If ambiguous, ASK first.
- NEVER create fake translators / mocked services in tests for components.
Use real instances composed via
createActiveUixorattachActiveUix. - NEVER skip hooks (
--no-verify) or bypass signing unless explicitly asked. If a hook fails, fix the root cause. $reactivesurvives,$libdoes not. The$reactivealias points to the currentsrc/libs/reactive; the singular$libalias was removed in the cleanup phase and any code that resurfaces it is a regression.
Reference Documents
Start here: docs/README.md — the documentation entry point.
It maps the whole corpus (the strata, the reading order, and where every doc
lives) with task-oriented shortcuts ("I want to…"). New sessions and agents
should read it first to orient.
Architecture overview and motivations: src/uix/active_architecture.md.
Per-layer references:
src/uix/morfo/README.mdsrc/uix/sema/README.mdsrc/uix/soma/SOMA_ARCHITECTURE.md+src/uix/soma/COMPONENT_GUIDE.mdsrc/uix/eidos/README.md+src/uix/eidos/components/README.md
The canonical guide for sema vocabulary, morfo event shape, color
tokens, persistence and a11ySemantic lives in
src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md.
That document is authoritative — when in doubt about families, intents,
verbs, color subsets per component, or holds, that's the source.
Sema cascade selectors must use the typed builder
Cascade rules in src/uix/sema/components/*.ts MUST construct their
selector via semaSelector(morfo, partKebab, matchers?) from
$uix/morfo. Never hand-write selector strings that target morfo-
emitted attrs ([data-{component}], [data-{component}-{part}],
[data-event="..."]). The helper:
- Compile-error if
partKebabdoesn't exist onmorfo.parts. - Compile-error if
matchers.eventNamedoesn't matchmorfo.events. - Type-checks
eventFamily/eventIntentagainst the canonical sema unions. - Output is the same CSS string the cascade rule needs — zero runtime cost beyond concatenation.
Hand-written selectors in cascade rules are an architecture violation: when morfo renames a part or event, the strings go stale silently. The helper wires the cascade to the morfo's contract so renames break at the type level.
// good
import { semaSelector } from '$uix/morfo'
import { dialogMorfo } from '$uix/morfo/components/dialog'
{
selector: semaSelector(dialogMorfo, 'content', { eventFamily: 'commit' }),
haptic: { kind: 'tap' }
}
// bad — string drifts silently if morfo renames
{
selector: '[data-dialog-content][data-event-family="commit"]',
haptic: { kind: 'tap' }
}
Sema: intent policy per family
Per book canon there are 8 families, encoded in SEMA_FAMILY_POLICY at
src/uix/sema/types.ts. Each family declares two independent axes:
{
contact: { intentRequirement: 'optional', intentGuidance: 'discouraged' },
commit: { intentRequirement: 'required', intentGuidance: 'expected' },
signal: { intentRequirement: 'required', intentGuidance: 'expected' },
handle: { intentRequirement: 'optional', intentGuidance: 'contextual' },
emerge: { intentRequirement: 'optional', intentGuidance: 'contextual' },
shift: { intentRequirement: 'optional', intentGuidance: 'contextual' },
sustain: { intentRequirement: 'optional', intentGuidance: 'contextual' },
delegate: { intentRequirement: 'optional', intentGuidance: 'contextual' }
}
intentRequirement('required' | 'optional' | 'forbidden') — compile-time gate.required→intentREQUIRED inMorfoEventSemantic(discriminated union).forbiddenis 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 channels (time · motion · presence · depth · shape · color · sound · haptic); the framework SPLITS them by owner — and this is the single canonical narrative:
- Sema executes 2 runtime channels —
sound+haptic, the ONLY two declared inSemaChannelSignatures— plus projects thevisualmeta-channel (it stampsdata-event-*during the hold).motion/color/presencewere explicitly removed from sema (sema-map.ts); the engine knows no DOM/CSS. - Eidos materializes the visual channels (motion / presence / depth / shape /
color) by reacting in CSS to those
data-event-*+ the morfo'sdata-state/data-intent. Eidos is the sole visual owner.
Apps add more sema channels (a11y, voice, …) via TypeScript declaration
merging of SemaChannelSignatures.
The semantic tokens are data-event-*: the engine stamps data-event,
data-event-family, data-event-intent, data-event-phase, data-event-id
on signal.target BEFORE the cascade resolves. Cascade rules use CSS
selectors that target those tokens directly — same surface that eidos CSS
already reads. Engine owns stamp/unstamp; VisualChannel only owns the hold.
Cascade rules are flat (no overrides: { eventLabel: ... } middle
layer): a single selector + per-channel deltas, like a CSS rule.
{
selector: '[data-dialog-content][data-event-intent="threat"]',
sound: { sampleUrl: '/sounds/dialog-fail.wav' },
haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] }
}
Resolution cascade (each layer overrides the previous):
1. SEMA_MAP.families[F].base — per-channel signatures
2. SEMA_MAP.intents[I].deltas — valenced families only;
numbers ADD (modificadores)
3. signal.overrides + signal.channels — per-event morfo overrides;
numbers REPLACE
4. engineOpts.overrides.runtime — path-based globals; REPLACE
5a. engineOpts.components[].cascade — per-component packs
(sema/components/{name}.ts)
5b. engineOpts.overrides.cascade — app-level rules; appended
AFTER packs so they win
on equal specificity
Per-component packs live in src/uix/sema/components/{name}.ts —
symmetric to morfo / soma / eidos. Each exports a Sema with
cascade rules + optional preloadSamples (WAVs decoded at boot).
Override semantics — numbers:
- intent.deltas (capa 2): numbers ADD (modificadores compositivos).
- overrides (capas 4, 5, 6): numbers REPLACE (
color: red, not "add red"). - Use
{ op: 'add' }/{ op: 'multiply' }/{ op: 'replace' }for explicit semantics either way.
intent.deltas owns the perceptual signature of the intent — the
sound primitives pitch, gain, contour (and the haptic intensity
when shaped per-intent) come from capa 2. Cascade rules (capas 6a/6b)
MUST NOT override these primitives — doing so flattens the perceptual
difference between risk / threat / loss (or affirm / fulfill).
Cascade rules add character (haptic.kind, haptic.pattern,
sound.sampleUrl, motion.easing, presence.backdrop) — not the
intent's evaluative profile. If a per-component cascade rule needs to
shift a numeric primitive (e.g. close-* in emerge goes descending),
use { op: 'add', value: -150 } so it composes on top of the intent
rather than erasing it.
HapticChannel (V1, real — Vibration API) is opt-in via
new EngineSemantic({ haptic: true }). Sound and haptic both default
off because they have audible / tactile side effects.
Canonical sources: src/uix/sema/sema-map.ts (SEMA_MAP value +
SemaChannelSignatures registry + Sema), src/uix/sema/resolver.ts
(cascade implementation + CSS specificity), src/uix/sema/stamp.ts
(stamp/unstamp), src/uix/sema/components/*.ts (per-component packs),
src/uix/sema/README.md.
Eidos drift defense — types over lint
Selector drift between morfo's emitted attrs and the layers that style / react to them (eidos CSS, sema cascade) is defended at compile time via typed builders, not at lint time.
- Sema cascade rules (
sema/components/*.ts) consumesemaSelector(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
.cssrecipes are still raw CSS today — there is no CSS-side typed builder. For those,scripts/eidos-lint.tsremains as an opt-in safety net that classifies each[data-*]selector asmorfo-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:losserapurple≡ primary → mapeado aplum(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/…) aButton.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 desvelte-check→ 0 errores.
API runtime — theme builder (c4d2e34d):
- Nuevo
src/uix/eidos/lib/build-scheme.ts(PURO):buildScheme(seed, opts)componederiveScheme+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 bloqueuix-eidos-schemeDESPUÉS del de tema (gana cascada), RE-DERIVA al cambiar de modo (sigue light/dark). DevuelveBuildSchemeResult.opts:variant(tonal|vibrant|monochrome) +temper(cohesión de intents, mantiene hue) +overridesper-rol +selector. Exportado de$uix/eidos. Demo/temas/colordogfoodabuildScheme.
Wide-gamut OKLCH (909ab7f9 output + 0bb03559 generador):
- Output OKLCH-nativo default-on (RFC §7 estrategia A):
render-css > appendColorScaleDeclarationsemite por cada paso de paleta el hex (fallback) + un hermanooklch()que gana donde se soporta. SIN flag. - Generador wide-gamut-TRUE:
buildScheme/applyColorSchemeretienen el OKLCH raw degenerateScale(sin clamp) →result.wideGamut+result.roles[].stepsOklch. NuevoschemeDeclarations(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 ELIMINAbox-shadow→ el focus ring (--focus-ring, box-shadow) desaparecía. Fix: foundation emite siemprerenderForcedColorsBlock→@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
border6 (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.
Decisiones con efecto duradero:
- Capas:
uix.color= matemática ·eidos/lib/build-scheme= composición (pura) ·ActiveEidos= aplicación DOM. No mezclar. - Wide-gamut default-on, sin flag (estrategia A); fallback hex → sRGB idéntico, no-disruptivo.
- 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 usanvar(--focus-ring)(box-shadow) aoutlinepropio 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.
Docs: COLOR_ENGINE_RFC.md §6.2/§7, THEMING.md §26/§27/§28 + TOC, README.md tabla, THEMING_AUDIT_2026-06-01.md P3-1/2/3 resueltos.
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: eldata-statetardaba 244ms en cambiar tras el click. Causa: el provider del checkbox fija el estado en el HANDLER delruntime.trigger, y el morfo declarabasequence: 'pre'→ el runtime haceawait 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 checkmarkstroke-duration220ms 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.
- DOCTRINA NUEVA: un control cuyo estado se fija en el HANDLER del trigger DEBE usar
-
P3-11 surface ladder (
76e18772). Lightoverlayeraneutral-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:rootdel tema; aditivo, gated, estrictamente más fuerte. THEMING §28. -
Cierre del backlog del engine (
afa15aea).THEMING_AUDITP3 todo fixed-or-decided:- ✅ P3-5 guard
appendScaledMetricDeclarations(solo escala números finitos ≠0; keywords/var()/calc()verbatim — evitacalc(auto * …)). - ✅ P3-6 confirmado ya resuelto (dispose vía
dom, sindocumentdirecto). - ✅ P3-8 index ya NO re-exporta los render-fns crudos (API pública = clase
ActiveEidos;./lib/render-csspara 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.
- ✅ P3-5 guard
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.
Session hand-off — 2026-05-27 (persistence + a11ySemantic + polymorphic)
Sprint cerrando los 3 ítems diferidos del hand-off 2026-05-08: persistence + holds-by-intent (libro §6), a11ySemantic per event (libro §9), polymorphic events (libro §5.3). Detalle completo en src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md D.9 / D.10 / D.11.
Persistence (libro §6.1) — separa hold (duración perceptible mínima) de persistence (lifecycle real):
- Nuevo tipo
SignalPersistence = 'transient' | 'untilAction' | 'untilFix' | 'stateBound'ensrc/uix/sema/types.ts, propagado aSemanticSignal,MorfoEventSemanticyTriggerOptions. - Tabla canónica
SEMA_HOLDS_BY_INTENTensrc/uix/sema/holds.ts(referencia, NO auto-aplicada — default conservador'transient'). EngineSemantic.emit()ahora devuelvePromise<string>(el id). Parapersistence !== 'transient'mantiene la proyección viva pasado el hold; exponeengine.clear(id),engine.clearTarget(target),engine.hasActive(id).SomaRuntime.trigger()devuelveTriggerResult { id?, persistence? }. Exponeruntime.clearSignal(id),runtime.clearTarget(target),runtime.partRef(part)(helper que devuelve el HTMLElement registrado de un part — útil para que providers limpien sin trackear ids).- 6 morfos declaran
persistenceexplícita:announce.signal-alert(untilAction),dialog/drawer.close-after-fail(transient explícito),file-upload.signal-warn-reject+form.signal-warn-invalid(untilFix),password-field.signal-notify-caps-state(stateBound). - 3 providers cabledados con
clearTarget(provider): form (limpia al validar / reset), file-upload (limpia antes de cada nueva ronda de accept/reject + en remove / clear), password-field (limpia indicator al apagarse caps lock).
a11ySemantic (libro §9.1):
- Nuevo
MorfoA11ySemanticinterface ensrc/uix/morfo/types.ts:requiresPersistentTrace?,requiresLiveRegion?,requiresFocusMove?,keyboardEquivalent?,reducedMotionFallback?: 'state' | 'text' | 'focus' | 'none'. Añadido comoa11ySemantic?opcional enMorfoEvent. Pasa por compile aActionPlan.a11ySemantic. - Helper de reduced-motion en
src/arts/adom/reduced-motion.svelte.ts(paralelo aviewport.svelte.ts):ReducedMotionTrackerreactivo del media query con SSR-safe fallback. Expuesto enActiveDom.prefersReducedMotion.matches. ActiveUix.announce(message, priority?, timeout?): live region compartida lazy-creada víadom.writeNode. No depende de soma. Polite/assertive son regiones separadas, cleanup endispose().Soma.runtime()auto-wiressources.announce = (m, p) => uix.announce(m, p). Tests usan fakeannounce.SomaRuntime.trigger()honra a11ySemantic después del emit: live region (conopts.message), focus move, reduced-motion fallback (incluye forzarchannels: []cuando fallback = 'state').- 6 morfos anotados (los mismos consumidores).
Polymorphic events (libro §5.3) — ADITIVO sobre el shape concreto:
- Morfo declara
family+intent+verbcomo default. Si añadeallowedFamilies: readonly SemaFamily[], los providers pueden override la family enruntime.trigger(name, { semantic: { family, intent?, verb? } }). El default family del morfo es IMPLÍCITAMENTE allowed. isPolymorphicSemantic(semantic)helper exportado de$uix/morfo.SomaRuntimePolymorphicErrorcuando 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 accedenevent.semantic.familydirectamente.
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— DONECaller messages para announce— DONE (form / file-upload / password-field / dialog / drawer)Demo polymorphic real— DONE (Dialog refactorizado a polymorphic close)
Session hand-off — 2026-05-27 #2 (messages + persistence ext + Dialog polymorphic)
Segunda mitad del sprint cerrando el backlog del hand-off anterior. 3 grupos de cambios.
M — Caller messages para live region:
Cada provider que dispara un evento con a11ySemantic.requiresLiveRegion ahora pasa opts.message con texto localizado. La live region (ya cabledada via Soma.runtime() → sources.announce) finalmente anuncia algo:
form-provider:signal-warn-invalid→ "1 form error..." / "{N} form errors..." (count =Object.keys(form.issues).length). UsaFORM_LANGS.ERROR_SUMMARY_*.file-upload-provider:signal-warn-reject→ "1 file was rejected" / "{N} files were rejected". Nuevas entradasFILE_UPLOAD_LANGS.REJECT_SUMMARY_SINGLE/MULTI+ langs catalog.password-field-provider:signal-notify-caps-state→ "Caps Lock is on" (usaPASSWORD_FIELD_LANGS.CAPS_WARNINGque ya existía).dialog-provider.dismissWith(action, { message }): signatura aceptandomessageopcional, forwarded al trigger.drawer-provider.dismissWith(action, { message }): igual que dialog.
P — Persistence extendido a tags-input + textarea:
Misma doctrina §6.2 — signal+risk = untilFix:
tags-input.signal-warn-reject→ persistence:untilFix+ a11ySemantic + provider clear viaclearTarget(input). Nueva entradatags-input.reject-warningen langs catalog. Provider gana método privadoemitWarnReject(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 entradatextarea.overflow-warningen langs catalog. Provider emite SOLO en transición INTO overflow (no en cada keystroke al cap) — eluntilFixproyecta 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 eventocloseconfamily: 'emerge', verb: 'close', allowedFamilies: ['emerge', 'commit', 'signal']. SINprewrite(el provider escribedata-last-actionimperativamente). - Validador relajado: el invariante "cada
values[]debe ser prewritten por algún event" cayó al sentido único "cada prewrite con value debe estar envalues[]". Razón: con polymorphism los values pueden ser escritos imperativamente. El comentario enschema.tsexplica. - Provider (
dialog-provider.svelte.ts):DISMISS_CAUSESmapa de acciones a{ lastAction, semantic }.dismissWith(action, opts)traduce adom.apply+runtime.trigger('close', { semantic, ... }).triggerClosees privado ahora. - Cascade sema (
sema/components/dialog.ts): selectoreseventName: 'close-dismiss-outside'→eventName: 'close', state: { attr: 'data-last-action', value: 'dismissed-outside' }.eventNamePrefix: 'close-'→eventName: 'close', eventFamily: 'emerge'. - Eidos CSS (
dialog.css): NO cambió — ya leíadata-last-actionpara tintar, no nombres de evento. - API pública preservada: consumidores externos no notan diferencia (
dismissWithmantiene 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 converb: 'warn'que podrían también beneficiarse de persistence untilFix (revisar caso por caso).
Session hand-off — 2026-05-27 #3 (Drawer + Popover polymorphic close)
Tercera tanda del sprint — completa el rollout del patrón polymorphic close (book §5.3) a los dos componentes hermanos de Dialog. Mismo refactor aplicado de forma sistemática.
Drawer:
- Morfo: 5 close-* → 1 polymorphic
closeconfamily: 'emerge', allowedFamilies: ['emerge', 'commit', 'signal'], sin prewrite. Drag events (drag-start / drag-progress / drag-end / resize) intactos. - Provider:
DISMISS_CAUSESmap +dismissWith(action, opts?)traduce adom.apply(data-last-action)+runtime.trigger('close', { semantic }).triggerCloseprivado. 4 callsites internos migrados adismissWith(Content escape, Content keydown escape, Overlay click-outside, Close button). - Sema cascade (
sema/components/drawer.ts): selectoreventNamePrefix: 'close-'→eventName: 'close', eventFamily: 'emerge'. - Test (
drawer-provider.svelte.test.ts):triggerClose('close-dismiss', ...)→dismissWith('dismiss'); expect name='close'.
Popover:
- Morfo: igual refactor. 5 close-* → 1 polymorphic
close. - Provider: igual patrón con DISMISS_CAUSES + dismissWith. Internal callsites (5 lugares: scheduleHoverClose, trigger toggle click, trigger toggle keydown, escape, outside) migrados a
dismissWith. Close button: usa el propactiontraducido 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 usabadrawerMorfo→ ahora usacolorPickerMorfo.runtime.svelte.test.ts: prewrite test usabadrawerMorfo→ ahora usacolorPickerMorfo.
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
prewritedeclarativo + sirven como fixtures de tests. - API pública intacta —
dismissWithmantiene signatura en los tres overlays. - Eidos CSS no tocó (ya leía
data-last-action).
Tests: 680/680 pass en src/uix/sema + morfo + soma + src/arts/adom/test. npm run check: 1 error pre-existente.
Pendientes a futuro (picker family cubierto en hand-off 2026-05-27 #4):
Picker family refactor— DONE- Otros
signal-warn-*no cubiertos (textarea / tags-input ya hechos; revisar caso por caso si emergen más).
Session hand-off — 2026-05-27 #4 (Picker family polymorphic close)
Cuarta tanda — completa el rollout polymorphic close al picker family (5 componentes). Más decoupling de tests.
Refactor de los 5 pickers:
color-picker: 4 close-* → 1 polymorphicclosedate-picker: 4 close-* → 1 polymorphicclosedate-range-picker: 4 close-* (incl.close-range-commit) → 1 polymorphicclosetime-picker: 4 close-* → 1 polymorphicclosetime-range-picker: 4 close-* → 1 polymorphicclose
Cada morfo: family: 'emerge', verb: 'close', target: v.partRef('calendar'|'clock'|'content'), persistence: 'transient', allowedFamilies: ['emerge', 'commit', 'signal'].
Hallazgo importante: los picker providers NO disparan los close events. Solo togglean opts.open = false. Los eventos estaban declarados pero inertes — su único consumidor era el schema validator y los compiler tests. El refactor es alineación doctrinal, no de comportamiento.
Sema cascade: solo color-picker tiene un sema pack y no referenciaba close-* (solo handle-*). Cero updates necesarios en cascades.
Test fixtures decoupling:
- Nuevo
src/uix/morfo/test-fixtures.tsconprewriteFixtureMorfosintético. compile.test.ts: migrado decolorPickerMorfo→prewriteFixtureMorfo.runtime.svelte.test.ts: igual migración.- Beneficio: los tests validan el contrato del compiler/runtime sin depender del catálogo de componentes. Futuros refactors del catálogo no rompen estos tests.
Estado final del rollout polymorphic (3 sprints combinados):
| Componente | Polymorphic morfo | Provider cabledado |
|---|---|---|
| Dialog | ✓ | ✓ (dismissWith → runtime.trigger) |
| Drawer | ✓ | ✓ |
| Popover | ✓ | ✓ |
| color-picker | ✓ | ✗ (toggle opts.open) |
| date-picker | ✓ | ✗ |
| date-range-picker | ✓ | ✗ |
| time-picker | ✓ | ✗ |
| time-range-picker | ✓ | ✗ |
Único morfo con shape pre-polymorphic restante: el fixture sintético prewriteFixtureMorfo — vivo sólo para tests.
Tests: 680/680 pass. npm run check: 1 error pre-existente.
Pendiente a futuro:
- Cabledar
runtime.trigger('close', { semantic })en los providers picker cuando justifique disparar el evento (telemetría, sound on commit picker, etc.).
Session hand-off — 2026-05-27 #5 (TSC v2.2 + cobertura universal de theming)
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.
Extensiones TSC v2.2 (src/uix/eidos/lib/config-types.ts + lib/render-css.ts + lib/config.ts):
- Multi-part scope —
RecipeTokenMultiDeclaration.parts?: readonly string[]. Cuando un token tieneparts: ['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 dondedata-colorvive 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-groupmodifica--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-*conparts: ['trigger', 'content']. (_accent-solidno 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 inlinematrix()que recorre los 8 colores × 3 variants._badge-*usaparts: ['badge']para retargetear a[data-avatar-badge]. Reemplaza ~150 declarations CSS por las mismas declarations, generadas desde TS.toggle-group: bloquecomposition: { toggle: { targetSelector: '[data-toggle-group-item]', tokens: { 'palette-*': {...} } } }con 8 palette tokens × 4 colors. Reemplaza los 4 bloques CSS per-color.
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.mdtabla 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 expressionsentoggle-group.cssson la única duplicación entre recipes/base.ts y CSS. Si Toggle's derivations cambian, hay que actualizar ambos.
Session hand-off — 2026-05-27 #6 (variants canon — EIDOS_VARIANTS + lint)
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 constanteEIDOS_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 constanteEIDOS_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 decomponents/{c}/types.tsvia 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 aEIDOS_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).
Session hand-off — 2026-05-28 (toggle-group structural identity)
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 partItemdeclara{ attr: 'data-toggle', value: v.literal(''), severity: 'required' }. Cada item proyectadata-toggle=""como atributo de presencia. Cero overhead, captura la identidad estructural.src/uix/eidos/components/toggle-group/context.ts(nuevo): contexto Svelte tipado (ToggleGroupEidosCtxcon getters reactivos paravariantysize) — 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 escribedata-variant={ctx?.variant}+data-size={ctx?.size}en el button. Combinado condata-toggledel 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/solidon:rgb(18,165,148)=#12a594(palette-solid affirm)risk/outlineon:color(srgb 0.2 0.118 0.043)≈#331e0b(color-mix outline on-bg)threat/ghoston: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.
Session hand-off — 2026-05-28 #2 (Words editor — 6 sprints UX)
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
orientationdrift ensoma/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):
onUploadImagethreading en somaWords.Provider,'insert-image'añadido a 2 RecordsWordsToolbarButtonCommandName,leafItemsnippet hoisted FUERA de<SomaWords.Provider>(snippets dentro de un component element son props en Svelte 5; el snippet era helper local), 5 arrays inline enwords-drawer.svelteextraídos a constantes typedas const satisfies readonly { id: WordsCommandName | WordsMark; ... }[].
EV-A — spam de eventos del canvas:
isInsideWordsToolextendido 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 → firecommit-save-content+ re-focus → firecontact-focus= 2 sonidos por interacción.contact-focustarget movido decontentaprovideren 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. Nuevodata-dragging+ CSS fade del ancla a 0.35 opacity.
EV-D — inserter al borde inferior:
seam.ypara seams entre bloques cambia de(a.bottom + b.top) / 2(midpoint) aa.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]ganamax-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')adocument.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:
- Cascade sema de
contact-focusREMOVIDA → canvas mudo en focus (EV-A fixaba solo el target; la cascade seguía sonando). - Rail bg → flat silver (
#d4d4d4) + borde derecho#9a9a9a, sin dot pattern. Tokenswords.rail-bg / rail-borderen 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. - Toolbar:
undo+redoDIRECTOS (no popover);insert-menueliminado. Resultado: 5 items. - Family-panel base 12.5 → 16rem · tools 17rem · link 20rem. Sin scroll horizontal.
- Link popover sin scroll vertical (consecuencia del #4).
- Engine
insertParagraphcon 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 familycontactenSEMA_MAPtiene 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"ydata-words-node="table", no solo"block". Los selectores enfindBlockElement,blockUnderCursorY,listBlockBoundariesaceptan los tres tipos para top-level blocks. Listas y tablas tienen drag handle + inserter seam. :hoverrules 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-focusno se emite. Si en el futuro un consumidor necesita el evento, descomentar elruntime.triggerenwords-provider.svelte.ts > onfocus. - Tokens FIXED-TONE para concepto físico (Words rail):
--words-rail-bg/borderson hex literal en el recipe, novar(--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 soloblock. 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:
insertParagraphya 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'. - POLISH-1b / POLISH-3 / WORDS-F2.3-F2.6 / WORDS-DRAGDROP-SOMA — heredados.
Tests al cierre: 467/467 pass en morfo + sema + eidos + soma/components/words. 85/85 engine pass. npm run check: 0 errors.
Session hand-off — 2026-05-09 (selector discipline + lint reframing)
- Typed selector builder applied in
src/uix/sema/components/dialog.ts. Every cascade rule now constructs its selector viasemaSelector(dialogMorfo, 'content', matchers?)instead of hand-writing strings. The helper lives insrc/uix/morfo/selectors.tsand is exported from$uix/morfo. - Eidos-lint reframed from "architectural drift mechanism" to "opt-in safety net for plain CSS recipes". The architectural mechanism is compile-time typing. The lint script + scripts continue to work unchanged but are no longer the doctrinal source of safety.
- Intent.deltas signature ownership documented — cascade rules MUST
NOT override
pitch/gain/contouronsound, because doing so collapses the per-intent perceptual difference. Captured both inline in the dialog cascade (NOTE comment on the intent block) and in the "Override semantics — numbers" rule above. - Documentation updated:
src/uix/morfo/README.md— new "Typed selector builder —semaSelector" section.src/uix/eidos/README.md— replaced "Linter" section with "Defensa contra drift de selectores" framing the two-tier defense (compile-time builder for TS/Svelte, opt-in lint for plain CSS).src/uix/sema/README.md— already documented the builder + the cascade discipline + the SEMA_FAMILY_POLICY shape (from the prior refactor).
Verification at hand-off: npm run check reports 0 errors, 2
pre-existing Svelte warnings unrelated to this work. npm run test
reports 1883/1883 tests green across 154 files (~22s).
Session hand-off — 2026-05-08 (post-canon alignment)
The codebase has been aligned to the canonical guide. Most recent architectural move:
- Runtime relocated + renamed — what was
MorfoRuntimeinsrc/uix/morfo/runtime.svelte.tsis nowSomaRuntimeinsrc/uix/soma/runtime.svelte.ts. Reason: morfo must remain pure declarative TypeScript (DNA); the runtime imports Svelte runes,$adom,$uix/sema, and$libs/reactive— those are soma's territory. Per the doctrine "morfo declares, soma executes" the runtime now lives where it conceptually belongs. Active-uix'suix.runtime(morfo, sources)factory stays unchanged; it importscreateSomaRuntimefrom$soma. - Sema is now genuinely ornamental —
ActiveUix.semanticreturnsEngineSemantic | undefined(no longer throws).SomaRuntime.trigger()skips theemitstep and the target-resolution check when no engine is present. Components stay functional in SSR / headless tests / audio-disabled environments.
The earlier swept phases (still in effect):
- Sema verbs (
src/uix/sema/verbs.ts): restructured to family-keyedRecord.select/toggle/acknowledgemoved tocommit;editremoved fromhandle(now expressed asshift.enter-mode). New verbs added per the canon (tap, focus, release, complete, restore, remind, route, step, syncing, etc.).validateEventNamerecognises both{verb}-{variant}and{family}-{verb}shapes. - Morfo event shape (
src/uix/morfo/types.ts):targetmoved insidesemantic; added optionalverb(advisory, validated againstSEMA_VERBS[family]) andsequence: 'pre' | 'coincident' | 'post'. All 22 events across toggle/toast/popover/drawer/dialog refactored to the new shape. - Color tokens (
src/uix/eidos/themes/base/{light,dark}.css): doctrinal 8 tokens —primary, secondary, neutral, affirm, fulfill, risk, threat, loss. Renamedsuccess → fulfill,warning → risk,danger → threat(same hex). New primitives forsecondary(slate-blue),affirm(teal-mint, low activation),loss(deep violet-grave, posterior).infopalette deleted entirely. Legacy aliases in_static.cssdeleted (clean cut, no transition). 29 consumers (recipe CSS + tokens) migrated to doctrinal names.
Earlier in the session:
- Toggle is the eidos pilot (
src/uix/eidos/components/toggle/): recipe + Svelte wrapper + types + index + README. The pattern is documented insrc/uix/eidos/components/README.md. - Eidos has a shared lib at
src/uix/eidos/lib/types.ts. First type isSize('xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'full'). Components narrow withExtract<Size, 'sm' | 'md' | 'lg'>per the per-component-subset doctrine. - No
Eidosprefix on types. The path$uix/eidos/components/{x}already conveys layer. Public types areToggleProps,ToggleVariant,ToggleSize. - API doctrine: soma stays compound (
Toggle.Provider); eidos exports bothdefaultandProviderfor single-part components so<Toggle>works for the ergonomic case while<Toggle.Provider>stays available. - SoundChannel eager-init landed in
src/uix/sema/chans/sound.tsto fix the autoplay race: the AudioContext is created + resumed synchronously on the first user gesture (capture-phase listener ondocument). - Demo page (
web/routes/toggle/+page.svelte) has the live preview rendered ALWAYS above the tablist (so Sema-tab Play buttons can fire on the real toggle) and the motion preview amplifies scale ×8 visually only — doctrinal values stay in the<dl>.
Mandatory checklist before any eidos component migration:
- Recover the air baseline from git:
git ls-tree -r 0a391408^ --name-only | Select-String "air.*{name}" git show 0a391408^:src/uix/air/components/{name}/types.ts git show 0a391408^:src/uix/air/components/{name}/{name}.svelte - Inventory air's props, slots, effects, perceptual emits.
- Build a comparison table: feature × (air had it | canon allows it |
eidos exposes it). Flag each gap as
regression,enhanced,dropped-by-canon, ornuevo. - Present the table BEFORE coding. Get explicit per-gap decision.
- Only then write the wrapper.
Switch was migrated WITHOUT this discipline and shipped a regression
(no ResponsiveProp<Size> for breakpoint-aware sizing). Don't repeat.
Next concrete steps:
- Migrate
collapsible → dialog → drawer → popover → toast → avatarto the eidos wrapper pattern. For each: walk the air-baseline checklist above first. Toggle / switch are templates for the subdirectory shape, but the per-component prop surface must come from air's recovery, not from copying toggle's props. - Persistence + holds-by-intent (guide §6) — defer until the
first concrete
signal.warn/signal.alertconsumer appears. Today every signal istransientwith a numeric hold. Need to addpersistence: 'transient' | 'untilAction' | 'untilFix' | 'stateBound'toSemanticSignal+ the holds-by-intent table per the canon. - a11ySemantic per event (guide §9) — defer until there's a reduced-motion runtime helper + live-region helper to consume the contract.
- Polymorphic events (guide §5.3,
allowedFamilies+defaultSemantic) — defer; no current component needs polymorphism. - The docs site's topbar prefs (sound mute toggle, theme switcher)
are wired up but the sound mute does NOT yet drive
SoundChannel.masterGain. Wiring belongs on layout.svelte's$effect.
Baseline npm run check shows 39 pre-existing errors in test files
unrelated to this work. Tests are 217/217 green across uix.
Session hand-off — 2026-05-26 (sema canon + soma emission + READMEs)
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.expressionnew field ('pack' | 'family-default' | 'delegated' | 'none') declares how each morfo with declared events materialises its perceptual signature. Schema-validated; lint enforces coverage. Seesrc/uix/morfo/types.ts+LIBRO_VARIACIONES_Y_EXTENSIONES.mdD.4.SemaFamilyis now 8 (addeddelegateper book cap. 29).SEMA_FAMILY_POLICYcarries two axes (intentRequirement+intentGuidance) — see updated section above.SEMA_VERBSextended withsignal.inform,commit.unselect, contextual verbs (apply,partial,block,move,upload,acknowledge,confirm).- 38 sema packs under
src/uix/sema/components/*.tscompose 9 canonical tuning profiles (form.commit.soft/subtle,form.toggle.silent,tooltip.silent,emerge.soft,emerge.exit/.soft,emerge.medium,emerge.dismiss.passive,tabs.select.soft). Apps override atdefineEngineSemantic({ overrides: { cascade: [...] } }). - 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
fallbackTargetresolving 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/tosrc/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 atsrc/uix/eidos/components/picker-shell/README.md. LIBRO_VARIACIONES_Y_EXTENSIONES.mdis the authoritative registry for project decisions vs the book canon. Sections D.4 (expressionfield), 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;sampleOverlayrejected permanently), D.8 (channels scope —sound+hapticare 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 eventstable 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).
Behavioral Guidelines
These reduce common LLM coding mistakes. They bias toward caution over speed — for trivial tasks, use judgment.
1. Think before coding
Don't assume. Don't hide confusion. Surface tradeoffs.
- State assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them — don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
2. Simplicity first
Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
3. Surgical changes
Touch only what you must. Clean up only your own mess.
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it — don't delete it.
When your changes create orphans, remove the imports / variables / functions your changes made unused. Don't remove pre-existing dead code unless asked.
4. Goal-driven execution
Define success criteria. Loop until verified.
- "Add validation" → "Write tests for invalid inputs, then make them pass."
- "Fix the bug" → "Write a test that reproduces it, then make it pass."
- "Refactor X" → "Ensure tests pass before and after."
Strong success criteria let you loop independently. Weak criteria require constant clarification.