|
|
---
|
|
|
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`/`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`), 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:'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):
|
|
|
|
|
|
```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) (['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.
|
|
|
|
|
|
```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='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`:
|
|
|
|
|
|
```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).
|