15 KiB
soma
Librería headless de componentes compuestos para Svelte 5. Capa de
comportamiento dentro de UIX — emite los data-* y aria-* que el
contrato declara en morfo, gestiona estado y eventos, y delega lo
visual a eidos mediante el DOM.
Doctrina del API: soma mantiene la forma compound (
Toggle.Provider,Tabs.Provider + Tabs.Trigger + ...) por simetria con los multi-parte. Eidos no inventa una API flat paralela: aplica la capa visual sobre la anatomia declarada por morfo y materializada por soma.
Cómo leer este README: es la guía de entrada y de autoría — qué es soma, qué componentes le pertenecen y cómo se construye uno. La referencia arquitectónica profunda (runtime, layers internas, contratos
data-*, anti-patterns) vive enSOMA_ARCHITECTURE.md; el mapa de dónde está cada tema es §5.
1. Proposito
soma resuelve behavior, accesibilidad, composicion y estado para componentes compuestos. No resuelve presentacion visual — eso es responsabilidad de eidos.
soma existe para:
- keyboard navigation entre partes de un componente
- focus management (trap, scope, roving)
- ARIA relationships entre partes (trigger↔content, tab↔panel)
- floating/positioning de overlays
- portal rendering
- presence management (enter/exit animations)
- dismiss on outside click / Escape
- gesture tracking (drag, swipe, resize)
- state machines para componentes con multiples estados
- form integration (hidden inputs, validation context)
soma NO existe para:
- colores, tipografia, espaciado, animaciones visuales
- tokens de tema
- responsive design
- iconografia
- componentes de una sola parte sin behavior complejo
2. Criterio de pertenencia
Un componente pertenece a soma si cumple ambos criterios:
Composicion de partes
El componente tiene 2 o mas subcomponentes que se comunican via context. Ejemplo: Accordion tiene Root, Item, Trigger, Content — cada parte lee estado del padre.
Behavior complejo
El componente implementa al menos uno de:
- Keyboard navigation no trivial (roving focus, arrow keys, typeahead)
- Focus management (trap, scope, restore)
- Floating positioning (popover, tooltip, dropdown)
- ARIA relationships que requieren IDs cruzados (aria-controls, aria-labelledby)
- State machine con transiciones (open/closed, editing/preview)
- Drag/gesture behavior (slider, splitter, drawer, toast)
- Form integration via context (validation state, hidden inputs)
Si un componente cumple solo uno de los criterios o ninguno, no necesita pasar por soma — su lógica puede vivir directamente en el wrapper de eidos.
3. Independencia
soma solo depende de:
- svelte (runes: $state, $derived, $effect)
- runed (Context, watch)
- clsx (class merging)
- @floating-ui (positioning)
$libs/reactive,$libs/days,$libs/datagrid,$libs/forms, etc. — utilidades puras del repo (no façades)$uix/morfo— el contrato cross-layer (compileMorfo + SomaRuntime)$uix/sema— vocabulario semántico + EngineSemantic
soma NO depende de eidos. La capa visual lee del DOM y de los tipos públicos del soma; la dirección del acoplamiento es eidos → soma, no al revés.
Los motores reutilizables que no son comportamiento headless viven fuera de
Soma: src/libs/datagrid para tablas, src/libs/forms para estado/validacion
de formularios y src/libs/strings para scoring/fuzzy search. Soma no los
reexporta: los consumidores importan esos motores desde $libs/*, que es su
fuente canonica.
Imports
Dentro de un componente/layer Soma: usar paths relativos para piezas del
mismo componente o de Soma. Para servicios/utilidades cross-layer usar el alias
canonico ($libs/*, $uix/morfo, $adom) para dejar clara la frontera de
ownership. El alias $soma/* es superficie publica para consumidores, no para
imports internos del propio Soma.
// Inside a component — relative
import { DRAWER_LANGS } from './langs';
import type { DrawerSide } from './types';
import { Presence } from '../../layers/presence.svelte';
// Cross-layer utility — alias
import { createTable } from '$libs/datagrid';
Consumidores (layouts, app code, test pages): usan el alias $soma/ que configuran en su build.
// Consumer code — alias
import { Soma } from '$soma';
import * as Drawer from '$soma/components/drawer';
soma sí importa del paquete hermano morfo ($uix/morfo), que es el contrato declarativo de la superficie DOM de cada componente (partes, data-attrs, ARIA, keyboard, focus). Ver §4.
4. Morfo — contrato declarativo cross-layer
Cada componente tiene un archivo en src/uix/morfo/components/{kebab}.ts que declara, en un único objeto tipado, la superficie DOM pública del componente:
- parts — el árbol de partes (name, kebab, kind, defaultElement, role, states, supportsNesting).
- data — qué data-attrs emite cada parte, con valores enum cuando aplica y severity (
required/recommended/optional). - aria — qué atributos ARIA emite cada parte, con la fuente del valor tipada vía tagged union (
v.literal,v.stateRef,v.partRef,v.propRef,v.translationRef) y condición de emisión opcional. - keyboard — los atajos de teclado relevantes por parte.
- focus — política de foco para overlays (
initial,trap,return,restore). - texts — slots de texto propios del componente, declarados como idlangrefs (
'#?components.{kebab}.{key}|Fallback'). El catálogo multilingüe vive ensrc/uix/langs/components/{kebab}.ts. - apg — URL al patrón WAI-ARIA APG cuando aplica.
- scope — las capas que implementan el componente:
['soma'],['soma', 'eidos'], etc.
El morfo es la única fuente de verdad del contrato público: Soma, Eidos,
Sema y la docs auto-generada lo consumen todos. El dev guide completo —por qué
existe morfo, archetypes, la regla 2-de-3, validación y qué NO va en morfo—
vive en src/uix/morfo/README.md.
Cómo soma consume un morfo
Cada provider raíz crea un runtime con su morfo. Ese paso compila la
declaración y registra el contrato data-* (los catálogos de texto por
componente los registra ActiveUix desde src/uix/langs/components/*):
import { dialogMorfo } from '../../../morfo/components/dialog';
this.soma = Soma.require();
this.runtime = this.soma.runtime(dialogMorfo, sources);
Cuando un provider necesita nombres de selector DOM usa createAttrs(morfo)
desde $uix/morfo — un helper tipado de nombres, no registra contrato ni
escribe en el DOM:
import { createAttrs } from '$uix/morfo';
const attrs = createAttrs(dialogMorfo); // { provider: 'data-dialog', trigger: 'data-dialog-trigger', ... }
Requisito de autoría: cada morfo se declara como as const satisfies Morfo
para no perder los literales (un morfo tipado como : Morfo degrada
createAttrs a Record<string, string>):
// ✅ Obligatorio
export const dialogMorfo = { ... } as const satisfies Morfo;
El modelo de ejecución (cómo SomaRuntime transcribe el morfo en
comportamiento) vive en SOMA_ARCHITECTURE.md §3.bis
y §5.
5. Referencia profunda
Este README cubre la entrada y la autoría. La referencia arquitectónica vive
en SOMA_ARCHITECTURE.md; la guía paso a paso de
implementación, en COMPONENT_GUIDE.md.
| Tema | Documento |
|---|---|
| Modelo de ejecución (Morfo → SomaRuntime → Provider → Effects → ADom) | SOMA_ARCHITECTURE §3.bis |
SomaRuntime.part(), ProviderOpts / WithRefOpts |
SOMA_ARCHITECTURE §5 |
| Layers (Presence, FocusScope, Dismissal, Gesture, Floating, SafePolygon) | SOMA_ARCHITECTURE §6 |
Soma class, servicios y tipos date/time ($libs/days) |
SOMA_ARCHITECTURE §7 |
Sistema reactivo (state / readableActive / writableActive) |
SOMA_ARCHITECTURE §8 |
| Helpers internos (mergeProps, KEYS, focus, scroll lock) | SOMA_ARCHITECTURE §8.bis |
Contratos data-* + CSS variables |
SOMA_ARCHITECTURE §9 |
| IDs, barrels, fronteras externas | SOMA_ARCHITECTURE §10–§12 |
| Estructura de directorios + naming | SOMA_ARCHITECTURE §13 |
| Anti-patterns + regla de estabilidad | SOMA_ARCHITECTURE §14, §16 |
| Checklist de autoría (pasos 1–40 + reglas A1–A37) | COMPONENT_GUIDE.md |
| Criterios de aceptación (machine-auditados) | COMPONENT_COMPLETION_CHECKLIST.md |
6. Patron de componente
Provider ({name}-provider.svelte.ts)
import { accordionMorfo } from '$uix/morfo/components/accordion';
// Canonical field shapes defined in types.ts, referenced here
interface AccordionOpts
extends WithRefOpts, StateProps<AccordionStateFields>, ActiveProps<AccordionActiveFields> {}
export class AccordionProvider {
static readonly ctx = context<AccordionProvider>('Accordion');
static get() {
return this.ctx.getOr(undefined) as AccordionProvider | undefined;
}
static require() {
return this.ctx.get();
}
readonly opts: AccordionOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
static create(opts: AccordionOpts) {
return new AccordionProvider(opts);
}
private constructor(opts: AccordionOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(accordionMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: AccordionProvider.ctx,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-orientation': this.opts.orientation?.current,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
} as const)
);
}
Wrapper svelte ({name}.svelte)
<script lang="ts">
import { readableActive, writableActive } from '../../reactive';
import { mergeProps } from '../../props';
import { createId } from '$active-uix/id';
import { AccordionProvider } from '../accordion-provider.svelte';
import type { AccordionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'accordion'),
value = $bindable([]),
disabled = false,
children,
child,
...restProps
}: AccordionProps = $props();
const state = AccordionProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => (value = v)
),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
7. Patron de composicion
Los componentes compuestos siguen el patron Provider → Parts con contexto:
<Accordion.Provider bind:value>
<Accordion.Item value="one">
<Accordion.Header>
<Accordion.Trigger>Click me</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>Content here</Accordion.Content>
</Accordion.Item>
</Accordion.Provider>
Flujo de datos
Provider
├── crea AccordionProvider
├── registra en context via runtime.part(..., { context, owner })
└── children
├── Item
│ ├── crea AccordionItemProvider
│ ├── lee AccordionProvider via AccordionProvider.require()
│ └── children
│ ├── Trigger → lee AccordionItemProvider.require()
│ └── Content → lee AccordionItemProvider.require()
└── Item
└── ...
Regla de context
- Root siempre registra en context al crear su
runtimePart(runtime.part(..., { owner: this, context: XProvider.ctx })) - Sub-parts leen con
XProvider.require()(obligatorio) oXProvider.get()(opcional) - Si una sub-part tiene hijos que necesitan su estado, crea su propio context (Item tiene ctx, Trigger lo lee)
- El context es por componente instance — multiples Accordion en la misma pagina funcionan independientemente
8. Relacion con eidos
soma → headless behavior, accesibilidad, data-* contracts, context
eidos → visual layer: tokens, CSS recipes, sizes, variants, event reactions
sema → perception/events: hold, sound, haptic
Eidos consume Soma vía los data-* públicos y los subpaths públicos
(import { Accordion } from '$soma/components/accordion'); responde a estados
([data-accordion][data-state='open'] { ... }), añade props visuales (size,
variant, color) y reutiliza los catálogos de texto. Nunca importa Provider classes
internas, no depende de estructura DOM incidental ni duplica behavior que soma
ya resuelve.
El reparto estricto de responsabilidades entre las capas y la frontera data-*
viven en SOMA_ARCHITECTURE.md §2.
9. Construir un componente nuevo
Dos documentos cubren el ciclo, cada uno con un rol:
- Cómo construir — el proceso de autoría ordenado (comparar con librerías de
referencia, declarar el morfo, escribir provider + wrapper, demo interactivo,
verificación) vive en
COMPONENT_GUIDE.md: checklist de 1 a 40 + las reglas A1–A37 con su rationale. - Cuándo está terminado — los criterios de aceptación a través de las
cuatro capas (morfo · soma · sema · eidos + recipe CSS + demo), machine-
auditados por
npm run component:audit, viven en../COMPONENT_COMPLETION_CHECKLIST.md.
Este README no reproduce ninguno de los dos — son la fuente única de su concern.
10. Inventory
El catálogo vivo de componentes son los directorios bajo
src/uix/soma/components/; cada uno declara su contrato en
src/uix/morfo/components/{kebab}.ts con un campo scope (['soma'],
['soma', 'eidos'], …). Hardcodear la lista aquí la deja desincronizada, así
que la fuente de verdad es el árbol de directorios + los morfos.
Criterio de admisión
Nuevas piezas se aceptan solo si cumplen el criterio de pertenencia de §2 y declaran primero su morfo.
Visual-native (no soma)
Avatar, Icon y SVG son eidos-native hoy. Primitivas de una sola parte como Badge, Button, Label, Separator, Spinner, AspectRatio, Typography o Layout deben seguir eidos-native salvo que aparezca behavior compuesto real.