--- 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 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. ```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`): ```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, ActiveProps {} export class AccordionProvider { static readonly ctx = context('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 {#if child} {@render child({ props: mergedProps })} {:else}
{@render children?.()}
{/if} ``` ### Authoring notes (common frictions) Small clarifications that trip up first-time authors (and agents building from the docs alone): - **`state()` vs `$state`.** Use the `state(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` 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 = Omit`; `PrimitiveDivAttributes = OmitManaged>` (the div's native attrs minus the ones soma manages). The props idiom `WithChild<{…}> & Without` = 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 Click me Content here ``` ### 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.