20 KiB
| title | type | audience | authority | status | source |
|---|---|---|---|---|---|
| UIX — the thesis | reference | human + agent | E1 architecture — the narrative introduction to the four layers and their boundaries | current | migrated from src/uix/README.md (2026-07-02, docs-book F7.2) |
UIX
A short architectural positioning document for src/uix.
Whole-system view: to understand the four layers (morfo, soma, sema, eidos) in a single read — motivations and articulation included — go to
active-architecture. This document keeps the more narrative introduction.Semantic vocabulary (families, intents, verbs, channels): the single source of truth is
CANON.md, anchored to the book and the code. (The original Spanish implementation guide survives as a historical seed atdecisions/guia-semantica-historica.md.)
UIX does not try to be "another component library". The bet is more ambitious and structural: separate layers that almost every current framework keeps mixed together.
In most UI systems these things live glued to each other:
- the component's public DOM contract
- headless behavior
- accessibility
- event semantics
- the visual layer
- modal engines (sound, haptic, motion)
- app-service integration
UIX splits that block into pieces with strong boundaries.
1. The central idea
UIX models the interface as several cooperating layers, not as one giant component that does everything at once.
App
├─ cross-cutting services (active-app)
│ └─ adom, langs, format, ...
└─ components
└─ structural / semantic / behavioral / visual layer
The intuition:
- a component's public structure is not the same as its behavior
- an event's semantics are not the same as its materialization
- the active DOM is not the same as pure DOM utilities
- the app should not couple modal engines to each other
UIX gives those separations explicit names and contracts.
2. The UIX layers
Morfo (src/uix/morfo/)
The component's cross-layer structural contract. It declares parts,
data-*, ARIA, focus, keyboard, events and — when text belongs to the
contract — the component's texts slots (idlangrefs). It is neither prose nor
runtime: it is the canonical public shape.
Sema / Events (src/uix/sema/)
The semantic vocabulary and the orchestration of perceptual events. sema
remains the historical folder name; ActiveUix's public surface calls the
service events.
- canonical families (8):
contact,commit,signal,handle,emerge,shift,sustain,delegate - canonical intents:
neutral,affirm,fulfill,risk,threat,loss uix.events.emit(signal)for providers to publish occurrences- a channel registry (
VisualChannel,SoundChannel,HapticChannel) src/uix/sema/channels.tsas the single source of channel ids, signatures and overridessrc/uix/sema/sounds.tsas the single nominal repository of synthetic sounds, external.wavs and dynamic recipes- the
VisualChannel's strict sequential semantics: it preparesdata-event-*through a DOM projector, holds them for theholdwindow, and cleans up BEFORE resolving the Promise
Sema does not decide what happened (the provider decides that). It only orchestrates the occurrence and dispatches it to the registered channels.
See: architecture/sema.md
Soma (src/uix/soma/)
The headless behavior layer: state, context, a11y, keyboard / pointer / focus, event emission.
Soma does not know the concrete implementation of the modal engines. Its job is to emit the component's facts, not to materialize them.
See: architecture/soma.md,
SOMA_ARCHITECTURE.md
Eidos (src/uix/eidos/)
The visual layer: tokens, themes, per-component CSS recipes, archetype rules, event reactions and Svelte wrappers over soma's headless providers with the visual props (variant, size, block, iconOnly, icon, checkMark).
The general module is flattened into ActiveEidos:
ActiveEidos— the active runtime / visual context created byActiveEidos.create(...). It manages visual configuration, primitives, canonical roles, themes, validation, CSS generation and persistence. WithActiveUixservices it receivesdom,langs,prefsandformat;modeanddensityarrive through explicit sources or its own defaults. It injects<style data-uix-eidos>only when the app wants runtime CSS instead of precompiled CSS.
The app can provide a full EidosConfig or a partial themeBase patch via
ActiveEidos.create({ config/themeBase }), or delegate theme values to
external CSS with themeSource: 'css'. getCssContract() exposes the typed
list of custom properties and renderContractCss() publishes it as empty CSS
for external themes. For editors or live preferences, ActiveEidos can
inject a runtime variable map into its own <style> validated against that
contract. When the app wants to persist a full configuration, it stores a
versioned EidosConfigDocument (kind + version + options) and can pass it
back as ActiveEidos.create({ config: document }).
Eidos's size is canonical and discrete: xxs..xxl/full. ActiveEidos
generates coordinated physical tokens for xxs..xxl; full stays reserved
for responsive layout.
The generated contract also includes alpha color scales
(--scale-blue-a1..a12 and --primitive-primary-a1..a12), border
(width/style + aliases), layout (containerWidth, contentWidth,
aspectRatio), density scalars wired to data-density, opacity, z-index and
shadows with a physical 1..6 scale plus per-theme semantic aliases. The
active recipe aliases (--toast-*, --dialog-*, etc.) live in
EidosConfig.recipes; the generated static artifact lives in
src/uix/eidos/generated/base.css and src/uix/eidos/index.css imports it
as the foundation for SSR/docs with ActiveEidos.create({ applyDom: false }).
The old contracts/ and themes/base/ CSS were retired from the active
tree; the contract is obtained from ActiveEidos.getCssContract() /
renderContractCss(). The tokens/ directory is no longer part of the
runtime. ActiveEidos.listRecipes() and getRecipeTokens(component) expose
those aliases for theme editors without reading CSS.
Every soma component ships an eidos wrapper (the Soma → Eidos migration
completed in 2026-05): each wraps soma's public parts and adds only visual
surface. The live inventory is the eidos/components/ tree plus
npm run component:audit.
Eidos reads from the DOM what the other layers write (parts, data-attrs, ARIA, event signals) — it never imports soma or sema internals.
active-uix (src/uix/active-uix/)
The composition root. Only ActiveApp and ActiveUix may create shared
services:
createActiveUix(options)— standalone; instantiates services andprefsinternally.attachActiveUix(activeApp)— attaches to anActiveAppalready composed by the application; validates the required services and creates no substitutes.
Active layers consume ActiveUix via getActiveUix() and expose the minimal
surface their components need. Soma components read Soma; eidos components
read ActiveEidos. That way they neither know which boot path was used nor
depend on the full ActiveUix surface.
Full chapter: architecture/active-uix.md
ActiveUix and prefs
ActiveUix neither requires nor knows arts/frontend; that artifact was
retired. The pattern follows ActiveApp: prefs is core and consumer layers
sync from its dimensions, but each layer projects only what it owns.
prefs.languagesyncs the active language ofuix.langs.prefs.localefeedsuix.format.prefs.directionis the single source of effective direction; it derives fromlanguagewhen there is no override.prefs.motion,prefs.soundandprefs.hapticare cross-modal perception/interaction preferences.theme,modeanddensitybelong to Eidos:themenames the visual family,moderesolveslight | dark, anddensitytunes visual ergonomics.
Global writes go through whichever ActiveDom is injected, never direct
mutations:
ActivePrefsDomProjection -> dir, data-motion, data-sound, data-haptic
ActiveEidos -> data-theme, data-mode, data-density
ActiveUix does not auto-create ActivePrefsDomProjection. That projector
belongs to arts/prefs and the composition root wires it when it wants those
global attrs. Eidos is created separately through ActiveEidos when runtime
visuals are needed.
Example of a standalone UIX shell (like the docs' /uix route):
const uix = createActiveUix({ langs, prefs: { schema } });
setActiveUix(uix);
Soma.create();
const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom });
const eidos = ActiveEidos.create({
theme: 'base',
modeSource,
applyDom: true
});
The light/dark toggle feeds ActiveEidos.modeSource, not uix.prefs.theme.
prefs.theme is not part of UIX's core preset.
In standalone mode, createActiveUix() instantiates ActivePrefs with the
standard UIX preset and creates the configured services. In attach mode,
attachActiveUix(app) reuses app.prefs, app.langs, app.dom,
app.clipboard, app.format and the perceptual engine when they exist. UIX
exposes it as uix.events; in attach mode ActiveApp's service is called
events. langs and dom are required for attach; if missing, an early
error is thrown. clipboard, format and events are optional services:
if a layer asks for them and ActiveUix does not expose them, they fail with
an explicit error instead of creating substitutes.
soma.prefs is the preferences view the headless providers need, backed by
uix.prefs, not by frontend.
adom (src/arts/adom/, alias $adom)
The application's active DOM runtime. It is not a semantic engine. Its only
job is to coordinate and synchronize DOM mutations via
app.dom.apply(change) and app.dom.remove(target, names).
ADom receives already-resolved instructions. It does not interpret them.
libs/dom (src/libs/dom/)
Pure or nearly-pure DOM utilities (contains, getDocument, getWindow,
focus, traversal, base observer wrappers). It contains no active runtime —
that role belongs to adom.
2.bis How a component executes
The closed architecture (post-2026-04-25) defines six pieces with disjoint responsibilities. None invades the next.
Morfo declares
SomaRuntime transcribes
Provider supplies sources, targets and handlers
Effects sync derived attrs
EngineSemantic orchestrates prepare + dispatch to perceptual channels
VisualChannel prepares data-event* via SignalProjector + holds
SignalProjector projects data-event* via uix.dom
ADom applies DOM mutations (the structural commit)
The operational split
Morfo is DNA: one file per component declaring parts, data-*, aria-*,
role, keyboard, focus, events. It executes nothing.
SomaRuntime (in soma/) interprets the morfo. One instance per component
receives from the provider the state sources, the DOM targets and the event
handlers. It exposes partProps(part), attachPart(part, target),
keydown(part, event), trigger(eventName).
Provider supplies what the morfo cannot infer: reactive getters for
states and props, reactive getters for parts (dynamic ids), synchronous
handlers for the events, and the glue for orthogonal layers (Presence,
Dismissal, ScrollLock — those are not morfo).
Effects (registered by the runtime on mount) listen for source changes and
apply the derived attrs via dom.apply.
EngineSemantic receives the occurrence from runtime.trigger. It generates
the id, runs the channels' prepare hooks and dispatches the signal to ALL
registered channels. The built-in VisualChannel projects data-event-*
through SignalProjector, holds the attrs for the configured hold, cleans
them up and only then resolves the Promise (strict sequential semantics).
Non-visual channels (sound, haptic) are fire-and-forget.
ADom only applies the subsequent structural commit. It does not interpret.
Sema and ADom are sibling layers: the engine no longer depends on ADom.
The runtime.trigger(eventName) sequence
1. imperative prewrite (transient markers like data-last-action)
2. await events.emit(event)
3. the provider's synchronous handler mutates state
4. effects derive and apply structural attrs (data-state, aria-*)
The handler mutates state. The effects see the change and rewrite the DOM. ADom is the only writer of mutable attrs.
Three Soma scenarios
// Structural change without a signal
provider.commitState(change);
// internally: dom.apply(change)
// Structural change with a signal
provider.commitState(change, event);
// internally: await events.emit(event); dom.apply(change)
// Signal without structural change
provider.emitEvent(event);
// internally: void events.emit(event)
Operational rules
- What
dom.applywrites, Svelte does not render.partPropsonly emits static identity (id, marker, ref). eventshandlers are synchronous. Async goes outside the trigger.- Guards (
if (disabled) return) live at the call-site, not inside the handler — if they reach the handler, a perceptual signal was already emitted. SemaandADomare sibling layers: neither depends on the other.morfo.events.commitsis descriptive: it documents the observable, it does not execute it. The real causal chain is handler → state → effect.
3. What makes UIX different
3.1 The structural contract is its own layer
In most libraries the component's public structure is scattered:
- attributes in the provider
- roles in the render
- parts in CSS
- selector names in docs
- contracts in tests
UIX concentrates that in Morfo. That enables:
- docs derived from the contract
- cross-layer validation (the eidos linter)
- less drift between headless and visual
- more reliable tooling
3.2 Semantics never mix with execution
UIX separates the name of the occurrence from its modal materialization. Sound, vibration, CSS and future engines use the same vocabulary without being glued to each other.
3.3 Headless behavior doesn't carry every modality
Soma is not a mega-engine that knows everything. It doesn't play WAVs or
haptic feedback, and it doesn't decide each channel's perceptual physics.
Soma emits. Channels execute.
3.4 The active DOM is app infrastructure
UIX recognizes there are cross-cutting DOM facts several consumers want to
listen to, which is why it introduces adom as an app service.
The rule is not "all DOM access goes through ADom". The rule is more precise:
UIX-managed writes, document/window listeners, global queries, observers
(ResizeObserver, MutationObserver, IntersectionObserver) and
cross-cutting imperative actions go through ActiveDom. That includes focus
and imperative scroll (focus, scrollTo, scrollIntoView,
scrollWindowTo). Local reads of an element the component already owns
(contains, closest, getBoundingClientRect, scrollTop) stay in the
component.
3.5 The app composes services, not "super components"
active-app lets the application compose langs, dom, clipboard,
format, events, and ActiveUix delivers them to layer scopes (Soma,
Eidos) instead of forcing every component to know the full active root.
4. What UIX is not
UIX is not:
- a flat collection of visual components
- a mere opinionated wrapper over existing primitives
- a classic design system where visuals, behavior and contracts live together
- a monolithic event service that executes every modality
- a global
EventEmitterdressed up as architecture
UIX's originality is not in inventing exotic names, but in separating real problems that other systems usually accept as a single block.
5. Dependency rules
Morfo → declares contracts
SomaRuntime → interprets morfo inside Soma
Provider → supplies sources, targets, handlers
Effects → sync state → attrs
EngineSemantic → registry + prepare/dispatch of signals to channels
VisualChannel → prepares data-event* via SignalProjector + holds
SignalProjector → projects data-event* via uix.dom
ADom → applies DOM mutations (the structural commit)
Eidos → materializes visually by reading the DOM
App → composes services
Hard rules:
Morfoknows neitherSomanor any runtime codeSomaRuntimereadsMorfoand depends onSemaProviderdoes not write mutable attrs to the DOM directly; it supplies them as sources to the runtimeEngineSemanticdoes not mutate the DOM directly; it only orchestrates channel hooksVisualChannel.prepare()delegates DOM projection to aSignalProjectorDomSignalProjectorwrites through theActiveDomreceived fromActiveUixSemaandADomare sibling layersADomknows neitherMorfo, norSema, norSoma, norEidos; it only executes the mutations, listeners and cross-cutting DOM actions it receivesActiveEidosadaptsActiveUixfor visual components; wrappers do not importgetActiveUix()directlyEidosconsumes DOM anddata-*, notSoma/Semainternals- What
dom.applywrites, Svelte does not render
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. If only soma benefits, the pattern is a virtual prop on the provider — do not extend the contract.
| Extension | Soma | Sema | Eidos | In morfo |
|---|---|---|---|---|
parts[].archetype |
✅ emit | ✅ verbs by role | ✅ transversal selectors | ✅ |
events[].semantic |
✅ payload | ✅ vocabulary | ✅ tinting | ✅ |
events[].prewrite |
✅ executes | ✅ sequence | ✅ exit tinting | ✅ |
firstOf value source |
✅ only | — | — | ❌ |
prop-not-nullish cond |
✅ only | — | — | ❌ |
| Field-context OR | — virtual prop | — | — | ❌ |
Canonical cross-layer vocabularies
- Archetypes: the cross-component part classification. Defined in
src/uix/morfo/types.ts:ARCHETYPE_VOCABULARY. Emitted asdata-archetype="..."byruntime.partProps. - Verbs: the canonical action verbs for
morfo.events[].name. Defined insrc/uix/sema/verbs.ts:SEMA_VERBS. Name convention:{family}-{verb}[-{nuance}](e.g.commit-save,emerge-dismiss-escape). The family prefix is mandatory —validateMorfothrows without it.
6. The difference in one sentence
Morfo declares, SomaRuntime transcribes, Provider supplies, Effects sync, Sema emits, ADom applies, Eidos reads.
Seven pieces, seven responsibilities, none invades the next.
7. Suggested reading order
CANON.md— the semantic canon: the vocabulary (families, intents, verbs, composition, channels) anchored to the book + code. The source of truth everything else links.architecture/morfo.md— declaration, archetypes, the 2-of-3 rulearchitecture/sema.md— theemitcontract, canonical verbs, channelsarchitecture/eidos.md— what eidos consumes from the DOM + wrappersSOMA_ARCHITECTURE.md— the runtime that transcribes morfocomponent-guide.md— the operational guide to create/migrate componentssrc/arts/adom/README.md—dom.applyarchitecture/active-uix.md— the composition root