demo(motion): loops + lifecycle + staggered cascade with an SVG

A `/demos/motion` page showcasing the content domain:
- Loops (spin/pulse/ping/bounce) on clean SVG via `motionAttrs`.
- Enter → loop → exit lifecycle on a pigeon SVG adapted from the `/demos` paloma
  (the page has no ActiveEidos provider, so it loads the static eidos CSS and
  replicates `<Motion>`'s exit inline).
- Staggered list: container-driven cascade IN (first item first) and OUT in reverse
  (last leaves first), retained then unmounted — the §D.13.2 bridge.

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

@ -0,0 +1,449 @@
<script lang="ts">
// Motion demo — the CONTENT domain of the universal `motion` prop (RFC §D.12):
// · LOOPS (infinite): spin · pulse · ping · bounce, applied to plain SVG via
// `motionAttrs(preset)` (makes any element a content surface).
// · ENTER / EXIT: `<Motion motion="…">` plays a preset's enter on mount and its
// exit on removal — shown on the pigeon SVG lifted from the `/demos` paloma.
// Both stop automatically under `prefers-reduced-motion` (the generated rules).
import { motionAttrs, type MotionPresetName } from '$uix/eidos';
import Pigeon from './pigeon.svelte';
// A standalone `/demos` page has no ActiveEidos provider, so we load the generated
// eidos foundation (motion keyframes, presets, loops + the `--duration-*` tokens)
// statically and replicate `<Motion>`'s tiny exit lifecycle inline (motionLeave).
// Under /uix — where the provider lives — this section would just be `<Motion>`.
import '@/uix/eidos/index.css';
const loops: { name: MotionPresetName; blurb: string }[] = [
{ name: 'spin', blurb: 'loaders, sync, progress' },
{ name: 'pulse', blurb: 'skeletons, “live”, breathing' },
{ name: 'ping', blurb: 'notification radar' },
{ name: 'bounce', blurb: 'scroll cues, attention' }
];
// ── Enter / exit playground ────────────────────────────────────────────
let show = $state(true);
let enterExit = $state<MotionPresetName>('scale-fade');
let pigeonLoop = $state<MotionPresetName>('bounce');
const enterExitPresets: MotionPresetName[] = ['scale-fade', 'slide-fade', 'fade', 'slide-full'];
const pigeonLoops: MotionPresetName[] = ['none', 'bounce', 'pulse'];
// `<Motion>`'s exit mechanism, inlined: on removal flip to the exit state so the
// preset's exit rule plays, then hold the node for its declared duration before
// Svelte unmounts it. (Enter is just `motionAttrs` on mount.)
function motionLeave(node: HTMLElement) {
node.setAttribute('data-state', 'closed');
const cs = getComputedStyle(node);
const ms = (parseFloat(cs.animationDelay) + parseFloat(cs.animationDuration)) * 1000 || 200;
// `tick` (a no-op) makes Svelte run the transition timeline for `duration` and
// THEN unmount — a `{ duration }`-only return never completes (the node sticks at
// the exit frame, invisible, and a re-show reuses the dead node).
return { duration: ms, tick: () => {} };
}
// ── Staggered list — container-driven cascade, enter AND exit ──────────────
const STAGGER_EACH_MS = 65;
const listItems = ['Inbox', 'Drafts', 'Sent', 'Archive', 'Trash'];
const listPresets: MotionPresetName[] = ['slide-fade', 'scale-fade', 'fade'];
let listMounted = $state(true);
let listState = $state<'open' | 'closed'>('open');
let listPreset = $state<MotionPresetName>('slide-fade');
let listClosing = $state(false);
let closeTimer: ReturnType<typeof setTimeout> | undefined;
// The pragmatic CSS bridge: a closing container drives the child EXIT cascade
// (reverse stagger) while it is RETAINED in the DOM — we hold it for the stagger
// window (N × each + the item exit), then unmount. The container's `data-state`
// flips to `closed`; the items inherit it and cascade out, last-first.
function toggleList() {
clearTimeout(closeTimer);
if (listState === 'open') {
listState = 'closed';
listClosing = true;
const windowMs = listItems.length * STAGGER_EACH_MS + 200;
closeTimer = setTimeout(() => {
listMounted = false;
listClosing = false;
}, windowMs);
} else {
listMounted = true;
listState = 'open';
listClosing = false;
}
}
</script>
<div class="page">
<header class="head">
<p class="eyebrow">UIX · motion · content domain</p>
<h1>Loops &amp; lifecycle</h1>
<p class="lede">
A <code>motion</code> preset on plain content. <strong>Loops</strong> run forever while present;
<strong>enter / exit</strong> ride the element's mount &amp; removal. No JS engine — the foundation
CSS does it, and reduced-motion stops it.
</p>
</header>
<!-- ── LOOPS ─────────────────────────────────────────────────────────── -->
<section>
<h2>Loops <span class="muted">— infinite, content domain</span></h2>
<div class="loop-grid">
{#each loops as loop (loop.name)}
<figure class="tile">
<div class="art">
{#if loop.name === 'spin'}
<svg viewBox="0 0 48 48" class="glyph" {...motionAttrs('spin')}>
<circle cx="24" cy="24" r="17" fill="none" stroke="var(--track)" stroke-width="5" />
<circle
cx="24"
cy="24"
r="17"
fill="none"
stroke="#6366f1"
stroke-width="5"
stroke-linecap="round"
stroke-dasharray="70 200"
/>
</svg>
{:else if loop.name === 'pulse'}
<svg viewBox="0 0 48 48" class="glyph" {...motionAttrs('pulse')}>
<circle cx="24" cy="24" r="15" fill="#8b5cf6" />
</svg>
{:else if loop.name === 'ping'}
<svg viewBox="0 0 48 48" class="glyph">
<circle
cx="24"
cy="24"
r="9"
fill="#ef4444"
{...motionAttrs('ping')}
style="transform-origin:center; transform-box:fill-box"
/>
<circle cx="24" cy="24" r="9" fill="#ef4444" />
</svg>
{:else}
<svg viewBox="0 0 48 48" class="glyph bounce-art" {...motionAttrs('bounce')}>
<circle cx="24" cy="22" r="11" fill="#10b981" />
</svg>
{/if}
</div>
<figcaption>
<code>motion=&quot;{loop.name}&quot;</code>
<span class="muted">{loop.blurb}</span>
</figcaption>
</figure>
{/each}
</div>
<p class="note">
Applied with <code>{'{...motionAttrs(preset)}'}</code> on the SVG (or the ring, for <code>ping</code>).
The preset is the primary trigger — no event, no state to compose with.
</p>
</section>
<!-- ── ENTER / EXIT ──────────────────────────────────────────────────── -->
<section>
<h2>Enter / exit <span class="muted">— the content lifecycle</span></h2>
<div class="lab">
<div class="stage">
{#if show}
<div class="motion-box" {...motionAttrs(enterExit)} out:motionLeave>
<div class="pigeon-holder" {...motionAttrs(pigeonLoop)}>
<Pigeon />
</div>
</div>
{/if}
</div>
<div class="controls">
<button class="primary" onclick={() => (show = !show)}>
{show ? 'Hide' : 'Show'} the pigeon
</button>
<label class="ctl">
<span>enter / exit preset</span>
<span class="chips" role="radiogroup">
{#each enterExitPresets as p (p)}
<button class="chip" data-active={enterExit === p} onclick={() => (enterExit = p)}
>{p}</button
>
{/each}
</span>
</label>
<label class="ctl">
<span>loop while shown</span>
<span class="chips" role="radiogroup">
{#each pigeonLoops as p (p)}
<button class="chip" data-active={pigeonLoop === p} onclick={() => (pigeonLoop = p)}
>{p}</button
>
{/each}
</span>
</label>
<p class="note">
<code>&lt;Motion motion=&quot;{enterExit}&quot;&gt;</code> plays enter on mount, exit on removal
(it retains the node for the eidos-declared duration). The loop on the inner element runs the
whole time it's present — <strong>enter → loop → exit</strong>, the full content lifecycle.
</p>
</div>
</div>
</section>
<!-- ── STAGGERED LIST (container-driven cascade, enter + exit) ──────────── -->
<section>
<h2>Staggered list <span class="muted">— container-driven cascade, in &amp; out</span></h2>
<div class="lab">
<div class="stage stage-list">
{#if listMounted}
<div
class="stagger-list"
data-stagger
data-state={listState}
style="--motion-stagger-each: {STAGGER_EACH_MS}ms"
>
{#each listItems as item (item)}
<div class="stagger-item" data-animation-style={listPreset}>
<span class="dot"></span>{item}
</div>
{/each}
</div>
{/if}
</div>
<div class="controls">
<button class="primary" onclick={toggleList} disabled={listClosing}>
{listState === 'open' ? 'Cascade out' : 'Cascade in'}
</button>
<label class="ctl">
<span>item preset</span>
<span class="chips" role="radiogroup">
{#each listPresets as p (p)}
<button class="chip" data-active={listPreset === p} onclick={() => (listPreset = p)}
>{p}</button
>
{/each}
</span>
</label>
<p class="note">
The <code>[data-stagger]</code> container's <code>data-state</code> drives its direct children.
Enter cascades in (first item first); <strong>exit cascades out in reverse</strong> (last leaves
first) via <code>--motion-stagger-index-rev</code>. The pragmatic CSS bridge: the container is
retained for the stagger window, then unmounts — no JS orchestration engine.
</p>
</div>
</div>
</section>
</div>
<style>
.page {
--track: #e2e8f0;
max-width: 920px;
margin: 0 auto;
padding: 3rem 1.5rem 5rem;
color: #0f172a;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.head {
margin-bottom: 2.5rem;
}
.eyebrow {
margin: 0 0 0.4rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6366f1;
}
h1 {
margin: 0 0 0.6rem;
font-size: 2.4rem;
letter-spacing: -0.02em;
}
.lede {
margin: 0;
max-width: 60ch;
color: #475569;
line-height: 1.6;
}
section {
margin-top: 3rem;
}
h2 {
margin: 0 0 1.2rem;
font-size: 1.25rem;
}
.muted {
color: #94a3b8;
font-weight: 400;
}
.loop-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.tile {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.art {
display: flex;
align-items: center;
justify-content: center;
height: 92px;
}
.glyph {
width: 56px;
height: 56px;
overflow: visible;
}
figcaption {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.82rem;
}
code {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.85em;
background: #f1f5f9;
padding: 0.1em 0.4em;
border-radius: 6px;
}
.note {
margin: 1.2rem 0 0;
color: #64748b;
font-size: 0.9rem;
line-height: 1.6;
}
.lab {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
align-items: stretch;
}
.stage {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
background: radial-gradient(circle at 50% 35%, #eef2ff 0%, #fff 70%);
border: 1px solid #e2e8f0;
border-radius: 20px;
overflow: hidden;
}
.motion-box {
transform-origin: center;
}
.pigeon-holder {
width: 230px;
height: 230px;
}
.stage-list {
padding: 1.5rem;
}
.stagger-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 100%;
max-width: 260px;
}
.stagger-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.75rem 1rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.05);
font-weight: 600;
color: #334155;
}
.stagger-item .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6366f1;
flex: none;
}
.controls {
display: flex;
flex-direction: column;
gap: 1.1rem;
justify-content: center;
}
.primary {
align-self: flex-start;
padding: 0.7rem 1.4rem;
background: #6366f1;
color: #fff;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 10px 20px rgba(99, 102, 241, 0.25);
}
.primary:hover {
background: #4f46e5;
}
.ctl {
display: flex;
flex-direction: column;
gap: 0.45rem;
font-size: 0.78rem;
font-weight: 600;
color: #475569;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
padding: 0.35rem 0.7rem;
background: #f1f5f9;
border: 1px solid transparent;
border-radius: 999px;
font: inherit;
font-weight: 600;
color: #475569;
cursor: pointer;
}
.chip[data-active='true'] {
background: #e0e7ff;
border-color: #6366f1;
color: #4338ca;
}
@media (max-width: 720px) {
.loop-grid {
grid-template-columns: repeat(2, 1fr);
}
.lab {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,55 @@
<script lang="ts">
// A clean, static pigeon — adapted from the `paloma.svelte` demo SVG. No bespoke
// animation or interactivity here: it's just the graphic, so the MOTION SYSTEM
// (enter/exit via `<Motion>`, loops via `motionAttrs`) drives it from outside.
</script>
<svg viewBox="105 128 170 210" class="pigeon" role="img" aria-label="Carrier pigeon">
<defs>
<linearGradient id="pg-body" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#f3f4f6" />
<stop offset="60%" stop-color="#d1d5db" />
<stop offset="100%" stop-color="#9ca3af" />
</linearGradient>
<linearGradient id="pg-wing" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#e5e7eb" />
<stop offset="100%" stop-color="#4b5563" />
</linearGradient>
</defs>
<!-- contact shadow -->
<ellipse cx="188" cy="326" rx="52" ry="8" fill="#0f172a" opacity="0.12" />
<!-- legs -->
<g stroke="#f59e0b" stroke-width="4" stroke-linecap="round" fill="none">
<path d="M 185 275 L 180 315 M 180 315 L 170 318 M 180 315 L 185 319" />
<path d="M 205 275 L 210 312 M 210 312 L 202 316 M 210 312 L 218 315" />
</g>
<!-- tail -->
<path d="M 150 250 L 110 280 L 130 290 Z" fill="#9ca3af" />
<!-- body -->
<path
d="M 160 200 C 150 250, 220 280, 230 240 C 240 200, 210 170, 180 170 C 165 170, 160 185, 160 200 Z"
fill="url(#pg-body)"
/>
<!-- iridescent breast -->
<path d="M 195 180 C 180 190, 185 220, 220 225 C 222 210, 215 190, 195 180 Z" fill="#14b8a6" opacity="0.25" />
<!-- head -->
<circle cx="210" cy="150" r="24" fill="url(#pg-body)" />
<!-- eye -->
<circle cx="218" cy="146" r="4" fill="#000000" />
<circle cx="219" cy="144" r="1.2" fill="#ffffff" />
<!-- beak -->
<path d="M 232 146 L 252 150 L 233 156 Z" fill="#fbbf24" stroke="#d97706" stroke-width="1" />
<path d="M 232 154 L 250 156 L 231 159 Z" fill="#f59e0b" stroke="#d97706" stroke-width="1" />
<!-- wing -->
<path d="M 185 200 C 160 170, 145 250, 195 245 C 210 240, 200 210, 185 200 Z" fill="url(#pg-wing)" />
</svg>
<style>
.pigeon {
width: 100%;
height: 100%;
overflow: visible;
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.