feat(motion): motion engine as arts/motion service + F6 tokens + F7 typegen

Relocate the motion runtime out of eidos into a runtime art (src/arts/motion,
$motion), exposed as uix.motion and consumed by BOTH soma (Presence.motion ->
motion.run) and eidos (delegates + registers presets) -- dissolving the
soma->eidos coupling. Remove DialogProps.runMotion / eidos.motionRunner; the
bridge is now EngineMotion.run (reads data-animation-style). Delete the 4
relocated dead files (lib/motion/{types,runtime,runner,presets/js}.ts); the
preset DATA (presets/css.ts) stays in eidos. Regenerate generated/base.css.

F6 - token rigor (Carbon): tokenize the raw firma durations (slower/deliberate/
emphatic/sustained holds, escalating by announce intent severity), add
--motion-distance-xl (30px shared-axis), --motion-scale-through (0.92), the
emphasized easing, and productive/expressive sets ([data-motion-set=expressive]).

F7 - extensibility + typegen: app-extensible, type-safe preset-name registry
(EidosMotionPresets, mirroring SemaChannelSignatures); MotionPresetName =
keyof EidosMotionPresets | none | (string & {}).

Also sweeps other in-progress working-tree edits (web/routes/temas/grafito).

Verify: npm run check -> 1 pre-existing error (grafito), 0 new; motion 22/22,
active-uix 25/25, Presence 2/2, Dialog 3/3. Pre-existing words-track failures
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 718cb4ac14
commit be6bd540aa

@ -255,7 +255,62 @@
"PowerShell(npm run generate:eidos-css 2>&1)",
"PowerShell(Get-ChildItem -Recurse G:\\\\dev\\\\svelte\\\\vicen\\\\web\\\\routes\\\\temas -File | ForEach-Object { \"{0,6} {1}\" -f \\(Get-Content $_.FullName | Measure-Object -Line\\).Lines, $_.FullName.Replace\\('G:\\\\dev\\\\svelte\\\\vicen\\\\web\\\\routes\\\\temas\\\\',''\\) })",
"Bash(Get-ChildItem -Path \"G:\\\\dev\\\\svelte\\\\vicen\\\\src\\\\uix\\\\eidos\\\\components\" -Directory)",
"Bash(Select-Object -ExpandProperty Name)"
"Bash(Select-Object -ExpandProperty Name)",
"Bash(echo \"CHECK_DONE exit=$?\")",
"Bash(echo \"SOMA_TESTS_DONE exit=$?\")",
"Bash(echo \"vocab exit=$?\")",
"Bash(echo \"morfo exit=$?\")",
"Bash(npx tsx *)",
"Bash(echo \"eidoslint exit=$?\")",
"Bash(Get-ChildItem -Path \"G:\\\\dev\\\\svelte\\\\vicen\\\\src\\\\uix\\\\soma\\\\components\\\\words\" -Recurse -File)",
"Bash(Select-Object FullName)",
"Bash(Sort-Object FullName)",
"Bash(grep -E \"\\\\[data-words\" G:/dev/svelte/vicen/src/uix/eidos/components/words/words.css | sed 's/.*\\\\\\(\\\\[data-words[^]]*\\\\]\\\\\\).*/\\\\1/' | sort | uniq > /tmp/eidos_attrs.txt && wc -l /tmp/eidos_attrs.txt)",
"Bash(echo \"exit=$? \\(0=found→agent WRONG, 1=missing→agent RIGHT\\)\")",
"Bash(awk '/const columnsSpec|type: .columns./{print NR\": \"$0}' src/uix/soma/components/words/engine/blocks/built-ins.ts)",
"Bash(awk 'NR>=400 && NR<=440' src/uix/eidos/components/words/words.css)",
"Bash(awk '/export function toggleMarkSet/,/^}/' src/uix/soma/components/words/engine/operations/marks.ts)",
"Bash(awk '/export function actionFromPaste/,/^}/' src/uix/soma/components/words/engine/operations/input.ts)",
"Bash(perl -ne 'print \"$.: \", $_ if /\\\\x00/' src/uix/soma/components/words/engine/operations/input.ts)",
"Bash(perl -i -pe 's/\\\\x00/\\\\\\\\0/g' src/uix/soma/components/words/engine/operations/input.ts)",
"Bash(echo \"check started in background \\(pid $!\\)\")",
"Bash(break)",
"Bash(echo \"check started \\(pid $!\\)\")",
"Bash(rg -n 'bind:api|api\\\\s*=\\\\s*$bindable|getContext|setContext|WordsProvider.ctx|export.*getWords|getWordsApi|context<' src/uix/soma/components/words/components/words.svelte src/uix/soma/components/words/exports.ts)",
"Bash(echo \"words tests started \\(pid $!\\)\")",
"Bash(rg -n \"\\\\-\\\\-words-block-gap\" src/uix/eidos/components/words/*.ts src/uix/eidos/components/words/recipe* src/uix/eidos/**/recipes/*.ts)",
"Bash(rg -rn \"block-gap\" src/uix/eidos/components/words/)",
"Bash(rg -rn \"block-gap|content-py|content-px\" src/uix/eidos --glob '*.ts')",
"Bash(rg -rn \"block-gap\" src/uix/eidos --glob '*.ts')",
"Bash(rg -rn \"data-words-node\" src/uix/soma/components/words/engine/)",
"Bash(rg -n 'import \\\\{ Button|from '\\\\''\\\\$uix/eidos/components/button'\\\\''' src/uix/eidos/components/words/words-inspector.svelte)",
"Bash(rg --files src/uix/soma/components/words/engine/extensions)",
"Bash(xargs -I{} sh -c \"rg -n 'function slashInsertionCommand' {} && sed -n '/function slashInsertionCommand/,/^}/p' {}\")",
"Bash(rg -n \"insertIntoActiveColumn\" src/uix/eidos/components/words/*.svelte)",
"Bash(Get-ChildItem -Path \"G:\\\\dev\\\\svelte\\\\vicen\\\\src\\\\uix\\\\\" -Directory | Select-Object -ExpandProperty Name)",
"WebFetch(domain:www.chakra-ui.com)",
"Bash(echo \"---EXIT: $?---\")",
"PowerShell($ProgressPreference='SilentlyContinue'; \\(Get-Command gh -ErrorAction SilentlyContinue\\).Source; gh --version 2>&1 | Select-Object -First 1)",
"WebFetch(domain:api.github.com)",
"WebFetch(domain:cdn.jsdelivr.net)",
"Bash(tee /tmp/eidos-check.log)",
"Bash(grep -iE \"ERROR \" /tmp/eidos-check.log | grep -iv \"WARNING\" | head; echo \"=== any of my new files mentioned at all? ===\"; grep -icE \"lib.motion|presets.css|runtime\\\\.ts\" /tmp/eidos-check.log)",
"Bash(Select-Object Name)",
"Bash(for dir *)",
"WebFetch(domain:m3.material.io)",
"WebFetch(domain:carbondesignsystem.com)",
"WebFetch(domain:motion.dev)",
"WebFetch(domain:spectrum.adobe.com)",
"WebFetch(domain:fluent2.microsoft.design)",
"WebFetch(domain:www.hackingwithswift.com)",
"WebFetch(domain:www.useyourloaf.com)",
"WebFetch(domain:swiftwithmajid.com)",
"WebFetch(domain:design.google)",
"WebFetch(domain:swiftui-lab.com)",
"WebFetch(domain:nerdyak.tech)",
"Bash(tee /tmp/f1-check.log)",
"Bash(tee /tmp/f1-check2.log)",
"Bash(tee /tmp/f2-check.log)"
],
"additionalDirectories": [
"\\tmp"

@ -30,4 +30,5 @@ export { defineActivePerm } from './perm.ts';
export { defineActiveSession } from './session.ts';
export { defineActiveStorage } from './storage.ts';
export { defineEngineHttp } from './http.ts';
export { defineEngineMotion } from './motion.ts';
export { defineEngineSium } from './sium.ts';

@ -0,0 +1,30 @@
import { createEngineMotion } from '$motion';
import type { EngineMotion, MotionDom } from '$motion';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineEngineMotion()` produces a service factory for the `motion` slot. The
* engine needs the DOM service (frame scheduling + reduced-motion), injected via
* `serviceDependencies: ['dom']`. Without a `dom` service it degrades — JS
* drivers settle; CSS presets are declarative anyway. Eidos registers its
* presets into the engine at boot; soma's `Presence` consumes it via `run()`.
*/
export function defineEngineMotion(): AppServiceFactory<
'motion',
readonly [],
readonly ['dom'],
EngineMotion
> {
return {
name: 'motion',
coreDependencies: [],
serviceDependencies: ['dom'],
initMode: 'lazy',
create({ services }): EngineMotion {
return createEngineMotion({ dom: services.dom as MotionDom | undefined });
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,84 @@
# motion — animation runtime
`EngineMotion` is the runtime engine for the UIX motion system: it registers
motion **presets** and **runs** them. It is an art (a pure runtime artifact,
public methods over private state, no reactive `$state`) so that **both** UIX
layers can consume it via `uix.motion` without a cross-layer dependency:
- **soma** (`Presence`) calls `motion.run(node, phase)` to run + await a JS
preset (a `spring`) before unmounting — the gating soma can't get from
`getAnimations()` alone.
- **eidos** generates the CSS (keyframes + preset rules), registers its visual
presets into the engine, and drives wrappers.
The engine owns the **execution**; eidos owns the **CSS generation** and the
visual presets **data**. Neither imports the other — they meet at `uix.motion`.
## The two moments
Motion occurs in two moments, each animable (one, the other, or both):
| | `--event` | `--state` |
|---|---|---|
| Attr | `data-event-*` (sema) | `data-state` (soma) |
| What | the perceptual **firma** during a signal's hold | the transition to/from a persistent condition |
| Registry | `signatures` (eidos generates CSS) | `presets` (named, per-component) |
`EngineMotion` runs the **`--state`** presets (CSS → settled; JS → driver). The
`--event` firma is CSS that eidos generates from `signatures`; the engine does
not run it.
## API
```ts
const motion = createEngineMotion({ dom }) // dom: a MotionDom port
motion.register('scale-fade', { driver: 'css', … }) // eidos registers presets
motion.register('pop', { driver: 'spring', enter: spring({ … }) })
motion.run(node, 'enter') // resolve node's data-animation-style + run
motion.enter(el, 'pop') // run a preset by name
motion.cancel(el) // cancel active JS motion on el
await motion.pending(el) // combined finished, for Presence
motion.dispose() // idempotent: cancel all + clear registry
```
- **CSS preset** → declarative: a settled handle (the generated CSS + soma's
`Presence` via `getAnimations()` do the work).
- **JS preset** → runs its `MotionRun` driver, normalises the result
(`Animation` | `Animation[]` | `MotionHandle`) to one handle, tracks it per
element. `pending(el)` is what Presence awaits for drivers (`spring`) that
`getAnimations()` can't see.
## Drivers (`./drivers`)
`MotionRun` factories for JS presets:
- **`spring`** — self-contained physics: an independent semi-implicit-Euler
spring per property, stepped via `ctx.dom.requestFrame`. Overshoot + settle —
the curve no cubic-bezier expresses. Cancellable via `ctx.signal`.
- **`waapi`** — `el.animate(...)`. The `Animation` is in `getAnimations()`, so
Presence awaits it for free.
- **`rect`** — FLIP: measures first/last rects, animates the inverse delta via
WAAPI (layout / shared-element transitions).
## The DOM port
This art imports no other art. The DOM dependency arrives **injected** and is
typed by the structural `MotionDom` port (`requestFrame` / `cancelFrame` /
`prefersReducedMotion`); the real `ActiveDom` satisfies it.
## Composition
```ts
// active-app (attach path)
const App = createActiveApp({
services: { dom: defineActiveDom(), motion: defineEngineMotion() }
});
// active-uix (standalone) creates it directly and exposes uix.motion.
```
Without a `dom` service the engine degrades (JS drivers settle; CSS is
declarative regardless). `duration` / `ease` are `string` (token keys or raw
values); eidos validates the keys — this art stays token-agnostic.

@ -0,0 +1,158 @@
/**
* Built-in JS motion drivers — the mechanical engine.
*
* `MotionRun` factories `EngineMotion` executes for a JS `StatePreset`. Unlike
* CSS presets (declarative, awaited via `getAnimations()`), these run
* imperatively; the engine tracks each handle's `finished`.
*
* - `waapi` wraps the Web Animations API. The returned `Animation` IS in
* `getAnimations()`, so soma's `Presence` awaits it for free.
* - `spring` is a self-contained physics integrator — an INDEPENDENT
* semi-implicit-Euler spring per property, stepped by `ctx.dom.requestFrame`.
* The curve no cubic-bezier can express (overshoot + settle). NOT in
* `getAnimations()`, so its `finished` reaches Presence via `pending(el)`.
* - `rect` is FLIP: measures first/last rects and animates the inverse delta.
*/
import type { MotionHandle, MotionRun } from './types'
export interface SpringPhysics {
/** Higher = snappier pull toward the target. @default 170 */
readonly stiffness?: number
/** Higher = less oscillation (more friction). @default 22 */
readonly damping?: number
/** Higher = more inertia. @default 1 */
readonly mass?: number
/** Settle threshold on position (distance to target). @default 0.01 */
readonly restDelta?: number
/** Settle threshold on velocity. @default 0.01 */
readonly restSpeed?: number
}
export interface SpringConfig extends SpringPhysics {
/** CSS property (or custom prop) → `[from, to]` numeric pair. */
readonly values: Readonly<Record<string, readonly [number, number]>>
/** Per-property unit appended on write (e.g. `{ '--ty': 'px' }`). Default none. */
readonly unit?: Readonly<Record<string, string>>
}
// Fixed integrator step (seconds). A constant step keeps the simulation stable
// and frame-rate independent in feel; rAF cadence drives wall-clock pacing.
const FIXED_DT = 1 / 60
/**
* A spring-driven `MotionRun`. Runs one independent spring per property and
* settles when ALL are at rest (within `restDelta` / `restSpeed`). Honours
* `ctx.signal` for cancellation (resolves `finished` so awaiters proceed).
*/
export function spring(config: SpringConfig): MotionRun {
const k = config.stiffness ?? 170
const c = config.damping ?? 22
const m = config.mass ?? 1
const restDelta = config.restDelta ?? 0.01
const restSpeed = config.restSpeed ?? 0.01
return (ctx): MotionHandle => {
const tracks = Object.entries(config.values).map(([prop, [from, to]]) => ({
prop,
to,
x: from,
v: 0,
unit: config.unit?.[prop] ?? ''
}))
let resolve!: () => void
const finished = new Promise<void>((r) => (resolve = r))
let frame: number | undefined
let done = false
const write = (t: (typeof tracks)[number]) => {
ctx.el.style.setProperty(t.prop, `${t.x}${t.unit}`)
}
const settle = () => {
if (done) return
done = true
if (frame !== undefined) {
ctx.dom.cancelFrame(frame, ctx.el)
frame = undefined
}
resolve()
}
const step = () => {
if (ctx.signal.aborted) {
settle()
return
}
let allRest = true
for (const t of tracks) {
const force = -k * (t.x - t.to) - c * t.v
t.v += (force / m) * FIXED_DT
t.x += t.v * FIXED_DT
if (Math.abs(t.v) > restSpeed || Math.abs(t.x - t.to) > restDelta) {
allRest = false
} else {
t.x = t.to
t.v = 0
}
write(t)
}
if (allRest) {
settle()
return
}
frame = ctx.dom.requestFrame(step, ctx.el)
}
tracks.forEach(write) // initial paint at `from`
ctx.signal.addEventListener('abort', settle, { once: true })
frame = ctx.dom.requestFrame(step, ctx.el)
return { finished, cancel: settle }
}
}
/**
* A WAAPI-driven `MotionRun`. Returns the `Animation` — which the engine
* normalises to a handle, and which `getAnimations()` already tracks.
*/
export function waapi(
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
options?: number | KeyframeAnimationOptions
): MotionRun {
return (ctx) => ctx.el.animate(keyframes, options)
}
const SETTLED: MotionHandle = { finished: Promise.resolve(), cancel() {} }
/**
* A FLIP-driven `MotionRun` (layout / shared-element transition). Given the
* element's PRE-change rect (`ctx.sourceRect`), it measures the post-change rect
* and animates the inverse delta (translate + scale) back to identity via WAAPI
* — so the element appears to slide/scale FROM where it was TO where it now is.
* The caller measures `sourceRect` BEFORE mutating layout, then runs this AFTER.
*/
export function rect(options?: { duration?: number; easing?: string }): MotionRun {
return (ctx) => {
const first = ctx.sourceRect
if (!first) return SETTLED
const last = ctx.el.getBoundingClientRect()
const dx = first.left - last.left
const dy = first.top - last.top
const sx = last.width ? first.width / last.width : 1
const sy = last.height ? first.height / last.height : 1
// No measurable layout shift → nothing to animate.
if (dx === 0 && dy === 0 && sx === 1 && sy === 1) return SETTLED
return ctx.el.animate(
[
{
transformOrigin: 'top left',
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`
},
{ transformOrigin: 'top left', transform: 'none' }
],
{ duration: options?.duration ?? 300, easing: options?.easing ?? 'cubic-bezier(0.2, 0, 0, 1)' }
)
}
}

@ -0,0 +1,231 @@
/**
* EngineMotion — the registry + run surface for state-moment motion presets.
*
* Pure runtime art (public methods over private state, no reactive `$state`).
* Composed by active-uix as `uix.motion` and consumed by BOTH soma (Presence
* calls `run(node, phase)`) and eidos (registers presets + drives wrappers).
*
* Two driver families:
* - **CSS** presets are DECLARATIVE: a wrapper sets `data-animation-style`; the
* generated CSS animates on `data-state` and soma's `Presence` awaits it via
* `getAnimations()`. The engine does NOT drive them → settled handle.
* - **JS** presets (`waapi` / `spring` / `rect` / `svelte`) RUN here: the engine
* builds a `MotionContext`, invokes the preset's `MotionRun`, normalises the
* result to one handle, and tracks it per element for `cancel` / `pending`.
*/
import type {
JsStatePreset,
MotionContext,
MotionDom,
MotionHandle,
MotionSide,
StatePreset
} from './types'
import { isCssStatePreset } from './types'
/** A handle that is already finished — for declarative (CSS) presets. */
const SETTLED_HANDLE: MotionHandle = Object.freeze({
finished: Promise.resolve(),
cancel() {}
})
const DEFAULT_DURATION = (): number => 240
const DEFAULT_EASE = (): string => 'cubic-bezier(0.4, 0, 0.2, 1)'
export interface EngineMotionOptions {
/** Injected DOM port (frame scheduling + reduced-motion). Optional in headless/tests. */
readonly dom?: MotionDom
/** Initial presets. Eidos registers its built-ins + theme presets at boot. */
readonly presets?: Readonly<Record<string, StatePreset>>
}
/** Per-run overrides. `dom` falls back to the engine's injected dom. */
export interface MotionRunOptions {
readonly dom?: MotionDom
readonly reduced?: boolean
readonly side?: MotionSide
readonly sourceRect?: DOMRect
readonly targetRect?: DOMRect
readonly duration?: (key: string) => number
readonly ease?: (key: string) => string
}
export interface EngineMotion {
/** Register (or replace) a preset by name. */
register(name: string, preset: StatePreset): void
resolve(name: string): StatePreset | undefined
has(name: string): boolean
list(): string[]
/** Run a preset by name. CSS → settled; JS → driver. */
enter(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle
exit(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle
/**
* Resolve the preset named by `el`'s `data-animation-style` and run it for
* `phase`. CSS / unknown → settled; JS → driver. Soma's `Presence` calls this
* (the engine reads the attr, so soma never learns the motion contract).
*/
run(el: HTMLElement, phase: 'enter' | 'exit', opts?: MotionRunOptions): MotionHandle
/** Cancel active JS-driven motion on `el`. */
cancel(el: HTMLElement): void
/** Combined `finished` of active JS-driven motion on `el`, for Presence to await. */
pending(el: HTMLElement): Promise<void>
/** Idempotent: cancels every active handle and clears the registry. */
dispose(): void
}
export function createEngineMotion(options?: EngineMotionOptions): EngineMotion {
const boundDom = options?.dom
const presets = new Map<string, StatePreset>()
if (options?.presets) {
for (const [name, preset] of Object.entries(options.presets)) presets.set(name, preset)
}
// Active JS-driven handles, per element. A plain Map (not WeakMap) so dispose
// can iterate; emptied sets are deleted so it doesn't leak detached elements.
const active = new Map<HTMLElement, Set<MotionHandle>>()
let disposed = false
function track(el: HTMLElement, handle: MotionHandle): void {
let set = active.get(el)
if (!set) active.set(el, (set = new Set()))
set.add(handle)
void handle.finished.finally(() => {
const s = active.get(el)
if (!s) return
s.delete(handle)
if (s.size === 0) active.delete(el)
})
}
function runJs(
el: HTMLElement,
preset: JsStatePreset,
phase: 'enter' | 'exit',
opts?: MotionRunOptions
): MotionHandle {
const dom = opts?.dom ?? boundDom
const reduced = opts?.reduced ?? dom?.prefersReducedMotion.matches ?? false
// Reduced motion: 'instant' snaps (no JS run). Other policies run the
// MotionRun (it can read `ctx.reduced` and self-degrade) or rely on a CSS
// `fallback` the wrapper applies declaratively.
if (reduced && (preset.reduce ?? 'instant') === 'instant') return SETTLED_HANDLE
const motionRun = phase === 'enter' ? preset.enter : preset.exit
// A driver that schedules frames needs a dom. Without a run or a dom, settle.
if (!motionRun || !dom) return SETTLED_HANDLE
const controller = new AbortController()
const ctx: MotionContext = {
moment: 'state',
phase,
el,
dom,
reduced,
duration: opts?.duration ?? DEFAULT_DURATION,
ease: opts?.ease ?? DEFAULT_EASE,
side: opts?.side,
sourceRect: opts?.sourceRect,
targetRect: opts?.targetRect,
signal: controller.signal
}
const handle = toHandle(motionRun(ctx), controller)
track(el, handle)
return handle
}
function byName(
el: HTMLElement,
name: string,
phase: 'enter' | 'exit',
opts?: MotionRunOptions
): MotionHandle {
const preset = presets.get(name)
// Unknown name OR a CSS preset → declarative (generated CSS + Presence). JS → run.
if (!preset || isCssStatePreset(preset)) return SETTLED_HANDLE
return runJs(el, preset, phase, opts)
}
return {
register(name, preset) {
presets.set(name, preset)
},
resolve(name) {
return presets.get(name)
},
has(name) {
return presets.has(name)
},
list() {
return [...presets.keys()]
},
enter(el, name, opts) {
return byName(el, name, 'enter', opts)
},
exit(el, name, opts) {
return byName(el, name, 'exit', opts)
},
run(el, phase, opts) {
const name = el.getAttribute('data-animation-style')
if (!name) return SETTLED_HANDLE
return byName(el, name, phase, opts)
},
cancel(el) {
const set = active.get(el)
if (!set) return
for (const handle of [...set]) handle.cancel()
active.delete(el)
},
pending(el) {
const set = active.get(el)
if (!set || set.size === 0) return Promise.resolve()
return Promise.all([...set].map((h) => h.finished)).then(() => {})
},
dispose() {
if (disposed) return
disposed = true
for (const set of active.values()) {
for (const handle of [...set]) handle.cancel()
}
active.clear()
presets.clear()
}
}
}
/**
* Normalise a `MotionRun` result to a single `MotionHandle`. An `Animation`
* (or `Animation[]`) and a `MotionHandle` both expose `finished` + `cancel`;
* cancellation also aborts the shared signal so a `spring` stops its frame loop.
*/
function toHandle(
result: Animation | readonly Animation[] | MotionHandle,
controller: AbortController
): MotionHandle {
if (Array.isArray(result)) {
const anims = result as readonly Animation[]
return {
finished: Promise.all(anims.map((a) => a.finished)).then(() => {}),
cancel() {
controller.abort()
for (const a of anims) safeCancel(a)
}
}
}
const single = result as { finished: Promise<unknown>; cancel: () => void }
return {
finished: Promise.resolve(single.finished).then(() => {}),
cancel() {
controller.abort()
safeCancel(single)
}
}
}
function safeCancel(a: { cancel?: () => void }): void {
try {
a.cancel?.()
} catch {
// Already finished / detached — nothing to cancel.
}
}

@ -0,0 +1,28 @@
// Public surface of the motion artifact. Named re-exports so consumers who only
// need the engine do not pay for the drivers they never reach.
export { createEngineMotion } from './engine-motion'
export type { EngineMotion, EngineMotionOptions, MotionRunOptions } from './engine-motion'
export { spring, waapi, rect } from './drivers'
export type { SpringConfig, SpringPhysics } from './drivers'
export { isCssStatePreset } from './types'
export type {
CssPhase,
CssStatePreset,
EventSignature,
JsDriver,
JsStatePreset,
KeyframeName,
KeyframeStops,
MotionConfig,
MotionContext,
MotionDom,
MotionHandle,
MotionPresetName,
MotionRun,
MotionSide,
ReducePolicy,
StatePreset
} from './types'

@ -0,0 +1,146 @@
/**
* Motion — type contract (two-moment model).
*
* `EngineMotion` is a runtime artifact (an art), like `EngineTimers`: public
* methods over private state, no reactive `$state`. It RUNS animations and
* tracks handles. Living in `arts/` (below the UIX layers) lets BOTH soma
* (Presence) and eidos consume it via `uix.motion` with no cross-layer
* dependency. Eidos owns the CSS GENERATION (keyframes/preset rules → CSS) and
* the visual presets DATA; this art owns the engine.
*
* Per the arts convention, this artifact does NOT import other arts. The DOM
* dependency arrives injected and is typed by the structural `MotionDom` port
* below — the real `ActiveDom` satisfies it. Duration / ease are `string` (token
* keys like `'moderate'` or raw values like `'600ms'`); eidos validates the keys.
*
* Motion occurs in TWO moments, each animable (one, the other, or both):
* 1. `--event` (`data-event-*`): the perceptual flourish during a sema signal's
* HOLD — the `motion` channel of the firma, generic by family/intent/event.
* → `EventSignature` (eidos generates its CSS).
* 2. `--state` (`data-state`): the transition to/from a persistent condition,
* per-component via the `motion` prop. → `StatePreset`.
*/
/**
* The structural DOM port `EngineMotion` needs — a minimal subset of `ActiveDom`,
* so this art imports no other art. The injected `ActiveDom` satisfies it.
*/
export interface MotionDom {
requestFrame(callback: FrameRequestCallback, node?: Element | null): number
cancelFrame(handle: number, node?: Element | null): void
readonly prefersReducedMotion: { readonly matches: boolean }
}
/** Degradation under `prefers-reduced-motion: reduce`. */
export type ReducePolicy = 'instant' | 'opacity-only' | 'none'
/** Placement side (from `data-side`). */
export type MotionSide = 'top' | 'right' | 'bottom' | 'left'
/** Name of a registered `@keyframes`. */
export type KeyframeName = string
/** Name of a `StatePreset` — the value of the `motion` prop. `'none'` disables. */
export type MotionPresetName = string
/**
* `@keyframes` as data: stop (`from`/`to`/`0%`…) → CSS declarations (kebab prop →
* value). Values may reference CSS vars. Consumed by eidos's CSS generation.
*/
export type KeyframeStops = Readonly<Record<string, Readonly<Record<string, string>>>>
/** A CSS phase: composed keyframes + token refs + side-awareness. */
export interface CssPhase {
readonly keyframes: KeyframeName | readonly KeyframeName[]
/** Duration token key (`'moderate'`) or raw value. */
readonly duration?: string
/** Ease token key (`'out'`) or raw value. */
readonly ease?: string
readonly transformOrigin?: string
/** Per-`data-side` override (placement-aware). The base `keyframes` is the fallback. */
readonly bySide?: Partial<Record<MotionSide, KeyframeName | readonly KeyframeName[]>>
}
// ── Momento --event: the perceptual firma (generic by event) ─────────────────
/**
* The `motion` channel of an event's perceptual firma. Reacts to `data-event-*`
* during the hold. Defined by `family` / `intent` / `event` (not per component):
* a `commit` settles the same everywhere. Eidos generates its CSS.
*/
export interface EventSignature {
readonly family?: string
readonly intent?: string
/** Name(s) / prefix(es) of `data-event` (`['present','open']`, …). */
readonly event?: string | readonly string[]
readonly keyframes: KeyframeName | readonly KeyframeName[]
/** Duration token key or raw hold (`'600ms'`). */
readonly duration?: string
readonly ease?: string
readonly fill?: 'none' | 'forwards' | 'backwards' | 'both'
readonly reduce?: ReducePolicy
}
// ── Momento --state: named per-component preset (`motion` prop) ───────────────
/** JS drivers. The `css` driver is declarative (eidos generates the CSS). */
export type JsDriver = 'waapi' | 'spring' | 'rect' | 'svelte'
/**
* State-transition preset, applied per component via the `motion` prop (writes
* `data-animation-style`). Reacts to `data-state`.
*/
export interface CssStatePreset {
readonly driver: 'css'
readonly enter?: CssPhase
readonly exit?: CssPhase
readonly reduce?: ReducePolicy
}
export interface MotionContext {
readonly moment: 'event' | 'state'
readonly phase: 'enter' | 'exit'
readonly el: HTMLElement
readonly dom: MotionDom
readonly reduced: boolean
duration(key: string): number
ease(key: string): string
readonly side?: MotionSide
readonly sourceRect?: DOMRect
readonly targetRect?: DOMRect
readonly signal: AbortSignal
}
export interface MotionHandle {
readonly finished: Promise<void>
cancel(): void
}
export type MotionRun = (ctx: MotionContext) => Animation | readonly Animation[] | MotionHandle
/** State preset with a JS driver. */
export interface JsStatePreset {
readonly driver: JsDriver
readonly enter?: MotionRun
readonly exit?: MotionRun
readonly requires?: readonly ('sourceRect' | 'targetRect' | 'placement')[]
readonly reduce?: ReducePolicy
readonly fallback?: CssStatePreset
}
export type StatePreset = CssStatePreset | JsStatePreset
/**
* The motion config: keyframes + the two animation surfaces. Eidos generates CSS
* from `keyframes` + `signatures` + the css `presets`; the engine resolves +
* runs the `presets` (settled for css, driver for js).
*/
export interface MotionConfig {
readonly keyframes?: Readonly<Record<KeyframeName, KeyframeStops>>
readonly signatures?: Readonly<Record<string, EventSignature>>
readonly presets?: Readonly<Record<string, StatePreset>>
}
export function isCssStatePreset(preset: StatePreset): preset is CssStatePreset {
return preset.driver === 'css'
}

@ -36,6 +36,7 @@ import {
type ResponsiveProp
} from '$libs/dom';
import { EngineSemantic } from '$uix/sema';
import { createEngineMotion, type EngineMotion } from '$motion';
import type { LangNode, SupportedLocale } from '$libs/langs';
import { commonLangs, componentLangs } from '$uix/langs';
@ -120,6 +121,10 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
})
: undefined;
// Motion runtime — bound to the real dom (undefined when dom is disabled, so
// JS drivers settle instead of throwing). Eidos registers its presets at boot.
const motion = createEngineMotion({ dom });
if (options.registerDefaultLangs ?? true) {
langs.extend('common', commonLangs);
langs.extend('components', componentLangs);
@ -137,6 +142,7 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
dom,
disabledDom,
events,
motion,
portal: options.portal,
detachLangsPrefs
});
@ -152,6 +158,11 @@ export function attachActiveUix(app: ActiveApp, options: AttachActiveUixOptions
const appDom = (app as unknown as { dom: ActiveDom }).dom;
const appEvents = (app as unknown as { events?: EngineSemantic }).events;
// Motion: use the app's engine if it declared one (`defineEngineMotion`),
// else create a fallback bound to the app's dom. active-uix owns the fallback.
const appMotion = (app as unknown as { motion?: EngineMotion }).motion;
const motion = appMotion ?? createEngineMotion({ dom: appDom });
const ownsMotion = appMotion === undefined;
if (options.registerDefaultLangs ?? true) {
(app.langs as ActiveLangs).extend('common', commonLangs);
@ -163,6 +174,8 @@ export function attachActiveUix(app: ActiveApp, options: AttachActiveUixOptions
app,
dom: appDom,
events: appEvents,
motion,
ownsMotion,
portal: options.portal,
detachLangsPrefs: undefined
});
@ -182,6 +195,7 @@ interface StandaloneInit {
dom: ActiveDom | undefined;
disabledDom: ActiveDom | undefined;
events: EngineSemantic | undefined;
motion: EngineMotion;
portal: string | HTMLElement | undefined;
detachLangsPrefs: (() => void) | undefined;
}
@ -191,6 +205,9 @@ interface AttachInit {
app: ActiveApp;
dom: ActiveDom;
events: EngineSemantic | undefined;
motion: EngineMotion;
/** active-uix created the motion engine as a fallback (the app didn't declare one). */
ownsMotion: boolean;
portal: string | HTMLElement | undefined;
detachLangsPrefs: (() => void) | undefined;
}
@ -359,6 +376,10 @@ class ActiveUixImpl implements ActiveUix {
return this.init.events;
}
get motion(): EngineMotion {
return this.init.motion;
}
// ── Core ───────────────────────────────────────────────────────
get logger(): EngineLogger {
return this.init.mode === 'standalone' ? this.init.logger : this.init.app.logger;
@ -460,10 +481,11 @@ class ActiveUixImpl implements ActiveUix {
// dependency order: format → events/sema → dom → langs → prefs →
// timers → bus → logger.
if (this.init.mode === 'standalone') {
const { format, events, dom, disabledDom, clipboard, langs, prefs, timers, bus, logger } =
const { format, events, motion, dom, disabledDom, clipboard, langs, prefs, timers, bus, logger } =
this.init;
format?.dispose();
events?.dispose();
motion.dispose();
dom?.dispose();
disabledDom?.dispose();
clipboard?.dispose();
@ -472,6 +494,10 @@ class ActiveUixImpl implements ActiveUix {
timers.dispose();
bus.dispose();
logger.dispose();
} else if (this.init.ownsMotion) {
// Attach: the app owns its services. The ONE exception is a motion engine
// we created as a fallback because the app did not declare one.
this.init.motion.dispose();
}
// Attach mode: the app is owned by the application, not by us.

@ -19,6 +19,7 @@ import type { ActivePrefs } from '$prefs';
import type { LangNode, SupportedLocale } from '$libs/langs';
import type { PrefsEnvironment, PrefsIntentOf, PrefsSchema } from '$libs/prefs';
import type { EngineSemantic, EngineSemanticOptions } from '$uix/sema';
import type { EngineMotion } from '$motion';
/**
* Langs configuration. Required because every UIX consumer needs at
@ -102,6 +103,14 @@ export interface ActiveUix {
*/
readonly events: EngineSemantic | undefined;
/**
* Motion runtime (`arts/motion`). Always present. soma's `Presence` runs +
* awaits JS presets via `motion.run(node, phase)`; eidos registers its
* presets and drives wrappers. CSS presets stay declarative (awaited via
* `getAnimations()`).
*/
readonly motion: EngineMotion;
// ── Core (always present, regardless of boot mode) ─────────────────
readonly logger: EngineLogger;
/**

@ -42,6 +42,7 @@ import {
type RenderThemeCssOptions
} from './lib/render-css';
import { createThemeBaseEidosConfig } from './lib/themes/base';
import type { EngineMotion } from '$motion';
import type { EidosConfigPatch } from './lib/options';
import type {
ColorRole,
@ -184,6 +185,15 @@ export class ActiveEidos {
if (this.#cssVariables) this.renderCssVariables(this.#cssVariables, this.#cssVariablesOptions);
// Register this config's motion presets into the shared engine
// (`uix.motion`). soma's `Presence` + eidos wrappers resolve them by name
// there; eidos owns the CSS generation, the engine owns execution.
if (this.#uix && this.#config.motion?.presets) {
for (const [name, preset] of Object.entries(this.#config.motion.presets)) {
this.#uix.motion.register(name, preset);
}
}
if (this.#applyDom) {
this.apply();
this.#unsubscribe = this.#preferences.onPreferenceChange(() => this.apply());
@ -202,6 +212,19 @@ export class ActiveEidos {
return this.#requireDom();
}
/**
* The motion runtime — the shared `uix.motion` service (`arts/motion`). Eidos
* registers its presets into it at construction; soma's `Presence` resolves
* them by name via `motion.run(node, phase)`. Eidos owns the CSS generation;
* the engine owns execution. See `eidos-motion.md`.
*/
get motion(): EngineMotion {
if (!this.#uix) {
throw new ActiveEidosConfigError('motion runtime requires the uix service');
}
return this.#uix.motion;
}
get langs(): ActiveLangs {
if (!this.#langs) {
throw new ActiveEidosConfigError('langs service is required by ActiveEidos components');

@ -3,7 +3,7 @@
import type { AccordionContentProps } from './types';
import { getAccordionEidosContext } from './accordion-context';
let { children, ...rest }: AccordionContentProps = $props();
let { motion = 'collapse', children, ...rest }: AccordionContentProps = $props();
const accordion = getAccordionEidosContext();
const resolvedSize = $derived(accordion?.size ?? 'md');
@ -12,4 +12,9 @@
<!-- Forward children directly so headless can call it with `{ open }` snippet
props at render time. Wrapping in `{#snippet children}` would shadow
the prop. -->
<Accordion.Content {...rest} data-size={resolvedSize} {children} />
<Accordion.Content
{...rest}
data-size={resolvedSize}
data-animation-style={motion === 'none' ? undefined : motion}
{children}
/>

@ -215,6 +215,16 @@
[data-accordion-content] {
overflow: hidden;
/* Presence motion (momento --state): the `collapse` preset interpolates
* height between 0 and the measured open height. Soma writes the scroll
* height into `--_accordion-content-height`; bridge it to the preset's
* `--height` contract and pin duration + easing to the accordion's tokens
* (so the migration is bit-for-bit and the recipe tokens stay live). */
--height: var(--_accordion-content-height);
--motion-duration-enter: var(--accordion-content-open-duration);
--motion-duration-exit: var(--accordion-content-close-duration);
--motion-ease-enter: var(--accordion-content-open-ease);
--motion-ease-exit: var(--accordion-content-close-ease);
padding: 0 var(--_accordion-content-px) var(--_accordion-content-py);
background: var(--_accordion-content-bg);
color: var(--accordion-content-color);
@ -239,35 +249,11 @@
padding: 0 var(--accordion-content-px-full) var(--accordion-content-py-full);
}
/* Vertical (default): expand/collapse height. Soma writes the measured
* scroll height into `--_accordion-content-height`. */
[data-accordion-content][data-state='open']:not([data-motion-disabled]) {
animation: accordion-expand var(--accordion-content-open-duration)
var(--accordion-content-open-ease);
}
[data-accordion-content][data-state='closed']:not([data-motion-disabled]) {
animation: accordion-collapse var(--accordion-content-close-duration)
var(--accordion-content-close-ease);
}
@keyframes accordion-expand {
from {
height: 0;
}
to {
height: var(--_accordion-content-height);
}
}
@keyframes accordion-collapse {
from {
height: var(--_accordion-content-height);
}
to {
height: 0;
}
}
/* Expand/collapse height now comes from the motion system (preset `collapse`
* via `data-animation-style`). Soma writes the measured scroll height into
* `--_accordion-content-height`, bridged to the preset's `--height` above;
* the preset's `reduce: 'instant'` policy adds reduced-motion handling the
* bespoke lacked. `data-state` is steady styling only. See `eidos-motion.md`. */
/* ── Horizontal orientation ────────────────────────────────────────
* Keep the accordion contract intact, but make the visual shape usable:

@ -1,4 +1,5 @@
import type { ControlVariant, ResponsiveProp } from '$uix/eidos/lib/types';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
import type {
ProviderProps,
ItemProps,
@ -12,7 +13,14 @@ export type AccordionSize = 'sm' | 'md' | 'lg' | 'full';
export type AccordionVariant = ControlVariant;
export type AccordionItemProps = ItemProps;
export type AccordionHeaderProps = HeaderProps;
export type AccordionContentProps = ContentProps;
export type AccordionContentProps = ContentProps & {
/**
* Presence animation preset (momento `--state`, drives `data-animation-style`).
* `collapse` interpolates the measured open height. `'none'` disables.
* @default 'collapse'
*/
motion?: MotionPresetName;
};
/**
* Root props in eidos accept `ResponsiveProp<AccordionSize>` for

@ -26,6 +26,7 @@
let {
size = 'md',
position = 'middle-center',
motion = 'scale-fade',
width,
minWidth,
maxWidth,
@ -80,6 +81,7 @@
data-size={resolvedSize}
data-position={resolvedPosition}
data-sheet={isSheet ? '' : undefined}
data-animation-style={motion === 'none' ? undefined : motion}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}

@ -10,7 +10,7 @@
import * as Dialog from '$soma/components/dialog';
import type { DialogOverlayProps } from './types';
let { overlayOpacity, style, children, ...rest }: DialogOverlayProps = $props();
let { motion = 'fade', overlayOpacity, style, children, ...rest }: DialogOverlayProps = $props();
function serializeOpacity(value: number | string | undefined): string | undefined {
if (value === undefined) return undefined;
@ -28,6 +28,6 @@
);
</script>
<Dialog.Overlay {...rest} style={composedStyle}>
<Dialog.Overlay {...rest} style={composedStyle} data-animation-style={motion === 'none' ? undefined : motion}>
{@render children?.()}
</Dialog.Overlay>

@ -67,30 +67,14 @@
z-index: var(--dialog-overlay-z);
}
[data-dialog-overlay][data-state='open'] {
animation: dialog-overlay-in var(--dialog-overlay-in-duration) ease both;
}
[data-dialog-overlay][data-state='closed'] {
animation: dialog-overlay-out var(--dialog-overlay-out-duration) ease both;
}
@keyframes dialog-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialog-overlay-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
/* Overlay + content enter/exit motion comes from the motion system (presets
* `fade` / `scale-fade` via `data-animation-style`). `data-state` here is only
* steady styling. The prior recipe timing (120ms in / 280ms out) is preserved
* via the per-component duration override. See `eidos-motion.md`. */
[data-dialog-overlay],
[data-dialog-content] {
--motion-duration-enter: 120ms;
--motion-duration-exit: 280ms;
}
/* ── Content ───────────────────────────────────────────────────────────── */
@ -124,31 +108,10 @@
outline: none;
}
/* Content entrance / exit. Air only animated the overlay, leaving the
* content to snap in/out (sub-perceptual). Soma's Presence layer waits for
* `getAnimations().finished` before unmounting, so these animations gate
* the cycle by themselves. */
[data-dialog-content][data-state='open'] {
animation: dialog-content-in var(--dialog-overlay-in-duration) var(--ease-out) both;
}
[data-dialog-content][data-state='closed'] {
animation: dialog-content-out var(--dialog-overlay-out-duration) var(--ease-default) both;
}
@keyframes dialog-content-in {
from {
opacity: 0;
transform: scale(0.96);
}
}
@keyframes dialog-content-out {
to {
opacity: 0;
transform: scale(0.96);
}
}
/* Content enter/exit motion comes from the motion system (preset `scale-fade`
* via `data-animation-style`). Soma's Presence waits for `getAnimations()`
* before unmounting, so the preset animation gates the cycle. `data-state` is
* steady styling only. */
/* ── Position (3×3 grid) ────────────────────────────────────────────────
* The default `[data-dialog-content]` rule above already centers the panel

@ -1,6 +1,7 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { Position, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
import type {
ProviderProps,
TriggerProps,
@ -73,6 +74,11 @@ export type DialogContentProps = ContentProps & {
minHeight?: ResponsiveProp<DialogCssLength>;
/** Maximum content height override. Number values are serialized as px. */
maxHeight?: ResponsiveProp<DialogCssLength>;
/**
* Presence animation preset (momento `--state`, drives `data-animation-style`).
* `'none'` disables. @default 'scale-fade'
*/
motion?: MotionPresetName;
};
export type DialogOverlayProps = OverlayProps & {
@ -82,6 +88,11 @@ export type DialogOverlayProps = OverlayProps & {
* `--dialog-overlay-opacity`.
*/
overlayOpacity?: number | string;
/**
* Presence animation preset for the overlay (drives `data-animation-style`).
* `'none'` disables. @default 'fade'
*/
motion?: MotionPresetName;
};
export type DialogTitleProps = TitleProps;

@ -19,6 +19,7 @@
// snippet args without shadowing the prop.
let {
size = 'md',
motion = 'slide-full',
width,
minWidth,
maxWidth,
@ -64,7 +65,12 @@
const composedStyle = $derived(composeInlineStyle(style, dimensionsStyle));
</script>
<Drawer.Content {...rest} style={composedStyle} data-size={resolvedSize}>
<Drawer.Content
{...rest}
style={composedStyle}
data-size={resolvedSize}
data-animation-style={motion === 'none' ? undefined : motion}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}

@ -15,7 +15,7 @@
import * as Drawer from '$soma/components/drawer';
import type { DrawerOverlayProps } from './types';
let { overlayOpacity, style, children, ...rest }: DrawerOverlayProps = $props();
let { motion = 'fade', overlayOpacity, style, children, ...rest }: DrawerOverlayProps = $props();
function serializeOpacity(value: number | string | undefined): string | undefined {
if (value === undefined) return undefined;
@ -33,6 +33,6 @@
);
</script>
<Drawer.Overlay {...rest} style={composedStyle}>
<Drawer.Overlay {...rest} style={composedStyle} data-animation-style={motion === 'none' ? undefined : motion}>
{@render children?.()}
</Drawer.Overlay>

@ -56,20 +56,12 @@
opacity: 1;
}
[data-drawer-overlay][data-variant='overlay'][data-state='open'] {
animation: drawer-overlay-in var(--duration-normal) var(--ease-out) both;
}
[data-drawer-overlay][data-variant='overlay'][data-state='closed'] {
animation: drawer-overlay-out var(--duration-normal) var(--ease-default) both;
}
@keyframes drawer-overlay-in {
from { opacity: 0; }
}
@keyframes drawer-overlay-out {
to { opacity: 0; }
/* Overlay enter/exit motion comes from the motion system (preset `fade` via
* `data-animation-style`). The overlay is `display:none` for inline/persistent,
* so the fade only shows for `variant='overlay'`. Timing preserved via override. */
[data-drawer-overlay] {
--motion-duration-enter: var(--duration-normal);
--motion-duration-exit: var(--duration-normal);
}
/* ── Content (variant-shared base) ──────────────────────────────────────── */
@ -275,65 +267,22 @@
height: 100%;
}
/* ── Slide-in / slide-out per side (overlay + inline only) ──────────────── */
/* Persistent has no entry/exit animation — it is always present.
* Both `overlay` and `inline` slide; `:where(...)` keeps specificity flat
* so the per-variant positioning rules above still win. */
[data-drawer-content]:where([data-variant='overlay'], [data-variant='inline'])[data-state='open'][data-side='right'] {
animation: drawer-in-right var(--duration-moderate) var(--ease-out) both;
}
[data-drawer-content]:where([data-variant='overlay'], [data-variant='inline'])[data-state='closed'][data-side='right'] {
animation: drawer-out-right var(--duration-normal) var(--ease-default) both;
}
[data-drawer-content]:where([data-variant='overlay'], [data-variant='inline'])[data-state='open'][data-side='left'] {
animation: drawer-in-left var(--duration-moderate) var(--ease-out) both;
}
[data-drawer-content]:where([data-variant='overlay'], [data-variant='inline'])[data-state='closed'][data-side='left'] {
animation: drawer-out-left var(--duration-normal) var(--ease-default) both;
}
[data-drawer-content]:where([data-variant='overlay'], [data-variant='inline'])[data-state='open'][data-side='top'] {
animation: drawer-in-top var(--duration-moderate) var(--ease-out) both;
}
[data-drawer-content]:where([data-variant='overlay'], [data-variant='inline'])[data-state='closed'][data-side='top'] {
animation: drawer-out-top var(--duration-normal) var(--ease-default) both;
}
[data-drawer-content]:where([data-variant='overlay'], [data-variant='inline'])[data-state='open'][data-side='bottom'] {
animation: drawer-in-bottom var(--duration-moderate) var(--ease-out) both;
}
[data-drawer-content]:where([data-variant='overlay'], [data-variant='inline'])[data-state='closed'][data-side='bottom'] {
animation: drawer-out-bottom var(--duration-normal) var(--ease-default) both;
}
@keyframes drawer-in-right {
from { transform: translateX(calc(100% + var(--drawer-content-inset))); }
}
@keyframes drawer-out-right {
to { transform: translateX(calc(100% + var(--drawer-content-inset))); }
}
@keyframes drawer-in-left {
from { transform: translateX(calc(-100% - var(--drawer-content-inset))); }
}
@keyframes drawer-out-left {
to { transform: translateX(calc(-100% - var(--drawer-content-inset))); }
}
@keyframes drawer-in-top {
from { transform: translateY(calc(-100% - var(--drawer-content-inset))); }
}
@keyframes drawer-out-top {
to { transform: translateY(calc(-100% - var(--drawer-content-inset))); }
/* ── Presence motion (overlay + inline) ─────────────────────────────────
* Driven by the motion system: the eidos wrapper writes
* `data-animation-style='slide-full'` and the registry's side-aware preset
* (full slide, keyed on `data-side`) animates enter/exit. The panel is INSET
* by `--drawer-content-inset`, so it must travel its own size PLUS that gap to
* fully clear the edge — `--motion-slide-leave` feeds the preset that extra.
* See `eidos-motion.md` (momento --state). */
[data-drawer-content] {
--motion-slide-leave: var(--drawer-content-inset);
--motion-duration-enter: var(--duration-moderate);
--motion-duration-exit: var(--duration-normal);
}
@keyframes drawer-in-bottom {
from { transform: translateY(calc(100% + var(--drawer-content-inset))); }
}
@keyframes drawer-out-bottom {
to { transform: translateY(calc(100% + var(--drawer-content-inset))); }
/* `persistent` is always present — it opts out of the presence animation. */
[data-drawer-content][data-variant='persistent'] {
animation: none !important;
}
/* ── data-last-action exit tinting (overlay + inline only) ──────────────── */

@ -1,6 +1,7 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
import type {
ProviderProps,
TriggerProps,
@ -57,6 +58,12 @@ export type DrawerContentProps = ContentProps & {
minHeight?: ResponsiveProp<DrawerCssLength>;
/** Maximum block-size constraint for the panel. Numbers serialize as px. */
maxHeight?: ResponsiveProp<DrawerCssLength>;
/**
* Presence animation preset (momento `--state`, drives `data-animation-style`).
* Side-aware + inset-aware via the registry preset. `'none'` disables.
* `'persistent'` variant always opts out. @default 'slide-full'
*/
motion?: MotionPresetName;
};
export type DrawerOverlayProps = OverlayProps & {
@ -66,6 +73,11 @@ export type DrawerOverlayProps = OverlayProps & {
* `--drawer-overlay-opacity`. Only effective when `variant='overlay'`.
*/
overlayOpacity?: number | string;
/**
* Presence animation preset for the overlay (drives `data-animation-style`).
* `'none'` disables. @default 'fade'
*/
motion?: MotionPresetName;
};
export type DrawerCloseProps = CloseProps;

@ -18,6 +18,7 @@
// shadowing the prop.
let {
size = 'md',
motion = 'slide-fade',
matchAnchorWidth = false,
width,
minWidth,
@ -68,7 +69,12 @@
const composedStyle = $derived(composeInlineStyle(style, dimensionsStyle));
</script>
<Popover.Content {...rest} style={composedStyle} data-size={resolvedSize}>
<Popover.Content
{...rest}
style={composedStyle}
data-size={resolvedSize}
data-animation-style={motion === 'none' ? undefined : motion}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}

@ -7,9 +7,9 @@
import * as Popover from '$soma/components/popover';
import type { PopoverOverlayProps } from './types';
let { children, ...rest }: PopoverOverlayProps = $props();
let { motion = 'fade', children, ...rest }: PopoverOverlayProps = $props();
</script>
<Popover.Overlay {...rest}>
<Popover.Overlay {...rest} data-animation-style={motion === 'none' ? undefined : motion}>
{@render children?.()}
</Popover.Overlay>

@ -54,18 +54,14 @@
z-index: var(--popover-content-z);
}
[data-popover-overlay][data-state='open'] {
animation: popover-overlay-in var(--duration-normal) var(--ease-out) both;
}
[data-popover-overlay][data-state='closed'] {
animation: popover-overlay-out var(--duration-fast) var(--ease-default) both;
}
@keyframes popover-overlay-in {
from { opacity: 0; }
}
@keyframes popover-overlay-out {
to { opacity: 0; }
/* Overlay + content enter/exit motion comes from the motion system (presets
* `fade` / `slide-fade` via `data-animation-style`). `data-state` is steady
* styling only. The prior recipe timing (normal in / fast out) is preserved
* via the per-component duration override. */
[data-popover-overlay],
[data-popover-content] {
--motion-duration-enter: var(--duration-normal);
--motion-duration-exit: var(--duration-fast);
}
/* ── Content ──────────────────────────────────────────────────────────── */
@ -147,69 +143,11 @@
}
/* ── Content entrance/exit per anchored side ─────────────────────────────
* Soma's Floating layer writes `data-side` (top/right/bottom/left) on
* Content based on the resolved placement. The popover slides into place
* FROM the trigger edge (i.e., 'top' side = popover above the trigger,
* slides UP from below).
*
* Air calls this 'revelation'. ~180ms entrance / 120ms exit. Fade + small
* translate (4px) + scale (0.985 → 1).
*/
[data-popover-content][data-state='open'][data-side='top'] {
animation: popover-in-top var(--duration-normal) var(--ease-out) both;
}
[data-popover-content][data-state='closed'][data-side='top'] {
animation: popover-out-top var(--duration-fast) var(--ease-default) both;
}
[data-popover-content][data-state='open'][data-side='bottom'] {
animation: popover-in-bottom var(--duration-normal) var(--ease-out) both;
}
[data-popover-content][data-state='closed'][data-side='bottom'] {
animation: popover-out-bottom var(--duration-fast) var(--ease-default) both;
}
[data-popover-content][data-state='open'][data-side='right'] {
animation: popover-in-right var(--duration-normal) var(--ease-out) both;
}
[data-popover-content][data-state='closed'][data-side='right'] {
animation: popover-out-right var(--duration-fast) var(--ease-default) both;
}
[data-popover-content][data-state='open'][data-side='left'] {
animation: popover-in-left var(--duration-normal) var(--ease-out) both;
}
[data-popover-content][data-state='closed'][data-side='left'] {
animation: popover-out-left var(--duration-fast) var(--ease-default) both;
}
@keyframes popover-in-top {
from { opacity: 0; transform: translateY(4px) scale(0.985); }
}
@keyframes popover-out-top {
to { opacity: 0; transform: translateY(4px) scale(0.985); }
}
@keyframes popover-in-bottom {
from { opacity: 0; transform: translateY(-4px) scale(0.985); }
}
@keyframes popover-out-bottom {
to { opacity: 0; transform: translateY(-4px) scale(0.985); }
}
@keyframes popover-in-right {
from { opacity: 0; transform: translateX(-4px) scale(0.985); }
}
@keyframes popover-out-right {
to { opacity: 0; transform: translateX(-4px) scale(0.985); }
}
@keyframes popover-in-left {
from { opacity: 0; transform: translateX(4px) scale(0.985); }
}
@keyframes popover-out-left {
to { opacity: 0; transform: translateX(4px) scale(0.985); }
}
* Now driven by the motion system: the eidos wrapper writes
* `data-animation-style='slide-fade'` and the registry's side-aware preset
* (slide + scale 0.985 + fade, keyed on `data-side`) animates enter/exit.
* Soma's Presence waits for the preset animation before unmounting.
* `data-state` here is steady styling only. See `eidos-motion.md`. */
/* ── Arrow (optional) ─────────────────────────────────────────────────────
* Soma's FloatingArrow renders `<span data-popover-arrow>` with inline

@ -1,5 +1,6 @@
import type { Snippet } from 'svelte';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
import type {
ProviderProps,
TriggerProps,
@ -27,7 +28,13 @@ export type PopoverTitleProps = TitleProps;
export type PopoverDescriptionProps = DescriptionProps;
export type PopoverCloseProps = CloseProps;
export type PopoverAnchorProps = AnchorProps;
export type PopoverOverlayProps = OverlayProps;
export type PopoverOverlayProps = OverlayProps & {
/**
* Presence animation preset for the modal overlay (drives
* `data-animation-style`). `'none'` disables. @default 'fade'
*/
motion?: MotionPresetName;
};
export type PopoverContentProps = ContentProps & {
/**
@ -56,4 +63,9 @@ export type PopoverContentProps = ContentProps & {
minHeight?: ResponsiveProp<PopoverCssLength>;
/** Maximum block-size constraint. Numbers serialize as px. */
maxHeight?: ResponsiveProp<PopoverCssLength>;
/**
* Presence animation preset (momento `--state`, drives `data-animation-style`).
* Side-aware via the registry preset. `'none'` disables. @default 'slide-fade'
*/
motion?: MotionPresetName;
};

File diff suppressed because it is too large Load Diff

@ -1,203 +1,31 @@
/**
* Eidos — reactions to perceptual signals (`data-event*`).
* Eidos — base hints for perceptual signals (`data-event-*`).
*
* The VisualChannel of Sema writes signal attrs under the `data-event-*`
* namespace during a configurable hold window:
* - data-event (event name, e.g. 'announce', 'dismiss')
* - data-event-id (occurrence id from the engine)
* - data-event-phase ('active')
* - data-event-family (optional, e.g. 'signal')
* - data-event-intent (optional, e.g. 'loss')
* The per-family/intent motion REACTIONS (the "firma perceptiva") and their
* `@keyframes` now live in `EidosConfig.motion.{keyframes,signatures}` and are
* generated into `generated/base.css` (theme-extensible). See
* `lib/motion/presets/css.ts` (`BUILTIN_SIGNATURES`) + `eidos-motion.md` §15.
*
* The channel never touches state attrs (`data-state`, `data-intent`,
* `data-disabled`, ...) — those are owned by the runtime/morfo. Read
* `data-event-intent` for signal-bound intent reactions; read
* `data-intent` for the persistent state of a component.
* This file keeps only the two GLOBAL concerns that apply to ANY active signal,
* regardless of family:
*
* Hold defaults per family (internal to chans/visual.ts):
* - emerge / commit / handle: 240ms
* - signal / shift / sustain: 600ms
* - contact: 120ms
* Authors override per signal via `signal.hold`, or globally via
* `new EngineSemantic({ visual: { defaultHold } })`.
*
* Even with the configured hold, prefer `animation: @keyframes` over
* `transition` for the visible response: the animation runs to completion
* independent of the signal's lifetime, so it works correctly with custom
* holds and won't reverse mid-flight if the signal is cleared early.
*
* Rule of thumb:
* - Use `animation: @keyframes` for visible reactions to signals.
* - Use `transition` for ongoing state attrs (data-state, data-disabled).
* 1. the compositor hint, and
* 2. the reduced-motion cap — under `data-motion='reduce'` (or the media
* query) the firma's MOTION is suppressed, but the signal still reaches
* the user via sound + haptic (the cross-modal firma), so its meaning is
* not lost. That is exactly the two-moment + cross-modal advantage.
*/
/* ── Keyframes ──────────────────────────────────────────────────────────── */
@keyframes eidos-announce-pulse-neutral {
0%,
100% {
box-shadow: 0 0 0 0 transparent;
}
20% {
box-shadow: 0 0 0 var(--focus-ring-width) var(--color-neutral-element);
}
}
@keyframes eidos-announce-pulse-affirm {
0%,
100% {
box-shadow: 0 0 0 0 transparent;
}
20% {
box-shadow: 0 0 0 var(--focus-ring-width) var(--color-fulfill-element);
}
}
@keyframes eidos-announce-pulse-fulfill {
0%,
100% {
box-shadow: 0 0 0 0 transparent;
}
20% {
box-shadow: 0 0 0 var(--focus-ring-width) var(--color-fulfill-element);
}
}
@keyframes eidos-announce-pulse-risk {
0%,
100% {
box-shadow: 0 0 0 0 transparent;
}
15% {
box-shadow: 0 0 0 4px var(--color-risk-element);
}
30% {
box-shadow: 0 0 0 2px var(--color-risk-element);
}
}
@keyframes eidos-announce-pulse-threat {
0%,
100% {
box-shadow: 0 0 0 0 transparent;
}
10% {
box-shadow: 0 0 0 6px var(--color-threat-element);
}
35% {
box-shadow: 0 0 0 3px var(--color-threat-element);
}
}
@keyframes eidos-dismiss-fade {
to {
opacity: 0;
transform: translateX(8%);
}
}
@keyframes eidos-present-rise {
from {
opacity: 0;
transform: translateY(8px);
}
}
/* contact family — brief squeeze on press, ~120ms */
@keyframes eidos-press-squeeze {
0% {
transform: scale(1);
}
40% {
transform: scale(0.96);
filter: brightness(0.96);
}
100% {
transform: scale(1);
}
}
/* commit family — small settle bump as the new state lands, ~240ms.
Uses the primary token so the settle pulse picks up the active theme
(light/dark) instead of a fixed indigo. */
@keyframes eidos-commit-settle {
0% {
box-shadow: 0 0 0 0 transparent;
}
30% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary-solid) 32%, transparent);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
/* ── Base: any event signal during its active phase ──────────────────────
* Hint to the compositor; cleared automatically when the signal disappears.
*/
[data-event][data-event-phase='active'] {
will-change: opacity, transform, box-shadow;
}
/* ── Dismiss family — fade + slide out (animation runs 320ms) ──────── */
[data-event^='dismiss'][data-event-phase='active'],
[data-event^='close'][data-event-phase='active'] {
animation: eidos-dismiss-fade var(--duration-slow) var(--ease-default) forwards;
}
/* ── Present / open — rise into place ─────────────────────────────── */
[data-event^='present'][data-event-phase='active'],
[data-event^='open'][data-event-phase='active'] {
animation: eidos-present-rise var(--duration-slow) var(--ease-out);
}
/* ── Announce — intent-tinted pulse (visible 600-1000ms) ────────────
* Reads data-event-intent (signal-bound), NOT data-intent (state) —
* the channel writes only its own namespace.
*/
[data-event^='announce'][data-event-phase='active'][data-event-intent='neutral'] {
animation: eidos-announce-pulse-neutral 600ms var(--ease-default);
}
[data-event^='announce'][data-event-phase='active'][data-event-intent='affirm'] {
animation: eidos-announce-pulse-affirm 600ms var(--ease-default);
}
[data-event^='announce'][data-event-phase='active'][data-event-intent='fulfill'] {
animation: eidos-announce-pulse-fulfill 600ms var(--ease-default);
}
[data-event^='announce'][data-event-phase='active'][data-event-intent='risk'] {
animation: eidos-announce-pulse-risk 800ms var(--ease-spring);
}
[data-event^='announce'][data-event-phase='active'][data-event-intent='threat'] {
animation: eidos-announce-pulse-threat 1000ms var(--ease-spring);
[data-motion='reduce'] [data-event][data-event-phase='active'] {
animation-duration: 1ms !important;
}
/* ── Press / contact — quick squeeze acknowledgement ─────────────────
* Fired on any control whose morfo declares a `contact` event (toggle,
* switch, button…). Brief (~120ms), non-valenced. The sema visual
* channel writes data-event-family='contact' during the hold.
*/
[data-event-family='contact'][data-event-phase='active'] {
animation: eidos-press-squeeze 140ms var(--ease-default);
}
/* ── Commit — consolidation pulse on the landing of a new state ──────
* Fired by morfos that declare a `commit` event (toggle press, switch
* change, dialog close-save, etc.). The pulse uses the indigo accent;
* intent variants below tint by valence.
*/
[data-event-family='commit'][data-event-phase='active'] {
animation: eidos-commit-settle 260ms var(--ease-out);
}
[data-event-family='commit'][data-event-intent='fulfill'][data-event-phase='active'] {
animation: eidos-announce-pulse-fulfill 320ms var(--ease-out);
}
[data-event-family='commit'][data-event-intent='affirm'][data-event-phase='active'] {
animation: eidos-announce-pulse-affirm 320ms var(--ease-out);
}
[data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
animation: eidos-announce-pulse-threat 400ms var(--ease-spring);
@media (prefers-reduced-motion: reduce) {
[data-event][data-event-phase='active'] {
animation-duration: 1ms !important;
}
}
/* `data-last-action` tinting lives in component recipes (e.g.
* dialog.css), not here. If a future second consumer appears with the
* same exit-tint pattern, factor out then — not before. */

@ -94,18 +94,25 @@
--duration-normal: 180ms;
--duration-moderate: 240ms;
--duration-slow: 320ms;
--duration-slower: 400ms;
--duration-deliberate: 600ms;
--duration-emphatic: 800ms;
--duration-sustained: 1000ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-alert: cubic-bezier(0.36, 0.07, 0.19, 0.97);
--ease-symmetric: cubic-bezier(0.4, 0, 0.6, 1);
--ease-emphasized: cubic-bezier(0.05, 0.7, 0.1, 1);
--motion-distance-xs: 2px;
--motion-distance-sm: 4px;
--motion-distance-md: 8px;
--motion-distance-lg: 16px;
--motion-distance-xl: 30px;
--motion-scale-enter: 0.985;
--motion-scale-press: 0.97;
--motion-scale-through: 0.92;
--motion-stagger: 20ms;
--icon-size-xxs: calc(12px * var(--scaling));
--icon-size-xs: calc(14px * var(--scaling));
@ -770,8 +777,6 @@
--dialog-overlay-opacity: 62%;
--dialog-overlay-blur: 10px;
--dialog-overlay-z: 70;
--dialog-overlay-in-duration: 120ms;
--dialog-overlay-out-duration: 280ms;
--dialog-content-bg: var(--color-surface-overlay);
--dialog-content-color: var(--color-content-primary);
--dialog-content-border: var(--color-border-default);
@ -4213,6 +4218,707 @@
--scaling: var(--scaling-110);
}
[data-motion-set='expressive'] {
--ease-default: cubic-bezier(0.4, 0.14, 0.3, 1);
--ease-out: cubic-bezier(0, 0, 0.3, 1);
--ease-in: cubic-bezier(0.4, 0.14, 1, 1);
}
@property --motion-duration-enter {
syntax: '*';
inherits: false;
}
@property --motion-ease-enter {
syntax: '*';
inherits: false;
}
@property --motion-duration-exit {
syntax: '*';
inherits: false;
}
@property --motion-ease-exit {
syntax: '*';
inherits: false;
}
@property --motion-stagger-index {
syntax: '<integer>';
inherits: false;
initial-value: 0;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes scale-in {
from {
scale: var(--motion-scale-enter);
}
to {
scale: 1;
}
}
@keyframes scale-out {
from {
scale: 1;
}
to {
scale: var(--motion-scale-enter);
}
}
@keyframes slide-from-top {
from {
translate: 0 calc(var(--motion-distance-md) * -1);
}
to {
translate: 0 0;
}
}
@keyframes slide-from-bottom {
from {
translate: 0 var(--motion-distance-md);
}
to {
translate: 0 0;
}
}
@keyframes slide-from-left {
from {
translate: calc(var(--motion-distance-md) * -1) 0;
}
to {
translate: 0 0;
}
}
@keyframes slide-from-right {
from {
translate: var(--motion-distance-md) 0;
}
to {
translate: 0 0;
}
}
@keyframes slide-to-top {
from {
translate: 0 0;
}
to {
translate: 0 calc(var(--motion-distance-md) * -1);
}
}
@keyframes slide-to-bottom {
from {
translate: 0 0;
}
to {
translate: 0 var(--motion-distance-md);
}
}
@keyframes slide-to-left {
from {
translate: 0 0;
}
to {
translate: calc(var(--motion-distance-md) * -1) 0;
}
}
@keyframes slide-to-right {
from {
translate: 0 0;
}
to {
translate: var(--motion-distance-md) 0;
}
}
@keyframes slide-from-top-full {
from {
translate: 0 calc(-100% - var(--motion-slide-leave, 0px));
}
to {
translate: 0 0;
}
}
@keyframes slide-from-bottom-full {
from {
translate: 0 calc(100% + var(--motion-slide-leave, 0px));
}
to {
translate: 0 0;
}
}
@keyframes slide-from-left-full {
from {
translate: calc(-100% - var(--motion-slide-leave, 0px)) 0;
}
to {
translate: 0 0;
}
}
@keyframes slide-from-right-full {
from {
translate: calc(100% + var(--motion-slide-leave, 0px)) 0;
}
to {
translate: 0 0;
}
}
@keyframes slide-to-top-full {
from {
translate: 0 0;
}
to {
translate: 0 calc(-100% - var(--motion-slide-leave, 0px));
}
}
@keyframes slide-to-bottom-full {
from {
translate: 0 0;
}
to {
translate: 0 calc(100% + var(--motion-slide-leave, 0px));
}
}
@keyframes slide-to-left-full {
from {
translate: 0 0;
}
to {
translate: calc(-100% - var(--motion-slide-leave, 0px)) 0;
}
}
@keyframes slide-to-right-full {
from {
translate: 0 0;
}
to {
translate: calc(100% + var(--motion-slide-leave, 0px)) 0;
}
}
@keyframes expand-height {
from {
height: var(--collapsed-height, 0);
}
to {
height: var(--height);
}
}
@keyframes collapse-height {
from {
height: var(--height);
}
to {
height: var(--collapsed-height, 0);
}
}
@keyframes slide-axis-x-in {
from {
translate: var(--motion-distance-xl) 0;
}
to {
translate: 0 0;
}
}
@keyframes slide-axis-x-out {
from {
translate: 0 0;
}
to {
translate: calc(var(--motion-distance-xl) * -1) 0;
}
}
@keyframes slide-axis-y-in {
from {
translate: 0 var(--motion-distance-xl);
}
to {
translate: 0 0;
}
}
@keyframes slide-axis-y-out {
from {
translate: 0 0;
}
to {
translate: 0 calc(var(--motion-distance-xl) * -1);
}
}
@keyframes scale-through {
from {
scale: var(--motion-scale-through);
}
to {
scale: 1;
}
}
@keyframes announce-pulse-neutral {
0%, 100% {
box-shadow: 0 0 0 0 transparent;
}
20% {
box-shadow: 0 0 0 var(--focus-ring-width) var(--color-neutral-element);
}
}
@keyframes announce-pulse-affirm {
0%, 100% {
box-shadow: 0 0 0 0 transparent;
}
20% {
box-shadow: 0 0 0 var(--focus-ring-width) var(--color-fulfill-element);
}
}
@keyframes announce-pulse-fulfill {
0%, 100% {
box-shadow: 0 0 0 0 transparent;
}
20% {
box-shadow: 0 0 0 var(--focus-ring-width) var(--color-fulfill-element);
}
}
@keyframes announce-pulse-risk {
0%, 100% {
box-shadow: 0 0 0 0 transparent;
}
15% {
box-shadow: 0 0 0 4px var(--color-risk-element);
}
30% {
box-shadow: 0 0 0 2px var(--color-risk-element);
}
}
@keyframes announce-pulse-threat {
0%, 100% {
box-shadow: 0 0 0 0 transparent;
}
10% {
box-shadow: 0 0 0 6px var(--color-threat-element);
}
35% {
box-shadow: 0 0 0 3px var(--color-threat-element);
}
}
@keyframes dismiss-fade {
to {
opacity: 0;
transform: translateX(8%);
}
}
@keyframes present-rise {
from {
opacity: 0;
transform: translateY(8px);
}
}
@keyframes press-squeeze {
0% {
transform: scale(1);
}
40% {
transform: scale(0.96);
filter: brightness(0.96);
}
100% {
transform: scale(1);
}
}
@keyframes commit-settle {
0% {
box-shadow: 0 0 0 0 transparent;
}
30% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary-solid) 32%, transparent);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
[data-event^='present'][data-event-phase='active'],
[data-event^='open'][data-event-phase='active'] {
animation: present-rise var(--duration-slow) var(--ease-out);
}
[data-event^='dismiss'][data-event-phase='active'],
[data-event^='close'][data-event-phase='active'] {
animation: dismiss-fade var(--duration-slow) var(--ease-default) forwards;
}
[data-event^='announce'][data-event-intent='neutral'][data-event-phase='active'] {
animation: announce-pulse-neutral var(--duration-deliberate) var(--ease-default);
}
[data-event^='announce'][data-event-intent='affirm'][data-event-phase='active'] {
animation: announce-pulse-affirm var(--duration-deliberate) var(--ease-default);
}
[data-event^='announce'][data-event-intent='fulfill'][data-event-phase='active'] {
animation: announce-pulse-fulfill var(--duration-deliberate) var(--ease-default);
}
[data-event^='announce'][data-event-intent='risk'][data-event-phase='active'] {
animation: announce-pulse-risk var(--duration-emphatic) var(--ease-spring);
}
[data-event^='announce'][data-event-intent='threat'][data-event-phase='active'] {
animation: announce-pulse-threat var(--duration-sustained) var(--ease-spring);
}
[data-event-family='contact'][data-event-phase='active'] {
animation: press-squeeze var(--duration-fast) var(--ease-default);
}
[data-event-family='commit'][data-event-phase='active'] {
animation: commit-settle var(--duration-moderate) var(--ease-out);
}
[data-event-family='commit'][data-event-intent='fulfill'][data-event-phase='active'] {
animation: announce-pulse-fulfill var(--duration-slow) var(--ease-out);
}
[data-event-family='commit'][data-event-intent='affirm'][data-event-phase='active'] {
animation: announce-pulse-affirm var(--duration-slow) var(--ease-out);
}
[data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
animation: announce-pulse-threat var(--duration-slower) var(--ease-spring);
}
[data-animation-style='fade'][data-state='open'] {
animation: fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='fade'][data-state='closed'] {
animation: fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-animation-style='scale-fade'][data-state='open'] {
animation: scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
transform-origin: var(--floating-transform-origin);
}
[data-animation-style='scale-fade'][data-state='closed'] {
animation: scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
transform-origin: var(--floating-transform-origin);
}
[data-motion='reduce'] [data-animation-style='scale-fade'][data-state='open'] {
animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='scale-fade'][data-state='open'] {
animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
}
}
[data-motion='reduce'] [data-animation-style='scale-fade'][data-state='closed'] {
animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='scale-fade'][data-state='closed'] {
animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;
}
}
[data-animation-style='slide-fade'][data-state='open'] {
animation: slide-from-bottom var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='slide-fade'][data-side='top'][data-state='open'] {
animation: slide-from-bottom var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='slide-fade'][data-side='bottom'][data-state='open'] {
animation: slide-from-top var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='slide-fade'][data-side='left'][data-state='open'] {
animation: slide-from-right var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='slide-fade'][data-side='right'][data-state='open'] {
animation: slide-from-left var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='slide-fade'][data-state='closed'] {
animation: slide-to-bottom var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-animation-style='slide-fade'][data-side='top'][data-state='closed'] {
animation: slide-to-bottom var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-animation-style='slide-fade'][data-side='bottom'][data-state='closed'] {
animation: slide-to-top var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-animation-style='slide-fade'][data-side='left'][data-state='closed'] {
animation: slide-to-right var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-animation-style='slide-fade'][data-side='right'][data-state='closed'] {
animation: slide-to-left var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-motion='reduce'] [data-animation-style='slide-fade'][data-state='open'] {
animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='slide-fade'][data-state='open'] {
animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
}
}
[data-motion='reduce'] [data-animation-style='slide-fade'][data-state='closed'] {
animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='slide-fade'][data-state='closed'] {
animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;
}
}
[data-animation-style='slide-full'][data-state='open'] {
animation: slide-from-right-full var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='slide-full'][data-side='top'][data-state='open'] {
animation: slide-from-top-full var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='slide-full'][data-side='bottom'][data-state='open'] {
animation: slide-from-bottom-full var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='slide-full'][data-side='left'][data-state='open'] {
animation: slide-from-left-full var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='slide-full'][data-side='right'][data-state='open'] {
animation: slide-from-right-full var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='slide-full'][data-state='closed'] {
animation: slide-to-right-full var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-animation-style='slide-full'][data-side='top'][data-state='closed'] {
animation: slide-to-top-full var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-animation-style='slide-full'][data-side='bottom'][data-state='closed'] {
animation: slide-to-bottom-full var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-animation-style='slide-full'][data-side='left'][data-state='closed'] {
animation: slide-to-left-full var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-animation-style='slide-full'][data-side='right'][data-state='closed'] {
animation: slide-to-right-full var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-motion='reduce'] [data-animation-style='slide-full'][data-state='open'] {
animation: none !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='slide-full'][data-state='open'] {
animation: none !important;
}
}
[data-motion='reduce'] [data-animation-style='slide-full'][data-state='closed'] {
animation: none !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='slide-full'][data-state='closed'] {
animation: none !important;
}
}
[data-animation-style='collapse'][data-state='open'] {
animation: expand-height var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='collapse'][data-state='closed'] {
animation: collapse-height var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-motion='reduce'] [data-animation-style='collapse'][data-state='open'] {
animation: none !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='collapse'][data-state='open'] {
animation: none !important;
}
}
[data-motion='reduce'] [data-animation-style='collapse'][data-state='closed'] {
animation: none !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='collapse'][data-state='closed'] {
animation: none !important;
}
}
[data-animation-style='shared-axis-x'][data-state='open'] {
animation: slide-axis-x-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='shared-axis-x'][data-state='closed'] {
animation: slide-axis-x-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-motion='reduce'] [data-animation-style='shared-axis-x'][data-state='open'] {
animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='shared-axis-x'][data-state='open'] {
animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
}
}
[data-motion='reduce'] [data-animation-style='shared-axis-x'][data-state='closed'] {
animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='shared-axis-x'][data-state='closed'] {
animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;
}
}
[data-animation-style='shared-axis-y'][data-state='open'] {
animation: slide-axis-y-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='shared-axis-y'][data-state='closed'] {
animation: slide-axis-y-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-motion='reduce'] [data-animation-style='shared-axis-y'][data-state='open'] {
animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='shared-axis-y'][data-state='open'] {
animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
}
}
[data-motion='reduce'] [data-animation-style='shared-axis-y'][data-state='closed'] {
animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='shared-axis-y'][data-state='closed'] {
animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;
}
}
[data-animation-style='fade-through'][data-state='open'] {
animation: scale-through var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='fade-through'][data-state='closed'] {
animation: fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards;
}
[data-motion='reduce'] [data-animation-style='fade-through'][data-state='open'] {
animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='fade-through'][data-state='open'] {
animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
}
}
[data-motion='reduce'] [data-animation-style='fade-through'][data-state='closed'] {
animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;
}
@media (prefers-reduced-motion: reduce) {
[data-animation-style='fade-through'][data-state='closed'] {
animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;
}
}
:root,
[data-theme='base-light'],
[data-theme='light'] {

@ -167,6 +167,9 @@ export type {
ZIndexKey,
ZIndexPrimitiveSet
} from './lib/config-types';
// Momento-`--state` preset-name registry (F7 typegen) — app-extensible via
// `declare module '$uix/eidos' { interface EidosMotionPresets { … } }`.
export type { EidosMotionPresets, MotionPresetName } from './lib/motion/registry';
export {
ASPECT_RATIO_KEYS,
BORDER_STYLE_KEYS,

@ -1,4 +1,5 @@
import type { ResponsiveProp, Size, SizePrimitiveKey } from './types';
import type { MotionConfig } from '$motion';
import { DENSITIES, type Density } from '$libs/density';
import { INTENTS, type Intent } from '$uix/intent';
@ -484,10 +485,31 @@ export type RecipeTokenSet = Readonly<Record<string, RecipeTokenMap>>;
*/
export type RecipeTokenObject = RecipeTokenDeclaration;
export const DURATION_KEYS = ['instant', 'fast', 'normal', 'moderate', 'slow'] as const;
export const EASE_KEYS = ['default', 'out', 'in', 'spring', 'alert', 'symmetric'] as const;
export const MOTION_DISTANCE_KEYS = ['xs', 'sm', 'md', 'lg'] as const;
export const MOTION_SCALE_KEYS = ['enter', 'press'] as const;
export const DURATION_KEYS = [
'instant',
'fast',
'normal',
'moderate',
'slow',
// Long end — perceptual holds for the firma (momento --event). The `announce`
// pulses escalate by intent severity (deliberate → emphatic → sustained),
// mirroring the book's holds-by-intent.
'slower',
'deliberate',
'emphatic',
'sustained'
] as const;
export const EASE_KEYS = [
'default',
'out',
'in',
'spring',
'alert',
'symmetric',
'emphasized'
] as const;
export const MOTION_DISTANCE_KEYS = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
export const MOTION_SCALE_KEYS = ['enter', 'press', 'through'] as const;
export const ICON_STROKE_WIDTH_KEYS = ['sm', 'md', 'lg'] as const;
export type DurationKey = (typeof DURATION_KEYS)[number];
@ -501,6 +523,16 @@ export interface MotionPrimitiveSet {
readonly distance: Record<MotionDistanceKey, string>;
readonly scale: Record<MotionScaleKey, string>;
readonly stagger: string;
/**
* Optional "expressive" motion set (Carbon's productive/expressive split):
* a partial token override emitted under `[data-motion-set='expressive']`.
* Productive (efficient, the default) lives in the records above; expressive
* (celebratory, emphasized) remaps a subset — typically the easing curves.
*/
readonly expressive?: {
readonly ease?: Partial<Record<EaseKey, string>>;
readonly duration?: Partial<Record<DurationKey, string>>;
};
}
export type IconSizeKey = Exclude<Size, 'full'>;
@ -592,6 +624,12 @@ export interface EidosConfig {
readonly semantics: SemanticSet;
readonly themes?: ThemeMap;
readonly recipes?: RecipeTokenSet;
/**
* Motion system: named `@keyframes` + presets (the composition layer).
* Distinct from `primitives.motion` (duration/ease/distance/scale tokens).
* `css` presets generate foundation CSS; JS presets run via the motion engine (`uix.motion`).
*/
readonly motion?: MotionConfig;
}
export interface EidosConfigSnapshot {
@ -599,6 +637,7 @@ export interface EidosConfigSnapshot {
readonly semantics: SemanticSet;
readonly themes?: ThemeMap;
readonly recipes?: RecipeTokenSet;
readonly motion?: MotionConfig;
}
export type EidosCssContractScope = 'static' | 'theme';

@ -129,6 +129,7 @@ export function validateEidosConfig(options: EidosConfig): EidosValidationReport
validateMotionPrimitives(options, issues)
validateIconPrimitives(options, issues)
validateRecipeTokens(options, issues)
validateMotion(options, issues)
validateRecordPrimitive(
'primitives.opacity',
options.primitives.opacity,
@ -157,6 +158,85 @@ export function assertValidEidosConfig(options: EidosConfig): void {
throw new EidosConfigValidationError(report.issues)
}
// Motion: every keyframe a css preset references must be declared, and
// `opacity-only` presets need the fade keyframes the reduced rules emit.
// JS-driver presets validate enter/exit presence + a css fallback (their run
// functions are opaque — validated at runtime).
function validateMotion(options: EidosConfig, issues: EidosValidationIssue[]): void {
const motion = options.motion
if (!motion) return
const keyframeNames = new Set(Object.keys(motion.keyframes ?? {}))
const checkKeyframes = (
path: string,
keyframes: string | readonly string[] | undefined
): void => {
if (keyframes === undefined) return
const names = typeof keyframes === 'string' ? [keyframes] : keyframes
for (const name of names) {
if (!keyframeNames.has(name)) {
issues.push({
path,
message: `references unknown keyframe '${name}' (not in motion.keyframes)`
})
}
}
}
for (const [name, preset] of Object.entries(motion.presets ?? {})) {
const base = `motion.presets.${name}`
if (preset.driver !== 'css') {
// JS-driver preset: the run functions are opaque (runtime-validated), but
// an inert preset (no enter/exit) and a non-css fallback are config bugs.
if (!preset.enter && !preset.exit) {
issues.push({
path: base,
message: `a '${preset.driver}' preset must declare enter and/or exit`
})
}
if (preset.fallback && preset.fallback.driver !== 'css') {
issues.push({ path: `${base}.fallback`, message: 'a JS preset fallback must be a css preset' })
}
continue
}
if (!preset.enter && !preset.exit) {
issues.push({ path: base, message: 'a css preset must declare enter and/or exit' })
}
for (const phaseName of ['enter', 'exit'] as const) {
const phase = preset[phaseName]
if (!phase) continue
checkKeyframes(`${base}.${phaseName}.keyframes`, phase.keyframes)
for (const [side, keyframes] of Object.entries(phase.bySide ?? {})) {
checkKeyframes(`${base}.${phaseName}.bySide.${side}`, keyframes)
}
}
if (preset.reduce === 'opacity-only') {
for (const kf of ['fade-in', 'fade-out']) {
if (!keyframeNames.has(kf)) {
issues.push({
path: `${base}.reduce`,
message: `reduce 'opacity-only' requires the '${kf}' keyframe`
})
}
}
}
}
// Momento --event: cada firma debe declarar un matcher y referenciar
// keyframes existentes.
for (const [name, signature] of Object.entries(motion.signatures ?? {})) {
const base = `motion.signatures.${name}`
if (!signature.family && !signature.intent && !signature.event) {
issues.push({
path: base,
message: 'a signature must declare at least one of family / intent / event'
})
}
checkKeyframes(`${base}.keyframes`, signature.keyframes)
}
}
function validateEidosConfigShape(
options: EidosConfig,
issues: EidosValidationIssue[]

@ -0,0 +1,358 @@
/**
* Built-in motion — keyframes + CSS-driven presets.
*
* These ship in the base config so `renderStaticCss` emits them into the
* foundation CSS, and the motion engine (`uix.motion`) registers them. A theme/app can
* override or add presets via `EidosConfig.motion`.
*
* Authoring notes:
* - Keyframe stops use CSS property names (kebab) → value strings; the
* generator emits them verbatim.
* - Slides/scales use the INDIVIDUAL transform properties (`translate` /
* `scale`) — not the `transform` shorthand — so composed keyframes
* (`['slide-from-bottom', 'fade-in']`) don't clobber each other.
* - Partial slides read `var(--motion-distance-md)` (the distance token);
* collapse reads `var(--height)` / `var(--collapsed-height, 0)` (the
* measured size a component sets). The keyframe is a template.
*/
import type { CssStatePreset, EventSignature, KeyframeStops } from '$motion'
const D = 'var(--motion-distance-md)'
const NEG_D = `calc(${D} * -1)`
/** Registered `@keyframes`, by name. */
export const BUILTIN_KEYFRAMES: Readonly<Record<string, KeyframeStops>> = {
'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } },
'fade-out': { from: { opacity: '1' }, to: { opacity: '0' } },
// Individual `scale` so it composes with `fade-*` (opacity) under one anim.
'scale-in': { from: { scale: 'var(--motion-scale-enter)' }, to: { scale: '1' } },
'scale-out': { from: { scale: '1' }, to: { scale: 'var(--motion-scale-enter)' } },
// Partial slides (~8px nudge) — popover / tooltip / menu / toast.
'slide-from-top': { from: { translate: `0 ${NEG_D}` }, to: { translate: '0 0' } },
'slide-from-bottom': { from: { translate: `0 ${D}` }, to: { translate: '0 0' } },
'slide-from-left': { from: { translate: `${NEG_D} 0` }, to: { translate: '0 0' } },
'slide-from-right': { from: { translate: `${D} 0` }, to: { translate: '0 0' } },
'slide-to-top': { from: { translate: '0 0' }, to: { translate: `0 ${NEG_D}` } },
'slide-to-bottom': { from: { translate: '0 0' }, to: { translate: `0 ${D}` } },
'slide-to-left': { from: { translate: '0 0' }, to: { translate: `${NEG_D} 0` } },
'slide-to-right': { from: { translate: '0 0' }, to: { translate: `${D} 0` } },
// Full slides (100%) — drawers / sheets. `--motion-slide-leave` (default 0)
// lets an INSET panel travel its own size PLUS the gap to fully clear the
// edge (a drawer set off `var(--space-3)` sets it to that inset).
'slide-from-top-full': {
from: { translate: '0 calc(-100% - var(--motion-slide-leave, 0px))' },
to: { translate: '0 0' }
},
'slide-from-bottom-full': {
from: { translate: '0 calc(100% + var(--motion-slide-leave, 0px))' },
to: { translate: '0 0' }
},
'slide-from-left-full': {
from: { translate: 'calc(-100% - var(--motion-slide-leave, 0px)) 0' },
to: { translate: '0 0' }
},
'slide-from-right-full': {
from: { translate: 'calc(100% + var(--motion-slide-leave, 0px)) 0' },
to: { translate: '0 0' }
},
'slide-to-top-full': {
from: { translate: '0 0' },
to: { translate: '0 calc(-100% - var(--motion-slide-leave, 0px))' }
},
'slide-to-bottom-full': {
from: { translate: '0 0' },
to: { translate: '0 calc(100% + var(--motion-slide-leave, 0px))' }
},
'slide-to-left-full': {
from: { translate: '0 0' },
to: { translate: 'calc(-100% - var(--motion-slide-leave, 0px)) 0' }
},
'slide-to-right-full': {
from: { translate: '0 0' },
to: { translate: 'calc(100% + var(--motion-slide-leave, 0px)) 0' }
},
// Collapse/expand to a MEASURED size (component sets `--height`). The
// recipe must set `overflow: hidden` on the animated element.
'expand-height': {
from: { height: 'var(--collapsed-height, 0)' },
to: { height: 'var(--height)' }
},
'collapse-height': {
from: { height: 'var(--height)' },
to: { height: 'var(--collapsed-height, 0)' }
},
// Shared axis (Material) — directional slide + fade, per axis. Forward
// direction (incoming from the leading edge, outgoing to the trailing edge).
// Travels the canonical `--motion-distance-xl` (30px); a theme retunes it there.
'slide-axis-x-in': {
from: { translate: 'var(--motion-distance-xl) 0' },
to: { translate: '0 0' }
},
'slide-axis-x-out': {
from: { translate: '0 0' },
to: { translate: 'calc(var(--motion-distance-xl) * -1) 0' }
},
'slide-axis-y-in': {
from: { translate: '0 var(--motion-distance-xl)' },
to: { translate: '0 0' }
},
'slide-axis-y-out': {
from: { translate: '0 0' },
to: { translate: '0 calc(var(--motion-distance-xl) * -1)' }
},
// Fade through (Material) — incoming scales up from `--motion-scale-through`
// (0.92); the outgoing side is opacity-only (see the `fade-through` preset's exit).
'scale-through': { from: { scale: 'var(--motion-scale-through)' }, to: { scale: '1' } },
// ── Firma perceptiva (momento --event) — migrado 1:1 de events.css ──────────
// Pulsos por intent (announce / commit-intent), settle de commit, squeeze de
// contact, fade de dismiss, rise de present. Referencian color tokens del
// theme. (NOTA fiel: announce-affirm usa `fulfill-element` como en events.css.)
'announce-pulse-neutral': {
'0%, 100%': { 'box-shadow': '0 0 0 0 transparent' },
'20%': { 'box-shadow': '0 0 0 var(--focus-ring-width) var(--color-neutral-element)' }
},
'announce-pulse-affirm': {
'0%, 100%': { 'box-shadow': '0 0 0 0 transparent' },
'20%': { 'box-shadow': '0 0 0 var(--focus-ring-width) var(--color-fulfill-element)' }
},
'announce-pulse-fulfill': {
'0%, 100%': { 'box-shadow': '0 0 0 0 transparent' },
'20%': { 'box-shadow': '0 0 0 var(--focus-ring-width) var(--color-fulfill-element)' }
},
'announce-pulse-risk': {
'0%, 100%': { 'box-shadow': '0 0 0 0 transparent' },
'15%': { 'box-shadow': '0 0 0 4px var(--color-risk-element)' },
'30%': { 'box-shadow': '0 0 0 2px var(--color-risk-element)' }
},
'announce-pulse-threat': {
'0%, 100%': { 'box-shadow': '0 0 0 0 transparent' },
'10%': { 'box-shadow': '0 0 0 6px var(--color-threat-element)' },
'35%': { 'box-shadow': '0 0 0 3px var(--color-threat-element)' }
},
'dismiss-fade': { to: { opacity: '0', transform: 'translateX(8%)' } },
'present-rise': { from: { opacity: '0', transform: 'translateY(8px)' } },
'press-squeeze': {
'0%': { transform: 'scale(1)' },
'40%': { transform: 'scale(0.96)', filter: 'brightness(0.96)' },
'100%': { transform: 'scale(1)' }
},
'commit-settle': {
'0%': { 'box-shadow': '0 0 0 0 transparent' },
'30%': {
'box-shadow': '0 0 0 3px color-mix(in srgb, var(--color-primary-solid) 32%, transparent)'
},
'100%': { 'box-shadow': '0 0 0 0 transparent' }
}
}
/**
* Built-in event-moment SIGNATURES (the perceptual firma), migrated 1:1 from
* the hand-written `events.css`. Generic by `family` / `intent` / `event` — a
* `commit` settles the same everywhere; an `announce` pulses by intent. This is
* the CSS half of the firma; sema emits sound + haptic from the SAME event, so
* motion + sound + haptic stay coordinated from one signal (the coupling
* SwiftUI's `.sensoryFeedback` leaves disconnected). Durations use the token
* scale (F6): the long end (`deliberate`/`emphatic`/`sustained`) are the
* perceptual holds, escalating with the `announce` intent's severity.
*/
export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
present: { event: ['present', 'open'], keyframes: 'present-rise', duration: 'slow', ease: 'out' },
dismiss: {
event: ['dismiss', 'close'],
keyframes: 'dismiss-fade',
duration: 'slow',
ease: 'default',
fill: 'forwards'
},
'announce-neutral': {
event: 'announce',
intent: 'neutral',
keyframes: 'announce-pulse-neutral',
duration: 'deliberate',
ease: 'default'
},
'announce-affirm': {
event: 'announce',
intent: 'affirm',
keyframes: 'announce-pulse-affirm',
duration: 'deliberate',
ease: 'default'
},
'announce-fulfill': {
event: 'announce',
intent: 'fulfill',
keyframes: 'announce-pulse-fulfill',
duration: 'deliberate',
ease: 'default'
},
'announce-risk': {
event: 'announce',
intent: 'risk',
keyframes: 'announce-pulse-risk',
duration: 'emphatic',
ease: 'spring'
},
'announce-threat': {
event: 'announce',
intent: 'threat',
keyframes: 'announce-pulse-threat',
duration: 'sustained',
ease: 'spring'
},
press: { family: 'contact', keyframes: 'press-squeeze', duration: 'fast', ease: 'default' },
commit: { family: 'commit', keyframes: 'commit-settle', duration: 'moderate', ease: 'out' },
'commit-fulfill': {
family: 'commit',
intent: 'fulfill',
keyframes: 'announce-pulse-fulfill',
duration: 'slow',
ease: 'out'
},
'commit-affirm': {
family: 'commit',
intent: 'affirm',
keyframes: 'announce-pulse-affirm',
duration: 'slow',
ease: 'out'
},
'commit-threat': {
family: 'commit',
intent: 'threat',
keyframes: 'announce-pulse-threat',
duration: 'slower',
ease: 'spring'
}
}
/**
* Built-in CSS presets. Phase-aware: one name covers enter (`data-state='open'`)
* and exit (`data-state='closed'`). `bySide` makes a preset placement-aware
* (reads `data-side`); the base `keyframes` is the no-side fallback. Exit
* durations are intentionally shorter than enter (asymmetry).
*/
export const BUILTIN_CSS_PRESETS: Readonly<Record<string, CssStatePreset>> = {
fade: {
driver: 'css',
enter: { keyframes: 'fade-in', duration: 'moderate', ease: 'out' },
exit: { keyframes: 'fade-out', duration: 'fast', ease: 'in' },
// Already opacity-only by nature — safe under reduced motion.
reduce: 'none'
},
'scale-fade': {
driver: 'css',
enter: {
keyframes: ['scale-in', 'fade-in'],
duration: 'moderate',
ease: 'out',
transformOrigin: 'var(--floating-transform-origin)'
},
exit: {
keyframes: ['scale-out', 'fade-out'],
duration: 'fast',
ease: 'in',
transformOrigin: 'var(--floating-transform-origin)'
},
reduce: 'opacity-only'
},
// Canonical floating-panel entrance — slide + subtle scale + fade, by side.
// The `scale-in` (0.985) is what anchors a popover/menu/tooltip/select to its
// trigger (matches Radix / Base UI / Chakra). Composes cleanly: slide uses
// `translate`, scale uses `scale`, fade uses `opacity` — distinct properties.
'slide-fade': {
driver: 'css',
enter: {
keyframes: ['slide-from-bottom', 'scale-in', 'fade-in'],
duration: 'moderate',
ease: 'out',
bySide: {
top: ['slide-from-bottom', 'scale-in', 'fade-in'],
bottom: ['slide-from-top', 'scale-in', 'fade-in'],
left: ['slide-from-right', 'scale-in', 'fade-in'],
right: ['slide-from-left', 'scale-in', 'fade-in']
}
},
exit: {
keyframes: ['slide-to-bottom', 'scale-out', 'fade-out'],
duration: 'fast',
ease: 'in',
bySide: {
top: ['slide-to-bottom', 'scale-out', 'fade-out'],
bottom: ['slide-to-top', 'scale-out', 'fade-out'],
left: ['slide-to-right', 'scale-out', 'fade-out'],
right: ['slide-to-left', 'scale-out', 'fade-out']
}
},
reduce: 'opacity-only'
},
'slide-full': {
driver: 'css',
enter: {
keyframes: 'slide-from-right-full',
duration: 'moderate',
ease: 'out',
bySide: {
top: 'slide-from-top-full',
bottom: 'slide-from-bottom-full',
left: 'slide-from-left-full',
right: 'slide-from-right-full'
}
},
exit: {
keyframes: 'slide-to-right-full',
duration: 'fast',
ease: 'in',
bySide: {
top: 'slide-to-top-full',
bottom: 'slide-to-bottom-full',
left: 'slide-to-left-full',
right: 'slide-to-right-full'
}
},
reduce: 'instant'
},
collapse: {
driver: 'css',
enter: { keyframes: 'expand-height', duration: 'moderate', ease: 'out' },
exit: { keyframes: 'collapse-height', duration: 'fast', ease: 'in' },
reduce: 'instant'
},
// ── Material 3 transitions (choreography) ────────────────────────────────────
// Shared axis — coordinated directional motion for spatial / navigational
// changes (outgoing + incoming move together along one axis). Per-element here;
// an app triggers both sides together for the "shared" effect.
'shared-axis-x': {
driver: 'css',
enter: { keyframes: ['slide-axis-x-in', 'fade-in'], duration: 'moderate', ease: 'out' },
exit: { keyframes: ['slide-axis-x-out', 'fade-out'], duration: 'fast', ease: 'in' },
reduce: 'opacity-only'
},
'shared-axis-y': {
driver: 'css',
enter: { keyframes: ['slide-axis-y-in', 'fade-in'], duration: 'moderate', ease: 'out' },
exit: { keyframes: ['slide-axis-y-out', 'fade-out'], duration: 'fast', ease: 'in' },
reduce: 'opacity-only'
},
// Fade through — for content with NO relationship (incoming scales up + fades
// in; outgoing is opacity-only).
'fade-through': {
driver: 'css',
enter: { keyframes: ['scale-through', 'fade-in'], duration: 'moderate', ease: 'out' },
exit: { keyframes: 'fade-out', duration: 'fast', ease: 'in' },
reduce: 'opacity-only'
}
}

@ -0,0 +1,40 @@
/**
* Type-safe, app-extensible registry of momento-`--state` preset names (the
* value of a component's `motion` prop). Mirrors sema's `SemaChannelSignatures`:
* the built-ins are declared here; an app adds its own presets — registered at
* runtime via `EidosConfig.motion.presets` — by declaration-merging the
* interface, which makes the new name autocomplete + type-check on every
* `motion` prop:
*
* declare module '$uix/eidos' {
* interface EidosMotionPresets {
* genie: true
* 'my-fancy-slide': true
* }
* }
*
* Boundary: the runtime engine (`uix.motion`, `$motion`) stays `string`-typed —
* its registry is open at runtime. THIS registry is a COMPILE-TIME ergonomic
* for component props + config authoring; it does not constrain the engine.
*
* Keep these keys in sync with `BUILTIN_CSS_PRESETS` (`presets/css.ts`) — a test
* in `motion.test.ts` enforces the built-ins are all declared here.
*/
export interface EidosMotionPresets {
fade: true
'scale-fade': true
'slide-fade': true
'slide-full': true
collapse: true
'shared-axis-x': true
'shared-axis-y': true
'fade-through': true
}
/**
* The `motion` prop type. Known presets (built-in + app-merged) autocomplete;
* `'none'` disables animation; an arbitrary string (an app preset registered at
* runtime without a typed slot) is still accepted — same `keyof T | (string & {})`
* shape sema uses for `SemaChannelId`.
*/
export type MotionPresetName = keyof EidosMotionPresets | 'none' | (string & {})

@ -122,7 +122,13 @@ export const STATIC_MOTION = {
fast: '120ms',
normal: '180ms',
moderate: '240ms',
slow: '320ms'
slow: '320ms',
// Long end — perceptual holds for the firma. The `announce` pulses escalate
// by intent severity (deliberate → emphatic → sustained).
slower: '400ms',
deliberate: '600ms',
emphatic: '800ms',
sustained: '1000ms'
},
ease: {
default: 'cubic-bezier(0.4, 0, 0.2, 1)',
@ -130,19 +136,34 @@ export const STATIC_MOTION = {
in: 'cubic-bezier(0.4, 0, 1, 1)',
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
alert: 'cubic-bezier(0.36, 0.07, 0.19, 0.97)',
symmetric: 'cubic-bezier(0.4, 0, 0.6, 1)'
symmetric: 'cubic-bezier(0.4, 0, 0.6, 1)',
// M3 emphasized (decelerate-dominant) — the expressive standard.
emphasized: 'cubic-bezier(0.05, 0.7, 0.1, 1)'
},
distance: {
xs: '2px',
sm: '4px',
md: '8px',
lg: '16px'
lg: '16px',
// Shared-axis (Material) canonical travel — larger than the partial nudges.
xl: '30px'
},
scale: {
enter: '0.985',
press: '0.97'
press: '0.97',
// Fade-through (Material) incoming scale.
through: '0.92'
},
stagger: '20ms'
stagger: '20ms',
// Expressive set (Carbon's productive/expressive split): emphasized easing
// applied under `[data-motion-set='expressive']`. Productive stays the default.
expressive: {
ease: {
default: 'cubic-bezier(0.4, 0.14, 0.3, 1)',
out: 'cubic-bezier(0, 0, 0.3, 1)',
in: 'cubic-bezier(0.4, 0.14, 1, 1)'
}
}
} as const
export const STATIC_ICON = {

@ -732,8 +732,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'overlay-opacity': '62%',
'overlay-blur': '10px',
'overlay-z': '70',
'overlay-in-duration': '120ms',
'overlay-out-duration': '280ms',
'content-bg': 'var(--color-surface-overlay)',
'content-color': 'var(--color-content-primary)',
'content-border': 'var(--color-border-default)',

@ -3,6 +3,7 @@ import {
COLOR_SCALE_STEPS,
COLOR_ROLES,
DENSITY_KEYS,
DURATION_KEYS,
SCALING_KEYS,
DEFAULT_SCALING,
completeColorRoleMap,
@ -42,6 +43,14 @@ import {
import { createEidosCssContract } from './contract'
import { toKebab } from './utils'
import { STATIC_SCALING } from './primitives/static'
import type {
CssStatePreset,
CssPhase,
EventSignature,
KeyframeName,
KeyframeStops,
MotionConfig
} from '$motion'
import { EidosCssVariableError, EidosThemeNotFoundError } from '../errors'
export interface RenderThemeCssOptions {
@ -198,6 +207,25 @@ export function renderStaticCss(options: EidosConfig): string {
if (primitives.density) blocks.push(renderDensityBlocks())
blocks.push(renderScalingBlocks())
// Expressive motion set (Carbon's productive/expressive split): a scoped
// override of the easing / duration tokens under `[data-motion-set='expressive']`.
// Productive (the default) lives in `:root`; only the deltas are emitted here.
const expressive = primitives.motion?.expressive
if (expressive) {
const exDeclarations: string[] = []
for (const [key, value] of Object.entries(expressive.ease ?? {})) {
if (value) exDeclarations.push(cssVar(`ease-${key}`, value))
}
for (const [key, value] of Object.entries(expressive.duration ?? {})) {
if (value) exDeclarations.push(cssVar(`duration-${key}`, value))
}
if (exDeclarations.length) {
blocks.push(renderBlock("[data-motion-set='expressive']", exDeclarations))
}
}
if (options.motion) blocks.push(renderMotionBlocks(options.motion))
return blocks.join('\n\n')
}
@ -419,6 +447,190 @@ function renderScalingBlocks(): string {
.join('\n\n')
}
// ── Motion ─────────────────────────────────────────────────────────────────
// Emits the registered `@keyframes` + per-preset rules selected by
// `data-animation-style` × `data-state` (enter→open / exit→closed), with
// `data-side`-aware variants. Only `css`-driver presets generate CSS; JS
// presets run through the motion engine (`uix.motion`). Reduced motion is honored per preset
// policy. Enter uses `fill: backwards` (no initial flash, no forwards
// accumulation); exit uses `fill: forwards` (hold the end state until soma's
// Presence unmounts the element).
const MOTION_PHASES = [
{ phase: 'enter', state: 'open', fill: 'backwards', dur: 'moderate', ease: 'out' },
{ phase: 'exit', state: 'closed', fill: 'forwards', dur: 'fast', ease: 'in' }
] as const
function renderMotionBlocks(motion: MotionConfig): string {
const blocks: string[] = []
// Per-component duration + easing overrides. A preset owns the SHAPE + a
// default timing/curve; a component (or theme) tunes its own enter/exit by
// setting these on its animated element — `phaseDeclarations` reads them with
// the preset token as fallback. `inherits: false` so a preset component nested
// inside another doesn't inherit the ancestor's values.
for (const phase of ['enter', 'exit'] as const) {
blocks.push(`@property --motion-duration-${phase} {\n\tsyntax: '*';\n\tinherits: false;\n}`)
blocks.push(`@property --motion-ease-${phase} {\n\tsyntax: '*';\n\tinherits: false;\n}`)
}
// Stagger index is per-item — `inherits: false` stops it leaking into nested
// groups; the rhythm (`--motion-stagger-each`) inherits from the container.
blocks.push(
"@property --motion-stagger-index {\n\tsyntax: '<integer>';\n\tinherits: false;\n\tinitial-value: 0;\n}"
)
for (const [name, stops] of Object.entries(motion.keyframes ?? {})) {
blocks.push(renderKeyframes(name, stops))
}
// Momento --event: la firma perceptiva (genérica por family/intent/event).
for (const [name, signature] of Object.entries(motion.signatures ?? {})) {
blocks.push(...renderSignatureRules(name, signature))
}
// Momento --state: presets de transición per-componente (data-state).
for (const [name, preset] of Object.entries(motion.presets ?? {})) {
if (preset.driver !== 'css') continue
blocks.push(...renderCssPresetRules(name, preset))
blocks.push(...renderReducedMotionRules(name, preset))
}
return blocks.join('\n\n')
}
function renderKeyframes(name: KeyframeName, stops: KeyframeStops): string {
const body = Object.entries(stops)
.map(([stop, decls]) => {
const inner = Object.entries(decls)
.map(([prop, value]) => `\t\t${prop}: ${value};`)
.join('\n')
return `\t${stop} {\n${inner}\n\t}`
})
.join('\n')
return `@keyframes ${name} {\n${body}\n}`
}
function renderCssPresetRules(name: string, preset: CssStatePreset): string[] {
const rules: string[] = []
for (const { phase, state, fill, dur, ease } of MOTION_PHASES) {
const cssPhase = preset[phase]
if (!cssPhase) continue
rules.push(
renderBlock(
`[data-animation-style='${name}'][data-state='${state}']`,
phaseDeclarations(cssPhase, cssPhase.keyframes, fill, dur, ease, phase)
)
)
for (const [side, keyframes] of Object.entries(cssPhase.bySide ?? {})) {
rules.push(
renderBlock(
`[data-animation-style='${name}'][data-side='${side}'][data-state='${state}']`,
phaseDeclarations(cssPhase, keyframes, fill, dur, ease, phase)
)
)
}
}
return rules
}
function phaseDeclarations(
cssPhase: CssPhase,
keyframes: KeyframeName | readonly KeyframeName[],
fill: string,
defaultDuration: string,
defaultEase: string,
phase: string
): string[] {
const names = typeof keyframes === 'string' ? [keyframes] : keyframes
// Per-component overrides (`--motion-{duration,ease}-{enter,exit}`) → preset token.
const dur = `var(--motion-duration-${phase}, var(--duration-${cssPhase.duration ?? defaultDuration}))`
const ease = `var(--motion-ease-${phase}, var(--ease-${cssPhase.ease ?? defaultEase}))`
const animation = names.map((kf) => `${kf} ${dur} ${ease} ${fill}`).join(', ')
const declarations = [`animation: ${animation};`]
// Stagger (Material list choreography, zero JS orchestration): a container
// sets `--motion-stagger-each` (rhythm) and each item `--motion-stagger-index`,
// so each item's enter is delayed by index × each. Default 0 → no stagger; the
// `backwards` fill holds the `from` state until each item's turn.
if (phase === 'enter') {
declarations.push(
'animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));'
)
}
if (cssPhase.transformOrigin) {
declarations.push(`transform-origin: ${cssPhase.transformOrigin};`)
}
return declarations
}
// Reduced motion: 'none' runs as-is (a pure fade is acceptable); 'instant'
// kills the animation (snap to the steady `[data-state]` style); 'opacity-only'
// keeps a brief fade and drops transforms. Emitted both under the projected
// `[data-motion='reduce']` and the media query, so it works with or without
// the prefs DOM projection. `!important` beats the bySide variants.
function renderReducedMotionRules(name: string, preset: CssStatePreset): string[] {
const policy = preset.reduce ?? 'none'
if (policy === 'none') return []
const rules: string[] = []
for (const { phase, state } of MOTION_PHASES) {
if (!preset[phase]) continue
const inner =
policy === 'instant'
? ['animation: none !important;']
: phase === 'enter'
? ['animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;']
: ['animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;']
const selector = `[data-animation-style='${name}'][data-state='${state}']`
rules.push(renderBlock(`[data-motion='reduce'] ${selector}`, inner))
rules.push(
`@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selector, inner))}\n}`
)
}
return rules
}
// ── Momento --event: la firma perceptiva (genérica por evento) ───────────────
// Reacciona a `data-event-*` durante el hold de sema. El selector se construye
// por `family` / `intent` / `event`; es lo que hoy hace `events.css` a mano
// (F2 migrará su contenido aquí). Es el canal `motion` de la firma (GUIA §11).
function signatureSelectorList(sig: EventSignature): string[] {
const suffix =
`${sig.family ? `[data-event-family='${sig.family}']` : ''}` +
`${sig.intent ? `[data-event-intent='${sig.intent}']` : ''}` +
`[data-event-phase='active']`
const events =
sig.event === undefined ? [undefined] : typeof sig.event === 'string' ? [sig.event] : sig.event
return events.map((e) => `${e ? `[data-event^='${e}']` : ''}${suffix}`)
}
// Duración: un token conocido → `var(--duration-X)`; cualquier otra cosa (un
// hold crudo como `600ms`) se emite tal cual. F6 tokenizará los crudos.
function resolveDurationValue(value: string): string {
return (DURATION_KEYS as readonly string[]).includes(value) ? `var(--duration-${value})` : value
}
function renderSignatureRules(_name: string, sig: EventSignature): string[] {
const names = typeof sig.keyframes === 'string' ? [sig.keyframes] : sig.keyframes
const dur = resolveDurationValue(sig.duration ?? 'moderate')
const ease = `var(--ease-${sig.ease ?? 'out'})`
const fill = sig.fill && sig.fill !== 'none' ? ` ${sig.fill}` : ''
const animation = names.map((kf) => `${kf} ${dur} ${ease}${fill}`).join(', ')
const selectors = signatureSelectorList(sig)
const rules = [renderBlock(selectors.join(',\n'), [`animation: ${animation};`])]
const policy = sig.reduce ?? 'none'
if (policy !== 'none') {
const inner =
policy === 'instant'
? ['animation: none !important;']
: ['animation: fade-in var(--duration-fast) var(--ease-out) !important;']
rules.push(
renderBlock(selectors.map((s) => `[data-motion='reduce'] ${s}`).join(',\n'), inner)
)
rules.push(
`@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selectors.join(',\n'), inner))}\n}`
)
}
return rules
}
function appendTypographyDeclarations(
declarations: string[],
typography: TypographyPrimitiveSet

@ -13,6 +13,7 @@ import type {
} from '../config-types'
import { THEME_BASE_RECIPE_TOKENS } from '../recipes/base'
import { RADIX_EXTRA_LIGHT_SCALES, RADIX_EXTRA_DARK_SCALES } from './radix-scales'
import { BUILTIN_KEYFRAMES, BUILTIN_SIGNATURES, BUILTIN_CSS_PRESETS } from '../motion/presets/css'
export const THEME_BASE_COLOR_ROLES: ColorRoleMap = {
primary: 'purple',
@ -468,6 +469,11 @@ export const THEME_BASE_OPTIONS: EidosConfig = defineEidosConfig({
}
},
recipes: THEME_BASE_RECIPE_TOKENS,
motion: {
keyframes: BUILTIN_KEYFRAMES,
signatures: BUILTIN_SIGNATURES,
presets: BUILTIN_CSS_PRESETS
},
themes: {
'base-light': {
color: {

@ -0,0 +1,334 @@
import { describe, expect, it } from 'vitest'
import { createEngineMotion, rect, spring, waapi } from '$motion'
import type { MotionContext, MotionHandle } from '$motion'
import { validateEidosConfig } from './lib/config'
import { createThemeBaseEidosConfig } from './lib/themes/base'
// ── Fakes (server project = node, no DOM / rAF) ───────────────────────────────
/** A dom whose `requestFrame` queue is stepped manually via `flush`. */
function makeFakeDom() {
const cbs = new Map<number, () => void>()
let nextId = 1
const dom = {
requestFrame(cb: () => void) {
const id = nextId++
cbs.set(id, cb)
return id
},
cancelFrame(id: number) {
cbs.delete(id)
},
prefersReducedMotion: { matches: false }
}
return {
dom: dom as unknown as MotionContext['dom'],
/** Step queued frames until the queue drains or `max` steps run. */
flush(max = 5000) {
let n = 0
while (cbs.size && n < max) {
const id = cbs.keys().next().value as number
const cb = cbs.get(id)!
cbs.delete(id)
cb()
n++
}
return n
},
get pending() {
return cbs.size
}
}
}
/** An element that records `style.setProperty` writes. */
function makeFakeEl() {
const props: Record<string, string> = {}
const el = {
style: {
setProperty(name: string, value: string) {
props[name] = value
}
}
}
return { el: el as unknown as HTMLElement, props }
}
// ── Engine ────────────────────────────────────────────────────────────────────
describe('eidos motion — JS run engine', () => {
it('runs a JS preset, tracks the handle, and resolves pending() on finish', async () => {
let resolveRun!: () => void
const runFinished = new Promise<void>((r) => (resolveRun = r))
let calls = 0
const rt = createEngineMotion({
presets: {
jsx: {
driver: 'spring',
enter: () => {
calls++
return { finished: runFinished, cancel() {} } satisfies MotionHandle
}
}
}
})
const { el } = makeFakeEl()
const { dom } = makeFakeDom()
rt.enter(el, 'jsx', { dom })
expect(calls).toBe(1)
let pendingResolved = false
const pendingPromise = rt.pending(el).then(() => (pendingResolved = true))
await Promise.resolve()
expect(pendingResolved).toBe(false) // handle still running
resolveRun()
await pendingPromise
expect(pendingResolved).toBe(true)
})
it('cancel(el) cancels active JS handles and resolves their finished', async () => {
let cancelled = 0
const rt = createEngineMotion({
presets: {
jsx: {
driver: 'spring',
enter: () => ({ finished: new Promise<void>(() => {}), cancel: () => cancelled++ })
}
}
})
const { el } = makeFakeEl()
const { dom } = makeFakeDom()
rt.enter(el, 'jsx', { dom })
rt.cancel(el)
expect(cancelled).toBe(1)
})
it('skips the JS run under reduced motion when policy is instant (settled)', async () => {
let calls = 0
const rt = createEngineMotion({
presets: {
jsx: {
driver: 'spring',
reduce: 'instant',
enter: () => {
calls++
return { finished: Promise.resolve(), cancel() {} }
}
}
}
})
const { el } = makeFakeEl()
const { dom } = makeFakeDom()
const handle = rt.enter(el, 'jsx', { dom, reduced: true })
expect(calls).toBe(0)
await expect(handle.finished).resolves.toBeUndefined()
})
it('treats CSS presets + unknown names as declarative (settled, no run)', async () => {
const rt = createEngineMotion({
presets: { css: { driver: 'css', enter: { keyframes: 'fade-in' } } }
})
const { el } = makeFakeEl()
const { dom } = makeFakeDom()
await expect(rt.enter(el, 'css', { dom }).finished).resolves.toBeUndefined()
await expect(rt.enter(el, 'nope', { dom }).finished).resolves.toBeUndefined()
expect(rt.pending(el)).resolves.toBeUndefined()
})
it('normalises a WAAPI Animation return into a handle', async () => {
const fakeAnim = { finished: Promise.resolve(), cancel() {} }
const el = { animate: () => fakeAnim } as unknown as HTMLElement
const rt = createEngineMotion({
presets: { w: { driver: 'waapi', enter: waapi([{ opacity: 0 }, { opacity: 1 }], 120) } }
})
await expect(rt.enter(el, 'w', { dom: makeFakeDom().dom }).finished).resolves.toBeUndefined()
})
})
// ── Drivers ─────────────────────────────────────────────────────────────────
describe('eidos motion — spring driver (physics)', () => {
it('paints `from` immediately, drives the property to its target, and settles', async () => {
const rt = createEngineMotion({
presets: { pop: { driver: 'spring', enter: spring({ values: { opacity: [0, 1] } }) } }
})
const { el, props } = makeFakeEl()
const fd = makeFakeDom()
const handle = rt.enter(el, 'pop', { dom: fd.dom })
expect(Number(props.opacity)).toBeCloseTo(0, 5) // initial paint at `from`
const stepped = fd.flush()
expect(stepped).toBeGreaterThan(1) // it actually animated over frames
expect(fd.pending).toBe(0) // settled → no more frames queued
await handle.finished
expect(Number(props.opacity)).toBeCloseTo(1, 5) // landed on target
})
it('applies per-property units and runs independent springs', async () => {
const rt = createEngineMotion({
presets: {
slide: {
driver: 'spring',
enter: spring({ values: { '--ty': [16, 0], opacity: [0, 1] }, unit: { '--ty': 'px' } })
}
}
})
const { el, props } = makeFakeEl()
const fd = makeFakeDom()
const handle = rt.enter(el, 'slide', { dom: fd.dom })
fd.flush()
await handle.finished
expect(props['--ty']).toBe('0px')
expect(Number(props.opacity)).toBeCloseTo(1, 5)
})
it('stops the frame loop and resolves finished when cancelled mid-flight', async () => {
const rt = createEngineMotion({
presets: { pop: { driver: 'spring', enter: spring({ values: { opacity: [0, 1] } }) } }
})
const { el } = makeFakeEl()
const fd = makeFakeDom()
const handle = rt.enter(el, 'pop', { dom: fd.dom })
fd.flush(3) // step a few frames (not yet settled)
expect(fd.pending).toBe(1)
handle.cancel()
expect(fd.pending).toBe(0) // pending frame cancelled
await expect(handle.finished).resolves.toBeUndefined()
})
})
describe('eidos motion — rect driver (FLIP)', () => {
const elWith = (last: Partial<DOMRect>, onAnimate?: (kf: unknown) => void) =>
({
getBoundingClientRect: () =>
({ left: 0, top: 0, width: 100, height: 100, ...last }) as DOMRect,
animate: (kf: unknown) => {
onAnimate?.(kf)
return { finished: Promise.resolve(), cancel() {} }
}
}) as unknown as HTMLElement
const ctx = (el: HTMLElement, sourceRect?: DOMRect) =>
({ el, sourceRect, signal: new AbortController().signal }) as unknown as MotionContext
it('settles when no sourceRect (nothing to flip)', async () => {
const handle = rect()(ctx(elWith({}))) as MotionHandle
await expect(handle.finished).resolves.toBeUndefined()
})
it('animates the inverse delta (translate + scale) via WAAPI', () => {
let kf: { transform: string }[] | undefined
const el = elWith({ left: 40, top: 20, width: 200, height: 50 }, (k) => {
kf = k as { transform: string }[]
})
const first = { left: 0, top: 0, width: 100, height: 100 } as DOMRect
rect()(ctx(el, first))
expect(kf?.[0].transform).toBe('translate(-40px, -20px) scale(0.5, 2)')
expect(kf?.[1].transform).toBe('none')
})
it('no-ops (settled) when first === last (no layout shift)', async () => {
const el = elWith({ left: 0, top: 0, width: 100, height: 100 })
const first = { left: 0, top: 0, width: 100, height: 100 } as DOMRect
const handle = rect()(ctx(el, first)) as MotionHandle
await expect(handle.finished).resolves.toBeUndefined()
})
})
// ── Validation ────────────────────────────────────────────────────────────────
describe('eidos motion — JS preset validation', () => {
it('flags a JS preset declaring neither enter nor exit (inert)', () => {
const report = validateEidosConfig({
...createThemeBaseEidosConfig(),
motion: { presets: { inert: { driver: 'spring' } } }
})
expect(report.ok).toBe(false)
expect(report.issues.some((i) => i.path === 'motion.presets.inert')).toBe(true)
})
it('flags a JS preset whose fallback is not a css preset', () => {
const report = validateEidosConfig({
...createThemeBaseEidosConfig(),
motion: {
presets: {
bad: {
driver: 'spring',
enter: spring({ values: { opacity: [0, 1] } }),
fallback: { driver: 'spring' } as never
}
}
}
})
expect(report.ok).toBe(false)
expect(report.issues.some((i) => i.path === 'motion.presets.bad.fallback')).toBe(true)
})
it('accepts a valid JS preset (no motion.presets issues)', () => {
const report = validateEidosConfig({
...createThemeBaseEidosConfig(),
motion: {
presets: { ok: { driver: 'spring', enter: spring({ values: { opacity: [0, 1] } }) } }
}
})
expect(report.issues.some((i) => i.path.startsWith('motion.presets.ok'))).toBe(false)
})
})
// ── run() resolves data-animation-style (Presence bridge) ─────────────────────
describe('eidos motion — run() bridge', () => {
const fakeDom = {
prefersReducedMotion: { matches: false },
requestFrame: () => 0,
cancelFrame: () => {}
} as never
const node = (animationStyle?: string) =>
({
getAttribute: (k: string) => (k === 'data-animation-style' ? (animationStyle ?? null) : null),
style: { setProperty() {} }
}) as unknown as HTMLElement
it('settles when the element has no data-animation-style', async () => {
const rt = createEngineMotion({
dom: fakeDom,
presets: { js: { driver: 'spring', enter: () => ({ finished: Promise.resolve(), cancel() {} }) } }
})
await expect(rt.run(node(), 'enter').finished).resolves.toBeUndefined()
})
it('settles for a CSS preset (declarative — awaited via getAnimations)', async () => {
const rt = createEngineMotion({
dom: fakeDom,
presets: { fade: { driver: 'css', enter: { keyframes: 'fade-in' } } }
})
await expect(rt.run(node('fade'), 'enter').finished).resolves.toBeUndefined()
})
it('runs the JS preset named by data-animation-style', async () => {
let calls = 0
const rt = createEngineMotion({
dom: fakeDom,
presets: {
js: {
driver: 'spring',
enter: () => {
calls++
return { finished: Promise.resolve(), cancel() {} }
}
}
}
})
await expect(rt.run(node('js'), 'enter').finished).resolves.toBeUndefined()
expect(calls).toBe(1)
})
})

@ -0,0 +1,250 @@
import { describe, expect, it } from 'vitest';
import { validateEidosConfig } from './lib/config';
import { createEngineMotion } from '$motion';
import { BUILTIN_CSS_PRESETS, BUILTIN_KEYFRAMES } from './lib/motion/presets/css';
import { renderStaticCss } from './lib/render-css';
import { createThemeBaseEidosConfig } from './lib/themes/base';
import type { EidosMotionPresets } from './lib/motion/registry';
describe('eidos motion — CSS generation', () => {
const css = renderStaticCss(createThemeBaseEidosConfig());
it('emits an @keyframes for every built-in keyframe', () => {
for (const name of Object.keys(BUILTIN_KEYFRAMES)) {
expect(css).toContain(`@keyframes ${name} {`);
}
});
it('emits enter (open) + exit (closed) rules per css preset, composing keyframes', () => {
expect(css).toContain(`[data-animation-style='scale-fade'][data-state='open']`);
expect(css).toContain(`[data-animation-style='scale-fade'][data-state='closed']`);
// composed keyframes under one animation; the duration is per-component
// overridable (`--motion-duration-{enter,exit}`) with the preset token as
// the fallback. Enter holds `backwards`, exit `forwards`.
expect(css).toContain(
'scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards'
);
expect(css).toContain(
'fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards'
);
expect(css).toContain(
'scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards'
);
// the duration + easing override custom props are registered (inherits:false)
expect(css).toContain('@property --motion-duration-enter');
expect(css).toContain('@property --motion-ease-exit');
});
it('emits the stagger choreography hook on enter rules (Material list)', () => {
// per-item index registered as a non-inheriting integer
expect(css).toContain('@property --motion-stagger-index');
expect(css).toContain("syntax: '<integer>'");
// enter rules carry the index × each delay (default 0 → no stagger)
expect(css).toContain(
'animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms))'
);
});
it('emits the Material transition presets (shared-axis + fade-through)', () => {
expect(css).toContain(`[data-animation-style='shared-axis-x'][data-state='open']`);
expect(css).toContain(`[data-animation-style='shared-axis-y'][data-state='open']`);
expect(css).toContain(`[data-animation-style='fade-through'][data-state='open']`);
expect(css).toContain('@keyframes scale-through');
// shared-axis composes a directional slide with a fade
expect(css).toMatch(/shared-axis-x'\]\[data-state='open'\] \{\s*animation: slide-axis-x-in/);
});
it('emits side-aware variants for slide-fade (data-side)', () => {
expect(css).toContain(`[data-animation-style='slide-fade'][data-side='top'][data-state='open']`);
expect(css).toContain(
`[data-animation-style='slide-fade'][data-side='bottom'][data-state='open']`
);
expect(css).toContain(
`[data-animation-style='slide-fade'][data-side='left'][data-state='open']`
);
});
it('honors reduced motion per policy (projected attr + media query)', () => {
// opacity-only preset drops transforms, keeps a fade
expect(css).toContain(
`[data-motion='reduce'] [data-animation-style='scale-fade'][data-state='open']`
);
expect(css).toContain('@media (prefers-reduced-motion: reduce)');
// instant preset kills the animation
expect(css).toContain(`[data-motion='reduce'] [data-animation-style='collapse'][data-state='open']`);
expect(css).toMatch(
/\[data-motion='reduce'\] \[data-animation-style='collapse'\]\[data-state='open'\] \{\s*animation: none !important;/
);
// 'none' presets (fade) get NO per-preset reduced override
expect(css).not.toContain(`[data-motion='reduce'] [data-animation-style='fade'][data-state`);
});
it('emits event-moment signatures (data-event-*) from motion.signatures', () => {
const withSig = renderStaticCss({
...createThemeBaseEidosConfig(),
motion: {
keyframes: { 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } } },
signatures: {
'announce-threat': {
family: 'signal',
intent: 'threat',
keyframes: 'fade-in',
duration: 'slow'
}
}
}
});
expect(withSig).toContain(
`[data-event-family='signal'][data-event-intent='threat'][data-event-phase='active']`
);
expect(withSig).toContain('fade-in var(--duration-slow)');
});
});
describe('eidos motion — runtime registry', () => {
const runtime = createEngineMotion(createThemeBaseEidosConfig().motion);
it('registers the built-in presets', () => {
for (const name of Object.keys(BUILTIN_CSS_PRESETS)) {
expect(runtime.has(name)).toBe(true);
expect(runtime.resolve(name)?.driver).toBe('css');
}
expect(runtime.list()).toEqual(expect.arrayContaining(Object.keys(BUILTIN_CSS_PRESETS)));
});
it('returns undefined for an unknown preset', () => {
expect(runtime.resolve('does-not-exist')).toBeUndefined();
expect(runtime.has('does-not-exist')).toBe(false);
});
it('css presets resolve enter/exit to a settled handle (declarative path)', async () => {
// CSS presets never touch the element — soma's Presence drives them.
const el = {} as HTMLElement;
await expect(runtime.enter(el, 'scale-fade').finished).resolves.toBeUndefined();
await expect(runtime.exit(el, 'fade').finished).resolves.toBeUndefined();
// an unknown name is a no-op settled handle too
await expect(runtime.enter(el, 'nope').finished).resolves.toBeUndefined();
});
it('register adds a preset', () => {
const local = createEngineMotion();
expect(local.has('fade')).toBe(false);
local.register('fade', BUILTIN_CSS_PRESETS.fade);
expect(local.resolve('fade')?.driver).toBe('css');
});
});
describe('eidos motion — validation', () => {
it('passes for the base config', () => {
expect(validateEidosConfig(createThemeBaseEidosConfig()).ok).toBe(true);
});
it('flags a css preset referencing an unknown keyframe', () => {
const report = validateEidosConfig({
...createThemeBaseEidosConfig(),
motion: {
keyframes: { 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } } },
presets: {
broken: { driver: 'css', enter: { keyframes: 'no-such-keyframe' } }
}
}
});
expect(report.ok).toBe(false);
expect(report.issues.some((issue) => issue.path.startsWith('motion.presets.broken'))).toBe(
true
);
});
it("flags an 'opacity-only' preset missing the fade keyframes", () => {
const report = validateEidosConfig({
...createThemeBaseEidosConfig(),
motion: {
keyframes: { 'slide-in': { from: { translate: '0 4px' }, to: { translate: '0 0' } } },
presets: {
slide: {
driver: 'css',
enter: { keyframes: 'slide-in' },
reduce: 'opacity-only'
}
}
}
});
expect(report.ok).toBe(false);
expect(report.issues.some((issue) => issue.message.includes('fade-in'))).toBe(true);
});
it('flags a signature referencing an unknown keyframe', () => {
const report = validateEidosConfig({
...createThemeBaseEidosConfig(),
motion: {
keyframes: { 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } } },
signatures: { bad: { family: 'commit', keyframes: 'no-such-kf' } }
}
});
expect(report.ok).toBe(false);
expect(report.issues.some((issue) => issue.path.startsWith('motion.signatures.bad'))).toBe(true);
});
});
describe('eidos motion — F6 token rigor', () => {
const css = renderStaticCss(createThemeBaseEidosConfig());
it('emits the extended duration scale (perceptual holds for the firma)', () => {
expect(css).toContain('--duration-slower: 400ms;');
expect(css).toContain('--duration-deliberate: 600ms;');
expect(css).toContain('--duration-emphatic: 800ms;');
expect(css).toContain('--duration-sustained: 1000ms;');
});
it('emits the emphasized easing + shared-axis (xl) + fade-through (through) tokens', () => {
expect(css).toContain('--ease-emphasized:');
expect(css).toContain('--motion-distance-xl: 30px;');
expect(css).toContain('--motion-scale-through: 0.92;');
});
it('tokenizes the firma durations — announce pulses escalate by intent severity', () => {
expect(css).toContain('animation: announce-pulse-neutral var(--duration-deliberate)');
expect(css).toContain('animation: announce-pulse-risk var(--duration-emphatic)');
expect(css).toContain('animation: announce-pulse-threat var(--duration-sustained)');
// press / commit snap onto the existing scale (sub-perceptual)
expect(css).toContain('animation: press-squeeze var(--duration-fast)');
expect(css).toContain('animation: commit-settle var(--duration-moderate)');
});
it('points the Material keyframes at the new tokens (no hardcoded 30px / 0.92)', () => {
expect(css).toContain('translate: var(--motion-distance-xl) 0');
expect(css).toContain('scale: var(--motion-scale-through)');
});
it('emits the expressive motion set (Carbon productive/expressive) as a scoped override', () => {
expect(css).toContain("[data-motion-set='expressive']");
// expressive remaps the easing curves; productive (`:root`) stays the default
expect(css).toMatch(/\[data-motion-set='expressive'\] \{[^}]*--ease-out:/);
});
it('does NOT remap expressive tokens that the set leaves to productive', () => {
// the base set only overrides default/out/in — `spring` is untouched
expect(css).not.toMatch(/\[data-motion-set='expressive'\] \{[^}]*--ease-spring:/);
});
});
describe('eidos motion — F7 typegen registry', () => {
it('declares every built-in CSS preset as a type-safe registry name', () => {
// `registered` is the closed built-in set; the `satisfies` makes each a
// `keyof EidosMotionPresets` — a TYPO fails compile (the registry's
// `(string & {})` escape is NOT in play here). The runtime check pins it to
// the ACTUAL presets, so adding a built-in without registering it fails too.
const registered = [
'fade',
'scale-fade',
'slide-fade',
'slide-full',
'collapse',
'shared-axis-x',
'shared-axis-y',
'fade-through'
] as const satisfies readonly (keyof EidosMotionPresets)[];
expect([...Object.keys(BUILTIN_CSS_PRESETS)].sort()).toEqual([...registered].sort());
});
});

@ -156,13 +156,15 @@ export class DialogProvider {
dom: this.soma.dom,
open: opts.open,
ref: this.contentRef,
onComplete: (open) => opts.onOpenChangeComplete.current(open)
onComplete: (open) => opts.onOpenChangeComplete.current(open),
motion: this.soma.motion
});
this.overlayPresence = new Presence({
dom: this.soma.dom,
open: opts.open,
ref: this.overlayRef
ref: this.overlayRef,
motion: this.soma.motion
});
// Single handler shared by all 5 close-* events. Each event has

@ -8,6 +8,7 @@ import type { ActiveDates } from '$format/dates/types';
import type { ActiveUnits } from '$format/units/types';
import type { EngineLogger } from '$logger';
import type { EngineSemantic } from '$uix/sema';
import type { EngineMotion } from '$motion';
import { createActiveUixPrefsView, type ActiveUixPrefsView } from '$active-uix/prefs';
import type { Morfo } from '$uix/morfo';
import { createSomaRuntime, type SomaRuntime, type SomaRuntimeSources } from '../runtime.svelte';
@ -59,6 +60,9 @@ export class Soma {
get events(): EngineSemantic | undefined {
return this.uix.events;
}
get motion(): EngineMotion {
return this.uix.motion;
}
get nums(): ActiveNumbers | undefined {
return this.uix.format?.numbers;
}

@ -0,0 +1,110 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { flushSync } from 'svelte';
import { state } from '$libs/reactive';
import { Presence } from './presence.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
// Step-able frame queue so the Presence lifecycle runs synchronously (no jsdom
// rAF timing). Presence only touches requestFrame / cancelFrame.
function makeFakeDom() {
const cbs = new Map<number, () => void>();
let nextId = 0;
return {
dom: {
requestFrame: (cb: () => void) => {
const id = ++nextId;
cbs.set(id, cb);
return id;
},
cancelFrame: (id: number) => cbs.delete(id)
} as never,
flush(max = 50) {
let n = 0;
while (cbs.size && n < max) {
const id = cbs.keys().next().value as number;
const cb = cbs.get(id)!;
cbs.delete(id);
cb();
n++;
}
}
};
}
// A node whose only Presence-relevant surface is getAnimations() → [] (jsdom has
// no Web Animations; JS gating comes from motion.run, not getAnimations()).
const fakeNode = () => ({ getAnimations: () => [] }) as unknown as HTMLElement;
describe('soma Presence — motion.run (JS-driver gating)', () => {
it('waits for motion.run("exit") before unmounting', async () => {
let resolveExit: (() => void) | undefined;
const phases: string[] = [];
const open = state(true);
const ref = state(fakeNode());
const fd = makeFakeDom();
// Minimal EngineMotion stub: only `run` matters to Presence. 'exit' returns
// a handle whose `finished` stays pending until we resolve it (gates unmount).
const motion = {
run: (_node: HTMLElement, phase: 'enter' | 'exit') => {
phases.push(phase);
if (phase === 'exit')
return { finished: new Promise<void>((r) => (resolveExit = r)), cancel() {} };
return { finished: Promise.resolve(), cancel() {} };
}
} as never;
const { result: presence, cleanup } = withEffectRoot(
() => new Presence({ dom: fd.dom, open, ref, motion })
);
flushSync();
fd.flush();
expect(presence.isPresent).toBe(true);
open.current = false;
flushSync();
fd.flush();
await Promise.resolve();
expect(phases).toContain('exit');
expect(presence.isPresent).toBe(true); // gated by the unresolved motion.run('exit')
resolveExit?.();
await Promise.resolve();
flushSync();
expect(presence.isPresent).toBe(false); // unmounts once the spring finished
cleanup();
});
it('unmounts on the declarative path when no motion is wired', async () => {
const open = state(true);
const ref = state(fakeNode());
const fd = makeFakeDom();
const { result: presence, cleanup } = withEffectRoot(
() => new Presence({ dom: fd.dom, open, ref })
);
flushSync();
fd.flush();
expect(presence.isPresent).toBe(true);
open.current = false;
flushSync();
fd.flush();
await Promise.resolve();
flushSync();
// getAnimations() is empty (jsdom) and no motion → unmounts immediately.
expect(presence.isPresent).toBe(false);
cleanup();
});
});

@ -1,6 +1,7 @@
import { type Active, type ActiveProps } from '$libs/reactive';
import { watch } from 'runed';
import type { ActiveDom } from '$adom';
import type { EngineMotion } from '$motion';
// ── Types ────────────────────────────────────────────────────────────────────
@ -10,6 +11,15 @@ export interface PresenceOptions extends ActiveProps<{ open: boolean; ref: HTMLE
onComplete?: (open: boolean) => void;
/** When false, skips animation waiting — unmounts immediately on close. @default true */
enabled?: boolean;
/**
* The motion runtime (`uix.motion` / `soma.motion`). When present, the cycle
* runs `motion.run(node, phase)` and awaits its `finished` ALONGSIDE
* `getAnimations()` — so a JS preset (a `spring`, invisible to
* `getAnimations()`) still gates the unmount. CSS presets settle immediately
* (their generated CSS is awaited via `getAnimations()`). The component passes
* `this.soma.motion`; the engine reads the node's `data-animation-style`.
*/
motion?: EngineMotion;
}
export type TransitionStatus = 'starting' | 'ending' | undefined;
@ -80,8 +90,9 @@ export class Presence {
if (runId !== this.runId) return;
this.transitionStatus = undefined;
// Wait for animations to complete
this.waitForAnimations(runId, () => {
// Start any JS-driven motion, then wait for CSS + JS to complete.
const extra = this.startMotion('enter');
this.waitForAnimations(runId, extra, () => {
this.opts.onComplete?.(true);
});
});
@ -104,8 +115,9 @@ export class Presence {
const runId = ++this.runId;
// Wait for exit animations, then unmount
this.waitForAnimations(runId, () => {
// Start any JS-driven exit motion, then wait for CSS + JS, then unmount.
const extra = this.startMotion('exit');
this.waitForAnimations(runId, extra, () => {
if (runId !== this.runId) return;
this.shouldRender = false;
this.transitionStatus = undefined;
@ -115,7 +127,18 @@ export class Presence {
// ── Animation waiting ────────────────────────────────────────────────────
private waitForAnimations(runId: number, onDone: () => void) {
/** Start the JS-driven motion for this phase, if a `motion` engine is wired. */
private startMotion(phase: 'enter' | 'exit'): Promise<void> | undefined {
const node = this.opts.ref.current;
if (!node) return undefined;
return this.opts.motion?.run(node, phase)?.finished;
}
private waitForAnimations(
runId: number,
extra: Promise<void> | undefined,
onDone: () => void
) {
// getAnimations() needs a frame to see the active animations
this.requestFrame(() => {
if (runId !== this.runId) return;
@ -126,13 +149,16 @@ export class Presence {
return;
}
const animations = node.getAnimations();
if (animations.length === 0) {
// CSS animations (via getAnimations) + any JS-driven `finished`. A spring
// is invisible to getAnimations(), so its `finished` joins the wait here.
const finishers: Promise<unknown>[] = node.getAnimations().map((a) => a.finished);
if (extra) finishers.push(extra);
if (finishers.length === 0) {
onDone();
return;
}
Promise.all(animations.map((a) => a.finished))
Promise.all(finishers)
.then(() => {
if (runId !== this.runId) return;
onDone();

@ -27,6 +27,7 @@ const config = {
'$http': resolve(__dirname, 'src/arts/http'),
'$langs': resolve(__dirname, 'src/arts/langs'),
'$logger': resolve(__dirname, 'src/arts/logger'),
'$motion': resolve(__dirname, 'src/arts/motion'),
'$orca': resolve(__dirname, 'src/arts/orca'),
'$perm': resolve(__dirname, 'src/arts/perm'),
'$prefs': resolve(__dirname, 'src/arts/prefs'),

@ -21,6 +21,7 @@ const aliases = {
'$http': resolve(__dirname, 'src/arts/http'),
'$langs': resolve(__dirname, 'src/arts/langs'),
'$logger': resolve(__dirname, 'src/arts/logger'),
'$motion': resolve(__dirname, 'src/arts/motion'),
'$orca': resolve(__dirname, 'src/arts/orca'),
'$perm': resolve(__dirname, 'src/arts/perm'),
'$prefs': resolve(__dirname, 'src/arts/prefs'),

@ -0,0 +1,40 @@
<script lang="ts">
/**
* Runtime context for the motion demo's component section (P2+).
*
* Boots a minimal ActiveUix + Soma + ActiveEidos so the page can render
* real eidos components (Dialog / Popover / Drawer / Toast) with the
* `motion` prop. The foundation CSS (which carries the generated motion
* presets) is imported here; ActiveEidos runs with `applyDom: false`
* because the static import already provides the tokens — the runtime is
* here for the component context, not for CSS injection. Theme + reduced
* motion are projected to `document.documentElement` by the page so they
* also cover portaled content (Dialog / Popover / Drawer / Toast).
*/
import { onDestroy } from 'svelte'
import { createActiveUix, setActiveUix } from '$active-uix'
import { Soma } from '$soma/core/soma.svelte'
import { siumLangs } from '$sium/langs/langs'
import { secsLangs } from '$libs/secs'
import { ActiveEidos } from '$uix/eidos'
import '@/uix/eidos/index.css'
let { children } = $props()
const uix = createActiveUix({
langs: { schema: { sium: siumLangs, secs: secsLangs }, defaultLocale: 'es' as const },
// Engine present so portal components (Dialog / Popover / Drawer) hydrate;
// sound + haptic off to keep the visual-motion demo quiet.
events: { sound: false, haptic: false }
})
setActiveUix(uix)
Soma.create()
const eidos = ActiveEidos.create({ applyDom: false })
onDestroy(() => {
eidos.dispose()
uix.dispose()
})
</script>
{@render children?.()}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,2 @@
// Live demo of the Eidos motion presets — no static prerender.
export const prerender = false

File diff suppressed because it is too large Load Diff

@ -1,157 +0,0 @@
<script lang="ts">
/**
* Isla cliente: secciones con componentes que portalean su contenido a
* `<body>` (popover, dialog, dropdown, menubar, context-menu, tooltip).
*
* El SSR de esos portales rompe la hidratación de toda la página (mueven
* nodos a `<body>` y desincronizan el cursor de hidratación de Svelte).
* Por eso se importan SOLO desde aquí y este componente se carga con
* `import()` dinámico tras `onMount` (ver `+page.svelte`): nunca entra en
* el SSR ni en la hidratación inicial, y monta limpio en cliente — con el
* contexto del layout (ActiveUix / Soma / Eidos / events) disponible.
*/
import Button from '$uix/eidos/components/button'
import Popover from '$uix/eidos/components/popover'
import Dialog from '$uix/eidos/components/dialog'
import DropdownMenu from '$uix/eidos/components/dropdown-menu'
import ContextMenu from '$uix/eidos/components/context-menu'
import Menubar from '$uix/eidos/components/menubar'
import Tooltip from '$uix/eidos/components/tooltip'
let menuNotifications = $state(true)
let menuTheme = $state('system')
let menubarValue = $state<string | undefined>(undefined)
</script>
<!-- ═══ Overlays ═══════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Overlays</h2>
<p>Popover y dialog — paneles portaleados con la superficie, borde y sombra del tema.</p>
</div>
<div data-temas-panel data-temas-cluster>
<Popover>
<Popover.Trigger>Abrir popover</Popover.Trigger>
<Popover.Portal>
<Popover.Content side="bottom" align="start" sideOffset={8}>
<Popover.Arrow />
<Popover.Title>Dimensiones</Popover.Title>
<Popover.Description>Ajusta el ancho del panel lateral.</Popover.Description>
<div data-temas-cluster style="margin-block-start: var(--space-3)">
<Button size="sm">Aplicar</Button>
<Popover.Close>Cerrar</Popover.Close>
</div>
</Popover.Content>
</Popover.Portal>
</Popover>
<Dialog>
<Dialog.Trigger>Abrir dialog</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>¿Eliminar proyecto?</Dialog.Title>
<Dialog.Description>
Esta acción es permanente y no se puede deshacer.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Dialog.Close>Cancelar</Dialog.Close>
<Button intent="threat">Eliminar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
</div>
</section>
<!-- ═══ Menús ══════════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Menús</h2>
<p>Dropdown, menubar, context menu y tooltip — todos portaleados, con la sombra del tema.</p>
</div>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Menubar</p>
<Menubar bind:value={menubarValue}>
<Menubar.Menu value="file">
<Menubar.Trigger>Archivo</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.Item>Nuevo documento</Menubar.Item>
<Menubar.Item>Abrir…</Menubar.Item>
<Menubar.Separator />
<Menubar.Item>Guardar</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<Menubar.Menu value="edit">
<Menubar.Trigger>Editar</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.Item>Deshacer</Menubar.Item>
<Menubar.Item>Rehacer</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<Menubar.Menu value="view">
<Menubar.Trigger>Ver</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.CheckboxItem bind:checked={menuNotifications}>
Mostrar barra lateral
</Menubar.CheckboxItem>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
</Menubar>
<div data-temas-cluster>
<DropdownMenu>
<DropdownMenu.Trigger variant="outline">Opciones</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" sideOffset={6}>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Cuenta</DropdownMenu.GroupHeading>
<DropdownMenu.Item>Perfil</DropdownMenu.Item>
<DropdownMenu.Item>Facturación</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={menuNotifications}>
Notificaciones
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup bind:value={menuTheme}>
<DropdownMenu.RadioItem value="light">Claro</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark">Oscuro</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="system">Sistema</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu>
<Tooltip.Group delayDuration={250}>
<Tooltip>
<Tooltip.Trigger>Pasa el ratón</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>
<Tooltip.Arrow />
Tooltip con la superficie y la sombra del tema
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
</Tooltip.Group>
</div>
<ContextMenu>
<ContextMenu.Trigger>
<div data-temas-ctx-target>Click derecho aquí</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item>Copiar</ContextMenu.Item>
<ContextMenu.Item>Pegar</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item>Duplicar</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
</div>
</section>

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save

Powered by TurnKey Linux.