41 KiB
Soma Architecture
Documento de referencia arquitectonica para src/uix/soma.
soma es la capa de primitives headless del sistema UIX. Implementa
behavior, accesibilidad y composicion de partes; la presentacion visual
es responsabilidad de eidos. Cada componente se describe primero como
morfo (contrato declarativo) y soma lo materializa mediante clases
concretas de estado que registran sus partes en SomaRuntime.
1. Proposito
soma existe para dar una base comun sobre la que construir interfaces complejas sin repetir la misma logica de:
- contexto compartido
- control de foco
- teclado y puntero
aria-*data-*- sincronizacion de estado
- integracion con servicios transversales
- animaciones de entrada/salida
- posicionamiento flotante
Su objetivo no es ser una capa visual ni de producto. soma define primitives reutilizables y predecibles; la capa visual decide el look and feel.
2. Arquitectura de capas
soma → headless: behavior, accesibilidad, data-* contracts, context, servicios
eidos → visual: tokens, CSS, temas, recipes, reacciones a data-event-*
events → percepcion: sound/haptic/hold y dispatch de ocurrencias semanticas
app → producto: composicion final, contenido, logica de negocio
Cada capa tiene responsabilidades estrictas:
soma aporta
- comportamiento (keyboard, focus, dismiss, scroll lock)
- accesibilidad (ARIA, roles, live regions)
- contratos
data-*estables y validados - contexto y composicion de partes
- servicios de runtime (
langs,format,logger) - sistema de animaciones (presence, data-starting/ending-style, onComplete)
- posicionamiento flotante (@floating-ui)
La capa visual aporta
- apariencia (tokens, colores, tipografia, spacing)
- tono visual (temas light/dark, variantes)
- decisiones de diseno opinionated (sizes, recipes)
- motion CSS y reacciones visuales a
data-event-* - responsive design
La capa visual NUNCA
- importa state classes internas de soma
- depende de estructura DOM incidental
- accede a propiedades privadas
- duplica behavior que soma ya resuelve
- usa
data-*fuera de los contratos publicados
La frontera es los data-* attrs y las CSS variables que soma expone.
3. Principios de diseno
3.1 El desarrollador no necesita conocer los internos
Los layers, el sistema reactivo, el floating engine — son implementacion interna. El desarrollador de componentes interactua con:
- clases concretas de estado +
SomaRuntime Somaclass para servicios- Barrel imports jerárquicos (
import { Dialog } from '$soma/components')
3.2 Un patron, no tres
Todo componente sigue el mismo patron:
- State class concreta registra sus partes con
SomaRuntime.part(...) - Wrapper
.sveltefino convierte props → Active/State - Props derivados via
$derived.by+runtimePart.assert - Contexto para comunicacion padre-hijo
Los roots sin DOM usan ProviderOpts (ref opcional), las partes con DOM usan
WithRefOpts. Las unicas excepciones son partes declarativas que pueden vivir
fuera de su provider (AnnounceRegion, FeedSentinel): si encuentran un
provider reutilizan su runtime; si no, crean un runtime propio desde el
Soma del scope actual.
3.3 Layers como behaviors, no como wrappers
Los layers se instancian en el constructor del Provider y exponen .props para merge. No hay nesting de componentes wrapper en template.
// Correcto: behaviors integrados
readonly focusScope = FocusScope.use({...});
readonly dismissal = Dismissal.use({...});
readonly props = $derived.by(() => this.runtimePart.assert({
...this.runtimePart.props,
...this.focusScope.props,
...this.dismissal.props,
}));
<!-- Incorrecto: nesting de wrappers (patron terra) -->
<ScrollLock>
<FocusScope>
<DismissibleLayer>
{content}
</DismissibleLayer>
</FocusScope>
</ScrollLock>
3.4 Soma es una clase, no una configuracion
Soma es la identidad runtime del framework. No es un archivo de configuracion — es el objeto raiz que provee servicios via context.
// En un Provider:
readonly soma = Soma.require();
const dir = this.soma.prefs.getDir();
3.5 Los data-* son contrato publico
Los data-* attrs son la frontera entre soma y la capa visual. Cambiarlos es breaking change.
Convencion (obligatoria, sin excepciones):
- provider:
data-{component}(nodata-{component}-provider, nodata-soma-*) - parte:
data-{component}-{part} - estado:
data-state,data-disabled,data-side,data-align,data-orientation - animacion:
data-starting-style,data-ending-style - nesting:
data-nested,data-nested-open
Los nombres los emite el compilador de morfo que consume SomaRuntime.
createAttrs(morfo) queda como helper tipado para querySelector y tooling,
no como sistema de registro ni escritura DOM. Cualquier selector CSS, cadena
en README o snippet debe coincidir exactamente con esos nombres generados. El
validador de contratos (assertContract) solo verifica valores enumerados, no
nombres ni presencia — la consistencia de nombres es responsabilidad del autor
del componente (checklist item 27).
3.6 La accesibilidad base no se delega
soma resuelve ARIA por defecto. El consumidor no necesita añadir role, aria-modal, aria-expanded, aria-controls, etc. — el Provider los genera.
Texto funcional se resuelve via langs.ts() con idlangref. El catalogo propio del componente vive en morfo.translations y se referencia con v.translationRef(...); texto compartido como close/cancel/save vive en common.* y se referencia con v.commonRef(...) o un idlangref absoluto. langs.ts por componente queda como comodidad opcional para constantes imperativas, no como catalogo canonico.
3.7 DOM global via ActiveDom
Soma usa el ActiveDom del scope para escrituras gestionadas por UIX,
listeners de document/window, queries globales, foco imperativo y scroll de
ventana. No existe fachada soma/events: dom.listen(...) es la superficie
canonica para registrar listeners con cleanup.
Las lecturas locales de un elemento propio (contains, closest,
getBoundingClientRect, clientWidth, scrollTop) no se envuelven en
ActiveDom; son parte del comportamiento local del componente.
3.8 Props documentadas obligatoriamente
Todas las props de todos los componentes llevan JSDoc en types.ts. Cada prop: descripcion, @default, notas de comportamiento.
3.9 Comparacion con referencias
Cada componente se compara con ark-ui, bits-ui y radix-ui antes de implementar. Se documentan las props que otros tienen y soma no, con justificacion.
3.bis Arquitectura cerrada (post-2026-04-25)
El reparto de responsabilidades entre Morfo, Soma, Sema y ADom esta cerrado en seis piezas con responsabilidades disjuntas:
Morfo declara
SomaRuntime transcribe (vive en soma/)
Provider aporta sources, targets y handlers
Effects sincronizan attrs derivados
EngineSemantic despacha senales a canales perceptivos
VisualChannel materializa la senal en el DOM (data-event*, hold, cleanup)
ADom aplica mutaciones DOM (commit estructural)
SomaRuntime — la pieza nueva
SomaRuntime es la pieza que faltaba entre Morfo (declaracion) y
Provider (ejecucion). Lee el morfo y produce el comportamiento.
Una instancia por componente:
readonly soma = Soma.require();
readonly runtime = this.soma.runtime(morfo, {
states: { open: () => this.opts.open.current },
props: { disabled: () => this.opts.disabled.current },
parts: { content: () => this.contentId.current },
events: {
open: () => {
this.opts.open.current = true;
},
'close-cancel': () => {
this.opts.open.current = false;
}
}
});
API V1:
runtime.part(part, opts)— unica API publica para registrar una parte. Devuelve el handle (props,resolveProps,assert) y, consyncAttrs: true, sincroniza attrs derivados por morfo viadom.apply.runtime.partProps(part)— devuelve{ id, ref, marker, data-archetype? }. Solo identidad estatica (eldata-archetypees classification cross-component, nunca cambia).runtime.keydown(part, event)— dispatch de teclas declaradas enmorfo.keyboard.runtime.trigger(eventName)— orquesta la secuencia perceptiva + state.
Provider en el modelo nuevo
El provider deja de tener helpers locales de resolucion de morfo. Solo aporta:
- getters reactivos para
states,props,parts - handlers sincronos para los
events - glue de layers ortogonales (Presence, Dismissal, ScrollLock)
Cada part-provider conserva el handle devuelto por runtime.part(...):
readonly runtimePart = runtime.part('trigger', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
readonly props = $derived.by(() => this.runtimePart.props);
Tres operaciones que cubren todos los escenarios
// Cambio estructural sin senal
provider.commitState(change);
// Cambio estructural con senal
provider.commitState(change, event);
// Senal sin cambio estructural
provider.emitEvent(event);
Internamente:
async commitState(change, event?) {
if (event) await this.soma.events?.emit(event);
this.soma.dom.apply(change);
}
emitEvent(event) {
void this.soma.events?.emit(event);
}
La secuencia de runtime.trigger(eventName)
1. prewrite imperativo (transient markers como data-last-action)
2. await events.emit(event)
3. handler sincrono del provider muta state
4. effects derivan y aplican attrs estructurales (data-state, aria-*)
Los effects del runtime escuchan los sources reactivos y reaplican attrs cada vez que el estado cambia. ADom es el unico escritor de attrs mutables.
Reglas operativas
partProps(part)solo emite identidad estatica. Lo mutable lo escribe ADom.- Lo que
dom.applyescribe, Svelte no lo renderiza desdepartProps. - Event handlers son sincronos. Async va antes del trigger.
- Guards (
if (disabled) return) van en el call-site, no dentro del handler. events/VisualChannelpueden usarActiveDoma traves del projector inyectado;ActiveDomno conoceevents.morfo.events.commitses descriptivo, no ejecutable. El smoke valida.
Pilotaje (orden incremental)
- Toggle — primer caso, solo
partProps. - Collapsible — anade
keydown. - Toast — primer test real de
trigger()conintent. - Dialog — al final, cuando layers + portal ya esten validados.
Ver tambien:
- src/uix/morfo/README.md — declaracion, archetypes, regla 2-de-3
- src/uix/sema/README.md — contrato de
emit, vocabulario de verbs - src/uix/adom/README.md —
dom.apply - src/uix/eidos/README.md — qué consume eidos del DOM
- src/uix/README.md §2.bis — vista cross-layer
Cross-layer hooks que soma emite por la regla 2-de-3
Soma escribe al DOM no solo lo que necesita; escribe también lo que sema y eidos van a consumir. La regla "2-de-3" justifica qué entra al morfo y por tanto qué emite el runtime:
data-archetype— emitido porpartPropscuando la parte declara archetype. Eidos lo usa para selectores transversales ([data-archetype=trigger] { ... }); sema puede asociar verbs por archetype.data-event*— emitido porevents.emit(a través del VisualChannel) durante un hold configurable (240ms emerge/commit/handle, 600ms alert/sustain por defecto). Eidos lo usa para tintar transiciones de eventos ([data-event^=dismiss]).data-{component}/data-{component}-{part}— los markers estructurales clásicos. Eidos los usa para selectores per-componente.
4. Modelo de componente
La forma base de soma es Componente.Parte:
import { Dialog } from '$soma/components';
Dialog.Provider; // root — crea contexto
Dialog.Trigger; // accion — abre/cierra
Dialog.Content; // contenido — layers integrados
Dialog.Overlay; // fondo — presence
Dialog.Title; // metadata ARIA
Dialog.Description; // metadata ARIA
Dialog.Close; // accion — cierra
Provider (root)
Crea el estado central, lo registra en context, gestiona presence para content y overlay. Puede o no renderizar DOM:
- Con DOM (Collapsible, Accordion): usa
WithRefOpts, renderiza<div> - Sin DOM (Dialog, Popover): usa
ProviderOpts, solo renderiza children
Subcomponentes
Leen estado del root via .require(). No reimplementan logica — derivan props, ARIA, data-*, events del estado del padre.
Portal
Componente interno (components/internal/portal.svelte). Renderiza hijos en otro nodo DOM. El contexto de Svelte se preserva.
Picker composition (shared state across providers)
Pickers (DatePicker, DateRangePicker, TimePicker, y futuros TimeRangePicker) no reimplementan Popover / Field / Calendar — los componen con estado compartido. El root wrapper crea tres (o más) Providers que apuntan a los mismos writableActive refs:
// Root wrapper
const sharedValue = writableActive(() => value, (v) => (value = v));
const sharedPlaceholder = writableActive(() => placeholder, (v) => (placeholder = v));
const sharedOpen = writableActive(() => open, (v) => (open = v));
{Name}PickerProvider.create({ value: sharedValue, placeholder: sharedPlaceholder, open: sharedOpen, ...config });
PopoverProvider.create({ open: sharedOpen, ... });
{Base}FieldProvider.create({ value: sharedValue, placeholder: sharedPlaceholder, ...config });
// Calendar/RangeCalendar/slider providers are created in their own wrapper (DatePicker.Calendar, TimePicker.HourSlider, …)
El picker expone solo wrappers únicos para Provider, Trigger, y el puente al calendario/slider. Los demás exports re-exportan desde los componentes compuestos — sus data-* nativos (data-popover-*, data-date-field-*, data-calendar-*, data-slider-*) siguen siendo la API de estilo autoritativa. El picker sólo añade atributos de identidad (data-{picker}-trigger, data-{picker}-calendar) en sus propios wrappers.
Auto-close / auto-anchor: el PickerProvider expone handleSelect() que el wrapper del calendario llama al completarse una selección. Los range pickers re-anclan placeholder para que el mes final caiga en la columna más a la derecha visible, evitando mostrar al usuario un mes que no contiene su selección.
Ver A27 en COMPONENT_GUIDE.md para el checklist completo.
5. Runtime parts
readonly soma = Soma.require();
readonly runtime = this.soma.runtime(accordionMorfo, {});
const runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: AccordionProvider.ctx
});
readonly props = $derived.by(() =>
runtimePart.assert({
...runtimePart.props,
'data-state': this.state
})
);
SomaRuntime.part() centraliza lo mecanico de cada parte:
interface SomaRuntimePart {
readonly attachment: RefAttachment | undefined;
readonly props: Record<string, unknown>;
resolveProps(bindings?): Record<string, unknown>;
assert<P extends Record<string, unknown>>(props: P): P;
}
syncAttrs: true activa la escritura imperativa por uix.dom para partes
cuyos sources ya estan declarados en el runtime. Si un provider sigue
componiendo attrs en render props, no activa syncAttrs.
Dos interfaces de opts:
ProviderOpts—{ id: Active<string>; ref?: State<HTMLElement | null> }— para roots sin DOMWithRefOpts—{ id: Active<string>; ref: State<HTMLElement | null> }— para parts con DOM
6. Layers
layers/ contiene solo clases de comportamiento (.svelte.ts). Son infraestructura consumida por Providers, nunca directamente por el consumidor.
Inventario
| Layer | API | Responsabilidad |
|---|---|---|
Presence |
new Presence(opts) |
Mount/unmount con animaciones. isPresent, transitionAttrs, onComplete. |
FocusScope |
FocusScope.use(opts) |
Focus trap, loop, auto-focus, restore. Singleton manager con stack. |
Dismissal |
Dismissal.use(opts) |
Escape + click-outside. Registry global. Behaviors: close, ignore, defer. |
TextSelection |
TextSelection.use(opts) |
Previene selection overflow durante drag. |
ScrollLock |
new ScrollLock(initial?, delay?) |
Body scroll lock con refcount. Soporta delay para animaciones. |
ResizeObserver$ |
new ResizeObserver$(getter, cb) |
ResizeObserver con lifecycle Svelte. |
Floating* |
FloatingProvider.create(), FloatingContent.create(opts), etc. |
Posicionamiento relativo a anchor via @floating-ui. |
Gesture.base |
Gesture.base(opts) |
Pointer tracking + axis lock + velocity. |
Gesture.drag |
Gesture.drag(opts) |
Base + progress + snap points + dismiss. |
Gesture.resize |
Gesture.resize(opts) |
Base + delta + min/max constraints. |
SafePolygon |
new SafePolygon(opts) |
Hover-gap corridor between trigger↔content. |
layers/floating/placement.ts es la fuente unica para Side, Align,
Boundary, SIDE_OPTIONS y ALIGN_OPTIONS. floating/types.ts consume esa
fuente y no importa del runtime floating.svelte.ts, evitando ciclos entre
tipos y clases.
Cobertura P0 actual
Los tests por componente estan creciendo desde las piezas de mayor riesgo. A 2026-05-15 existen tests directos para:
$libs/datagrid/table-core.svelte.test.ts— motor de data grid.$libs/forms/form-core.svelte.test.ts— motor de formularios + Standard Schema.dialog/dialog-provider.svelte.test.ts— open/close, fallback target y eventos morfo.drawer/drawer-provider.svelte.test.ts— direccion logica, dismiss y modo persistent.command/command-provider.svelte.test.ts— filtro, visibilidad, navegación y selección.combobox/combobox-provider.svelte.test.ts— selección,inputValuey navegación sin items disabled.select/select-provider.svelte.test.ts— selección single/multiple y navegación sin items disabled.popover/popover-provider.svelte.test.ts— toggle, hover timers y señales runtime.toast/toaster.svelte.test.ts— overflow, dismiss/remove y promesas.calendar/calendar-provider.svelte.test.ts— placeholder, meses, selección y flags.range-calendar/range-calendar-provider.svelte.test.ts— placeholder, orden de endpoints y limites min/max.date-field/date-field-provider.svelte.test.ts— helpers UI de segmentos, lectura DOM, navegación porActiveDom, validación y commit segmentado.date-picker/date-picker-provider.svelte.test.ts— registro runtime y cierre controlado porcloseOnDateSelect.date-range-picker/date-range-picker-provider.svelte.test.ts— registro runtime y cierre controlado porcloseOnRangeSelect.time-picker/time-picker-provider.svelte.test.ts— registro runtime, valores de placeholder y escritura de sliders al valor compartido.time-range-picker/time-range-picker-provider.svelte.test.ts— valores por endpoint, escritura de sliders y cierre al completar rango.time-range-field/time-range-field-provider.svelte.test.ts— validación de orden/min/max, validación custom y foco de label viaActiveDom.time-field/time-field-provider.svelte.test.ts— validación, sincronización de segmentos 12h y commit solo cuando los segmentos renderizados estan completos.date-range-field/date-range-field-provider.svelte.test.ts— validación de orden/min/max, validación custom y foco de label viaActiveDom.virtual-list/virtual-list-provider.svelte.test.ts— cálculo de ventana,scrollToIndexy compensación anti-jump viaActiveDom.virtual-grid/virtual-grid-provider.svelte.test.ts— cálculo de ventana 2D yscrollToCellviaActiveDom.number-field/number-field-provider.svelte.test.ts— parsing localizable, teclado spinbutton, triggers, props ARIA y scrubber.file-upload/file-upload-provider.svelte.test.ts— aceptación/rechazo, dropzone, input oculto, items, progress y acciones remove/clear.color-field/color-field-provider.svelte.test.ts— commit por segmentos, edición hex por teclado, selector de formato, input oculto y foco viaActiveDom.color-picker/color-picker-provider.svelte.test.ts— helpers de canales, trigger/value/hidden, area 2D, slider de canal y swatches.navigation-menu/navigation-menu-provider.svelte.test.ts— timers UIX, enlace item/trigger/content, foco por teclado y props de list/link.tree-grid/tree-grid-provider.svelte.test.ts— expansión, selección/rango, filas visibles, navegación y props de row/cell/header/expand trigger.dropdown-menu/dropdown-menu-provider.svelte.test.ts— trigger open/close, scoping de items, selección, checkbox/radio groups, submenu y group/separator.context-menu/context-menu-provider.svelte.test.ts— apertura porcontextmenu, anchor virtual, scoping de items, selección, checkbox/radio, submenu y group/separator.menubar/menubar-provider.svelte.test.ts— coordinación de menús hermanos, hover-follow, navegación horizontal y cambio de menú desde content.listbox/listbox-provider.svelte.test.ts— props ARIA root, selección, navegación, typeahead, indicador de item y grupos.tree-view/tree-view-provider.svelte.test.ts— expansión/selección, navegación root, typeahead, props de ramas/hojas y partes auxiliares.accordion/accordion-provider.svelte.test.ts— modos single/multiple, eventosopen/closepor item, navegación de triggers y header/content.checkbox/checkbox-provider.svelte.test.ts— commits check/uncheck, indeterminate, hidden input y sincronización conCheckboxGroup.radio-group/radio-group-provider.svelte.test.ts— roving tabindex, selección síncrona, navegación con auto-select, hidden input y guardas.switch/switch-provider.svelte.test.ts—commit-toggleruntime, keyboard, hidden input, integración conFieldProvidery thumb.toggle/toggle-provider.svelte.test.ts—commit-toggleruntime, attrs DOM viaActiveDom, integración conFieldProvidery guardas.toggle-group/toggle-group-provider.svelte.test.ts— modos single/multiple, roving tabindex y navegación de items saltando disabled.tabs/tabs-provider.svelte.test.ts— registros trigger/content, activacion automatic/manual, navegacion roving, fallback tab stop y partes auxiliares.slider/slider-provider.svelte.test.ts— contrato provider/range/thumb/tick, snapping/clamping, drag pointer confirmado y teclado de thumb.progress/progress-provider.svelte.test.tsymeter/meter-provider.svelte.test.ts—data-value/data-min/data-maxemitidos por morfo/runtime, estados derivados e indicador sincronizado.collapsible/collapsible-provider.svelte.test.ts— refs cruzadas trigger/content, eventosexpand/collapsey guardas disabled.
Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por
componentes de riesgo medio y familias menos centrales. table-core,
form-core y el scorer de Command ya no viven dentro de Soma: se consumen
desde $libs/datagrid, $libs/forms y $libs/strings.
Convencion
.use(opts)→ lifecycle auto-gestionado (watch/$effect internos). Constructor privado.new X(opts)→ lifecycle manual. El consumidor controla..props→ objeto para spread en el Provider.
Animaciones (Presence)
Lifecycle:
OPENING:
open=true → shouldRender=true + data-starting-style
→ next rAF: data-starting-style removed (triggers CSS transition)
→ getAnimations().finished → onComplete(true)
CLOSING:
open=false → data-ending-style (element stays in DOM!)
→ getAnimations().finished
→ shouldRender=false + data-ending-style removed → onComplete(false)
forceMountmantiene el elemento en DOM siempre (para transiciones CSS)onCompleteusagetAnimations()API, no eventostransitionend/animationend- Run ID cancellation previene callbacks stale en toggle rapido
7. Soma class (component runtime scope)
Soma reads ActiveUix from context and exposes services to components.
Components import from $soma, never from $active-app. Nestable: child
<Soma portalTo="#modals"> overrides parent.
class Soma {
static create(opts?: SomaOptions): Soma; // factory + context set
static get(): Soma | undefined; // safe read
static require(): Soma; // throws if not found
readonly uix: ActiveUix;
readonly portalTo: string | HTMLElement | undefined;
// Service accessors (delegate to ActiveUix)
get langs(): ActiveLangs;
get nums(): ActiveNumbers | undefined;
get money(): ActiveCurrency | undefined;
get dates(): ActiveDates | undefined;
get units(): ActiveUnits | undefined;
get prefs(): ActiveUixPrefsView;
get logger(): EngineLogger;
}
Service access from components
Components access services through Soma, never through App directly:
const soma = Soma.get();
soma?.langs.ts('#?common.buttons.close|Close'); // translation via idlangref
soma?.prefs.getDir(); // effective direction from uix.prefs.direction
soma?.money?.format(1099); // currency formatting
soma?.dates?.getDateOrder(); // DMY / MDY / YMD
soma?.dates?.getHourCycle(); // 12 | 24 (numeric — not '12h' / '24h')
soma?.portalTo; // portal target
Date / time types and formatting
Soma imports date-related symbols from $libs/days, the canonical date library. Components never import from $lib/util/dates (legacy) or @internationalized/date directly. There is no Soma re-export façade for the date domain.
- Value types:
CalendarDate,CalendarDateTime,Time,ZonedDateTime - Types:
DateValue,TimeValue,DateRange,DateMatcher,Month,WeekStartsOn,HourCycle,TimeGranularity,DateOrder,Granularity,SegmentPart,EditableTimeSegmentPart,TimeSegmentObj,SegmentValueObj,DayPeriod, … - Queries:
isSameDay,hasTime,isZonedDateTime,isTimeBefore,isTimeAfter,today,now,startOfMonth,endOfMonth,getLastFirstDayOfWeek,getNextLastDayOfWeek, … - Operations:
dateValueToDate,convertTimeValueToDateValue,convertTimeValueToTime,toCalendarDate,toZoned, … - Parsing:
parseDate,parseDateTime,parseTime - Formatting:
DateFormatter,getCachedDateFormat,getPlaceholder,getDefaultDate,getDefaultTime,inferGranularity,inferTimeGranularity,getDefaultHourCycle,resolveDateOrder(locale),resolveHourCycle(locale) - Segments (dias/segments.ts): constants (
DATE_SEGMENT_PARTS,EDITABLE_TIME_SEGMENT_PARTS, …), type guards (isDateSegmentPart,isEditableTimeSegmentPart,isDateAndTimeSegmentObj, …), pure helpers (initializeSegmentValues,initializeTimeSegmentValues,getValueFromSegments,getTimeValueFromSegments,areAllSegmentsFilled,createSegmentContent,createTimeSegmentContent,getOptsByGranularity,getOptsByTimeGranularity).
HourCycle is canonically the numeric form 12 | 24 across the whole framework, matching Intl.DateTimeFormat's hour12 resolved option. String forms like '12h'/'24h' are legacy and must not appear in new code.
soma/datetime/ holds only UI-level helpers (screen-reader announcer, DOM segment navigation, SegmentState shape with lastKeyZero/hasLeftFocus/updating, isAcceptableSegmentKey using KEYS, description-element DOM writers). It must not re-export $libs/days symbols — consumers import from $libs/days directly. Extending $libs/days is the default for new date/time helpers; adding to soma/datetime/ is only correct when the helper is genuinely UI-specific.
Static method convention (project-wide)
All classes that use Svelte context follow the same pattern:
| Method | Returns | Use when |
|---|---|---|
X.create(opts) |
instance | Creating + registering in context |
X.get() |
instance or undefined |
Parent/context is optional |
X.require() |
instance (throws) | Parent/context is required |
This applies to App, Soma, and all state classes that use context. No
standalone functions. No from(). No ctx exposed.
Texto funcional
Las traducciones propias del componente se declaran en el morfo:
export const drawerMorfo = {
name: 'Drawer',
kebab: 'drawer',
translations: {
trigger: { es: 'Abrir cajon', en: 'Open drawer' }
},
parts: [
{
name: 'Trigger',
kebab: 'trigger',
aria: [{ attr: 'aria-label', value: v.translationRef('trigger', 'Open drawer') }]
}
]
} as const satisfies Morfo;
Las traducciones compartidas no se duplican por componente:
value: v.commonRef('buttons.close', 'Close'); // #?common.buttons.close|Close
ActiveUix conecta el registro de morfos con ActiveLangs. Cuando el
provider crea createSomaRuntime(morfo, sources) o soma.runtime(morfo, sources), registerMorfo(morfo) registra el contrato data-* y publica
morfo.translations bajo components.{kebab}.
commonLangs en core/langs.ts aporta los defaults de common.*.
ActiveUix los registra sin pisar hojas existentes, de modo que el
integrador puede pasar sus propias traducciones y UIX solo completa lo que
falte.
No existe catálogo global por componente. ActiveUix conecta el registro de
morfos; cada componente publica sus textos cuando su morfo se registra.
8. Sistema reactivo
Capa fina sobre runes de Svelte 5 que permite pasar estado reactivo por referencia entre clases.
state<T>(initial)→State<T>(mutable,.current)readableActive(() => value)→Active<T>(readonly derived)writableActive(getter, setter)→State<T>(two-way binding)
Los wrappers .svelte convierten props normales a Active/State con estas funciones. Esta conversion es la frontera entre el mundo de props de Svelte y el mundo de clases reactivas de soma.
9. Contratos data-*
Los data-* son API publica formal, validados con assertContract().
Convencion:
data-dialog → provider (sin -provider, sin -root)
data-dialog-trigger → parte
data-dialog-content → parte
data-state="open|closed" → estado
data-disabled → flag
data-side="top|right|bottom|left" → posicion flotante
data-align="start|center|end" → alineacion
data-starting-style → animacion de entrada (1 frame)
data-ending-style → animacion de salida (persiste)
data-nested → es hijo de otro del mismo tipo
data-nested-open → tiene un hijo abierto
data-dragging → gesture drag activo
data-highlighted → item con virtual focus (aria-activedescendant)
data-resizing → splitter resize activo
CSS variables expuestas:
--soma-floating-transform-origin
--soma-floating-available-width
--soma-floating-available-height
--soma-floating-anchor-width
--soma-floating-anchor-height
--soma-dialog-depth
--soma-dialog-nested-count
--drawer-progress → 0-1 drag progress
--drawer-offset-x / y → drag offset in px
--soma-toast-swipe-move-x / y → toast swipe offset
10. IDs
Los IDs se generan con contexto de componente:
soma-dialog-c12
soma-dialog-trigger-c13
soma-dialog-content-c14
Pattern: soma-{component}-{part}-{uid}. Descriptivos e inspeccionables.
11. Barrel exports
Componentes (jerárquico)
// $soma/components/index.ts
export * as Collapsible from './collapsible';
export * as Dialog from './dialog';
export * as Popover from './popover';
Consumo:
import { Dialog, Popover } from '$soma/components';
Dialog.Provider; // no SomaDialogProvider, no TerraDialogProvider
Dialog.Trigger;
Internal
import { Portal, Arrow, VisuallyHidden, Soma } from '$soma/components/internal';
12. Fronteras externas
soma distingue entre:
- internos:
layers/,reactive/,dom/,provider/— helpers propios - externos:
@floating-ui/dom,runed,tabbable— dependencias npm
Si una dependencia tiene API inestable o podria cambiar, se accede a traves de una frontera formal (como layers/floating/ wrappea @floating-ui). Las dependencias estables (runed, svelte) se importan directamente.
13. Estructura del directorio
src/uix/soma/
├── SOMA_ARCHITECTURE.md ← this document
├── COMPONENT_GUIDE.md ← step-by-step implementation guide
├── README.md ← API reference
├── runtime.svelte.ts ← SomaRuntime (morfo interpreter)
├── errors.ts ← typed runtime/context errors
├── core/
│ ├── soma.svelte.ts ← Soma class (root instance)
│ └── langs.ts ← shared commonLangs defaults
├── reactive/ ← reactive system
├── provider/ ← context + opts bridge
├── props/ ← mergeProps, composeHandlers
├── keyboard/ ← KEYS, directional
├── dom/ ← DOM utilities, focus
├── css/ ← styleToString, cssToStyleObj
├── id/ ← createId, useId
├── types/ ← shared types + service interfaces
├── layers/ ← behavior layers (classes only)
│ ├── presence.svelte.ts
│ ├── focus-scope.svelte.ts
│ ├── dismissal.svelte.ts
│ ├── text-selection.svelte.ts
│ ├── scroll-lock.svelte.ts
│ ├── resize-observer.svelte.ts
│ └── floating/
├── datetime/ ← UI-only helpers (announcer, segment DOM nav,
│ segment UI-state shapes, segment-key predicates,
│ description-element writers). NO date math,
│ NO re-exports of days — import `$libs/days`
│ directly.
├── components/
│ ├── internal/ ← Portal, Arrow, VisuallyHidden, <Soma>
│ ├── {name}/ ← each headless component
│ │ ├── {name}-provider.svelte.ts ← state classes (NOT {name}.svelte.ts)
│ │ ├── types.ts ← public props + canonical field shapes
│ │ ├── langs.ts ← optional idlangref constants for imperative strings
│ │ ├── exports.ts
│ │ ├── index.ts
│ │ └── components/
│ │ ├── {name}.svelte ← root wrapper
│ │ ├── {name}-trigger.svelte
│ │ └── ...
│ └── index.ts ← hierarchical barrel
└── index.ts ← main barrel
File naming convention
- State class:
{name}-provider.svelte.ts— NOT{name}.svelte.ts- Avoids Vite module resolution ambiguity with
{name}.sveltewrapper
- Avoids Vite module resolution ambiguity with
- Reflects what's inside: provider/state classes
- Root wrapper:
{name}.svelteincomponents/subdirectory - Export name: always
Provider, neverRoot
14. Anti-patterns
Avoid in soma:
- Complex logic inside wrapper
.svelte— belongs in Provider - Props drilling when context is the correct pattern
data-*attrs outside of contract- Inventing part names without checking reference library anatomies (ark-ui, bits-ui, radix-ui)
- Nesting layers as component wrappers in templates
- Inline
z-index: autothat overrides CSS - Coupling primitives to app libraries
- Product copy inside the primitive
- Speculative abstractions ("just in case")
- One-line files that only re-export (merge into parent)
- Redundant naming prefixes (SomaDialog, DialogLayerState)
- Dummy refs to satisfy a type — use
ProviderOptsfor no-DOM roots - State class file named same as wrapper —
select.svelte.ts+components/select.sveltecauses Vite module duplication. Always use{name}-provider.svelte.ts - Event handlers not in props — defining onclick as a class method but not including it in the props derived object
- getContext in event handlers — getContext only works during initialization. Capture references in constructor
- Exporting as Root — always
Provider, neverRoot - Skipping reference library comparison — mandatory step, no exceptions
- Comments in Spanish — all code comments in English
- Standalone context functions — no
createX(),getX(),useX()as loose functions. UseX.create(),X.get(),X.require()static methods - Importing from
$lib/ext/appin components — components access services throughSoma, never App directly from()as factory name — usecreate()consistently- Re-implementing date/time helpers inside soma — extend
$libs/days(A23). Importing from$lib/util/dates(legacy vendored) or@internationalized/datedirectly is forbidden; use$libs/days. - Re-export façades over days — a soma module whose only job is to forward
$libs/dayssymbols is dead weight. Consumers import from$libs/daysdirectly. - UI-level helpers in dias, or date math in
soma/datetime/— dias is pure (no DOM, no Svelte, no KEYS);soma/datetime/is UI-only (screen-reader announcer, DOM segment navigation,SegmentStateshapes, KEYS-based predicates). No crossover readonlySegmentswithout a concrete value anchor — A24: warn viasoma?.logger.warnwhenvalueis undefined. Range components split intostartReadonlySegments/endReadonlySegments(A25)keydown.preventDefault()as the only guard on contenteditable segments — IME/paste/drop bypass keydown. Always addonbeforeinput: e => e.preventDefault()(A26)- Time placeholders as
'––'— usecreateSegmentContent/createTimeSegmentContentfromdias/segments.ts; time parts render ashh/mm/ss(A28) - Pickers that reimplement field/calendar/popover — compose via shared
writableActiverefs (A27). OnlyProvider,Trigger, and the calendar/slider bridge are unique parts - Demo pages as galleries of canned snippets — every soma demo must be an interactive testbed wiring every public prop to a live control, including a Field-integration section (A29)
HourCycleas'12h' \| '24h'— canonical form is numeric12 \| 24(matchesIntl.DateTimeFormat.hour12). String forms are legacy
15. Estado actual y deuda histórica
Soma ha pasado por varias fases. La forma actual (rama active-uix,
post-2026-05-08):
- Provider inheritance dropped — los providers ya no heredan de un
base abstracto; son clases concretas. La mecanica DOM comun vive en
SomaRuntime.part(...), y los casos que necesitan eventos semanticos usan el mismoSomaRuntimeparatrigger/keydown. - SomaRuntime cachea la compilación del morfo (
compileMorfopor WeakMap) y registra loseffectsque sincronizanstate → attrsvíadom.apply. - Naming:
Provider(nuncaRoot); child providers referencian al padre comoprovider, nuncaroot. El export de un componente multi-parte sigue la forma compound `Toggle.Provider + Toggle.Trigger- ...`.
- Data-attr naming:
data-{component}(provider) ydata-{component}-{kebab}(sub-parts). Sin prefijodata-soma-*. El compiler emite estos viacompiled.parts.attrs. - State files:
{name}-provider.svelte.ts(explicit, no ambiguity). - IDs: descriptive (
soma-dialog-trigger-c13).
16. Regla de estabilidad
Un componente de soma se considera estable cuando:
- su API publica esta clara y documentada con JSDoc
- sus
data-*estan registrados y validados conassertContract - el wrapper y el Provider siguen el patron general
- su accesibilidad base esta resuelta (ARIA, roles, keyboard)
- sus props se han comparado con ark-ui, bits-ui y radix-ui
- tiene demo page funcional en
web/routes/{componente}/ - no depende de hacks locales, z-index hardcodeados, ni demo CSS para sostenerse
- compila con 0 errores (
svelte-check)
17. New component checklist
See COMPONENT_GUIDE.md for the full step-by-step process (27 general steps + 4 date/time specific, with rules A1–A29). Summary:
[ ] 1. Compare with ark-ui, bits-ui, radix-ui — feature table
[ ] 2. Verify membership criteria
[ ] 3. Define parts + attrs + morfo.translations when the component owns text
[ ] 4. Create types.ts (props + canonical field shapes)
[ ] 5. Create langs.ts only for imperative idlangref constants, not as the catalog
[ ] 6. Create {name}-provider.svelte.ts (concrete state classes, no Provider inheritance)
[ ] 7. Create wrapper .svelte files (thin)
[ ] 8. Create exports.ts + index.ts
[ ] 9. Create interactive demo page + link in index (A29)
[ ] 10. README.md with anatomy, ARIA, data-attrs, comparison table
[ ] 11. svelte-check + test in browser
[ ] 12. Date/time components: only consume date/time domain via `$libs/days`,
`onbeforeinput` on contenteditable, readonly-without-value warning,
picker composition pattern (A23–A28)