43 KiB
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
Somaclass 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:
- A concrete state class registers its parts with
SomaRuntime.part(...) - A thin
.sveltewrapper converts props → Active/State - Derived props via
$derived.by+runtimePart.assert - 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}(notdata-{component}-provider, neverdata-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, withsyncAttrs: true, syncs morfo-derived attrs viadom.apply.runtime.partProps(part)— returns{ id, ref, marker, data-archetype? }. Static identity only (thedata-archetypeis cross-component classification; it never changes).runtime.keydown(part, event)— dispatches keys declared inmorfo.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.applywrites, Svelte does not render frompartProps. - Event handlers are synchronous. Async goes before the trigger.
- Guards (
if (disabled) return) live at the call-site, not inside the handler. events/VisualChannelmay useActiveDomthrough the injected projector;ActiveDomdoes not knowevents.morfo.events.commitsis descriptive, not executable. Smoke validates it.
Piloting (the incremental order)
- Toggle — the first case,
partPropsonly. - Collapsible — adds
keydown. - Toast — the first real test of
trigger()withintent. - Dialog — last, once layers + portal are validated.
See also:
architecture/morfo.md— declaration, archetypes, the 2-of-3 rulearchitecture/sema.md— theemitcontract, verb vocabularysrc/arts/adom/README.md—dom.applyarchitecture/eidos.md— what eidos consumes from the DOMarchitecture/overview.md§2.bis — the cross-layer view
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 bypartPropswhen the part declares an archetype. Eidos uses it for transversal selectors ([data-archetype=trigger] { ... }); sema can associate verbs by archetype.data-event*— emitted byevents.emit(through the VisualChannel) during a configurable hold. Eidos uses it to tint event transitions ([data-event^=emerge-dismiss]). Per-family hold values live inSEMA_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 rootsWithRefOpts—{ 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)
forceMountkeeps the element in the DOM always (for CSS transitions)onCompleteuses thegetAnimations()API, nottransitionend/animationendevents- Run-ID cancellation prevents stale callbacks on fast toggles
- JS-driver gating (the
motionoption): aspring(pure rAF) does not appear ingetAnimations().Presencereceivesmotion: EngineMotion(=soma.motion, relocated toarts/motion) and callsmotion.run(node, phase); it awaits itsfinishedALONGSIDEgetAnimations()before unmounting. For CSS presets (or nodes withoutdata-animation-style),runreturns an already-settled handle → the declarative path is unchanged. (Replaces the oldrunMotion/eidos.motionRunnerhook.)
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 withcomposeHandlers class→ merged with clsxstyle→ merged (object + string)- ARIA naming attrs (
ARIA_NAMING_ATTRS—aria-label) → FIRST wins. The framework-wide call shape puts the consumer'srestPropsfirst, so the consumer's explicit label beats the morfo's default (two-class precedence, A-85 — seearchitecture/morfo.mdStep 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:
svelteonly (clsxis imported inprops/props.tswithout 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}.sveltewrapper
- Avoids Vite module-resolution ambiguity with the
- Reflects what's inside: provider/state classes
- Root wrapper:
{name}.sveltein thecomponents/subdirectory - Export name: always
Provider, neverRoot
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: autooverriding 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
ProviderOptsfor no-DOM roots - State class file named like the wrapper —
select.svelte.ts+components/select.sveltecauses 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, neverRoot - 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 theX.create(),X.get(),X.require()statics - Importing from
$lib/ext/appin components — components access services throughSoma, never App directly from()as a factory name — usecreate()consistently- Re-implementing date/time helpers inside soma — extend
$libs/days(A23). Importing from$lib/util/dates(legacy vendored) or@internationalized/datedirectly is forbidden; use$libs/days. - Re-export façades over days — a soma module whose only job is to
forward
$libs/dayssymbols is dead weight. Consumers import from$libs/daysdirectly. - 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,SegmentStateshapes, KEYS-based predicates). No crossover readonlySegmentswithout a concrete value anchor — A24: warn viasoma?.logger.warnwhenvalueis undefined. Range components split intostartReadonlySegments/endReadonlySegments(A25)keydown.preventDefault()as the only guard on contenteditable segments — IME/paste/drop bypass keydown. Always addonbeforeinput: e => e.preventDefault()(A26)- Time placeholders as
'––'— usecreateSegmentContent/createTimeSegmentContentfromdias/segments.ts; time parts render ashh/mm/ss(A28) - Pickers that reimplement field/calendar/popover — compose via shared
writableActiverefs (A27). OnlyProvider,Triggerand 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)
HourCycleas'12h' \| '24h'— the canonical form is numeric12 \| 24(matchesIntl.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 sameSomaRuntimefortrigger/keydown. - SomaRuntime caches the morfo compilation (
compileMorfoby WeakMap) and registers theeffectsthat syncstate → attrsviadom.apply. - Naming:
Provider(neverRoot); child providers reference the parent asprovider, neverroot. A multi-part component's export keeps the compound shapeToggle.Provider + Toggle.Trigger + .... - Data-attr naming:
data-{component}(provider) anddata-{component}-{kebab}(sub-parts). Nodata-soma-*prefix. The compiler emits these viacompiled.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 withassertContract - 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)