You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/architecture/active-architecture.md

882 lines
43 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
title: UIX — Active Architecture
type: reference
audience: human + agent
authority: E1 architecture — the deep whole-system view: motivations, the four layers, the transcription chain, the hard rules
status: current
source: migrated from src/uix/active_architecture.md (2026-07-02, docs-book F7.2)
---
# UIX — Active Architecture
> The living document of UIX's active architecture: motivations, the four
> layers, how they articulate, what problem they solve, what they
> deliberately leave out. This doc is the whole-system view; the per-layer
> chapters are the operational reference. Dated status snapshots live in
> [`docs/process/`](../process/) (see §10).
---
## 0. Minimum contracts per module
What each module requires, what is optional, how it degrades and when it
fails. The ownership and degradation rules are stated, timelessly, in
[`architecture/active-uix.md`](./active-uix.md) §"Ownership and degradation
rules".
> Executable source: `src/uix/contracts.ts`. Boundary test:
> `src/uix/contracts.test.ts`.
```text
Module Requires Optional When missing Error
active-uix langs,prefs,dom* clipboard,format,events,portal standalone disabledDom missing langs/dom in attach
morfo none translations registers no translations no
soma dom events,langs,format,clipboard disabledDom from uix invalid morfo/event/part; absent optional service
sema projector/dom* sound,haptic,visual:false none SemaConfigError without dom/projector
eidos dom* langs,format,prefs,mode/density sources applyDom:false missing dom with applyDom active
adom ActiveDom surface target/window/breakpoints disabledDom only explicit ADom errors without a real DOM
* `dom` means an `ActiveDom` surface, not necessarily a real DOM. It may be
`disabledDom` only in standalone when the integrator asks for `dom:false`.
In attach it must come from `ActiveApp`.
* Outside `ActiveUix`, an `EngineSemantic` with the visual channel active
must receive `dom` or `projector`; `visual:false` is the explicit
degradation.
```
Future changes must derive from this table, not from constructors invented
in lower layers.
Applied correction: `ActiveUix` neither imports nor instantiates
`Soma`/`Eidos`. `portal` remains a generic UIX setting; `Soma` consumes it as
the default for `portalTo`, and `ActiveEidos.create(...)` creates the visual
scope when the app needs Eidos.
---
## 0.1 Canonical naming
The architecture may keep the historical folder names (`morfo`, `soma`,
`sema`, `eidos`), but the public surface must use a consistent grammar.
General rule: one name represents one concept; if a term is a historical
alias, it must be marked as such with a retirement path.
| Concept | Canonical name | Avoid / retire |
| ------------------------------------ | ----------------------------------------- | --------------------------------------------------------- |
| Runtime translation service | `langs` | `lang` as a service |
| Active language | `prefs.language` | `locale` for the translation language |
| Locale / regional formats | `prefs.locale` | `language` for formats |
| Declarative text catalogs | `translations` | `langs` inside `morfo`; global per-component tables |
| UIX preferences | `prefs` | `settings`, `presentation` as new names |
| UIX perceptual events | `events` | `semantic` as a public service |
| In-flight occurrence | `signal` | using it for the whole layer |
| A morfo event's semantic payload | `semantic` | mixing it with the runtime service |
| Declarative TS contract | `morfo` | `contract` as a duplicated TS API |
| Exported CSS/data contract | `contract` | `morfo` for external CSS |
| Runtime CSS bridge | `ActiveEidos` | a mandatory visual runtime for components |
| Independent pure engine | `EngineX` only if it lives outside `ActiveX` | decorative engines |
| Eidos visual root | `DrawerProps`, `DialogProps` | `DrawerProviderProps` in the visual API |
Decisions already applied:
- `ActiveUix.events` is the canonical name of the perceptual engine. In
attach mode it reads `app.events`; `defineUixServices(...)` declares the
service under the same name.
- There is no public `ActiveUix.semantic` service. `semantic` survives only
as the payload name in `morfo.events[].semantic`.
- `morfo.texts` is the declarative field for component-owned idlangrefs —
the `morfo.translations` nomenclature was renamed to `texts` during the
2026-05 migration (see `langs/components/*.ts` for the per-component
catalogs). `langs` remains the runtime service.
- `prefs` is the only name for preferences. `ActiveUix` exposes the raw
`ActivePrefs`; Soma/Eidos consume bounded views. No `settings` is
introduced.
- `ActiveUix.motion` (`EngineMotion`, `arts/motion`) is the animation engine,
consumed by Soma (`soma.motion`) and Eidos (`eidos.motion`). It lives in
`arts/`, not in Eidos, so Soma can animate (spring) without a soma→eidos
dependency. In attach it reads `app.motion`.
Retirement order:
1. Keep `assertContract` as a data-contract validator, not as a parallel
registry. `registerContract` remains for tooling/direct tests; Soma
registers contracts via `registerMorfo()`.
2. Only afterwards clean up prop names in visual components.
---
## 1. The thesis in one sentence
> UIX treats a component as **four layers with explicit contracts**, not as a
> monolithic block mixing structure, behavior, semantics and presentation.
The four layers are **Morfo · Soma · Sema · Eidos**. Each does one sharp job
and communicates with the others only through the DOM and a shared
declarative contract. None invades the next.
---
## 2. The problem it solves
In most UI frameworks a component accumulates:
- the DOM's **public contract** (attributes, parts, ARIA)
- the **headless behavior** (state, keyboard, focus, events)
- the event's **semantics** (what "opening a dialog" means beyond an
attribute change)
- the **visual layer** (CSS, animations, theming)
- the **modal engines** (sound, haptic and CSS reactions via DOM events)
- the **app-service integration** (i18n, dates, theme, etc.)
All of that lives mixed together. Renaming a `part` touches six places with
no automatic verification. An event's semantics are buried in hardcoded
strings only the component knows. CSS couples to incidental DOM structure.
Sound engines rewrite per-component mappings. When you want to change a
cross-cutting decision — "all triggers must share a common hover dim" — you
must enumerate the 30 components that have a trigger.
UIX breaks that block into four layers with disjoint responsibilities and a
common communication channel: **the DOM with attributes declared by the
cross-layer contract**.
---
## 3. The four layers
### Morfo — the cross-layer contract
`Morfo` declares the component's genetics: its parts, the `data-*` it emits,
the ARIA it contributes, the roles, the states, the semantic events it may
fire, and the keys it dispatches. One declaration per component, in
TypeScript, validated by sium.
Morfo **executes nothing**. It is DNA, not protein.
```ts
// src/uix/morfo/components/dialog.ts (excerpt)
export const dialogMorfo = {
name: 'Dialog',
kebab: 'dialog',
scope: ['soma', 'sema'],
events: [{
name: 'emerge-close-cancel',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
sequence: 'pre'
},
prewrite: [{ part: v.partRef('content'),
attr: 'data-last-action', value: 'cancelled' }],
commits: { part: v.partRef('content'),
attr: 'data-state', value: 'closed' }
}],
parts: [
{ name: 'Trigger', kebab: 'trigger', archetype: 'trigger', role: 'button', ... },
{ name: 'Content', kebab: 'content', archetype: 'content', role: 'dialog', ... },
// ...
]
} as const satisfies Morfo
```
Morfo is **the single cross-layer articulation point**. Any data the other
layers need to share with each other passes through here. It is the most
important structural rule: if two layers need to know the same thing, that
"same thing" lives in morfo.
### Soma — the headless behavior
`Soma` consumes morfo and transcribes it into executable behavior. It reads
`morfo.events`, `morfo.keyboard`, `morfo.parts[].data` and `aria`, and
materializes them: dispatches keys, applies attributes to the DOM, manages
state, integrates with context (Field, Form, Soma).
Soma **decides no visuals**. It knows no colors. No transitions. No sounds.
Only states, events, focus, keyboard and how to materialize all of that in
the DOM.
Soma's central piece is `SomaRuntime`: a morfo interpreter that receives the
reactive sources from the provider (states, props, parts, events, actions)
and takes care of:
- emitting the static attrs (`partProps`)
- applying the state-derived attrs via `dom.apply` (effects)
- dispatching keys via `keydown(part, event)`
- executing events via `trigger(eventName)` with the full perceptual chain
The provider contributes **what the morfo cannot infer**: reactive getters
over internal state, concrete handlers, and the glue for orthogonal layers
(Presence, Dismissal, ScrollLock).
### Sema — vocabulary + perceptual channels
`Sema` defines the framework's canonical vocabulary and orchestrates the
**dispatch of perceptual signals** to a set of modular channels.
Canonical vocabulary (`SEMA_MAP` in `src/uix/sema/sema-map.ts`):
- **8 families** — `contact`, `commit`, `signal`, `handle`, `emerge`,
`shift`, `sustain`, `delegate`. Each declares a `hold`, a base for the real
channels (`sound`, `haptic`) and the set of active channels.
- **6 intents** — `neutral`, `affirm`, `fulfill`, `risk`, `threat`, `loss`.
Each intent declares per-channel `deltas` applied over the family base when
the family is valenced.
- **Action verbs** (`SEMA_VERBS` in `src/uix/sema/verbs.ts`) — `present`,
`dismiss`, `commit`, `cancel`, `announce`, `warn`, … — the canonical verbs
for `morfo.events[].semantic.verb`, and the tail of `morfo.events[].name`.
Sema **does not decide which event happened** — the provider decides. The
`EngineSemantic` only:
- keeps a registry of channels implementing `Channel`
- generates each occurrence's `id`
- resolves the per-channel `EffectiveSignature` (base × intent deltas)
- dispatches each signal to every registered channel
- blocks the caller only for as long as the visual channel needs
```
src/uix/sema/
├── engine.ts registry + channel prepare/dispatch
├── resolver.ts resolveSignature(signal): EffectiveSignature
├── sema-map.ts per-family base + per-intent deltas table (typed)
├── verbs.ts SEMA_VERBS catalog
└── chans/
├── types.ts Channel interface
├── visual.ts VisualChannel (built-in, data-event projection + hold)
├── sound.ts SoundChannel (Web Audio, prepare-time priming)
└── haptic.ts HapticChannel
```
The **visual channel** (built-in) is the only one sharing the DOM plane with
the subsequent structural commit, and therefore the only one that blocks the
caller. `EngineSemantic` runs generic channel hooks; `VisualChannel.prepare()`
projects `data-event` + `data-event-id` + `data-event-phase` (and optionally
`data-event-family`, `data-event-intent` and `data-event-direction`) onto the
target through a
`SignalProjector`. In `ActiveUix` that projector receives `uix.dom`, so attr
writes enter through the same DOM owner soma uses. `VisualChannel` holds the
configurable window and the cleanup removes the projection before resolving
the Promise (strict sequential semantics).
> **Namespace discipline**: the semantic projection writes **only**
> attributes under the `data-event-*` prefix. It never touches `data-state`,
> `data-intent`, `data-disabled` or other state attrs — those belong to the
> runtime/morfo. Eidos reads `data-event-intent` for reactions to the
> transient signal and `data-intent` (when the morfo emits it) for the
> persistent state.
The visual channel's internal hold defaults come from
`SEMA_MAP.families[*].hold` and resolve over the perceptual scale
`SEMA_DURATIONS`: `glimpse`, `brief`, `noticed`, etc. The integrator can
override per signal (`signal.hold`) or globally via
`new EngineSemantic({ dom, visual: { defaultHold } })`.
The **SoundChannel** is implemented: it synthesizes short earcons via Web
Audio (two oscillators → biquad lowpass → ADSR-lite envelope, parameterized
by `effective.sound.{pitch, centroid, gain, contour, roughness, duration}`).
It does **prepare-time priming**: it creates + resumes the `AudioContext` in
the `prepare()` of an audible signal, synchronously inside the user gesture.
Only afterwards does it register the capture-phase listener on `document` for
later re-resumes. It is opt-in: `new EngineSemantic({ sound: true })`.
Reusable sound signatures live in `src/uix/sema/sounds.ts`. Component packs
reference names (`sound('handle.pickup.air')`, `sound('notification.ping')`)
or dynamic recipes, never loose constants. A repository entry can be
synthetic or an external `.wav` with a synthetic fallback; `SoundChannel`
plays `sampleUrl` and falls back to synthesis when fetch/decode fails.
The **HapticChannel** is an opt-in channel; any non-visual channel is
fire-and-forget: it manages its own timing on its plane without affecting the
caller.
### Eidos — the visual layer
`Eidos` is the visual layer. Its access to the system is **the DOM**: it
reads parts, data-attrs, ARIA, archetypes and event signals the other layers
write. It does not import soma internals; it does not ask sema.
Eidos **is not just CSS**. It covers what the dead `air/` branch called the
"visual runtime" plus the token system — inheriting no code. Its current
structure:
```
src/uix/eidos/
├── active-eidos.svelte.ts ActiveEidos: visual runtime/context created by ActiveEidos.create
├── archetypes.css rules common to [data-archetype=*]
├── events.css global hints for [data-event-*] (sema visual)
├── generated/base.css foundation CSS generated from the base EidosConfig (incl. @font-face)
├── lib/ config support, recipes, CSS contract and shared types
└── components/{x}/ recipe + Svelte wrapper + per-component types
├── {x}.css recipe (selectors [data-{x}], variants)
├── {x}.svelte wrapper over soma's headless provider
├── types.ts visual Props + soma's public props
└── index.ts default root + attached parts
```
`ActiveEidos` is the source of truth for theming: primitives (color + alpha
scales, size map, spaces, control height, radius, border, opacity, z-index,
focus ring, layout, typography, shadow, motion, icon), semantic roles and
themes. It also resolves the active theme from its visual sources (`theme`,
`modeSource`, `densitySource` or defaults) and injects runtime CSS when the
app doesn't precompile it. External themes can come from CSS alone if they
honor the custom-property contract
(`themeSource: 'auto' | 'config' | 'css'`); `getCssContract()` publishes that
contract as typed data and `renderContractCss()` materializes it as empty CSS
from the config. Per-component recipe aliases (`--toast-*`, `--dialog-*`,
etc.) live in `EidosConfig.recipes` and are generated into
`generated/base.css`; the CSS recipes remain selectors/states, not a parallel
token source. `ActiveEidos.listRecipes()` and `getRecipeTokens(component)`
are the query surface for theme editors; they return names and defensive
copies, never mutable handles into the internal config. `ActiveEidos` can
also write runtime variables into its own style block, validating them
against the contract so a theme editor doesn't mutate CSS by hand, variable
by variable. Full-configuration persistence uses `EidosConfigDocument`
(`kind + version + options`), keeping `EidosConfig` a pure authoring object
with versioning at the storage/exchange edge.
`ActiveEidos` is also the context the Svelte wrappers consume:
`ActiveEidos.require()` exposes only the visual surface (`dom`, `langs`,
`format`, `prefs` and helpers like `resolve(...)`, `breakpoint(...)` and
`isBelow(...)`). Wrappers do not import `getActiveUix()` directly.
The public Svelte wrapper follows disciplined option C: a visual root
`<Drawer>` / `<Tabs>` / `<Checkbox>` and attached parts `<Drawer.Trigger>`,
`<Drawer.Content>`, etc. There is no public `Provider` and no flat
snippet-first API.
Selection rules (eidos reads, never writes):
```css
/* Style common to all triggers, component-independent */
[data-archetype='trigger'] {
cursor: pointer;
}
/* Tint the exit anim by cause (saved/cancelled/dismissed) */
[data-state='closed'][data-last-action='cancelled'] {
animation: ...;
}
/* React to a perceptual signal during the hold (200–260ms by family) */
[data-event-family='commit'][data-event-phase='active'] {
animation: eidos-commit-settle 260ms var(--ease-out);
}
/* Variant by transient intent (the signal's, not the state's) */
[data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
animation: eidos-announce-pulse-threat 400ms var(--ease-spring);
}
```
The DOM is the channel between events/sema and eidos. The `VisualChannel`
projects the occurrence through `SignalProjector` + `ActiveDom`; Eidos
reacts.
---
## 3.bis ActiveUix without `frontend` (closed)
`frontend` no longer exists as an active artifact. The cross-cutting source
of preferences is `ActivePrefs`, following the same pattern `ActiveApp` uses;
DOM projection is explicit and lives outside `ActiveUix`.
The current partition:
- `uix.langs` — language and translations; syncs from `prefs.language`.
- `uix.format` — regional formats; consumes `prefs.locale` as a
`LocaleSource`.
- `uix.clipboard` — clipboard write capability; in standalone it is created
unless `clipboard:false`, in attach it is consumed from `app.clipboard`
when a component asks.
- `uix.dom` — the single writer of global attrs via `dom.apply`.
- `uix.motion` — the animation engine (`EngineMotion`, `arts/motion`):
registers + runs `--state`-moment presets (CSS settle / JS
spring/waapi/rect drivers). Consumed by Soma (`Presence` via `soma.motion`)
and Eidos (`eidos.motion`: generates CSS + registers its presets). In
standalone it is created with the available `dom`; in attach it reads
`app.motion`.
- `uix.prefs` — effective cross-cutting preferences: `language`, `locale`,
`direction`, `motion`, `sound`, `haptic`, etc.
- `uix.portal` — the generic portal target; layers like Soma adapt it to
their API (`portalTo`) without `ActiveUix` knowing those layers.
Eidos stays outside `ActiveUix`'s surface: `ActiveEidos.create(...)` creates
the visual context and, when runtime CSS is needed, uses `uix.dom`,
`uix.langs`, `uix.format` and explicit `mode`/`density` sources when the
integrator doesn't want the defaults.
`prefs.direction` is the single source of effective direction. If the user
sets no intent, it derives from `prefs.language`; calling
`prefs.direction.set('rtl')` makes that override rule; calling
`prefs.direction.clear()` goes back to deriving. The `html[dir]` attribute is
only the DOM projection of that effective value; `html[lang]` is the
symmetrical projection of `prefs.language`, and travels with it because the
browser reads both from the DOM — font selection, hyphenation, screen-reader
announcement.
That projection answers the page, not the component. A component knows its own
direction because it resolves one — the chain runs the prop, then prefs, and
never reads the DOM projection back; a component that inherits from a parent
composes that link at the call site rather than adding a step to the resolver.
The projection is why the common case needs no per-component assertion at all,
and the rest — the chain, which attribute carries the assertion, which selector
may read it — is [`canon/direction-contract.md`](../canon/direction-contract.md).
DOM projection is split by ownership:
```text
ActivePrefsDomProjection -> dir, lang, data-motion, data-sound, data-haptic
ActiveEidos -> data-theme, data-mode, data-density
```
In standalone mode, `createActiveUix()` instantiates `ActivePrefs` with the
standard UIX preset and creates the configured services. In attach mode,
`attachActiveUix(app)` reuses `app.prefs` because `prefs` belongs to
`ActiveApp`'s core. `langs` and `dom` are the required services for attach:
if they are missing, `attachActiveUix(app)` fails early. `clipboard`,
`events` and `format` are optional; if a layer needs them and the app didn't
declare them, `ActiveUix`'s getter fails explicitly.
`ActiveUix` does not auto-project preferences onto the DOM. The cross-modal
projection exists in `arts/prefs` as `createActivePrefsDomProjection(...)`;
the composition root that wants those global attributes wires it. This
allows `ActiveApp` without UIX, UIX without Eidos, or Eidos with precompiled
CSS — without duplicate projectors.
When a UIX shell wants runtime visual mode, the canonical flow is:
```ts
const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom });
const eidos = ActiveEidos.create({
theme: 'base',
modeSource,
applyDom: true
});
```
`prefsProjection` and `eidos` are disposed with the shell. Light/dark mode is
not written into `prefs.theme`; it is passed to `ActiveEidos` as a visual
source.
---
## 4. How they articulate — the transcription chain
The four layers form a declarative transcription chain where each translates
the previous contract into its own language:
```
Morfo declares (TypeScript constant + sium schema)
↓
SomaRuntime transcribes (Soma — reading morfo + sources)
↓
Provider supplies sources/handlers (Soma — TypeScript class)
↓
Effects sync attrs (Soma — $effect + dom.apply)
↓
EngineSemantic dispatches signals (Sema — registry + prepare/dispatch)
↓
VisualChannel prepares data-event* (Sema — via SignalProjector/uix.dom)
↓
Eidos reads the DOM and applies CSS (Eidos — selectors + tokens)
```
Plus, in parallel (not in the chain):
- **ADom** materializes the `dom.apply`/`dom.remove` Soma asks for on the
derived structural attrs (data-state, aria-\*, etc.).
- **Sema's non-visual channels** (sound, haptic, future) receive the same
signal and materialize it in their modality — fire-and-forget.
The pieces with disjoint responsibilities:
| Piece | Responsibility | Doesn't do |
| ------------------- | ----------------------------------------------------- | ---------------------------------------- |
| **Morfo** | Declare the contract | Execute anything |
| **SomaRuntime** | Transcribe morfo into behavior | Decide business logic |
| **Provider** | Supply reactive sources + handlers | Write mutable attrs to the DOM |
| **Effects** | Apply derived attrs via `dom.apply` | Decide which attrs (morfo says that) |
| **EngineSemantic** | Channel registry + prepare/dispatch | Know DOM, audio, vibration |
| **VisualChannel** | Project `data-event*` via projector + awaited hold | Write structural attrs |
| **SignalProjector** | Project `data-event*` via `dom.apply` | Decide when to emit |
| **ADom** | Imperative DOM mutations for structural attrs | Know the upper layers |
`Eidos` stays outside that chain: it reads from the DOM; it does not
participate in the transcription.
---
## 5. The causal chain of one interaction
A concrete example: the user clicks a Toast's **×** button.
```
1. Browser fires click → Svelte calls Close.onclick
2. Close.onclick runs:
void this.toastItem.runtime.trigger('emerge-dismiss')
3. SomaRuntime.trigger('emerge-dismiss'):
3.1. Looks up event 'emerge-dismiss' in morfo.events ✓
3.2. Resolves target = the Item DOM element via partRef('item')
3.3. AWAITS events.emit({ target, name: 'emerge-dismiss', family: 'emerge' })
EngineSemantic dispatches the signal to ALL registered channels:
- VisualChannel.prepare(): SignalProjector applies data-event*
via dom.apply(target, data-event-family=emerge)
- VisualChannel.handle(): holds the window (240ms for emerge)
- cleanup: dom.apply(target, data-event*=undefined)
- SoundChannel, HapticChannel: fire-and-forget (not awaited)
The Promise resolves when the VisualChannel finished the cleanup
(strict sequential semantics)
4. SomaRuntime invokes the provider's handler:
sources.events['emerge-dismiss']() →
this.provider.toaster.dismiss(opts.toast.current.id) →
toast.dismissing = true (state mutation)
5. The runtime's reactive EFFECTS see that isOpen changed:
resolvePartAttrs recomputes the item part's attrs
dom.apply(target, { 'data-state': 'closed' }) on the next tick
6. Eidos (CSS) has been reacting throughout the sequence:
- during t=0..240ms: [data-event^="emerge-dismiss"] fires an @keyframes fade-out
(CSS animation, not transition: it runs full-duration even if the attr
disappears afterwards)
- at t≈245ms: [data-state="closed"] takes over
- the Presence layer applies data-ending-style; CSS finishes the animation
```
State is the single source of truth. The DOM is derivation. The perceptual
signal PRECEDES the structural change by the full hold (~240ms for emerge) —
the caller waits for the cleanup before mutating state, giving CSS a
perceivable window to choreograph the exit.
---
## 6. The primitives that travel between layers
### DOM attributes — the universal channel
Everything that travels between layers travels through DOM attributes:
| Attribute | Who writes | Who reads |
| ------------------------------------------- | --------------------------------------- | ----------------------------- |
| `data-{component}` | partProps (static) | Eidos (root selector) |
| `data-{component}-{part}` | partProps (static) | Eidos (part selector) |
| `data-archetype="trigger"` | partProps (static) | Eidos (transversal selector) |
| `id` | partProps | ARIA refs, tests |
| `role` | dom.apply (effect) | Screen readers, Eidos |
| `aria-*` | dom.apply (effect) | Screen readers, Eidos |
| `data-state="open"` | dom.apply (effect) | Eidos (variant selector) |
| `data-disabled` | dom.apply (effect) | Eidos (state selector) |
| `data-event="emerge-dismiss"` | sema.emit (transient) | Eidos (event selector) |
| `data-event-phase="active"` | sema.emit (transient) | Eidos |
| `data-event-id="sig-N"` | sema.emit (transient) | Future sound/haptic |
| `data-event-family="commit"` | sema.emit (transient) | Eidos (family selector) |
| `data-event-intent="risk"` | sema.emit (transient) | Eidos (signal tinting) |
| `data-event-direction="forward"` | sema.emit (transient, per-emit only) | Eidos (directional signature) |
| `data-color="primary"` | dom.apply (effect) | Eidos (per-token recipe) |
| `data-intent="risk"` | dom.apply (effect, optional per morfo) | Eidos (persistent state) |
| `dir` | prefs projection (page) / provider | Browser bidi, Eidos `:dir()` |
| `data-dir` | provider (resolved, opt-in per recipe) | Eidos (unconditional hook) |
| `data-last-action="cancelled"` | trigger prewrite | Eidos (exit tinting) |
| `data-starting-style` / `data-ending-style` | Presence layer | Eidos (animation hooks) |
**Operational rule**: what `dom.apply` writes, Svelte does not render. Static
identity (id + marker + archetype + ref attachment) ships via `partProps`.
State-derived attrs ship via `dom.apply` from effects. There is no
double-write.
### Cross-layer vocabularies
> **Canonical:** the semantic vocabulary (families, intents, verbs) lives in
> [`CANON.md`](../CANON.md). The summary below is for the cross-layer view;
> the canon + code are authoritative.
Two stable vocabularies anchor the articulation:
**Archetypes** — part categories that appear across multiple components. The
canonical inventory is the `ARCHETYPE_VOCABULARY` const
(`src/uix/morfo/types.ts`) — not copied here: a copied list drifted (it froze
at 24 while the code had 26). A `Trigger` of Dialog, Popover, DropdownMenu
and Tooltip is the same category — Eidos can style them transversally with
`[data-archetype=trigger]`.
**Verbs** (`src/uix/sema/verbs.ts:SEMA_VERBS`), grouped by family:
```
contact: press · tap · activate · focus · trigger · release
commit: select · unselect · toggle · save · submit · confirm · complete ·
fail · cancel · reset · discard · delete · restore · expire ·
acknowledge · apply · partial · block · move · set · remove ·
reorder · upload
signal: announce · notify · warn · alert · inform · emphasize · remind
handle: pick · carry · drop · drag · resize · reorder · rotate · scroll · zoom
emerge: present · dismiss · open · close · expand · collapse · reveal · hide
shift: enter-mode · exit-mode · navigate · route · step · return · context
sustain: start · progress · loading · waiting · syncing · processing ·
streaming · pending · retrying · upload · end
delegate: offer · plan · authorize · act · review · escalate · return
```
Verbs that look like one family but belong to another per the canon:
**select / toggle / acknowledge** are `commit` (they fix state; they are not
mere contact); **edit** is `shift.enter-mode` (it changes the regime).
`morfo.events[].name` **declares its family**: the shape is
`{family}-{verb}[-{nuance}]` (`commit-toggle`, `emerge-close-cancel`), and
`validateMorfo` rejects a name that does not start with its own family. That
lets Sema/Sound/Haptic/Eidos subscribe or style by family or verb without
enumerating components.
**Intents** (`src/uix/sema/sema-map.ts:SEMA_MAP.intents`), 6 values:
```
neutral — no affective load (default)
affirm — low positive ("all is well")
fulfill — resolutive positive ("goal accomplished")
risk — moderate negative ("check this")
threat — active negative ("alarm, immediate attention")
loss — consummated consequence (negative + low activation, posterior)
```
Intent is orthogonal to family: a `commit` can be `affirm` (subscribe),
`risk` (publish), `threat` (delete), or `neutral` (a plain toggle). The
provider declares it in `morfo.events[].semantic.intent` (literal) or
exposes it as a prop (`fromProp + supported subset`).
---
## 7. Hard rules
The operational invariants that keep the system coherent:
1. **Morfo knows no runtime code.** It is pure declaration.
2. **SomaRuntime depends on Dom and Semantic.** By construction, not by
import. The provider injects them.
3. **The provider does not write mutable attrs to the DOM directly.** It
supplies them as sources to the runtime.
4. **`Semantic` may use `Dom` (downward).** `Dom` does not know `Semantic`.
5. **`ADom` knows no upper layers.** It only applies the mutations,
listeners and cross-cutting DOM actions it receives.
The DOM boundary does not require wrapping local reads: a component may
call `el.contains(...)`, `el.closest(...)`, `el.getBoundingClientRect()`
or read its own element's `scrollTop`. By contrast, `document/window`
listeners, global queries, imperative focus and window scrolling go
through `ActiveDom`.
**Timing, not just ownership.** Layout-forcing reads
(`getBoundingClientRect`, `getComputedStyle`, `offset*`, `scroll*`,
`client*`) must run POST-LAYOUT, never synchronously right after a
DOM/style write — read-after-write forces a mid-turn reflow (the
`[Violation] Forced reflow while executing JavaScript` family). Defer them
with `dom.measure(read, node?)` (the frame-coalesced read queue, the
sanctioned vehicle) or from a `dom.raf` callback; a bare deferred read
complies just like `dom.apply` does for writes. To resolve a theme token
into a concrete color, use `eidos.resolveToken(token)` (config + the
`uix.color` engine) — NOT a `getComputedStyle` probe. The dev-only
`uix.perf` detector (opt-in `reflowDetector`) attributes violations at
runtime via Long Animation Frames. The framework governs layout READS the
same way `dom.apply` governs writes.
6. **`Eidos` consumes DOM and `data-*`, not Soma/Sema internals.** If it
needs something, it must be declared in morfo or emitted in a sema
signal.
7. **What `dom.apply` writes, Svelte does not render from `partProps`.** One
authority per attribute.
8. **State is the single source of truth. The DOM is derivation.** Handlers
mutate state; effects derive attrs.
9. **Event handlers in `runtime.trigger` are synchronous.** Async goes
before calling `trigger`.
10. **Guards live at the call-site, not inside the handler.** If the guard
reaches the handler, the perceptual signal was already emitted.
11. **`morfo.events[].commits` is descriptive, not executable.** It
documents the observable; smoke validates it.
12. **The 2-of-3 rule for extending Morfo.** A morfo extension is only
justified when **at least two of the three layers** (soma, sema, eidos)
consume it. Soma-only conveniences live in the provider via a virtual
prop.
---
## 8. The authorship / transcription distinction
A useful lens for deciding where each thing lives:
- **Authorship** — written once by a human, with intent. A component's
`Props`, the morfo, the event handlers. It lives in the author's
TypeScript.
- **Transcription** — mechanically derived from authorship. The provider's
`Opts`, the per-prop `readableActive(() => x)` wrapping, the structural
attrs. A helper / runtime / generator derives it.
UIX aims for only the authorship to be human. Transcription is code that
writes code:
| Authorship | Transcription | How |
| --------------------------------------- | ----------------------- | -------------------------------------------- |
| `Props` | `Opts` | hand-written `extends WithRefOpts, StateProps<>, ActiveProps<>` — or `OptsFromProps<P, Managed, StateKey, Preserve>` |
| Each wrapper prop | Active/State boxes | `bindProps<XOpts>({ ... })` — target-typed |
| A part's whole `{ id, ref }` bag | `WithRefOpts` | `partOpts(() => id, () => ref, setRef)` |
| `morfo.events[].commits` | Final DOM after handler | Effects derive |
| `morfo.parts[].data` | Attributes on each tick | Resolver + dom.apply |
| `morfo.events[].name` + canonical verb | `data-event="..."` | sema.emit |
Two details the table cannot carry, both load-bearing:
- **`Preserve` is not optional decoration.** `OptsFromProps` strips `undefined`
from an optional prop by default; the fourth parameter lists the keys whose
absence MEANS something (`dir` above all) and keeps their `T | undefined`.
Omitting it silently destroys the distinction —
[`canon/direction-contract.md`](../canon/direction-contract.md) §1.
- **`bindProps` is target-typed, never inferred.** The declared `Opts`
computes the config's expected shape (`ConfigFor<O>`), so keys, getter types
AND setter bodies are checked and the return IS the opts — no cast. Designs
that infer FROM the bag degrade the setter's parameter to `any`.
That last point has a behavioural twin, and it is a **cross-layer contract**,
not a build step:
> **No silent internal write.** Every internal write to a bindable notifies, by
> construction — the change callback lives INSIDE that key's setter, which is
> the only write path, so the provider cannot forget it. Exactly one named
> exception: a **coalesced** (debounced) notification, which is never lost
> because clear / submit / unmount flush it. A path that writes without
> notifying is a defect — the `bind:` consumer and the callback consumer would
> see different histories of the same component.
Rules and the third convention:
[`guides/component-guide.md`](../guides/component-guide.md) §Callback
conventions; acceptance row `E-3.7` in
[`guides/completion-checklist.md`](../guides/completion-checklist.md).
This distinction explains why the 2-of-3 rule holds: the morfo is
**cross-layer authorship**. If only soma needs something, it is
soma-internal transcription — not authorial, and it doesn't belong in morfo.
---
## 9. What this architecture is NOT
To avoid mission creep, it helps to fix what UIX **does not want to be**:
- **Not a visual collection.** Eidos is visual; UIX as a system is not.
- **Not an opinionated wrapper over existing primitives.** The four layers
are original; they don't wrap Radix/Headless UI.
- **Not a classic design system.** Tokens, themes and recipes belong to
Eidos, not to the core.
- **Not a monolithic event service that executes every modality.** Sound,
Haptic, Motion and future modalities register as **channels** of
`EngineSemantic`; each manages its own modality. The engine is only
registry + dispatch.
- **Not a global EventEmitter dressed up as architecture.** Every event has
a specific DOM target and a semantic owner declared in morfo.
- **Not a mini-DSL in JSON.** Morfo is descriptive declaration, not a
program. Logic lives in the provider's TypeScript; morfo only says which
attrs and which semantics.
---
## 10. Project status
> Dated status snapshots ("what is implemented as of X") live in
> [`docs/process/`](../process/) — e.g.
> `active-architecture-snapshot-2026-05.md`. This document describes the
> architecture, not the progress.
## 11. Acknowledged risks
No design is risk-free. UIX has four, explicitly:
### 11.1 Layer excess
If the boundaries don't stay sharp, the system feels more complex than what
it solves. The 2-of-3 rule and the "virtual prop" doctrine mitigate this,
but they require sustained discipline.
### 11.2 Names without discipline
`Morfo`, `Sema`, `Soma`, `Eidos` are names that only work if the contracts
are sharp. If Sema starts knowing about the DOM, or Soma decides visuals,
the names become decoration.
### 11.3 Responsibility invasion
The constant danger is one layer trying to do another's job:
- `Sema` becoming a multimodal runtime (a regression).
- `Soma` deciding CSS or motion.
- `SomaRuntime` interpreting business logic.
- `Eidos` reaching into soma internals.
UIX only works if each layer accepts its limits.
### 11.4 Lack of precedent
There are no UI systems with this exact composition. That means more
architectural freedom but also fewer external patterns to copy when an edge
case appears.
---
## 12. Why it can be worth it
If the boundaries hold, UIX offers something uncommon:
- **Architectural explainability.** Every decision falls into a recognizable
layer; "where does this live" has a predictable answer.
- **Less cross-layer drift.** The morfo is authoritative; the other layers
derive. Renaming a part touches one place, not six.
- **Automatic contract validation.** Sium schema + smoke + morfo-check catch
structural drift before it reaches production.
- **More freedom to introduce new engines.** Sound, Haptic, Motion, any
future modality registers as an additional `Channel` in `EngineSemantic`
without touching morfo or soma.
- **Honesty about framework-vs-integrator boundaries.** UIX provides
vocabularies, contracts, transport and extension points; it doesn't
pretend to decide every modality for every app.
The important idea:
> **Cross-modal coherence can be treated as the integrator's responsibility,
> not as the false promise of a centralized runtime that claims to know
> everything.**
---
## 13. The summary sentence
> **Morfo declares · SomaRuntime transcribes · Provider supplies · Effects
> sync · Semantic emits · Dom applies · Eidos reads.**
Seven words describing the whole chain. If an architectural decision
contradicts one of those seven, the decision is wrong — or the architecture
must evolve consciously.
---
## 14. To go deeper
- [`architecture/overview.md`](./overview.md) — the general positioning (more narrative)
- [`architecture/morfo.md`](./morfo.md) — declaration, archetypes, the 2-of-3 rule
- [`architecture/sema.md`](./sema.md) — the `emit` contract, canonical verbs
- [`SOMA_ARCHITECTURE.md`](./soma-architecture.md) — the runtime + components
- [`component-guide.md`](../guides/component-guide.md) — the operational guide to create/migrate components
- [`architecture/eidos.md`](./eidos.md) — the visual layer: tokens, themes, recipes, wrappers
- [`src/arts/adom/README.md`](../../src/arts/adom/README.md) — `dom.apply` + reactive DOM services
- [`guia-semantica-historica.md`](../decisions/guia-semantica-historica.md) — the original API conventions (historical seed; [`CANON.md`](../CANON.md) rules)
Detailed design decisions and historical trade-offs live in the
`active-uix` branch's git log.

Powered by TurnKey Linux.