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
parent
718cb4ac14
commit
be6bd540aa
@ -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'
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 & {})
|
||||
@ -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,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();
|
||||
});
|
||||
});
|
||||
@ -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…
Reference in new issue