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

43 KiB

title: Soma Architecture — the deep reference type: reference audience: human + agent authority: E1 architecture — soma's deep reference: runtime, layers, contracts, helpers, anti-patterns status: current source: migrated from src/uix/soma/SOMA_ARCHITECTURE.md (2026-07-02, docs-book F7.2)

Soma Architecture

The architectural reference for src/uix/soma.

soma is the headless-primitives layer of the UIX system. It implements behavior, accessibility and part composition; visual presentation is eidos's responsibility. Every component is first described as a morfo (the declarative contract) and soma materializes it through concrete state classes that register their parts with SomaRuntime.

1. Purpose

soma is UIX's headless-primitives layer: behavior, accessibility and part composition, on top of which complex interfaces are built without repeating context, focus, keyboard, aria-*, data-*, state synchronization, animations and floating positioning. It is neither a visual nor a product layer; the visual layer decides the look and feel.

The narrative purpose and the membership criteria (which component belongs in soma, which stays out) live in architecture/soma.md §1–§2. This document is the deep architectural reference.

2. Layer architecture

soma   → headless: behavior, accessibility, data-* contracts, context, services
eidos  → visual: tokens, CSS, themes, recipes, reactions to data-event-*
events → perception: sound/haptic/hold and dispatch of semantic occurrences
app    → product: final composition, content, business logic

Each layer has strict responsibilities:

soma provides

  • behavior (keyboard, focus, dismiss, scroll lock)
  • accessibility (ARIA, roles, live regions)
  • stable, validated data-* contracts
  • context and part composition
  • runtime services (langs, format, logger)
  • the animation system (presence, data-starting/ending-style, onComplete)
  • floating positioning (the in-house engine: layers/floating + $ethereal)

The visual layer provides

  • appearance (tokens, colors, typography, spacing)
  • visual tone (light/dark themes, variants)
  • opinionated design decisions (sizes, recipes)
  • CSS motion and visual reactions to data-event-*
  • responsive design

The visual layer NEVER

  • imports soma's internal state classes
  • depends on incidental DOM structure
  • accesses private properties
  • duplicates behavior soma already solves
  • uses data-* outside the published contracts

The boundary is the data-* attrs and the CSS variables soma exposes.

3. Design principles

3.1 The developer doesn't need to know the internals

The layers, the reactive system, the floating engine — they are internal implementation. The component developer interacts with:

  • concrete state classes + SomaRuntime
  • the Soma class for services
  • hierarchical barrel imports (import { Dialog } from '$soma/components')
  • explicit subpaths when public helpers are needed ($soma/provider, $soma/keyboard, $soma/runtime.svelte)

3.2 One pattern, not three

Every component follows the same pattern:

  1. A concrete state class registers its parts with SomaRuntime.part(...)
  2. A thin .svelte wrapper converts props → Active/State
  3. Derived props via $derived.by + runtimePart.assert
  4. Context for parent–child communication

DOM-less roots use ProviderOpts (optional ref); parts with DOM use WithRefOpts. The only exceptions are declarative parts that may live outside their provider (AnnounceRegion, FeedSentinel): if they find a provider they reuse its runtime; otherwise they create their own runtime from the current scope's Soma.

3.3 Layers as behaviors, not as wrappers

Layers are instantiated in the Provider's constructor and expose .props for merging. There is no wrapper-component nesting in templates.

// Correct: integrated behaviors
readonly focusScope = FocusScope.use({...});
readonly dismissal = Dismissal.use({...});
readonly props = $derived.by(() => this.runtimePart.assert({
    ...this.runtimePart.props,
    ...this.focusScope.props,
    ...this.dismissal.props,
}));
<!-- Incorrect: wrapper nesting (the terra pattern) -->
<ScrollLock>
	<FocusScope>
		<DismissibleLayer>
			{content}
		</DismissibleLayer>
	</FocusScope>
</ScrollLock>

3.4 Soma is a class, not a configuration

Soma is the framework's runtime identity. It is not a configuration file — it is the root object that provides services via context.

// In the wrapper: the prop and the preference resolve here
dir: activeDir(() => dir, soma),

// In a Provider: the default lands once
readonly soma = Soma.require();
readonly resolvedDir = $derived.by<Direction>(() => this.opts.dir.current ?? 'ltr');

soma.prefs.getDir() is one link of that chain, never the whole of it: a provider that calls it directly drops the consumer's dir prop. Contract: canon/direction-contract.md.

3.5 The data-* attrs are a public contract

The data-* attrs are the boundary between soma and the visual layer. Changing them is a breaking change.

Convention (mandatory, no exceptions):

  • provider: data-{component} (not data-{component}-provider, never data-soma-*)
  • part: data-{component}-{part}
  • state: data-state, data-disabled, data-side, data-align, data-orientation
  • animation: data-starting-style, data-ending-style
  • nesting: data-nested, data-nested-open

The names are emitted by the morfo compiler that SomaRuntime consumes. createAttrs(morfo) remains a typed helper for querySelector and tooling — not a registration system, and it writes nothing to the DOM. Any CSS selector, README string or snippet must match those generated names exactly. The contract validator (assertContract) only verifies enumerated values, not names or presence — name consistency is the component author's responsibility (checklist item 27).

3.6 Base accessibility is not delegated

soma solves ARIA by default. The consumer doesn't need to add role, aria-modal, aria-expanded, aria-controls, etc. — the Provider generates them.

Functional text resolves via langs.ts() with an idlangref. The morfo declares its text slots in morfo.texts (idlangrefs) and references them with v.translationRef(...); the multilingual catalog lives in src/uix/langs/components/{kebab}.ts. Shared text like close/cancel/save lives in common.* and is referenced with v.commonRef(...) or an absolute idlangref. A per-component langs.ts remains an optional convenience for imperative constants, not the canonical catalog.

3.7 Global DOM via ActiveDom

Soma uses the scope's ActiveDom for UIX-managed writes, document/window listeners, global queries, imperative focus and window scrolling. There is no soma/events façade: dom.listen(...) is the canonical surface for registering listeners with cleanup.

Local reads of an owned element (contains, closest, getBoundingClientRect, clientWidth, scrollTop) are not wrapped in ActiveDom; they are part of the component's local behavior.

3.8 Props documented, mandatorily

Every prop of every component carries JSDoc in types.ts. Each prop: a description, @default, behavior notes.

3.9 Comparison with references

Every component is compared against ark-ui, bits-ui and radix-ui before implementation. Props others have and soma doesn't are documented, with justification.

3.bis The closed architecture (post-2026-04-25)

The split of responsibilities between Morfo, Soma, Sema and ADom is closed in six pieces with disjoint responsibilities:

Morfo          declares
SomaRuntime    transcribes (lives in soma/)
Provider       supplies sources, targets and handlers
Effects        sync derived attrs
EngineSemantic dispatches signals to perceptual channels
VisualChannel  materializes the signal in the DOM (data-event*, hold, cleanup)
ADom           applies DOM mutations (the structural commit)

SomaRuntime — the missing piece

SomaRuntime is the piece that was missing between Morfo (declaration) and Provider (execution). It reads the morfo and produces the behavior.

One instance per component:

readonly soma = Soma.require();
readonly runtime = this.soma.runtime(morfo, {
	states: { open: () => this.opts.open.current },
	props: { disabled: () => this.opts.disabled.current },
	parts: { content: () => this.contentId.current },
	events: {
		open: () => {
			this.opts.open.current = true;
		},
		'close-cancel': () => {
			this.opts.open.current = false;
		}
	}
});

V1 API:

  • runtime.part(part, opts) — the single public API to register a part. Returns the handle (props, resolveProps, assert) and, with syncAttrs: true, syncs morfo-derived attrs via dom.apply.
  • runtime.partProps(part) — returns { id, ref, marker, data-archetype? }. Static identity only (the data-archetype is cross-component classification; it never changes).
  • runtime.keydown(part, event) — dispatches keys declared in morfo.keyboard.
  • runtime.trigger(eventName) — orchestrates the perceptual + state sequence.

The runtime is typed by ITS morfo (2026-08-10)

SomaRuntime<M> carries the morfo type, with no default argument: part kebabs (part / partProps / keydown / partRef), event names (trigger), and the events / actions handler keys in SomaRuntimeSources<M> are all checked against the declaration (PartKebabOf<M> / EventNameOf<M> / ActionNameOf<M>, the same extractors semaSelector uses on the consumption side). A provider annotates SomaRuntime<typeof xxxMorfo>; a wrapper that creates a runtime for one morfo narrows its sources the same way (createMenuDialRuntime).

Why no default: an untyped runtime is an anonymous emission surface — an event name or handler key the morfo never declared compiles, runs, and every consumer written FROM the morfo (sema packs, eidos recipes) aims at something that never happens. That is the drift class three audits measured (S-12, S-08/S-15, A-36) and the pack census guards on the consumption side; the emission side is closed at the type level instead of by census. SomaRuntime<Morfo> (the widened instantiation) re-opens it — the only honest use is the layer-contract table in src/uix/contracts.ts; anywhere else, treat it as drift.

The Provider in the new model

The provider no longer has local morfo-resolution helpers. It only supplies:

  • reactive getters for states, props, parts
  • synchronous handlers for the events
  • the glue for orthogonal layers (Presence, Dismissal, ScrollLock)

Each part-provider keeps the handle returned by runtime.part(...):

readonly runtimePart = runtime.part('trigger', {
	id: opts.id,
	ref: opts.ref,
	owner: this,
	syncAttrs: true
});

readonly props = $derived.by(() => this.runtimePart.props);

Three operations covering every scenario

// Structural change without a signal
provider.commitState(change);

// Structural change with a signal
provider.commitState(change, event);

// Signal without structural change
provider.emitEvent(event);

Internally:

async commitState(change, event?) {
  if (event) await this.soma.events?.emit(event);
  this.soma.dom.apply(change);
}

emitEvent(event) {
  void this.soma.events?.emit(event);
}

The runtime.trigger(eventName) sequence

1. imperative prewrite (transient markers like data-last-action)
2. await events.emit(event)
3. the provider's synchronous handler mutates state
4. effects derive and apply structural attrs (data-state, aria-*)

The runtime's effects listen to the reactive sources and re-apply attrs every time state changes. ADom is the only writer of mutable attrs.

Operational rules

  • partProps(part) emits static identity only. Mutables are written by ADom.
  • What dom.apply writes, Svelte does not render from partProps.
  • Event handlers are synchronous. Async goes before the trigger.
  • Guards (if (disabled) return) live at the call-site, not inside the handler.
  • events/VisualChannel may use ActiveDom through the injected projector; ActiveDom does not know events.
  • morfo.events.commits is descriptive, not executable. Smoke validates it.

Piloting (the incremental order)

  1. Toggle — the first case, partProps only.
  2. Collapsible — adds keydown.
  3. Toast — the first real test of trigger() with intent.
  4. Dialog — last, once layers + portal are validated.

See also:

Cross-layer hooks soma emits per the 2-of-3 rule

Soma writes to the DOM not only what it needs; it also writes what sema and eidos will consume. The "2-of-3" rule decides what enters the morfo and therefore what the runtime emits:

  • data-archetype — emitted by partProps when the part declares an archetype. Eidos uses it for transversal selectors ([data-archetype=trigger] { ... }); sema can associate verbs by archetype.
  • data-event* — emitted by events.emit (through the VisualChannel) during a configurable hold. Eidos uses it to tint event transitions ([data-event^=emerge-dismiss]). Per-family hold values live in SEMA_MAP.families[*].hold.
  • data-{component} / data-{component}-{part} — the classic structural markers. Eidos uses them for per-component selectors.

4. Component model

Soma's base shape is Component.Part:

import { Dialog } from '$soma/components';

Dialog.Provider; // root — creates context
Dialog.Trigger; // action — opens/closes
Dialog.Content; // content — integrated layers
Dialog.Overlay; // backdrop — presence
Dialog.Title; // ARIA metadata
Dialog.Description; // ARIA metadata
Dialog.Close; // action — closes

Provider (root)

Creates the central state, registers it in context, manages presence for content and overlay. It may or may not render DOM:

  • With DOM (Collapsible, Accordion): uses WithRefOpts, renders a <div>
  • Without DOM (Dialog, Popover): uses ProviderOpts, renders only children

Subcomponents

They read the root's state via .require(). They don't reimplement logic — they derive props, ARIA, data-*, events from the parent's state.

Portal

An internal component (components/internal/portal.svelte). Renders children into another DOM node. Svelte context is preserved.

Picker composition (shared state across providers)

Pickers (DatePicker, DateRangePicker, TimePicker, TimeRangePicker) do not reimplement Popover / Field / Calendar — they compose them with shared state. The root wrapper creates three (or more) Providers pointing at the same writableActive refs:

// Root wrapper
const sharedValue = writableActive(() => value, (v) => (value = v));
const sharedPlaceholder = writableActive(() => placeholder, (v) => (placeholder = v));
const sharedOpen = writableActive(() => open, (v) => (open = v));

{Name}PickerProvider.create({ value: sharedValue, placeholder: sharedPlaceholder, open: sharedOpen, ...config });
PopoverProvider.create({ open: sharedOpen, ... });
{Base}FieldProvider.create({ value: sharedValue, placeholder: sharedPlaceholder, ...config });
// Calendar/RangeCalendar/slider providers are created in their own wrapper (DatePicker.Calendar, TimePicker.HourSlider, …)

The picker exposes only unique wrappers for Provider, Trigger and the calendar/slider bridge. The remaining exports re-export from the composed components — their native data-* (data-popover-*, data-date-field-*, data-calendar-*, data-slider-*) stay the authoritative styling API. The picker only adds identity attributes (data-{picker}-trigger, data-{picker}-calendar) on its own wrappers.

Auto-close / auto-anchor: the PickerProvider exposes handleSelect(), which the calendar wrapper calls when a selection completes. Range pickers re-anchor placeholder so the final month lands in the rightmost visible column, never showing the user a month that doesn't contain their selection.

See A27 in component-guide.md for the full checklist.

5. Runtime parts

readonly soma = Soma.require();
readonly runtime = this.soma.runtime(accordionMorfo, {});
const runtimePart = this.runtime.part('provider', {
	id: opts.id,
	ref: opts.ref,
	owner: this,
	context: AccordionProvider.ctx
});

readonly props = $derived.by(() =>
    runtimePart.assert({
        ...runtimePart.props,
        'data-state': this.state
    })
);

SomaRuntime.part() centralizes each part's mechanics:

interface SomaRuntimePart {
	readonly attachment: RefAttachment | undefined;
	readonly props: Record<string, unknown>;
	resolveProps(bindings?): Record<string, unknown>;
	assert<P extends Record<string, unknown>>(props: P): P;
}

syncAttrs: true enables the imperative write via uix.dom for parts whose sources are already declared on the runtime. A provider still composing attrs in render props does not enable syncAttrs.

Two opts interfaces:

  • ProviderOpts — { id: Active<string>; ref?: State<HTMLElement | null> } — for DOM-less roots
  • WithRefOpts — { id: Active<string>; ref: State<HTMLElement | null> } — for parts with DOM

6. Layers

layers/ contains behavior classes only (.svelte.ts). They are infrastructure consumed by Providers, never directly by the consumer.

Inventory

Layer API Responsibility
Presence new Presence(opts) Animation-aware mount/unmount. isPresent, transitionAttrs, onComplete.
FocusScope FocusScope.use(opts) Focus trap, loop, auto-focus, restore. Singleton manager with a stack.
Dismissal Dismissal.use(opts) Escape + click-outside. Global registry. Behaviors: close, ignore, defer.
TextSelection TextSelection.use(opts) Prevents selection overflow during drag.
ScrollLock new ScrollLock(initial?, delay?) Body scroll lock with refcount. Supports a delay for animations.
ResizeObserver$ new ResizeObserver$(getter, cb) ResizeObserver with Svelte lifecycle.
Floating* FloatingProvider.create(), FloatingContent.create(opts), etc. Anchor-relative positioning — the in-house engine (layers/floating + $ethereal; @floating-ui is a parity-tests devDep).
Gesture.base Gesture.base(opts) Pointer tracking + axis lock + velocity.
Gesture.drag Gesture.drag(opts) Base + progress + snap points + dismiss.
Gesture.resize Gesture.resize(opts) Base + delta + min/max constraints.
SafePolygon new SafePolygon(opts) (floating/safe-polygon.ts) Hover-gap corridor between trigger↔content.
Stacking module-level registry (stacking.svelte.ts) Shared z-order of movable surfaces (FloatPanel): bringToFront, data-topmost/data-behind.
AxialDrag new AxialDrag(opts) (manipulation/) Single-axis drag with snap points + release state.
ZoomPan new ZoomPan(config) Scale + pan of content inside a fixed viewport (Cropper); pure state + math.
ImageProvider new ImageProvider(opts) Image load state (idle/loading/loaded/error) with delay.
ListSelection pure functions (list-selection.ts) The single/multi selection machine + allowDeselect, shared by Select/Combobox.

layers/floating/placement.ts is the single source for Side, Align, Boundary, SIDE_OPTIONS and ALIGN_OPTIONS. floating/types.ts consumes that source and does not import from the floating.svelte.ts runtime, avoiding cycles between types and classes.

Provider test coverage

Every active Soma provider has a direct *-provider.svelte.test.ts — the guard returns NO_MISSING_PROVIDER_TESTS when a provider ships without one, so the live inventory is the test tree itself (one test file next to each provider). The reusable engines live outside Soma and carry their own tests: $libs/datagrid (table core), $libs/forms (form core + Standard Schema) and $libs/strings (Command's scorer).

Convention

  • .use(opts) → self-managed lifecycle (internal watch/$effect). Private constructor.
  • new X(opts) → manual lifecycle. The consumer controls it.
  • .props → an object to spread into the Provider.

Animations (Presence)

Lifecycle:

OPENING:
  open=true → shouldRender=true + data-starting-style
  → next rAF: data-starting-style removed (triggers CSS transition)
  → getAnimations().finished → onComplete(true)

CLOSING:
  open=false → data-ending-style (element stays in DOM!)
  → getAnimations().finished
  → shouldRender=false + data-ending-style removed → onComplete(false)
  • forceMount keeps the element in the DOM always (for CSS transitions)
  • onComplete uses the getAnimations() API, not transitionend/animationend events
  • Run-ID cancellation prevents stale callbacks on fast toggles
  • JS-driver gating (the motion option): a spring (pure rAF) does not appear in getAnimations(). Presence receives motion: EngineMotion (= soma.motion, relocated to arts/motion) and calls motion.run(node, phase); it awaits its finished ALONGSIDE getAnimations() before unmounting. For CSS presets (or nodes without data-animation-style), run returns an already-settled handle → the declarative path is unchanged. (Replaces the old runMotion/eidos.motionRunner hook.)

7. The Soma class (the component runtime scope)

Soma reads ActiveUix from context and exposes services to components. Components import Soma internals through relative paths, never from $active-app and never through their own $soma/* public alias. Nestable: a child <Soma portalTo="#modals"> overrides the parent.

class Soma {
	static create(opts?: SomaOptions): Soma; // factory + context set
	static get(): Soma | undefined; // safe read
	static require(): Soma; // throws if not found

	readonly uix: ActiveUix;
	readonly portalTo: string | HTMLElement | undefined;

	// Service accessors (delegate to ActiveUix)
	get langs(): ActiveLangs;
	get nums(): ActiveNumbers | undefined;
	get money(): ActiveCurrency | undefined;
	get dates(): ActiveDates | undefined;
	get units(): ActiveUnits | undefined;
	get prefs(): ActiveUixPrefsView;
	get logger(): EngineLogger;
	get motion(): EngineMotion; // arts/motion — Presence's JS-driver gating
}

Service access from components

Components access services through Soma, never through App directly:

const soma = Soma.get();
soma?.langs.ts('#?common.buttons.close|Close'); // translation via idlangref
soma?.prefs.getDir(); // the app's direction — one link of the chain, see below
soma?.money?.format(1099); // currency formatting
soma?.dates?.getDateOrder(); // DMY / MDY / YMD
soma?.dates?.getHourCycle(); // 12 | 24 (numeric — not '12h' / '24h')
soma?.portalTo; // portal target

A component never takes its own direction from that accessor: the wrapper runs activeDir(() => dir, soma) and the provider defaults once in resolvedDir (§3.4) — canon/direction-contract.md.

Date / time types and formatting

Soma imports date-related symbols from $libs/days, the canonical date library. Components never import from $lib/util/dates (legacy) or @internationalized/date directly. There is no Soma re-export façade for the date domain.

  • Value types: CalendarDate, CalendarDateTime, Time, ZonedDateTime
  • Types: DateValue, TimeValue, DateRange, DateMatcher, Month, WeekStartsOn, HourCycle, TimeGranularity, DateOrder, Granularity, SegmentPart, EditableTimeSegmentPart, TimeSegmentObj, SegmentValueObj, DayPeriod, …
  • Queries: isSameDay, hasTime, isZonedDateTime, isTimeBefore, isTimeAfter, today, now, startOfMonth, endOfMonth, getLastFirstDayOfWeek, getNextLastDayOfWeek, …
  • Operations: dateValueToDate, convertTimeValueToDateValue, convertTimeValueToTime, toCalendarDate, toZoned, …
  • Parsing: parseDate, parseDateTime, parseTime
  • Formatting: DateFormatter, getCachedDateFormat, getPlaceholder, getDefaultDate, getDefaultTime, inferGranularity, inferTimeGranularity, getDefaultHourCycle, resolveDateOrder(locale), resolveHourCycle(locale)
  • Segments (dias/segments.ts): constants (DATE_SEGMENT_PARTS, EDITABLE_TIME_SEGMENT_PARTS, …), type guards (isDateSegmentPart, isEditableTimeSegmentPart, isDateAndTimeSegmentObj, …), pure helpers (initializeSegmentValues, initializeTimeSegmentValues, getValueFromSegments, getTimeValueFromSegments, areAllSegmentsFilled, createSegmentContent, createTimeSegmentContent, getOptsByGranularity, getOptsByTimeGranularity).

HourCycle is canonically the numeric form 12 | 24 across the whole framework, matching Intl.DateTimeFormat's hour12 resolved option. String forms like '12h'/'24h' are legacy and must not appear in new code.

soma/datetime/ holds only UI-level helpers (the screen-reader announcer, DOM segment navigation, the SegmentState shape with lastKeyZero/hasLeftFocus/updating, isAcceptableSegmentKey using KEYS, description-element DOM writers). It must not re-export $libs/days symbols — consumers import from $libs/days directly. Extending $libs/days is the default for new date/time helpers; adding to soma/datetime/ is only correct when the helper is genuinely UI-specific.

The static-method convention (project-wide)

All classes using Svelte context follow the same pattern:

Method Returns Use when
X.create(opts) instance Creating + registering in context
X.get() instance or undefined Parent/context is optional
X.require() instance (throws) Parent/context is required

This applies to App, Soma, and every state class using context. No standalone functions. No from(). No exposed ctx.

Functional text

The component's own text slots are declared in the morfo as idlangrefs (the multilingual catalog lives in src/uix/langs/components/{kebab}.ts):

export const drawerMorfo = {
	name: 'Drawer',
	kebab: 'drawer',
	texts: {
		trigger: '#?components.drawer.trigger|Open drawer'
	},
	parts: [
		{
			name: 'Trigger',
			kebab: 'trigger',
			aria: [{ attr: 'aria-label', value: v.translationRef('trigger', 'Open drawer') }]
		}
	]
} as const satisfies Morfo;

Shared translations are not duplicated per component:

value: v.commonRef('buttons.close', 'Close'); // #?common.buttons.close|Close

ActiveUix registers the src/uix/langs/components/* catalogs into ActiveLangs under components.{kebab}.*. When the provider creates createSomaRuntime(morfo, sources) or soma.runtime(morfo, sources), registerMorfo(morfo) compiles and registers the data-* contract; the morfo only declares its slots (morfo.texts), not the catalog.

commonLangs in src/uix/langs.ts supplies the common.* defaults. ActiveUix registers them without overwriting existing leaves, so the integrator can pass their own translations and UIX only fills what is missing.

There is no global per-component catalog. ActiveUix wires the morfo registry; each component publishes its texts when its morfo registers.

8. The reactive system

A thin layer over Svelte 5 runes that lets reactive state be passed by reference between classes.

  • state<T>(initial) → State<T> (mutable, .current)
  • readableActive(() => value) → Active<T> (readonly derived)
  • writableActive(getter, setter) → State<T> (two-way binding)

Types:

type Active<T> = { readonly current: T }; // readonly container
type State<T> = { current: T }; // mutable container
type ActiveProps<T> = { [K in keyof T]: Active<T[K]> };
type StateProps<T> = { [K in keyof T]: State<T[K]> };

The .svelte wrappers convert plain props into Active/State with these functions. That conversion is the boundary between Svelte's prop world and soma's reactive-class world. Providers receive their options typed as StateProps<…> / ActiveProps<…>.

8.bis Internal helpers

Infrastructure modules providers consume. Not consumer API; imported by relative path inside soma.

props — mergeProps

const merged = mergeProps(restProps, state.props);
  • handlers (onclick, onfocus, …) → composed with composeHandlers
  • class → merged with clsx
  • style → merged (object + string)
  • ARIA naming attrs (ARIA_NAMING_ATTRS — aria-label) → FIRST wins. The framework-wide call shape puts the consumer's restProps first, so the consumer's explicit label beats the morfo's default (two-class precedence, A-85 — see architecture/morfo.md Step 4). Contract attrs keep last-wins: the runtime must win on state/wiring or the component lies.
  • hidden: false / disabled: false → removed (a Svelte fix)
  • the rest → last wins

provider — context()

context<T>(name) wraps $libs/reactive's Context with descriptive errors. Providers don't touch it directly: they expose the static create() / get() / require() methods (see §7).

const ctx = context<AccordionProvider>('Accordion');
ctx.set(instance); // registers in Svelte context
ctx.get(); // reads — throws if missing
ctx.getOr(fallback); // reads with a fallback

keyboard — KEYS, getDirectionalKeys

KEYS.ENTER; // 'Enter'
KEYS.ESCAPE; // 'Escape'
KEYS.ARROW_DOWN; // 'ArrowDown'
KEYS.SPACE; // ' '

const { nextKey, prevKey } = getDirectionalKeys('ltr', 'horizontal');
// nextKey: 'ArrowRight', prevKey: 'ArrowLeft'

IsUsingKeyboard.current; // boolean — keyboard vs pointer

dom — focus, roving, scroll lock

focusWithoutScroll(element);
focusFirst(candidates);
getTabbableCandidates(container);
getTabbableEdges(container);

const roving = new RovingFocusGroup({ candidateAttr, rootNode, loop, orientation });
roving.handleKeydown(currentElement, event);

const lock = new ScrollLock();
lock.locked.current = true; // locks body scroll

attrs — boolean helpers

boolToStr(true); // 'true'
boolToEmptyStrOrUndef(true); // ''
boolToEmptyStrOrUndef(false); // undefined
boolToTrueOrUndef(true); // true
boolToTrueOrUndef(false); // undefined

9. data-* contracts

The data-* attrs are formal public API, validated with assertContract().

Convention:

data-dialog                    → provider (no -provider, no -root)
data-dialog-trigger            → part
data-dialog-content            → part
data-state="open|closed"       → state
data-disabled                  → flag
data-side="top|right|bottom|left" → floating position
data-align="start|center|end"  → alignment
data-starting-style            → enter animation (1 frame)
data-ending-style              → exit animation (persists)
data-nested                    → is a child of another of the same type
data-nested-open               → has an open child
data-dragging                  → gesture drag active
data-highlighted               → item with virtual focus (aria-activedescendant)
data-resizing                  → splitter resize active

Exposed CSS variables:

--floating-transform-origin
--floating-available-width
--floating-available-height
--floating-anchor-width
--floating-anchor-height
--dialog-depth
--dialog-nested-count
--drawer-progress              → 0-1 drag progress
--drawer-offset-x / y          → drag offset in px
--toast-swipe-move-x / y  → toast swipe offset

10. IDs

IDs are generated with component context:

soma-dialog-c12
soma-dialog-trigger-c13
soma-dialog-content-c14

Pattern: soma-{component}-{part}-{uid}. Descriptive and inspectable.

11. Barrel exports

Components (hierarchical)

// $soma/components/index.ts
export * as Collapsible from './collapsible';
export * as Dialog from './dialog';
export * as Popover from './popover';

Consumption:

import { Dialog, Popover } from '$soma/components';
Dialog.Provider; // not SomaDialogProvider, not TerraDialogProvider
Dialog.Trigger;

Internal

import { Portal, Arrow, VisuallyHidden, Soma } from '$soma/components/internal';

12. External boundaries

soma distinguishes between:

  • internal: layers/, reactive/, dom/, provider/ — its own helpers
  • external: svelte only (clsx is imported in props/props.ts without being declared — it resolves as a transitive of svelte; a debt pending decision)

Floating positioning stopped being an external dependency: it is the in-house engine (layers/floating + $ethereal); @floating-ui survives only as a devDependency for the parity tests. runed and tabbable followed the same path (2026-07): they are no longer npm dependencies. runed's reactive runes were ported into $libs/reactive (Context, watch, Previous, Debounced, FiniteStateMachine, resource, …) and $adom (ElementSize, rebuilt on the ActiveDom runtime); tabbable's focus-order engine was ported into $libs/dom (tabbable-core, consumed by the existing tabbable.ts wrapper and surfaced through $adom). soma now depends on nobody but svelte. If a dependency has an unstable API or could change, it is accessed through a formal boundary (as layers/floating/ does with the positioning engine).

13. Directory structure

src/uix/soma/
├── SOMA_ARCHITECTURE.md       ← stub (this chapter lives in docs/architecture/)
├── COMPONENT_GUIDE.md         ← stub (the guide lives in docs/guides/component-guide.md)
├── README.md                  ← stub (the soma chapter lives in docs/architecture/)
├── runtime.svelte.ts          ← SomaRuntime (morfo interpreter)
├── errors.ts                  ← typed runtime/context errors
├── core/
│   └── soma.svelte.ts         ← the Soma class (root instance)
├── reactive/                  ← the reactive system
├── provider/                  ← context + opts bridge
├── props/                     ← mergeProps, composeHandlers
├── keyboard/                  ← KEYS, directional
├── dom/                       ← DOM utilities, focus
├── css/                       ← styleToString, cssToStyleObj
├── id/                        ← createId  (useId → $active-uix/id)
├── types/                     ← shared types + service interfaces
├── layers/                    ← behavior layers (classes only)
│   ├── presence.svelte.ts
│   ├── focus-scope.svelte.ts
│   ├── dismissal.svelte.ts
│   ├── text-selection.svelte.ts
│   ├── scroll-lock.svelte.ts
│   ├── resize-observer.svelte.ts
│   └── floating/
├── datetime/                 ← UI-only helpers (announcer, segment DOM nav,
│                                segment UI-state shapes, segment-key predicates,
│                                description-element writers). NO date math,
│                                NO re-exports of days — import `$libs/days`
│                                directly.
├── components/
│   ├── internal/              ← Portal, Arrow, VisuallyHidden, <Soma>
│   ├── {name}/                ← each headless component
│   │   ├── {name}-provider.svelte.ts  ← state classes (NOT {name}.svelte.ts)
│   │   ├── types.ts                  ← public props + canonical field shapes
│   │   ├── langs.ts                  ← optional idlangref constants for imperative strings
│   │   ├── exports.ts
│   │   ├── index.ts
│   │   └── components/
│   │       ├── {name}.svelte          ← root wrapper
│   │       ├── {name}-trigger.svelte
│   │       └── ...
│   └── index.ts               ← hierarchical barrel
└── index.ts                   ← root scope only (`Soma`)

File naming convention

  • State class: {name}-provider.svelte.ts — NOT {name}.svelte.ts
    • Avoids Vite module-resolution ambiguity with the {name}.svelte wrapper
  • Reflects what's inside: provider/state classes
  • Root wrapper: {name}.svelte in the components/ subdirectory
  • Export name: always Provider, never Root

14. Anti-patterns

Avoid in soma:

  • Complex logic inside the wrapper .svelte — it belongs in the Provider
  • Props drilling when context is the correct pattern
  • data-* attrs outside the contract
  • Inventing part names without checking the reference-library anatomies (ark-ui, bits-ui, radix-ui)
  • Nesting layers as component wrappers in templates
  • Inline z-index: auto overriding CSS
  • Coupling primitives to app libraries
  • Product copy inside the primitive
  • Speculative abstractions ("just in case")
  • One-line files that only re-export (merge into the parent)
  • Redundant naming prefixes (SomaDialog, DialogLayerState)
  • Dummy refs to satisfy a type — use ProviderOpts for no-DOM roots
  • State class file named like the wrapper — select.svelte.ts + components/select.svelte causes Vite module duplication. Always {name}-provider.svelte.ts
  • Event handlers not in props — defining onclick as a class method but not including it in the derived props object
  • getContext in event handlers — getContext only works during initialization. Capture references in the constructor
  • Exporting as Root — always Provider, never Root
  • Skipping the reference-library comparison — a mandatory step, no exceptions
  • Comments in Spanish — all code comments in English
  • Standalone context functions — no createX(), getX(), useX() as loose functions. Use the X.create(), X.get(), X.require() statics
  • Importing from $lib/ext/app in components — components access services through Soma, never App directly
  • from() as a factory name — use create() consistently
  • Re-implementing date/time helpers inside soma — extend $libs/days (A23). Importing from $lib/util/dates (legacy vendored) or @internationalized/date directly is forbidden; use $libs/days.
  • Re-export façades over days — a soma module whose only job is to forward $libs/days symbols is dead weight. Consumers import from $libs/days directly.
  • UI-level helpers in dias, or date math in soma/datetime/ — dias is pure (no DOM, no Svelte, no KEYS); soma/datetime/ is UI-only (the screen-reader announcer, DOM segment navigation, SegmentState shapes, KEYS-based predicates). No crossover
  • readonlySegments without a concrete value anchor — A24: warn via soma?.logger.warn when value is undefined. Range components split into startReadonlySegments / endReadonlySegments (A25)
  • keydown.preventDefault() as the only guard on contenteditable segments — IME/paste/drop bypass keydown. Always add onbeforeinput: e => e.preventDefault() (A26)
  • Time placeholders as '––' — use createSegmentContent / createTimeSegmentContent from dias/segments.ts; time parts render as hh/mm/ss (A28)
  • Pickers that reimplement field/calendar/popover — compose via shared writableActive refs (A27). Only Provider, Trigger and the calendar/slider bridge are unique parts
  • Demo pages as galleries of canned snippets — every soma demo must be an interactive testbed wiring every public prop to a live control, including a Field-integration section (A29)
  • HourCycle as '12h' \| '24h' — the canonical form is numeric 12 \| 24 (matches Intl.DateTimeFormat.hour12). String forms are legacy

15. Current shape (standing decisions)

Soma has gone through several phases. Its current shape (post-2026-05-08):

  • Provider inheritance dropped — providers no longer inherit from an abstract base; they are concrete classes. The shared DOM mechanics live in SomaRuntime.part(...), and cases needing semantic events use the same SomaRuntime for trigger/keydown.
  • SomaRuntime caches the morfo compilation (compileMorfo by WeakMap) and registers the effects that sync state → attrs via dom.apply.
  • Naming: Provider (never Root); child providers reference the parent as provider, never root. A multi-part component's export keeps the compound shape Toggle.Provider + Toggle.Trigger + ....
  • Data-attr naming: data-{component} (provider) and data-{component}-{kebab} (sub-parts). No data-soma-* prefix. The compiler emits these via compiled.parts.attrs.
  • State files: {name}-provider.svelte.ts (explicit, no ambiguity).
  • IDs: descriptive (soma-dialog-trigger-c13).

16. The stability rule

A soma component is considered stable when:

  • its public API is clear and JSDoc-documented
  • its data-* are registered and validated with assertContract
  • the wrapper and the Provider follow the general pattern
  • its base accessibility is solved (ARIA, roles, keyboard)
  • its props have been compared against ark-ui, bits-ui and radix-ui
  • it has a working demo page at web/routes/uix/components/{component}/
  • it doesn't depend on local hacks, hardcoded z-indexes or demo CSS to stand
  • it compiles with 0 errors (svelte-check)

17. New component checklist

See component-guide.md for the full step-by-step process (27 general steps + 4 date/time specific, with rules A1–A29). Summary:

[ ] 1. Compare with ark-ui, bits-ui, radix-ui — feature table
[ ] 2. Verify membership criteria
[ ] 3. Define parts + attrs + morfo.texts when the component owns text
[ ] 4. Create types.ts (props + canonical field shapes)
[ ] 5. Create langs.ts only for imperative idlangref constants, not as the catalog
[ ] 6. Create {name}-provider.svelte.ts (concrete state classes, no Provider inheritance)
[ ] 7. Create wrapper .svelte files (thin)
[ ] 8. Create exports.ts + index.ts
[ ] 9. Create interactive demo page + link in index (A29)
[ ] 10. README.md with anatomy, ARIA, data-attrs, comparison table
[ ] 11. svelte-check + test in browser
[ ] 12. Date/time components: only consume date/time domain via `$libs/days`,
        `onbeforeinput` on contenteditable, readonly-without-value warning,
        picker composition pattern (A23–A28)

Powered by TurnKey Linux.