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

792 lines
41 KiB

This file contains ambiguous Unicode characters!

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

---
title: Eidos Motion — Design and Architecture
type: reference
audience: human + agent
authority: E1/E3 — the two-moment motion model and the engine architecture
status: current
source: 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`](../../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`](./motion-guide.md). For decisions and history (incl.
> the "coordinated" engine retired in Plan A) →
> [`MOTION_SERVICE_RFC.md`](../../src/uix/eidos/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](#1-thesis-and-positioning)
2. [The two moments](#2-the-two-moments)
3. [Motion across the 4 layers](#3-motion-across-the-4-layers)
4. [Architecture — the two-surface registry](#4-architecture--the-two-surface-registry)
5. [The types](#5-the-types)
6. [The `EngineMotion` API](#6-the-enginemotion-api)
7. [The drivers](#7-the-drivers)
8. [The DOM contract](#8-the-dom-contract)
9. [Soma integration (`Presence`)](#9-soma-integration-presence)
10. [Reduced motion](#10-reduced-motion)
11. [Primitives and keyframes](#11-primitives-and-keyframes)
12. [Initial content (signatures + presets)](#12-initial-content-signatures--presets)
13. [Per-component defaults](#13-per-component-defaults)
14. [Where the code lives](#14-where-the-code-lives)
15. [The event-moment: from `events.css` to `signatures`](#15-the-event-moment-from-eventscss-to-signatures)
16. [Comparison with Chakra UI v3](#16-comparison-with-chakra-ui-v3)
17. [Naming decisions](#17-naming-decisions)
18. [Implementation phases](#18-implementation-phases)
19. [Out of scope / deferred](#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`](../architecture/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](../../src/uix/eidos/MOTION_SERVICE_RFC.md). `@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](../../src/uix/eidos/MOTION_SERVICE_RFC.md).
> **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](../../src/uix/eidos/MOTION_SERVICE_RFC.md).
> **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`/`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`), projected as `data-motion` by `ActivePrefsDomProjection`,
tracked by `ReducedMotionTracker` (`arts/adom`), exposed on
`ActiveDom.prefersReducedMotion.matches`.
---
## 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:'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):
```ts
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) (['present','open'], …)
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.
```ts
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 `Animation`s 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):
```css
[data-event='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 …; }
```
**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`:
```css
[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) + `--floating-transform-origin` (soma floating).
**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`](../../src/uix/eidos/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 + `@media
(prefers-reduced-motion)` per policy (`instant` → `animation: none`;
`opacity-only` → fade only; `none` → untouched).
- **Runtime (JS drivers)**: `MotionContext.reduced` from
`ActiveDom.prefersReducedMotion`; 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) |
**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 |
| `--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 `--floating-transform-origin` |
| `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`](../architecture/active-architecture.md) §5/§6
(the causal chain, the attrs), THEMING (tokens),
[`soma-architecture.md`](../architecture/soma-architecture.md) (Presence),
[`sema.md`](../architecture/sema.md) (signature/signals),
`arts/adom/README.md` (reduced-motion).

Powered by TurnKey Linux.