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
dev f5a2a7fb49
eidos: pilot wrapper pattern + doctrinal API conventions
5 months ago
..
color migrate $lib/util/* → $libs/* in soma + test pages (Phase 2) 5 months ago
components eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago
core active-uix: introduce UIX layer between active-app and components 5 months ago
css soma: gesture layer, drawer component, full audit fixes across 26 components 6 months ago
datetime uix: consolidate DOM + relocate morfo runtime to its layer 5 months ago
events soma: gesture layer, drawer component, full audit fixes across 26 components 6 months ago
external migrate $lib/util/* → $libs/* in soma + test pages (Phase 2) 5 months ago
id Refactor soma: consolidate layers, add Soma class, Dialog component, animation system 6 months ago
keyboard soma: gesture layer, drawer component, full audit fixes across 26 components 6 months ago
layers remove backward-compat re-export shims 5 months ago
props soma: gesture layer, drawer component, full audit fixes across 26 components 6 months ago
provider soma/provider: dedupe against the morfo compiler (Phase 2b minimal) 5 months ago
types remove backward-compat re-export shims 5 months ago
AUDIT_1.md soma: gesture layer, drawer component, full audit fixes across 26 components 6 months ago
COMPONENT_GUIDE.md morfo: add permutation runner (A37) — third validation layer for state transitions 6 months ago
README.md soma: fix slider thumb alignment + document morfo in README 6 months ago
SOMA_ARCHITECTURE.md docs: align cross-layer docs with channel-based Sema 5 months ago
audit-prompt.md soma/morfo: typed createAttrs + 6 audit batches + form hang fix + A36 6 months ago
codex_audit.md soma: tier 1/2 components, Field/NumberField integration, codex_audit fixes 6 months ago
index.ts remove backward-compat re-export shims 5 months ago
soma-audit-2026-04-20.md soma/morfo: typed createAttrs + 6 audit batches + form hang fix + A36 6 months ago
soma-audit-2026-04-21.md sium: v2.0 → v2.2 — Issue idlangref, SiumProvider, progressive forms, plurals 6 months ago
study.md soma: tier 1/2 components, Field/NumberField integration, codex_audit fixes 6 months ago

README.md

soma

Libreria headless de componentes compuestos para Svelte 5.

soma es la evolucion de terra — un rediseño desde cero que elimina el boilerplate, organiza las utilidades por responsabilidad, y define reglas claras de pertenencia.


1. Proposito

soma resuelve behavior, accesibilidad, composicion y estado para componentes compuestos. No resuelve presentacion visual — eso es responsabilidad de air/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, es air-native — vive directamente en air con su propia state class.


3. Independencia

soma no importa de terra
terra no importa de soma
soma no importa de air
soma no importa de $lib/util/reactive

soma solo depende de:

  • svelte (runes: $state, $derived, $effect)
  • runed (Context, watch)
  • clsx (class merging)
  • @floating-ui (positioning)

Todo lo demas vive dentro de src/uix/soma/.

Imports

Dentro de soma (provider files, layer files, utility files): usar paths relativos. Hace la libreria portable sin forzar aliases en el build del consumidor.

// Inside soma — relative
import { DRAWER_LANGS } from './langs';
import type { DrawerSide } from './types';
import { Presence } from '../../layers/presence.svelte';

Consumidores (layouts, app code, test pages): usan el alias $soma/ que configuran en su build.

// Consumer code — alias
import { componentLangs } from '$soma/core/langs';
import * as Drawer from '$soma/components/drawer';

soma no importa de $lib — cualquier utilidad que necesite (e.g., funciones triviales) debe vivir dentro de soma.

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 §4b.


4. Estructura

src/uix/soma/
│
├── README.md
│
├── reactive/              ← sistema reactivo (propio, sin deps externas)
│   ├── reactive.svelte.ts ← state, readableActive, writableActive, autoReset
│   └── index.ts
│
├── provider/              ← Provider base class + context
│   ├── provider.svelte.ts ← Provider<TOpts> base class
│   ├── context.ts         ← context<T>(name) — wraps runed Context
│   └── index.ts
│
├── props/                 ← prop merging
│   ├── merge-props.ts     ← mergeProps(...sources)
│   ├── compose-handlers.ts
│   └── index.ts
│
├── attrs/                 ← data-* system (consumes morfo — see §4b)
│   ├── create-attrs.ts    ← createAttrs(morfo) — literal-typed attr map
│   ├── contracts.ts       ← registerContract(morfo), assertContract
│   ├── helpers.ts         ← boolToStr, boolToEmptyStrOrUndef, etc.
│   └── index.ts
│
├── keyboard/              ← keyboard system
│   ├── keys.ts            ← KEYS constant
│   ├── directional.ts     ← getDirectionalKeys
│   ├── is-using-keyboard.svelte.ts
│   └── index.ts
│
├── dom/                   ← DOM utilities
│   ├── core.ts            ← isHTMLElement, contains, getDocument, etc.
│   ├── env.ts             ← isBrowser, isIOS, isTouch
│   ├── context.svelte.ts  ← DOMContext class
│   ├── focus/             ← focus utilities
│   │   ├── focus.ts       ← focus(), focusFirst(), focusWithoutScroll()
│   │   ├── tabbable.ts    ← getTabbableCandidates(), edges
│   │   ├── roving-focus.svelte.ts ← RovingFocusGroup class
│   │   ├── arrow-nav.ts   ← useArrowNavigation()
│   │   └── guards.ts      ← isElementHidden, isFocusVisible
│   └── index.ts
│
├── events/                ← event system
│   ├── types.ts           ← EventCallback, typed helpers
│   ├── add-event-listener.ts
│   └── index.ts
│
├── css/                   ← style utilities
│   ├── parse.ts           ← cssToStyleObj, styleToString
│   ├── sr-only.ts         ← srOnlyStyles
│   └── index.ts
│
├── id/                    ← ID generation
│   ├── create-id.ts
│   └── index.ts
│
├── types/                 ← shared types
│   ├── component.ts       ← WithChild, WithRefOpts, Orientation, Direction
│   ├── events.ts          ← SomaEvent, SomaKeyboardEvent, SomaMouseEvent
│   ├── html.ts            ← PrimitiveDivAttributes, PrimitiveButtonAttributes
│   ├── guards.ts          ← isNull, isFunction, isNumberString
│   └── index.ts
│
├── layers/                ← behavior layers (ver §10)
│   ├── presence.svelte.ts       ← animation-aware mount/unmount
│   ├── focus-scope.svelte.ts    ← focus trap, auto-focus, restore
│   ├── dismissal.svelte.ts      ← escape + interact-outside (merged)
│   ├── text-selection.svelte.ts ← prevenir selection overflow
│   ├── scroll-lock.svelte.ts    ← body scroll lock con refcount
│   ├── resize-observer.svelte.ts ← ResizeObserver con lifecycle Svelte
│   ├── floating/                ← posicionamiento relativo a anchor (@floating-ui)
│   │   ├── floating.svelte.ts
│   │   ├── use-floating.svelte.ts
│   │   ├── safe-polygon.ts
│   │   ├── types.ts
│   │   ├── utils.ts
│   │   └── index.ts
│   ├── gesture/                 ← drag/swipe/resize gesture tracking
│   │   ├── gesture.svelte.ts   ← Gesture.base(), Gesture.drag(), Gesture.resize()
│   │   ├── velocity.ts         ← ring buffer + velocity calculation (pure)
│   │   ├── types.ts
│   │   └── index.ts
│   └── index.ts
│
├── core/
│   ├── soma.svelte.ts     ← Soma class (root instance, service accessors)
│   └── langs.ts           ← componentLangs (default translations for soma components)
│
├── components/
│   ├── internal/          ← componentes Svelte internos de soma
│   │   ├── arrow.svelte
│   │   ├── visually-hidden.svelte
│   │   ├── portal.svelte
│   │   ├── portal-consumer.svelte
│   │   └── index.ts
│   │
│   └── [componente]/      ← each headless component
│       ├── [comp]-provider.svelte.ts  ← Provider subclasses
│       ├── types.ts                   ← public props + canonical field shapes
│       ├── langs.ts                   ← idlangref constants
│       ├── components/                ← svelte wrappers
│       ├── exports.ts                 ← barrel (Provider, not Root)
│       └── index.ts
│
├── exports.ts             ← barrel principal
└── index.ts

4b. 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).
  • 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, air, eidos, sema y la docs auto-generada lo consumen todos.

¿Por qué morfo existe?

Antes de morfo, la información estructural de un componente vivía en seis sitios:

  1. createAttrs({ parts: [...] }) — nombres de partes dentro del provider.
  2. registerContract({ parts: {...} }) — enums de data-attrs en el provider.
  3. ARIA hard-coded en cada $derived.by(...) de props.
  4. Keyboard handlers distribuidos por el provider.
  5. Prose en el README.
  6. Selectores en CSS de air/eidos, .csem de sema y tablas de docs.

Renombrar una parte (content → panel) tocaba 6+ sitios sin verificación automática. Drift cross-layer (soma emite data-dialog-content, eidos estiliza data-dialog-panel) era silencioso.

Con morfo, todo se declara una sola vez. createAttrs y registerContract consumen el morfo directamente. El script npm run morfo:check valida el DOM real contra la declaración en CI.

Cómo soma consume un morfo

Cada provider raíz del componente empieza así:

import { createAttrs, registerContract } from '../../attrs';
import { dialogMorfo } from '../../../morfo/components/dialog';

const attrs = createAttrs(dialogMorfo);  // typed: { provider: 'data-dialog', trigger: 'data-dialog-trigger', ... }
registerContract(dialogMorfo);

createAttrs es genérica con const type parameter (TS 5.0+): infiere el tipo del objeto retornado a partir de la forma literal del morfo. Si un wrapper o provider escribe attrs.trigerr, es error de compilación. El soporte de autocomplete lista las partes válidas.

Requisito de autoría: cada morfo se declara como as const satisfies Morfo:

// ✅ Obligatorio
export const dialogMorfo = { ... } as const satisfies Morfo;

// ❌ Pierde literales, degrada `createAttrs` a `Record<string, string>`
export const dialogMorfo: Morfo = { ... };

Ver el dev guide completo en src/uix/morfo/README.md.

Validación

Tres capas atrapan tres clases de drift:

  • Schema (build/dev): validateMorfo(morfo) en src/uix/morfo/schema.ts valida shape (sium) + invariantes cruzados (kebabs únicos, partRef.target resuelve, stateRef.state está en states[], etc.).
  • Strict mode en assertContract (dev runtime): valida que los data-attrs emitidos por el provider tienen valores declarados en el morfo. Logs warnings en consola cuando algo se desvía.
  • CI: npm run morfo:check navega a cada demo y valida el DOM real contra el morfo; npm run morfo:vocabulary detecta divergencias de vocabulario canónico (open|closed, active|inactive, etc.).

Qué NO va en morfo

No Va en
Props del componente {component}/types.ts con JSDoc
Summary, comparativa, ejemplos {component}/README.md
Traducciones {component}/langs.ts (idlangref)
Event handlers, state machines {component}-provider.svelte.ts
Recetas visuales src/uix/eidos/ (futuro)

5. Sistema reactivo

Los runes de Svelte 5 ($state, $derived) son compiler magic — solo funcionan en archivos .svelte y .svelte.ts, y no se pueden pasar como valores entre clases o funciones en TypeScript puro. soma necesita exactamente eso: pasar estado reactivo como argumento de constructor para componer Providers, layers y gestures. La capa reactive/ resuelve esto con contenedores (Active<T>, State<T>) que envuelven runes y exponen .current — el mismo patrón que React refs y Solid signals. Cuando Svelte ofrezca signals exportables de primera clase, esta capa se convierte en un alias thin migrable sin tocar los consumidores.

Primitivas

// Mutable container — backed by $state
const count = state(0);
count.current++;

// Readonly derived — backed by $derived.by
const double = readableActive(() => count.current * 2);
double.current; // 2

// Writable derived — getter + setter (para two-way binding)
const bound = writableActive(
	() => pressed,
	(v) => (pressed = v)
);

Tipos

// Readonly reactive container
type Active<T> = { readonly current: T };

// Mutable reactive container
type State<T> = { current: T };

// Mapping helpers — wraps all properties
type ActiveProps<T> = { [K in keyof T]: Active<T[K]> };
type StateProps<T> = { [K in keyof T]: State<T[K]> };

Regla

Los providers de soma reciben sus opciones como Active<T> o State<T>. Los wrappers svelte convierten props normales a Active/State con readableActive/writableActive. Esta conversion es la frontera entre el mundo de props de Svelte y el mundo de clases reactivas de soma.


6. Sistema de contexto

Hay dos niveles: la funcion context() (interna) y los static methods del Provider (API publica).

Nivel interno — context()

import { context } from '../provider';

const ctx = context<AccordionProvider>('Accordion');

ctx.set(instance);          // registra en Svelte context
ctx.get();                  // lee — throws si no existe
ctx.getOr(fallback);        // lee con fallback

context() wraps runed's Context con mensajes de error descriptivos.

Nivel publico — static methods

Los consumidores (sub-parts, otros componentes) nunca tocan ctx directamente. Usan los static methods:

// Root provider registra via create()
static create(opts) {
    return new AccordionProvider(opts);
    // super() llama ctx.set(this) automaticamente
}

// Sub-parts leen via require() o get()
this.provider = AccordionProvider.require();  // throws
this.group = TooltipGroupProvider.get();      // undefined si no existe
Method Returns Use
create() instance Root crea + registra
get() instance | undefined Padre opcional
require() instance (throws) Padre obligatorio

7. Provider

Base class de la que heredan todos los componentes headless de soma.

import { Provider, context, type WithRefOpts } from '../provider';

export class AccordionProvider extends Provider<AccordionOpts> {
    static readonly ctx = context<AccordionProvider>('Accordion');
    static get() { return this.ctx.getOr(undefined) as AccordionProvider | undefined; }
    static require() { return this.ctx.get(); }

    static create(opts: AccordionOpts) {
        return new AccordionProvider(opts);
    }

    private constructor(opts: AccordionOpts) {
        super(opts, 'Accordion', 'provider', attrs.provider, AccordionProvider.ctx);
    }

    readonly props = $derived.by(() =>
        this.assertProps({
            ...this.baseProps,
            'data-orientation': this.opts.orientation?.current,
            'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
        } as const)
    );
}

Lo que Provider proporciona

abstract class Provider<TOpts> {
    readonly opts: TOpts;
    readonly attachment: RefAttachment;

    constructor(opts, component, part, partAttr, ctx?, onRefChange?);

    protected get baseProps(): { id, [partAttr], ...attachment };
    protected assertProps<P>(props: P): P;  // validates data-* contract
}

Lo que cada componente define

  • static ctx — solo si usa context
  • static create() / get() / require() — static method convention
  • Constructor — llama super() con component/part info
  • readonly props = $derived.by(...) — ARIA, data-*, events
  • Event handlers — logica de interaccion
  • Derived state — valores computados

8. Sistema de attrs

import { accordionMorfo } from '$uix/morfo/components/accordion';
const attrs = createAttrs(accordionMorfo);

attrs.provider; // "data-accordion"
attrs.item;     // "data-accordion-item"
attrs.trigger;  // "data-accordion-trigger"
// Selector: `[${attrs.content}]`

Regla de naming

  • La parte provider genera data-{component} (sin sufijo -provider)
  • Las demas partes generan data-{component}-{part}
  • Estos attrs son API publica — cambiarlos es breaking change
  • air/eidos los usa como selectores CSS

Contracts (via morfo)

El morfo declara cada parte y sus data-* attrs (required/recommended/optional, con valores enumerados cuando aplica). assertProps() valida en desarrollo que los atributos emitidos cumplen el morfo. scripts/morfo-check.ts valida contra el DOM real.

Boolean helpers

boolToStr(true);              // 'true'
boolToEmptyStrOrUndef(true);  // ''
boolToEmptyStrOrUndef(false); // undefined
boolToTrueOrUndef(true);     // true
boolToTrueOrUndef(false);    // undefined

9. mergeProps

const merged = mergeProps(restProps, state.props);

Comportamiento:

  • Event handlers (onclick, onfocus, etc.) → compuestos con composeHandlers
  • class → merged con clsx
  • style → merged (object + string)
  • hidden: false / disabled: false → eliminados (fix Svelte)
  • Resto → last wins

10. Layers

Los layers son infraestructura compartida que los componentes compuestos usan para resolver problemas transversales.

Principio

layers/ contiene solo clases de comportamiento (.svelte.ts). Son infraestructura consumida por Providers.

Los componentes Svelte internos de soma (Portal, Arrow, VisuallyHidden) viven en components/internal/ — no son layers.

Behavior layers — consumo en Provider

Los behavior layers se instancian dentro del Provider y exponen .props para merge:

class DrawerContentProvider extends Provider<ContentOpts> {
    readonly focusScope = FocusScope.use({ ref, trap, ... });
    readonly dismissal = Dismissal.use({ ref, onEscapeKeydown, ... });
    readonly scrollLock = new ScrollLock();
    readonly gesture = Gesture.drag({ ref, direction, threshold, ... });

    readonly props = $derived.by(() => this.assertProps({
        ...this.baseProps,
        ...this.focusScope.props,
        ...this.dismissal.props,
        ...this.gesture.props,
        role: 'dialog',
    }));
}

Convencion .use() vs new vs Gesture.x()

  • X.use(opts) — el layer gestiona su propio lifecycle via watch/$effect. Constructor privado.
  • new X(opts) — el consumidor controla el lifecycle.
  • Gesture.base/drag/resize(opts) — factory functions, tres especializaciones.
Layer API Props Descripcion
Presence new Presence(opts) — Animation-aware mount/unmount. Expone isPresent.
FocusScope FocusScope.use(opts) { tabindex: -1 } Focus trap, loop, auto-focus, restore.
Dismissal Dismissal.use(opts) { onfocuscapture, onblurcapture } Escape + interact-outside. Global registry.
TextSelection TextSelection.use(opts) — Previene selection overflow durante drag.
ScrollLock new ScrollLock() — Body scroll lock con refcount.
ResizeObserver$ new ResizeObserver$(getter, cb) — ResizeObserver con lifecycle Svelte.
Gesture.base Gesture.base(opts) { onpointerdown } Pointer tracking + axis lock + velocity.
Gesture.drag Gesture.drag(opts) { onpointerdown } Base + progress + snap points + dismiss.
Gesture.resize Gesture.resize(opts) { onpointerdown } Base + delta + min/max constraints.
SafePolygon new SafePolygon(opts) — Hover-gap corridor between trigger↔content.

Gesture layer

Three specializations, shared base. The layer measures — the component decides what it means.

Gesture.base()    → pointer tracking + axis lock + velocity + cancel
Gesture.drag()    → base + offset + progress + snap points + threshold + dismiss
Gesture.resize()  → base + delta forwarding + min/max constraints

Key rules:

  • setPointerCapture deferred until moveBuffer exceeded for containers with child buttons (Drawer, Slider). Pure drag handles (Splitter) capture immediately — the handle IS the drag target
  • Cleanup on unmount: $effect(() => { return () => { this.gesture.cancel(); }; })
  • CSS vars (--drawer-progress, --drawer-offset-x/y) set by provider, consumed by visual layer
  • During drag: transition: none + inline transform for immediate feedback
  • Design: src/uix/soma/layers/GESTURES.md

SafePolygon

Handles pointer gap between trigger and floating content (DropdownMenu submenus, Tooltip). Calculates a corridor polygon — pointer can traverse the gap without closing. Used by any component where trigger and content have physical separation.

Patterns

Registry over DOM queries: Sub-parts register in a Map on mount, unregister on unmount. Keyboard nav iterates the registry instead of querySelectorAll. Used by Stepper triggers, Select labels.

Virtual focus (aria-activedescendant): For Select, Combobox — focus stays on trigger, items highlighted via data-highlighted. DOM focus (roving tabindex) for Menu, Toolbar, RadioGroup.

Exit animation via Presence: Toast, Drawer — dismiss marks element as dismissing, Presence animates exit, onComplete removes from array.

Naming

  • Archivo = concepto: dismissal.svelte.ts, presence.svelte.ts
  • Clase = concepto: Dismissal, Presence, FocusScope, ScrollLock, Gesture
  • Opts type: {Concepto}Opts: DismissalOpts, GestureBaseOpts
  • Sin sufijos redundantes: no Layer, no State, no Body

11. Keyboard

KEYS.ENTER;      // 'Enter'
KEYS.ESCAPE;     // 'Escape'
KEYS.ARROW_DOWN; // 'ArrowDown'
KEYS.SPACE;      // ' '

const { nextKey, prevKey } = getDirectionalKeys('ltr', 'horizontal');
// nextKey: 'ArrowRight', prevKey: 'ArrowLeft'

IsUsingKeyboard.current; // boolean — teclado vs pointer

12. DOM utilities

Focus

focusWithoutScroll(element)
focusFirst(candidates[])
getTabbableCandidates(container)
getTabbableEdges(container)

Roving Focus

const roving = new RovingFocusGroup({
    candidateAttr: attrs.trigger,
    rootNode: ref,
    loop: true,
    orientation: 'horizontal'
});
roving.handleKeydown(currentElement, event);

Scroll Lock

const lock = new ScrollLock();
lock.locked.current = true;  // locks body scroll
lock.locked.current = false; // restores

13. Soma (root instance)

Soma es la identidad runtime del framework. Lee servicios de App via context. No inyecta traducciones — el consumidor las carga.

Clase

class Soma {
    static create(opts?: SomaOptions): Soma; // crea y registra en context
    static get(): Soma | undefined;          // lee del context (safe)
    static require(): Soma;                  // throws si no existe

    readonly app: App;
    readonly portalTo: string | HTMLElement | undefined;

    // Service accessors (delegan a App)
    get langs(): AppLangs;
    get nums(): AppNums | undefined;
    get money(): AppMoney | undefined;
    get dates(): AppDates | undefined;
    get units(): AppUnits | undefined;
    get presentation(): AppPresentation;
    get logger(): AppLogger;
}

Traducciones

Soma no inyecta traducciones en langs. Exporta componentLangs desde core/langs.ts que el consumidor importa y extiende:

import { componentLangs } from '$soma/core/langs';

langs.extend('common', commonLangs);          // project-wide
langs.extend('components', componentLangs);     // soma components

Namespace structure:

common.buttons.close              ← shared (soma + eidos + app)
common.buttons.open
components.dialog.trigger         ← component-specific
components.drawer.trigger

Uso en Providers

// drawer/langs.ts — constants per component
export const DRAWER_LANGS = {
    TRIGGER: '#?components.drawer.trigger|Open drawer',
    CLOSE: '#?common.buttons.close|Close',
} as const;

// drawer-provider.svelte.ts
import { DRAWER_LANGS } from './langs';

class DrawerCloseProvider {
    readonly props = $derived.by(() => ({
        'aria-label': this.provider.soma?.langs.ts(DRAWER_LANGS.CLOSE),
    }));
}

Rules:

  • langs.ts() for simple strings, langs.t() only for interpolated templates
  • Fallback inline via idlangref (#?path|fallback), never ?? 'fallback'
  • No translate() helpers in providers
  • Common keys (close, open, cancel) in common.*, not per-component

Setup

<Soma>
    <App />
</Soma>

14. Naming conventions

Clases

  • AccordionProvider — root provider del componente
  • AccordionItemProvider — sub-parte
  • AccordionTriggerProvider — sub-parte
  • Provider — base class abstracta

Files

  • accordion-provider.svelte.ts — Provider subclasses (NOT accordion.svelte.ts)
  • accordion.svelte — root wrapper (in components/)
  • accordion-item.svelte — sub-part wrapper
  • types.ts — public props + canonical field shapes
  • langs.ts — idlangref constants
  • exports.ts — barrel (exports Provider, not Root)

Props

  • AccordionProps not AccordionProviderProps (root doesn't need "Provider" suffix in type name)
  • AccordionItemProps — sub-part
  • AccordionTriggerProps — sub-part

Documentacion de props (norma obligatoria)

Todas las props de todos los componentes deben documentarse con JSDoc en types.ts. Cada prop lleva: descripcion, @default si tiene valor por defecto, notas de comportamiento si aplica.

Comparacion con librerias de referencia

Cada componente headless debe compararse con su equivalente en ark-ui, bits-ui y radix-ui. Documentar: que props tienen ellos que soma no, y justificar si se omiten o se incluyen.

Attrs

  • data-accordion — root (sin -root)
  • data-accordion-item — sub-parte
  • data-state — estado compartido (open/closed, on/off, checked/unchecked)
  • data-disabled — flag de disabled
  • data-orientation — orientacion
  • data-dragging — gesture activo

Events

  • DOM handlers: onclick, onkeydown, onfocus (lowercase, Svelte convention)
  • User callbacks: onOpenChange, onValueChange (camelCase)

Layers

  • Archivo = concepto: dismissal.svelte.ts, presence.svelte.ts
  • Clase = concepto: Dismissal, Presence, FocusScope, ScrollLock, Gesture
  • Opts = {Concepto}Opts: DismissalOpts, GestureBaseOpts
  • Sin sufijos redundantes

15. Patron de componente

Provider ({name}-provider.svelte.ts)

import { accordionMorfo } from '$uix/morfo/components/accordion';
const attrs = createAttrs(accordionMorfo);

// Canonical field shapes defined in types.ts, referenced here
interface AccordionOpts
    extends WithRefOpts, StateProps<AccordionStateFields>, ActiveProps<AccordionActiveFields> {}

export class AccordionProvider extends Provider<AccordionOpts> {
    static readonly ctx = context<AccordionProvider>('Accordion');
    static get() { return this.ctx.getOr(undefined) as AccordionProvider | undefined; }
    static require() { return this.ctx.get(); }

    static create(opts: AccordionOpts) {
        return new AccordionProvider(opts);
    }

    private constructor(opts: AccordionOpts) {
        super(opts, 'Accordion', 'provider', attrs.provider, AccordionProvider.ctx);
    }

    readonly props = $derived.by(() =>
        this.assertProps({
            ...this.baseProps,
            '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 '../../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}

16. 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 super() → ctx.set(this)
  └── 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 via super() (pasa ctx al constructor de Provider)
  • Sub-parts leen con XProvider.require() (obligatorio) o XProvider.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

17. Relacion con air/eidos

soma   → headless behavior, accesibilidad, data-* contracts, context
air    → visual layer: tokens, CSS recipes, sizes, variants
eidos  → enhanced visual layer: motion, sound, advanced interactions

air/eidos consume soma:

  • Importa componentes: import { Accordion } from '$uix/soma'
  • Responde a data-*: [data-accordion][data-state='open'] { ... }
  • Añade props visuales: size, variant, color
  • Usa mismas translations: #?common.buttons.close|Close, #?components.dialog.trigger|Open dialog

air/eidos NUNCA:

  • Importa Provider classes internas de soma
  • Depende de estructura DOM incidental
  • Accede a propiedades privadas
  • Duplica behavior que soma ya resuelve

18. Checklist de componente nuevo

Referencia completa con todos los pasos en COMPONENT_GUIDE.md. Resumen:

[ ] 1. Comparar con ark-ui, bits-ui, radix-ui — feature table
[ ] 2. Verificar criterio de pertenencia
[ ] 3. Definir partes + attrs + contract
[ ] 4. Crear types.ts (props + canonical field shapes)
[ ] 5. Crear langs.ts (idlangref constants)
[ ] 6. Crear {name}-provider.svelte.ts (Provider subclasses)
[ ] 7. Crear wrappers .svelte (thin)
[ ] 8. Crear exports.ts + index.ts
[ ] 9. Crear test page + link en index
[ ] 10. svelte-check + test in browser

19. Inventory

Implemented

Accordion, Checkbox (Group), Collapsible, Combobox, ContextMenu, Dialog (AlertDialog variant), Drawer, DropdownMenu, Editable, LinkPreview, NumberField, Pagination, Popover, RadioGroup, ScrollArea, Select, Slider, Splitter, Stepper, Switch, Tabs, Table, TagsInput, Toast, Toggle (Group), Toolbar, Tooltip, TreeView.

Planned

Tier 2 (menu + form): Menubar, Command, Field/Form, FileUpload Tier 3 (dates): Calendar, RangeCalendar, DateField, DatePicker, DateRangeField, DateRangePicker Tier 4 (time): TimeField, TimePicker, TimeRangeField Tier 5 (color): ColorPicker, ColorField, ColorRangePicker, ColorRangeField Tier 6 (sound): SoundPicker, SoundRangePicker

Air-native (no soma)

Avatar, Badge, Button, Label, Meter, Progress, Separator, Spinner, PinInput, RatingGroup, ColorPicker, AspectRatio, Typography, Layout, Icon.

Powered by TurnKey Linux.