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/docs/architecture/soma.md

472 lines
17 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
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<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.

Powered by TurnKey Linux.