43 KiB
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/(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 §"Ownership and degradation
rules".
Executable source:
src/uix/contracts.ts. Boundary test:src/uix/contracts.test.ts.
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.eventsis the canonical name of the perceptual engine. In attach mode it readsapp.events;defineUixServices(...)declares the service under the same name.- There is no public
ActiveUix.semanticservice.semanticsurvives only as the payload name inmorfo.events[].semantic. morfo.textsis the declarative field for component-owned idlangrefs — themorfo.translationsnomenclature was renamed totextsduring the 2026-05 migration (seelangs/components/*.tsfor the per-component catalogs).langsremains the runtime service.prefsis the only name for preferences.ActiveUixexposes the rawActivePrefs; Soma/Eidos consume bounded views. Nosettingsis introduced.ActiveUix.motion(EngineMotion,arts/motion) is the animation engine, consumed by Soma (soma.motion) and Eidos (eidos.motion). It lives inarts/, not in Eidos, so Soma can animate (spring) without a soma→eidos dependency. In attach it readsapp.motion.
Retirement order:
- Keep
assertContractas a data-contract validator, not as a parallel registry.registerContractremains for tooling/direct tests; Soma registers contracts viaregisterMorfo(). - 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.
// 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 ahold, 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-channeldeltasapplied over the family base when the family is valenced. - Action verbs (
SEMA_VERBSinsrc/uix/sema/verbs.ts) —present,dismiss,commit,cancel,announce,warn, … — the canonical verbs formorfo.events[].semantic.verb, and the tail ofmorfo.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 touchesdata-state,data-intent,data-disabledor other state attrs — those belong to the runtime/morfo. Eidos readsdata-event-intentfor reactions to the transient signal anddata-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):
/* 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 fromprefs.language.uix.format— regional formats; consumesprefs.localeas aLocaleSource.uix.clipboard— clipboard write capability; in standalone it is created unlessclipboard:false, in attach it is consumed fromapp.clipboardwhen a component asks.uix.dom— the single writer of global attrs viadom.apply.uix.motion— the animation engine (EngineMotion,arts/motion): registers + runs--state-moment presets (CSS settle / JS spring/waapi/rect drivers). Consumed by Soma (Presenceviasoma.motion) and Eidos (eidos.motion: generates CSS + registers its presets). In standalone it is created with the availabledom; in attach it readsapp.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) withoutActiveUixknowing 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.
DOM projection is split by ownership:
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:
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.removeSoma 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. 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:
-
Morfo knows no runtime code. It is pure declaration.
-
SomaRuntime depends on Dom and Semantic. By construction, not by import. The provider injects them.
-
The provider does not write mutable attrs to the DOM directly. It supplies them as sources to the runtime.
-
Semanticmay useDom(downward).Domdoes not knowSemantic. -
ADomknows 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'sscrollTop. By contrast,document/windowlisteners, global queries, imperative focus and window scrolling go throughActiveDom.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 JavaScriptfamily). Defer them withdom.measure(read, node?)(the frame-coalesced read queue, the sanctioned vehicle) or from adom.rafcallback; a bare deferred read complies just likedom.applydoes for writes. To resolve a theme token into a concrete color, useeidos.resolveToken(token)(config + theuix.colorengine) — NOT agetComputedStyleprobe. The dev-onlyuix.perfdetector (opt-inreflowDetector) attributes violations at runtime via Long Animation Frames. The framework governs layout READS the same waydom.applygoverns writes. -
Eidosconsumes DOM anddata-*, not Soma/Sema internals. If it needs something, it must be declared in morfo or emitted in a sema signal. -
What
dom.applywrites, Svelte does not render frompartProps. One authority per attribute. -
State is the single source of truth. The DOM is derivation. Handlers mutate state; effects derive attrs.
-
Event handlers in
runtime.triggerare synchronous. Async goes before callingtrigger. -
Guards live at the call-site, not inside the handler. If the guard reaches the handler, the perceptual signal was already emitted.
-
morfo.events[].commitsis descriptive, not executable. It documents the observable; smoke validates it. -
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-propreadableActive(() => 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:
Preserveis not optional decoration.OptsFromPropsstripsundefinedfrom an optional prop by default; the fourth parameter lists the keys whose absence MEANS something (dirabove all) and keeps theirT | undefined. Omitting it silently destroys the distinction —canon/direction-contract.md§1.bindPropsis target-typed, never inferred. The declaredOptscomputes 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 toany.
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 §Callback
conventions; acceptance row E-3.7 in
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/— 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:
Semabecoming a multimodal runtime (a regression).Somadeciding CSS or motion.SomaRuntimeinterpreting business logic.Eidosreaching 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
ChannelinEngineSemanticwithout 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— the general positioning (more narrative)architecture/morfo.md— declaration, archetypes, the 2-of-3 rulearchitecture/sema.md— theemitcontract, canonical verbsSOMA_ARCHITECTURE.md— the runtime + componentscomponent-guide.md— the operational guide to create/migrate componentsarchitecture/eidos.md— the visual layer: tokens, themes, recipes, wrapperssrc/arts/adom/README.md—dom.apply+ reactive DOM servicesguia-semantica-historica.md— the original API conventions (historical seed;CANON.mdrules)
Detailed design decisions and historical trade-offs live in the
active-uix branch's git log.