feat(motion): `<Motion>` wrapper — content animate-in/out primitive, (b) step 3

The ergonomic companion to `motionAttrs`: wrap any content to animate it IN and
OUT. `<Motion motion="scale-fade">…</Motion>` plays the preset's enter on mount
and its exit on removal (Svelte `out:` flips to `data-state='closed'` + retains
the node for the eidos-declared duration, like `<Cascade.Item>`). Rides the
existing state-presets — no new mechanism, no JS engine.

- morfo `motion.ts` (scope ['eidos']): one Provider part declaring `data-state`.
- eidos `motion/`: component (spreads `motionAttrs` + `out:` retention) + types +
  index + README (incl. the `<Motion>` vs `motionAttrs` table + the content-domain
  boundary).
- badge demo: a `<Motion>` show/hide example next to the prop showcase.

Caught + fixed a real collision: `data-motion` is already the reduced-motion prefs
attr (`[data-motion='reduce']`), so the wrapper emits NO part-presence marker (a
prefs `closest('[data-motion]')` would have matched it) — only the functional
`data-animation-style` + `data-state` from `motionAttrs`.

Verified at runtime: enter (data-state=open → `scale-in, fade-in`, no data-motion)
and exit (Hide → retained with data-state=closed → `scale-out, fade-out`). `check`
clean for the morfo + component + demo.

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

@ -0,0 +1,71 @@
# Motion
A single-element **content** animator (RFC §D.12 — the content domain). Wrap any content you
want to animate **in and out**; it rides the existing state-presets + reduced-motion — **no new
mechanism, no JS engine**. Same `motion` selector as the overlays and `<Cascade>`.
```svelte
<script>
import { Motion } from '$uix/eidos/components/motion'
let show = $state(true)
</script>
{#if show}
<Motion motion="scale-fade">
<div class="card">…</div>
</Motion>
{/if}
```
`motion` is any registered preset (`fade` / `scale-fade` / `slide-fade` / …); `'none'` disables.
The keyframe / duration / easing / reduced-motion all come from the preset.
## How it works
| Phase | Mechanism |
| --- | --- |
| **enter** (on mount) | `motionAttrs(motion)` puts `data-animation-style` + a presentation `data-state="open"` on the element → the EXISTING `[data-animation-style][data-state='open']` enter rule plays once. Flash-free (attrs at insertion, `backwards` fill). |
| **exit** (on removal) | Svelte `out:` flips the node to `data-state='closed'` (the existing exit rule plays) and **retains** it for the eidos-declared duration (read via the ActiveDom `getComputedStyle`), then unmounts. |
The wrapper writes **no visual var** and runs **no JS animation** — it only sets the state attr
and holds the node during exit. Eidos owns the keyframes.
## `<Motion>` vs `motionAttrs`
| | Use | Extra node | Exit |
| --- | --- | --- | --- |
| **`<Motion>`** | wrap content to animate in/out | yes (a real box — needed for transform/opacity) | ✓ (`out:` retention) |
| **`motionAttrs(preset)`** | spread onto your OWN element | no | — (enter only; you manage exit) |
Reach for `motionAttrs` when an extra wrapper would disrupt layout (inline content) and you only
need the entrance; reach for `<Motion>` when you want the full in/out lifecycle.
## Domain boundary
Motion is the **content** domain only — content that simply appears/disappears, with no semantic
event and no state machine. Do **not** wrap:
- a **stateful** component (its own `data-state`: a Card's `selected`/`idle`, a checkbox's
`checked`) — Motion's presentation `data-state` would clash; that component selects its preset
via its own `motion` prop tied to its own state.
- an **event** surface (an overlay's `emerge`, a `commit`) — the firma owns the motion; the prop
there is an override, not a wrapper.
## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `motion` | `MotionPresetName` | `'fade'` | The preset played on mount (enter) and removal (exit). `'none'` disables. |
Plus any `HTMLAttributes<HTMLDivElement>`.
## Decisions
- **No recipe, no morfo events.** Motion is structural lifecycle + a preset selector; the morfo
(`scope: ['eidos']`) declares only the `data-state` of its single part. It reuses the
`motion` registry + the state-presets — it adds no keyframes of its own.
- **Reuses `data-state`, not a bespoke trigger.** The content domain fires via a constant
presentation `data-state="open"` (the `<Cascade.Item>` pattern), so the existing enter/exit +
reduced-motion rules apply verbatim and the generator stays untouched (RFC §D.12).
- **A real box, not `display: contents`.** The transform/opacity need a layout box. For
zero-extra-node, use `motionAttrs` on your own element.

@ -0,0 +1,16 @@
// Motion — a single-element content animator (RFC §D.12, content domain).
//
// import { Motion } from '$uix/eidos/components/motion';
//
// {#if show}
// <Motion motion="scale-fade">…</Motion>
// {/if}
//
// Plays the preset's enter on mount + exit on removal (out: retention), riding the
// existing state-presets. For NO extra wrapper node, spread `motionAttrs(preset)`
// onto your own element (enter-only).
import Motion from './motion.svelte';
export { Motion };
export default Motion;
export type { MotionProps } from './types';

@ -0,0 +1,44 @@
<script lang="ts">
/**
* Eidos `<Motion>` — a single-element CONTENT animator (RFC §D.12, content domain).
*
* Names a `motion` preset; plays its ENTER on mount (presentation `data-state="open"`
* via `motionAttrs`, reusing the EXISTING state-preset) and its EXIT on removal (flips
* to `data-state='closed'` so the existing exit preset plays, then retains the node via
* Svelte `out:` for the eidos-declared duration before unmount). No new mechanism, no JS
* engine — just the existing enter/exit rules + reduced-motion.
*
* {#if show}<Motion motion="scale-fade">…</Motion>{/if}
*
* Renders a real box (needed for the transform/opacity to apply). For NO extra node,
* spread `motionAttrs(preset)` onto your own element instead (enter-only). For a
* STATEFUL component or an EVENT firma, don't wrap — those own the trigger.
*/
import { ActiveEidos, motionAttrs } from '$uix/eidos';
import type { MotionProps } from './types';
let { motion = 'fade', children, ...rest }: MotionProps = $props();
const eidos = ActiveEidos.require();
// Lifecycle only (no visual var written): on removal flip to `data-state='closed'`
// so the EXISTING exit preset plays, then hold the node for its eidos-declared
// duration (read ActiveDom-compliant) before Svelte unmounts it.
function leave(node: HTMLElement) {
node.setAttribute('data-state', 'closed');
const cs = eidos.dom.getWindow(node).getComputedStyle(node);
const ms = (parseFloat(cs.animationDelay) + parseFloat(cs.animationDuration)) * 1000 || 200;
return { duration: ms };
}
</script>
<!--
No part-presence marker: `data-motion` is taken by the reduced-motion prefs
(`[data-motion='reduce']`) — emitting it here would collide (a prefs
`closest('[data-motion]')` could match this wrapper). The functional attrs
(`data-animation-style` + `data-state`) come from `motionAttrs`; that's all the
enter/exit rules need.
-->
<div {...motionAttrs(motion)} out:leave {...rest}>
{@render children?.()}
</div>

@ -0,0 +1,22 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
/**
* Props for `<Motion>` — a single-element CONTENT animator (RFC §D.12). Wrap any
* content you want to animate in and out; it rides the existing state-presets
* (no new mechanism, no JS engine), reusing the same `motion` selector as the
* overlays and `<Cascade>`.
*
* {#if show}<Motion motion="scale-fade">…</Motion>{/if}
*/
export type MotionProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/**
* The motion preset — any registered eidos preset (`fade` / `scale-fade` /
* `slide-fade` / …). Plays its ENTER on mount and its EXIT on removal; the
* keyframe / duration / reduced-motion all come from the preset. `'none'`
* disables. @default 'fade'
*/
motion?: MotionPresetName;
children?: Snippet;
};

@ -0,0 +1,42 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Motion — a single-element CONTENT animator (RFC §D.12, content domain).
*
* Pure `--state` moment, NOT a sema event: the consumer names a `motion` preset,
* the Provider plays its ENTER on mount (presentation `data-state="open"`) and its
* EXIT on removal (the eidos wrapper flips to `closed` + retains via Svelte `out:`
* for the preset's declared duration). Eidos paints it by reacting to the EXISTING
* preset's `data-state` rule — no new mechanism, no JS engine. The `motion` preset
* (`data-animation-style`) is an eidos-only visual selector; the morfo declares
* only the structural `data-state`.
*
* For a STATEFUL component (its own `data-state` machine) or an EVENT firma, do
* NOT wrap with Motion — those own the trigger. Motion is the content domain only.
*/
export const motionMorfo = {
name: 'Motion',
kebab: 'motion',
scope: ['eidos'],
events: [],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
states: ['open', 'closed'],
data: [
{
attr: 'data-state',
values: ['open', 'closed'],
value: v.stateRef('open')
}
],
aria: []
}
]
} as const satisfies Morfo;

@ -9,6 +9,7 @@
import { SHAPE_FAMILIES, type ShapeFamily } from '$uix/eidos/lib/types';
import { Box } from '$uix/eidos/components/box';
import { Stack } from '$uix/eidos/components/stack';
import { Motion } from '$uix/eidos/components/motion';
import { compileMorfo } from '$uix/morfo';
import { badgeMorfo } from '@/uix/morfo/components/badge';
@ -32,6 +33,8 @@
let removeCount = $state(0);
// Remount key so the on-mount entrance animation can be replayed on demand.
let motionReplay = $state(0);
// Toggle for the `<Motion>` wrapper demo (enter on mount + exit on unmount).
let motionShow = $state(true);
const variants: BadgeVariant[] = ['soft', 'solid', 'outline', 'ghost'];
const sizes: BadgeSize[] = ['xs', 'sm', 'md', 'lg'];
@ -796,6 +799,25 @@
<Badge color="risk" motion="slide-fade">motion=&quot;slide-fade&quot;</Badge>
</Box>
{/key}
<p style="margin: var(--uix-space-2) 0 0; color: var(--uix-muted);">
<code>&lt;Motion&gt;</code> wraps content to animate it IN <em>and</em> OUT — enter on
mount, exit on unmount (it retains the node for the exit). Toggle:
</p>
<button
type="button"
onclick={() => (motionShow = !motionShow)}
style="align-self: flex-start; padding: var(--uix-space-1) var(--uix-space-3); border: 1px solid var(--uix-border); border-radius: var(--uix-radius-sm); background: var(--uix-surface); cursor: pointer;"
>
{motionShow ? 'Hide' : 'Show'}
</button>
<Box style="min-block-size: 2rem; display: flex; align-items: center;">
{#if motionShow}
<Motion motion="scale-fade">
<Badge color="fulfill" size="lg">wrapped in &lt;Motion&gt;</Badge>
</Motion>
{/if}
</Box>
</Box>
</section>
{/if}

Loading…
Cancel
Save

Powered by TurnKey Linux.