|
|
---
|
|
|
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
|
|
|
<!-- 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.
|
|
|
|
|
|
```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<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`](../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<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(...)`:
|
|
|
|
|
|
```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^=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 `<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:
|
|
|
|
|
|
```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<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.
|
|
|
|
|
|
```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<T>(initial)` → `State<T>` (mutable, `.current`)
|
|
|
- `readableActive(() => value)` → `Active<T>` (readonly derived)
|
|
|
- `writableActive(getter, setter)` → `State<T>` (two-way binding)
|
|
|
|
|
|
Types:
|
|
|
|
|
|
```ts
|
|
|
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`
|
|
|
|
|
|
```ts
|
|
|
const merged = mergeProps(restProps, state.props);
|
|
|
```
|
|
|
|
|
|
- handlers (`onclick`, `onfocus`, …) → composed with `composeHandlers`
|
|
|
- `class` → merged with clsx
|
|
|
- `style` → merged (object + string)
|
|
|
- `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).
|
|
|
|
|
|
```ts
|
|
|
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`
|
|
|
|
|
|
```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, <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`](../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)
|
|
|
```
|