18 KiB
| title | type | audience | authority | status | source |
|---|---|---|---|---|---|
| Soma — the headless behavior layer | reference | human + agent | E1 architecture — soma's entry and authoring guide (deep reference stays in SOMA_ARCHITECTURE) | current | migrated from src/uix/soma/README.md (2026-07-02, docs-book F7.2) |
soma
A headless compound-component library for Svelte 5. The behavior layer inside
UIX — it emits the data-* and aria-* the contract declares in morfo,
manages state and events, and delegates the visual to eidos through the DOM.
API doctrine: soma keeps the compound shape (
Toggle.Provider,Tabs.Provider + Tabs.Trigger + ...) for symmetry with the multi-part components. Eidos does not invent a parallel flat API: it applies the visual layer over the anatomy declared by morfo and materialized by soma.
How to read this document: it is the entry + authoring guide — what soma is, which components belong to it and how one is built. The deep architectural reference (runtime, internal layers,
data-*contracts, anti-patterns) lives inSOMA_ARCHITECTURE.md; §5 maps where each topic lives.
1. Purpose
soma solves behavior, accessibility, composition and state for compound components. It does not solve visual presentation — that is eidos's responsibility.
soma exists for:
- keyboard navigation across a component's parts
- focus management (trap, scope, roving)
- ARIA relationships between parts (trigger↔content, tab↔panel)
- floating/positioning of overlays
- portal rendering
- presence management (enter/exit animations)
- dismiss on outside click / Escape
- gesture tracking (drag, swipe, resize)
- state machines for components with multiple states
- form integration (hidden inputs, validation context)
soma does NOT exist for:
- colors, typography, spacing, visual animations
- theme tokens
- responsive design
- iconography
- single-part components without complex behavior
2. Membership criteria
A component belongs to soma when it meets both criteria:
Part composition
The component has 2 or more subcomponents communicating via context. Example: Accordion has Root, Item, Trigger, Content — each part reads the parent's state.
Complex behavior
The component implements at least one of:
- Non-trivial keyboard navigation (roving focus, arrow keys, typeahead)
- Focus management (trap, scope, restore)
- Floating positioning (popover, tooltip, dropdown)
- ARIA relationships requiring cross-references by id (aria-controls, aria-labelledby)
- A state machine with transitions (open/closed, editing/preview)
- Drag/gesture behavior (slider, splitter, drawer, toast)
- Form integration via context (validation state, hidden inputs)
If a component meets only one criterion or neither, it does not need to go through soma — its logic can live directly in the eidos wrapper.
3. Independence
soma only depends on:
- svelte (runes: $state, $derived, $effect)
$libs/reactive— the repo's reactive runes, including the own ports ofContext+watch(formerly theruneddependency, removed 2026-07)$adom—ElementSizeand the DOM-reactive runes (own ports rebuilt on ActiveDom, also formerlyruned)$libs/dom—tabbable-corefor focus order (own port, formerly thetabbabledependency, removed 2026-07)$libs/days,$libs/datagrid,$libs/forms, etc. — the repo's pure utilities (no façades)$uix/morfo— the cross-layer contract (compileMorfo + SomaRuntime)$uix/sema— the semantic vocabulary + EngineSemantic
Floating positioning is an in-house engine (layers/floating + $ethereal);
@floating-ui remains a devDependency (demo + parity tests) and is imported
nowhere in the library. clsx was a PHANTOM for a while — imported by
props/props.ts without being declared (it resolved as a transitive) —
until 2026-07-11 (DEP-1, clean-room): inlined as the own toClassString
flattener in props/props.ts, import gone. The authoritative list is
package.json > dependencies.
soma does NOT depend on eidos. The visual layer reads from the DOM and from soma's public types; the coupling direction is eidos → soma, never the reverse.
Reusable engines that are not headless behavior live outside Soma:
src/libs/datagrid for tables, src/libs/forms for form state/validation
and src/libs/strings for scoring/fuzzy search. Soma does not re-export
them: consumers import those engines from $libs/*, their canonical source.
Imports
Inside a Soma component/layer: use relative paths for pieces of the same
component or of Soma. For cross-layer services/utilities use the canonical
alias ($libs/*, $uix/morfo, $adom) to make the ownership boundary
explicit. The $soma/* alias is public surface for consumers, not for Soma's
own internal imports.
// 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';
Consumers (layouts, app code, test pages) use the $soma/ alias
configured in their build.
// Consumer code — alias
import { Soma } from '$soma';
import * as Drawer from '$soma/components/drawer';
soma does import from its sibling package morfo ($uix/morfo), the
declarative contract of each component's DOM surface (parts, data-attrs,
ARIA, keyboard, focus). See §4.
4. Morfo — the cross-layer declarative contract
Every component has a file at src/uix/morfo/components/{kebab}.ts declaring,
in a single typed object, the component's public DOM surface:
- parts — the part tree (name, kebab, kind, defaultElement, role, states, supportsNesting).
- data — which data-attrs each part emits, with enum values where
applicable and a severity (
required/recommended/optional). - aria — which ARIA attributes each part emits, with the value source
typed via a tagged union (
v.literal,v.stateRef,v.partRef,v.propRef,v.translationRef) and an optional emission condition. - keyboard — the relevant keyboard shortcuts per part.
- focus — the focus policy for overlays (
initial,trap,return,restore). - texts — the component's own text slots, declared as idlangrefs
(
'#?components.{kebab}.{key}|Fallback'). The multilingual catalog lives insrc/uix/langs/components/{kebab}.ts. - apg — the WAI-ARIA APG pattern URL when one applies.
- scope — the layers implementing the component:
['soma'],['soma', 'eidos'], etc.
The morfo is the single source of truth for the public contract: Soma,
Eidos, Sema and the auto-generated docs all consume it. The full dev guide —
why morfo exists, archetypes, the 2-of-3 rule, validation and what does NOT
go in morfo — lives in architecture/morfo.md.
How soma consumes a morfo
Each root provider creates a runtime with its morfo. That step compiles the
declaration and registers the data-* contract (the per-component text
catalogs are registered by ActiveUix from src/uix/langs/components/*):
import { dialogMorfo } from '../../../morfo/components/dialog';
this.soma = Soma.require();
this.runtime = this.soma.runtime(dialogMorfo, sources);
When a provider needs DOM selector names it uses createAttrs(morfo) from
$uix/morfo — a typed name helper; it registers no contract and writes
nothing to the DOM:
import { createAttrs } from '$uix/morfo';
const attrs = createAttrs(dialogMorfo); // { provider: 'data-dialog', trigger: 'data-dialog-trigger', ... }
Authoring requirement: every morfo is declared as const satisfies Morfo
so the literals are not lost (a morfo typed : Morfo degrades createAttrs
to Record<string, string>):
// ✅ Mandatory
export const dialogMorfo = { ... } as const satisfies Morfo;
The execution model (how SomaRuntime transcribes the morfo into behavior)
lives in SOMA_ARCHITECTURE.md
§3.bis and §5.
5. Deep reference
This document covers entry and authoring. The architectural reference
lives in SOMA_ARCHITECTURE.md;
the step-by-step implementation guide, in
COMPONENT_GUIDE.md.
| Topic | Document |
|---|---|
| Execution model (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 |
The Soma class, services and date/time types ($libs/days) |
SOMA_ARCHITECTURE §7 |
The reactive system (state / readableActive / writableActive) |
SOMA_ARCHITECTURE §8 |
| Internal helpers (mergeProps, KEYS, focus, scroll lock) | SOMA_ARCHITECTURE §8.bis |
data-* contracts + CSS variables |
SOMA_ARCHITECTURE §9 |
| IDs, barrels, external boundaries | SOMA_ARCHITECTURE §10–§12 |
| Directory structure + naming | SOMA_ARCHITECTURE §13 |
| Anti-patterns + the stability rule | SOMA_ARCHITECTURE §14, §16 |
| Authoring checklist (steps 1–40 + rules A1–A37) | guides/component-guide.md |
| Acceptance criteria (machine-audited) | guides/completion-checklist.md |
6. Component pattern
Provider ({name}-provider.svelte.ts)
import { accordionMorfo } from '$uix/morfo/components/accordion';
// Canonical field shapes defined in types.ts, referenced here.
// EXPORTED so the wrapper can target-type its `bindProps<AccordionOpts>` call.
export 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
});
}
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)
);
}
Svelte wrapper ({name}.svelte)
<script lang="ts">
import { bindProps } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { AccordionProvider, type AccordionOpts } from '../accordion-provider.svelte';
import type { AccordionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'accordion'),
value = $bindable([]),
onValueChange = () => {},
disabled = false,
children,
child,
...restProps
}: AccordionProps = $props();
const state = AccordionProvider.create(
bindProps<AccordionOpts>({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
// The bindable-write callback rides the setter — no provider write can
// skip the notification (guide: Callback conventions).
value: {
get: () => value,
set: (v) => {
value = v;
onValueChange(v);
}
},
disabled: () => disabled
})
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
A part whose whole bag is { id, ref } — half the catalogue — uses partOpts
instead of a hand-built literal:
const state = AccordionItemProvider.create( partOpts( () => id, () => ref, (v) => (ref = v) ) );
The wrapper is also where the direction chain runs: a component with a dir
prop CALLS activeDir(() => dir) in its init (it publishes DirectionContext)
and passes the resulting box through the bag as an Active pass-through; the
provider defaults it once in resolvedDir —
canon/direction-contract.md.
Full pattern + the callback conventions:
guides/component-guide.md §Wrapper Pattern.
Authoring notes (common frictions)
Small clarifications that trip up first-time authors (and agents building from the docs alone):
state<T>()vs$state. Use thestate<T>(initial)helper ($reactive) for a reactive box you must pass by reference — a provider field a sub-part writes across the context boundary (e.g. alabelIdthat an optionalLabelpart sets so the Provider can reference it inaria-labelledby). It returns aState<T>whose.currentis mutable. Use the bare$staterune for a local reactive field read/written directly in the same scope (e.g. a provider'sdraggingflag).roleon a Provider.roleisoptionalon every part (morfo schema). A Provider that renders a generic container (adivwrapping the interactive parts) legitimately omits it; declareroleonly when that element itself carries the semantics — Toggle/Switch's provider IS the button; the Knob's Control part isrole: 'slider', not its provider container.Without<>/PrimitiveDivAttributes.Without<T, U> = Omit<T, keyof U>;PrimitiveDivAttributes = OmitManaged<HTMLAttributes<HTMLDivElement>>(the div's native attrs minus the ones soma manages). The props idiomWithChild<{…}> & Without<PrimitiveDivAttributes, {}>= the component's own props plus the passthrough native attributes.- Who binds
pointermove/pointerup. A gesture's.propsexposes onlyonpointerdown; the gesture layer bindspointermove/pointerup/pointercancelon the document itself viadom.listenat pointerdown; pointer capture is deferred until movement — the provider never wires them. Spreadgesture.propsand you get the whole gesture; don't add move/up handlers yourself.
7. Composition pattern
Compound components follow the Provider → Parts pattern with context:
<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>
Data flow
Provider
├── creates AccordionProvider
├── registers in context via runtime.part(..., { context, owner })
└── children
├── Item
│ ├── creates AccordionItemProvider
│ ├── reads AccordionProvider via AccordionProvider.require()
│ └── children
│ ├── Trigger → reads AccordionItemProvider.require()
│ └── Content → reads AccordionItemProvider.require()
└── Item
└── ...
Context rule
- The root always registers in context when creating its
runtimePart(runtime.part(..., { owner: this, context: XProvider.ctx })) - Sub-parts read with
XProvider.require()(mandatory) orXProvider.get()(optional) - If a sub-part has children that need its state, it creates its own context (Item has a ctx, Trigger reads it)
- Context is per component instance — multiple Accordions on the same page work independently
8. Relationship with eidos
soma → headless behavior, accessibility, data-* contracts, context
eidos → visual layer: tokens, CSS recipes, sizes, variants, event reactions
sema → perception/events: hold, sound, haptic
Eidos consumes Soma via the public data-* and the public subpaths
(import { Accordion } from '$soma/components/accordion'); it responds to
states ([data-accordion][data-state='open'] { ... }), adds visual props
(size, variant, color) and reuses the text catalogs. It never imports
internal Provider classes, never depends on incidental DOM structure, and
never duplicates behavior soma already solves.
The strict split of responsibilities between the layers and the data-*
boundary live in
SOMA_ARCHITECTURE.md §2.
9. Building a new component
Two documents cover the cycle, each with one role:
- How to build — the ordered authoring process (compare against reference
libraries, declare the morfo, write provider + wrapper, interactive demo,
verification) lives in
component-guide.md: the 1–40 checklist + rules A1–A37 with their rationale. - When it is done — the acceptance criteria across the four layers
(morfo · soma · sema · eidos + recipe CSS + demo), machine-audited by
npm run component:audit, live incompletion-checklist.md.
This document reproduces neither — they are the single source of their concern.
10. Inventory
The live component catalog is the set of directories under
src/uix/soma/components/; each declares its contract in
src/uix/morfo/components/{kebab}.ts with a scope field (['soma'],
['soma', 'eidos'], …). Hardcoding the list here would let it drift, so the
source of truth is the directory tree + the morfos.
Admission criteria
New pieces are accepted only if they meet §2's membership criteria and declare their morfo first.
Visual-native (not soma)
Avatar, Icon and SVG are eidos-native today. Single-part primitives like Badge, Button, Label, Separator, Spinner, AspectRatio, Typography or Layout should stay eidos-native unless real compound behavior appears.