--- 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`](./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. ```ts // 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, })); ``` ```svelte {content} ``` ### 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. ```ts // 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(() => 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`](../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: ```ts 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` 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` are all checked against the declaration (`PartKebabOf` / `EventNameOf` / `ActionNameOf`, the same extractors `semaSelector` uses on the consumption side). A provider annotates `SomaRuntime`; 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` (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(...)`: ```ts 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 ```ts // 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: ```ts 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: - [`architecture/morfo.md`](./morfo.md) — declaration, archetypes, the 2-of-3 rule - [`architecture/sema.md`](./sema.md) — the `emit` contract, verb vocabulary - [`src/arts/adom/README.md`](../../src/arts/adom/README.md) — `dom.apply` - [`architecture/eidos.md`](./eidos.md) — what eidos consumes from the DOM - [`architecture/overview.md`](./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 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`: ```ts 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 `
` - **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: ```ts // 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`](../guides/component-guide.md) for the full checklist. ## 5. Runtime parts ```ts 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: ```ts interface SomaRuntimePart { readonly attachment: RefAttachment | undefined; readonly props: Record; resolveProps(bindings?): Record; assert

>(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; ref?: State }` — for DOM-less roots - `WithRefOpts` — `{ id: Active; ref: State }` — 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 `` overrides the parent. ```ts 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: ```ts 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`](../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`): ```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: ```ts 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(initial)` → `State` (mutable, `.current`) - `readableActive(() => value)` → `Active` (readonly derived) - `writableActive(getter, setter)` → `State` (two-way binding) Types: ```ts type Active = { readonly current: T }; // readonly container type State = { current: T }; // mutable container type ActiveProps = { [K in keyof T]: Active }; type StateProps = { [K in keyof T]: State }; ``` 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` ```ts 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(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). ```ts const ctx = context('Accordion'); ctx.set(instance); // registers in Svelte context ctx.get(); // reads — throws if missing ctx.getOr(fallback); // reads with a fallback ``` ### keyboard — `KEYS`, `getDirectionalKeys` ```ts 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 ```ts 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 ```ts 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) ```ts // $soma/components/index.ts export * as Collapsible from './collapsible'; export * as Dialog from './dialog'; export * as Popover from './popover'; ``` Consumption: ```ts import { Dialog, Popover } from '$soma/components'; Dialog.Provider; // not SomaDialogProvider, not TerraDialogProvider Dialog.Trigger; ``` ### Internal ```ts 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, │ ├── {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`](../guides/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) ```