From 54268717b47d05fdbf7fa08a08fbf5599ab87a31 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 21 Jun 2026 00:10:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(motion):=20``=20wrapper=20?= =?UTF-8?q?=E2=80=94=20content=20animate-in/out=20primitive,=20(b)=20step?= =?UTF-8?q?=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ergonomic companion to `motionAttrs`: wrap any content to animate it IN and OUT. `…` 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 ``). 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 `` vs `motionAttrs` table + the content-domain boundary). - badge demo: a `` 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) --- src/uix/eidos/components/motion/README.md | 71 +++++++++++++++++++ src/uix/eidos/components/motion/index.ts | 16 +++++ src/uix/eidos/components/motion/motion.svelte | 44 ++++++++++++ src/uix/eidos/components/motion/types.ts | 22 ++++++ src/uix/morfo/components/motion.ts | 42 +++++++++++ web/routes/uix/components/badge/+page.svelte | 22 ++++++ 6 files changed, 217 insertions(+) create mode 100644 src/uix/eidos/components/motion/README.md create mode 100644 src/uix/eidos/components/motion/index.ts create mode 100644 src/uix/eidos/components/motion/motion.svelte create mode 100644 src/uix/eidos/components/motion/types.ts create mode 100644 src/uix/morfo/components/motion.ts diff --git a/src/uix/eidos/components/motion/README.md b/src/uix/eidos/components/motion/README.md new file mode 100644 index 000000000..51a86dd0a --- /dev/null +++ b/src/uix/eidos/components/motion/README.md @@ -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 ``. + +```svelte + + +{#if show} + +
…
+
+{/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. + +## `` vs `motionAttrs` + +| | Use | Extra node | Exit | +| --- | --- | --- | --- | +| **``** | 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 `` 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`. + +## 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 `` 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. diff --git a/src/uix/eidos/components/motion/index.ts b/src/uix/eidos/components/motion/index.ts new file mode 100644 index 000000000..32e231eb2 --- /dev/null +++ b/src/uix/eidos/components/motion/index.ts @@ -0,0 +1,16 @@ +// Motion — a single-element content animator (RFC §D.12, content domain). +// +// import { Motion } from '$uix/eidos/components/motion'; +// +// {#if show} +// … +// {/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'; diff --git a/src/uix/eidos/components/motion/motion.svelte b/src/uix/eidos/components/motion/motion.svelte new file mode 100644 index 000000000..2f823add7 --- /dev/null +++ b/src/uix/eidos/components/motion/motion.svelte @@ -0,0 +1,44 @@ + + + +
+ {@render children?.()} +
diff --git a/src/uix/eidos/components/motion/types.ts b/src/uix/eidos/components/motion/types.ts new file mode 100644 index 000000000..508a36d60 --- /dev/null +++ b/src/uix/eidos/components/motion/types.ts @@ -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 `` — 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 ``. + * + * {#if show}…{/if} + */ +export type MotionProps = Omit, '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; +}; diff --git a/src/uix/morfo/components/motion.ts b/src/uix/morfo/components/motion.ts new file mode 100644 index 000000000..2d07d15e1 --- /dev/null +++ b/src/uix/morfo/components/motion.ts @@ -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; diff --git a/web/routes/uix/components/badge/+page.svelte b/web/routes/uix/components/badge/+page.svelte index 6ba6101f8..8d2d05208 100644 --- a/web/routes/uix/components/badge/+page.svelte +++ b/web/routes/uix/components/badge/+page.svelte @@ -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 `` 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 @@ motion="slide-fade" {/key} + +

+ <Motion> wraps content to animate it IN and OUT — enter on + mount, exit on unmount (it retains the node for the exit). Toggle: +

+ + + {#if motionShow} + + wrapped in <Motion> + + {/if} + {/if}