You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/SOMA_ARCHITECTURE.md

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
  • Soma class para servicios
  • Barrel imports jerárquicos (import { Dialog } from '$soma/components')

3.2 Un patron, no tres

Todo componente sigue el mismo patron:

  1. State class concreta registra sus partes con SomaRuntime.part(...)
  2. Wrapper .svelte fino convierte props → Active/State
  3. Props derivados via $derived.by + runtimePart.assert
  4. 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} (no data-{component}-provider, no data-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, con syncAttrs: true, sincroniza attrs derivados por morfo via dom.apply.
  • runtime.partProps(part) — devuelve { id, ref, marker, data-archetype? }. Solo identidad estatica (el data-archetype es classification cross-component, nunca cambia).
  • runtime.keydown(part, event) — dispatch de teclas declaradas en morfo.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.apply escribe, Svelte no lo renderiza desde partProps.
  • Event handlers son sincronos. Async va antes del trigger.
  • Guards (if (disabled) return) van en el call-site, no dentro del handler.
  • events/VisualChannel pueden usar ActiveDom a traves del projector inyectado; ActiveDom no conoce events.
  • morfo.events.commits es descriptivo, no ejecutable. El smoke valida.

Pilotaje (orden incremental)

  1. Toggle — primer caso, solo partProps.
  2. Collapsible — anade keydown.
  3. Toast — primer test real de trigger() con intent.
  4. Dialog — al final, cuando layers + portal ya esten validados.

Ver tambien:

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 por partProps cuando la parte declara archetype. Eidos lo usa para selectores transversales ([data-archetype=trigger] { ... }); sema puede asociar verbs por archetype.
  • data-event* — emitido por events.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 DOM
  • WithRefOpts — { 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, inputValue y 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 por ActiveDom, validación y commit segmentado.
  • date-picker/date-picker-provider.svelte.test.ts — registro runtime y cierre controlado por closeOnDateSelect.
  • date-range-picker/date-range-picker-provider.svelte.test.ts — registro runtime y cierre controlado por closeOnRangeSelect.
  • 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 via ActiveDom.
  • 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 via ActiveDom.
  • virtual-list/virtual-list-provider.svelte.test.ts — cálculo de ventana, scrollToIndex y compensación anti-jump via ActiveDom.
  • virtual-grid/virtual-grid-provider.svelte.test.ts — cálculo de ventana 2D y scrollToCell via ActiveDom.
  • 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 via ActiveDom.
  • 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 por contextmenu, 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, eventos open/close por item, navegación de triggers y header/content.
  • checkbox/checkbox-provider.svelte.test.ts — commits check/uncheck, indeterminate, hidden input y sincronización con CheckboxGroup.
  • 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-toggle runtime, keyboard, hidden input, integración con FieldProvider y thumb.
  • toggle/toggle-provider.svelte.test.ts — commit-toggle runtime, attrs DOM via ActiveDom, integración con FieldProvider y 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.ts y meter/meter-provider.svelte.test.ts — data-value/data-min/data-max emitidos por morfo/runtime, estados derivados e indicador sincronizado.

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)
  • forceMount mantiene el elemento en DOM siempre (para transiciones CSS)
  • onComplete usa getAnimations() API, no eventos transitionend/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}.svelte wrapper
  • Reflects what's inside: provider/state classes
  • Root wrapper: {name}.svelte in components/ subdirectory
  • Export name: always Provider, never Root

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: auto that 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 ProviderOpts for no-DOM roots
  • State class file named same as wrapper — select.svelte.ts + components/select.svelte causes 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, never Root
  • 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. Use X.create(), X.get(), X.require() static methods
  • Importing from $lib/ext/app in components — components access services through Soma, never App directly
  • from() as factory name — use create() consistently
  • Re-implementing date/time helpers inside soma — extend $libs/days (A23). Importing from $lib/util/dates (legacy vendored) or @internationalized/date directly is forbidden; use $libs/days.
  • Re-export façades over days — a soma module whose only job is to forward $libs/days symbols is dead weight. Consumers import from $libs/days directly.
  • 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, SegmentState shapes, KEYS-based predicates). No crossover
  • readonlySegments without a concrete value anchor — A24: warn via soma?.logger.warn when value is undefined. Range components split into startReadonlySegments / endReadonlySegments (A25)
  • keydown.preventDefault() as the only guard on contenteditable segments — IME/paste/drop bypass keydown. Always add onbeforeinput: e => e.preventDefault() (A26)
  • Time placeholders as '––' — use createSegmentContent / createTimeSegmentContent from dias/segments.ts; time parts render as hh/mm/ss (A28)
  • Pickers that reimplement field/calendar/popover — compose via shared writableActive refs (A27). Only Provider, 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)
  • HourCycle as '12h' \| '24h' — canonical form is numeric 12 \| 24 (matches Intl.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 mismo SomaRuntime para trigger/keydown.
  • SomaRuntime cachea la compilación del morfo (compileMorfo por WeakMap) y registra los effects que sincronizan state → attrs vía dom.apply.
  • Naming: Provider (nunca Root); child providers referencian al padre como provider, nunca root. El export de un componente multi-parte sigue la forma compound `Toggle.Provider + Toggle.Trigger
    • ...`.
  • Data-attr naming: data-{component} (provider) y data-{component}-{kebab} (sub-parts). Sin prefijo data-soma-*. El compiler emite estos via compiled.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 con assertContract
  • 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)

Powered by TurnKey Linux.