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

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

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

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 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 at pointerdown; pointer capture is deferred until movement — 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:

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