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/theming/motion.md

51 KiB

title type audience authority status source
Eidos Motion — Design and Architecture reference human + agent E1/E3 — the two-moment motion model and the engine architecture current migrated from src/uix/eidos/eidos-motion.md (2026-07-02, docs-book F7.3)

Eidos Motion — Design and Architecture

Status: the two-moment model — F1–F7 implemented (2026-06-04).

This document describes Eidos's animation system. The model, anchored in the canon (the historical seed GUIA_IMPLEMENTACION_SEMAUIX.md §4.3, §7, §11), is:

An interaction has TWO moments, and EACH may carry animation — one, the other, or both:

  • The --event moment (data-event-*): the perceptual flourish during a sema signal's hold. It is the motion channel of the perceptual signature (§11), defined per event (family/intent/event). Transient.
  • The --state moment (data-state): the transition to/from a persistent condition. Defined per component (the motion prop). Persistent.

This is what makes UIX different from every current framework: the others collapse presence animation onto a single axis (Chakra: only data-state + Presence). UIX animates both moments and integrates them with the perceptual signature — more coherent and richer.

Implemented (F1–F6): the two moments (keyframes + signatures + presets + generation + the EngineMotion engine + validation + tests + the /temas/animations demo); the signature migrated from events.css into the registry (signatures, F2); the recipes migrated to presets with per-component duration/easing overrides (F3); the JS drivers spring (physics) / waapi / rect (FLIP) + Presence.motion + a real overlay (Dialog) bouncing (F4); choreography — stagger + the Material shared-axis/fade-through presets (F5); token rigor — the raw signature tokenized (long perceptual holds slower/deliberate/ emphatic/sustained), shared-axis on --motion-distance-xl, the emphasized easing, the [data-motion-set='expressive'] set (F6); the extensible typegen of preset names — the augmentable registry EidosMotionPresets (F7). Roadmap F1–F7 complete. data-motion-ref and the "TSC event:* scope" are obsolete (THEMING §13/§14, no real use).

Refactor (post-F5): the motion engine is a SERVICE. The runtime no longer lives in eidos: it was relocated to src/arts/motion (an art — a pure runtime artifact with no UI or cross-art dependencies), exposed as uix.motion and consumed by both layers — soma (Presence receives motion: EngineMotion and calls motion.run(node, phase)) and eidos (delegates via eidos.motion + registers its css presets in the service at boot). This dissolves the soma→eidos coupling: the DialogProps.runMotion prop and eidos.motionRunner disappear — the bridge is now EngineMotion.run, which reads the node's data-animation-style. Eidos keeps the CSS generation (lib/render-css.ts) + the presets/keyframes/signatures DATA (lib/motion/presets/css.ts); the types, the engine and the drivers (spring/waapi/rect) live in $motion. See src/arts/motion/README.md. (Sections §5/§6/§9 + the file tree below already reflect the new home.)

USAGE extension — 3 domains (2026-06-21). This document describes the ENGINE, which has two moments (event/state). At the USAGE level the motion prop covers three domains: event (the signature) · state (the per-component transition) · content (the third: content entering/leaving/looping, via motionAttrs / <Motion> / the spin/pulse/… loops — a USAGE layer on top of the state-preset machinery, NOT a third engine moment). The dedicated state-domain (data-motion-state, <Card> pilot), the container-driven cascade in both directions and the [data-debug-stagger] affordance are post-F7. For the task-oriented guide (recipes · preset catalog · loops · state-domain · staggered cascade · reduced-motion · debug) → motion-guide.md. For decisions and history (incl. the "coordinated" engine retired in Plan A) → MOTION_SERVICE_RFC.md.

TL;DR:

  • Two animatable moments: --event (data-event-*, the perceptual signature, signatures) and --state (data-state, the per-component transition, presets). One, the other, or both; they compose in sequence (sequence).
  • One registry (EidosConfig.motion) with three maps: keyframes, signatures (the event-moment), presets (the state-moment).
  • data-state belongs to soma; data-event-* to sema; eidos reads both and animates (§7: the rule is not to OVERWRITE the other layer's attribute, not "one axis only").
  • Usage: the state-moment via the prop motion="scale-fade" → data-animation-style; the event-moment is automatic when the event fires (the signature).
  • Drivers: css (the floor, Chakra parity) + JS (waapi/spring/rect/ svelte — physics/FLIP/genie/orchestration, beyond Chakra), for either moment.

Table of contents

  1. Thesis and positioning
  2. The two moments
  3. Motion across the 4 layers
  4. Architecture — the two-surface registry
  5. The types
  6. The EngineMotion API
  7. The drivers
  8. The DOM contract
  9. Soma integration (Presence)
  10. Reduced motion
  11. Primitives and keyframes
  12. Initial content (signatures + presets)
  13. Per-component defaults
  14. Where the code lives
  15. The event-moment: from events.css to signatures
  16. Comparison with Chakra UI v3
  17. Naming decisions
  18. Implementation phases
  19. Out of scope / deferred

1. Thesis and positioning

An interaction does not have one animatable moment; it has two, and they are of different natures:

  1. The perceptual occurrence — "something just happened" — transient, with a hold, an intent and a sequence. Sema writes it as data-event-*.
  2. The state change — "this is now open" — persistent, the source of truth. Soma writes it as data-state.

The canon makes it explicit (GUIA §4.3): emerge = "something enters or leaves the perceptual field" → it is an event, not a state. GUIA §11 defines the per-event perceptual signature, where motion is one of the channels (alongside sound/color/presence/haptic): the exit movement of a commit.delete + loss is "withdrawal/descent"; that of a signal.alert + threat is "protruding entrance". Movement is defined per event.

UIX's differentiator. Every current framework animates presence on one axis (Chakra: data-state + Presence; Radix/Ark: same). UIX distinguishes the two moments and animates both, integrating the event-moment with the perceptual signature (sound/haptic included). That is what makes it more coherent (one model, two sharp moments) and richer (the animation of "what happened" is never confused with "what state we are in").

What eidos owns: the typed DATA registry (EidosConfig.motion = keyframes + signatures + presets), the CSS generation for both surfaces, the motion prop and the data-animation-style attr. The execution engine (EngineMotion) is NOT eidos's: it lives in $motion and is consumed via uix.motion (eidos delegates + registers its css presets there).

What it does NOT own: emitting the signal and its semantic signature → sema; the mount/unmount lifecycle and data-state → soma Presence; the attrs animated over (data-state, data-side, data-starting/ending-style) → declared in morfo; the allow/reduce pref → ActivePrefs.


2. The two moments

The --event moment The --state moment
Attribute data-event-* data-state
What it is the perceptual occurrence (the signal) the transition to/from a persistent condition
Its animation the signature's flourish (settle, pulse, intent-tinted withdrawal, a present's entrance…) the presence/layout transition (scale, slide, grow to the open height…)
Granularity per event (family/intent/event) — generic, consistent system-wide per component (the motion prop)
Attr owner sema (stamps it during the hold) soma (effects)
Nature transient (lives the hold) persistent
In the registry signatures presets
Canon §11 (perceptual signature), §4.3 (emerge), §7.1 §7.2, §5.2 (sequence)

Each moment may carry animation — one, the other, or both:

  • press (a button): only the event moment (contact.press → squeeze). No state transition.
  • a dialog opening: the event flourish of emerge.present and the state transition to open.
  • a collapse: the height transition (state) and/or the expand event flourish.

They compose in sequence (GUIA §5.2): sequence: 'pre' runs the event's flourish before the state changes (e.g. animate the exit before closing); 'post', after (celebrate after the real result). The architecture chapter describes it in the causal chain (active-architecture.md §5): the event's animation runs during the hold, then the state takes over.

data-state and data-event-* never mix (§7.3): the rule is that sema does not overwrite state attributes and vice versa (ownership). It does NOT say only one may animate — eidos reads both and animates both. What is forbidden is stepping on the other's name, not animating over both axes.

When both write animation on ONE node (KNOWN-FRAGILE). Eidos animates both axes, but a firma and a --state/stagger preset both set the single animation shorthand — only one applies. On a shared node (a DropdownMenu item that staggers on open/close AND receives a commit-select event) both selectors compute (0,3,0), so CSS source order decides: the generator emits all signatures before all presets, so the later stagger wins and the generic firma is masked — directionally the intended outcome (the coordinated owns the visual axis, RFC B.2), but produced accidentally by emission order, NOT by an opt-out (the channels:[] / expression:'none' source-silence is dormant; the old animation: none !important neutralization is retired). No shipped component shows a defect (e.g. dropdown select feedback is sound+haptic only; the masked pulse is the generic fallback and the item is fading out at select). This is a provisional accident, not a contract — pinned by motion.test.ts ("pins the firma-vs-stagger cascade precedence") so a generator reorder or a future (0,4,0) [data-event^='…'] signature fails loudly. The structural fix ("una firma por evento") is RFC §D.4-B. @layer (order- independent precedence) is a deferred, tracked end-state — triggered only when a coordinated component gains a visual commit channel, (0,4,0) signatures ship, or @layer is adopted for other reasons.

Child coordination (stagger / cascade) — CLOSED model, RFC §D.11. There was a third "coordinated" axis (PresenceGroup / cascade-* presets over data-starting/ending-style); it was retired on 2026-06-19. The final model: the cascade is NOT a separate system — it is the --state moment (a declared preset) + the stagger that already existed (index × --motion-stagger-each, parallel = 0, cascade = N) + one foundation rule that writes the index from structure ([data-stagger] > *:nth-child → --motion-stagger-index / :nth-last-child → -rev, generated in lib/render-css.ts). The event's signature (emerge → present-rise) is only the container's flourish (generic, over data-event-*); the children's timing is per-component realization, not a sema channel. Three orthogonal axes (sema emits · motion is the engine · eidos materializes) + the full lifecycle: RFC §D.11.

The UNIVERSAL motion prop — one selector, three domains (design framing, [RFC §D.12]). After retiring the coordinated axis, ONE system remains → a single motion prop (today <Cascade> still uses animation; it gets unified). The goal: any component (not just overlays) can receive motion="X" from a type-safe registered catalog. What the prop means is decided by the discriminant "does the animation realize a perceptual event?": event → the signature rules, the prop is an override/violation (types/lint); state → it selects the state-preset (data-state); content (no event) → the prop is the primary path. The sound/haptic coupling applies only to the event domain — content ones stay in clean parity. Detail + plan (a)+(b): RFC §D.12.

Structural constraints of the CSS model — [RFC §D.13]. The contracts the declarative approach demands: (1) structure — [data-stagger] ↔ DIRECT children; :nth-child ignores comments/{#if} (hardened), but an intermediate wrapper element breaks the count → a cascading grouper becomes its own scope (inherits:false isolates the index); display:contents without re-scoping = a dead zone. (2) exit — unit-exit via Presence (retain + await; the parent's opacity drags the children) works today; per-child staggered-exit requires lifecycle JS (PresenceGroup, deferred) → the container-driven rule is ENTER-ONLY; never raw {#if} on an animated surface. (3) debug — the index is inherits:false (no ancestor can stomp it); everything lives typed in Computed; the opt-in [data-debug-stagger] mode materializes it (an ::after badge per child with its index, via a counter mirroring :nth-child - 1).


3. Motion across the 4 layers

Layer What it contributes Moment
Morfo Declares the attrs: data-state + states (open/closed), the events (emerge/commit/signal + sequence/persistence/intent), data-side/data-align, data-starting/ending-style. both
Sema Stamps data-event-* (family/intent/direction/phase/id) during the hold; resolves the signature (sound/haptic are runtime channels; motion/color/presence are materialized by eidos reading data-event-*). --event
Soma Writes data-state via effects; Presence keeps the node during the exit and awaits the animation (getAnimations() + Promise.all(finished)). Fires the event with its sequence. --state (+ fires the event)
Eidos keyframes + signatures (event-moment, over data-event-*) + presets (state-moment, over data-state) + CSS generation; delegates the JS engine to uix.motion (the service) and registers its css presets there at boot. Reads both axes and animates. both

Reduced-motion is already wired: MotionEffective = 'allow' | 'reduce' (libs/motion), resolved ONCE by prefs (resolveMotion folds the OS hint under the user's intent), projected as data-motion by ActivePrefsDomProjection, and handed to every JS runtime as a MotionSource (createMotionSourceFromPrefs). The OS hint itself is tracked by ReducedMotionTracker (arts/adom) and exposed on ActiveDom.prefersReducedMotion.matches — it feeds the prefs ENVIRONMENT, and nothing else reads it to decide.


4. Architecture — the two-surface registry

EidosConfig.motion
├── keyframes:  { 'fade-in': {...}, 'scale-in': {...}, ... }   registered @keyframes
│
├── signatures: {            ← the --event MOMENT (the signature, generic per event)
│     'emerge-present': { family:'emerge', event:'emerge-present', keyframes:['fade-in'], ... },
│     'commit-settle':  { family:'commit', keyframes:['settle'], ... },
│     'announce-threat':{ family:'signal', intent:'threat', keyframes:['pulse'], ... }
│   }                         → generates [data-event-*][data-event-phase='active'] rules
│
└── presets: {               ← the --state MOMENT (per-component transition)
      'scale-fade': { driver:'css', enter:{...}, exit:{...} },
      'slide-fade': { driver:'css', enter:{ bySide }, exit:{ bySide } },
      'genie':      { driver:'rect', enter, exit }              (P3, JS)
    }                         → generates [data-animation-style][data-state] rules
        │
        ▼
  uix.motion : EngineMotion  (service · arts/motion — resolves, runs JS drivers, honors reduce)
        ▲  eidos.motion delegates here + registers the css presets at boot
        │
        ▼
  Eidos reads data-event-* (signature)  +  data-state (transition)  →  animates
  • signatures is the generic signature: a commit settles the same way system-wide; an emerge.present enters the same way. Sema's packs (sema/components/*.ts) fine-tune a concrete component's signature. It is what events.css used to do by hand (§15).
  • presets is the per-component transition, chosen with the motion prop.
  • Theme/app can add or override in both maps.

5. The types

$motion (src/arts/motion/types.ts) — relocated to an art. duration/ease are string (decoupled from DurationKey/EaseKey: eidos resolves the tokens in its generation layer; the art doesn't know the scale):

type ReducePolicy = 'instant' | 'opacity-only' | 'none';
type MotionSide = 'top' | 'right' | 'bottom' | 'left';
type KeyframeName = string; // key into motion.keyframes
type MotionPresetName = string; // the `motion` prop's value; 'none' disables

// One CSS phase: composed keyframes + tokens + side-awareness.
interface CssPhase {
	keyframes: KeyframeName | KeyframeName[]; // comma-composed: ['scale-in','fade-in']
	duration?: string; // token key ('moderate'…) or raw ('600ms')
	ease?: string;
	transformOrigin?: string; // e.g. 'var(--_floating-transform-origin)'
	bySide?: Partial<Record<MotionSide, KeyframeName | KeyframeName[]>>;
}

// ── The --event moment: the perceptual signature (generic per event) ──
interface EventSignature {
	family?: string; // data-event-family ('emerge' | 'commit' | 'signal' | …)
	intent?: string; // data-event-intent (valenced families)
	event?: string | string[]; // data-event name(s)/prefix(es) (['emerge-present','emerge-open'], …)
	direction?: string; // data-event-direction ('forward' | 'backward') — sense of travel,
	// decided per emit. A REFINEMENT, never a matcher on its own.
	keyframes: KeyframeName | KeyframeName[];
	duration?: string; // token key OR raw hold ('600ms', outside the scale)
	ease?: EaseKey;
	fill?: 'none' | 'forwards' | 'backwards' | 'both';
	reduce?: ReducePolicy;
}

// ── The --state moment: per-component preset (the `motion` prop) ──
interface CssStatePreset {
	driver: 'css';
	enter?: CssPhase; // [data-state='open']
	exit?: CssPhase; // [data-state='closed']
	reduce?: ReducePolicy;
}
interface JsStatePreset {
	// implemented (waapi/spring/rect/svelte)
	driver: 'waapi' | 'spring' | 'rect' | 'svelte';
	enter?: MotionRun;
	exit?: MotionRun;
	requires?: ('sourceRect' | 'targetRect' | 'placement')[];
	reduce?: ReducePolicy;
	fallback?: CssStatePreset; // declared; NOT auto-applied today
	// (the spring driver honors ctx.reduced itself)
}
type StatePreset = CssStatePreset | JsStatePreset;

// ── The registry ──
interface MotionConfig {
	keyframes?: Record<KeyframeName, KeyframeStops>;
	signatures?: Record<string, EventSignature>; // the --event moment
	presets?: Record<string, StatePreset>; // the --state moment
}

Structural decision: the event-moment is generic per event (signatures, by family/intent), not packaged per preset — because the signature (§11) is defined per event and must be consistent across components. Per-component fine-tuning of the event-moment goes in sema's packs. The motion prop only chooses the state preset.


6. The EngineMotion API

Lives in $motion (the uix.motion service; eidos.motion and soma.motion expose it — the same instance). Registration + the declarative CSS path + JS driver execution. The caller passes MotionRunOptions (dom, reduced, side, sourceRect/targetRect, duration/ease) for the drivers that need them.

interface EngineMotion {
	register(name: string, preset: StatePreset): void;
	resolve(name: string): StatePreset | undefined;
	has(name: string): boolean;
	list(): string[];
	// css: declarative (settled handle); js: builds the MotionContext, runs the
	// driver, normalizes the return into a handle and tracks it per element.
	enter(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle;
	exit(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle;
	// Presence's bridge (replaces the old `runner`): reads the node's
	// `data-animation-style` and delegates to enter/exit (css → settled handle;
	// js → runs the driver).
	run(el: HTMLElement, phase: 'enter' | 'exit', opts?: MotionRunOptions): MotionHandle;
	cancel(el: HTMLElement): void; // cancels active JS handles
	pending(el: HTMLElement): Promise<void>; // combined finished (for Presence)
	dispose(): void; // cancels everything (service cleanup)
}
  • A css state preset is declarative: the wrapper sets data-animation-style and the [data-animation-style][data-state] CSS rule + soma's Presence do the rest. The runtime doesn't "run" it.
  • A JS state preset runs the driver (spring/waapi/rect/svelte), normalizes the return (Animation | Animation[] | MotionHandle) into one handle, tracks it per element (cancel/pending), and honors the ReducePolicy.
  • The event-moment (signatures) does not go through enter/exit: it is generated CSS reacting to data-event-*.

6.1 — Cleanup policy (WAAPI / spring)

fill: forwards leaves Animations dangling in getAnimations() → it would break Presence's exit wait. Discipline: rest states live in the [data-state] CSS; waapi presets animate without fill: forwards and the runtime cancel()s on finish; for rest states not expressible in CSS (measured FLIP), commitStyles() + cancel().


7. The drivers

They apply to either moment (a signature or a state preset).

Driver Substrate For Beyond CSS
css @keyframes + tokens (generated) fade / scale / slide / collapse / the signature — (the Chakra floor)
waapi el.animate() runtime-computed keyframes measured distance/size, cancelable
spring a semi-implicit Euler integrator (RAF via ActiveDom) overshoot/settle, drag, snap-back real physics — what no cubic-bezier expresses
rect rect measurement (via ActiveDom) → WAAPI FLIP, shared-element, genie animating between two real positions
svelte (opt-in) transition: / animate:flip {#each} list reorders Svelte's declarative

Bundle: css (generated) is the floor. spring is a self-contained integrator (no dependency — one independent spring per property, stepped by ctx.dom.requestFrame); waapi wraps el.animate (a browser API, already visible to getAnimations()); rect (FLIP) composes measurement + WAAPI. The helpers live in lib/motion/presets/js.ts (spring() / waapi() / rect()). svelte (opt-in) is reserved for animate:flip in {#each}. An external adapter (motion-one) is opt-in.

The svelte nuance: {#if} transitions would fight soma's Presence; the core path is WAAPI/spring (composes with getAnimations). svelte is reserved for animate:flip in {#each} (Svelte owns that lifecycle).

rect batching: FLIP with N elements does its pass in two phases over a single requestFrame (measure all sources → mutate → measure all targets → animate). ActiveDom already provides requestFrame; the batching belongs to the driver, not to ActiveDom.


8. The DOM contract

The --event moment (sema writes it during the hold; eidos reacts):

[data-event='emerge-present'][data-event-phase='active'] {
	animation: fade-in …;
}
[data-event-family='commit'][data-event-phase='active'] {
	animation: settle …;
}
[data-event-family='signal'][data-event-intent='threat'][data-event-phase='active'] {
	animation: pulse …;
}
[data-event-family='shift'][data-event-direction='forward'][data-event-phase='active'] {
	animation: shift-cross-forward …;
}

data-event-direction (forward | backward) is the sixth attr of the stamp and the only one decided PER EMIT rather than declared in the morfo: the event name already says shift-enter-mode vs shift-exit-mode, so the attr carries only the sense of travel. The shift crossing is the first firma to read it — its keyframes travel the inline axis via --motion-shift-sign (+1 :dir(ltr) / −1 :dir(rtl)), never a physical translateX.

The --state moment (soma writes it; eidos reacts). The wrapper sets data-animation-style (the motion prop); the rest state comes from the recipe over the same data-state:

[data-animation-style='scale-fade'][data-state='open'] {
	animation:
		scale-in,
		fade-in …;
}
[data-animation-style='scale-fade'][data-state='closed'] {
	animation:
		scale-out,
		fade-out …;
}
[data-animation-style='slide-fade'][data-side='top'][data-state='open'] {
	animation:
		slide-from-bottom,
		fade-in …;
}

Public prop: motion?: MotionPresetName | 'none' (per-component default; 'none' disables). Placement-aware: it reads data-side/data-align (morfo) + the --_floating-transform-origin alias each floating host's recipe declares from its own --_{c}-floating-transform-origin channel (§15).

NOT data-motion: that name already has two owners (the allow/reduce pref + NavMenu's direction). The engine's attr is data-animation-style (mirroring Chakra's animationStyle). See §17.


9. Soma integration (Presence)

soma/layers/presence.svelte.ts already does the lifecycle: on close it keeps the node, awaits node.getAnimations() + Promise.all(finished) (runId guard, enabled flag), and unmounts. CSS animations (both moments) and el.animate() ones (JS drivers) appear in getAnimations() → they are awaited with no new plumbing.

Presence.motion (F4 · the service refactor) — the hook for drivers NOT in getAnimations() (the spring, pure RAF): Presence receives motion: EngineMotion and calls motion.run(node, phase) on enter/exit; run starts the JS animation and returns a MotionHandle, whose finished Presence awaits ALONGSIDE getAnimations(). For a css preset (or a node without data-animation-style), run returns an already-settled handle → the declarative path is unchanged. Soma does NOT import eidos: it consumes the service via soma.motion (= uix.motion), the same instance as eidos.motion. Wired in Dialog's provider (contentPresence/overlayPresence receive motion: this.soma.motion); the same pattern fits any overlay — just pass motion to its Presence, no prop and no coupling (the old DialogProps.runMotion / eidos.motionRunner disappeared in the refactor).

Child orchestration (pending, being redesigned): it hangs off the event's signature (sema) materialized by eidos, not off a parallel engine — see Appendix D of MOTION_SERVICE_RFC.md.

9.1 — SSR / hydration

Overlays mount on open, client-side; motion fires on transitions, not on first mount (a mounted-open element suppresses the enter). No in-flight SSR→JS handoff; the CSS fallback covers no-JS.

9.2 — Exit + a11y: inert (soma's)

During the exit the node stays mounted: it must be inert/aria-hidden and unfocusable. The morfo's focus.return already returns focus; inert during the exit window is a Presence/Dismissal improvement in soma (the pattern is already used in color-field).


10. Reduced motion

  • CSS (both moments): [data-motion='reduce'] … rules per policy (instant → animation: none; opacity-only → fade only; none → untouched) — and only those. Eidos does not read media: the effective preference is prefs' (resolveMotion folds the OS hint, and an explicit allow overrules it) and arrives stamped on <html>. A @media (prefers-reduced-motion) in a recipe would be a second source, still suppressing the motion of a user who asked to keep it. Guarded by src/uix/eidos/reduced-motion-media.test.ts.
  • Runtime (JS drivers): MotionContext.reduced from the EFFECTIVE preference — a MotionSource over prefs.motion (createMotionSourceFromPrefs), injected into EngineMotion / EngineScene / sema's haptic channel by the composition root. The same ONE source as the CSS above: the engines no longer read ActiveDom.prefersReducedMotion, which is the OS hint, not the answer. MotionRunOptions.reduced stays as a per-run PIN and wins over the source. The ReducePolicy applies before running.
  • Morfo declares a11ySemantic.reducedMotionFallback per event; soma silences the signal when it applies. The engine is coherent with that decision.

11. Primitives and keyframes

Existing tokens (STATIC_MOTION, generated, read by css and js):

Token Values
--duration-{k} instant 0 · fast 120 · normal 180 · moderate 240 · slow 320 ms
--ease-{k} default · out · in · spring · alert · symmetric
--motion-distance-{k} xs 2 · sm 4 · md 8 · lg 16 px
--motion-scale-{k} enter 0.985 · press 0.97 · through 0.92 (fade-through) · lift 1.02 (drag pickup — the only >1, see §Draggable surfaces in THEMING)
--motion-stagger 20ms — the stagger STEP a menu ripples at (dropdown-menu, context-menu set --motion-stagger-each from it)
--motion-stagger-viewport 70ms — the reveal rhythm of a viewport animator inside [data-stagger]; the foundation writes it into --motion-stagger-each on that selector alone (changelog §57)

Override vars (runtime, NOT theme tokens) — a component sets them on its element; the preset reads them with a token fallback:

Var For Notes
--motion-duration-{enter,exit} per-component timing (F3) @property inherits:false
--motion-ease-{enter,exit} per-component curve (F3) @property inherits:false
--motion-slide-leave slide-full's extra travel for inset panels (drawer) default 0px
--height / --collapsed-height the collapse's measured height (soma writes it) —
--motion-stagger-each the stagger rhythm (on the container, inherits) default 0ms → parallel; N → cascade. One exception, and it is foundation, not a component: [data-stagger] > [data-animation-trigger='viewport'] gets var(--motion-stagger-viewport) (the theme token above), so a section reveals on rhythm with no per-item delay. <Cascade> keeps its 0
--motion-stagger-index / -index-rev the per-item index from structure — [data-stagger] > *:nth-child (fwd, enter) / :nth-last-child (rev, exit); generated in render-css.ts, nobody writes it @property inherits:false, <integer>

Keyframes (EidosConfig.motion.keyframes, parity with Chakra's theme.keyframes): individual translate/scale (they compose without stomping each other); parameterized by CSS var for dynamic sizes/distances (var(--height), var(--collapsed-height,0)). Built-in: fade-in/out, scale-in/out, slide-from/to-{side}[-full], expand/collapse-height; the signature (announce-pulse-*, commit-settle, press-squeeze, dismiss-fade, present-rise); Material (slide-axis-{x,y}-{in,out}, scale-from-92).


12. Initial content (signatures + presets)

presets (the --state moment) — built-in (css):

Name Notes
fade · scale-fade enter+exit; scale-fade with the --_floating-transform-origin alias (§15)
slide-fade side-aware (data-side): slide + scale 0.985 + fade
slide-full edge drawer, inset-aware (--motion-slide-leave)
collapse measured height (var(--height))
shared-axis-x/y · fade-through Material 3 (F5)

The JS drivers (spring/waapi/rect, presets/js.ts) are registered per app/demo. The first named built-in JS preset is spring-pop (driver:'spring', presets/js.ts → BUILTIN_JS_PRESETS), registered directly on ActiveEidos — not via the serializable EidosConfig.motion.presets, which cannot carry its MotionRun functions (structuredClone fails). Demos additionally register panel-spring, dialog-spring, flip.

signatures (the --event moment) — built-in, migrated from events.css (F2, §15): present/dismiss, commit (+ the fulfill/affirm/threat intents), press (contact), announce (+ 5 intents). Generic per family/intent/event.


13. Per-component defaults

The per-component state-moment (the motion prop, the wrapper's default):

Component Part Default motion
Dialog overlay / content fade / scale-fade
Popover · Tooltip · Dropdown · Select · Menubar · ContextMenu content slide-fade
Drawer content slide-full
Accordion · Collapsible content collapse
Toast item slide-fade

The event-moment is automatic: when the event fires (e.g. commit, emerge.present), the corresponding signatures entry reacts — no prop.


14. Where the code lives

arts/motion/                  ← THE SERVICE (a pure art: no UI or cross-art deps)
  types.ts            ✓  MotionDom (structural port), MotionConfig, EventSignature, StatePreset, MotionHandle…
  engine-motion.ts    ✓  createEngineMotion → EngineMotion (register/resolve/enter/exit/run/cancel/pending/dispose)
  drivers.ts          ✓  JS drivers: spring() (physics) / waapi() / rect() (FLIP)
  index.ts            ✓  the `$motion` barrel (named re-exports) + README.md
arts/active-app/service-factories/motion.ts  ✓  defineEngineMotion (factory; coreDep 'dom')

eidos/lib/motion/presets/css.ts  ✓  DATA: keyframes + state presets + signatures (BUILTIN_*) + Material (F5)
eidos/lib/config-types.ts   ✓  EidosConfig.motion ($motion's MotionConfig type)
eidos/lib/render-css.ts     ✓  GENERATES CSS: presets ([data-state]) + signatures ([data-event-*]) + overrides + stagger
eidos/lib/config.ts         ✓  validates presets (css + js) + signatures
eidos/lib/themes/base.ts    ✓  built-in DATA (keyframes + signatures + presets)
eidos/active-eidos.svelte.ts  ✓  eidos.motion → delegates to uix.motion + registers the css presets at boot
eidos/components/dialog/    ✓  wrapper: motion prop → data-animation-style (no runMotion)

active-uix/active-uix.svelte.ts  ✓  uix.motion : EngineMotion (createActiveUix creates it; attach reads it from the app)
soma/core/soma.svelte.ts    ✓  soma.motion → uix.motion
soma/layers/presence.svelte.ts  ✓  `motion: EngineMotion` opt → motion.run(node, phase) (JS gating) + tests

(✓ done. Relocated to arts/motion in the service refactor. Pending: passing motion to more overlays (drawer/popover/…); named built-in JS drivers; F6/F7.)


15. The event-moment: from events.css to signatures

events.css WAS "the hand-implemented --event surface". In F2 it migrated to signatures: the 9 keyframes + 12 reactions (announce pulse per intent, commit settle, dismiss fade, press squeeze, present rise) now live in EidosConfig.motion.{keyframes,signatures} (presets/css.ts → BUILTIN_SIGNATURES) and are generated into generated/base.css — theme-extensible. events.css was reduced to two global concerns: the compositor hint (will-change) and the reduced-motion cap (the signature's motion is silenced under reduce, but sound + haptic keep communicating — the cross-modal advantage). archetypes.css (4 baseline hover/focus transitions) stays as the transversal micro-interaction.


16. Comparison with Chakra UI v3

Capability Eidos Chakra v3
The --state moment (data-state + named presets) ✅ presets + the motion prop ✅ animationStyle + data-state
Presence (hold-through-exit) ✅ soma (getAnimations, covers transitions) ✅ (animationend)
keyframes registry + tokens ✅ ✅
placement-aware ✅ data-side ✅ data-placement
The --event moment (perceptual signature) ✅ signatures + sema (intent/hold/sequence) ❌ doesn't exist
Both moments integrated ✅ ❌ (only --state)
physics / FLIP / orchestration (JS drivers) ✅ spring / rect / waapi ❌

The --event moment integrated with the perceptual signature (and with sound/haptic) is what no current framework has. Chakra animates the state; UIX animates the state and the occurrence, keeping them distinct.


17. Naming decisions

  • The state-moment attr: data-animation-style (NOT data-motion, which is reserved for the reduce-motion pref allow/reduce).
  • The event-moment attrs: the data-event-* sema already stamps.
  • Cleanup (F7) ✓: NavMenu's directional data-motion (a planned rename) does not exist in the code — moot. data-motion-ref and the "TSC event:* scope" are discarded (no real use).

18. Implementation phases

The "beat every framework" roadmap. F1–F6 implemented:

  • F1 — The two-moment foundation ✓: the types (MotionConfig {keyframes, signatures, presets}, EventSignature, StatePreset) + generation (both surfaces) + the EngineMotion engine (the uix.motion service, relocated to $motion in the refactor) + validation + tests.
  • F2 — The cross-modal signature ✓ (beats SwiftUI): events.css migrated to signatures (§15); the signature's motion + sound + haptic come from the SAME event (a coupling SwiftUI's .sensoryFeedback leaves loose).
  • F3 — Polished presence ✓ (matches Base UI): Dialog/Popover/Drawer/ Accordion migrated to presets; per-component duration + easing overrides (--motion-duration/ease-{enter,exit} + @property inherits:false), inset-aware slide (--motion-slide-leave), the measured-height bridge (--height). Zero timing regression.
  • F4 — The mechanical JS engine ✓ (matches Framer): the spring (real physics) / waapi / rect (FLIP) drivers; Presence.motion → motion.run(node, phase) (the engine as the uix.motion service, consumed by soma and eidos with no coupling); a real Dialog bouncing.
  • F5 — Choreography ✓ (matches Material 3): declarative stagger (--motion-stagger-{index,each}), the shared-axis-x/y + fade-through presets; container-transform via the rect driver.
  • F6 — Token rigor ✓ (matches Carbon): the raw signature tokenized — the duration scale gains the long stretch (slower 400 · deliberate 600 · emphatic 800 · sustained 1000ms). (Corrected 2026-07-06: F6 originally set the neutral/affirm/fulfill announces to deliberate claiming alignment "with the book's holds-by-intent" — that aligned with the DRIFTED runtime table, not the book. Per the book's regions (TABLA 32.2): neutral/affirm = moderate ("breve"), fulfill = slower ("breve-media"), and the long end (emphatic/sustained) belongs to risk/threat. The hold never truncates a signature — the visual channel awaits the expression, capped by MAX_EXPRESSION_WAIT_MS; see decisions/book-deviations.md D.12.) Shared-axis travels the canonical --motion-distance-xl (30px); fade-through uses --motion-scale-through (0.92); press/commit snap to the existing scale (sub-perceptible). The emphasized easing (M3 emphasized-decelerate). Productive/ expressive sets (Carbon): primitives.motion.expressive emits a [data-motion-set='expressive'] scope remapping the easing — productive is the default. Distance-scaled duration stays a pairing convention (distance token ↔ duration token), not a runtime formula (no CSS consumer today — the rect driver would do it by measurement if a consumer asks).
  • F7 — Extensibility + typegen ✓ (beyond everyone): the --state moment's preset names are type-safe + app-extensible via an augmentable registry — EidosMotionPresets (mirroring SemaChannelSignatures), with MotionPresetName = keyof EidosMotionPresets | 'none' | (string & {}). An app adds type-safe presets with declare module '$uix/eidos' { interface EidosMotionPresets { … } }: the motion prop autocompletes them and a typo is a compile error. The engine ($motion/uix.motion) stays string (open at runtime) — the registry is compile-time ergonomics over the props; a test pins the built-in set against it. NavMenu's directional data-motion no longer exists (the rename is moot). transition with named groups stays deferred (no consumer; getAnimations({ subtree: true }) would cover it).

Roadmap F1–F7 complete. Every phase kept zero regression for anyone not using motion or firing new events.


19. Out of scope / deferred

  • A motion-one adapter or another external JS engine (opt-in, never a base dependency).
  • Exotic drivers (complex timelines) — only with a real consumer.
  • Widening durations to 7 steps (Chakra-style) — only if 5 falls short.

Revision notes: 2026-06-21 — closure of the motion prop's domain (§D.12): the state domain (the dedicated data-motion-state attr + the select-pop emphasis preset, <Card> pilot), content loops (spin/pulse/ping/bounce, un-gated infinite rules) + the <Motion> exit fix (tick no-op), the [data-debug-stagger] debug affordance, and the first named built-in JS preset spring-pop (the spring driver's debut, registered on ActiveEidos; demonstrated on Popover.Content motion="spring-pop"). Loops+lifecycle demo at web/routes/demos/motion. Earlier (2026-06-04): F1–F7 implemented + the engine relocated to arts/motion (the uix.motion service). If the code diverges, the code wins; open an issue. References: the historical seed GUIA_IMPLEMENTACION_SEMAUIX.md (§4.3, §7, §11 — the event/state/signature model), active-architecture.md §5/§6 (the causal chain, the attrs), THEMING (tokens), soma-architecture.md (Presence), sema.md (signature/signals), arts/adom/README.md (reduced-motion).

Powered by TurnKey Linux.