|
|
---
|
|
|
title: Soma — the headless behavior layer
|
|
|
type: reference
|
|
|
audience: human + agent
|
|
|
authority: E1 architecture — soma's entry and authoring guide (deep reference stays in SOMA_ARCHITECTURE)
|
|
|
status: current
|
|
|
source: 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 in
|
|
|
> [`SOMA_ARCHITECTURE.md`](./soma-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 of
|
|
|
`Context` + `watch` (formerly the `runed` dependency, removed 2026-07)
|
|
|
- `$adom` — `ElementSize` and the DOM-reactive runes (own ports rebuilt on
|
|
|
ActiveDom, also formerly `runed`)
|
|
|
- `$libs/dom` — `tabbable-core` for focus order (own port, formerly the
|
|
|
`tabbable` dependency, 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 of what soma actually reaches for is **soma's own
|
|
|
imports** — `package.json > dependencies` is the repo's runtime manifest, not
|
|
|
soma's, and currently declares `csstype` + `esm-env`, which no file under
|
|
|
`src/` imports.
|
|
|
|
|
|
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.
|
|
|
|
|
|
```ts
|
|
|
// 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.
|
|
|
|
|
|
```ts
|
|
|
// 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
|
|
|
in `src/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`](./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/*`):
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```ts
|
|
|
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>`):
|
|
|
|
|
|
```ts
|
|
|
// ✅ Mandatory
|
|
|
export const dialogMorfo = { ... } as const satisfies Morfo;
|
|
|
```
|
|
|
|
|
|
The execution model (how `SomaRuntime` transcribes the morfo into behavior)
|
|
|
lives in [`SOMA_ARCHITECTURE.md`](./soma-architecture.md)
|
|
|
§3.bis and §5.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 5. Deep reference
|
|
|
|
|
|
This document covers entry and authoring. The **architectural reference**
|
|
|
lives in [`SOMA_ARCHITECTURE.md`](./soma-architecture.md);
|
|
|
the **step-by-step implementation guide**, in
|
|
|
[`COMPONENT_GUIDE.md`](../../src/uix/soma/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)
|
|
|
|
|
|
```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)
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### Svelte wrapper ({name}.svelte)
|
|
|
|
|
|
```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}
|
|
|
```
|
|
|
|
|
|
### Authoring notes (common frictions)
|
|
|
|
|
|
Small clarifications that trip up first-time authors (and agents building from
|
|
|
the docs alone):
|
|
|
|
|
|
- **`state<T>()` vs `$state`.** Use the `state<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. a `labelId` that an optional
|
|
|
`Label` part sets so the Provider can reference it in `aria-labelledby`). It
|
|
|
returns a `State<T>` whose `.current` is mutable. Use the bare `$state` rune
|
|
|
for a **local** reactive field read/written directly in the same scope (e.g. a
|
|
|
provider's `dragging` flag).
|
|
|
- **`role` on a Provider.** `role` is `optional` on every part (morfo schema).
|
|
|
A Provider that renders a generic container (a `div` wrapping the interactive
|
|
|
parts) legitimately omits it; declare `role` only when that element itself
|
|
|
carries the semantics — Toggle/Switch's provider IS the button; the Knob's
|
|
|
*Control* part is `role: '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 idiom
|
|
|
`WithChild<{…}> & Without<PrimitiveDivAttributes, {}>` = the component's own
|
|
|
props **plus** the passthrough native attributes.
|
|
|
- **Who binds `pointermove`/`pointerup`.** A gesture's `.props` exposes **only**
|
|
|
`onpointerdown`; the gesture **layer** binds `pointermove` / `pointerup` /
|
|
|
`pointercancel` on the document itself (via `dom.listen`, after pointer
|
|
|
capture) — the provider never wires them. Spread `gesture.props` and you get
|
|
|
the whole gesture; don't add move/up handlers yourself.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 7. Composition pattern
|
|
|
|
|
|
Compound components follow the Provider → Parts pattern with context:
|
|
|
|
|
|
```svelte
|
|
|
<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) or `XProvider.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`](./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`](../guides/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 in
|
|
|
[`completion-checklist.md`](../guides/completion-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.
|