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

43 KiB

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/ (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.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.

// 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):

/* 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.

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.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. 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 §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 §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:

  • 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

Detailed design decisions and historical trade-offs live in the active-uix branch's git log.

Powered by TurnKey Linux.